From b3845e1f220d0f156df8a2cf9296b2a089bb2d41 Mon Sep 17 00:00:00 2001 From: David Spruill Date: Sat, 8 Aug 2026 11:40:13 -0400 Subject: [PATCH 1/6] Start the process of migrating from an HTTP based MCP connection to an STDIO one - better handling multiple instances --- .context/resume.md | 9 +- .github/copilot-instructions.md | 41 +++ .github/workflows/ci.yml | 33 +- .github/workflows/release.yml | 2 + .gitignore | 3 +- .gitmodules | 6 + Bootstrap.ps1 | 87 ----- CHANGELOG.md | 15 + Controls/NodeGraphController.cpp | 153 ++++++-- Controls/NodeGraphController.h | 71 ++++ Engine/Mcp/EngineMcpRoutes.cpp | 50 +++ MainWindow.RenderTick.cpp | 20 +- MainWindow.xaml.cpp | 11 + MainWindow.xaml.h | 20 ++ README.md | 16 +- Rendering/DisplayMonitor.cpp | 16 + Rendering/DisplayProfile.h | 35 ++ Rendering/IccProfileParser.cpp | 26 +- ShaderLab.vcxproj | 2 +- ShaderLab.vcxproj.filters | 2 +- ShaderLabEngine.vcxproj | 33 +- Tests/RunTests.ps1 | 103 ++++-- Tests/fixtures/test_cli_basic.json | 106 ++++-- docs/README.md | 1 + docs/development/build.md | 85 ++++- docs/development/mcp-stdio-migration.md | 457 ++++++++++++++++++++++++ docs/development/project-structure.md | 7 +- docs/history/decision-log.md | 8 + pch.h | 1 + scripts/EnsureExprTk.ps1 | 22 -- scripts/EnsureMiniz.ps1 | 47 --- third_party/exprtk | 1 + third_party/miniz | 1 + third_party/miniz_export.h | 11 + 34 files changed, 1188 insertions(+), 313 deletions(-) create mode 100644 .gitmodules delete mode 100644 Bootstrap.ps1 create mode 100644 docs/development/mcp-stdio-migration.md delete mode 100644 scripts/EnsureExprTk.ps1 delete mode 100644 scripts/EnsureMiniz.ps1 create mode 160000 third_party/exprtk create mode 160000 third_party/miniz create mode 100644 third_party/miniz_export.h diff --git a/.context/resume.md b/.context/resume.md index 163b4a8..a3d5c77 100644 --- a/.context/resume.md +++ b/.context/resume.md @@ -148,7 +148,7 @@ These are critical lessons learned during development. Any AI agent or developer - Windows App SDK 1.8. - Windows 10 SDK 10.0.26100+. - PowerShell 5.1+. -- Internet on first build (for `EnsureExprTk.ps1` + `EnsureMiniz.ps1`). +- Git (exprtk + miniz are submodules; clone with `--recurse-submodules`). ### Build ```pwsh @@ -161,8 +161,7 @@ msbuild ShaderLab.slnx /p:Configuration=Debug /p:Platform=x64 Pre-build scripts run automatically on first build: - `scripts\EnsureDevCert.ps1` — generates / installs the local F5 dev cert (`CN=ShaderLab`). -- `scripts\EnsureExprTk.ps1` — downloads `exprtk.hpp` (MIT) into `third_party\exprtk\`. -- `scripts\EnsureMiniz.ps1` — downloads `miniz` (MIT) for `.effectgraph` zip DEFLATE. +- (exprtk + miniz are now git submodules under `third_party\`, not downloaded at build time.) NuGet packages restore automatically (packages.config style). @@ -203,7 +202,7 @@ ShaderLab\ ├── docs/ # Architecture tree (architecture / effects / ui-ux / hosts / development / history) ├── docs/effects/new-effect-defaults.md # D2D effect default-property reference ├── CHANGELOG.md # Version history -├── Bootstrap.ps1 # One-command fresh-clone setup +├── .gitmodules # submodule pins (exprtk, miniz) │ ├── pch.h / pch.cpp # App PCH ├── pch_engine.h / pch_engine.cpp # Engine + Test + Headless PCH @@ -281,8 +280,6 @@ ShaderLab\ │ ├── scripts\ │ ├── EnsureDevCert.ps1 -│ ├── EnsureExprTk.ps1 -│ ├── EnsureMiniz.ps1 │ └── Install.ps1 # Per-arch unsigned-MSIX installer │ ├── .github\ diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index f3e6ba6..0afb155 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -12,6 +12,7 @@ ShaderLab is a WinUI 3 desktop application (C++/WinRT) for developing, testing, ## Build +- Clone with `--recurse-submodules` (or run `git submodule update --init --recursive`). `exprtk` and `miniz` are git submodules under `third_party/`, pinned to explicit commits; `third_party/miniz_export.h` is an in-tree shim, not part of the submodule. See [build.md](../docs/development/build.md). - Open `ShaderLab.slnx` in Visual Studio 2022 17.8+ - NuGet packages restore automatically (packages.config style, not PackageReference) - Build target: **Debug | x64** (also supports ARM64, Release) @@ -64,6 +65,46 @@ ShaderLabHeadless.exe (console host, no WinUI dependency) Render loop: a **render worker `std::jthread`** (MTA) drives graph evaluation at the active monitor's refresh rate (clamped to 60–240 Hz, decision #50). Per tick: drain `RenderThreadDispatcher` closures → dirty-propagation BFS → `BeginDraw` on the render-side D2D context → `GraphEvaluator.Evaluate()` → `ProcessDeferredCompute()` (D3D11 compute analysis nodes; **must** be inside the `BeginDraw`/`EndDraw` so the internal `dc->DrawImage` actually runs — decision #63) → `DrawImage(previewOutput)` into one of two double-buffered offscreen `ID3D11Texture2D`s → `EndDraw` → publish `m_offscreenPublishedIdx`. The **UI thread** (XAML STA) runs a `DispatcherQueueTimer` that blits the latest published offscreen onto the `SwapChainPanel`-bound swap chain and `Present1`s; UI Present cost is sub-ms regardless of graph throughput. Users build tone mappers as graph effects (the ICtCp suite is the preferred path); there is no built-in tone-mapping pass. See [Threading Model](../docs/architecture/threading-model.md) for the full UI ↔ worker contract (decision #68). +### Graph access rule (READ THIS BEFORE TOUCHING `m_graph` FROM UI CODE) + +The render worker is the **single writer** of the live `EffectGraph`, and it writes +continuously — clock-node `properties[...] =` inserts every tick, plus every MCP +closure (add/remove/clear/set-property). Getting this wrong is a data race that +surfaces as an access violation deep inside `std::map`, **not** as a compile error. +Two such crashes shipped before the rule was written down; both resolved to +`node->properties.find()` called from the UI thread during canvas paint. + +Three access paths, pick deliberately: + +1. **UI-thread reads → the per-frame `GraphUiSnapshot`, never `m_graph`.** + The worker publishes an immutable value copy of every node and edge each frame + (`BuildGraphUiSnapshot`, atomically stored in `m_uiGraphSnapshot`); read it via + `MainWindow::CurrentGraphSnapshot()` or, inside the node-graph editor, + `NodeGraphController::Snapshot()`. At most one frame stale — fine for display + and hit-testing. Hold the returned `shared_ptr` for the whole read. +2. **Writes (any thread) → `RenderThreadDispatcher::DispatchSync`.** Never mutate + `m_graph` directly from a pointer/interaction handler. +3. **Layout computation** (`RebuildLayout` / `AutoLayout` / `ComputeNodeVisual`) + → live `m_graph`, but **only on the render thread**; it must see post-mutation + state immediately, so a snapshot would be a frame stale and miss a just-added + node. UI-side callers go through `MainWindow::RunLayoutOnRenderThread`. + +Two locks exist, with a strict order. `MainWindow::m_graphMutex` guards the worker's +own tick/drain — a backstop, not the pattern; new code should use the snapshot +rather than take it, because the worker holds it for the whole tick (~50 ms on a +heavy graph) and locking the UI behind that stalls the canvas. +`NodeGraphController::m_visualsMutex` guards `m_visuals`, which is genuinely written +from both threads (`RebuildLayout` on the render thread; `AddNode` / `DeleteSelected` +/ `UpdateDragNodes` on the UI thread) and read by every paint and hit-test. + +**Lock order: `m_graphMutex` → `m_visualsMutex`.** The render thread acquires them +in that order, so UI code must **never** hold `m_visualsMutex` across a +`DispatchSync` — dispatch the graph write first, release, then lock to update +visuals. Two corollaries, both of which were live bugs: don't hold references into +`m_visuals` across a dispatch (copy by value — they dangle if the worker rebuilds +layout while you wait), and don't read `m_visuals` from inside a dispatched closure, +which runs on the render thread. + ## Namespace Convention All code lives under `ShaderLab::` with sub-namespaces matching directories: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 676edef..33156d7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,6 +29,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + with: + submodules: recursive - name: Setup MSBuild uses: microsoft/setup-msbuild@v2 @@ -92,25 +94,40 @@ jobs: if-no-files-found: ignore retention-days: 7 - bootstrap-smoke: - name: Bootstrap.ps1 smoke (clean clone) + clean-clone-smoke: + name: Clean-clone smoke (submodules + build) runs-on: windows-latest - # Catches onboarding-cliff regressions: a fresh clone must build via - # the documented one-command setup. Only runs Debug|x64 -- the full - # matrix is build-and-test above. + # Catches onboarding-cliff regressions: a fresh clone must build with + # nothing but `git submodule update --init --recursive` + restore + + # msbuild. Only runs Debug|x64 -- the full matrix is build-and-test above. steps: - - name: Checkout (clean) + - name: Checkout (clean, no submodules) uses: actions/checkout@v4 + - name: Initialize submodules + # Done explicitly rather than via checkout's `submodules:` input so + # this job exercises the exact command the README tells contributors + # to run on a fresh clone. + run: git submodule update --init --recursive + - name: Setup MSBuild uses: microsoft/setup-msbuild@v2 - name: Setup NuGet uses: NuGet/setup-nuget@v2 - - name: Run Bootstrap.ps1 (cert + ExprTk + restore + smoke build) + - name: NuGet restore + run: nuget restore ShaderLab.slnx -SolutionDirectory . + + - name: Build (Debug | x64) shell: pwsh - run: .\Bootstrap.ps1 -Build -Configuration Debug -Platform x64 + run: | + msbuild ShaderLab.slnx ` + /p:Configuration=Debug ` + /p:Platform=x64 ` + /p:AppxBundle=Never ` + /p:AppxPackageSigningEnabled=false ` + /m /nologo /v:minimal - name: Run unit tests shell: pwsh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3dd2a6c..b576c8b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -35,6 +35,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + with: + submodules: recursive - name: Resolve version id: ver diff --git a/.gitignore b/.gitignore index 61535cb..864f331 100644 --- a/.gitignore +++ b/.gitignore @@ -87,7 +87,8 @@ $RECYCLE.BIN/ .DS_Store Screenshot_53.png -third_party/ +# third_party/ holds git submodules (exprtk, miniz) plus the in-tree +# miniz_export.h shim -- all tracked. Do NOT ignore it. enc_temp_folder/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..f587858 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,6 @@ +[submodule "third_party/exprtk"] + path = third_party/exprtk + url = https://github.com/ArashPartow/exprtk.git +[submodule "third_party/miniz"] + path = third_party/miniz + url = https://github.com/richgel999/miniz.git diff --git a/Bootstrap.ps1 b/Bootstrap.ps1 deleted file mode 100644 index 4283ed5..0000000 --- a/Bootstrap.ps1 +++ /dev/null @@ -1,87 +0,0 @@ -# ShaderLab — first-time setup script -# -# Runs the per-build helpers that MSBuild would otherwise run on first -# build, plus a NuGet restore and an optional Debug|x64 smoke build, so -# a fresh clone is ready to F5 in one command. -# -# Usage: -# .\Bootstrap.ps1 # cert + ExprTk + restore (no build) -# .\Bootstrap.ps1 -Build # the above + Debug|x64 smoke build - -param( - [switch]$Build = $false, - [ValidateSet('Debug', 'Release')] - [string]$Configuration = 'Debug', - [ValidateSet('x64', 'ARM64')] - [string]$Platform = 'x64' -) - -$ErrorActionPreference = 'Stop' -$Repo = $PSScriptRoot -Push-Location $Repo -try { - Write-Host "ShaderLab Bootstrap" - Write-Host "===================" - Write-Host "Repo: $Repo" - Write-Host "Configuration: $Configuration | Platform: $Platform" - Write-Host "" - - # 1. Dev cert (signed F5 deploy needs it; release builds don't). - Write-Host "[1/4] Ensuring dev signing certificate..." - & "$Repo\scripts\EnsureDevCert.ps1" ` - -PfxPath "$Repo\ShaderLab_TemporaryKey.pfx" ` - -Password "shaderlab" - - # 2. ExprTk single-header download (Numeric Expression node). - Write-Host "[2/4] Ensuring third_party/exprtk/exprtk.hpp..." - & "$Repo\scripts\EnsureExprTk.ps1" -TargetDir "$Repo\third_party\exprtk" - - # 3. NuGet restore. - # The repo uses packages.config (NOT PackageReference). MSBuild's - # /t:Restore target on a .slnx silently no-ops on packages.config - # style ("Nothing to do. None of the projects specified contain - # packages to restore.") even though the manifest is right there. - # Use nuget.exe directly against the root packages.config -- that - # path explicitly supports packages.config and is what NuGet - # documents for this scenario. Falls back to msbuild if nuget.exe - # isn't on PATH. - Write-Host "[3/4] Restoring NuGet packages..." - $msbuild = "${env:ProgramFiles}\Microsoft Visual Studio\2022\Community\MSBuild\Current\Bin\MSBuild.exe" - if (-not (Test-Path $msbuild)) { - $msbuild = 'msbuild' # fall back to PATH - } - - $nuget = Get-Command nuget.exe -ErrorAction SilentlyContinue - if ($nuget) { - & $nuget.Source restore "$Repo\packages.config" -PackagesDirectory "$Repo\packages" -NonInteractive - if ($LASTEXITCODE -ne 0) { - throw "NuGet restore failed (exit $LASTEXITCODE)" - } - } else { - # MSBuild fallback path. Requires VS to be configured with the - # NuGet packages.config restore feature; not the default in CI. - & $msbuild ShaderLab.slnx /t:Restore /p:RestorePackagesConfig=true /p:Configuration=$Configuration /p:Platform=$Platform /v:minimal /m /nologo - if ($LASTEXITCODE -ne 0) { - throw "NuGet restore failed (exit $LASTEXITCODE)" - } - } - - # 4. Smoke build (optional, off by default). - if ($Build) { - Write-Host "[4/4] Smoke build ($Configuration|$Platform)..." - & $msbuild ShaderLab.slnx /p:Configuration=$Configuration /p:Platform=$Platform /v:minimal /m /nologo - if ($LASTEXITCODE -ne 0) { - throw "Build failed (exit $LASTEXITCODE)" - } - Write-Host "Smoke build OK." - } - else { - Write-Host "[4/4] Skipping smoke build (use -Build to enable)." - } - - Write-Host "" - Write-Host "Bootstrap OK. Open ShaderLab.slnx in Visual Studio and F5 to deploy + run." -} -finally { - Pop-Location -} diff --git a/CHANGELOG.md b/CHANGELOG.md index fb20c0e..efacbe1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,21 @@ Format follows [Keep a Changelog](https://keepachangelog.com/). ## [Unreleased] +### Changed + +- **Native dependencies are now git submodules; `Bootstrap.ps1` and the `Ensure*` download scripts are gone.** `exprtk` and `miniz` were previously fetched over the network by MSBuild pre-build PowerShell into a wholly-gitignored `third_party/`, which meant the effective dependency versions were invisible to git and not reproducible — `EnsureExprTk.ps1` in particular pulled `exprtk.hpp` from `master`, an unpinned floating reference. Both are now submodules with explicit pins: + - `third_party/exprtk` → [ArashPartow/exprtk](https://github.com/ArashPartow/exprtk) @ `1e4a80b` (MIT) + - `third_party/miniz` → [richgel999/miniz](https://github.com/richgel999/miniz) @ tag `3.1.2` (MIT) — a deliberate bump from the 3.0.2 the old script downloaded, since `.effectgraph` archives can originate from untrusted sources. +- **`third_party/miniz_export.h` added** — a 3-line in-tree shim defining an empty `MINIZ_EXPORT`. Upstream's `miniz.h` includes `miniz_export.h`, which their CMake generates via `generate_export_header()` and which is absent from the git tree; miniz's own amalgamation step substitutes the same empty define when producing the single-file release pair. This keeps a CMake toolchain out of an MSBuild-only repo. miniz links statically into `ShaderLabEngine.dll`, so an empty macro is the correct definition. +- **Only three miniz sources are compiled** — `miniz.c`, `miniz_tdef.c`, `miniz_tinfl.c`. `miniz_zip.c` is omitted: `EffectGraphFile.cpp` writes the ZIP container itself and uses only `tdefl_compress_mem_to_heap`, `tinfl_decompress_mem_to_heap`, and `mz_free`. +- **New `VerifySubmodules` MSBuild target** in `ShaderLabEngine.vcxproj`. The old scripts self-healed a fresh clone by downloading on first build; submodules don't, so a clone missing `--recurse-submodules` now fails fast with the exact `git submodule update --init --recursive` command instead of a cascade of missing-header errors. +- **CI**: `actions/checkout` gains `submodules: recursive` in `ci.yml` and `release.yml`. The `bootstrap-smoke` job (decision #56) becomes `clean-clone-smoke` — it still guards the onboarding cliff, but runs the documented submodule-init command explicitly rather than relying on checkout's `submodules:` input, so the path contributors are told to use is the one CI exercises. + +### Removed + +- `Bootstrap.ps1`, `scripts/EnsureExprTk.ps1`, `scripts/EnsureMiniz.ps1`. Bootstrap's three jobs are covered elsewhere: the dev cert by the existing `EnsureDevSigningCertificate` target in `ShaderLab.vcxproj`, ExprTk by the submodule, and NuGet restore by Visual Studio / CI. +- The blanket `third_party/` entry in `.gitignore`, which was hiding the dependency tree from git. + ## [1.7.3] - 2026-05-10 ### Fixed diff --git a/Controls/NodeGraphController.cpp b/Controls/NodeGraphController.cpp index 5eb5941..6606404 100644 --- a/Controls/NodeGraphController.cpp +++ b/Controls/NodeGraphController.cpp @@ -16,9 +16,18 @@ namespace ShaderLab::Controls void NodeGraphController::RebuildLayout() { + // Writer. Runs on the render thread when driven by the engine-sink event + // hooks, so the UI thread must not be iterating m_visuals meanwhile -- + // that race crashed inside RenderNodes' loop header. Callers must not + // already hold m_visualsMutex (see the LOCK ORDER RULE in the header). + std::unique_lock lk(m_visualsMutex); + m_visuals.clear(); if (!m_graph) return; + // THREADING RULE #3: layout reads the LIVE graph, not a snapshot -- it + // must see a just-added node immediately, so callers are responsible for + // being on the render thread. for (const auto& node : m_graph->Nodes()) { m_visuals[node.id] = ComputeNodeVisual(node); @@ -188,6 +197,7 @@ namespace ShaderLab::Controls D2D1_RECT_F NodeGraphController::ContentBounds() const { + std::shared_lock lk(m_visualsMutex); if (m_visuals.empty()) return D2D1::RectF(0.0f, 0.0f, 0.0f, 0.0f); @@ -222,6 +232,7 @@ namespace ShaderLab::Controls uint32_t NodeGraphController::HitTestNode(D2D1_POINT_2F canvasPoint) const { // unordered_map has no ordering — just check all nodes. + std::shared_lock lk(m_visualsMutex); uint32_t hitId = 0; for (const auto& [id, v] : m_visuals) { @@ -240,6 +251,7 @@ namespace ShaderLab::Controls { constexpr float hitRadius = PinRadius * 2.5f; + std::shared_lock lk(m_visualsMutex); for (const auto& [id, visual] : m_visuals) { // Check image output pins. @@ -332,9 +344,14 @@ namespace ShaderLab::Controls } } }; + // LOCK ORDER RULE: dispatch the graph write first, WITHOUT holding + // m_visualsMutex -- taking it here and then waiting on the render thread + // would invert against the render thread's m_graphMutex -> m_visualsMutex + // order and deadlock. if (m_dispatcher) m_dispatcher->DispatchSync(applyPositions); else applyPositions(); + std::unique_lock lk(m_visualsMutex); for (uint32_t nodeId : m_selection.selectedNodeIds) { // Update visual. @@ -477,15 +494,28 @@ namespace ShaderLab::Controls return false; } - // Resolve pin indices to field/property names. - auto srcIt = m_visuals.find(srcNodeId); - auto dstIt = m_visuals.find(dstNodeId); - if (srcIt != m_visuals.end() && dstIt != m_visuals.end() && - srcPinIdx < srcIt->second.dataOutputPinNames.size() && - dstPinIdx < dstIt->second.dataInputPinNames.size()) + // Resolve pin indices to field/property names. Copy them out by + // VALUE under the visuals lock, then release before dispatching: + // holding references into m_visuals across DispatchSync would both + // invert the lock order and dangle if the render thread rebuilt + // layout while we waited. + std::wstring fieldName, propName; + bool namesResolved = false; + { + std::shared_lock lk(m_visualsMutex); + auto srcIt = m_visuals.find(srcNodeId); + auto dstIt = m_visuals.find(dstNodeId); + if (srcIt != m_visuals.end() && dstIt != m_visuals.end() && + srcPinIdx < srcIt->second.dataOutputPinNames.size() && + dstPinIdx < dstIt->second.dataInputPinNames.size()) + { + fieldName = srcIt->second.dataOutputPinNames[srcPinIdx]; + propName = dstIt->second.dataInputPinNames[dstPinIdx]; + namesResolved = true; + } + } + if (namesResolved) { - auto& fieldName = srcIt->second.dataOutputPinNames[srcPinIdx]; - auto& propName = dstIt->second.dataInputPinNames[dstPinIdx]; std::wstring err; if (m_dispatcher) { @@ -571,7 +601,10 @@ namespace ShaderLab::Controls void NodeGraphController::SelectAll() { if (!m_graph) return; - for (const auto& node : m_graph->Nodes()) + // THREADING RULE #1: UI-thread read -> snapshot. + auto snap = Snapshot(); + const auto& nodes = snap ? snap->nodes : m_graph->Nodes(); + for (const auto& node : nodes) { m_selection.selectedNodeIds.insert(node.id); } @@ -585,15 +618,19 @@ namespace ShaderLab::Controls if (m_dispatcher) { auto idsToRemove = m_selection.selectedNodeIds; + // LOCK ORDER RULE: dispatch first, unlocked; then take the visuals + // lock. Holding it across DispatchSync would deadlock. m_dispatcher->DispatchSync([&]{ for (uint32_t nodeId : idsToRemove) m_graph->RemoveNode(nodeId); }); + std::unique_lock lk(m_visualsMutex); for (uint32_t nodeId : idsToRemove) m_visuals.erase(nodeId); } else { + std::unique_lock lk(m_visualsMutex); for (uint32_t nodeId : m_selection.selectedNodeIds) { m_graph->RemoveNode(nodeId); @@ -661,9 +698,14 @@ namespace ShaderLab::Controls id = m_graph->AddNode(std::move(movedNode)); } + // The graph write above already went through the dispatcher, so taking + // the visuals lock here is after the fact -- never across DispatchSync. auto* added = m_graph->FindNode(id); if (added) + { + std::unique_lock lk(m_visualsMutex); m_visuals[id] = ComputeNodeVisual(*added); + } return id; } @@ -972,6 +1014,18 @@ namespace ShaderLab::Controls EnsureResources(dc); + // THREADING RULE #1: the paint reads the published snapshot, never the + // live graph. Acquired once here (rather than per sub-call) so edges and + // nodes in one frame are mutually consistent, and held for the whole + // paint so EffectNode pointers into it stay valid. + m_paintSnapshot = Snapshot(); + + // m_visuals is written by RebuildLayout on the render thread, so the + // whole paint reads it under a shared lock. Held across all three + // sub-renders so they see one consistent layout. Nothing inside the + // paint dispatches, so this cannot invert the lock order. + std::shared_lock visualsLock(m_visualsMutex); + // Apply pan/zoom transform. D2D1_MATRIX_3X2_F transform = D2D1::Matrix3x2F::Scale(m_zoom, m_zoom) * @@ -984,13 +1038,15 @@ namespace ShaderLab::Controls dc->SetTransform(D2D1::Matrix3x2F::Identity()); m_needsRedraw = false; + m_paintSnapshot.reset(); } void NodeGraphController::RenderEdges(ID2D1DeviceContext* dc) { if (!m_brushEdge) return; - for (const auto& edge : m_graph->Edges()) + const auto& edges = m_paintSnapshot ? m_paintSnapshot->edges : m_graph->Edges(); + for (const auto& edge : edges) { auto srcIt = m_visuals.find(edge.sourceNodeId); auto dstIt = m_visuals.find(edge.destNodeId); @@ -1033,7 +1089,8 @@ namespace ShaderLab::Controls // Render data edges (property bindings) as orange curves. if (m_brushDataEdge) { - for (const auto& node : m_graph->Nodes()) + const auto& dataNodes = m_paintSnapshot ? m_paintSnapshot->nodes : m_graph->Nodes(); + for (const auto& node : dataNodes) { auto dstIt = m_visuals.find(node.id); if (dstIt == m_visuals.end()) continue; @@ -1116,7 +1173,11 @@ namespace ShaderLab::Controls { for (const auto& [nodeId, visual] : m_visuals) { - const auto* node = m_graph->FindNode(nodeId); + // THREADING RULE #1: snapshot, not the live graph. Every node-> + // deref below (properties, analysisOutput, clockTime, customEffect) + // would otherwise race the render worker. + const auto* node = m_paintSnapshot ? m_paintSnapshot->FindNode(nodeId) + : m_graph->FindNode(nodeId); if (!node) continue; bool selected = m_selection.selectedNodeIds.contains(nodeId); @@ -1599,6 +1660,7 @@ namespace ShaderLab::Controls uint32_t NodeGraphController::HitTestSlider(D2D1_POINT_2F canvasPoint) const { + std::shared_lock lk(m_visualsMutex); for (const auto& [id, v] : m_visuals) { if (!v.isParameterNode) continue; @@ -1611,6 +1673,7 @@ namespace ShaderLab::Controls uint32_t NodeGraphController::HitTestPlayButton(D2D1_POINT_2F canvasPoint) const { + std::shared_lock lk(m_visualsMutex); for (const auto& [id, v] : m_visuals) { if (!v.isClockNode) continue; @@ -1651,11 +1714,17 @@ namespace ShaderLab::Controls EdgeHit best{}; if (!m_graph) return best; + // THREADING RULE #1: UI-thread (pointer handler) read -> snapshot. + // Held for the whole hit-test so both loops see one consistent frame. + auto snap = Snapshot(); + std::shared_lock visualsLock(m_visualsMutex); + const float tol2 = tolerance * tolerance; float bestDist = tol2; // Image edges. - for (const auto& edge : m_graph->Edges()) + const auto& edges = snap ? snap->edges : m_graph->Edges(); + for (const auto& edge : edges) { auto srcIt = m_visuals.find(edge.sourceNodeId); auto dstIt = m_visuals.find(edge.destNodeId); @@ -1681,7 +1750,8 @@ namespace ShaderLab::Controls } // Data binding edges. - for (const auto& node : m_graph->Nodes()) + const auto& hitNodes = snap ? snap->nodes : m_graph->Nodes(); + for (const auto& node : hitNodes) { auto dstIt = m_visuals.find(node.id); if (dstIt == m_visuals.end()) continue; @@ -1832,14 +1902,33 @@ namespace ShaderLab::Controls bool NodeGraphController::UpdateSliderDrag(uint32_t nodeId, D2D1_POINT_2F canvasPoint) { - auto vIt = m_visuals.find(nodeId); - if (vIt == m_visuals.end() || !vIt->second.isParameterNode) return false; + // Copy the two layout values we need out under the visuals lock, then + // release it. The closure below runs on the RENDER thread, so it must + // not touch m_visuals -- and we must not hold the lock across + // DispatchSync (LOCK ORDER RULE). + bool isClockNode = false; + D2D1_RECT_F sliderRect{}; + { + std::shared_lock lk(m_visualsMutex); + auto vIt = m_visuals.find(nodeId); + if (vIt == m_visuals.end() || !vIt->second.isParameterNode) return false; + isClockNode = vIt->second.isClockNode; + sliderRect = vIt->second.sliderRect; + } + if (!m_graph) return false; + + // THREADING RULE #2: this reads AND WRITES node state (clockTime, + // properties, dirty), so the whole graph interaction runs on the render + // thread. It previously mutated the live graph straight from the + // pointer handler, racing the render worker's per-tick writes. + bool changed = false; + auto apply = [&] { auto* node = m_graph->FindNode(nodeId); - if (!node || !node->customEffect.has_value()) return false; + if (!node || !node->customEffect.has_value()) return; // Clock nodes: seek by setting clockTime. - if (vIt->second.isClockNode) + if (isClockNode) { float startTime = 0.0f, stopTime = 10.0f; auto stIt = node->properties.find(L"StartTime"); @@ -1851,13 +1940,13 @@ namespace ShaderLab::Controls float duration = stopTime - startTime; if (duration <= 0.0f) duration = 1.0f; - float t = (canvasPoint.x - vIt->second.sliderRect.left) - / (vIt->second.sliderRect.right - vIt->second.sliderRect.left); + float t = (canvasPoint.x - sliderRect.left) + / (sliderRect.right - sliderRect.left); t = (std::max)(0.0f, (std::min)(1.0f, t)); node->clockTime = static_cast(t * duration); node->dirty = true; - m_needsRedraw = true; - return true; + changed = true; + return; } float pMin = 0.0f, pMax = 1.0f, step = 0.01f; @@ -1883,8 +1972,8 @@ namespace ShaderLab::Controls } } - float t = (canvasPoint.x - vIt->second.sliderRect.left) - / (vIt->second.sliderRect.right - vIt->second.sliderRect.left); + float t = (canvasPoint.x - sliderRect.left) + / (sliderRect.right - sliderRect.left); t = (std::max)(0.0f, (std::min)(1.0f, t)); float newVal = pMin + t * (pMax - pMin); @@ -1894,21 +1983,29 @@ namespace ShaderLab::Controls newVal = (std::max)(pMin, (std::min)(pMax, newVal)); auto propIt = node->properties.find(L"Value"); - if (propIt == node->properties.end()) return false; + if (propIt == node->properties.end()) return; float oldVal = 0.0f; if (auto* f = std::get_if(&propIt->second)) oldVal = *f; - if (std::abs(newVal - oldVal) < 0.0001f) return false; + if (std::abs(newVal - oldVal) < 0.0001f) return; propIt->second = newVal; node->dirty = true; - m_needsRedraw = true; - return true; + changed = true; + + }; // end apply + + if (m_dispatcher) m_dispatcher->DispatchSync(apply); + else apply(); + + if (changed) m_needsRedraw = true; + return changed; } bool NodeGraphController::IsParameterNode(uint32_t nodeId) const { + std::shared_lock lk(m_visualsMutex); auto it = m_visuals.find(nodeId); return it != m_visuals.end() && it->second.isParameterNode; } diff --git a/Controls/NodeGraphController.h b/Controls/NodeGraphController.h index 220a72f..7daa381 100644 --- a/Controls/NodeGraphController.h +++ b/Controls/NodeGraphController.h @@ -2,6 +2,7 @@ #include "pch.h" #include "../Graph/EffectGraph.h" +#include "../Graph/GraphUiSnapshot.h" #include "../Effects/EffectRegistry.h" #include "../Rendering/RenderThreadDispatcher.h" @@ -84,6 +85,44 @@ namespace ShaderLab::Controls // direct-call path (e.g. tests that don't have a dispatcher). void SetDispatcher(::ShaderLab::Rendering::RenderThreadDispatcher* d) { m_dispatcher = d; } + // ---- THREADING RULE: how this controller may touch graph data ---- + // + // Three access paths exist. Picking the wrong one is a data race that + // shows up as an access violation deep inside std::map, not as a + // compile error -- so read this before adding any new graph access. + // + // 1. READS on the UI thread -> ALWAYS via Snapshot(). + // The render worker mutates the live EffectGraph continuously + // (clock properties every tick, plus every MCP closure). Reading + // m_graph directly from the UI thread races those writes: a paint + // landing mid-mutation walks a std::map being rebalanced, or a node + // graph_clear just destroyed. Snapshot() returns an immutable + // per-frame value copy published by the render thread, so it is + // always safe and at most one frame stale -- fine for display and + // hit-testing. Hold the returned shared_ptr for the whole read. + // + // 2. WRITES (from any thread) -> ALWAYS through m_dispatcher. + // See SetDispatcher above. Never mutate m_graph directly from a + // pointer/interaction handler. + // + // 3. Layout computation (RebuildLayout/AutoLayout/ComputeNodeVisual) + // -> live m_graph, but ONLY on the render thread. These must see + // post-mutation state immediately (a snapshot would be a frame + // stale and a just-added node would be missing), so they read the + // live graph and callers are responsible for being on the render + // thread -- UI-side callers go through + // MainWindow::RunLayoutOnRenderThread or m_dispatcher. + // + // Supplies the latest published GraphUiSnapshot (MainWindow owns it). + // May return nullptr before the first frame is published, in which case + // read paths fall back to the live graph -- safe only because the render + // worker has not spawned yet at that point. + void SetSnapshotProvider( + std::function()> p) + { + m_snapshotProvider = std::move(p); + } + // ---- Layout ---- // Rebuild visual layout from the current graph state. @@ -222,6 +261,38 @@ namespace ShaderLab::Controls Graph::EffectGraph* m_graph{ nullptr }; ::ShaderLab::Rendering::RenderThreadDispatcher* m_dispatcher{ nullptr }; + std::function()> m_snapshotProvider; + + // UI-thread read accessor -- see the THREADING RULE above. Returns the + // latest published snapshot, or nullptr when none exists yet. + std::shared_ptr Snapshot() const + { + return m_snapshotProvider ? m_snapshotProvider() : nullptr; + } + + // Snapshot held for the duration of one Render() call. Acquired once so + // edges and nodes within a single paint always come from the same + // frame, and so pointers into it stay valid for the whole paint. + std::shared_ptr m_paintSnapshot; + + // Guards m_visuals. Separate from the graph: m_visuals is written from + // BOTH threads -- RebuildLayout runs on the render thread (from the + // engine-sink event hooks) while AddNode/DeleteSelected/UpdateDragNodes + // write it on the UI thread after dispatching their graph mutation -- + // and it is read by every paint and hit-test on the UI thread. + // + // Kept deliberately fine-grained. The obvious alternative, reusing + // MainWindow::m_graphMutex, couples the canvas to the render worker's + // whole tick (~50ms on a heavy graph) and stalls painting. + // + // LOCK ORDER RULE: never hold this across a RenderThreadDispatcher + // DispatchSync. The render thread takes m_graphMutex then (via + // RebuildLayout) this one; a UI thread holding this while waiting on the + // render thread inverts that order and deadlocks. Always dispatch the + // graph write FIRST, release, then take this to update visuals. + // For the same reason no lock-holder may call RebuildLayout, which + // acquires it itself (std::shared_mutex is not recursive). + mutable std::shared_mutex m_visualsMutex; ConnectionCallback m_connectionCallback; // Cached visual layout. diff --git a/Engine/Mcp/EngineMcpRoutes.cpp b/Engine/Mcp/EngineMcpRoutes.cpp index ab8355b..c3ee0b7 100644 --- a/Engine/Mcp/EngineMcpRoutes.cpp +++ b/Engine/Mcp/EngineMcpRoutes.cpp @@ -216,6 +216,56 @@ namespace ShaderLab::Mcp } json += "}"; + // Property bindings. Mirrors the shape EffectGraph::ToJson writes so + // an agent reading a node sees the same structure it would find in a + // saved .effectgraph. Without this an agent can create a binding via + // /graph/bind-property and observe its effect, but has no way to read + // back which properties are already bound — e.g. whether a custom + // gamut's primaries are wired to a Working Space node. + if (!node.propertyBindings.empty()) + { + json += ",\"propertyBindings\":{"; + bool firstBinding = true; + for (const auto& [propName, binding] : node.propertyBindings) + { + if (!firstBinding) json += ","; + json += "\"" + JsonEscape(WideToUtf8(propName)) + "\":{"; + if (binding.wholeArray) + { + json += std::format( + "\"wholeArray\":true,\"sourceNodeId\":{},\"sourceFieldName\":\"{}\"", + binding.wholeArraySourceNodeId, + JsonEscape(WideToUtf8(binding.wholeArraySourceFieldName))); + } + else + { + json += "\"sources\":["; + for (size_t i = 0; i < binding.sources.size(); ++i) + { + if (i > 0) json += ","; + const auto& src = binding.sources[i]; + if (src.has_value()) + { + json += std::format( + "{{\"nodeId\":{},\"field\":\"{}\",\"index\":{},\"comp\":{}}}", + src->sourceNodeId, + JsonEscape(WideToUtf8(src->sourceFieldName)), + src->sourceIndex, + src->sourceComponent); + } + else + { + json += "null"; + } + } + json += "]"; + } + json += "}"; + firstBinding = false; + } + json += "}"; + } + // Pins. json += ",\"inputPins\":["; for (size_t i = 0; i < node.inputPins.size(); ++i) diff --git a/MainWindow.RenderTick.cpp b/MainWindow.RenderTick.cpp index 1f56dfa..b273ea6 100644 --- a/MainWindow.RenderTick.cpp +++ b/MainWindow.RenderTick.cpp @@ -182,7 +182,15 @@ namespace winrt::ShaderLab::implementation while (!stop.stop_requested() && !m_renderShouldStop.load(std::memory_order_acquire)) { m_renderDispatcher.WaitFor(std::chrono::milliseconds(16)); - m_renderDispatcher.Drain(); + { + // Every MCP mutation arrives as a closure drained here. Hold the + // graph exclusively across the whole drain rather than per + // closure: RenderThreadDispatcher runs a nested DispatchSync + // inline when already on the consumer thread, which would + // self-deadlock a non-recursive mutex taken per closure. + std::unique_lock graphLock(m_graphMutex); + m_renderDispatcher.Drain(); + } if (stop.stop_requested() || m_renderShouldStop.load(std::memory_order_acquire)) break; if (m_isShuttingDown) break; @@ -198,6 +206,16 @@ namespace winrt::ShaderLab::implementation // Per-tick non-GPU work that previously lived in OnRenderTick: // working space sync, capture/clock tick, video upload, dirty // propagation. Then the offscreen render itself. + // + // This whole body mutates m_graph -- UpdateWorkingSpaceNodes + // writes Working Space properties, the clock tick does + // node.properties[...] = (a std::map INSERT), and the evaluator + // walks and dirties nodes. Hold the graph exclusively so the UI + // thread's canvas paint cannot read a half-mutated node. Taken + // in a separate scope from the Drain() lock above so the two + // never nest. + std::unique_lock graphLock(m_graphMutex); + UpdateWorkingSpaceNodes(); // Use the render-thread D2D context for source uploads. They diff --git a/MainWindow.xaml.cpp b/MainWindow.xaml.cpp index 59977ce..91cf54d 100644 --- a/MainWindow.xaml.cpp +++ b/MainWindow.xaml.cpp @@ -534,6 +534,11 @@ namespace winrt::ShaderLab::implementation m_nodeGraphController.SetGraph(&m_graph); m_nodeGraphController.SetDispatcher(&m_renderDispatcher); + // UI-thread reads in the controller go through the per-frame snapshot + // the render worker publishes, never the live graph. See the THREADING + // RULE block in NodeGraphController.h. + m_nodeGraphController.SetSnapshotProvider( + [this] { return CurrentGraphSnapshot(); }); m_nodeGraphController.SetConnectionCallback( [this](uint32_t srcId, uint32_t srcPin, uint32_t dstId, uint32_t dstPin, bool isData) { auto* srcNode = m_graph.FindNode(srcId); @@ -2456,6 +2461,12 @@ namespace winrt::ShaderLab::implementation auto* dc = m_uiD2dContext.get(); if (!dc) return; + // No graph lock here: NodeGraphController now paints from the per-frame + // GraphUiSnapshot rather than live EffectNode pointers, so this thread + // touches no mutable graph state. Taking m_graphMutex here would stall + // the canvas behind the render worker's tick, which reaches ~50ms on a + // heavy graph (measured: 4K source + 2K compute) versus ~0.6ms idle. + float graphDpiX = 96.0f * (std::max)(1.0f, static_cast(NodeGraphPanel().CompositionScaleX())); float graphDpiY = 96.0f * (std::max)(1.0f, static_cast(NodeGraphPanel().CompositionScaleY())); dc->SetDpi(graphDpiX, graphDpiY); diff --git a/MainWindow.xaml.h b/MainWindow.xaml.h index 4353090..61d0a45 100644 --- a/MainWindow.xaml.h +++ b/MainWindow.xaml.h @@ -319,6 +319,26 @@ namespace winrt::ShaderLab::implementation // Effect graph. ::ShaderLab::Graph::EffectGraph m_graph; + + // Guards m_graph against the UI thread reading it while the render + // worker mutates it. + // + // The render worker is the single WRITER (clock-property inserts every + // tick, plus every MCP closure drained from m_renderDispatcher), but the + // UI thread is a concurrent READER: RenderNodeGraph paints the canvas + // from live EffectNode pointers via NodeGraphController. With no lock, + // a canvas paint that lands during a graph mutation walks a std::map + // that is being rebalanced -- or a node that has just been destroyed by + // graph_clear -- and access-violates inside _Tree::_Find. Diagnosed from + // two distinct WER fault offsets that both resolved into + // NodeGraphController::RenderNodes' node->properties.find() calls. + // + // Writers take it exclusively on the render thread; the UI canvas paint + // takes it shared. Lock at the Drain()/tick-body level rather than + // per-closure, so a nested inline DispatchSync (which + // RenderThreadDispatcher permits on the consumer thread) cannot + // self-deadlock on a non-recursive mutex. + mutable std::shared_mutex m_graphMutex; ::ShaderLab::Effects::SourceNodeFactory m_sourceFactory; // Controllers. diff --git a/README.md b/README.md index 95af743..546f732 100644 --- a/README.md +++ b/README.md @@ -28,19 +28,17 @@ The release manifest carries the special OID `2.25.31172936891398431765440773059 ## Local Development -The project ships without a code-signing certificate. On first build, MSBuild auto-runs: +Clone recursively — the two native dependencies (`exprtk`, `miniz`, both MIT) are git submodules under `third_party/`: -- **`scripts/EnsureDevCert.ps1`** — generates a self-signed cert (`CN=ShaderLab`) and imports it into `TrustedPeople` for F5 deploy. -- **`scripts/EnsureExprTk.ps1`** — downloads `exprtk.hpp` (single-header math expression parser, MIT-licensed) into `third_party/exprtk/`. +```pwsh +git clone --recurse-submodules https://github.com//ShaderLab.git +``` -After that, F5 (Debug | x64, startup project = `ShaderLab`) deploys and launches the packaged app. +On an existing clone: `git submodule update --init --recursive`. -For a one-shot setup on a fresh clone: +Then open `ShaderLab.slnx` and F5 (Debug | x64, startup project = `ShaderLab`) to deploy and launch the packaged app. NuGet restores automatically, and MSBuild auto-runs **`scripts/EnsureDevCert.ps1`** on first build to generate a self-signed `CN=ShaderLab` cert and import it into `TrustedPeople` for F5 deploy — the project ships without a code-signing certificate. -```pwsh -.\Bootstrap.ps1 # cert + ExprTk + NuGet restore (no build) -.\Bootstrap.ps1 -Build # the above + Debug|x64 smoke build -``` +If the submodules are missing, the build stops with an actionable error rather than a cascade of missing-header failures. See [docs/development/build.md](docs/development/build.md) for full prerequisites, configurations, and the dependency map. diff --git a/Rendering/DisplayMonitor.cpp b/Rendering/DisplayMonitor.cpp index 725e90d..141bc63 100644 --- a/Rendering/DisplayMonitor.cpp +++ b/Rendering/DisplayMonitor.cpp @@ -299,6 +299,17 @@ namespace ShaderLab::Rendering default: caps.activeColorMode = caps.hdrEnabled ? 2u : 0u; break; } + // Reconcile hdrEnabled with the mode we just read. The seed value + // came from the legacy DXGI_OUTPUT_DESC1::ColorSpace heuristic, + // which reports G22_NONE_P709 whenever the output snapshot predates + // the panel entering HDR — the EDID-derived luminance/primaries in + // that same desc are still correct, so the stale color space is easy + // to miss. DisplayConfig is the live authority, so it wins here just + // as it does for bitsPerColor below. Without this, an HDR display + // reports "SDR" in the status bar and over MCP while + // activeColorMode correctly says HDR. + caps.hdrEnabled = (caps.activeColorMode == 2u); + // Trust DisplayConfig over the legacy color-space heuristic for // bitsPerColor too — DXGI_OUTPUT_DESC1 reports 8 in many WCG // configurations even though the actual scanout is 10-bit. @@ -510,6 +521,10 @@ namespace ShaderLab::Rendering p.primaryGreen = { m_caps.greenPrimaryX, m_caps.greenPrimaryY }; p.primaryBlue = { m_caps.bluePrimaryX, m_caps.bluePrimaryY }; p.whitePoint = { m_caps.whitePointX, m_caps.whitePointY }; + // Classify from the EDID primaries we just copied in. Without this the + // field keeps its struct default (sRGB), which misreports every + // wide-gamut panel as sRGB. + p.gamut = DetectGamut(p.primaryRed, p.primaryGreen, p.primaryBlue); return p; } @@ -524,6 +539,7 @@ namespace ShaderLab::Rendering p.primaryGreen = { m_caps.greenPrimaryX, m_caps.greenPrimaryY }; p.primaryBlue = { m_caps.bluePrimaryX, m_caps.bluePrimaryY }; p.whitePoint = { m_caps.whitePointX, m_caps.whitePointY }; + p.gamut = DetectGamut(p.primaryRed, p.primaryGreen, p.primaryBlue); return p; } } diff --git a/Rendering/DisplayProfile.h b/Rendering/DisplayProfile.h index 6b47c1b..62c7bc8 100644 --- a/Rendering/DisplayProfile.h +++ b/Rendering/DisplayProfile.h @@ -33,6 +33,41 @@ namespace ShaderLab::Rendering } } + // Classifies a set of CIE xy primaries against the well-known gamuts. + // Returns Custom when the primaries don't match any standard within + // tolerance — real panels frequently land there, so Custom is a normal + // result rather than an error. Shared by the ICC path and the live-display + // path so both classify identically. + inline GamutId DetectGamut(const ChromaticityXY& r, + const ChromaticityXY& g, + const ChromaticityXY& b) noexcept + { + auto close = [](float v, float target, float tol = 0.02f) + { + return std::abs(v - target) < tol; + }; + + // sRGB / BT.709 + if (close(r.x, 0.64f) && close(r.y, 0.33f) && + close(g.x, 0.30f) && close(g.y, 0.60f) && + close(b.x, 0.15f) && close(b.y, 0.06f)) + return GamutId::sRGB; + + // DCI-P3 / Display P3 + if (close(r.x, 0.680f) && close(r.y, 0.320f) && + close(g.x, 0.265f) && close(g.y, 0.690f) && + close(b.x, 0.150f) && close(b.y, 0.060f)) + return GamutId::DCI_P3; + + // BT.2020 + if (close(r.x, 0.708f) && close(r.y, 0.292f) && + close(g.x, 0.170f) && close(g.y, 0.797f) && + close(b.x, 0.131f) && close(b.y, 0.046f)) + return GamutId::BT2020; + + return GamutId::Custom; + } + // Extended display profile combining capabilities with colorimetry. struct DisplayProfile { diff --git a/Rendering/IccProfileParser.cpp b/Rendering/IccProfileParser.cpp index 9e03588..3a64203 100644 --- a/Rendering/IccProfileParser.cpp +++ b/Rendering/IccProfileParser.cpp @@ -150,30 +150,8 @@ namespace return {}; } - GamutId DetectGamut(const ChromaticityXY& r, const ChromaticityXY& g, const ChromaticityXY& b) noexcept - { - auto close = [](float a, float b, float tol = 0.02f) { return std::abs(a - b) < tol; }; - - // sRGB / BT.709 - if (close(r.x, 0.64f) && close(r.y, 0.33f) && - close(g.x, 0.30f) && close(g.y, 0.60f) && - close(b.x, 0.15f) && close(b.y, 0.06f)) - return GamutId::sRGB; - - // DCI-P3 / Display P3 - if (close(r.x, 0.680f) && close(r.y, 0.320f) && - close(g.x, 0.265f) && close(g.y, 0.690f) && - close(b.x, 0.150f) && close(b.y, 0.060f)) - return GamutId::DCI_P3; - - // BT.2020 - if (close(r.x, 0.708f) && close(r.y, 0.292f) && - close(g.x, 0.170f) && close(g.y, 0.797f) && - close(b.x, 0.131f) && close(b.y, 0.046f)) - return GamutId::BT2020; - - return GamutId::Custom; - } + // DetectGamut now lives in DisplayProfile.h so the live-display path in + // DisplayMonitor can classify with the identical thresholds. } namespace ShaderLab::Rendering diff --git a/ShaderLab.vcxproj b/ShaderLab.vcxproj index 1485572..e136b8e 100644 --- a/ShaderLab.vcxproj +++ b/ShaderLab.vcxproj @@ -295,7 +295,7 @@ - + diff --git a/ShaderLab.vcxproj.filters b/ShaderLab.vcxproj.filters index 2c6d98a..aeb399e 100644 --- a/ShaderLab.vcxproj.filters +++ b/ShaderLab.vcxproj.filters @@ -144,7 +144,7 @@ - + diff --git a/ShaderLabEngine.vcxproj b/ShaderLabEngine.vcxproj index df37029..f3f6f90 100644 --- a/ShaderLabEngine.vcxproj +++ b/ShaderLabEngine.vcxproj @@ -65,7 +65,9 @@ - $(ProjectDir);$(ProjectDir)third_party\exprtk;$(ProjectDir)third_party\miniz;%(AdditionalIncludeDirectories) + + $(ProjectDir);$(ProjectDir)third_party;$(ProjectDir)third_party\exprtk;$(ProjectDir)third_party\miniz;%(AdditionalIncludeDirectories) Use pch_engine.h $(IntDir)pch_engine.pch @@ -177,11 +179,26 @@ NotUsing + NotUsing CompileAsC 4127;4244;4267;4456;4457;4458;4459;4505;4701;4702;4715;4996;%(DisableSpecificWarnings) + + NotUsing + CompileAsC + 4127;4244;4267;4456;4457;4458;4459;4505;4701;4702;4715;4996;%(DisableSpecificWarnings) + + + NotUsing + CompileAsC + 4127;4244;4267;4456;4457;4458;4459;4505;4701;4702;4715;4996;%(DisableSpecificWarnings) + @@ -198,11 +215,13 @@ - - - - - - + + + + \ No newline at end of file diff --git a/Tests/RunTests.ps1 b/Tests/RunTests.ps1 index bb8e813..05e5317 100644 --- a/Tests/RunTests.ps1 +++ b/Tests/RunTests.ps1 @@ -6,17 +6,20 @@ Builds, deploys, and launches ShaderLab, then runs integration tests against the MCP JSON-RPC server. Reports pass/fail per test with exit code for CI. -.PARAMETER SkipBuild - Skip MSBuild and deployment (use existing installation). - .PARAMETER Filter Run only tests matching this wildcard pattern (e.g. "Graph*"). .PARAMETER Adapter GPU adapter for CLI tests: "default" or "warp". Default: "default". + +.NOTES + This suite does NOT build, deploy or launch anything -- it runs against an + already-running ShaderLab with the MCP server up. Build and launch first, + then run this. (The old -SkipBuild switch and $script:MSBuild path were dead + code: nothing here ever invoked MSBuild, and the hardcoded path pointed at a + VS edition that isn't necessarily installed.) #> param( - [switch]$SkipBuild, [string]$Filter = "*", [string]$Adapter = "default" ) @@ -29,7 +32,6 @@ $script:TestDir = $PSScriptRoot $script:RepoRoot = Split-Path $script:TestDir -Parent $script:FixturesDir = Join-Path $script:TestDir "fixtures" $script:OutputDir = Join-Path $script:TestDir "output" -$script:MSBuild = "C:\Program Files\Microsoft Visual Studio\18\Enterprise\MSBuild\Current\Bin\MSBuild.exe" # ============================================================================ # Helpers @@ -43,7 +45,10 @@ function McpCall($toolName, $arguments = @{}) { method = "tools/call" params = @{ name = $toolName; arguments = $arguments } } | ConvertTo-Json -Depth 5 - $r = Invoke-RestMethod -Uri "$script:McpBase/mcp" -Method Post ` + # POST "/" is the JSON-RPC endpoint. (This used to POST to "/mcp", which + # only resolved because route matching is longest-PREFIX, so "/mcp" fell + # through to the "/" catch-all. Depending on that is fragile.) + $r = Invoke-RestMethod -Uri "$script:McpBase/" -Method Post ` -ContentType "application/json" -Body $body -TimeoutSec 30 if ($r.result.isError) { throw "MCP error: $($r.result.content[0].text)" } $text = $r.result.content[0].text @@ -65,8 +70,14 @@ function WaitForCondition($description, $scriptBlock, $timeoutSec = 10, $pollMs } function WaitForMcp($timeoutSec = 30) { + # Probe "GET /" -- the static health route. Do NOT probe "/graph": that goes + # through IEngineCommandSink::Dispatch onto the render worker, so its latency + # depends on the render loop being healthy. A readiness check that can be + # starved by the thing it is waiting for reports "server down" when the + # server is merely busy (observed: 3 consecutive 2s timeouts on /graph, then + # ~1ms once settled). return WaitForCondition "MCP server ready" { - $null = Invoke-RestMethod -Uri "$script:McpBase/graph" -Method Get -TimeoutSec 2 + $null = Invoke-RestMethod -Uri "$script:McpBase/" -Method Get -TimeoutSec 5 $true } $timeoutSec 500 } @@ -113,9 +124,24 @@ function WaitForDirtySettle($timeoutSec = 5) { # Test Registration # ============================================================================ +# Fail fast and legibly if the app isn't up, rather than emitting one +# "connection refused" per test. +if (-not (WaitForMcp 30)) { + Write-Host "MCP server not reachable at $script:McpBase" -ForegroundColor Red + Write-Host "Build, deploy and launch ShaderLab first, then re-run." -ForegroundColor Red + exit 1 +} + function RunTest($name, $scriptBlock) { if ($name -notlike $Filter) { return } Write-Host "[$name] " -NoNewline + # If the app died mid-suite, stop rather than reporting every remaining + # test as a failure -- a crash is one fault, not twenty. + if ($script:AppDied) { + Write-Host "SKIP (app died earlier)" -ForegroundColor DarkYellow + $script:TestResults += @{ Name = $name; Pass = $false; Error = "skipped: app died earlier" } + return + } try { ClearGraph $result = & $scriptBlock @@ -129,6 +155,10 @@ function RunTest($name, $scriptBlock) { } catch { Write-Host "FAIL - $($_.Exception.Message)" -ForegroundColor Red $script:TestResults += @{ Name = $name; Pass = $false; Error = $_.Exception.Message } + if (-not (Get-Process ShaderLab -ErrorAction SilentlyContinue)) { + $script:AppDied = $true + Write-Host " !! ShaderLab process is gone -- treating as a crash, skipping the rest." -ForegroundColor Red + } } } @@ -155,9 +185,11 @@ RunTest "Graph.AddClockNode" { } RunTest "Graph.AddMathNode" { - $id = AddNode "Add" + # Was "Add". The discrete Add/Max math nodes were retired in favour of the + # ExprTk-backed Numeric Expression node. + $id = AddNode "Numeric Expression" $node = GetNode $id - return $node.name -eq "Add" + return $node.name -eq "Numeric Expression" } RunTest "Graph.AddVideoSource" { @@ -286,8 +318,12 @@ foreach ($effectName in $sourceEffects) { } # Test analysis effects -$analysisEffects = @("Luminance Heatmap", "Gamut Highlight", "Vectorscope", - "Waveform Monitor", "Nit Map", "Split Comparison") +# NOTE: "Vectorscope" and "Waveform Monitor" were removed from this list -- +# they are not in the effect registry (verified via list_effects). Note that +# .context/resume.md still lists both under "Analysis -> Scopes", so the doc is +# stale, not this list. +$analysisEffects = @("Luminance Heatmap", "Gamut Highlight", + "Nit Map", "Split Comparison") foreach ($effectName in $analysisEffects) { RunTest "Eval.Analysis.$($effectName -replace ' ','')" { $src = AddNode "Gamut Source" @@ -311,42 +347,47 @@ RunTest "Binding.FloatParameterToEffect" { $src = AddNode "Gamut Source" Connect $src 0 $blur 0 BindProperty $blur "StandardDeviation" $param "Value" + # Bindings on a D2D effect only propagate when the effect is actually + # evaluated, and evaluation only reaches nodes in the render path. Without + # this the property stays at its authored default (3.0) and the test fails + # for a reason that has nothing to do with binding. (Data-only nodes such as + # Numeric Expression differ -- they evaluate on the tick regardless.) + McpCall "set_preview_node" @{ nodeId = $blur } | Out-Null WaitForDirtySettle 3 - # The bound value should propagate — check the node's runtime properties. $node = GetNode $blur - # StandardDeviation may show as the bound value or as a float ~5.0. $sd = $node.properties.StandardDeviation return $null -ne $sd -and $sd -ge 4.5 } -RunTest "Binding.MathAddNode" { - $a = AddNode "Float Parameter" - $b = AddNode "Float Parameter" - $add = AddNode "Add" - SetProperty $a "Value" 3.0 - SetProperty $b "Value" 7.0 - BindProperty $add "A" $a "Value" - BindProperty $add "B" $b "Value" +RunTest "Eval.NumericExpressionDirect" { + # Replaces the retired "Add" node test. Note only the "A" input exists as a + # property over MCP -- setting Expression to something referencing B does + # NOT create a B property, so multi-variable expressions are not currently + # drivable through the MCP surface. + $e = AddNode "Numeric Expression" + SetProperty $e "Expression" "A * 2" + SetProperty $e "A" 5.0 WaitForDirtySettle 2 - $analysis = GetAnalysis $add + $analysis = GetAnalysis $e if (-not $analysis -or -not $analysis.fields) { return $false } $result = ($analysis.fields | Where-Object { $_.name -eq "Result" }) return $null -ne $result -and [math]::Abs($result.value[0] - 10.0) -lt 0.01 } -RunTest "Binding.MathMaxNode" { +RunTest "Binding.NumericExpressionBound" { + # Replaces the retired "Max" node test, and is the real regression guard for + # binding propagation into a data node: A is driven by an upstream Float + # Parameter rather than set directly. $a = AddNode "Float Parameter" - $b = AddNode "Float Parameter" - $max = AddNode "Max" - SetProperty $a "Value" 3.0 - SetProperty $b "Value" 7.0 - BindProperty $max "A" $a "Value" - BindProperty $max "B" $b "Value" + $e = AddNode "Numeric Expression" + SetProperty $e "Expression" "A * 2" + SetProperty $a "Value" 6.0 + BindProperty $e "A" $a "Value" WaitForDirtySettle 2 - $analysis = GetAnalysis $max + $analysis = GetAnalysis $e if (-not $analysis -or -not $analysis.fields) { return $false } $result = ($analysis.fields | Where-Object { $_.name -eq "Result" }) - return $null -ne $result -and [math]::Abs($result.value[0] - 7.0) -lt 0.01 + return $null -ne $result -and [math]::Abs($result.value[0] - 12.0) -lt 0.01 } # ============================================================================ diff --git a/Tests/fixtures/test_cli_basic.json b/Tests/fixtures/test_cli_basic.json index f8a2c72..10da2ad 100644 --- a/Tests/fixtures/test_cli_basic.json +++ b/Tests/fixtures/test_cli_basic.json @@ -1,6 +1,6 @@ { "formatVersion": 2, - "appVersion": "1.4.0", + "appVersion": "1.7.3", "nodes": [ { "id": 1, @@ -11,11 +11,27 @@ 60 ], "properties": [ + { + "name": "BluePrimary", + "type": "float2", + "value": [ + 0.15000000596046448, + 0.05999999865889549 + ] + }, { "name": "Gamut", "type": "float", "value": 0 }, + { + "name": "GreenPrimary", + "type": "float2", + "value": [ + 0.30000001192092896, + 0.6000000238418579 + ] + }, { "name": "Luminance", "type": "float", @@ -27,34 +43,12 @@ "value": 64 }, { - "name": "WsBlueX_hidden", - "type": "float", - "value": 0.1435546875 - }, - { - "name": "WsBlueY_hidden", - "type": "float", - "value": 0.0556640625 - }, - { - "name": "WsGreenX_hidden", - "type": "float", - "value": 0.244140625 - }, - { - "name": "WsGreenY_hidden", - "type": "float", - "value": 0.708984375 - }, - { - "name": "WsRedX_hidden", - "type": "float", - "value": 0.68359375 - }, - { - "name": "WsRedY_hidden", - "type": "float", - "value": 0.3046875 + "name": "RedPrimary", + "type": "float2", + "value": [ + 0.6399999856948853, + 0.33000001311302185 + ] } ], "inputPins": [], @@ -66,7 +60,7 @@ ], "customEffect": { "shaderType": 0, - "hlslSource": "\n// ---- ShaderLab Color Math Library ----\n\n// scRGB: linear Rec.709 primaries, 1.0 = 80 nits SDR white\n// Pipeline operates in FP16 scRGB throughout.\n\n// sRGB EOTF (decode gamma)\nfloat3 SRGBToLinear(float3 c) {\n return float3(\n c.r <= 0.04045 ? c.r / 12.92 : pow((c.r + 0.055) / 1.055, 2.4),\n c.g <= 0.04045 ? c.g / 12.92 : pow((c.g + 0.055) / 1.055, 2.4),\n c.b <= 0.04045 ? c.b / 12.92 : pow((c.b + 0.055) / 1.055, 2.4));\n}\n\n// sRGB inverse EOTF (encode gamma)\nfloat3 LinearToSRGB(float3 c) {\n return float3(\n c.r <= 0.0031308 ? c.r * 12.92 : 1.055 * pow(c.r, 1.0/2.4) - 0.055,\n c.g <= 0.0031308 ? c.g * 12.92 : 1.055 * pow(c.g, 1.0/2.4) - 0.055,\n c.b <= 0.0031308 ? c.b * 12.92 : 1.055 * pow(c.b, 1.0/2.4) - 0.055);\n}\n\n// scRGB (Rec.709 linear) -> CIE XYZ (D65)\nstatic const float3x3 REC709_TO_XYZ = float3x3(\n 0.4123908, 0.3575843, 0.1804808,\n 0.2126390, 0.7151687, 0.0721923,\n 0.0193308, 0.1191950, 0.9505322\n);\n\n// CIE XYZ (D65) -> scRGB (Rec.709 linear)\nstatic const float3x3 XYZ_TO_REC709 = float3x3(\n 3.2409699, -1.5373832, -0.4986108,\n -0.9692436, 1.8759675, 0.0415551,\n 0.0556301, -0.2039770, 1.0569715\n);\n\n// scRGB -> CIE XYZ\nfloat3 ScRGBToXYZ(float3 rgb) {\n return mul(REC709_TO_XYZ, rgb);\n}\n\n// CIE XYZ -> scRGB\nfloat3 XYZToScRGB(float3 xyz) {\n return mul(XYZ_TO_REC709, xyz);\n}\n\n// CIE XYZ -> CIE xyY\nfloat3 XYZToxyY(float3 xyz) {\n float sum = xyz.x + xyz.y + xyz.z;\n float2 xy = (sum < 1e-10) ? float2(0.3127, 0.3290) : float2(xyz.x / sum, xyz.y / sum);\n return float3(xy, xyz.y);\n}\n\n// CIE xyY -> CIE XYZ\nfloat3 xyYToXYZ(float3 xyY) {\n float X = (xyY.y < 1e-10) ? 0.0 : xyY.x * xyY.z / xyY.y;\n float Z = (xyY.y < 1e-10) ? 0.0 : (1.0 - xyY.x - xyY.y) * xyY.z / xyY.y;\n return float3(X, xyY.z, Z);\n}\n\n// Luminance in nits from scRGB (1.0 scRGB = 80 nits)\nfloat ScRGBToNits(float3 rgb) {\n return dot(rgb, float3(0.2126390, 0.7151687, 0.0721923)) * 80.0;\n}\n\n// Luminance in nits from scRGB (Y component, handles negative values)\nfloat ScRGBLuminanceNits(float3 rgb) {\n return max(0.0, dot(rgb, float3(0.2126390, 0.7151687, 0.0721923))) * 80.0;\n}\n\n// PQ (ST.2084) EOTF: PQ signal [0,1] -> linear nits [0,10000]\nfloat PQ_EOTF(float N) {\n float Np = pow(max(N, 0.0), 1.0 / 78.84375);\n float num = max(Np - 0.8359375, 0.0);\n float den = 18.8515625 - 18.6875 * Np;\n return 10000.0 * pow(num / max(den, 1e-10), 1.0 / 0.1593017578125);\n}\n\n// PQ inverse EOTF: linear nits [0,10000] -> PQ signal [0,1]\nfloat PQ_InvEOTF(float L) {\n float Lp = pow(max(L, 0.0) / 10000.0, 0.1593017578125);\n float num = 0.8359375 + 18.8515625 * Lp;\n float den = 1.0 + 18.6875 * Lp;\n return pow(num / den, 78.84375);\n}\n\n// Rec.2020 linear -> CIE XYZ\nstatic const float3x3 REC2020_TO_XYZ = float3x3(\n 0.6369580, 0.1446169, 0.1688810,\n 0.2627002, 0.6779981, 0.0593017,\n 0.0000000, 0.0280727, 1.0609851\n);\n\n// CIE XYZ -> Rec.2020 linear\nstatic const float3x3 XYZ_TO_REC2020 = float3x3(\n 1.7166512, -0.3556708, -0.2533663,\n -0.6666844, 1.6164812, 0.0157685,\n 0.0176399, -0.0427706, 0.9421031\n);\n\n// DCI-P3 (D65) linear -> CIE XYZ\nstatic const float3x3 P3D65_TO_XYZ = float3x3(\n 0.4865709, 0.2656677, 0.1982173,\n 0.2289746, 0.6917385, 0.0792869,\n 0.0000000, 0.0451134, 1.0439444\n);\n\n// CIE XYZ -> DCI-P3 (D65) linear\nstatic const float3x3 XYZ_TO_P3D65 = float3x3(\n 2.4934969, -0.9313836, -0.4027108,\n -0.8294890, 1.7626641, 0.0236247,\n 0.0358458, -0.0761724, 0.9568845\n);\n\n// Gamut primaries in CIE xy coordinates\n// Rec.709/sRGB\nstatic const float2 GAMUT_709_R = float2(0.64, 0.33);\nstatic const float2 GAMUT_709_G = float2(0.30, 0.60);\nstatic const float2 GAMUT_709_B = float2(0.15, 0.06);\n\n// DCI-P3 (D65)\nstatic const float2 GAMUT_P3_R = float2(0.680, 0.320);\nstatic const float2 GAMUT_P3_G = float2(0.265, 0.690);\nstatic const float2 GAMUT_P3_B = float2(0.150, 0.060);\n\n// Rec.2020\nstatic const float2 GAMUT_2020_R = float2(0.708, 0.292);\nstatic const float2 GAMUT_2020_G = float2(0.170, 0.797);\nstatic const float2 GAMUT_2020_B = float2(0.131, 0.046);\n\n// D65 white point\nstatic const float2 D65_WHITE = float2(0.3127, 0.3290);\n\n// Check if point p is inside triangle (a, b, c) using barycentric coordinates\nbool PointInTriangle(float2 p, float2 a, float2 b, float2 c) {\n float2 v0 = c - a, v1 = b - a, v2 = p - a;\n float d00 = dot(v0, v0);\n float d01 = dot(v0, v1);\n float d02 = dot(v0, v2);\n float d11 = dot(v1, v1);\n float d12 = dot(v1, v2);\n float inv = 1.0 / (d00 * d11 - d01 * d01);\n float u = (d11 * d02 - d01 * d12) * inv;\n float v = (d00 * d12 - d01 * d02) * inv;\n return (u >= 0) && (v >= 0) && (u + v <= 1.0);\n}\n\n// Turbo colormap approximation (for luminance heatmaps)\nfloat3 TurboColormap(float t) {\n t = saturate(t);\n float r = saturate(0.13572138 + t * (4.61539260 + t * (-42.66032258 + t * (132.13108234 + t * (-152.94239396 + t * 59.28637943)))));\n float g = saturate(0.09140261 + t * (2.19418839 + t * (4.84296658 + t * (-14.18503333 + t * (4.27729857 + t * 2.82956604)))));\n float b = saturate(0.10667330 + t * (12.64194608 + t * (-60.58204836 + t * (110.36276771 + t * (-89.90310912 + t * 27.34824973)))));\n return float3(r, g, b);\n}\n\n// D65 reference white in XYZ\nstatic const float3 D65_XYZ = float3(0.95047, 1.00000, 1.08883);\n\n// CIE Lab helper\nfloat LabF(float t) {\n // Signed extension: handle negative XYZ values from out-of-gamut scRGB.\n float at = abs(t);\n float ft = (at > 0.008856) ? pow(at, 1.0/3.0) : (7.787 * at + 16.0/116.0);\n return (t < 0.0) ? -ft : ft;\n}\n\n// CIE XYZ -> CIE L*a*b* (D65)\nfloat3 XYZToLab(float3 xyz) {\n float fx = LabF(xyz.x / D65_XYZ.x);\n float fy = LabF(xyz.y / D65_XYZ.y);\n float fz = LabF(xyz.z / D65_XYZ.z);\n float L = 116.0 * fy - 16.0;\n float a = 500.0 * (fx - fy);\n float b = 200.0 * (fy - fz);\n return float3(L, a, b);\n}\n\n// scRGB -> CIE L*a*b*\nfloat3 ScRGBToLab(float3 rgb) {\n return XYZToLab(ScRGBToXYZ(rgb));\n}\n\n// ---- ICtCp (BT.2100) ----\n// Pipeline: scRGB -> XYZ -> LMS (BT.2124 cross-talk) -> PQ encode -> ICtCp\n\n// XYZ to LMS (BT.2124 / Hunt-Pointer-Estevez with cross-talk)\nstatic const float3x3 XYZ_TO_LMS_ICTCP = float3x3(\n 0.3592832, 0.6976051, -0.0358916,\n -0.1920808, 1.1004768, 0.0753741,\n 0.0070797, 0.0748262, 0.8433009\n);\n\n// LMS to XYZ (inverse)\nstatic const float3x3 LMS_TO_XYZ_ICTCP = float3x3(\n 2.0701800, -1.3264569, 0.2066510,\n 0.3649882, 0.6805541, -0.0453723,\n -0.0496570, -0.0492033, 1.1880720\n);\n\n// PQ-encoded LMS to ICtCp\nstatic const float3x3 PQLMS_TO_ICTCP = float3x3(\n 2048.0/4096.0, 2048.0/4096.0, 0.0/4096.0,\n 6610.0/4096.0, -13613.0/4096.0, 7003.0/4096.0,\n 17933.0/4096.0, -17390.0/4096.0, -543.0/4096.0\n);\n\n// ICtCp to PQ-encoded LMS (inverse)\nstatic const float3x3 ICTCP_TO_PQLMS = float3x3(\n 1.0, 0.008609037, 0.111029625,\n 1.0, -0.008609037, -0.111029625,\n 1.0, 0.560031336, -0.320627175\n);\n\n// scRGB -> ICtCp\nfloat3 ScRGBToICtCp(float3 rgb) {\n // scRGB (1.0 = 80 nits) -> absolute luminance XYZ\n float3 xyz = ScRGBToXYZ(max(rgb, 0.0));\n // Scale to absolute nits for PQ (XYZ Y=1 = 80 nits in scRGB)\n xyz *= 80.0;\n float3 lms = mul(XYZ_TO_LMS_ICTCP, xyz);\n lms = max(lms, 0.0);\n // PQ encode each LMS component (input in nits, output [0,1])\n float3 pqLms = float3(\n PQ_InvEOTF(lms.x),\n PQ_InvEOTF(lms.y),\n PQ_InvEOTF(lms.z));\n return mul(PQLMS_TO_ICTCP, pqLms);\n}\n\n// ICtCp -> scRGB\nfloat3 ICtCpToScRGB(float3 ictcp) {\n float3 pqLms = mul(ICTCP_TO_PQLMS, ictcp);\n // PQ decode to nits\n float3 lms = float3(\n PQ_EOTF(pqLms.x),\n PQ_EOTF(pqLms.y),\n PQ_EOTF(pqLms.z));\n float3 xyz = mul(LMS_TO_XYZ_ICTCP, lms);\n // Scale back from nits to scRGB (80 nits = 1.0)\n xyz /= 80.0;\n return XYZToScRGB(xyz);\n}\n\n// OKLab: linear sRGB -> OKLab\nfloat3 LinearToOKLab(float3 c) {\n float l = 0.4122214708 * c.r + 0.5363325363 * c.g + 0.0514459929 * c.b;\n float m = 0.2119034982 * c.r + 0.6806995451 * c.g + 0.1073969566 * c.b;\n float s = 0.0883024619 * c.r + 0.2817188376 * c.g + 0.6299787005 * c.b;\n float l_ = pow(max(l, 0.0), 1.0/3.0);\n float m_ = pow(max(m, 0.0), 1.0/3.0);\n float s_ = pow(max(s, 0.0), 1.0/3.0);\n return float3(\n 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_,\n 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_,\n 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_\n );\n}\n\n// ---- I-channel (PQ-encoded nits) helpers for ICtCp tone mapping ----\n// In BT.2100 ICtCp, I is a weighted PQ-encoded sum of LMS. For\n// chromaticity-preserving operations (compress / expand only I, leave\n// Ct/Cp), it is standard to treat I as if it were PQ(neutral_nits) and\n// design the curve in nits-via-PQ space. This is what BT.2390 does.\n\n// Convert a nit value to its corresponding I coordinate.\nfloat NitsToI(float nits) {\n return PQ_InvEOTF(max(nits, 0.0));\n}\n\n// Convert an I coordinate back to nits.\nfloat IToNits(float I) {\n return PQ_EOTF(I);\n}\n\n// Reinhard compression on I, expressed in I-space directly. Anchored\n// Mᅢᄊbius: maps 0 -> 0 and peakIn_I -> peakOut_I exactly, with f'(0)=1\n// (linear at the low end) and smooth rolloff near peakIn. Both peaks\n// are I coordinates (PQ values). For HDR -> SDR pass peakIn = HDR_I,\n// peakOut = SDR_I. Inputs above peakIn_I are clamped so the curve\n// can't walk past its anchor onto the rising branch beyond peakIn.\nfloat ReinhardCompressI(float I, float peakIn_I, float peakOut_I) {\n float pp = peakIn_I * peakOut_I;\n if (pp <= 1e-12) return 0.0;\n float Ic = clamp(I, 0.0, peakIn_I);\n float denom = pp + Ic * (peakIn_I - peakOut_I);\n return Ic * pp / max(denom, 1e-12);\n}\n\n// Inverse of ReinhardCompressI: given an I value in [0, peakOut_I]\n// returns the I in [0, peakIn_I] that would compress to it. Same\n// peakIn/peakOut convention as ReinhardCompressI: peakIn is the\n// *uncompressed* range, peakOut is the *compressed* range. For\n// SDR -> HDR expansion callers pass peakIn = HDR_I, peakOut = SDR_I.\n// Inputs above peakOut_I are clamped so the curve saturates at peakIn\n// rather than racing toward the asymptote.\nfloat ReinhardExpandI(float I, float peakIn_I, float peakOut_I) {\n float pp = peakIn_I * peakOut_I;\n if (pp <= 1e-12) return 0.0;\n float Ic = clamp(I, 0.0, peakOut_I);\n float denom = pp - Ic * (peakIn_I - peakOut_I);\n return Ic * pp / max(denom, 1e-12);\n}\n\n// Gamut Source - generates all colors within a selected color gamut\n// Fixed coordinate system centered on D65 white point. Scale fits Rec.2020\n// so all three gamuts share the same spatial mapping.\n\ncbuffer constants : register(b0) {\n float Gamut; // 0=Rec.709, 1=DCI-P3, 2=Rec.2020, 3=Working Space\n float Luminance; // nits (default 80.0, maps to scRGB 1.0)\n float OutputSize; // pixels (default 1024)\n float WsRedX_hidden; float WsRedY_hidden;\n float WsGreenX_hidden; float WsGreenY_hidden;\n float WsBlueX_hidden; float WsBlueY_hidden;\n};\n\nfloat4 main(\n float4 pos : SV_POSITION,\n float4 uv0 : TEXCOORD0) : SV_TARGET\n{\n float size = max(OutputSize, 128.0);\n\n // Select gamut primaries\n float2 r, g, b;\n if (Gamut > 2.5) { r = float2(WsRedX_hidden, WsRedY_hidden);\n g = float2(WsGreenX_hidden, WsGreenY_hidden);\n b = float2(WsBlueX_hidden, WsBlueY_hidden); }\n else if (Gamut > 1.5){ r = GAMUT_2020_R; g = GAMUT_2020_G; b = GAMUT_2020_B; }\n else if (Gamut > 0.5){ r = GAMUT_P3_R; g = GAMUT_P3_G; b = GAMUT_P3_B; }\n else { r = GAMUT_709_R; g = GAMUT_709_G; b = GAMUT_709_B; }\n\n float2 center = D65_WHITE;\n float halfExtent = 0.50;\n\n float2 uv = uv0.xy / size;\n float2 xy;\n xy.x = center.x + (uv.x - 0.5) * 2.0 * halfExtent;\n xy.y = center.y - (uv.y - 0.5) * 2.0 * halfExtent;\n\n if (!PointInTriangle(xy, r, g, b))\n return float4(0, 0, 0, 1.0);\n\n float Y = Luminance / 80.0;\n float3 xyY_val = float3(xy.x, xy.y, Y);\n float3 xyz = xyYToXYZ(xyY_val);\n float3 rgb = XYZToScRGB(xyz);\n\n return float4(rgb, 1.0);\n}\n", + "hlslSource": "\n// ---- ShaderLab Color Math Library ----\n\n// scRGB: linear Rec.709 primaries, 1.0 = 80 nits SDR white\n// Pipeline operates in FP16 scRGB throughout.\n\n// sRGB EOTF (decode gamma)\nfloat3 SRGBToLinear(float3 c) {\n return float3(\n c.r <= 0.04045 ? c.r / 12.92 : pow((c.r + 0.055) / 1.055, 2.4),\n c.g <= 0.04045 ? c.g / 12.92 : pow((c.g + 0.055) / 1.055, 2.4),\n c.b <= 0.04045 ? c.b / 12.92 : pow((c.b + 0.055) / 1.055, 2.4));\n}\n\n// sRGB inverse EOTF (encode gamma)\nfloat3 LinearToSRGB(float3 c) {\n return float3(\n c.r <= 0.0031308 ? c.r * 12.92 : 1.055 * pow(c.r, 1.0/2.4) - 0.055,\n c.g <= 0.0031308 ? c.g * 12.92 : 1.055 * pow(c.g, 1.0/2.4) - 0.055,\n c.b <= 0.0031308 ? c.b * 12.92 : 1.055 * pow(c.b, 1.0/2.4) - 0.055);\n}\n\n// scRGB (Rec.709 linear) -> CIE XYZ (D65)\nstatic const float3x3 REC709_TO_XYZ = float3x3(\n 0.4123908, 0.3575843, 0.1804808,\n 0.2126390, 0.7151687, 0.0721923,\n 0.0193308, 0.1191950, 0.9505322\n);\n\n// CIE XYZ (D65) -> scRGB (Rec.709 linear)\nstatic const float3x3 XYZ_TO_REC709 = float3x3(\n 3.2409699, -1.5373832, -0.4986108,\n -0.9692436, 1.8759675, 0.0415551,\n 0.0556301, -0.2039770, 1.0569715\n);\n\n// scRGB -> CIE XYZ\nfloat3 ScRGBToXYZ(float3 rgb) {\n return mul(REC709_TO_XYZ, rgb);\n}\n\n// CIE XYZ -> scRGB\nfloat3 XYZToScRGB(float3 xyz) {\n return mul(XYZ_TO_REC709, xyz);\n}\n\n// CIE XYZ -> CIE xyY\nfloat3 XYZToxyY(float3 xyz) {\n float sum = xyz.x + xyz.y + xyz.z;\n float2 xy = (sum < 1e-10) ? float2(0.3127, 0.3290) : float2(xyz.x / sum, xyz.y / sum);\n return float3(xy, xyz.y);\n}\n\n// CIE xyY -> CIE XYZ\nfloat3 xyYToXYZ(float3 xyY) {\n float X = (xyY.y < 1e-10) ? 0.0 : xyY.x * xyY.z / xyY.y;\n float Z = (xyY.y < 1e-10) ? 0.0 : (1.0 - xyY.x - xyY.y) * xyY.z / xyY.y;\n return float3(X, xyY.z, Z);\n}\n\n// Luminance in nits from scRGB (1.0 scRGB = 80 nits)\nfloat ScRGBToNits(float3 rgb) {\n return dot(rgb, float3(0.2126390, 0.7151687, 0.0721923)) * 80.0;\n}\n\n// Luminance in nits from scRGB (Y component, handles negative values)\nfloat ScRGBLuminanceNits(float3 rgb) {\n return max(0.0, dot(rgb, float3(0.2126390, 0.7151687, 0.0721923))) * 80.0;\n}\n\n// PQ (ST.2084) EOTF: PQ signal [0,1] -> linear nits [0,10000]\nfloat PQ_EOTF(float N) {\n float Np = pow(max(N, 0.0), 1.0 / 78.84375);\n float num = max(Np - 0.8359375, 0.0);\n float den = 18.8515625 - 18.6875 * Np;\n return 10000.0 * pow(num / max(den, 1e-10), 1.0 / 0.1593017578125);\n}\n\n// PQ inverse EOTF: linear nits [0,10000] -> PQ signal [0,1]\nfloat PQ_InvEOTF(float L) {\n float Lp = pow(max(L, 0.0) / 10000.0, 0.1593017578125);\n float num = 0.8359375 + 18.8515625 * Lp;\n float den = 1.0 + 18.6875 * Lp;\n return pow(num / den, 78.84375);\n}\n\n// Rec.2020 linear -> CIE XYZ\nstatic const float3x3 REC2020_TO_XYZ = float3x3(\n 0.6369580, 0.1446169, 0.1688810,\n 0.2627002, 0.6779981, 0.0593017,\n 0.0000000, 0.0280727, 1.0609851\n);\n\n// CIE XYZ -> Rec.2020 linear\nstatic const float3x3 XYZ_TO_REC2020 = float3x3(\n 1.7166512, -0.3556708, -0.2533663,\n -0.6666844, 1.6164812, 0.0157685,\n 0.0176399, -0.0427706, 0.9421031\n);\n\n// DCI-P3 (D65) linear -> CIE XYZ\nstatic const float3x3 P3D65_TO_XYZ = float3x3(\n 0.4865709, 0.2656677, 0.1982173,\n 0.2289746, 0.6917385, 0.0792869,\n 0.0000000, 0.0451134, 1.0439444\n);\n\n// CIE XYZ -> DCI-P3 (D65) linear\nstatic const float3x3 XYZ_TO_P3D65 = float3x3(\n 2.4934969, -0.9313836, -0.4027108,\n -0.8294890, 1.7626641, 0.0236247,\n 0.0358458, -0.0761724, 0.9568845\n);\n\n// Gamut primaries in CIE xy coordinates\n// Rec.709/sRGB\nstatic const float2 GAMUT_709_R = float2(0.64, 0.33);\nstatic const float2 GAMUT_709_G = float2(0.30, 0.60);\nstatic const float2 GAMUT_709_B = float2(0.15, 0.06);\n\n// DCI-P3 (D65)\nstatic const float2 GAMUT_P3_R = float2(0.680, 0.320);\nstatic const float2 GAMUT_P3_G = float2(0.265, 0.690);\nstatic const float2 GAMUT_P3_B = float2(0.150, 0.060);\n\n// Rec.2020\nstatic const float2 GAMUT_2020_R = float2(0.708, 0.292);\nstatic const float2 GAMUT_2020_G = float2(0.170, 0.797);\nstatic const float2 GAMUT_2020_B = float2(0.131, 0.046);\n\n// D65 white point\nstatic const float2 D65_WHITE = float2(0.3127, 0.3290);\n\n// Check if point p is inside triangle (a, b, c) using barycentric coordinates\nbool PointInTriangle(float2 p, float2 a, float2 b, float2 c) {\n float2 v0 = c - a, v1 = b - a, v2 = p - a;\n float d00 = dot(v0, v0);\n float d01 = dot(v0, v1);\n float d02 = dot(v0, v2);\n float d11 = dot(v1, v1);\n float d12 = dot(v1, v2);\n float inv = 1.0 / (d00 * d11 - d01 * d01);\n float u = (d11 * d02 - d01 * d12) * inv;\n float v = (d00 * d12 - d01 * d02) * inv;\n return (u >= 0) && (v >= 0) && (u + v <= 1.0);\n}\n\n// Turbo colormap approximation (for luminance heatmaps)\nfloat3 TurboColormap(float t) {\n t = saturate(t);\n float r = saturate(0.13572138 + t * (4.61539260 + t * (-42.66032258 + t * (132.13108234 + t * (-152.94239396 + t * 59.28637943)))));\n float g = saturate(0.09140261 + t * (2.19418839 + t * (4.84296658 + t * (-14.18503333 + t * (4.27729857 + t * 2.82956604)))));\n float b = saturate(0.10667330 + t * (12.64194608 + t * (-60.58204836 + t * (110.36276771 + t * (-89.90310912 + t * 27.34824973)))));\n return float3(r, g, b);\n}\n\n// D65 reference white in XYZ\nstatic const float3 D65_XYZ = float3(0.95047, 1.00000, 1.08883);\n\n// CIE Lab helper\nfloat LabF(float t) {\n // Signed extension: handle negative XYZ values from out-of-gamut scRGB.\n float at = abs(t);\n float ft = (at > 0.008856) ? pow(at, 1.0/3.0) : (7.787 * at + 16.0/116.0);\n return (t < 0.0) ? -ft : ft;\n}\n\n// CIE XYZ -> CIE L*a*b* (D65)\nfloat3 XYZToLab(float3 xyz) {\n float fx = LabF(xyz.x / D65_XYZ.x);\n float fy = LabF(xyz.y / D65_XYZ.y);\n float fz = LabF(xyz.z / D65_XYZ.z);\n float L = 116.0 * fy - 16.0;\n float a = 500.0 * (fx - fy);\n float b = 200.0 * (fy - fz);\n return float3(L, a, b);\n}\n\n// scRGB -> CIE L*a*b*\nfloat3 ScRGBToLab(float3 rgb) {\n return XYZToLab(ScRGBToXYZ(rgb));\n}\n\n// ---- ICtCp (BT.2100) ----\n// Pipeline: scRGB -> XYZ -> LMS (BT.2124 cross-talk) -> PQ encode -> ICtCp\n\n// XYZ to LMS (BT.2124 / Hunt-Pointer-Estevez with cross-talk)\nstatic const float3x3 XYZ_TO_LMS_ICTCP = float3x3(\n 0.3592832, 0.6976051, -0.0358916,\n -0.1920808, 1.1004768, 0.0753741,\n 0.0070797, 0.0748262, 0.8433009\n);\n\n// LMS to XYZ (inverse)\nstatic const float3x3 LMS_TO_XYZ_ICTCP = float3x3(\n 2.0701800, -1.3264569, 0.2066510,\n 0.3649882, 0.6805541, -0.0453723,\n -0.0496570, -0.0492033, 1.1880720\n);\n\n// PQ-encoded LMS to ICtCp\nstatic const float3x3 PQLMS_TO_ICTCP = float3x3(\n 2048.0/4096.0, 2048.0/4096.0, 0.0/4096.0,\n 6610.0/4096.0, -13613.0/4096.0, 7003.0/4096.0,\n 17933.0/4096.0, -17390.0/4096.0, -543.0/4096.0\n);\n\n// ICtCp to PQ-encoded LMS (inverse)\nstatic const float3x3 ICTCP_TO_PQLMS = float3x3(\n 1.0, 0.008609037, 0.111029625,\n 1.0, -0.008609037, -0.111029625,\n 1.0, 0.560031336, -0.320627175\n);\n\n// scRGB -> ICtCp\nfloat3 ScRGBToICtCp(float3 rgb) {\n // scRGB (1.0 = 80 nits) -> absolute luminance XYZ\n float3 xyz = ScRGBToXYZ(max(rgb, 0.0));\n // Scale to absolute nits for PQ (XYZ Y=1 = 80 nits in scRGB)\n xyz *= 80.0;\n float3 lms = mul(XYZ_TO_LMS_ICTCP, xyz);\n lms = max(lms, 0.0);\n // PQ encode each LMS component (input in nits, output [0,1])\n float3 pqLms = float3(\n PQ_InvEOTF(lms.x),\n PQ_InvEOTF(lms.y),\n PQ_InvEOTF(lms.z));\n return mul(PQLMS_TO_ICTCP, pqLms);\n}\n\n// ICtCp -> scRGB\nfloat3 ICtCpToScRGB(float3 ictcp) {\n float3 pqLms = mul(ICTCP_TO_PQLMS, ictcp);\n // Defensive clamp: PQ_EOTF is only defined for V in [0, 1]. Out-of-range\n // pqLms (which can happen when callers modify I-channel without rescaling\n // Ct/Cp, or with out-of-gamut chroma) produce NaN/Inf via the EOTF.\n pqLms = saturate(pqLms);\n // PQ decode to nits\n float3 lms = float3(\n PQ_EOTF(pqLms.x),\n PQ_EOTF(pqLms.y),\n PQ_EOTF(pqLms.z));\n float3 xyz = mul(LMS_TO_XYZ_ICTCP, lms);\n // Scale back from nits to scRGB (80 nits = 1.0)\n xyz /= 80.0;\n return XYZToScRGB(xyz);\n}\n\n// OKLab: linear sRGB -> OKLab\nfloat3 LinearToOKLab(float3 c) {\n float l = 0.4122214708 * c.r + 0.5363325363 * c.g + 0.0514459929 * c.b;\n float m = 0.2119034982 * c.r + 0.6806995451 * c.g + 0.1073969566 * c.b;\n float s = 0.0883024619 * c.r + 0.2817188376 * c.g + 0.6299787005 * c.b;\n float l_ = pow(max(l, 0.0), 1.0/3.0);\n float m_ = pow(max(m, 0.0), 1.0/3.0);\n float s_ = pow(max(s, 0.0), 1.0/3.0);\n return float3(\n 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_,\n 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_,\n 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_\n );\n}\n\n// ---- I-channel (PQ-encoded nits) helpers for ICtCp tone mapping ----\n// In BT.2100 ICtCp, I is a weighted PQ-encoded sum of LMS. For\n// chromaticity-preserving operations (compress / expand only I, leave\n// Ct/Cp), it is standard to treat I as if it were PQ(neutral_nits) and\n// design the curve in nits-via-PQ space. This is what BT.2390 does.\n\n// Convert a nit value to its corresponding I coordinate.\nfloat NitsToI(float nits) {\n return PQ_InvEOTF(max(nits, 0.0));\n}\n\n// Convert an I coordinate back to nits.\nfloat IToNits(float I) {\n return PQ_EOTF(I);\n}\n\n// Reinhard compression on I, expressed in I-space directly. Anchored\n// Mᅢᄊbius: maps 0 -> 0 and peakIn_I -> peakOut_I exactly, with f'(0)=1\n// (linear at the low end) and smooth rolloff near peakIn. Both peaks\n// are I coordinates (PQ values). For HDR -> SDR pass peakIn = HDR_I,\n// peakOut = SDR_I. Inputs above peakIn_I are clamped so the curve\n// can't walk past its anchor onto the rising branch beyond peakIn.\nfloat ReinhardCompressI(float I, float peakIn_I, float peakOut_I) {\n float pp = peakIn_I * peakOut_I;\n if (pp <= 1e-12) return 0.0;\n float Ic = clamp(I, 0.0, peakIn_I);\n float denom = pp + Ic * (peakIn_I - peakOut_I);\n return Ic * pp / max(denom, 1e-12);\n}\n\n// Inverse of ReinhardCompressI: given an I value in [0, peakOut_I]\n// returns the I in [0, peakIn_I] that would compress to it. Same\n// peakIn/peakOut convention as ReinhardCompressI: peakIn is the\n// *uncompressed* range, peakOut is the *compressed* range. For\n// SDR -> HDR expansion callers pass peakIn = HDR_I, peakOut = SDR_I.\n// Inputs above peakOut_I are clamped so the curve saturates at peakIn\n// rather than racing toward the asymptote.\nfloat ReinhardExpandI(float I, float peakIn_I, float peakOut_I) {\n float pp = peakIn_I * peakOut_I;\n if (pp <= 1e-12) return 0.0;\n float Ic = clamp(I, 0.0, peakOut_I);\n float denom = pp - Ic * (peakIn_I - peakOut_I);\n return Ic * pp / max(denom, 1e-12);\n}\n\n// Gamut Source - generates all colors within a selected color gamut\n// Fixed coordinate system centered on D65 white point. Scale fits Rec.2020\n// so all three gamuts share the same spatial mapping.\n//\n// Gamut modes:\n// 0 = Rec.709\n// 1 = DCI-P3\n// 2 = Rec.2020\n// 3 = Custom (uses RedPrimary/GreenPrimary/BluePrimary; bind to\n// Working Space.RedPrimary etc. for a monitor-matched source.)\n\ncbuffer constants : register(b0) {\n float Gamut;\n float Luminance; // nits (default 80.0, maps to scRGB 1.0)\n float OutputSize; // pixels (default 1024)\n float2 RedPrimary;\n float2 GreenPrimary;\n float2 BluePrimary;\n};\n\nfloat4 main(\n float4 pos : SV_POSITION,\n float4 uv0 : TEXCOORD0) : SV_TARGET\n{\n float size = max(OutputSize, 128.0);\n\n // Read all cbuffer vars at top to keep DXC from optimizing them out.\n float gamut = Gamut;\n float2 cR = RedPrimary;\n float2 cG = GreenPrimary;\n float2 cB = BluePrimary;\n\n // Select gamut primaries\n float2 r, g, b;\n if (gamut > 2.5) { r = cR; g = cG; b = cB; }\n else if (gamut > 1.5){ r = GAMUT_2020_R; g = GAMUT_2020_G; b = GAMUT_2020_B; }\n else if (gamut > 0.5){ r = GAMUT_P3_R; g = GAMUT_P3_G; b = GAMUT_P3_B; }\n else { r = GAMUT_709_R; g = GAMUT_709_G; b = GAMUT_709_B; }\n\n float2 center = D65_WHITE;\n float halfExtent = 0.50;\n\n float2 uv = uv0.xy / size;\n float2 xy;\n xy.x = center.x + (uv.x - 0.5) * 2.0 * halfExtent;\n xy.y = center.y - (uv.y - 0.5) * 2.0 * halfExtent;\n\n if (!PointInTriangle(xy, r, g, b))\n return float4(0, 0, 0, 1.0);\n\n float Y = Luminance / 80.0;\n float3 xyY_val = float3(xy.x, xy.y, Y);\n float3 xyz = xyYToXYZ(xyY_val);\n float3 rgb = XYZToScRGB(xyz);\n\n return float4(rgb, 1.0);\n}\n", "inputNames": [], "parameters": [ { @@ -84,7 +78,7 @@ "Rec.709", "DCI-P3", "Rec.2020", - "Working Space" + "Custom" ] }, { @@ -110,6 +104,54 @@ "type": "float", "value": 512 } + }, + { + "name": "RedPrimary", + "typeName": "float2", + "minValue": 0, + "maxValue": 1, + "step": 0.0010000000474974513, + "default": { + "name": "RedPrimary", + "type": "float2", + "value": [ + 0.6399999856948853, + 0.33000001311302185 + ] + }, + "visibleWhen": "Gamut == 3" + }, + { + "name": "GreenPrimary", + "typeName": "float2", + "minValue": 0, + "maxValue": 1, + "step": 0.0010000000474974513, + "default": { + "name": "GreenPrimary", + "type": "float2", + "value": [ + 0.30000001192092896, + 0.6000000238418579 + ] + }, + "visibleWhen": "Gamut == 3" + }, + { + "name": "BluePrimary", + "typeName": "float2", + "minValue": 0, + "maxValue": 1, + "step": 0.0010000000474974513, + "default": { + "name": "BluePrimary", + "type": "float2", + "value": [ + 0.15000000596046448, + 0.05999999865889549 + ] + }, + "visibleWhen": "Gamut == 3" } ], "threadGroupX": 8, @@ -118,7 +160,7 @@ "analysisOutputType": 0, "analysisOutputSize": 256, "shaderLabEffectId": "Gamut Source", - "shaderLabEffectVersion": 2 + "shaderLabEffectVersion": 3 } } ], diff --git a/docs/README.md b/docs/README.md index cd2f8ae..ddb3f95 100644 --- a/docs/README.md +++ b/docs/README.md @@ -50,6 +50,7 @@ Reference for the effect catalog and per-effect mechanics. - [Build Instructions](development/build.md) — prerequisites, configurations, dependency map. - [Project Structure](development/project-structure.md) — full file tree with per-file descriptions. +- [MCP Migration: HTTP → stdio](development/mcp-stdio-migration.md) — **in-progress** implementation plan for replacing the embedded HTTP MCP server with stdio + a broker relay. Step-by-step, with the platform questions already settled by spike. ## History diff --git a/docs/development/build.md b/docs/development/build.md index 015272e..2810b77 100644 --- a/docs/development/build.md +++ b/docs/development/build.md @@ -4,23 +4,98 @@ - Visual Studio 2022 17.8+ **or** Visual Studio 2026 Insiders (with C++ Desktop and UWP workloads) - Windows App SDK 1.8 - Windows 10 SDK (10.0.26100+) -- PowerShell 5.1+ (for the pre-build scripts) -- Internet access on first build (so `EnsureExprTk.ps1` can download `exprtk.hpp`) +- PowerShell 5.1+ (for the dev-cert pre-build step) +- Git (the native dependencies are submodules) + +## Clone + +The two native third-party dependencies are git submodules, so clone +recursively: + +```pwsh +git clone --recurse-submodules https://github.com//ShaderLab.git +``` + +On an existing clone, or after pulling a change that moves a submodule +pointer: + +```pwsh +git submodule update --init --recursive +``` + +If you skip this, `ShaderLabEngine.vcxproj`'s `VerifySubmodules` target +fails the build with that exact command rather than emitting a wall of +missing-header errors. + +## Native Dependencies (submodules) + +| Path | Upstream | Pin | License | Used by | +|---|---|---|---|---| +| `third_party/exprtk` | [ArashPartow/exprtk](https://github.com/ArashPartow/exprtk) | commit `1e4a80b` | MIT | `Rendering/MathExpression.cpp` — Numeric Expression node | +| `third_party/miniz` | [richgel999/miniz](https://github.com/richgel999/miniz) | tag `3.1.2` | MIT | `Rendering/EffectGraphFile.cpp` — `.effectgraph` ZIP DEFLATE | + +`third_party/miniz_export.h` is a **3-line in-tree shim**, not part of the +submodule. Upstream's `miniz.h` includes `miniz_export.h`, which their +CMake generates via `generate_export_header()`; miniz's own amalgamation +step substitutes an empty `#define MINIZ_EXPORT` instead. We do the same, +so consuming the submodule doesn't drag a CMake toolchain into an +otherwise MSBuild-only repo. miniz links statically into +`ShaderLabEngine.dll` and its symbols are never re-exported, so the empty +macro is correct. + +Only three of miniz's split sources are compiled — `miniz.c`, +`miniz_tdef.c`, `miniz_tinfl.c`. `miniz_zip.c` is deliberately omitted: +`EffectGraphFile.cpp` writes the ZIP container itself and uses only raw +DEFLATE (`tdefl_compress_mem_to_heap`, `tinfl_decompress_mem_to_heap`, +`mz_free`). ## Build 1. Open `ShaderLab.slnx` in Visual Studio. -2. Pre-build steps run automatically on first build: - - `scripts\EnsureDevCert.ps1` — generates and installs the local F5 dev cert. - - `scripts\EnsureExprTk.ps1` — downloads `exprtk.hpp` (MIT) into `third_party\exprtk\`. +2. `scripts\EnsureDevCert.ps1` runs automatically on first build to generate + and install the local `CN=ShaderLab` F5 dev cert. 3. NuGet packages restore automatically. 4. Build configurations: - `Debug | x64`, `Release | x64` - `Debug | ARM64`, `Release | ARM64` + +### Building ARM64 *on* an ARM64 host + +CI cross-compiles ARM64 from an x64 runner, so neither of these surfaces there. Both +bite when building ARM64 natively on an ARM64 machine, and both fail in misleading ways: + +- **Install the `Microsoft.VisualStudio.Component.UWP.VC.ARM64` component.** Without + it, `MSBuild\Microsoft\VC\\Application Type\Windows Store\10.0\Platforms\` + contains only `Win32` and `x64`, so the packaged WinUI app has no ARM64 platform. + `OutDir` then falls back to a managed default and the build fails with *"The + BaseOutputPath/OutputPath property is not set for project 'ShaderLab.vcxproj'"*. The + plain desktop projects build fine, so the failure looks partial and unrelated. +- **Invoke the ARM64 MSBuild** — `MSBuild\Current\Bin\arm64\MSBuild.exe`, not + `MSBuild\Current\Bin\MSBuild.exe`. The default binary is 32-bit and reports + `PROCESSOR_ARCHITECTURE=x86` under emulation, so the toolset selection in + `Microsoft.Cpp.ToolsetLocation.props` never matches its ARM64 branch and falls + through to the **32-bit** `bin\HostX86\arm64\cl.exe`. That compiler exhausts its + ~3 GB address space on the large generated translation units and fails with + `C3859: Failed to create virtual memory for PCH` + `C1076: internal heap limit + reached`. Passing `/p:PreferredToolArchitecture=x64` does **not** help — that props + file declares `TreatAsLocalProperty` and demotes the value straight back to `x86`. + Using the ARM64 MSBuild yields `VCToolArchitecture=NativeARM64` and the whole + solution builds clean. 5. Outputs (per arch): - `x64\Debug\ShaderLabEngine\ShaderLabEngine.dll` - `x64\Debug\ShaderLab\ShaderLab.exe` - `x64\Debug\ShaderLabTests\ShaderLabTests.exe` +### Updating a dependency + +```pwsh +cd third_party/miniz +git fetch --tags +git checkout +cd ../.. +git add third_party/miniz +git commit -m "Bump miniz to " +``` + ## Releases GitHub Actions workflow `.github/workflows/release.yml` runs as a matrix (`x64`, `ARM64`). Just before MSBuild, the workflow injects the unsigned-namespace OID into the manifest's `Publisher` so that the resulting MSIX is installable via `Add-AppxPackage -AllowUnsigned`. The in-repo `Package.appxmanifest` keeps the plain `CN=ShaderLab` publisher so signed F5 deploys keep working. diff --git a/docs/development/mcp-stdio-migration.md b/docs/development/mcp-stdio-migration.md new file mode 100644 index 0000000..d3d8bad --- /dev/null +++ b/docs/development/mcp-stdio-migration.md @@ -0,0 +1,457 @@ +# MCP Migration: HTTP → stdio + broker relay + +Implementation plan for replacing the embedded HTTP MCP server with a stdio transport +fronted by a singleton broker. Written to be picked up on a different machine — see +[Picking this up](#picking-this-up) for prerequisites and the exact commands. + +Status: **planning complete, implementation not started.** Steps 1–9 below are +outstanding. Two throwaway spikes have already settled the platform questions; their +results are recorded in [Settled by spike](#settled-by-spike) so they are not +re-litigated. + +--- + +## Why + +The MCP server is a Winsock HTTP listener embedded in every host +(`Engine/Mcp/McpHttpServer.cpp`), default port 47808. + +1. **Unaddressable.** The listener scans 10 ports and nothing publishes the bound one, + so a client config can't reliably find a session, and multiple ShaderLab windows + can't be told apart at all. +2. **Unauthenticated and in the clear.** Every response carries + `Access-Control-Allow-Origin: *` on an unauthenticated loopback socket. Any local + process can drive the graph and observe traffic, including `render_capture_node` + image payloads. +3. **Wrong transport for the ecosystem.** MCP clients expect stdio. + +--- + +## Target architecture + +``` +MCP client ──stdio──> shim ─┐ +MCP client ──stdio──> shim ─┤ sealed frames + \\.\pipe\ShaderLab.mcp.v1. (hub: blind relay) + ShaderLab.exe (session) ─┤ + ShaderLabHeadless.exe (session)┘ +``` + +| Component | Role | Packaged? | Sees plaintext? | +|---|---|---|---| +| **Shim** (`--stdio`) | Full MCP front-end: answers `initialize`, owns `list_sessions`/`use_session`, splices `tools/list`, correlates `id`, owns request timeouts | **No** — copied to `%LOCALAPPDATA%\ShaderLab\bin\` | Yes (endpoint) | +| **Hub** (`--hub`) | Blind byte relay. Routes on `{channelId, seq}`; channel liveness only | **Yes** — activation is the only way it survives the client's job object | **No** | +| **Session** | MCP JSON-RPC + route table, engine-side so both hosts get it | Yes | Yes (endpoint) | + +### Why encrypt at all + +Everything runs in one user session, and same-user isolation is not a hard boundary on +Windows. This is **not** a defence against a local attacker and the plan should not +claim otherwise. The reason is architectural: it makes "the hub is plumbing" a property +of the code rather than a convention — the hub cannot log, cache, dump or inspect a +payload because it does not hold the key, and that stays true when someone later adds +diagnostics to the relay. A 4K inline capture (~33 MB of base64) never enters the +address space of the component most likely to be crash-dumping. + +Ephemeral P-256 ECDH → HKDF-SHA256 → AES-256-GCM, via BCrypt. No new dependency. + +### Binary pairing — the one enforced boundary + +Verify the peer **process**, not its claims, and keep it version-tolerant: + +- **Identity** = package family name only. *Not* the install root — it changes per + version and isn't reliably under `WindowsApps`. +- **Compatibility** = protocol version only, carried in the pipe name (`v1`). Bumped + only on wire-format breaks, never per release. +- **App / engine ABI version** = informational, surfaced via `list_sessions`, never a + rejection reason. Comparing it would reject the old-shim/new-hub pairing on **every** + routine upgrade and destroy the benefit of making the shim update-immune. + +--- + +## Settled by spike + +Measured with throwaway packages, not reasoned about. **Platform:** Windows 11 +(10.0.26xxx), ARM64. Behaviour is expected to be identical on x64, but the two items +marked ⚠ are worth re-checking if they ever look wrong, and none of this was verified +at the manifest's declared `10.0.17763` floor. + +| Question | Answer | +|---|---| +| Can a detached hub survive the MCP client's job object? | Only via `IApplicationActivationManager`. Plain `CreateProcess` is killed with the job; `CREATE_BREAKAWAY_FROM_JOB` fails `ERROR_ACCESS_DENIED`. | +| Must the hub be a packaged ``? | Yes — activation needs an AUMID. `AppListEntry="none"` + console subsystem validates and activates fine. | +| Does `ActivateApplication`'s `arguments` reach `argv`? | Yes. **All hub config must travel this way.** | +| Does the launcher's environment reach the hub? | **No** (cwd is `system32`). `SHADERLAB_MCP_*` env vars never reach a production hub. | +| Console window on activation? | **Yes, visible.** The hub must `FreeConsole()` first thing in `--hub` mode. | +| Two activations → one process or two? | **Two.** Redundant hubs are normal, so the election-loser path must be fast, silent and console-free. | +| Named pipe, user-only DACL + `PIPE_REJECT_REMOTE_CLIENTS`, under MSIX? | Works. | +| `GetNamedPipeServerProcessId` from the *client* handle? | ⚠ Works, though the docs imply server-side handles only. Cover it with a unit test. | +| Does an MSIX update kill a **packaged** shim? | **Yes** — hard kill, `exitCode=1`, no console ctrl event, so it can't even emit a final JSON-RPC error. Without `-ForceApplicationShutdown` the update instead **fails** with `0x80073D02 ERROR_PACKAGES_IN_USE`. Note `scripts/Install.ps1` passes the force flag. | +| Does an **unpackaged** shim survive an update? | **Yes, immune.** This is why the shim is copied out of the package. | +| Can the on-disk shim be refreshed while an old one runs? | ⚠ Overwrite-in-place is blocked; **rename-then-write works** and the running process is unaffected. | + +--- + +## Current state + +**Complete:** the activation spike, and repair of `Tests/RunTests.ps1` — the repo's only +MCP regression suite (26 MCP-driven tests). It was not runnable as found: a dead +`$MSBuild` path, an unused `WaitForMcp`, a readiness probe pointed at a +render-dispatched route with a 2 s timeout, and 6 stale tests referencing effects that +no longer exist. Now 33/33. + +Also completed, unplanned: two intermittent access violations were root-caused to a +single race (the UI thread reading live `EffectNode`s during canvas paint while the +render worker mutated the graph) and fixed using the pre-existing but unused +`GraphUiSnapshot`, plus a lock on `m_visuals`. This was a prerequisite — the suite +couldn't gate anything while 3 of 4 runs crashed. See decision-log #70; the resulting +threading rules live in `Controls/NodeGraphController.h` and +`.github/copilot-instructions.md`. + +**Not started:** Steps 1–9. + +--- + +## Picking this up + +### Prerequisites + +Standard repo prerequisites from [build.md](build.md), plus: + +- The MCP suite needs a **running GUI ShaderLab** — it drives `tools/call`, which is + GUI-only until Step 3. It does not build or launch anything itself. +- Deploy from the per-arch layout, never from `AppX\`: + `Add-AppxPackage -Register \\ShaderLab\AppxManifest.xml` + +### Verification commands + +```pwsh +# Unit suite (no GPU dependency beyond WARP) +\\ShaderLabTests\ShaderLabTests.exe --adapter warp + +# MCP regression suite — requires ShaderLab already running +pwsh -NoProfile -File .\Tests\RunTests.ps1 + +# Headless smoke +.\Tests\RunHeadlessSmoke.ps1 -Configuration Debug -Platform x64 +``` + +### Diagnosing a crash without a debugger + +The dev box used for this work had no `cdb`/WinDbg. WER's Application-log event 1000 +gives a `Fault offset`, which is an RVA; `dbghelp.dll` plus the shipped PDB resolves it +to symbol + line offline. Confirm the event's `time stamp` matches the PE +`TimeDateStamp` first, or you resolve against the wrong layout. Use +`SymSetOptions(SYMOPT_LOAD_LINES | SYMOPT_UNDNAME)` — **not** `SYMOPT_DEFERRED_LOADS`, +which silently yields no symbols — and pass the real `SizeOfImage` to +`SymLoadModuleExW`. Note the same bug can surface as two different offsets depending on +inlining; resolve every distinct offset before assuming multiple defects. + +--- + +## Step 1 — Route hygiene *(HTTP still live)* + +`MainWindow.McpRoutes.cpp`, `Engine/Mcp/EngineMcpRoutes.cpp` + +- Delete the `GET /render/pixel/` stub. It returns "coming soon" but first touches + `D2DDeviceContext()` / `CreateBitmap()` on the caller's thread with no dispatch, and + `std::stof`s unvalidated input. +- Promote the 4 inline `tools/call` handlers to real routes: + - `graph_overview` → `IEngineCommandSink::Dispatch` (render thread). Currently reads + `m_graph` on the UI thread. + - `graph_rename_node` → mutate on the render thread, then fire an event hook that + `TryEnqueue`s the UI work. **Do not** route it straight through `Dispatch`: it calls + `RebuildLayout` / `PopulatePreviewNodeSelector` / `PopulateAddNodeFlyout`, which + touch XAML, and the render worker is MTA → `RPC_E_WRONG_THREAD`. Worse, + `winrt::hresult_error` does not derive from `std::exception`, so it escapes the + handler and surfaces as a generic 500 *after* the rename has committed. + - `list_effects` → genuinely engine-pure; move to `EngineMcpRoutes.cpp`. + - `get_display_info` → **not** engine-pure; it reads + `m_renderEngine.ActiveFormat().name` and `EngineContext` has no `RenderEngine`. + Either extend `EngineContext` (an ABI change — defer to Step 2) or leave it + app-side. Decide explicitly. +- Drop `image_stats`. It maps to `/render/image-stats`, removed by decision #63 — but + it does **not** 404. Routing is longest-**prefix**, so it falls through to the + `POST /` catch-all, re-enters the JSON-RPC dispatcher, finds no `method`, and returns + **HTTP 200** with a nested error. `isError` is `statusCode >= 400`, so the agent sees + a successful tool result containing an error. + +**Verify:** `RunTests.ps1` 33/33 over HTTP. `list_effects` should now answer from +`ShaderLabHeadless --script`, which is a new capability worth smoke-testing. + +--- + +## Step 2 — Transport-neutral types + rename *(one-way)* + +New `Engine/Mcp/McpTypes.h`; `McpHttpServer.{h,cpp}` → `McpRouter.{h,cpp}` + +- Extract `ShaderLab::Mcp::Response`, un-welding the engine ABI from the transport + header. **Add a no-reply discriminator** — over stdio there is no way to distinguish + "reply with an empty body" from "send nothing", and an empty line is not valid JSON. +- Change `Handler` to `(path, query, body)` and strip `?` for **matching only**. + `node_logs` depends on the raw query reaching its handler, and the regression is + silent: `sinceSeq` stays 0 and the route returns the entire log every poll rather + than erroring. A third caller is easy to miss — `ShaderLabHeadless/Main.cpp` passes + arbitrary user-supplied paths straight into `RouteRequest`. +- Add `McpRouter::HasRoute()`. Bump `SHADERLAB_ENGINE_ABI_VERSION`. +- Extend `EngineContext` here if Step 1 deferred `get_display_info`. + +> ~39 `AddRoute` lambdas change signature and every later step is written against the +> new one. "Revertable" applies to the **transport**, not to this step. Every +> subsequent step that adds exported engine symbols needs its own ABI bump. + +**Verify:** builds on both platforms; the ABI check passes in both hosts; +`curl 'localhost:47808/node/1/logs?since=3'` now honours `since` (a fix, not a +regression). + +--- + +## Step 3 — MCP dispatcher + tool catalog into the engine + +New `Engine/Mcp/McpJsonRpc.{h,cpp}`, `Engine/Mcp/McpToolCatalog.{h,cpp}` + +- Move the dispatcher out of `MainWindow.McpRoutes.cpp`; the GUI's `POST /` becomes a + thin delegate. This proves the relocation over the still-live HTTP transport before + any IPC exists. +- The catalog is a declarative table, but **`responseMode` is a function of the + response, not the tool**: `graph_snapshot` / `render_capture_node` repack to MCP image + content only if `inline == true` **and** status 200 **and** the body re-parses **and** + it has both `base64` and `mimeType`; otherwise control falls through to the generic + text wrapper. The table needs an argument predicate plus a fallback. Also awkward: + `node_logs` (arg `sinceSeq` → query `since`, default 0), `graph_load_json` (unwrap a + named string field into the raw body), `graph_snapshot` (args consumed twice). +- **stdio conformance — all of these are currently violated:** + - One JSON message per line, no embedded newlines. `initialize`, `tools/list` and + `resources/list` are multi-line raw string literals today. Invisible over HTTP, + fatal over stdio. + - Notifications emit **zero bytes**, and detection keys off an **absent `id`**, not a + `notifications/` prefix. + - Never `id: null` on an error path. Over one multiplexed stream it matches no + pending request and the client hangs until its own timeout. Also guard the + unchecked `GetNamedObject(L"params")`. + - `_setmode(_fileno(stdout), _O_BINARY)` — text mode translates `\n` to `\r\n`, so a + string-level "no `\r`" assertion passes while the wire bytes disagree. + - Unify the **three** divergent JSON escapers; raw control characters in HLSL error + text currently produce invalid JSON. +- Decide `protocolVersion` deliberately. The code pins `2024-11-05` but rejects + batching, which that revision requires. +- Preserve `resources/list` / `resources/read` — a second URI→path table the catalog + schema doesn't cover. Note `shaderlab://context` maps to a GUI-only route and will + 404 on a headless session. +- `tools/list_changed` is **mandatory**, not optional: the GUI registers ~16 routes the + engine does not, so `use_session` from a GUI to a headless session genuinely changes + the tool set — no version skew required. + +> Do **not** build a `HasRoute`-based catalog coverage test. Prefix semantics match +> everything against `POST /`; exact semantics fail six *working* tools that are +> sub-paths multiplexed inside prefix handlers (`/graph/save`, `/graph/node/{id}`, +> `/registry/effect/{name}`, `/node/{id}/logs`, `/analysis/{id}`, `/effect/hlsl/{id}`). +> Use a **live round-trip test** instead: drive every catalog tool against a real +> headless session and assert a correctly-shaped result. + +**Verify:** `RunTests.ps1` green over HTTP. Headless can now serve `tools/call`, so +**wire `RunTests.ps1` into CI at this step** — this is the first point at which it can +gate, because CI has no interactive desktop. + +--- + +## Step 4 — Frame codec, crypto, peer identity *(no IPC yet)* + +New `Engine/Mcp/McpFrame.{h,cpp}`, `McpCrypto.{h,cpp}`, `McpPeerIdentity.{h,cpp}` + +- Frames: 4-byte little-endian length + UTF-8 payload. The header carries + `{channelId, seq}` in the clear (the hub legitimately routes on it); the body is + sealed. Keep that split explicit in the type so it cannot drift. +- 64 MB cap. A 4K inline capture is ~33 MB of base64; 8K would be ~130 MB, so either + cap resolution server-side or fail explicitly rather than desyncing. +- Crypto: BCrypt ephemeral **P-256**. X25519 was rejected — CNG named-curve support is + unverified at the declared `10.0.17763` floor and it buys nothing against an empty + threat model. Two traps: `BCryptDeriveKey` with `BCRYPT_KDF_RAW_SECRET` returns the + secret **byte-reversed**, and CNG's `ECCPUBLICBLOB` carries a header, so don't size + buffers against the raw curve. +- Peer identity: `GetNamedPipeClientProcessId` / `ServerProcessId` → `OpenProcess` + (`PROCESS_QUERY_LIMITED_INFORMATION` suffices, no `SeDebugPrivilege` for same-account + targets) → **`OpenProcessToken`** → `GetPackageFamilyNameFromToken`. Or use + `GetPackageFamilyName(HANDLE)` and skip the token step. Unpackaged peers return + `APPMODEL_ERROR_NO_PACKAGE`; the sizing call returns `ERROR_INSUFFICIENT_BUFFER`, not + success. The PID-reuse creation-time check is a sanity check, not a guarantee — say so + in the comment. +- **Unpackaged fallback**, required for dev and CI: when *both* peers lack package + identity, fall back to "same image directory + matching build id", gated behind + `SHADERLAB_MCP_ALLOW_UNPACKAGED=1` so it can never silently engage in an installed + configuration. Mixed packaged/unpackaged is always refused. + +**Verify:** unit tests — 40 MB round-trip, truncated and oversize frames, sequence +desync, tampered ciphertext (GCM tag), full handshake, and peer identity resolved +against the current process as its own peer (which also proves the client-handle call +actually works). + +--- + +## Step 5 — Hub + shim, zero sessions + +New `ShaderLabMcpBroker/` + `.vcxproj` (add to `ShaderLab.slnx`); `Package.appxmanifest` + +- **Manifest:** add `uap3` and `desktop` namespaces; add a second + `` with `Executable="ShaderLabMcpBroker.exe"` (literal — + `$targetnametoken$` only substitutes for the primary app), + `EntryPoint="Windows.FullTrustApplication"`, `AppListEntry="none"`, and a **full** + `` — the logo attributes are mandatory even when the entry is + hidden, and omitting them fails package validation. **No `AppExecutionAlias`** (see + Step 8). +- **Packaging:** payload target mirroring `CopyEngineRuntime`, which must run + `BeforeTargets="_ComputeAppxPackagePayload"` or the package builds clean with a + missing exe. `ProjectReference` with `ReferenceOutputAssembly=false` **and + `LinkLibraryDependencies=false`** (an exe produces no import lib). Do not link + `ShaderLabEngine.lib` — it would drag D3D/MF into a process that should start in + milliseconds and never touches a GPU. +- Hub calls `FreeConsole()` first thing. All config arrives via `arguments`, never the + environment. +- **Election:** never treat `ERROR_ACCESS_DENIED` from `FILE_FLAG_FIRST_PIPE_INSTANCE` + as a bare "I lost" signal — it also means "parameters differ from the existing + instance" (exactly the stale-hub-after-update case) and "genuine DACL denial". Prove + the loss by connecting and completing `hello`; otherwise exit non-zero with a + distinct reason. +- The readiness event is manual-reset and **stays signalled after a hub dies**. Add a + wait timeout and a polling fallback, or a session will wait forever on a corpse. +- **DACL footgun:** `FILE_CREATE_PIPE_INSTANCE` shares a bit with `FILE_APPEND_DATA`, + so a DACL written with `GENERIC_WRITE` lets any same-user process create another + instance of your pipe. Use individual rights. +- Overlapped I/O on both ends; one writer per pipe; create the next pipe instance + before serving the current one. `CancelIoEx` returns `ERROR_NOT_FOUND` when the read + already completed, and the `OVERLAPPED` plus its buffer must outlive the completion + packet even after a successful cancel — freeing at cancel-return is the classic UAF. +- The **shim** owns `initialize`, `list_sessions` / `use_session`, `id` correlation and + **request timeouts**. The hub cannot time out by `reqId` — that lives inside the + sealed body — and dropping a frame would break the sequence counter. +- **stdout carries JSON-RPC frames only.** A stray `printf` corrupts every session and + is painful to diagnose. Logs go to `%LOCALAPPDATA%\ShaderLab\logs\`. +- `SHADERLAB_MCP_PIPE` alone does not isolate CI — the launch mutex and readiness event + are also global named objects and need the same treatment. + +**Verify:** new `Tests/RunBrokerSmoke.ps1` — election, framing, idle exit, stdout +hygiene, `initialize` / `tools/list` with no session attached. CI must **pre-launch the +hub** (no unpackaged activation path exists), so CI does not cover election — document +that gap rather than pretending otherwise. + +--- + +## Step 6 — Session client + headless session + +New `Engine/Mcp/McpSessionClient.{h,cpp}`; `ShaderLabHeadless/Main.cpp` + +- Written once against `McpRouter&` and used by both hosts. Handshake, reconnect with + backoff, one in-flight request per session. +- Session id must be a **persisted per-window GUID**, not an ordinal. After a hub + restart, windows reconnect in nondeterministic order and an ordinal can silently + repoint a pinned client at a different graph. A dead pinned session returns a distinct + `session_gone`; never silently re-route. +- `ShaderLabHeadless --mcp-session` wires it to the existing `HeadlessSink` and route + registry. +- Drop `node_logs` from the smoke assertions, or relocate the route — it reads a + `MainWindow` member and a headless session cannot serve it. + +**Verify:** the full `RunBrokerSmoke.ps1` in CI on WARP, end-to-end through real engine +routes. This retires election, framing, crypto and reconnect risk. It does **not** +retire GUI integration risk: `HeadlessSink::Dispatch` is a direct synchronous call with +no DispatcherQueue, no render dispatcher, no XAML, and all 8 event hooks are no-ops. + +--- + +## Step 7 — GUI session client + +`MainWindow.*`, `Controls/*` + +- **Shutdown ordering.** `~MainWindow` currently calls `m_mcpServer->Stop()` on the UI + thread, while ~14 routes block *on* the UI thread via `MainWindow::DispatchSync`. + Putting the session join in the same place reproduces a 30 s stall rather than fixing + it. Session `Stop()` must be: reject-new → `bye` → `CancelIoEx` → **join**, before + `m_renderDispatcher.Shutdown()`. +- `MainWindow::DispatchSync` **discards `TryEnqueue`'s return value**. Once the + DispatcherQueue is shutting down the event never fires and every request eats its + full 30 s timeout. +- `RenderThreadDispatcher::Shutdown()` / `ResetConsumer()` clear the queue **without + failing the pending promises**, so no timeout ladder is enforceable until they do. +- `SwitchAdapter` joins the worker and calls `ResetConsumer()`, silently dropping queued + closures — gate the session to 503 while a switch is in progress. Note a user clicking + the GPU dropdown mid-request hits this too. +- Put the timeout ladder in one header: render < `DispatchSync` < shim < client. +- Toolbar: the toggle means "expose this window to MCP"; the label shows + `MCP: no hub` / `MCP: session 1 of 2` / `MCP: off`; the export button emits the stdio + snippet. `ActivityCallback`'s `peerAddress` becomes `clientId`. + +**Verify:** manual — two ShaderLab windows, session routing via `use_session`, a GPU +switch mid-request, clean shutdown, and every tool exercised. + +--- + +## Step 8 — Shim distribution + +`MainWindow` startup, `scripts/Install.ps1`, `.mcp.json` + +- ShaderLab copies `ShaderLabMcpShim.exe` to `%LOCALAPPDATA%\ShaderLab\bin\` on launch, + using **rename-then-write**: overwrite-in-place is blocked while an old shim is + running, but renaming the old file and writing a new one at the original path + succeeds and leaves the running process untouched. +- The client config points at that stable path. Being unpackaged, the shim is immune to + update and uninstall, and it can still activate the packaged hub AUMID (a + non-packaged caller activating a packaged app is proven to work). +- `Install.ps1` prints the ready-to-paste client snippet. `.mcp.json` becomes a stdio + config pointing at the build-tree shim for contributors. + +**Upgrade behaviour to document.** A newer MSIX does *not* update the MCP the client is +talking to, and that is deliberate: + +| Component | Auto-updated? | When it actually changes | +|---|---|---| +| Hub + sessions (packaged) | Yes, immediately | Next activation / app launch | +| Shim **binary on disk** | No — it's a copy | When ShaderLab next re-copies it | +| Shim **process** the client is running | No — immune by design | Only when the MCP client restarts | + +Sequence for an in-place upgrade with a client connected: the install kills every +packaged process (sessions and hub) but not the shim, so the client's stdio server +stays alive instead of hitting EOF; `list_sessions` returns empty and `tools/call` +returns a clean "no session attached"; the shim re-activates the AUMID and gets the new +hub; the next ShaderLab launch registers a session and refreshes the on-disk shim. A +routine release therefore keeps a running client working. Only a deliberate protocol +break severs it, and then the old shim reports "no hub — restart your MCP client". + +**Verify:** install version *n*, connect a client, install *n+1* in place, confirm the +client survives and recovers once ShaderLab relaunches. + +--- + +## Step 9 — Delete HTTP *(point of no return)* + +- Remove `Start` / `Stop` / `Port` / `ListenerThread` / `HandleConnection`, the sockets, + `WSAStartup` / `WSACleanup`, `ws2_32.lib` and the CORS handling. Keep `AddRoute`, + `RouteRequest`, `Response` and `ActivityCallback`. +- Delete `POST /` and `GET /` (~480 lines). Note that removing the catch-all is a + **behaviour change for every unmatched path**, not just cleanup. +- Port `RunTests.ps1` to stdio in the same commit — it POSTs to `/mcp`, which resolves + only via that catch-all. +- Bump the ABI. Rewrite [mcp-server.md](../hosts/mcp-server.md) (it claims 27 tools and + port 47808; there are 40). Add a decision-log entry; update `README.md`, + `docs/architecture/{overview,engine-host-split,threading-model}.md`, + `.github/copilot-instructions.md` and `.context/resume.md`; mark decisions #31 and #58 + superseded. + +**Verify:** full CI plus `RunBrokerSmoke.ps1` and `RunHeadlessSmoke.ps1`. Grepping for +`47808` and `WSA` should return nothing outside `CHANGELOG.md` and the decision log. + +--- + +## Open risks + +1. **ARM64 is never exercised at runtime.** `ci.yml` pins `platform: [x64]` and + `release.yml` runs no tests, yet both spikes were measured only on ARM64. The + coverage is exactly inverted from what you'd want. +2. **CI can only test the unpackaged identity path**, so `IApplicationActivationManager` + — the mechanism that actually ships — stays untested in automation. +3. **The unpackaged fallback is the weak point of binary pairing** — the one path where + "same build" is asserted rather than proven by the OS. Assert in the smoke test that + an installed configuration refuses to use it. + +--- + +Back to [docs/](../README.md) • [Repo root](../../README.md) diff --git a/docs/development/project-structure.md b/docs/development/project-structure.md index cc2827c..cb316db 100644 --- a/docs/development/project-structure.md +++ b/docs/development/project-structure.md @@ -15,7 +15,7 @@ ShaderLab/ ├── Version.h # App version + graph format version ├── README.md # This file ├── CHANGELOG.md # Version history -├── Bootstrap.ps1 # One-command fresh-clone setup (cert + ExprTk + restore) +├── .gitmodules # third_party submodule pins (exprtk, miniz) │ ├── pch.h / pch.cpp # App PCH (WinRT, WinUI, D2D, D3D, STL) ├── pch_engine.h / pch_engine.cpp # Engine/Test/Headless PCH (WinRT base, D2D, D3D, MF, STL) @@ -101,10 +101,11 @@ ShaderLab/ ├── Shaders/ # HLSL source files (user shaders) ├── Assets/ # App icons, splash screen ├── third_party/ -│ └── exprtk/ # exprtk.hpp (downloaded by EnsureExprTk.ps1, gitignored) +│ ├── exprtk/ # submodule: ArashPartow/exprtk (MIT) — exprtk.hpp +│ ├── miniz/ # submodule: richgel999/miniz @ 3.1.2 (MIT) +│ └── miniz_export.h # in-tree shim: empty MINIZ_EXPORT (upstream CMake-generates it) ├── scripts/ │ ├── EnsureDevCert.ps1 # Generates + installs CN=ShaderLab dev cert for F5 -│ ├── EnsureExprTk.ps1 # Downloads exprtk.hpp on first build │ └── Install.ps1 # Per-arch unsigned-MSIX installer for end users ├── .github/ │ ├── workflows/ diff --git a/docs/history/decision-log.md b/docs/history/decision-log.md index 8a6e1e2..c142b75 100644 --- a/docs/history/decision-log.md +++ b/docs/history/decision-log.md @@ -73,4 +73,12 @@ --- +| 69 | Native dependencies become git submodules; `Bootstrap.ps1` + the `Ensure*` download scripts retired | `exprtk` and `miniz` were acquired by imperative PowerShell that ran as an MSBuild pre-build step and downloaded from the network on first build (`scripts/EnsureExprTk.ps1` pulled `exprtk.hpp` from `raw.githubusercontent.com/master` — an **unpinned floating reference**; `scripts/EnsureMiniz.ps1` pulled the miniz 3.0.2 release zip). `third_party/` was gitignored wholesale, so the actual dependency versions were invisible to `git` and unreproducible across machines and time. Both are now submodules pinned to explicit commits: `third_party/exprtk` at `1e4a80b`, `third_party/miniz` at tag `3.1.2` (a deliberate bump from the 3.0.2 the script fetched, since `.effectgraph` archives can come from untrusted sources). **miniz wrinkle**: the git tree is *not* what the release zip contains. Upstream ships split sources (`miniz.c` / `miniz_tdef.c` / `miniz_tinfl.c` / `miniz_zip.c`) and `miniz.h` unconditionally `#include`s `miniz_export.h`, which CMake's `generate_export_header()` produces and which is absent from the repo; miniz's own `amalgamate.sh` substitutes an empty `#define MINIZ_EXPORT` when generating the single-file release pair. Rather than drag a CMake toolchain into an MSBuild-only repo, we vendor the same substitution as a 3-line `third_party/miniz_export.h` and put `third_party\` on the include path. Only three of the four sources are compiled — `miniz_zip.c` is omitted because `EffectGraphFile.cpp` writes the ZIP container itself and uses only `tdefl_compress_mem_to_heap` / `tinfl_decompress_mem_to_heap` / `mz_free`. **Trade-off accepted**: the old scripts self-healed a fresh clone (build → auto-download), whereas a clone missing `--recurse-submodules` now *fails*. Mitigated by a `VerifySubmodules` MSBuild target that errors with the exact `git submodule update --init --recursive` command instead of a wall of missing-header diagnostics. `Bootstrap.ps1`'s three jobs are now covered elsewhere: the dev cert by the existing `EnsureDevSigningCertificate` target in `ShaderLab.vcxproj`, ExprTk by the submodule, NuGet restore by VS/CI — so it was deleted along with both `Ensure*` download scripts. CI's `bootstrap-smoke` job (decision #56) becomes `clean-clone-smoke`, which still guards the onboarding cliff by running the documented submodule-init command explicitly rather than using `actions/checkout`'s `submodules:` input. | Day 14 | + +--- + +| 70 | UI-thread graph reads move to `GraphUiSnapshot`; two shipped access violations were one race | Two intermittent crashes — one reproducing under MCP-driven graph churn (3 of 4 full `Tests/RunTests.ps1` runs died), one when dragging the window between monitors (4 of 7) — turned out to be **the same bug**. Resolving both WER fault offsets against the shipped PDB with DbgHelp (no debugger is installed on the dev box; see the recipe in the crash-triage notes) gave `0x9A74` → `NodeGraphController::RenderNodes` at `NodeGraphController.cpp:1448` and `0x11D2C` → `std::_Tree::_Find` in `xtree` — the *same* `node->properties.find(L"Value")`, once inlined and once not. **Root cause**: `NodeGraphController` held a pointer to the live `EffectGraph` and dereferenced `EffectNode`s on the **UI thread** during canvas paint and hit-testing, while the render worker mutated that graph continuously — `node.properties[...] =` inserts every tick for clock nodes (`MainWindow.RenderTick.cpp:230,256`) plus every MCP closure. A paint landing mid-mutation walks a `std::map` being rebalanced, or a node `graph_clear` just destroyed. No synchronization existed between the two threads. **Fix**: the controller now reads the per-frame `GraphUiSnapshot` that the render worker already published (`BuildGraphUiSnapshot` → `m_uiGraphSnapshot`, decision #68) — a mechanism that existed, was documented at `MainWindow::CurrentGraphSnapshot`, and **had zero callers**. `Render`/`RenderEdges`/`RenderNodes` take one snapshot per paint (so edges and nodes are mutually consistent and pointers stay valid); `HitTestEdge` and `SelectAll` take one per call. `UpdateSliderDrag` was worse than a read — it wrote `node->clockTime` / `properties` / `dirty` straight from the pointer handler — and now routes through `RenderThreadDispatcher::DispatchSync` like `UpdateDragNodes` already did. **Two locks, not one — learned the hard way.** The first attempt used a single `MainWindow::m_graphMutex` (`std::shared_mutex`, worker exclusive / UI paint shared) and did fix both crashes. But that one lock was silently covering *two* distinct races: the live-graph derefs **and** `m_visuals`, which `RebuildLayout` clears and refills from the render thread (via the `OnNodeChanged` hook) while the UI paint iterates it. Migrating the paint to snapshots and then dropping the paint's lock re-exposed the second race — 4 of 5 display-change runs crashed, now at `RenderNodes+0x6C`, the loop header rather than a `properties.find`. So `m_visuals` gets its own `NodeGraphController::m_visualsMutex`, deliberately fine-grained: reusing `m_graphMutex` would couple the canvas to the worker's whole tick, measured at ~0.6 ms idle but **~50 ms on a heavy graph** (4K source + 2K compute coverage), trading a crash for a visible stall. `m_graphMutex` stays as a backstop around the worker's drain/tick. **Lock-order rule** (documented at both declarations): the render thread takes `m_graphMutex` → `m_visualsMutex`, so UI code must never hold `m_visualsMutex` across a `DispatchSync`; `AddNode`/`DeleteSelected`/`UpdateDragNodes` dispatch the graph write first, then lock to update visuals. Two latent bugs fell out of applying that rule: `EndConnection` held `std::wstring` **references into `m_visuals` across a `DispatchSync`** (dangling if the worker rebuilt layout mid-wait) and `UpdateSliderDrag` read `m_visuals` from inside its dispatched closure, i.e. on the render thread; both now copy by value first. **Result**: 5/5 clean MCP suite runs (was 3/4 crashing), 7/7 on the display-change repro (was 4/7 crashing), 183 unit tests green. The three-path rule (UI reads → snapshot, writes → dispatcher, layout → live graph on the render thread only) is now written into `.github/copilot-instructions.md` and the `NodeGraphController.h` header, because the failure mode is an AV inside `std::map` rather than a compile error. | Day 14 | + +--- + Back to [docs/](../README.md) • [Repo root](../../README.md) \ No newline at end of file diff --git a/pch.h b/pch.h index c2dde56..5d6f926 100644 --- a/pch.h +++ b/pch.h @@ -85,6 +85,7 @@ #include #include #include +#include #include #include #include diff --git a/scripts/EnsureExprTk.ps1 b/scripts/EnsureExprTk.ps1 deleted file mode 100644 index 032e002..0000000 --- a/scripts/EnsureExprTk.ps1 +++ /dev/null @@ -1,22 +0,0 @@ -# Downloads exprtk.hpp (single-header math expression library by Arash -# Partow) into third_party\exprtk\ on first build. The header is licensed -# under the MIT license. Skipped if the file is already present. -[CmdletBinding()] -param( - [Parameter(Mandatory = $true)] [string] $TargetDir -) - -$ErrorActionPreference = 'Stop' - -$target = Join-Path $TargetDir 'exprtk.hpp' -if (Test-Path -LiteralPath $target) { - exit 0 -} - -New-Item -ItemType Directory -Force -Path $TargetDir | Out-Null - -$url = 'https://raw.githubusercontent.com/ArashPartow/exprtk/master/exprtk.hpp' -Write-Host "Downloading exprtk.hpp from $url ..." -[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12 -Invoke-WebRequest -Uri $url -OutFile $target -UseBasicParsing -Write-Host "Saved $target" diff --git a/scripts/EnsureMiniz.ps1 b/scripts/EnsureMiniz.ps1 deleted file mode 100644 index 644e536..0000000 --- a/scripts/EnsureMiniz.ps1 +++ /dev/null @@ -1,47 +0,0 @@ -# Downloads the miniz amalgamation (single-file miniz.c + miniz.h by Rich -# Geldreich) into third_party\miniz\ on first build. miniz is licensed -# under the MIT license. Skipped if the files are already present. -[CmdletBinding()] -param( - [Parameter(Mandatory = $true)] [string] $TargetDir -) - -$ErrorActionPreference = 'Stop' - -$cTarget = Join-Path $TargetDir 'miniz.c' -$hTarget = Join-Path $TargetDir 'miniz.h' -if ((Test-Path -LiteralPath $cTarget) -and (Test-Path -LiteralPath $hTarget)) { - exit 0 -} - -New-Item -ItemType Directory -Force -Path $TargetDir | Out-Null - -# Pinned release: 3.0.2 ships the amalgamated single-file build -# (miniz.c + miniz.h) in the release zip's root directory. -$version = '3.0.2' -$url = "https://github.com/richgel999/miniz/releases/download/$version/miniz-$version.zip" -$tempZip = Join-Path ([System.IO.Path]::GetTempPath()) "miniz-$version.zip" -$tempDir = Join-Path ([System.IO.Path]::GetTempPath()) "miniz-$version-extract" - -Write-Host "Downloading miniz $version from $url ..." -[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12 -Invoke-WebRequest -Uri $url -OutFile $tempZip -UseBasicParsing - -if (Test-Path -LiteralPath $tempDir) { Remove-Item -Recurse -Force -LiteralPath $tempDir } -Expand-Archive -LiteralPath $tempZip -DestinationPath $tempDir -Force - -# The release zip layout puts miniz.c / miniz.h either at the root or -# under a versioned folder. Find them recursively to be tolerant. -$cSrc = Get-ChildItem -Path $tempDir -Recurse -Filter 'miniz.c' | Select-Object -First 1 -$hSrc = Get-ChildItem -Path $tempDir -Recurse -Filter 'miniz.h' | Select-Object -First 1 -if (-not $cSrc -or -not $hSrc) { - throw "miniz.c / miniz.h not found inside $tempZip" -} - -Copy-Item -LiteralPath $cSrc.FullName -Destination $cTarget -Force -Copy-Item -LiteralPath $hSrc.FullName -Destination $hTarget -Force - -Remove-Item -Recurse -Force -LiteralPath $tempDir -Remove-Item -Force -LiteralPath $tempZip - -Write-Host "Saved $cTarget and $hTarget" diff --git a/third_party/exprtk b/third_party/exprtk new file mode 160000 index 0000000..1e4a80b --- /dev/null +++ b/third_party/exprtk @@ -0,0 +1 @@ +Subproject commit 1e4a80b5ec9b4832ed59c6faa65f625a01b18ef0 diff --git a/third_party/miniz b/third_party/miniz new file mode 160000 index 0000000..77d0dce --- /dev/null +++ b/third_party/miniz @@ -0,0 +1 @@ +Subproject commit 77d0dce8627735138c51770d1799a1ef48f2117d diff --git a/third_party/miniz_export.h b/third_party/miniz_export.h new file mode 100644 index 0000000..fe04dd4 --- /dev/null +++ b/third_party/miniz_export.h @@ -0,0 +1,11 @@ +#pragma once + +// miniz's CMake build generates this header via generate_export_header() to +// decorate the public API for shared-library builds. We link miniz statically +// into ShaderLabEngine.dll -- its symbols are internal and never re-exported -- +// so an empty macro is the correct definition, and it lets us consume the miniz +// submodule without dragging a CMake toolchain into an MSBuild-only repo. +// +// This mirrors what miniz's own amalgamation step does (CMakeLists.txt injects +// the same empty #define into the generated single-file miniz.h). +#define MINIZ_EXPORT From 9f0dfd85781d7d58de29431f28f08e35e0be6755 Mon Sep 17 00:00:00 2001 From: David Spruill Date: Wed, 12 Aug 2026 01:15:00 -0400 Subject: [PATCH 2/6] MCP over stdio now fully works --- .context/resume.md | 302 +++----- .github/copilot-instructions.md | 12 +- .github/workflows/ci.yml | 45 ++ .mcp.json | 8 +- CHANGELOG.md | 57 ++ Engine/Mcp/EngineMcpRoutes.cpp | 410 +++++++---- Engine/Mcp/EngineMcpRoutes.h | 35 +- Engine/Mcp/McpHttpServer.cpp | 414 ----------- Engine/Mcp/McpHttpServer.h | 82 --- Engine/Mcp/McpRouter.cpp | 107 +++ Engine/Mcp/McpRouter.h | 79 +++ EngineExport.h | 11 +- MainWindow.McpRoutes.cpp | 908 ++++++++---------------- MainWindow.RenderTick.cpp | 545 +------------- MainWindow.xaml.cpp | 181 +++-- MainWindow.xaml.h | 79 ++- Package.appxmanifest | 27 +- README.md | 6 +- Rendering/RenderThreadDispatcher.h | 79 ++- ShaderLab.slnx | 1 + ShaderLab.vcxproj | 23 + ShaderLabEngine.vcxproj | 21 +- ShaderLabHeadless/Main.cpp | 78 +- Tests/RunTests.ps1 | 311 ++++++-- Tests/TestRunner.cpp | 507 +++++++++++++ docs/architecture/engine-host-split.md | 8 +- docs/architecture/overview.md | 2 +- docs/architecture/threading-model.md | 27 +- docs/development/build.md | 2 + docs/development/mcp-stdio-migration.md | 649 ++++++++++++++++- docs/development/project-structure.md | 53 +- docs/history/decision-log.md | 21 +- docs/hosts/headless.md | 4 +- docs/hosts/mcp-server.md | 110 ++- scripts/Install.ps1 | 21 + 35 files changed, 2929 insertions(+), 2296 deletions(-) delete mode 100644 Engine/Mcp/McpHttpServer.cpp delete mode 100644 Engine/Mcp/McpHttpServer.h create mode 100644 Engine/Mcp/McpRouter.cpp create mode 100644 Engine/Mcp/McpRouter.h diff --git a/.context/resume.md b/.context/resume.md index a3d5c77..5955bf3 100644 --- a/.context/resume.md +++ b/.context/resume.md @@ -4,14 +4,13 @@ **ShaderLab** is a WinUI 3 desktop application (C++/WinRT) for developing, testing, and debugging Direct2D shader effects with full HDR and wide color gamut (WCG) support, with a particular focus on tone-mapping and color-correction R&D. -- **Location**: `C:\Users\david\source\repos\ShaderLab\ShaderLab.slnx` -- **Version**: **1.5.0** released; Phase 8 GPU-binding work-in-progress on top (4 commits stacked locally, none pushed). +- **Location**: `C:\Users\david\source\ShaderLab\ShaderLab.slnx` +- **Version**: **1.7.3** released. Current branch `user/daspr/mcp_migration_httptostdio`: the **MCP HTTP → stdio + broker migration is COMPLETE** (all 9 steps). The embedded HTTP listener is deleted; the broker (shim → hub → session over named pipes, bodies sealed) is the only MCP transport. Engine ABI **3**. Only the **manual verification sweep** at the end of [docs/development/mcp-stdio-migration.md](../docs/development/mcp-stdio-migration.md) remains before full sign-off (WinUI window lifecycle, packaged install/activation, a real MCP client, in-place upgrade). - **Graph format version**: **2** (unchanged). -- **Engine ABI version**: **1** (`SHADERLAB_ENGINE_ABI_VERSION` in `EngineExport.h`). -- **Language**: C++/WinRT — direct COM access to `ID2D1EffectImpl`, `ID2D1DrawTransform`, `ID2D1ComputeTransform`. -- **Branch / repo state**: `main`, tagged `v1.5.0`. Working tree clean; the 4 stacked commits are Phase 8 prep + cleanup, not yet pushed. +- **Engine ABI version**: **3** (`SHADERLAB_ENGINE_ABI_VERSION` in `EngineExport.h`; Step 2 re-typed `McpRouter`/`Mcp::Response`, Step 9 deleted the HTTP transport). +- **Language**: C++/WinRT — direct COM access to `ID2D1EffectImpl`, `ID2D1DrawTransform`, `ID2D1ComputeTransform`. No C#. -> Authoritative sources of truth: [`docs/`](../docs/README.md) (architecture tree + per-file references) and especially [`docs/history/decision-log.md`](../docs/history/decision-log.md) (**63 entries**), `CHANGELOG.md` (per-version diffs, including `[Unreleased]` for the post-1.5 work), `Version.h` (numeric version), `.github/copilot-instructions.md` (AI agent rules). This file is a fast-orientation summary; it can drift — re-check the docs tree before relying on details. +> Authoritative sources of truth: [`docs/`](../docs/README.md) (architecture tree + per-file references) and especially [`docs/history/decision-log.md`](../docs/history/decision-log.md) (**70 entries**; #64–67 were never written — that stretch is covered by `CHANGELOG.md` §1.6.0), `CHANGELOG.md` (per-version diffs), `Version.h` (numeric version), `.github/copilot-instructions.md` (AI agent rules, including the graph-access threading rule). This file is a fast-orientation summary; it can drift — re-check the docs tree before relying on details. --- @@ -19,24 +18,40 @@ | Project | Output | Purpose | |--------|--------|---------| -| `ShaderLabEngine.vcxproj` | `ShaderLabEngine.dll` | Pure-native engine: graph model, evaluator, ICC reader, video, ExprTk math, D3D11 compute runner, `IEngineComputeOutput` COM interface, MCP HTTP server + 20 engine-pure routes. Exported via `SHADERLAB_API`. | -| `ShaderLab.vcxproj` | `ShaderLab.exe` (MSIX) | WinUI 3 packaged app. `RenderEngine`, all XAML, controllers, `MainWindow.McpRoutes.cpp` (16 UI-coupled routes + JSON-RPC dispatcher + `GuiEngineCommandSink`). Depends on the engine DLL. | -| `ShaderLabTests.vcxproj` | `ShaderLabTests.exe` | Standalone console test runner (`Tests/TestRunner.cpp` + `Tests/Math/*`). 119 tests including HLSL math bench. CI uses `--adapter warp`. | -| `ShaderLabHeadless.vcxproj` | `ShaderLabHeadless.exe` | Console host: PNG render / FP32 pixel readback (`--pixels`) / JSON batch script mode (`--script`). No WinUI dependency. | +| `ShaderLabEngine.vcxproj` | `ShaderLabEngine.dll` | Host-agnostic engine: graph model + `GraphUiSnapshot`, evaluator, `RenderThreadDispatcher`, ICC reader, video + live-capture sources, ExprTk math, D3D11 compute runner + `CustomComputeBridgeEffect` + `BytecodeCache`, `IEngineComputeOutput` COM interface, MCP router (`McpRouter`, HTTP listener until migration Step 9) + JSON-RPC dispatcher + 39-tool catalog + **25 engine-pure routes**. Exported via `SHADERLAB_API`. | +| `ShaderLab.vcxproj` | `ShaderLab.exe` (MSIX) | WinUI 3 packaged app. `RenderEngine` (app-only), all XAML, controllers, the render worker thread, `MainWindow.McpRoutes.cpp` (**18 app-side routes** + JSON-RPC dispatcher + `GuiEngineCommandSink`). Depends on the engine DLL. | +| `ShaderLabTests.vcxproj` | `ShaderLabTests.exe` | Standalone console test runner — **244 tests** (graph/evaluator/bindings, BytecodeCache, GraphUiSnapshot, RenderThreadDispatcher, McpRouter + JSON-RPC dispatcher contracts, MCP frame codec + crypto + peer identity, GPU-binding + skip-readback matrices, 51-test HLSL math bench). CI uses `--adapter warp`. | +| `ShaderLabHeadless.vcxproj` | `ShaderLabHeadless.exe` | Console host, no WinUI: PNG render, FP32 pixel readback (`--pixels`), JSON batch script mode (`--script`), **MCP session mode (`--mcp-session`, registers with the broker hub)**, bytecode-cache reap/clear ops, `--enable/--disable-gpu-bindings`. | +| `ShaderLabMcpBroker.vcxproj` | `ShaderLabMcpBroker.exe` | MCP broker. `--hub`: singleton blind relay — first-instance election, per-peer pairing, session registry + channel relay (routes on channelId only; bodies sealed end-to-end). `--stdio`: the MCP client's front-end — owns initialize + list_sessions/use_session, pins a session, runs the initiator handshake, seals/forwards requests, splices tools/list. Does NOT link the engine — compiles the `McpFrame`/`McpCrypto`/`McpPeerIdentity`/`McpChannel` TUs directly. Packaged as the manifest's second ``. | -This split (decision #41 + #58) keeps WinUI out of the test path, lets engine logic be exercised in isolation, and gives MCP agents a fully-functional logged-out host for parameter sweeps. +This split (decisions #41 + #58) keeps WinUI out of the test path, lets engine logic be exercised in isolation, and gives MCP agents a fully-functional logged-out host for parameter sweeps. --- -## Complete Feature Set (v1.5.0 + Phase 8 in-progress) +## Threading Model (v1.7.0, decisions #68 + #70) + +All D3D11/D2D graph work runs on a dedicated **render worker `std::jthread`**; the UI thread only blits a double-buffered offscreen into the `SwapChainPanel` swap chain and `Present1`s (presenting from the worker is impossible — XAML composition is STA-bound). The worker per tick: drain `RenderThreadDispatcher` closures → working-space sync → live-capture/clock/video tick → dirty-propagation BFS → `RenderFrameToOffscreen` → publish index + `GraphUiSnapshot`. A version-gated blit keeps the UI thread from vsync-blocking when the worker publishes slower than the UI ticks. + +**Graph access rule** (the full text lives in `Controls/NodeGraphController.h` and `.github/copilot-instructions.md`; getting it wrong is an access violation inside `std::map`, not a compile error): + +1. **UI-thread reads → the per-frame `GraphUiSnapshot`**, never live `m_graph`. +2. **Writes (any thread) → `RenderThreadDispatcher::DispatchSync`.** +3. **Layout computation → live graph, render thread only.** + +Two locks with a strict order (`m_graphMutex` → `m_visualsMutex`); never hold `m_visualsMutex` across a `DispatchSync`. See [docs/architecture/threading-model.md](../docs/architecture/threading-model.md) for the diagrams and the resource-ownership table. + +--- + +## Complete Feature Set (v1.7.3) ### Core - Node-based DAG graph editor for D2D effect composition. - 40+ wrapped built-in D2D effects (`Effects/EffectRegistry.cpp`) across 9 categories. -- 35 ShaderLab built-in effects (`Effects/ShaderLabEffects.cpp` + `Effects/ColorMath.cpp`). +- ShaderLab built-in effect library with embedded HLSL (`Effects/ShaderLabEffects.cpp` + `Effects/ColorMath.cpp`) — current catalog table in [docs/effects/builtin-catalog.md](../docs/effects/builtin-catalog.md). - Custom pixel shader effects (`ID2D1DrawTransform`). - Custom D2D compute shader effects (`ID2D1ComputeTransform`, per-tile dispatch). -- Custom **D3D11 compute shader effects** (`D3D11ComputeRunner`) — bypass D2D tiling for full-image reductions with atomics and groupshared memory. The runner now also implements `IEngineComputeOutput` (Phase 8 GPU-binding interface). +- Custom **D3D11 compute shader effects** — routed through `CustomComputeBridgeEffect` (D2D wrapper) + `D3D11ComputeRunner`, which implements `IEngineComputeOutput` so downstream compute consumers can bind analysis SRVs directly (Phase 8, shipped in 1.6.0, feature flag default ON). +- **`BytecodeCache`**: compile-once bytecode store with eager GPU-binding-variant precompile and disk persistence under `%LOCALAPPDATA%\ShaderLab\bytecode\`; reaper wired to the status-bar broom button and headless CLI flags. - Live HLSL hot-reload with `D3DCompile` + `D3DReflect` auto-property discovery. - Effect Designer modal window for authoring custom pixel / D2D-compute / D3D11-compute effects with full parameter definition. - Graph JSON serialization with versioning (format version 2) — saved as `.effectgraph` zip files (DEFLATE via miniz) with optional **embedded media**. @@ -46,76 +61,60 @@ This split (decision #41 + #58) keeps WinUI out of the test path, lets engine lo Grouped by `category` + optional `subcategory` (Add Node flyout sub-grouping): - **Analysis → Highlights**: Luminance Heatmap, Nit Map, Gamut Highlight, Luminance Highlight. -- **Analysis → Scopes**: CIE Histogram (CS), CIE Chromaticity Plot, Vectorscope, Waveform Monitor. -- **Analysis → Comparison**: Delta E Comparator (CIEDE2000), Split Comparison. -- **Analysis → Gamut Mapping**: Gamut Map (Clip / Nearest / Compress / Fit), ICtCp Gamut Map, Gamut Coverage. -- **Analysis → Tone Mapping (ICtCp suite)**: ICtCp Round-Trip Validator, ICtCp Tone Map (HDR → SDR), ICtCp Inverse Tone Map (SDR → HDR), ICtCp Saturation, ICtCp Highlight Desaturation. Bind their numeric peak/SDR-white parameters to the `Working Space` node's analysis outputs to track Display Settings or simulated profiles automatically. -- **Analysis → Statistics** (D3D11 compute, data-only): Channel Statistics, Luminance Statistics, Chromaticity Statistics. The legacy `StatisticsEffect` D2D wrapper class was retired (decision #62) along with its dedicated `/render/image-stats` MCP route + `Rendering::GpuReduction` (decision #63) — agents now use the standard `/graph/add-node` + `/analysis/` workflow against these effects. +- **Analysis → Scopes**: CIE Histogram (D3D11 compute), CIE Chromaticity Plot. (Vectorscope and Waveform Monitor were **removed in 1.6.0** — no clear use case after the compute-scatter migration.) +- **Analysis → Comparison**: Delta E Comparator (CIEDE2000, `OutputMode` Heatmap / Grayscale dE), Split Comparison. +- **Analysis → Gamut Mapping**: Gamut Map (Clip / Nearest / Compress / Fit), ICtCp Gamut Map, Gamut Coverage (single-group D3D11 compute scatter since 1.6.0). +- **Analysis → Tone Mapping (ICtCp suite)**: ICtCp Round-Trip Validator, ICtCp Tone Map (HDR → SDR; D3D11 compute since 1.6.0, `SourcePeakNits`/`TargetPeakNits` gpuBindable), ICtCp Inverse Tone Map (SDR → HDR), ICtCp Saturation, ICtCp Highlight Desaturation. Bind their numeric peak/SDR-white parameters to the `Working Space` node's analysis outputs to track Display Settings or simulated profiles automatically. +- **Analysis → Statistics** (D3D11 compute, data-only): Channel Statistics, Luminance Statistics, Chromaticity Statistics. Stats are not architecturally special (decision #63): agents use `/graph/add-node` + `/analysis/`. - **Source / Generators**: Gamut Source, ICtCp Boundary, Color Checker, Zone Plate, Gradient Generator, HDR Test Pattern. -- **Live capture sources**: DXGI Desktop Duplication (per-output enumerated), Windows Graphics Capture (WinUI picker). Per-frame ticking via `SourceNodeFactory::TickAndUploadLiveCaptures` from `OnRenderTick`. +- **Live capture sources**: DXGI Desktop Duplication (per-output enumerated), Windows Graphics Capture (WinUI picker). Per-frame ticking via `SourceNodeFactory::TickAndUploadLiveCaptures` on the render worker. - **Data / Parameter nodes** (no shader, evaluator-handled): Float, Integer, Toggle, Gamut, Clock, Numeric Expression (ExprTk, A..Z inputs), Random (deterministic seed → [0,1) hash), **Working Space** (mirrors active display profile into 14 typed analysis fields). Every effect carries a stable `effectId` + numeric `effectVersion`; saved graphs detect upgrades and offer per-node / batch upgrade in the Properties panel. ### Property System - `PropertyValue` variant: `float`, `int32`, `uint32`, `bool`, `wstring`, `float2`, `float3`, `float4`, `D2D1_MATRIX_5X4_F`, `vector`. -- Per-component property bindings (Grasshopper-style data flow), with array (whole-vector) bindings for LUT-shaped fields. +- Per-component property bindings (Grasshopper-style data flow), with array (whole-vector) bindings for LUT-shaped fields. `gpuBindable` parameters + `gpuPublish` analysis fields route upstream compute SRVs directly to D3D11 compute consumers (CPU readback skipped when no CPU consumer needs the value). - Enum labels for named dropdown parameters; `bool` rendered as `ToggleSwitch`. -- No `_hidden` suffix convention (removed in Phase-0 cleanup, v1.4.x). Earlier saved graphs may carry stale `WsRedX_hidden` / `MonMaxNits_hidden` / `SdrWhiteNits_hidden` keys; those load into memory but are inert (no shader cbuffer references them, no UI surfaces them). Cross-version graph compatibility is not currently promised. Sink-only properties (e.g., the Working Space node's `ActiveColorMode`, `SdrWhiteNits`, primaries) live in `ShaderLabEffectDescriptor::hiddenDefaults` without the `_hidden` suffix and are kept off the UI by the customEffect declared-parameter filter. -- `visibleWhen` conditional visibility on parameters (`"Mode == 1"`, `"Strength > 0"`, etc.). +- `visibleWhen` conditional visibility on parameters (`"Mode == 1"`, `"Strength > 0"`, etc.) — including conditionally-visible **input pins**, extended on the canvas after MCP or Properties-panel changes (1.7.3 fix). - Visual data pins (orange diamonds) on the node graph for binding connections. ### Rendering - **Always scRGB FP16 pipeline** (`DXGI_FORMAT_R16G16B16A16_FLOAT`, `DXGI_COLOR_SPACE_RGB_FULL_G10_NONE_P709`). DWM/ACM handles final display conversion. -- **Refresh-rate-driven render loop** (60–240 Hz) — interval re-derived from `EnumDisplaySettings(dmDisplayFrequency)` on every display change. -- Dirty-gated render loop with **dirty propagation pre-pass** (any dirty node marks its direct downstream consumers dirty before evaluation; runs again after `TickAndUploadVideos` so video updates flow through analysis-only compute nodes too). -- No built-in tone-mapping pass in the render path — users build tone mappers as graph effects (the ICtCp suite is the preferred path). Decision-log entry #54 retired the legacy `Rendering/ToneMapper` class in the Phase-1 cleanup. -- Display profile mocking (presets + ICC file loading via `mscms.dll`). -- Monitor gamut detection from `DXGI_OUTPUT_DESC1` primaries. -- **OS-reported SDR white level** queried via `DisplayConfigGetDeviceInfo(DISPLAYCONFIG_DEVICE_INFO_GET_SDR_WHITE_LEVEL)`, tracks the *Settings → Display → HDR → "SDR content brightness"* slider; exposed to graphs as `working_space.SdrWhiteNits`. Effects pull the value via property bindings — no per-frame host injection. -- GPU info display (hardware adapter name or "Software (WARP)"). -- **DXVA2 / Media Foundation video sources** with `ID3D10Multithread::SetMultithreadProtected(TRUE)` so background-thread Lock2D from the decoder doesn't crash D3D11. -- `OutputWindow` system: each `Output` node gets its own OS window with independent SwapChainPanel, pan/zoom, save-to-file. Bidirectional sync (close window ↔ delete node). -- D2D-rendered node graph canvas with pan/zoom, bezier edges (Alt+click delete via bezier hit-test), color-coded nodes, dot grid, dark theme. - -### MCP Server (Phase 7 architecture, post-1.5.0) -JSON-RPC 2.0 over Streamable HTTP (`POST /`). Implements `Content-Length` **and** `Transfer-Encoding: chunked`. Has `GET /` health-check + correct `202 Accepted` for `notifications/*`. Toolbar shows an amber activity dot when the server has handled a request in the last few seconds. - -- **GUI host**: port **47808** (auto-increments). -- **Headless host (`ShaderLabHeadless --script`)**: port **47809** (different default to avoid shared-machine conflicts). - -The server itself + 20 engine-pure routes live in `Engine/Mcp/{McpHttpServer,EngineMcpRoutes}.{h,cpp}`; 16 UI-coupled / host-specific routes stay in `MainWindow.McpRoutes.cpp`. Both hosts register the same engine-side route set through the same `IEngineCommandSink` interface — routes call `sink.Dispatch(closure)` where the closure receives a fresh `EngineContext`. After successful mutation, closures fire one of 8 event hooks (`OnNodeAdded`, `OnNodeRemoved`, `OnNodeChanged`, `OnGraphCleared`, `OnGraphLoaded`, `OnGraphStructureChanged`, `OnCustomEffectRecompiled`, `OnDisplayProfileChanged`); the GUI overrides each hook to call the same UI methods native interactions use, so MCP-driven mutations are indistinguishable from native UI interactions at the host level. +- **Render worker thread** (see Threading Model above); UI-thread Present cost is a sub-ms FP16 blit. +- **Refresh-rate-driven loop** (60–240 Hz) — interval re-derived from `EnumDisplaySettings(dmDisplayFrequency)` on every display change. +- Dirty-gated evaluation with dirty-propagation pre-pass; no built-in tone-mapping pass — users build tone mappers as graph effects (the ICtCp suite is the preferred path). +- Display profile mocking (presets + ICC file loading via `mscms.dll`); monitor gamut from `DXGI_OUTPUT_DESC1` primaries; **OS-reported SDR white level** via `DisplayConfigGetDeviceInfo`, exposed to graphs as `working_space.SdrWhiteNits`. +- **DXVA2 / Media Foundation video sources** with `ID3D10Multithread` protection. +- `OutputWindow` system: each `Output` node gets its own OS window (cross-thread `OutputSinkRenderState`, worker renders native-size, UI fits + presents). Bidirectional sync (close window ↔ delete node); `OnNodeAdded` auto-spawns windows for MCP/file-load Output nodes. +- D2D-rendered node graph canvas with pan/zoom, bezier edges (Alt+click delete), color-coded nodes, dot grid, dark theme; canvas paints from the `GraphUiSnapshot`. -**Engine-pure routes** (in `Engine/Mcp/EngineMcpRoutes.cpp`): `/registry`, `/effect/hlsl/`, `/effect/compile`, `/graph/add-node`, `/graph/remove-node`, `/graph/connect`, `/graph/disconnect`, `/graph/set-property`, `/graph/load`, `/graph/clear`, `/graph/bind-property`, `/graph/unbind-property`, `GET /graph` (incl. `/graph/save`, `/graph/node/`), `/custom-effects`, `/analysis/`, `/render/pixel-region`, `/render/capture-node`, `/display/profiles`, `/display/profile`, `/display/profile/clear`. The previously-engine `/render/image-stats` was retired in decision #63. +### MCP Server (stdio via the broker; HTTP deleted in migration Step 9) +JSON-RPC 2.0 (protocol `2025-06-18`, batching rejected) over the broker — **no HTTP**. `Engine/Mcp/McpRouter.{h,cpp}` is now a pure route registry (`AddRoute`/`RouteRequest`/`HasRoute`, query split, `ActivityCallback` fired on top-level `POST /`); transport-neutral types in `McpTypes.h` (`Mcp::Response` + `noReply`); the JSON-RPC dispatcher + declarative 39-tool `McpToolCatalog` in `McpJsonRpc.{h,cpp}`. Enable/disable per window via the toolbar toggle, `--mcp` flag, or `config.json`; the toggle registers this window as a hub **session**. -**App-side routes** (in `MainWindow.McpRoutes.cpp`): UI-coupled (`/graph/snapshot`, `/graph/view*`, `/preview/view*`, `/render/preview-node`, `/render/capture`, `/render/pixel-trace`, `/render/pixel//`) and host-specific (`/`, `POST /` JSON-RPC dispatcher, `/context`, `/perf`, `/node//logs`). +- **Transport = broker** (`ShaderLabMcpBroker`): a client's unpackaged **shim** (`--stdio`, distributed to `%LOCALAPPDATA%\ShaderLab\bin\`, update-immune) activates the packaged **hub** (`--hub`, blind relay routing on `{channelId, seq}`; shim↔session bodies sealed P-256/HKDF/AES-GCM), lists **sessions** (GUID-identified per window), `use_session ` to pin one. `ShaderLabHeadless --mcp-session` registers a headless session. +- **25 engine-pure routes** (`EngineMcpRoutes.cpp`) via `IEngineCommandSink` — `/graph/apply`, `/effects`, `/graph/overview`, `/display/info`, etc. In the GUI, `GuiEngineCommandSink::Dispatch` marshals to the **render thread** with `ctx.dc = RenderD2DContext()`, then fires the 8 event hooks so MCP mutations look native. **16 app-side routes** (`MainWindow.McpRoutes.cpp`): UI-coupled + `/context`/`/perf`/`/node//logs`. Tools whose backing route is absent on a host return isError "Tool not available" via `HasSpecificRoute`. +- **39 tools** in `tools/list` (see [docs/hosts/mcp-server.md](../docs/hosts/mcp-server.md)). Pairing: strict PFN for sessions, role-relaxed for the shim; `DefaultPipeBaseName()` is the shared meeting point. Full design: [docs/development/mcp-stdio-migration.md](../docs/development/mcp-stdio-migration.md). -The **Working Space** parameter node — a strict sink with no input pins — mirrors the active display profile (live or any simulated preset/ICC) into 14 typed analysis output fields (`ActiveColorMode`, `Hdr/WcgSupported`/`UserEnabled`, `IsSimulated`, `SdrWhiteNits`, `PeakNits`, `MinNits`, `MaxFullFrameNits`, plus four CIE-xy primaries as Float2). Bind any downstream property to drive an effect from the live working space — e.g. wire a tone-mapper's peak-nits to `working_space.PeakNits` and it tracks Display Settings or simulated profile changes automatically. Updated by `Rendering::UpdateWorkingSpaceNodes` (engine helper, called from `MainWindow::UpdateWorkingSpaceNodes` shim and the engine display-profile MCP routes). - -GUI MCP routes that mutate engine state run through `MainWindow::DispatchSync` to marshal to the UI thread; engine-side route bodies execute inside the closure passed to `IEngineCommandSink::Dispatch`. +The **Working Space** parameter node — a strict sink with no input pins — mirrors the active display profile (live or simulated preset/ICC) into 14 typed analysis output fields. Bind any downstream property to drive an effect from the live working space. Updated by `Rendering::UpdateWorkingSpaceNodes` on the render worker. ### Effect Designer - Three shader types: pixel (`ps_5_0`), D2D compute (`cs_5_0`), **D3D11 compute** (`cs_5_0`, host-dispatched). -- Parameter types: float, float2, float3, float4, int, uint, bool, enum. -- Enum parameters with comma-separated label definition → ComboBox. -- Bool parameters render as `ToggleSwitch`. -- Analysis output fields with typed declarations (Float, Float2, Float3, Float4, FloatArray, Float2/3/4Array). +- Parameter types: float, float2, float3, float4, int, uint, bool, enum; analysis output fields with typed declarations. - HLSL auto-formatting and scaffold generation per shader type (D3D11 scaffold injects auto `Width`/`Height` cbuffer + stride-reduction template). -- "Edit in Effect Designer" opens any built-in effect for inspection / fork. `LoadDefinition` correctly restores Output Type selector + analysis-field rows. -- Add to Graph / Update in Graph buttons. +- "Edit in Effect Designer" opens any built-in effect for inspection / fork; Add to Graph / Update in Graph buttons. +- Talks to `MainWindow` only through two `std::function` callbacks — no back-pointer. ### Versioning -- `Version.h`: App **1.5.0**, Graph format version **2**, plus `LibraryVersion()` (sum of all effect versions). -- `EngineExport.h::SHADERLAB_ENGINE_ABI_VERSION` = **1** (independent of app version; bumped manually on engine ABI breaks; mismatch between header and DLL aborts startup with a friendly message-box). -- Status bar shows pipeline / display / FPS; **title bar** shows app version + library version. +- `Version.h`: App **1.7.3**, Graph format version **2**, plus `LibraryVersion()` (sum of all effect versions). +- `EngineExport.h::SHADERLAB_ENGINE_ABI_VERSION` = **1** (independent of app version; mismatch between header and DLL aborts startup with a friendly message box). - Saved graphs include `formatVersion` + `appVersion`; loading newer-format graphs shows an error dialog. Per-effect `effectId`/`effectVersion` round-trip and surface upgrade prompts. ### UI / UX -- Segoe Fluent Icons toolbar with tooltips. -- `.effectgraph` file-type association (FTA) + Ctrl+S accelerators + unsaved-changes guard + async save/load with progress dialog. -- Auto-arrange resets viewport so off-screen graphs come back into view. -- New nodes spawn at the **center of the current viewport** (graph coords, accounting for pan/zoom). -- Closing an `OutputWindow` forces a single render pass so the deleted Output node disappears immediately. +- Segoe Fluent Icons toolbar with tooltips; status bar shows pipeline / display / FPS; title bar shows app version + library version. +- `.effectgraph` file-type association + Ctrl+S accelerators + unsaved-changes guard + async save/load with progress dialog. +- Status-bar broom button runs both reapers (orphan graph media + bytecode-cache drift) and reports freed bytes. +- Auto-arrange resets viewport; new nodes spawn at the center of the current viewport. --- @@ -134,7 +133,7 @@ These are critical lessons learned during development. Any AI agent or developer 9. **Variable-input D2D custom effects** (``) require BOTH `ID2D1Effect::SetInputCount(N)` (external) AND updating the transform node's internal count. Without the external call, `SetInput()` fails with `E_INVALIDARG`. 10. **Monitor gamut from `DXGI_OUTPUT_DESC1` primaries** (`RedPrimary`, `GreenPrimary`, `BluePrimary`, `WhitePoint`). Always write primaries into the cbuffer on every evaluate (correct on first frame), only mark dirty on actual change (prevents feedback loops). 11. **D2D → D3D11 texture handoff requires `dc->Flush()`** between `DrawImage` and any D3D11 read of the underlying texture. D2D batches commands until `EndDraw()` or `Flush()` — without an explicit flush, D3D11 reads zeros. Applied in `DispatchUserD3D11Compute`. -12. **`ProcessDeferredCompute` requires an active D2D draw session** (decision #63). It calls `dc->DrawImage` internally to pre-render the upstream chain into an FP32 bitmap, and outside `BeginDraw`/`EndDraw` that DrawImage silently no-ops — the compute reads black input and emits Min/Max/Mean = 0. The GUI's `RenderFrame`, the headless host's `runEval` / `RunRender`, and the test bench all wrap accordingly. +12. **`ProcessDeferredCompute` requires an active D2D draw session** (decision #63). It calls `dc->DrawImage` internally to pre-render the upstream chain into an FP32 bitmap, and outside `BeginDraw`/`EndDraw` that DrawImage silently no-ops — the compute reads black input and emits Min/Max/Mean = 0. The GUI's render path, the headless host's `runEval` / `RunRender`, and the test bench all wrap accordingly. 13. **D3D11 compute output → D2D bitmap interop**: `CreateBitmapFromDxgiSurface` must set `bp.dpiX/dpiY = 96.0f`. Default 0 DPI causes `GetImageLocalBounds` to return zero-size bounds. 14. **D3D11 multithread protection** (`ID3D10Multithread::SetMultithreadProtected(TRUE)`) must be enabled when using DXVA2 video decode on background threads with `Lock2D` on GPU buffers. 15. **D3D11 compute cbuffers**: when HLSL declares `uint`/`int`/`bool` but the property is stored as `float`, the pack code must reflect the declared `D3D_SHADER_VARIABLE_TYPE` and `static_cast` to the right type before writing — raw `memcpy` of a float bit-pattern produces nonsense ints/uints. @@ -144,167 +143,78 @@ These are critical lessons learned during development. Any AI agent or developer ## Build / Deploy / Launch ### Prerequisites -- Visual Studio 2022 17.8+ **or** VS 2026 Insiders (C++ Desktop + UWP workloads). -- Windows App SDK 1.8. -- Windows 10 SDK 10.0.26100+. -- PowerShell 5.1+. -- Git (exprtk + miniz are submodules; clone with `--recurse-submodules`). +- Visual Studio 2022 17.8+ **or** VS 2026 (C++ Desktop + UWP workloads). +- Windows App SDK 1.8; Windows 10 SDK 10.0.26100+; PowerShell 5.1+. +- Git — `exprtk` + `miniz` are **submodules** (decision #69); clone with `--recurse-submodules` or run `git submodule update --init --recursive`. A clone without them fails fast via the `VerifySubmodules` MSBuild target. `third_party/miniz_export.h` is an in-tree shim, not part of the submodule. ### Build ```pwsh -# Via Visual Studio -Open ShaderLab.slnx → Build → Debug | x64 +# Via Visual Studio: open ShaderLab.slnx → Build (Debug | x64 or Debug | ARM64) -# Via MSBuild +# Via MSBuild (x64 host) msbuild ShaderLab.slnx /p:Configuration=Debug /p:Platform=x64 -``` - -Pre-build scripts run automatically on first build: -- `scripts\EnsureDevCert.ps1` — generates / installs the local F5 dev cert (`CN=ShaderLab`). -- (exprtk + miniz are now git submodules under `third_party\`, not downloaded at build time.) -NuGet packages restore automatically (packages.config style). +# On an ARM64 host you MUST use the ARM64-native MSBuild — see the +# "Building ARM64 on an ARM64 host" section of docs/development/build.md +# for why the default 32-bit MSBuild fails with PCH out-of-memory errors. +``` -### Configurations -- `Debug | x64`, `Release | x64`, `Debug | ARM64`, `Release | ARM64`. +`scripts\EnsureDevCert.ps1` runs automatically on first build (local `CN=ShaderLab` F5 cert). NuGet restores automatically (packages.config style). ### Deploy (local F5) ```pwsh -Add-AppxPackage -Register "x64\Debug\ShaderLab\AppxManifest.xml" +Add-AppxPackage -Register "\\ShaderLab\AppxManifest.xml" ``` -**Never deploy from `AppX\`** — it accumulates stale artifacts that cause XAML 0xc000027b crashes. Always deploy from `x64\Debug\ShaderLab\AppxManifest.xml`. After building, close existing running instances (`Stop-Process`) before redeploying. - -### Releases -GitHub Actions `release.yml` runs as a matrix (x64, ARM64). Just before MSBuild, the workflow injects the unsigned-namespace OID into `Package.appxmanifest`'s `Publisher` so the resulting MSIX is installable via `Add-AppxPackage -AllowUnsigned`. The in-repo manifest stays plain `CN=ShaderLab` so signed F5 deploys keep working. End-user `Install.ps1` detects host arch, installs bundled VCLibs / WindowsAppRuntime dependency MSIXes, then ShaderLab. +**Never deploy from `AppX\`** — it accumulates stale artifacts that cause XAML 0xc000027b crashes. Close running instances before redeploying. -### Linked Libraries -`d3d11.lib`, `d2d1.lib`, `dxgi.lib`, `d3dcompiler.lib`, `dxguid.lib`, `windowscodecs.lib`, `mfplat.lib`, `mfreadwrite.lib`, `mfuuid.lib`, `mscms.lib`. +### Verification +```pwsh +\\ShaderLabTests\ShaderLabTests.exe --adapter warp # 261 unit tests +pwsh -NoProfile -File .\Tests\RunBrokerSmoke.ps1 -Platform ARM64 # broker smoke 26/26 +# MCP suite is shim-driven (no HTTP). Against a running GUI (shim activates the hub): +pwsh -NoProfile -File .\Tests\RunTests.ps1 -HubAumid 'ShaderLab_9v3yd384n9j18!Hub' +# or against a headless session (what CI does; GUI-only tests self-skip): +# $env:SHADERLAB_MCP_ALLOW_UNPACKAGED='1' +# ShaderLabMcpBroker.exe --hub --pipe P ; ShaderLabHeadless.exe --graph fixture --mcp-session --pipe P --adapter warp +# pwsh -NoProfile -File .\Tests\RunTests.ps1 -Pipe P +.\Tests\RunHeadlessSmoke.ps1 -Configuration Debug -Platform x64 # headless smoke +``` -### CI -`.github/workflows/ci.yml` builds Debug+Release x64 and runs `ShaderLabTests.exe --adapter warp`. Tests include graph DAG / topo sort / cycle detection, JSON round-trip, all ShaderLab effects compile-and-evaluate (analysis + source + tone-mapping), property bindings propagation, Numeric Expression input/output round-trip, Clock node, and three-node chain integration. +### CI / Releases +`.github/workflows/ci.yml`: `build-and-test` (Debug+Release x64, WARP unit tests) + `clean-clone-smoke` (checks out **without** submodules, runs the documented submodule-init command explicitly, builds, tests, headless smoke). `release.yml` runs an x64 + ARM64 matrix and injects the unsigned-namespace OID into the manifest just before MSBuild; end-user `Install.ps1` installs dependency MSIXes then ShaderLab. --- ## Project Structure +The annotated per-file tree lives in [docs/development/project-structure.md](../docs/development/project-structure.md) — maintained there, not here. Orientation summary: + ``` -ShaderLab\ -├── ShaderLab.slnx # Solution -├── ShaderLab.vcxproj # WinUI 3 packaged app (MSIX) -├── ShaderLabEngine.vcxproj # Engine DLL (shared by app + tests) -├── ShaderLabTests.vcxproj # Console test runner -├── packages.config # NuGet manifest -├── Package.appxmanifest # MSIX identity (plain CN=ShaderLab) -├── app.manifest # DPI awareness, heap type -├── EngineExport.h / .cpp # SHADERLAB_API + ABI version + ShaderLab_GetAbiVersion C export -├── Version.h # App 1.5.0, graph format 2 -├── README.md # Slim repo intro + pointer to docs/ -├── docs/ # Architecture tree (architecture / effects / ui-ux / hosts / development / history) -├── docs/effects/new-effect-defaults.md # D2D effect default-property reference -├── CHANGELOG.md # Version history -├── .gitmodules # submodule pins (exprtk, miniz) -│ -├── pch.h / pch.cpp # App PCH -├── pch_engine.h / pch_engine.cpp # Engine + Test + Headless PCH -├── App.xaml / .h / .cpp # WinUI 3 entry point -├── MainWindow.xaml / .h / .cpp # Main window (~4700 lines after Phase 4 split) -├── MainWindow.WorkingSpace.cpp # Display-profile selection + UpdateWorkingSpaceNodes shim -├── MainWindow.GraphFileIo.cpp # Save/load + miniz embedded media + heartbeat reaper -├── MainWindow.RenderTick.cpp # OnRenderTick / RenderFrame / dirty propagation -├── MainWindow.McpRoutes.cpp # 16 UI-coupled MCP routes + GuiEngineCommandSink + JSON-RPC dispatcher -├── EffectDesignerWindow.* # Effect Designer modal window -│ -├── Tests\ -│ ├── TestRunner.cpp # Standalone test entry point (119 tests) -│ ├── ShaderTestBench.{h,cpp} # D3D11 compute test harness -│ ├── Math\ # 51 HLSL math tests across 5 categories -│ ├── TestCommon.h # Shared TEST() macro -│ ├── RunTests.ps1 / RunMathTests.ps1 / RunHeadlessSmoke.ps1 / RunCliTests.ps1 -│ └── fixtures\test_cli_basic.json # Golden graph for headless smoke -│ -├── ShaderLabHeadless\ -│ └── Main.cpp # Console host: PNG render / --pixels / --script -│ -├── Engine\Mcp\ # (engine) MCP server + engine-pure routes -│ ├── McpHttpServer.{h,cpp} # Winsock2 + HTTP + chunked transport -│ └── EngineMcpRoutes.{h,cpp} # 20 engine-pure routes + IEngineCommandSink + EngineContext -│ -├── Graph\ # (engine) DAG data model -│ ├── NodeType.h / PropertyValue.h -│ ├── EffectNode.h / EffectEdge.h -│ └── EffectGraph.h / .cpp # DAG, topo sort, JSON, bindings, versioning -│ -├── Rendering\ # (engine) eval + display + math -│ ├── RenderEngine.h / .cpp # (app-only) D3D11 + D2D1 + swap chain -│ ├── GraphEvaluator.h / .cpp # Topological eval, dirty propagation, deferred D3D11 compute -│ ├── FalseColorOverlay.h / .cpp # False color rendering overlay -│ ├── DisplayMonitor.h / .cpp # HDR/SDR detection, primaries, OS SDR white, jthread -│ ├── DisplayProfile.h # Profile structs, presets -│ ├── DisplayInfo.h # DisplayCapabilities + monitor primaries -│ ├── PipelineFormat.h # scRGB FP16 (always) -│ ├── IccProfileParser.h / .cpp # mscms.dll-based ICC reader -│ ├── D3D11ComputeRunner.{h,cpp} # Generic D3D11 compute dispatcher; implements IEngineComputeOutput -│ ├── PixelReadback.{h,cpp} # FP32 RGBA region readback helper -│ ├── CaptureNode.{h,cpp} # D2D + WIC PNG encode of any node -│ ├── WorkingSpaceSync.{h,cpp} # Working Space parameter node refresh -│ ├── EffectGraphFile.{h,cpp} # .effectgraph zip (miniz) + embedded media -│ └── MathExpression.{h,cpp} # ExprTk evaluator (PCH disabled, math-only flags) -│ -├── Effects\ # (engine) effect catalog + custom effect base -│ ├── ShaderLabEffects.{h,cpp} # 35 ShaderLab effects with embedded HLSL -│ ├── ColorMath.cpp # Shared HLSL color math library -│ ├── EffectRegistry.{h,cpp} # 40+ wrapped D2D effect catalog -│ ├── IEngineComputeOutput.h # Phase 8 COM interface for GPU-resident analysis -│ ├── ShaderLabParamsHlsl.{h,cpp} # Engine-embedded shaderlab_params.hlsli macro library -│ ├── CustomPixelShaderEffect.* # ID2D1DrawTransform implementation -│ ├── CustomComputeShaderEffect.* # ID2D1ComputeTransform implementation -│ ├── ShaderCompiler.{h,cpp} # D3DCompile + D3DReflect wrapper + ID3DInclude resolver -│ ├── ImageLoader.{h,cpp} # WIC HDR/SDR image loading -│ ├── VideoSourceProvider.{h,cpp} # MF video decoding → D2D bitmaps -│ ├── DxgiDuplicationSourceProvider.{h,cpp} # Live DXGI Desktop Duplication capture -│ ├── WindowsGraphicsCaptureSourceProvider.{h,cpp} # WinUI graphics-capture picker -│ ├── SourceNodeFactory.{h,cpp} # Source node creation + per-frame live-capture tick -│ └── PropertyMetadata.h # Effect property metadata -│ -├── Controls\ # (app) editor controllers -│ ├── NodeGraphController.* # D2D canvas node graph editor -│ ├── ShaderEditorController.* # Live HLSL compile controller -│ ├── PixelInspectorController.* # GPU readback pixel inspection -│ ├── PixelTraceController.* # Recursive pixel trace through graph -│ ├── OutputWindow.* # Per-Output-node OS window -│ ├── LogWindow.* # Log viewer -│ └── NodeLog.h # Per-node log entry types -│ -├── third_party\ -│ └── exprtk\ # exprtk.hpp (downloaded, gitignored) -│ -├── scripts\ -│ ├── EnsureDevCert.ps1 -│ └── Install.ps1 # Per-arch unsigned-MSIX installer -│ -├── .github\ -│ ├── workflows\ -│ │ ├── ci.yml # Build + tests + bootstrap-smoke on every push / PR -│ │ └── release.yml # x64 + ARM64 matrix, OID injection -│ └── copilot-instructions.md # AI agent rules -└── .context\ - └── resume.md # This file +ShaderLab\ 4 vcxproj at repo root; MainWindow.xaml.cpp (~5000 lines) + sibling +│ partial TUs (WorkingSpace / GraphFileIo / RenderTick / McpRoutes) +├── Engine\Mcp\ McpRouter + McpTypes + McpJsonRpc + McpToolCatalog + McpTimeouts + 25 engine routes; +│ broker plumbing: McpFrame + McpCrypto + McpPeerIdentity + McpChannel; +│ McpSessionClient (registers a session with the hub; used by headless + GUI) +├── ShaderLabMcpBroker\ hub relay + stdio shim (Main.cpp); compiles the Mcp* plumbing directly +├── Graph\ EffectGraph DAG + GraphUiSnapshot (immutable per-frame UI copy) +├── Rendering\ Evaluator, RenderThreadDispatcher, display/ICC, readback, .effectgraph zip +├── Effects\ Effect catalogs, custom-effect COM classes, compute bridge, BytecodeCache +├── Controls\ Canvas editor, output windows, inspectors, log windows (app-only) +├── ShaderLabHeadless\ Console host ├── Tests\ Runner + math bench + PS1 suites +└── third_party\ exprtk + miniz submodules + miniz_export.h shim ``` --- ## Active Development Focus -**Phase 8 — GPU-binding for analysis chains** (v1.6 work-in-progress, 4 commits stacked locally). Goal: eliminate the `Map()` round-trip when an upstream compute analysis effect's output feeds a downstream effect's parameter via the data-pin binding system. Today every analysis field is read back to CPU, written into a `PropertyValue`, packed into a cbuffer, and uploaded — pointless GPU→CPU→GPU on integrated GPUs (~1ms stall per analysis node per frame). The architecture lands incrementally: +**MCP transport migration: HTTP → stdio + broker — COMPLETE** (branch `user/daspr/mcp_migration_httptostdio`; decision #71, supersedes #31/#58; engine ABI **3**). Full plan + the end-of-migration manual sweep: [docs/development/mcp-stdio-migration.md](../docs/development/mcp-stdio-migration.md). + +The embedded Winsock HTTP listener is deleted. The transport is now: a client's unpackaged **shim** (`ShaderLabMcpBroker --stdio`, copied to `%LOCALAPPDATA%\ShaderLab\bin\` via rename-then-write, so it survives ShaderLab updates) activates a singleton packaged **hub** (`--hub`, a blind relay routing on a clear `{channelId, seq}` frame header; shim↔session bodies sealed with ephemeral P-256 ECDH → HKDF → AES-256-GCM) which relays to per-window **sessions** (`McpSessionClient`, GUID-identified, so `use_session` pins a stable graph across hub restarts). This fixed the three original defects: multiple windows are now addressable, the unauthenticated loopback port is gone, and clients get the stdio transport they expect. The engine keeps the pure routing surface (`McpRouter::{AddRoute,RouteRequest,HasRoute}` + the `McpJsonRpc` dispatcher + the 39-tool `McpToolCatalog`); GUI tool calls still marshal to the render worker via `GuiEngineCommandSink::Dispatch` and fire the 8 event hooks. Along the way: `RenderThreadDispatcher` fails pending `DispatchSync` promises fast on shutdown/reset (no 30 s stall), the timeout ladder lives in `Engine/Mcp/McpTimeouts.h`, `SwitchAdapter` gates the sink to 503, pairing is role-aware (strict PFN for sessions, relaxed for the shim), and the dead pre-worker tick path was removed. Verified green: 261 unit tests, broker smoke 26/26, headless smoke, and the shim-driven `RunTests.ps1` at 40/40 (GUI) / 21/21 (headless) on WARP; grep for `47808`/`WSA` is clean. **Remaining before full sign-off:** the manual verification sweep (WinUI window lifecycle, packaged install/activation, a real MCP client, in-place upgrade) at the end of the migration doc. -1. **Foundation** (✅ committed): `IEngineComputeOutput` COM interface + `gpuBindable` / `gpuPublish` data-model flags + `shaderlab_params.hlsli` engine-embedded macro library + `ShaderCompiler` macro/include support + `D3D11ComputeRunner` becomes a no-op-refcounted COM impl with a cached SRV. -2. **Bridge effect** (in-progress, `p8-bridge-effect`): generalize the retired StatisticsEffect pattern so D3D11 compute custom effects are wrapped in a D2D effect (`CustomComputeBridgeEffect`) — `node->cachedEffect` non-null, single discovery channel via QI on cachedEffect, the special-case branch at `GraphEvaluator.cpp` line 146-194 collapses into `CreateOrGetEffect`. -3. **Bytecode cache** (`p8-cache-mem` then `p8-cache-disk` then `p8-cache-reaper`): variant precompile keyed on `(effectId, version, sourceHash, macroBitset)`. N+1 eager shapes per insert, lazy multi-bind variants, on-disk persistence at `%LOCALAPPDATA%\ShaderLab\bytecode\`, version/source-drift reaper. -4. **Evaluator QI hookup** (`p8-evaluator-qi`): for each property binding, QI upstream effect for `IEngineComputeOutput`. If supported AND consumer parameter is gpuBindable, bind the SRV directly into the consumer's `t`-slot, skip CPU readback for that field. Behind `ShaderLab::Performance::EnableGpuBindings` feature flag. -5. **Migrate first-class effects** (`p8-migrate-ictcp` then more): mark `TargetPeakNits` / `SourcePeakNits` etc. on the ICtCp suite as `gpuBindable`, wrap their HLSL with `SHADERLAB_PARAM` / `SHADERLAB_LOAD_PARAM` macros. -6. **Disk-cleanup status-bar button**: unified broom button in the bottom-left status bar that runs both reapers (orphan graph media + bytecode-cache version drift) and reports freed bytes. +Recently shipped context: **1.6.0** was the Phase 8 GPU-binding release (SRV routing between compute effects, bytecode cache + disk persistence, `CustomComputeBridgeEffect`, ICtCp Tone Map on compute, Vectorscope/Waveform removed); **1.7.0** was the render-worker-thread release (decision #68); **1.7.1–1.7.3** were targeted fixes (deferred-compute regression, Win2D removal, clock-controls-on-load + `visibleWhen` pin extension). -The thesis driving recent **product** work (ICtCp tone mapping, dE fidelity loop) carries through: I (intensity) is decoupled from Ct/Cp (chromaticity), so manipulating I alone preserves hue and saturation by construction; the empirical fidelity loop (`Working Space` + `Delta E Comparator` Grayscale dE + `Luminance Statistics` live readout) lets us tune effect parameters against measured CIEDE2000 color difference rather than visual impression. Phase 8 is engine perf work that unblocks running that loop fast enough on lower-powered hardware. +The product thesis carries through: I (intensity) is decoupled from Ct/Cp in ICtCp, so manipulating I alone preserves hue and saturation by construction; the empirical fidelity loop (`Working Space` + `Delta E Comparator` Grayscale dE + `Luminance Statistics` live readout) tunes effect parameters against measured CIEDE2000 rather than visual impression. The MCP work is what lets agents drive that loop reliably across multiple sessions. --- @@ -312,7 +222,7 @@ The thesis driving recent **product** work (ICtCp tone mapping, dE fidelity loop - **More tone-mapping operators** in the ICtCp subcategory (BT.2390, hue-preserving ACES, adaptive). - **Auto-bind affordances** so SDR-white / monitor-peak hidden defaults can be wired from any matching upstream output without manual binding. -- **Effect Designer export** — emit standalone C++ header / module files for D3D11 compute effects so teams can fork them into their own codebases. +- **Effect Designer export** — emit standalone C++ header / module files for D3D11 compute effects. - **External binary import** — load pre-compiled D2D effect DLLs (`ID2D1EffectImpl`) and `.cso` compute binaries directly into the graph. - **Multi-dispatch GPU reduction pyramid** for images > ~33 MP (current `D3D11ComputeRunner` dispatches a single 1024-thread group). - **Hide `Prim*` data pins from OOG-style nodes** — host-managed hidden properties should never surface as connectable orange diamonds. diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 0afb155..741b6f9 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -56,8 +56,14 @@ ShaderLabEngine.dll (host-agnostic) │ ├── SourceNodeFactory — Image / video / Flood / DXGI Desktop Duplication / Windows Graphics Capture │ └── DxgiDuplicationSourceProvider, WindowsGraphicsCaptureSourceProvider, VideoSourceProvider └── Engine/Mcp/ - ├── McpHttpServer — Winsock2 TCP server, route registration, JSON-RPC dispatcher - └── EngineMcpRoutes — 20 engine-pure routes + IEngineCommandSink + EngineContext + ├── McpRouter — route registry (longest-prefix, query split, HasRoute); HTTP listener deleted in stdio-mig. Step 9 — the broker is the only transport + ├── McpTypes — transport-neutral Mcp::Response (+ noReply) + shared JsonEscape/WideToUtf8 + ├── McpJsonRpc — engine-side JSON-RPC dispatcher (initialize / tools / resources / ping) + ├── McpToolCatalog — declarative 39-tool table (list JSON + route mapping + arg modes) + ├── McpFrame/McpCrypto/McpPeerIdentity/McpChannel — broker plumbing (frame codec, P-256/HKDF/AES-GCM, pairing, per-channel SecureChannel) + ├── McpSessionClient — registers a session with the hub; serves sealed requests via the router (headless + GUI) + ├── McpTimeouts — the MCP timeout ladder (render < DispatchSync < shim < client), static_assert-ordered + └── EngineMcpRoutes — 25 engine-pure routes + IEngineCommandSink + EngineContext ShaderLabHeadless.exe (console host, no WinUI dependency) └── Main.cpp — PNG render / --pixels FP32 readback / --script JSON batch mode @@ -174,7 +180,7 @@ Active development centers on **tone-mapping and color-correction effects author - **Graph serialization**: `Windows.Data.Json` (zero extra dependencies). GUID fields use `StringFromGUID2`/`CLSIDFromString`. - **Effect registry**: Singleton with 40+ built-in D2D effects across 9 categories. Case-insensitive name lookup. - **ShaderLab effects library**: 33 built-in effects in `Effects/ShaderLabEffects.h/.cpp` across categories: Analysis (Heatmaps + Scopes + Statistics + Tone-Mapping), Color Processing (Gamut Map + ICtCp Gamut Map + Scale), Source / Generator, Composition (Split Comparison), and the data-only Parameter / Clock / Numeric Expression / Random / Working Space nodes. Embedded HLSL with shared color math from `Effects/ColorMath.cpp`. Auto-compiled at first use; bytecode cached on disk under `%LOCALAPPDATA%\ShaderLab\bytecode\` (decision #58 catalog → see [builtin-catalog.md](../docs/effects/builtin-catalog.md) for the full per-effect type table). -- **MCP server**: JSON-RPC 2.0 server on port 47808 (47809 for headless to avoid shared-machine conflicts). The server itself + 20 engine-pure routes live in `Engine/Mcp/{McpHttpServer,EngineMcpRoutes}.{h,cpp}`; 16 UI-coupled / host-specific routes stay in `MainWindow.McpRoutes.cpp`. Both hosts register the same engine-side route set through the same `IEngineCommandSink` interface (decision #58). Engine-side routes are uniform: pure mutation closures dispatched via `sink.Dispatch`, with 8 event hooks (`OnNodeAdded`, `OnNodeRemoved`, `OnNodeChanged`, `OnGraphCleared`, `OnGraphLoaded`, `OnGraphStructureChanged`, `OnCustomEffectRecompiled`, `OnDisplayProfileChanged`) the GUI overrides to keep its UI in sync. +- **MCP server**: JSON-RPC 2.0 (protocol 2025-06-18, batching rejected) over **stdio via the broker** — the embedded HTTP listener was deleted in stdio-migration Step 9 (decision #71, superseding #31/#58; engine ABI **3**). The router, dispatcher, 39-tool catalog + 25 engine-pure routes live in `Engine/Mcp/` (handlers take `(path, query, body)`); 16 app-side routes stay in `MainWindow.McpRoutes.cpp`. Each host registers as a hub **session** (`McpSessionClient`, GUID-identified); a client's shim (`ShaderLabMcpBroker --stdio`, unpackaged, distributed to `%LOCALAPPDATA%\ShaderLab\bin\`) activates the packaged hub and pins a session with `use_session`. Both hosts register the same engine-side route set through `IEngineCommandSink`: pure mutation closures dispatched via `sink.Dispatch`, with 8 event hooks (`OnNodeAdded`, `OnNodeRemoved`, `OnNodeChanged`, `OnGraphCleared`, `OnGraphLoaded`, `OnGraphStructureChanged`, `OnCustomEffectRecompiled`, `OnDisplayProfileChanged`) the GUI overrides to keep its UI in sync. CI drives `RunTests.ps1` through a shim against a headless `--mcp-session`. - **Versioning**: `Version.h` defines app version (currently **1.7.3**) and graph format version (2). Both are stored in saved graphs. Forward compatibility check on load. `EngineExport.h::SHADERLAB_ENGINE_ABI_VERSION` is independent — bumped manually on engine ABI breaks; mismatch between header and DLL aborts startup with a friendly message-box. - **Refresh-rate-driven render loop on the worker thread**: the render worker `std::jthread` runs the graph evaluate at the active monitor's refresh rate (clamped to 60–240 Hz). Dirty-gated: skips evaluate when no nodes changed, no output window is open, and `m_forceRender` is false. The UI thread runs a `DispatcherQueueTimer` at the same rate, but its body is just "drain dispatcher + blit offscreen + Present1" — sub-ms cost. The interval is re-applied on every display change so dragging the window across monitors picks up the new rate. - **`ProcessDeferredCompute` requires an active D2D draw session**: it calls `dc->DrawImage` internally to pre-render the upstream chain into an FP32 bitmap, and outside `BeginDraw`/`EndDraw` that DrawImage silently no-ops. The GUI's `RenderFrame`, the headless host's `runEval` / `RunRender`, and the test bench all wrap accordingly. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 33156d7..424c4b9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -74,6 +74,51 @@ jobs: exit $LASTEXITCODE } + # Broker smoke (stdio-migration Step 5): election, framing, idle + # exit, stdout hygiene, no-session shim protocol. The script + # PRE-LAUNCHES an unpackaged hub -- no unpackaged activation path + # exists, so IApplicationActivationManager-based election of the + # PACKAGED hub is NOT covered here (documented gap; manual test). + - name: Broker smoke + shell: pwsh + run: | + pwsh -NoProfile -File Tests\RunBrokerSmoke.ps1 -Configuration ${{ env.Configuration }} -Platform ${{ env.Platform }} + if ($LASTEXITCODE -ne 0) { + Write-Error "RunBrokerSmoke.ps1 reported $LASTEXITCODE failure(s)" + exit $LASTEXITCODE + } + + # MCP suite vs a real headless session over the broker (stdio-migration + # Step 9: HTTP is gone, so the suite drives shim -> hub -> session). + # Pre-launch a hub + a headless --mcp-session on an isolated pipe, then + # RunTests.ps1 starts a shim and pins that session. GUI-only tests + # self-skip (the pinned session's label is "headless"). CI is unpackaged, + # so SHADERLAB_MCP_ALLOW_UNPACKAGED=1 enables the same-build-tree pairing. + - name: MCP suite vs headless session + shell: pwsh + env: + SHADERLAB_MCP_ALLOW_UNPACKAGED: '1' + run: | + $bin = "$env:GITHUB_WORKSPACE\${{ env.Platform }}\${{ env.Configuration }}" + $broker = "$bin\ShaderLabMcpBroker\ShaderLabMcpBroker.exe" + $headless = "$bin\ShaderLabHeadless\ShaderLabHeadless.exe" + $pipe = "ShaderLab.mcp.ci.$([guid]::NewGuid().ToString('N'))" + $hub = Start-Process $broker -ArgumentList '--hub','--pipe',$pipe,'--idle-exit-sec','600' -PassThru + $sess = Start-Process $headless -ArgumentList ` + '--graph','Tests\fixtures\test_cli_basic.json', ` + '--mcp-session','--pipe',$pipe,'--session-label','ci-headless','--adapter','warp' -PassThru + try { + pwsh -NoProfile -File Tests\RunTests.ps1 -Pipe $pipe -Adapter warp + $suiteExit = $LASTEXITCODE + } finally { + Stop-Process -Id $sess.Id -Force -ErrorAction SilentlyContinue + Stop-Process -Id $hub.Id -Force -ErrorAction SilentlyContinue + } + if ($suiteExit -ne 0) { + Write-Error "RunTests.ps1 reported $suiteExit failure(s) against the headless session" + exit $suiteExit + } + - name: Upload AppX layout artifact if: matrix.configuration == 'Release' uses: actions/upload-artifact@v4 diff --git a/.mcp.json b/.mcp.json index 8c7a735..3632982 100644 --- a/.mcp.json +++ b/.mcp.json @@ -2,9 +2,9 @@ "inputs": [], "servers": { "ShaderLab": { - "type": "http", - "url": "http://localhost:47808/", - "headers": {} + "type": "stdio", + "command": "x64/Debug/ShaderLabMcpBroker/ShaderLabMcpBroker.exe", + "args": ["--stdio"] } } -} \ No newline at end of file +} diff --git a/CHANGELOG.md b/CHANGELOG.md index efacbe1..c90eb51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,61 @@ Format follows [Keep a Changelog](https://keepachangelog.com/). ## [Unreleased] +### Added + +- **MCP stdio-migration Step 1 — route hygiene** (see `docs/development/mcp-stdio-migration.md`). Four `tools/call` handlers that ran inline inside the GUI's JSON-RPC dispatcher are now real routes: + - `GET /effects` (`list_effects`) and `GET /graph/overview` (`graph_overview`) moved **engine-side** — both hosts serve them, so `ShaderLabHeadless --script` can now enumerate effects and summarize the graph. `graph_overview` previously read `m_graph` on the UI thread while the render worker mutated it; it now runs through `IEngineCommandSink::Dispatch` on the render thread. + - `POST /graph/rename-node` (`graph_rename_node`) and `GET /display/info` (`get_display_info`) are **app-side** routes. Rename mutates + rebuilds layout on the render thread and `TryEnqueue`s the XAML refresh (preview selector + Add Node flyout) to the UI thread, so a busy UI can no longer turn a committed rename into a 500 — previously the whole body ran via UI-thread `DispatchSync` and parse failures escaped as `winrt::hresult_error`. `get_display_info` stays app-side *by decision*: it reads `RenderEngine::ActiveFormat()` and `EngineContext` has no `RenderEngine`; extending it is an ABI change deferred to Step 2. + - `Tests/RunTests.ps1` gains 6 promoted-route tests (39 total). +- **MCP stdio-migration Step 9 — the HTTP transport is deleted (point of no return).** The broker (shim → hub → session over named pipes, bodies sealed) is now the only MCP transport; the embedded Winsock listener is gone. Engine ABI **2 → 3**. (Decision #71 supersedes #31 and #58.) + - `McpRouter` lost `Start` / `Stop` / `Port` / `IsRunning` / `ListenerThread` / `HandleConnection` / `WSAStartup` / `WSACleanup` / `ws2_32.lib` / the CORS preflight / the `GET /` health route, and now compiles with the PCH. It keeps the pure routing surface — `AddRoute` / `RouteRequest` / `HasRoute` / `HasSpecificRoute` — and fires `ActivityCallback` from the top-level `POST /` with a `clientId` (was an HTTP `peerAddress`). + - `MainWindow` and `ShaderLabHeadless` lost every HTTP call site (`Start(47808)`, `--serve`, `--port`); the GUI toggle and `~MainWindow` drive the session client alone, the toolbar activity indicator keys off `m_sessionClient`, and the export button's HTTP fallback is gone. + - **`Tests/RunTests.ps1` ported to stdio in the same change**: it starts a shim, pins the first registered session, and runs every test as a `tools/call` over stdio (`-Pipe` / `-HubAumid` params; the one `GET /graph` became `graph_overview`). GUI-only tests self-skip from the pinned session's label. CI's headless step pre-launches a hub + `ShaderLabHeadless --mcp-session` and drives via the shim. + - Verified: 261 unit tests (the `McpRouter` routing tests survive), broker smoke 26/26, headless smoke, and `RunTests.ps1` 40/40 against the GUI + 21/21 against a headless session, on WARP, both platforms. Grepping for `47808` / `WSA` / `ws2_32` returns nothing outside `CHANGELOG.md` and the decision log. +- **MCP stdio-migration Step 8 — shim distribution + on-demand hub activation.** The full production flow is now in place, so a real MCP client can drive ShaderLab over stdio. + - **`MainWindow::EnsureShimDistributed()`** copies `ShaderLabMcpBroker.exe` (the package payload) to `%LOCALAPPDATA%\ShaderLab\bin\` on every MCP start, **rename-then-write**: an existing copy is moved aside (works while a shim runs from it — the process keeps its image) and a fresh binary lands at the canonical path; stale `.old` files are reaped. The distributed shim is unpackaged, hence immune to MSIX update / uninstall — an MCP client keeps talking to it across a ShaderLab upgrade. + - **Shim gains `--hub-aumid`**: when no hub answers, `ShimState::EnsureHub` activates the packaged hub via `IApplicationActivationManager` (`--hub --pipe ` so it binds the shared default pipe) and polls before retrying — the client-driven bootstrap (the MCP client's shim brings the hub up). `MainWindow::HubAumid()` derives `!Hub`. + - **Toolbar export button** now copies a **stdio** MCP config (`command` = the distributed shim, `args` = `--stdio --hub-aumid `), falling back to the HTTP snippet only when there's no broker payload (dev). `.mcp.json` becomes a stdio config pointing at the build-tree broker for contributors; `Install.ps1` prints the ready-to-paste per-user stdio snippet. + - Verified on a real packaged install: the shim distributes on launch; a held shim survives a re-distribution (naive overwrite blocked, rename-then-write succeeds, running process untouched); with no hub running, the distributed shim activates the packaged hub, the GUI session registers, and `use_session` + `graph_add_node` drive end-to-end. Broker smoke 26/26 and the HTTP suite 40/40 unregressed. +- **MCP stdio-migration Step 7 — GUI session client + dispatcher hardening.** The GUI (`MainWindow`) now registers as an MCP session with the broker hub (`m_sessionClient` + `m_sessionThread`, started with the MCP toggle / autostart, alongside the still-live HTTP listener). It serves through the same `McpRouter`, so tool calls marshal to the render worker via `GuiEngineCommandSink::Dispatch` and fire the 8 live event hooks — MCP-over-stdio drives the graph identically to the HTTP path. + - **`RenderThreadDispatcher` fail-fast**: `Shutdown()` and `ResetConsumer()` now invoke queued items with a cancel flag so pending `DispatchSync` promises **fail immediately** instead of eating their 30 s timeout (the shutdown / adapter-switch stall). A `DispatchSync` on an already-shut-down queue also fails fast. Queue element became `std::function`. +3 unit tests (261 total). + - **`MainWindow::DispatchSync`** checks `TryEnqueue`'s return value (previously discarded) and throws immediately when the DispatcherQueue is shutting down, rather than waiting the full timeout per request during window close. + - **Timeout ladder** collected in one `Engine/Mcp/McpTimeouts.h` (`static_assert`-ordered: render closure < `DispatchSync` < shim < client), wired into the sink's render-rung wait and the shim's session-wait. + - **Shutdown ordering**: `~MainWindow` stops the session first (while the worker is alive), then the HTTP listener, then the render dispatcher + worker — no in-flight request stranded on a joined worker. + - **Adapter-switch gating**: `GuiEngineCommandSink::Dispatch` returns 503 for the whole `SwitchAdapter` teardown/rebuild window. + - **Role-aware pairing**: the hub enforces strict binary pairing on SESSION registration (drives real graphs) but accepts a SHIM (unpackaged in production, talking to the packaged hub; payloads sealed end-to-end regardless). `DefaultPipeBaseName()` (in `McpPeerIdentity`, compiled by both binaries) is the single SID-derived default pipe name so hub, shim and sessions meet without a `--pipe` override. + - **Residual sweep** (Step 2 findings): the two UI-thread live-graph reads in the 250 ms tick now read under a shared `m_graphMutex` lock and act after release; `m_frameGeneration` is `std::atomic`; and the dead pre-worker tick path is **removed** — `MainWindow::RenderTickBody` + `RenderFrame` (~500 lines) and the `MainWindow::CaptureNodeAsPng` / `ReadPixelRegion` shims (~65 lines), all unreferenced since the v1.7.0 worker-thread migration. The `render_capture_node` / `read_pixel_region` MCP routes (engine-side, using `Rendering::CaptureNodeAsPng` / `Rendering::ReadPixelRegion`) were re-verified afterward. + - Verified with a GUI-as-session end-to-end (activate the packaged hub, the running GUI registers a GUID session, an unpackaged shim drives `graph_add_node` / `graph_overview` / `list_gpus` through the render worker); the HTTP MCP suite still passes 40/40 against the GUI. +- **MCP stdio-migration Step 6 — session client + hub relay** (sealed shim→hub→session end-to-end). New `Engine/Mcp/McpChannel.{h,cpp}` (`SecureChannel`: the P-256 handshake + AES-256-GCM seal/open for one shim↔session channel, AAD bound to `{channelId, seq}` — one implementation compiled by both the broker shim and the engine session client) and `Engine/Mcp/McpSessionClient.{h,cpp}` (written once against `McpRouter&`: connects to the hub as a session, serves each sealed request by routing plaintext JSON-RPC through the router's `POST /` dispatcher, reconnects with capped backoff, one in-flight request per session). + - **Hub** gained a session registry + blind channel relay: `open-channel` allocates a channelId pairing a shim with a session; data frames route on channelId only (bodies stay sealed and opaque to the hub); a dead session emits a distinct `session-gone`. **Shim** pins a session (`use_session`, validated against the live registry), runs the initiator handshake, seals and forwards requests verbatim (the request id passes through, so the shim is a pass-through for forwarded methods), and **splices `tools/list`** — the 2 shim tools plus the pinned session's catalog, merged as real JSON values. + - **`ShaderLabHeadless --mcp-session [--session-id GUID] [--session-label] [--pipe]`** registers with the hub instead of listening on a port; the session id is a persisted per-window GUID (generated when omitted), never an ordinal. + - **Binary pairing**: the unpackaged dev/CI fallback now accepts a shared parent directory (sibling per-project out dirs under one `\\`), not just an exact directory — still gated behind `SHADERLAB_MCP_ALLOW_UNPACKAGED=1`, still requiring a matching build id, still never engaging in a packaged configuration. + - **`RunBrokerSmoke.ps1`** grows the end-to-end session gate: launch a headless session on WARP, register it, `use_session`, confirm `tools/list` splices, drive `graph_overview` + `graph_add_node` through the sealed relay, and assert `session_gone` on session kill (26 checks). +12 unit tests (256 total): the `SecureChannel` loopback (handshake, seal/open, tamper / wrong-channel / wrong-seq / cross-channel-key rejection) and the sibling-directory pairing cases. +- **MCP stdio-migration Step 5 — hub + stdio shim** (`ShaderLabMcpBroker`, zero sessions yet). New `ShaderLabMcpBroker.vcxproj` in `ShaderLab.slnx` — one console binary, two modes, deliberately NOT linked against the engine (the hub must start in milliseconds and never touches a GPU; it compiles the Step 4 `McpFrame`/`McpCrypto`/`McpPeerIdentity` TUs directly). + - `--hub`: the singleton blind relay. `FreeConsole()` first, then first-instance election on the named pipe with the **prove-the-loss** rule — `ERROR_ACCESS_DENIED` / `ERROR_PIPE_BUSY` is not treated as a bare loss (it also means stale-instance or DACL-denial), so the loser connects and completes `hello` before exiting 0. Overlapped per-connection I/O with next-instance-created-before-serve, per-peer `McpPeerIdentity` pairing at hello, channel-0 control ops (`hello`/`list-sessions`/`bye`), idle exit (`--idle-exit-sec`). Explicit-rights pipe DACL (never `GENERIC_WRITE`, so the `FILE_CREATE_PIPE_INSTANCE` grant is deliberate). + - `--stdio`: the MCP client's front-end. Binary-mode NDJSON on stdin/stdout (stdout carries protocol bytes only; logs go to `%LOCALAPPDATA%\ShaderLab\logs\`); owns `initialize` (protocol 2025-06-18), the `list_sessions`/`use_session` tools, and hub-op timeouts. Graph tools return a clean isError "No session attached" until Step 6 registers sessions. + - **Packaging**: `Package.appxmanifest` gains `uap3`/`desktop` namespaces and a second `` (`Executable="ShaderLabMcpBroker.exe"`, `EntryPoint="Windows.FullTrustApplication"`, `AppListEntry="none"`, full VisualElements, no `AppExecutionAlias`) — activation via this AUMID is the only way the hub survives an MCP client's job object. A `CopyBrokerRuntime` target mirrors `CopyEngineRuntime` into the Appx payload. + - **New `Tests/RunBrokerSmoke.ps1`** (19 checks: election winner/loser, framing, no-session shim protocol incl. zero-byte notifications, stdout/stderr hygiene, idle exit), wired into CI for Debug + Release. Packaged-hub AUMID activation stays a manual test (no unpackaged activation path exists); verified by hand that the `!Hub` AUMID activates windowless and receives its `--pipe` argument. +- **MCP stdio-migration Step 4 — broker plumbing as pure units** (no IPC yet; nothing wired to a pipe). Three new `Engine/Mcp/` modules, all linking `bcrypt.lib` with no new external dependency: + - `McpFrame.{h,cpp}` — length-prefixed wire codec `[u32 totalLen][u32 channelId][u64 seq][body]` (little-endian, 64 MB cap). The `{channelId, seq}` header is a distinct clear type from the sealed body (the hub routes on the header, never the body); `TryDecodeFrame` never consumes on a partial read and reports an over-cap prefix as an explicit `Oversize` rather than desyncing. + - `McpCrypto.{h,cpp}` — ephemeral P-256 ECDH → HKDF-SHA256 → AES-256-GCM. Both CNG traps handled: `BCRYPT_KDF_RAW_SECRET` returns the secret byte-reversed (corrected to big-endian), and the exported public blob carries a header ahead of the raw curve points. Two direction keys via HKDF info labels so the GCM nonce is the frame seq; the clear header rides as AAD so header-tamper / seq-desync fail authentication at `Open()`. + - `McpPeerIdentity.{h,cpp}` — peer resolution by package family name (via `GetPackageFamilyName(HANDLE)`) + pipe PID both directions, and `EvaluatePairing`: packaged→PFN equality, unpackaged→same-dir+same-build behind `SHADERLAB_MCP_ALLOW_UNPACKAGED=1`, mixed packaged/unpackaged always refused. + - +33 unit tests (244 total): 40 MB frame round-trip, oversize/truncated/malformed frames, HKDF vs RFC 5869 A.1, mirrored handshake, tampered ciphertext/tag/AAD + sequence-desync rejection, a real loopback pipe exercising both `GetNamedPipe{Client,Server}ProcessId`, and the full pairing-policy matrix. +- **MCP stdio-migration Step 3 — JSON-RPC dispatcher + tool catalog into the engine.** + - New `Engine/Mcp/McpJsonRpc.{h,cpp}`: the dispatcher (initialize / tools/list / tools/call / resources / ping) moved out of `MainWindow.McpRoutes.cpp` (-376 lines there); `RegisterJsonRpcEndpoint` installs `GET /` (health, now reporting `"host":"gui"|"headless"`) + `POST /` on the host's router. New `Engine/Mcp/McpToolCatalog.{h,cpp}`: 39 declarative tool rows (single-line list JSON + method/path + arg mode + image-inline eligibility) replacing the hand-written forwarding ladder. + - **stdio conformance**: every response is single-line JSON; notifications (absent `id` — that is the detection, not a name prefix) produce zero reply bytes (`Response::None()`; 202 over HTTP); `id` is echoed on every error path (`params` access is guarded — previously a winrt exception surfaced as an uncorrelatable 500); one shared `Mcp::JsonEscape` replaces three divergent escapers, so control characters in HLSL error text can no longer produce invalid JSON. + - **protocolVersion `2025-06-18`** (was 2024-11-05): the revision that removed JSON-RPC batching, which this server never supported; batch arrays now get an explicit `-32600`. + - **`ShaderLabHeadless --serve [--port N]`** (default 47809): serves the full MCP protocol against a loaded graph with no GUI. `RunTests.ps1` gains `-Port` + host-kind self-skipping (12 GUI-only tests), and **CI now runs the MCP suite against a headless session** — the first point at which it can gate. + - **Fixed a resurrected silent-success bug found during verification**: with `POST /` registered on headless, a tool whose backing route is absent fell through longest-prefix matching into the dispatcher itself and read as an id-less notification (fake 202 success — the `image_stats` failure class). `McpRouter::HasSpecificRoute` (catch-all excluded) now guards every tools/call forward; absent tools return isError "Tool not available on this host". + - +18 unit tests (211 total: router `HasSpecificRoute`, dispatcher conformance, catalog shape) and a new `Route.CatalogRoundTrip` suite test driving every advertised tool with safe canned args (40 suite tests total; 21 runnable headless). +- **MCP stdio-migration Step 2 — transport-neutral types + router rename.** Engine ABI **1 → 2**. + - New `Engine/Mcp/McpTypes.h`: `ShaderLab::Mcp::Response` extracted from the transport header (un-welding `IEngineCommandSink` and every route from the HTTP implementation), with a **`noReply` discriminator** — over HTTP a notification still goes out as 202-empty, but the stdio transport must emit zero bytes for notifications and keys off the flag, not an empty body. The JSON-RPC dispatcher's notification paths return `Response::None()`. + - `McpHttpServer.{h,cpp}` → **`McpRouter.{h,cpp}`** (git mv; class renamed). `McpRouter::HasRoute()` added. + - **Handlers now receive `(path, query, body)`** — all 43 route lambdas across both hosts. The router owns the query split and matches on the bare path, which **fixes a silent bug**: the HTTP listener used to strip the query before routing, so `GET /node/{id}/logs?since=N` ignored `since` over raw HTTP and returned the whole log every poll (only `RouteRequest` callers that embedded the query in the path string got filtering). + - `EngineContext` gains `getPipelineFormatName`; **`GET /display/info` moves engine-side**, so `ShaderLabHeadless` now serves `get_display_info` (reporting the FormatScRgbFP16 pipeline name + real `DisplayMonitor` caps). + - +10 `McpRouter` unit tests (193 total) pinning the query-split contract, longest-prefix matching with queries, `HasRoute`, and `noReply`. + - **Finding, deferred to Step 6/7**: the per-node runtime-error transition logger sits in the dead pre-worker `RenderFrame` path and has not run since v1.7.0 — no MCP-reachable code path produces `node_logs` entries any more (errors are still *set* and visible via `graph_get_node`, just never logged). Recorded in `docs/development/mcp-stdio-migration.md`. + ### Changed - **Native dependencies are now git submodules; `Bootstrap.ps1` and the `Ensure*` download scripts are gone.** `exprtk` and `miniz` were previously fetched over the network by MSBuild pre-build PowerShell into a wholly-gitignored `third_party/`, which meant the effective dependency versions were invisible to git and not reproducible — `EnsureExprTk.ps1` in particular pulled `exprtk.hpp` from `master`, an unpinned floating reference. Both are now submodules with explicit pins: @@ -19,6 +74,8 @@ Format follows [Keep a Changelog](https://keepachangelog.com/). - `Bootstrap.ps1`, `scripts/EnsureExprTk.ps1`, `scripts/EnsureMiniz.ps1`. Bootstrap's three jobs are covered elsewhere: the dev cert by the existing `EnsureDevSigningCertificate` target in `ShaderLab.vcxproj`, ExprTk by the submodule, and NuGet restore by Visual Studio / CI. - The blanket `third_party/` entry in `.gitignore`, which was hiding the dependency tree from git. +- **The `GET /render/pixel/{x}/{y}` stub** (MCP Step 1). It returned "coming soon" while touching the UI D2D context on the listener thread with no dispatch and `std::stof`-ing unvalidated input. `POST /render/pixel-region` is the real readback; `/context` no longer advertises the stub. +- **The phantom `image_stats` tool** (MCP Step 1). Its route was retired by decision #63, but the tool stayed in `tools/list` and its ladder entry forwarded into the longest-prefix `POST /` catch-all, producing an HTTP-200 "success" wrapping a JSON-RPC error. 39 tools remain; `Route.ImageStatsRemoved` now guards against a silent-success regression. ## [1.7.3] - 2026-05-10 diff --git a/Engine/Mcp/EngineMcpRoutes.cpp b/Engine/Mcp/EngineMcpRoutes.cpp index c3ee0b7..0bcce90 100644 --- a/Engine/Mcp/EngineMcpRoutes.cpp +++ b/Engine/Mcp/EngineMcpRoutes.cpp @@ -1,5 +1,6 @@ #include "pch_engine.h" #include "EngineMcpRoutes.h" +#include "McpRouter.h" #include "../../Graph/EffectGraph.h" #include "../../Rendering/GraphEvaluator.h" @@ -28,67 +29,24 @@ namespace ShaderLab::Mcp namespace { // ---- Small response helpers -------------------------------------- - McpHttpServer::Response Json(uint16_t status, const std::string& body) + Response Json(uint16_t status, const std::string& body) { - McpHttpServer::Response r; + Response r; r.statusCode = status; r.body = body; r.contentType = "application/json"; return r; } - McpHttpServer::Response Error(uint16_t status, const std::string& msg) + Response Error(uint16_t status, const std::string& msg) { return Json(status, "{\"error\":\"" + msg + "\"}"); } - std::string WideToUtf8(std::wstring_view ws) - { - if (ws.empty()) return {}; - int len = ::WideCharToMultiByte(CP_UTF8, 0, - ws.data(), static_cast(ws.size()), - nullptr, 0, nullptr, nullptr); - std::string out(len, '\0'); - ::WideCharToMultiByte(CP_UTF8, 0, - ws.data(), static_cast(ws.size()), - out.data(), len, nullptr, nullptr); - return out; - } - - // Escape a UTF-8 string for embedding in a JSON string literal. - // Mirrors the helper that used to live in MainWindow.McpRoutes.cpp; - // covers the required JSON escapes plus the most common control- - // char cases. Same output bytes for ASCII-clean inputs. - std::string JsonEscape(std::string_view s) - { - std::string out; - out.reserve(s.size() + 8); - for (char c : s) - { - switch (c) - { - case '"': out += "\\\""; break; - case '\\': out += "\\\\"; break; - case '\n': out += "\\n"; break; - case '\r': out += "\\r"; break; - case '\t': out += "\\t"; break; - case '\b': out += "\\b"; break; - case '\f': out += "\\f"; break; - default: - if (static_cast(c) < 0x20) - { - char buf[8]; - std::snprintf(buf, sizeof(buf), "\\u%04x", static_cast(c)); - out += buf; - } - else - { - out += c; - } - } - } - return out; - } + // WideToUtf8 + JsonEscape now come from McpTypes.h (the single + // shared implementations — stdio-migration Step 3 unified the + // previously-divergent escapers). This TU sits inside namespace + // ShaderLab::Mcp, so unqualified calls resolve to them directly. // Base64 (standard alphabet, '=' padding, no line wrapping). // Used for /render/capture-node `inline` PNG payloads. @@ -406,10 +364,10 @@ namespace ShaderLab::Mcp // graph_snapshot, preview/graph view tools, render/preview-node. // ---- GET /registry — D2D + ShaderLab effect catalog (static) ------- - void RegisterRegistry(McpHttpServer& server) + void RegisterRegistry(McpRouter& server) { server.AddRoute(L"GET", L"/registry", - [](const std::wstring& path, const std::string&) -> McpHttpServer::Response { + [](const std::wstring& path, const std::wstring&, const std::string&) -> Response { auto& reg = ::ShaderLab::Effects::EffectRegistry::Instance(); // /registry/effect/ — detailed effect info. @@ -459,17 +417,161 @@ namespace ShaderLab::Mcp }); } + // ---- GET /effects — all effects grouped by category (static) ------ + // Promoted from the GUI's inline `list_effects` tools/call handler + // (stdio-migration Step 1) so the headless host serves it too. Both + // catalogs are immutable after startup, so no Dispatch is needed — + // same reasoning as /registry. The built-in D2D "Analysis" category + // is deliberately skipped: the ShaderLab analysis effects supersede + // those wrappers in the catalog agents should pick from. + void RegisterListEffects(McpRouter& server) + { + server.AddRoute(L"GET", L"/effects", + [](const std::wstring&, const std::wstring&, const std::string&) -> Response { + std::string json = "{\"builtIn\":{"; + auto& reg = ::ShaderLab::Effects::EffectRegistry::Instance(); + bool firstCat = true; + for (const auto& cat : reg.Categories()) + { + if (cat == L"Analysis") continue; + if (!firstCat) json += ","; + json += "\"" + JsonEscape(WideToUtf8(cat)) + "\":["; + bool firstFx = true; + for (const auto* e : reg.ByCategory(cat)) + { + if (!firstFx) json += ","; + json += "\"" + JsonEscape(WideToUtf8(e->name)) + "\""; + firstFx = false; + } + json += "]"; + firstCat = false; + } + json += "},\"shaderLab\":{"; + auto& sl = ::ShaderLab::Effects::ShaderLabEffects::Instance(); + firstCat = true; + for (const auto& cat : sl.Categories()) + { + if (!firstCat) json += ","; + json += "\"" + JsonEscape(WideToUtf8(cat)) + "\":["; + bool firstFx = true; + for (const auto* e : sl.ByCategory(cat)) + { + if (!firstFx) json += ","; + json += "\"" + JsonEscape(WideToUtf8(e->name)) + "\""; + firstFx = false; + } + json += "]"; + firstCat = false; + } + json += "}}"; + return Json(200, json); + }); + } + + // ---- GET /graph/overview — compact summary (nodes, edges, preview) - + // Promoted from the GUI's inline `graph_overview` tools/call handler + // (stdio-migration Step 1). Reads the live graph, so it runs through + // sink.Dispatch; previously it read m_graph on the UI thread while + // the render worker mutated it. Longest-prefix routing sends + // /graph/overview here rather than to the shorter GET /graph route. + void RegisterGraphOverview(McpRouter& server, IEngineCommandSink& sink) + { + server.AddRoute(L"GET", L"/graph/overview", + [&sink](const std::wstring&, const std::wstring&, const std::string&) -> Response + { + return sink.Dispatch([](EngineContext& ctx) -> Response { + uint32_t previewId = ctx.getPreviewNodeId ? ctx.getPreviewNodeId() : 0; + std::string json = "{\"previewNodeId\":" + std::to_string(previewId) + ",\"nodes\":["; + bool first = true; + for (const auto& n : ctx.graph->Nodes()) + { + if (!first) json += ","; + std::string typeStr; + switch (n.type) + { + case Graph::NodeType::Source: typeStr = "Source"; break; + case Graph::NodeType::BuiltInEffect: typeStr = "BuiltIn"; break; + case Graph::NodeType::PixelShader: typeStr = "PixelShader"; break; + case Graph::NodeType::ComputeShader: typeStr = "ComputeShader"; break; + case Graph::NodeType::Output: typeStr = "Output"; break; + } + json += std::format("{{\"id\":{},\"name\":\"{}\",\"type\":\"{}\"", + n.id, JsonEscape(WideToUtf8(n.name)), typeStr); + if (!n.runtimeError.empty()) + json += ",\"error\":\"" + JsonEscape(WideToUtf8(n.runtimeError)) + "\""; + json += std::format(",\"inputs\":{},\"outputs\":{}}}", + n.inputPins.size(), n.outputPins.size()); + first = false; + } + json += "],\"edges\":["; + first = true; + for (const auto& e : ctx.graph->Edges()) + { + if (!first) json += ","; + json += std::format("[{},{},{},{}]", + e.sourceNodeId, e.sourcePin, e.destNodeId, e.destPin); + first = false; + } + json += "]}"; + return Json(200, json); + }); + }); + } + + // ---- GET /display/info — caps + active profile + pipeline --------- + // Moved from MainWindow.McpRoutes.cpp in stdio-migration Step 2. + // Step 1 left it app-side because it needs the pipeline-format + // name and EngineContext had no way to supply one; the + // getPipelineFormatName shim (added with this step's ABI bump) + // closes that gap, so both hosts serve it now. + void RegisterDisplayInfo(McpRouter& server, IEngineCommandSink& sink) + { + server.AddRoute(L"GET", L"/display/info", + [&sink](const std::wstring&, const std::wstring&, const std::string&) -> Response + { + return sink.Dispatch([](EngineContext& ctx) -> Response { + auto profile = ctx.displayMonitor->ActiveProfile(); + auto live = ctx.displayMonitor->LiveProfile(); + auto caps = ctx.displayMonitor->CachedCapabilities(); + auto verStr = WideToUtf8(std::wstring(::ShaderLab::VersionString)); + std::wstring fmtName = ctx.getPipelineFormatName + ? ctx.getPipelineFormatName() : std::wstring(L"unknown"); + std::string json = std::format( + "{{\"appVersion\":\"{}\",\"graphFormatVersion\":{}" + ",\"pipeline\":\"{}\"" + ",\"display\":{{\"hdr\":{},\"maxNits\":{:.0f},\"sdrWhiteNits\":{:.0f}" + ",\"simulated\":{},\"profileName\":\"{}\"" + ",\"activeGamut\":{{\"red\":[{:.4f},{:.4f}],\"green\":[{:.4f},{:.4f}],\"blue\":[{:.4f},{:.4f}]}}" + ",\"monitorGamut\":{{\"red\":[{:.4f},{:.4f}],\"green\":[{:.4f},{:.4f}],\"blue\":[{:.4f},{:.4f}]}}" + "}}}}", + verStr, ::ShaderLab::GraphFormatVersion, + JsonEscape(WideToUtf8(fmtName)), + caps.hdrEnabled ? "true" : "false", + caps.maxLuminanceNits, caps.sdrWhiteLevelNits, + profile.isSimulated ? "true" : "false", + JsonEscape(WideToUtf8(profile.profileName)), + profile.primaryRed.x, profile.primaryRed.y, + profile.primaryGreen.x, profile.primaryGreen.y, + profile.primaryBlue.x, profile.primaryBlue.y, + live.primaryRed.x, live.primaryRed.y, + live.primaryGreen.x, live.primaryGreen.y, + live.primaryBlue.x, live.primaryBlue.y); + return Json(200, json); + }); + }); + } + // ---- POST /graph/connect — wire output pin -> input pin ----------- // Note: the GUI app also runs m_nodeGraphController.AutoLayout() // and adds NodeLog entries. Those are UI side effects; the engine // route just does the graph mutation. The GUI's render tick will // pick up the dirty state and refresh the canvas next frame. - void RegisterConnect(McpHttpServer& server, IEngineCommandSink& sink) + void RegisterConnect(McpRouter& server, IEngineCommandSink& sink) { server.AddRoute(L"POST", L"/graph/connect", - [&sink](const std::wstring&, const std::string& body) -> McpHttpServer::Response + [&sink](const std::wstring&, const std::wstring&, const std::string& body) -> Response { - return sink.Dispatch([&body, &sink](EngineContext& ctx) -> McpHttpServer::Response { + return sink.Dispatch([&body, &sink](EngineContext& ctx) -> Response { try { auto jobj = WDJ::JsonObject::Parse(winrt::to_hstring(body)); @@ -489,12 +591,12 @@ namespace ShaderLab::Mcp } // ---- POST /graph/disconnect — remove a single edge ---------------- - void RegisterDisconnect(McpHttpServer& server, IEngineCommandSink& sink) + void RegisterDisconnect(McpRouter& server, IEngineCommandSink& sink) { server.AddRoute(L"POST", L"/graph/disconnect", - [&sink](const std::wstring&, const std::string& body) -> McpHttpServer::Response + [&sink](const std::wstring&, const std::wstring&, const std::string& body) -> Response { - return sink.Dispatch([&body, &sink](EngineContext& ctx) -> McpHttpServer::Response { + return sink.Dispatch([&body, &sink](EngineContext& ctx) -> Response { try { auto jobj = WDJ::JsonObject::Parse(winrt::to_hstring(body)); @@ -517,12 +619,12 @@ namespace ShaderLab::Mcp // Note: the GUI's m_nodeGraphController.RebuildLayout() drops out; // the render tick will pick up the dirty state and rebuild // automatically. Headless host has no canvas anyway. - void RegisterBindProperty(McpHttpServer& server, IEngineCommandSink& sink) + void RegisterBindProperty(McpRouter& server, IEngineCommandSink& sink) { server.AddRoute(L"POST", L"/graph/bind-property", - [&sink](const std::wstring&, const std::string& body) -> McpHttpServer::Response + [&sink](const std::wstring&, const std::wstring&, const std::string& body) -> Response { - return sink.Dispatch([&body, &sink](EngineContext& ctx) -> McpHttpServer::Response { + return sink.Dispatch([&body, &sink](EngineContext& ctx) -> Response { try { auto jobj = WDJ::JsonObject::Parse(winrt::to_hstring(body)); @@ -545,12 +647,12 @@ namespace ShaderLab::Mcp } // ---- POST /graph/unbind-property ----------------------------------- - void RegisterUnbindProperty(McpHttpServer& server, IEngineCommandSink& sink) + void RegisterUnbindProperty(McpRouter& server, IEngineCommandSink& sink) { server.AddRoute(L"POST", L"/graph/unbind-property", - [&sink](const std::wstring&, const std::string& body) -> McpHttpServer::Response + [&sink](const std::wstring&, const std::wstring&, const std::string& body) -> Response { - return sink.Dispatch([&body, &sink](EngineContext& ctx) -> McpHttpServer::Response { + return sink.Dispatch([&body, &sink](EngineContext& ctx) -> Response { try { auto jobj = WDJ::JsonObject::Parse(winrt::to_hstring(body)); @@ -577,12 +679,12 @@ namespace ShaderLab::Mcp // After AddNode the OnNodeAdded event fires so the host (if any) // can run AutoLayout + PopulatePreviewNodeSelector + log entry. // Same UI path the toolbar AddNode flyout takes. - void RegisterAddNode(McpHttpServer& server, IEngineCommandSink& sink) + void RegisterAddNode(McpRouter& server, IEngineCommandSink& sink) { server.AddRoute(L"POST", L"/graph/add-node", - [&sink](const std::wstring&, const std::string& body) -> McpHttpServer::Response + [&sink](const std::wstring&, const std::wstring&, const std::string& body) -> Response { - return sink.Dispatch([&body, &sink](EngineContext& ctx) -> McpHttpServer::Response { + return sink.Dispatch([&body, &sink](EngineContext& ctx) -> Response { try { auto jobj = WDJ::JsonObject::Parse(winrt::to_hstring(body)); @@ -590,7 +692,7 @@ namespace ShaderLab::Mcp return Json(400, R"({"error":"Provide effectName"})"); auto name = jobj.GetNamedString(L"effectName"); - auto addAndReply = [&](Graph::EffectNode&& node) -> McpHttpServer::Response { + auto addAndReply = [&](Graph::EffectNode&& node) -> Response { auto id = ctx.graph->AddNode(std::move(node)); ctx.graph->MarkAllDirty(); sink.OnNodeAdded(id); @@ -707,12 +809,12 @@ namespace ShaderLab::Mcp // Read-only; no Dispatch needed. Library effects (ShaderLab built-in) // are reported with isLibraryEffect=true so agents know they're // read-only-shipped and shouldn't try to recompile them. - void RegisterEffectHlsl(McpHttpServer& server, IEngineCommandSink& sink) + void RegisterEffectHlsl(McpRouter& server, IEngineCommandSink& sink) { server.AddRoute(L"GET", L"/effect/hlsl/", - [&sink](const std::wstring& path, const std::string&) -> McpHttpServer::Response + [&sink](const std::wstring& path, const std::wstring&, const std::string&) -> Response { - return sink.Dispatch([path](EngineContext& ctx) -> McpHttpServer::Response { + return sink.Dispatch([path](EngineContext& ctx) -> Response { if (path.size() <= 13) return Json(400, R"({"error":"Missing nodeId in URL"})"); uint32_t nodeId = 0; @@ -788,12 +890,12 @@ namespace ShaderLab::Mcp // Engine drops graph state and the evaluator cache. The OnGraphCleared // event runs the host's UI cleanup (output windows, preview selector // reset). Same path /graph/clear via UI button takes. - void RegisterClear(McpHttpServer& server, IEngineCommandSink& sink) + void RegisterClear(McpRouter& server, IEngineCommandSink& sink) { server.AddRoute(L"POST", L"/graph/clear", - [&sink](const std::wstring&, const std::string&) -> McpHttpServer::Response + [&sink](const std::wstring&, const std::wstring&, const std::string&) -> Response { - return sink.Dispatch([&sink](EngineContext& ctx) -> McpHttpServer::Response { + return sink.Dispatch([&sink](EngineContext& ctx) -> Response { ctx.evaluator->ReleaseCache(); ctx.graph->Clear(); ctx.graph->MarkAllDirty(); @@ -808,10 +910,10 @@ namespace ShaderLab::Mcp // OnGraphLoaded event runs the host's per-load setup (heartbeats, // re-opens output windows for nodes that had them, preview selector // refresh). Same path the file-open dialog takes. - void RegisterLoad(McpHttpServer& server, IEngineCommandSink& sink) + void RegisterLoad(McpRouter& server, IEngineCommandSink& sink) { server.AddRoute(L"POST", L"/graph/load", - [&sink](const std::wstring&, const std::string& body) -> McpHttpServer::Response + [&sink](const std::wstring&, const std::wstring&, const std::string& body) -> Response { // Parse on the listener thread; assignment requires the // dispatch thread (it owns m_graph). @@ -824,7 +926,7 @@ namespace ShaderLab::Mcp { return Json(400, std::string(R"({"error":")") + ex.what() + R"("})"); } - return sink.Dispatch([&loaded, &sink](EngineContext& ctx) -> McpHttpServer::Response { + return sink.Dispatch([&loaded, &sink](EngineContext& ctx) -> Response { ctx.evaluator->ReleaseCache(); *ctx.graph = std::move(loaded); ctx.graph->MarkAllDirty(); @@ -835,12 +937,12 @@ namespace ShaderLab::Mcp } // ---- POST /graph/remove-node --------------------------------------- - void RegisterRemoveNode(McpHttpServer& server, IEngineCommandSink& sink) + void RegisterRemoveNode(McpRouter& server, IEngineCommandSink& sink) { server.AddRoute(L"POST", L"/graph/remove-node", - [&sink](const std::wstring&, const std::string& body) -> McpHttpServer::Response + [&sink](const std::wstring&, const std::wstring&, const std::string& body) -> Response { - return sink.Dispatch([&body, &sink](EngineContext& ctx) -> McpHttpServer::Response { + return sink.Dispatch([&body, &sink](EngineContext& ctx) -> Response { try { auto jobj = WDJ::JsonObject::Parse(winrt::to_hstring(body)); @@ -856,12 +958,12 @@ namespace ShaderLab::Mcp } // ---- POST /graph/set-property — mutates m_graph ------------------- - void RegisterSetProperty(McpHttpServer& server, IEngineCommandSink& sink) + void RegisterSetProperty(McpRouter& server, IEngineCommandSink& sink) { server.AddRoute(L"POST", L"/graph/set-property", - [&sink](const std::wstring&, const std::string& body) -> McpHttpServer::Response + [&sink](const std::wstring&, const std::wstring&, const std::string& body) -> Response { - return sink.Dispatch([&body, &sink](EngineContext& ctx) -> McpHttpServer::Response { + return sink.Dispatch([&body, &sink](EngineContext& ctx) -> Response { try { auto jobj = WDJ::JsonObject::Parse(winrt::to_hstring(body)); @@ -900,8 +1002,43 @@ namespace ShaderLab::Mcp node->properties[key] = val.GetBoolean(); break; case WDJ::JsonValueType::String: - node->properties[key] = std::wstring(val.GetString()); + { + // Some MCP clients stringify untyped (schema {}) + // argument values, so a numeric/bool param can + // arrive as a JSON string ("203", "true"). Coerce + // to the target's real type -- a bogus wstring + // otherwise both starves the shader (param reads 0) + // and breaks bindability (IsBindablePropertyType + // rejects non-float/bool variants). + std::wstring sval(val.GetString()); + std::wstring want; // float | uint | int | bool | "" + if (node->customEffect.has_value()) + for (const auto& p : node->customEffect->parameters) + if (p.name == key) { want = p.typeName; break; } + if (want.empty()) + { + auto it = node->properties.find(key); + if (it != node->properties.end()) + { + if (std::holds_alternative(it->second)) want = L"float"; + else if (std::holds_alternative(it->second)) want = L"uint"; + else if (std::holds_alternative(it->second)) want = L"int"; + else if (std::holds_alternative(it->second)) want = L"bool"; + } + } + if (want.empty() && (key == L"IsPlaying" || key == L"isPlaying")) + want = L"bool"; + try + { + if (want == L"float") node->properties[key] = std::stof(sval); + else if (want == L"uint") node->properties[key] = static_cast(std::stoul(sval)); + else if (want == L"int") node->properties[key] = static_cast(std::stol(sval)); + else if (want == L"bool") node->properties[key] = (sval == L"true" || sval == L"1"); + else node->properties[key] = sval; + } + catch (...) { node->properties[key] = sval; } break; + } case WDJ::JsonValueType::Array: { auto arr = val.GetArray(); @@ -972,12 +1109,12 @@ namespace ShaderLab::Mcp // nodes; refs and numeric IDs are interchangeable in those positions. // The whole closure runs as a single render-thread dispatch -- atomic // either succeeds or fails as one unit. - void RegisterApply(McpHttpServer& server, IEngineCommandSink& sink) + void RegisterApply(McpRouter& server, IEngineCommandSink& sink) { server.AddRoute(L"POST", L"/graph/apply", - [&sink](const std::wstring&, const std::string& body) -> McpHttpServer::Response + [&sink](const std::wstring&, const std::wstring&, const std::string& body) -> Response { - return sink.Dispatch([&body, &sink](EngineContext& ctx) -> McpHttpServer::Response { + return sink.Dispatch([&body, &sink](EngineContext& ctx) -> Response { WDJ::JsonObject root; try { root = WDJ::JsonObject::Parse(winrt::to_hstring(body)); } catch (...) { return Json(400, R"({"error":"Invalid JSON"})"); } @@ -1053,8 +1190,40 @@ namespace ShaderLab::Mcp node.properties[key] = val.GetBoolean(); break; case WDJ::JsonValueType::String: - node.properties[key] = std::wstring(val.GetString()); + { + // Same coercion as /graph/set-property: untyped MCP + // arg values may arrive stringified, so map "203" / + // "true" to the target param's real type instead of + // storing a bindability-breaking, shader-starving wstring. + std::wstring sval(val.GetString()); + std::wstring want; + if (node.customEffect.has_value()) + for (const auto& p : node.customEffect->parameters) + if (p.name == key) { want = p.typeName; break; } + if (want.empty()) + { + auto it = node.properties.find(key); + if (it != node.properties.end()) + { + if (std::holds_alternative(it->second)) want = L"float"; + else if (std::holds_alternative(it->second)) want = L"uint"; + else if (std::holds_alternative(it->second)) want = L"int"; + else if (std::holds_alternative(it->second)) want = L"bool"; + } + } + if (want.empty() && (key == L"IsPlaying" || key == L"isPlaying")) + want = L"bool"; + try + { + if (want == L"float") node.properties[key] = std::stof(sval); + else if (want == L"uint") node.properties[key] = static_cast(std::stoul(sval)); + else if (want == L"int") node.properties[key] = static_cast(std::stol(sval)); + else if (want == L"bool") node.properties[key] = (sval == L"true" || sval == L"1"); + else node.properties[key] = sval; + } + catch (...) { node.properties[key] = sval; } break; + } case WDJ::JsonValueType::Array: { auto arr = val.GetArray(); @@ -1340,14 +1509,14 @@ namespace ShaderLab::Mcp } - void RegisterPixelRegion(McpHttpServer& server, IEngineCommandSink& sink) + void RegisterPixelRegion(McpRouter& server, IEngineCommandSink& sink) { // POST /render/pixel-region -- Read FP32 RGBA pixel grid. // Body: { nodeId, x, y, w, h } (capped at 32x32 = 1024 pixels) server.AddRoute(L"POST", L"/render/pixel-region", - [&sink](const std::wstring&, const std::string& body) -> McpHttpServer::Response + [&sink](const std::wstring&, const std::wstring&, const std::string& body) -> Response { - return sink.Dispatch([&body, &sink](EngineContext& ctx) -> McpHttpServer::Response { + return sink.Dispatch([&body, &sink](EngineContext& ctx) -> Response { WDJ::JsonObject jo{ nullptr }; if (!WDJ::JsonObject::TryParse(winrt::to_hstring(body), jo)) return Json(400, R"({"error":"Invalid JSON body"})"); @@ -1420,12 +1589,12 @@ namespace ShaderLab::Mcp // The host that wants /graph to surface a "previewNodeId" provides // ctx.getPreviewNodeId. Headless leaves it null and we emit 0, // which matches "no preview pane" semantics. - void RegisterGetGraph(McpHttpServer& server, IEngineCommandSink& sink) + void RegisterGetGraph(McpRouter& server, IEngineCommandSink& sink) { server.AddRoute(L"GET", L"/graph", - [&sink](const std::wstring& path, const std::string&) -> McpHttpServer::Response + [&sink](const std::wstring& path, const std::wstring&, const std::string&) -> Response { - return sink.Dispatch([&path](EngineContext& ctx) -> McpHttpServer::Response { + return sink.Dispatch([&path](EngineContext& ctx) -> Response { // /graph/save -> raw graph JSON via EffectGraph::ToJson. if (path == L"/graph/save") { @@ -1472,12 +1641,12 @@ namespace ShaderLab::Mcp } // ---- GET /custom-effects — all nodes with a customEffect def ----- - void RegisterCustomEffects(McpHttpServer& server, IEngineCommandSink& sink) + void RegisterCustomEffects(McpRouter& server, IEngineCommandSink& sink) { server.AddRoute(L"GET", L"/custom-effects", - [&sink](const std::wstring&, const std::string&) -> McpHttpServer::Response + [&sink](const std::wstring&, const std::wstring&, const std::string&) -> Response { - return sink.Dispatch([](EngineContext& ctx) -> McpHttpServer::Response { + return sink.Dispatch([](EngineContext& ctx) -> Response { std::string json = "["; bool first = true; for (const auto& node : ctx.graph->Nodes()) @@ -1494,12 +1663,12 @@ namespace ShaderLab::Mcp } // ---- GET /analysis/{id} — analysis output fields ------------------ - void RegisterAnalysisOutput(McpHttpServer& server, IEngineCommandSink& sink) + void RegisterAnalysisOutput(McpRouter& server, IEngineCommandSink& sink) { server.AddRoute(L"GET", L"/analysis/", - [&sink](const std::wstring& path, const std::string&) -> McpHttpServer::Response + [&sink](const std::wstring& path, const std::wstring&, const std::string&) -> Response { - return sink.Dispatch([&path](EngineContext& ctx) -> McpHttpServer::Response { + return sink.Dispatch([&path](EngineContext& ctx) -> Response { auto rest = path.substr(10); // after "/analysis/" uint32_t nodeId = 0; try { nodeId = static_cast(std::stoul(rest)); } @@ -1576,12 +1745,12 @@ namespace ShaderLab::Mcp // Body: { nodeId }. Returns { width, height } at 96 DPI in pixels. // Useful for diagnosing rect-bloat issues that the capture-node // route hides via its maxDim clamp. - void RegisterImageBounds(McpHttpServer& server, IEngineCommandSink& sink) + void RegisterImageBounds(McpRouter& server, IEngineCommandSink& sink) { server.AddRoute(L"POST", L"/render/image-bounds", - [&sink](const std::wstring&, const std::string& body) -> McpHttpServer::Response + [&sink](const std::wstring&, const std::wstring&, const std::string& body) -> Response { - return sink.Dispatch([&body](EngineContext& ctx) -> McpHttpServer::Response { + return sink.Dispatch([&body](EngineContext& ctx) -> Response { WDJ::JsonObject jo{ nullptr }; if (!WDJ::JsonObject::TryParse(winrt::to_hstring(body), jo)) return Json(400, R"({"error":"Invalid JSON body"})"); @@ -1611,12 +1780,12 @@ namespace ShaderLab::Mcp // path + size. If inline=true, also returns a base64 PNG payload. // Uses Rendering::CaptureNodeAsPng so this route is identical // between GUI and headless hosts. - void RegisterCaptureNode(McpHttpServer& server, IEngineCommandSink& sink) + void RegisterCaptureNode(McpRouter& server, IEngineCommandSink& sink) { server.AddRoute(L"POST", L"/render/capture-node", - [&sink](const std::wstring&, const std::string& body) -> McpHttpServer::Response + [&sink](const std::wstring&, const std::wstring&, const std::string& body) -> Response { - return sink.Dispatch([&body](EngineContext& ctx) -> McpHttpServer::Response { + return sink.Dispatch([&body](EngineContext& ctx) -> Response { WDJ::JsonObject jo{ nullptr }; if (!WDJ::JsonObject::TryParse(winrt::to_hstring(body), jo)) return Json(400, R"({"error":"Invalid JSON body"})"); @@ -1693,12 +1862,12 @@ namespace ShaderLab::Mcp // OnCustomEffectRecompiled so the GUI rebuilds the canvas // layout (parameter pins may have changed) and Add Node flyout. // Mirrors EffectDesignerWindow's "Update in Graph" path. - void RegisterCompileEffect(McpHttpServer& server, IEngineCommandSink& sink) + void RegisterCompileEffect(McpRouter& server, IEngineCommandSink& sink) { server.AddRoute(L"POST", L"/effect/compile", - [&sink](const std::wstring&, const std::string& body) -> McpHttpServer::Response + [&sink](const std::wstring&, const std::wstring&, const std::string& body) -> Response { - return sink.Dispatch([&body, &sink](EngineContext& ctx) -> McpHttpServer::Response { + return sink.Dispatch([&body, &sink](EngineContext& ctx) -> Response { try { auto jobj = WDJ::JsonObject::Parse(winrt::to_hstring(body)); @@ -1868,12 +2037,12 @@ namespace ShaderLab::Mcp } // GET /display/profiles — All built-in presets + active + live. - void RegisterGetDisplayProfiles(McpHttpServer& server, IEngineCommandSink& sink) + void RegisterGetDisplayProfiles(McpRouter& server, IEngineCommandSink& sink) { server.AddRoute(L"GET", L"/display/profiles", - [&sink](const std::wstring&, const std::string&) -> McpHttpServer::Response + [&sink](const std::wstring&, const std::wstring&, const std::string&) -> Response { - return sink.Dispatch([](EngineContext& ctx) -> McpHttpServer::Response { + return sink.Dispatch([](EngineContext& ctx) -> Response { auto presets = ::ShaderLab::Rendering::AllPresets(); std::string json = "{\"presets\":["; for (size_t i = 0; i < presets.size(); ++i) @@ -1901,12 +2070,12 @@ namespace ShaderLab::Mcp // POST /display/profile — apply a simulated profile. // Body: exactly one of {preset, presetIndex, iccPath, custom}. - void RegisterSetDisplayProfile(McpHttpServer& server, IEngineCommandSink& sink) + void RegisterSetDisplayProfile(McpRouter& server, IEngineCommandSink& sink) { server.AddRoute(L"POST", L"/display/profile", - [&sink](const std::wstring&, const std::string& body) -> McpHttpServer::Response + [&sink](const std::wstring&, const std::wstring&, const std::string& body) -> Response { - return sink.Dispatch([&body, &sink](EngineContext& ctx) -> McpHttpServer::Response { + return sink.Dispatch([&body, &sink](EngineContext& ctx) -> Response { using namespace ::ShaderLab::Rendering; WDJ::JsonObject jo{ nullptr }; @@ -2035,12 +2204,12 @@ namespace ShaderLab::Mcp } // POST /display/profile/clear — revert to the live OS profile. - void RegisterClearDisplayProfile(McpHttpServer& server, IEngineCommandSink& sink) + void RegisterClearDisplayProfile(McpRouter& server, IEngineCommandSink& sink) { server.AddRoute(L"POST", L"/display/profile/clear", - [&sink](const std::wstring&, const std::string&) -> McpHttpServer::Response + [&sink](const std::wstring&, const std::wstring&, const std::string&) -> Response { - return sink.Dispatch([&sink](EngineContext& ctx) -> McpHttpServer::Response { + return sink.Dispatch([&sink](EngineContext& ctx) -> Response { ctx.displayMonitor->ClearSimulatedProfile(); ::ShaderLab::Rendering::UpdateWorkingSpaceNodes(*ctx.graph, *ctx.displayMonitor); sink.OnDisplayProfileChanged(); @@ -2050,9 +2219,12 @@ namespace ShaderLab::Mcp } } - void RegisterEngineRoutes(McpHttpServer& server, IEngineCommandSink& sink) + void RegisterEngineRoutes(McpRouter& server, IEngineCommandSink& sink) { RegisterRegistry(server); + RegisterListEffects(server); + RegisterGraphOverview(server, sink); + RegisterDisplayInfo(server, sink); RegisterEffectHlsl(server, sink); RegisterAddNode(server, sink); RegisterRemoveNode(server, sink); diff --git a/Engine/Mcp/EngineMcpRoutes.h b/Engine/Mcp/EngineMcpRoutes.h index 2d7f218..8d31536 100644 --- a/Engine/Mcp/EngineMcpRoutes.h +++ b/Engine/Mcp/EngineMcpRoutes.h @@ -2,9 +2,9 @@ // Engine MCP routes — engine-side route handlers for the MCP server. // -// Phase 7 architecture: the McpHttpServer runs in the engine DLL. -// The GUI app and headless host both instantiate it and register a -// mix of routes: +// Phase 7 architecture: the McpRouter (né McpHttpServer, renamed in +// stdio-migration Step 2) runs in the engine DLL. The GUI app and +// headless host both instantiate it and register a mix of routes: // // * Engine-pure routes (graph mutation, render capture, pixel // readback, image stats, etc) live in this TU and are registered @@ -24,11 +24,15 @@ #include "pch_engine.h" #include "../../EngineExport.h" #include "../../Rendering/DisplayProfile.h" -#include "McpHttpServer.h" +#include "McpTypes.h" #include #include +namespace ShaderLab +{ + class McpRouter; +} namespace ShaderLab::Graph { class EffectGraph; @@ -59,9 +63,9 @@ namespace ShaderLab::Mcp ID3D11DeviceContext* d3dContext{ nullptr }; // Force a fresh evaluation of the graph if the host has any - // dirty propagation / tick logic. Headless: no-op (caller did - // this already). GUI: calls MainWindow::RenderFrame so dirty - // nodes are repopulated before readback / capture. + // dirty propagation / tick logic. Headless: runs the eval closure + // (runEval). GUI: calls RenderFrameToOffscreen on the render worker + // so dirty nodes are repopulated before readback / capture. // Returning void; cannot fail. std::function renderFrame; @@ -71,6 +75,15 @@ namespace ShaderLab::Mcp // to 0. std::function getPreviewNodeId; + // Active pipeline-format display name (e.g. "scRGB FP16") for + // /display/info. The GUI reads RenderEngine::ActiveFormat(); + // headless supplies the FormatScRgbFP16 constant. Optional — if + // unset the route reports "unknown". This is the EngineContext + // extension that made get_display_info engine-pure + // (stdio-migration Step 2; deferred from Step 1 pending the ABI + // bump). + std::function getPipelineFormatName; + // Optional host-state shim for the most-recently-loaded ICC // profile (so it can appear under "loadedIcc" in the // /display/profiles response and be re-applied through the @@ -82,7 +95,7 @@ namespace ShaderLab::Mcp // Functional / closure-based command sink (Q4 architecture choice). // The route handler hands a closure to Dispatch; the host runs it - // on the right thread and returns the McpHttpServer::Response back + // on the right thread and returns the Mcp::Response back // to the listener thread that the route returns on. // // Engine state mutations also fire **events** (the OnXxx virtuals @@ -101,8 +114,8 @@ namespace ShaderLab::Mcp // host. Closure receives a freshly-built EngineContext. // Synchronous: the calling thread blocks until the closure // completes. Closure exceptions propagate. - virtual McpHttpServer::Response Dispatch( - std::function closure) = 0; + virtual Response Dispatch( + std::function closure) = 0; // ---- Engine state-change events (UI hooks) ----------------------- // @@ -138,6 +151,6 @@ namespace ShaderLab::Mcp // call once per process. The sink must outlive the server (handlers // capture it by reference). SHADERLAB_API void RegisterEngineRoutes( - McpHttpServer& server, + McpRouter& server, IEngineCommandSink& sink); } diff --git a/Engine/Mcp/McpHttpServer.cpp b/Engine/Mcp/McpHttpServer.cpp deleted file mode 100644 index 0217842..0000000 --- a/Engine/Mcp/McpHttpServer.cpp +++ /dev/null @@ -1,414 +0,0 @@ -// This file does NOT use the precompiled header because WinSock2.h -// must be included before windows.h, which the PCH already includes. -#define WIN32_LEAN_AND_MEAN -#define _WINSOCKAPI_ // Prevent winsock1 from windows.h -#include -#include -#include - -#pragma comment(lib, "ws2_32.lib") - -#include -#include -#include -#include -#include -#include -#include -#include - -// Minimal redefinition of SOCKET_T to match header. -typedef unsigned long long SOCKET_T; - -#include "McpHttpServer.h" - -namespace ShaderLab -{ - McpHttpServer::~McpHttpServer() - { - Stop(); - } - - void McpHttpServer::AddRoute(const std::wstring& method, const std::wstring& pathPrefix, Handler handler) - { - m_routes.push_back({ method, pathPrefix, std::move(handler) }); - } - - void McpHttpServer::SetActivityCallback(ActivityCallback cb) - { - std::lock_guard lock(m_activityMutex); - m_activityCallback = std::move(cb); - } - - bool McpHttpServer::Start(uint16_t port) - { - if (m_running.load()) - return true; - - WSADATA wsaData{}; - if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0) - return false; - - m_thread = std::jthread([this, port](std::stop_token) { ListenerThread(port); }); - return true; - } - - void McpHttpServer::Stop() - { - m_running.store(false); - - // Close the listen socket to unblock accept(). - if (m_listenSock != ~0ULL) - { - closesocket(static_cast(m_listenSock)); - m_listenSock = ~0ULL; - } - - if (m_thread.joinable()) - m_thread.join(); - - WSACleanup(); - } - - void McpHttpServer::ListenerThread(uint16_t port) - { - SOCKET sock = INVALID_SOCKET; - - // Try up to 10 ports starting from the requested port. - for (uint16_t attempt = 0; attempt < 10; ++attempt) - { - uint16_t tryPort = port + attempt; - sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); - if (sock == INVALID_SOCKET) - { - OutputDebugStringW(L"[MCP] socket() failed\n"); - return; - } - - int yes = 1; - setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, reinterpret_cast(&yes), sizeof(yes)); - - sockaddr_in addr{}; - addr.sin_family = AF_INET; - addr.sin_port = htons(tryPort); - addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); - - if (bind(sock, reinterpret_cast(&addr), sizeof(addr)) == SOCKET_ERROR) - { - OutputDebugStringW(std::format(L"[MCP] bind() failed on port {}, trying next\n", tryPort).c_str()); - closesocket(sock); - sock = INVALID_SOCKET; - continue; - } - - if (listen(sock, SOMAXCONN) == SOCKET_ERROR) - { - OutputDebugStringW(std::format(L"[MCP] listen() failed on port {}\n", tryPort).c_str()); - closesocket(sock); - sock = INVALID_SOCKET; - continue; - } - - // Success! - m_port = tryPort; - OutputDebugStringW(std::format(L"[MCP] Listening on port {}\n", tryPort).c_str()); - break; - } - - if (sock == INVALID_SOCKET) - { - OutputDebugStringW(L"[MCP] Failed to bind to any port\n"); - return; - } - m_listenSock = static_cast(sock); - - OutputDebugStringW(std::format(L"[MCP] Listening on http://localhost:{}/\n", port).c_str()); - m_running.store(true); - - while (m_running.load()) - { - SOCKET client = accept(sock, nullptr, nullptr); - if (client == INVALID_SOCKET) - break; - - // Handle each connection synchronously (simple; sufficient for MCP bridge). - HandleConnection(static_cast(client)); - } - - m_running.store(false); - } - - void McpHttpServer::HandleConnection(SOCKET_T clientSock) - { - SOCKET s = static_cast(clientSock); - - try - { - // Read the full HTTP request (up to 64KB). - std::string raw; - raw.resize(65536); - int totalRead = 0; - - // Read until we have the complete headers + body. - while (totalRead < static_cast(raw.size()) - 1) - { - int n = recv(s, raw.data() + totalRead, static_cast(raw.size()) - totalRead - 1, 0); - if (n <= 0) - break; - totalRead += n; - - // Check if we have a complete HTTP request. - std::string_view sv(raw.data(), totalRead); - auto headerEnd = sv.find("\r\n\r\n"); - if (headerEnd != std::string_view::npos) - { - // Detect Transfer-Encoding: chunked (case-insensitive). - bool chunked = false; - { - std::string headersLower(sv.substr(0, headerEnd)); - for (auto& c : headersLower) c = static_cast(std::tolower(static_cast(c))); - auto tePos = headersLower.find("transfer-encoding:"); - if (tePos != std::string::npos) - { - auto teEnd = headersLower.find("\r\n", tePos); - if (teEnd != std::string::npos && - headersLower.find("chunked", tePos, teEnd - tePos) != std::string::npos) - { - chunked = true; - } - } - } - - if (chunked) - { - // Done when we've seen the terminating "0\r\n\r\n" chunk. - if (sv.find("\r\n0\r\n\r\n", headerEnd) != std::string_view::npos) - break; - continue; - } - - // Check Content-Length for body. - auto clPos = sv.find("Content-Length:"); - if (clPos == std::string_view::npos) - clPos = sv.find("content-length:"); - if (clPos != std::string_view::npos) - { - auto valStart = clPos + 15; - while (valStart < sv.size() && sv[valStart] == ' ') ++valStart; - auto valEnd = sv.find("\r\n", valStart); - int contentLen = std::atoi(std::string(sv.substr(valStart, valEnd - valStart)).c_str()); - int bodyStart = static_cast(headerEnd) + 4; - if (totalRead >= bodyStart + contentLen) - break; // Complete request. - } - else - { - break; // No body expected. - } - } - } - raw.resize(totalRead); - - // Parse method and path from the request line. - std::wstring method, path; - std::string body; - { - auto lineEnd = raw.find("\r\n"); - if (lineEnd == std::string::npos) - { - closesocket(s); - return; - } - std::string requestLine = raw.substr(0, lineEnd); - auto sp1 = requestLine.find(' '); - auto sp2 = requestLine.find(' ', sp1 + 1); - if (sp1 == std::string::npos || sp2 == std::string::npos) - { - closesocket(s); - return; - } - - std::string methodStr = requestLine.substr(0, sp1); - std::string pathStr = requestLine.substr(sp1 + 1, sp2 - sp1 - 1); - - method = std::wstring(methodStr.begin(), methodStr.end()); - path = std::wstring(pathStr.begin(), pathStr.end()); - - // Remove query string. - auto qpos = path.find(L'?'); - if (qpos != std::wstring::npos) - path = path.substr(0, qpos); - - // Extract body. - auto headerEnd = raw.find("\r\n\r\n"); - if (headerEnd != std::string::npos && headerEnd + 4 < raw.size()) - body = raw.substr(headerEnd + 4); - - // If chunked, decode chunks into the actual payload. - std::string headersBlock = (headerEnd != std::string::npos) ? raw.substr(0, headerEnd) : std::string{}; - std::string headersLower = headersBlock; - for (auto& c : headersLower) c = static_cast(std::tolower(static_cast(c))); - if (headersLower.find("transfer-encoding:") != std::string::npos && - headersLower.find("chunked") != std::string::npos) - { - std::string decoded; - size_t i = 0; - while (i < body.size()) - { - auto crlf = body.find("\r\n", i); - if (crlf == std::string::npos) break; - std::string sizeLine = body.substr(i, crlf - i); - // Strip optional chunk extensions after ';'. - auto semi = sizeLine.find(';'); - if (semi != std::string::npos) sizeLine.resize(semi); - size_t chunkSize = 0; - try { chunkSize = std::stoul(sizeLine, nullptr, 16); } - catch (...) { break; } - i = crlf + 2; - if (chunkSize == 0) break; - if (i + chunkSize > body.size()) break; - decoded.append(body, i, chunkSize); - i += chunkSize; - if (i + 2 <= body.size() && body[i] == '\r' && body[i + 1] == '\n') - i += 2; - } - body = std::move(decoded); - } - } - - // Handle CORS preflight. - if (method == L"OPTIONS") - { - std::string resp = - "HTTP/1.1 204 No Content\r\n" - "Access-Control-Allow-Origin: *\r\n" - "Access-Control-Allow-Methods: GET, POST, OPTIONS\r\n" - "Access-Control-Allow-Headers: Content-Type\r\n" - "Content-Length: 0\r\n" - "\r\n"; - send(s, resp.data(), static_cast(resp.size()), 0); - closesocket(s); - return; - } - - // Route and respond. - { - std::string mlog(method.begin(), method.end()); - std::string plog(path.begin(), path.end()); - OutputDebugStringA(("[MCP] " + mlog + " " + plog + " (body=" + std::to_string(body.size()) + "B)\n").c_str()); - // Log headers for diagnosing parse failures (one-shot, capped). - auto headerEnd = raw.find("\r\n\r\n"); - std::string headers = (headerEnd != std::string::npos) ? raw.substr(0, headerEnd) : raw; - if (headers.size() > 1024) headers.resize(1024); - OutputDebugStringA(("[MCP] headers:\n" + headers + "\n").c_str()); - } - Response resp = RouteRequest(method, path, body); - - // Notify the activity callback BEFORE we send the response so - // the caller's UI indicator pulses as close to the request edge - // as possible. Failure to format the peer address is non-fatal. - { - ActivityCallback cb; - { - std::lock_guard lock(m_activityMutex); - cb = m_activityCallback; - } - if (cb) - { - std::string peerStr; - sockaddr_in peer{}; - int peerLen = sizeof(peer); - if (getpeername(s, reinterpret_cast(&peer), &peerLen) == 0) - { - char ipBuf[64]{}; - inet_ntop(AF_INET, &peer.sin_addr, ipBuf, sizeof(ipBuf)); - peerStr = std::format("{}:{}", ipBuf, ntohs(peer.sin_port)); - } - std::string methodUtf8; - methodUtf8.reserve(method.size()); - for (wchar_t wc : method) - methodUtf8.push_back(static_cast(wc & 0x7F)); - try { cb(methodUtf8, path, resp.statusCode, peerStr); } - catch (...) { OutputDebugStringA("[MCP] activity callback threw\n"); } - } - } - - std::string statusText; - switch (resp.statusCode) - { - case 200: statusText = "OK"; break; - case 202: statusText = "Accepted"; break; - case 204: statusText = "No Content"; break; - case 400: statusText = "Bad Request"; break; - case 404: statusText = "Not Found"; break; - case 500: statusText = "Internal Server Error"; break; - default: statusText = "Error"; break; - } - std::string httpResp = std::format( - "HTTP/1.1 {} {}\r\n" - "Content-Type: {}\r\n" - "Content-Length: {}\r\n" - "Access-Control-Allow-Origin: *\r\n" - "Connection: close\r\n" - "\r\n", - resp.statusCode, statusText, resp.contentType, resp.body.size()); - httpResp += resp.body; - - send(s, httpResp.data(), static_cast(httpResp.size()), 0); - closesocket(s); - } - catch (const std::exception& ex) - { - OutputDebugStringA((std::string("[MCP] HandleConnection std::exception: ") + ex.what() + "\n").c_str()); - try { closesocket(s); } catch (...) {} - } - catch (...) - { - // Last-resort barrier: any escaping exception (winrt::hresult_error, - // SEH translation, etc.) tearing down the listener thread can crash - // the process. Swallow + log + close the socket. - OutputDebugStringW(L"[MCP] HandleConnection: unhandled non-standard exception\n"); - try { closesocket(s); } catch (...) {} - } - } - - McpHttpServer::Response McpHttpServer::RouteRequest( - const std::wstring& method, const std::wstring& path, const std::string& body) - { - const Route* bestRoute = nullptr; - size_t bestLen = 0; - - for (const auto& route : m_routes) - { - if (route.method != method) - continue; - if (path.starts_with(route.pathPrefix) && route.pathPrefix.size() > bestLen) - { - bestRoute = &route; - bestLen = route.pathPrefix.size(); - } - } - - if (bestRoute) - { - try - { - return bestRoute->handler(path, body); - } - catch (const std::exception& ex) - { - return { 500, std::string(R"({"error":")") + ex.what() + R"("})" }; - } - catch (...) - { - // winrt::hresult_error and SEH translations land here. The - // route handler must not allow these to escape because they - // would unwind off the listener thread and (with /EHa) can - // take the process with them. - return { 500, R"({"error":"Unhandled non-standard exception in route handler"})" }; - } - } - - return { 404, R"({"error":"Not found"})" }; - } -} diff --git a/Engine/Mcp/McpHttpServer.h b/Engine/Mcp/McpHttpServer.h deleted file mode 100644 index 9b7902a..0000000 --- a/Engine/Mcp/McpHttpServer.h +++ /dev/null @@ -1,82 +0,0 @@ -#pragma once - -#include "../../EngineExport.h" -#include -#include -#include -#include -#include -#include -#include - -// Forward-declare to avoid pulling winsock headers into every TU. -typedef unsigned long long SOCKET_T; - -namespace ShaderLab -{ - // Lightweight HTTP server using raw Winsock2 TCP sockets. - // Runs on a background thread; handlers must dispatch UI-thread work - // via DispatcherQueue themselves. - class SHADERLAB_API McpHttpServer - { - public: - struct Response - { - uint16_t statusCode{ 200 }; - std::string body; - std::string contentType{ "application/json" }; - }; - - using Handler = std::function; - - // Activity callback: invoked on the listener thread immediately after each - // request is routed (one call per HTTP request, regardless of route match). - // The callback MUST be cheap and thread-safe — UI updates should be deferred - // to the UI thread by the consumer. Arguments: - // method — HTTP verb (e.g. "GET", "POST") - // path — full request path (after query strip) - // statusCode — final HTTP status returned to the client - // peerAddress — "127.0.0.1:54321" style string for the remote endpoint - using ActivityCallback = std::function; - - McpHttpServer() = default; - ~McpHttpServer(); - - void AddRoute(const std::wstring& method, const std::wstring& pathPrefix, Handler handler); - bool Start(uint16_t port = 47808); - void Stop(); - bool IsRunning() const { return m_running.load(); } - uint16_t Port() const { return m_port; } - - // Register a callback invoked after every HTTP request the server handles. - // Set to nullptr to clear. Safe to call before or after Start(). - void SetActivityCallback(ActivityCallback cb); - - // Route a request programmatically (used by the MCP JSON-RPC handler). - Response RouteRequest(const std::wstring& method, const std::wstring& path, const std::string& body); - - private: - void ListenerThread(uint16_t port); - void HandleConnection(SOCKET_T clientSock); - - struct Route - { - std::wstring method; - std::wstring pathPrefix; - Handler handler; - }; - - std::vector m_routes; - SOCKET_T m_listenSock{ ~0ULL }; - std::jthread m_thread; - std::atomic m_running{ false }; - uint16_t m_port{ 0 }; - - std::mutex m_activityMutex; - ActivityCallback m_activityCallback; - }; -} diff --git a/Engine/Mcp/McpRouter.cpp b/Engine/Mcp/McpRouter.cpp new file mode 100644 index 0000000..7c14dc4 --- /dev/null +++ b/Engine/Mcp/McpRouter.cpp @@ -0,0 +1,107 @@ +#include "pch_engine.h" +#include "McpRouter.h" + +#include + +namespace ShaderLab +{ + void McpRouter::AddRoute(const std::wstring& method, const std::wstring& pathPrefix, Handler handler) + { + m_routes.push_back({ method, pathPrefix, std::move(handler) }); + } + + void McpRouter::SetActivityCallback(ActivityCallback cb) + { + std::lock_guard lock(m_activityMutex); + m_activityCallback = std::move(cb); + } + + const McpRouter::Route* McpRouter::FindRoute( + const std::wstring& method, const std::wstring& matchPath) const + { + const Route* bestRoute = nullptr; + size_t bestLen = 0; + for (const auto& route : m_routes) + { + if (route.method != method) + continue; + if (matchPath.starts_with(route.pathPrefix) && route.pathPrefix.size() > bestLen) + { + bestRoute = &route; + bestLen = route.pathPrefix.size(); + } + } + return bestRoute; + } + + bool McpRouter::HasRoute(const std::wstring& method, const std::wstring& path) const + { + auto qpos = path.find(L'?'); + return FindRoute(method, + qpos == std::wstring::npos ? path : path.substr(0, qpos)) != nullptr; + } + + bool McpRouter::HasSpecificRoute(const std::wstring& method, const std::wstring& path) const + { + auto qpos = path.find(L'?'); + const Route* best = FindRoute(method, + qpos == std::wstring::npos ? path : path.substr(0, qpos)); + return best && best->pathPrefix != L"/"; + } + + Mcp::Response McpRouter::RouteRequest( + const std::wstring& method, const std::wstring& path, const std::string& body) + { + // Split the query off: matching happens against the bare path, and + // the query reaches the handler as its own argument. + std::wstring matchPath = path; + std::wstring query; + if (auto qpos = path.find(L'?'); qpos != std::wstring::npos) + { + matchPath = path.substr(0, qpos); + query = path.substr(qpos + 1); + } + + Mcp::Response resp; + if (const Route* bestRoute = FindRoute(method, matchPath)) + { + try + { + resp = bestRoute->handler(matchPath, query, body); + } + catch (const std::exception& ex) + { + resp = { 500, std::string(R"({"error":")") + ex.what() + R"("})" }; + } + catch (...) + { + // winrt::hresult_error / SEH translations. A handler must + // never let these escape onto a transport thread. + resp = { 500, R"({"error":"Unhandled non-standard exception in route handler"})" }; + } + } + else + { + resp = { 404, R"({"error":"Not found"})" }; + } + + // Activity ping for the host's UI indicator — once per top-level + // dispatcher request (POST /), not per internal sub-route the + // dispatcher itself routes. clientId is a generic label now that + // there is no HTTP peer address. + if (method == L"POST" && matchPath == L"/") + { + ActivityCallback cb; + { + std::lock_guard lock(m_activityMutex); + cb = m_activityCallback; + } + if (cb) + { + try { cb("POST", L"/", resp.statusCode, "session"); } + catch (...) {} + } + } + return resp; + } +} diff --git a/Engine/Mcp/McpRouter.h b/Engine/Mcp/McpRouter.h new file mode 100644 index 0000000..97e121c --- /dev/null +++ b/Engine/Mcp/McpRouter.h @@ -0,0 +1,79 @@ +#pragma once + +#include "../../EngineExport.h" +#include "McpTypes.h" +#include +#include +#include +#include +#include + +namespace ShaderLab +{ + // MCP route registry. The embedded Winsock HTTP listener was removed in + // stdio-migration Step 9 (point of no return) — the ONLY transport now is + // the broker (shim → hub → session over named pipes; McpSessionClient + // routes each sealed request through RouteRequest). What remains is the + // pure routing surface every transport is written against: AddRoute, + // RouteRequest (longest-prefix, owns the query split), HasRoute. + class SHADERLAB_API McpRouter + { + public: + // Handler receives the query-stripped path, the raw query string + // (text after the first '?', empty if none), and the body. The + // router owns the split, so matching happens on the bare path and + // the query reaches the handler regardless of caller. + using Handler = std::function; + + // Fired once per top-level dispatcher request (POST /) so a host can + // pulse a UI activity indicator. `clientId` identifies the caller — + // a session/channel label now that HTTP peer addresses are gone + // (formerly `peerAddress`). Must be cheap + thread-safe. + using ActivityCallback = std::function; + + McpRouter() = default; + ~McpRouter() = default; + + void AddRoute(const std::wstring& method, const std::wstring& pathPrefix, Handler handler); + + // Register a callback invoked after each top-level POST / request. + // Set to nullptr to clear. Safe to call any time. + void SetActivityCallback(ActivityCallback cb); + + // Route a request. `path` may carry a query string; the router + // splits it before matching. Fires the activity callback when this + // is the top-level dispatcher call (POST /). + Mcp::Response RouteRequest(const std::wstring& method, const std::wstring& path, + const std::string& body); + + // True if any registered route would match (longest-prefix, query + // stripped). Catch-alls ("POST /") match every path of their method. + bool HasRoute(const std::wstring& method, const std::wstring& path) const; + + // Like HasRoute, but a bare "/" catch-all does not count — the + // dispatcher asks this before forwarding a tools/call so an absent + // backing route fails legibly instead of falling into POST /. + bool HasSpecificRoute(const std::wstring& method, const std::wstring& path) const; + + private: + struct Route + { + std::wstring method; + std::wstring pathPrefix; + Handler handler; + }; + + const Route* FindRoute(const std::wstring& method, const std::wstring& matchPath) const; + + std::vector m_routes; + std::mutex m_activityMutex; + ActivityCallback m_activityCallback; + }; +} diff --git a/EngineExport.h b/EngineExport.h index 2ecddb9..7bbf370 100644 --- a/EngineExport.h +++ b/EngineExport.h @@ -17,7 +17,16 @@ // // **History** (compatibility-breaking changes): // 1: Initial. Phase 6. -#define SHADERLAB_ENGINE_ABI_VERSION 1 +// 2: stdio-migration Step 2 — McpHttpServer renamed McpRouter, +// Response extracted to ShaderLab::Mcp::Response (McpTypes.h) with +// a noReply discriminator, Handler gains a query argument, +// IEngineCommandSink::Dispatch re-typed accordingly, EngineContext +// gains getPipelineFormatName. +// 3: stdio-migration Step 9 — the HTTP transport is deleted. McpRouter +// loses Start/Stop/Port/IsRunning + the Winsock listener (keeps +// AddRoute/RouteRequest/HasRoute); the broker (shim → hub → session) +// is the only transport. ActivityCallback's peer arg becomes clientId. +#define SHADERLAB_ENGINE_ABI_VERSION 3 // C-linkage entry so it can be GetProcAddress'd if a host wants to do a // version check before dynamically loading the DLL. diff --git a/MainWindow.McpRoutes.cpp b/MainWindow.McpRoutes.cpp index fb9c2a5..26ff4f6 100644 --- a/MainWindow.McpRoutes.cpp +++ b/MainWindow.McpRoutes.cpp @@ -1,6 +1,10 @@ #include "pch.h" #include "MainWindow.xaml.h" -#include "Engine/Mcp/McpHttpServer.h" +#include "Engine/Mcp/McpRouter.h" +#include "Engine/Mcp/McpJsonRpc.h" +#include "Engine/Mcp/McpTimeouts.h" +#include +#include #include "Effects/CustomPixelShaderEffect.h" #include "Effects/CustomComputeShaderEffect.h" #include "Effects/ShaderLabEffects.h" @@ -82,36 +86,184 @@ namespace winrt::ShaderLab::implementation // Move the lambda into a shared_ptr so it stays alive even if the // DispatcherQueue holds the callback longer than this scope. auto fnPtr = std::make_shared>(std::forward(fn)); - DispatcherQueue().TryEnqueue([state, fnPtr]() + // TryEnqueue returns false once the DispatcherQueue is shutting down + // (window closing). The old code DISCARDED that bool, so the event + // never fired and every in-flight request ate its full 30 s timeout + // during shutdown. Fail fast instead — this is the DispatchSync rung + // of the timeout ladder (Engine/Mcp/McpTimeouts.h). + if (!DispatcherQueue().TryEnqueue([state, fnPtr]() + { + try { state->result = (*fnPtr)(); } + catch (...) { state->ex = std::current_exception(); } + SetEvent(state->event); + })) { - try { state->result = (*fnPtr)(); } - catch (...) { state->ex = std::current_exception(); } - SetEvent(state->event); - }); + throw std::runtime_error("DispatchSync: UI dispatcher queue is shutting down"); + } - // 30s timeout -- generous for stats/readback paths but still a - // backstop against a wedged UI thread. - DWORD wait = WaitForSingleObject(state->event, 30000); + DWORD wait = WaitForSingleObject(state->event, + static_cast(::ShaderLab::Mcp::kDispatchSyncTimeout.count())); if (wait != WAIT_OBJECT_0) - throw std::runtime_error("DispatchSync: UI thread did not respond within 30s"); + throw std::runtime_error("DispatchSync: UI thread did not respond in time"); if (state->ex) std::rethrow_exception(state->ex); if (!state->result.has_value()) throw std::runtime_error("DispatchSync: lambda completed without producing a result"); return std::move(*state->result); } - ::ShaderLab::McpHttpServer::Response MainWindow::GuiEngineCommandSink::Dispatch( - std::function<::ShaderLab::McpHttpServer::Response( + // stdio-migration Step 8: the hub's AUMID for the client's shim to + // activate. Packaged: "!Hub". Unpackaged (dev): empty + // — no packaged hub to activate, so the shim only works against a hub + // that's already running (e.g. a deployed GUI, or a manual --hub). + std::wstring MainWindow::HubAumid() + { + UINT32 len = 0; + LONG rc = ::GetCurrentPackageFamilyName(&len, nullptr); + if (rc != ERROR_INSUFFICIENT_BUFFER) + return {}; // APPMODEL_ERROR_NO_PACKAGE -> unpackaged + std::wstring pfn(len, L'\0'); + if (::GetCurrentPackageFamilyName(&len, pfn.data()) != ERROR_SUCCESS) + return {}; + pfn.resize(len ? len - 1 : 0); // drop the null terminator + return pfn + L"!Hub"; + } + + // stdio-migration Step 8: copy the shim (ShaderLabMcpBroker.exe, next to + // ShaderLab.exe in the package payload) to a STABLE UNPACKAGED path, + // %LOCALAPPDATA%\ShaderLab\bin\. Being unpackaged, that copy is immune to + // MSIX update / uninstall — the MCP client keeps talking to it across a + // ShaderLab upgrade. Returns the target path (empty on failure). + // + // Rename-then-write: a running shim holds the file open, so overwrite-in- + // place fails. Renaming the existing copy aside works even while it runs + // (the process keeps its image), then the fresh binary lands at the + // canonical path for the NEXT client launch. Stale .old files are reaped + // best-effort (a still-running shim keeps its .old locked until it exits). + std::wstring MainWindow::EnsureShimDistributed() + { + wchar_t exePath[MAX_PATH * 2]{}; + if (::GetModuleFileNameW(nullptr, exePath, ARRAYSIZE(exePath)) == 0) + return {}; + std::wstring dir = exePath; + auto slash = dir.find_last_of(L'\\'); + if (slash == std::wstring::npos) return {}; + std::wstring source = dir.substr(0, slash) + L"\\ShaderLabMcpBroker.exe"; + if (::GetFileAttributesW(source.c_str()) == INVALID_FILE_ATTRIBUTES) + return {}; // no broker payload (unexpected in a real build) + + PWSTR local = nullptr; + if (FAILED(::SHGetKnownFolderPath(FOLDERID_LocalAppData, 0, nullptr, &local))) + return {}; + std::wstring binDir = std::wstring(local) + L"\\ShaderLab\\bin"; + ::CoTaskMemFree(local); + ::SHCreateDirectoryExW(nullptr, binDir.c_str(), nullptr); + std::wstring target = binDir + L"\\ShaderLabMcpBroker.exe"; + + if (::GetFileAttributesW(target.c_str()) != INVALID_FILE_ATTRIBUTES) + { + std::wstring aside = target + L"." + std::to_wstring(::GetTickCount64()) + L".old"; + ::MoveFileExW(target.c_str(), aside.c_str(), MOVEFILE_REPLACE_EXISTING); + } + ::CopyFileW(source.c_str(), target.c_str(), FALSE); + + // Reap stale .old copies whose shim has since exited. + WIN32_FIND_DATAW fd{}; + HANDLE h = ::FindFirstFileW((target + L".*.old").c_str(), &fd); + if (h != INVALID_HANDLE_VALUE) + { + do { ::DeleteFileW((binDir + L"\\" + fd.cFileName).c_str()); } + while (::FindNextFileW(h, &fd)); + ::FindClose(h); + } + return (::GetFileAttributesW(target.c_str()) != INVALID_FILE_ATTRIBUTES) + ? target : std::wstring{}; + } + + // stdio-migration Step 7: register this window with the broker hub as a + // session. The client serves each sealed request by routing through + // m_mcpServer -- the same router/dispatcher/engine routes the HTTP + // transport uses -- so tool calls marshal to the render worker and fire + // the 8 event hooks exactly like an HTTP request. Idempotent. + void MainWindow::StartMcpSession() + { + if (m_sessionClient || !m_mcpServer) + return; + + // Refresh the on-disk shim (rename-then-write) so a client launching + // it gets this build, while any shim an MCP client already has open + // keeps running (update-immune by design, stdio-migration Step 8). + EnsureShimDistributed(); + + if (m_mcpSessionId.empty()) + { + GUID g{}; + CoCreateGuid(&g); + wchar_t buf[64]{}; + StringFromGUID2(g, buf, ARRAYSIZE(buf)); + m_mcpSessionId = buf; // stable for this window's lifetime, not an ordinal + } + + ::ShaderLab::Mcp::SessionClientOptions opts; + opts.sessionId = m_mcpSessionId; + opts.label = std::format(L"ShaderLab {} (pid {})", + std::wstring(::ShaderLab::VersionString), GetCurrentProcessId()); + m_sessionClient = std::make_unique<::ShaderLab::Mcp::McpSessionClient>( + *m_mcpServer, std::move(opts)); + m_sessionThread = std::thread([this] { m_sessionClient->Run(); }); + } + + // Stop the session BEFORE the render dispatcher shuts down: reject-new -> + // (Run observes stop) -> close pipe (CancelIo equivalent) -> join. If we + // instead joined after m_renderDispatcher.Shutdown(), an in-flight + // session request could be mid-DispatchSync onto a worker that is already + // gone -- the 30 s stall the plan warns about. + void MainWindow::StopMcpSession() + { + if (m_sessionClient) + m_sessionClient->Stop(); + if (m_sessionThread.joinable()) + m_sessionThread.join(); + m_sessionClient.reset(); + } + + // Toolbar label. The toggle means "expose this window to MCP"; the label + // reflects whether this window is registered as a hub session. (Richer + // "session N of M / no hub" wording would need a hub round-trip on the UI + // tick; deferred.) + void MainWindow::UpdateMcpStatusLabel() + { + if (!McpServerLabel()) + return; + McpServerLabel().Text(m_sessionClient ? L"MCP: on" : L"MCP: off"); + } + + ::ShaderLab::Mcp::Response MainWindow::GuiEngineCommandSink::Dispatch( + std::function<::ShaderLab::Mcp::Response( ::ShaderLab::Mcp::EngineContext&)> closure) { + // While an adapter switch is in flight the render worker is joined + // and the device stack is being torn down + rebuilt, so there is no + // valid consumer or D2D context to marshal to. Fail with 503 rather + // than dispatching into a half-dead engine. A user clicking the GPU + // dropdown mid-request hits exactly this. + if (window->m_adapterSwitchInProgress.load(std::memory_order_acquire)) + { + ::ShaderLab::Mcp::Response busy; + busy.statusCode = 503; + busy.body = R"({"error":"GPU adapter switch in progress; retry shortly"})"; + return busy; + } + // Marshal the engine work to the render thread (single writer to // m_graph). Re-entrant calls from inside the consumer thread run - // inline (RenderThreadDispatcher detects this). - ::ShaderLab::McpHttpServer::Response resp; + // inline (RenderThreadDispatcher detects this). The render rung of + // the timeout ladder (Engine/Mcp/McpTimeouts.h) bounds the wait so a + // wedged closure surfaces before MainWindow::DispatchSync above it. + ::ShaderLab::Mcp::Response resp; try { resp = window->m_renderDispatcher.DispatchSync( - [this, &closure]() -> ::ShaderLab::McpHttpServer::Response { + [this, &closure]() -> ::ShaderLab::Mcp::Response { ::ShaderLab::Mcp::EngineContext ctx{}; ctx.graph = &window->m_graph; ctx.evaluator = &window->m_graphEvaluator; @@ -125,6 +277,9 @@ namespace winrt::ShaderLab::implementation ctx.d3dContext = window->m_renderEngine.D3DContext(); ctx.renderFrame = [this]() { window->RenderFrameToOffscreen(0.0); }; ctx.getPreviewNodeId = [this]() -> uint32_t { return window->m_previewNodeId; }; + ctx.getPipelineFormatName = [this]() -> std::wstring { + return std::wstring(window->m_renderEngine.ActiveFormat().name); + }; ctx.getLoadedIccProfile = [this]() -> std::optional<::ShaderLab::Rendering::DisplayProfile> { return window->m_loadedIccProfile; }; @@ -132,11 +287,12 @@ namespace winrt::ShaderLab::implementation window->m_loadedIccProfile = p; }; return closure(ctx); - }); + }, + ::ShaderLab::Mcp::kRenderClosureTimeout); } catch (const std::exception& e) { - ::ShaderLab::McpHttpServer::Response err; + ::ShaderLab::Mcp::Response err; err.statusCode = 500; err.body = std::string(R"({"error":")") + e.what() + R"("})"; err.contentType = "application/json"; @@ -298,7 +454,7 @@ namespace winrt::ShaderLab::implementation void MainWindow::SetupMcpRoutes() { if (!m_mcpServer) - m_mcpServer = std::make_unique<::ShaderLab::McpHttpServer>(); + m_mcpServer = std::make_unique<::ShaderLab::McpRouter>(); if (!m_engineSink) m_engineSink = std::make_unique(this); @@ -337,22 +493,22 @@ namespace winrt::ShaderLab::implementation ::ShaderLab::Mcp::RegisterEngineRoutes(*m_mcpServer, *m_engineSink); // ===================================================================== - // GET / — Health check / probe (some MCP clients GET / before POST). + // GET / (health) + POST / (JSON-RPC dispatcher) — engine-provided + // (stdio-migration Step 3). RegisterJsonRpcEndpoint installs both on + // this router; the dispatcher forwards tools/call into the routes + // registered here via the declarative Engine/Mcp/McpToolCatalog. // ===================================================================== - m_mcpServer->AddRoute(L"GET", L"/", [](const std::wstring& path, const std::string&) - -> ::ShaderLab::McpHttpServer::Response { - // Only match exact "/" — longer GET paths fall through to other routes. - if (path != L"/") - return { 404, R"({"error":"Not found"})" }; - return { 200, R"({"name":"shaderlab","transport":"streamable-http","endpoint":"POST /"})" }; - }); + ::ShaderLab::Mcp::JsonRpcOptions rpcOptions; + rpcOptions.hostKind = "gui"; + ::ShaderLab::Mcp::RegisterJsonRpcEndpoint(*m_mcpServer, std::move(rpcOptions)); + } // ===================================================================== // GET /context — System prompt / onboarding for calling agents // ===================================================================== - m_mcpServer->AddRoute(L"GET", L"/context", [](const std::wstring&, const std::string&) - -> ::ShaderLab::McpHttpServer::Response + m_mcpServer->AddRoute(L"GET", L"/context", [](const std::wstring&, const std::wstring&, const std::string&) + -> ::ShaderLab::Mcp::Response { std::string doc = R"JSON({ "name": "ShaderLab", @@ -382,7 +538,7 @@ namespace winrt::ShaderLab::implementation "customExample": "Gamut analysis: Output[0,0].x = maxLuminance (float), Output[1,0] = gamutBounds (float4), etc." }, "nodeTypes": ["Source", "BuiltInEffect", "PixelShader", "ComputeShader", "Output"], -"outputNote": "PNG captures are tone-mapped SDR. Use /render/pixel/X/Y for true scRGB float values. Values above 1.0 are HDR.", +"outputNote": "PNG captures are tone-mapped SDR. Use POST /render/pixel-region for true scRGB float values. Values above 1.0 are HDR.", "endpoints": { "GET /context": "This document", "GET /graph": "Full graph state with nodes, edges, properties, custom effects", @@ -390,7 +546,7 @@ namespace winrt::ShaderLab::implementation "GET /registry/effects": "All built-in D2D effects", "GET /custom-effects": "All custom effects in graph with HLSL source", "GET /render/capture": "Output as base64 PNG, SDR tone-mapped", - "GET /render/pixel/{x}/{y}": "scRGB float4 plus luminance at coordinates", + "POST /render/pixel-region": "FP32 scRGB region readback, body: nodeId x y w h", "POST /graph/add-node": "Add a node, body: effectName string", "POST /graph/remove-node": "Remove node, body: nodeId number", "POST /graph/connect": "Connect pins, body: srcId srcPin dstId dstPin", @@ -463,14 +619,14 @@ namespace winrt::ShaderLab::implementation // ===================================================================== // POST /render/preview-node // ===================================================================== - m_mcpServer->AddRoute(L"POST", L"/render/preview-node", [this](const std::wstring&, const std::string& body) - -> ::ShaderLab::McpHttpServer::Response + m_mcpServer->AddRoute(L"POST", L"/render/preview-node", [this](const std::wstring&, const std::wstring&, const std::string& body) + -> ::ShaderLab::Mcp::Response { try { auto jobj = winrt::Windows::Data::Json::JsonObject::Parse(winrt::to_hstring(body)); uint32_t nodeId = static_cast(jobj.GetNamedNumber(L"nodeId")); - return DispatchSync([&]() -> ::ShaderLab::McpHttpServer::Response { + return DispatchSync([&]() -> ::ShaderLab::Mcp::Response { m_previewNodeId = nodeId; m_needsFitPreview = true; m_forceRender = true; @@ -490,53 +646,66 @@ namespace winrt::ShaderLab::implementation // ===================================================================== // ===================================================================== - // GET /render/pixel/{x}/{y} + // GET /render/pixel/{x}/{y} -- REMOVED (stdio-migration Step 1). + // Was a "coming soon" stub that touched the UI D2D context on the + // listener thread with no dispatch and std::stof'd unvalidated + // input. True pixel readback is POST /render/pixel-region (engine + // route); an unmatched GET path now 404s from the router. // ===================================================================== - m_mcpServer->AddRoute(L"GET", L"/render/pixel/", [this](const std::wstring& path, const std::string&) - -> ::ShaderLab::McpHttpServer::Response - { - // Parse /render/pixel/{x}/{y} - auto rest = path.substr(14); // after "/render/pixel/" - auto slash = rest.find(L'/'); - if (slash == std::wstring::npos) - return { 400, R"({"error":"Format: /render/pixel/{x}/{y}"})" }; - - float x = std::stof(rest.substr(0, slash)); - float y = std::stof(rest.substr(slash + 1)); - - auto* dc = m_renderEngine.D2DDeviceContext(); - if (!dc) return { 500, R"({"error":"No device context"})" }; - - auto* image = ResolveDisplayImage(m_previewNodeId); - if (!image) return { 404, R"({"error":"No output image"})" }; - - // Read pixel value using a 1x1 bitmap copy. - D2D1_POINT_2U srcPoint = { static_cast(x), static_cast(y) }; - D2D1_BITMAP_PROPERTIES1 bmpProps = {}; - bmpProps.pixelFormat = { DXGI_FORMAT_R32G32B32A32_FLOAT, D2D1_ALPHA_MODE_PREMULTIPLIED }; - bmpProps.bitmapOptions = D2D1_BITMAP_OPTIONS_CPU_READ | D2D1_BITMAP_OPTIONS_CANNOT_DRAW; - - winrt::com_ptr readBitmap; - HRESULT hr = dc->CreateBitmap(D2D1::SizeU(1, 1), nullptr, 0, bmpProps, readBitmap.put()); - if (FAILED(hr)) return { 500, R"({"error":"Failed to create read bitmap"})" }; - D2D1_RECT_U srcRect = { srcPoint.x, srcPoint.y, srcPoint.x + 1, srcPoint.y + 1 }; - // Need to render the image to a target first, then copy. - // For simplicity, report from the cached pixel inspector logic. - // TODO: implement proper pixel readback + // ===================================================================== + // POST /graph/rename-node + // ===================================================================== + // Promoted from the inline `graph_rename_node` tools/call handler + // (stdio-migration Step 1). Stays app-side: the rename must refresh + // XAML surfaces (preview selector + Add Node flyout) and + // IEngineCommandSink has no rename hook -- adding one is an engine + // ABI change, deferred until Step 2 bumps the ABI anyway. + // Threading: mutation + RebuildLayout run on the render thread + // (layout's home per the NodeGraphController rules); the XAML + // refresh is TryEnqueue'd to the UI thread fire-and-forget, so a + // busy UI can no longer turn a committed rename into a 500. + m_mcpServer->AddRoute(L"POST", L"/graph/rename-node", [this](const std::wstring&, const std::wstring&, const std::string& body) + -> ::ShaderLab::Mcp::Response + { + uint32_t nodeId = 0; + std::wstring newName; + try + { + auto jobj = winrt::Windows::Data::Json::JsonObject::Parse(winrt::to_hstring(body)); + nodeId = static_cast(jobj.GetNamedNumber(L"nodeId")); + newName = std::wstring(jobj.GetNamedString(L"name")); + } + catch (...) { return { 400, R"({"error":"Invalid request: need nodeId + name"})" }; } + + auto resp = m_renderDispatcher.DispatchSync( + [this, nodeId, &newName]() -> ::ShaderLab::Mcp::Response { + auto* node = m_graph.FindNode(nodeId); + if (!node) return { 404, R"({"error":"Node not found"})" }; + node->name = newName; + m_nodeGraphController.RebuildLayout(); + return { 200, R"({"ok":true})" }; + }); - return { 200, std::format("{{\"x\":{:.0f},\"y\":{:.0f},\"note\":\"Pixel readback via MCP coming soon\"}}", x, y) }; + if (resp.statusCode == 200) + { + DispatcherQueue().TryEnqueue([this]() { + PopulatePreviewNodeSelector(); + PopulateAddNodeFlyout(); + }); + } + return resp; }); // ===================================================================== // ===================================================================== // GET /render/capture -- Save output PNG to temp file, return path // ===================================================================== - m_mcpServer->AddRoute(L"GET", L"/render/capture", [this](const std::wstring&, const std::string&) - -> ::ShaderLab::McpHttpServer::Response + m_mcpServer->AddRoute(L"GET", L"/render/capture", [this](const std::wstring&, const std::wstring&, const std::string&) + -> ::ShaderLab::Mcp::Response { return m_renderDispatcher.DispatchSync( - [this]() -> ::ShaderLab::McpHttpServer::Response { + [this]() -> ::ShaderLab::Mcp::Response { // Run on render thread (single writer to graph + owns the // engine D2D context). Force a full re-evaluation so the // capture reflects current state. @@ -558,10 +727,7 @@ namespace winrt::ShaderLab::implementation WriteFile(hFile, pngData.data(), static_cast(pngData.size()), &written, nullptr); CloseHandle(hFile); - auto pathUtf8 = ToUtf8(filePath); - // Escape backslashes for JSON. - std::string escaped; - for (char c : pathUtf8) { if (c == '\\') escaped += "\\\\"; else escaped += c; } + auto escaped = ::ShaderLab::Mcp::JsonEscape(ToUtf8(filePath)); return { 200, std::format("{{\"path\":\"{}\",\"size\":{}}}", escaped, pngData.size()) }; }); @@ -570,8 +736,8 @@ namespace winrt::ShaderLab::implementation // ===================================================================== // GET /perf — Return per-frame performance timings // ===================================================================== - m_mcpServer->AddRoute(L"GET", L"/perf", [this](const std::wstring&, const std::string&) - -> ::ShaderLab::McpHttpServer::Response + m_mcpServer->AddRoute(L"GET", L"/perf", [this](const std::wstring&, const std::wstring&, const std::string&) + -> ::ShaderLab::Mcp::Response { auto& t = m_lastFrameTiming; if (t.framesSampled == 0) @@ -594,13 +760,22 @@ namespace winrt::ShaderLab::implementation t.framesSampled, t.endDrawFailed) }; }); + // ===================================================================== + // GET /display/info -- moved to Engine/Mcp/EngineMcpRoutes.cpp + // (stdio-migration Step 2). Step 1 parked it here because it needs + // the pipeline-format name; EngineContext::getPipelineFormatName + // (supplied by GuiEngineCommandSink::Dispatch from + // RenderEngine::ActiveFormat()) closed that gap, so it is now + // engine-pure and headless serves it too. + // ===================================================================== + // ===================================================================== // GET /node/{id}/logs — Return per-node log entries // ===================================================================== - m_mcpServer->AddRoute(L"GET", L"/node/", [this](const std::wstring& path, const std::string&) - -> ::ShaderLab::McpHttpServer::Response + m_mcpServer->AddRoute(L"GET", L"/node/", [this](const std::wstring& path, const std::wstring& query, const std::string&) + -> ::ShaderLab::Mcp::Response { - return DispatchSync([&]() -> ::ShaderLab::McpHttpServer::Response { + return DispatchSync([&]() -> ::ShaderLab::Mcp::Response { // Parse nodeId and optional /logs suffix from path. // Expected: /node/{id}/logs or /node/{id}/logs?since={seq} auto stripped = path.substr(6); // remove "/node/" @@ -614,12 +789,16 @@ namespace winrt::ShaderLab::implementation return { 200, R"({"logs":[]})" }; auto& log = it->second; - // Check for ?since= parameter. + // ?since= arrives via the query argument (stdio-migration + // Step 2). Previously the HTTP listener stripped the query + // before routing, so raw-HTTP polls silently returned the + // whole log every time; only RouteRequest callers that + // embedded "?since=" in the path string got filtering. uint64_t sinceSeq = 0; - auto qPos = path.find(L"since="); + auto qPos = query.find(L"since="); if (qPos != std::wstring::npos) { - try { sinceSeq = std::stoull(path.substr(qPos + 6)); } catch (...) {} + try { sinceSeq = std::stoull(query.substr(qPos + 6)); } catch (...) {} } std::string json = "{\"logs\":["; @@ -643,19 +822,9 @@ namespace winrt::ShaderLab::implementation if (entry.level == ::ShaderLab::Controls::LogLevel::Warning) levelStr = "Warning"; else if (entry.level == ::ShaderLab::Controls::LogLevel::Error) levelStr = "Error"; - // JSON-escape the message. - std::string msg = ToUtf8(entry.message); - std::string escaped; - for (char c : msg) { - if (c == '"') escaped += "\\\""; - else if (c == '\\') escaped += "\\\\"; - else if (c == '\n') escaped += "\\n"; - else if (c == '\r') escaped += "\\r"; - else if (c == '\t') escaped += "\\t"; - else if (static_cast(c) < 0x20) - escaped += std::format("\\u{:04x}", static_cast(c)); - else escaped += c; - } + // Shared escaper (stdio-migration Step 3 unified the + // previously-divergent copies). + std::string escaped = ::ShaderLab::Mcp::JsonEscape(ToUtf8(entry.message)); json += std::format("{{\"seq\":{},\"time\":\"{}\",\"level\":\"{}\",\"message\":\"{}\"}}", entry.sequence, timeBuf, levelStr, escaped); @@ -680,8 +849,8 @@ namespace winrt::ShaderLab::implementation // ===================================================================== // POST /render/pixel-trace — Run pixel trace at normalized coordinates // ===================================================================== - m_mcpServer->AddRoute(L"POST", L"/render/pixel-trace", [this](const std::wstring&, const std::string& body) - -> ::ShaderLab::McpHttpServer::Response + m_mcpServer->AddRoute(L"POST", L"/render/pixel-trace", [this](const std::wstring&, const std::wstring&, const std::string& body) + -> ::ShaderLab::Mcp::Response { try { @@ -690,7 +859,7 @@ namespace winrt::ShaderLab::implementation float normX = static_cast(jobj.GetNamedNumber(L"x")); float normY = static_cast(jobj.GetNamedNumber(L"y")); - return m_renderDispatcher.DispatchSync([&]() -> ::ShaderLab::McpHttpServer::Response { + return m_renderDispatcher.DispatchSync([&]() -> ::ShaderLab::Mcp::Response { // P13: pixel-trace runs on the render thread. m_graph is // single-writer there; the render-side D2D context shares // the engine's multi-threaded D2D device with the worker's @@ -832,10 +1001,10 @@ namespace winrt::ShaderLab::implementation // Captures the live node-graph view at the swap-chain panel size. // Always writes PNG to a unique %TEMP% file. When inline=true, also // returns base64-encoded bytes in the response. - m_mcpServer->AddRoute(L"POST", L"/graph/snapshot", [this](const std::wstring&, const std::string& body) - -> ::ShaderLab::McpHttpServer::Response + m_mcpServer->AddRoute(L"POST", L"/graph/snapshot", [this](const std::wstring&, const std::wstring&, const std::string& body) + -> ::ShaderLab::Mcp::Response { - return DispatchSync([&]() -> ::ShaderLab::McpHttpServer::Response { + return DispatchSync([&]() -> ::ShaderLab::Mcp::Response { bool wantInline = false; if (!body.empty()) { @@ -891,10 +1060,10 @@ namespace winrt::ShaderLab::implementation }); // GET /graph/view — current pan/zoom + viewport + content bounds - m_mcpServer->AddRoute(L"GET", L"/graph/view", [this](const std::wstring&, const std::string&) - -> ::ShaderLab::McpHttpServer::Response + m_mcpServer->AddRoute(L"GET", L"/graph/view", [this](const std::wstring&, const std::wstring&, const std::string&) + -> ::ShaderLab::Mcp::Response { - return DispatchSync([&]() -> ::ShaderLab::McpHttpServer::Response { + return DispatchSync([&]() -> ::ShaderLab::Mcp::Response { m_nodeGraphController.RebuildLayout(); auto pan = m_nodeGraphController.PanOffset(); float zoom = m_nodeGraphController.Zoom(); @@ -914,10 +1083,10 @@ namespace winrt::ShaderLab::implementation }); // POST /graph/view — body: { zoom?, panX?, panY? } - m_mcpServer->AddRoute(L"POST", L"/graph/view", [this](const std::wstring&, const std::string& body) - -> ::ShaderLab::McpHttpServer::Response + m_mcpServer->AddRoute(L"POST", L"/graph/view", [this](const std::wstring&, const std::wstring&, const std::string& body) + -> ::ShaderLab::Mcp::Response { - return DispatchSync([&]() -> ::ShaderLab::McpHttpServer::Response { + return DispatchSync([&]() -> ::ShaderLab::Mcp::Response { winrt::Windows::Data::Json::JsonObject jo{ nullptr }; if (!winrt::Windows::Data::Json::JsonObject::TryParse(winrt::to_hstring(body), jo)) return { 400, R"({"error":"Invalid JSON body"})" }; @@ -964,10 +1133,10 @@ namespace winrt::ShaderLab::implementation }); // POST /graph/view/fit — body: { padding?:number (DIPs, default 40) } - m_mcpServer->AddRoute(L"POST", L"/graph/view/fit", [this](const std::wstring&, const std::string& body) - -> ::ShaderLab::McpHttpServer::Response + m_mcpServer->AddRoute(L"POST", L"/graph/view/fit", [this](const std::wstring&, const std::wstring&, const std::string& body) + -> ::ShaderLab::Mcp::Response { - return DispatchSync([&]() -> ::ShaderLab::McpHttpServer::Response { + return DispatchSync([&]() -> ::ShaderLab::Mcp::Response { float padding = 40.0f; if (!body.empty()) { @@ -994,10 +1163,10 @@ namespace winrt::ShaderLab::implementation // ===================================================================== // GET /gpu/list — Enumerate GPU adapters + identify the active one // ===================================================================== - m_mcpServer->AddRoute(L"GET", L"/gpu/list", [this](const std::wstring&, const std::string&) - -> ::ShaderLab::McpHttpServer::Response + m_mcpServer->AddRoute(L"GET", L"/gpu/list", [this](const std::wstring&, const std::wstring&, const std::string&) + -> ::ShaderLab::Mcp::Response { - return DispatchSync([&]() -> ::ShaderLab::McpHttpServer::Response { + return DispatchSync([&]() -> ::ShaderLab::Mcp::Response { auto adapters = ::ShaderLab::Rendering::RenderEngine::EnumerateAdapters(); std::string json = "{\"active\":{"; json += "\"name\":\"" + ToUtf8(m_renderEngine.AdapterName()) + "\""; @@ -1039,10 +1208,10 @@ namespace winrt::ShaderLab::implementation // SwitchAdapter sequence: graph save -> device teardown -> new // device init -> graph reload. // ===================================================================== - m_mcpServer->AddRoute(L"POST", L"/gpu/switch", [this](const std::wstring&, const std::string& body) - -> ::ShaderLab::McpHttpServer::Response + m_mcpServer->AddRoute(L"POST", L"/gpu/switch", [this](const std::wstring&, const std::wstring&, const std::string& body) + -> ::ShaderLab::Mcp::Response { - return DispatchSync([&]() -> ::ShaderLab::McpHttpServer::Response { + return DispatchSync([&]() -> ::ShaderLab::Mcp::Response { using namespace ::ShaderLab::Rendering; namespace WDJ = winrt::Windows::Data::Json; WDJ::JsonObject jobj{ nullptr }; @@ -1143,10 +1312,10 @@ namespace winrt::ShaderLab::implementation // ===================================================================== // GET /preview/view — Current preview pan/zoom + image bounds // ===================================================================== - m_mcpServer->AddRoute(L"GET", L"/preview/view", [this](const std::wstring&, const std::string&) - -> ::ShaderLab::McpHttpServer::Response + m_mcpServer->AddRoute(L"GET", L"/preview/view", [this](const std::wstring&, const std::wstring&, const std::string&) + -> ::ShaderLab::Mcp::Response { - return DispatchSync([&]() -> ::ShaderLab::McpHttpServer::Response { + return DispatchSync([&]() -> ::ShaderLab::Mcp::Response { auto bounds = GetPreviewImageBounds(); float imgW = (std::max)(0.0f, bounds.right - bounds.left); float imgH = (std::max)(0.0f, bounds.bottom - bounds.top); @@ -1163,10 +1332,10 @@ namespace winrt::ShaderLab::implementation // POST /preview/view — Set preview pan/zoom (any subset). // Body: { zoom?, panX?, panY? } zoom clamped to [0.01, 100.0] // ===================================================================== - m_mcpServer->AddRoute(L"POST", L"/preview/view", [this](const std::wstring&, const std::string& body) - -> ::ShaderLab::McpHttpServer::Response + m_mcpServer->AddRoute(L"POST", L"/preview/view", [this](const std::wstring&, const std::wstring&, const std::string& body) + -> ::ShaderLab::Mcp::Response { - return DispatchSync([&]() -> ::ShaderLab::McpHttpServer::Response { + return DispatchSync([&]() -> ::ShaderLab::Mcp::Response { namespace WDJ = winrt::Windows::Data::Json; WDJ::JsonObject jo{ nullptr }; if (!WDJ::JsonObject::TryParse(winrt::to_hstring(body), jo)) @@ -1211,10 +1380,10 @@ namespace winrt::ShaderLab::implementation // ===================================================================== // POST /preview/view/fit — Fit preview image to viewport. // ===================================================================== - m_mcpServer->AddRoute(L"POST", L"/preview/view/fit", [this](const std::wstring&, const std::string&) - -> ::ShaderLab::McpHttpServer::Response + m_mcpServer->AddRoute(L"POST", L"/preview/view/fit", [this](const std::wstring&, const std::wstring&, const std::string&) + -> ::ShaderLab::Mcp::Response { - return DispatchSync([&]() -> ::ShaderLab::McpHttpServer::Response { + return DispatchSync([&]() -> ::ShaderLab::Mcp::Response { FitPreviewToView(); m_forceRender = true; return { 200, std::format( @@ -1227,492 +1396,11 @@ namespace winrt::ShaderLab::implementation // GET /effect/hlsl/{nodeId} -- moved to Engine/Mcp/EngineMcpRoutes.cpp // ===================================================================== // ===================================================================== - // POST / — MCP JSON-RPC 2.0 endpoint (Streamable HTTP transport) + // POST / (JSON-RPC dispatcher) + GET / (health) -- moved to + // Engine/Mcp/McpJsonRpc.cpp (stdio-migration Step 3). The GUI + // registers the shared endpoint via RegisterJsonRpcEndpoint in + // SetupMcpRoutes above; the tool catalog lives in + // Engine/Mcp/McpToolCatalog.cpp. // ===================================================================== - m_mcpServer->AddRoute(L"POST", L"/", [this](const std::wstring&, const std::string& body) - -> ::ShaderLab::McpHttpServer::Response - { - try - { - { - std::string preview = body.size() > 512 ? body.substr(0, 512) + "..." : body; - OutputDebugStringA(("[MCP] POST / body: " + preview + "\n").c_str()); - } - winrt::Windows::Data::Json::JsonObject jobj{ nullptr }; - if (!winrt::Windows::Data::Json::JsonObject::TryParse(winrt::to_hstring(body), jobj)) - { - OutputDebugStringA(("[MCP] POST / parse error. bodySize=" + std::to_string(body.size()) - + " raw=[" + body + "]\n").c_str()); - return { 200, R"({"jsonrpc":"2.0","id":null,"error":{"code":-32700,"message":"Parse error"}})" }; - } - if (!jobj.HasKey(L"method")) - { - OutputDebugStringA("[MCP] POST / missing method field\n"); - return { 200, R"({"jsonrpc":"2.0","id":null,"error":{"code":-32600,"message":"Invalid Request"}})" }; - } - auto method = ToUtf8(std::wstring(jobj.GetNamedString(L"method"))); - OutputDebugStringA(("[MCP] method=" + method + "\n").c_str()); - auto id = jobj.HasKey(L"id") ? jobj.GetNamedValue(L"id") : winrt::Windows::Data::Json::JsonValue::CreateNullValue(); - std::string idStr; - if (id.ValueType() == winrt::Windows::Data::Json::JsonValueType::Number) - idStr = std::format("{}", static_cast(id.GetNumber())); - else if (id.ValueType() == winrt::Windows::Data::Json::JsonValueType::String) - idStr = "\"" + ToUtf8(std::wstring(id.GetString())) + "\""; - else - idStr = "null"; - - auto wrapResult = [&](const std::string& result) -> std::string { - return std::format(R"JSON({{"jsonrpc":"2.0","id":{},"result":{}}})JSON", idStr, result); - }; - - // ---- initialize ---- - if (method == "initialize") - { - auto verStr = ToUtf8(std::wstring(::ShaderLab::VersionString)); - std::string result = R"JSON({ -"protocolVersion": "2024-11-05", -"capabilities": { - "tools": {}, - "resources": {} -}, -"serverInfo": { - "name": "shaderlab", - "version": ")JSON" + verStr + R"JSON(" -} -})JSON"; - return { 200, wrapResult(result) }; - } - - // ---- notifications/initialized (no response needed but we ack) ---- - if (method == "notifications/initialized") - { - return { 202, "" }; - } - // Any other notification (no id, method starts with "notifications/") - if (method.rfind("notifications/", 0) == 0) - { - return { 202, "" }; - } - - // ---- tools/list ---- - if (method == "tools/list") - { - std::string tools = R"JSON({"tools":[ -{"name":"graph_add_node","description":"Add a node. Use effectName for built-in/ShaderLab effects. For sources use effectName='Video' or 'Image' with optional filePath.","inputSchema":{"type":"object","properties":{"effectName":{"type":"string","description":"Effect name, or 'Video'/'Image' for source nodes"},"filePath":{"type":"string","description":"File path for Video/Image source nodes (optional)"}},"required":["effectName"]}}, -{"name":"graph_remove_node","description":"Remove a node by ID","inputSchema":{"type":"object","properties":{"nodeId":{"type":"number"}},"required":["nodeId"]}}, -{"name":"graph_connect","description":"Connect output pin to input pin","inputSchema":{"type":"object","properties":{"srcId":{"type":"number"},"srcPin":{"type":"number"},"dstId":{"type":"number"},"dstPin":{"type":"number"}},"required":["srcId","srcPin","dstId","dstPin"]}}, -{"name":"graph_disconnect","description":"Disconnect an edge","inputSchema":{"type":"object","properties":{"srcId":{"type":"number"},"srcPin":{"type":"number"},"dstId":{"type":"number"},"dstPin":{"type":"number"}},"required":["srcId","srcPin","dstId","dstPin"]}}, -{"name":"graph_set_property","description":"Set a node property. Value can be number, bool, string, or array for vectors.","inputSchema":{"type":"object","properties":{"nodeId":{"type":"number"},"key":{"type":"string"},"value":{}},"required":["nodeId","key","value"]}}, -{"name":"graph_get_node","description":"Get detailed info about a node","inputSchema":{"type":"object","properties":{"nodeId":{"type":"number"}},"required":["nodeId"]}}, -{"name":"graph_save_json","description":"Serialize graph to JSON","inputSchema":{"type":"object","properties":{}}}, -{"name":"graph_load_json","description":"Load graph from JSON string","inputSchema":{"type":"object","properties":{"json":{"type":"string"}},"required":["json"]}}, -{"name":"graph_clear","description":"Clear the graph","inputSchema":{"type":"object","properties":{}}}, -{"name":"graph_apply","description":"Apply a graph patch in one call: add nodes (using client refs), connect edges, set property bindings. Call /graph/clear first if you need a fresh graph. Body: { nodes:[{ref,effect,filePath?,properties?}], edges:[{from,to,fromPin?,toPin?}], bindings:[{node,property,from:'ref.field'|{node,field},component?}] }. 'from' and 'to' accept either a ref string or numeric nodeId. Returns refToId map + nodeIds in add order.","inputSchema":{"type":"object","properties":{"nodes":{"type":"array"},"edges":{"type":"array"},"bindings":{"type":"array"}}}}, -{"name":"effect_compile","description":"Compile HLSL for a custom effect node","inputSchema":{"type":"object","properties":{"nodeId":{"type":"number"},"hlsl":{"type":"string"}},"required":["nodeId","hlsl"]}}, -{"name":"set_preview_node","description":"Set which node is previewed","inputSchema":{"type":"object","properties":{"nodeId":{"type":"number"}},"required":["nodeId"]}}, -{"name":"render_capture","description":"Capture preview as PNG. Note: HDR values clipped to SDR.","inputSchema":{"type":"object","properties":{}}}, -{"name":"perf_timings","description":"Get per-frame performance timings (ms) for render pipeline phases","inputSchema":{"type":"object","properties":{}}}, -{"name":"node_logs","description":"Get per-node log entries (timestamped info/warning/error). Use sinceSeq for incremental reads.","inputSchema":{"type":"object","properties":{"nodeId":{"type":"number"},"sinceSeq":{"type":"number","description":"Only return entries after this sequence number"}},"required":["nodeId"]}}, -{"name":"registry_get_effect","description":"Get metadata for a built-in effect","inputSchema":{"type":"object","properties":{"name":{"type":"string"}},"required":["name"]}}, -{"name":"graph_bind_property","description":"Bind a node property to an upstream analysis output field","inputSchema":{"type":"object","properties":{"nodeId":{"type":"number"},"propertyName":{"type":"string"},"sourceNodeId":{"type":"number"},"sourceFieldName":{"type":"string"},"sourceComponent":{"type":"number","description":"0-3 for .xyzw component (scalar dest only)"}},"required":["nodeId","propertyName","sourceNodeId","sourceFieldName"]}}, -{"name":"graph_unbind_property","description":"Remove a property binding","inputSchema":{"type":"object","properties":{"nodeId":{"type":"number"},"propertyName":{"type":"string"}},"required":["nodeId","propertyName"]}}, -{"name":"read_analysis_output","description":"Read typed analysis output fields from a compute/analysis node","inputSchema":{"type":"object","properties":{"nodeId":{"type":"number"}},"required":["nodeId"]}}, -{"name":"read_pixel_trace","description":"Run pixel trace at normalized coordinates, returns per-node pixel values and analysis outputs","inputSchema":{"type":"object","properties":{"nodeId":{"type":"number"},"x":{"type":"number","description":"Normalized X (0-1)"},"y":{"type":"number","description":"Normalized Y (0-1)"}},"required":["nodeId","x","y"]}}, -{"name":"list_effects","description":"List all available effects (Built-in D2D + ShaderLab) with categories","inputSchema":{"type":"object","properties":{}}}, -{"name":"graph_overview","description":"Compact graph summary: nodes (id, name, type, error), edges, preview node","inputSchema":{"type":"object","properties":{}}}, -{"name":"get_display_info","description":"Current display capabilities, active profile, pipeline format, app version","inputSchema":{"type":"object","properties":{}}}, -{"name":"graph_rename_node","description":"Rename a node","inputSchema":{"type":"object","properties":{"nodeId":{"type":"number"},"name":{"type":"string"}},"required":["nodeId","name"]}}, -{"name":"graph_snapshot","description":"Capture a PNG snapshot of the live node-graph editor view at the current pan/zoom and panel size. With inline=true returns the image as MCP image content (base64). Without inline, returns the temp file path only.","inputSchema":{"type":"object","properties":{"inline":{"type":"boolean","description":"If true, return the PNG bytes inline as MCP image content"}}}}, -{"name":"graph_get_view","description":"Get the node-graph view's current zoom, pan offset, viewport size, and the bounding box of all nodes (in canvas space).","inputSchema":{"type":"object","properties":{}}}, -{"name":"graph_set_view","description":"Pan and/or zoom the node-graph editor view. Any subset of {zoom, panX, panY} may be supplied. Changes apply immediately to the live UI. zoom is clamped to [0.1, 5.0]; pan has no clamp. Coordinate convention: screen = zoom * canvas + pan.","inputSchema":{"type":"object","properties":{"zoom":{"type":"number"},"panX":{"type":"number"},"panY":{"type":"number"}}}}, -{"name":"graph_fit_view","description":"Fit the node-graph view to show all nodes with the given viewport-space padding (DIPs, default 40). No-op when the graph is empty.","inputSchema":{"type":"object","properties":{"padding":{"type":"number"}}}}, -{"name":"list_display_profiles","description":"List all built-in display profile presets and the currently active simulated/live profile. Returns full caps (HDR, peak nits, SDR white) and CIE primaries.","inputSchema":{"type":"object","properties":{}}}, -{"name":"set_display_profile","description":"Apply a simulated display profile (overrides OS-reported caps until cleared). Specify exactly ONE of: preset (factory or display name), presetIndex (0-based), iccPath (.icc/.icm file), custom (full chroma + nits spec).","inputSchema":{"type":"object","properties":{"preset":{"type":"string"},"presetIndex":{"type":"number"},"iccPath":{"type":"string"},"custom":{"type":"object","properties":{"name":{"type":"string"},"hdrEnabled":{"type":"boolean"},"sdrWhiteNits":{"type":"number"},"peakNits":{"type":"number"},"minNits":{"type":"number"},"maxFullFrameNits":{"type":"number"},"primaryRed":{"type":"array","items":{"type":"number"}},"primaryGreen":{"type":"array","items":{"type":"number"}},"primaryBlue":{"type":"array","items":{"type":"number"}},"whitePoint":{"type":"array","items":{"type":"number"}},"gamut":{"type":"string"}},"required":["peakNits"]}}}}, -{"name":"clear_simulated_profile","description":"Revert to the live OS-reported display profile (clears any simulated/preset/ICC override).","inputSchema":{"type":"object","properties":{}}}, -{"name":"render_capture_node","description":"Capture any node's resolved output as PNG (FORCES a render frame so dirty nodes evaluate). With inline=true returns the image as MCP image content (base64). 404 if node missing; 409 with notReady=true if the node is dirty / has unconnected inputs.","inputSchema":{"type":"object","properties":{"nodeId":{"type":"number"},"inline":{"type":"boolean"}},"required":["nodeId"]}}, -{"name":"preview_get_view","description":"Get the preview pane's current zoom + pan + image bounds + zoom limits.","inputSchema":{"type":"object","properties":{}}}, -{"name":"preview_set_view","description":"Set the preview pane's zoom and/or pan. zoom clamped to [0.01, 100.0]. Returns post-clamp values.","inputSchema":{"type":"object","properties":{"zoom":{"type":"number"},"panX":{"type":"number"},"panY":{"type":"number"}}}}, -{"name":"preview_fit_view","description":"Fit the preview image to the preview viewport (auto zoom + center).","inputSchema":{"type":"object","properties":{}}}, -{"name":"image_stats","description":"GPU-accelerated per-channel image statistics (min/max/mean/median/p95/sum + nonzero counts). Forces a render frame first so the target node is fresh. Channels default to luminance+R+G+B+A; pass channels:[\"luminance\"] to skip the others. nonzeroOnly excludes zero pixels from min/max/mean/sum.","inputSchema":{"type":"object","properties":{"nodeId":{"type":"number"},"nonzeroOnly":{"type":"boolean"},"channels":{"type":"array","items":{"type":"string","enum":["luminance","r","g","b","a"]}}},"required":["nodeId"]}}, -{"name":"read_pixel_region","description":"Read a small w x h region of FP32 RGBA pixels from a node's output (scRGB linear-light). Region is capped at 32x32 (1024 pixels) and per-axis at 64. Pixels are returned row-major as a flat float array (RGBARGBA...).","inputSchema":{"type":"object","properties":{"nodeId":{"type":"number"},"x":{"type":"number"},"y":{"type":"number"},"w":{"type":"number"},"h":{"type":"number"}},"required":["nodeId","x","y","w","h"]}}, -{"name":"effect_get_hlsl","description":"Read a node's custom-effect HLSL source, parameter list, compile state, and last runtime error. For non-custom nodes returns hasCustomEffect=false (200, not 404). For ShaderLab library effects, also includes isLibraryEffect=true + shaderLabEffectId/Version.","inputSchema":{"type":"object","properties":{"nodeId":{"type":"number"}},"required":["nodeId"]}}, -{"name":"list_gpus","description":"Enumerate available GPU adapters (DXGI). Returns the active adapter and a list of all installed adapters with name, vendorId, deviceId, dedicated VRAM (MB), LUID, and isWarp flag.","inputSchema":{"type":"object","properties":{}}}, -{"name":"switch_gpu","description":"Switch the active GPU adapter. Triggers a full graph-save, device-teardown, and graph-reload cycle. Use mode='warp' for the WARP software adapter, 'default' to let the driver pick, or 'adapter' with either {luid:{low,high}} or {name:'partial-match'}. Falls back to default if the requested adapter fails to initialize.","inputSchema":{"type":"object","properties":{"mode":{"type":"string","enum":["warp","default","adapter"]},"name":{"type":"string","description":"Substring match against adapter name (used when mode='adapter')"},"luid":{"type":"object","properties":{"low":{"type":"number"},"high":{"type":"number"}}}},"required":["mode"]}} -]})JSON"; - return { 200, wrapResult(tools) }; - } - - // ---- tools/call ---- - if (method == "tools/call") - { - auto params = jobj.GetNamedObject(L"params"); - auto toolName = ToUtf8(std::wstring(params.GetNamedString(L"name"))); - auto args = params.HasKey(L"arguments") ? params.GetNamedObject(L"arguments") : winrt::Windows::Data::Json::JsonObject(); - auto argsStr = ToUtf8(std::wstring(args.Stringify())); - - // Route to existing REST handlers. - ::ShaderLab::McpHttpServer::Response restResp = { 404, "" }; - - if (toolName == "graph_add_node") - restResp = m_mcpServer->RouteRequest(L"POST", L"/graph/add-node", argsStr); - else if (toolName == "graph_remove_node") - restResp = m_mcpServer->RouteRequest(L"POST", L"/graph/remove-node", argsStr); - else if (toolName == "graph_connect") - restResp = m_mcpServer->RouteRequest(L"POST", L"/graph/connect", argsStr); - else if (toolName == "graph_disconnect") - restResp = m_mcpServer->RouteRequest(L"POST", L"/graph/disconnect", argsStr); - else if (toolName == "graph_set_property") - restResp = m_mcpServer->RouteRequest(L"POST", L"/graph/set-property", argsStr); - else if (toolName == "graph_get_node") - { - auto nodeId = static_cast(args.GetNamedNumber(L"nodeId")); - restResp = m_mcpServer->RouteRequest(L"GET", std::format(L"/graph/node/{}", nodeId), ""); - } - else if (toolName == "graph_save_json") - restResp = m_mcpServer->RouteRequest(L"GET", L"/graph/save", ""); - else if (toolName == "graph_load_json") - { - auto jsonStr = ToUtf8(std::wstring(args.GetNamedString(L"json"))); - restResp = m_mcpServer->RouteRequest(L"POST", L"/graph/load", jsonStr); - } - else if (toolName == "graph_clear") - restResp = m_mcpServer->RouteRequest(L"POST", L"/graph/clear", ""); - else if (toolName == "graph_apply") - restResp = m_mcpServer->RouteRequest(L"POST", L"/graph/apply", argsStr); - else if (toolName == "effect_compile") - restResp = m_mcpServer->RouteRequest(L"POST", L"/effect/compile", argsStr); - else if (toolName == "set_preview_node") - restResp = m_mcpServer->RouteRequest(L"POST", L"/render/preview-node", argsStr); - else if (toolName == "render_capture") - restResp = m_mcpServer->RouteRequest(L"GET", L"/render/capture", ""); - else if (toolName == "perf_timings") - restResp = m_mcpServer->RouteRequest(L"GET", L"/perf", ""); - else if (toolName == "node_logs") - { - auto nodeId = static_cast(args.GetNamedNumber(L"nodeId")); - uint64_t sinceSeq = 0; - if (args.HasKey(L"sinceSeq")) - sinceSeq = static_cast(args.GetNamedNumber(L"sinceSeq")); - restResp = m_mcpServer->RouteRequest(L"GET", - std::format(L"/node/{}/logs?since={}", nodeId, sinceSeq), ""); - } - else if (toolName == "registry_get_effect") - { - auto name = std::wstring(args.GetNamedString(L"name")); - restResp = m_mcpServer->RouteRequest(L"GET", L"/registry/effect/" + name, ""); - } - else if (toolName == "graph_bind_property") - restResp = m_mcpServer->RouteRequest(L"POST", L"/graph/bind-property", argsStr); - else if (toolName == "graph_unbind_property") - restResp = m_mcpServer->RouteRequest(L"POST", L"/graph/unbind-property", argsStr); - else if (toolName == "read_analysis_output") - { - auto nodeId = static_cast(args.GetNamedNumber(L"nodeId")); - restResp = m_mcpServer->RouteRequest(L"GET", std::format(L"/analysis/{}", nodeId), ""); - } - else if (toolName == "read_pixel_trace") - restResp = m_mcpServer->RouteRequest(L"POST", L"/render/pixel-trace", argsStr); - else if (toolName == "list_effects") - { - restResp = DispatchSync([&]() -> ::ShaderLab::McpHttpServer::Response { - std::string json = "{\"builtIn\":{"; - auto& reg = ::ShaderLab::Effects::EffectRegistry::Instance(); - auto cats = reg.Categories(); - bool firstCat = true; - for (const auto& cat : cats) - { - if (cat == L"Analysis") continue; - if (!firstCat) json += ","; - json += "\"" + ToUtf8(cat) + "\":["; - auto effects = reg.ByCategory(cat); - bool firstFx = true; - for (const auto* e : effects) - { - if (!firstFx) json += ","; - json += "\"" + ToUtf8(e->name) + "\""; - firstFx = false; - } - json += "]"; - firstCat = false; - } - json += "},\"shaderLab\":{"; - auto& sl = ::ShaderLab::Effects::ShaderLabEffects::Instance(); - auto slCats = sl.Categories(); - firstCat = true; - for (const auto& cat : slCats) - { - if (!firstCat) json += ","; - json += "\"" + ToUtf8(cat) + "\":["; - auto effects = sl.ByCategory(cat); - bool firstFx = true; - for (const auto* e : effects) - { - if (!firstFx) json += ","; - json += "\"" + ToUtf8(e->name) + "\""; - firstFx = false; - } - json += "]"; - firstCat = false; - } - json += "}}"; - return { 200, json }; - }); - } - else if (toolName == "graph_overview") - { - restResp = DispatchSync([&]() -> ::ShaderLab::McpHttpServer::Response { - std::string json = "{\"previewNodeId\":" + std::to_string(m_previewNodeId) + ",\"nodes\":["; - bool first = true; - for (const auto& n : m_graph.Nodes()) - { - if (!first) json += ","; - std::string typeStr; - switch (n.type) - { - case ::ShaderLab::Graph::NodeType::Source: typeStr = "Source"; break; - case ::ShaderLab::Graph::NodeType::BuiltInEffect: typeStr = "BuiltIn"; break; - case ::ShaderLab::Graph::NodeType::PixelShader: typeStr = "PixelShader"; break; - case ::ShaderLab::Graph::NodeType::ComputeShader: typeStr = "ComputeShader"; break; - case ::ShaderLab::Graph::NodeType::Output: typeStr = "Output"; break; - } - json += std::format("{{\"id\":{},\"name\":\"{}\",\"type\":\"{}\"", - n.id, ToUtf8(n.name), typeStr); - if (!n.runtimeError.empty()) - json += ",\"error\":\"" + ToUtf8(n.runtimeError) + "\""; - json += std::format(",\"inputs\":{},\"outputs\":{}}}", n.inputPins.size(), n.outputPins.size()); - first = false; - } - json += "],\"edges\":["; - first = true; - for (const auto& e : m_graph.Edges()) - { - if (!first) json += ","; - json += std::format("[{},{},{},{}]", e.sourceNodeId, e.sourcePin, e.destNodeId, e.destPin); - first = false; - } - json += "]}"; - return { 200, json }; - }); - } - else if (toolName == "get_display_info") - { - restResp = DispatchSync([&]() -> ::ShaderLab::McpHttpServer::Response { - auto profile = m_displayMonitor.ActiveProfile(); - auto live = m_displayMonitor.LiveProfile(); - auto caps = m_displayMonitor.CachedCapabilities(); - auto verStr = ToUtf8(std::wstring(::ShaderLab::VersionString)); - std::string json = std::format( - "{{\"appVersion\":\"{}\",\"graphFormatVersion\":{}" - ",\"pipeline\":\"{}\"" - ",\"display\":{{\"hdr\":{},\"maxNits\":{:.0f},\"sdrWhiteNits\":{:.0f}" - ",\"simulated\":{},\"profileName\":\"{}\"" - ",\"activeGamut\":{{\"red\":[{:.4f},{:.4f}],\"green\":[{:.4f},{:.4f}],\"blue\":[{:.4f},{:.4f}]}}" - ",\"monitorGamut\":{{\"red\":[{:.4f},{:.4f}],\"green\":[{:.4f},{:.4f}],\"blue\":[{:.4f},{:.4f}]}}" - "}}}}", - verStr, ::ShaderLab::GraphFormatVersion, - ToUtf8(std::wstring(m_renderEngine.ActiveFormat().name)), - caps.hdrEnabled ? "true" : "false", - caps.maxLuminanceNits, caps.sdrWhiteLevelNits, - profile.isSimulated ? "true" : "false", - ToUtf8(profile.profileName), - profile.primaryRed.x, profile.primaryRed.y, - profile.primaryGreen.x, profile.primaryGreen.y, - profile.primaryBlue.x, profile.primaryBlue.y, - live.primaryRed.x, live.primaryRed.y, - live.primaryGreen.x, live.primaryGreen.y, - live.primaryBlue.x, live.primaryBlue.y); - return { 200, json }; - }); - } - else if (toolName == "graph_rename_node") - { - restResp = DispatchSync([&]() -> ::ShaderLab::McpHttpServer::Response { - auto nodeId = static_cast(args.GetNamedNumber(L"nodeId")); - auto newName = std::wstring(args.GetNamedString(L"name")); - auto* node = m_graph.FindNode(nodeId); - if (!node) return { 404, R"({"error":"Node not found"})" }; - node->name = newName; - m_nodeGraphController.RebuildLayout(); - PopulatePreviewNodeSelector(); - PopulateAddNodeFlyout(); - return { 200, R"({"ok":true})" }; - }); - } - else if (toolName == "graph_snapshot") - { - // Forward to REST handler. Re-serialize args to JSON so - // the route gets a proper body containing {inline:bool}. - restResp = m_mcpServer->RouteRequest(L"POST", L"/graph/snapshot", argsStr); - - // When the agent requested inline image bytes, repack - // the response as MCP-native image content (not text). - bool wantInline = false; - if (args.HasKey(L"inline")) - { - auto v = args.GetNamedValue(L"inline"); - if (v.ValueType() == winrt::Windows::Data::Json::JsonValueType::Boolean) - wantInline = v.GetBoolean(); - } - if (wantInline && restResp.statusCode == 200) - { - // Parse base64 + mimeType out of the REST response - // and emit MCP image content directly so we skip - // the text-escape wrapping below. - winrt::Windows::Data::Json::JsonObject ro{ nullptr }; - if (winrt::Windows::Data::Json::JsonObject::TryParse( - winrt::to_hstring(restResp.body), ro) - && ro.HasKey(L"base64") && ro.HasKey(L"mimeType")) - { - auto b64 = ToUtf8(std::wstring(ro.GetNamedString(L"base64"))); - auto mime = ToUtf8(std::wstring(ro.GetNamedString(L"mimeType"))); - std::string content = std::format( - R"JSON({{"content":[{{"type":"image","data":"{}","mimeType":"{}"}}],"isError":false}})JSON", - b64, mime); - return { 200, wrapResult(content) }; - } - } - } - else if (toolName == "graph_get_view") - restResp = m_mcpServer->RouteRequest(L"GET", L"/graph/view", ""); - else if (toolName == "graph_set_view") - restResp = m_mcpServer->RouteRequest(L"POST", L"/graph/view", argsStr); - else if (toolName == "graph_fit_view") - restResp = m_mcpServer->RouteRequest(L"POST", L"/graph/view/fit", argsStr); - else if (toolName == "list_display_profiles") - restResp = m_mcpServer->RouteRequest(L"GET", L"/display/profiles", ""); - else if (toolName == "set_display_profile") - restResp = m_mcpServer->RouteRequest(L"POST", L"/display/profile", argsStr); - else if (toolName == "clear_simulated_profile") - restResp = m_mcpServer->RouteRequest(L"POST", L"/display/profile/clear", ""); - else if (toolName == "preview_get_view") - restResp = m_mcpServer->RouteRequest(L"GET", L"/preview/view", ""); - else if (toolName == "preview_set_view") - restResp = m_mcpServer->RouteRequest(L"POST", L"/preview/view", argsStr); - else if (toolName == "preview_fit_view") - restResp = m_mcpServer->RouteRequest(L"POST", L"/preview/view/fit", ""); - else if (toolName == "image_stats") - restResp = m_mcpServer->RouteRequest(L"POST", L"/render/image-stats", argsStr); - else if (toolName == "read_pixel_region") - restResp = m_mcpServer->RouteRequest(L"POST", L"/render/pixel-region", argsStr); - else if (toolName == "effect_get_hlsl") - { - auto nodeId = static_cast(args.GetNamedNumber(L"nodeId")); - restResp = m_mcpServer->RouteRequest(L"GET", - std::format(L"/effect/hlsl/{}", nodeId), ""); - } - else if (toolName == "list_gpus") - restResp = m_mcpServer->RouteRequest(L"GET", L"/gpu/list", ""); - else if (toolName == "switch_gpu") - restResp = m_mcpServer->RouteRequest(L"POST", L"/gpu/switch", argsStr); - else if (toolName == "render_capture_node") - { - // Forward to REST handler; if inline=true was requested - // and we got a successful PNG back, repack as MCP-native - // image content (mirroring the graph_snapshot flow). - restResp = m_mcpServer->RouteRequest(L"POST", L"/render/capture-node", argsStr); - bool wantInline = args.HasKey(L"inline") - && args.GetNamedValue(L"inline").ValueType() == winrt::Windows::Data::Json::JsonValueType::Boolean - && args.GetNamedBoolean(L"inline"); - if (wantInline && restResp.statusCode == 200) - { - winrt::Windows::Data::Json::JsonObject ro{ nullptr }; - if (winrt::Windows::Data::Json::JsonObject::TryParse( - winrt::to_hstring(restResp.body), ro) - && ro.HasKey(L"base64") && ro.HasKey(L"mimeType")) - { - auto b64 = ToUtf8(std::wstring(ro.GetNamedString(L"base64"))); - auto mime = ToUtf8(std::wstring(ro.GetNamedString(L"mimeType"))); - std::string content = std::format( - R"JSON({{"content":[{{"type":"image","data":"{}","mimeType":"{}"}}],"isError":false}})JSON", - b64, mime); - return { 200, wrapResult(content) }; - } - } - } - - bool isError = restResp.statusCode >= 400; - - // MCP requires content[].text to be a STRING, not raw JSON. - // Escape the body for embedding in a JSON string value. - std::string escaped; - for (char c : restResp.body) - { - if (c == '"') escaped += "\\\""; - else if (c == '\\') escaped += "\\\\"; - else if (c == '\n') escaped += "\\n"; - else if (c == '\r') escaped += "\\r"; - else if (c == '\t') escaped += "\\t"; - else escaped += c; - } - - std::string content = std::format( - R"JSON({{"content":[{{"type":"text","text":"{}"}}],"isError":{}}})JSON", - escaped.empty() ? "" : escaped, - isError ? "true" : "false"); - - return { 200, wrapResult(content) }; - } - - // ---- resources/list ---- - if (method == "resources/list") - { - std::string resources = R"JSON({"resources":[ -{"uri":"shaderlab://context","name":"ShaderLab Context","description":"System prompt: pipeline format, shader conventions, API reference","mimeType":"application/json"}, -{"uri":"shaderlab://graph","name":"Effect Graph","description":"Full graph state with nodes, edges, properties, custom effect definitions","mimeType":"application/json"}, -{"uri":"shaderlab://registry/effects","name":"Built-in Effects","description":"All 48+ built-in D2D effects with property metadata","mimeType":"application/json"}, -{"uri":"shaderlab://custom-effects","name":"Custom Effects","description":"Custom effects in graph with HLSL source and compile status","mimeType":"application/json"} -]})JSON"; - return { 200, wrapResult(resources) }; - } - - // ---- resources/read ---- - if (method == "resources/read") - { - auto params2 = jobj.GetNamedObject(L"params"); - auto uri = ToUtf8(std::wstring(params2.GetNamedString(L"uri"))); - - std::string restPath; - if (uri == "shaderlab://context") restPath = "/context"; - else if (uri == "shaderlab://graph") restPath = "/graph"; - else if (uri == "shaderlab://registry/effects") restPath = "/registry/effects"; - else if (uri == "shaderlab://custom-effects") restPath = "/custom-effects"; - else - return { 200, wrapResult(R"JSON({"contents":[]})JSON") }; - - auto restResp = m_mcpServer->RouteRequest(L"GET", std::wstring(restPath.begin(), restPath.end()), ""); - - // Escape the JSON body for embedding in the text field. - std::string escaped; - for (char c : restResp.body) - { - if (c == '"') escaped += "\\\""; - else if (c == '\\') escaped += "\\\\"; - else if (c == '\n') escaped += "\\n"; - else if (c == '\r') escaped += "\\r"; - else escaped += c; - } - - std::string result = std::format( - R"JSON({{"contents":[{{"uri":"{}","mimeType":"application/json","text":"{}"}}]}})JSON", - uri, escaped); - return { 200, wrapResult(result) }; - } - - // ---- ping ---- - if (method == "ping") - return { 200, wrapResult("{}") }; - - // Unknown method. - return { 200, std::format( - R"JSON({{"jsonrpc":"2.0","id":{},"error":{{"code":-32601,"message":"Method not found: {}"}}}})JSON", - idStr, method) }; - } - catch (const std::exception& ex) - { - return { 200, std::format( - R"JSON({{"jsonrpc":"2.0","id":null,"error":{{"code":-32700,"message":"Parse error: {}"}}}})JSON", - ex.what()) }; - } - }); } } diff --git a/MainWindow.RenderTick.cpp b/MainWindow.RenderTick.cpp index b273ea6..5c1219e 100644 --- a/MainWindow.RenderTick.cpp +++ b/MainWindow.RenderTick.cpp @@ -1,8 +1,9 @@ -// MainWindow partial (Phase 4 split): the OnRenderTick / RenderFrame +// MainWindow partial (Phase 4 split): the OnRenderTick / RenderWorkerLoop // render loop, including the dirty-propagation pre-pass, video tick, // and output-window present. All methods are members of // `winrt::ShaderLab::implementation::MainWindow`. Extracted from -// MainWindow.xaml.cpp at commit c177770. +// MainWindow.xaml.cpp at commit c177770. (The pre-worker RenderTickBody / +// RenderFrame were removed in stdio-migration Step 7 — dead since v1.7.0.) #include "pch.h" #include "MainWindow.xaml.h" @@ -19,10 +20,10 @@ namespace winrt::ShaderLab::implementation // Handles XAML-touching work only -- editor-canvas redraw on the // UI-side D2D context, FPS panel text update, video seek slider, // properties panel refresh, MCP indicator, log windows. - // - Render-worker thread (m_renderWorker): runs RenderWorkerLoop -> - // RenderTickBody. Handles all graph + GPU work -- working space - // sync, capture/clock/video upload, dirty propagation, RenderFrame - // (which evaluates the graph and presents the main swap chain), + // - Render-worker thread (m_renderWorker): runs RenderWorkerLoop, whose + // per-tick body handles all graph + GPU work -- working space sync, + // capture/clock/video upload, dirty propagation, RenderFrameToOffscreen + // (evaluates the graph and draws into the double-buffered offscreen), // and snapshot publication. // // The two threads communicate through: @@ -76,10 +77,11 @@ namespace winrt::ShaderLab::implementation // self-invalidate when those change. Without this, the canvas only // redraws on UI-side interaction, so a playing Clock or Video looks // frozen even though the worker is ticking and republishing. - if (m_frameGeneration != m_lastSeenFrameGeneration) + const uint64_t curGen = m_frameGeneration.load(std::memory_order_acquire); + if (curGen != m_lastSeenFrameGeneration) { m_nodeGraphController.SetNeedsRedraw(); - m_lastSeenFrameGeneration = m_frameGeneration; + m_lastSeenFrameGeneration = curGen; } RenderNodeGraph(); @@ -123,13 +125,25 @@ namespace winrt::ShaderLab::implementation { if (!m_logWindows.empty()) UpdateLogWindows(); - if (m_selectedNodeId != 0 && m_graph.HasDirtyNodes()) + // Decide whether the selected node's Properties panel needs a + // refresh WITHOUT holding a live-graph pointer on the UI thread + // (the decision-#70 race). Read under a shared lock on + // m_graphMutex, capture a plain bool, release, THEN act -- never + // hold the lock across UpdatePropertiesPanel, which dispatches to + // the render worker (m_graphMutex is taken exclusively there; + // holding it across the dispatch would deadlock). Step 7 residual. + bool refreshBoundProps = false; + if (m_selectedNodeId != 0) { - auto* selNode = m_graph.FindNode(m_selectedNodeId); - if (selNode && !selNode->propertyBindings.empty() && - !IsPropertiesPanelInteracting()) - UpdatePropertiesPanel(); + std::shared_lock graphLock(m_graphMutex); + if (m_graph.HasDirtyNodes()) + { + auto* selNode = m_graph.FindNode(m_selectedNodeId); + refreshBoundProps = selNode && !selNode->propertyBindings.empty(); + } } + if (refreshBoundProps && !IsPropertiesPanelInteracting()) + UpdatePropertiesPanel(); UpdateMcpActivityIndicator(); UpdateFpsTooltip(); } @@ -349,9 +363,10 @@ namespace winrt::ShaderLab::implementation } // Publish snapshot. - ++m_frameGeneration; + const uint64_t frameGen = + m_frameGeneration.fetch_add(1, std::memory_order_release) + 1; auto snap = ::ShaderLab::Graph::BuildGraphUiSnapshot( - m_graph, m_previewNodeId, m_graphGeneration, m_frameGeneration); + m_graph, m_previewNodeId, m_graphGeneration, frameGen); std::atomic_store(&m_uiGraphSnapshot, std::shared_ptr(snap)); } @@ -369,506 +384,6 @@ namespace winrt::ShaderLab::implementation m_renderDispatcher.Drain(); } - // --------------------------------------------------------------------- - // RenderTickBody -- body of the render-thread tick. All graph + GPU work. - // Equivalent to the old OnRenderTick body before the split. - // --------------------------------------------------------------------- - void MainWindow::RenderTickBody(double deltaSec) - { - if (m_isShuttingDown) return; - if (!m_renderEngine.IsInitialized()) return; - - auto tTickStart = std::chrono::high_resolution_clock::now(); - - // Mirror the active display profile into Working Space parameter - // nodes. This is a cheap node-list walk that no-ops when no - // Working Space nodes are present and only marks dirty when at - // least one field actually changed, so freshly-added nodes pick - // up live values immediately without hooking every AddNode site. - UpdateWorkingSpaceNodes(); - - // Tick live capture providers (DXGI Desktop Duplication, Windows - // Graphics Capture). These don't go through the dirty/video- - // provider path the rest of the source-prep loop uses, so call - // their dedicated tick here. A captured frame marks the source - // node dirty so the existing needsEval gate triggers a re-eval - // and present. - if (auto* dc5 = static_cast(m_renderEngine.D2DDeviceContext())) - { - auto& nodes = const_cast&>(m_graph.Nodes()); - if (m_sourceFactory.TickAndUploadLiveCaptures(nodes, dc5)) - m_forceRender = true; - } - - // Tick clock nodes: advance time. - for (auto& node : const_cast&>(m_graph.Nodes())) - { - if (node.isClock) - { - auto getF = [&](const std::wstring& k, float def) { - auto it = node.properties.find(k); - if (it != node.properties.end()) - if (auto* f = std::get_if(&it->second)) return *f; - return def; - }; - - bool autoDuration = getF(L"AutoDuration", 1.0f) > 0.5f; - if (autoDuration && node.propertyBindings.count(L"StopTime")) - { - autoDuration = false; - node.properties[L"AutoDuration"] = 0.0f; - } - if (autoDuration) - { - float maxDur = 0.0f; - for (const auto& other : m_graph.Nodes()) - { - if (other.id == node.id) continue; - bool boundToThisClock = false; - for (const auto& [propName, binding] : other.propertyBindings) - { - for (const auto& src : binding.sources) - { - if (src && src->sourceNodeId == node.id) - { boundToThisClock = true; break; } - } - if (boundToThisClock) break; - } - if (!boundToThisClock) continue; - for (const auto& field : other.analysisOutput.fields) - { - if (field.name == L"Duration" && field.components[0] > 0.0f) - if (field.components[0] > maxDur) maxDur = field.components[0]; - } - } - if (maxDur > 0.0f) - node.properties[L"StopTime"] = maxDur; - } - - if (node.isPlaying) - { - float startTime = getF(L"StartTime", 0.0f); - float stopTime = getF(L"StopTime", 10.0f); - float speed = getF(L"Speed", 1.0f); - bool loop = getF(L"Loop", 1.0f) > 0.5f; - - double duration = static_cast(stopTime - startTime); - if (duration <= 0.0) duration = 1.0; - - node.clockTime += deltaSec * speed; - - if (loop) - { - while (node.clockTime >= duration) node.clockTime -= duration; - while (node.clockTime < 0.0) node.clockTime += duration; - } - else - { - node.clockTime = std::clamp(node.clockTime, 0.0, duration); - if (node.clockTime >= duration) node.isPlaying = false; - } - - node.dirty = true; - m_nodeGraphController.SetNeedsRedraw(); - } - } - } - - // Resolve source node property bindings (e.g., Clock.Time → Video.Time) - // BEFORE ticking video sources, so they see the updated time values. - m_graphEvaluator.ResolveSourceBindings(m_graph); - - // Tick video sources and upload new frames. - auto* dc = m_renderEngine.D2DDeviceContext(); - if (dc) - { - try { - m_sourceFactory.TickAndUploadVideos( - const_cast&>(m_graph.Nodes()), - dc, deltaSec); - } catch (...) {} - } - - // Propagate dirty flags downstream so D3D11 compute effects - // re-dispatch when upstream sources change (video frames, animation). - // Runs AFTER video tick so new-frame dirty flags reach compute nodes. - { - std::vector queue; - for (const auto& node : m_graph.Nodes()) - if (node.dirty) queue.push_back(node.id); - for (size_t i = 0; i < queue.size(); ++i) - { - for (const auto* edge : m_graph.GetOutputEdges(queue[i])) - { - auto* dn = m_graph.FindNode(edge->destNodeId); - if (dn && !dn->dirty) - { - dn->dirty = true; - queue.push_back(edge->destNodeId); - } - } - } - } - - // Only re-evaluate the graph when something changed. Always render - // if output windows are open (they need continuous present). - bool wasForceRender = m_forceRender; - bool hasDirty = m_graph.HasDirtyNodes(); - bool hasOutputWindows = !m_outputWindows.empty(); - bool needsEval = hasDirty || m_needsFitPreview || m_forceRender || hasOutputWindows; - auto tVideoTickEnd = std::chrono::high_resolution_clock::now(); - if (needsEval) - { - RenderFrame(deltaSec); - m_forceRender = false; - m_frameCount.fetch_add(1, std::memory_order_relaxed); - if (hasDirty || wasForceRender) - ++m_graphGeneration; - // Layout rebuild touches m_visuals which is owned by UI thread's - // controller. Marshal to UI dispatcher. - if (wasForceRender) - { - DispatcherQueue().TryEnqueue([this]{ - m_nodeGraphController.RebuildLayout(); - }); - } - } - auto tRenderFrameEnd = std::chrono::high_resolution_clock::now(); - - // Publish a fresh GraphUiSnapshot so UI / MCP consumers see the latest - // state. - ++m_frameGeneration; - auto snap = ::ShaderLab::Graph::BuildGraphUiSnapshot( - m_graph, m_previewNodeId, m_graphGeneration, m_frameGeneration); - std::atomic_store(&m_uiGraphSnapshot, - std::shared_ptr(snap)); - - // Frame-timing accumulation. - { - auto usec = [](auto a, auto b) { - return std::chrono::duration(b - a).count(); - }; - const double a = 0.1; - auto& t = m_frameTiming; - t.totalUs = t.totalUs * (1-a) + (deltaSec * 1'000'000.0) * a; - t.videoTickUs = t.videoTickUs * (1-a) + usec(tTickStart, tVideoTickEnd) * a; - t.framesSampled++; - if (t.framesSampled % 30 == 0) - m_lastFrameTiming = t; - } - } - - - void MainWindow::RenderFrame(double deltaSeconds) - { - if (!m_renderEngine.IsInitialized()) - return; - - auto* dc = m_renderEngine.D2DDeviceContext(); - if (!dc) return; - - auto tFrameStart = std::chrono::high_resolution_clock::now(); - - // Re-prepare dirty source nodes (e.g., Flood color changed, video frame advance). - for (auto& node : const_cast&>(m_graph.Nodes())) - { - if (node.type == ::ShaderLab::Graph::NodeType::Source && - (node.dirty || m_sourceFactory.GetVideoProvider(node.id))) - { - try { - m_sourceFactory.PrepareSourceNode(node, dc, deltaSeconds, m_renderEngine.D3DDevice(), m_renderEngine.D3DContext()); - } catch (...) { - node.runtimeError = L"Source preparation failed"; - node.dirty = false; - } - } - } - - // Compute which nodes are needed (feed a visible output). - // Start by marking all nodes unneeded, then mark roots and propagate upstream. - { - for (auto& node : const_cast&>(m_graph.Nodes())) - node.needed = false; - - // Roots: Output nodes, preview node, output window nodes. - // Data-only/analysis nodes are NOT automatic roots — they only - // evaluate when dirty or when something downstream needs them. - std::vector roots; - for (const auto& node : m_graph.Nodes()) - { - if (node.type == ::ShaderLab::Graph::NodeType::Output) - roots.push_back(node.id); - // Only include data-only analysis nodes if they're dirty - // (need initial computation or property changed). - if (node.dirty && node.customEffect.has_value() && - node.customEffect->analysisOutputType == ::ShaderLab::Graph::AnalysisOutputType::Typed) - roots.push_back(node.id); - } - if (m_previewNodeId != 0) - roots.push_back(m_previewNodeId); - for (const auto& window : m_outputWindows) - roots.push_back(window->NodeId()); - - // BFS upstream from roots. - std::unordered_set visited; - std::vector queue = roots; - while (!queue.empty()) - { - uint32_t id = queue.back(); - queue.pop_back(); - if (visited.count(id)) continue; - visited.insert(id); - auto* node = m_graph.FindNode(id); - if (node) node->needed = true; - // Add all upstream nodes (via both image and data edges). - for (const auto* edge : m_graph.GetInputEdges(id)) - queue.push_back(edge->sourceNodeId); - // Add property binding sources. - if (node) - { - for (const auto& [propName, binding] : node->propertyBindings) - { - if (binding.wholeArray) - queue.push_back(binding.wholeArraySourceNodeId); - for (const auto& src : binding.sources) - { - if (src.has_value()) - queue.push_back(src->sourceNodeId); - } - } - } - } - } - - auto tSourcesEnd = std::chrono::high_resolution_clock::now(); - - // Evaluate the effect graph. - m_graphEvaluator.Evaluate(m_graph, dc); - - // If any effects were newly created this frame, evaluate again immediately. - // D2D needs the first pass to initialize transform pipeline; the second - // pass produces correct output with the proper cbuffer values. - if (m_graph.HasDirtyNodes()) - m_graphEvaluator.Evaluate(m_graph, dc); - - auto tEvalEnd = std::chrono::high_resolution_clock::now(); - - // Deferred fit: after first evaluation with valid output, fit the preview. - if (m_needsFitPreview && GetPreviewImage()) - { - m_needsFitPreview = false; - FitPreviewToView(); - } - - // Begin draw to swap chain. - auto* drawDc = m_renderEngine.BeginDraw(); - if (!drawDc) - return; - - // Process deferred D3D11 compute dispatches inside the active D2D - // draw session, where all effect chains are fully materialized. - // Phase 8c: install the per-frame CPU-analysis interest set so - // ProcessDeferredCompute knows which compute nodes need to read - // their structured buffer back to CPU. Currently: - // * the selected node so the Properties panel + canvas value - // labels stay live for the user's focus; - // * every upstream source that the selected node is bound to, - // so the selected node's bound *parameter* values (not just - // its analysis fields) stay live in the Properties panel - // for nodes that consume an upstream stats output (e.g. - // ICtCp Tone Map showing TargetPeakNits = LumStats.Mean - // should display the changing Mean even though TargetPeakNits - // is served via the GPU SRV). - // Every other compute node whose downstream consumers are - // entirely GPU-routed will skip its CopyResource + Map round-trip. - // Throttling at Performance::CpuAnalysisHintThrottleMs (default - // 2 s = 0.5 Hz) keeps the readback rate human-readable while - // playing video. - { - std::unordered_set interest; - if (m_selectedNodeId != 0) - { - interest.insert(m_selectedNodeId); - if (auto* sel = m_graph.FindNode(m_selectedNodeId)) - { - for (const auto& [propName, binding] : sel->propertyBindings) - { - if (binding.wholeArray) - interest.insert(binding.wholeArraySourceNodeId); - for (const auto& srcOpt : binding.sources) - { - if (srcOpt.has_value()) - interest.insert(srcOpt->sourceNodeId); - } - } - } - } - m_graphEvaluator.SetCpuAnalysisInterest(std::move(interest)); - } - uint32_t computeCount = static_cast(m_graphEvaluator.DeferredComputeCount()); - auto tComputeStart = std::chrono::high_resolution_clock::now(); - if (m_graphEvaluator.ProcessDeferredCompute(m_graph, drawDc)) - { - m_nodeGraphController.SetNeedsRedraw(); - if (m_graph.HasDirtyNodes()) - { - // Post-PDC re-evaluate: re-apply properties on D2D effects - // downstream of the just-dispatched compute bridges so - // their internal intermediate caches invalidate. We do - // NOT want compute nodes to re-add themselves to - // m_deferredCompute here -- those entries would leak - // into next frame's PDC and cause a duplicate dispatch - // (stale-source then current-source overwriting the - // same UAV in alternation, which manifests as visible - // two-frame flicker). - m_graphEvaluator.SetDeferredComputeFrozen(true); - m_graphEvaluator.Evaluate(m_graph, drawDc); - m_graphEvaluator.SetDeferredComputeFrozen(false); - } - } - - auto tComputeEnd = std::chrono::high_resolution_clock::now(); - - // Log compute dispatch timing if it was slow (>10ms). - if (computeCount > 0) - { - double computeMs = std::chrono::duration(tComputeEnd - tComputeStart).count(); - if (computeMs > 10.0) - { - // Log to each compute node that dispatched. - for (const auto& node : m_graph.Nodes()) - { - if (node.customEffect.has_value() && - node.customEffect->shaderType == ::ShaderLab::Graph::CustomShaderType::D3D11ComputeShader && - !node.outputPins.empty() && node.cachedOutput) - { - m_nodeLogs[node.id].Warning( - std::format(L"Slow compute dispatch: {:.1f}ms ({} dispatches)", computeMs, computeCount)); - } - } - } - } - - // Log per-node state changes (errors) — only on transitions. - for (const auto& node : m_graph.Nodes()) - { - auto& log = m_nodeLogs[node.id]; - // Log runtime errors when they change. - static std::unordered_map s_lastError; - if (node.runtimeError != s_lastError[node.id]) - { - s_lastError[node.id] = node.runtimeError; - if (!node.runtimeError.empty()) - log.Error(node.runtimeError); - else - log.Info(L"Error cleared"); - } - } - - // Set DPI to 96 so D2D coordinates match WinUI DIPs exactly. - // The XAML compositor handles physical pixel scaling. - // This ensures the preview transform, crosshair overlay, and pixel - // trace coordinates all use the same coordinate space. - float oldDpiX, oldDpiY; - drawDc->GetDpi(&oldDpiX, &oldDpiY); - drawDc->SetDpi(96.0f, 96.0f); - - drawDc->Clear(D2D1::ColorF(D2D1::ColorF::Black)); - - // Apply preview pan/zoom transform. - D2D1_MATRIX_3X2_F previewTransform = - D2D1::Matrix3x2F::Scale(m_previewZoom, m_previewZoom) * - D2D1::Matrix3x2F::Translation(m_previewPanX, m_previewPanY); - drawDc->SetTransform(previewTransform); - - auto* previewImage = ResolveDisplayImage(m_previewNodeId); - if (previewImage) - { - drawDc->SetTransform(previewTransform); - drawDc->DrawImage(previewImage); - } - else if (m_previewNodeId != 0) - { - // Draw "No Input" when previewing a node with broken upstream. - winrt::com_ptr dwFactory; - DWriteCreateFactory(DWRITE_FACTORY_TYPE_SHARED, - __uuidof(IDWriteFactory), dwFactory.as().put()); - if (dwFactory) - { - winrt::com_ptr fmt; - dwFactory->CreateTextFormat(L"Segoe UI", nullptr, - DWRITE_FONT_WEIGHT_NORMAL, DWRITE_FONT_STYLE_NORMAL, - DWRITE_FONT_STRETCH_NORMAL, 18.0f, L"en-us", fmt.put()); - if (fmt) - { - fmt->SetTextAlignment(DWRITE_TEXT_ALIGNMENT_CENTER); - fmt->SetParagraphAlignment(DWRITE_PARAGRAPH_ALIGNMENT_CENTER); - winrt::com_ptr brush; - drawDc->CreateSolidColorBrush(D2D1::ColorF(0.5f, 0.5f, 0.5f, 0.8f), brush.put()); - if (brush) - { - D2D1_SIZE_F sz = drawDc->GetSize(); - drawDc->DrawText(L"No Input", 8, fmt.get(), - D2D1::RectF(0, 0, sz.width, sz.height), brush.get()); - } - } - } - } - - drawDc->SetTransform(D2D1::Matrix3x2F::Identity()); - drawDc->SetDpi(oldDpiX, oldDpiY); - - auto tDrawEnd = std::chrono::high_resolution_clock::now(); - - m_renderEngine.EndDraw(); - m_renderEngine.Present(); - - auto tPresentEnd = std::chrono::high_resolution_clock::now(); - - // Accumulate per-frame timing (exponential moving average, alpha=0.1). - // Note: `totalUs` is set by OnRenderTick to the wall-clock tick-to-tick - // interval -- thats the only number that matches the displayed FPS. - // Everything below is a sub-phase of that interval. - { - auto usec = [](auto a, auto b) { - return std::chrono::duration(b - a).count(); - }; - const double a = 0.1; - auto& t = m_frameTiming; - t.sourcesPrepUs = t.sourcesPrepUs * (1-a) + usec(tFrameStart, tSourcesEnd) * a; - t.evaluateUs = t.evaluateUs * (1-a) + usec(tSourcesEnd, tEvalEnd) * a; - t.deferredComputeUs = t.deferredComputeUs * (1-a) + usec(tEvalEnd, tComputeEnd) * a; - t.drawUs = t.drawUs * (1-a) + usec(tComputeEnd, tDrawEnd) * a; - t.endDrawFlushUs = t.endDrawFlushUs * (1-a) + usec(tDrawEnd, tPresentEnd) * a; - t.computeDispatches = computeCount; - } - - auto tOutWinsStart = std::chrono::high_resolution_clock::now(); - // Present to any open output windows. - PresentOutputWindows(); - auto tOutWinsEnd = std::chrono::high_resolution_clock::now(); - - // Refresh pixel trace after graph evaluation (before next frame). - if (m_traceActive) - { - PopulatePixelTraceTree(); - RenderTraceSwatches(); - } - auto tTraceEnd = std::chrono::high_resolution_clock::now(); - // Update crosshair position each frame (tracks with pan/zoom). - UpdateCrosshairOverlay(); - - { - auto usec = [](auto a, auto b) { - return std::chrono::duration(b - a).count(); - }; - const double a = 0.1; - auto& t = m_frameTiming; - t.outputWindowsUs = t.outputWindowsUs * (1-a) + usec(tOutWinsStart, tOutWinsEnd) * a; - t.traceUs = t.traceUs * (1-a) + usec(tOutWinsEnd, tTraceEnd) * a; - } - } - // ------------------------------------------------------------------------- // RenderFrameToOffscreen / BlitOffscreenToSwapChain // diff --git a/MainWindow.xaml.cpp b/MainWindow.xaml.cpp index 91cf54d..1506209 100644 --- a/MainWindow.xaml.cpp +++ b/MainWindow.xaml.cpp @@ -340,21 +340,19 @@ namespace winrt::ShaderLab::implementation { if (!m_mcpServer) SetupMcpRoutes(); - if (m_mcpServer && !m_mcpServer->IsRunning()) - m_mcpServer->Start(47808); - // Wait briefly for the listener thread to bind and set the port. - Sleep(100); - uint16_t actualPort = m_mcpServer ? m_mcpServer->Port() : 47808; - McpServerLabel().Text(std::format(L"MCP Server :{}", actualPort)); + // Expose this window to MCP as a hub session. The HTTP + // listener was removed in stdio-migration Step 9 — the broker + // (shim → hub → session) is the only transport now. + StartMcpSession(); + UpdateMcpStatusLabel(); McpExportConfigButton().Visibility(winrt::Microsoft::UI::Xaml::Visibility::Visible); ResetMcpActivityState(); UpdateMcpActivityIndicator(); } else { - if (m_mcpServer) - m_mcpServer->Stop(); - McpServerLabel().Text(L"MCP Server"); + StopMcpSession(); + McpServerLabel().Text(L"MCP: off"); McpExportConfigButton().Visibility(winrt::Microsoft::UI::Xaml::Visibility::Collapsed); ResetMcpActivityState(); UpdateMcpActivityIndicator(); @@ -363,20 +361,50 @@ namespace winrt::ShaderLab::implementation McpExportConfigButton().Click([this](auto&&, auto&&) { - uint16_t port = m_mcpServer ? m_mcpServer->Port() : 47808; namespace DP = winrt::Windows::ApplicationModel::DataTransfer; - auto pkg = DP::DataPackage(); - std::wstring config = std::format( - L"{{\n" - L" \"mcpServers\": {{\n" - L" \"shaderlab\": {{\n" - L" \"url\": \"http://localhost:{}/\"\n" - L" }}\n" - L" }}\n" - L"}}", port); - pkg.SetText(config); - DP::Clipboard::SetContent(pkg); - PipelineFormatText().Text(std::format(L"MCP config copied to clipboard (http://localhost:{})", port)); + auto jsonEsc = [](const std::wstring& s) { + std::wstring o; + for (wchar_t c : s) { if (c == L'\\' || c == L'"') o += L'\\'; o += c; } + return o; + }; + + std::wstring config, note; + auto shim = EnsureShimDistributed(); + if (!shim.empty()) + { + // Preferred: stdio config pointing at the stable unpackaged + // shim copy. The --hub-aumid lets the shim activate the + // packaged hub on demand (client-driven bootstrap). + auto aumid = HubAumid(); + std::wstring argsJson = aumid.empty() + ? L"\"--stdio\"" + : std::format(L"\"--stdio\", \"--hub-aumid\", \"{}\"", jsonEsc(aumid)); + config = std::format( + L"{{\n" + L" \"mcpServers\": {{\n" + L" \"shaderlab\": {{\n" + L" \"command\": \"{}\",\n" + L" \"args\": [{}]\n" + L" }}\n" + L" }}\n" + L"}}", jsonEsc(shim), argsJson); + note = L"MCP stdio config copied to clipboard"; + } + else + { + // No broker payload found (unexpected in a real build). There + // is no HTTP fallback any more — the listener is gone (Step 9). + config.clear(); + note = L"MCP shim not found — reinstall or rebuild ShaderLab."; + } + + if (!config.empty()) + { + auto pkg = DP::DataPackage(); + pkg.SetText(config); + DP::Clipboard::SetContent(pkg); + } + PipelineFormatText().Text(note); }); } @@ -385,9 +413,11 @@ namespace winrt::ShaderLab::implementation m_isShuttingDown = true; m_renderShouldStop.store(true, std::memory_order_release); - // Stop MCP server before tearing down resources. - if (m_mcpServer) - m_mcpServer->Stop(); + // Stop the MCP session FIRST, while the render worker is still alive, + // so an in-flight session request drains rather than stranding on a + // joined worker (the 30 s stall the migration plan warns about). + // Then (below) the render dispatcher + worker. + StopMcpSession(); if (m_renderTimer) { @@ -513,13 +543,11 @@ namespace winrt::ShaderLab::implementation SetupMcpRoutes(); if (m_autoStartMcp && m_mcpServer) { - m_mcpServer->Start(47808); + StartMcpSession(); McpServerToggle().IsChecked(true); - // Delay slightly to let the listener thread bind. DispatcherQueue().TryEnqueue([this]() { - uint16_t actualPort = m_mcpServer ? m_mcpServer->Port() : 47808; - McpServerLabel().Text(std::format(L"MCP Server :{}", actualPort)); + UpdateMcpStatusLabel(); McpExportConfigButton().Visibility(winrt::Microsoft::UI::Xaml::Visibility::Visible); ResetMcpActivityState(); UpdateMcpActivityIndicator(); @@ -649,12 +677,12 @@ namespace winrt::ShaderLab::implementation void MainWindow::UpdateMcpActivityIndicator() { - // Hide the dot entirely when the server is off. - if (!m_mcpServer || !m_mcpServer->IsRunning()) + // Hide the dot entirely when this window isn't exposed as a session. + if (!m_sessionClient) { McpActivityDot().Visibility(winrt::Microsoft::UI::Xaml::Visibility::Collapsed); Controls::ToolTipService::SetToolTip(McpServerToggle(), - winrt::box_value(winrt::hstring(L"Start/stop MCP server for AI assistant integration"))); + winrt::box_value(winrt::hstring(L"Expose this window to MCP (AI assistant integration)"))); return; } @@ -707,10 +735,9 @@ namespace winrt::ShaderLab::implementation return; m_mcpLastUiUpdateSeq = seq; std::wstring tooltip; - uint16_t port = m_mcpServer->Port(); if (totalCount == 0) { - tooltip = std::format(L"MCP Server :{} \u2014 listening (no requests yet)", port); + tooltip = L"MCP session \u2014 registered (no requests yet)"; } else { @@ -741,10 +768,10 @@ namespace winrt::ShaderLab::implementation else ageStr = std::format(L"{}m ago", ageMs / 60000); tooltip = std::format( - L"MCP Server :{} \u2014 {} request{}\n" + L"MCP session \u2014 {} request{}\n" L"Last: {} {} \u2192 {} ({})\n" L"From: {}{}", - port, totalCount, (totalCount == 1 ? L"" : L"s"), + totalCount, (totalCount == 1 ? L"" : L"s"), methodW, pathW, status, ageStr, peerW.empty() ? L"(unknown)" : peerW.c_str(), peerCount > 1 ? std::format(L" (\u00d7{} distinct clients)", peerCount).c_str() : L""); @@ -843,6 +870,11 @@ namespace winrt::ShaderLab::implementation void MainWindow::SwitchAdapter( ::ShaderLab::Rendering::DevicePreference pref, LUID adapterLuid) { + // Gate MCP session/HTTP requests to 503 for the whole teardown + + // rebuild window (GuiEngineCommandSink::Dispatch checks this). Reset + // in the exit paths below. + m_adapterSwitchInProgress.store(true, std::memory_order_release); + // Stop UI render timer. if (m_renderTimer) m_renderTimer.Stop(); @@ -912,6 +944,7 @@ namespace winrt::ShaderLab::implementation catch (...) { // Total failure — restart timer and bail. if (m_renderTimer) m_renderTimer.Start(); + m_adapterSwitchInProgress.store(false, std::memory_order_release); return; } } @@ -1032,6 +1065,10 @@ namespace winrt::ShaderLab::implementation } m_forceRender = true; }); + + // Switch complete: the worker is back on the new device and the + // engine is usable again, so let MCP requests through. + m_adapterSwitchInProgress.store(false, std::memory_order_release); } // ----------------------------------------------------------------------- @@ -5050,72 +5087,12 @@ namespace winrt::ShaderLab::implementation catch (...) { return {}; } } - std::vector MainWindow::CaptureNodeAsPng(uint32_t nodeId, - bool& outNotFound, - bool& outNotReady) - { - outNotFound = false; - outNotReady = false; - - // Force a render frame so dirty downstream nodes evaluate before we - // try to resolve the output. Same convention as /render/capture. - RenderFrame(); - - auto* image = ResolveDisplayImage(nodeId); - if (!image) - { - // Disambiguate "no such node" vs "node exists but isn't ready". - auto* node = m_graph.FindNode(nodeId); - if (!node) { outNotFound = true; return {}; } - outNotReady = true; - return {}; - } - return CaptureImageAsPng(image); - } - - bool MainWindow::ReadPixelRegion(uint32_t nodeId, - int32_t x, int32_t y, uint32_t w, uint32_t h, - std::vector& outPixels, - uint32_t& outActualW, uint32_t& outActualH, - bool& outNotFound, bool& outNotReady) - { - outPixels.clear(); - outActualW = 0; - outActualH = 0; - outNotFound = false; - outNotReady = false; - - auto* dc = m_renderEngine.D2DDeviceContext(); - if (!dc) return false; - - // Force a fresh frame so dirty nodes evaluate before readback. - // The engine helper (Rendering::ReadPixelRegion) is otherwise - // pure -- doesn't drive eval -- so the host has to ensure the - // graph is up-to-date. - RenderFrame(); - - auto result = ::ShaderLab::Rendering::ReadPixelRegion( - m_graph, nodeId, x, y, w, h, dc); - - switch (result.status) - { - case ::ShaderLab::Rendering::ReadPixelRegionStatus::Success: - outPixels = std::move(result.pixels); - outActualW = result.actualWidth; - outActualH = result.actualHeight; - return true; - case ::ShaderLab::Rendering::ReadPixelRegionStatus::NotFound: - outNotFound = true; - return false; - case ::ShaderLab::Rendering::ReadPixelRegionStatus::NotReady: - outNotReady = true; - return false; - case ::ShaderLab::Rendering::ReadPixelRegionStatus::InvalidRegion: - case ::ShaderLab::Rendering::ReadPixelRegionStatus::D2DError: - default: - return false; - } - } + // MainWindow::CaptureNodeAsPng and MainWindow::ReadPixelRegion were + // removed in the stdio-migration Step 7 residual sweep. They were + // pre-worker MCP shims (each calling the dead MainWindow::RenderFrame on + // the UI D2D context) with no remaining callers -- the render_capture_node + // and read_pixel_region routes are engine-side now, driving the render + // worker and using Rendering::CaptureNodeAsPng / Rendering::ReadPixelRegion. std::vector MainWindow::CaptureGraphAsPng() { diff --git a/MainWindow.xaml.h b/MainWindow.xaml.h index 61d0a45..1e01d00 100644 --- a/MainWindow.xaml.h +++ b/MainWindow.xaml.h @@ -18,8 +18,9 @@ #include "Controls/LogWindow.h" #include "Controls/NodeLog.h" #include "EffectDesignerWindow.xaml.h" -#include "Engine/Mcp/McpHttpServer.h" +#include "Engine/Mcp/McpRouter.h" #include "Engine/Mcp/EngineMcpRoutes.h" +#include "Engine/Mcp/McpSessionClient.h" #include "Rendering/RenderThreadDispatcher.h" namespace winrt::ShaderLab::implementation @@ -210,13 +211,12 @@ namespace winrt::ShaderLab::implementation // Render loop. UI-only work runs on the m_renderTimer DispatcherQueueTimer // (XAML reads/writes, FPS panel updates, RenderNodeGraph against the UI - // D2D context). RenderTickBody runs on the dedicated render-worker - // thread (or, in synchronous mode, inline from OnRenderTick). + // D2D context). The render-worker frame body is RenderFrameToOffscreen + // (below); the pre-worker RenderTickBody / RenderFrame were removed in + // the stdio-migration Step 7 residual sweep (dead since v1.7.0). void OnRenderTick( winrt::Microsoft::UI::Dispatching::DispatcherQueueTimer const& sender, winrt::Windows::Foundation::IInspectable const& args); - void RenderTickBody(double deltaSec); - void RenderFrame(double deltaSeconds = 0.0); // Render-thread frame body. Walks the graph, runs eval + deferred // compute, draws the preview image into one of the offscreen @@ -283,10 +283,14 @@ namespace winrt::ShaderLab::implementation // Generation counters. graphGeneration bumps every time the render // path observes a graph mutation (HasDirtyNodes etc.); frameGeneration - // bumps once per render tick. Both are written only from the render - // path so a non-atomic uint64 is fine. - uint64_t m_graphGeneration{ 0 }; - uint64_t m_frameGeneration{ 0 }; + // bumps once per render tick. graphGeneration is render-path-only, so + // a plain uint64 is fine. frameGeneration is WRITTEN by the render + // worker but READ on the UI thread (OnRenderTick, to gate canvas + // redraws), so it is atomic — a torn read is practically impossible + // for an aligned 64-bit word but this is a formal data race otherwise + // (stdio-migration Step 7 residual sweep). + uint64_t m_graphGeneration{ 0 }; + std::atomic m_frameGeneration{ 0 }; // UI-thread cached value of the last snapshot frameGeneration we // observed in OnRenderTick. When the worker thread bumps @@ -528,31 +532,13 @@ namespace winrt::ShaderLab::implementation std::vector CapturePreviewAsPng(); // Encode a D2D image as a PNG byte buffer (BGRA8 via WIC). Used by - // CapturePreviewAsPng and CaptureNodeAsPng to share the encoder path. - // Caps each axis at maxDim pixels to keep responses bounded. + // CapturePreviewAsPng to share the encoder path. Caps each axis at + // maxDim pixels to keep responses bounded. + // (The node-capture / pixel-region MCP shims that also used this were + // removed in Step 7 — those routes are engine-side now, via + // Rendering::CaptureNodeAsPng / Rendering::ReadPixelRegion.) std::vector CaptureImageAsPng(ID2D1Image* image, uint32_t maxDim = 2048); - // Capture an arbitrary node's resolved output as a PNG byte buffer. - // Forces a render frame first so dirty downstream nodes evaluate. - // Returns: - // - empty + outNotFound=true when the node ID doesn't exist. - // - empty + outNotReady=true when the node exists but isn't yet ready. - // - empty + neither flag set on encode failure. - std::vector CaptureNodeAsPng(uint32_t nodeId, - bool& outNotFound, - bool& outNotReady); - - // Read a w x h pixel region from a node's resolved output as scRGB - // FP32 RGBA values (one float4 per pixel, row-major from top-left). - // Forces a render frame first. Region is clipped to image bounds. - // Returns true on success and populates `outPixels` with w*h*4 floats. - // outActualW/H reflect the (clipped) region actually read. - bool ReadPixelRegion(uint32_t nodeId, - int32_t x, int32_t y, uint32_t w, uint32_t h, - std::vector& outPixels, - uint32_t& outActualW, uint32_t& outActualH, - bool& outNotFound, bool& outNotReady); - // Capture the live node-graph view (current pan/zoom, sized to the // graph swap-chain panel) as a PNG byte buffer. Renders into an // off-screen bitmap so it doesn't disturb the live render tick. @@ -579,8 +565,29 @@ namespace winrt::ShaderLab::implementation // Effect Designer window. winrt::ShaderLab::EffectDesignerWindow m_designerWindow{ nullptr }; - // MCP HTTP server for AI agent integration. - std::unique_ptr<::ShaderLab::McpHttpServer> m_mcpServer; + // MCP route registry + HTTP listener for AI agent integration + // (McpRouter; the HTTP transport goes away in stdio-mig. Step 9). + std::unique_ptr<::ShaderLab::McpRouter> m_mcpServer; + + // stdio-migration Step 7: this window as an MCP session registered + // with the broker hub. The client serves sealed requests by routing + // through m_mcpServer (same routes/dispatcher as the HTTP path), so + // both transports coexist until Step 9 deletes HTTP. Stopped FIRST + // in shutdown (reject-new -> bye -> cancel -> join) before the render + // dispatcher, so no session request is stranded on a joined worker. + std::unique_ptr<::ShaderLab::Mcp::McpSessionClient> m_sessionClient; + std::thread m_sessionThread; + std::wstring m_mcpSessionId; // persisted per-window GUID (not an ordinal) + void StartMcpSession(); + void StopMcpSession(); + void UpdateMcpStatusLabel(); + std::wstring HubAumid(); // packaged hub AUMID (empty if unpackaged) + std::wstring EnsureShimDistributed(); // copy shim to %LOCALAPPDATA%\ShaderLab\bin\, return path + + // Set while SwitchAdapter tears down + rebuilds the device stack. + // GuiEngineCommandSink::Dispatch returns 503 during the window so a + // request never marshals into a joined worker / dead D2D context. + std::atomic m_adapterSwitchInProgress{ false }; // TEMP (Phase 8 perf debugging): default ON so the MCP-driven // graph-building loop doesn't require a manual toggle every // restart. Revert to false once the crash repro is sorted. @@ -600,8 +607,8 @@ namespace winrt::ShaderLab::implementation { MainWindow* window{ nullptr }; explicit GuiEngineCommandSink(MainWindow* w) : window(w) {} - ::ShaderLab::McpHttpServer::Response Dispatch( - std::function<::ShaderLab::McpHttpServer::Response( + ::ShaderLab::Mcp::Response Dispatch( + std::function<::ShaderLab::Mcp::Response( ::ShaderLab::Mcp::EngineContext&)> closure) override; // ---- Event hooks --------------------------------------------- diff --git a/Package.appxmanifest b/Package.appxmanifest index 5da5ede..42e1067 100644 --- a/Package.appxmanifest +++ b/Package.appxmanifest @@ -3,8 +3,10 @@ xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10" xmlns:mp="http://schemas.microsoft.com/appx/2014/phone/manifest" xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10" + xmlns:uap3="http://schemas.microsoft.com/appx/manifest/uap/windows10/3" + xmlns:desktop="http://schemas.microsoft.com/appx/manifest/application/windows10" xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities" - IgnorableNamespaces="uap rescap"> + IgnorableNamespaces="uap uap3 desktop rescap"> + + + + + diff --git a/README.md b/README.md index 546f732..c8f80e4 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Release builds ship as **unsigned MSIX packages** — no signing certificate nee 2. Download the architecture-matched zip from the GitHub Releases page: - `ShaderLab--x64.zip` for AMD64 / Intel - `ShaderLab--arm64.zip` for ARM64 (Snapdragon X / Surface Pro) -3. Extract and run: +3. Extract and run `Install.ps1` **from an elevated PowerShell** (see the limitation below): ```pwsh .\Install.ps1 ``` @@ -22,6 +22,8 @@ Release builds ship as **unsigned MSIX packages** — no signing certificate nee `Install.ps1` calls `Add-AppxPackage -AllowUnsigned`, which installs unsigned MSIX packages on systems with Developer Mode enabled (Windows 10 1903+ / Windows 11). The script installs the bundled dependency packages (Microsoft VCLibs, Windows App Runtime) for the host architecture first, then ShaderLab itself. +> ⚠️ **Known limitation — admin is required (unsigned + full-trust).** ShaderLab is a full-trust packaged app: its main app and the background MCP **Hub** both declare `Windows.FullTrustApplication` (executable activations). Per [Microsoft's unsigned-package rules](https://learn.microsoft.com/windows/msix/package/unsigned-package), an unsigned package containing executable content can only be installed **for all users, which requires elevation** — a per-user, non-elevated `Add-AppxPackage -AllowUnsigned` fails with `0x80073D2B` ("an unsigned package cannot include Executable activations"). Run `Install.ps1` from an **elevated** PowerShell. A **signed** release would install per-user with no admin; signing the release with a real code-signing certificate is the cleaner long-term fix (tracked as a release-process gap). + The release manifest carries the special OID `2.25.311729368913984317654407730594956997722=1` (Windows' "unsigned namespace") that allows `-AllowUnsigned`. The OID is injected by the release workflow only — the in-repo manifest stays plain `CN=ShaderLab` so signed F5 deploys keep working locally. --- @@ -72,7 +74,7 @@ Core capabilities: - **Analysis viewers** (Luminance / Channel / Chromaticity Statistics, CIE Histogram + Plot, Gamut Coverage, Luminance Heatmap, etc.) — all share the same compute-bridge architecture and route their outputs as SRVs to downstream consumers when possible. - **Tone-mapping suite** (D2D `HDR Tone Map`, ICtCp Tone Map, ICtCp Inverse Tone Map, ICtCp Gamut Map, etc.) operating in scRGB FP16 with PQ / HLG / sRGB transfer functions. - **HDR / WCG aware** — DXGI adapter-change tracking, ICC profile parsing, monitor primaries piped into Custom-gamut analysis effects via the Working Space node. -- **MCP server** + **headless host** for AI-agent and CI integration; the MCP route layer lives in `ShaderLabEngine.dll` so headless and GUI hosts share the route implementations. +- **MCP integration** (stdio, via a broker: shim → hub → per-window sessions) + **headless host** for AI-agent and CI use; the MCP route layer lives in `ShaderLabEngine.dll` so headless and GUI hosts share the route implementations. Build: Visual Studio 2022 17.8+, Windows 10 SDK 10.0.26100+, C++/WinRT only (no C#). diff --git a/Rendering/RenderThreadDispatcher.h b/Rendering/RenderThreadDispatcher.h index 6cc79c3..8ca027b 100644 --- a/Rendering/RenderThreadDispatcher.h +++ b/Rendering/RenderThreadDispatcher.h @@ -59,7 +59,8 @@ namespace ShaderLab::Rendering // Enqueue a closure. Returns immediately. In synchronous mode (or when // called re-entrantly from the consumer thread), the closure runs - // inline on the calling thread instead of being queued. + // inline on the calling thread instead of being queued. Fire-and- + // forget work is simply dropped once the dispatcher is shutting down. void DispatchAsync(std::function fn) { if (!fn) return; @@ -68,10 +69,13 @@ namespace ShaderLab::Rendering fn(); return; } + // Wrap as the cancel-aware queue element: on cancel it is a no-op + // (async work has no promise to fail). + Item item = [fn = std::move(fn)](bool cancelled) { if (!cancelled) fn(); }; { std::scoped_lock lock(m_mutex); if (m_shuttingDown) return; - m_queue.push_back(std::move(fn)); + m_queue.push_back(std::move(item)); } m_cv.notify_one(); } @@ -100,9 +104,25 @@ namespace ShaderLab::Rendering auto prom = std::make_shared>(); auto fut = prom->get_future(); - DispatchAsync( - [prom, fn = std::forward(fn)]() mutable + // Cancel-aware element: when the dispatcher shuts down (or an + // adapter switch resets the consumer) the queued item is invoked + // with cancelled=true so this promise FAILS FAST rather than the + // caller eating its full timeout. This is what makes the + // render < DispatchSync < shim < client timeout ladder + // enforceable (see Engine/Mcp/McpTimeouts.h). + Item item = + [prom, fn = std::forward(fn)](bool cancelled) mutable { + if (cancelled) + { + try { throw std::runtime_error( + "RenderThreadDispatcher::DispatchSync: dispatcher shut down"); } + catch (...) { + try { prom->set_exception(std::current_exception()); } + catch (...) {} + } + return; + } try { if constexpr (std::is_void_v) { fn(); prom->set_value(); } @@ -113,7 +133,21 @@ namespace ShaderLab::Rendering try { prom->set_exception(std::current_exception()); } catch (...) { /* promise already satisfied */ } } - }); + }; + + bool queued = false; + { + std::scoped_lock lock(m_mutex); + if (!m_shuttingDown) + { + m_queue.push_back(std::move(item)); + queued = true; + } + } + if (queued) + m_cv.notify_one(); + else + item(true); // shutting down: fail the promise immediately if (fut.wait_for(timeout) != std::future_status::ready) throw std::runtime_error("RenderThreadDispatcher::DispatchSync: timed out"); @@ -142,14 +176,14 @@ namespace ShaderLab::Rendering if (m_consumerId.load(std::memory_order_acquire) == std::thread::id{}) RegisterConsumer(); - std::deque> local; + std::deque local; { std::scoped_lock lock(m_mutex); local.swap(m_queue); } for (auto& fn : local) { - try { fn(); } + try { fn(false); } catch (...) { // Closures own their own error reporting (e.g. promises). @@ -196,26 +230,35 @@ namespace ShaderLab::Rendering // Lifecycle: clear consumer registration. Useful when the consumer // thread exits and a new consumer is about to register (e.g. adapter // switch teardown -> new RenderEngineThread). Caller must guarantee - // no thread is currently calling Drain/Wait. + // no thread is currently calling Drain/Wait. Pending DispatchSync + // promises are FAILED (not silently dropped), so a request in flight + // during an adapter switch returns an error instead of hanging. void ResetConsumer() { + std::deque local; + { + std::scoped_lock lock(m_mutex); + local.swap(m_queue); + m_shuttingDown = false; + } + for (auto& fn : local) { try { fn(true); } catch (...) {} } m_consumerId.store(std::thread::id{}, std::memory_order_release); - std::scoped_lock lock(m_mutex); - m_shuttingDown = false; - m_queue.clear(); } - // Stop accepting new work. Pending closures still in the queue are - // dropped. Any threads waiting on DispatchSync() will time out after - // their own deadline. Wait()/WaitFor() return immediately. + // Stop accepting new work. Pending closures are invoked with + // cancelled=true so their DispatchSync promises FAIL FAST (rather + // than the caller eating a 30 s timeout). Wait()/WaitFor() return + // immediately; subsequent DispatchSync calls also fail fast. void Shutdown() { if (m_synchronous) return; + std::deque local; { std::scoped_lock lock(m_mutex); m_shuttingDown = true; - m_queue.clear(); + local.swap(m_queue); } + for (auto& fn : local) { try { fn(true); } catch (...) {} } m_cv.notify_all(); } @@ -241,10 +284,14 @@ namespace ShaderLab::Rendering std::this_thread::get_id(); } + // Queue element carries a cancel flag: run(false) executes normally, + // run(true) fails any attached promise (shutdown / consumer reset). + using Item = std::function; + const bool m_synchronous; mutable std::mutex m_mutex; std::condition_variable m_cv; - std::deque> m_queue; + std::deque m_queue; std::atomic m_consumerId{}; bool m_shuttingDown{ false }; }; diff --git a/ShaderLab.slnx b/ShaderLab.slnx index 51de079..5d3e2ad 100644 --- a/ShaderLab.slnx +++ b/ShaderLab.slnx @@ -7,6 +7,7 @@ + diff --git a/ShaderLab.vcxproj b/ShaderLab.vcxproj index e136b8e..2ce9fde 100644 --- a/ShaderLab.vcxproj +++ b/ShaderLab.vcxproj @@ -289,6 +289,14 @@ false true + + + {E4F5A6B7-3344-4D55-BE66-778899AABB22} + false + false + @@ -336,6 +344,21 @@ + + + + + + + ShaderLabMcpBroker.exe + + + This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}. diff --git a/ShaderLabEngine.vcxproj b/ShaderLabEngine.vcxproj index f3f6f90..e9d0b86 100644 --- a/ShaderLabEngine.vcxproj +++ b/ShaderLabEngine.vcxproj @@ -101,7 +101,15 @@ - + + + + + + + + + @@ -175,10 +183,15 @@ - - NotUsing - + + + + + + + + XAML @@ -109,20 +109,23 @@ frame. ## MCP mutation (e.g. `/graph/add-node`, `/graph/set-property`) -The MCP server's Winsock thread receives the request, parses the JSON body, -and asks `GuiEngineCommandSink::Dispatch` to marshal the work. The sink -posts a closure to the dispatcher and blocks until the worker has run it. +The `McpSessionClient` thread opens a sealed channel request, routes it +through `McpRouter` to the tool's route, and `GuiEngineCommandSink::Dispatch` +marshals the work. The sink posts a closure to the dispatcher and blocks +until the worker has run it. (The transport is the broker — shim → hub → +session over named pipes — since the HTTP listener was deleted in +stdio-migration Step 9; the threading below is unchanged from the HTTP era.) ```mermaid sequenceDiagram - participant Net as MCP client - participant Srv as MCP server thread + participant Net as MCP client (via shim/hub) + participant Srv as Session client thread participant Sink as GuiEngineCommandSink participant Disp as RenderThreadDispatcher participant W as Render worker participant UI as UI thread (event hook) - Net->>Srv: POST /graph/add-node {effect:"..."} + Net->>Srv: tools/call graph_add_node {effect:"..."} Srv->>Sink: Dispatch(closure) Sink->>Disp: DispatchSync(...) Note over Srv: blocks here
(returns response when worker is done) @@ -134,7 +137,7 @@ sequenceDiagram Sink->>Sink: OnNodeAdded(nodeId) Sink->>UI: DispatcherQueue().TryEnqueue(
update XAML, AutoLayout, etc.) Sink-->>Srv: Response{200, JSON body} - Srv-->>Net: HTTP 200 + body + Srv-->>Net: sealed response frame UI->>UI: (later) rebuild Properties panel,
repaint node-graph canvas ``` @@ -157,14 +160,14 @@ sequenceDiagram participant DC as m_renderD2dContext participant Graph as m_graph - Net->>Sink: POST /render/pixel-region {nodeId,x,y,w,h} + Net->>Sink: tools/call read_pixel_region {nodeId,x,y,w,h} Sink->>W: DispatchSync(closure) W->>Graph: ResolveDisplayImage(nodeId) W->>W: force RenderFrameToOffscreen()
so dirty nodes evaluate W->>DC: PixelReadback::ReadRegion()
(BeginDraw → DrawImage → EndDraw → Map) DC-->>W: vector RGBA pixels W-->>Sink: Response{200, JSON pixel data} - Sink-->>Net: HTTP 200 + body + Sink-->>Net: sealed response frame ``` The `Map()` call inside `PixelReadback` is synchronous — it blocks the worker diff --git a/docs/development/build.md b/docs/development/build.md index 2810b77..15322f5 100644 --- a/docs/development/build.md +++ b/docs/development/build.md @@ -84,6 +84,8 @@ bite when building ARM64 natively on an ARM64 machine, and both fail in misleadi - `x64\Debug\ShaderLabEngine\ShaderLabEngine.dll` - `x64\Debug\ShaderLab\ShaderLab.exe` - `x64\Debug\ShaderLabTests\ShaderLabTests.exe` + - `x64\Debug\ShaderLabHeadless\ShaderLabHeadless.exe` + - `x64\Debug\ShaderLabMcpBroker\ShaderLabMcpBroker.exe` (also copied into the app layout + MSIX payload as the `Hub` application) ### Updating a dependency diff --git a/docs/development/mcp-stdio-migration.md b/docs/development/mcp-stdio-migration.md index d3d8bad..9fc25f0 100644 --- a/docs/development/mcp-stdio-migration.md +++ b/docs/development/mcp-stdio-migration.md @@ -1,11 +1,22 @@ # MCP Migration: HTTP → stdio + broker relay -Implementation plan for replacing the embedded HTTP MCP server with a stdio transport -fronted by a singleton broker. Written to be picked up on a different machine — see -[Picking this up](#picking-this-up) for prerequisites and the exact commands. - -Status: **planning complete, implementation not started.** Steps 1–9 below are -outstanding. Two throwaway spikes have already settled the platform questions; their +Replaced the embedded HTTP MCP server with a stdio transport fronted by a singleton +broker. **The migration is complete** (see the status line below); this doc is now both +the completion record and the onboarding guide for the MCP subsystem. + +**New here? Start with [Picking this up (fresh clone, little context)](#picking-this-up-fresh-clone-little-context)** — it's self-contained: what the pieces are, how to build from a +clone, how to run every test, how to deploy, and where the code lives. The only work +left is the [Manual verification sweep](#manual-verification-sweep-run-once-the-migration-is-complete). Everything below the onboarding section (`## Why` onward) is +design rationale + the nine-step history, kept for reference. + +Status: **COMPLETE — all 9 steps done, 2026-08-10.** The embedded HTTP transport is +deleted; the broker (shim → hub → session over named pipes, bodies sealed) is the only +MCP transport. Engine ABI **3**. What remains before the migration can be called fully +signed-off is the **manual verification sweep** at the end of this doc (WinUI window +lifecycle, packaged install/activation, a real MCP client, the in-place-upgrade +sequence) — everything automatable is green: 261 unit tests, broker smoke 26/26, +headless smoke, and the shim-driven `RunTests.ps1` at 40/40 (GUI) / 21/21 (headless) +on WARP. Two throwaway spikes have already settled the platform questions; their results are recorded in [Settled by spike](#settled-by-spike) so they are not re-litigated. @@ -14,7 +25,7 @@ re-litigated. ## Why The MCP server is a Winsock HTTP listener embedded in every host -(`Engine/Mcp/McpHttpServer.cpp`), default port 47808. +(`Engine/Mcp/McpRouter.cpp`, né `McpHttpServer.cpp` until Step 2), default port 47808. 1. **Unaddressable.** The listener scans 10 ports and nothing publishes the bound one, so a client config can't reliably find a session, and multiple ShaderLab windows @@ -94,6 +105,11 @@ at the manifest's declared `10.0.17763` floor. ## Current state +> **Historical snapshot** (mid-migration, ~Steps 2–3). Every residual noted below was +> resolved in a later step — kept for the reasoning trail. For today's state see the +> status line at the top and the per-step ✅ completion notes; for practical commands +> see [Picking this up](#picking-this-up-fresh-clone-little-context). + **Complete:** the activation spike, and repair of `Tests/RunTests.ps1` — the repo's only MCP regression suite (26 MCP-driven tests). It was not runnable as found: a dead `$MSBuild` path, an unused `WaitForMcp`, a readiness probe pointed at a @@ -108,34 +124,408 @@ couldn't gate anything while 3 of 4 runs crashed. See decision-log #70; the resu threading rules live in `Controls/NodeGraphController.h` and `.github/copilot-instructions.md`. -**Not started:** Steps 1–9. +**Known residuals of the same defect class** — verified against the live tree +(2026-08-09) and parked here deliberately so Step 7 picks them up with the rest of +the `MainWindow.*` threading work rather than as drive-by fixes now: + +- `MainWindow.RenderTick.cpp:126-131` — the 250 ms periodic UI tick reads the + **live** graph on the UI thread: `m_graph.HasDirtyNodes()`, then + `m_graph.FindNode(m_selectedNodeId)` and a walk of `selNode->propertyBindings`, + all outside both the snapshot and `m_graphMutex`. Same shape as the two + decision-#70 crashes, different caller. Note `GraphUiSnapshot` mirrors per-node + `dirty` but **not** `propertyBindings`, so the clean fix is a small snapshot + extension (e.g. a has-bindings flag), not just a call-site swap; the + alternative — taking `m_graphMutex` shared — buys the worker-tick stall #70 + measured at ~50 ms worst case. +- `MainWindow.xaml.h:284-289` — `m_frameGeneration` is a plain `uint64_t` whose + declaration comment argues "written only from the render path so a non-atomic + uint64 is fine", but `OnRenderTick` now reads it on the UI thread + (`MainWindow.RenderTick.cpp:79`) to gate canvas redraws. Formally a data race + (in practice benign for aligned 64-bit loads on x64/ARM64); make it + `std::atomic` with relaxed ordering and fix the comment, which + currently asserts a property that is no longer true. +- Dead pre-worker tick path: `MainWindow::RenderTickBody` + (`MainWindow.RenderTick.cpp:376`, zero callers), `MainWindow::RenderFrame` + (renders on the **UI** D2D context; only reachable from the dead body), and the + `MainWindow::CaptureNodeAsPng` / `MainWindow::ReadPixelRegion` wrappers (zero + callers; superseded by the engine routes, which correctly run on the render + thread with `ctx.dc = RenderD2DContext()`). Delete in Step 7 — anyone reasoning + about shutdown ordering or context ownership will otherwise trip over a + `RenderFrame` that runs on the wrong context. + +**Step 1 — route hygiene: complete** (2026-08-10). `/effects` + `/graph/overview` are +engine routes (both hosts serve them); `/graph/rename-node` + `/display/info` are real +app-side routes — rename mutates on the render thread and `TryEnqueue`s the XAML +refresh, and `get_display_info` **stays app-side by decision** (it reads +`RenderEngine::ActiveFormat()`; extending `EngineContext` waits for Step 2's ABI +bump). The `GET /render/pixel/` stub and the phantom `image_stats` tool are gone +(**39** tools now). `RunTests.ps1` gained 6 promoted-route tests — 39/39 green — and +`ShaderLabHeadless --script` now answers `GET /effects` / `GET /graph/overview`, the +first headless `list_effects` this step's verify gate asked for. + +**Step 2 — transport-neutral types + rename: complete** (2026-08-10). +`Engine/Mcp/McpTypes.h` holds `Mcp::Response` (+ `noReply` discriminator, honoured as +202-empty over HTTP); `McpHttpServer.{h,cpp}` → `McpRouter.{h,cpp}` via `git mv`; all +43 route lambdas take `(path, query, body)` with the router owning the query split; +`McpRouter::HasRoute()` added; ABI **2**; `EngineContext::getPipelineFormatName` +landed and `/display/info` moved engine-side (headless serves `get_display_info`). +Verified: both platforms build, both hosts pass the ABI check, suite 39/39, and 10 +new `McpRouter` unit tests (193 total) pin the query-split contract — the planned +`curl '?since=3'` check could not demonstrate filtering live because **no +MCP-reachable path produces node-log entries any more** (next paragraph), so the +contract is pinned at unit level instead. + +**Additional residual, found during Step 2 verification:** the per-node +runtime-error transition logger (`m_nodeLogs[...]` writes around +`MainWindow.RenderTick.cpp:745-766`) lives in the **dead pre-worker `RenderFrame` +path**, so it has silently not run since the v1.7.0 worker migration. Node runtime +errors are still *set* (visible via `graph_get_node`) but never logged; the only +remaining `m_nodeLogs` writers are GUI-native interactions (canvas connect, panel +edit, clock click, file drop). Over MCP, `node_logs` can only ever return entries a +human created first. Fold into Step 6's `node_logs` decision — relocating the route +is pointless without also reviving a producer on the live worker path. + +**Step 3 — dispatcher + tool catalog into the engine: complete** (2026-08-10). +`Engine/Mcp/McpJsonRpc.{h,cpp}` (dispatcher) + `Engine/Mcp/McpToolCatalog.{h,cpp}` +(39 declarative rows); the GUI's 376-line inline dispatcher is deleted and +`RegisterJsonRpcEndpoint` on the host's router is the whole integration for both +hosts. `ShaderLabHeadless --serve [--port]` serves the full protocol, and +`RunTests.ps1 -Port` + host-kind self-skipping made the suite CI-runnable — the +"MCP suite vs headless session" step in `ci.yml` now gates every push. All the +stdio-conformance items landed: single-line messages, zero-byte notifications +keyed off an absent id, id echoed on every error path, guarded `params`, +`_setmode` deferred to the actual stdio transport (Step 6), one shared +`Mcp::JsonEscape`. **protocolVersion decision:** pinned to `2025-06-18` — the +revision that *removed* JSON-RPC batching — with an explicit -32600 for batch +arrays; the previously-pinned 2024-11-05 required batching this server never had. +Verified: 211 unit tests (15 dispatcher + catalog), GUI suite 40/40, headless +suite 21/21 with 19 GUI-only tests self-skipping, both platforms build. + +**Found during Step 3 (fixed):** registering `POST /` on a headless router meant a +tool with no backing route fell through longest-prefix matching into the +dispatcher itself and read as an id-less notification — a silent fake success, +the `image_stats` failure class resurrected. `McpRouter::HasSpecificRoute` +(catch-all excluded) now guards every tools/call forward; absent tools return an +isError "Tool not available on this host" result. This is also why per-host +tools/list filtering must NOT be attempted via route lookup — the Step 5 shim +splices tools/list per session as planned, and both hosts advertise the full +catalog until then. + +**Step 4 — frame codec + crypto + peer identity: complete** (2026-08-10). Three +new pure-unit modules under `Engine/Mcp/`, no IPC yet: +- `McpFrame.{h,cpp}` — `[u32 totalLen][u32 channelId][u64 seq][body]`, LE, 64 MB + cap. The `{channelId, seq}` header is a distinct clear type from the sealed + body so the split cannot drift. `TryDecodeFrame` never consumes on a partial + read, and an over-cap length prefix is an explicit `Oversize` (poison the + connection) rather than a desync. +- `McpCrypto.{h,cpp}` — ephemeral P-256 ECDH → HKDF-SHA256 → AES-256-GCM via + BCrypt. Both CNG traps handled and commented: `BCRYPT_KDF_RAW_SECRET` returns + the secret byte-reversed (flipped to big-endian), and the public blob carries + a `BCRYPT_ECCKEY_BLOB` header ahead of X‖Y (never sized against the bare + curve). Two direction keys via HKDF info labels so the GCM nonce is just the + seq; the clear frame header rides as AAD, turning header tamper or seq desync + into an `Open()` auth failure. +- `McpPeerIdentity.{h,cpp}` — `GetPackageFamilyName(HANDLE)` (with the + ERROR_INSUFFICIENT_BUFFER sizing trap noted), pipe PID both directions, and + `EvaluatePairing` as pure policy: packaged→PFN equality, unpackaged→ + dir+build gated behind `SHADERLAB_MCP_ALLOW_UNPACKAGED=1`, mixed always + refused (no override). + +Verified: +33 unit tests (244 total), both platforms build. Coverage includes +the plan's full list — 40 MB round-trip, truncated/oversize/malformed frames, +sequence desync, tampered ciphertext/tag/AAD, HKDF against RFC 5869 A.1, the +mirrored handshake, and a real loopback named pipe proving +`GetNamedPipeServerProcessId` from the **client** handle works (the ⚠ spike item) +plus peer identity resolving the current process as its own peer. + +**Step 5 — hub + shim, zero sessions: complete** (2026-08-10). New +`ShaderLabMcpBroker/` + `.vcxproj` (in `ShaderLab.slnx`): ONE console binary, two +modes. `--hub` FreeConsole()s first, runs the first-instance election with the +prove-the-loss rule (ERROR_ACCESS_DENIED → connect + complete hello; healthy +incumbent → exit 0, otherwise exit 3 with a distinct log line), serves overlapped +per-connection I/O with next-instance-before-serve, verifies every peer via +`McpPeerIdentity` pairing at hello, answers channel-0 control ops, and idle-exits +(`--idle-exit-sec`). `--stdio` is the shim: binary-mode NDJSON, owns `initialize` +(protocol 2025-06-18) + the `list_sessions`/`use_session` tools + hub-op timeouts; +graph tools return a clean isError "No session attached"; logs to +`%LOCALAPPDATA%\ShaderLab\logs\`, stdout carries protocol bytes only. The broker +does NOT link the engine — the Step 4 modules compile directly into the exe. +Manifest gained `uap3`/`desktop` namespaces + `` +(literal executable, `Windows.FullTrustApplication`, `AppListEntry="none"`, full +VisualElements, no AppExecutionAlias); `CopyBrokerRuntime` mirrors +`CopyEngineRuntime` into the Appx payload. Named-object isolation: `--pipe` / +`SHADERLAB_MCP_PIPE` sets the base name and the readiness event derives from it. +Verified: `Tests/RunBrokerSmoke.ps1` 19/19 (election winner/loser, framing, +no-session shim protocol incl. zero-byte notifications, stdout+stderr hygiene, +idle exit) — wired into CI with the pre-launched-hub caveat documented. + +**Found during Step 5 (both fixed, one measured):** the explicit pipe DACL must +include `FILE_READ_EA`/`FILE_WRITE_EA` — `CreateNamedPipe` internally requests +`FILE_GENERIC_READ|WRITE`, and without the EA bits the hub's own next-instance +create fails `ERROR_ACCESS_DENIED` (measured; the smoke caught the hub dying +after its first client). And the shim's `HubConnection` needed rule-of-five move +semantics — the compiler-generated copy duplicated the raw pipe HANDLE and the +temporary's destructor closed it ("Hub connection lost" on the first +post-connect request). + +**Step 5 note for Step 6/8:** the unpackaged pairing fallback compares image +DIRECTORIES. Dev binaries live in per-project out dirs +(`…\ShaderLabMcpBroker\` vs `…\ShaderLabHeadless\` vs `…\ShaderLab\`), so +hub↔session pairing across projects in the build tree will refuse as written. +The broker smoke passes because hub and shim are the same exe. Step 6 must +either stage dev binaries into one directory, relax the fallback to the common +`\` root, or accept per-pair env overrides — decide there. + +**Step 6 — session client + headless session: complete** (2026-08-10). New +`Engine/Mcp/McpChannel.{h,cpp}` (the shared per-channel `SecureChannel`: P-256 +handshake + AES-GCM seal/open, AAD = {channelId,seq}, one home compiled by both +the broker shim and the engine) and `Engine/Mcp/McpSessionClient.{h,cpp}` (written +once against `McpRouter&`: connects to the hub as role=session, serves each sealed +channel request by routing plaintext JSON-RPC through the router's `POST /` +dispatcher, reconnects with capped backoff, one in-flight request per session). +The hub gained a session registry + blind channel relay (`open-channel` allocates +a channelId pairing shim↔session; frames route on channelId only; a dead session +emits a distinct `session-gone`). The shim pins a session (`use_session`, validated +against the live registry), runs the initiator handshake, seals/forwards requests +verbatim (id passes through), and **splices `tools/list`** (shim's 2 tools + the +pinned session's catalog, merged as real JSON values). `ShaderLabHeadless +--mcp-session [--session-id GUID] [--session-label] [--pipe]` wires it to the +existing `HeadlessSink`; the session id is a persisted per-window GUID (generated +when omitted), never an ordinal. Verified: 256 unit tests (+12 channel + pairing), +`RunBrokerSmoke` **26/26** now driving a real WARP headless session end-to-end +(register → `use_session` → spliced `tools/list` → `graph_overview` + +`graph_add_node` through the sealed relay → `session_gone` on session kill). + +**Step 5 directory-pairing note resolved:** the unpackaged fallback now accepts a +shared parent dir (both binaries under `\\`), not just an exact +dir, so hub↔session pairing works across sibling per-project out dirs. Still gated +behind `SHADERLAB_MCP_ALLOW_UNPACKAGED=1`, still requires matching build id, still +never engages packaged. Two new pairing tests pin accept-sibling / refuse-different-root. + +**`node_logs` decision (deferred item from Steps 2/6):** left in the catalog, +served GUI-only. On a headless session `tools/call node_logs` returns the standard +isError "Tool not available on this host" (via `HasSpecificRoute`) — correct, not a +hang. Reviving a worker-thread log producer is out of scope for the transport +migration; tracked as a separate cleanup. The smoke asserts nothing on `node_logs`. + +**Step 7 — GUI session client: complete** (2026-08-10). The same `McpSessionClient` +is wired into `MainWindow` (`m_sessionClient` + `m_sessionThread`, started with the +MCP toggle / autostart, alongside the still-live HTTP listener). It serves through +`m_mcpServer`'s router, so tool calls marshal to the render worker via +`GuiEngineCommandSink::Dispatch` and fire the 8 live event hooks. The correctness +work the plan flagged: +- **`RenderThreadDispatcher` fail-fast**: `Shutdown()` / `ResetConsumer()` now invoke + queued items with `cancelled=true` so pending `DispatchSync` promises FAIL + immediately instead of eating a 30 s timeout; a `DispatchSync` on an + already-shut-down queue also fails fast. Queue element is now + `std::function`. +3 unit tests. +- **`MainWindow::DispatchSync`** now checks `TryEnqueue`'s return and throws + immediately when the DispatcherQueue is shutting down (was discarded → 30 s per + request during close). +- **Timeout ladder** in one header (`Engine/Mcp/McpTimeouts.h`, `static_assert`-ed + ordering): render closure < `DispatchSync` < shim < client. Wired into the sink's + render-rung wait and the shim's session-wait. +- **Shutdown ordering**: `~MainWindow` stops the session FIRST (Stop → close pipe → + join) while the worker is still alive, THEN the HTTP listener, THEN + `m_renderDispatcher.Shutdown()` + worker join — no request stranded on a joined + worker. +- **Adapter-switch gating**: `m_adapterSwitchInProgress` makes the sink return 503 + for the whole `SwitchAdapter` teardown/rebuild window (reset on every exit path). +- **Toolbar**: the toggle exposes this window to MCP; the label shows + `MCP: off` / `MCP: on (session + :port)`. + +**Two design points surfaced and resolved here** (both needed before a GUI session +was reachable at all): +- **Role-aware pairing.** Strict binary pairing is enforced on SESSION registration + (a session drives real graphs) but NOT on a SHIM — the shim is unpackaged while + the production hub is packaged (an expected mix that the old strict rule refused), + and shim↔session payloads are sealed end-to-end anyway. The session client still + strictly verifies the hub. +- **One default pipe name.** `DefaultPipeBaseName()` (`McpPeerIdentity`, compiled by + both binaries) is the single SID-derived default so hub, shim and every session + client meet without a `--pipe` override — the broker and session client had + diverged (`…` vs `…default`). + +**Residual sweep (complete).** The two UI-thread live-graph reads in the 250 ms tick +now read under a shared `m_graphMutex` lock and act after release; `m_frameGeneration` +is `std::atomic`; and the dead pre-worker tick path is **deleted** — +`MainWindow::RenderTickBody` + `RenderFrame` (RenderTick.cpp, ~500 lines) and the +`MainWindow::CaptureNodeAsPng` / `ReadPixelRegion` shims (xaml.cpp, ~65 lines), all +unreferenced since v1.7.0. The `render_capture_node` / `read_pixel_region` routes were +re-verified over MCP afterward (they are engine-side, using `Rendering::CaptureNodeAsPng` +/ `Rendering::ReadPixelRegion` on the render worker). Build clean, no new warnings. + +**Verify (plan says manual).** Automated what was feasible: unit suite 261 (dispatcher +fail-fast), both platforms build, the HTTP MCP suite still 40/40 against the GUI +(no regression), and a **new GUI-as-session end-to-end** — activate the packaged hub, +the running GUI registers a GUID session, an unpackaged shim lists/pins it and drives +`graph_add_node` + `graph_overview` + `list_gpus` (a GUI-only tool, proving the request +reached the real GUI host) through the render worker. `session_gone`, GPU-switch-mid- +request, and fully-graceful two-window close remain manual. + +**Toolbar / activity items deferred to Step 8:** the stdio export snippet (the shim +isn't distributed until Step 8) and the richer `MCP: session 1 of 2` / `MCP: no hub` +label (needs a hub round-trip on the UI tick); `ActivityCallback`'s `peerAddress → +clientId` rename rides with the Step 9 HTTP removal. + +**Not started:** Steps 8–9. --- -## Picking this up - -### Prerequisites +## Picking this up (fresh clone, little context) + +Self-contained. If you just cloned the repo and know nothing else about it, read this +section top to bottom. + +### What this is + +ShaderLab is a Windows desktop app (WinUI 3 / C++/WinRT, no C#) for authoring and +debugging Direct2D / D3D11 shader effects. It exposes an **MCP** (Model Context +Protocol) server so AI agents can drive its node-based effect graph. This migration +replaced the app's old embedded **HTTP** MCP server with a **stdio broker**, because the +HTTP one was unaddressable across multiple windows, unauthenticated on a loopback port, +and the wrong transport for MCP clients. It is **done**; the only work left is the +[Manual verification sweep](#manual-verification-sweep-run-once-the-migration-is-complete). + +### The moving parts (glossary) + +Four executables, all built from `ShaderLab.slnx`: + +- **`ShaderLab.exe`** — the packaged WinUI app. Each open window registers itself as an + MCP **session**. +- **`ShaderLabEngine.dll`** — the shared native engine (graph model, evaluator, and all + the `Engine/Mcp/*` MCP code). Both hosts link it. +- **`ShaderLabHeadless.exe`** — console host, no WinUI. `--mcp-session` registers a + headless session (what CI and no-GUI testing use); also does PNG render / pixel + readback / `--script` batch mode. +- **`ShaderLabMcpBroker.exe`** — the broker. `--hub` = the singleton relay; `--stdio` = + the shim an MCP client launches. One binary, two modes. Deliberately does **not** link + the engine (the hub must start in milliseconds). + +Transport chain: **MCP client → shim (stdio) → hub (named pipe, blind relay) → session +(engine routes).** Shim↔session bodies are AES-256-GCM sealed, so the hub relays but +can't read them. Sessions are GUID-identified; a client calls `list_sessions`, then +`use_session ` to pick a window, then drives tools. + +- **The pipe**: `\\.\pipe\ShaderLab.mcp.v1.` by default (`DefaultPipeBaseName()` + in `Engine/Mcp/McpPeerIdentity.cpp` — the one source of truth all four binaries share). + Override with `--pipe ` or the `SHADERLAB_MCP_PIPE` env var. +- **Pairing**: the hub accepts a **session** only if its package family name matches + (production). For dev/CI, where everything is unpackaged, it falls back to "same build + id + shared config root" — but **only** when `SHADERLAB_MCP_ALLOW_UNPACKAGED=1` is set, + so it can never engage in an installed configuration. The **shim** is accepted + regardless of packaging (role-relaxed; its payloads are sealed to the session anyway). + +### Build from a fresh clone + +Full prerequisites are in [build.md](build.md); two things bite a fresh clone specifically: + +1. **Init the submodules first**, or the build stops with a "run this command" error: + ```pwsh + git submodule update --init --recursive # exprtk + miniz are submodules + nuget restore ShaderLab.slnx -SolutionDirectory . + ``` +2. **On an ARM64 host you MUST use the ARM64-native MSBuild** — the default 32-bit one + silently picks a 32-bit compiler that runs out of memory on the large generated files: + ```pwsh + & 'C:\Program Files\Microsoft Visual Studio\18\Community\MSBuild\Current\Bin\arm64\MSBuild.exe' ` + ShaderLab.slnx /p:Configuration=Debug /p:Platform=ARM64 /m + ``` + On an **x64 host**, plain `msbuild ShaderLab.slnx /p:Configuration=Debug /p:Platform=x64 /m` + is fine. (Details: build.md → "Building ARM64 on an ARM64 host".) + +Outputs land in `\\\` (e.g. `ARM64\Debug\ShaderLabTests\`). + +### Run the automated tests + +Substitute your `` (`ARM64` / `x64`) and `` (`Debug` / `Release`). +Suites 1–4 need **no packaging and no desktop** — this is what CI runs: -Standard repo prerequisites from [build.md](build.md), plus: +```pwsh +# 1. Unit suite — 261 tests on WARP; self-contained (routing, crypto, frame codec, +# per-channel handshake, dispatcher fail-fast, peer pairing, HLSL math bench). +\\ShaderLabTests\ShaderLabTests.exe --adapter warp -- The MCP suite needs a **running GUI ShaderLab** — it drives `tools/call`, which is - GUI-only until Step 3. It does not build or launch anything itself. -- Deploy from the per-arch layout, never from `AppX\`: - `Add-AppxPackage -Register \\ShaderLab\AppxManifest.xml` +# 2. Broker smoke — 26 checks: hub election, framing, sealed session round-trip, +# session_gone, idle exit, stdout hygiene. +pwsh -NoProfile -File Tests\RunBrokerSmoke.ps1 -Configuration -Platform + +# 3. Headless render smoke — PNG + FP32 pixel readback + a 7-step analysis script. +pwsh -NoProfile -File Tests\RunHeadlessSmoke.ps1 -Configuration -Platform + +# 4. MCP integration suite (40 tests) over the broker, against a HEADLESS session. +# GUI-only tests self-skip (the pinned session's label is "headless"). This is +# exactly the "MCP suite vs headless session" CI step. +$env:SHADERLAB_MCP_ALLOW_UNPACKAGED = '1' +$bin = "\" +$pipe = "ShaderLab.mcp.dev.$([guid]::NewGuid().ToString('N'))" +$hub = Start-Process "$bin\ShaderLabMcpBroker\ShaderLabMcpBroker.exe" ` + -ArgumentList '--hub','--pipe',$pipe,'--idle-exit-sec','600' -PassThru +$sess = Start-Process "$bin\ShaderLabHeadless\ShaderLabHeadless.exe" ` + -ArgumentList '--graph','Tests\fixtures\test_cli_basic.json','--mcp-session', ` + '--pipe',$pipe,'--adapter','warp' -PassThru +try { pwsh -NoProfile -File Tests\RunTests.ps1 -Pipe $pipe -Adapter warp } +finally { + Stop-Process -Id $sess.Id, $hub.Id -Force -ErrorAction SilentlyContinue + Remove-Item Env:\SHADERLAB_MCP_ALLOW_UNPACKAGED -ErrorAction SilentlyContinue +} +``` -### Verification commands +To run the MCP suite against the **GUI** (fuller coverage — the GUI-only tests actually +execute instead of skipping), deploy + launch the app (next section), then: ```pwsh -# Unit suite (no GPU dependency beyond WARP) -\\ShaderLabTests\ShaderLabTests.exe --adapter warp +$aumid = "$((Get-AppxPackage ShaderLab).PackageFamilyName)!Hub" +pwsh -NoProfile -File Tests\RunTests.ps1 -HubAumid $aumid +``` + +The suite starts a shim; `-HubAumid` lets the shim activate the packaged hub, the running +GUI's session registers, and it gets pinned. No `-Pipe` = the default per-user pipe. -# MCP regression suite — requires ShaderLab already running -pwsh -NoProfile -File .\Tests\RunTests.ps1 +### Deploy + launch the packaged app -# Headless smoke -.\Tests\RunHeadlessSmoke.ps1 -Configuration Debug -Platform x64 +`ShaderLab.exe` is a packaged MSIX app. Register the loose-file layout, launch by +activation: + +```pwsh +# Register from the LAYOUT ROOT, never from AppX\ (that subfolder holds stale artifacts). +Add-AppxPackage -Register \\ShaderLab\AppxManifest.xml +explorer.exe "shell:AppsFolder\$((Get-AppxPackage ShaderLab).PackageFamilyName)!App" ``` +Traps (all hit during this migration): +- Register fails `0x80073D02` ("in use") → kill running `ShaderLab` processes first. +- Register fails `0x80070490` ("Indexed state handler") after a manifest change → + `Remove-AppxPackage` the old registration, then re-register (re-registering the same + version over a *different* layout is a silent no-op that keeps the old path). +- A direct `Start-Process ShaderLab.exe` aborts with a CRT "abort() has been called" + dialog under packaging — always launch by activation. +- A titleless lingering `ShaderLab.exe` process is a hung shutdown — kill it; it blocks + redeploys. +- MCP auto-starts a **session** on launch (via `%LOCALAPPDATA%\ShaderLab\config.json` + = `{"mcp": true}`, or the toolbar toggle). There is **no HTTP port** to poll — + readiness = the session shows up in `list_sessions` (the suite polls this for you). + On launch the GUI also copies the shim to `%LOCALAPPDATA%\ShaderLab\bin\`. + +### Where the MCP code lives + +| File | Role | +|---|---| +| `Engine/Mcp/McpRouter.{h,cpp}` | Pure route registry — `AddRoute` / `RouteRequest` (longest-prefix, owns the query split) / `HasRoute`. No transport. | +| `Engine/Mcp/McpTypes.h` | `Mcp::Response` (+ `noReply`) and the shared `JsonEscape` / `WideToUtf8`. | +| `Engine/Mcp/McpJsonRpc.{h,cpp}` | JSON-RPC dispatcher: `initialize` / `tools/*` / `resources/*` / `ping`. Registered on the router by both hosts. | +| `Engine/Mcp/McpToolCatalog.{h,cpp}` | Declarative 39-tool table (list JSON + route mapping + arg mode). | +| `Engine/Mcp/EngineMcpRoutes.{h,cpp}` | 25 engine-pure routes + `IEngineCommandSink` + `EngineContext`. | +| `Engine/Mcp/McpFrame.{h,cpp}` | Wire frame codec: `[len][channelId][seq][body]`, 64 MB cap. | +| `Engine/Mcp/McpCrypto.{h,cpp}` | P-256 ECDH → HKDF-SHA256 → AES-256-GCM (BCrypt). | +| `Engine/Mcp/McpChannel.{h,cpp}` | Per-channel `SecureChannel`: handshake + seal/open. | +| `Engine/Mcp/McpPeerIdentity.{h,cpp}` | Peer identity, `EvaluatePairing`, `DefaultPipeBaseName`. | +| `Engine/Mcp/McpSessionClient.{h,cpp}` | Registers a session with the hub; serves sealed requests via the router. Used by GUI + headless. | +| `Engine/Mcp/McpTimeouts.h` | The timeout ladder (render < DispatchSync < shim < client). | +| `ShaderLabMcpBroker/Main.cpp` | The `--hub` relay + `--stdio` shim (compiles the `Mcp{Frame,Crypto,PeerIdentity,Channel}` TUs directly). | +| `MainWindow.McpRoutes.cpp` | 16 app-side routes, `GuiEngineCommandSink`, session start/stop + shim distribution. | +| `Tests/RunTests.ps1` / `RunBrokerSmoke.ps1` | The shim-driven integration suite / the broker smoke. | + ### Diagnosing a crash without a debugger The dev box used for this work had no `cdb`/WinDbg. WER's Application-log event 1000 @@ -149,7 +539,7 @@ inlining; resolve every distinct offset before assuming multiple defects. --- -## Step 1 — Route hygiene *(HTTP still live)* +## Step 1 — Route hygiene *(HTTP still live)* — ✅ done 2026-08-10 `MainWindow.McpRoutes.cpp`, `Engine/Mcp/EngineMcpRoutes.cpp` @@ -179,9 +569,18 @@ inlining; resolve every distinct offset before assuming multiple defects. **Verify:** `RunTests.ps1` 33/33 over HTTP. `list_effects` should now answer from `ShaderLabHeadless --script`, which is a new capability worth smoke-testing. +> **Done.** 39/39 (the suite gained `Route.RenameNode`, `Route.RenameNodeMissing`, +> `Route.GraphOverview`, `Route.ListEffects`, `Route.DisplayInfo`, +> `Route.ImageStatsRemoved`), and the headless script smoke returns 200 for both +> `GET /effects` and `GET /graph/overview`. Route placement decisions: +> `list_effects`/`graph_overview` engine-side, `rename-node`/`display/info` app-side +> (rename needs XAML refresh and no sink hook exists pre-ABI-bump; display info reads +> `RenderEngine`). `/context`'s endpoint table no longer advertises the deleted +> pixel stub. + --- -## Step 2 — Transport-neutral types + rename *(one-way)* +## Step 2 — Transport-neutral types + rename *(one-way)* — ✅ done 2026-08-10 New `Engine/Mcp/McpTypes.h`; `McpHttpServer.{h,cpp}` → `McpRouter.{h,cpp}` @@ -204,9 +603,17 @@ New `Engine/Mcp/McpTypes.h`; `McpHttpServer.{h,cpp}` → `McpRouter.{h,cpp}` `curl 'localhost:47808/node/1/logs?since=3'` now honours `since` (a fix, not a regression). +> **Done.** ARM64 + x64 build clean; GUI aborts-on-mismatch check and headless +> banner both report ABI 2; suite 39/39. The `?since=` fix is pinned by 10 +> `McpRouter` unit tests (query reaches handler, path stripped for match, +> longest-prefix with query present, `HasRoute`, 404, `noReply`) because the live +> curl can't show filtering — no MCP-drivable log producer exists post-1.7.0 (see +> *Current state*). `get_display_info` additionally moved engine-side here, per +> Step 1's deferral. + --- -## Step 3 — MCP dispatcher + tool catalog into the engine +## Step 3 — MCP dispatcher + tool catalog into the engine — ✅ done 2026-08-10 New `Engine/Mcp/McpJsonRpc.{h,cpp}`, `Engine/Mcp/McpToolCatalog.{h,cpp}` @@ -253,9 +660,18 @@ New `Engine/Mcp/McpJsonRpc.{h,cpp}`, `Engine/Mcp/McpToolCatalog.{h,cpp}` **wire `RunTests.ps1` into CI at this step** — this is the first point at which it can gate, because CI has no interactive desktop. +> **Done.** GUI 40/40 (the suite gained `Route.CatalogRoundTrip`, the live +> round-trip this step prescribes: every advertised tool driven with safe canned +> args, asserting a well-formed envelope); headless 21/21 + 19 GUI-only +> self-skips via the health route's new `host` field; `ci.yml` runs the suite +> against `ShaderLabHeadless --serve` on WARP in both Debug and Release. The +> awkward catalog cases landed as predicted: `node_logs` (NodeLogs arg mode → +> `?since=` query), `graph_load_json` (UnwrapField), and the image-repack +> predicate as a function of the response with a text fallback. + --- -## Step 4 — Frame codec, crypto, peer identity *(no IPC yet)* +## Step 4 — Frame codec, crypto, peer identity *(no IPC yet)* — ✅ done 2026-08-10 New `Engine/Mcp/McpFrame.{h,cpp}`, `McpCrypto.{h,cpp}`, `McpPeerIdentity.{h,cpp}` @@ -286,9 +702,18 @@ desync, tampered ciphertext (GCM tag), full handshake, and peer identity resolve against the current process as its own peer (which also proves the client-handle call actually works). +> **Done.** `TestMcpFrameCrypto` + `TestMcpPeerIdentity` in `Tests/TestRunner.cpp` +> (33 tests). Every listed check plus: HKDF pinned to RFC 5869 A.1, tampered AAD +> and wrong-direction-key rejection, `EncodeFrame` refusing an over-cap body, and +> the full pairing matrix (packaged same/diff PFN, unpackaged gated/ungated/ +> dir-mismatch/build-mismatch, mixed-always-refused). The loopback pipe test +> exercises **both** `GetNamedPipeClientProcessId` (server handle) and +> `GetNamedPipeServerProcessId` (client handle) — the latter is the ⚠ spike item. +> All modules link `bcrypt.lib`; no new external dependency. + --- -## Step 5 — Hub + shim, zero sessions +## Step 5 — Hub + shim, zero sessions — ✅ done 2026-08-10 New `ShaderLabMcpBroker/` + `.vcxproj` (add to `ShaderLab.slnx`); `Package.appxmanifest` @@ -334,9 +759,16 @@ hygiene, `initialize` / `tools/list` with no session attached. CI must **pre-lau hub** (no unpackaged activation path exists), so CI does not cover election — document that gap rather than pretending otherwise. +> **Done.** 19/19 smoke checks; in CI for Debug + Release. Clarification on the +> gap: the smoke DOES cover the unpackaged first-instance election (two `--hub` +> launches racing the same pipe; loser proves the incumbent via hello and exits +> 0). What no automation covers is **activation-based** launch of the packaged +> hub (`IApplicationActivationManager` → AUMID `…!Hub`) and the packaged↔packaged +> pairing path — both manual, both spike-proven mechanisms. + --- -## Step 6 — Session client + headless session +## Step 6 — Session client + headless session — ✅ done 2026-08-10 New `Engine/Mcp/McpSessionClient.{h,cpp}`; `ShaderLabHeadless/Main.cpp` @@ -349,16 +781,26 @@ New `Engine/Mcp/McpSessionClient.{h,cpp}`; `ShaderLabHeadless/Main.cpp` - `ShaderLabHeadless --mcp-session` wires it to the existing `HeadlessSink` and route registry. - Drop `node_logs` from the smoke assertions, or relocate the route — it reads a - `MainWindow` member and a headless session cannot serve it. + `MainWindow` member and a headless session cannot serve it. **Step 2 finding:** + the route's data source is nearly dry anyway — the runtime-error transition + logger died with the v1.7.0 worker migration (see *Current state*), so decide + producer revival and route placement together. **Verify:** the full `RunBrokerSmoke.ps1` in CI on WARP, end-to-end through real engine routes. This retires election, framing, crypto and reconnect risk. It does **not** retire GUI integration risk: `HeadlessSink::Dispatch` is a direct synchronous call with no DispatcherQueue, no render dispatcher, no XAML, and all 8 event hooks are no-ops. +> **Done.** `RunBrokerSmoke.ps1` 26/26 (13 new session checks incl. the sealed +> `graph_overview`/`graph_add_node` round-trips and `session_gone`), in CI on WARP. +> As the plan predicts, this retires transport risk but NOT GUI-integration risk — +> Step 7 wires the same `McpSessionClient` into `MainWindow` where `Dispatch` +> marshals to the render worker and the 8 event hooks are live, which is where the +> shutdown-ordering / timeout-ladder work lands. + --- -## Step 7 — GUI session client +## Step 7 — GUI session client — ✅ done 2026-08-10 `MainWindow.*`, `Controls/*` @@ -379,25 +821,71 @@ no DispatcherQueue, no render dispatcher, no XAML, and all 8 event hooks are no- - Toolbar: the toggle means "expose this window to MCP"; the label shows `MCP: no hub` / `MCP: session 1 of 2` / `MCP: off`; the export button emits the stdio snippet. `ActivityCallback`'s `peerAddress` becomes `clientId`. +- Sweep the residuals listed in [Current state](#current-state) while in this + code: the two UI-thread live-graph reads in the periodic UI tick, the + non-atomic `m_frameGeneration`, and the dead pre-worker tick path + (`RenderTickBody` / `RenderFrame` / the `MainWindow::CaptureNodeAsPng` + + `ReadPixelRegion` wrappers). **Verify:** manual — two ShaderLab windows, session routing via `use_session`, a GPU switch mid-request, clean shutdown, and every tool exercised. +> **Done.** Automated a GUI-as-session end-to-end (packaged hub via AUMID → running +> GUI registers a GUID session → unpackaged shim lists/pins it → `graph_add_node` / +> `graph_overview` / `list_gpus` through the render worker); HTTP suite still 40/40 +> against the GUI; 261 unit tests incl. dispatcher fail-fast. The two-window / +> GPU-switch-mid-request / graceful-close cases stay manual (WinUI window lifecycle +> isn't scriptable here). Role-aware pairing + the shared default pipe name were +> resolved as part of this step — see the completion note above. + --- -## Step 8 — Shim distribution +## Step 8 — Shim distribution — ✅ done 2026-08-10 `MainWindow` startup, `scripts/Install.ps1`, `.mcp.json` -- ShaderLab copies `ShaderLabMcpShim.exe` to `%LOCALAPPDATA%\ShaderLab\bin\` on launch, - using **rename-then-write**: overwrite-in-place is blocked while an old shim is - running, but renaming the old file and writing a new one at the original path +> **Done.** `MainWindow::EnsureShimDistributed()` copies `ShaderLabMcpBroker.exe` +> (the package payload, next to `ShaderLab.exe`) to +> `%LOCALAPPDATA%\ShaderLab\bin\` on every MCP start, **rename-then-write**: an +> existing copy is `MoveFileEx`'d aside (works while a shim runs from it — the +> process keeps its image) and a fresh binary lands at the canonical path; stale +> `.old` files are reaped best-effort. `MainWindow::HubAumid()` derives +> `!Hub`. The shim gained `--hub-aumid`: when no hub answers, +> `ShimState::EnsureHub` **activates the packaged hub** via +> `IApplicationActivationManager` (`--hub --pipe ` so it binds the shared +> default pipe) and polls `WaitNamedPipe` before retrying — the client-driven +> bootstrap. The toolbar export button emits the **stdio** config +> (`command` = the distributed shim, `args` = `--stdio --hub-aumid `), +> falling back to the HTTP snippet only when there's no broker payload (dev). +> `.mcp.json` is now a stdio config pointing at the build-tree broker for +> contributors; `Install.ps1` prints the ready-to-paste per-user stdio snippet. +> +> Verified on a real packaged install: (1) the shim appears in +> `%LOCALAPPDATA%\ShaderLab\bin\` on GUI launch; (2) with a shim held open, a +> naive overwrite is blocked but the real rename-then-write succeeds and the held +> shim keeps running (update-immune); (3) with **no hub running**, the distributed +> shim activates the packaged hub, the GUI session then registers, and +> `use_session` + `graph_add_node` drive end-to-end through the render worker; +> broker smoke 26/26 and the HTTP suite 40/40 unregressed. + +Note the shim IS `ShaderLabMcpBroker.exe --stdio` (one binary, two modes — Step 5); +"the shim" below means a copy of that exe placed on a stable unpackaged path. + +- ShaderLab copies `ShaderLabMcpBroker.exe` to `%LOCALAPPDATA%\ShaderLab\bin\` on + launch, using **rename-then-write**: overwrite-in-place is blocked while an old shim + is running, but renaming the old file and writing a new one at the original path succeeds and leaves the running process untouched. - The client config points at that stable path. Being unpackaged, the shim is immune to update and uninstall, and it can still activate the packaged hub AUMID (a non-packaged caller activating a packaged app is proven to work). - `Install.ps1` prints the ready-to-paste client snippet. `.mcp.json` becomes a stdio config pointing at the build-tree shim for contributors. +- **Toolbar export button** (was: HTTP snippet) — **done**: emits the stdio config + pointing at the distributed shim with `--hub-aumid` (see the ✅ note above). + `ActivityCallback`'s `peerAddress → clientId` rename landed with Step 9. The richer + `MCP: session 1 of 2` / `MCP: no hub` toggle label (a hub round-trip on the UI tick) + was **not** done — it's the one intentionally-deferred UI nicety, tracked in the + manual sweep's follow-up list, not a blocker. **Upgrade behaviour to document.** A newer MSIX does *not* update the MCP the client is talking to, and that is deliberate: @@ -419,9 +907,30 @@ break severs it, and then the old shim reports "no hub — restart your MCP clie **Verify:** install version *n*, connect a client, install *n+1* in place, confirm the client survives and recovers once ShaderLab relaunches. +> **Automated the mechanism, not the full MSIX-upgrade path.** The rename-then-write +> update-immunity (a held shim survives a re-distribution) and the client-driven hub +> activation are verified above. The literal install-*n* → install-*n+1*-with-client- +> connected sequence needs two signed builds and is in the end-of-migration +> **manual sweep** (below). + --- -## Step 9 — Delete HTTP *(point of no return)* +## Step 9 — Delete HTTP *(point of no return)* — ✅ done 2026-08-10 + +> **Done.** `McpRouter` lost the Winsock listener (`Start`/`Stop`/`Port`/`IsRunning`/ +> `ListenerThread`/`HandleConnection`/`WSAStartup`/`WSACleanup`/`ws2_32.lib`/CORS) +> and the `GET /` health route; it keeps `AddRoute` / `RouteRequest` / `HasRoute` / +> `HasSpecificRoute` and fires `ActivityCallback` from the top-level `POST /` with a +> `clientId` (was `peerAddress`). `McpRouter.cpp` now compiles with the PCH (no more +> `NotUsing`). `MainWindow` and `ShaderLabHeadless` lost every HTTP call site +> (`Start(47808)` / `--serve` / `--port`); the GUI toggle + `~MainWindow` drive the +> session client only, and the activity indicator keys off `m_sessionClient`. +> `RunTests.ps1` was **ported to stdio in the same change**: it starts a shim, pins +> the first session, and runs every test as a `tools/call` (the one `GET /graph` became +> `graph_overview`). CI's headless step now pre-launches a hub + `--mcp-session` and +> drives via the shim. ABI **2 → 3**. Grep confirms `47808`/`WSA`/`ws2_32` appear +> nowhere outside `CHANGELOG.md` and the decision log; decisions #31 and #58 are marked +> superseded by #71. Verified per the status line above. - Remove `Start` / `Stop` / `Port` / `ListenerThread` / `HandleConnection`, the sockets, `WSAStartup` / `WSACleanup`, `ws2_32.lib` and the CORS handling. Keep `AddRoute`, @@ -451,6 +960,74 @@ client survives and recovers once ShaderLab relaunches. 3. **The unpackaged fallback is the weak point of binary pairing** — the one path where "same build" is asserted rather than proven by the OS. Assert in the smoke test that an installed configuration refuses to use it. +4. **The documented unsigned install flow does not work for this app.** ShaderLab is a + full-trust package (both `App` and the MCP `Hub` are `Windows.FullTrustApplication`), + and per [Microsoft's unsigned-package rules](https://learn.microsoft.com/windows/msix/package/unsigned-package) + an unsigned package with executable activations can only be installed **all-users + (elevated)** — a per-user `Add-AppxPackage -AllowUnsigned` (what `Install.ps1` runs) + fails `0x80073D2B` ("an unsigned package cannot include Executable activations"). + `Install.ps1` is non-elevated, so the README's "unsigned MSIX + Developer Mode + run + `Install.ps1`" flow fails as written. Fix: either **sign the release** (a real cert → + clean per-user install, no admin, no OID hack) or make `Install.ps1` **self-elevate** + and document the admin requirement. Discovered running the install sweep on a real + ARM64 box, 2026-08-10. + +--- + +## Manual verification sweep (run once the migration is complete) + +CI + the unit / broker / headless suites cover the transport, crypto, election, pairing, +and engine-route paths. This sweep covers only what automation can't: the packaged app +driven **the way a real user drives it** — install, launch, and a real MCP client over +stdio, on real hardware. Run it on a real machine (ideally both x64 and ARM64 — see +risk #1) before calling the migration signed-off. + +The rule for this sweep: **do exactly what an end user does, and nothing else.** The only +terminal step is the install (`Install.ps1`, the shipped installer). Everything after is +driven from ShaderLab's UI and your MCP client, and every check is something the user can +*see* — no PowerShell probes, no activation helper, no log scraping. Two checks were +deliberately dropped as *not* end-user actions: the isolated `IApplicationActivationManager` +probe (the client-connect step below exercises the real `ActivateHub()` instead — a +terminal re-implementation could pass while the shipped path is broken), and the +rogue-unpackaged-session refusal (owned by the pairing unit tests + broker smoke). If a +check fails and you need to isolate it, the hub/shim/session log is at +`%LOCALAPPDATA%\ShaderLab\logs\broker-*.log`. + +Prerequisites: a release build's signed-namespace MSIX zip (`release.yml` produces it), +Developer Mode on, and a real MCP client — the one the export snippet targets. + +**Install & launch (Steps 5, 8)** +- [ ] `Install.ps1` on the release zip installs and ShaderLab launches from the Start + menu, with **no** second Start-menu entry for the hub (it ships hidden). ⚠️ Run + `Install.ps1` from an **elevated** PowerShell — ShaderLab is a full-trust app, and an + unsigned package with executable activations can only be installed all-users (admin); + a non-elevated `-AllowUnsigned` fails `0x80073D2B` (see risk #4). +- [ ] With MCP enabled, the toolbar shows a session/hub-ready state and the **export** + button copies a working **stdio** config snippet. + +**Real MCP client end-to-end — this one flow proves activation (Steps 6–9)** +- [ ] Paste the export snippet into your MCP client and connect. The client's shim + activates the packaged hub on its own — no manual step. The session shows up in the + client; list + select it and drive **every tool** at least once, including the image + tools (`graph_snapshot` / `render_capture_node` inline) and one large (~4K) inline + capture near the 64 MB frame cap, with the image rendering correctly in the client. +- [ ] **Warm-hub survival:** fully close the MCP client, then reopen and reconnect. It + reconnects **without** a cold ShaderLab restart — proof the hub outlived the client's + job object instead of dying with it. +- [ ] **Update-immune shim:** with the client connected and working, relaunch ShaderLab — + and, separately, install *n → n+1* (the real in-place upgrade). Neither disrupts the + connected client; it recovers once ShaderLab is back. + +**Two windows & live UI (Step 7)** +- [ ] Open **two ShaderLab windows**, MCP on in both; each registers its own session. In + the client the two sessions are distinguishable; select one and mutate its graph — + the change lands in **that** window on screen, not the other. +- [ ] **GPU switch mid-request:** start a slow tool call from the client, then switch GPUs + from ShaderLab's **GPU dropdown**. The in-flight call returns a clean error (not a + hang) and the app keeps working. +- [ ] **Graceful close:** close a window with its **X** while the client is connected. It + closes promptly — no ~30 s stall — the session disappears from the client's list, and + the client sees a clean `session_gone`, not a dropped connection. --- diff --git a/docs/development/project-structure.md b/docs/development/project-structure.md index cb316db..4155a30 100644 --- a/docs/development/project-structure.md +++ b/docs/development/project-structure.md @@ -3,37 +3,48 @@ ``` ShaderLab/ ├── ShaderLab.slnx # Solution file -├── ShaderLab.vcxproj # WinUI 3 app project (MSIX packaged app) +├── ShaderLab.vcxproj # WinUI 3 app project (MSIX packaged app; packages the Hub too) ├── ShaderLabEngine.vcxproj # Shared native engine DLL project ├── ShaderLabTests.vcxproj # Standalone console test runner project ├── ShaderLabHeadless.vcxproj # Console host project (no WinUI dependency) +├── ShaderLabMcpBroker.vcxproj # MCP broker (hub + stdio shim); no engine link ├── packages.config # NuGet package manifest ├── Package.appxmanifest # MSIX app identity ├── app.manifest # DPI awareness, heap type ├── EngineExport.h # SHADERLAB_API import/export macro + ABI version constant ├── EngineExport.cpp # ShaderLab_GetAbiVersion() C export ├── Version.h # App version + graph format version -├── README.md # This file +├── README.md # Slim repo intro: install, dev quickstart, doc index ├── CHANGELOG.md # Version history ├── .gitmodules # third_party submodule pins (exprtk, miniz) +├── .mcp.json # MCP client config for contributors (stdio → build-tree ShaderLabMcpBroker --stdio) │ ├── pch.h / pch.cpp # App PCH (WinRT, WinUI, D2D, D3D, STL) ├── pch_engine.h / pch_engine.cpp # Engine/Test/Headless PCH (WinRT base, D2D, D3D, MF, STL) ├── App.xaml / .h / .cpp # Application entry point -├── MainWindow.xaml / .h / .cpp # Main window layout + initialization (~4700 lines) +├── MainWindow.xaml / .h / .cpp # Main window layout + initialization (~5000 lines) ├── MainWindow.WorkingSpace.cpp # Display-profile selection + ICC loader + UpdateWorkingSpaceNodes shim ├── MainWindow.GraphFileIo.cpp # Save/load + miniz embedded-media archive + heartbeat reaper -├── MainWindow.RenderTick.cpp # OnRenderTick / RenderFrame / dirty-propagation pre-pass / output-window present -├── MainWindow.McpRoutes.cpp # 16 UI-coupled MCP routes + GuiEngineCommandSink + JSON-RPC dispatcher (~1500 lines) +├── MainWindow.RenderTick.cpp # OnRenderTick (UI blit + Present) / RenderWorkerLoop + RenderFrameToOffscreen (render worker) / dirty propagation +├── MainWindow.McpRoutes.cpp # 16 app-side MCP routes + GuiEngineCommandSink (~1150 lines; JSON-RPC dispatcher moved engine-side in Step 3) ├── MainWindow.idl # WinRT interface definition ├── EffectDesignerWindow.xaml / .h / .cpp # Effect Designer modal window │ -├── Engine/Mcp/ # Engine DLL: MCP server + engine-pure routes -│ ├── McpHttpServer.h / .cpp # Winsock2 TCP server, route registration, JSON-RPC -│ ├── EngineMcpRoutes.h / .cpp # 20 engine-pure routes + IEngineCommandSink + EngineContext +├── Engine/Mcp/ # Engine DLL: MCP router + engine-pure routes +│ ├── McpRouter.h / .cpp # Pure route registry (longest-prefix, query split, HasRoute); HTTP listener deleted in Step 9 +│ ├── McpTypes.h # Transport-neutral Mcp::Response (+ noReply) + shared JsonEscape/WideToUtf8 +│ ├── McpJsonRpc.h / .cpp # Engine-side JSON-RPC dispatcher: initialize / tools / resources / ping (Step 3) +│ ├── McpToolCatalog.h / .cpp # Declarative 39-tool table: list JSON + route mapping + arg modes +│ ├── McpFrame.h / .cpp # Broker wire codec: [len][channelId][seq][body], 64 MB cap (Step 4, no IPC yet) +│ ├── McpCrypto.h / .cpp # P-256 ECDH -> HKDF-SHA256 -> AES-256-GCM via BCrypt (Step 4) +│ ├── McpPeerIdentity.h / .cpp # Package-family peer identity + binary-pairing policy (Step 4) +│ ├── McpChannel.h / .cpp # Per-channel SecureChannel: P-256 handshake + AES-GCM seal/open (Step 6) +│ ├── McpSessionClient.h / .cpp # Registers a session with the hub, serves sealed requests via the router (Step 6; used by headless + GUI) +│ ├── McpTimeouts.h # The MCP timeout ladder (render < DispatchSync < shim < client), static_assert-ordered (Step 7) +│ ├── EngineMcpRoutes.h / .cpp # 25 engine-pure routes + IEngineCommandSink + EngineContext │ ├── Tests/ # ShaderLabTests + smoke scripts -│ ├── TestRunner.cpp # 113 tests (graph, evaluator, MCP, math bench) +│ ├── TestRunner.cpp # 261 tests total (graph, evaluator, dispatcher [+fail-fast], snapshot, bytecode cache, router, JSON-RPC, frame/crypto/peer/channel, math bench) │ ├── TestCommon.h # Shared TEST() macro across TUs │ ├── ShaderTestBench.h / .cpp # D3D11 compute test harness for HLSL math │ ├── Math/ # 51 HLSL math tests @@ -42,12 +53,19 @@ ShaderLab/ │ │ ├── MobiusReinhardTests.cpp # ICtCp tone-map curve invariants │ │ ├── DeltaETests.cpp # Sharma reference pairs for CIEDE2000 │ │ └── GamutTests.cpp # CIE xy boundary tests +│ ├── RunTests.ps1 # 40-test MCP integration suite (shim-driven; pins a running session, GUI-only tests self-skip on headless) │ ├── RunMathTests.ps1 # Local runner for the math test bench │ ├── RunHeadlessSmoke.ps1 # CI smoke (PNG + FP32 pixels + script batch) +│ ├── RunBrokerSmoke.ps1 # CI smoke (Step 5): hub election, shim protocol, idle exit │ └── fixtures/test_cli_basic.json # Golden graph for headless smoke │ ├── ShaderLabHeadless/ -│ └── Main.cpp # Console host: PNG render / --pixels / --script +│ └── Main.cpp # Console host: PNG render / --pixels / --script / --serve / --mcp-session +│ +├── ShaderLabMcpBroker/ # MCP broker binary: --hub relay + --stdio shim +│ └── Main.cpp # election, overlapped pipe I/O, session registry + channel relay, +│ # shim pinning + handshake + tools/list splice (no engine link; +│ # compiles Engine/Mcp/Mcp{Frame,Crypto,PeerIdentity,Channel}) │ ├── Graph/ # Engine: effect graph data model │ ├── NodeType.h # NodeType enum @@ -55,6 +73,7 @@ ShaderLab/ │ ├── EffectNode.h # EffectNode struct, ParameterDefinition, AnalysisFieldDef │ ├── EffectEdge.h # EffectEdge struct │ ├── EffectGraph.h / .cpp # DAG, topological sort, JSON, versioning +│ ├── GraphUiSnapshot.h / .cpp # Immutable per-frame value copy of nodes + edges for UI-thread reads (decision #70) │ ├── Rendering/ # Engine: rendering + analysis (RenderEngine stays app-side) │ ├── DisplayInfo.h # DisplayCapabilities struct @@ -69,6 +88,8 @@ ShaderLab/ │ ├── PixelReadback.h / .cpp # Engine helper: FP32 RGBA region readback │ ├── CaptureNode.h / .cpp # Engine helper: D2D + WIC PNG encode of any node's output │ ├── WorkingSpaceSync.h / .cpp # Engine helper: refresh Working Space parameter nodes +│ ├── EffectGraphFile.h / .cpp # .effectgraph zip container (miniz DEFLATE) + embedded media +│ ├── FalseColorOverlay.h / .cpp # Clipping / luminance-zone / out-of-gamut overlays │ ├── MathExpression.h / .cpp # ExprTk-backed expression evaluator (PCH disabled on .cpp) │ ├── Effects/ # Engine: built-in effect wrappers + custom effect base @@ -83,6 +104,8 @@ ShaderLab/ │ ├── CustomComputeShaderEffect.h / .cpp # ID2D1EffectImpl + ID2D1ComputeTransform for user D2D compute │ ├── CustomComputeBridgeEffect.h / .cpp # D2D wrapper for D3D11 compute (Phase 8 unifies discovery) │ ├── BytecodeCache.h / .cpp # Compile-once bytecode store + disk LRU cache +│ ├── ShaderLabParamsHlsl.h / .cpp # Engine-embedded shaderlab_params.hlsli macro library (Phase 8) +│ ├── Performance.h / .cpp # GPU-binding feature flags + telemetry counters │ ├── IEngineComputeOutput.h # COM interface for compute effects exposing GPU-resident SRVs │ ├── DxgiDuplicationSourceProvider.h / .cpp # Live-capture provider for DXGI Desktop Duplication │ ├── VideoSourceProvider.h / .cpp # Media Foundation video decode + frame upload @@ -109,13 +132,13 @@ ShaderLab/ │ └── Install.ps1 # Per-arch unsigned-MSIX installer for end users ├── .github/ │ ├── workflows/ -│ │ ├── ci.yml # PR / push CI build + tests + bootstrap-smoke +│ │ ├── ci.yml # PR / push CI: build-and-test (unit + MCP suite vs headless serve) + clean-clone-smoke │ │ └── release.yml # Tagged-release matrix (x64 + ARM64) │ └── copilot-instructions.md -├── x64\Debug\ShaderLabEngine\ # Engine DLL output -├── x64\Debug\ShaderLab\ # WinUI app output -├── x64\Debug\ShaderLabTests\ # Console test output -├── x64\Debug\ShaderLabHeadless\ # Console host output +├── \\ShaderLabEngine\ # Engine DLL output (x64|ARM64 × Debug|Release) +├── \\ShaderLab\ # WinUI app output — deploy from here, never from AppX\ +├── \\ShaderLabTests\ # Console test output +├── \\ShaderLabHeadless\ # Console host output └── packages/ # NuGet packages (restored) ``` diff --git a/docs/history/decision-log.md b/docs/history/decision-log.md index c142b75..52578d3 100644 --- a/docs/history/decision-log.md +++ b/docs/history/decision-log.md @@ -48,7 +48,7 @@ | 44 | Alt+click edge delete via bezier hit-test | `NodeGraphController::HitTestEdge` samples the cubic bezier of each edge with a small distance tolerance; Alt+click and right-click delete share the same `RemoveEdge` path for both image edges and orange data-binding edges. | Day 9 | | 45 | Multi-arch matrix release (x64 + ARM64) | `release.yml` runs MSBuild as a matrix; `Install.ps1` detects the host architecture, installs the bundled VCLibs / WinAppRuntime dependency MSIXes first, then the matching ShaderLab MSIX. Release zips are named `ShaderLab--.zip`. | Day 9 | | 46 | Inject unsigned-namespace OID only at release-build time | The Windows "unsigned namespace" OID `2.25.…` in `Publisher` is required by `Add-AppxPackage -AllowUnsigned` but breaks signed F5 deploy. Solution: keep `Package.appxmanifest` plain (`CN=ShaderLab`) in the repo; the release workflow inserts the OID immediately before MSBuild runs. | Day 9 | -| 47 | Numeric Expression node via ExprTk single-header +| 47 | Numeric Expression node via ExprTk single-header | (rationale cells were never completed — see #48, the ExprTk Release-safety configuration adopted alongside this node, and `docs/effects/numeric-expression.md` for the node itself) | Day 9 | | 48 | ExprTk feature-disable macros for Release safety | The MSVC Release optimizer crashed in ExprTk's regex / IO paths on first evaluation. Defining `exprtk_disable_string_capabilities`, `exprtk_disable_rtl_io`, `exprtk_disable_rtl_io_file`, `exprtk_disable_rtl_vecops`, `exprtk_disable_enhanced_features`, and `exprtk_disable_caseinsensitivity` before `#include`-ing `exprtk.hpp` (with PCH disabled on `MathExpression.cpp`) keeps the math-only core and eliminates the crash. | Day 9 | | 49 | Switch ICC reader to mscms.dll | Removed the in-house ICC binary parser in favor of `OpenColorProfileW` + `GetColorProfileElement` (mscms.dll). We still interpret the small XYZType / textDescriptionType / multiLocalizedUnicodeType tag bodies, but mscms owns container layout, tag addressing, and v2/v4 version handling. Public `IccProfileParser::LoadFromFile` API and `IccProfileData` struct are unchanged. Engine link list gains `mscms.lib`. | Day 10 | | 50 | Refresh-rate-driven render loop (60\u2013240 Hz) | Render `DispatcherQueueTimer` interval is now derived from the active monitor's `dmDisplayFrequency` (via `MonitorFromWindow` \u2192 `GetMonitorInfoW` \u2192 `EnumDisplaySettingsW`), clamped to [60, 240] Hz, refreshed on every display change. 120 / 144 / 165 / 240 Hz panels and high-FPS video sources run at native cadence. Interval is set in microseconds so non-integer-ms periods stay accurate. | Day 10 | @@ -68,17 +68,36 @@ --- +> **Numbering note:** entries **#64–67 were never written** — the gap is original to +> this file's creation, not lost content. That stretch of work (the v1.6.x "Phase 8 +> GPU-binding" release: `CustomComputeBridgeEffect`, `BytecodeCache` + disk +> persistence, the evaluator's GPU-SRV binding hookup, the ICtCp Tone Map compute +> migration, and the Vectorscope / Waveform Monitor removal) is documented in +> `CHANGELOG.md` §1.6.0 instead. Numbering resumes at #68; cross-references to +> #68+ elsewhere in the docs are correct. +| # | Decision | Rationale | Date | +|---|----------|-----------|------| | 68 | Render-engine worker thread + offscreen-blit composition + per-output cross-thread sinks | Heavy graph evaluation (4K HDR video → ICtCp tone-map chain → analysis effects) on the UI dispatcher was starving input event delivery: dropdown highlights, hover, click latency all visibly stalled when graph eval hit ~10 fps. The migration moves all D3D11/D2D work to a dedicated `RenderWorker` `std::jthread`. **Architectural pivot from the original plan**: P11 ("Present main swap chain from render thread") turned out to be infeasible — `IDXGISwapChain1::Present1` on a chain bound to a XAML `SwapChainPanel` throws `RPC_E_WRONG_THREAD` from a render-thread MTA, even with multi-threaded D2D + D3D11 multithread protection. The XAML composition integration path is STA-bound. **Replacement**: double-buffered offscreen render — worker draws into `ID3D11Texture2D`s wrapped as `ID2D1Bitmap1`s on the multi-threaded engine D2D device, publishes `m_offscreenPublishedIdx` atomically; UI thread blits the latest published buffer to the SwapChainPanel-bound chain via its OWN D2D context (created from the same multi-threaded D2D device + same D3D11 device with `MultithreadProtected`). UI Present cost is now an FP16 copy-blit + Present1 — sub-millisecond, never blocked by eval. **MCP routing**: `GuiEngineCommandSink::Dispatch` marshals to `m_renderDispatcher.DispatchSync` (render thread) instead of UI thread. `m_graph` is single-writer, single-reader on the worker; the dispatcher drains queued closures BEFORE each per-tick body so a closure runs while the worker is implicitly paused. **Output windows (P12)**: cross-thread `OutputSinkRenderState` shared_ptr per Output node. Render thread iterates a snapshot of `m_outputSinks` and produces image-native-size offscreens with a buffer-generation handshake; UI thread does fit-to-panel transform + Present1 per output window. MCP `/graph/apply effect=Output` round-trips Output nodes; `OnNodeAdded` auto-spawns a window. **Pixel trace (P13)**: `PopulatePixelTraceTree` (UI tick) and `/render/pixel-trace` (MCP) both `m_renderDispatcher.DispatchSync` the `BuildTrace`/`ReTrace` call, using `RenderD2DContext()` and walking `m_graph` while the worker is paused. **Result**: FPS-counter dropdown latency stays well under 50 ms even when graph eval runs at 10 fps; complex HDR tone-map graphs (Video + Clock + Scale + Lum + HDR Tone Map + ICtCp suite + Split Comparison) hold steady around 15-18 fps on T1200 with no input drops. | Day 13 | --- +| # | Decision | Rationale | Date | +|---|----------|-----------|------| | 69 | Native dependencies become git submodules; `Bootstrap.ps1` + the `Ensure*` download scripts retired | `exprtk` and `miniz` were acquired by imperative PowerShell that ran as an MSBuild pre-build step and downloaded from the network on first build (`scripts/EnsureExprTk.ps1` pulled `exprtk.hpp` from `raw.githubusercontent.com/master` — an **unpinned floating reference**; `scripts/EnsureMiniz.ps1` pulled the miniz 3.0.2 release zip). `third_party/` was gitignored wholesale, so the actual dependency versions were invisible to `git` and unreproducible across machines and time. Both are now submodules pinned to explicit commits: `third_party/exprtk` at `1e4a80b`, `third_party/miniz` at tag `3.1.2` (a deliberate bump from the 3.0.2 the script fetched, since `.effectgraph` archives can come from untrusted sources). **miniz wrinkle**: the git tree is *not* what the release zip contains. Upstream ships split sources (`miniz.c` / `miniz_tdef.c` / `miniz_tinfl.c` / `miniz_zip.c`) and `miniz.h` unconditionally `#include`s `miniz_export.h`, which CMake's `generate_export_header()` produces and which is absent from the repo; miniz's own `amalgamate.sh` substitutes an empty `#define MINIZ_EXPORT` when generating the single-file release pair. Rather than drag a CMake toolchain into an MSBuild-only repo, we vendor the same substitution as a 3-line `third_party/miniz_export.h` and put `third_party\` on the include path. Only three of the four sources are compiled — `miniz_zip.c` is omitted because `EffectGraphFile.cpp` writes the ZIP container itself and uses only `tdefl_compress_mem_to_heap` / `tinfl_decompress_mem_to_heap` / `mz_free`. **Trade-off accepted**: the old scripts self-healed a fresh clone (build → auto-download), whereas a clone missing `--recurse-submodules` now *fails*. Mitigated by a `VerifySubmodules` MSBuild target that errors with the exact `git submodule update --init --recursive` command instead of a wall of missing-header diagnostics. `Bootstrap.ps1`'s three jobs are now covered elsewhere: the dev cert by the existing `EnsureDevSigningCertificate` target in `ShaderLab.vcxproj`, ExprTk by the submodule, NuGet restore by VS/CI — so it was deleted along with both `Ensure*` download scripts. CI's `bootstrap-smoke` job (decision #56) becomes `clean-clone-smoke`, which still guards the onboarding cliff by running the documented submodule-init command explicitly rather than using `actions/checkout`'s `submodules:` input. | Day 14 | --- +| # | Decision | Rationale | Date | +|---|----------|-----------|------| | 70 | UI-thread graph reads move to `GraphUiSnapshot`; two shipped access violations were one race | Two intermittent crashes — one reproducing under MCP-driven graph churn (3 of 4 full `Tests/RunTests.ps1` runs died), one when dragging the window between monitors (4 of 7) — turned out to be **the same bug**. Resolving both WER fault offsets against the shipped PDB with DbgHelp (no debugger is installed on the dev box; see the recipe in the crash-triage notes) gave `0x9A74` → `NodeGraphController::RenderNodes` at `NodeGraphController.cpp:1448` and `0x11D2C` → `std::_Tree::_Find` in `xtree` — the *same* `node->properties.find(L"Value")`, once inlined and once not. **Root cause**: `NodeGraphController` held a pointer to the live `EffectGraph` and dereferenced `EffectNode`s on the **UI thread** during canvas paint and hit-testing, while the render worker mutated that graph continuously — `node.properties[...] =` inserts every tick for clock nodes (`MainWindow.RenderTick.cpp:230,256`) plus every MCP closure. A paint landing mid-mutation walks a `std::map` being rebalanced, or a node `graph_clear` just destroyed. No synchronization existed between the two threads. **Fix**: the controller now reads the per-frame `GraphUiSnapshot` that the render worker already published (`BuildGraphUiSnapshot` → `m_uiGraphSnapshot`, decision #68) — a mechanism that existed, was documented at `MainWindow::CurrentGraphSnapshot`, and **had zero callers**. `Render`/`RenderEdges`/`RenderNodes` take one snapshot per paint (so edges and nodes are mutually consistent and pointers stay valid); `HitTestEdge` and `SelectAll` take one per call. `UpdateSliderDrag` was worse than a read — it wrote `node->clockTime` / `properties` / `dirty` straight from the pointer handler — and now routes through `RenderThreadDispatcher::DispatchSync` like `UpdateDragNodes` already did. **Two locks, not one — learned the hard way.** The first attempt used a single `MainWindow::m_graphMutex` (`std::shared_mutex`, worker exclusive / UI paint shared) and did fix both crashes. But that one lock was silently covering *two* distinct races: the live-graph derefs **and** `m_visuals`, which `RebuildLayout` clears and refills from the render thread (via the `OnNodeChanged` hook) while the UI paint iterates it. Migrating the paint to snapshots and then dropping the paint's lock re-exposed the second race — 4 of 5 display-change runs crashed, now at `RenderNodes+0x6C`, the loop header rather than a `properties.find`. So `m_visuals` gets its own `NodeGraphController::m_visualsMutex`, deliberately fine-grained: reusing `m_graphMutex` would couple the canvas to the worker's whole tick, measured at ~0.6 ms idle but **~50 ms on a heavy graph** (4K source + 2K compute coverage), trading a crash for a visible stall. `m_graphMutex` stays as a backstop around the worker's drain/tick. **Lock-order rule** (documented at both declarations): the render thread takes `m_graphMutex` → `m_visualsMutex`, so UI code must never hold `m_visualsMutex` across a `DispatchSync`; `AddNode`/`DeleteSelected`/`UpdateDragNodes` dispatch the graph write first, then lock to update visuals. Two latent bugs fell out of applying that rule: `EndConnection` held `std::wstring` **references into `m_visuals` across a `DispatchSync`** (dangling if the worker rebuilt layout mid-wait) and `UpdateSliderDrag` read `m_visuals` from inside its dispatched closure, i.e. on the render thread; both now copy by value first. **Result**: 5/5 clean MCP suite runs (was 3/4 crashing), 7/7 on the display-change repro (was 4/7 crashing), 183 unit tests green. The three-path rule (UI reads → snapshot, writes → dispatcher, layout → live graph on the render thread only) is now written into `.github/copilot-instructions.md` and the `NodeGraphController.h` header, because the failure mode is an AV inside `std::map` rather than a compile error. | Day 14 | --- +| # | Decision | Rationale | Date | +|---|----------|-----------|------| +| 71 | MCP transport migrated from embedded HTTP to a stdio broker; the HTTP listener is deleted (**supersedes #31 and #58**) | The MCP server was a Winsock HTTP listener embedded in every host (decision #31, Day 5), moved engine-side with `IEngineCommandSink` in Phase 7 (decision #58, Day 12). It had three defects: **unaddressable** (it scanned 10 ports and published none, so a client config couldn't find a session and multiple ShaderLab windows were indistinguishable), **unauthenticated and in the clear** on a loopback socket with `Access-Control-Allow-Origin: *` (any local process could drive the graph and read `render_capture_node` image payloads), and the **wrong transport** for an ecosystem that expects stdio. The replacement, built over nine steps (`docs/development/mcp-stdio-migration.md`), is a stdio **shim** (`ShaderLabMcpBroker --stdio`, distributed unpackaged to `%LOCALAPPDATA%\ShaderLab\bin\` so it's update-immune) fronting a singleton named-pipe **hub** (packaged blind relay, activated on demand via `IApplicationActivationManager`; routes on a clear `{channelId, seq}` frame header while shim↔session bodies are sealed with ephemeral P-256 ECDH → HKDF-SHA256 → AES-256-GCM so the hub can never inspect a payload) to per-window **sessions** (engine-side, GUID-identified so `use_session` pins a stable graph across hub restarts). The engine keeps the pure routing surface (`McpRouter::{AddRoute,RouteRequest,HasRoute}`, the `McpJsonRpc` dispatcher, the declarative 39-tool `McpToolCatalog`); only the Winsock transport (`Start`/`Stop`/`Port`/`ListenerThread`/`HandleConnection`/`WSAStartup`/`ws2_32.lib`/CORS/`GET /`) is gone. **Security note, explicit:** the sealing is *not* a defence against a local attacker (same-user isolation is not a hard boundary on Windows) — it makes "the hub is plumbing" a property of the code rather than a convention. The one enforced boundary is binary pairing at hello: sessions must match by package family name (or, gated behind `SHADERLAB_MCP_ALLOW_UNPACKAGED=1` for dev/CI, same build id + shared config root); the unpackaged shim is accepted role-wise. Engine ABI **2 → 3**. Verified: 261 unit tests, broker smoke 26/26, headless smoke, `RunTests.ps1` (now shim-driven) 40/40 against the GUI and 21/21 against a headless session, on WARP. Grepping for `47808` / `WSA` returns nothing outside `CHANGELOG.md` and this log. | Day 14 | + +--- + Back to [docs/](../README.md) • [Repo root](../../README.md) \ No newline at end of file diff --git a/docs/hosts/headless.md b/docs/hosts/headless.md index 2ef6b8e..b2dd7a6 100644 --- a/docs/hosts/headless.md +++ b/docs/hosts/headless.md @@ -47,9 +47,11 @@ ShaderLabHeadless --graph PATH --node ID --output PNG_PATH [options] } ``` +- **MCP session** (`--mcp-session [--session-id GUID] [--session-label NAME] [--pipe BASE]`; stdio-migration Step 6). Loads a graph and registers with the broker hub as a **session**, so a shim-fronted MCP client selects it with `use_session` and drives it through the sealed relay. `initialize` / `tools/list` / `tools/call` / `resources/*` all work with no GUI; requests arrive as sealed channel frames, get routed through the router's `POST /` dispatcher, and the response is sealed back. Tools whose backing route is GUI-only (snapshot/view/gpu/perf/logs) return an `isError` "Tool not available on this host" result. `--session-id` is a persisted per-window GUID (a fresh one is generated when omitted); `--session-label` is what `list_sessions` surfaces (headless labels contain "headless", which the test suite uses to self-skip GUI-only tests). Reconnects to the hub with backoff after a drop. This is what CI's "MCP suite vs headless session" step drives, and the headless half of `RunBrokerSmoke.ps1`. (The Step 3 `--serve` HTTP mode was removed with the rest of the HTTP transport in Step 9.) + ## Engine-side reuse -The MCP route registry (`RegisterEngineRoutes`) is what backs both the GUI host's HTTP server **and** the headless `--script` mode. The same closures execute against the same engine state — only the sink's `Dispatch` impl differs between hosts. The GUI sink marshals to the render worker thread via `RenderThreadDispatcher::DispatchSync` (post-P7); the headless sink runs the closure inline since the script runner thread is the only consumer. The headless host overrides none of the eight `IEngineCommandSink` event hooks; without a UI to keep in sync, every hook is a no-op. +The MCP route registry (`RegisterEngineRoutes`) is what backs every host — the GUI window's session, the headless `--mcp-session`, and the headless `--script` mode all register the same routes. The same closures execute against the same engine state — only the sink's `Dispatch` impl differs between hosts. The GUI sink marshals to the render worker thread via `RenderThreadDispatcher::DispatchSync` (post-P7); the headless sink runs the closure inline since the script runner thread is the only consumer. The headless host overrides none of the eight `IEngineCommandSink` event hooks; without a UI to keep in sync, every hook is a no-op. ## Smoke coverage diff --git a/docs/hosts/mcp-server.md b/docs/hosts/mcp-server.md index f91fac7..972e682 100644 --- a/docs/hosts/mcp-server.md +++ b/docs/hosts/mcp-server.md @@ -1,52 +1,110 @@ # MCP Server (AI Agent Integration) -ShaderLab includes an embedded HTTP server implementing the **Model Context Protocol (MCP)** JSON-RPC 2.0 for programmatic control by AI agents. The server itself, plus 20 engine-pure routes, ships in the engine DLL — both the GUI host and `ShaderLabHeadless --script` mode register the same routes through the same `IEngineCommandSink` interface (see [Engine / Host Split](#engine--host-split)). +ShaderLab includes an embedded HTTP server implementing the **Model Context Protocol (MCP)** JSON-RPC 2.0 for programmatic control by AI agents. The full protocol surface ships in the engine DLL: the route registry + HTTP listener (`Engine/Mcp/McpRouter.{h,cpp}`), the JSON-RPC dispatcher (`McpJsonRpc.{h,cpp}` — `initialize`, `tools/*`, `resources/*`, `ping`), the declarative 39-tool catalog (`McpToolCatalog.{h,cpp}`), and **25 engine-pure routes**. Both hosts get the identical dispatcher via `RegisterJsonRpcEndpoint`; `ShaderLabHeadless --serve` therefore answers `tools/call` with no GUI at all (see [Engine / Host Split](../architecture/engine-host-split.md)). A further **16 app-side routes** (view/preview/GPU tools, `/context`, `/perf`, node logs) live in `MainWindow.McpRoutes.cpp`; calling a tool whose backing route is absent on the answering host returns an `isError` "Tool not available on this host" result. Handlers receive `(path, query, body)`; the router owns the query split, so `?since=`-style parameters work identically over HTTP, the tools ladder, and headless scripts. + +**Protocol version: `2025-06-18`.** Batch (JSON array) requests are rejected with `-32600` — 2025-06-18 removed batching from MCP, making it the first revision this server is actually conformant with. Responses are single-line JSON; notifications (absent `id`) produce no reply body (zero bytes on the wire). + +> **Transport migration in progress.** The HTTP transport described here is being +> replaced by a stdio shim + named-pipe broker so multiple ShaderLab windows +> become individually addressable. Plan, rationale, and status: +> [mcp-stdio-migration.md](../development/mcp-stdio-migration.md). Everything on +> this page describes the **current** (HTTP) behaviour. ## Connection -- Default port: **47808** (auto-increments if in use) -- Transport: Streamable HTTP (`POST /` for JSON-RPC) -- Enable: MCP toggle in toolbar, `--mcp` flag, or `config.json` +**Transport: stdio via the broker** (the embedded HTTP listener was removed in migration Step 9). ShaderLab copies its MCP shim to `%LOCALAPPDATA%\ShaderLab\bin\ShaderLabMcpBroker.exe` on launch and exposes each window as an MCP **session** via a singleton broker hub. Point your MCP client at that shim — the toolbar's **export button** copies a ready-to-paste config, and `Install.ps1` prints one after install: + +```json +{ "mcpServers": { "shaderlab": { + "command": "%LOCALAPPDATA%\\ShaderLab\\bin\\ShaderLabMcpBroker.exe", + "args": ["--stdio", "--hub-aumid", "!Hub"] } } } +``` + +- **The shim** (`--stdio`) is the client's front-end: it owns `initialize` + `list_sessions` / `use_session`, activates the packaged hub on demand, correlates ids, and splices the pinned session's tools into `tools/list`. Being unpackaged, it survives ShaderLab updates. +- **The hub** is a blind relay — it routes frames on a clear `{channelId, seq}` header and never holds a key; shim↔session bodies are sealed (P-256 ECDH → HKDF → AES-256-GCM). +- **Each window** registers as a GUID-identified session; `ShaderLabHeadless --mcp-session` registers a headless one. Call `list_sessions`, `use_session `, then drive the graph. +- Enable/disable per window via the MCP toolbar toggle, the `--mcp` flag, or `%LOCALAPPDATA%\ShaderLab\config.json`. -## Tools (27 total) +Full design + rationale: [MCP stdio migration](../development/mcp-stdio-migration.md). + +## Tools (39 total) + +### Graph structure | Tool | Description | |------|-------------| -| `graph_add_node` | Add built-in D2D or ShaderLab effect (placed at viewport center). | +| `graph_add_node` | Add built-in D2D or ShaderLab effect (placed at viewport center); `Video`/`Image` with `filePath` for sources. | | `graph_remove_node` | Remove a node. | | `graph_rename_node` | Rename a node. | -| `graph_connect` | Connect image pins. | -| `graph_disconnect` | Disconnect image pins. | -| `graph_set_property` | Set a node property. | -| `graph_get_node` | Get node details + analysis results. | -| `graph_save_json` | Serialize graph to JSON. | -| `graph_load_json` | Load graph from JSON. | -| `graph_clear` | Clear entire graph (keeps Output). | +| `graph_connect` / `graph_disconnect` | Connect / disconnect image pins. | +| `graph_apply` | Bulk patch in one call: add nodes (client refs), connect edges, set bindings. Returns refToId map. | +| `graph_clear` | Clear entire graph. | | `graph_overview` | Compact graph summary (nodes, edges, preview). | -| `graph_bind_property` | Bind property to upstream analysis field. | -| `graph_unbind_property` | Remove a property binding. | -| `effect_compile` | Compile HLSL (+ optional analysisFields). | +| `graph_get_node` | Node details incl. properties, pins, `propertyBindings`, analysis results. | +| `graph_save_json` / `graph_load_json` | Serialize / load the graph as JSON. | + +### Properties & bindings + +| Tool | Description | +|------|-------------| +| `graph_set_property` | Set a node property (number, bool, string, or array for vectors). | +| `graph_bind_property` / `graph_unbind_property` | Bind a property to an upstream analysis field (per-component or whole-array) / remove a binding. | + +### Shaders & effects + +| Tool | Description | +|------|-------------| +| `effect_compile` | Compile HLSL for a custom effect node (+ optional analysisFields). | +| `effect_get_hlsl` | Read a node's HLSL source, parameters, compile state, last runtime error. | +| `list_effects` | List all effects by category (engine route `GET /effects` since migration Step 1 — headless serves it too). | +| `registry_get_effect` | Get built-in effect metadata. | + +> `image_stats` was removed in migration Step 1. It had been advertised long after +> decision #63 retired its route, and longest-prefix routing turned calls into an +> HTTP-200 "success" wrapping a JSON-RPC error. Use a Statistics node + +> `read_analysis_output` instead. + +### Rendering & readback + +| Tool | Description | +|------|-------------| | `set_preview_node` | Set which node is previewed. | | `render_capture` | Capture preview as PNG (HDR clipped to SDR). | -| `registry_get_effect` | Get built-in effect metadata. | +| `render_capture_node` | Capture any node's resolved output as PNG; `inline=true` returns MCP image content. | | `read_analysis_output` | Read typed analysis fields from a compute / analysis / parameter node. | +| `read_pixel_region` | FP32 RGBA region readback (scRGB linear), capped 32×32. | | `read_pixel_trace` | Pixel trace at normalized coords (per-node values). | -| `list_effects` | List all effects by category. | -| `get_display_info` | Display caps, active profile, pipeline, app version. | -| `node_logs` | Per-node timestamped info / warning / error log entries. | + +### Display & environment + +| Tool | Description | +|------|-------------| +| `get_display_info` | Display caps, active profile, pipeline, app version (engine route `GET /display/info` since migration Step 2 — headless serves it too). | +| `list_display_profiles` | Built-in presets + currently active simulated/live profile. | +| `set_display_profile` | Apply a simulated profile (preset / presetIndex / iccPath / custom spec). | +| `clear_simulated_profile` | Revert to the live OS-reported profile. | +| `list_gpus` | Enumerate DXGI adapters (active adapter, LUID, VRAM, isWarp). | +| `switch_gpu` | Switch adapter (`warp` / `default` / `adapter` by LUID or name substring). Full save–teardown–reload cycle. | + +### Editor view & diagnostics (GUI host only) + +| Tool | Description | +|------|-------------| +| `graph_snapshot` | PNG snapshot of the live node-graph editor view; `inline=true` returns MCP image content. | +| `graph_get_view` / `graph_set_view` / `graph_fit_view` | Read / set / fit the editor's zoom + pan. | +| `preview_get_view` / `preview_set_view` / `preview_fit_view` | Read / set / fit the preview pane's zoom + pan. | +| `node_logs` | Per-node timestamped info / warning / error log entries (`sinceSeq` for incremental reads). | | `perf_timings` | Per-node evaluation timings from the most recent frame. | -| `graph_snapshot` | PNG snapshot of the live node-graph editor view. With `inline=true` returns image bytes as MCP image content; otherwise returns the temp file path. | -| `graph_get_view` | Read the editor's current zoom, pan, viewport size, and content bounds. | -| `graph_set_view` | Apply `{zoom?, panX?, panY?}` to the live editor — same effect as user pan/zoom input. | -| `graph_fit_view` | Fit the editor view to all nodes with a viewport-space `padding` (DIPs, default 40). | + +Resources: `shaderlab://context`, `shaderlab://graph`, `shaderlab://registry/effects`, `shaderlab://custom-effects` via `resources/list` / `resources/read`. ## Known Limitations - **Compile-before-connect**: First-time compile of a compute shader node that's already connected to the render pipeline crashes D2D. Workaround: compile the shader while the node is disconnected, then wire it in. Recompiles of already-compiled nodes work fine. - **FP16 precision**: Analysis readback values show minor quantization (e.g., 0.1 → 0.099976) due to the D2D output buffer using 16-bit float precision. - **HLSL optimizer removes unreferenced cbuffer vars**: With `D3DCOMPILE_WARNINGS_ARE_ERRORS`, variables not referenced on ALL code paths are optimized out. Read all cbuffer vars at top of `main()` before branches. -- **ExprTk math-only subset**: Numeric Expression has the regex / IO / enhanced subsystems disabled (see [Numeric Expression Node](#numeric-expression-node-exprtk)). Expressions must produce finite scalar `float` results — no strings, no file I/O, no vector return values. +- **ExprTk math-only subset**: Numeric Expression has the regex / IO / enhanced subsystems disabled. Expressions must produce finite scalar `float` results — no strings, no file I/O, no vector return values. --- -Back to [docs/](../README.md) • [Repo root](../../README.md) \ No newline at end of file +Back to [docs/](../README.md) • [Repo root](../../README.md) diff --git a/scripts/Install.ps1 b/scripts/Install.ps1 index 78403ea..819c9c3 100644 --- a/scripts/Install.ps1 +++ b/scripts/Install.ps1 @@ -84,3 +84,24 @@ if (Test-Path $depsDir) { # Install the main package. -AllowUnsigned needs Developer Mode. Add-AppxPackage -Path $MsixPath -AllowUnsigned -ForceApplicationShutdown Write-Host 'Installed. Launch ShaderLab from the Start menu.' -ForegroundColor Green + +# ---- MCP client config (stdio-migration Step 8) ------------------------- +# ShaderLab copies its MCP shim to %LOCALAPPDATA%\ShaderLab\bin\ on first +# launch, and exposes each window as an MCP session via the broker hub. Print +# a ready-to-paste stdio client config: the shim path is stable + unpackaged +# (immune to ShaderLab updates), and --hub-aumid lets it start the packaged +# hub on demand. Launch ShaderLab once so the shim is present before use. +try { + $pkg = Get-AppxPackage -Name 'ShaderLab' | Select-Object -First 1 + if ($pkg) { + $aumid = "$($pkg.PackageFamilyName)!Hub" + $shim = Join-Path $env:LOCALAPPDATA 'ShaderLab\bin\ShaderLabMcpBroker.exe' + $cfg = [ordered]@{ mcpServers = [ordered]@{ shaderlab = [ordered]@{ + command = $shim + args = @('--stdio', '--hub-aumid', $aumid) + } } } + Write-Host '' + Write-Host 'MCP client config (paste into your MCP client after launching ShaderLab once):' -ForegroundColor Cyan + Write-Host ($cfg | ConvertTo-Json -Depth 6) + } +} catch { } From 5bc47bd2207d68170972782197c807ab33a0379b Mon Sep 17 00:00:00 2001 From: David Spruill Date: Wed, 12 Aug 2026 01:22:14 -0400 Subject: [PATCH 3/6] Track broker project + Engine/Mcp transport sources missed by commit -am The prior commit (9f0dfd8) used -am, which skips untracked files, so the entire ShaderLabMcpBroker project and the Engine/Mcp transport TUs (crypto, frame, channel, session client, JSON-RPC dispatcher, tool catalog, types/timeouts) plus RunBrokerSmoke.ps1 were left out -- the pushed tree could not build and omitted the tools/list_changed shim fix. Add them. Co-Authored-By: Claude Opus 4.8 --- Engine/Mcp/McpChannel.cpp | 68 ++ Engine/Mcp/McpChannel.h | 69 ++ Engine/Mcp/McpCrypto.cpp | 340 +++++++++ Engine/Mcp/McpCrypto.h | 129 ++++ Engine/Mcp/McpFrame.cpp | 82 +++ Engine/Mcp/McpFrame.h | 72 ++ Engine/Mcp/McpJsonRpc.cpp | 347 +++++++++ Engine/Mcp/McpJsonRpc.h | 71 ++ Engine/Mcp/McpPeerIdentity.cpp | 179 +++++ Engine/Mcp/McpPeerIdentity.h | 87 +++ Engine/Mcp/McpSessionClient.cpp | 251 +++++++ Engine/Mcp/McpSessionClient.h | 64 ++ Engine/Mcp/McpTimeouts.h | 44 ++ Engine/Mcp/McpToolCatalog.cpp | 163 ++++ Engine/Mcp/McpToolCatalog.h | 48 ++ Engine/Mcp/McpTypes.h | 93 +++ ShaderLabMcpBroker.vcxproj | 138 ++++ ShaderLabMcpBroker/Main.cpp | 1223 +++++++++++++++++++++++++++++++ Tests/RunBrokerSmoke.ps1 | 221 ++++++ 19 files changed, 3689 insertions(+) create mode 100644 Engine/Mcp/McpChannel.cpp create mode 100644 Engine/Mcp/McpChannel.h create mode 100644 Engine/Mcp/McpCrypto.cpp create mode 100644 Engine/Mcp/McpCrypto.h create mode 100644 Engine/Mcp/McpFrame.cpp create mode 100644 Engine/Mcp/McpFrame.h create mode 100644 Engine/Mcp/McpJsonRpc.cpp create mode 100644 Engine/Mcp/McpJsonRpc.h create mode 100644 Engine/Mcp/McpPeerIdentity.cpp create mode 100644 Engine/Mcp/McpPeerIdentity.h create mode 100644 Engine/Mcp/McpSessionClient.cpp create mode 100644 Engine/Mcp/McpSessionClient.h create mode 100644 Engine/Mcp/McpTimeouts.h create mode 100644 Engine/Mcp/McpToolCatalog.cpp create mode 100644 Engine/Mcp/McpToolCatalog.h create mode 100644 Engine/Mcp/McpTypes.h create mode 100644 ShaderLabMcpBroker.vcxproj create mode 100644 ShaderLabMcpBroker/Main.cpp create mode 100644 Tests/RunBrokerSmoke.ps1 diff --git a/Engine/Mcp/McpChannel.cpp b/Engine/Mcp/McpChannel.cpp new file mode 100644 index 0000000..c566be1 --- /dev/null +++ b/Engine/Mcp/McpChannel.cpp @@ -0,0 +1,68 @@ +#include "pch_engine.h" +#include "McpChannel.h" + +namespace ShaderLab::Mcp +{ + std::array ChannelAad(uint32_t channelId, uint64_t seq) + { + std::array aad{}; + for (int i = 0; i < 4; ++i) aad[i] = static_cast(channelId >> (8 * i)); + for (int i = 0; i < 8; ++i) aad[4 + i] = static_cast(seq >> (8 * i)); + return aad; + } + + std::optional SecureChannel::Create(bool initiator) + { + auto keys = EcdhKeyPair::Generate(); + if (!keys) + return std::nullopt; + SecureChannel ch; + ch.m_initiator = initiator; + ch.m_helloBody = keys->PublicBlob(); + ch.m_keys = std::move(*keys); + return ch; + } + + bool SecureChannel::OnPeerHello(std::span peerHelloBody) + { + if (Ready()) + return true; + if (!m_keys || peerHelloBody.empty()) + return false; + + auto sk = DeriveSessionKeys(*m_keys, peerHelloBody, m_initiator); + if (!sk) + return false; + auto send = GcmChannel::Create(sk->sendKey); + auto recv = GcmChannel::Create(sk->recvKey); + if (!send || !recv) + return false; + m_send = std::move(*send); + m_recv = std::move(*recv); + return true; + } + + std::optional> SecureChannel::Seal( + uint32_t channelId, uint64_t seq, std::span plaintext) const + { + if (!m_send) + return std::nullopt; + auto aad = ChannelAad(channelId, seq); + std::vector out; + if (!m_send->Seal(seq, aad, plaintext, out)) + return std::nullopt; + return out; + } + + std::optional> SecureChannel::Open( + uint32_t channelId, uint64_t seq, std::span body) const + { + if (!m_recv) + return std::nullopt; + auto aad = ChannelAad(channelId, seq); + std::vector out; + if (!m_recv->Open(seq, aad, body, out)) + return std::nullopt; + return out; + } +} diff --git a/Engine/Mcp/McpChannel.h b/Engine/Mcp/McpChannel.h new file mode 100644 index 0000000..0486fa5 --- /dev/null +++ b/Engine/Mcp/McpChannel.h @@ -0,0 +1,69 @@ +#pragma once + +// Per-channel secure state for the broker relay (stdio-migration Step 6). +// +// A "channel" is one shim↔session conversation multiplexed over the hub +// by {channelId}. The hub relays channel frames blindly; the two +// ENDPOINTS run a SecureChannel each to seal/open bodies, so the hub +// never holds a key. This class is the single home of that handshake + +// AEAD framing so the shim (broker, initiator) and the session client +// (engine, acceptor) cannot drift — both compile this TU. +// +// Frame body sub-protocol on a channel (channelId > 0): +// seq == 0 : HANDSHAKE. body = the sender's P-256 public blob, CLEAR. +// Both ends send one; keys derive once both are seen. +// seq >= 1 : DATA. body = AES-256-GCM(seq, aad = {channelId,seq}, plaintext). +// Each direction has its own key + its own seq counter, so seq values +// repeat across directions harmlessly (that is why two keys are derived). + +#include "pch_engine.h" +#include "../../EngineExport.h" +#include "McpCrypto.h" + +#include +#include +#include + +namespace ShaderLab::Mcp +{ + class SHADERLAB_API SecureChannel + { + public: + // initiator = the side that opened the channel (the shim). + static std::optional Create(bool initiator); + + SecureChannel(SecureChannel&&) noexcept = default; + SecureChannel& operator=(SecureChannel&&) noexcept = default; + + // Our handshake frame body (public blob) — send it at seq 0. + const std::vector& HelloBody() const { return m_helloBody; } + + // Feed the peer's handshake body (their public blob). Derives the + // two direction keys. Idempotent-safe: a second call is ignored. + bool OnPeerHello(std::span peerHelloBody); + + bool Ready() const { return m_send.has_value() && m_recv.has_value(); } + + // Seal `plaintext` for a DATA frame at (channelId, seq>=1). + std::optional> Seal( + uint32_t channelId, uint64_t seq, std::span plaintext) const; + + // Open a received DATA frame body at (channelId, seq>=1). Fails + // (nullopt) on any tamper or a seq that doesn't match the sender's. + std::optional> Open( + uint32_t channelId, uint64_t seq, std::span body) const; + + private: + SecureChannel() = default; + bool m_initiator{ false }; + std::optional m_keys; + std::vector m_helloBody; + std::optional m_send; + std::optional m_recv; + }; + + // AAD for a channel DATA frame: channelId (LE u32) then seq (LE u64). + // Exposed so callers can keep the seal/open AAD identical to the wire + // header without duplicating the byte layout. + SHADERLAB_API std::array ChannelAad(uint32_t channelId, uint64_t seq); +} diff --git a/Engine/Mcp/McpCrypto.cpp b/Engine/Mcp/McpCrypto.cpp new file mode 100644 index 0000000..9e88b28 --- /dev/null +++ b/Engine/Mcp/McpCrypto.cpp @@ -0,0 +1,340 @@ +#include "pch_engine.h" +#include "McpCrypto.h" + +#include +#pragma comment(lib, "bcrypt.lib") + +namespace ShaderLab::Mcp +{ + namespace + { + constexpr const char* kHkdfSalt = "ShaderLab.mcp.v1"; + constexpr const char* kInfoInitiatorToAcceptor = "ShaderLab.mcp.v1 i2a"; + constexpr const char* kInfoAcceptorToInitiator = "ShaderLab.mcp.v1 a2i"; + + bool Ok(NTSTATUS s) { return s >= 0; } + + std::span Bytes(const char* s) + { + return { reinterpret_cast(s), strlen(s) }; + } + + // HMAC-SHA256 of (data1 || data2 || data3) under `key`. + bool HmacSha256(std::span key, + std::span data1, + std::span data2, + std::span data3, + std::span out32) + { + if (out32.size() != 32) return false; + BCRYPT_ALG_HANDLE alg{}; + if (!Ok(BCryptOpenAlgorithmProvider(&alg, BCRYPT_SHA256_ALGORITHM, + nullptr, BCRYPT_ALG_HANDLE_HMAC_FLAG))) + return false; + BCRYPT_HASH_HANDLE hash{}; + bool ok = Ok(BCryptCreateHash(alg, &hash, nullptr, 0, + const_cast(key.data()), static_cast(key.size()), 0)); + if (ok && !data1.empty()) + ok = Ok(BCryptHashData(hash, const_cast(data1.data()), + static_cast(data1.size()), 0)); + if (ok && !data2.empty()) + ok = Ok(BCryptHashData(hash, const_cast(data2.data()), + static_cast(data2.size()), 0)); + if (ok && !data3.empty()) + ok = Ok(BCryptHashData(hash, const_cast(data3.data()), + static_cast(data3.size()), 0)); + if (ok) + ok = Ok(BCryptFinishHash(hash, out32.data(), 32, 0)); + if (hash) BCryptDestroyHash(hash); + BCryptCloseAlgorithmProvider(alg, 0); + return ok; + } + } + + // ---- HKDF-SHA256 (RFC 5869) ------------------------------------------- + bool HkdfSha256(std::span ikm, + std::span salt, + std::span info, + std::span okm) + { + if (okm.empty() || okm.size() > 255 * 32) + return false; + + // Extract: PRK = HMAC(salt, IKM). RFC: an absent salt is a + // 32-byte zero string for SHA-256. + std::array zeroSalt{}; + std::span saltUse = salt.empty() + ? std::span(zeroSalt.data(), zeroSalt.size()) : salt; + std::array prk{}; + if (!HmacSha256(saltUse, ikm, {}, {}, prk)) + return false; + + // Expand: T(i) = HMAC(PRK, T(i-1) || info || i), i from 1. + std::array t{}; + size_t written = 0; + uint8_t counter = 1; + while (written < okm.size()) + { + std::span prev = (counter == 1) + ? std::span{} + : std::span(t.data(), t.size()); + uint8_t ctrByte[1] = { counter }; + if (!HmacSha256(prk, prev, info, { ctrByte, 1 }, t)) + return false; + const size_t take = std::min(32, okm.size() - written); + memcpy(okm.data() + written, t.data(), take); + written += take; + ++counter; + } + return true; + } + + // ---- EcdhKeyPair ------------------------------------------------------- + std::optional EcdhKeyPair::Generate() + { + EcdhKeyPair kp; + BCRYPT_ALG_HANDLE alg{}; + if (!Ok(BCryptOpenAlgorithmProvider(&alg, BCRYPT_ECDH_P256_ALGORITHM, nullptr, 0))) + return std::nullopt; + kp.m_alg = alg; + + BCRYPT_KEY_HANDLE key{}; + if (!Ok(BCryptGenerateKeyPair(alg, &key, 256, 0)) || + !Ok(BCryptFinalizeKeyPair(key, 0))) + { + if (key) BCryptDestroyKey(key); + return std::nullopt; // kp's dtor closes the provider + } + kp.m_key = key; + + // Export the public blob (BCRYPT_ECCKEY_BLOB header + X || Y). + ULONG len = 0; + if (!Ok(BCryptExportKey(key, nullptr, BCRYPT_ECCPUBLIC_BLOB, nullptr, 0, &len, 0))) + return std::nullopt; + kp.m_publicBlob.resize(len); + if (!Ok(BCryptExportKey(key, nullptr, BCRYPT_ECCPUBLIC_BLOB, + kp.m_publicBlob.data(), len, &len, 0))) + return std::nullopt; + kp.m_publicBlob.resize(len); + return kp; + } + + EcdhKeyPair::EcdhKeyPair(EcdhKeyPair&& o) noexcept + : m_alg(o.m_alg), m_key(o.m_key), m_publicBlob(std::move(o.m_publicBlob)) + { + o.m_alg = nullptr; + o.m_key = nullptr; + } + + EcdhKeyPair& EcdhKeyPair::operator=(EcdhKeyPair&& o) noexcept + { + if (this != &o) + { + this->~EcdhKeyPair(); + m_alg = o.m_alg; m_key = o.m_key; m_publicBlob = std::move(o.m_publicBlob); + o.m_alg = nullptr; o.m_key = nullptr; + } + return *this; + } + + EcdhKeyPair::~EcdhKeyPair() + { + if (m_key) BCryptDestroyKey(static_cast(m_key)); + if (m_alg) BCryptCloseAlgorithmProvider(static_cast(m_alg), 0); + m_key = nullptr; + m_alg = nullptr; + } + + std::optional> EcdhKeyPair::SharedSecret( + std::span peerPublicBlob) const + { + if (!m_alg || !m_key || peerPublicBlob.empty()) + return std::nullopt; + + BCRYPT_KEY_HANDLE peer{}; + if (!Ok(BCryptImportKeyPair(static_cast(m_alg), nullptr, + BCRYPT_ECCPUBLIC_BLOB, &peer, + const_cast(peerPublicBlob.data()), + static_cast(peerPublicBlob.size()), 0))) + return std::nullopt; + + BCRYPT_SECRET_HANDLE secret{}; + std::optional> result; + if (Ok(BCryptSecretAgreement(static_cast(m_key), peer, &secret, 0))) + { + ULONG len = 0; + if (Ok(BCryptDeriveKey(secret, BCRYPT_KDF_RAW_SECRET, nullptr, nullptr, 0, &len, 0))) + { + std::vector raw(len); + if (Ok(BCryptDeriveKey(secret, BCRYPT_KDF_RAW_SECRET, nullptr, + raw.data(), len, &len, 0))) + { + raw.resize(len); + // CNG trap: BCRYPT_KDF_RAW_SECRET hands the shared + // secret back byte-REVERSED (little-endian). Flip it + // to the conventional big-endian form. + std::reverse(raw.begin(), raw.end()); + result = std::move(raw); + } + } + BCryptDestroySecret(secret); + } + BCryptDestroyKey(peer); + return result; + } + + // ---- Session key derivation ------------------------------------------- + std::optional DeriveSessionKeys( + const EcdhKeyPair& mine, + std::span peerPublicBlob, + bool isInitiator) + { + auto secret = mine.SharedSecret(peerPublicBlob); + if (!secret) + return std::nullopt; + + std::array i2a{}, a2i{}; + if (!HkdfSha256(*secret, Bytes(kHkdfSalt), Bytes(kInfoInitiatorToAcceptor), i2a) || + !HkdfSha256(*secret, Bytes(kHkdfSalt), Bytes(kInfoAcceptorToInitiator), a2i)) + return std::nullopt; + + SessionKeys keys; + keys.sendKey = isInitiator ? i2a : a2i; + keys.recvKey = isInitiator ? a2i : i2a; + return keys; + } + + // ---- GcmChannel -------------------------------------------------------- + std::optional GcmChannel::Create(std::span key32) + { + if (key32.size() != kSessionKeyBytes) + return std::nullopt; + + GcmChannel ch; + BCRYPT_ALG_HANDLE alg{}; + if (!Ok(BCryptOpenAlgorithmProvider(&alg, BCRYPT_AES_ALGORITHM, nullptr, 0))) + return std::nullopt; + ch.m_alg = alg; + + if (!Ok(BCryptSetProperty(alg, BCRYPT_CHAINING_MODE, + reinterpret_cast(const_cast(BCRYPT_CHAIN_MODE_GCM)), + sizeof(BCRYPT_CHAIN_MODE_GCM), 0))) + return std::nullopt; + + ULONG objLen = 0, cb = 0; + if (!Ok(BCryptGetProperty(alg, BCRYPT_OBJECT_LENGTH, + reinterpret_cast(&objLen), sizeof(objLen), &cb, 0))) + return std::nullopt; + ch.m_keyObject.resize(objLen); + + BCRYPT_KEY_HANDLE key{}; + if (!Ok(BCryptGenerateSymmetricKey(alg, &key, + ch.m_keyObject.data(), objLen, + const_cast(key32.data()), static_cast(key32.size()), 0))) + return std::nullopt; + ch.m_key = key; + return ch; + } + + GcmChannel::GcmChannel(GcmChannel&& o) noexcept + : m_alg(o.m_alg), m_key(o.m_key), m_keyObject(std::move(o.m_keyObject)) + { + o.m_alg = nullptr; + o.m_key = nullptr; + } + + GcmChannel& GcmChannel::operator=(GcmChannel&& o) noexcept + { + if (this != &o) + { + this->~GcmChannel(); + m_alg = o.m_alg; m_key = o.m_key; m_keyObject = std::move(o.m_keyObject); + o.m_alg = nullptr; o.m_key = nullptr; + } + return *this; + } + + GcmChannel::~GcmChannel() + { + if (m_key) BCryptDestroyKey(static_cast(m_key)); + if (m_alg) BCryptCloseAlgorithmProvider(static_cast(m_alg), 0); + m_key = nullptr; + m_alg = nullptr; + } + + namespace + { + std::array NonceFromSeq(uint64_t seq) + { + // 4 zero bytes + 8-byte LE seq. Per-direction keys make this + // safe; the seq binding makes desync an auth failure. + std::array n{}; + for (int i = 0; i < 8; ++i) + n[4 + i] = static_cast(seq >> (8 * i)); + return n; + } + } + + bool GcmChannel::Seal(uint64_t seq, + std::span aad, + std::span plaintext, + std::vector& out) const + { + if (!m_key) return false; + auto nonce = NonceFromSeq(seq); + std::array tag{}; + + BCRYPT_AUTHENTICATED_CIPHER_MODE_INFO info; + BCRYPT_INIT_AUTH_MODE_INFO(info); + info.pbNonce = nonce.data(); + info.cbNonce = static_cast(nonce.size()); + info.pbAuthData = aad.empty() ? nullptr : const_cast(aad.data()); + info.cbAuthData = static_cast(aad.size()); + info.pbTag = tag.data(); + info.cbTag = static_cast(tag.size()); + + std::vector cipher(plaintext.size()); + ULONG written = 0; + if (!Ok(BCryptEncrypt(static_cast(m_key), + const_cast(plaintext.data()), static_cast(plaintext.size()), + &info, nullptr, 0, + plaintext.empty() ? nullptr : cipher.data(), + static_cast(cipher.size()), &written, 0))) + return false; + cipher.resize(written); + cipher.insert(cipher.end(), tag.begin(), tag.end()); + out = std::move(cipher); + return true; + } + + bool GcmChannel::Open(uint64_t seq, + std::span aad, + std::span cipherWithTag, + std::vector& out) const + { + if (!m_key || cipherWithTag.size() < kGcmTagBytes) return false; + auto nonce = NonceFromSeq(seq); + const size_t cipherLen = cipherWithTag.size() - kGcmTagBytes; + + BCRYPT_AUTHENTICATED_CIPHER_MODE_INFO info; + BCRYPT_INIT_AUTH_MODE_INFO(info); + info.pbNonce = nonce.data(); + info.cbNonce = static_cast(nonce.size()); + info.pbAuthData = aad.empty() ? nullptr : const_cast(aad.data()); + info.cbAuthData = static_cast(aad.size()); + info.pbTag = const_cast(cipherWithTag.data() + cipherLen); + info.cbTag = kGcmTagBytes; + + std::vector plain(cipherLen); + ULONG written = 0; + if (!Ok(BCryptDecrypt(static_cast(m_key), + const_cast(cipherWithTag.data()), static_cast(cipherLen), + &info, nullptr, 0, + cipherLen == 0 ? nullptr : plain.data(), + static_cast(plain.size()), &written, 0))) + return false; + plain.resize(written); + out = std::move(plain); + return true; + } +} diff --git a/Engine/Mcp/McpCrypto.h b/Engine/Mcp/McpCrypto.h new file mode 100644 index 0000000..f9c9a7c --- /dev/null +++ b/Engine/Mcp/McpCrypto.h @@ -0,0 +1,129 @@ +#pragma once + +// Session crypto for the MCP broker pipe (stdio-migration Step 4). +// +// Ephemeral P-256 ECDH -> HKDF-SHA256 -> AES-256-GCM, all via BCrypt — +// no new dependency. X25519 was rejected: CNG named-curve support is +// unverified at the manifest's declared 10.0.17763 floor and buys +// nothing against an empty threat model. +// +// WHY ENCRYPT AT ALL: not defence against a local attacker (same-user +// isolation is not a hard boundary on Windows, and this must never be +// claimed otherwise). It is architectural: the hub relays frames whose +// bodies it cannot log, cache, dump or inspect because it never holds a +// key — and that stays true when someone later adds diagnostics to the +// relay. A ~33 MB inline capture never enters the address space of the +// component most likely to be crash-dumping. +// +// Direction separation: the handshake derives TWO keys (initiator-> +// acceptor and acceptor->initiator) via HKDF info labels, so the GCM +// nonce can simply be the frame sequence number without cross-direction +// reuse. The frame's clear header travels as AAD, which is what turns a +// header tamper or a sequence desync into an authentication failure at +// Open() rather than silent misdelivery. + +#include "pch_engine.h" +#include "../../EngineExport.h" + +#include +#include +#include +#include +#include + +namespace ShaderLab::Mcp +{ + inline constexpr size_t kGcmTagBytes = 16; + inline constexpr size_t kSessionKeyBytes = 32; + + // Ephemeral P-256 key pair. Move-only RAII over the CNG handles; the + // private key never leaves the object. + class SHADERLAB_API EcdhKeyPair + { + public: + static std::optional Generate(); + + EcdhKeyPair(EcdhKeyPair&&) noexcept; + EcdhKeyPair& operator=(EcdhKeyPair&&) noexcept; + EcdhKeyPair(const EcdhKeyPair&) = delete; + EcdhKeyPair& operator=(const EcdhKeyPair&) = delete; + ~EcdhKeyPair(); + + // CNG BCRYPT_ECCPUBLIC_BLOB bytes. NOTE: the blob carries an + // 8-byte BCRYPT_ECCKEY_BLOB header ahead of the raw X||Y curve + // points — never size buffers against the bare curve width. + const std::vector& PublicBlob() const { return m_publicBlob; } + + // Raw ECDH shared secret with a peer's public blob, already + // corrected for the CNG trap: BCryptDeriveKey with + // BCRYPT_KDF_RAW_SECRET returns the secret byte-REVERSED + // (little-endian); this returns the conventional big-endian + // form so the HKDF input matches every other ECDH stack. + std::optional> SharedSecret( + std::span peerPublicBlob) const; + + private: + EcdhKeyPair() = default; + void* m_alg{ nullptr }; // BCRYPT_ALG_HANDLE + void* m_key{ nullptr }; // BCRYPT_KEY_HANDLE + std::vector m_publicBlob; + }; + + // HKDF-SHA256 (RFC 5869) over BCrypt HMAC primitives. Exposed so the + // unit tests can pin the RFC test vectors. + SHADERLAB_API bool HkdfSha256( + std::span ikm, + std::span salt, + std::span info, + std::span okm); + + struct SessionKeys + { + std::array sendKey{}; + std::array recvKey{}; + }; + + // Full key agreement: ECDH(mine, peer) -> HKDF-SHA256 with the + // protocol salt -> two direction keys. `isInitiator` resolves which + // derived key is send vs recv; the two sides of a handshake call + // this with opposite values and end up with mirrored keys. + SHADERLAB_API std::optional DeriveSessionKeys( + const EcdhKeyPair& mine, + std::span peerPublicBlob, + bool isInitiator); + + // One AES-256-GCM direction. Nonce = 4 zero bytes + 8-byte LE seq — + // safe because each direction has its own key, and binding the seq + // into the nonce (plus the clear frame header into the AAD) makes a + // desynchronized or replayed sequence fail authentication. + class SHADERLAB_API GcmChannel + { + public: + static std::optional Create(std::span key32); + + GcmChannel(GcmChannel&&) noexcept; + GcmChannel& operator=(GcmChannel&&) noexcept; + GcmChannel(const GcmChannel&) = delete; + GcmChannel& operator=(const GcmChannel&) = delete; + ~GcmChannel(); + + // out = ciphertext || 16-byte tag. + bool Seal(uint64_t seq, + std::span aad, + std::span plaintext, + std::vector& out) const; + + // Fails (returns false, out untouched) on any tamper: body, + // tag, AAD, or a seq that differs from the sealing seq. + bool Open(uint64_t seq, + std::span aad, + std::span cipherWithTag, + std::vector& out) const; + + private: + GcmChannel() = default; + void* m_alg{ nullptr }; // BCRYPT_ALG_HANDLE + void* m_key{ nullptr }; // BCRYPT_KEY_HANDLE + std::vector m_keyObject; // CNG key object storage + }; +} diff --git a/Engine/Mcp/McpFrame.cpp b/Engine/Mcp/McpFrame.cpp new file mode 100644 index 0000000..e7e65f9 --- /dev/null +++ b/Engine/Mcp/McpFrame.cpp @@ -0,0 +1,82 @@ +#include "pch_engine.h" +#include "McpFrame.h" + +namespace ShaderLab::Mcp +{ + namespace + { + void PutU32(std::vector& out, uint32_t v) + { + out.push_back(static_cast(v)); + out.push_back(static_cast(v >> 8)); + out.push_back(static_cast(v >> 16)); + out.push_back(static_cast(v >> 24)); + } + + void PutU64(std::vector& out, uint64_t v) + { + for (int i = 0; i < 8; ++i) + out.push_back(static_cast(v >> (8 * i))); + } + + uint32_t GetU32(const uint8_t* p) + { + return static_cast(p[0]) + | (static_cast(p[1]) << 8) + | (static_cast(p[2]) << 16) + | (static_cast(p[3]) << 24); + } + + uint64_t GetU64(const uint8_t* p) + { + uint64_t v = 0; + for (int i = 0; i < 8; ++i) + v |= static_cast(p[i]) << (8 * i); + return v; + } + } + + bool EncodeFrame(const Frame& frame, std::vector& out) + { + const uint64_t totalLen = kFrameHeaderBytes + frame.body.size(); + if (totalLen > kMaxFrameBytes) + return false; + + out.reserve(out.size() + 4 + static_cast(totalLen)); + PutU32(out, static_cast(totalLen)); + PutU32(out, frame.header.channelId); + PutU64(out, frame.header.seq); + out.insert(out.end(), frame.body.begin(), frame.body.end()); + return true; + } + + FrameDecodeResult TryDecodeFrame(std::span data) + { + FrameDecodeResult r; + if (data.size() < 4) + return r; // NeedMoreData + + const uint32_t totalLen = GetU32(data.data()); + if (totalLen > kMaxFrameBytes) + { + r.status = FrameDecodeStatus::Oversize; + return r; + } + if (totalLen < kFrameHeaderBytes) + { + r.status = FrameDecodeStatus::Malformed; + return r; + } + if (data.size() < 4ull + totalLen) + return r; // NeedMoreData — payload incomplete + + const uint8_t* p = data.data() + 4; + r.frame.header.channelId = GetU32(p); + r.frame.header.seq = GetU64(p + 4); + const size_t bodyLen = totalLen - kFrameHeaderBytes; + r.frame.body.assign(p + kFrameHeaderBytes, p + kFrameHeaderBytes + bodyLen); + r.consumed = 4ull + totalLen; + r.status = FrameDecodeStatus::Ok; + return r; + } +} diff --git a/Engine/Mcp/McpFrame.h b/Engine/Mcp/McpFrame.h new file mode 100644 index 0000000..84af219 --- /dev/null +++ b/Engine/Mcp/McpFrame.h @@ -0,0 +1,72 @@ +#pragma once + +// Wire frame codec for the MCP broker pipe (stdio-migration Step 4). +// +// Layout, little-endian throughout: +// +// [u32 totalLen][u32 channelId][u64 seq][body bytes ...] +// ^ prefix ^------- header -------^^--- payload --^ +// +// totalLen counts everything AFTER the prefix (12 header bytes + body). +// The header travels IN THE CLEAR — the hub legitimately routes on +// {channelId, seq} — while the body is sealed by McpCrypto once a +// session is established. That split is deliberately explicit in the +// type (clear FrameHeader vs opaque body) so it cannot drift: nothing +// the hub needs is ever inside the sealed portion, and nothing sealed +// is ever readable by the hub. +// +// Frames are capped at 64 MB. A 4K inline capture is ~33 MB of base64; +// 8K would be ~130 MB, so producers must cap resolution server-side — +// the codec fails EXPLICITLY (Oversize) rather than desyncing the +// stream by half-consuming a giant length prefix. + +#include "pch_engine.h" +#include "../../EngineExport.h" + +#include +#include +#include + +namespace ShaderLab::Mcp +{ + inline constexpr size_t kFrameHeaderBytes = 12; // channelId + seq + inline constexpr size_t kMaxFrameBytes = 64ull * 1024 * 1024; // totalLen ceiling + + struct FrameHeader + { + uint32_t channelId{ 0 }; + uint64_t seq{ 0 }; + }; + + struct Frame + { + FrameHeader header; // clear — the hub routes on this + std::vector body; // sealed once a session key exists + }; + + // Appends the encoded frame to `out`. Returns false (appending + // nothing) if the body would exceed the frame cap. + SHADERLAB_API bool EncodeFrame(const Frame& frame, std::vector& out); + + enum class FrameDecodeStatus : uint8_t + { + Ok, // one complete frame decoded; `consumed` bytes eaten + NeedMoreData, // prefix or payload incomplete; nothing consumed + Oversize, // declared totalLen exceeds kMaxFrameBytes — the + // stream is poisoned; the caller must drop the + // connection, not try to resynchronize + Malformed, // declared totalLen too small to hold the header + }; + + struct FrameDecodeResult + { + FrameDecodeStatus status{ FrameDecodeStatus::NeedMoreData }; + Frame frame; // valid only when status == Ok + size_t consumed{ 0 }; // bytes eaten from the buffer front + }; + + // Attempts to decode one frame from the front of `data`. Never + // consumes on any status other than Ok, so the caller's accumulation + // buffer stays coherent across partial reads. + SHADERLAB_API FrameDecodeResult TryDecodeFrame(std::span data); +} diff --git a/Engine/Mcp/McpJsonRpc.cpp b/Engine/Mcp/McpJsonRpc.cpp new file mode 100644 index 0000000..3b2e746 --- /dev/null +++ b/Engine/Mcp/McpJsonRpc.cpp @@ -0,0 +1,347 @@ +#include "pch_engine.h" +#include "McpJsonRpc.h" +#include "McpRouter.h" +#include "McpToolCatalog.h" +#include "../../Version.h" + +#include +#include + +namespace ShaderLab::Mcp +{ + namespace WDJ = winrt::Windows::Data::Json; + + namespace + { + // The one protocol revision this server implements. 2025-06-18 + // removed JSON-RPC batching from MCP — this server never accepted + // batches, so it is the first revision the implementation is + // actually conformant with (the old dispatcher pinned 2024-11-05, + // which required batching). Always answered regardless of the + // client's requested version; a client that cannot speak it is + // expected to disconnect per spec. + constexpr const char* kProtocolVersion = "2025-06-18"; + + std::string WrapResult(const std::string& idStr, const std::string& result) + { + return std::format( + R"JSON({{"jsonrpc":"2.0","id":{},"result":{}}})JSON", idStr, result); + } + + std::string WrapError(const std::string& idStr, int code, std::string_view message) + { + return std::format( + R"JSON({{"jsonrpc":"2.0","id":{},"error":{{"code":{},"message":"{}"}}}})JSON", + idStr, code, JsonEscape(message)); + } + + // Tool result envelope: MCP requires content[].text to be a STRING, + // so the route body is escaped through the shared JsonEscape (the + // old dispatcher's ad-hoc loop passed raw control characters from + // HLSL error text straight through, producing invalid JSON). + std::string TextToolResult(const std::string& body, bool isError) + { + return std::format( + R"JSON({{"content":[{{"type":"text","text":"{}"}}],"isError":{}}})JSON", + JsonEscape(body), isError ? "true" : "false"); + } + + // Error surfaced as a TOOL RESULT (isError:true), not a protocol + // error — per MCP, tool-level failures are results so the model + // can see them. + Response ToolErrorResult(const std::string& idStr, std::string_view message) + { + return { 200, WrapResult(idStr, TextToolResult(std::string(message), true)) }; + } + + // responseMode is a function of the RESPONSE, not the tool: repack + // as MCP image content only if the tool is flagged, the caller + // asked (inline == true), the route succeeded, the body re-parses, + // and it carries base64 + mimeType. Anything else falls through to + // the generic text wrapper. + bool TryImageRepack(const ToolDef& tool, const WDJ::JsonObject& args, + const Response& restResp, const std::string& idStr, + Response& out) + { + if (!tool.imageInline) return false; + if (!args.HasKey(L"inline")) return false; + auto v = args.GetNamedValue(L"inline"); + if (v.ValueType() != WDJ::JsonValueType::Boolean || !v.GetBoolean()) return false; + if (restResp.statusCode != 200) return false; + + WDJ::JsonObject ro{ nullptr }; + if (!WDJ::JsonObject::TryParse(winrt::to_hstring(restResp.body), ro)) return false; + if (!ro.HasKey(L"base64") || !ro.HasKey(L"mimeType")) return false; + + auto b64 = WideToUtf8(std::wstring(ro.GetNamedString(L"base64"))); + auto mime = WideToUtf8(std::wstring(ro.GetNamedString(L"mimeType"))); + std::string content = std::format( + R"JSON({{"content":[{{"type":"image","data":"{}","mimeType":"{}"}}],"isError":false}})JSON", + b64, mime); + out = { 200, WrapResult(idStr, content) }; + return true; + } + + // tools/call → route request, per the catalog row. + Response DispatchToolCall(McpRouter& router, const std::string& idStr, + const WDJ::JsonObject& params) + { + if (!params.HasKey(L"name") || + params.GetNamedValue(L"name").ValueType() != WDJ::JsonValueType::String) + return { 200, WrapError(idStr, -32602, "Invalid params: missing tool name") }; + + auto toolName = WideToUtf8(std::wstring(params.GetNamedString(L"name"))); + WDJ::JsonObject args; + if (params.HasKey(L"arguments")) + { + auto av = params.GetNamedValue(L"arguments"); + if (av.ValueType() != WDJ::JsonValueType::Object) + return { 200, WrapError(idStr, -32602, "Invalid params: arguments must be an object") }; + args = av.GetObject(); + } + + const ToolDef* tool = FindTool(toolName); + if (!tool) + return ToolErrorResult(idStr, "Unknown tool: " + toolName); + + std::wstring path = tool->pathTemplate; + std::string body; + switch (tool->argMode) + { + case ToolArgMode::BodyPassthrough: + body = WideToUtf8(std::wstring(args.Stringify())); + break; + case ToolArgMode::NoBody: + break; + case ToolArgMode::PathNumber: + { + if (!args.HasKey(tool->argKey)) + return ToolErrorResult(idStr, + "Missing required argument: " + WideToUtf8(tool->argKey)); + auto num = static_cast(args.GetNamedNumber(tool->argKey)); + path = std::vformat(tool->pathTemplate, std::make_wformat_args(num)); + break; + } + case ToolArgMode::PathString: + { + if (!args.HasKey(tool->argKey)) + return ToolErrorResult(idStr, + "Missing required argument: " + WideToUtf8(tool->argKey)); + path = std::wstring(tool->pathTemplate) + + std::wstring(args.GetNamedString(tool->argKey)); + break; + } + case ToolArgMode::NodeLogs: + { + if (!args.HasKey(tool->argKey)) + return ToolErrorResult(idStr, + "Missing required argument: " + WideToUtf8(tool->argKey)); + auto nodeId = static_cast(args.GetNamedNumber(tool->argKey)); + uint64_t sinceSeq = 0; + if (args.HasKey(L"sinceSeq")) + sinceSeq = static_cast(args.GetNamedNumber(L"sinceSeq")); + path = std::vformat(tool->pathTemplate, std::make_wformat_args(nodeId, sinceSeq)); + break; + } + case ToolArgMode::UnwrapField: + { + if (!args.HasKey(tool->argKey)) + return ToolErrorResult(idStr, + "Missing required argument: " + WideToUtf8(tool->argKey)); + body = WideToUtf8(std::wstring(args.GetNamedString(tool->argKey))); + break; + } + } + + // A tool whose backing route is absent on this host must fail + // legibly. Without this check the request falls through + // longest-prefix matching into this dispatcher's own POST / + // catch-all and reads as a bogus notification — the retired + // image_stats silent-success bug, resurrected. + if (!router.HasSpecificRoute(tool->method, path)) + return ToolErrorResult(idStr, + "Tool not available on this host: " + toolName); + + Response restResp = router.RouteRequest(tool->method, path, body); + + Response repacked; + if (TryImageRepack(*tool, args, restResp, idStr, repacked)) + return repacked; + + bool isError = restResp.statusCode >= 400; + return { 200, WrapResult(idStr, TextToolResult(restResp.body, isError)) }; + } + } + + std::vector DefaultResources() + { + return { + { "shaderlab://context", "ShaderLab Context", + "System prompt: pipeline format, shader conventions, API reference", L"/context" }, + { "shaderlab://graph", "Effect Graph", + "Full graph state with nodes, edges, properties, custom effect definitions", L"/graph" }, + { "shaderlab://registry/effects", "Built-in Effects", + "All 48+ built-in D2D effects with property metadata", L"/registry/effects" }, + { "shaderlab://custom-effects", "Custom Effects", + "Custom effects in graph with HLSL source and compile status", L"/custom-effects" }, + }; + } + + void RegisterJsonRpcEndpoint(McpRouter& router, JsonRpcOptions options) + { + if (options.resources.empty()) + options.resources = DefaultResources(); + + // The GET / health route was removed with the HTTP transport + // (stdio-migration Step 9) — nothing probes it now; the shim owns the + // client-facing handshake and callers reach the dispatcher via the + // sealed session channel. host kind still rides in initialize's + // serverInfo below. + + // POST / — the JSON-RPC dispatcher. + router.AddRoute(L"POST", L"/", + [&router, options = std::move(options)] + (const std::wstring&, const std::wstring&, const std::string& body) -> Response + { + // Extracted as early as possible so every later error path can + // echo it. "null" survives only for unparseable JSON. + std::string idStr = "null"; + try + { + WDJ::JsonObject jobj{ nullptr }; + if (!WDJ::JsonObject::TryParse(winrt::to_hstring(body), jobj)) + { + // Distinguish a batch (JSON array) from garbage for a + // clearer message; both are -32600/-32700 class. + WDJ::JsonArray arr{ nullptr }; + if (WDJ::JsonArray::TryParse(winrt::to_hstring(body), arr)) + return { 200, WrapError("null", -32600, + "Batch requests are not supported (protocol 2025-06-18)") }; + return { 200, WrapError("null", -32700, "Parse error") }; + } + + bool hasId = jobj.HasKey(L"id"); + if (hasId) + { + auto id = jobj.GetNamedValue(L"id"); + if (id.ValueType() == WDJ::JsonValueType::Number) + idStr = std::format("{}", static_cast(id.GetNumber())); + else if (id.ValueType() == WDJ::JsonValueType::String) + idStr = "\"" + JsonEscape(WideToUtf8(std::wstring(id.GetString()))) + "\""; + else + hasId = false; // null / other id types: treat as notification + } + + if (!jobj.HasKey(L"method") || + jobj.GetNamedValue(L"method").ValueType() != WDJ::JsonValueType::String) + { + if (!hasId) return Response::None(); + return { 200, WrapError(idStr, -32600, "Invalid Request: missing method") }; + } + auto method = WideToUtf8(std::wstring(jobj.GetNamedString(L"method"))); + + // Notification: absent id, no reply bytes. That is the + // whole detection — NOT a "notifications/" name prefix. + if (!hasId) + return Response::None(); + + // Guarded params accessor (the old dispatcher's unchecked + // GetNamedObject(L"params") threw winrt::hresult_error on + // array/absent params and surfaced as a generic 500). + auto getParams = [&](WDJ::JsonObject& out) -> bool { + if (!jobj.HasKey(L"params")) return false; + auto pv = jobj.GetNamedValue(L"params"); + if (pv.ValueType() != WDJ::JsonValueType::Object) return false; + out = pv.GetObject(); + return true; + }; + + if (method == "initialize") + { + auto verStr = WideToUtf8(std::wstring(::ShaderLab::VersionString)); + std::string result = std::format( + R"JSON({{"protocolVersion":"{}","capabilities":{{"tools":{{}},"resources":{{}}}},"serverInfo":{{"name":"shaderlab-{}","version":"{}"}}}})JSON", + kProtocolVersion, options.hostKind, verStr); + return { 200, WrapResult(idStr, result) }; + } + + if (method == "tools/list") + { + std::string tools = R"({"tools":[)"; + bool first = true; + for (const auto& t : ToolCatalog()) + { + if (!first) tools += ","; + tools += t.listJson; + first = false; + } + tools += "]}"; + return { 200, WrapResult(idStr, tools) }; + } + + if (method == "tools/call") + { + WDJ::JsonObject params; + if (!getParams(params)) + return { 200, WrapError(idStr, -32602, "Invalid params") }; + return DispatchToolCall(router, idStr, params); + } + + if (method == "resources/list") + { + std::string res = R"({"resources":[)"; + bool first = true; + for (const auto& r : options.resources) + { + if (!first) res += ","; + res += std::format( + R"JSON({{"uri":"{}","name":"{}","description":"{}","mimeType":"application/json"}})JSON", + JsonEscape(r.uri), JsonEscape(r.name), JsonEscape(r.description)); + first = false; + } + res += "]}"; + return { 200, WrapResult(idStr, res) }; + } + + if (method == "resources/read") + { + WDJ::JsonObject params; + if (!getParams(params) || + !params.HasKey(L"uri") || + params.GetNamedValue(L"uri").ValueType() != WDJ::JsonValueType::String) + return { 200, WrapError(idStr, -32602, "Invalid params: missing uri") }; + auto uri = WideToUtf8(std::wstring(params.GetNamedString(L"uri"))); + + const ResourceDef* match = nullptr; + for (const auto& r : options.resources) + if (r.uri == uri) { match = &r; break; } + if (!match) + return { 200, WrapResult(idStr, R"JSON({"contents":[]})JSON") }; + + auto restResp = router.RouteRequest(L"GET", match->routePath, ""); + std::string result = std::format( + R"JSON({{"contents":[{{"uri":"{}","mimeType":"application/json","text":"{}"}}]}})JSON", + JsonEscape(uri), JsonEscape(restResp.body)); + return { 200, WrapResult(idStr, result) }; + } + + if (method == "ping") + return { 200, WrapResult(idStr, "{}") }; + + return { 200, WrapError(idStr, -32601, "Method not found: " + method) }; + } + catch (const std::exception& ex) + { + return { 200, WrapError(idStr, -32603, + std::string("Internal error: ") + ex.what()) }; + } + catch (...) + { + // winrt::hresult_error and friends: answer with the id we + // extracted rather than letting the router's barrier turn + // this into an uncorrelatable 500. + return { 200, WrapError(idStr, -32603, "Internal error") }; + } + }); + } +} diff --git a/Engine/Mcp/McpJsonRpc.h b/Engine/Mcp/McpJsonRpc.h new file mode 100644 index 0000000..7828faf --- /dev/null +++ b/Engine/Mcp/McpJsonRpc.h @@ -0,0 +1,71 @@ +#pragma once + +// Engine-side MCP JSON-RPC dispatcher (stdio-migration Step 3). +// +// Moved out of MainWindow.McpRoutes.cpp so BOTH hosts serve the MCP +// protocol surface (initialize / tools/list / tools/call / resources / +// ping) over whatever transport fronts the router — the legacy HTTP +// listener today, the stdio session client from Step 6 on. The GUI's +// former inline dispatcher is deleted; RegisterJsonRpcEndpoint on the +// host's router is the whole integration. +// +// stdio-conformance rules enforced here (all were violated by the old +// GUI dispatcher; invisible over HTTP, fatal over a line-framed stream): +// * Every response is ONE JSON message with no embedded newlines. +// * Notifications (requests with an ABSENT id — that is the detection, +// not a "notifications/" name prefix) produce Response::None(): the +// HTTP transport sends 202-empty, the stdio transport emits zero +// bytes. +// * `id: null` never appears on an error path when the request carried +// an id — a null id matches no pending request on a multiplexed +// stream and hangs the client until its own timeout. The one +// remaining null is the unparseable-JSON case, where no id is +// recoverable (the Step 5 shim owns that failure class). +// * protocolVersion is pinned to 2025-06-18, the revision that REMOVED +// JSON-RPC batching — this server never supported batching, and the +// previously-pinned 2024-11-05 required it. Batch (array) requests +// get an explicit -32600. + +#include "pch_engine.h" +#include "../../EngineExport.h" +#include "McpTypes.h" + +#include +#include + +namespace ShaderLab +{ + class McpRouter; +} + +namespace ShaderLab::Mcp +{ + struct ResourceDef + { + std::string uri; // "shaderlab://graph" + std::string name; // display name + std::string description; + std::wstring routePath; // GET route serving the content + }; + + struct JsonRpcOptions + { + // Surfaced in GET / health and serverInfo so callers (and the test + // suite) can tell which host answered: "gui" or "headless". + std::string hostKind{ "gui" }; + + // Resource URI -> route table. Empty selects DefaultResources(). + // Note shaderlab://context maps to the GUI-only /context route and + // 404s on a headless host — accepted; the resource list is shared. + std::vector resources; + }; + + SHADERLAB_API std::vector DefaultResources(); + + // Registers GET / (exact-match health probe) and POST / (JSON-RPC + // dispatcher) on the router. Call after RegisterEngineRoutes; the + // dispatcher forwards tools/call to whatever routes the host has + // registered, so tools whose backing route is absent on this host + // return an isError text result rather than protocol failures. + SHADERLAB_API void RegisterJsonRpcEndpoint(McpRouter& router, JsonRpcOptions options); +} diff --git a/Engine/Mcp/McpPeerIdentity.cpp b/Engine/Mcp/McpPeerIdentity.cpp new file mode 100644 index 0000000..df1d122 --- /dev/null +++ b/Engine/Mcp/McpPeerIdentity.cpp @@ -0,0 +1,179 @@ +#include "pch_engine.h" +#include "McpPeerIdentity.h" +#include "../../Version.h" + +#include +#include +#include + +namespace ShaderLab::Mcp +{ + std::optional ResolveProcessIdentity(uint32_t pid) + { + // PROCESS_QUERY_LIMITED_INFORMATION suffices for same-account + // targets — no SeDebugPrivilege involved. + HANDLE h = ::OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid); + if (!h) + return std::nullopt; + + PeerIdentity id; + id.pid = pid; + + FILETIME exitT{}, kernelT{}, userT{}; + ::GetProcessTimes(h, &id.creationTime, &exitT, &kernelT, &userT); + + wchar_t image[MAX_PATH * 2]{}; + DWORD imageLen = ARRAYSIZE(image); + if (::QueryFullProcessImageNameW(h, 0, image, &imageLen)) + { + std::wstring path(image, imageLen); + auto slash = path.find_last_of(L'\\'); + id.imageDirectory = (slash == std::wstring::npos) ? path : path.substr(0, slash); + } + + // GetPackageFamilyName(HANDLE) skips the OpenProcessToken step. + // Trap: for a packaged process the sizing call reports + // ERROR_INSUFFICIENT_BUFFER, not success — so just supply the + // max-size buffer up front. Unpackaged peers report + // APPMODEL_ERROR_NO_PACKAGE. + wchar_t pfn[PACKAGE_FAMILY_NAME_MAX_LENGTH + 1]{}; + UINT32 pfnLen = ARRAYSIZE(pfn); + LONG rc = ::GetPackageFamilyName(h, &pfnLen, pfn); + ::CloseHandle(h); + + if (rc == ERROR_SUCCESS) + { + id.kind = PeerIdentityKind::Packaged; + // pfnLen includes the null terminator. + id.packageFamilyName.assign(pfn, pfnLen ? pfnLen - 1 : 0); + } + else if (rc == APPMODEL_ERROR_NO_PACKAGE) + { + id.kind = PeerIdentityKind::Unpackaged; + } + else + { + return std::nullopt; + } + return id; + } + + std::optional ResolvePipeClientIdentity(HANDLE serverPipeHandle) + { + ULONG pid = 0; + if (!::GetNamedPipeClientProcessId(serverPipeHandle, &pid)) + return std::nullopt; + return ResolveProcessIdentity(static_cast(pid)); + } + + std::optional ResolvePipeServerIdentity(HANDLE clientPipeHandle) + { + // Documented ambiguously (server-side handles), measured to work + // from the client handle — the spike result the unit test pins. + ULONG pid = 0; + if (!::GetNamedPipeServerProcessId(clientPipeHandle, &pid)) + return std::nullopt; + return ResolveProcessIdentity(static_cast(pid)); + } + + std::wstring LocalBuildId() + { + return std::format(L"{}#abi{}", ::ShaderLab::VersionString, + static_cast(SHADERLAB_ENGINE_ABI_VERSION)); + } + + std::wstring DefaultPipeBaseName() + { + std::wstring sid = L"nouser"; + HANDLE tok{}; + if (::OpenProcessToken(::GetCurrentProcess(), TOKEN_QUERY, &tok)) + { + BYTE buf[SECURITY_MAX_SID_SIZE + sizeof(TOKEN_USER)]{}; + DWORD len = sizeof(buf); + if (::GetTokenInformation(tok, TokenUser, buf, len, &len)) + { + LPWSTR s = nullptr; + if (::ConvertSidToStringSidW(reinterpret_cast(buf)->User.Sid, &s)) + { + sid = s; + ::LocalFree(s); + } + } + ::CloseHandle(tok); + } + return L"ShaderLab.mcp.v1." + sid; + } + + namespace + { + bool UnpackagedFallbackAllowed() + { + wchar_t buf[8]{}; + DWORD n = ::GetEnvironmentVariableW(L"SHADERLAB_MCP_ALLOW_UNPACKAGED", buf, ARRAYSIZE(buf)); + return n == 1 && buf[0] == L'1'; + } + + bool EqualsIgnoreCase(std::wstring_view a, std::wstring_view b) + { + if (a.size() != b.size()) return false; + for (size_t i = 0; i < a.size(); ++i) + if (towlower(a[i]) != towlower(b[i])) return false; + return true; + } + + // Directory one level up, trailing separator stripped. Used to + // compare the shared config root of two dev binaries that live in + // sibling per-project out dirs. + std::wstring_view ParentDir(std::wstring_view dir) + { + while (!dir.empty() && (dir.back() == L'\\' || dir.back() == L'/')) + dir.remove_suffix(1); + auto slash = dir.find_last_of(L"\\/"); + return slash == std::wstring_view::npos ? dir : dir.substr(0, slash); + } + } + + PairingVerdict EvaluatePairing( + const PeerIdentity& self, + const PeerIdentity& peer, + std::wstring_view selfBuildId, + std::wstring_view peerBuildId) + { + const bool selfPackaged = self.kind == PeerIdentityKind::Packaged; + const bool peerPackaged = peer.kind == PeerIdentityKind::Packaged; + + // Mixed packaged/unpackaged is always refused — no env override. + if (selfPackaged != peerPackaged) + return PairingVerdict::RefusedMixed; + + if (selfPackaged) + { + return self.packageFamilyName == peer.packageFamilyName + ? PairingVerdict::Accept + : PairingVerdict::RefusedMismatch; + } + + // Both unpackaged: dev/CI fallback, explicitly opted into. + if (!UnpackagedFallbackAllowed()) + return PairingVerdict::RefusedUnpackagedNotAllowed; + + // Build id (version + engine ABI) must match: that is the real + // "same build tree" signal. Directory match is the belt: accept + // either the exact same directory (hub + shim are one exe) OR a + // shared parent directory (sibling per-project out dirs like + // \ShaderLabMcpBroker\ vs \ShaderLabHeadless\). The + // shared-parent relaxation only ever engages behind the env gate, + // never in an installed (packaged) configuration, which is the + // whole point of the gate. The strict packaged path above is + // untouched. + const bool sameBuild = selfBuildId == peerBuildId && !selfBuildId.empty(); + if (!sameBuild) + return PairingVerdict::RefusedMismatch; + const bool sameDir = EqualsIgnoreCase(self.imageDirectory, peer.imageDirectory); + const bool sameRoot = EqualsIgnoreCase( + ParentDir(self.imageDirectory), ParentDir(peer.imageDirectory)); + return (sameDir || sameRoot) + ? PairingVerdict::Accept + : PairingVerdict::RefusedMismatch; + } +} diff --git a/Engine/Mcp/McpPeerIdentity.h b/Engine/Mcp/McpPeerIdentity.h new file mode 100644 index 0000000..7d63de0 --- /dev/null +++ b/Engine/Mcp/McpPeerIdentity.h @@ -0,0 +1,87 @@ +#pragma once + +// Peer identity + binary-pairing policy for the MCP broker pipe +// (stdio-migration Step 4). +// +// The one enforced boundary: verify the peer PROCESS, not its claims, +// and keep it version-tolerant. +// * Identity = package family name only. NOT the install root — that +// changes per version and isn't reliably under WindowsApps. +// * Compatibility = protocol version only (carried in the pipe name), +// bumped on wire-format breaks, never per release. App/engine +// versions are informational — comparing them would reject the +// old-shim/new-hub pairing on every routine upgrade and destroy the +// benefit of making the shim update-immune. +// * Unpackaged fallback (dev + CI only): when BOTH peers lack package +// identity, "same image directory + matching build id" — but only +// when SHADERLAB_MCP_ALLOW_UNPACKAGED=1, so it can never silently +// engage in an installed configuration. Mixed packaged/unpackaged +// is always refused. + +#include "pch_engine.h" +#include "../../EngineExport.h" + +#include +#include +#include +#include + +namespace ShaderLab::Mcp +{ + enum class PeerIdentityKind : uint8_t + { + Packaged, // has a package family name + Unpackaged, // APPMODEL_ERROR_NO_PACKAGE + }; + + struct PeerIdentity + { + PeerIdentityKind kind{ PeerIdentityKind::Unpackaged }; + uint32_t pid{ 0 }; + std::wstring packageFamilyName; // Packaged only + std::wstring imageDirectory; // directory of the exe (no trailing slash) + // Process creation time, captured with the identity. A PID-reuse + // sanity check for long-lived channels — a SANITY CHECK, not a + // guarantee: nothing stops the kernel recycling a PID between + // our query and any later use of it. + FILETIME creationTime{}; + }; + + SHADERLAB_API std::optional ResolveProcessIdentity(uint32_t pid); + + // Resolve the process on the other end of a named pipe. The server- + // from-client-handle call is documented ambiguously (the docs imply + // server-side handles only) but works — the spike measured it and + // the unit tests keep it covered. + SHADERLAB_API std::optional ResolvePipeClientIdentity(HANDLE serverPipeHandle); + SHADERLAB_API std::optional ResolvePipeServerIdentity(HANDLE clientPipeHandle); + + // "Same build" string for the unpackaged fallback: app version + + // engine ABI, both compile-time constants of the calling binary. + SHADERLAB_API std::wstring LocalBuildId(); + + // The default broker pipe base name, per-user isolated by SID: + // "ShaderLab.mcp.v1.". THE single source of truth so the hub, + // the shim, and every session client agree on where to meet when no + // --pipe / SHADERLAB_MCP_PIPE override is given (stdio-migration Step 6/7). + SHADERLAB_API std::wstring DefaultPipeBaseName(); + + enum class PairingVerdict : uint8_t + { + Accept, + RefusedMismatch, // packaged: different PFN; unpackaged: different dir/build + RefusedMixed, // one packaged, one not — always refused + RefusedUnpackagedNotAllowed, // both unpackaged but the env gate is absent + }; + + // Pure policy (env var read aside): no OS calls, so the full verdict + // matrix is unit-testable with synthesized identities. The + // unpackaged fallback is the weak point of binary pairing — the one + // path where "same build" is asserted rather than proven by the OS — + // which is exactly why it hides behind SHADERLAB_MCP_ALLOW_UNPACKAGED=1. + SHADERLAB_API PairingVerdict EvaluatePairing( + const PeerIdentity& self, + const PeerIdentity& peer, + std::wstring_view selfBuildId, + std::wstring_view peerBuildId); +} diff --git a/Engine/Mcp/McpSessionClient.cpp b/Engine/Mcp/McpSessionClient.cpp new file mode 100644 index 0000000..4426e1c --- /dev/null +++ b/Engine/Mcp/McpSessionClient.cpp @@ -0,0 +1,251 @@ +#include "pch_engine.h" +#include "McpSessionClient.h" +#include "McpRouter.h" +#include "McpFrame.h" +#include "McpChannel.h" +#include "McpPeerIdentity.h" +#include "McpTypes.h" + +#include +#include + +namespace ShaderLab::Mcp +{ + namespace WDJ = winrt::Windows::Data::Json; + + namespace + { + std::wstring ResolvePipePath(const std::wstring& base) + { + std::wstring name = base; + if (name.empty()) + { + wchar_t env[256]{}; + if (GetEnvironmentVariableW(L"SHADERLAB_MCP_PIPE", env, ARRAYSIZE(env)) > 0) + name = env; + else + name = DefaultPipeBaseName(); // shared source of truth + } + return L"\\\\.\\pipe\\" + name; + } + + // Blocking write of a whole buffer to a byte-mode pipe. + bool WriteAll(HANDLE pipe, const std::vector& bytes) + { + size_t off = 0; + while (off < bytes.size()) + { + DWORD wrote = 0; + if (!WriteFile(pipe, bytes.data() + off, + static_cast(bytes.size() - off), &wrote, nullptr) || wrote == 0) + return false; + off += wrote; + } + return true; + } + + bool SendFrame(HANDLE pipe, uint32_t channelId, uint64_t seq, + const std::vector& body) + { + Frame f; + f.header.channelId = channelId; + f.header.seq = seq; + f.body = body; + std::vector wire; + if (!EncodeFrame(f, wire)) + return false; + return WriteAll(pipe, wire); + } + + bool SendControlJson(HANDLE pipe, uint64_t seq, const std::string& json) + { + return SendFrame(pipe, 0, seq, std::vector(json.begin(), json.end())); + } + } + + struct McpSessionClient::Impl + { + McpRouter& router; + SessionClientOptions opts; + std::atomic stop{ false }; + std::atomic pipe{ INVALID_HANDLE_VALUE }; + + // Per-channel acceptor state. + struct Channel + { + SecureChannel sc; + uint64_t sendSeq{ 1 }; // handshake was seq 0 + }; + std::unordered_map channels; + + Impl(McpRouter& r, SessionClientOptions o) : router(r), opts(std::move(o)) {} + + // One connect → register → serve cycle. Returns when the pipe + // dies or Stop() fires. + void ServeOnce() + { + channels.clear(); + const std::wstring path = ResolvePipePath(opts.pipeBaseName); + + HANDLE h = CreateFileW(path.c_str(), + FILE_READ_DATA | FILE_WRITE_DATA | FILE_WRITE_ATTRIBUTES | SYNCHRONIZE, + 0, nullptr, OPEN_EXISTING, 0, nullptr); + if (h == INVALID_HANDLE_VALUE) + return; + pipe.store(h); + + // Verify hub identity + pairing before registering. + auto self = ResolveProcessIdentity(GetCurrentProcessId()); + auto hub = ResolvePipeServerIdentity(h); + uint64_t outSeq = 1; + bool ok = self && hub; + if (ok) + { + auto myBuild = WideToUtf8(LocalBuildId()); + ok = SendControlJson(h, outSeq++, std::format( + R"({{"op":"hello","role":"session","sessionId":"{}","label":"{}","protocol":"v1","buildId":"{}","pid":{}}})", + JsonEscape(WideToUtf8(opts.sessionId)), + JsonEscape(WideToUtf8(opts.label)), + JsonEscape(myBuild), GetCurrentProcessId())); + } + + std::vector acc; + if (ok) + { + Frame ack; + if (ReadFrameBlocking(h, acc, ack) && ack.header.channelId == 0) + { + auto obj = ParseControl(ack); + auto hubBuild = obj ? std::wstring(obj->GetNamedString(L"buildId", L"")) : L""; + ok = obj && WideToUtf8(obj->GetNamedString(L"op", L"")) == "hello-ack" + && EvaluatePairing(*self, *hub, LocalBuildId(), hubBuild) == PairingVerdict::Accept; + } + else ok = false; + } + + if (ok) + { + for (;;) + { + if (stop.load()) break; + Frame f; + if (!ReadFrameBlocking(h, acc, f)) + break; + HandleFrame(h, outSeq, f); + } + } + + HANDLE cur = pipe.exchange(INVALID_HANDLE_VALUE); + if (cur != INVALID_HANDLE_VALUE) + CloseHandle(cur); + } + + void HandleFrame(HANDLE h, uint64_t& outSeq, const Frame& f) + { + if (f.header.channelId == 0) + { + auto obj = ParseControl(f); + auto op = obj ? WideToUtf8(obj->GetNamedString(L"op", L"")) : std::string(); + if (op == "channel-close" && obj) + channels.erase(static_cast(obj->GetNamedNumber(L"channelId", 0))); + // Other control ops (ping etc.) need no reply from a session. + return; + } + + const uint32_t cid = f.header.channelId; + if (f.header.seq == 0) + { + // Handshake: peer (shim) public blob. Acceptor side. + auto sc = SecureChannel::Create(/*initiator=*/false); + if (!sc || !sc->OnPeerHello(f.body)) + return; + auto helloBody = sc->HelloBody(); + channels.insert_or_assign(cid, Channel{ std::move(*sc), 1 }); + SendFrame(h, cid, 0, helloBody); // our hello, seq 0 + return; + } + + auto it = channels.find(cid); + if (it == channels.end() || !it->second.sc.Ready()) + return; // data before handshake; drop + + auto plain = it->second.sc.Open(cid, f.header.seq, f.body); + if (!plain) + return; // tamper / desync: drop the frame + + std::string requestJson(plain->begin(), plain->end()); + Response resp = router.RouteRequest(L"POST", L"/", requestJson); + if (resp.noReply) + return; // JSON-RPC notification: no response frame + + std::vector respBytes(resp.body.begin(), resp.body.end()); + uint64_t seq = it->second.sendSeq++; + auto sealed = it->second.sc.Seal(cid, seq, respBytes); + if (sealed) + SendFrame(h, cid, seq, *sealed); + } + + static std::optional ParseControl(const Frame& f) + { + std::string s(f.body.begin(), f.body.end()); + WDJ::JsonObject o{ nullptr }; + if (!WDJ::JsonObject::TryParse(winrt::to_hstring(s), o)) + return std::nullopt; + return o; + } + + // Blocking read that accumulates until one frame decodes. Returns + // false when the pipe closes (or Stop() closed our handle). + bool ReadFrameBlocking(HANDLE h, std::vector& acc, Frame& out) + { + for (;;) + { + auto d = TryDecodeFrame(acc); + if (d.status == FrameDecodeStatus::Ok) + { + acc.erase(acc.begin(), acc.begin() + d.consumed); + out = std::move(d.frame); + return true; + } + if (d.status == FrameDecodeStatus::Oversize || + d.status == FrameDecodeStatus::Malformed) + return false; // poisoned stream + + uint8_t buf[16 * 1024]; + DWORD got = 0; + if (!ReadFile(h, buf, sizeof(buf), &got, nullptr) || got == 0) + return false; + acc.insert(acc.end(), buf, buf + got); + } + } + }; + + McpSessionClient::McpSessionClient(McpRouter& router, SessionClientOptions options) + : m_impl(std::make_unique(router, std::move(options))) + { + } + + McpSessionClient::~McpSessionClient() = default; + + void McpSessionClient::Run() + { + uint32_t backoffMs = 250; + while (!m_impl->stop.load()) + { + m_impl->ServeOnce(); + if (m_impl->stop.load()) + break; + // Reconnect with capped exponential backoff after a drop. + Sleep(backoffMs); + backoffMs = std::min(backoffMs * 2, 4000); + } + } + + void McpSessionClient::Stop() + { + m_impl->stop.store(true); + HANDLE h = m_impl->pipe.exchange(INVALID_HANDLE_VALUE); + if (h != INVALID_HANDLE_VALUE) + CloseHandle(h); // unblocks a pending blocking ReadFile + } +} diff --git a/Engine/Mcp/McpSessionClient.h b/Engine/Mcp/McpSessionClient.h new file mode 100644 index 0000000..3a57995 --- /dev/null +++ b/Engine/Mcp/McpSessionClient.h @@ -0,0 +1,64 @@ +#pragma once + +// Session client (stdio-migration Step 6). +// +// Connects a ShaderLab host to the broker hub as a "session", so an MCP +// client (via the stdio shim) can select it with use_session and drive +// its graph. Written ONCE against McpRouter& and reused by both hosts — +// headless wires it now (`--mcp-session`), the GUI in Step 7. +// +// Per shim↔session channel the client runs a SecureChannel (acceptor): +// it opens each sealed request, hands the plaintext JSON-RPC to +// `router.RouteRequest(L"POST", L"/", body)` — i.e. the same engine-side +// dispatcher the HTTP transport uses — and seals the response back. The +// hub relays frames blindly by channelId and never holds a key. +// +// Threading: Run() blocks on a dedicated thread and owns the connection. +// It reconnects with capped backoff after a drop (a hub restart, an +// update). Stop() unblocks it. Requests are served serially — one +// in-flight per session, which matches HeadlessSink's synchronous +// Dispatch and keeps the graph single-writer. + +#include "pch_engine.h" +#include "../../EngineExport.h" + +#include +#include + +namespace ShaderLab +{ + class McpRouter; +} + +namespace ShaderLab::Mcp +{ + struct SessionClientOptions + { + std::wstring pipeBaseName; // empty -> SHADERLAB_MCP_PIPE / default + std::wstring sessionId; // persisted per-window GUID (NOT an ordinal) + std::wstring label; // human label surfaced by list_sessions + }; + + class SHADERLAB_API McpSessionClient + { + public: + McpSessionClient(McpRouter& router, SessionClientOptions options); + ~McpSessionClient(); + + McpSessionClient(const McpSessionClient&) = delete; + McpSessionClient& operator=(const McpSessionClient&) = delete; + + // Blocks: connect → register → serve, reconnecting with backoff + // until Stop(). Returns when stopped. + void Run(); + + // Wakes Run() out of its current wait and closes the connection; + // Run() then observes the stop flag and returns. Safe from any + // thread. + void Stop(); + + private: + struct Impl; + std::unique_ptr m_impl; + }; +} diff --git a/Engine/Mcp/McpTimeouts.h b/Engine/Mcp/McpTimeouts.h new file mode 100644 index 0000000..20ff474 --- /dev/null +++ b/Engine/Mcp/McpTimeouts.h @@ -0,0 +1,44 @@ +#pragma once + +// The one place the MCP timeout ladder lives (stdio-migration Step 7). +// +// Each hop must give the hop below it time to fail on its OWN terms and +// report a real error, rather than the outer hop timing out first and +// leaving the inner one orphaned: +// +// render-thread closure < MainWindow::DispatchSync < shim < client +// +// * kRenderClosure — a single engine route body on the render worker. +// * kDispatchSync — MainWindow::DispatchSync waiting on the worker. +// Strictly greater, so a wedged closure surfaces as +// the closure's failure, not a bare dispatch timeout. +// * kShimRequest — the shim's wait for a session's sealed response +// (McpSessionClient forwards a request and waits). +// Greater than kDispatchSync so the session can turn +// a slow route into a real JSON-RPC error first. +// * kMcpClient — informational: the MCP client's own request budget. +// We never enforce it; it must exceed kShimRequest or +// the client gives up before the shim can answer. The +// ecosystem default is ~60 s; kept here so the ladder +// is legible in one place. +// +// These are the render/DispatchSync/shim rungs the GUI enforces; the shim +// (a separate binary) reads the same numbers via the shared source of +// truth this header is. Adjust here, nowhere else. + +#include + +namespace ShaderLab::Mcp +{ + inline constexpr std::chrono::milliseconds kRenderClosureTimeout{ 20'000 }; + inline constexpr std::chrono::milliseconds kDispatchSyncTimeout{ 25'000 }; + inline constexpr std::chrono::milliseconds kShimRequestTimeout{ 30'000 }; + inline constexpr std::chrono::milliseconds kMcpClientBudget{ 60'000 }; + + static_assert(kRenderClosureTimeout < kDispatchSyncTimeout, + "render closure must fail before the DispatchSync waiting on it"); + static_assert(kDispatchSyncTimeout < kShimRequestTimeout, + "DispatchSync must fail before the shim waiting on the session"); + static_assert(kShimRequestTimeout < kMcpClientBudget, + "the shim must answer before the MCP client's own budget expires"); +} diff --git a/Engine/Mcp/McpToolCatalog.cpp b/Engine/Mcp/McpToolCatalog.cpp new file mode 100644 index 0000000..2b07294 --- /dev/null +++ b/Engine/Mcp/McpToolCatalog.cpp @@ -0,0 +1,163 @@ +#include "pch_engine.h" +#include "McpToolCatalog.h" + +// The listJson strings below are the tools/list entries formerly embedded +// as one giant multi-line raw literal in MainWindow.McpRoutes.cpp. Each is +// a SINGLE LINE by construction — the stdio transport frames one JSON +// message per line, so no catalog string may ever contain a newline +// (invisible over HTTP, fatal over stdio). Content is unchanged from the +// pre-catalog dispatcher except for the removed `image_stats` phantom. + +namespace ShaderLab::Mcp +{ + namespace + { + // Shorthand so rows below stay readable. + using M = ToolArgMode; + + const std::vector kCatalog = { + + // ---- Graph structure ------------------------------------------------ + { "graph_add_node", + R"JSON({"name":"graph_add_node","description":"Add a node. Use effectName for built-in/ShaderLab effects. For sources use effectName='Video' or 'Image' with optional filePath.","inputSchema":{"type":"object","properties":{"effectName":{"type":"string","description":"Effect name, or 'Video'/'Image' for source nodes"},"filePath":{"type":"string","description":"File path for Video/Image source nodes (optional)"}},"required":["effectName"]}})JSON", + L"POST", L"/graph/add-node", M::BodyPassthrough }, + { "graph_remove_node", + R"JSON({"name":"graph_remove_node","description":"Remove a node by ID","inputSchema":{"type":"object","properties":{"nodeId":{"type":"number"}},"required":["nodeId"]}})JSON", + L"POST", L"/graph/remove-node", M::BodyPassthrough }, + { "graph_rename_node", + R"JSON({"name":"graph_rename_node","description":"Rename a node","inputSchema":{"type":"object","properties":{"nodeId":{"type":"number"},"name":{"type":"string"}},"required":["nodeId","name"]}})JSON", + L"POST", L"/graph/rename-node", M::BodyPassthrough }, + { "graph_connect", + R"JSON({"name":"graph_connect","description":"Connect output pin to input pin","inputSchema":{"type":"object","properties":{"srcId":{"type":"number"},"srcPin":{"type":"number"},"dstId":{"type":"number"},"dstPin":{"type":"number"}},"required":["srcId","srcPin","dstId","dstPin"]}})JSON", + L"POST", L"/graph/connect", M::BodyPassthrough }, + { "graph_disconnect", + R"JSON({"name":"graph_disconnect","description":"Disconnect an edge","inputSchema":{"type":"object","properties":{"srcId":{"type":"number"},"srcPin":{"type":"number"},"dstId":{"type":"number"},"dstPin":{"type":"number"}},"required":["srcId","srcPin","dstId","dstPin"]}})JSON", + L"POST", L"/graph/disconnect", M::BodyPassthrough }, + { "graph_apply", + R"JSON({"name":"graph_apply","description":"Apply a graph patch in one call: add nodes (using client refs), connect edges, set property bindings. Call /graph/clear first if you need a fresh graph. Body: { nodes:[{ref,effect,filePath?,properties?}], edges:[{from,to,fromPin?,toPin?}], bindings:[{node,property,from:'ref.field'|{node,field},component?}] }. 'from' and 'to' accept either a ref string or numeric nodeId. Returns refToId map + nodeIds in add order.","inputSchema":{"type":"object","properties":{"nodes":{"type":"array"},"edges":{"type":"array"},"bindings":{"type":"array"}}}})JSON", + L"POST", L"/graph/apply", M::BodyPassthrough }, + { "graph_clear", + R"JSON({"name":"graph_clear","description":"Clear the graph","inputSchema":{"type":"object","properties":{}}})JSON", + L"POST", L"/graph/clear", M::NoBody }, + { "graph_overview", + R"JSON({"name":"graph_overview","description":"Compact graph summary: nodes (id, name, type, error), edges, preview node","inputSchema":{"type":"object","properties":{}}})JSON", + L"GET", L"/graph/overview", M::NoBody }, + { "graph_get_node", + R"JSON({"name":"graph_get_node","description":"Get detailed info about a node","inputSchema":{"type":"object","properties":{"nodeId":{"type":"number"}},"required":["nodeId"]}})JSON", + L"GET", L"/graph/node/{}", M::PathNumber, L"nodeId" }, + { "graph_save_json", + R"JSON({"name":"graph_save_json","description":"Serialize graph to JSON","inputSchema":{"type":"object","properties":{}}})JSON", + L"GET", L"/graph/save", M::NoBody }, + { "graph_load_json", + R"JSON({"name":"graph_load_json","description":"Load graph from JSON string","inputSchema":{"type":"object","properties":{"json":{"type":"string"}},"required":["json"]}})JSON", + L"POST", L"/graph/load", M::UnwrapField, L"json" }, + + // ---- Properties & bindings ------------------------------------------ + { "graph_set_property", + R"JSON({"name":"graph_set_property","description":"Set a node property. Value can be number, bool, string, or array for vectors.","inputSchema":{"type":"object","properties":{"nodeId":{"type":"number"},"key":{"type":"string"},"value":{}},"required":["nodeId","key","value"]}})JSON", + L"POST", L"/graph/set-property", M::BodyPassthrough }, + { "graph_bind_property", + R"JSON({"name":"graph_bind_property","description":"Bind a node property to an upstream analysis output field","inputSchema":{"type":"object","properties":{"nodeId":{"type":"number"},"propertyName":{"type":"string"},"sourceNodeId":{"type":"number"},"sourceFieldName":{"type":"string"},"sourceComponent":{"type":"number","description":"0-3 for .xyzw component (scalar dest only)"}},"required":["nodeId","propertyName","sourceNodeId","sourceFieldName"]}})JSON", + L"POST", L"/graph/bind-property", M::BodyPassthrough }, + { "graph_unbind_property", + R"JSON({"name":"graph_unbind_property","description":"Remove a property binding","inputSchema":{"type":"object","properties":{"nodeId":{"type":"number"},"propertyName":{"type":"string"}},"required":["nodeId","propertyName"]}})JSON", + L"POST", L"/graph/unbind-property", M::BodyPassthrough }, + + // ---- Shaders & effects ---------------------------------------------- + { "effect_compile", + R"JSON({"name":"effect_compile","description":"Compile HLSL for a custom effect node","inputSchema":{"type":"object","properties":{"nodeId":{"type":"number"},"hlsl":{"type":"string"}},"required":["nodeId","hlsl"]}})JSON", + L"POST", L"/effect/compile", M::BodyPassthrough }, + { "effect_get_hlsl", + R"JSON({"name":"effect_get_hlsl","description":"Read a node's custom-effect HLSL source, parameter list, compile state, and last runtime error. For non-custom nodes returns hasCustomEffect=false (200, not 404). For ShaderLab library effects, also includes isLibraryEffect=true + shaderLabEffectId/Version.","inputSchema":{"type":"object","properties":{"nodeId":{"type":"number"}},"required":["nodeId"]}})JSON", + L"GET", L"/effect/hlsl/{}", M::PathNumber, L"nodeId" }, + { "list_effects", + R"JSON({"name":"list_effects","description":"List all available effects (Built-in D2D + ShaderLab) with categories","inputSchema":{"type":"object","properties":{}}})JSON", + L"GET", L"/effects", M::NoBody }, + { "registry_get_effect", + R"JSON({"name":"registry_get_effect","description":"Get metadata for a built-in effect","inputSchema":{"type":"object","properties":{"name":{"type":"string"}},"required":["name"]}})JSON", + L"GET", L"/registry/effect/", M::PathString, L"name" }, + + // ---- Rendering & readback ------------------------------------------- + { "set_preview_node", + R"JSON({"name":"set_preview_node","description":"Set which node is previewed","inputSchema":{"type":"object","properties":{"nodeId":{"type":"number"}},"required":["nodeId"]}})JSON", + L"POST", L"/render/preview-node", M::BodyPassthrough }, + { "render_capture", + R"JSON({"name":"render_capture","description":"Capture preview as PNG. Note: HDR values clipped to SDR.","inputSchema":{"type":"object","properties":{}}})JSON", + L"GET", L"/render/capture", M::NoBody }, + { "render_capture_node", + R"JSON({"name":"render_capture_node","description":"Capture any node's resolved output as PNG (FORCES a render frame so dirty nodes evaluate). With inline=true returns the image as MCP image content (base64). 404 if node missing; 409 with notReady=true if the node is dirty / has unconnected inputs.","inputSchema":{"type":"object","properties":{"nodeId":{"type":"number"},"inline":{"type":"boolean"}},"required":["nodeId"]}})JSON", + L"POST", L"/render/capture-node", M::BodyPassthrough, nullptr, /*imageInline=*/true }, + { "read_analysis_output", + R"JSON({"name":"read_analysis_output","description":"Read typed analysis output fields from a compute/analysis node","inputSchema":{"type":"object","properties":{"nodeId":{"type":"number"}},"required":["nodeId"]}})JSON", + L"GET", L"/analysis/{}", M::PathNumber, L"nodeId" }, + { "read_pixel_region", + R"JSON({"name":"read_pixel_region","description":"Read a small w x h region of FP32 RGBA pixels from a node's output (scRGB linear-light). Region is capped at 32x32 (1024 pixels) and per-axis at 64. Pixels are returned row-major as a flat float array (RGBARGBA...).","inputSchema":{"type":"object","properties":{"nodeId":{"type":"number"},"x":{"type":"number"},"y":{"type":"number"},"w":{"type":"number"},"h":{"type":"number"}},"required":["nodeId","x","y","w","h"]}})JSON", + L"POST", L"/render/pixel-region", M::BodyPassthrough }, + { "read_pixel_trace", + R"JSON({"name":"read_pixel_trace","description":"Run pixel trace at normalized coordinates, returns per-node pixel values and analysis outputs","inputSchema":{"type":"object","properties":{"nodeId":{"type":"number"},"x":{"type":"number","description":"Normalized X (0-1)"},"y":{"type":"number","description":"Normalized Y (0-1)"}},"required":["nodeId","x","y"]}})JSON", + L"POST", L"/render/pixel-trace", M::BodyPassthrough }, + + // ---- Display & environment ------------------------------------------ + { "get_display_info", + R"JSON({"name":"get_display_info","description":"Current display capabilities, active profile, pipeline format, app version","inputSchema":{"type":"object","properties":{}}})JSON", + L"GET", L"/display/info", M::NoBody }, + { "list_display_profiles", + R"JSON({"name":"list_display_profiles","description":"List all built-in display profile presets and the currently active simulated/live profile. Returns full caps (HDR, peak nits, SDR white) and CIE primaries.","inputSchema":{"type":"object","properties":{}}})JSON", + L"GET", L"/display/profiles", M::NoBody }, + { "set_display_profile", + R"JSON({"name":"set_display_profile","description":"Apply a simulated display profile (overrides OS-reported caps until cleared). Specify exactly ONE of: preset (factory or display name), presetIndex (0-based), iccPath (.icc/.icm file), custom (full chroma + nits spec).","inputSchema":{"type":"object","properties":{"preset":{"type":"string"},"presetIndex":{"type":"number"},"iccPath":{"type":"string"},"custom":{"type":"object","properties":{"name":{"type":"string"},"hdrEnabled":{"type":"boolean"},"sdrWhiteNits":{"type":"number"},"peakNits":{"type":"number"},"minNits":{"type":"number"},"maxFullFrameNits":{"type":"number"},"primaryRed":{"type":"array","items":{"type":"number"}},"primaryGreen":{"type":"array","items":{"type":"number"}},"primaryBlue":{"type":"array","items":{"type":"number"}},"whitePoint":{"type":"array","items":{"type":"number"}},"gamut":{"type":"string"}},"required":["peakNits"]}}}})JSON", + L"POST", L"/display/profile", M::BodyPassthrough }, + { "clear_simulated_profile", + R"JSON({"name":"clear_simulated_profile","description":"Revert to the live OS-reported display profile (clears any simulated/preset/ICC override).","inputSchema":{"type":"object","properties":{}}})JSON", + L"POST", L"/display/profile/clear", M::NoBody }, + { "list_gpus", + R"JSON({"name":"list_gpus","description":"Enumerate available GPU adapters (DXGI). Returns the active adapter and a list of all installed adapters with name, vendorId, deviceId, dedicated VRAM (MB), LUID, and isWarp flag.","inputSchema":{"type":"object","properties":{}}})JSON", + L"GET", L"/gpu/list", M::NoBody }, + { "switch_gpu", + R"JSON({"name":"switch_gpu","description":"Switch the active GPU adapter. Triggers a full graph-save, device-teardown, and graph-reload cycle. Use mode='warp' for the WARP software adapter, 'default' to let the driver pick, or 'adapter' with either {luid:{low,high}} or {name:'partial-match'}. Falls back to default if the requested adapter fails to initialize.","inputSchema":{"type":"object","properties":{"mode":{"type":"string","enum":["warp","default","adapter"]},"name":{"type":"string","description":"Substring match against adapter name (used when mode='adapter')"},"luid":{"type":"object","properties":{"low":{"type":"number"},"high":{"type":"number"}}}},"required":["mode"]}})JSON", + L"POST", L"/gpu/switch", M::BodyPassthrough }, + + // ---- Editor view & diagnostics (GUI-only backing routes) ------------ + { "graph_snapshot", + R"JSON({"name":"graph_snapshot","description":"Capture a PNG snapshot of the live node-graph editor view at the current pan/zoom and panel size. With inline=true returns the image as MCP image content (base64). Without inline, returns the temp file path only.","inputSchema":{"type":"object","properties":{"inline":{"type":"boolean","description":"If true, return the PNG bytes inline as MCP image content"}}}})JSON", + L"POST", L"/graph/snapshot", M::BodyPassthrough, nullptr, /*imageInline=*/true }, + { "graph_get_view", + R"JSON({"name":"graph_get_view","description":"Get the node-graph view's current zoom, pan offset, viewport size, and the bounding box of all nodes (in canvas space).","inputSchema":{"type":"object","properties":{}}})JSON", + L"GET", L"/graph/view", M::NoBody }, + { "graph_set_view", + R"JSON({"name":"graph_set_view","description":"Pan and/or zoom the node-graph editor view. Any subset of {zoom, panX, panY} may be supplied. Changes apply immediately to the live UI. zoom is clamped to [0.1, 5.0]; pan has no clamp. Coordinate convention: screen = zoom * canvas + pan.","inputSchema":{"type":"object","properties":{"zoom":{"type":"number"},"panX":{"type":"number"},"panY":{"type":"number"}}}})JSON", + L"POST", L"/graph/view", M::BodyPassthrough }, + { "graph_fit_view", + R"JSON({"name":"graph_fit_view","description":"Fit the node-graph view to show all nodes with the given viewport-space padding (DIPs, default 40). No-op when the graph is empty.","inputSchema":{"type":"object","properties":{"padding":{"type":"number"}}}})JSON", + L"POST", L"/graph/view/fit", M::BodyPassthrough }, + { "preview_get_view", + R"JSON({"name":"preview_get_view","description":"Get the preview pane's current zoom + pan + image bounds + zoom limits.","inputSchema":{"type":"object","properties":{}}})JSON", + L"GET", L"/preview/view", M::NoBody }, + { "preview_set_view", + R"JSON({"name":"preview_set_view","description":"Set the preview pane's zoom and/or pan. zoom clamped to [0.01, 100.0]. Returns post-clamp values.","inputSchema":{"type":"object","properties":{"zoom":{"type":"number"},"panX":{"type":"number"},"panY":{"type":"number"}}}})JSON", + L"POST", L"/preview/view", M::BodyPassthrough }, + { "preview_fit_view", + R"JSON({"name":"preview_fit_view","description":"Fit the preview image to the preview viewport (auto zoom + center).","inputSchema":{"type":"object","properties":{}}})JSON", + L"POST", L"/preview/view/fit", M::NoBody }, + { "node_logs", + R"JSON({"name":"node_logs","description":"Get per-node log entries (timestamped info/warning/error). Use sinceSeq for incremental reads.","inputSchema":{"type":"object","properties":{"nodeId":{"type":"number"},"sinceSeq":{"type":"number","description":"Only return entries after this sequence number"}},"required":["nodeId"]}})JSON", + L"GET", L"/node/{}/logs?since={}", M::NodeLogs, L"nodeId" }, + { "perf_timings", + R"JSON({"name":"perf_timings","description":"Get per-frame performance timings (ms) for render pipeline phases","inputSchema":{"type":"object","properties":{}}})JSON", + L"GET", L"/perf", M::NoBody }, + }; + } + + const std::vector& ToolCatalog() + { + return kCatalog; + } + + const ToolDef* FindTool(std::string_view name) + { + for (const auto& t : kCatalog) + if (name == t.name) + return &t; + return nullptr; + } +} diff --git a/Engine/Mcp/McpToolCatalog.h b/Engine/Mcp/McpToolCatalog.h new file mode 100644 index 0000000..d8bef1b --- /dev/null +++ b/Engine/Mcp/McpToolCatalog.h @@ -0,0 +1,48 @@ +#pragma once + +// Declarative MCP tool catalog (stdio-migration Step 3). +// +// One row per tool: the single-line tools/list JSON entry plus how a +// tools/call invocation maps onto the route registry. The dispatcher +// (McpJsonRpc.cpp) is generic; everything tool-specific lives here. +// +// Response shaping deliberately does NOT live in this table: +// `responseMode` is a function of the response, not the tool. Tools +// flagged `imageInline` repack to MCP image content only when +// inline == true AND status == 200 AND the body re-parses AND it has +// both `base64` and `mimeType`; anything else falls through to the +// generic text wrapper in the dispatcher. + +#include "pch_engine.h" +#include "../../EngineExport.h" + +#include +#include +#include + +namespace ShaderLab::Mcp +{ + enum class ToolArgMode : uint8_t + { + BodyPassthrough, // arguments object serialized as the request body + NoBody, // empty body; arguments ignored + PathNumber, // pathTemplate has one {} slot, filled with numeric argKey + PathString, // argKey's raw string value appended to pathTemplate + NodeLogs, // /node/{nodeId}/logs?since={sinceSeq}; sinceSeq defaults 0 + UnwrapField, // body = raw string content of argKey (graph_load_json) + }; + + struct ToolDef + { + const char* name; // "graph_add_node" + const char* listJson; // single-line {"name":...,"description":...,"inputSchema":...} + const wchar_t* method; // L"GET" / L"POST" + const wchar_t* pathTemplate; // route path (format slot for Path* / NodeLogs modes) + ToolArgMode argMode{ ToolArgMode::BodyPassthrough }; + const wchar_t* argKey{ nullptr }; // Path* / NodeLogs / UnwrapField primary argument + bool imageInline{ false }; + }; + + SHADERLAB_API const std::vector& ToolCatalog(); + SHADERLAB_API const ToolDef* FindTool(std::string_view name); +} diff --git a/Engine/Mcp/McpTypes.h b/Engine/Mcp/McpTypes.h new file mode 100644 index 0000000..a74d9ad --- /dev/null +++ b/Engine/Mcp/McpTypes.h @@ -0,0 +1,93 @@ +#pragma once + +// Transport-neutral MCP types (stdio-migration Step 2). +// +// Response used to live inside McpHttpServer as a nested struct, which +// welded the engine ABI (IEngineCommandSink::Dispatch, every route +// handler) to the HTTP transport header. Routes and sinks now depend on +// this header only; the transport (McpRouter's HTTP listener today, the +// stdio session client later) is an implementation detail behind it. + +#include +#include +#include +#include + +namespace ShaderLab::Mcp +{ + // ---- Shared JSON string utilities (stdio-migration Step 3) ------------ + // + // There used to be THREE divergent JSON escapers (EngineMcpRoutes' full + // one, plus two ad-hoc loops in the GUI dispatcher that missed raw + // control characters — HLSL compiler output could produce invalid + // JSON). This is now the single authority; everything that embeds text + // in a JSON string literal goes through it. + + inline std::string WideToUtf8(std::wstring_view ws) + { + if (ws.empty()) return {}; + int len = ::WideCharToMultiByte(CP_UTF8, 0, + ws.data(), static_cast(ws.size()), + nullptr, 0, nullptr, nullptr); + std::string out(len, '\0'); + ::WideCharToMultiByte(CP_UTF8, 0, + ws.data(), static_cast(ws.size()), + out.data(), len, nullptr, nullptr); + return out; + } + + inline std::string JsonEscape(std::string_view s) + { + std::string out; + out.reserve(s.size() + 8); + for (char c : s) + { + switch (c) + { + case '"': out += "\\\""; break; + case '\\': out += "\\\\"; break; + case '\n': out += "\\n"; break; + case '\r': out += "\\r"; break; + case '\t': out += "\\t"; break; + case '\b': out += "\\b"; break; + case '\f': out += "\\f"; break; + default: + if (static_cast(c) < 0x20) + { + char buf[8]; + std::snprintf(buf, sizeof(buf), "\\u%04x", static_cast(c)); + out += buf; + } + else + { + out += c; + } + } + } + return out; + } + + struct Response + { + uint16_t statusCode{ 200 }; + std::string body; + std::string contentType{ "application/json" }; + + // No-reply discriminator. Over HTTP an empty 202 body and "no + // response" are the same wire bytes, so the distinction never + // mattered. Over stdio they are different things: a JSON-RPC + // notification must produce ZERO bytes (an empty line is not + // valid JSON), while an empty-body reply still produces a framed + // message. Handlers that answer notifications return None(); + // transports check noReply before serializing anything. + bool noReply{ false }; + + static Response None() + { + Response r; + r.statusCode = 202; // HTTP transport still sends 202 Accepted + r.noReply = true; + return r; + } + }; +} diff --git a/ShaderLabMcpBroker.vcxproj b/ShaderLabMcpBroker.vcxproj new file mode 100644 index 0000000..4822818 --- /dev/null +++ b/ShaderLabMcpBroker.vcxproj @@ -0,0 +1,138 @@ + + + + + + {E4F5A6B7-3344-4D55-BE66-778899AABB22} + ShaderLabMcpBroker + ShaderLabMcpBroker + ShaderLabMcpBroker + 10.0 + 10.0.17763.0 + Win32Proj + Unicode + + + + Debugx64 + DebugARM64 + Releasex64 + ReleaseARM64 + + + Application + true + v145 + v143 + Unicode + true + + + Application + true + v145 + v143 + Unicode + true + + + Application + false + v145 + v143 + Unicode + true + false + + + Application + false + v145 + v143 + Unicode + true + false + + + + + + + + + $(ProjectDir)$(Platform)\$(Configuration)\ShaderLabMcpBroker\ + $(ProjectDir)$(Platform)\$(Configuration)\ShaderLabMcpBroker\obj\ + + + + + $(ProjectDir);%(AdditionalIncludeDirectories) + Use + pch_engine.h + $(IntDir)pch_engine.pch + Level4 + true + stdcpp20 + SHADERLAB_ENGINE_EXPORTS;%(PreprocessorDefinitions) + 4251;%(DisableSpecificWarnings) + %(AdditionalOptions) /bigobj + + + Console + windowsapp.lib;%(AdditionalDependencies) + + + + + WIN32;_CONSOLE;_DEBUG;%(PreprocessorDefinitions) + + + + + WIN32;_CONSOLE;NDEBUG;%(PreprocessorDefinitions) + + + true + true + + + + + + + + + + + + + + Create + + + + + + + + + + + + + + + This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}. + + + + + + + diff --git a/ShaderLabMcpBroker/Main.cpp b/ShaderLabMcpBroker/Main.cpp new file mode 100644 index 0000000..159a279 --- /dev/null +++ b/ShaderLabMcpBroker/Main.cpp @@ -0,0 +1,1223 @@ +// ShaderLabMcpBroker — hub + stdio shim (stdio-migration Step 5). +// +// ONE binary, two modes: +// +// --hub The singleton blind relay. Wins/loses the first-instance +// election on \\.\pipe\, verifies every connecting peer +// (McpPeerIdentity pairing), answers channel-0 control ops +// (hello / list-sessions / bye), and — from Step 6 — relays +// sealed frames between shims and sessions by {channelId,seq} +// without ever holding a key. Zero sessions exist in Step 5. +// +// --stdio The MCP front-end an MCP client launches. Owns initialize, +// the list_sessions / use_session tools, id correlation and +// request timeouts. Talks NDJSON on stdin/stdout (binary mode; +// stdout carries JSON-RPC frames ONLY — logs go to +// %LOCALAPPDATA%\ShaderLab\logs\). +// +// Deliberately NOT linked against ShaderLabEngine.dll — the hub must +// start in milliseconds and never touches a GPU. The Step 4 modules it +// needs (McpFrame, McpPeerIdentity, McpTypes' JsonEscape) are compiled +// into this exe directly. +// +// Config travels via ARGUMENTS (--pipe, --idle-exit-sec): the activated +// hub never receives the launcher's environment (spike-verified). The +// SHADERLAB_MCP_PIPE env var is honoured as a dev/CI fallback where a +// real environment exists (shim, tests); the readiness event name is +// derived from the pipe name so one override isolates every named object. + +#include "pch_engine.h" +#include "../Engine/Mcp/McpFrame.h" +#include "../Engine/Mcp/McpTypes.h" +#include "../Engine/Mcp/McpPeerIdentity.h" +#include "../Engine/Mcp/McpChannel.h" +#include "../Engine/Mcp/McpTimeouts.h" +#include "../Version.h" + +#include +#include +#include +#include +#include +#include + +using namespace ShaderLab::Mcp; +namespace WDJ = winrt::Windows::Data::Json; + +namespace +{ + // ---- Logging (never stdout: the shim's stdout is protocol bytes) ------ + FILE* g_log = nullptr; + + void OpenLog(const wchar_t* mode) + { + PWSTR localAppData = nullptr; + if (FAILED(SHGetKnownFolderPath(FOLDERID_LocalAppData, 0, nullptr, &localAppData))) + return; + std::wstring dir = std::wstring(localAppData) + L"\\ShaderLab\\logs"; + CoTaskMemFree(localAppData); + CreateDirectoryW((dir.substr(0, dir.find_last_of(L'\\'))).c_str(), nullptr); + CreateDirectoryW(dir.c_str(), nullptr); + auto path = std::format(L"{}\\broker-{}-{}.log", dir, mode, GetCurrentProcessId()); + _wfopen_s(&g_log, path.c_str(), L"a"); + } + + void Log(const std::string& line) + { + if (!g_log) return; + SYSTEMTIME st; GetLocalTime(&st); + auto stamped = std::format("{:02}:{:02}:{:02}.{:03} {}\n", + st.wHour, st.wMinute, st.wSecond, st.wMilliseconds, line); + fwrite(stamped.data(), 1, stamped.size(), g_log); + fflush(g_log); + } + + // ---- Naming ------------------------------------------------------------ + // The default pipe/event base derives from DefaultPipeBaseName() + // (Engine/Mcp/McpPeerIdentity) so the hub, shim and every session client + // agree on the meeting point without a --pipe override. + + struct BrokerNames + { + std::wstring baseName; // e.g. ShaderLab.mcp.v1.S-1-5-21-... + std::wstring pipePath; // \\.\pipe\ + std::wstring readyEvent; // Local\.ready + }; + + BrokerNames ResolveNames(const std::wstring& pipeArg) + { + BrokerNames n; + if (!pipeArg.empty()) + n.baseName = pipeArg; + else + { + wchar_t env[256]{}; + if (GetEnvironmentVariableW(L"SHADERLAB_MCP_PIPE", env, ARRAYSIZE(env)) > 0) + n.baseName = env; + else + n.baseName = DefaultPipeBaseName(); // shared with the session client + } + n.pipePath = L"\\\\.\\pipe\\" + n.baseName; + n.readyEvent = L"Local\\" + n.baseName + L".ready"; + return n; + } + + // ---- Control-channel JSON --------------------------------------------- + std::string Utf8(std::wstring_view ws) { return WideToUtf8(ws); } + + Frame MakeControl(uint64_t seq, const std::string& json) + { + Frame f; + f.header.channelId = 0; + f.header.seq = seq; + f.body.assign(json.begin(), json.end()); + return f; + } + + std::optional ParseControl(const Frame& f) + { + std::string s(f.body.begin(), f.body.end()); + WDJ::JsonObject o{ nullptr }; + if (!WDJ::JsonObject::TryParse(winrt::to_hstring(s), o)) + return std::nullopt; + return o; + } + + // ---- Overlapped pipe I/O helpers -------------------------------------- + // One writer per pipe: callers serialize writes themselves. The + // OVERLAPPED + buffer must outlive the completion packet even after a + // successful CancelIoEx — every path below waits for completion via + // GetOverlappedResult(bWait=TRUE) before the buffers go out of scope. + bool WriteAll(HANDLE pipe, const std::vector& bytes) + { + OVERLAPPED ov{}; + ov.hEvent = CreateEventW(nullptr, TRUE, FALSE, nullptr); + if (!ov.hEvent) return false; + DWORD written = 0; + bool ok = WriteFile(pipe, bytes.data(), static_cast(bytes.size()), nullptr, &ov) + ? true : (GetLastError() == ERROR_IO_PENDING); + if (ok) + ok = GetOverlappedResult(pipe, &ov, &written, TRUE) && written == bytes.size(); + CloseHandle(ov.hEvent); + return ok; + } + + // Reads until one complete frame decodes, the deadline passes, or the + // pipe dies. `acc` persists across calls so partial reads carry over. + enum class ReadFrameStatus { Ok, Timeout, Closed, Poisoned }; + ReadFrameStatus ReadFrame(HANDLE pipe, std::vector& acc, Frame& out, DWORD timeoutMs) + { + const ULONGLONG deadline = GetTickCount64() + timeoutMs; + for (;;) + { + auto d = TryDecodeFrame(acc); + if (d.status == FrameDecodeStatus::Ok) + { + acc.erase(acc.begin(), acc.begin() + d.consumed); + out = std::move(d.frame); + return ReadFrameStatus::Ok; + } + if (d.status == FrameDecodeStatus::Oversize || d.status == FrameDecodeStatus::Malformed) + return ReadFrameStatus::Poisoned; // stream is unrecoverable + + ULONGLONG now = GetTickCount64(); + if (now >= deadline) + return ReadFrameStatus::Timeout; + + uint8_t buf[16 * 1024]; + OVERLAPPED ov{}; + ov.hEvent = CreateEventW(nullptr, TRUE, FALSE, nullptr); + if (!ov.hEvent) return ReadFrameStatus::Closed; + DWORD got = 0; + BOOL started = ReadFile(pipe, buf, sizeof(buf), nullptr, &ov); + if (!started && GetLastError() != ERROR_IO_PENDING) + { + CloseHandle(ov.hEvent); + return ReadFrameStatus::Closed; + } + DWORD wait = WaitForSingleObject(ov.hEvent, static_cast(deadline - now)); + if (wait != WAIT_OBJECT_0) + { + // Timed out: cancel, then WAIT for the completion packet — + // ERROR_NOT_FOUND from CancelIoEx just means the read + // completed in the race, so still drain the result before + // buf leaves scope (the classic use-after-free otherwise). + CancelIoEx(pipe, &ov); + if (GetOverlappedResult(pipe, &ov, &got, TRUE) && got > 0) + acc.insert(acc.end(), buf, buf + got); + CloseHandle(ov.hEvent); + continue; // loop re-checks decode + deadline + } + if (!GetOverlappedResult(pipe, &ov, &got, TRUE) || got == 0) + { + CloseHandle(ov.hEvent); + return ReadFrameStatus::Closed; + } + acc.insert(acc.end(), buf, buf + got); + CloseHandle(ov.hEvent); + } + } + + bool SendControl(HANDLE pipe, uint64_t& seq, const std::string& json) + { + std::vector wire; + if (!EncodeFrame(MakeControl(seq++, json), wire)) + return false; + return WriteAll(pipe, wire); + } + + // ---- Client-side connect + hello -------------------------------------- + struct HubConnection + { + HANDLE pipe{ INVALID_HANDLE_VALUE }; + uint64_t seq{ 1 }; + std::vector acc; + + // Owns the pipe handle: rule-of-five matters here. Without an + // explicit move ctor the compiler-generated COPY would duplicate + // the raw HANDLE value and the source's destructor would close + // it — the returned connection then holds a dead handle (this + // exact bug shipped for about an hour; the smoke caught it as + // "Hub connection lost" on the first post-connect request). + HubConnection() = default; + HubConnection(HubConnection&& o) noexcept + : pipe(o.pipe), seq(o.seq), acc(std::move(o.acc)) + { + o.pipe = INVALID_HANDLE_VALUE; + } + HubConnection& operator=(HubConnection&& o) noexcept + { + if (this != &o) + { + if (pipe != INVALID_HANDLE_VALUE) CloseHandle(pipe); + pipe = o.pipe; seq = o.seq; acc = std::move(o.acc); + o.pipe = INVALID_HANDLE_VALUE; + } + return *this; + } + HubConnection(const HubConnection&) = delete; + HubConnection& operator=(const HubConnection&) = delete; + ~HubConnection() { if (pipe != INVALID_HANDLE_VALUE) CloseHandle(pipe); } + }; + + // Connects, verifies the hub's process identity, exchanges hello. + // role: "shim" | "hub-probe". Returns nullopt with reason logged. + std::optional ConnectToHub(const BrokerNames& names, const char* role) + { + HubConnection c; + c.pipe = CreateFileW(names.pipePath.c_str(), + FILE_READ_DATA | FILE_WRITE_DATA | FILE_WRITE_ATTRIBUTES | SYNCHRONIZE, + 0, nullptr, OPEN_EXISTING, FILE_FLAG_OVERLAPPED, nullptr); + if (c.pipe == INVALID_HANDLE_VALUE) + { + Log(std::format("connect: CreateFileW failed {}", GetLastError())); + return std::nullopt; + } + + // Verify the peer PROCESS before trusting anything it says. + auto self = ResolveProcessIdentity(GetCurrentProcessId()); + auto hub = ResolvePipeServerIdentity(c.pipe); + if (!self || !hub) + { + Log("connect: identity resolution failed"); + return std::nullopt; + } + + auto myBuild = Utf8(LocalBuildId()); + if (!SendControl(c.pipe, c.seq, std::format( + R"({{"op":"hello","role":"{}","protocol":"v1","buildId":"{}","pid":{}}})", + role, JsonEscape(myBuild), GetCurrentProcessId()))) + { + Log("connect: hello write failed"); + return std::nullopt; + } + + Frame reply; + if (ReadFrame(c.pipe, c.acc, reply, 3000) != ReadFrameStatus::Ok) + { + Log("connect: no hello reply within 3s"); + return std::nullopt; + } + auto obj = ParseControl(reply); + if (!obj || Utf8(obj->GetNamedString(L"op", L"")) != "hello-ack") + { + Log("connect: hub refused or malformed hello reply"); + return std::nullopt; + } + + // The shim (and hub-probe) do NOT strictly pair the hub: a shim is + // inherently cross-identity with a packaged hub, and a rogue hub + // sees only sealed bytes. The strict session↔hub pairing lives in + // McpSessionClient (a session must verify it registers with a real + // ShaderLab hub). Identity was still resolved above so the peer PID + // is known; the hello-ack confirms a live hub answered. + (void)hub; + return c; + } + + // ======================================================================== + // HUB + // ======================================================================== + // One connected peer (shim OR session). shared_ptr because the channel + // and session tables reference it across threads; the last ref closes + // the pipe. Writes come from the owning thread (control replies) AND + // relay threads, so every write serializes through writeMx (one-writer- + // per-pipe, enforced by the lock rather than by convention). + struct Conn + { + HANDLE pipe{ INVALID_HANDLE_VALUE }; + std::mutex writeMx; + uint64_t ctrlSeq{ 1 }; + std::string role; + std::wstring sessionId; + std::wstring label; + + ~Conn() { if (pipe != INVALID_HANDLE_VALUE) CloseHandle(pipe); } + + bool WriteFrameLocked(const Frame& f) + { + std::vector wire; + if (!EncodeFrame(f, wire)) return false; + std::lock_guard lk(writeMx); + return WriteAll(pipe, wire); + } + bool SendControl(const std::string& json) + { + Frame f; + f.header.channelId = 0; + f.header.seq = ctrlSeq++; // only the owning thread sends control + f.body.assign(json.begin(), json.end()); + return WriteFrameLocked(f); + } + bool Relay(const Frame& f) { return WriteFrameLocked(f); } + }; + + struct Hub + { + std::atomic clients{ 0 }; + std::atomic lastActivity{ 0 }; + PeerIdentity self; + std::wstring buildId; + + std::mutex mapMx; + std::unordered_map> sessions; // by sessionId + struct Pair { std::shared_ptr shim, session; }; + std::unordered_map channels; + std::atomic nextChannel{ 1 }; + + std::string SessionsJson() + { + std::lock_guard lk(mapMx); + std::string j = R"({"op":"sessions","sessions":[)"; + bool first = true; + for (auto& [id, c] : sessions) + { + if (!first) j += ","; + j += std::format(R"({{"id":"{}","label":"{}"}})", + JsonEscape(Utf8(id)), JsonEscape(Utf8(c->label))); + first = false; + } + j += "]}"; + return j; + } + }; + + void ServeConnection(std::shared_ptr conn, Hub* hub) + { + std::vector acc; + bool counted = false; + + auto finish = [&] { + // Tear down any channels + session registration this conn owned. + std::vector> notifyShims; + { + std::lock_guard lk(hub->mapMx); + if (!conn->sessionId.empty()) + { + auto it = hub->sessions.find(conn->sessionId); + if (it != hub->sessions.end() && it->second.get() == conn.get()) + hub->sessions.erase(it); + } + for (auto it = hub->channels.begin(); it != hub->channels.end();) + { + if (it->second.shim.get() == conn.get()) + { + // shim gone: tell the session to free the channel. + if (it->second.session) + it->second.session->SendControl(std::format( + R"({{"op":"channel-close","channelId":{}}})", it->first)); + it = hub->channels.erase(it); + } + else if (it->second.session.get() == conn.get()) + { + // session gone: tell the shim, distinctly. + if (it->second.shim) + it->second.shim->SendControl(std::format( + R"({{"op":"session-gone","channelId":{},"sessionId":"{}"}})", + it->first, JsonEscape(Utf8(conn->sessionId)))); + it = hub->channels.erase(it); + } + else ++it; + } + } + FlushFileBuffers(conn->pipe); + DisconnectNamedPipe(conn->pipe); + if (counted) hub->clients.fetch_sub(1); + hub->lastActivity.store(GetTickCount64()); + }; + + // First frame must be a channel-0 hello. + Frame first; + if (ReadFrame(conn->pipe, acc, first, 5000) != ReadFrameStatus::Ok || + first.header.channelId != 0) + { + Log("conn: no hello"); + finish(); + return; + } + auto hello = ParseControl(first); + if (!hello || Utf8(hello->GetNamedString(L"op", L"")) != "hello") + { + Log("conn: malformed hello"); + finish(); + return; + } + + auto clientIdent = ResolvePipeClientIdentity(conn->pipe); + auto theirBuild = std::wstring(hello->GetNamedString(L"buildId", L"")); + conn->role = Utf8(hello->GetNamedString(L"role", L"")); + conn->label = std::wstring(hello->GetNamedString(L"label", L"")); + conn->sessionId = std::wstring(hello->GetNamedString(L"sessionId", L"")); + auto protocol = Utf8(hello->GetNamedString(L"protocol", L"")); + + // Role-aware pairing. The strict binary-pairing boundary is enforced + // on SESSION registration — a session serves tool calls that mutate + // real graphs, so it must be a genuine ShaderLab (packaged→PFN match, + // or the gated unpackaged fallback). A SHIM is the MCP client's + // front-end whose payloads are sealed end-to-end to the session; the + // hub is blind to them, and in production the shim is unpackaged + // while the hub is packaged (an expected mix). Refusing that mix + // would make the whole shim↔hub link impossible, so a shim is + // accepted once its identity resolves and the protocol matches. + const char* refuse = nullptr; + if (protocol != "v1") + refuse = "protocol-mismatch"; + else if (!clientIdent) + refuse = "identity-unresolved"; + else if (conn->role == "session") + { + if (conn->sessionId.empty()) + refuse = "missing-session-id"; + else if (EvaluatePairing(hub->self, *clientIdent, hub->buildId, theirBuild) + != PairingVerdict::Accept) + refuse = "pairing-refused"; + } + + if (refuse) + { + Log(std::format("conn: refused ({}, role={})", refuse, conn->role)); + conn->SendControl(std::format(R"({{"op":"refused","reason":"{}"}})", refuse)); + finish(); + return; + } + + int sessionCount; + { + std::lock_guard lk(hub->mapMx); + if (conn->role == "session") + hub->sessions[conn->sessionId] = conn; + sessionCount = static_cast(hub->sessions.size()); + } + + conn->SendControl(std::format( + R"({{"op":"hello-ack","hubPid":{},"protocol":"v1","buildId":"{}","sessionCount":{}}})", + GetCurrentProcessId(), JsonEscape(Utf8(hub->buildId)), sessionCount)); + Log(std::format("conn: accepted role={} pid={} sessionId={}", + conn->role, clientIdent->pid, Utf8(conn->sessionId))); + + if (conn->role == "hub-probe") + { + finish(); + return; + } + + counted = true; + hub->clients.fetch_add(1); + hub->lastActivity.store(GetTickCount64()); + + for (;;) + { + Frame f; + auto rs = ReadFrame(conn->pipe, acc, f, 60'000); + if (rs == ReadFrameStatus::Timeout) + continue; + if (rs != ReadFrameStatus::Ok) + { + if (rs == ReadFrameStatus::Poisoned) + Log("conn: poisoned stream, dropping"); + break; + } + hub->lastActivity.store(GetTickCount64()); + + if (f.header.channelId == 0) + { + auto obj = ParseControl(f); + auto op = obj ? Utf8(obj->GetNamedString(L"op", L"")) : std::string(); + if (op == "list-sessions") + { + conn->SendControl(hub->SessionsJson()); + } + else if (op == "open-channel") + { + // shim asks to reach a session by id. Blind: the hub + // only pairs the two conns; the sealed handshake runs + // end-to-end over the allocated channel afterwards. + auto sid = obj ? std::wstring(obj->GetNamedString(L"sessionId", L"")) : L""; + std::shared_ptr target; + uint32_t cid = 0; + { + std::lock_guard lk(hub->mapMx); + auto it = hub->sessions.find(sid); + if (it != hub->sessions.end()) + { + target = it->second; + cid = hub->nextChannel.fetch_add(1); + hub->channels[cid] = Hub::Pair{ conn, target }; + } + } + if (target) + conn->SendControl(std::format( + R"({{"op":"channel-open","channelId":{},"sessionId":"{}"}})", + cid, JsonEscape(Utf8(sid)))); + else + conn->SendControl(std::format( + R"({{"op":"session-gone","sessionId":"{}"}})", JsonEscape(Utf8(sid)))); + } + else if (op == "bye") + break; + else + conn->SendControl(R"({"op":"error","reason":"unknown-op"})"); + } + else + { + // Relay: forward the (opaque, sealed) frame to the other + // end of this channel. The hub reads ONLY the channelId. + std::shared_ptr other; + { + std::lock_guard lk(hub->mapMx); + auto it = hub->channels.find(f.header.channelId); + if (it != hub->channels.end()) + other = (it->second.shim.get() == conn.get()) + ? it->second.session : it->second.shim; + } + if (other) + other->Relay(f); + else + conn->SendControl(std::format( + R"({{"op":"channel-gone","channelId":{}}})", f.header.channelId)); + } + } + Log("conn: closed"); + finish(); + } + + // Election-loss proof: connect to the incumbent and complete hello. + bool ProveHealthyHub(const BrokerNames& names) + { + return ConnectToHub(names, "hub-probe").has_value(); + } + + int RunHub(const BrokerNames& names, uint32_t idleExitSec) + { + // First thing: the activated hub gets a visible console otherwise + // (spike-verified), and the election loser must be console-free. + FreeConsole(); + OpenLog(L"hub"); + + // Explicit-rights DACL for the current user — every bit + // enumerated individually, never GENERIC_WRITE, so the + // FILE_CREATE_PIPE_INSTANCE grant (it shares the + // FILE_APPEND_DATA bit) is a visible decision instead of an + // accident. It IS granted, deliberately: the hub itself creates + // every subsequent instance under this same SID and the access + // check runs against the first instance's DACL. The EA rights + // are required too — CreateNamedPipe internally requests + // FILE_GENERIC_READ|WRITE, which include FILE_READ_EA / + // FILE_WRITE_EA; omit them and the hub's own next-instance + // create fails ERROR_ACCESS_DENIED (measured). Same-user + // instance squatting is outside the threat model (same-user + // isolation is not a hard boundary; see McpCrypto.h) — the + // enforced boundary is peer pairing at hello time, not the DACL. + HANDLE tok{}; + OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &tok); + BYTE tokBuf[SECURITY_MAX_SID_SIZE + sizeof(TOKEN_USER)]{}; + DWORD tokLen = sizeof(tokBuf); + GetTokenInformation(tok, TokenUser, tokBuf, tokLen, &tokLen); + CloseHandle(tok); + PSID userSid = reinterpret_cast(tokBuf)->User.Sid; + + EXPLICIT_ACCESSW ea{}; + ea.grfAccessPermissions = FILE_READ_DATA | FILE_WRITE_DATA | + FILE_READ_ATTRIBUTES | FILE_WRITE_ATTRIBUTES | + FILE_READ_EA | FILE_WRITE_EA | SYNCHRONIZE | + FILE_CREATE_PIPE_INSTANCE | READ_CONTROL; + ea.grfAccessMode = SET_ACCESS; + ea.grfInheritance = NO_INHERITANCE; + ea.Trustee.TrusteeForm = TRUSTEE_IS_SID; + ea.Trustee.TrusteeType = TRUSTEE_IS_USER; + ea.Trustee.ptstrName = reinterpret_cast(userSid); + + PACL acl = nullptr; + if (SetEntriesInAclW(1, &ea, nullptr, &acl) != ERROR_SUCCESS) + { + Log("hub: SetEntriesInAcl failed"); + return 2; + } + SECURITY_DESCRIPTOR sd{}; + InitializeSecurityDescriptor(&sd, SECURITY_DESCRIPTOR_REVISION); + SetSecurityDescriptorDacl(&sd, TRUE, acl, FALSE); + SECURITY_ATTRIBUTES sa{ sizeof(sa), &sd, FALSE }; + + HANDLE first = CreateNamedPipeW(names.pipePath.c_str(), + PIPE_ACCESS_DUPLEX | FILE_FLAG_OVERLAPPED | FILE_FLAG_FIRST_PIPE_INSTANCE, + PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT | PIPE_REJECT_REMOTE_CLIENTS, + PIPE_UNLIMITED_INSTANCES, 64 * 1024, 64 * 1024, 0, &sa); + if (first == INVALID_HANDLE_VALUE) + { + DWORD err = GetLastError(); + // ERROR_ACCESS_DENIED is NOT a bare "I lost" — it also means + // "parameters differ from the existing instance" (the stale- + // hub-after-update case) and "genuine DACL denial". Prove the + // loss by completing hello against the incumbent. + if (err == ERROR_ACCESS_DENIED || err == ERROR_PIPE_BUSY) + { + if (ProveHealthyHub(names)) + { + Log("hub: lost election to a healthy incumbent, exiting 0"); + LocalFree(acl); + return 0; + } + Log(std::format("hub: pipe exists but no healthy hub answered " + "(stale instance or DACL denial), err={}", err)); + LocalFree(acl); + return 3; + } + Log(std::format("hub: CreateNamedPipe failed {}", err)); + LocalFree(acl); + return 2; + } + + // Won the election. Publish readiness. The event is manual-reset + // and — if this process dies — STAYS SIGNALLED: waiters (Step 6 + // sessions) must pair it with a connect timeout + retry poll, or + // they wait forever on a corpse. Graceful exits reset it below. + HANDLE ready = CreateEventW(nullptr, TRUE, FALSE, names.readyEvent.c_str()); + + Hub hub; + auto selfIdent = ResolveProcessIdentity(GetCurrentProcessId()); + if (!selfIdent) + { + Log("hub: cannot resolve own identity"); + LocalFree(acl); + return 2; + } + hub.self = *selfIdent; + hub.buildId = LocalBuildId(); + hub.lastActivity.store(GetTickCount64()); + + Log(std::format("hub: elected, serving {} (idle-exit {}s)", + Utf8(names.pipePath), idleExitSec)); + if (ready) SetEvent(ready); + + HANDLE instance = first; + std::vector workers; + int exitCode = 0; + for (;;) + { + OVERLAPPED ov{}; + ov.hEvent = CreateEventW(nullptr, TRUE, FALSE, nullptr); + BOOL pending = !ConnectNamedPipe(instance, &ov); + DWORD err = pending ? GetLastError() : ERROR_PIPE_CONNECTED; + bool connected = (err == ERROR_PIPE_CONNECTED); + + while (!connected) + { + if (err != ERROR_IO_PENDING) + { + Log(std::format("hub: ConnectNamedPipe failed {}", err)); + exitCode = 2; + break; + } + DWORD w = WaitForSingleObject(ov.hEvent, 1000); + if (w == WAIT_OBJECT_0) + { + connected = true; + break; + } + // Idle check while nobody is knocking. + if (hub.clients.load() == 0 && idleExitSec > 0 && + GetTickCount64() - hub.lastActivity.load() > idleExitSec * 1000ull) + { + Log("hub: idle, exiting"); + CancelIoEx(instance, &ov); + DWORD dummy = 0; + GetOverlappedResult(instance, &ov, &dummy, TRUE); + CloseHandle(ov.hEvent); + CloseHandle(instance); + if (ready) { ResetEvent(ready); CloseHandle(ready); } + for (auto& t : workers) if (t.joinable()) t.detach(); + LocalFree(acl); + return 0; + } + } + CloseHandle(ov.hEvent); + if (exitCode != 0) + break; + + // Create the NEXT instance before serving this one, so a + // second client never finds zero listening instances. + HANDLE next = CreateNamedPipeW(names.pipePath.c_str(), + PIPE_ACCESS_DUPLEX | FILE_FLAG_OVERLAPPED, + PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT | PIPE_REJECT_REMOTE_CLIENTS, + PIPE_UNLIMITED_INSTANCES, 64 * 1024, 64 * 1024, 0, &sa); + if (next == INVALID_HANDLE_VALUE) + { + Log(std::format("hub: next-instance CreateNamedPipe failed {}", GetLastError())); + exitCode = 2; + CloseHandle(instance); + break; + } + + hub.lastActivity.store(GetTickCount64()); + auto conn = std::make_shared(); + conn->pipe = instance; + workers.emplace_back(ServeConnection, conn, &hub); + instance = next; + } + + if (ready) { ResetEvent(ready); CloseHandle(ready); } + for (auto& t : workers) if (t.joinable()) t.detach(); + LocalFree(acl); + return exitCode; + } + + // ======================================================================== + // SHIM + // ======================================================================== + void EmitLine(const std::string& line) + { + // stdout carries JSON-RPC frames only — one line per message, + // written as raw bytes ('\n' stays '\n'; stdout is _O_BINARY). + HANDLE out = GetStdHandle(STD_OUTPUT_HANDLE); + DWORD written = 0; + WriteFile(out, line.data(), static_cast(line.size()), &written, nullptr); + const char nl = '\n'; + WriteFile(out, &nl, 1, &written, nullptr); + } + + std::string WrapResult(const std::string& idStr, const std::string& result) + { + return std::format(R"JSON({{"jsonrpc":"2.0","id":{},"result":{}}})JSON", idStr, result); + } + + std::string WrapError(const std::string& idStr, int code, std::string_view message) + { + return std::format( + R"JSON({{"jsonrpc":"2.0","id":{},"error":{{"code":{},"message":"{}"}}}})JSON", + idStr, code, JsonEscape(message)); + } + + std::string TextToolResult(const std::string& body, bool isError) + { + return std::format( + R"JSON({{"content":[{{"type":"text","text":"{}"}}],"isError":{}}})JSON", + JsonEscape(body), isError ? "true" : "false"); + } + + constexpr const char* kShimToolsJson = + R"JSON({"tools":[)JSON" + R"JSON({"name":"list_sessions","description":"List the ShaderLab sessions currently registered with the MCP hub. Each entry carries a session id usable with use_session.","inputSchema":{"type":"object","properties":{}}},)JSON" + R"JSON({"name":"use_session","description":"Attach this MCP connection to a ShaderLab session by id (from list_sessions). Subsequent tool calls are routed to that session.","inputSchema":{"type":"object","properties":{"sessionId":{"type":"string"}},"required":["sessionId"]}})JSON" + R"JSON(]})JSON"; + + bool SendData(HANDLE pipe, uint32_t channelId, uint64_t seq, + const std::vector& body) + { + Frame f; + f.header.channelId = channelId; + f.header.seq = seq; + f.body = body; + std::vector wire; + if (!EncodeFrame(f, wire)) return false; + return WriteAll(pipe, wire); + } + + // Activate the packaged hub via its AUMID. This is the ONLY way the hub + // survives the MCP client's job object (spike: plain CreateProcess dies + // with the job). A non-packaged shim activating a packaged app is proven + // to work. Args carry the pipe base so the activated hub binds the same + // pipe the shim will connect to (activation does NOT inherit our env). + bool ActivateHub(const std::wstring& aumid, const std::wstring& pipeBase) + { + if (aumid.empty()) + return false; + winrt::com_ptr mgr; + HRESULT hr = CoCreateInstance(CLSID_ApplicationActivationManager, nullptr, + CLSCTX_LOCAL_SERVER, IID_PPV_ARGS(mgr.put())); + if (FAILED(hr)) + { + Log(std::format("activate: CoCreateInstance failed 0x{:08X}", static_cast(hr))); + return false; + } + std::wstring args = std::format(L"--hub --pipe {}", pipeBase); + DWORD pid = 0; + hr = mgr->ActivateApplication(aumid.c_str(), args.c_str(), AO_NONE, &pid); + if (FAILED(hr)) + { + Log(std::format("activate: ActivateApplication failed 0x{:08X}", static_cast(hr))); + return false; + } + Log(std::format("activate: hub pid {}", pid)); + return true; + } + + struct ShimState + { + BrokerNames names; + std::wstring hubAumid; // packaged hub AUMID for on-demand activation + std::optional hub; // lazy; may be nullopt (no hub) + + // Pinned session (from use_session) + its secure channel. One + // channel, serial requests — the shim owns id correlation, so a + // single in-flight request per session is sufficient. + std::wstring pinnedSession; + uint32_t channelId{ 0 }; + std::optional channel; + uint64_t chanSendSeq{ 1 }; + + bool EnsureHub() + { + if (hub && hub->pipe != INVALID_HANDLE_VALUE) + return true; + hub = ConnectToHub(names, "shim"); + if (hub) + return true; + + // No hub answered. If we know the packaged hub's AUMID, activate + // it (this is the client-driven bootstrap: the MCP client's shim + // brings the hub up) and retry with a short poll for the pipe. + if (hubAumid.empty() || !ActivateHub(hubAumid, names.baseName)) + return false; + for (int i = 0; i < 20; ++i) // ~5 s + { + Sleep(250); + if (WaitNamedPipeW(names.pipePath.c_str(), 200)) + { + hub = ConnectToHub(names, "shim"); + if (hub) return true; + } + } + return false; + } + + void DropChannel() + { + channelId = 0; + channel.reset(); + chanSendSeq = 1; + } + }; + + // Round-trip a control op that expects a single control reply. + std::optional HubControlRoundTrip(ShimState& st, const std::string& opJson) + { + if (!st.EnsureHub()) return std::nullopt; + if (!SendControl(st.hub->pipe, st.hub->seq, opJson)) { st.hub.reset(); return std::nullopt; } + Frame reply; + // Skip any interleaved channel frames; a control op answers on ch0. + for (int i = 0; i < 64; ++i) + { + if (ReadFrame(st.hub->pipe, st.hub->acc, reply, 5000) != ReadFrameStatus::Ok) + { st.hub.reset(); return std::nullopt; } + if (reply.header.channelId == 0) + return ParseControl(reply); + } + return std::nullopt; + } + + // Ensure a ready secure channel to the pinned session. Returns false + // (and clears the pin) if the session is gone. + bool EnsureChannel(ShimState& st) + { + if (st.channelId != 0 && st.channel && st.channel->Ready()) + return true; + st.DropChannel(); + if (st.pinnedSession.empty() || !st.EnsureHub()) + return false; + + auto reply = HubControlRoundTrip(st, std::format( + R"({{"op":"open-channel","sessionId":"{}"}})", JsonEscape(Utf8(st.pinnedSession)))); + if (!reply) return false; + auto op = Utf8(reply->GetNamedString(L"op", L"")); + if (op == "session-gone") { st.pinnedSession.clear(); return false; } + if (op != "channel-open") return false; + st.channelId = static_cast(reply->GetNamedNumber(L"channelId", 0)); + + auto sc = SecureChannel::Create(/*initiator=*/true); + if (!sc) { st.DropChannel(); return false; } + // Send our hello (seq 0), then read the session's hello on this channel. + if (!SendData(st.hub->pipe, st.channelId, 0, sc->HelloBody())) + { st.hub.reset(); st.DropChannel(); return false; } + + Frame f; + for (int i = 0; i < 64; ++i) + { + if (ReadFrame(st.hub->pipe, st.hub->acc, f, 5000) != ReadFrameStatus::Ok) + { st.hub.reset(); st.DropChannel(); return false; } + if (f.header.channelId == 0) + { + auto o = ParseControl(f); + auto cop = o ? Utf8(o->GetNamedString(L"op", L"")) : std::string(); + if (cop == "session-gone" || cop == "channel-gone" || cop == "channel-close") + { st.pinnedSession.clear(); st.DropChannel(); return false; } + continue; + } + if (f.header.channelId == st.channelId && f.header.seq == 0) + { + if (!sc->OnPeerHello(f.body)) { st.DropChannel(); return false; } + st.channel = std::move(*sc); + st.chanSendSeq = 1; + return st.channel->Ready(); + } + } + st.DropChannel(); + return false; + } + + // Seal `requestLine`, send it to the pinned session, return the + // session's response line (already a full JSON-RPC response carrying + // the request's id — the shim is a pass-through for forwarded + // methods). nullopt means session_gone / channel failure. + std::optional ForwardToSession(ShimState& st, const std::string& requestLine) + { + if (!EnsureChannel(st)) + return std::nullopt; + const uint32_t cid = st.channelId; + uint64_t seq = st.chanSendSeq++; + std::vector plain(requestLine.begin(), requestLine.end()); + auto sealed = st.channel->Seal(cid, seq, plain); + if (!sealed || !SendData(st.hub->pipe, cid, seq, *sealed)) + { st.hub.reset(); st.DropChannel(); return std::nullopt; } + + Frame f; + const DWORD shimTimeout = static_cast(kShimRequestTimeout.count()); + for (int i = 0; i < 64; ++i) + { + if (ReadFrame(st.hub->pipe, st.hub->acc, f, shimTimeout) != ReadFrameStatus::Ok) + { st.hub.reset(); st.DropChannel(); return std::nullopt; } + if (f.header.channelId == 0) + { + auto o = ParseControl(f); + auto cop = o ? Utf8(o->GetNamedString(L"op", L"")) : std::string(); + if (cop == "session-gone" || cop == "channel-gone" || cop == "channel-close") + { st.pinnedSession.clear(); st.DropChannel(); return std::nullopt; } + continue; + } + if (f.header.channelId == cid && f.header.seq >= 1) + { + auto plainResp = st.channel->Open(cid, f.header.seq, f.body); + if (!plainResp) { st.DropChannel(); return std::nullopt; } + return std::string(plainResp->begin(), plainResp->end()); + } + } + return std::nullopt; + } + + std::string HandleShimRequest(ShimState& st, const std::string& line) + { + std::string idStr = "null"; + try + { + WDJ::JsonObject jobj{ nullptr }; + if (!WDJ::JsonObject::TryParse(winrt::to_hstring(line), jobj)) + return WrapError("null", -32700, "Parse error"); + + bool hasId = jobj.HasKey(L"id"); + if (hasId) + { + auto id = jobj.GetNamedValue(L"id"); + if (id.ValueType() == WDJ::JsonValueType::Number) + idStr = std::format("{}", static_cast(id.GetNumber())); + else if (id.ValueType() == WDJ::JsonValueType::String) + idStr = "\"" + JsonEscape(Utf8(std::wstring(id.GetString()))) + "\""; + else + hasId = false; + } + if (!jobj.HasKey(L"method")) + return hasId ? WrapError(idStr, -32600, "Invalid Request: missing method") + : std::string(); + auto method = Utf8(std::wstring(jobj.GetNamedString(L"method"))); + + if (!hasId) + return {}; // notification: zero bytes + + // ---- Shim-owned methods (never forwarded) ---- + if (method == "initialize") + { + return WrapResult(idStr, std::format( + R"JSON({{"protocolVersion":"2025-06-18","capabilities":{{"tools":{{"listChanged":true}},"resources":{{}}}},"serverInfo":{{"name":"shaderlab-shim","version":"{}"}}}})JSON", + JsonEscape(Utf8(::ShaderLab::VersionString)))); + } + if (method == "ping") + return WrapResult(idStr, "{}"); + if (method == "resources/list") + return WrapResult(idStr, R"JSON({"resources":[]})JSON"); + if (method == "tools/list") + { + // Splice: the 2 shim tools + (when a session is pinned) + // that session's catalog, merged as real JSON values — + // never string-spliced. use_session flipping the pinned + // session is why the shim advertises tools.listChanged. + WDJ::JsonObject shimObj{ nullptr }; + WDJ::JsonObject::TryParse(winrt::to_hstring(std::string(kShimToolsJson)), shimObj); + WDJ::JsonArray merged; + auto appendAll = [&](WDJ::JsonArray const& arr) { + for (uint32_t i = 0; i < arr.Size(); ++i) + { + WDJ::JsonValue v{ nullptr }; + if (WDJ::JsonValue::TryParse(arr.GetAt(i).Stringify(), v)) + merged.Append(v); + } + }; + appendAll(shimObj.GetNamedArray(L"tools")); + if (!st.pinnedSession.empty()) + { + auto resp = ForwardToSession(st, + R"({"jsonrpc":"2.0","id":0,"method":"tools/list"})"); + WDJ::JsonObject ro{ nullptr }; + if (resp && WDJ::JsonObject::TryParse(winrt::to_hstring(*resp), ro) + && ro.HasKey(L"result")) + { + auto result = ro.GetNamedObject(L"result"); + if (result.HasKey(L"tools")) + appendAll(result.GetNamedArray(L"tools")); + } + } + WDJ::JsonObject outObj; + outObj.Insert(L"tools", merged); + return WrapResult(idStr, Utf8(std::wstring(outObj.Stringify()))); + } + + const bool isToolCall = (method == "tools/call"); + if (isToolCall) + { + if (!jobj.HasKey(L"params") || + jobj.GetNamedValue(L"params").ValueType() != WDJ::JsonValueType::Object) + return WrapError(idStr, -32602, "Invalid params"); + auto params = jobj.GetNamedObject(L"params"); + if (!params.HasKey(L"name")) + return WrapError(idStr, -32602, "Invalid params: missing tool name"); + auto tool = Utf8(std::wstring(params.GetNamedString(L"name"))); + + if (tool == "list_sessions") + { + auto reply = HubControlRoundTrip(st, R"({"op":"list-sessions"})"); + if (!reply) + return WrapResult(idStr, TextToolResult( + "No hub is running, or it did not answer. Launch ShaderLab " + "(or restart your MCP client after installing) and retry.", true)); + if (Utf8(reply->GetNamedString(L"op", L"")) != "sessions") + return WrapResult(idStr, TextToolResult("Malformed hub reply.", true)); + auto arr = reply->GetNamedArray(L"sessions", WDJ::JsonArray()); + return WrapResult(idStr, TextToolResult( + std::format(R"({{"sessions":{}}})", Utf8(std::wstring(arr.Stringify()))), + false)); + } + if (tool == "use_session") + { + auto args = params.HasKey(L"arguments") && + params.GetNamedValue(L"arguments").ValueType() == WDJ::JsonValueType::Object + ? params.GetNamedObject(L"arguments") : WDJ::JsonObject(); + auto sid = std::wstring(args.GetNamedString(L"sessionId", L"")); + if (sid.empty()) + return WrapResult(idStr, TextToolResult("use_session requires a sessionId.", true)); + + // Validate against the live registry before pinning. + auto reply = HubControlRoundTrip(st, R"({"op":"list-sessions"})"); + bool found = false; + if (reply && Utf8(reply->GetNamedString(L"op", L"")) == "sessions") + { + auto arr = reply->GetNamedArray(L"sessions", WDJ::JsonArray()); + for (uint32_t i = 0; i < arr.Size(); ++i) + if (std::wstring(arr.GetObjectAt(i).GetNamedString(L"id", L"")) == sid) + { found = true; break; } + } + if (!found) + return WrapResult(idStr, TextToolResult( + "Unknown session. Call list_sessions to see registered sessions.", true)); + st.DropChannel(); + st.pinnedSession = sid; + // Respond, then announce the tool set changed. Now that a session + // is pinned, tools/list returns its catalog (see above), so a + // listChanged-aware client (e.g. Claude Code) must re-fetch to see + // the session's tools. The shim advertises tools.listChanged in + // initialize for exactly this; without emitting the notification the + // session's tools stay invisible after attach. Emitted here rather + // than returned so the use_session reply goes out first. + EmitLine(WrapResult(idStr, TextToolResult( + std::format(R"({{"attached":"{}"}})", JsonEscape(Utf8(sid))), false))); + EmitLine(R"({"jsonrpc":"2.0","method":"notifications/tools/list_changed"})"); + return ""; // both frames already written above + } + // Any other tool: route to the pinned session. + } + else if (method != "resources/read") + { + // Unknown non-forwardable method. + return WrapError(idStr, -32601, "Method not found: " + method); + } + + // ---- Forwarded methods (graph tools, resources/read) ---- + if (st.pinnedSession.empty()) + { + return isToolCall + ? WrapResult(idStr, TextToolResult( + "No session attached. Call list_sessions, then use_session, " + "before using graph tools.", true)) + : WrapError(idStr, -32001, "No session attached"); + } + auto forwarded = ForwardToSession(st, line); + if (forwarded) + return *forwarded; // full JSON-RPC response, id already correct + return isToolCall + ? WrapResult(idStr, TextToolResult( + "session_gone: the pinned ShaderLab session is no longer registered " + "with the hub. Call list_sessions and use_session again.", true)) + : WrapError(idStr, -32001, "session_gone"); + } + catch (...) + { + return WrapError(idStr, -32603, "Internal error"); + } + } + + int RunStdio(const BrokerNames& names, const std::wstring& hubAumid) + { + // Binary mode: text mode would translate '\n' to "\r\n" on the + // wire while string-level assertions still pass. + _setmode(_fileno(stdin), _O_BINARY); + _setmode(_fileno(stdout), _O_BINARY); + OpenLog(L"shim"); + Log(std::format("shim: started, pipe base {}, aumid {}", + Utf8(names.baseName), hubAumid.empty() ? "(none)" : Utf8(hubAumid))); + + ShimState st; + st.names = names; + st.hubAumid = hubAumid; + st.EnsureHub(); // best-effort; absence (+ activation) handled per-request + + HANDLE in = GetStdHandle(STD_INPUT_HANDLE); + std::string acc; + char buf[16 * 1024]; + for (;;) + { + DWORD got = 0; + if (!ReadFile(in, buf, sizeof(buf), &got, nullptr) || got == 0) + break; // client closed stdin: exit + acc.append(buf, got); + + size_t nl; + while ((nl = acc.find('\n')) != std::string::npos) + { + std::string line = acc.substr(0, nl); + acc.erase(0, nl + 1); + if (!line.empty() && line.back() == '\r') + line.pop_back(); + if (line.empty()) + continue; + auto reply = HandleShimRequest(st, line); + if (!reply.empty()) + EmitLine(reply); + } + } + + if (st.hub) + SendControl(st.hub->pipe, st.hub->seq, R"({"op":"bye"})"); + Log("shim: stdin closed, exiting"); + return 0; + } +} + +int wmain(int argc, wchar_t* argv[]) +{ + winrt::init_apartment(winrt::apartment_type::multi_threaded); + + bool hubMode = false, stdioMode = false; + std::wstring pipeArg; + std::wstring hubAumid; + uint32_t idleExitSec = 120; + for (int i = 1; i < argc; ++i) + { + std::wstring a = argv[i]; + if (a == L"--hub") hubMode = true; + else if (a == L"--stdio") stdioMode = true; + else if (a == L"--pipe" && i + 1 < argc) pipeArg = argv[++i]; + else if (a == L"--hub-aumid" && i + 1 < argc) hubAumid = argv[++i]; + else if (a == L"--idle-exit-sec" && i + 1 < argc) + idleExitSec = static_cast(_wtoi(argv[++i])); + else + { + fwprintf(stderr, L"ShaderLabMcpBroker --hub|--stdio [--pipe NAME] " + L"[--hub-aumid AUMID] [--idle-exit-sec N]\n"); + return 1; + } + } + if (hubMode == stdioMode) // exactly one mode required + { + fwprintf(stderr, L"ShaderLabMcpBroker: exactly one of --hub / --stdio is required\n"); + return 1; + } + + auto names = ResolveNames(pipeArg); + return hubMode ? RunHub(names, idleExitSec) : RunStdio(names, hubAumid); +} diff --git a/Tests/RunBrokerSmoke.ps1 b/Tests/RunBrokerSmoke.ps1 new file mode 100644 index 0000000..7a131d0 --- /dev/null +++ b/Tests/RunBrokerSmoke.ps1 @@ -0,0 +1,221 @@ +<# +.SYNOPSIS + MCP broker smoke (stdio-migration Step 5): election, framing, idle + exit, stdout hygiene, initialize / tools/list with no session attached. + +.DESCRIPTION + Runs entirely against a PRE-LAUNCHED unpackaged hub on an isolated + pipe name (a fresh GUID per run — the readiness event derives from + the pipe name, so one override isolates every named object). This is + deliberate: no unpackaged activation path exists, so neither this + script nor CI covers IApplicationActivationManager-based election of + the PACKAGED hub — that stays a manual test (documented gap, not a + pretended coverage). + + SHADERLAB_MCP_ALLOW_UNPACKAGED=1 is set for the duration: hub and + shim are the same unpackaged binary in the same directory, which is + exactly the dev/CI pairing fallback. +#> +param( + [string]$Configuration = "Debug", + [string]$Platform = "x64" +) + +$ErrorActionPreference = "Stop" +$root = Split-Path $PSScriptRoot -Parent +$exe = Join-Path $root "$Platform\$Configuration\ShaderLabMcpBroker\ShaderLabMcpBroker.exe" +$headless = Join-Path $root "$Platform\$Configuration\ShaderLabHeadless\ShaderLabHeadless.exe" +$fixture = Join-Path $PSScriptRoot "fixtures\test_cli_basic.json" +if (-not (Test-Path $exe)) { + Write-Error "Broker not found at $exe -- build first." + exit 1 +} + +$script:failures = 0 +function Check($name, $cond) { + if ($cond) { Write-Host "[PASS] $name" -ForegroundColor Green } + else { Write-Host "[FAIL] $name" -ForegroundColor Red; $script:failures++ } +} + +$pipe = "ShaderLab.mcp.test.$([guid]::NewGuid().ToString('N'))" +$env:SHADERLAB_MCP_ALLOW_UNPACKAGED = '1' +Write-Host "Broker: $exe" +Write-Host "Pipe: $pipe" + +$hub = $null +$shim = $null +$session = $null +try { + # ---- 1. Hub election winner comes up ---------------------------------- + $hub = Start-Process $exe -ArgumentList '--hub','--pipe',$pipe,'--idle-exit-sec','4' ` + -PassThru -WindowStyle Hidden + $deadline = (Get-Date).AddSeconds(8) + $up = $false + while ((Get-Date) -lt $deadline) { + if (Test-Path "\\.\pipe\$pipe") { $up = $true; break } + if ($hub.HasExited) { break } + Start-Sleep -Milliseconds 200 + } + Check "Hub.PipeAppears" $up + Check "Hub.StaysRunning" (-not $hub.HasExited) + + # ---- 2. Election: a second hub proves the incumbent and exits 0 ------- + $hub2 = Start-Process $exe -ArgumentList '--hub','--pipe',$pipe,'--idle-exit-sec','4' ` + -PassThru -WindowStyle Hidden + $exited = $hub2.WaitForExit(8000) + Check "Election.LoserExitsQuickly" $exited + Check "Election.LoserExitCodeZero" ($exited -and $hub2.ExitCode -eq 0) + Check "Election.WinnerSurvives" (-not $hub.HasExited) + + # ---- 3. Shim conversation over redirected stdio ----------------------- + $psi = [System.Diagnostics.ProcessStartInfo]::new() + $psi.FileName = $exe + $psi.Arguments = "--stdio --pipe $pipe" + $psi.UseShellExecute = $false + $psi.RedirectStandardInput = $true + $psi.RedirectStandardOutput = $true + $psi.RedirectStandardError = $true + $shim = [System.Diagnostics.Process]::Start($psi) + + function SendRaw([string]$line) { $shim.StandardInput.WriteLine($line) } + function Send($obj) { SendRaw ($obj | ConvertTo-Json -Depth 8 -Compress) } + function Recv($timeoutMs = 6000) { + $task = $shim.StandardOutput.ReadLineAsync() + if (-not $task.Wait($timeoutMs)) { return $null } + return $task.Result + } + + Send @{ jsonrpc = '2.0'; id = 1; method = 'initialize'; params = @{ protocolVersion = '2025-06-18' } } + $line = Recv + $init = if ($line) { $line | ConvertFrom-Json } else { $null } + Check "Shim.InitializeAnswers" ($null -ne $init) + Check "Shim.InitializeVersion" ($init.result.protocolVersion -eq '2025-06-18') + Check "Shim.InitializeServerName" ($init.result.serverInfo.name -eq 'shaderlab-shim') + Check "Shim.SingleLineJson" ($line -and -not $line.Contains("`r")) + + Send @{ jsonrpc = '2.0'; id = 2; method = 'tools/list' } + $tl = (Recv) | ConvertFrom-Json + $names = @($tl.result.tools | ForEach-Object name) + Check "Shim.ToolsListSessionTools" (($names -contains 'list_sessions') -and ($names -contains 'use_session') -and $names.Count -eq 2) + + Send @{ jsonrpc = '2.0'; id = 3; method = 'tools/call'; params = @{ name = 'list_sessions'; arguments = @{} } } + $ls = (Recv) | ConvertFrom-Json + $sessionsOk = $false + if ($ls.result.isError -eq $false) { + try { $sessionsOk = @(($ls.result.content[0].text | ConvertFrom-Json).sessions).Count -eq 0 } catch {} + } else { + Write-Host " list_sessions error text: $($ls.result.content[0].text)" -ForegroundColor DarkGray + } + Check "Shim.ListSessionsEmpty" $sessionsOk + + Send @{ jsonrpc = '2.0'; id = 4; method = 'tools/call'; params = @{ name = 'graph_add_node'; arguments = @{ effectName = 'x' } } } + $ga = (Recv) | ConvertFrom-Json + Check "Shim.GraphToolNoSessionError" (($ga.result.isError -eq $true) -and ($ga.result.content[0].text -match 'No session attached')) + + Send @{ jsonrpc = '2.0'; id = 5; method = 'tools/call'; params = @{ name = 'use_session'; arguments = @{ sessionId = 'no-such-session' } } } + $usebad = (Recv) | ConvertFrom-Json + Check "Shim.UseUnknownSessionError" (($usebad.result.isError -eq $true) -and ($usebad.result.content[0].text -match 'Unknown session')) + + # ---- 3b. End-to-end through a real headless session ------------------- + # This is the Step 6 gate: launch a headless session (WARP), register it + # with the hub, list it, pin it, drive a real engine route through the + # sealed shim->hub->session channel, and read the result back. + if (Test-Path $headless) { + $session = Start-Process $headless -ArgumentList ` + '--graph',$fixture,'--mcp-session','--pipe',$pipe, ` + '--session-label','smoke-session','--adapter','warp' -PassThru -WindowStyle Hidden + + # Wait for the session to register (list_sessions returns it). + $sid = $null + $deadline2 = (Get-Date).AddSeconds(30) + while ((Get-Date) -lt $deadline2) { + Send @{ jsonrpc = '2.0'; id = 100; method = 'tools/call'; params = @{ name = 'list_sessions'; arguments = @{} } } + $r = (Recv) | ConvertFrom-Json + if ($r.result.isError -eq $false) { + $ss = @(($r.result.content[0].text | ConvertFrom-Json).sessions) + if ($ss.Count -ge 1) { $sid = $ss[0].id; break } + } + if ($session.HasExited) { break } + Start-Sleep -Milliseconds 500 + } + Check "Session.Registers" ($null -ne $sid) + + if ($sid) { + Send @{ jsonrpc = '2.0'; id = 101; method = 'tools/call'; params = @{ name = 'use_session'; arguments = @{ sessionId = $sid } } } + $use = (Recv) | ConvertFrom-Json + Check "Session.UseAttaches" ($use.result.isError -eq $false) + + # tools/list now splices the session's catalog in. + Send @{ jsonrpc = '2.0'; id = 102; method = 'tools/list' } + $spliced = @(((Recv) | ConvertFrom-Json).result.tools | ForEach-Object name) + Check "Session.ToolsListSpliced" (($spliced -contains 'list_sessions') -and ($spliced -contains 'graph_add_node') -and ($spliced -contains 'graph_overview')) + + # Drive a real engine route end-to-end (sealed through the hub). + Send @{ jsonrpc = '2.0'; id = 103; method = 'tools/call'; params = @{ name = 'graph_overview'; arguments = @{} } } + $ov = (Recv) | ConvertFrom-Json + $ovOk = $false + if ($ov.result.isError -eq $false) { + try { $ovOk = $null -ne ($ov.result.content[0].text | ConvertFrom-Json).nodes } catch {} + } + Check "Session.GraphOverviewRoundTrip" $ovOk + + Send @{ jsonrpc = '2.0'; id = 104; method = 'tools/call'; params = @{ name = 'graph_add_node'; arguments = @{ effectName = 'Gaussian Blur' } } } + $add = (Recv) | ConvertFrom-Json + $addOk = $false + if ($add.result.isError -eq $false) { + try { $addOk = ($add.result.content[0].text | ConvertFrom-Json).nodeId -ge 1 } catch {} + } + Check "Session.MutatingRouteRoundTrip" $addOk + + # session_gone: kill the session, then a forwarded call must + # surface a distinct error, not a hang or silent success. + Stop-Process -Id $session.Id -Force -ErrorAction SilentlyContinue + $session.WaitForExit(5000) | Out-Null + Start-Sleep -Milliseconds 800 + Send @{ jsonrpc = '2.0'; id = 105; method = 'tools/call'; params = @{ name = 'graph_overview'; arguments = @{} } } + $gone = (Recv) | ConvertFrom-Json + Check "Session.GoneSurfacesDistinctError" (($gone.result.isError -eq $true) -and ($gone.result.content[0].text -match 'session_gone|No session')) + } + } else { + Write-Host "[SKIP] headless not built -- session end-to-end checks skipped" -ForegroundColor DarkYellow + } + + # Notification must emit ZERO bytes: send one, then a ping — the very + # next line back must be the ping reply. + Send @{ jsonrpc = '2.0'; method = 'notifications/initialized' } + Send @{ jsonrpc = '2.0'; id = 9; method = 'ping' } + $ping = (Recv) | ConvertFrom-Json + Check "Shim.NotificationSilent" ($ping.id -eq 9) + + SendRaw 'this is not json' + $err = (Recv) | ConvertFrom-Json + Check "Shim.ParseErrorShape" ($err.error.code -eq -32700) + + $shim.StandardInput.Close() + $shimExited = $shim.WaitForExit(6000) + Check "Shim.ExitsOnStdinClose" $shimExited + Check "Shim.ExitCodeZero" ($shimExited -and $shim.ExitCode -eq 0) + + # stdout hygiene: everything received above parsed as JSON, and the + # shim wrote nothing to stderr. (Checked after exit — StreamReader + # peeks BLOCK on a live process with an empty stream.) + $stderrText = if ($shimExited) { $shim.StandardError.ReadToEnd() } else { 'shim still running' } + Check "Shim.StdErrQuiet" ([string]::IsNullOrEmpty($stderrText)) + + # ---- 4. Idle exit ------------------------------------------------------ + # Last client gone; --idle-exit-sec 4 should take the hub down. + $hubExited = $hub.WaitForExit(15000) + Check "Hub.IdleExit" $hubExited + Check "Hub.IdleExitCodeZero" ($hubExited -and $hub.ExitCode -eq 0) +} +finally { + foreach ($p in @($shim, $session, $hub)) { + if ($p -and -not $p.HasExited) { Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue } + } + Remove-Item Env:\SHADERLAB_MCP_ALLOW_UNPACKAGED -ErrorAction SilentlyContinue +} + +Write-Host "" +if ($script:failures -eq 0) { Write-Host "BROKER SMOKE: ALL CHECKS PASSED" -ForegroundColor Green } +else { Write-Host "BROKER SMOKE: $($script:failures) FAILURE(S)" -ForegroundColor Red } +exit $script:failures From cc5473be49c5d340a07d461125e062c86631b5ea Mon Sep 17 00:00:00 2001 From: David Spruill Date: Fri, 14 Aug 2026 00:13:20 -0400 Subject: [PATCH 4/6] Improve compatibility of the image source effect to properly handle HDR/SDR for a wider variety of sources. Make the app respect panel brightness and SDR white level. --- .github/copilot-instructions.md | 4 +- App.xaml.cpp | 5 +- CHANGELOG.md | 16 + Effects/ImageLoader.cpp | 250 +++++--- Effects/ImageLoader.h | 32 +- Effects/ShaderLabEffects.cpp | 15 +- Engine/Mcp/EngineMcpRoutes.cpp | 39 +- Engine/Mcp/EngineMcpRoutes.h | 9 +- Engine/Mcp/McpCrypto.h | 6 +- Engine/Mcp/McpToolCatalog.cpp | 2 +- MainWindow.GraphFileIo.cpp | 1 + MainWindow.McpRoutes.cpp | 16 +- MainWindow.RenderTick.cpp | 19 + MainWindow.WorkingSpace.cpp | 10 +- MainWindow.xaml.cpp | 130 ++-- MainWindow.xaml.h | 27 +- Package.appxmanifest | 4 +- README.md | 6 +- Rendering/CaptureNode.cpp | 34 +- Rendering/DisplayInfo.h | 52 +- Rendering/DisplayMonitor.cpp | 619 +++++++------------ Rendering/DisplayMonitor.h | 110 ++-- Rendering/DisplayProfile.h | 53 +- Rendering/IccProfileParser.cpp | 12 +- Rendering/RenderEngine.h | 2 +- Rendering/WorkingSpaceSync.cpp | 36 +- ShaderLab.vcxproj | 2 +- ShaderLabEngine.vcxproj | 2 +- ShaderLabHeadless.vcxproj | 2 +- ShaderLabHeadless/Main.cpp | 6 +- ShaderLabMcpBroker.vcxproj | 2 +- ShaderLabTests.vcxproj | 2 +- docs/README.md | 2 +- docs/architecture/display-monitoring.md | 91 ++- docs/architecture/display-profile-mocking.md | 6 +- docs/architecture/overview.md | 2 +- docs/development/mcp-stdio-migration.md | 11 +- docs/development/project-structure.md | 2 +- docs/effects/working-space.md | 4 +- docs/history/decision-log.md | 6 + docs/hosts/headless.md | 13 + pch_engine.h | 1 + scripts/Install.ps1 | 5 +- 43 files changed, 923 insertions(+), 745 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 741b6f9..bc352ca 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -37,7 +37,7 @@ ShaderLabEngine.dll (host-agnostic) ├── Graph/ — EffectGraph, EffectNode, EffectEdge, NodeType, PropertyValue ├── Rendering/ │ ├── GraphEvaluator — Topological walk + per-node D2D effect cache + ProcessDeferredCompute - │ ├── DisplayMonitor — HDR/SDR detection, WM_DISPLAYCHANGE + adapter-changed jthread + │ ├── DisplayMonitor — HDR/SDR/WCG detection via WinRT AdvancedColorInfo (event-driven) │ ├── D3D11ComputeRunner — Generic D3D11 compute dispatch (RWStructuredBuffer), │ │ also implements IEngineComputeOutput (Phase 8 GPU-binding interface) │ ├── PixelReadback — FP32 RGBA region readback helper @@ -176,7 +176,7 @@ Active development centers on **tone-mapping and color-correction effects author - **Pipeline is always scRGB FP16**: `DXGI_FORMAT_R16G16B16A16_FLOAT` with `DXGI_COLOR_SPACE_RGB_FULL_G10_NONE_P709`. No pipeline format switching — DWM/ACM handles final display conversion. - **Swap chain**: `CreateSwapChainForComposition` + `ISwapChainPanelNative` (WinUI 3 requirement). Color space set via `SetColorSpace1()`. -- **Display monitoring**: Dual path — `WM_DISPLAYCHANGE` via hidden message-only HWND + `IDXGIFactory7::RegisterAdaptersChangedEvent` on a jthread. +- **Display monitoring**: Event-driven — `DisplayInformation` bound to the main window (`IDisplayInformationStaticsInterop::GetForWindow`) raises `AdvancedColorInfoChanged` for HDR toggles, the SDR-brightness slider, and monitor moves; one `AdvancedColorInfo` snapshot feeds all of `DisplayCapabilities`. Requires Win11 22H2 (10.0.22621 min OS). Headless snapshots the primary monitor via `GetForMonitor` (no events). - **Graph serialization**: `Windows.Data.Json` (zero extra dependencies). GUID fields use `StringFromGUID2`/`CLSIDFromString`. - **Effect registry**: Singleton with 40+ built-in D2D effects across 9 categories. Case-insensitive name lookup. - **ShaderLab effects library**: 33 built-in effects in `Effects/ShaderLabEffects.h/.cpp` across categories: Analysis (Heatmaps + Scopes + Statistics + Tone-Mapping), Color Processing (Gamut Map + ICtCp Gamut Map + Scale), Source / Generator, Composition (Split Comparison), and the data-only Parameter / Clock / Numeric Expression / Random / Working Space nodes. Embedded HLSL with shared color math from `Effects/ColorMath.cpp`. Auto-compiled at first use; bytecode cached on disk under `%LOCALAPPDATA%\ShaderLab\bytecode\` (decision #58 catalog → see [builtin-catalog.md](../docs/effects/builtin-catalog.md) for the full per-effect type table). diff --git a/App.xaml.cpp b/App.xaml.cpp index 4fbe60c..960b1ae 100644 --- a/App.xaml.cpp +++ b/App.xaml.cpp @@ -423,9 +423,10 @@ namespace winrt::ShaderLab::implementation if (w == 0 || h == 0 || w > 8192 || h > 8192) continue; winrt::com_ptr renderBmp; + // _SRGB: encode the linear scene on write (see ImageLoader). D2D1_BITMAP_PROPERTIES1 bmpProps = D2D1::BitmapProperties1( D2D1_BITMAP_OPTIONS_TARGET, - D2D1::PixelFormat(DXGI_FORMAT_B8G8R8A8_UNORM, D2D1_ALPHA_MODE_PREMULTIPLIED)); + D2D1::PixelFormat(DXGI_FORMAT_B8G8R8A8_UNORM_SRGB, D2D1_ALPHA_MODE_PREMULTIPLIED)); dc->CreateBitmap(D2D1::SizeU(w, h), nullptr, 0, bmpProps, renderBmp.put()); if (!renderBmp) continue; @@ -442,7 +443,7 @@ namespace winrt::ShaderLab::implementation winrt::com_ptr cpuBmp; D2D1_BITMAP_PROPERTIES1 cpuProps = D2D1::BitmapProperties1( D2D1_BITMAP_OPTIONS_CPU_READ | D2D1_BITMAP_OPTIONS_CANNOT_DRAW, - D2D1::PixelFormat(DXGI_FORMAT_B8G8R8A8_UNORM, D2D1_ALPHA_MODE_PREMULTIPLIED)); + D2D1::PixelFormat(DXGI_FORMAT_B8G8R8A8_UNORM_SRGB, D2D1_ALPHA_MODE_PREMULTIPLIED)); dc->CreateBitmap(D2D1::SizeU(w, h), nullptr, 0, cpuProps, cpuBmp.put()); if (!cpuBmp) continue; cpuBmp->CopyFromBitmap(nullptr, renderBmp.get(), nullptr); diff --git a/CHANGELOG.md b/CHANGELOG.md index c90eb51..4576515 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,8 +5,24 @@ Format follows [Keep a Changelog](https://keepachangelog.com/). ## [Unreleased] +### Fixed + +- **Image sources were never color-managed into the pipeline's working space** — `ImageLoader` created SDR bitmaps as plain `B8G8R8A8_UNORM` (no sRGB decode: encoded 0.5 entered the linear-scRGB pipeline as 50% luminance instead of ~21%), classified 16-bit integer PNG/TIFF as "HDR" (no decode either), would have read HDR10 PQ stills as linear light, and ignored embedded ICC profiles entirely. Unnoticed because the capture/save paths were symmetrically un-encoded, so pass-through graphs round-tripped byte-identical — but on an HDR display mid-tones rendered too bright, and every "linear-space" effect (the whole ICtCp suite) operated on gamma values. **Fix**: the loader now has one canonical contract — every source exits as a flattened **linear scRGB FP16** bitmap. WIC decodes without touching the transfer; the D2D `ColorManagement` effect (BEST quality) converts to scRGB honoring the embedded ICC profile when present, else per-format: 8/16-bit integer → sRGB, 10-bit 1010102 → HDR10 (PQ/BT.2020), float/half → already scRGB (pass-through, so Windows HDR screenshots load losslessly). Matching sRGB encode-on-write added to `CaptureNode`, the node-save path, and the CLI capture; the node-editor canvas and other UI surfaces intentionally stay plain UNORM. **Known remaining gap**: screen/window/video capture *sources* still ingest `B8G8R8A8_UNORM` without decode — same bug class, needs its own pass (video also involves BT.709 transfer). + ### Added +- **Display monitoring rewritten on WinRT `AdvancedColorInfo`; minimum OS raised to Windows 11 22H2 (10.0.22621)** (decision #72, superseding #12/#13). + - `DisplayMonitor` now binds a `DisplayInformation` to the main window via `IDisplayInformationStaticsInterop::GetForWindow` and subscribes to **`AdvancedColorInfoChanged`** — the event fires for HDR toggles, the Windows **"SDR content brightness" slider**, and monitor moves. One `AdvancedColorInfo` snapshot supplies the active kind (SDR/WCG/HDR), kind availability, all four luminance values (including `SdrWhiteNits`, which now tracks the slider **live**), and the EDID primaries/white point. Deleted: the `WM_DISPLAYCHANGE` message-only window (a latent bug — message-only windows never receive broadcasts, so that path never fired), the 500 ms monitor-move poll thread, the DXGI adapters-changed jthread, and both `QueryDisplayConfig` walks (SDR white level + type-15 advanced-color info). + - The change-detection diff now covers **every** capability field — previously `sdrWhiteLevelNits`, `activeColorMode`, the supported/user-enabled flags, primary Y components, and the white point were omitted, so slider moves never fired the callback even when detected. + - **Threading**: the display-change callback no longer mutates the graph from the UI thread (a data race against the render worker); it only queues a **coalesced** status-bar/timer refresh (`m_forceRender` is now `std::atomic`). Working Space propagation is the worker's per-tick sync alone. `ClearSimulatedProfile` no longer runs an OS query while holding the caps mutex; callbacks are invoked without holding the callback mutex. + - **Adaptive-color event storm**: panels with adaptive color raise `AdvancedColorInfoChanged` at ambient-sensor rate with sub-nit drift. The first cut turned each event into `MarkAllDirty` + per-event UI work, freezing the app on a 4K graph. Fixed: no graph-wide invalidation on display changes at all (the Working Space node's dirty flag is the designed propagation to binding consumers), per-field dead-bands in `UpdateWorkingSpaceNodes` (1 nit luminance / 0.0005 chromaticity), and the callback's UI refresh coalesced behind a pending flag. + - **Headless** now takes a one-shot `GetForMonitor` snapshot of the primary monitor at startup (`InitializeForPrimaryMonitor`), so `get_display_info` reports real caps instead of fabricated defaults; struct defaults remain the fallback when no display is reachable (CI). + - `DisplayCapabilities::colorSpace` (DXGI) is **removed** — its only reader was the old change-diff. `bitsPerColor` is now derived from the active kind (WCG/HDR → 10, SDR → 8); the `list_display_profiles` wire field is unchanged. `ModeString()` now reports `WCG` for ACM-active SDR displays instead of `SDR`. + - `StampSimulatedColorMode` takes the whole profile and classifies wide-gamut SDR profiles (e.g. the Adobe RGB preset) as WCG/ACM (`activeColorMode` 1, `wcgSupported`/`wcgUserEnabled` true); MCP **custom** display profiles now get stamped at all — previously a custom HDR profile reported `ActiveColorMode=0`/`hdrSupported=false` through the Working Space node while `get_display_info` said `hdr:true`. + - GUI MCP dispatch now catches `winrt::hresult_error` (it does not derive from `std::exception`) and returns its message instead of an opaque 500. + - **WinUI 3 gotcha, found live**: `GetForWindow` requires a running `Windows.System.DispatcherQueue`, but WinUI 3 threads only run `Microsoft.UI.Dispatching.DispatcherQueue` — the call threw and the monitor silently served SDR defaults on an HDR panel. `InitializeRendering` now creates the system queue via `CreateDispatcherQueueController` (the system-backdrop pattern) before binding. Display-binding/query failures are no longer silent: `DisplayMonitor::LastError()` surfaces as an optional `monitorStatus` field in `get_display_info`. + - Verified live on the ASUS UX3607OA (4013-nit OLED): dragging the Windows SDR-brightness slider updates `sdrWhiteNits` (348→232), and the OS-scaled color volume (peak/full-frame/min luminance) tracks in `get_display_info` and Working Space node analysis outputs with no restart or polling. + - **MCP stdio-migration Step 1 — route hygiene** (see `docs/development/mcp-stdio-migration.md`). Four `tools/call` handlers that ran inline inside the GUI's JSON-RPC dispatcher are now real routes: - `GET /effects` (`list_effects`) and `GET /graph/overview` (`graph_overview`) moved **engine-side** — both hosts serve them, so `ShaderLabHeadless --script` can now enumerate effects and summarize the graph. `graph_overview` previously read `m_graph` on the UI thread while the render worker mutated it; it now runs through `IEngineCommandSink::Dispatch` on the render thread. - `POST /graph/rename-node` (`graph_rename_node`) and `GET /display/info` (`get_display_info`) are **app-side** routes. Rename mutates + rebuilds layout on the render thread and `TryEnqueue`s the XAML refresh (preview selector + Add Node flyout) to the UI thread, so a busy UI can no longer turn a committed rename into a 500 — previously the whole body ran via UI-thread `DispatchSync` and parse failures escaped as `winrt::hresult_error`. `get_display_info` stays app-side *by decision*: it reads `RenderEngine::ActiveFormat()` and `EngineContext` has no `RenderEngine`; extending it is an ABI change deferred to Step 2. diff --git a/Effects/ImageLoader.cpp b/Effects/ImageLoader.cpp index 9c7ac76..a9c7d12 100644 --- a/Effects/ImageLoader.cpp +++ b/Effects/ImageLoader.cpp @@ -3,6 +3,61 @@ namespace ShaderLab::Effects { + namespace + { + // Float/half formats: linear scRGB by convention (JPEG XR HDR, + // Windows HDR screenshots, float TIFF). No transfer to decode. + bool IsFloatFormat(const WICPixelFormatGUID& fmt) + { + return fmt == GUID_WICPixelFormat64bppRGBAHalf + || fmt == GUID_WICPixelFormat64bppRGBHalf + || fmt == GUID_WICPixelFormat128bppRGBAFloat + || fmt == GUID_WICPixelFormat128bppRGBFloat; + } + + // 10-bit packed formats: HDR10 stills (PQ transfer, BT.2020 + // primaries) unless an embedded profile says otherwise. + bool IsPq10Format(const WICPixelFormatGUID& fmt) + { + return fmt == GUID_WICPixelFormat32bppRGBA1010102 + || fmt == GUID_WICPixelFormat32bppRGBA1010102XR; + } + + // 16-bit integer formats: gamma-encoded SDR data at high + // precision (16-bit PNG/TIFF). Previously misclassified as HDR + // and fed to the pipeline without any transfer decode. + bool IsWideIntFormat(const WICPixelFormatGUID& fmt) + { + return fmt == GUID_WICPixelFormat48bppRGB + || fmt == GUID_WICPixelFormat64bppRGBA; + } + + // First embedded ICC profile as a D2D color context, if any. + winrt::com_ptr TryGetEmbeddedContext( + IWICBitmapFrameDecode* frame, + ID2D1DeviceContext5* dc, + IWICImagingFactory2* wicFactory) + { + winrt::com_ptr ctx; + UINT count = 0; + if (FAILED(frame->GetColorContexts(0, nullptr, &count)) || count == 0) + return ctx; + + winrt::com_ptr wicCtx; + if (FAILED(wicFactory->CreateColorContext(wicCtx.put()))) + return ctx; + IWICColorContext* slots[] = { wicCtx.get() }; + UINT fetched = 0; + if (FAILED(frame->GetColorContexts(1, slots, &fetched)) || fetched == 0) + return ctx; + + // Fails for uncalibrated/Exif-only contexts — caller falls + // back to the per-format assumption. + dc->CreateColorContextFromWicColorContext(wicCtx.get(), ctx.put()); + return ctx; + } + } + ImageLoader::ImageLoader() { winrt::check_hresult( @@ -14,20 +69,114 @@ namespace ShaderLab::Effects } // ----------------------------------------------------------------------- - // HDR pixel format detection + // Canonical decode: any WIC frame -> linear scRGB FP16 bitmap // ----------------------------------------------------------------------- - bool ImageLoader::IsHdrPixelFormat(const WICPixelFormatGUID& fmt) + winrt::com_ptr ImageLoader::CreateScRgbBitmap( + IWICBitmapFrameDecode* frame, + ID2D1DeviceContext5* dc) { - // Floating-point and >8bpc formats that indicate HDR content. - return fmt == GUID_WICPixelFormat64bppRGBAHalf - || fmt == GUID_WICPixelFormat64bppRGBHalf - || fmt == GUID_WICPixelFormat128bppRGBAFloat - || fmt == GUID_WICPixelFormat128bppRGBFloat - || fmt == GUID_WICPixelFormat48bppRGB - || fmt == GUID_WICPixelFormat64bppRGBA - || fmt == GUID_WICPixelFormat32bppRGBA1010102 - || fmt == GUID_WICPixelFormat32bppRGBA1010102XR; + WICPixelFormatGUID srcFormat{}; + frame->GetPixelFormat(&srcFormat); + const bool isFloat = IsFloatFormat(srcFormat); + const bool isPq10 = IsPq10Format(srcFormat); + const bool isWideInt = IsWideIntFormat(srcFormat); + + // Intermediate conversion preserves precision but must NOT touch + // the transfer function — WIC's int->half conversion is a plain + // normalization of the ENCODED values, and the ColorManagement + // effect below is what decodes them. + WICPixelFormatGUID targetFormat = (isFloat || isPq10 || isWideInt) + ? GUID_WICPixelFormat64bppRGBAHalf + : GUID_WICPixelFormat32bppPBGRA; + + winrt::com_ptr converter; + if (FAILED(m_wicFactory->CreateFormatConverter(converter.put()))) + return nullptr; + if (FAILED(converter->Initialize( + frame, targetFormat, + WICBitmapDitherTypeNone, nullptr, 0.0f, + WICBitmapPaletteTypeCustom))) + return nullptr; + + // Wrap in a PLAIN-format D2D bitmap (no _SRGB variant): decoding + // is the ColorManagement effect's job, and a hardware + // decode-on-sample here would double-decode. + // Note on alpha: PBGRA is premultiplied in the ENCODED space; + // color-managing premultiplied values is slightly wrong for + // translucent pixels and exact for opaque ones — acceptable for + // photographic sources. + D2D1_BITMAP_PROPERTIES1 rawProps = D2D1::BitmapProperties1( + D2D1_BITMAP_OPTIONS_NONE, + D2D1::PixelFormat( + (targetFormat == GUID_WICPixelFormat64bppRGBAHalf) + ? DXGI_FORMAT_R16G16B16A16_FLOAT + : DXGI_FORMAT_B8G8R8A8_UNORM, + D2D1_ALPHA_MODE_PREMULTIPLIED)); + + winrt::com_ptr raw; + if (FAILED(dc->CreateBitmapFromWicBitmap(converter.get(), &rawProps, raw.put()))) + return nullptr; + + // Source color space: embedded ICC profile wins; otherwise assume + // by format. Float formats without a profile are already linear + // scRGB — return them untouched. + winrt::com_ptr srcCtx = + TryGetEmbeddedContext(frame, dc, m_wicFactory.get()); + if (!srcCtx) + { + if (isFloat) + return raw; + if (isPq10) + { + winrt::com_ptr hdr10Ctx; + dc->CreateColorContextFromDxgiColorSpace( + DXGI_COLOR_SPACE_RGB_FULL_G2084_NONE_P2020, hdr10Ctx.put()); + srcCtx = hdr10Ctx; + } + else + dc->CreateColorContext(D2D1_COLOR_SPACE_SRGB, nullptr, 0, srcCtx.put()); + } + + winrt::com_ptr dstCtx; + dc->CreateColorContext(D2D1_COLOR_SPACE_SCRGB, nullptr, 0, dstCtx.put()); + if (!srcCtx || !dstCtx) + return raw; // color management unavailable — best effort + + winrt::com_ptr cm; + if (FAILED(dc->CreateEffect(CLSID_D2D1ColorManagement, cm.put()))) + return raw; + cm->SetInput(0, raw.get()); + cm->SetValue(D2D1_COLORMANAGEMENT_PROP_SOURCE_COLOR_CONTEXT, srcCtx.get()); + cm->SetValue(D2D1_COLORMANAGEMENT_PROP_DESTINATION_COLOR_CONTEXT, dstCtx.get()); + // BEST = float-precision path; required for HDR color spaces. + cm->SetValue(D2D1_COLORMANAGEMENT_PROP_QUALITY, D2D1_COLORMANAGEMENT_QUALITY_BEST); + + // Flatten once at load so every downstream consumer sees one + // canonical linear-scRGB FP16 bitmap. Assumes no BeginDraw is + // active on `dc` (loads happen in the source-prep phase). + const D2D1_SIZE_U px = raw->GetPixelSize(); + D2D1_BITMAP_PROPERTIES1 flatProps = D2D1::BitmapProperties1( + D2D1_BITMAP_OPTIONS_TARGET, + D2D1::PixelFormat(DXGI_FORMAT_R16G16B16A16_FLOAT, + D2D1_ALPHA_MODE_PREMULTIPLIED)); + winrt::com_ptr flat; + if (FAILED(dc->CreateBitmap(px, nullptr, 0, flatProps, flat.put()))) + return raw; + + winrt::com_ptr oldTarget; + dc->GetTarget(oldTarget.put()); + dc->SetTarget(flat.get()); + dc->BeginDraw(); + dc->Clear(D2D1::ColorF(0.f, 0.f, 0.f, 0.f)); + dc->SetTransform(D2D1::Matrix3x2F::Identity()); + dc->DrawImage(cm.get()); + const HRESULT hrEnd = dc->EndDraw(); + dc->SetTarget(oldTarget.get()); + if (FAILED(hrEnd)) + return raw; + + return flat; } // ----------------------------------------------------------------------- @@ -41,7 +190,6 @@ namespace ShaderLab::Effects if (!dc || !m_wicFactory || filePath.empty()) return nullptr; - // Decode the image file. winrt::com_ptr decoder; HRESULT hr = m_wicFactory->CreateDecoderFromFilename( filePath.c_str(), @@ -52,52 +200,12 @@ namespace ShaderLab::Effects if (FAILED(hr)) return nullptr; - // Get the first frame. winrt::com_ptr frame; hr = decoder->GetFrame(0, frame.put()); if (FAILED(hr)) return nullptr; - // Check the source pixel format to decide SDR vs HDR path. - WICPixelFormatGUID srcFormat{}; - frame->GetPixelFormat(&srcFormat); - bool isHdr = IsHdrPixelFormat(srcFormat); - - // Convert to a D2D-compatible pixel format. - // HDR → 64bpp RGBA Half (FP16, scRGB-compatible) - // SDR → 32bpp PBGRA (premultiplied, B8G8R8A8_UNORM) - WICPixelFormatGUID targetFormat = isHdr - ? GUID_WICPixelFormat64bppRGBAHalf - : GUID_WICPixelFormat32bppPBGRA; - - winrt::com_ptr converter; - hr = m_wicFactory->CreateFormatConverter(converter.put()); - if (FAILED(hr)) - return nullptr; - - hr = converter->Initialize( - frame.get(), - targetFormat, - WICBitmapDitherTypeNone, - nullptr, - 0.0f, - WICBitmapPaletteTypeCustom); - if (FAILED(hr)) - return nullptr; - - // Create the D2D bitmap from the WIC source. - D2D1_BITMAP_PROPERTIES1 bitmapProps = D2D1::BitmapProperties1( - D2D1_BITMAP_OPTIONS_NONE, - D2D1::PixelFormat( - isHdr ? DXGI_FORMAT_R16G16B16A16_FLOAT : DXGI_FORMAT_B8G8R8A8_UNORM, - D2D1_ALPHA_MODE_PREMULTIPLIED)); - - winrt::com_ptr bitmap; - hr = dc->CreateBitmapFromWicBitmap(converter.get(), &bitmapProps, bitmap.put()); - if (FAILED(hr)) - return nullptr; - - return bitmap; + return CreateScRgbBitmap(frame.get(), dc); } // ----------------------------------------------------------------------- @@ -125,40 +233,6 @@ namespace ShaderLab::Effects if (FAILED(hr)) return nullptr; - WICPixelFormatGUID srcFormat{}; - frame->GetPixelFormat(&srcFormat); - bool isHdr = IsHdrPixelFormat(srcFormat); - - WICPixelFormatGUID targetFormat = isHdr - ? GUID_WICPixelFormat64bppRGBAHalf - : GUID_WICPixelFormat32bppPBGRA; - - winrt::com_ptr converter; - hr = m_wicFactory->CreateFormatConverter(converter.put()); - if (FAILED(hr)) - return nullptr; - - hr = converter->Initialize( - frame.get(), - targetFormat, - WICBitmapDitherTypeNone, - nullptr, - 0.0f, - WICBitmapPaletteTypeCustom); - if (FAILED(hr)) - return nullptr; - - D2D1_BITMAP_PROPERTIES1 bitmapProps = D2D1::BitmapProperties1( - D2D1_BITMAP_OPTIONS_NONE, - D2D1::PixelFormat( - isHdr ? DXGI_FORMAT_R16G16B16A16_FLOAT : DXGI_FORMAT_B8G8R8A8_UNORM, - D2D1_ALPHA_MODE_PREMULTIPLIED)); - - winrt::com_ptr bitmap; - hr = dc->CreateBitmapFromWicBitmap(converter.get(), &bitmapProps, bitmap.put()); - if (FAILED(hr)) - return nullptr; - - return bitmap; + return CreateScRgbBitmap(frame.get(), dc); } } diff --git a/Effects/ImageLoader.h b/Effects/ImageLoader.h index 1154f9f..6c6f3d7 100644 --- a/Effects/ImageLoader.h +++ b/Effects/ImageLoader.h @@ -8,33 +8,39 @@ namespace ShaderLab::Effects // Loads image files from disk via WIC and converts them to ID2D1Bitmap1 // suitable for use as source images in the effect graph. // - // Supports common formats (PNG, JPEG, TIFF, BMP, DDS, HDR, HEIF, etc.) - // through the Windows Imaging Component codec pipeline. - // - // For HDR images, the loader converts to GUID_WICPixelFormat64bppRGBAHalf - // (FP16) to preserve extended range. For SDR, it uses GUID_WICPixelFormat32bppPBGRA. + // Contract: EVERY image the loader returns is a linear scRGB FP16 + // bitmap (R16G16B16A16_FLOAT, 1.0 = 80 nits). WIC hands us encoded + // pixels; the D2D ColorManagement effect performs the transfer decode + // and primaries conversion at float precision, honoring an embedded + // ICC profile when present and otherwise assuming per-format: + // - 8-bit and 16-bit integer formats → sRGB + // - 10-bit 1010102 formats → HDR10 (PQ / BT.2020) + // - float / half formats → already linear scRGB (no-op) + // The result is flattened once at load, so downstream consumers see a + // single canonical format regardless of source. class SHADERLAB_API ImageLoader { public: ImageLoader(); - // Load an image from a file path and return a D2D bitmap. - // The bitmap pixel format depends on the source: - // - SDR images → B8G8R8A8_UNORM (premultiplied alpha) - // - HDR images → R16G16B16A16_FLOAT (scRGB) - // Returns nullptr on failure. + // Load an image from a file path. Returns a linear scRGB FP16 + // bitmap, or nullptr on failure. winrt::com_ptr LoadFromFile( const std::wstring& filePath, ID2D1DeviceContext5* dc); - // Load from an already-opened IStream. + // Load from an already-opened IStream. Same contract. winrt::com_ptr LoadFromStream( IStream* stream, ID2D1DeviceContext5* dc); private: - // Determine if a WIC pixel format is HDR (floating-point / >8bpc). - static bool IsHdrPixelFormat(const WICPixelFormatGUID& fmt); + // Decode one WIC frame into the canonical linear scRGB FP16 + // bitmap (see class comment). Must be called OUTSIDE an active + // BeginDraw on `dc` — it flattens through a temporary target. + winrt::com_ptr CreateScRgbBitmap( + IWICBitmapFrameDecode* frame, + ID2D1DeviceContext5* dc); winrt::com_ptr m_wicFactory; }; diff --git a/Effects/ShaderLabEffects.cpp b/Effects/ShaderLabEffects.cpp index 7e75961..9d41e3e 100644 --- a/Effects/ShaderLabEffects.cpp +++ b/Effects/ShaderLabEffects.cpp @@ -2280,6 +2280,7 @@ cbuffer constants : register(b0) { SHADERLAB_PARAM(float, SourcePeakNits) // SDR source peak (e.g. 80, 203) SHADERLAB_PARAM(float, TargetPeakNits) // typical 1000-10000 float Strength; // 0..1 lerp from identity to expanded + float DiffuseWhiteNits; // shadow/mid anchor (HDR paper white) }; [numthreads(8, 8, 1)] @@ -2300,6 +2301,17 @@ void main(uint3 dtid : SV_DispatchThreadID) float sdrI = NitsToI(SourcePeakNits); float hdrI = NitsToI(TargetPeakNits); float expanded = ReinhardExpandI(ictcp.x, hdrI, sdrI); + + // Shadow/mid anchor: the pure inverse-Reinhard has slope 1 at black + // in I-space, so shadows keep their SDR nit levels while the rest of + // the picture expands -- perceptually crushed blacks. Let the low end + // instead scale like an SDR presentation at DiffuseWhiteNits paper + // white (nits x D/S, the BT.2446-style lift), and let the expansion + // curve take over wherever it exceeds that. + float diffuseScale = max(DiffuseWhiteNits, 1.0) / max(SourcePeakNits, 1.0); + float lifted = NitsToI(IToNits(ictcp.x) * diffuseScale); + expanded = min(max(expanded, lifted), hdrI); + ictcp.x = lerp(ictcp.x, expanded, saturate(Strength)); float3 outRgb = ICtCpToScRGB(ictcp); @@ -2308,7 +2320,7 @@ void main(uint3 dtid : SV_DispatchThreadID) )HLSL"; ShaderLabEffectDescriptor desc; desc.name = L"ICtCp Inverse Tone Map (SDR -> HDR)"; - desc.effectId = L"ICtCp Inverse Tone Map"; desc.effectVersion = 11; + desc.effectId = L"ICtCp Inverse Tone Map"; desc.effectVersion = 12; desc.category = L"Analysis"; desc.subcategory = L"Tone Mapping"; desc.shaderType = Graph::CustomShaderType::D3D11ComputeShader; @@ -2322,6 +2334,7 @@ void main(uint3 dtid : SV_DispatchThreadID) Graph::ParameterDefinition{ L"SourcePeakNits", L"float", 203.0f, 80.0f, 500.0f, 1.0f, {}, L"", true }, Graph::ParameterDefinition{ L"TargetPeakNits", L"float", 1000.0f, 100.0f, 10000.0f, 50.0f, {}, L"", true }, Graph::ParameterDefinition{ L"Strength", L"float", 1.0f, 0.0f, 1.0f, 0.05f }, + Graph::ParameterDefinition{ L"DiffuseWhiteNits", L"float", 203.0f, 80.0f, 400.0f, 1.0f }, }; m_effects.push_back(std::move(desc)); } diff --git a/Engine/Mcp/EngineMcpRoutes.cpp b/Engine/Mcp/EngineMcpRoutes.cpp index 0bcce90..903af05 100644 --- a/Engine/Mcp/EngineMcpRoutes.cpp +++ b/Engine/Mcp/EngineMcpRoutes.cpp @@ -536,6 +536,14 @@ namespace ShaderLab::Mcp auto verStr = WideToUtf8(std::wstring(::ShaderLab::VersionString)); std::wstring fmtName = ctx.getPipelineFormatName ? ctx.getPipelineFormatName() : std::wstring(L"unknown"); + // Non-empty when the WinRT display binding or last + // query failed — the caps below are struct defaults, + // not measurements. Emitted so clients can tell. + auto monitorErr = ctx.displayMonitor->LastError(); + std::string statusField = monitorErr.empty() + ? std::string{} + : std::format(",\"monitorStatus\":\"{}\"", + JsonEscape(WideToUtf8(monitorErr))); std::string json = std::format( "{{\"appVersion\":\"{}\",\"graphFormatVersion\":{}" ",\"pipeline\":\"{}\"" @@ -543,6 +551,7 @@ namespace ShaderLab::Mcp ",\"simulated\":{},\"profileName\":\"{}\"" ",\"activeGamut\":{{\"red\":[{:.4f},{:.4f}],\"green\":[{:.4f},{:.4f}],\"blue\":[{:.4f},{:.4f}]}}" ",\"monitorGamut\":{{\"red\":[{:.4f},{:.4f}],\"green\":[{:.4f},{:.4f}],\"blue\":[{:.4f},{:.4f}]}}" + "{}" "}}}}", verStr, ::ShaderLab::GraphFormatVersion, JsonEscape(WideToUtf8(fmtName)), @@ -555,7 +564,8 @@ namespace ShaderLab::Mcp profile.primaryBlue.x, profile.primaryBlue.y, live.primaryRed.x, live.primaryRed.y, live.primaryGreen.x, live.primaryGreen.y, - live.primaryBlue.x, live.primaryBlue.y); + live.primaryBlue.x, live.primaryBlue.y, + statusField); return Json(200, json); }); }); @@ -1795,13 +1805,21 @@ namespace ShaderLab::Mcp bool wantInline = jo.HasKey(L"inline") && jo.GetNamedValue(L"inline").ValueType() == WDJ::JsonValueType::Boolean && jo.GetNamedBoolean(L"inline"); + // Optional maxDim: fit the longer edge to this many px + // (aspect preserved). Smaller = lower-res preview = + // fewer inline tokens; omit for the 2048 default. + uint32_t maxDim = 2048; + if (jo.HasKey(L"maxDim") + && jo.GetNamedValue(L"maxDim").ValueType() == WDJ::JsonValueType::Number) + maxDim = std::clamp( + static_cast(jo.GetNamedNumber(L"maxDim")), 32u, 8192u); // Force a fresh frame so dirty nodes evaluate before // capture. Headless host's renderFrame is a no-op. if (ctx.renderFrame) ctx.renderFrame(); auto cap = ::ShaderLab::Rendering::CaptureNodeAsPng( - *ctx.graph, nodeId, ctx.dc); + *ctx.graph, nodeId, ctx.dc, maxDim); using S = ::ShaderLab::Rendering::CaptureNodeStatus; switch (cap.status) { @@ -2147,10 +2165,6 @@ namespace ShaderLab::Mcp p.profileName = L"Custom MCP profile"; p.caps.hdrEnabled = co.HasKey(L"hdrEnabled") && co.GetNamedBoolean(L"hdrEnabled"); - p.caps.colorSpace = p.caps.hdrEnabled - ? DXGI_COLOR_SPACE_RGB_FULL_G2084_NONE_P2020 - : DXGI_COLOR_SPACE_RGB_FULL_G22_NONE_P709; - p.caps.bitsPerColor = p.caps.hdrEnabled ? 10 : 8; p.caps.sdrWhiteLevelNits = co.HasKey(L"sdrWhiteNits") ? static_cast(co.GetNamedNumber(L"sdrWhiteNits")) : (p.caps.hdrEnabled ? 203.0f : 80.0f); @@ -2177,7 +2191,12 @@ namespace ShaderLab::Mcp !readChroma(L"whitePoint", p.whitePoint)) return Json(400, R"({"error":"primaries / whitePoint must be 2-element arrays"})"); - p.gamut = GamutId::Custom; + // Default the gamut from the primaries (sRGB + // struct defaults classify as sRGB) so the + // stamp below doesn't misread a plain-sRGB + // custom as wide-gamut; an explicit "gamut" + // key still overrides. Mirrors the ICC path. + p.gamut = DetectGamut(p.primaryRed, p.primaryGreen, p.primaryBlue); if (co.HasKey(L"gamut")) { auto gn = std::wstring(co.GetNamedString(L"gamut")); @@ -2186,6 +2205,12 @@ namespace ShaderLab::Mcp else if (gn == L"BT.2020" || gn == L"BT2020" || gn == L"Rec2020") p.gamut = GamutId::BT2020; else p.gamut = GamutId::Custom; } + // Stamp coherent activeColorMode / *Supported / + // *UserEnabled / bitsPerColor — without this a + // custom HDR profile reported ActiveColorMode=0 + // (SDR) through the Working Space node while + // get_display_info said hdr:true. + StampSimulatedColorMode(p); chosen = p; } diff --git a/Engine/Mcp/EngineMcpRoutes.h b/Engine/Mcp/EngineMcpRoutes.h index 8d31536..dc52068 100644 --- a/Engine/Mcp/EngineMcpRoutes.h +++ b/Engine/Mcp/EngineMcpRoutes.h @@ -16,10 +16,11 @@ // // Routes execute through `IEngineCommandSink::Dispatch`, which marshals // the closure to the right thread for the host: -// * GUI app: dispatches to the UI thread via DispatcherQueue (so -// concurrent UI tick and MCP requests don't race the graph). -// * Headless host: synchronous direct call (single-threaded MCP -// access; the listener thread serializes requests). +// * GUI app: marshals to the RENDER WORKER thread via +// RenderThreadDispatcher::DispatchSync (post-P7 the worker is the +// single graph writer; see MainWindow.McpRoutes.cpp). +// * Headless host: synchronous direct call on the session client's +// run thread (single-threaded MCP access serializes requests). #include "pch_engine.h" #include "../../EngineExport.h" diff --git a/Engine/Mcp/McpCrypto.h b/Engine/Mcp/McpCrypto.h index f9c9a7c..5b5d9cf 100644 --- a/Engine/Mcp/McpCrypto.h +++ b/Engine/Mcp/McpCrypto.h @@ -3,9 +3,9 @@ // Session crypto for the MCP broker pipe (stdio-migration Step 4). // // Ephemeral P-256 ECDH -> HKDF-SHA256 -> AES-256-GCM, all via BCrypt — -// no new dependency. X25519 was rejected: CNG named-curve support is -// unverified at the manifest's declared 10.0.17763 floor and buys -// nothing against an empty threat model. +// no new dependency. X25519 was rejected: CNG named-curve support was +// unverified at the OS floor declared at the time (10.0.17763; since +// raised to 10.0.22621) and buys nothing against an empty threat model. // // WHY ENCRYPT AT ALL: not defence against a local attacker (same-user // isolation is not a hard boundary on Windows, and this must never be diff --git a/Engine/Mcp/McpToolCatalog.cpp b/Engine/Mcp/McpToolCatalog.cpp index 2b07294..15834da 100644 --- a/Engine/Mcp/McpToolCatalog.cpp +++ b/Engine/Mcp/McpToolCatalog.cpp @@ -85,7 +85,7 @@ namespace ShaderLab::Mcp R"JSON({"name":"render_capture","description":"Capture preview as PNG. Note: HDR values clipped to SDR.","inputSchema":{"type":"object","properties":{}}})JSON", L"GET", L"/render/capture", M::NoBody }, { "render_capture_node", - R"JSON({"name":"render_capture_node","description":"Capture any node's resolved output as PNG (FORCES a render frame so dirty nodes evaluate). With inline=true returns the image as MCP image content (base64). 404 if node missing; 409 with notReady=true if the node is dirty / has unconnected inputs.","inputSchema":{"type":"object","properties":{"nodeId":{"type":"number"},"inline":{"type":"boolean"}},"required":["nodeId"]}})JSON", + R"JSON({"name":"render_capture_node","description":"Capture any node's resolved output as PNG -- full frame, aspect preserved (FORCES a render frame so dirty nodes evaluate). With inline=true returns the image as MCP image content (base64). maxDim fits the longer edge to that many px (default 2048); use a small value (e.g. 512) for a low-res preview = fewer tokens, or larger for full detail. 404 if node missing; 409 with notReady=true if the node is dirty / has unconnected inputs.","inputSchema":{"type":"object","properties":{"nodeId":{"type":"number"},"inline":{"type":"boolean"},"maxDim":{"type":"number","description":"Longer-edge cap in px, aspect preserved. Small=preview/fewer tokens, large=full detail. Default 2048."}},"required":["nodeId"]}})JSON", L"POST", L"/render/capture-node", M::BodyPassthrough, nullptr, /*imageInline=*/true }, { "read_analysis_output", R"JSON({"name":"read_analysis_output","description":"Read typed analysis output fields from a compute/analysis node","inputSchema":{"type":"object","properties":{"nodeId":{"type":"number"}},"required":["nodeId"]}})JSON", diff --git a/MainWindow.GraphFileIo.cpp b/MainWindow.GraphFileIo.cpp index e70d9fe..37efb65 100644 --- a/MainWindow.GraphFileIo.cpp +++ b/MainWindow.GraphFileIo.cpp @@ -881,6 +881,7 @@ namespace winrt::ShaderLab::implementation void MainWindow::ResetAfterGraphLoad(bool reopenOutputWindows) { m_previewNodeId = 0; + m_previewViews.clear(); m_traceActive = false; m_lastTraceTopologyHash = 0; m_traceRowCache.clear(); diff --git a/MainWindow.McpRoutes.cpp b/MainWindow.McpRoutes.cpp index 26ff4f6..9a3978b 100644 --- a/MainWindow.McpRoutes.cpp +++ b/MainWindow.McpRoutes.cpp @@ -290,6 +290,19 @@ namespace winrt::ShaderLab::implementation }, ::ShaderLab::Mcp::kRenderClosureTimeout); } + catch (const winrt::hresult_error& e) + { + // winrt::hresult_error does NOT derive from std::exception — + // without this catch a throwing WinRT call inside a route + // closure surfaces as an opaque 500 from McpRouter's catch(...). + ::ShaderLab::Mcp::Response err; + err.statusCode = 500; + err.body = std::string(R"({"error":")") + + ::ShaderLab::Mcp::JsonEscape(winrt::to_string(e.message())) + + R"("})"; + err.contentType = "application/json"; + return err; + } catch (const std::exception& e) { ::ShaderLab::Mcp::Response err; @@ -627,8 +640,7 @@ namespace winrt::ShaderLab::implementation auto jobj = winrt::Windows::Data::Json::JsonObject::Parse(winrt::to_hstring(body)); uint32_t nodeId = static_cast(jobj.GetNamedNumber(L"nodeId")); return DispatchSync([&]() -> ::ShaderLab::Mcp::Response { - m_previewNodeId = nodeId; - m_needsFitPreview = true; + SelectPreviewNode(nodeId); m_forceRender = true; m_graph.MarkAllDirty(); return { 200, R"({"ok":true})" }; diff --git a/MainWindow.RenderTick.cpp b/MainWindow.RenderTick.cpp index 5c1219e..6cb9620 100644 --- a/MainWindow.RenderTick.cpp +++ b/MainWindow.RenderTick.cpp @@ -54,6 +54,15 @@ namespace winrt::ShaderLab::implementation auto tTickStart = std::chrono::high_resolution_clock::now(); + // Cache the preview panel's DIP size (UI-thread-only XAML read) so the + // render worker can fit a newly-selected node to view after eval + // without touching XAML. See FitPreviewToView / m_needsFitPreview. + if (auto panel = PreviewPanel()) + { + m_previewViewportW = static_cast(panel.ActualWidth()); + m_previewViewportH = static_cast(panel.ActualHeight()); + } + // Drain pending dispatcher closures: NO-OP from the UI side post-P7. // The worker thread is the registered consumer and drains its own // queue. UI thread reading the queue would race graph mutations and @@ -360,6 +369,16 @@ namespace winrt::ShaderLab::implementation m_frameCount.fetch_add(1, std::memory_order_relaxed); if (hasDirty || wasForceRender) ++m_graphGeneration; + // Fit-after-eval: the selected node's cachedOutput now has + // valid bounds, so a pending fit (set by SelectPreviewNode) + // computes zoom/pan here on the worker -- worker-owned + // bounds + the UI-cached viewport, no XAML. Force one more + // frame so the fitted transform actually renders. + if (m_needsFitPreview && FitPreviewToView()) + { + m_needsFitPreview = false; + m_forceRender = true; + } } // Publish snapshot. diff --git a/MainWindow.WorkingSpace.cpp b/MainWindow.WorkingSpace.cpp index 6365c26..d72bd8a 100644 --- a/MainWindow.WorkingSpace.cpp +++ b/MainWindow.WorkingSpace.cpp @@ -51,19 +51,17 @@ namespace winrt::ShaderLab::implementation void MainWindow::ApplyDisplayProfile(const ::ShaderLab::Rendering::DisplayProfile& profile) { + // Fires the display-change callback synchronously, which sets + // m_displayCapsDirty + m_forceRender; the render worker (the only + // graph writer) MarkAllDirty()s and re-syncs Working Space nodes + // on its next tick. Only the status bar needs a direct poke. m_displayMonitor.SetSimulatedProfile(profile); - m_graph.MarkAllDirty(); - m_forceRender = true; - UpdateWorkingSpaceNodes(); UpdateStatusBar(); } void MainWindow::RevertToLiveDisplay() { m_displayMonitor.ClearSimulatedProfile(); - m_graph.MarkAllDirty(); - m_forceRender = true; - UpdateWorkingSpaceNodes(); UpdateStatusBar(); } diff --git a/MainWindow.xaml.cpp b/MainWindow.xaml.cpp index 1506209..c5ce4b8 100644 --- a/MainWindow.xaml.cpp +++ b/MainWindow.xaml.cpp @@ -16,6 +16,9 @@ #include #include #include +#include + +#pragma comment(lib, "CoreMessaging.lib") using namespace winrt; using namespace Microsoft::UI::Xaml; @@ -319,7 +322,7 @@ namespace winrt::ShaderLab::implementation { if (n.type == ::ShaderLab::Graph::NodeType::Output) { - m_previewNodeId = n.id; + SelectPreviewNode(n.id); break; } } @@ -460,6 +463,25 @@ namespace winrt::ShaderLab::implementation void MainWindow::InitializeRendering() { + // DisplayInformation::GetForWindow requires a running + // Windows.System.DispatcherQueue on this thread. WinUI 3 threads + // run Microsoft.UI.Dispatching.DispatcherQueue — a distinct type — + // so create the system one if absent (same pattern system-backdrop + // controllers use). It pumps via this thread's existing message + // loop; the controller must outlive the queue's consumers. + if (!m_systemDqController && + !winrt::Windows::System::DispatcherQueue::GetForCurrentThread()) + { + DispatcherQueueOptions options{ + sizeof(DispatcherQueueOptions), + DQTYPE_THREAD_CURRENT, + DQTAT_COM_NONE }; + ABI::Windows::System::IDispatcherQueueController* controller{ nullptr }; + if (SUCCEEDED(::CreateDispatcherQueueController(options, &controller))) + m_systemDqController.attach( + reinterpret_cast<::IUnknown*>(controller)); + } + // Query display capabilities and pick a default pipeline format. m_displayMonitor.Initialize(m_hwnd); auto caps = m_displayMonitor.CachedCapabilities(); @@ -477,28 +499,29 @@ namespace winrt::ShaderLab::implementation // always return fresh values regardless of selection. ::ShaderLab::Performance::SetSkipUnneededCpuReadbackEnabled(true); - // Now that we have a DXGI factory, register adapter-change monitoring. - if (m_renderEngine.DXGIFactory()) - { - m_displayMonitor.Shutdown(); - m_displayMonitor.Initialize(m_hwnd, m_renderEngine.DXGIFactory()); - } - - // Subscribe to display changes so we can update the status bar. + // Subscribe to display changes (AdvancedColorInfoChanged: HDR + // toggle, SDR-brightness slider, monitor move, profile sim). + // NO graph work here: the render worker's per-tick + // UpdateWorkingSpaceNodes reads ActiveProfile() and dirties the + // Working Space node when a field really moved — that dirty is + // the designed propagation to binding consumers, and nothing + // else in the graph depends on display state ("bind, don't + // hide"). Displays with adaptive color fire this event at + // sensor rate (several Hz, sub-nit deltas), so the UI refresh + // is coalesced behind a pending flag — an event storm results + // in at most one queued refresh at a time. A MarkAllDirty here + // previously turned that storm into a continuous full-graph + // re-eval that froze the app. m_displayMonitor.SetCallback([this](const ::ShaderLab::Rendering::DisplayCapabilities& /*newCaps*/) { + if (m_displayUiRefreshPending.exchange(true)) + return; this->DispatcherQueue().TryEnqueue([this]() { - // Re-evaluate graph so effects using monitor gamut - // pick up the new primaries. - m_graph.MarkAllDirty(); - m_forceRender = true; + m_displayUiRefreshPending = false; // Pick up new refresh rate (e.g. user changed displays // or switched modes from 60 Hz to 144 Hz). UpdateRenderTimerInterval(); - // Push the new capabilities into any Working Space nodes - // so downstream binders see the live values immediately. - UpdateWorkingSpaceNodes(); UpdateStatusBar(); }); }); @@ -1212,13 +1235,12 @@ namespace winrt::ShaderLab::implementation int32_t count = static_cast(m_topoOrder.size()); if (key == vkOpenBracket && curIdx > 0) - m_previewNodeId = m_topoOrder[curIdx - 1]; + SelectPreviewNode(m_topoOrder[curIdx - 1]); else if (key == vkCloseBracket && curIdx < count - 1) - m_previewNodeId = m_topoOrder[curIdx + 1]; + SelectPreviewNode(m_topoOrder[curIdx + 1]); UpdatePreviewOverlay(); m_forceRender = true; - FitPreviewToView(); args.Handled(true); } } @@ -2679,15 +2701,9 @@ namespace winrt::ShaderLab::implementation bool isDataOnly = clickedNode && clickedNode->outputPins.empty(); if (!isAnalysisEffect && !isParamNode && !isDataOnly) - m_previewNodeId = hitNodeId; + SelectPreviewNode(hitNodeId); // fit on first view, else restore this node's pan/zoom m_forceRender = true; - // Defer the fit until the next eval populates cachedOutput. On - // the very first selection of a node (before its first eval), - // GetPreviewImageBounds() returns an empty rect, so an immediate - // FitPreviewToView() lands on the wrong zoom. The deferred path - // in OnRenderTick re-fits once bounds are available. - m_needsFitPreview = true; UpdatePreviewOverlay(); } else @@ -5025,9 +5041,12 @@ namespace winrt::ShaderLab::implementation try { winrt::com_ptr renderBitmap; + // _SRGB: encode the linear scRGB scene on write so the saved + // PNG is correctly gamma-encoded (pairs with the ImageLoader's + // decode-on-sample). D2D1_BITMAP_PROPERTIES1 bmpProps = D2D1::BitmapProperties1( D2D1_BITMAP_OPTIONS_TARGET, - D2D1::PixelFormat(DXGI_FORMAT_B8G8R8A8_UNORM, D2D1_ALPHA_MODE_PREMULTIPLIED)); + D2D1::PixelFormat(DXGI_FORMAT_B8G8R8A8_UNORM_SRGB, D2D1_ALPHA_MODE_PREMULTIPLIED)); winrt::check_hresult(dc->CreateBitmap(D2D1::SizeU(w, h), nullptr, 0, bmpProps, renderBitmap.put())); winrt::com_ptr oldTarget; @@ -5043,7 +5062,7 @@ namespace winrt::ShaderLab::implementation winrt::com_ptr cpuBitmap; D2D1_BITMAP_PROPERTIES1 cpuProps = D2D1::BitmapProperties1( D2D1_BITMAP_OPTIONS_CPU_READ | D2D1_BITMAP_OPTIONS_CANNOT_DRAW, - D2D1::PixelFormat(DXGI_FORMAT_B8G8R8A8_UNORM, D2D1_ALPHA_MODE_PREMULTIPLIED)); + D2D1::PixelFormat(DXGI_FORMAT_B8G8R8A8_UNORM_SRGB, D2D1_ALPHA_MODE_PREMULTIPLIED)); winrt::check_hresult(dc->CreateBitmap(D2D1::SizeU(w, h), nullptr, 0, cpuProps, cpuBitmap.put())); D2D1_POINT_2U destPt = { 0, 0 }; D2D1_RECT_U srcRc = { 0, 0, w, h }; @@ -5407,30 +5426,33 @@ namespace winrt::ShaderLab::implementation // Preview pan/zoom // ----------------------------------------------------------------------- - void MainWindow::FitPreviewToView() + // Returns true if a real fit was applied (viewport + evaluated image bounds + // both valid, or a permanent default for an infinite source); false if + // bounds/viewport aren't ready yet, so callers can defer and retry. Reads + // the UI-cached viewport (m_previewViewportW/H), so it is safe to call from + // the render worker right after an eval -- not only from the UI thread. + bool MainWindow::FitPreviewToView() { - auto vp = PreviewViewportDips(); - float vpW = vp.width; - float vpH = vp.height; + float vpW = m_previewViewportW; + float vpH = m_previewViewportH; if (vpW <= 0 || vpH <= 0) - { - m_previewZoom = 1.0f; - m_previewPanX = 0.0f; - m_previewPanY = 0.0f; - return; - } + return false; // viewport not measured yet -- defer auto bounds = GetPreviewImageBounds(); float imgW = bounds.right - bounds.left; float imgH = bounds.bottom - bounds.top; - // For infinite or very large images (e.g., Flood), use a default view. - if (imgW <= 0 || imgH <= 0 || imgW > 100000.0f || imgH > 100000.0f) + if (imgW <= 0 || imgH <= 0) + return false; // node not evaluated yet -- defer, leave view as-is + + // For infinite / very large images (e.g., Flood), settle on a default + // view and report it as fitted so the pending-fit flag clears. + if (imgW > 100000.0f || imgH > 100000.0f) { m_previewZoom = 1.0f; m_previewPanX = 0.0f; m_previewPanY = 0.0f; - return; + return true; } // Scale to fit with some padding. @@ -5441,6 +5463,32 @@ namespace winrt::ShaderLab::implementation // Center the image. m_previewPanX = (vpW - imgW * m_previewZoom) * 0.5f - bounds.left * m_previewZoom; m_previewPanY = (vpH - imgH * m_previewZoom) * 0.5f - bounds.top * m_previewZoom; + return true; + } + + // Change which node the preview shows, remembering per-node pan/zoom. + // The outgoing node's current view is saved; the incoming node's saved + // view is restored, or -- if it's never been examined -- we request a fit + // (deferred until its bounds exist; the OnRenderTick path applies it). + void MainWindow::SelectPreviewNode(uint32_t nodeId) + { + if (nodeId == m_previewNodeId) + return; + if (m_previewNodeId != 0) + m_previewViews[m_previewNodeId] = { m_previewZoom, m_previewPanX, m_previewPanY }; + m_previewNodeId = nodeId; + auto it = (nodeId != 0) ? m_previewViews.find(nodeId) : m_previewViews.end(); + if (it != m_previewViews.end()) + { + m_previewZoom = it->second.zoom; + m_previewPanX = it->second.panX; + m_previewPanY = it->second.panY; + m_needsFitPreview = false; + } + else + { + m_needsFitPreview = true; + } } // ----------------------------------------------------------------------- diff --git a/MainWindow.xaml.h b/MainWindow.xaml.h index 1e01d00..c3b1c86 100644 --- a/MainWindow.xaml.h +++ b/MainWindow.xaml.h @@ -244,6 +244,12 @@ namespace winrt::ShaderLab::implementation ::ShaderLab::Rendering::DisplayMonitor m_displayMonitor; ::ShaderLab::Rendering::GraphEvaluator m_graphEvaluator; + // Keeps the Windows.System.DispatcherQueue we create for the UI + // thread alive (WinUI 3 threads only run the Microsoft.UI variant; + // DisplayInformation::GetForWindow needs the system one). Held as + // IUnknown so the ABI type stays out of this header. + winrt::com_ptr<::IUnknown> m_systemDqController; + // Render-thread plumbing. The dispatcher carries closures from UI / // MCP / NodeGraphController producers to whichever thread owns // rendering. Until the actual worker thread spawns (Phase 7), the @@ -658,8 +664,22 @@ namespace winrt::ShaderLab::implementation float m_previewPanX{ 0.0f }; float m_previewPanY{ 0.0f }; float m_previewZoom{ 1.0f }; - bool m_needsFitPreview{ false }; - bool m_forceRender{ true }; // Force first render + after pan/zoom changes + std::atomic m_needsFitPreview{ false }; // set on UI/dispatch, read+cleared on worker + // Per-node preview view memory: returning to a previously-examined node + // restores its pan/zoom; a node examined for the first time fits. + struct PreviewView { float zoom{ 1.0f }; float panX{ 0.0f }; float panY{ 0.0f }; }; + std::unordered_map m_previewViews; + // Preview panel size, cached on the UI thread (OnRenderTick) so the + // render worker can fit-to-view after an eval without touching XAML. + float m_previewViewportW{ 0.0f }; + float m_previewViewportH{ 0.0f }; + // Written from the UI thread, the render worker, and MCP dispatch + // closures — atomic for cross-thread visibility. + std::atomic m_forceRender{ true }; // Force first render + after pan/zoom changes + // Coalesces display-change UI refreshes: adaptive-color displays + // raise AdvancedColorInfoChanged at sensor rate, and only one + // status-bar/timer refresh may be queued at a time. + std::atomic m_displayUiRefreshPending{ false }; bool m_isPreviewPanning{ false }; float m_previewPanStartX{ 0.0f }; float m_previewPanStartY{ 0.0f }; @@ -673,7 +693,8 @@ namespace winrt::ShaderLab::implementation float m_traceClickZoom{ 1.0f }; bool m_traceOutOfBounds{ false }; void UpdateCrosshairOverlay(); - void FitPreviewToView(); + bool FitPreviewToView(); // true if a real fit applied; false = defer (bounds/viewport not ready) + void SelectPreviewNode(uint32_t nodeId); // Trace swatch HDR swap chain. winrt::com_ptr m_traceSwapChain; diff --git a/Package.appxmanifest b/Package.appxmanifest index 42e1067..72462a3 100644 --- a/Package.appxmanifest +++ b/Package.appxmanifest @@ -22,8 +22,8 @@ - - + + diff --git a/README.md b/README.md index c8f80e4..c96e695 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ Release builds ship as **unsigned MSIX packages** — no signing certificate nee ``` 4. Launch ShaderLab from the Start menu. -`Install.ps1` calls `Add-AppxPackage -AllowUnsigned`, which installs unsigned MSIX packages on systems with Developer Mode enabled (Windows 10 1903+ / Windows 11). The script installs the bundled dependency packages (Microsoft VCLibs, Windows App Runtime) for the host architecture first, then ShaderLab itself. +`Install.ps1` calls `Add-AppxPackage -AllowUnsigned`, which installs unsigned MSIX packages on systems with Developer Mode enabled. ShaderLab requires **Windows 11 22H2 (build 22621) or later** — the display pipeline uses WinRT `AdvancedColorInfo` via desktop interop, which first shipped there. The script installs the bundled dependency packages (Microsoft VCLibs, Windows App Runtime) for the host architecture first, then ShaderLab itself. > ⚠️ **Known limitation — admin is required (unsigned + full-trust).** ShaderLab is a full-trust packaged app: its main app and the background MCP **Hub** both declare `Windows.FullTrustApplication` (executable activations). Per [Microsoft's unsigned-package rules](https://learn.microsoft.com/windows/msix/package/unsigned-package), an unsigned package containing executable content can only be installed **for all users, which requires elevation** — a per-user, non-elevated `Add-AppxPackage -AllowUnsigned` fails with `0x80073D2B` ("an unsigned package cannot include Executable activations"). Run `Install.ps1` from an **elevated** PowerShell. A **signed** release would install per-user with no admin; signing the release with a real code-signing certificate is the cleaner long-term fix (tracked as a release-process gap). @@ -73,10 +73,10 @@ Core capabilities: - **Effect Designer** for authoring custom pixel & compute shaders with live HLSL compile + reflection-driven property generation. - **Analysis viewers** (Luminance / Channel / Chromaticity Statistics, CIE Histogram + Plot, Gamut Coverage, Luminance Heatmap, etc.) — all share the same compute-bridge architecture and route their outputs as SRVs to downstream consumers when possible. - **Tone-mapping suite** (D2D `HDR Tone Map`, ICtCp Tone Map, ICtCp Inverse Tone Map, ICtCp Gamut Map, etc.) operating in scRGB FP16 with PQ / HLG / sRGB transfer functions. -- **HDR / WCG aware** — DXGI adapter-change tracking, ICC profile parsing, monitor primaries piped into Custom-gamut analysis effects via the Working Space node. +- **HDR / WCG aware** — event-driven display tracking (WinRT `AdvancedColorInfoChanged`: HDR toggles, the Windows SDR-brightness slider, monitor moves), ICC profile parsing, monitor primaries piped into Custom-gamut analysis effects via the Working Space node. - **MCP integration** (stdio, via a broker: shim → hub → per-window sessions) + **headless host** for AI-agent and CI use; the MCP route layer lives in `ShaderLabEngine.dll` so headless and GUI hosts share the route implementations. -Build: Visual Studio 2022 17.8+, Windows 10 SDK 10.0.26100+, C++/WinRT only (no C#). +Build: Visual Studio 2022 17.8+, Windows 10 SDK 10.0.26100+, C++/WinRT only (no C#). Runtime: Windows 11 22H2 (10.0.22621)+. --- diff --git a/Rendering/CaptureNode.cpp b/Rendering/CaptureNode.cpp index 6cfe8c7..3595a2a 100644 --- a/Rendering/CaptureNode.cpp +++ b/Rendering/CaptureNode.cpp @@ -54,24 +54,36 @@ namespace ShaderLab::Rendering D2D1_RECT_F bounds{}; dc->GetImageLocalBounds(image, &bounds); - uint32_t w = static_cast(bounds.right - bounds.left); - uint32_t h = static_cast(bounds.bottom - bounds.top); + float srcW = bounds.right - bounds.left; + float srcH = bounds.bottom - bounds.top; dc->SetDpi(oldDpiX, oldDpiY); - if (w == 0 || h == 0) { + if (srcW <= 0.f || srcH <= 0.f) { result.status = CaptureNodeStatus::EmptyImage; return result; } - w = (std::min)(w, maxDim); - h = (std::min)(h, maxDim); + + // Fit the LONGER edge to maxDim, preserving aspect ratio (never + // upscale). The previous code clamped each edge to maxDim + // independently and drew at native scale, so any image whose *both* + // dimensions exceeded maxDim was cropped to a maxDim-square of its + // top-left corner. Scale-to-fit captures the whole frame instead. + float scale = (std::min)(1.0f, + static_cast(maxDim) / (std::max)(srcW, srcH)); + uint32_t w = (std::max)(1u, static_cast(srcW * scale + 0.5f)); + uint32_t h = (std::max)(1u, static_cast(srcH * scale + 0.5f)); try { winrt::com_ptr renderBitmap; + // _SRGB target: the scene is linear scRGB; encode-on-write + // produces correctly gamma-encoded PNG bytes. (Paired with the + // loader's decode-on-sample — a plain-UNORM target would write + // linear values as bytes and captures would come out dark.) D2D1_BITMAP_PROPERTIES1 bmpProps = D2D1::BitmapProperties1( D2D1_BITMAP_OPTIONS_TARGET, - D2D1::PixelFormat(DXGI_FORMAT_B8G8R8A8_UNORM, D2D1_ALPHA_MODE_PREMULTIPLIED)); + D2D1::PixelFormat(DXGI_FORMAT_B8G8R8A8_UNORM_SRGB, D2D1_ALPHA_MODE_PREMULTIPLIED)); winrt::check_hresult(dc->CreateBitmap(D2D1::SizeU(w, h), nullptr, 0, bmpProps, renderBitmap.put())); @@ -80,15 +92,21 @@ namespace ShaderLab::Rendering dc->SetTarget(renderBitmap.get()); dc->BeginDraw(); dc->Clear(D2D1::ColorF(D2D1::ColorF::Black)); - dc->SetTransform(D2D1::Matrix3x2F::Identity()); + // Shift the image's local origin to 0,0, then scale to fit the + // (w,h) target so the whole frame lands aspect-correct. + dc->SetTransform( + D2D1::Matrix3x2F::Translation(-bounds.left, -bounds.top) * + D2D1::Matrix3x2F::Scale(scale, scale)); dc->DrawImage(image); dc->EndDraw(); dc->SetTarget(oldTarget.get()); winrt::com_ptr cpuBitmap; + // Same _SRGB variant as the render target — CopyFromBitmap + // requires matching formats; the bytes are already encoded. D2D1_BITMAP_PROPERTIES1 cpuProps = D2D1::BitmapProperties1( D2D1_BITMAP_OPTIONS_CPU_READ | D2D1_BITMAP_OPTIONS_CANNOT_DRAW, - D2D1::PixelFormat(DXGI_FORMAT_B8G8R8A8_UNORM, D2D1_ALPHA_MODE_PREMULTIPLIED)); + D2D1::PixelFormat(DXGI_FORMAT_B8G8R8A8_UNORM_SRGB, D2D1_ALPHA_MODE_PREMULTIPLIED)); winrt::check_hresult(dc->CreateBitmap(D2D1::SizeU(w, h), nullptr, 0, cpuProps, cpuBitmap.put())); D2D1_POINT_2U destPt = { 0, 0 }; diff --git a/Rendering/DisplayInfo.h b/Rendering/DisplayInfo.h index fcf3ac3..ff4b6d0 100644 --- a/Rendering/DisplayInfo.h +++ b/Rendering/DisplayInfo.h @@ -4,21 +4,23 @@ namespace ShaderLab::Rendering { - // Snapshot of the display's HDR / color capabilities, - // queried from DXGI_OUTPUT_DESC1 via IDXGIOutput6::GetDesc1, augmented - // with Windows DisplayConfig advanced-color info (HDR/WCG/ACM state). + // Snapshot of the display's HDR / color capabilities, sourced from + // WinRT Windows.Graphics.Display.AdvancedColorInfo (via a + // DisplayInformation bound to the app window). Requires Windows 11 + // 22H2 (10.0.22621) — the app's declared minimum OS. struct DisplayCapabilities { - // True when the OS reports Advanced Color (HDR) is active on this output. + // True when the OS reports HDR is the active advanced-color kind. bool hdrEnabled{ false }; - // Bits per color channel reported by the output. + // Bits per color channel. AdvancedColorInfo does not expose scanout + // depth, so this is derived from the active kind (WCG/HDR composite + // in FP16 and scan out at 10-bit+ → 10; plain SDR → 8). Simulated + // profiles (presets / ICC / MCP custom) set their own value. uint32_t bitsPerColor{ 8 }; - // Color space of the output (e.g. DXGI_COLOR_SPACE_RGB_FULL_G22_NONE_P709 for SDR). - DXGI_COLOR_SPACE_TYPE colorSpace{ DXGI_COLOR_SPACE_RGB_FULL_G22_NONE_P709 }; - - // SDR white level in nits (typically 80 for SDR, higher when HDR is on). + // SDR white level in nits (typically 80 for SDR; tracks the Windows + // Settings "SDR content brightness" slider when HDR is on). float sdrWhiteLevelNits{ 80.0f }; // Peak luminance the display can produce (in nits). @@ -30,8 +32,8 @@ namespace ShaderLab::Rendering // Maximum full-frame luminance (in nits). float maxFullFrameLuminanceNits{ 270.0f }; - // Monitor color primaries from DXGI (CIE xy chromaticity). - // Default to sRGB/Rec.709 if not available. + // Monitor color primaries (CIE xy chromaticity) from + // AdvancedColorInfo. Default to sRGB/Rec.709 if not available. float redPrimaryX{ 0.64f }; float redPrimaryY{ 0.33f }; float greenPrimaryX{ 0.30f }; @@ -41,18 +43,19 @@ namespace ShaderLab::Rendering float whitePointX{ 0.3127f }; float whitePointY{ 0.3290f }; - // Active color mode reported by Windows DisplayConfig - // (DISPLAYCONFIG_GET_ADVANCED_COLOR_INFO_2.activeColorMode). - // 0 = SDR, 1 = WCG/ACM (FP16 scRGB composition, display-referred - // luminance), 2 = HDR (FP16 scRGB composition, scene-referred - // luminance). Falls back to {0,2} derived from hdrEnabled when - // the type-15 query is unavailable. + // Active color mode, mapped 1:1 from WinRT AdvancedColorKind: + // 0 = SDR (StandardDynamicRange), 1 = WCG/ACM (WideColorGamut — + // FP16 scRGB composition, display-referred luminance), 2 = HDR + // (HighDynamicRange — FP16 scRGB composition, scene-referred + // luminance). uint32_t activeColorMode{ 0 }; - // Capability + user-toggle flags from - // DISPLAYCONFIG_GET_ADVANCED_COLOR_INFO_2. These reflect what the - // display HW reports vs. what the user has enabled in Settings. - // ACTIVE state is in `activeColorMode` / `hdrEnabled`. + // Capability flags. *Supported comes from + // AdvancedColorInfo::IsAdvancedColorKindAvailable (the kind is + // achievable on this display, whether or not it is active). + // *UserEnabled mirrors the ACTIVE kind — AdvancedColorInfo has no + // separate user-toggle probe, and an available-but-inactive kind + // is exactly the "supported, not enabled" state callers care about. bool hdrSupported{ false }; bool hdrUserEnabled{ false }; bool wcgSupported{ false }; @@ -61,7 +64,12 @@ namespace ShaderLab::Rendering // Human-readable summary for the status bar. std::wstring ModeString() const { - return hdrEnabled ? L"HDR" : L"SDR"; + switch (activeColorMode) + { + case 2: return L"HDR"; + case 1: return L"WCG"; + default: return L"SDR"; + } } std::wstring LuminanceString() const diff --git a/Rendering/DisplayMonitor.cpp b/Rendering/DisplayMonitor.cpp index 141bc63..6851e51 100644 --- a/Rendering/DisplayMonitor.cpp +++ b/Rendering/DisplayMonitor.cpp @@ -1,470 +1,291 @@ #include "pch_engine.h" #include "DisplayMonitor.h" -namespace ShaderLab::Rendering -{ - // ----------------------------------------------------------------------- - // Lifecycle - // ----------------------------------------------------------------------- +#include - DisplayMonitor::~DisplayMonitor() - { - Shutdown(); - } +namespace WGD = winrt::Windows::Graphics::Display; - void DisplayMonitor::Initialize(HWND appHwnd, IDXGIFactory7* dxgiFactory) +namespace ShaderLab::Rendering +{ + namespace { - m_appHwnd = appHwnd; - - // Take a fresh snapshot before anything else. - m_caps = QueryCurrentCapabilities(); - m_lastMonitor = MonitorFromWindow(m_appHwnd, MONITOR_DEFAULTTOPRIMARY); - - // Create a hidden message-only window to receive WM_DISPLAYCHANGE. - CreateMessageWindow(); - - // If a DXGI factory is available, register for adapter hot-plug. - if (dxgiFactory) + uint32_t ModeFromKind(WGD::AdvancedColorKind kind) { - RegisterAdapterChangeEvent(dxgiFactory); + switch (kind) + { + case WGD::AdvancedColorKind::HighDynamicRange: return 2u; + case WGD::AdvancedColorKind::WideColorGamut: return 1u; + default: return 0u; + } } - // Poll for monitor changes (moving the window between displays). - m_monitorPollThread = std::jthread([this](std::stop_token stop) + DisplayCapabilities CapsFromAdvancedColorInfo(WGD::AdvancedColorInfo const& aci) { - while (!stop.stop_requested()) + DisplayCapabilities caps{}; + + caps.activeColorMode = ModeFromKind(aci.CurrentAdvancedColorKind()); + caps.hdrEnabled = (caps.activeColorMode == 2u); + caps.hdrSupported = aci.IsAdvancedColorKindAvailable(WGD::AdvancedColorKind::HighDynamicRange); + caps.wcgSupported = aci.IsAdvancedColorKindAvailable(WGD::AdvancedColorKind::WideColorGamut); + // AdvancedColorInfo has no separate user-toggle probe; mirror + // the active kind (see DisplayInfo.h field docs). + caps.hdrUserEnabled = caps.hdrEnabled; + caps.wcgUserEnabled = (caps.activeColorMode == 1u); + + // Scanout depth isn't exposed; WCG/HDR modes composite FP16 and + // scan out 10-bit+, plain SDR is 8-bit. + caps.bitsPerColor = (caps.activeColorMode != 0u) ? 10u : 8u; + + // Virtual/remote outputs can report zeroed luminance — keep the + // struct defaults (270-nit class panel) rather than 0 nits. + if (const float maxNits = aci.MaxLuminanceInNits(); maxNits > 0.0f) + caps.maxLuminanceNits = maxNits; + if (const float maxFF = aci.MaxAverageFullFrameLuminanceInNits(); maxFF > 0.0f) + caps.maxFullFrameLuminanceNits = maxFF; + caps.minLuminanceNits = (std::max)(aci.MinLuminanceInNits(), 0.0f); + + // Tracks the Windows Settings "SDR content brightness" slider + // when HDR is active; 80 nits (scRGB 1.0) otherwise. + if (const float sdrWhite = aci.SdrWhiteLevelInNits(); sdrWhite > 0.0f) + caps.sdrWhiteLevelNits = sdrWhite; + + // EDID chromaticities. All-zero points mean the output has no + // colorimetry data (virtual display) — keep the sRGB defaults. + const auto r = aci.RedPrimary(); + const auto g = aci.GreenPrimary(); + const auto b = aci.BluePrimary(); + const auto w = aci.WhitePoint(); + if (r.X + r.Y + g.X + g.Y + b.X + b.Y > 0.01) { - std::this_thread::sleep_for(std::chrono::milliseconds(500)); - if (stop.stop_requested()) break; - - HMONITOR current = MonitorFromWindow(m_appHwnd, MONITOR_DEFAULTTOPRIMARY); - if (current != m_lastMonitor) - { - m_lastMonitor = current; - OnDisplayChanged(); - } + caps.redPrimaryX = static_cast(r.X); + caps.redPrimaryY = static_cast(r.Y); + caps.greenPrimaryX = static_cast(g.X); + caps.greenPrimaryY = static_cast(g.Y); + caps.bluePrimaryX = static_cast(b.X); + caps.bluePrimaryY = static_cast(b.Y); + caps.whitePointX = static_cast(w.X); + caps.whitePointY = static_cast(w.Y); } - }); - } - void DisplayMonitor::Shutdown() - { - if (m_monitorPollThread.joinable()) - { - m_monitorPollThread.request_stop(); - m_monitorPollThread.join(); + return caps; } - UnregisterAdapterChangeEvent(); - DestroyMessageWindow(); - m_appHwnd = nullptr; } // ----------------------------------------------------------------------- - // Capability query + // Lifecycle // ----------------------------------------------------------------------- - DisplayCapabilities DisplayMonitor::QueryCurrentCapabilities() const + DisplayMonitor::~DisplayMonitor() { - DisplayCapabilities caps{}; - - auto output = GetOutputForWindow(); - if (!output) - return caps; - - DXGI_OUTPUT_DESC1 desc{}; - if (SUCCEEDED(output->GetDesc1(&desc))) - { - // AdvancedColorSupported flag alone isn't enough — the user - // must also have toggled "Use HDR" in Windows Settings, which - // sets AdvancedColor*Active* (aka the color-space check). - caps.hdrEnabled = (desc.ColorSpace != DXGI_COLOR_SPACE_RGB_FULL_G22_NONE_P709); - caps.bitsPerColor = desc.BitsPerColor; - caps.colorSpace = desc.ColorSpace; - caps.maxLuminanceNits = desc.MaxLuminance; - caps.minLuminanceNits = desc.MinLuminance; - caps.maxFullFrameLuminanceNits = desc.MaxFullFrameLuminance; - - // Monitor color primaries from DXGI EDID data. - caps.redPrimaryX = desc.RedPrimary[0]; - caps.redPrimaryY = desc.RedPrimary[1]; - caps.greenPrimaryX = desc.GreenPrimary[0]; - caps.greenPrimaryY = desc.GreenPrimary[1]; - caps.bluePrimaryX = desc.BluePrimary[0]; - caps.bluePrimaryY = desc.BluePrimary[1]; - caps.whitePointX = desc.WhitePoint[0]; - caps.whitePointY = desc.WhitePoint[1]; - - // SDR white level: when HDR is on, this controls the nit value - // that scRGB 1.0 (a.k.a. SDR reference white) maps to on the - // display. Read it from the OS via DisplayConfigGetDeviceInfo - // so it tracks the user's Windows Settings -> Display -> HDR -> - // "SDR content brightness" slider. The returned SDRWhiteLevel - // is in 1/1000ths of 80 nits, per Microsoft's documentation. - // Fallback to 80 nits when the call isn't available (older - // Windows builds, non-DXGI outputs, virtual displays). - caps.sdrWhiteLevelNits = QuerySdrWhiteLevelForOutput(output.get()); - - // Pull ACM / advanced-color state (HDR/WCG support, user-enabled - // toggles, activeColorMode SDR/WCG/HDR) from DisplayConfig. - // Falls back to a hdrEnabled-derived activeColorMode internally - // when the type-15 query is unavailable. - QueryAdvancedColorInfo2(output.get(), caps); - } - - return caps; + Shutdown(); } - // ----------------------------------------------------------------------- - // DXGI output for the app window - // ----------------------------------------------------------------------- - - winrt::com_ptr DisplayMonitor::GetOutputForWindow() const + void DisplayMonitor::Initialize(HWND appHwnd) { - if (!m_appHwnd) - return nullptr; - - // Find the monitor that contains the majority of the app window. - HMONITOR hmon = MonitorFromWindow(m_appHwnd, MONITOR_DEFAULTTOPRIMARY); - - // Enumerate adapters → outputs to find the matching HMONITOR. - winrt::com_ptr factory; - if (FAILED(CreateDXGIFactory1(IID_PPV_ARGS(factory.put())))) - return nullptr; + Shutdown(); - winrt::com_ptr adapter; - for (UINT ai = 0; factory->EnumAdapters1(ai, adapter.put()) != DXGI_ERROR_NOT_FOUND; ++ai) + try { - winrt::com_ptr output; - for (UINT oi = 0; adapter->EnumOutputs(oi, output.put()) != DXGI_ERROR_NOT_FOUND; ++oi) + // GetForWindow requires a top-level HWND owned by this thread + // and a running DispatcherQueue; it hooks the window's message + // loop so the returned DisplayInformation tracks monitor moves + // and raises AdvancedColorInfoChanged on this thread. + auto interop = winrt::get_activation_factory< + WGD::DisplayInformation, IDisplayInformationStaticsInterop>(); + + WGD::DisplayInformation info{ nullptr }; + winrt::check_hresult(interop->GetForWindow( + appHwnd, + winrt::guid_of(), + winrt::put_abi(info))); + { - DXGI_OUTPUT_DESC desc{}; - if (SUCCEEDED(output->GetDesc(&desc)) && desc.Monitor == hmon) - { - winrt::com_ptr output6; - if (SUCCEEDED(output->QueryInterface(IID_PPV_ARGS(output6.put())))) - return output6; - } - output = nullptr; + std::lock_guard lock(m_capsMutex); + m_displayInfo = info; } - adapter = nullptr; - } - - return nullptr; - } - // ----------------------------------------------------------------------- - // SDR white level query (DisplayConfig) - // ----------------------------------------------------------------------- + m_aciRevoker = info.AdvancedColorInfoChanged( + winrt::auto_revoke, + [this](WGD::DisplayInformation const&, + winrt::Windows::Foundation::IInspectable const&) + { + OnDisplayChanged(); + }); - float DisplayMonitor::QuerySdrWhiteLevelForOutput(IDXGIOutput6* output) - { - // Default: 80 nits == scRGB 1.0 reference. Returned on any failure - // path (older Windows, virtual outputs, no DXGI desc). - constexpr float kDefaultNits = 80.0f; - if (!output) return kDefaultNits; - - DXGI_OUTPUT_DESC desc{}; - if (FAILED(output->GetDesc(&desc)) || desc.Monitor == nullptr) - return kDefaultNits; - - // Resolve HMONITOR -> GDI device name -> source mode -> target. - MONITORINFOEXW mi{}; - mi.cbSize = sizeof(mi); - if (!::GetMonitorInfoW(desc.Monitor, &mi)) - return kDefaultNits; - - UINT32 pathCount = 0; - UINT32 modeCount = 0; - if (::GetDisplayConfigBufferSizes(QDC_ONLY_ACTIVE_PATHS, &pathCount, &modeCount) != ERROR_SUCCESS) - return kDefaultNits; - - std::vector paths(pathCount); - std::vector modes(modeCount); - if (::QueryDisplayConfig(QDC_ONLY_ACTIVE_PATHS, - &pathCount, paths.data(), - &modeCount, modes.data(), - nullptr) != ERROR_SUCCESS) - return kDefaultNits; - paths.resize(pathCount); - modes.resize(modeCount); - - for (const auto& path : paths) + { + std::lock_guard lock(m_capsMutex); + m_lastError.clear(); + } + } + catch (const winrt::hresult_error& e) { - // Match by GDI device name on the source. - DISPLAYCONFIG_SOURCE_DEVICE_NAME srcName{}; - srcName.header.type = DISPLAYCONFIG_DEVICE_INFO_GET_SOURCE_NAME; - srcName.header.size = sizeof(srcName); - srcName.header.adapterId = path.sourceInfo.adapterId; - srcName.header.id = path.sourceInfo.id; - if (::DisplayConfigGetDeviceInfo(&srcName.header) != ERROR_SUCCESS) - continue; - if (wcscmp(srcName.viewGdiDeviceName, mi.szDevice) != 0) - continue; - - // SDRWhiteLevel is reported in 1/1000ths of 80 nits, i.e. - // nits = SDRWhiteLevel / 1000.0 * 80.0. Confirmed by the - // documented sample on Microsoft Learn. - DISPLAYCONFIG_SDR_WHITE_LEVEL whiteLevel{}; - whiteLevel.header.type = DISPLAYCONFIG_DEVICE_INFO_GET_SDR_WHITE_LEVEL; - whiteLevel.header.size = sizeof(whiteLevel); - whiteLevel.header.adapterId = path.targetInfo.adapterId; - whiteLevel.header.id = path.targetInfo.id; - if (::DisplayConfigGetDeviceInfo(&whiteLevel.header) != ERROR_SUCCESS) - return kDefaultNits; - - const float nits = static_cast(whiteLevel.SDRWhiteLevel) / 1000.0f * 80.0f; - if (nits >= 40.0f && nits <= 480.0f) // sanity-clamp to the slider's UI range - return nits; - return kDefaultNits; + // No display binding — serve struct defaults, never fire. + std::lock_guard lock(m_capsMutex); + m_displayInfo = nullptr; + m_lastError = L"GetForWindow failed: " + std::wstring(e.message()); + } + catch (...) + { + std::lock_guard lock(m_capsMutex); + m_displayInfo = nullptr; + m_lastError = L"GetForWindow failed (non-hresult exception)"; } - return kDefaultNits; + const auto caps = QueryCurrentCapabilities(); + { + std::lock_guard lock(m_capsMutex); + m_caps = caps; + } } - // ----------------------------------------------------------------------- - // Advanced color info (ACM / WCG / HDR mode) - // ----------------------------------------------------------------------- - - void DisplayMonitor::QueryAdvancedColorInfo2(IDXGIOutput6* output, - DisplayCapabilities& caps) + void DisplayMonitor::InitializeForPrimaryMonitor() { - // Default fallback: derive from already-populated caps.hdrEnabled - // (legacy DXGI_OUTPUT_DESC1 path). 0=SDR, 2=HDR. WCG isn't - // distinguishable without the type-15 query so we never report - // 1=WCG from the fallback. - caps.activeColorMode = caps.hdrEnabled ? 2u : 0u; - caps.hdrSupported = caps.hdrEnabled; - caps.hdrUserEnabled = caps.hdrEnabled; - caps.wcgSupported = false; - caps.wcgUserEnabled = false; - - if (!output) return; - - DXGI_OUTPUT_DESC desc{}; - if (FAILED(output->GetDesc(&desc)) || desc.Monitor == nullptr) - return; + Shutdown(); - // Resolve HMONITOR -> GDI device name -> source path -> target ID. - MONITORINFOEXW mi{}; - mi.cbSize = sizeof(mi); - if (!::GetMonitorInfoW(desc.Monitor, &mi)) - return; + try + { + // Snapshot-only binding for windowless hosts. Event + // registration would need a DispatcherQueue, which headless + // doesn't run — so no AdvancedColorInfoChanged subscription. + auto interop = winrt::get_activation_factory< + WGD::DisplayInformation, IDisplayInformationStaticsInterop>(); - UINT32 pathCount = 0; - UINT32 modeCount = 0; - if (::GetDisplayConfigBufferSizes(QDC_ONLY_ACTIVE_PATHS, &pathCount, &modeCount) != ERROR_SUCCESS) - return; + const HMONITOR primary = + MonitorFromPoint(POINT{ 0, 0 }, MONITOR_DEFAULTTOPRIMARY); - std::vector paths(pathCount); - std::vector modes(modeCount); - if (::QueryDisplayConfig(QDC_ONLY_ACTIVE_PATHS, - &pathCount, paths.data(), - &modeCount, modes.data(), - nullptr) != ERROR_SUCCESS) - return; - paths.resize(pathCount); - modes.resize(modeCount); + WGD::DisplayInformation info{ nullptr }; + winrt::check_hresult(interop->GetForMonitor( + primary, + winrt::guid_of(), + winrt::put_abi(info))); - for (const auto& path : paths) - { - DISPLAYCONFIG_SOURCE_DEVICE_NAME srcName{}; - srcName.header.type = DISPLAYCONFIG_DEVICE_INFO_GET_SOURCE_NAME; - srcName.header.size = sizeof(srcName); - srcName.header.adapterId = path.sourceInfo.adapterId; - srcName.header.id = path.sourceInfo.id; - if (::DisplayConfigGetDeviceInfo(&srcName.header) != ERROR_SUCCESS) - continue; - if (wcscmp(srcName.viewGdiDeviceName, mi.szDevice) != 0) - continue; - - DISPLAYCONFIG_GET_ADVANCED_COLOR_INFO_2 info{}; - info.header.type = DISPLAYCONFIG_DEVICE_INFO_GET_ADVANCED_COLOR_INFO_2; - info.header.size = sizeof(info); - info.header.adapterId = path.targetInfo.adapterId; - info.header.id = path.targetInfo.id; - if (::DisplayConfigGetDeviceInfo(&info.header) != ERROR_SUCCESS) - return; // keep the hdrEnabled-derived fallback - - caps.hdrSupported = info.highDynamicRangeSupported != 0; - caps.hdrUserEnabled = info.highDynamicRangeUserEnabled != 0; - caps.wcgSupported = info.wideColorSupported != 0; - caps.wcgUserEnabled = info.wideColorUserEnabled != 0; - - // Map DISPLAYCONFIG_ADVANCED_COLOR_MODE -> 0/1/2. - switch (info.activeColorMode) { - case DISPLAYCONFIG_ADVANCED_COLOR_MODE_SDR: caps.activeColorMode = 0; break; - case DISPLAYCONFIG_ADVANCED_COLOR_MODE_WCG: caps.activeColorMode = 1; break; - case DISPLAYCONFIG_ADVANCED_COLOR_MODE_HDR: caps.activeColorMode = 2; break; - default: caps.activeColorMode = caps.hdrEnabled ? 2u : 0u; break; + std::lock_guard lock(m_capsMutex); + m_displayInfo = info; + m_lastError.clear(); } - - // Reconcile hdrEnabled with the mode we just read. The seed value - // came from the legacy DXGI_OUTPUT_DESC1::ColorSpace heuristic, - // which reports G22_NONE_P709 whenever the output snapshot predates - // the panel entering HDR — the EDID-derived luminance/primaries in - // that same desc are still correct, so the stale color space is easy - // to miss. DisplayConfig is the live authority, so it wins here just - // as it does for bitsPerColor below. Without this, an HDR display - // reports "SDR" in the status bar and over MCP while - // activeColorMode correctly says HDR. - caps.hdrEnabled = (caps.activeColorMode == 2u); - - // Trust DisplayConfig over the legacy color-space heuristic for - // bitsPerColor too — DXGI_OUTPUT_DESC1 reports 8 in many WCG - // configurations even though the actual scanout is 10-bit. - if (info.bitsPerColorChannel != 0) - caps.bitsPerColor = info.bitsPerColorChannel; - - return; } - } - - // ----------------------------------------------------------------------- - // WM_DISPLAYCHANGE via hidden message-only window - // ----------------------------------------------------------------------- - - void DisplayMonitor::CreateMessageWindow() - { - WNDCLASSEXW wc{}; - wc.cbSize = sizeof(wc); - wc.lpfnWndProc = &DisplayMonitor::WndProc; - wc.hInstance = GetModuleHandleW(nullptr); - wc.lpszClassName = L"ShaderLab_DisplayMonitor"; - - m_wndClass = RegisterClassExW(&wc); - if (!m_wndClass) - return; - - // HWND_MESSAGE makes this a message-only window (invisible, no taskbar). - m_msgHwnd = CreateWindowExW( - 0, MAKEINTATOM(m_wndClass), L"", - 0, 0, 0, 0, 0, - HWND_MESSAGE, nullptr, wc.hInstance, this); - } - - void DisplayMonitor::DestroyMessageWindow() - { - if (m_msgHwnd) + catch (const winrt::hresult_error& e) { - DestroyWindow(m_msgHwnd); - m_msgHwnd = nullptr; + // No reachable display (CI, session 0) — struct defaults. + std::lock_guard lock(m_capsMutex); + m_displayInfo = nullptr; + m_lastError = L"GetForMonitor failed: " + std::wstring(e.message()); } - if (m_wndClass) + catch (...) { - UnregisterClassW(MAKEINTATOM(m_wndClass), GetModuleHandleW(nullptr)); - m_wndClass = 0; + std::lock_guard lock(m_capsMutex); + m_displayInfo = nullptr; + m_lastError = L"GetForMonitor failed (non-hresult exception)"; } - } - LRESULT CALLBACK DisplayMonitor::WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) - { - if (msg == WM_CREATE) + const auto caps = QueryCurrentCapabilities(); { - auto* cs = reinterpret_cast(lParam); - SetWindowLongPtrW(hwnd, GWLP_USERDATA, reinterpret_cast(cs->lpCreateParams)); - return 0; + std::lock_guard lock(m_capsMutex); + m_caps = caps; } + } - auto* self = reinterpret_cast(GetWindowLongPtrW(hwnd, GWLP_USERDATA)); - - if (msg == WM_DISPLAYCHANGE && self) - { - self->OnDisplayChanged(); - return 0; - } + void DisplayMonitor::Shutdown() + { + // Revoke on the owning (UI) thread: the event fires on this + // thread's DispatcherQueue, so after revoke() returns no handler + // is in flight and none will start. + m_aciRevoker.revoke(); - return DefWindowProcW(hwnd, msg, wParam, lParam); + std::lock_guard lock(m_capsMutex); + m_displayInfo = nullptr; } // ----------------------------------------------------------------------- - // IDXGIFactory7 adapter-changed event + // Capability query // ----------------------------------------------------------------------- - void DisplayMonitor::RegisterAdapterChangeEvent(IDXGIFactory7* factory) + DisplayCapabilities DisplayMonitor::QueryCurrentCapabilities() const { - if (!factory) - return; - - m_dxgiFactory.copy_from(factory); - - m_adapterEvent = CreateEventW(nullptr, FALSE, FALSE, nullptr); - if (!m_adapterEvent) - return; - - if (FAILED(m_dxgiFactory->RegisterAdaptersChangedEvent(m_adapterEvent, &m_adapterCookie))) + WGD::DisplayInformation info{ nullptr }; { - CloseHandle(m_adapterEvent); - m_adapterEvent = nullptr; - return; + std::lock_guard lock(m_capsMutex); + info = m_displayInfo; } + if (!info) + return DisplayCapabilities{}; - // Background thread waits on the event and calls OnDisplayChanged. - m_adapterThread = std::jthread([this](std::stop_token stop) + try { - while (!stop.stop_requested()) - { - DWORD result = WaitForSingleObject(m_adapterEvent, 500 /*ms poll for stop*/); - if (result == WAIT_OBJECT_0) - { - OnDisplayChanged(); - } - } - }); - } - - void DisplayMonitor::UnregisterAdapterChangeEvent() - { - // Stop the wait thread first. - if (m_adapterThread.joinable()) - { - m_adapterThread.request_stop(); - m_adapterThread.join(); + // DisplayInformation is agile — safe from any thread. + return CapsFromAdvancedColorInfo(info.GetAdvancedColorInfo()); } - - if (m_dxgiFactory && m_adapterCookie) + catch (const winrt::hresult_error& e) { - m_dxgiFactory->UnregisterAdaptersChangedEvent(m_adapterCookie); - m_adapterCookie = 0; + std::lock_guard lock(m_capsMutex); + m_lastError = L"GetAdvancedColorInfo failed: " + std::wstring(e.message()); + return DisplayCapabilities{}; } - - if (m_adapterEvent) + catch (...) { - CloseHandle(m_adapterEvent); - m_adapterEvent = nullptr; + std::lock_guard lock(m_capsMutex); + m_lastError = L"GetAdvancedColorInfo failed (non-hresult exception)"; + return DisplayCapabilities{}; } - - m_dxgiFactory = nullptr; } // ----------------------------------------------------------------------- // Change detection & callback dispatch // ----------------------------------------------------------------------- + bool DisplayMonitor::CapsChanged(const DisplayCapabilities& a, + const DisplayCapabilities& b) + { + const auto nits = [](float x, float y) { return std::abs(x - y) > 0.5f; }; + const auto black = [](float x, float y) { return std::abs(x - y) > 0.01f; }; + const auto chroma = [](float x, float y) { return std::abs(x - y) > 0.001f; }; + + return a.hdrEnabled != b.hdrEnabled + || a.activeColorMode != b.activeColorMode + || a.bitsPerColor != b.bitsPerColor + || a.hdrSupported != b.hdrSupported + || a.hdrUserEnabled != b.hdrUserEnabled + || a.wcgSupported != b.wcgSupported + || a.wcgUserEnabled != b.wcgUserEnabled + || nits(a.maxLuminanceNits, b.maxLuminanceNits) + || nits(a.maxFullFrameLuminanceNits, b.maxFullFrameLuminanceNits) + || nits(a.sdrWhiteLevelNits, b.sdrWhiteLevelNits) + || black(a.minLuminanceNits, b.minLuminanceNits) + || chroma(a.redPrimaryX, b.redPrimaryX) + || chroma(a.redPrimaryY, b.redPrimaryY) + || chroma(a.greenPrimaryX, b.greenPrimaryX) + || chroma(a.greenPrimaryY, b.greenPrimaryY) + || chroma(a.bluePrimaryX, b.bluePrimaryX) + || chroma(a.bluePrimaryY, b.bluePrimaryY) + || chroma(a.whitePointX, b.whitePointX) + || chroma(a.whitePointY, b.whitePointY); + } + void DisplayMonitor::OnDisplayChanged() { - auto newCaps = QueryCurrentCapabilities(); + // Query before locking — the WinRT read must not run under + // m_capsMutex (CachedCapabilities is called on hot paths). + const auto newCaps = QueryCurrentCapabilities(); - // Only fire the callback if something meaningful changed. bool changed = false; { std::lock_guard lock(m_capsMutex); - changed = (newCaps.hdrEnabled != m_caps.hdrEnabled) - || (newCaps.colorSpace != m_caps.colorSpace) - || (newCaps.bitsPerColor != m_caps.bitsPerColor) - || (std::abs(newCaps.maxLuminanceNits - m_caps.maxLuminanceNits) > 0.5f) - || (std::abs(newCaps.redPrimaryX - m_caps.redPrimaryX) > 0.001f) - || (std::abs(newCaps.greenPrimaryX - m_caps.greenPrimaryX) > 0.001f) - || (std::abs(newCaps.bluePrimaryX - m_caps.bluePrimaryX) > 0.001f); + changed = CapsChanged(m_caps, newCaps); m_caps = newCaps; } + if (!changed) + return; - if (changed) + // Copy the callback out so subscriber code never runs under our + // lock (re-entrant SetCallback would otherwise self-deadlock). + DisplayChangeCallback cb; { std::lock_guard lock(m_callbackMutex); - if (m_callback) - m_callback(newCaps); + cb = m_callback; } + if (cb) + cb(newCaps); } void DisplayMonitor::SetCallback(DisplayChangeCallback callback) @@ -485,26 +306,36 @@ namespace ShaderLab::Rendering m_simulatedProfile->isSimulated = true; } - // Notify subscribers with the simulated capabilities. - std::lock_guard lock(m_callbackMutex); - if (m_callback) - m_callback(profile.caps); + DisplayChangeCallback cb; + { + std::lock_guard lock(m_callbackMutex); + cb = m_callback; + } + if (cb) + cb(profile.caps); } void DisplayMonitor::ClearSimulatedProfile() { - DisplayCapabilities liveCaps{}; { std::lock_guard lock(m_capsMutex); m_simulatedProfile.reset(); - // Re-query live display to get current state. - m_caps = QueryCurrentCapabilities(); - liveCaps = m_caps; } - std::lock_guard lock(m_callbackMutex); - if (m_callback) - m_callback(liveCaps); + // Re-query live state outside the lock, then publish. + const auto liveCaps = QueryCurrentCapabilities(); + { + std::lock_guard lock(m_capsMutex); + m_caps = liveCaps; + } + + DisplayChangeCallback cb; + { + std::lock_guard lock(m_callbackMutex); + cb = m_callback; + } + if (cb) + cb(liveCaps); } DisplayProfile DisplayMonitor::ActiveProfile() const diff --git a/Rendering/DisplayMonitor.h b/Rendering/DisplayMonitor.h index e9e5aaa..1b47c37 100644 --- a/Rendering/DisplayMonitor.h +++ b/Rendering/DisplayMonitor.h @@ -7,15 +7,29 @@ namespace ShaderLab::Rendering { - // Monitors display capability changes (HDR toggle, luminance, adapter hot-plug) - // and notifies subscribers so the rendering pipeline can adapt. + // Monitors display capability changes (HDR toggle, SDR-white-level + // slider, luminance, primaries, window moved between monitors) and + // notifies subscribers so the rendering pipeline can adapt. // - // Two detection paths: - // 1. WM_DISPLAYCHANGE — resolution/depth changes, HDR toggle - // 2. IDXGIFactory7::RegisterAdaptersChangedEvent — GPU hot-plug / driver update + // Detection is event-driven via WinRT: a DisplayInformation bound to + // the app window (IDisplayInformationStaticsInterop::GetForWindow) + // raises AdvancedColorInfoChanged whenever any advanced-color + // parameter of the window's current display changes — including the + // Windows Settings "SDR content brightness" slider, HDR toggles, and + // monitor moves (the object hooks the window's message loop). + // Requires Windows 11 22H2 (10.0.22621), the app's declared minimum. // - // Both paths re-query IDXGIOutput6::GetDesc1 and fire the callback - // only when capabilities actually differ from the cached snapshot. + // Threading contract: + // - Initialize/Shutdown must be called on the thread that owns the + // HWND and runs a DispatcherQueue (the WinUI UI thread). The + // change event fires on that same thread, so after Shutdown() + // returns no callback can be in flight. + // - The DisplayInformation object is agile; QueryCurrentCapabilities + // may be called from any thread (MCP routes run it on the render + // worker in the GUI host and on the main thread headless). + // - The change callback is invoked WITHOUT internal locks held; it + // receives the LIVE caps even while a simulated profile is active + // (subscribers should re-read ActiveProfile()). class SHADERLAB_API DisplayMonitor { public: @@ -25,18 +39,26 @@ namespace ShaderLab::Rendering DisplayMonitor(const DisplayMonitor&) = delete; DisplayMonitor& operator=(const DisplayMonitor&) = delete; - // Initialize monitoring for the window identified by hWnd. - // dxgiFactory is used for adapter-change registration (may be nullptr - // if the D3D device isn't created yet — adapter monitoring will be skipped). - void Initialize(HWND appHwnd, IDXGIFactory7* dxgiFactory = nullptr); + // Bind to the window's display and subscribe to change events. + // Idempotent: re-initializing releases the previous binding first. + // On failure (no DispatcherQueue, invalid HWND) the monitor serves + // struct-default capabilities and never fires the callback. + void Initialize(HWND appHwnd); - // Tear down the message window and unregister the adapter event. + // Headless hosts (no HWND, no DispatcherQueue): take a one-shot + // capability snapshot of the primary monitor via GetForMonitor. + // No change events are delivered — snapshot-only. Falls back to + // struct defaults when no display is reachable (CI, session 0). + void InitializeForPrimaryMonitor(); + + // Unsubscribe from change events and release the display binding. void Shutdown(); - // Force a re-query of display capabilities right now. + // Re-query display capabilities right now (thread-safe; returns + // struct defaults when no display binding exists). DisplayCapabilities QueryCurrentCapabilities() const; - // Returns the most recently cached capabilities (no DXGI call). + // Returns the most recently cached capabilities (no OS query). // When a simulated profile is active, returns the simulated caps. DisplayCapabilities CachedCapabilities() const { @@ -46,6 +68,16 @@ namespace ShaderLab::Rendering return m_caps; } + // Non-empty when the display binding or the last capability query + // failed — the monitor is serving struct defaults. Surfaced over + // MCP (get_display_info "monitorStatus") so a broken binding is + // diagnosable instead of silently masquerading as an SDR panel. + std::wstring LastError() const + { + std::lock_guard lock(m_capsMutex); + return m_lastError; + } + // Register / clear the change callback. void SetCallback(DisplayChangeCallback callback); @@ -66,52 +98,26 @@ namespace ShaderLab::Rendering DisplayProfile LiveProfile() const; private: - // Hidden message-only window for WM_DISPLAYCHANGE. - static LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam); - void CreateMessageWindow(); - void DestroyMessageWindow(); - - // Adapter-changed event via IDXGIFactory7. - void RegisterAdapterChangeEvent(IDXGIFactory7* factory); - void UnregisterAdapterChangeEvent(); - - // Re-query and fire callback if changed. + // Re-query, diff against the cache, fire the callback if changed. void OnDisplayChanged(); - // Find the IDXGIOutput6 that covers the app window. - winrt::com_ptr GetOutputForWindow() const; - - // Query the OS for the SDR white-level (in nits) on the display - // that hosts the given output. Reads the same value as the - // Windows Settings -> Display -> HDR -> "SDR content brightness" - // slider via DisplayConfigGetDeviceInfo. Returns 80.0f on failure. - static float QuerySdrWhiteLevelForOutput(IDXGIOutput6* output); - - // Query DisplayConfig for ACM / advanced-color state on the display - // that hosts the given output. Populates the activeColorMode + - // *Supported / *UserEnabled fields on `caps`. Falls back to deriving - // activeColorMode from the existing `caps.hdrEnabled` when the - // type-15 (ADVANCED_COLOR_INFO_2) query is unavailable. - static void QueryAdvancedColorInfo2(IDXGIOutput6* output, - DisplayCapabilities& caps); + // Field-wise comparison with float tolerances (0.5 nit luminance, + // 0.01 nit black level, 0.001 chromaticity). + static bool CapsChanged(const DisplayCapabilities& a, + const DisplayCapabilities& b); - HWND m_appHwnd{ nullptr }; - HWND m_msgHwnd{ nullptr }; - ATOM m_wndClass{ 0 }; - - winrt::com_ptr m_dxgiFactory; - DWORD m_adapterCookie{ 0 }; - HANDLE m_adapterEvent{ nullptr }; - std::jthread m_adapterThread; + // m_displayInfo is written on the UI thread (Initialize/Shutdown) + // and read cross-thread; guarded by m_capsMutex. The revoker is + // only touched on the UI thread. + winrt::Windows::Graphics::Display::DisplayInformation m_displayInfo{ nullptr }; + winrt::Windows::Graphics::Display::DisplayInformation::AdvancedColorInfoChanged_revoker m_aciRevoker; DisplayCapabilities m_caps{}; std::optional m_simulatedProfile; + // mutable: QueryCurrentCapabilities (const) records query failures. + mutable std::wstring m_lastError; mutable std::mutex m_capsMutex; DisplayChangeCallback m_callback; std::mutex m_callbackMutex; - - // Periodic monitor-change polling (WM_DISPLAYCHANGE doesn't fire on window move). - HMONITOR m_lastMonitor{ nullptr }; - std::jthread m_monitorPollThread; }; } diff --git a/Rendering/DisplayProfile.h b/Rendering/DisplayProfile.h index 62c7bc8..aae94d6 100644 --- a/Rendering/DisplayProfile.h +++ b/Rendering/DisplayProfile.h @@ -89,31 +89,36 @@ namespace ShaderLab::Rendering // Preset factory functions // ----------------------------------------------------------------------- - // Preset helper: stamp coherent ACM/WCG/activeColorMode flags into a - // `caps` block based on hdrEnabled. Used by every Preset*() factory so + // Preset helper: stamp coherent ACM/WCG/activeColorMode flags and the + // derived bits-per-channel into a profile from its hdrEnabled + gamut. + // Call AFTER caps.hdrEnabled and p.gamut are set. Used by every + // Preset*() factory, the ICC path, and the MCP custom-profile route so // simulated profiles report a self-consistent display mode through the - // Working Space node and other consumers. - inline void StampSimulatedColorMode(DisplayCapabilities& caps) + // Working Space node and other consumers. Mirrors the live-display + // derivation in DisplayMonitor's CapsFromAdvancedColorInfo: a + // wide-gamut SDR profile simulates Windows ACM (activeColorMode 1). + inline void StampSimulatedColorMode(DisplayProfile& p) { - caps.activeColorMode = caps.hdrEnabled ? 2u : 0u; // 2=HDR, 0=SDR - caps.hdrSupported = caps.hdrEnabled; - caps.hdrUserEnabled = caps.hdrEnabled; - caps.wcgSupported = caps.hdrEnabled; // SDR presets don't simulate ACM/WCG - caps.wcgUserEnabled = false; + const bool hdr = p.caps.hdrEnabled; + const bool wide = (p.gamut != GamutId::sRGB); + p.caps.activeColorMode = hdr ? 2u : (wide ? 1u : 0u); // 2=HDR, 1=WCG/ACM, 0=SDR + p.caps.hdrSupported = hdr; + p.caps.hdrUserEnabled = hdr; + p.caps.wcgSupported = hdr || wide; + p.caps.wcgUserEnabled = !hdr && wide; + p.caps.bitsPerColor = (p.caps.activeColorMode != 0u) ? 10u : 8u; } inline DisplayProfile PresetSrgbSdr() { DisplayProfile p{}; p.caps.hdrEnabled = false; - p.caps.bitsPerColor = 8; - p.caps.colorSpace = DXGI_COLOR_SPACE_RGB_FULL_G22_NONE_P709; p.caps.sdrWhiteLevelNits = 80.0f; p.caps.maxLuminanceNits = 80.0f; p.caps.minLuminanceNits = 0.5f; p.caps.maxFullFrameLuminanceNits = 80.0f; - StampSimulatedColorMode(p.caps); p.gamut = GamutId::sRGB; + StampSimulatedColorMode(p); p.profileName = L"sRGB SDR (80 nits)"; p.isSimulated = true; return p; @@ -123,14 +128,12 @@ namespace ShaderLab::Rendering { DisplayProfile p{}; p.caps.hdrEnabled = false; - p.caps.bitsPerColor = 8; - p.caps.colorSpace = DXGI_COLOR_SPACE_RGB_FULL_G22_NONE_P709; p.caps.sdrWhiteLevelNits = 100.0f; p.caps.maxLuminanceNits = 270.0f; p.caps.minLuminanceNits = 0.5f; p.caps.maxFullFrameLuminanceNits = 270.0f; - StampSimulatedColorMode(p.caps); p.gamut = GamutId::sRGB; + StampSimulatedColorMode(p); p.profileName = L"sRGB SDR (270 nits, typical laptop)"; p.isSimulated = true; return p; @@ -140,19 +143,17 @@ namespace ShaderLab::Rendering { DisplayProfile p{}; p.caps.hdrEnabled = true; - p.caps.bitsPerColor = 10; - p.caps.colorSpace = DXGI_COLOR_SPACE_RGB_FULL_G2084_NONE_P2020; p.caps.sdrWhiteLevelNits = 203.0f; p.caps.maxLuminanceNits = 600.0f; p.caps.minLuminanceNits = 0.05f; p.caps.maxFullFrameLuminanceNits = 500.0f; - StampSimulatedColorMode(p.caps); // DCI-P3 primaries p.primaryRed = { 0.680f, 0.320f }; p.primaryGreen = { 0.265f, 0.690f }; p.primaryBlue = { 0.150f, 0.060f }; p.whitePoint = { 0.3127f, 0.3290f }; p.gamut = GamutId::DCI_P3; + StampSimulatedColorMode(p); p.profileName = L"DCI-P3 HDR (600 nits, MacBook Pro-class)"; p.isSimulated = true; return p; @@ -162,19 +163,17 @@ namespace ShaderLab::Rendering { DisplayProfile p{}; p.caps.hdrEnabled = true; - p.caps.bitsPerColor = 10; - p.caps.colorSpace = DXGI_COLOR_SPACE_RGB_FULL_G2084_NONE_P2020; p.caps.sdrWhiteLevelNits = 203.0f; p.caps.maxLuminanceNits = 1000.0f; p.caps.minLuminanceNits = 0.05f; p.caps.maxFullFrameLuminanceNits = 600.0f; - StampSimulatedColorMode(p.caps); // DCI-P3 primaries p.primaryRed = { 0.680f, 0.320f }; p.primaryGreen = { 0.265f, 0.690f }; p.primaryBlue = { 0.150f, 0.060f }; p.whitePoint = { 0.3127f, 0.3290f }; p.gamut = GamutId::DCI_P3; + StampSimulatedColorMode(p); p.profileName = L"DCI-P3 HDR (1000 nits, reference monitor)"; p.isSimulated = true; return p; @@ -184,19 +183,17 @@ namespace ShaderLab::Rendering { DisplayProfile p{}; p.caps.hdrEnabled = true; - p.caps.bitsPerColor = 10; - p.caps.colorSpace = DXGI_COLOR_SPACE_RGB_FULL_G2084_NONE_P2020; p.caps.sdrWhiteLevelNits = 203.0f; p.caps.maxLuminanceNits = 1000.0f; p.caps.minLuminanceNits = 0.005f; p.caps.maxFullFrameLuminanceNits = 600.0f; - StampSimulatedColorMode(p.caps); // BT.2020 primaries p.primaryRed = { 0.708f, 0.292f }; p.primaryGreen = { 0.170f, 0.797f }; p.primaryBlue = { 0.131f, 0.046f }; p.whitePoint = { 0.3127f, 0.3290f }; p.gamut = GamutId::BT2020; + StampSimulatedColorMode(p); p.profileName = L"BT.2020 HDR (1000 nits, HDR TV)"; p.isSimulated = true; return p; @@ -206,19 +203,17 @@ namespace ShaderLab::Rendering { DisplayProfile p{}; p.caps.hdrEnabled = true; - p.caps.bitsPerColor = 10; - p.caps.colorSpace = DXGI_COLOR_SPACE_RGB_FULL_G2084_NONE_P2020; p.caps.sdrWhiteLevelNits = 203.0f; p.caps.maxLuminanceNits = 4000.0f; p.caps.minLuminanceNits = 0.005f; p.caps.maxFullFrameLuminanceNits = 1000.0f; - StampSimulatedColorMode(p.caps); // BT.2020 primaries p.primaryRed = { 0.708f, 0.292f }; p.primaryGreen = { 0.170f, 0.797f }; p.primaryBlue = { 0.131f, 0.046f }; p.whitePoint = { 0.3127f, 0.3290f }; p.gamut = GamutId::BT2020; + StampSimulatedColorMode(p); p.profileName = L"BT.2020 HDR (4000 nits, mastering)"; p.isSimulated = true; return p; @@ -228,19 +223,17 @@ namespace ShaderLab::Rendering { DisplayProfile p{}; p.caps.hdrEnabled = false; - p.caps.bitsPerColor = 8; - p.caps.colorSpace = DXGI_COLOR_SPACE_RGB_FULL_G22_NONE_P709; p.caps.sdrWhiteLevelNits = 160.0f; p.caps.maxLuminanceNits = 160.0f; p.caps.minLuminanceNits = 0.5f; p.caps.maxFullFrameLuminanceNits = 160.0f; - StampSimulatedColorMode(p.caps); // Adobe RGB (1998) primaries p.primaryRed = { 0.6400f, 0.3300f }; p.primaryGreen = { 0.2100f, 0.7100f }; p.primaryBlue = { 0.1500f, 0.0600f }; p.whitePoint = { 0.3127f, 0.3290f }; p.gamut = GamutId::Custom; + StampSimulatedColorMode(p); p.profileName = L"Adobe RGB (1998)"; p.isSimulated = true; return p; diff --git a/Rendering/IccProfileParser.cpp b/Rendering/IccProfileParser.cpp index 3a64203..3079fb7 100644 --- a/Rendering/IccProfileParser.cpp +++ b/Rendering/IccProfileParser.cpp @@ -216,24 +216,20 @@ namespace ShaderLab::Rendering p.caps.maxLuminanceNits = lum; p.caps.hdrEnabled = (lum > 400.0f); - p.caps.bitsPerColor = p.caps.hdrEnabled ? 10u : 8u; - p.caps.colorSpace = p.caps.hdrEnabled - ? DXGI_COLOR_SPACE_RGB_FULL_G2084_NONE_P2020 - : DXGI_COLOR_SPACE_RGB_FULL_G22_NONE_P709; p.caps.sdrWhiteLevelNits = 80.0f; p.caps.minLuminanceNits = p.caps.hdrEnabled ? 0.05f : 0.5f; p.caps.maxFullFrameLuminanceNits = (std::min)(lum, lum * 0.8f + 100.0f); - // Reuse the preset helper to stamp coherent ACM/WCG/activeColorMode - // flags into the simulated caps, derived from hdrEnabled. - StampSimulatedColorMode(p.caps); - p.primaryRed = icc.primaryRed; p.primaryGreen = icc.primaryGreen; p.primaryBlue = icc.primaryBlue; p.whitePoint = icc.whitePoint; p.gamut = DetectGamut(icc.primaryRed, icc.primaryGreen, icc.primaryBlue); + + // Reuse the preset helper to stamp coherent ACM/WCG/activeColorMode + // flags + bits-per-channel, derived from hdrEnabled and the gamut. + StampSimulatedColorMode(p); p.profileName = icc.description.empty() ? L"ICC Profile" : icc.description; p.isSimulated = true; diff --git a/Rendering/RenderEngine.h b/Rendering/RenderEngine.h index 1153b94..fe9a8b5 100644 --- a/Rendering/RenderEngine.h +++ b/Rendering/RenderEngine.h @@ -2,7 +2,7 @@ #include "pch.h" #include "PipelineFormat.h" -#include "DisplayMonitor.h" +#include "DisplayInfo.h" namespace ShaderLab::Rendering { diff --git a/Rendering/WorkingSpaceSync.cpp b/Rendering/WorkingSpaceSync.cpp index 64c4efa..d1a80c6 100644 --- a/Rendering/WorkingSpaceSync.cpp +++ b/Rendering/WorkingSpaceSync.cpp @@ -15,20 +15,29 @@ namespace ShaderLab::Rendering const auto& caps = profile.caps; const bool isSim = monitor.IsSimulated(); - struct ScalarField { const wchar_t* name; float value; }; + // Per-field change epsilons. Displays with adaptive color / auto + // brightness re-report luminance continuously (sub-nit sensor + // jitter at several Hz); without a dead-band every drift dirties + // the node and re-evaluates every binding consumer — measured as + // a full-pipeline eval storm on a 4K graph. 1 nit is invisible + // while slider drags (multi-nit steps) still propagate instantly. + // Flags/mode use 0 (exact); MinNits values sit near 0.0005 so it + // gets a proportionally tiny band. + struct ScalarField { const wchar_t* name; float value; float eps; }; const ScalarField scalars[] = { - { L"ActiveColorMode", static_cast(caps.activeColorMode) }, - { L"HdrSupported", caps.hdrSupported ? 1.0f : 0.0f }, - { L"HdrUserEnabled", caps.hdrUserEnabled ? 1.0f : 0.0f }, - { L"WcgSupported", caps.wcgSupported ? 1.0f : 0.0f }, - { L"WcgUserEnabled", caps.wcgUserEnabled ? 1.0f : 0.0f }, - { L"IsSimulated", isSim ? 1.0f : 0.0f }, - { L"SdrWhiteNits", caps.sdrWhiteLevelNits }, - { L"PeakNits", caps.maxLuminanceNits }, - { L"MinNits", caps.minLuminanceNits }, - { L"MaxFullFrameNits", caps.maxFullFrameLuminanceNits }, + { L"ActiveColorMode", static_cast(caps.activeColorMode), 0.0f }, + { L"HdrSupported", caps.hdrSupported ? 1.0f : 0.0f, 0.0f }, + { L"HdrUserEnabled", caps.hdrUserEnabled ? 1.0f : 0.0f, 0.0f }, + { L"WcgSupported", caps.wcgSupported ? 1.0f : 0.0f, 0.0f }, + { L"WcgUserEnabled", caps.wcgUserEnabled ? 1.0f : 0.0f, 0.0f }, + { L"IsSimulated", isSim ? 1.0f : 0.0f, 0.0f }, + { L"SdrWhiteNits", caps.sdrWhiteLevelNits, 1.0f }, + { L"PeakNits", caps.maxLuminanceNits, 1.0f }, + { L"MinNits", caps.minLuminanceNits, 0.0001f }, + { L"MaxFullFrameNits", caps.maxFullFrameLuminanceNits, 1.0f }, }; + constexpr float kChromaEps = 0.0005f; struct VectorField { const wchar_t* name; float2 value; }; const VectorField vectors[] = { { L"RedPrimary", float2{ profile.primaryRed.x, profile.primaryRed.y } }, @@ -59,7 +68,7 @@ namespace ShaderLab::Rendering } if (auto* cur = std::get_if(&it->second)) { - if (*cur != f.value) + if (std::abs(*cur - f.value) > f.eps) { *cur = f.value; nodeChanged = true; @@ -83,7 +92,8 @@ namespace ShaderLab::Rendering } if (auto* cur = std::get_if(&it->second)) { - if (cur->x != f.value.x || cur->y != f.value.y) + if (std::abs(cur->x - f.value.x) > kChromaEps || + std::abs(cur->y - f.value.y) > kChromaEps) { *cur = f.value; nodeChanged = true; diff --git a/ShaderLab.vcxproj b/ShaderLab.vcxproj index 2ce9fde..882a699 100644 --- a/ShaderLab.vcxproj +++ b/ShaderLab.vcxproj @@ -28,7 +28,7 @@ Windows Store 10.0 10.0 - 10.0.17763.0 + 10.0.22621.0 true false true diff --git a/ShaderLabEngine.vcxproj b/ShaderLabEngine.vcxproj index e9d0b86..7425764 100644 --- a/ShaderLabEngine.vcxproj +++ b/ShaderLabEngine.vcxproj @@ -8,7 +8,7 @@ ShaderLabEngine ShaderLabEngine 10.0 - 10.0.17763.0 + 10.0.22621.0 Win32Proj Unicode
diff --git a/ShaderLabHeadless.vcxproj b/ShaderLabHeadless.vcxproj index bcecf17..eff3d05 100644 --- a/ShaderLabHeadless.vcxproj +++ b/ShaderLabHeadless.vcxproj @@ -8,7 +8,7 @@ ShaderLabHeadless ShaderLabHeadless 10.0 - 10.0.17763.0 + 10.0.22621.0 Win32Proj Unicode diff --git a/ShaderLabHeadless/Main.cpp b/ShaderLabHeadless/Main.cpp index 6d5ea1e..2ea9353 100644 --- a/ShaderLabHeadless/Main.cpp +++ b/ShaderLabHeadless/Main.cpp @@ -799,7 +799,11 @@ int RunScript(const Args& args) ShaderLab::Effects::SourceNodeFactory sourceFactory; ShaderLab::Rendering::GraphEvaluator evaluator; - ShaderLab::Rendering::DisplayMonitor displayMonitor; // headless: live caps default + // Snapshot the primary monitor's real advanced-color caps (no change + // events — headless runs no DispatcherQueue). Falls back to struct + // defaults when no display is reachable (CI, session 0). + ShaderLab::Rendering::DisplayMonitor displayMonitor; + displayMonitor.InitializeForPrimaryMonitor(); // Prep source nodes once (loads media off disk). Properties on // source nodes are typically static (file path); set-property on diff --git a/ShaderLabMcpBroker.vcxproj b/ShaderLabMcpBroker.vcxproj index 4822818..183a670 100644 --- a/ShaderLabMcpBroker.vcxproj +++ b/ShaderLabMcpBroker.vcxproj @@ -8,7 +8,7 @@ ShaderLabMcpBroker ShaderLabMcpBroker 10.0 - 10.0.17763.0 + 10.0.22621.0 Win32Proj Unicode diff --git a/ShaderLabTests.vcxproj b/ShaderLabTests.vcxproj index d856654..736e691 100644 --- a/ShaderLabTests.vcxproj +++ b/ShaderLabTests.vcxproj @@ -8,7 +8,7 @@ ShaderLabTests ShaderLabTests 10.0 - 10.0.17763.0 + 10.0.22621.0 Win32Proj Unicode diff --git a/docs/README.md b/docs/README.md index ddb3f95..d06047f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -15,7 +15,7 @@ Reference for "how does ShaderLab work under the hood". - [Pipeline Format Strategy](architecture/pipeline-format.md) — why the pipeline is always scRGB FP16 and how DWM/ACM handles the final display conversion. - [Effect Graph Model](architecture/effect-graph-model.md) — `EffectGraph` / `EffectNode` / `EffectEdge` / `PropertyValue`, JSON serialization, dirty tracking. - [Topological Evaluation](architecture/topological-evaluation.md) — Kahn's algorithm, evaluation order, cycle detection. -- [Display Monitoring](architecture/display-monitoring.md) — DXGI adapter-change events, `WM_DISPLAYCHANGE`, ICC profile parsing, SDR white level. +- [Display Monitoring](architecture/display-monitoring.md) — WinRT `AdvancedColorInfo` + `AdvancedColorInfoChanged` event, ICC profile parsing, SDR white level. - [Display Profile Mocking](architecture/display-profile-mocking.md) — simulated SDR/HDR/WCG environments and the testing harness. - [Compute Shader Analysis Pipeline](architecture/compute-analysis-pipeline.md) — D2D compute conventions, CPU readback, analysis output schema. - [D2D / D3D11 Hybrid Compute System](architecture/d2d-d3d11-hybrid-compute.md) — `CustomComputeBridgeEffect`, `D3D11ComputeRunner`, GPU-binding routing, COM class hierarchy. diff --git a/docs/architecture/display-monitoring.md b/docs/architecture/display-monitoring.md index ac90c67..7090557 100644 --- a/docs/architecture/display-monitoring.md +++ b/docs/architecture/display-monitoring.md @@ -1,34 +1,87 @@ # Display Monitoring +Display capabilities are sourced from the WinRT +**`Windows.Graphics.Display.AdvancedColorInfo`** API. At startup the GUI +host binds a `DisplayInformation` object to the main window via the +desktop interop factory (`IDisplayInformationStaticsInterop::GetForWindow`, +`windows.graphics.display.interop.h`) and subscribes to its +**`AdvancedColorInfoChanged`** event. This requires **Windows 11 22H2 +(10.0.22621)** — the app's declared minimum OS. + ```mermaid sequenceDiagram - participant App as ShaderLab - participant DXGI as DXGI Output + participant UI as UI thread participant DM as DisplayMonitor - participant PF as PipelineFormat - participant SC as SwapChain - - App->>DXGI: IDXGIOutput6::GetDesc1() - DXGI-->>App: DXGI_OUTPUT_DESC1 - App->>DM: Initialize(hWnd) - DM->>DM: Register WM_DISPLAYCHANGE - DM->>DM: Register IDXGIFactory7::RegisterAdaptersChangedEvent - - Note over DM: Display change detected - DM->>DXGI: Re-query IDXGIOutput6::GetDesc1() - DXGI-->>DM: Updated capabilities - DM->>PF: NotifyDisplayChanged(newCaps) - PF->>SC: Recreate with new format if needed - DM->>App: Update status bar + participant DI as DisplayInformation (WinRT) + participant RW as Render worker + + UI->>DM: Initialize(hWnd) + DM->>DI: GetForWindow(hWnd) + subscribe AdvancedColorInfoChanged + DI-->>DM: AdvancedColorInfo snapshot → DisplayCapabilities + + Note over DI: HDR toggle / SDR-brightness slider /
window moved to another monitor + DI->>DM: AdvancedColorInfoChanged (fires on UI thread) + DM->>DI: GetAdvancedColorInfo() re-query + DM->>DM: Field-wise diff vs cached caps + DM->>UI: callback → coalesced status-bar/timer refresh
(no graph work) + RW->>RW: per tick: UpdateWorkingSpaceNodes reads ActiveProfile,
dirties the Working Space node when a field moved ≥ epsilon —
that dirty is what re-evaluates binding consumers ``` +One `AdvancedColorInfo` snapshot supplies everything in +`DisplayCapabilities`: the active color kind (SDR / WCG / HDR → +`activeColorMode` 0/1/2), kind availability (`hdrSupported` / +`wcgSupported` via `IsAdvancedColorKindAvailable`), the four luminance +values (peak, min, max-full-frame, SDR white level), and the four EDID +chromaticity points (RGB primaries + white point). `bitsPerColor` is +derived from the active kind (WCG/HDR → 10, SDR → 8) since WinRT does +not expose scanout depth. The `GetForWindow`-bound object hooks the +window's message loop, so moving the window between monitors re-targets +the reported display automatically — no `WM_DISPLAYCHANGE` handling, no +monitor polling, and no DXGI adapter-change registration remain. + +The event fires on the UI thread (the thread whose `DispatcherQueue` +created the binding). The callback does **no graph work at all** — the +render worker's per-tick `UpdateWorkingSpaceNodes` is the sole +propagation path (single-writer discipline), and nothing else in the +graph depends on display state ("bind, don't hide"). This matters +because **adaptive-color displays fire the event at ambient-sensor +rate** with sub-nit luminance drift: the callback's UI refresh is +coalesced behind a pending flag, and the Working Space sync applies +per-field dead-bands (1 nit on luminance, 0.0005 on chromaticity) so +sensor jitter neither re-evaluates the graph nor floods the UI thread. +Slider drags move multiple nits and propagate on the next tick. +`DisplayInformation` is agile, so on-demand re-queries +(`QueryCurrentCapabilities`) are safe from any thread — MCP routes run +them on the render worker in the GUI host. + +**WinUI 3 prerequisite**: `GetForWindow` requires a running +`Windows.System.DispatcherQueue` on the calling thread — and WinUI 3 +threads only run `Microsoft.UI.Dispatching.DispatcherQueue`, a distinct +type. `MainWindow::InitializeRendering` creates the system queue via +`CreateDispatcherQueueController` (the same pattern system-backdrop +controllers use) before binding; without it the call throws and the +monitor serves struct defaults. Binding/query failures are recorded in +`DisplayMonitor::LastError()` and surfaced as the optional +`monitorStatus` field of `get_display_info`, so a broken binding is +diagnosable rather than masquerading as an SDR panel. + +**Headless host**: no window and no `DispatcherQueue`, so +`InitializeForPrimaryMonitor()` takes a one-shot snapshot of the primary +monitor via `GetForMonitor` (no change events). When no display is +reachable (CI, session 0) the monitor serves struct-default +capabilities. + ## SDR white level -`DisplayCapabilities::sdrWhiteLevelNits` is queried from the OS via `DisplayConfigGetDeviceInfo(DISPLAYCONFIG_DEVICE_INFO_GET_SDR_WHITE_LEVEL)`, decoded as `nits = SDRWhiteLevel / 1000 * 80`. This value tracks the user's **Settings → Display → HDR → "SDR content brightness"** slider when HDR is on; when HDR is off it falls back to 80 nits. +`DisplayCapabilities::sdrWhiteLevelNits` comes from +`AdvancedColorInfo::SdrWhiteLevelInNits`. It tracks the user's +**Settings → Display → HDR → "SDR content brightness"** slider live — +slider moves raise `AdvancedColorInfoChanged`, so the value updates +without any polling; when HDR is off it reports 80 nits (scRGB 1.0). The value is exposed to graphs through the **`Working Space` parameter node** (see [Working Space Integration](#working-space-integration)) on its `SdrWhiteNits` analysis output. Effects that need to know the nit value of scRGB 1.0 (the entire ICtCp suite) consume it via property bindings — wire `working_space.SdrWhiteNits` into the effect's nit-target parameter and it tracks both the OS slider and any simulated `DisplayProfile` preset automatically. There is no longer any per-effect "follow the live monitor" or "follow the working space" host-side plumbing; the Working Space node is the single explicit path. --- -Back to [docs/](../README.md) • [Repo root](../../README.md) \ No newline at end of file +Back to [docs/](../README.md) • [Repo root](../../README.md) diff --git a/docs/architecture/display-profile-mocking.md b/docs/architecture/display-profile-mocking.md index 0a15010..b9740cf 100644 --- a/docs/architecture/display-profile-mocking.md +++ b/docs/architecture/display-profile-mocking.md @@ -7,11 +7,13 @@ classDiagram class DisplayCapabilities { +bool hdrEnabled +uint32_t bitsPerColor - +DXGI_COLOR_SPACE_TYPE colorSpace +float sdrWhiteLevelNits +float maxLuminanceNits +float minLuminanceNits +float maxFullFrameLuminanceNits + +float redPrimaryX/Y greenPrimaryX/Y bluePrimaryX/Y whitePointX/Y + +uint32_t activeColorMode + +bool hdrSupported hdrUserEnabled wcgSupported wcgUserEnabled } class ChromaticityXY { @@ -91,7 +93,7 @@ sequenceDiagram alt Clear simulation UI->>DM: ClearSimulatedProfile() - DM->>DM: Re-query live DXGI output + DM->>DM: Re-query live AdvancedColorInfo DM->>CB: callback(liveCaps) end ``` diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index 6f3d718..a2b4eea 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -13,7 +13,7 @@ graph TB EV[GraphEvaluator / FalseColorOverlay] FX[EffectRegistry / ShaderLabEffects / SourceNodeFactory] IO[ImageLoader / VideoSourceProvider / ShaderCompiler] - MON[DisplayMonitor / ICC / GPU reduction] + MON[DisplayMonitor / ICC] MCP[Engine/Mcp: McpRouter + EngineMcpRoutes] end diff --git a/docs/development/mcp-stdio-migration.md b/docs/development/mcp-stdio-migration.md index 9fc25f0..ff412f2 100644 --- a/docs/development/mcp-stdio-migration.md +++ b/docs/development/mcp-stdio-migration.md @@ -84,8 +84,9 @@ Verify the peer **process**, not its claims, and keep it version-tolerant: Measured with throwaway packages, not reasoned about. **Platform:** Windows 11 (10.0.26xxx), ARM64. Behaviour is expected to be identical on x64, but the two items -marked ⚠ are worth re-checking if they ever look wrong, and none of this was verified -at the manifest's declared `10.0.17763` floor. +marked ⚠ are worth re-checking if they ever look wrong. (The manifest's declared +floor was `10.0.17763` when these were measured; it has since been raised to +`10.0.22621`, which only narrows the unverified range.) | Question | Answer | |---|---| @@ -680,9 +681,9 @@ New `Engine/Mcp/McpFrame.{h,cpp}`, `McpCrypto.{h,cpp}`, `McpPeerIdentity.{h,cpp} sealed. Keep that split explicit in the type so it cannot drift. - 64 MB cap. A 4K inline capture is ~33 MB of base64; 8K would be ~130 MB, so either cap resolution server-side or fail explicitly rather than desyncing. -- Crypto: BCrypt ephemeral **P-256**. X25519 was rejected — CNG named-curve support is - unverified at the declared `10.0.17763` floor and it buys nothing against an empty - threat model. Two traps: `BCryptDeriveKey` with `BCRYPT_KDF_RAW_SECRET` returns the +- Crypto: BCrypt ephemeral **P-256**. X25519 was rejected — CNG named-curve support was + unverified at the OS floor declared at the time (`10.0.17763`; since raised to + `10.0.22621`) and it buys nothing against an empty threat model. Two traps: `BCryptDeriveKey` with `BCRYPT_KDF_RAW_SECRET` returns the secret **byte-reversed**, and CNG's `ECCPUBLICBLOB` carries a header, so don't size buffers against the raw curve. - Peer identity: `GetNamedPipeClientProcessId` / `ServerProcessId` → `OpenProcess` diff --git a/docs/development/project-structure.md b/docs/development/project-structure.md index 4155a30..5c6bb2b 100644 --- a/docs/development/project-structure.md +++ b/docs/development/project-structure.md @@ -77,7 +77,7 @@ ShaderLab/ │ ├── Rendering/ # Engine: rendering + analysis (RenderEngine stays app-side) │ ├── DisplayInfo.h # DisplayCapabilities struct -│ ├── DisplayMonitor.h / .cpp # WM_DISPLAYCHANGE + adapter-changed event + simulated profile +│ ├── DisplayMonitor.h / .cpp # WinRT AdvancedColorInfoChanged event + simulated profile │ ├── DisplayProfile.h # DisplayProfile struct + preset factories │ ├── IccProfileParser.h / .cpp # mscms.dll-based ICC reader │ ├── PipelineFormat.h # PipelineFormat struct (scRGB FP16 always) diff --git a/docs/effects/working-space.md b/docs/effects/working-space.md index 23c8e2e..4e6bf01 100644 --- a/docs/effects/working-space.md +++ b/docs/effects/working-space.md @@ -2,8 +2,8 @@ The active display profile (live OS-reported caps or any simulated preset / ICC the user has applied) is exposed to graphs through a single first-class node: **`Working Space`** (Parameter category). Effects that operate in a specific color space pull from it via the property-binding system. -- **Single source of truth**: The Working Space node's 14 typed analysis output fields mirror the active profile — `ActiveColorMode` (0=SDR, 1=WCG/ACM, 2=HDR), `HdrSupported`, `HdrUserEnabled`, `WcgSupported`, `WcgUserEnabled`, `IsSimulated`, `SdrWhiteNits`, `PeakNits`, `MinNits`, `MaxFullFrameNits`, plus the four CIE-xy primaries `RedPrimary` / `GreenPrimary` / `BluePrimary` / `WhitePoint` (each Float2). -- **Updated by `MainWindow::UpdateWorkingSpaceNodes()`**, which runs on `ApplyDisplayProfile`, `RevertToLiveDisplay`, the display-change callback, and once per render tick. Only marks the node dirty when at least one field actually changed, so binding consumers re-evaluate on profile changes only. +- **Single source of truth**: The Working Space node's 14 typed analysis output fields mirror the active profile — `ActiveColorMode` (0=SDR, 1=WCG/ACM, 2=HDR — maps 1:1 onto WinRT `AdvancedColorKind`), `HdrSupported`, `HdrUserEnabled`, `WcgSupported`, `WcgUserEnabled`, `IsSimulated`, `SdrWhiteNits`, `PeakNits`, `MinNits`, `MaxFullFrameNits`, plus the four CIE-xy primaries `RedPrimary` / `GreenPrimary` / `BluePrimary` / `WhitePoint` (each Float2). For the live display, `*Supported` reflects `IsAdvancedColorKindAvailable` (achievable on this display) while `*UserEnabled` mirrors the ACTIVE kind — `AdvancedColorInfo` has no separate user-toggle probe, so "supported but not user-enabled" reads as `HdrSupported=1, HdrUserEnabled=0`. +- **Updated by the render worker once per tick** (`UpdateWorkingSpaceNodes` under the graph lock — the worker is the single graph writer), which reads `ActiveProfile()` and marks the node dirty only when a field moved beyond its dead-band (1 nit on luminance fields, 0.0005 on chromaticity — adaptive-color displays jitter sub-nit at sensor rate, and without the dead-band every drift re-evaluated all binding consumers). That node-dirty is the sole propagation path to binding consumers; nothing sets `MarkAllDirty` on display changes. `SdrWhiteNits` tracks the Windows "SDR content brightness" slider live (slider steps are multi-nit). - **Bind, don't hide**: Effects that need to know the working-space primaries or peak nits expose them as bindable `Float2` / `Float` parameters (typically a `Custom` enum mode that gates them via `visibleWhen`, plus a few static convenience modes like `sRGB` / `BT.2020`). Wire those parameters from the Working Space node to follow the live profile, or set them by hand for strict static analysis. There is no "follow the working space" toggle anymore — wiring the binding **is** the toggle. - **No legacy `_hidden` filter**: properties ending in `_hidden` used to be host-managed cbuffer slots filtered from the UI. Decision #51 stopped writing them; a Phase-0 cleanup deleted the filter sites. Old graphs still load — the stale keys sit in memory inert. Cross-version graph compatibility is not promised right now. diff --git a/docs/history/decision-log.md b/docs/history/decision-log.md index 52578d3..91ca29e 100644 --- a/docs/history/decision-log.md +++ b/docs/history/decision-log.md @@ -100,4 +100,10 @@ --- +| # | Decision | Rationale | Date | +|---|----------|-----------|------| +| 72 | Display monitoring rewritten on WinRT `AdvancedColorInfo` + `AdvancedColorInfoChanged`; min OS raised to Win11 22H2 (**supersedes #12 and #13**) | The old three-path detection was mostly dead: the `WM_DISPLAYCHANGE` window (#12) was created with `HWND_MESSAGE` — message-only windows never receive broadcasts, so that handler could not fire — leaving only a 500 ms HMONITOR-move poll and the adapter hot-plug event (#13), neither of which catches a same-monitor HDR toggle or the Windows "SDR content brightness" slider; the change-diff also omitted `sdrWhiteLevelNits` entirely, so slider moves were doubly invisible. The replacement binds a `DisplayInformation` to the app window (`IDisplayInformationStaticsInterop::GetForWindow`, desktop interop, needs 10.0.22621) whose `AdvancedColorInfoChanged` event fires on the UI thread for *any* advanced-color change — slider, HDR toggle, monitor move (the object hooks the window's message loop) — and one `AdvancedColorInfo` snapshot replaces `IDXGIOutput6::GetDesc1` + both `QueryDisplayConfig` walks (SDR white, type-15) with kind/availability/luminance/primaries in a single agile object that MCP routes can also re-query cross-thread. Net: ~350 lines of Win32 plumbing (message window, two jthreads, DXGI enumeration, DisplayConfig path-matching ×2) deleted; the DXGI-factory Initialize parameter and the GUI's shutdown-and-reinit-to-attach-it churn are gone; the callback now defers graph work to the render worker via atomic flags instead of mutating `m_graph` from the UI thread (the same class of race decision #70 killed). Headless gets real primary-monitor caps via `GetForMonitor` (snapshot-only — no DispatcherQueue, no events). Cost: the app now requires Win11 22H2; accepted deliberately — the SDR-Advanced-Color feature set the app analyzes barely exists before 22H2, and the machine fleet is already there. | Day 15 | + +--- + Back to [docs/](../README.md) • [Repo root](../../README.md) \ No newline at end of file diff --git a/docs/hosts/headless.md b/docs/hosts/headless.md index b2dd7a6..092bdc7 100644 --- a/docs/hosts/headless.md +++ b/docs/hosts/headless.md @@ -49,6 +49,19 @@ ShaderLabHeadless --graph PATH --node ID --output PNG_PATH [options] - **MCP session** (`--mcp-session [--session-id GUID] [--session-label NAME] [--pipe BASE]`; stdio-migration Step 6). Loads a graph and registers with the broker hub as a **session**, so a shim-fronted MCP client selects it with `use_session` and drives it through the sealed relay. `initialize` / `tools/list` / `tools/call` / `resources/*` all work with no GUI; requests arrive as sealed channel frames, get routed through the router's `POST /` dispatcher, and the response is sealed back. Tools whose backing route is GUI-only (snapshot/view/gpu/perf/logs) return an `isError` "Tool not available on this host" result. `--session-id` is a persisted per-window GUID (a fresh one is generated when omitted); `--session-label` is what `list_sessions` surfaces (headless labels contain "headless", which the test suite uses to self-skip GUI-only tests). Reconnects to the hub with backoff after a drop. This is what CI's "MCP suite vs headless session" step drives, and the headless half of `RunBrokerSmoke.ps1`. (The Step 3 `--serve` HTTP mode was removed with the rest of the HTTP transport in Step 9.) +## Display capabilities without a window + +The headless host has no HWND and runs no `DispatcherQueue`, so it cannot +bind a `DisplayInformation` to a window or receive +`AdvancedColorInfoChanged` events. Instead it calls +`DisplayMonitor::InitializeForPrimaryMonitor()` at startup — a one-shot +`GetForMonitor` snapshot of the primary monitor's real advanced-color +caps (HDR state, luminance, SDR white level, primaries). `get_display_info` +therefore reports genuine values, but they are frozen at launch; display +changes during a headless run are not observed. When no display is +reachable (CI runners, session 0) the snapshot fails silently and +struct-default capabilities are served (SDR, 80-nit white, sRGB). + ## Engine-side reuse The MCP route registry (`RegisterEngineRoutes`) is what backs every host — the GUI window's session, the headless `--mcp-session`, and the headless `--script` mode all register the same routes. The same closures execute against the same engine state — only the sink's `Dispatch` impl differs between hosts. The GUI sink marshals to the render worker thread via `RenderThreadDispatcher::DispatchSync` (post-P7); the headless sink runs the closure inline since the script runner thread is the only consumer. The headless host overrides none of the eight `IEngineCommandSink` event hooks; without a UI to keep in sync, every hook is a no-op. diff --git a/pch_engine.h b/pch_engine.h index a4be310..eb4ae92 100644 --- a/pch_engine.h +++ b/pch_engine.h @@ -12,6 +12,7 @@ #include #include #include +#include // Direct3D / Direct2D / DXGI #include diff --git a/scripts/Install.ps1 b/scripts/Install.ps1 index 819c9c3..90b95bc 100644 --- a/scripts/Install.ps1 +++ b/scripts/Install.ps1 @@ -4,8 +4,9 @@ .DESCRIPTION Calls Add-AppxPackage with -AllowUnsigned, which lets Windows install an - unsigned MSIX when Developer Mode is on (Windows 10 1903+ / Windows 11). - No code-signing certificate is required. + unsigned MSIX when Developer Mode is on. ShaderLab's manifest declares a + minimum OS of Windows 11 22H2 (10.0.22621); older builds are rejected at + install time. No code-signing certificate is required. .PARAMETER MsixPath Path to the .msix file. If omitted, looks for the first .msix next to this script. From 0b3d14ce4d04a9432bfddba4d586d2a4ae0469b0 Mon Sep 17 00:00:00 2001 From: David Spruill Date: Wed, 16 Sep 2026 17:29:29 -0400 Subject: [PATCH 5/6] Large scale fixes for the headless version, cleaned up some remaining source effect handling, added delta-E ITP, general project cleanup --- .context/resume.md | 411 ++++++++++++--------- .github/copilot-instructions.md | 51 ++- .github/workflows/ci.yml | 84 +++++ .gitignore | 24 +- CHANGELOG.md | 43 +++ Controls/LogWindow.cpp | 2 + Controls/NodeGraphController.cpp | 15 +- Controls/OutputWindow.cpp | 50 ++- Effects/ColorMath.cpp | 136 ++++++- Effects/Performance.cpp | 12 + Effects/Performance.h | 13 + Effects/ShaderLabEffects.cpp | 455 +++++++++++++++++++----- Effects/SourceNodeFactory.cpp | 18 +- Effects/SourceNodeFactory.h | 5 + Engine/Mcp/EngineMcpRoutes.cpp | 9 + Engine/Mcp/McpRouter.cpp | 8 +- Engine/Mcp/McpSessionClient.cpp | 98 ++++- Engine/Mcp/McpToolCatalog.cpp | 8 +- Graph/EffectGraph.cpp | 22 +- MainWindow.McpRoutes.cpp | 43 ++- MainWindow.RenderTick.cpp | 13 +- MainWindow.xaml.cpp | 85 ++++- MainWindow.xaml.h | 17 +- Rendering/GraphEvaluator.cpp | 281 ++++++++++++--- Rendering/GraphEvaluator.h | 36 ++ Rendering/RenderEngine.cpp | 25 +- ShaderLab.vcxproj | 2 +- ShaderLabEngine.vcxproj | 2 +- ShaderLabHeadless.vcxproj | 2 +- ShaderLabHeadless/Main.cpp | 343 +++++++++++++++--- ShaderLabMcpBroker.vcxproj | 2 +- ShaderLabTests.vcxproj | 2 +- Tests/Math/ColorMatrixTests.cpp | 55 ++- Tests/Math/DeltaETests.cpp | 78 ++++ Tests/Math/GamutTests.cpp | 129 +++++++ Tests/RunBrokerSmoke.ps1 | 36 +- Tests/RunHeadlessSmoke.ps1 | 80 +++++ Tests/TestRunner.cpp | 248 +++++++++++++ docs/README.md | 6 +- docs/architecture/engine-host-split.md | 4 +- docs/development/mcp-stdio-migration.md | 10 +- docs/development/project-structure.md | 4 +- docs/effects/builtin-catalog.md | 5 +- docs/hosts/headless.md | 16 +- docs/hosts/mcp-server.md | 12 +- 45 files changed, 2524 insertions(+), 476 deletions(-) diff --git a/.context/resume.md b/.context/resume.md index 5955bf3..0de0f31 100644 --- a/.context/resume.md +++ b/.context/resume.md @@ -1,200 +1,274 @@ # ShaderLab — Development Context (Resume Point) -## Project Identity +## How to use this file + +A fast orientation to the **shape** of the project: what the pieces are, why they are +split that way, and the hard-won rules that are expensive to rediscover. + +**It deliberately carries no counts, versions, or test totals.** Those rot between +edits and made this the most drift-prone file in the repo — it once claimed engine ABI +1 while three other lines in it said 3. Live numbers come from: + +| Question | Authority | +|---|---| +| App / graph-format version | `Version.h` | +| Engine ABI | `EngineExport.h::SHADERLAB_ENGINE_ABI_VERSION` | +| What changed, when, and why | [`CHANGELOG.md`](../CHANGELOG.md) | +| Architectural decisions + rationale | [`docs/history/decision-log.md`](../docs/history/decision-log.md) | +| Effect catalog | [`docs/effects/builtin-catalog.md`](../docs/effects/builtin-catalog.md) (a test pins the count) | +| MCP tools / routes | [`docs/hosts/mcp-server.md`](../docs/hosts/mcp-server.md) | +| Test totals | run the suite — it prints them | +| Agent working rules | [`CLAUDE.md`](../CLAUDE.md), [`.github/copilot-instructions.md`](../.github/copilot-instructions.md) | +| Everything else, in depth | [`docs/`](../docs/README.md) | + +If you find yourself adding a number here, put it in one of those instead. + +--- -**ShaderLab** is a WinUI 3 desktop application (C++/WinRT) for developing, testing, and debugging Direct2D shader effects with full HDR and wide color gamut (WCG) support, with a particular focus on tone-mapping and color-correction R&D. +## Project Identity -- **Location**: `C:\Users\david\source\ShaderLab\ShaderLab.slnx` -- **Version**: **1.7.3** released. Current branch `user/daspr/mcp_migration_httptostdio`: the **MCP HTTP → stdio + broker migration is COMPLETE** (all 9 steps). The embedded HTTP listener is deleted; the broker (shim → hub → session over named pipes, bodies sealed) is the only MCP transport. Engine ABI **3**. Only the **manual verification sweep** at the end of [docs/development/mcp-stdio-migration.md](../docs/development/mcp-stdio-migration.md) remains before full sign-off (WinUI window lifecycle, packaged install/activation, a real MCP client, in-place upgrade). -- **Graph format version**: **2** (unchanged). -- **Engine ABI version**: **3** (`SHADERLAB_ENGINE_ABI_VERSION` in `EngineExport.h`; Step 2 re-typed `McpRouter`/`Mcp::Response`, Step 9 deleted the HTTP transport). -- **Language**: C++/WinRT — direct COM access to `ID2D1EffectImpl`, `ID2D1DrawTransform`, `ID2D1ComputeTransform`. No C#. +**ShaderLab** is a WinUI 3 desktop application (C++/WinRT) for developing, testing, and +debugging Direct2D shader effects with full HDR and wide colour gamut (WCG) support, +with a particular focus on tone-mapping and colour-correction R&D. -> Authoritative sources of truth: [`docs/`](../docs/README.md) (architecture tree + per-file references) and especially [`docs/history/decision-log.md`](../docs/history/decision-log.md) (**70 entries**; #64–67 were never written — that stretch is covered by `CHANGELOG.md` §1.6.0), `CHANGELOG.md` (per-version diffs), `Version.h` (numeric version), `.github/copilot-instructions.md` (AI agent rules, including the graph-access threading rule). This file is a fast-orientation summary; it can drift — re-check the docs tree before relying on details. +- **Language**: C++/WinRT — direct COM access to `ID2D1EffectImpl`, + `ID2D1DrawTransform`, `ID2D1ComputeTransform`. No C#. +- **Solution**: `ShaderLab.slnx` at the repo root. --- -## Solution Layout (4 projects) +## Solution Layout | Project | Output | Purpose | |--------|--------|---------| -| `ShaderLabEngine.vcxproj` | `ShaderLabEngine.dll` | Host-agnostic engine: graph model + `GraphUiSnapshot`, evaluator, `RenderThreadDispatcher`, ICC reader, video + live-capture sources, ExprTk math, D3D11 compute runner + `CustomComputeBridgeEffect` + `BytecodeCache`, `IEngineComputeOutput` COM interface, MCP router (`McpRouter`, HTTP listener until migration Step 9) + JSON-RPC dispatcher + 39-tool catalog + **25 engine-pure routes**. Exported via `SHADERLAB_API`. | -| `ShaderLab.vcxproj` | `ShaderLab.exe` (MSIX) | WinUI 3 packaged app. `RenderEngine` (app-only), all XAML, controllers, the render worker thread, `MainWindow.McpRoutes.cpp` (**18 app-side routes** + JSON-RPC dispatcher + `GuiEngineCommandSink`). Depends on the engine DLL. | -| `ShaderLabTests.vcxproj` | `ShaderLabTests.exe` | Standalone console test runner — **244 tests** (graph/evaluator/bindings, BytecodeCache, GraphUiSnapshot, RenderThreadDispatcher, McpRouter + JSON-RPC dispatcher contracts, MCP frame codec + crypto + peer identity, GPU-binding + skip-readback matrices, 51-test HLSL math bench). CI uses `--adapter warp`. | -| `ShaderLabHeadless.vcxproj` | `ShaderLabHeadless.exe` | Console host, no WinUI: PNG render, FP32 pixel readback (`--pixels`), JSON batch script mode (`--script`), **MCP session mode (`--mcp-session`, registers with the broker hub)**, bytecode-cache reap/clear ops, `--enable/--disable-gpu-bindings`. | -| `ShaderLabMcpBroker.vcxproj` | `ShaderLabMcpBroker.exe` | MCP broker. `--hub`: singleton blind relay — first-instance election, per-peer pairing, session registry + channel relay (routes on channelId only; bodies sealed end-to-end). `--stdio`: the MCP client's front-end — owns initialize + list_sessions/use_session, pins a session, runs the initiator handshake, seals/forwards requests, splices tools/list. Does NOT link the engine — compiles the `McpFrame`/`McpCrypto`/`McpPeerIdentity`/`McpChannel` TUs directly. Packaged as the manifest's second ``. | +| `ShaderLabEngine.vcxproj` | `ShaderLabEngine.dll` | Host-agnostic engine: graph model + `GraphUiSnapshot`, evaluator, `RenderThreadDispatcher`, ICC reader, video + live-capture sources, ExprTk math, D3D11 compute runner + `CustomComputeBridgeEffect` + `BytecodeCache`, `IEngineComputeOutput` COM interface, and the whole MCP protocol surface (router, JSON-RPC dispatcher, tool catalog, engine-pure routes, broker crypto). Exported via `SHADERLAB_API`. | +| `ShaderLab.vcxproj` | `ShaderLab.exe` (MSIX) | WinUI 3 packaged app. `RenderEngine` (app-only), all XAML, controllers, the render worker thread, and the UI-coupled MCP routes in `MainWindow.McpRoutes.cpp` + `GuiEngineCommandSink`. Depends on the engine DLL. | +| `ShaderLabTests.vcxproj` | `ShaderLabTests.exe` | Standalone console runner — graph/evaluator/bindings, bytecode cache, snapshot, dispatcher, MCP router + JSON-RPC contracts, broker frame codec / crypto / peer identity, GPU-binding matrices, and the HLSL math bench. No WinUI; CI runs it on `--adapter warp`. | +| `ShaderLabHeadless.vcxproj` | `ShaderLabHeadless.exe` | Console host, no WinUI: image render (PNG or JPEG XR, chosen by output extension), `.effectgraph` ZIP + embedded media, FP32 pixel readback, JSON batch script mode, MCP session mode, bytecode-cache ops. | +| `ShaderLabMcpBroker.vcxproj` | `ShaderLabMcpBroker.exe` | MCP transport. `--hub`: singleton blind relay — first-instance election, per-peer pairing, session registry, channel relay (routes on channelId only; bodies sealed end-to-end). `--stdio`: the client's front-end — owns initialize + `list_sessions`/`use_session`, pins a session, runs the initiator handshake, seals/forwards requests, splices `tools/list`. Does **not** link the engine; compiles the `Mcp*` plumbing TUs directly. Packaged as the manifest's second ``. | -This split (decisions #41 + #58) keeps WinUI out of the test path, lets engine logic be exercised in isolation, and gives MCP agents a fully-functional logged-out host for parameter sweeps. +This split (decisions #41 + #58) keeps WinUI out of the test path, lets engine logic be +exercised in isolation, and gives MCP agents a fully-functional logged-out host for +parameter sweeps. --- -## Threading Model (v1.7.0, decisions #68 + #70) +## Threading Model -All D3D11/D2D graph work runs on a dedicated **render worker `std::jthread`**; the UI thread only blits a double-buffered offscreen into the `SwapChainPanel` swap chain and `Present1`s (presenting from the worker is impossible — XAML composition is STA-bound). The worker per tick: drain `RenderThreadDispatcher` closures → working-space sync → live-capture/clock/video tick → dirty-propagation BFS → `RenderFrameToOffscreen` → publish index + `GraphUiSnapshot`. A version-gated blit keeps the UI thread from vsync-blocking when the worker publishes slower than the UI ticks. +All D3D11/D2D graph work runs on a dedicated **render worker `std::jthread`**; the UI +thread only blits a double-buffered offscreen into the `SwapChainPanel` swap chain and +`Present1`s (presenting from the worker is impossible — XAML composition is STA-bound). +Per worker tick: drain `RenderThreadDispatcher` closures → working-space sync → +live-capture/clock/video tick → dirty-propagation BFS → `RenderFrameToOffscreen` → +publish index + `GraphUiSnapshot`. A version-gated blit keeps the UI thread from +vsync-blocking when the worker publishes slower than the UI ticks. -**Graph access rule** (the full text lives in `Controls/NodeGraphController.h` and `.github/copilot-instructions.md`; getting it wrong is an access violation inside `std::map`, not a compile error): +**Graph access rule** — getting it wrong is an access violation inside `std::map`, not a +compile error. Full text in `Controls/NodeGraphController.h`, `CLAUDE.md`, and +`.github/copilot-instructions.md`: 1. **UI-thread reads → the per-frame `GraphUiSnapshot`**, never live `m_graph`. 2. **Writes (any thread) → `RenderThreadDispatcher::DispatchSync`.** 3. **Layout computation → live graph, render thread only.** -Two locks with a strict order (`m_graphMutex` → `m_visualsMutex`); never hold `m_visualsMutex` across a `DispatchSync`. See [docs/architecture/threading-model.md](../docs/architecture/threading-model.md) for the diagrams and the resource-ownership table. +Two locks with a strict order (`m_graphMutex` → `m_visualsMutex`); never hold +`m_visualsMutex` across a `DispatchSync`. Diagrams and the resource-ownership table: +[docs/architecture/threading-model.md](../docs/architecture/threading-model.md). --- -## Complete Feature Set (v1.7.3) +## Feature Shape ### Core - Node-based DAG graph editor for D2D effect composition. -- 40+ wrapped built-in D2D effects (`Effects/EffectRegistry.cpp`) across 9 categories. -- ShaderLab built-in effect library with embedded HLSL (`Effects/ShaderLabEffects.cpp` + `Effects/ColorMath.cpp`) — current catalog table in [docs/effects/builtin-catalog.md](../docs/effects/builtin-catalog.md). -- Custom pixel shader effects (`ID2D1DrawTransform`). -- Custom D2D compute shader effects (`ID2D1ComputeTransform`, per-tile dispatch). -- Custom **D3D11 compute shader effects** — routed through `CustomComputeBridgeEffect` (D2D wrapper) + `D3D11ComputeRunner`, which implements `IEngineComputeOutput` so downstream compute consumers can bind analysis SRVs directly (Phase 8, shipped in 1.6.0, feature flag default ON). -- **`BytecodeCache`**: compile-once bytecode store with eager GPU-binding-variant precompile and disk persistence under `%LOCALAPPDATA%\ShaderLab\bytecode\`; reaper wired to the status-bar broom button and headless CLI flags. +- Wrapped built-in D2D effects, grouped by category (`Effects/EffectRegistry.cpp`), plus + the ShaderLab effect library with embedded HLSL (`Effects/ShaderLabEffects.cpp` + + `Effects/ColorMath.cpp`). +- Three custom-effect flavours: pixel (`ID2D1DrawTransform`), D2D compute + (`ID2D1ComputeTransform`, per-tile), and **D3D11 compute** — the last routed through + `CustomComputeBridgeEffect` + `D3D11ComputeRunner`, which implements + `IEngineComputeOutput` so downstream compute consumers can bind analysis SRVs directly. +- **`BytecodeCache`**: compile-once store with eager GPU-binding-variant precompile and + disk persistence under `%LOCALAPPDATA%\ShaderLab\bytecode\`; reaper on the status-bar + broom button and headless CLI flags. - Live HLSL hot-reload with `D3DCompile` + `D3DReflect` auto-property discovery. -- Effect Designer modal window for authoring custom pixel / D2D-compute / D3D11-compute effects with full parameter definition. -- Graph JSON serialization with versioning (format version 2) — saved as `.effectgraph` zip files (DEFLATE via miniz) with optional **embedded media**. - -### ShaderLab Built-in Effects (`Effects/ShaderLabEffects.cpp`) - -Grouped by `category` + optional `subcategory` (Add Node flyout sub-grouping): - -- **Analysis → Highlights**: Luminance Heatmap, Nit Map, Gamut Highlight, Luminance Highlight. -- **Analysis → Scopes**: CIE Histogram (D3D11 compute), CIE Chromaticity Plot. (Vectorscope and Waveform Monitor were **removed in 1.6.0** — no clear use case after the compute-scatter migration.) -- **Analysis → Comparison**: Delta E Comparator (CIEDE2000, `OutputMode` Heatmap / Grayscale dE), Split Comparison. -- **Analysis → Gamut Mapping**: Gamut Map (Clip / Nearest / Compress / Fit), ICtCp Gamut Map, Gamut Coverage (single-group D3D11 compute scatter since 1.6.0). -- **Analysis → Tone Mapping (ICtCp suite)**: ICtCp Round-Trip Validator, ICtCp Tone Map (HDR → SDR; D3D11 compute since 1.6.0, `SourcePeakNits`/`TargetPeakNits` gpuBindable), ICtCp Inverse Tone Map (SDR → HDR), ICtCp Saturation, ICtCp Highlight Desaturation. Bind their numeric peak/SDR-white parameters to the `Working Space` node's analysis outputs to track Display Settings or simulated profiles automatically. -- **Analysis → Statistics** (D3D11 compute, data-only): Channel Statistics, Luminance Statistics, Chromaticity Statistics. Stats are not architecturally special (decision #63): agents use `/graph/add-node` + `/analysis/`. -- **Source / Generators**: Gamut Source, ICtCp Boundary, Color Checker, Zone Plate, Gradient Generator, HDR Test Pattern. -- **Live capture sources**: DXGI Desktop Duplication (per-output enumerated), Windows Graphics Capture (WinUI picker). Per-frame ticking via `SourceNodeFactory::TickAndUploadLiveCaptures` on the render worker. -- **Data / Parameter nodes** (no shader, evaluator-handled): Float, Integer, Toggle, Gamut, Clock, Numeric Expression (ExprTk, A..Z inputs), Random (deterministic seed → [0,1) hash), **Working Space** (mirrors active display profile into 14 typed analysis fields). - -Every effect carries a stable `effectId` + numeric `effectVersion`; saved graphs detect upgrades and offer per-node / batch upgrade in the Properties panel. - -### Property System -- `PropertyValue` variant: `float`, `int32`, `uint32`, `bool`, `wstring`, `float2`, `float3`, `float4`, `D2D1_MATRIX_5X4_F`, `vector`. -- Per-component property bindings (Grasshopper-style data flow), with array (whole-vector) bindings for LUT-shaped fields. `gpuBindable` parameters + `gpuPublish` analysis fields route upstream compute SRVs directly to D3D11 compute consumers (CPU readback skipped when no CPU consumer needs the value). -- Enum labels for named dropdown parameters; `bool` rendered as `ToggleSwitch`. -- `visibleWhen` conditional visibility on parameters (`"Mode == 1"`, `"Strength > 0"`, etc.) — including conditionally-visible **input pins**, extended on the canvas after MCP or Properties-panel changes (1.7.3 fix). -- Visual data pins (orange diamonds) on the node graph for binding connections. +- Effect Designer modal for authoring custom effects of all three shader types. +- Graph serialization as `.effectgraph` ZIP (DEFLATE via miniz) with optional embedded + media; `media://` tokens rewritten to extracted paths on load. + +### Effect categories +Grouped by `category` + optional `subcategory`, which drives the Add Node flyout. +Members change; the structure doesn't — full table in +[builtin-catalog.md](../docs/effects/builtin-catalog.md). + +- **Analysis → Highlights** — false-colour / heatmap views of where the energy is. +- **Analysis → Scopes** — CIE histogram and chromaticity plot. +- **Analysis → Comparison** — Delta E Comparator (Heatmap / Grayscale dE; prefer the + **ΔE ITP** method for HDR/WCG), Split Comparison. +- **Analysis → Gamut Mapping** — Gamut Map, ICtCp Gamut Map, Gamut Coverage. +- **Analysis → Tone Mapping (the ICtCp suite)** — forward/inverse tone map, saturation, + highlight desaturation, round-trip validator. Bind their nit parameters to the + `Working Space` node to track Display Settings or a simulated profile automatically. +- **Analysis → Statistics** (compute, data-only) — channel / luminance / chromaticity. + Not architecturally special (decision #63): agents use `/graph/add-node` + `/analysis/`. +- **Source / Generators** — synthetic patterns and gamut sources. +- **Live capture sources** — DXGI Desktop Duplication (per-output) and Windows Graphics + Capture; ticked per frame by `SourceNodeFactory::TickAndUploadLiveCaptures`. +- **Data / Parameter nodes** (no shader, evaluator-handled) — Float / Integer / Toggle / + Gamut, Clock, Numeric Expression (ExprTk), Random, and **Working Space**. + +Every effect carries a stable `effectId` + numeric `effectVersion`; saved graphs detect +upgrades and offer per-node / batch upgrade in the Properties panel. + +### Property system +- `PropertyValue` variant: `float`, `int32`, `uint32`, `bool`, `wstring`, `float2/3/4`, + `D2D1_MATRIX_5X4_F`, `vector`. +- Per-component property bindings (Grasshopper-style data flow), plus whole-array + bindings for LUT-shaped fields. `gpuBindable` parameters + `gpuPublish` analysis + fields route upstream compute SRVs straight to D3D11 compute consumers, skipping CPU + readback when no CPU consumer needs the value. +- Enum labels render as dropdowns; `bool` as a `ToggleSwitch`. +- `visibleWhen` conditional visibility on parameters *and* input pins. +- Visual data pins (orange diamonds) on the canvas for binding connections. ### Rendering -- **Always scRGB FP16 pipeline** (`DXGI_FORMAT_R16G16B16A16_FLOAT`, `DXGI_COLOR_SPACE_RGB_FULL_G10_NONE_P709`). DWM/ACM handles final display conversion. -- **Render worker thread** (see Threading Model above); UI-thread Present cost is a sub-ms FP16 blit. -- **Refresh-rate-driven loop** (60–240 Hz) — interval re-derived from `EnumDisplaySettings(dmDisplayFrequency)` on every display change. -- Dirty-gated evaluation with dirty-propagation pre-pass; no built-in tone-mapping pass — users build tone mappers as graph effects (the ICtCp suite is the preferred path). -- Display profile mocking (presets + ICC file loading via `mscms.dll`); monitor gamut from `DXGI_OUTPUT_DESC1` primaries; **OS-reported SDR white level** via `DisplayConfigGetDeviceInfo`, exposed to graphs as `working_space.SdrWhiteNits`. -- **DXVA2 / Media Foundation video sources** with `ID3D10Multithread` protection. -- `OutputWindow` system: each `Output` node gets its own OS window (cross-thread `OutputSinkRenderState`, worker renders native-size, UI fits + presents). Bidirectional sync (close window ↔ delete node); `OnNodeAdded` auto-spawns windows for MCP/file-load Output nodes. -- D2D-rendered node graph canvas with pan/zoom, bezier edges (Alt+click delete), color-coded nodes, dot grid, dark theme; canvas paints from the `GraphUiSnapshot`. - -### MCP Server (stdio via the broker; HTTP deleted in migration Step 9) -JSON-RPC 2.0 (protocol `2025-06-18`, batching rejected) over the broker — **no HTTP**. `Engine/Mcp/McpRouter.{h,cpp}` is now a pure route registry (`AddRoute`/`RouteRequest`/`HasRoute`, query split, `ActivityCallback` fired on top-level `POST /`); transport-neutral types in `McpTypes.h` (`Mcp::Response` + `noReply`); the JSON-RPC dispatcher + declarative 39-tool `McpToolCatalog` in `McpJsonRpc.{h,cpp}`. Enable/disable per window via the toolbar toggle, `--mcp` flag, or `config.json`; the toggle registers this window as a hub **session**. - -- **Transport = broker** (`ShaderLabMcpBroker`): a client's unpackaged **shim** (`--stdio`, distributed to `%LOCALAPPDATA%\ShaderLab\bin\`, update-immune) activates the packaged **hub** (`--hub`, blind relay routing on `{channelId, seq}`; shim↔session bodies sealed P-256/HKDF/AES-GCM), lists **sessions** (GUID-identified per window), `use_session ` to pin one. `ShaderLabHeadless --mcp-session` registers a headless session. -- **25 engine-pure routes** (`EngineMcpRoutes.cpp`) via `IEngineCommandSink` — `/graph/apply`, `/effects`, `/graph/overview`, `/display/info`, etc. In the GUI, `GuiEngineCommandSink::Dispatch` marshals to the **render thread** with `ctx.dc = RenderD2DContext()`, then fires the 8 event hooks so MCP mutations look native. **16 app-side routes** (`MainWindow.McpRoutes.cpp`): UI-coupled + `/context`/`/perf`/`/node//logs`. Tools whose backing route is absent on a host return isError "Tool not available" via `HasSpecificRoute`. -- **39 tools** in `tools/list` (see [docs/hosts/mcp-server.md](../docs/hosts/mcp-server.md)). Pairing: strict PFN for sessions, role-relaxed for the shim; `DefaultPipeBaseName()` is the shared meeting point. Full design: [docs/development/mcp-stdio-migration.md](../docs/development/mcp-stdio-migration.md). - -The **Working Space** parameter node — a strict sink with no input pins — mirrors the active display profile (live or simulated preset/ICC) into 14 typed analysis output fields. Bind any downstream property to drive an effect from the live working space. Updated by `Rendering::UpdateWorkingSpaceNodes` on the render worker. - -### Effect Designer -- Three shader types: pixel (`ps_5_0`), D2D compute (`cs_5_0`), **D3D11 compute** (`cs_5_0`, host-dispatched). -- Parameter types: float, float2, float3, float4, int, uint, bool, enum; analysis output fields with typed declarations. -- HLSL auto-formatting and scaffold generation per shader type (D3D11 scaffold injects auto `Width`/`Height` cbuffer + stride-reduction template). -- "Edit in Effect Designer" opens any built-in effect for inspection / fork; Add to Graph / Update in Graph buttons. -- Talks to `MainWindow` only through two `std::function` callbacks — no back-pointer. - -### Versioning -- `Version.h`: App **1.7.3**, Graph format version **2**, plus `LibraryVersion()` (sum of all effect versions). -- `EngineExport.h::SHADERLAB_ENGINE_ABI_VERSION` = **1** (independent of app version; mismatch between header and DLL aborts startup with a friendly message box). -- Saved graphs include `formatVersion` + `appVersion`; loading newer-format graphs shows an error dialog. Per-effect `effectId`/`effectVersion` round-trip and surface upgrade prompts. - -### UI / UX -- Segoe Fluent Icons toolbar with tooltips; status bar shows pipeline / display / FPS; title bar shows app version + library version. -- `.effectgraph` file-type association + Ctrl+S accelerators + unsaved-changes guard + async save/load with progress dialog. -- Status-bar broom button runs both reapers (orphan graph media + bytecode-cache drift) and reports freed bytes. -- Auto-arrange resets viewport; new nodes spawn at the center of the current viewport. +- **Always scRGB FP16** (`DXGI_FORMAT_R16G16B16A16_FLOAT`, + `DXGI_COLOR_SPACE_RGB_FULL_G10_NONE_P709`). No format switching; DWM/ACM handles the + final display conversion. +- **No built-in tone-mapping pass** — users compose tone mappers as graph effects, with + the ICtCp suite as the preferred path. +- Refresh-rate-driven loop, re-derived from `EnumDisplaySettings` on every display + change. Dirty-gated evaluation with a dirty-propagation pre-pass. +- Display profile mocking (presets + ICC via `mscms.dll`); monitor gamut from + `DXGI_OUTPUT_DESC1` primaries; OS-reported SDR white level via + `DisplayConfigGetDeviceInfo`, exposed to graphs as `working_space.SdrWhiteNits`. +- DXVA2 / Media Foundation video sources with `ID3D10Multithread` protection. +- `OutputWindow`: each `Output` node gets its own OS window, worker renders native-size, + UI fits + presents. Bidirectional sync (close window ↔ delete node). +- D2D-rendered canvas with pan/zoom, bezier edges, colour-coded nodes; paints from the + `GraphUiSnapshot`. + +### MCP +JSON-RPC 2.0 over **stdio via the broker** — there is no HTTP listener (deleted in +stdio-migration Step 9). Enable per window via the toolbar toggle, `--mcp`, or +`config.json`; the toggle registers that window as a hub **session**. + +Transport is **shim → hub → session**: a client's unpackaged shim +(`ShaderLabMcpBroker --stdio`, copied to `%LOCALAPPDATA%\ShaderLab\bin\` by +rename-then-write so it survives app updates) activates the singleton packaged hub, +which relays blindly on a clear `{channelId, seq}` header while shim↔session bodies stay +sealed (ephemeral P-256 ECDH → HKDF → AES-256-GCM). Sessions are GUID-identified, so +`use_session` pins a stable graph across hub restarts. + +Engine-pure routes live in `EngineMcpRoutes.cpp` and reach the host through +`IEngineCommandSink`; the GUI's sink marshals to the render worker and then fires event +hooks so MCP mutations look native. UI-coupled routes stay in `MainWindow.McpRoutes.cpp`; +a tool whose backing route is absent on the answering host returns an `isError` +"Tool not available". Design: [mcp-server.md](../docs/hosts/mcp-server.md) and +[mcp-stdio-migration.md](../docs/development/mcp-stdio-migration.md). + +The **Working Space** node — a strict sink with no input pins — mirrors the active +display profile (live or simulated) into typed analysis output fields, so any downstream +property can be driven from the real working space. Updated by +`Rendering::UpdateWorkingSpaceNodes` on the render worker. + +### Judging HDR output +An agent's vision input is 8-bit SDR, so a captured PNG of an HDR frame has already +clipped everything above scRGB 1.0 and lost wide-gamut negatives — judging a tone mapper +from a tone-mapped screenshot is circular. Prefer numbers (FP32 readback, analysis +fields), then measured difference (Delta E Comparator with **ΔE ITP**), then diagnostic +renders that encode HDR facts into SDR-visible form. Full rule in `CLAUDE.md` +§*Looking at HDR output*. --- ## D2D Custom Effect Gotchas (Hard-Won Knowledge) -These are critical lessons learned during development. Any AI agent or developer working on custom D2D effects **must** be aware of these: - -1. **Typed cbuffer pack (Phase 3+)**: `uint`, `int`, and `bool` cbuffer slots in HLSL are now packed correctly even when the corresponding `PropertyValue` is stored as `float` (the default for enum-style parameters). The `Effects::PackPropertyToCBuffer` helper reflects each cbuffer variable's `D3D_SHADER_VARIABLE_TYPE` and converts via `static_cast` / `` / `BOOL` before writing. So you *can* declare `uint Mode` in HLSL and use clean `if (Mode == 1)` comparisons. **Pre-Phase-3 historical convention** (still works, used by all existing ShaderLab effects): declare enums as `float` in HLSL with `> 0.5` / `> 1.5` threshold comparisons. -2. **HLSL compiler optimizes out cbuffer variables** not referenced on ALL code paths when `D3DCOMPILE_WARNINGS_ARE_ERRORS` is set. Read all cbuffer vars at top of `main()` before any branches. -3. **D2D custom effects need TWO evaluation passes** for newly created effects — first creates/initializes, second produces correct output. The evaluator handles this with `m_justCreated` deferring analysis readback by one frame. -4. **`RegisterWithInputCount` requires `inputCount >= 1`**. Zero-input source effects use a hidden dummy 1×1 bitmap input. -5. **`MapInputRectsToOutputRect` with `SetFixedOutputSize`** must check fixed size FIRST, before input rect. -6. **D2D `TEXCOORD` values are in pixel/scene space**, NOT normalized [0,1]. Use `GetDimensions()` and divide, or call `Source.Load(int3(uv, 0))` directly. -7. **D2D custom effect transforms must NOT pass through infinite input rects** in `MapInputRectsToOutputRect`. Store the requested output rect from `MapOutputRectToInputRects` and return it. -8. **`ForceUploadConstantBuffer()` uploads cbuffer but doesn't invalidate cached output**. Need input toggle trick (disconnect+reconnect dummy input) to force re-evaluation. -9. **Variable-input D2D custom effects** (``) require BOTH `ID2D1Effect::SetInputCount(N)` (external) AND updating the transform node's internal count. Without the external call, `SetInput()` fails with `E_INVALIDARG`. -10. **Monitor gamut from `DXGI_OUTPUT_DESC1` primaries** (`RedPrimary`, `GreenPrimary`, `BluePrimary`, `WhitePoint`). Always write primaries into the cbuffer on every evaluate (correct on first frame), only mark dirty on actual change (prevents feedback loops). -11. **D2D → D3D11 texture handoff requires `dc->Flush()`** between `DrawImage` and any D3D11 read of the underlying texture. D2D batches commands until `EndDraw()` or `Flush()` — without an explicit flush, D3D11 reads zeros. Applied in `DispatchUserD3D11Compute`. -12. **`ProcessDeferredCompute` requires an active D2D draw session** (decision #63). It calls `dc->DrawImage` internally to pre-render the upstream chain into an FP32 bitmap, and outside `BeginDraw`/`EndDraw` that DrawImage silently no-ops — the compute reads black input and emits Min/Max/Mean = 0. The GUI's render path, the headless host's `runEval` / `RunRender`, and the test bench all wrap accordingly. -13. **D3D11 compute output → D2D bitmap interop**: `CreateBitmapFromDxgiSurface` must set `bp.dpiX/dpiY = 96.0f`. Default 0 DPI causes `GetImageLocalBounds` to return zero-size bounds. -14. **D3D11 multithread protection** (`ID3D10Multithread::SetMultithreadProtected(TRUE)`) must be enabled when using DXVA2 video decode on background threads with `Lock2D` on GPU buffers. -15. **D3D11 compute cbuffers**: when HLSL declares `uint`/`int`/`bool` but the property is stored as `float`, the pack code must reflect the declared `D3D_SHADER_VARIABLE_TYPE` and `static_cast` to the right type before writing — raw `memcpy` of a float bit-pattern produces nonsense ints/uints. +Stable, expensive to rediscover, and mirrored in `.github/copilot-instructions.md` — +change one, check the other. Anyone touching custom D2D effects must know these: + +1. **Typed cbuffer pack**: `uint` / `int` / `bool` cbuffer slots are packed correctly + even when the `PropertyValue` is stored as `float` (the default for enum-style + parameters) — `Effects::PackPropertyToCBuffer` reflects each variable's + `D3D_SHADER_VARIABLE_TYPE` and converts before writing. So `uint Mode` with clean + `if (Mode == 1)` works. The older convention (declare enums as `float`, compare with + `> 0.5` / `> 1.5`) still works and is what most existing effects use. +2. **The HLSL compiler optimizes out cbuffer variables** not referenced on *all* code + paths under `D3DCOMPILE_WARNINGS_ARE_ERRORS`. Read every cbuffer var at the top of + `main()` before branching. +3. **New D2D custom effects need TWO evaluation passes** — the first creates and + initializes, the second produces correct output. The evaluator handles this with + `m_justCreated` deferring analysis readback by one frame. +4. **`RegisterWithInputCount` requires `inputCount >= 1`.** Zero-input source effects + use a hidden dummy 1×1 bitmap input. +5. **`MapInputRectsToOutputRect` with `SetFixedOutputSize`** must check the fixed size + FIRST, before the input rect. +6. **D2D `TEXCOORD` is pixel/scene space**, not normalized [0,1]. Use `GetDimensions()` + and divide, or `Source.Load(int3(uv, 0))` directly. +7. **Transforms must NOT pass through infinite input rects** in + `MapInputRectsToOutputRect` — store the requested output rect from + `MapOutputRectToInputRects` and return that. +8. **`ForceUploadConstantBuffer()` uploads the cbuffer but does not invalidate cached + output.** Forcing re-evaluation needs the input-toggle trick. +9. **Variable-input custom effects** (``) need BOTH + `ID2D1Effect::SetInputCount(N)` externally AND the transform node's internal count + updated. Without the external call `SetInput()` fails `E_INVALIDARG`. +10. **Monitor gamut comes from `DXGI_OUTPUT_DESC1` primaries.** Write them into the + cbuffer on every evaluate (so frame one is right), but only mark dirty on actual + change (or property writes and evaluation feed back into each other). +11. **D2D → D3D11 texture handoff requires `dc->Flush()`** between `DrawImage` and any + D3D11 read. D2D batches until `EndDraw()` or `Flush()`; without it D3D11 reads zeros. +12. **`ProcessDeferredCompute` requires an active D2D draw session** (decision #63). It + calls `dc->DrawImage` internally; outside `BeginDraw`/`EndDraw` that silently no-ops + and the compute reads black, emitting Min/Max/Mean = 0 with no error anywhere. +13. **D3D11 compute output → D2D bitmap interop** must set `bp.dpiX/dpiY = 96.0f`. + Default 0 DPI makes `GetImageLocalBounds` return zero-size bounds. +14. **`ID3D10Multithread::SetMultithreadProtected(TRUE)`** is required when DXVA2 video + decode runs on background threads with `Lock2D` on GPU buffers. +15. **scRGB is signed on purpose.** Negative Rec.709 components are how wide-gamut colour + is expressed. A `max(rgb, 0)` or `saturate()` at the top of a colour transform is a + gamut clip, not a safety net — this silently sRGB-clipped the entire ICtCp suite + once. Use the signed PQ helpers in `Effects/ColorMath.cpp`. --- ## Build / Deploy / Launch -### Prerequisites -- Visual Studio 2022 17.8+ **or** VS 2026 (C++ Desktop + UWP workloads). -- Windows App SDK 1.8; Windows 10 SDK 10.0.26100+; PowerShell 5.1+. -- Git — `exprtk` + `miniz` are **submodules** (decision #69); clone with `--recurse-submodules` or run `git submodule update --init --recursive`. A clone without them fails fast via the `VerifySubmodules` MSBuild target. `third_party/miniz_export.h` is an in-tree shim, not part of the submodule. - -### Build -```pwsh -# Via Visual Studio: open ShaderLab.slnx → Build (Debug | x64 or Debug | ARM64) - -# Via MSBuild (x64 host) -msbuild ShaderLab.slnx /p:Configuration=Debug /p:Platform=x64 - -# On an ARM64 host you MUST use the ARM64-native MSBuild — see the -# "Building ARM64 on an ARM64 host" section of docs/development/build.md -# for why the default 32-bit MSBuild fails with PCH out-of-memory errors. -``` - -`scripts\EnsureDevCert.ps1` runs automatically on first build (local `CN=ShaderLab` F5 cert). NuGet restores automatically (packages.config style). - -### Deploy (local F5) -```pwsh -Add-AppxPackage -Register "\\ShaderLab\AppxManifest.xml" -``` -**Never deploy from `AppX\`** — it accumulates stale artifacts that cause XAML 0xc000027b crashes. Close running instances before redeploying. - -### Verification -```pwsh -\\ShaderLabTests\ShaderLabTests.exe --adapter warp # 261 unit tests -pwsh -NoProfile -File .\Tests\RunBrokerSmoke.ps1 -Platform ARM64 # broker smoke 26/26 -# MCP suite is shim-driven (no HTTP). Against a running GUI (shim activates the hub): -pwsh -NoProfile -File .\Tests\RunTests.ps1 -HubAumid 'ShaderLab_9v3yd384n9j18!Hub' -# or against a headless session (what CI does; GUI-only tests self-skip): -# $env:SHADERLAB_MCP_ALLOW_UNPACKAGED='1' -# ShaderLabMcpBroker.exe --hub --pipe P ; ShaderLabHeadless.exe --graph fixture --mcp-session --pipe P --adapter warp -# pwsh -NoProfile -File .\Tests\RunTests.ps1 -Pipe P -.\Tests\RunHeadlessSmoke.ps1 -Configuration Debug -Platform x64 # headless smoke -``` - -### CI / Releases -`.github/workflows/ci.yml`: `build-and-test` (Debug+Release x64, WARP unit tests) + `clean-clone-smoke` (checks out **without** submodules, runs the documented submodule-init command explicitly, builds, tests, headless smoke). `release.yml` runs an x64 + ARM64 matrix and injects the unsigned-namespace OID into the manifest just before MSBuild; end-user `Install.ps1` installs dependency MSIXes then ShaderLab. +Commands and the platform traps live in [docs/development/build.md](../docs/development/build.md), +`CLAUDE.md`, and the `.claude/skills/shaderlab-build` + `shaderlab-run` skills. The +shape: + +- **Prerequisites** — Visual Studio 2022 17.8+ or VS 2026 (C++ Desktop + UWP workloads), + Windows App SDK, a recent Windows SDK, PowerShell. +- **Submodules** — `exprtk` + `miniz` (decision #69). Clone with `--recurse-submodules` + or run `git submodule update --init --recursive`; a clone without them fails fast via + the `VerifySubmodules` MSBuild target. `third_party/miniz_export.h` is an in-tree shim, + not part of the submodule. +- **ARM64 hosts need the ARM64-native MSBuild.** The default is 32-bit and picks a + toolset that dies with misleading PCH out-of-memory errors. CI cross-compiles ARM64 + from an x64 runner, so only the `native-arm64` job covers this. +- **Deploy from the layout root, never `AppX\`** — `AppX\` accumulates stale artifacts + and produces XAML `0xc000027b` crashes. Close running instances first; the GUI locks + the engine DLL, and a lingering broker hub locks the broker copy. +- **Launch by shell activation**, not the exe directly — a packaged app started + directly aborts in CRT dependency resolution. +- **Verification** — unit runner (WARP), headless smoke, broker smoke, and the + shim-driven MCP suite. Each prints its own totals. --- ## Project Structure -The annotated per-file tree lives in [docs/development/project-structure.md](../docs/development/project-structure.md) — maintained there, not here. Orientation summary: +The annotated per-file tree is maintained in +[docs/development/project-structure.md](../docs/development/project-structure.md), not +here. Orientation only: ``` -ShaderLab\ 4 vcxproj at repo root; MainWindow.xaml.cpp (~5000 lines) + sibling -│ partial TUs (WorkingSpace / GraphFileIo / RenderTick / McpRoutes) -├── Engine\Mcp\ McpRouter + McpTypes + McpJsonRpc + McpToolCatalog + McpTimeouts + 25 engine routes; -│ broker plumbing: McpFrame + McpCrypto + McpPeerIdentity + McpChannel; -│ McpSessionClient (registers a session with the hub; used by headless + GUI) +ShaderLab\ vcxproj files at repo root; MainWindow.xaml.cpp + sibling partial +│ TUs (WorkingSpace / GraphFileIo / RenderTick / McpRoutes) +├── Engine\Mcp\ Router + types + JSON-RPC + tool catalog + timeouts + engine routes; +│ broker plumbing (frame codec, crypto, peer identity, channel); +│ McpSessionClient (registers a session with the hub; headless + GUI) ├── ShaderLabMcpBroker\ hub relay + stdio shim (Main.cpp); compiles the Mcp* plumbing directly ├── Graph\ EffectGraph DAG + GraphUiSnapshot (immutable per-frame UI copy) ├── Rendering\ Evaluator, RenderThreadDispatcher, display/ICC, readback, .effectgraph zip @@ -206,23 +280,38 @@ ShaderLab\ 4 vcxproj at repo root; MainWindow.xaml.cpp (~5000 lines) --- -## Active Development Focus - -**MCP transport migration: HTTP → stdio + broker — COMPLETE** (branch `user/daspr/mcp_migration_httptostdio`; decision #71, supersedes #31/#58; engine ABI **3**). Full plan + the end-of-migration manual sweep: [docs/development/mcp-stdio-migration.md](../docs/development/mcp-stdio-migration.md). +## Product Thesis -The embedded Winsock HTTP listener is deleted. The transport is now: a client's unpackaged **shim** (`ShaderLabMcpBroker --stdio`, copied to `%LOCALAPPDATA%\ShaderLab\bin\` via rename-then-write, so it survives ShaderLab updates) activates a singleton packaged **hub** (`--hub`, a blind relay routing on a clear `{channelId, seq}` frame header; shim↔session bodies sealed with ephemeral P-256 ECDH → HKDF → AES-256-GCM) which relays to per-window **sessions** (`McpSessionClient`, GUID-identified, so `use_session` pins a stable graph across hub restarts). This fixed the three original defects: multiple windows are now addressable, the unauthenticated loopback port is gone, and clients get the stdio transport they expect. The engine keeps the pure routing surface (`McpRouter::{AddRoute,RouteRequest,HasRoute}` + the `McpJsonRpc` dispatcher + the 39-tool `McpToolCatalog`); GUI tool calls still marshal to the render worker via `GuiEngineCommandSink::Dispatch` and fire the 8 event hooks. Along the way: `RenderThreadDispatcher` fails pending `DispatchSync` promises fast on shutdown/reset (no 30 s stall), the timeout ladder lives in `Engine/Mcp/McpTimeouts.h`, `SwitchAdapter` gates the sink to 503, pairing is role-aware (strict PFN for sessions, relaxed for the shim), and the dead pre-worker tick path was removed. Verified green: 261 unit tests, broker smoke 26/26, headless smoke, and the shim-driven `RunTests.ps1` at 40/40 (GUI) / 21/21 (headless) on WARP; grep for `47808`/`WSA` is clean. **Remaining before full sign-off:** the manual verification sweep (WinUI window lifecycle, packaged install/activation, a real MCP client, in-place upgrade) at the end of the migration doc. +I (intensity) is decoupled from Ct/Cp in ICtCp, so manipulating I alone preserves hue +and saturation *by construction*. That is why the tone-mapping work lives in ICtCp +rather than linear RGB or xyY, where the same operations turn into hue shifts and gamut +excursions. -Recently shipped context: **1.6.0** was the Phase 8 GPU-binding release (SRV routing between compute effects, bytecode cache + disk persistence, `CustomComputeBridgeEffect`, ICtCp Tone Map on compute, Vectorscope/Waveform removed); **1.7.0** was the render-worker-thread release (decision #68); **1.7.1–1.7.3** were targeted fixes (deferred-compute regression, Win2D removal, clock-controls-on-load + `visibleWhen` pin extension). +Around it sits the **empirical fidelity loop**: `Working Space` (the real display +profile) + `Delta E Comparator` in Grayscale dE mode + `Luminance Statistics`, giving a +live measured colour-difference readout while a parameter sweeps. Effects get tuned +against measured difference, not visual impression — and for HDR/WCG that measurement +must use **ΔE ITP**, since the CIE Lab metrics leave their fitted domain above roughly +100 nits. -The product thesis carries through: I (intensity) is decoupled from Ct/Cp in ICtCp, so manipulating I alone preserves hue and saturation by construction; the empirical fidelity loop (`Working Space` + `Delta E Comparator` Grayscale dE + `Luminance Statistics` live readout) tunes effect parameters against measured CIEDE2000 rather than visual impression. The MCP work is what lets agents drive that loop reliably across multiple sessions. +The MCP work exists to let agents drive that loop reliably across multiple windows. --- ## Potential Future Work -- **More tone-mapping operators** in the ICtCp subcategory (BT.2390, hue-preserving ACES, adaptive). -- **Auto-bind affordances** so SDR-white / monitor-peak hidden defaults can be wired from any matching upstream output without manual binding. -- **Effect Designer export** — emit standalone C++ header / module files for D3D11 compute effects. -- **External binary import** — load pre-compiled D2D effect DLLs (`ID2D1EffectImpl`) and `.cso` compute binaries directly into the graph. -- **Multi-dispatch GPU reduction pyramid** for images > ~33 MP (current `D3D11ComputeRunner` dispatches a single 1024-thread group). -- **Hide `Prim*` data pins from OOG-style nodes** — host-managed hidden properties should never surface as connectable orange diamonds. +- **More tone-mapping operators** in the ICtCp subcategory (BT.2390, hue-preserving + ACES, adaptive). +- **Auto-bind affordances** so SDR-white / monitor-peak hidden defaults can be wired + from any matching upstream output without manual binding. +- **Effect Designer export** — emit standalone C++ header / module files for D3D11 + compute effects. +- **External binary import** — load pre-compiled D2D effect DLLs (`ID2D1EffectImpl`) and + `.cso` compute binaries directly into the graph. +- **Multi-dispatch GPU reduction pyramid** for images beyond what a single thread group + can reduce. +- **Hide `Prim*` data pins from OOG-style nodes** — host-managed hidden properties should + never surface as connectable orange diamonds. +- **Content-adaptive screenshot tone mapping** — drive the knee from a statistics pass + (fraction of frame above SDR white, percentile content peak) plus a bypass fast path + when nothing exceeds it, so pure-SDR captures stay bit-exact. diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index bc352ca..bb3016c 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,5 +1,11 @@ # Copilot Instructions +> **Companion file:** [`CLAUDE.md`](../CLAUDE.md) at the repo root is the equivalent +> for Claude Code, and carries the machine-specific build / deploy / MCP procedures +> that live nowhere else (further detail in `.claude/skills/`). The two files overlap +> deliberately on the rules that cause crashes or build failures — the graph-access +> threading rule and the D2D effect gotchas. **Change one, check the other.** + ## Project Identity ShaderLab is a WinUI 3 desktop application (C++/WinRT) for developing, testing, and debugging Direct2D shader effects with full HDR/WCG support. The primary focus is building tone-mapping and color-correction effects as graph nodes, with empirical fidelity tooling — Delta E Comparator + Luminance Statistics + Working Space node form a closed-loop CIEDE2000 readout that lets us tune effect parameters against measured color accuracy, not visual impression. @@ -13,10 +19,17 @@ ShaderLab is a WinUI 3 desktop application (C++/WinRT) for developing, testing, ## Build - Clone with `--recurse-submodules` (or run `git submodule update --init --recursive`). `exprtk` and `miniz` are git submodules under `third_party/`, pinned to explicit commits; `third_party/miniz_export.h` is an in-tree shim, not part of the submodule. See [build.md](../docs/development/build.md). -- Open `ShaderLab.slnx` in Visual Studio 2022 17.8+ +- Open `ShaderLab.slnx` in Visual Studio 2022 17.8+ (VS 2026 / v18 also supported) - NuGet packages restore automatically (packages.config style, not PackageReference) -- Build target: **Debug | x64** (also supports ARM64, Release) -- No command-line build scripts exist; use MSBuild via VS or `msbuild ShaderLab.vcxproj /p:Configuration=Debug /p:Platform=x64` +- Configurations: **Debug | x64** and **Debug | ARM64** (plus Release for both). + CI builds x64 on `windows-latest` and ARM64 natively on `windows-11-arm`. +- No wrapper build scripts exist; use MSBuild via VS or + `msbuild ShaderLab.slnx /p:Configuration=Debug /p:Platform=x64` +- **Building ARM64 on an ARM64 host** needs the `arm64\MSBuild.exe` binary and the + `Microsoft.VisualStudio.Component.UWP.VC.ARM64` component — the default MSBuild is + 32-bit and fails with misleading `C3859`/`C1076` PCH errors. Full explanation in + [build.md](../docs/development/build.md); the commands are in + [`CLAUDE.md`](../CLAUDE.md) and `.claude/skills/shaderlab-build/`. - Required: Windows App SDK 1.8, Windows 10 SDK 10.0.26100+ - Linked native libs: `d3d11.lib`, `d2d1.lib`, `dxgi.lib`, `d3dcompiler.lib`, `dxguid.lib`, `windowscodecs.lib` - `/bigobj` is enabled; language standard is C++20 (VS 18+) or C++17 (VS 17) @@ -46,7 +59,7 @@ ShaderLabEngine.dll (host-agnostic) │ ├── IccProfileParser — mscms.dll-based ICC reader │ └── MathExpression — ExprTk-backed expression evaluator (Numeric Expression node) ├── Effects/ - │ ├── ShaderLabEffects — 35 ShaderLab effects (analysis/source/tone-map/parameter) with embedded HLSL + │ ├── ShaderLabEffects — 36 ShaderLab effects (analysis/source/tone-map/parameter) with embedded HLSL │ ├── ColorMath.cpp — Shared HLSL color math library (BT.709/BT.2020/P3, PQ/HLG, ICtCp) │ ├── EffectRegistry — 40+ wrapped D2D effects across 9 categories │ ├── ShaderCompiler — D3DCompile + D3DReflect + ID3DInclude resolver for shaderlab_params.hlsli @@ -162,6 +175,32 @@ When creating new D2D effects (the core purpose of this tool): 5. Register effects in `Effects::RegisterEngineD2DEffects()` at engine init (called from app + headless + tests) 6. Follow `CustomPixelShaderEffect` / `CustomComputeShaderEffect` as templates +## Looking at HDR Output (an AI agent cannot, directly) + +An agent's vision input is 8-bit SDR. A captured PNG of an HDR frame has already +clipped everything above scRGB 1.0 (80 nits) and discarded the negative components +that carry wide-gamut chroma. *Seeing* an HDR image requires tone mapping it — which +in this project is usually the thing under test, so judging a tone mapper from a +tone-mapped screenshot is circular. In order of preference: + +1. **Numbers first** — `read_pixel_region` / headless `--pixels` (FP32, unclipped) and + `read_analysis_output` on a Statistics node. Ground truth. +2. **Measured difference** — `Delta E Comparator` with `Method = dE ITP (BT.2124)` → + `Luminance Statistics` → Mean / p95 / Max. Use ITP rather than the CIE Lab metrics + for anything HDR or wide-gamut; Lab leaves its fitted domain above ~100 nits + (measured on a 1100 vs 1000 nit step: ITP 7.47, CIEDE2000 118.09, CIE76 253.26). +3. **Diagnostic renders when you need to look** — these encode HDR facts into an + SDR-visible image, so capturing them is legitimate: `Nit Map`, `Luminance Heatmap`, + `Gamut Highlight`, `CIE Chromaticity Plot`, `ICtCp Boundary`, and + `Delta E Comparator` in Heatmap mode. +4. **A raw capture of HDR content** — composition and gross sanity only. + +**State which path was used, and flag when a verdict passes through a tone map or is a +taste call rather than a measurement.** `render_capture` / `render_capture_node` clip +to SDR and their MCP tool descriptions say so. Headless `--output foo.jxr` writes a +lossless full-range artifact, but it still cannot be viewed — it is for archiving, +golden-image comparison, and round-tripping back in as an Image source. + ## Tone Mapping & Color Correction (Primary Development Focus) Active development centers on **tone-mapping and color-correction effects authored as graph nodes** — not a built-in tone-mapping pass. The render pipeline is intentionally pass-through (scRGB FP16 in, scRGB FP16 out); users compose tone mappers and color correction from graph effects, validate them with empirical fidelity tooling, and iterate. @@ -179,7 +218,7 @@ Active development centers on **tone-mapping and color-correction effects author - **Display monitoring**: Event-driven — `DisplayInformation` bound to the main window (`IDisplayInformationStaticsInterop::GetForWindow`) raises `AdvancedColorInfoChanged` for HDR toggles, the SDR-brightness slider, and monitor moves; one `AdvancedColorInfo` snapshot feeds all of `DisplayCapabilities`. Requires Win11 22H2 (10.0.22621 min OS). Headless snapshots the primary monitor via `GetForMonitor` (no events). - **Graph serialization**: `Windows.Data.Json` (zero extra dependencies). GUID fields use `StringFromGUID2`/`CLSIDFromString`. - **Effect registry**: Singleton with 40+ built-in D2D effects across 9 categories. Case-insensitive name lookup. -- **ShaderLab effects library**: 33 built-in effects in `Effects/ShaderLabEffects.h/.cpp` across categories: Analysis (Heatmaps + Scopes + Statistics + Tone-Mapping), Color Processing (Gamut Map + ICtCp Gamut Map + Scale), Source / Generator, Composition (Split Comparison), and the data-only Parameter / Clock / Numeric Expression / Random / Working Space nodes. Embedded HLSL with shared color math from `Effects/ColorMath.cpp`. Auto-compiled at first use; bytecode cached on disk under `%LOCALAPPDATA%\ShaderLab\bytecode\` (decision #58 catalog → see [builtin-catalog.md](../docs/effects/builtin-catalog.md) for the full per-effect type table). +- **ShaderLab effects library**: 36 built-in effects in `Effects/ShaderLabEffects.h/.cpp` across categories: Analysis (Heatmaps + Scopes + Statistics + Tone-Mapping), Color Processing (Gamut Map + ICtCp Gamut Map + Scale), Source / Generator, Composition (Split Comparison), and the data-only Parameter / Clock / Numeric Expression / Random / Working Space nodes. Embedded HLSL with shared color math from `Effects/ColorMath.cpp`. Auto-compiled at first use; bytecode cached on disk under `%LOCALAPPDATA%\ShaderLab\bytecode\` (decision #58 catalog → see [builtin-catalog.md](../docs/effects/builtin-catalog.md) for the full per-effect type table). - **MCP server**: JSON-RPC 2.0 (protocol 2025-06-18, batching rejected) over **stdio via the broker** — the embedded HTTP listener was deleted in stdio-migration Step 9 (decision #71, superseding #31/#58; engine ABI **3**). The router, dispatcher, 39-tool catalog + 25 engine-pure routes live in `Engine/Mcp/` (handlers take `(path, query, body)`); 16 app-side routes stay in `MainWindow.McpRoutes.cpp`. Each host registers as a hub **session** (`McpSessionClient`, GUID-identified); a client's shim (`ShaderLabMcpBroker --stdio`, unpackaged, distributed to `%LOCALAPPDATA%\ShaderLab\bin\`) activates the packaged hub and pins a session with `use_session`. Both hosts register the same engine-side route set through `IEngineCommandSink`: pure mutation closures dispatched via `sink.Dispatch`, with 8 event hooks (`OnNodeAdded`, `OnNodeRemoved`, `OnNodeChanged`, `OnGraphCleared`, `OnGraphLoaded`, `OnGraphStructureChanged`, `OnCustomEffectRecompiled`, `OnDisplayProfileChanged`) the GUI overrides to keep its UI in sync. CI drives `RunTests.ps1` through a shim against a headless `--mcp-session`. - **Versioning**: `Version.h` defines app version (currently **1.7.3**) and graph format version (2). Both are stored in saved graphs. Forward compatibility check on load. `EngineExport.h::SHADERLAB_ENGINE_ABI_VERSION` is independent — bumped manually on engine ABI breaks; mismatch between header and DLL aborts startup with a friendly message-box. - **Refresh-rate-driven render loop on the worker thread**: the render worker `std::jthread` runs the graph evaluate at the active monitor's refresh rate (clamped to 60–240 Hz). Dirty-gated: skips evaluate when no nodes changed, no output window is open, and `m_forceRender` is false. The UI thread runs a `DispatcherQueueTimer` at the same rate, but its body is just "drain dispatcher + blit offscreen + Present1" — sub-ms cost. The interval is re-applied on every display change so dragging the window across monitors picks up the new rate. @@ -216,7 +255,7 @@ The built-in effects library lives in `Effects/ShaderLabEffects.h/.cpp`: - **Embedded HLSL**: Each effect's shader code is stored as a `const char*` string constant. No external `.hlsl` files. - **Shared color math**: A common HLSL library (BT.709/BT.2020/P3 color matrices, PQ/HLG transfer functions, CIE XYZ↔xy conversions, luminance calculations) is prepended to each shader at compile time. - **Auto-compile**: Effects are compiled via `ShaderCompiler` at first use (when added to graph). Compiled bytecode is cached. -- **Categories** (33 effects total): +- **Categories** (36 effects total): - **Analysis → Heatmaps** (D3D11 Compute with image output): Luminance Heatmap, Luminance Highlight, Delta E Comparator. **Pixel Shader**: Gamut Highlight, Nit Map. - **Analysis → Scopes**: CIE Histogram (D3D11 Compute), CIE Chromaticity Plot (Pixel Shader). (Vectorscope and Waveform Monitor were removed in Phase 8 — they no longer ship.) - **Analysis → Statistics** (D3D11 Compute, data-only): Channel Statistics, Luminance Statistics, Chromaticity Statistics, Image Info. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 424c4b9..8dda7fc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -188,3 +188,87 @@ jobs: shell: pwsh run: .\Tests\RunHeadlessSmoke.ps1 -Configuration Debug -Platform x64 + + native-arm64: + name: Native ARM64 build & test + runs-on: windows-11-arm + # The x64 jobs above CROSS-compile ARM64 from an x64 runner, so the two + # traps that only bite when building ARM64 *on* an ARM64 host never surface + # there -- and they fail in misleading ways (see docs/development/build.md). + # This job is the regression guard for the toolset-selection trap. + # + # Scope is the three non-packaged projects (engine + test runner + both + # console hosts). The MSIX-packaged WinUI app additionally needs the + # Microsoft.VisualStudio.Component.UWP.VC.ARM64 component, which is not + # guaranteed on the hosted image; add ShaderLab.vcxproj here once that is + # confirmed present. + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Setup NuGet + uses: NuGet/setup-nuget@v2 + + - name: NuGet restore + run: nuget restore ShaderLab.slnx -SolutionDirectory . + + - name: Resolve the ARM64-native MSBuild + shell: pwsh + # Deliberately NOT microsoft/setup-msbuild: that puts + # MSBuild\Current\Bin\MSBuild.exe on PATH, which is 32-bit and reports + # PROCESSOR_ARCHITECTURE=x86 under emulation. Toolset selection in + # Microsoft.Cpp.ToolsetLocation.props then never matches its ARM64 + # branch and falls through to the 32-bit bin\HostX86\arm64\cl.exe, which + # exhausts its ~3 GB address space on the large generated translation + # units and dies with "C3859: Failed to create virtual memory for PCH" + # + C1076. /p:PreferredToolArchitecture=x64 does NOT help -- that props + # file declares TreatAsLocalProperty and demotes it straight back to x86. + # Fail loudly rather than silently building with the wrong toolset. + run: | + $vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" + $root = & $vswhere -latest -products * ` + -requires Microsoft.Component.MSBuild -property installationPath + if (-not $root) { Write-Error "vswhere found no VS installation"; exit 1 } + $msbuild = Join-Path $root 'MSBuild\Current\Bin\arm64\MSBuild.exe' + if (-not (Test-Path $msbuild)) { + Write-Error "ARM64-native MSBuild not found at $msbuild. Building with the default (32-bit) MSBuild would fail with misleading C3859/C1076 PCH errors -- see docs/development/build.md." + exit 1 + } + & $msbuild -version + "MSBUILD_ARM64=$msbuild" | Out-File -FilePath $env:GITHUB_ENV -Append + + - name: Build (Debug | ARM64) + shell: pwsh + run: | + foreach ($proj in 'ShaderLabTests.vcxproj', + 'ShaderLabHeadless.vcxproj', + 'ShaderLabMcpBroker.vcxproj') { + & $env:MSBUILD_ARM64 $proj /p:Configuration=Debug /p:Platform=ARM64 /m /nologo /v:minimal + if ($LASTEXITCODE -ne 0) { Write-Error "MSBuild failed for $proj"; exit $LASTEXITCODE } + } + + - name: Run unit tests + shell: pwsh + run: | + $exe = "$env:GITHUB_WORKSPACE\ARM64\Debug\ShaderLabTests\ShaderLabTests.exe" + if (-not (Test-Path $exe)) { Write-Error "Test runner not found at $exe"; exit 1 } + & $exe --adapter warp + if ($LASTEXITCODE -ne 0) { + Write-Error "ShaderLabTests reported $LASTEXITCODE failure(s)" + exit $LASTEXITCODE + } + + - name: Headless smoke + shell: pwsh + run: .\Tests\RunHeadlessSmoke.ps1 -Configuration Debug -Platform ARM64 + + - name: Broker smoke + shell: pwsh + run: | + pwsh -NoProfile -File Tests\RunBrokerSmoke.ps1 -Configuration Debug -Platform ARM64 + if ($LASTEXITCODE -ne 0) { + Write-Error "RunBrokerSmoke.ps1 reported $LASTEXITCODE failure(s)" + exit $LASTEXITCODE + } diff --git a/.gitignore b/.gitignore index 864f331..a2bc2b6 100644 --- a/.gitignore +++ b/.gitignore @@ -85,17 +85,31 @@ $RECYCLE.BIN/ # macOS .DS_Store -Screenshot_53.png # third_party/ holds git submodules (exprtk, miniz) plus the in-tree # miniz_export.h shim -- all tracked. Do NOT ignore it. +# Visual Studio scratch enc_temp_folder/ - # MSIX build staging output (regenerated each build) AppX/ -# Stray test outputs -output.jxr - +# Stray probe / capture output, REPO ROOT ONLY. ShaderLabHeadless picks its +# encoder from the --output extension (.jxr / .wdp = JPEG XR, anything else +# = PNG) and --pixels writes .bin / .raw, so ad-hoc probes land here easily. +# Root-anchored deliberately: Assets/ and docs/ hold tracked images, and a +# saved .effectgraph is a user document that must never be ignored. +/*.png +/*.jxr +/*.wdp +/*.bin +/*.raw + +# Test harness scratch output +Tests/output/ + +# Claude Code: the project config and skills are shared; per-user state is +# not. (.remember/ self-ignores via its own .gitignore.) +.claude/settings.local.json +.claude/.credentials.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 4576515..fbdbe77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,10 +7,53 @@ Format follows [Keep a Changelog](https://keepachangelog.com/). ### Fixed +- **`RunBrokerSmoke.ps1` mis-read every response after `use_session`** — five checks failing, and one **false pass**. Its `Recv` read one line and assumed it was the reply to the request just sent. But the shim advertises `tools.listChanged` in `initialize` and correctly emits `notifications/tools/list_changed` immediately after the `use_session` reply, once the pinned session's catalog is spliced in. That one unsolicited server→client line shifted every subsequent read by one: `ToolsListSpliced` read the notification, `GraphOverviewRoundTrip` read the `tools/list` reply, `MutatingRouteRoundTrip` read the `graph_overview` reply, `NotificationSilent` and `ParseErrorShape` read the two before them — and `GoneSurfacesDistinctError` **passed for the wrong reason**, matching `session_gone` in the *previous* call's response. Nothing in the product was wrong; the suite had been reporting 5 failures and one bogus success against a correct shim. `Recv` now correlates: it returns the next message carrying an `id`, recording notifications as it skips them, and `RecvRaw` keeps the old behavior for the one place a raw line is wanted. A new `Session.ToolsListChangedOnAttach` check asserts the notification is actually emitted, so the behavior that broke the harness is now pinned rather than merely tolerated. All 28 checks pass, stable over three consecutive runs. +- **Disabling MCP on a freshly launched window hung the app.** `McpSessionClient::Stop()` — called from the UI thread by the toolbar toggle and by `~MainWindow` — closed the pipe handle to "unblock a pending blocking ReadFile" while the session thread was concurrently inside `ReadFile` / `WriteFile` on that same handle. The pipe is opened without `FILE_FLAG_OVERLAPPED`, so its I/O is **synchronous**, and Win32 only cancels that via `CancelSynchronousIo()`; `CancelIo`/`CancelIoEx` cancel asynchronous operations, and closing a handle out from under in-flight I/O is undefined — a Debug build raises `STATUS_INVALID_HANDLE`, and once the value is recycled by another thread the session can write MCP bytes into an unrelated object. In practice the read did **not** reliably unblock, so `StopMcpSession`'s `m_sessionThread.join()` never returned and the UI thread deadlocked. Reproduced as a hang in the test suite by restoring the old `Stop()` — the run never completed; with the fix the same check returns in **0 ms**. The window is widest right after launch because the connect → hello → hello-ack handshake ran without consulting the stop flag at all, so a toggle during it was only noticed after the handshake finished. **Fix**: `Stop()` sets the flag and calls `CancelSynchronousIo` on a duplicated handle to the session thread that `Run()` publishes; the owning thread still does the close; the handle and thread handles are serialized by a small mutex held only around publish/cancel/close, never across I/O; and `stop` is now checked at each handshake step. This is what the migration plan already specified ("reject-new → `bye` → cancel → **join**") and matches the broker's own cancellation pattern. Three regression tests cover it, including a stub pipe server that parks the session in a blocking read so the close-from-another-thread path cannot come back. +- **Saving an output window's image failed silently.** `OutputWindow::SaveImageAsync` wrapped its whole WIC/D2D body — every step of which is `check_hresult`'d — in a bare `catch (...) {}`. Any failure after the file picker (unsupported pixel format for the chosen container, a `Map` failure, a denied path) therefore did nothing at all: no dialog, no status text, no log line, while the success path wrote "Saved: ``" into the same status field. Now reports `Save failed: ` there and traces to the debugger, with a separate non-hresult fallback. +- **Legacy graph migration dropped user data without saying so.** Both legacy-format migration blocks in `EffectGraph`'s node deserializer (analysis-field metadata, and the pre-map `propertyBindings` string) swallowed parse failures. A malformed binding array left the node partially migrated — bindings parsed before the throw kept, the rest gone — indistinguishable from never having authored them, and the dangling references only surfaced much later as bindings that would not resolve. Both now record the failure in `EffectNode::runtimeError`, which already surfaces in the Properties panel and `graph_get_node`. +- **Non-COM exceptions on the output-window present path were invisible.** The per-frame `Present` handler logged `winrt::hresult_error` but had a trailing bare `catch (...)`. It now reports once per process, so a non-COM failure is discoverable without flooding the log every frame. +- The remaining deliberate catch-alls (device/window teardown, the MCP router's host activity-indicator callback, transient XAML layout reads during teardown, and the MCP string-coercion fallback) now carry comments stating why swallowing is correct there — so an intentional swallow is distinguishable from an oversight at a glance. This is the pattern that previously cost a live debugging session, when `DisplayMonitor::Initialize` swallowed a throw and served SDR defaults on a 4000-nit HDR panel. +- **`.gitignore` refreshed.** Dropped a vestigial `Screenshot_53.png` rule (the file was long gone, and it had been filed under `# macOS`). Generalized the single `output.jxr` entry into root-anchored scratch patterns — `/*.png`, `/*.jxr`, `/*.wdp`, `/*.bin`, `/*.raw` — because headless now picks its encoder from the `--output` extension and `--pixels` writes raw blobs, so ad-hoc probes land at the repo root routinely. Root-anchored deliberately: `Assets/` and `docs/` hold tracked images, and a saved `.effectgraph` is a user document that must never be ignored. Added `Tests/output/` (harness scratch, previously two empty directories that git could not see but that would have leaked artifacts the moment a run populated them). Verified no tracked file matches any rule. +- **`.context/resume.md` de-volatilized.** The repo's session-handoff doc carried its own drift warning, and had earned it: four of the eight drift items in this release were in that one file, including claiming engine ABI **1** while three other lines in the same file said **3**, and a "Solution Layout (4 projects)" heading above a table of five. It now holds only what does not rot — the shape of the split and why, the threading contract, the D2D gotchas, the product thesis — with a table at the top routing every live number to its authority (`Version.h`, `EngineExport.h`, `CHANGELOG.md`, the decision log, the effect catalog, or "run the suite, it prints them") and an explicit instruction not to add numbers back. Also drops the hardcoded `C:\Users\...` path, which was the only absolute user path in a tracked file and meaningless to anyone else now the repo is public. 23% shorter by word count. +- **Documentation drift corrected.** The ShaderLab effect count was quoted as 33 in three places and 35 in three others while the registry held **36** (`HDR Screenshot Tonemap (8bpc)` was also missing from the catalog table entirely, now added); the test total read 244/261 against an actual 289; `docs/architecture/engine-host-split.md` and `.context/resume.md` claimed 18 app-side MCP routes against an actual 16; `.context/resume.md` stated engine ABI **1** in its versioning section while three other lines in the same file correctly said **3**; the decision log was cited as "70 entries" and "60+" against 68 numbered rows (ids run to #72 — #64–67 were never written); `docs/hosts/mcp-server.md` still opened by describing the embedded HTTP server that migration Step 9 deleted, contradicting its own transport section fifteen lines later; and `ShaderLabHeadless/Main.cpp`'s header comment still listed `--script` and MCP under "not yet implemented" although CI depends on both. + +- **Image sources re-decoded from disk on every graph event.** `SourceNodeFactory::PrepareSourceNode` keyed its bitmap cache on `node.dirty`, but in this codebase `dirty` means "re-evaluate downstream", not "reload from disk" — and the render tick only calls into source prep *when the node is dirty*, so the cache-hit branch was unreachable from the GUI. Every `MarkAllDirty()` (node add/remove, preview switch, display-profile change, and **every capture**) therefore triggered a full WIC decode plus colour-management pass. Measured at **98 ms of a 164 ms frame** on a 4K JPEG — the single largest cost in the app, and it made every MCP-driven capture pay a full re-decode. Fixed by keying the cache on the source path (`m_bitmapPathCache`), which is what actually invalidates a decode; `dirty` is now cleared on the cache-hit path as well. Failed loads still retry, deliberately, so a file appearing later is picked up. +- **The entire ICtCp suite silently sRGB-clipped wide-gamut input.** `ScRGBToICtCp` opened with `max(rgb, 0.0)`, described as keeping the LMS path well-defined. But negative Rec.709 components are precisely how scRGB expresses wide-gamut colour — a BT.2020 green is roughly `(-0.87, 1.00, 0.06)` — so that clamp was not a safety net, it was a gamut clip applied *before* any colour science ran. Measured on an identity round trip (`ICtCp Saturation` at 1.0, a no-op): R **−0.264 → −0.0005** and **−0.604 → −0.0005**, while in-gamut colours round-tripped to 0.05%. Consequences: the tone mappers, saturation, highlight desaturation and gamut map all operated on sRGB-clipped data; worse, `SampleBoundary` builds the target gamut shell by converting boundary chromaticities *through scRGB*, so selecting P3, BT.2020, or display-bound custom primaries produced an **sRGB-shaped boundary** — the gamut mapper was mapping already-clipped colour onto a clipped shell. **Fix**: signed PQ (`PQ_InvEOTF_Signed` / `PQ_EOTF_Signed`), mirroring the transfer curve through the origin exactly as `LabF` already does for CIE Lab, and the entry clamp removed. LMS stays non-negative for every physically realizable colour, so the signed path only engages on out-of-locus or below-black input. `saturate(pqLms)` on the inverse became `clamp(pqLms, -1, 1)`, preserving the NaN guard (PQ's rational form goes singular past |V|=1) while keeping sign. Call-site clamps removed too: three in `ICtCp Gamut Map`, plus the CIE-xy detection in the same shader and the `Gamut Coverage` scatter, both of which projected pixels onto the sRGB hull before testing coverage. Round trip now preserves −0.604 to within fp16 noise; three regression tests pin the sign and magnitude so a reintroduced clamp fails loudly. +- **`Split Comparison` simplified to trivial sampling (v7).** The shader hand-rolled its input sampling — `Sample()` with a UV scaled by `content / (output × atlas)` — nominally to compensate for D2D atlas padding. For equal-sized inputs that expression reduces algebraically to `Load()` at the same texel, so it was dead complexity; it also violated the contract established when `D2D1_PIXEL_OPTIONS_TRIVIAL_SAMPLING` was adopted (*every ShaderLab pixel shader reads inputs at the same coord as the output; cross-texel sampling belongs on the compute path*). Both inputs are now read with `Load(int3(uv0.xy, 0))`. The `ImageAW`/`ImageAH`/`ImageBW`/`ImageBH` cbuffer fields, their descriptor parameters, and the sampler are deleted; `OutputW`/`OutputH` remain for the wipe geometry. **Behavior change**: mismatched-size inputs are no longer stretched to fill the union rect — a smaller input covers only its own region and reads transparent black elsewhere. Put a `Scale` node upstream to compare branches of differing resolution. + +- **Synthetic-source and diagram default sizes raised 512 → 1024** via a single `kDefaultDiagramSize` constant in `ShaderLabEffects::RegisterAll`: `Gamut Source.OutputSize`, `Zone Plate.PlateSize`, `Gradient Generator.GradSize`, `HDR Test Pattern.PatternSize`, and `DiagramSize` on `CIE Chromaticity Plot`, `Gamut Coverage`, and `ICtCp Boundary`. This keeps newly-created graphs clear of the ≤512px two-input displacement below by default. Parameter *minimums* are unchanged — a small diagram is still valid standalone and cheap for the O(N)-per-pixel viewers — so the underlying defect remains reachable by hand. No effect versions bumped: defaults apply only to newly created nodes, and saved graphs carry their own stored values, so existing work is untouched. `Color Checker.PatchSize` is deliberately excluded: it sizes an individual patch rather than the output, and its 256px maximum cannot reach 1024 on both axes anyway. + +### Known issues + +- **Two-input pixel-shader effects render displaced when their inputs are ≤512px** (`Split Comparison` against a 512² `Gamut Source`: left half transparent black, right half a displaced wedge; correct at 1024² and 2048², and correct at every size on photo-sized sources). Measured: at output coord (268,231) the node returns `(0,0,0,0)` — `Load()`'s out-of-bounds result, distinguishable from genuine black which carries alpha 1 — while (460,330) returns the content living at input texel ~(183,16). Both readings agree that `uv0` is displaced roughly (−277,−314) from the output coordinate, so out-of-bounds reads dominate the frame. Single-input effects on the same source are unaffected, and reading each input in isolation (`SplitPosition` 0 vs 1) produces the *same* wedge, so this is not per-input atlas placement. `MapInputRectsToOutputRect` correctly returns the union `[0,0,512,512]`, so the displacement arises below that in D2D's coordinate plumbing and is not yet root-caused. Workaround: keep synthetic sources at ≥1024². +- **Enum parameters on D2D pixel-shader effects silently packed float bits into `uint` cbuffer slots.** `GraphEvaluator::ApplyCustomEffect` packed properties with a raw `memcpy` of the stored variant, so a float-stored enum like `Mode = 3.0f` landed in a `uint Mode` HLSL slot as `0x40400000` (1077936128) — every `uint`-enum switch on the pixel/D2D-compute path fell through to its default branch for any non-zero selection. In practice: `ICtCp Gamut Map` modes 1/2 silently behaved as mode 0, and non-default `TargetGamut`/mode selections on the other migrated-to-`uint` pixel effects (`Gamut Highlight`, `Gamut Map`, `ICtCp Boundary`, ...) were wrong since the v1.3.8 uint migration. Zero-valued defaults masked it (float 0.0 and uint 0 share a bit pattern), and the D3D11 compute-bridge path already used the typed helper, which is why compute effects were unaffected. Fix: `ApplyCustomEffect` now packs through `PackPropertyToCBuffer` (float→uint/int/bool conversion per the reflected slot type). Found via headless pixel-probe scripts while validating the new Soft Compress mode — its three knee params were the first cbuffer payload past byte 64 and the debugging trail led here; new unit tests pin the `ICtCp Gamut Map` cbuffer layout (size 80, knee params at offsets 64/68/72). + +- **Non-ASCII characters in UI strings rendered as mojibake** (`—` → `â€"` in the Save Image flyout) — every source file is BOM-less UTF-8, but MSVC's default source charset is the system codepage (Windows-1252), so em dashes in wide string literals were transcoded byte-by-byte at compile time. All five projects now compile with `/utf-8` (source and execution charset), which validated cleanly against all 128 tracked C++ files. - **Image sources were never color-managed into the pipeline's working space** — `ImageLoader` created SDR bitmaps as plain `B8G8R8A8_UNORM` (no sRGB decode: encoded 0.5 entered the linear-scRGB pipeline as 50% luminance instead of ~21%), classified 16-bit integer PNG/TIFF as "HDR" (no decode either), would have read HDR10 PQ stills as linear light, and ignored embedded ICC profiles entirely. Unnoticed because the capture/save paths were symmetrically un-encoded, so pass-through graphs round-tripped byte-identical — but on an HDR display mid-tones rendered too bright, and every "linear-space" effect (the whole ICtCp suite) operated on gamma values. **Fix**: the loader now has one canonical contract — every source exits as a flattened **linear scRGB FP16** bitmap. WIC decodes without touching the transfer; the D2D `ColorManagement` effect (BEST quality) converts to scRGB honoring the embedded ICC profile when present, else per-format: 8/16-bit integer → sRGB, 10-bit 1010102 → HDR10 (PQ/BT.2020), float/half → already scRGB (pass-through, so Windows HDR screenshots load losslessly). Matching sRGB encode-on-write added to `CaptureNode`, the node-save path, and the CLI capture; the node-editor canvas and other UI surfaces intentionally stay plain UNORM. **Known remaining gap**: screen/window/video capture *sources* still ingest `B8G8R8A8_UNORM` without decode — same bug class, needs its own pass (video also involves BT.709 transfer). ### Added +- **Native ARM64 CI job (`native-arm64`, `runs-on: windows-11-arm`).** The existing matrix *cross*-compiles ARM64 from an x64 runner, so the two traps that only bite when building ARM64 on an ARM64 host have never been covered — and both fail misleadingly (`C3859: Failed to create virtual memory for PCH` + `C1076` from the 32-bit toolset the default MSBuild silently selects). The job resolves `MSBuild\Current\Bin\arm64\MSBuild.exe` explicitly via `vswhere` rather than using `setup-msbuild`, and **fails loudly** if it is absent instead of falling through to the broken toolset. Scope is the engine, test runner and both console hosts plus the unit, headless and broker smoke suites; the MSIX-packaged app is excluded until the `Microsoft.VisualStudio.Component.UWP.VC.ARM64` component is confirmed on the hosted image. +- **`ΔE ITP` (ITU-R BT.2124) on `Delta E Comparator` (v7), and it is now the default `Method`.** The empirical fidelity loop — Delta E Comparator + Luminance Statistics + Working Space — was measuring HDR/WCG work with an SDR ruler. CIE76/94/2000 were fit to reflective samples under SDR viewing and leave their domain above roughly 100 nits and outside sRGB, which is where this pipeline lives. Measured on a ~10% luminance step at HDR levels (Gamut Source at 1100 vs 1000 nits): **ΔE ITP 7.47** — about 7 JND, interpretable — against **CIEDE2000 118.09** and **CIE76 253.26**, both meaningless for a barely-visible difference, because `L*` is referenced to SDR white and the step sits far outside its 0–100 range. `DeltaEITP` / `DeltaEITPFromScRGB` live in the shared `ColorMath.cpp` library, so any effect can use them, and they take PQ-encoded ICtCp per the standard (`720 · √(ΔI² + ΔT² + ΔP²)`, `T = 0.5·Ct`, `P = Cp`). One unit is ~1 JND in all four metrics, so numbers remain comparable when switching. Existing saved graphs keep whatever `Method` they stored; only newly added nodes get the new default. Eight bench tests pin identity, symmetry, the 720 scale, and the half-weighting of Ct that is the metric's defining asymmetry — plus a cross-check against CIEDE2000 on a small SDR step where both metrics are valid: they agree to **1.02×**, which is what grounds the scale constant end to end. +- **Headless: HDR-preserving JPEG XR output.** `--output` now picks its encoder from the extension — `.jxr` / `.wdp` writes a lossless 64bpp RGBA-half JPEG XR straight from the pipeline's linear scRGB, with no clamp and no transfer encoding, so values above 1.0 and the negative components that express wide-gamut colour both survive; anything else keeps the existing 8-bit sRGB PNG. Mirrors the GUI's `OutputWindow` save flyout, which already offered both, so the same node captured either way now produces the same file. **HDR output implies `--no-tonemap`**: the default `CLSID_D2D1HdrToneMap` targets 80 nits, so leaving it on handed the encoder an already-compressed SDR image to store in an HDR container — measured, an 800-nit source arrived at the encoder as 0.99 instead of 10.34. Naming a peak explicitly still wins, since tone mapping *into* an HDR deliverable (4000-nit source, 1000-nit target) is a real request. Verified end to end: source 10.3451 → JXR 10.3438, which is FP16 quantization at that magnitude. +- **Headless: `.effectgraph` ZIP archives with embedded media.** `--graph` previously read only bare JSON, so the container the GUI's Save actually writes could not be rendered headless — an agent or CI job had to be handed a manually-unpacked `graph.json`. Both load sites now go through one `LoadGraphFromPath` helper that detects the archive by PKZIP magic rather than extension (`.effectgraph` has historically named both forms), loads it via the engine's existing `EffectGraphFile`, rewrites each source node's `media://` token to the extracted path in both `shaderPath` and its mirrored property exactly as `MainWindow.GraphFileIo.cpp` does, and deletes the temp directory on every exit path through an RAII guard ordered to outlive the evaluator's file handles. Two smoke checks cover both features so neither can regress to the previous behavior. +- **Claude Code project context (`CLAUDE.md` + `.claude/`).** Claude Code does not read `.github/copilot-instructions.md`, so a fresh session started with none of the repo's agent guidance. `CLAUDE.md` now auto-loads the rules that prevent a crash or a misleading build failure — the graph-access threading contract, the pch requirement, the D2D/scRGB effect traps, the MCP stringified-untyped-arg rule — and delegates depth to `docs/`. Two skills (`.claude/skills/shaderlab-build`, `shaderlab-run`) carry the long procedures: MSBuild selection and the ARM64-host traps, MSIX register/activate and its HRESULT table, and the MCP suite. `.claude/settings.json` ships a portable permission allowlist; machine-specific entries belong in the gitignored `settings.local.json`. `copilot-instructions.md` and `CLAUDE.md` now cross-reference each other, since they deliberately overlap on the crash-causing rules. +- **Effect-catalog drift guard (`TestEffectCatalogCount`).** The ShaderLab effect count is quoted in six places outside the code and had drifted to two different wrong values. A test now pins the registry count against a constant and points at every file to update when it changes, plus asserts `effectId` uniqueness — a duplicate would make `effectVersion` upgrades ambiguous on load. + +- **`HDR Screenshot Tonemap (8bpc)` — the display-referred screenshot path as one fused effect.** Target is a Desktop Duplication capture: fp16 scRGB of the composed desktop, mixed HDR and SDR, with SDR white at `W/80` because DWM composites it at `W` nits while scRGB defines 1.0 = 80 nits. One pass does knee tone map → chroma correction → soft gamut compression into sRGB → white-level normalise → sRGB OETF → TPDF dither → 8-bit quantize, then **decodes the quantized result back to linear scRGB** so the graph keeps its working space and downstream analysis measures exactly the damage the 8-bit handback would do. Fused rather than chained because the product ships one shader over a 4K+ frame — the node-graph equivalent pays three ICtCp round trips where this pays one. + - **`KneeRatio` is a fraction of `W`, not absolute nits.** An absolute knee silently changes meaning the moment the SDR-brightness slider moves, which bit us live earlier (a 200-nit knee went from 0.86·W to 0.54·W without the value changing). + - **Chroma correction** (`ChromaCorrect`, default 1.0) scales Ct/Cp by `Iout/I`. Lowering I while holding chroma *raises* saturation, which is what makes naive knee output look neon in highlights; scaling holds saturation constant through the curve. + - **Dither** (`DitherStrength`, default 1.0 LSB) uses interleaved gradient noise summed to a triangular PDF, which decorrelates quantization error from the signal. It matters because the above-white band survives the curve inside ~33 of 256 codes after the OETF. Measured on a flat field whose true value falls between codes 187 and 188: undithered, all 64 pixels round to 187 and the patch mean lands 0.63% low; dithered, they distribute 35/29 across both codes and the mean lands within **0.08%** — an 8× error reduction, with the residual converted from a step into noise. + - Curve verified against known luminances at `W=200, knee=0.7·W, peak=1000`: 100 nits → 0.4998 and 140 nits → 0.6999 (identity holds exactly up to the knee), 1000 nits → 0.9986 (peak lands on white), 4000 nits → 0.9990 (saturates rather than blowing past). **200 nits (SDR white) → 0.8517**, i.e. the knee costs 15% of SDR white whenever it engages — see the note below. + - The residue clamp sits *after* gamut compression by design: the soft compressor asymptotes ~7% outside the boundary, so the clamp trims overshoot rather than destroying wide-gamut information. Moving it earlier reintroduces the clip this release just removed. + - Twelve new tests: shader compiles from the registry descriptor with the expected cbuffer, plus dither grid-exactness, triangular-dither bounds, and between-codes spreading. + +### Known issues + +- **The knee darkens SDR white by 15–20% whenever it engages** (measured: 0.8517 at `KneeRatio` 0.7). That is a good trade on a fullscreen HDR game and a questionable one on a desktop screenshot, where most of the frame is SDR UI the user expects to match their screen. No fixed knee is right for both, so the intended resolution is content-adaptive parameters driven by a statistics pass (fraction of frame above `W`, percentile content peak) plus a bypass fast path when a frame contains nothing above `W` — which also makes pure-SDR captures bit-exact against the naive path. Neither is implemented yet. + +- **`Soft Compress` mode on `ICtCp Gamut Map` (v11)** — ACES-RGC-style soft roll-off. The existing per-pixel modes project every out-of-gamut pixel exactly onto the gamut shell, so gradients crossing the boundary flatten (all outside colors collapse onto it). The new mode instead remaps each pixel's chroma radius through `SoftCompressDistance(d, SoftThreshold, SoftLimit)` in the shared color-math library: identity below `SoftThreshold`×boundary, then a C1-continuous power-curve knee (`KneeHardness` = the ACES `p`: 1.0 is exactly Reinhard, higher tracks identity longer and corners harder; default 1.2 per the ACES RGC) whose scale is solved so `SoftLimit`×boundary lands exactly on the shell — reserving a headroom band just inside the gamut so out-of-gamut colors stay ordered instead of clipping. Also touches legal near-boundary colors (the inherent trade), preserves hue (radial in Ct/Cp) and luminance (Y renormalization), and samples the boundary per-I-level like the other modes. New `BoundaryRadius` polygon-ray helper; the three knee params are `visibleWhen` mode 3. Six contract bench tests in `GamutTests.cpp` pin identity/anchor/C1-slope/monotonicity/softness/hardness-ordering (including the p=1 Reinhard closed form). +- **`KneeNits` on both ICtCp tone mappers (v13)** — BT.2390-EETF-style knee: bit-exact identity at or below `KneeNits`, curve only above, C1-continuous at the knee; `0` (default) reproduces the previous expand/compress-from-black curves exactly. Motivated by measured mixed-content failures (an image whose SDR chunk is presentation-referenced while other regions are scene-referred HDR): the classic forward curve washed a 241-nit SDR region to 79 nits, and the classic inverse curve clamped 113–241-nit content uniformly to the 1000-nit peak. The primary workflow this enables: **HDR screenshot of a mixed SDR/HDR desktop → faithful SDR image** via `ICtCp Tone Map (Knee≈0.7·W, Source=peak, Target=W)` → `White Level Adjustment (W→80)` → sRGB save, where `W` is the SDR-chunk white (the OS slider level). On the inverse mapper the knee supersedes the `DiffuseWhiteNits` lift (identity-below-knee is the contract; the lift stays for the knee-less mode). - **Display monitoring rewritten on WinRT `AdvancedColorInfo`; minimum OS raised to Windows 11 22H2 (10.0.22621)** (decision #72, superseding #12/#13). - `DisplayMonitor` now binds a `DisplayInformation` to the main window via `IDisplayInformationStaticsInterop::GetForWindow` and subscribes to **`AdvancedColorInfoChanged`** — the event fires for HDR toggles, the Windows **"SDR content brightness" slider**, and monitor moves. One `AdvancedColorInfo` snapshot supplies the active kind (SDR/WCG/HDR), kind availability, all four luminance values (including `SdrWhiteNits`, which now tracks the slider **live**), and the EDID primaries/white point. Deleted: the `WM_DISPLAYCHANGE` message-only window (a latent bug — message-only windows never receive broadcasts, so that path never fired), the 500 ms monitor-move poll thread, the DXGI adapters-changed jthread, and both `QueryDisplayConfig` walks (SDR white level + type-15 advanced-color info). - The change-detection diff now covers **every** capability field — previously `sdrWhiteLevelNits`, `activeColorMode`, the supported/user-enabled flags, primary Y components, and the white point were omitted, so slider moves never fired the callback even when detected. diff --git a/Controls/LogWindow.cpp b/Controls/LogWindow.cpp index 3fbf249..4185e11 100644 --- a/Controls/LogWindow.cpp +++ b/Controls/LogWindow.cpp @@ -164,6 +164,8 @@ namespace ShaderLab::Controls { if (m_isOpen && m_window) { + // Deliberate swallow: teardown. Closing an already-closed window + // throws, and every field is nulled immediately below regardless. try { m_window.Close(); } catch (...) {} } m_isOpen = false; diff --git a/Controls/NodeGraphController.cpp b/Controls/NodeGraphController.cpp index 6606404..4706dd8 100644 --- a/Controls/NodeGraphController.cpp +++ b/Controls/NodeGraphController.cpp @@ -1344,7 +1344,20 @@ namespace ShaderLab::Controls } } if (valStr.empty()) - valStr = std::format(L"{:.4g}", val); + { + // Vector properties (e.g. bound gamut primaries) + // previously fell through the float-only read + // above and always displayed "= 0". + using namespace winrt::Windows::Foundation::Numerics; + if (auto* v2 = std::get_if(&propIt->second)) + valStr = std::format(L"{:.3g}, {:.3g}", v2->x, v2->y); + else if (auto* v3 = std::get_if(&propIt->second)) + valStr = std::format(L"{:.3g}, {:.3g}, {:.3g}", v3->x, v3->y, v3->z); + else if (auto* v4 = std::get_if(&propIt->second)) + valStr = std::format(L"{:.3g}, {:.3g}, {:.3g}, {:.3g}", v4->x, v4->y, v4->z, v4->w); + else + valStr = std::format(L"{:.4g}", val); + } label += L" = " + valStr; } D2D1_RECT_F labelRect = { diff --git a/Controls/OutputWindow.cpp b/Controls/OutputWindow.cpp index 116d54f..549e030 100644 --- a/Controls/OutputWindow.cpp +++ b/Controls/OutputWindow.cpp @@ -340,7 +340,19 @@ namespace ShaderLab::Controls OutputDebugStringW(std::format(L"[OutputWindow] Present failed: {}\n", std::wstring_view(ex.message())).c_str()); } - catch (...) {} + catch (...) + { + // Non-COM exception on a per-frame path: report once rather than + // every frame, so it is discoverable without flooding the log. + static bool reported = false; + if (!reported) + { + reported = true; + OutputDebugStringW( + L"[OutputWindow] Present failed (non-hresult exception; " + L"reported once per process)\n"); + } + } } void OutputWindow::Close() @@ -367,7 +379,12 @@ namespace ShaderLab::Controls m_panel.SizeChanged(m_sizeChangedToken); m_window.Close(); } - catch (...) {} + catch (...) + { + // Deliberate swallow: teardown. Revoking a token or closing a + // window that XAML already tore down throws, and every field + // this method clears is nulled immediately below either way. + } m_window = nullptr; } @@ -543,9 +560,12 @@ namespace ShaderLab::Controls auto fileExt = std::wstring(file.FileType().c_str()); bool isJxr = (fileExt == L".jxr" || fileExt == L".wdp"); + // PNG uses the _SRGB variant: encode the linear scene on + // write (plain UNORM wrote linear bytes -> dark in viewers). + // JXR stays FP16 linear scRGB. DXGI_FORMAT renderFormat = isJxr ? DXGI_FORMAT_R16G16B16A16_FLOAT - : DXGI_FORMAT_B8G8R8A8_UNORM; + : DXGI_FORMAT_B8G8R8A8_UNORM_SRGB; winrt::com_ptr renderBitmap; D2D1_BITMAP_PROPERTIES1 bmpProps = D2D1::BitmapProperties1( @@ -621,7 +641,22 @@ namespace ShaderLab::Controls if (m_fpsText) m_fpsText.Text(L"Saved: " + file.Name()); } - catch (...) {} + catch (const winrt::hresult_error& ex) + { + // Every step above is check_hresult'd, so a WIC/D2D failure lands + // here. Report it where the success message goes -- silently doing + // nothing after the user picked a file reads as a no-op UI bug. + OutputDebugStringW(std::format(L"[OutputWindow] Save failed: {}\n", + std::wstring_view(ex.message())).c_str()); + if (m_fpsText) + m_fpsText.Text(L"Save failed: " + ex.message()); + } + catch (...) + { + OutputDebugStringW(L"[OutputWindow] Save failed (non-hresult exception)\n"); + if (m_fpsText) + m_fpsText.Text(L"Save failed"); + } } // ----------------------------------------------------------------------- @@ -682,7 +717,12 @@ namespace ShaderLab::Controls m_needsFit = true; } } - catch (...) {} + catch (...) + { + // Deliberate swallow: reading XAML layout properties races + // window teardown. Leaving m_needsResize alone just means the + // next tick re-reads the size, which is the desired behavior. + } } // Handle pending swap-chain resize before consuming a frame. diff --git a/Effects/ColorMath.cpp b/Effects/ColorMath.cpp index 7a3b880..8c2a431 100644 --- a/Effects/ColorMath.cpp +++ b/Effects/ColorMath.cpp @@ -226,40 +226,88 @@ static const float3x3 ICTCP_TO_PQLMS = float3x3( 1.0, 0.560031336, -0.320627175 ); +// PQ with a signed extension, mirroring the curve through the origin the +// same way LabF does for CIE Lab. PQ itself is only defined for +// non-negative light, but scRGB expresses wide-gamut colors as negative +// Rec.709 components -- a BT.2020 green is (-0.87, +1.0, +0.06)-ish. A +// hard clamp there is not a safety net, it is an sRGB gamut clip applied +// before any colour science runs. Mirroring keeps the excursion +// representable so the round trip is lossless. +float PQ_InvEOTF_Signed(float L) { + float v = PQ_InvEOTF(abs(L)); + return (L < 0.0) ? -v : v; +} + +float PQ_EOTF_Signed(float N) { + float v = PQ_EOTF(abs(N)); + return (N < 0.0) ? -v : v; +} + // scRGB -> ICtCp float3 ScRGBToICtCp(float3 rgb) { - // scRGB (1.0 = 80 nits) -> absolute luminance XYZ - float3 xyz = ScRGBToXYZ(max(rgb, 0.0)); + // scRGB (1.0 = 80 nits) -> absolute luminance XYZ. No clamp: negative + // components carry wide-gamut chroma, and the LMS mixing below is + // non-negative for every physically realizable colour anyway (the + // BT.2124 cone primaries enclose the visible locus), so the signed PQ + // only engages on genuinely out-of-locus or below-black input. + float3 xyz = ScRGBToXYZ(rgb); // Scale to absolute nits for PQ (XYZ Y=1 = 80 nits in scRGB) xyz *= 80.0; float3 lms = mul(XYZ_TO_LMS_ICTCP, xyz); - lms = max(lms, 0.0); - // PQ encode each LMS component (input in nits, output [0,1]) + // PQ encode each LMS component (input in nits, output [-1,1]) float3 pqLms = float3( - PQ_InvEOTF(lms.x), - PQ_InvEOTF(lms.y), - PQ_InvEOTF(lms.z)); + PQ_InvEOTF_Signed(lms.x), + PQ_InvEOTF_Signed(lms.y), + PQ_InvEOTF_Signed(lms.z)); return mul(PQLMS_TO_ICTCP, pqLms); } // ICtCp -> scRGB float3 ICtCpToScRGB(float3 ictcp) { float3 pqLms = mul(ICTCP_TO_PQLMS, ictcp); - // Defensive clamp: PQ_EOTF is only defined for V in [0, 1]. Out-of-range - // pqLms (which can happen when callers modify I-channel without rescaling - // Ct/Cp, or with out-of-gamut chroma) produce NaN/Inf via the EOTF. - pqLms = saturate(pqLms); + // Magnitude clamp: PQ_EOTF's rational form goes singular past |V| = 1 + // (the denominator crosses zero) and yields NaN/Inf, which callers can + // reach by moving I without rescaling Ct/Cp. Clamp the magnitude and + // keep the sign so wide-gamut excursions survive. + pqLms = clamp(pqLms, -1.0, 1.0); // PQ decode to nits float3 lms = float3( - PQ_EOTF(pqLms.x), - PQ_EOTF(pqLms.y), - PQ_EOTF(pqLms.z)); + PQ_EOTF_Signed(pqLms.x), + PQ_EOTF_Signed(pqLms.y), + PQ_EOTF_Signed(pqLms.z)); float3 xyz = mul(LMS_TO_XYZ_ICTCP, lms); // Scale back from nits to scRGB (80 nits = 1.0) xyz /= 80.0; return XYZToScRGB(xyz); } +// ---- Delta E ITP (ITU-R BT.2124) ---- +// +// The HDR/WCG colour-difference metric. CIE Lab's dE76/94/2000 were derived +// from reflective samples under SDR viewing and lose meaning above roughly +// 100 nits and outside sRGB -- exactly where this pipeline operates -- so +// dE ITP is the correct ruler for tone-mapping and gamut work here. +// +// dE_ITP = 720 * sqrt( dI^2 + dT^2 + dP^2 ), T = 0.5 * Ct, P = Cp +// +// The 0.5 on Ct converts BT.2100 ICtCp into the "ITP" difference space +// (Ct's range is twice Cp's); the 720 scales one unit to approximately one +// JND, so it is directly comparable to a dE2000 of 1. +// +// BT.2124 is defined on PQ-encoded ICtCp, which is what ScRGBToICtCp +// produces. Takes ICtCp triples, not scRGB -- convert first. +float DeltaEITP(float3 ictcp1, float3 ictcp2) { + float dI = ictcp1.x - ictcp2.x; + float dT = 0.5 * (ictcp1.y - ictcp2.y); + float dP = ictcp1.z - ictcp2.z; + return 720.0 * sqrt(dI * dI + dT * dT + dP * dP); +} + +// Convenience: dE ITP straight from two scRGB colours. +float DeltaEITPFromScRGB(float3 rgb1, float3 rgb2) { + return DeltaEITP(ScRGBToICtCp(rgb1), ScRGBToICtCp(rgb2)); +} + // OKLab: linear sRGB -> OKLab float3 LinearToOKLab(float3 c) { float l = 0.4122214708 * c.r + 0.5363325363 * c.g + 0.0514459929 * c.b; @@ -319,6 +367,66 @@ float ReinhardExpandI(float I, float peakIn_I, float peakOut_I) { float denom = pp - Ic * (peakIn_I - peakOut_I); return Ic * pp / max(denom, 1e-12); } + +// Soft gamut-distance compression (1D). `d` is a pixel's chroma radius +// normalized so the gamut boundary sits at 1.0 (d < 1 in-gamut, d > 1 +// out). Returns the remapped radius. Contract: +// - d <= threshold -> returned unchanged (identity zone) +// - d == limit -> maps exactly to 1.0 (the boundary) +// - monotone increasing, C1 at d == threshold (slope 1 where the +// curve meets the identity segment, so gradients don't kink) +// - d > limit -> may exceed 1.0 slightly (ACES-style; +// callers pick `limit` to cover their expected source range) +// threshold in [0, 1): where compression starts, e.g. 0.75. +// limit > 1: the source radius that lands exactly on the boundary. +// power >= 1: knee hardness. 1 reduces exactly to Reinhard; higher +// values track identity longer and turn harder near the boundary +// (less desaturation of legal colors, more crowding of illegal ones). +// ACES RGC ships 1.2. +// ---- 8-bit display-referred output helpers ----------------------------- +// These exist for the screenshot path, where the handback is 8bpc sRGB and +// we therefore own the quantizer. The above-white band survives the tone +// curve inside a very small number of codes (a 0.7*W knee leaves roughly 33 +// of 256 after the sRGB OETF), so quantizing without dither bands visibly +// in exactly the smooth HDR gradients the feature exists to preserve. + +// Interleaved Gradient Noise (Jimenez 2014). Cheap, deterministic, and +// spectrally much better behaved than a hash-based white noise, which makes +// it a reasonable dither source when a blue-noise texture isn't available. +// Expects integer pixel coordinates; returns [0, 1). +float InterleavedGradientNoise(float2 p) { + return frac(52.9829189 * frac(dot(p, float2(0.06711056, 0.00583715)))); +} + +// Triangular-PDF dither, [-1, 1] LSB. The sum of two independent uniforms +// decorrelates the quantization error from the signal; plain uniform dither +// leaves a residual pattern modulated by the signal itself. +float TriangularDither(float2 p) { + float n1 = InterleavedGradientNoise(p); + float n2 = InterleavedGradientNoise(p + 5.588238); + return n1 + n2 - 1.0; +} + +// Quantize an already-encoded [0,1] value to `levels` steps with dither. +// `strength` scales the dither in LSBs (1.0 = standard TPDF, 0 = none). +float3 DitherQuantize(float3 encoded, float2 p, float levels, float strength) { + float maxCode = max(levels - 1.0, 1.0); + float3 d = TriangularDither(p) * 0.5 * strength; + return saturate(round(saturate(encoded) * maxCode + d) / maxCode); +} + +float SoftCompressDistance(float d, float threshold, float limit, float power) { + float t = clamp(threshold, 0.0, 0.99); + float l = max(limit, 1.01); + float p = clamp(power, 1.0, 8.0); + if (d <= t) return d; + // ACES-RGC-style power curve y = t + x / (1 + (x/s)^p)^(1/p), with + // the scale s solved from the anchor f(l - t) == 1 - t, so d == l + // lands exactly on the boundary. f'(0) == 1 keeps the join C1. + float x = d - t; + float s = (l - t) / pow(pow((l - t) / (1.0 - t), p) - 1.0, 1.0 / p); + return t + x / pow(1.0 + pow(x / s, p), 1.0 / p); +} )HLSL"; SHADERLAB_API const std::string& GetColorMathHLSL() diff --git a/Effects/Performance.cpp b/Effects/Performance.cpp index ec85ab1..63a53e4 100644 --- a/Effects/Performance.cpp +++ b/Effects/Performance.cpp @@ -63,4 +63,16 @@ namespace ShaderLab::Performance { g_hintThrottleMs.store(ms, std::memory_order_relaxed); } + + namespace { std::atomic g_outputCaching{ true }; } + + bool IsEffectOutputCachingEnabled() + { + return g_outputCaching.load(std::memory_order_relaxed); + } + + void SetEffectOutputCachingEnabled(bool enabled) + { + g_outputCaching.store(enabled, std::memory_order_relaxed); + } } diff --git a/Effects/Performance.h b/Effects/Performance.h index b43b7a1..16830c5 100644 --- a/Effects/Performance.h +++ b/Effects/Performance.h @@ -83,4 +83,17 @@ namespace ShaderLab::Performance // Internal: bumped by the evaluator when a binding is detected as // GPU-routable. Exported for engine-side use only. SHADERLAB_API void IncrementGpuBindingDetection(); + + // Clean-subgraph output caching: when enabled, the evaluator sets + // D2D1_PROPERTY_CACHED on per-node effect outputs so re-drawing the + // terminal does not re-execute pixel passes whose subtree is + // unchanged, and invalidates those caches off the dirty walk (the + // D2D-invisible in-place texture updates: compute re-dispatch, video + // and live-capture uploads). Costs one GPU intermediate per cached + // node at its output resolution. + // + // Default ON; the flag exists as a kill switch if a stale-frame + // regression is suspected. + SHADERLAB_API bool IsEffectOutputCachingEnabled(); + SHADERLAB_API void SetEffectOutputCachingEnabled(bool enabled); } diff --git a/Effects/ShaderLabEffects.cpp b/Effects/ShaderLabEffects.cpp index 9d41e3e..7252755 100644 --- a/Effects/ShaderLabEffects.cpp +++ b/Effects/ShaderLabEffects.cpp @@ -329,6 +329,15 @@ void main(uint3 dtid : SV_DispatchThreadID) return node; } + // Default edge length for synthetic sources and diagram-style viewers. + // Held at 1024 rather than 512: two-input pixel-shader effects (Split + // Comparison et al.) render displaced when fed inputs <= 512px, so a + // smaller default silently breaks any A/B comparison built on these + // nodes. See the "Known issues" entry in CHANGELOG.md. The parameter + // minimums are deliberately left where they are -- a small diagram is + // still useful standalone, and cheap for the O(N)-per-pixel viewers. + static constexpr float kDefaultDiagramSize = 1024.0f; + void ShaderLabEffects::RegisterAll() { const auto& colorMath = GetColorMathHLSL(); @@ -744,7 +753,7 @@ float4 main( { L"ShowP3", L"float", 1.0f, 0.0f, 1.0f, 1.0f, { L"Hide", L"Show" } }, { L"ShowRec2020", L"float", 1.0f, 0.0f, 1.0f, 1.0f, { L"Hide", L"Show" } }, { L"Brightness", L"float", 2.0f, 0.1f, 10.0f, 0.1f }, - { L"DiagramSize", L"float", 512.0f, 128.0f, 4096.0f, 64.0f }, + { L"DiagramSize", L"float", kDefaultDiagramSize, 128.0f, 4096.0f, 64.0f }, // Custom-primary triangle ("monitor" gamut). Bind these to // `Working Space.RedPrimary` etc. for monitor-matched plotting. { L"ShowMonitor", L"float", 1.0f, 0.0f, 1.0f, 1.0f, { L"Hide", L"Show" } }, @@ -829,7 +838,7 @@ float4 main( desc.parameters = { { L"Gamut", L"float", 0.0f, 0.0f, 3.0f, 1.0f, { L"Rec.709", L"DCI-P3", L"Rec.2020", L"Custom" } }, { L"Luminance", L"float", 80.0f, 0.01f, 10000.0f, 10.0f }, - { L"OutputSize", L"float", 512.0f, 128.0f, 4096.0f, 64.0f }, + { L"OutputSize", L"float", kDefaultDiagramSize, 128.0f, 4096.0f, 64.0f }, { L"RedPrimary", L"float2", winrt::Windows::Foundation::Numerics::float2{ 0.64f, 0.33f }, 0.0f, 1.0f, 0.001f, {}, L"Gamut == 3" }, { L"GreenPrimary", L"float2", winrt::Windows::Foundation::Numerics::float2{ 0.30f, 0.60f }, 0.0f, 1.0f, 0.001f, {}, L"Gamut == 3" }, { L"BluePrimary", L"float2", winrt::Windows::Foundation::Numerics::float2{ 0.15f, 0.06f }, 0.0f, 1.0f, 0.001f, {}, L"Gamut == 3" }, @@ -925,7 +934,7 @@ float4 main( cbuffer constants : register(b0) { float Frequency; // default 0.5 - float PlateSize; // pixels (default 512) + float PlateSize; // pixels (default 1024) }; float4 main( @@ -950,7 +959,7 @@ float4 main( desc.inputNames = {}; desc.parameters = { { L"Frequency", L"float", 0.5f, 0.01f, 5.0f, 0.01f }, - { L"PlateSize", L"float", 512.0f, 64.0f, 2048.0f, 64.0f }, + { L"PlateSize", L"float", kDefaultDiagramSize, 64.0f, 2048.0f, 64.0f }, }; m_effects.push_back(std::move(desc)); } @@ -969,7 +978,7 @@ cbuffer constants : register(b0) { float EndR; // end color (scRGB) float EndG; float EndB; - float GradSize; // pixels (default 512) + float GradSize; // pixels (default 1024) }; float4 main( @@ -1011,7 +1020,7 @@ float4 main( { L"EndR", L"float", 1.0f, -1.0f, 125.0f, 0.01f }, { L"EndG", L"float", 1.0f, -1.0f, 125.0f, 0.01f }, { L"EndB", L"float", 1.0f, -1.0f, 125.0f, 0.01f }, - { L"GradSize", L"float", 512.0f, 64.0f, 2048.0f, 64.0f }, + { L"GradSize", L"float", kDefaultDiagramSize, 64.0f, 2048.0f, 64.0f }, }; m_effects.push_back(std::move(desc)); } @@ -1023,7 +1032,7 @@ float4 main( // Source effect: no input required. cbuffer constants : register(b0) { - float PatternSize; // pixels (default 512) + float PatternSize; // pixels (default 1024) }; float4 main( @@ -1092,7 +1101,7 @@ float4 main( desc.hlslSource = colorMath + hdrTestHLSL; desc.inputNames = {}; desc.parameters = { - { L"PatternSize", L"float", 512.0f, 256.0f, 2048.0f, 64.0f }, + { L"PatternSize", L"float", kDefaultDiagramSize, 256.0f, 2048.0f, 64.0f }, }; m_effects.push_back(std::move(desc)); } @@ -1102,7 +1111,13 @@ float4 main( { static const std::string deltaEHLSL = R"HLSL( // Delta E Comparator -- D3D11 compute, per-pixel color difference between -// two inputs (Reference at t0, Test at t1). Supports CIE76, CIE94, CIEDE2000. +// two inputs (Reference at t0, Test at t1). Supports CIE76, CIE94, CIEDE2000 +// and dE ITP (BT.2124). +// +// Prefer ITP for anything HDR or wide-gamut: the three Lab metrics were fit +// to reflective samples under SDR viewing and degrade above ~100 nits and +// outside sRGB. One unit is ~1 JND in all four, so the numbers stay +// comparable when switching. Texture2D Reference : register(t0); Texture2D Test : register(t1); @@ -1112,7 +1127,7 @@ cbuffer Constants : register(b0) { uint Width; uint Height; - uint Method; // 0 = CIE76, 1 = CIE94, 2 = CIEDE2000 + uint Method; // 0 = CIE76, 1 = CIE94, 2 = CIEDE2000, 3 = dE ITP float Scale; // visualization multiplier float MaxDeltaE; // clamp for colormap (dE >= this = full red) uint OutputMode; // 0 = Heatmap (Turbo), 1 = Grayscale dE / MaxDeltaE @@ -1222,14 +1237,21 @@ void main(uint3 dtid : SV_DispatchThreadID) float4 ref = Reference.Load(int3(dtid.xy, 0)); float4 test = Test.Load(int3(dtid.xy, 0)); - float3 labRef = ScRGBToLab(ref.rgb); - float3 labTest = ScRGBToLab(test.rgb); - float dE; uint method = Method; - if (method == 1) dE = DeltaE94(labRef, labTest); - else if (method == 2) dE = DeltaE2000(labRef, labTest); - else dE = DeltaE76(labRef, labTest); + if (method == 3) + { + // dE ITP works in PQ-encoded ICtCp, not Lab -- no XYZ->Lab hop. + dE = DeltaEITPFromScRGB(ref.rgb, test.rgb); + } + else + { + float3 labRef = ScRGBToLab(ref.rgb); + float3 labTest = ScRGBToLab(test.rgb); + if (method == 1) dE = DeltaE94(labRef, labTest); + else if (method == 2) dE = DeltaE2000(labRef, labTest); + else dE = DeltaE76(labRef, labTest); + } dE *= Scale; float mode = OutputMode; @@ -1254,7 +1276,7 @@ void main(uint3 dtid : SV_DispatchThreadID) ShaderLabEffectDescriptor desc; desc.name = L"Delta E Comparator"; - desc.effectId = L"Delta E Comparator"; desc.effectVersion = 6; + desc.effectId = L"Delta E Comparator"; desc.effectVersion = 7; desc.category = L"Analysis"; desc.subcategory = L"Comparison"; desc.shaderType = Graph::CustomShaderType::D3D11ComputeShader; @@ -1265,7 +1287,10 @@ void main(uint3 dtid : SV_DispatchThreadID) desc.hlslSource = colorMath + deltaEHLSL; desc.inputNames = { L"Reference", L"Test" }; desc.parameters = { - { L"Method", L"float", 2.0f, 0.0f, 2.0f, 1.0f, { L"CIE76", L"CIE94", L"CIEDE2000" } }, + // Default is dE ITP: this pipeline is HDR/WCG, where the three + // Lab metrics are out of their fitted domain. Saved graphs keep + // whatever Method they stored, so only new nodes pick it up. + { L"Method", L"float", 3.0f, 0.0f, 3.0f, 1.0f, { L"CIE76", L"CIE94", L"CIEDE2000", L"dE ITP (BT.2124)" } }, { L"Scale", L"float", 1.0f, 0.1f, 10.0f, 0.1f }, { L"MaxDeltaE", L"float", 1.0f, 0.1f, 100.0f, 0.1f }, { L"OutputMode", L"float", 0.0f, 0.0f, 1.0f, 1.0f, { L"Heatmap", L"Grayscale dE" } }, @@ -1402,7 +1427,10 @@ void main(uint3 GTid : SV_GroupThreadID) uint py = pi / Width; float4 s = Source[int2(px, py)]; if (s.a < 0.001) continue; - float3 xyz = ScRGBToXYZ(max(s.rgb, 0.0)); + // Unclamped: negative scRGB components are wide-gamut chroma, and + // clamping them collapses those pixels onto the sRGB hull -- which + // is exactly the coverage this scatter exists to measure. + float3 xyz = ScRGBToXYZ(s.rgb); float sum = xyz.x + xyz.y + xyz.z; if (sum < 1e-6) continue; float cieX = xyz.x / sum; @@ -1470,7 +1498,7 @@ void main(uint3 GTid : SV_GroupThreadID) desc.hlslSource = colorMath + gamutCoverageHLSL; desc.inputNames = { L"Source" }; desc.parameters = { - { L"DiagramSize", L"float", 512.0f, 128.0f, 4096.0f, 64.0f }, + { L"DiagramSize", L"float", kDefaultDiagramSize, 128.0f, 4096.0f, 64.0f }, { L"TargetGamut", L"float", 0.0f, 0.0f, 3.0f, 1.0f, { L"sRGB", L"DCI-P3", L"BT.2020", L"Custom" } }, { L"RedPrimary", L"float2", winrt::Windows::Foundation::Numerics::float2{ 0.64f, 0.33f }, 0.0f, 1.0f, 0.001f, {}, L"TargetGamut == 3" }, { L"GreenPrimary", L"float2", winrt::Windows::Foundation::Numerics::float2{ 0.30f, 0.60f }, 0.0f, 1.0f, 0.001f, {}, L"TargetGamut == 3" }, @@ -1745,7 +1773,7 @@ float4 main( cbuffer Constants : register(b0) { - uint Mode; // 0=Nearest on Shell, 1=Compress to Neutral, 2=Fit to Shell + uint Mode; // 0=Nearest on Shell, 1=Compress to Neutral, 2=Fit to Shell, 3=Soft Compress uint TargetGamut; // 0=sRGB, 1=DCI-P3, 2=BT.2020, 3=Custom float Strength; // 0=bypass, 1=full uint SourceGamut; // 0=sRGB, 1=DCI-P3, 2=BT.2020, 3=Custom @@ -1755,6 +1783,9 @@ cbuffer Constants : register(b0) float2 SourceRedPrimary; float2 SourceGreenPrimary; float2 SourceBluePrimary; + float SoftThreshold; // mode 3: fraction of boundary radius where compression starts + float SoftLimit; // mode 3: source radius (x boundary) that maps onto the boundary + float KneeHardness; // mode 3: ACES p. 1=Reinhard, higher=harder corner }; Texture2D InputTexture : register(t0); @@ -1833,6 +1864,27 @@ float2 CompressNeutral(float2 p, float2 poly[NBP]) return result; } +// Distance from the neutral axis (Ct=Cp=0) to the boundary polygon along +// direction `dir` (unit length). Returns 0 if the ray never hits an edge +// (degenerate polygon), which callers must guard. +float BoundaryRadius(float2 dir, float2 poly[NBP]) +{ + float bestT = 1e10; + for (uint i = 0; i < NBP; i++) + { + uint j = (i + 1) % NBP; + float2 a = poly[i]; + float2 ab = poly[j] - a; + float denom = dir.x * ab.y - dir.y * ab.x; + if (abs(denom) < 1e-10) continue; + float t = (a.x * ab.y - a.y * ab.x) / denom; + float u = (a.x * dir.y - a.y * dir.x) / denom; + if (t > 0.0 && u >= 0.0 && u <= 1.0 && t < bestT) + bestT = t; + } + return (bestT < 1e9) ? bestT : 0.0; +} + // Compute uniform ICtCp scale factor: for each source boundary vertex, // find how far it extends beyond the target boundary (ray from neutral). float ComputeICtCpFitScale(float2 srcBnd[NBP], float2 tgtBnd[NBP]) @@ -1888,6 +1940,9 @@ float4 main( float2 csR = SourceRedPrimary; float2 csG = SourceGreenPrimary; float2 csB = SourceBluePrimary; + float softT = SoftThreshold; + float softL = SoftLimit; + float softP = KneeHardness; float2 gR, gG, gB; uint g = (uint)targetF; @@ -1898,7 +1953,11 @@ float4 main( // Use CIE xy triangle test for reliable in/out-of-gamut detection, // then do the actual mapping in ICtCp for perceptual quality. - float3 xyz = ScRGBToXYZ(max(color.rgb, 0.0)); + // Unclamped: clamping negatives first projects the colour onto the + // sRGB gamut surface, which makes every wide-gamut pixel test as + // *inside* the target and defeats the detection entirely. XYZ stays + // non-negative for real colours, so the xyzSum guard below still holds. + float3 xyz = ScRGBToXYZ(color.rgb); float xyzSum = xyz.x + xyz.y + xyz.z; if (xyzSum < 1e-6) return color; float2 cieXY = float2(xyz.x / xyzSum, xyz.y / xyzSum); @@ -1915,7 +1974,7 @@ float4 main( else if (sg == 3) { sR = csR; sG = csG; sB = csB; } else { sR = GAMUT_709_R; sG = GAMUT_709_G; sB = GAMUT_709_B; } - float3 ictcp = ScRGBToICtCp(max(color.rgb, 0.0)); + float3 ictcp = ScRGBToICtCp(color.rgb); float origY = dot(color.rgb, float3(0.2126, 0.7152, 0.0722)); // Sample both source and target boundaries at this I level @@ -1936,6 +1995,36 @@ float4 main( mappedRGB *= origY / mappedY; color.rgb = mappedRGB; } + else if (mode == 3) + { + // Soft Compress: unlike modes 0/1 this also touches *in-gamut* + // pixels whose chroma radius exceeds SoftThreshold x boundary, + // buying smooth gradients across the boundary at the cost of + // slightly desaturating legal near-boundary colors. + float3 ictcp = ScRGBToICtCp(color.rgb); + float2 ctcp = float2(ictcp.y, ictcp.z); + float r = length(ctcp); + if (r > 1e-6) + { + float origY = dot(color.rgb, float3(0.2126, 0.7152, 0.0722)); + float2 bnd[NBP]; + SampleBoundary(gR, gG, gB, ictcp.x, bnd); + float2 dir = ctcp / r; + float B = BoundaryRadius(dir, bnd); + if (B > 1e-6) + { + float d = r / B; + float dNew = SoftCompressDistance(d, softT, softL, softP); + float2 mapped = dir * (dNew * B); + ctcp = lerp(ctcp, mapped, Strength); + float3 mappedRGB = ICtCpToScRGB(float3(ictcp.x, ctcp.x, ctcp.y)); + float mappedY = dot(max(mappedRGB, 0.0), float3(0.2126, 0.7152, 0.0722)); + if (mappedY > 1e-6) + mappedRGB *= origY / mappedY; + color.rgb = mappedRGB; + } + } + } else { // Modes 0 and 1: per-pixel nearest/compress (only out-of-gamut pixels). @@ -1950,7 +2039,7 @@ float4 main( bool insideGamut = PointInTriangle(cieXY, iR, iG, iB); if (!insideGamut) { - float3 ictcp = ScRGBToICtCp(max(color.rgb, 0.0)); + float3 ictcp = ScRGBToICtCp(color.rgb); float2 ctcp = float2(ictcp.y, ictcp.z); float origY = dot(color.rgb, float3(0.2126, 0.7152, 0.0722)); @@ -1974,17 +2063,20 @@ float4 main( ShaderLabEffectDescriptor desc; desc.name = L"ICtCp Gamut Map"; - desc.effectId = L"ICtCp Gamut Map"; desc.effectVersion = 10; + desc.effectId = L"ICtCp Gamut Map"; desc.effectVersion = 11; desc.category = L"Analysis"; desc.subcategory = L"Gamut Mapping"; desc.shaderType = Graph::CustomShaderType::PixelShader; desc.hlslSource = colorMath + perceptualGamutMapHLSL; desc.inputNames = { L"Source" }; desc.parameters = { - { L"Mode", L"float", 0.0f, 0.0f, 2.0f, 1.0f, { L"Nearest on Shell", L"Compress to Neutral", L"Fit to Shell" } }, + { L"Mode", L"float", 0.0f, 0.0f, 3.0f, 1.0f, { L"Nearest on Shell", L"Compress to Neutral", L"Fit to Shell", L"Soft Compress" } }, { L"TargetGamut", L"float", 0.0f, 0.0f, 3.0f, 1.0f, { L"sRGB", L"DCI-P3", L"BT.2020", L"Custom" } }, { L"Strength", L"float", 1.0f, 0.0f, 1.0f, 0.05f }, { L"SourceGamut", L"float", 2.0f, 0.0f, 3.0f, 1.0f, { L"sRGB", L"DCI-P3", L"BT.2020", L"Custom" }, L"Mode == 2" }, + { L"SoftThreshold", L"float", 0.75f, 0.0f, 0.99f, 0.01f, {}, L"Mode == 3" }, + { L"SoftLimit", L"float", 1.5f, 1.01f, 4.0f, 0.01f, {}, L"Mode == 3" }, + { L"KneeHardness", L"float", 1.2f, 1.0f, 4.0f, 0.05f, {}, L"Mode == 3" }, { L"TargetRedPrimary", L"float2", winrt::Windows::Foundation::Numerics::float2{ 0.64f, 0.33f }, 0.0f, 1.0f, 0.001f, {}, L"TargetGamut == 3" }, { L"TargetGreenPrimary", L"float2", winrt::Windows::Foundation::Numerics::float2{ 0.30f, 0.60f }, 0.0f, 1.0f, 0.001f, {}, L"TargetGamut == 3" }, { L"TargetBluePrimary", L"float2", winrt::Windows::Foundation::Numerics::float2{ 0.15f, 0.06f }, 0.0f, 1.0f, 0.001f, {}, L"TargetGamut == 3" }, @@ -2111,7 +2203,7 @@ float4 main( desc.hlslSource = colorMath + ictcpBoundaryHLSL; desc.inputNames = { L"Source" }; desc.parameters = { - { L"DiagramSize", L"float", 512.0f, 128.0f, 2048.0f, 64.0f }, + { L"DiagramSize", L"float", kDefaultDiagramSize, 128.0f, 2048.0f, 64.0f }, { L"TargetGamut", L"float", 0.0f, 0.0f, 3.0f, 1.0f, { L"sRGB", L"DCI-P3", L"BT.2020", L"Custom" } }, { L"Intensity", L"float", 0.5f, 0.05f, 0.95f, 0.05f }, { L"RedPrimary", L"float2", winrt::Windows::Foundation::Numerics::float2{ 0.64f, 0.33f }, 0.0f, 1.0f, 0.001f, {}, L"TargetGamut == 3" }, @@ -2121,6 +2213,190 @@ float4 main( m_effects.push_back(std::move(desc)); } + // ---- HDR Screenshot Tonemap (8bpc) ---- + // The screenshot pipeline's whole display-referred path in one pass: + // knee tone map -> chroma correction -> gamut compress to sRGB -> + // white-level normalize -> sRGB OETF -> dither -> 8-bit quantize. + // + // Target is a Desktop Duplication capture: fp16 scRGB of the composed + // desktop, mixed HDR and SDR, with SDR white sitting at W/80 because + // DWM composites it at W nits while scRGB defines 1.0 = 80 nits. + // + // Fused rather than chained because the product ships one shader over + // a 4K+ frame: the node-graph equivalent pays three ICtCp round trips + // where this pays one. It ends by DECODING the quantized 8-bit result + // back to linear scRGB, so the graph stays in its linear working + // space and downstream analysis (Delta E, statistics, heatmaps) + // measures exactly the damage the 8-bit handback would do. + { + static const std::string screenshotTonemapHLSL = R"HLSL( +// HDR Screenshot Tonemap (8bpc target) + +cbuffer Constants : register(b0) +{ + float SdrWhiteNits; // W: where DWM composited SDR white + float SourcePeakNits; // content (or display) peak + float KneeRatio; // identity below KneeRatio * W + float ChromaCorrect; // 0 = none, 1 = hold saturation through the curve + float GamutStrength; // 0 = bypass gamut compression + float GamutThreshold; // soft-compress knee start, fraction of boundary + float GamutLimit; // source radius mapping onto the boundary + float GamutHardness; // ACES p + float DitherStrength; // LSBs of TPDF dither, 0 = none + uint Quantize; // 0 = keep float, 1 = 8-bit round trip +}; + +Texture2D Source : register(t0); + +// Boundary sampling, duplicated from ICtCp Gamut Map rather than shared. +// TODO: hoist into the colour-math library once the product's cheaper +// sRGB-specific boundary (all channels within [0, peak]) replaces the +// general polygon walk -- 48 points with a ray intersection per pixel is +// the known hot spot at capture resolution. +#define SNBP 48 + +void SampleSrgbBoundary(float iVal, out float2 bnd[SNBP]) +{ + float nits = PQ_EOTF(iVal); + float Ys = max(nits / 80.0, 0.0001); + uint ppe = SNBP / 3; + for (uint i = 0; i < SNBP; i++) + { + float2 xy; + uint e = i / ppe; + float t = (float)(i % ppe) / (float)ppe; + if (e == 0) xy = lerp(GAMUT_709_R, GAMUT_709_G, t); + else if (e == 1) xy = lerp(GAMUT_709_G, GAMUT_709_B, t); + else xy = lerp(GAMUT_709_B, GAMUT_709_R, t); + float X = (xy.y > 1e-6) ? xy.x * Ys / xy.y : 0; + float Z = (xy.y > 1e-6) ? (1.0 - xy.x - xy.y) * Ys / xy.y : 0; + float3 ic = ScRGBToICtCp(XYZToScRGB(float3(X, Ys, Z))); + bnd[i] = float2(ic.y, ic.z); + } +} + +float SrgbBoundaryRadius(float2 dir, float2 poly[SNBP]) +{ + float bestT = 1e10; + for (uint i = 0; i < SNBP; i++) + { + uint j = (i + 1) % SNBP; + float2 a = poly[i]; + float2 ab = poly[j] - a; + float denom = dir.x * ab.y - dir.y * ab.x; + if (abs(denom) < 1e-10) continue; + float t = (a.x * ab.y - a.y * ab.x) / denom; + float u = (a.x * dir.y - a.y * dir.x) / denom; + if (t > 0.0 && u >= 0.0 && u <= 1.0 && t < bestT) + bestT = t; + } + return (bestT < 1e9) ? bestT : 0.0; +} + +float4 main( + float4 pos : SV_POSITION, + float4 uv0 : TEXCOORD0) : SV_TARGET +{ + float4 color = Source.Load(int3(uv0.xy, 0)); + float W = max(SdrWhiteNits, 1.0); + + float3 ictcp = ScRGBToICtCp(color.rgb); + + // ---- 1. Knee tone map on I ------------------------------------------- + // Identical shape to ICtCp Tone Map: identity at or below the knee, a + // shifted Reinhard above it with slope 1 at the join. Target peak is W, + // so the whole frame lands in [0, W] and step 4 can put white at 1.0. + // KneeRatio is a FRACTION of W, not absolute nits -- an absolute knee + // silently changes meaning the moment the SDR-brightness slider moves. + float peakIn = NitsToI(max(SourcePeakNits, W)); + float peakOut = NitsToI(W); + float kneeI = NitsToI(clamp(KneeRatio, 0.0, 0.999) * W); + float Iout; + if (ictcp.x <= kneeI || peakIn <= peakOut) + Iout = ictcp.x; + else + Iout = kneeI + ReinhardCompressI(ictcp.x - kneeI, peakIn - kneeI, peakOut - kneeI); + + // ---- 2. Chroma correction -------------------------------------------- + // Lowering I while holding Ct/Cp raises saturation (chroma is unchanged + // but lightness dropped), which is what makes naive knee output look + // neon in the highlights. Scaling chroma by Iout/I holds saturation + // constant through the curve; ChromaCorrect blends between the two. + float2 ctcp = float2(ictcp.y, ictcp.z); + if (ictcp.x > 1e-5) + ctcp *= lerp(1.0, Iout / ictcp.x, saturate(ChromaCorrect)); + + // Luminance the chroma stage should preserve, captured before the gamut + // compressor perturbs it. + float3 preGamut = ICtCpToScRGB(float3(Iout, ctcp.x, ctcp.y)); + float preY = dot(max(preGamut, 0.0), float3(0.2126, 0.7152, 0.0722)); + + // ---- 3. Soft gamut compression into sRGB ------------------------------ + if (GamutStrength > 0.001) + { + float r = length(ctcp); + if (r > 1e-6) + { + float2 bnd[SNBP]; + SampleSrgbBoundary(Iout, bnd); + float2 dir = ctcp / r; + float B = SrgbBoundaryRadius(dir, bnd); + if (B > 1e-6) + { + float dNew = SoftCompressDistance(r / B, GamutThreshold, GamutLimit, GamutHardness); + ctcp = lerp(ctcp, dir * (dNew * B), saturate(GamutStrength)); + } + } + } + + float3 mapped = ICtCpToScRGB(float3(Iout, ctcp.x, ctcp.y)); + float mapY = dot(max(mapped, 0.0), float3(0.2126, 0.7152, 0.0722)); + if (mapY > 1e-6) + mapped *= preY / mapY; + + // ---- 4. White-level normalize ---------------------------------------- + // scRGB 1.0 = 80 nits, SDR white sits at W/80, so this puts white at 1.0. + mapped *= 80.0 / W; + + // ---- 5. Residue clamp + sRGB OETF ------------------------------------ + // The soft compressor asymptotes slightly OUTSIDE the boundary by design + // (~1.07x at the default knee), so a clamp here is trimming a few percent + // of overshoot -- not, as before, destroying wide-gamut information. + // It must stay after the gamut stage for that to remain true. + float3 enc = LinearToSRGB(saturate(mapped)); + + // ---- 6. Dither + quantize -------------------------------------------- + if (Quantize != 0) + enc = DitherQuantize(enc, uv0.xy, 256.0, DitherStrength); + + // ---- 7. Back to linear so the graph keeps its working space ---------- + return float4(SRGBToLinear(enc), color.a); +} +)HLSL"; + + ShaderLabEffectDescriptor desc; + desc.name = L"HDR Screenshot Tonemap (8bpc)"; + desc.effectId = L"HDR Screenshot Tonemap"; desc.effectVersion = 1; + desc.category = L"Analysis"; + desc.subcategory = L"Tone Mapping"; + desc.shaderType = Graph::CustomShaderType::PixelShader; + desc.hlslSource = colorMath + screenshotTonemapHLSL; + desc.inputNames = { L"Source" }; + desc.parameters = { + { L"SdrWhiteNits", L"float", 200.0f, 80.0f, 1000.0f, 1.0f }, + { L"SourcePeakNits", L"float", 1000.0f, 80.0f, 10000.0f, 50.0f }, + { L"KneeRatio", L"float", 0.7f, 0.0f, 0.99f, 0.01f }, + { L"ChromaCorrect", L"float", 1.0f, 0.0f, 1.0f, 0.05f }, + { L"GamutStrength", L"float", 1.0f, 0.0f, 1.0f, 0.05f }, + { L"GamutThreshold", L"float", 0.75f, 0.0f, 0.99f, 0.01f }, + { L"GamutLimit", L"float", 1.5f, 1.01f, 4.0f, 0.01f }, + { L"GamutHardness", L"float", 1.2f, 1.0f, 4.0f, 0.05f }, + { L"DitherStrength", L"float", 1.0f, 0.0f, 2.0f, 0.05f }, + { L"Quantize", L"float", 1.0f, 0.0f, 1.0f, 1.0f, { L"Off (float)", L"8-bit" } }, + }; + m_effects.push_back(std::move(desc)); + } + // ---- ICtCp Round-Trip Validator ---- // Diagnostic effect: passes input through scRGB -> ICtCp -> scRGB // and outputs |out - in| * Gain. A correct implementation renders @@ -2199,6 +2475,7 @@ cbuffer constants : register(b0) { SHADERLAB_PARAM(float, TargetPeakNits) // SDR target peak (e.g. 80, 203) float Strength; // 0..1 lerp from identity to compressed+lifted float ToneLift; // 0..1 mid-tone lift + float KneeNits; // identity below this; 0 = compress from black }; [numthreads(8, 8, 1)] @@ -2214,7 +2491,27 @@ void main(uint3 dtid : SV_DispatchThreadID) float peakIn = NitsToI(SourcePeakNits); float peakOut = NitsToI(TargetPeakNits); - float compressed = ReinhardCompressI(ictcp.x, peakIn, peakOut); + + // Optional knee (BT.2390-EETF-style): content at or below KneeNits + // passes through UNCHANGED; only [knee, SourcePeak] compresses into + // [knee, TargetPeak]. This is the mixed-content-safe shape for the + // primary screenshot workflow — an HDR capture of a mixed SDR/HDR + // desktop converts to SDR with the SDR windows kept at their + // presentation brightness while true-HDR highlights roll off into + // the remaining headroom. The shifted Reinhard has slope 1 at the + // knee (C1-continuous). KneeNits = 0 reproduces the classic + // compress-from-black curve exactly. + float kneeI = NitsToI(clamp(KneeNits, 0.0, TargetPeakNits * 0.999)); + float compressed; + if (ictcp.x <= kneeI || peakIn <= peakOut) + { + compressed = ictcp.x; + } + else + { + compressed = kneeI + ReinhardCompressI( + ictcp.x - kneeI, peakIn - kneeI, peakOut - kneeI); + } // Anchored polynomial lift in I-space, applied AFTER compression. // Curve: f(x) = x + a*x*(1-x), evaluated in normalized [0, peakOut] @@ -2236,7 +2533,7 @@ void main(uint3 dtid : SV_DispatchThreadID) )HLSL"; ShaderLabEffectDescriptor desc; desc.name = L"ICtCp Tone Map (HDR -> SDR)"; - desc.effectId = L"ICtCp Tone Map"; desc.effectVersion = 12; + desc.effectId = L"ICtCp Tone Map"; desc.effectVersion = 13; desc.category = L"Analysis"; desc.subcategory = L"Tone Mapping"; desc.shaderType = Graph::CustomShaderType::D3D11ComputeShader; @@ -2255,6 +2552,7 @@ void main(uint3 dtid : SV_DispatchThreadID) Graph::ParameterDefinition{ L"TargetPeakNits", L"float", 203.0f, 80.0f, 500.0f, 1.0f, {}, L"", true }, Graph::ParameterDefinition{ L"Strength", L"float", 1.0f, 0.0f, 1.0f, 0.05f }, Graph::ParameterDefinition{ L"ToneLift", L"float", 0.0f, 0.0f, 1.0f, 0.05f }, + Graph::ParameterDefinition{ L"KneeNits", L"float", 0.0f, 0.0f, 500.0f, 1.0f }, }; m_effects.push_back(std::move(desc)); } @@ -2281,6 +2579,7 @@ cbuffer constants : register(b0) { SHADERLAB_PARAM(float, TargetPeakNits) // typical 1000-10000 float Strength; // 0..1 lerp from identity to expanded float DiffuseWhiteNits; // shadow/mid anchor (HDR paper white) + float KneeNits; // identity below this; 0 = expand from black }; [numthreads(8, 8, 1)] @@ -2300,17 +2599,43 @@ void main(uint3 dtid : SV_DispatchThreadID) // [0, sdrI] (SDR range); the helper returns I in [0, hdrI] (HDR). float sdrI = NitsToI(SourcePeakNits); float hdrI = NitsToI(TargetPeakNits); - float expanded = ReinhardExpandI(ictcp.x, hdrI, sdrI); + + // Optional knee: content at or below KneeNits passes through + // UNCHANGED; only the range above expands toward the target peak. + // This is the mixed-content-safe shape (BT.2390-style): an image + // whose "SDR chunk" is already presentation-referenced (e.g. white + // level boosted, or a mixed SDR/HDR composite) keeps that region + // intact instead of blowing everything above SourcePeakNits to the + // peak. The shifted inverse-Reinhard has slope 1 at the knee, so + // the curve is C1-continuous there. KneeNits = 0 reproduces the + // classic expand-from-black curve exactly. + float kneeI = NitsToI(clamp(KneeNits, 0.0, SourcePeakNits * 0.999)); + float expanded; + if (ictcp.x <= kneeI || hdrI <= sdrI) + { + expanded = ictcp.x; + } + else + { + expanded = kneeI + ReinhardExpandI( + ictcp.x - kneeI, hdrI - kneeI, sdrI - kneeI); + } // Shadow/mid anchor: the pure inverse-Reinhard has slope 1 at black // in I-space, so shadows keep their SDR nit levels while the rest of // the picture expands -- perceptually crushed blacks. Let the low end // instead scale like an SDR presentation at DiffuseWhiteNits paper // white (nits x D/S, the BT.2446-style lift), and let the expansion - // curve take over wherever it exceeds that. - float diffuseScale = max(DiffuseWhiteNits, 1.0) / max(SourcePeakNits, 1.0); - float lifted = NitsToI(IToNits(ictcp.x) * diffuseScale); - expanded = min(max(expanded, lifted), hdrI); + // curve take over wherever it exceeds that. Skipped when a knee is + // set -- the knee's contract is bit-exact identity below it, which + // a lift would violate. + if (KneeNits < 1.0) + { + float diffuseScale = max(DiffuseWhiteNits, 1.0) / max(SourcePeakNits, 1.0); + float lifted = NitsToI(IToNits(ictcp.x) * diffuseScale); + expanded = max(expanded, lifted); + } + expanded = min(expanded, hdrI); ictcp.x = lerp(ictcp.x, expanded, saturate(Strength)); @@ -2320,7 +2645,7 @@ void main(uint3 dtid : SV_DispatchThreadID) )HLSL"; ShaderLabEffectDescriptor desc; desc.name = L"ICtCp Inverse Tone Map (SDR -> HDR)"; - desc.effectId = L"ICtCp Inverse Tone Map"; desc.effectVersion = 12; + desc.effectId = L"ICtCp Inverse Tone Map"; desc.effectVersion = 13; desc.category = L"Analysis"; desc.subcategory = L"Tone Mapping"; desc.shaderType = Graph::CustomShaderType::D3D11ComputeShader; @@ -2335,6 +2660,7 @@ void main(uint3 dtid : SV_DispatchThreadID) Graph::ParameterDefinition{ L"TargetPeakNits", L"float", 1000.0f, 100.0f, 10000.0f, 50.0f, {}, L"", true }, Graph::ParameterDefinition{ L"Strength", L"float", 1.0f, 0.0f, 1.0f, 0.05f }, Graph::ParameterDefinition{ L"DiffuseWhiteNits", L"float", 203.0f, 80.0f, 400.0f, 1.0f }, + Graph::ParameterDefinition{ L"KneeNits", L"float", 0.0f, 0.0f, 500.0f, 1.0f }, }; m_effects.push_back(std::move(desc)); } @@ -2469,15 +2795,15 @@ void main(uint3 dtid : SV_DispatchThreadID) // // LineWidth controls the dividing line thickness in pixels. // -// Inputs of mismatched dimensions are stretched to the union output rect -// via normalized-UV Sample() (linear-filtered) -- so feeding e.g. a -// 4K source on ImageA and a 1080p tone-mapped result on ImageB still -// fills both halves of the wipe, instead of returning black for any -// out-of-bounds Load on the smaller input. +// Both inputs are read at the output coordinate. The effect runs with +// D2D1_PIXEL_OPTIONS_TRIVIAL_SAMPLING, whose contract is that a pixel +// shader reads its inputs 1:1 with the output; an input smaller than the +// union output rect therefore covers only its own region and reads black +// elsewhere. Put a Scale node upstream to compare branches of differing +// size. Texture2D ImageA : register(t0); Texture2D ImageB : register(t1); -SamplerState LinearSampler : register(s0); cbuffer Constants : register(b0) { @@ -2488,15 +2814,6 @@ cbuffer Constants : register(b0) // 45 = top-left-to-bottom-right diagonal float OutputW; // host-injected: union of input *content* widths float OutputH; // host-injected: union of input *content* heights - // Per-input content dimensions. D2D pixel-shader inputs live inside - // atlas allocations that are larger than the actual content rect (e.g. - // 1920x1080 content in a 4096x4096 atlas). [0,1] UV with Sample() maps - // to the full atlas, so we have to scale by content/atlas to land on - // the content sub-rect. - float ImageAW; - float ImageAH; - float ImageBW; - float ImageBH; }; float4 main( @@ -2510,28 +2827,9 @@ float4 main( float W = max(OutputW, 1.0); float H = max(OutputH, 1.0); - // Sample each input through its own atlas-aware UV. Logic: - // uvNormOutput = uv0 / (W,H) -> [0,1] across the wipe canvas - // contentUV = uvNormOutput * contentSize -> pixel coords within - // the input's content rect - // atlasUV = contentUV / atlasSize -> [0..content/atlas] within - // the actual D2D texture - // For exactly-sized inputs (compute outputs) atlas == content so atlasUV - // is the simple [0,1] mapping. For atlas-padded inputs (D2D pixel-shader - // outputs), atlas > content so atlasUV is < 1 and stays within content. - float2 atlasA, atlasB; - ImageA.GetDimensions(atlasA.x, atlasA.y); - ImageB.GetDimensions(atlasB.x, atlasB.y); - atlasA = max(atlasA, float2(1.0, 1.0)); - atlasB = max(atlasB, float2(1.0, 1.0)); - - float2 uvA = uv0.xy * float2(max(ImageAW, 1.0), max(ImageAH, 1.0)) - / (float2(W, H) * atlasA); - float2 uvB = uv0.xy * float2(max(ImageBW, 1.0), max(ImageBH, 1.0)) - / (float2(W, H) * atlasB); - - float4 a = ImageA.Sample(LinearSampler, uvA); - float4 b = ImageB.Sample(LinearSampler, uvB); + int3 texel = int3(uv0.xy, 0); + float4 a = ImageA.Load(texel); + float4 b = ImageB.Load(texel); // Direction vector along which we project pixel positions. float radians = Angle * 3.14159265 / 180.0; @@ -2562,7 +2860,7 @@ float4 main( ShaderLabEffectDescriptor desc; desc.name = L"Split Comparison"; - desc.effectId = L"Split Comparison"; desc.effectVersion = 6; + desc.effectId = L"Split Comparison"; desc.effectVersion = 7; desc.category = L"Analysis"; desc.subcategory = L"Comparison"; desc.shaderType = Graph::CustomShaderType::PixelShader; @@ -2572,15 +2870,12 @@ float4 main( { L"SplitPosition", L"float", 0.5f, 0.0f, 1.0f, 0.01f }, { L"LineWidth", L"float", 2.0f, 0.0f, 10.0f, 0.5f }, { L"Angle", L"float", 0.0f, -360.0f, 360.0f, 1.0f }, - // Hidden: host writes actual output-rect dimensions and - // per-input content dimensions every frame (see - // GraphEvaluator's pixel-shader eval). + // Hidden: host writes the actual output-rect dimensions + // every frame (see GraphEvaluator's pixel-shader eval). + // The per-input ImageAW/AH/BW/BH pair dropped in v7 along + // with the atlas-compensating Sample() path. Graph::ParameterDefinition{ L"OutputW", L"float", 1.0f, 1.0f, 16384.0f, 1.0f, {}, L"", true }, Graph::ParameterDefinition{ L"OutputH", L"float", 1.0f, 1.0f, 16384.0f, 1.0f, {}, L"", true }, - Graph::ParameterDefinition{ L"ImageAW", L"float", 1.0f, 1.0f, 16384.0f, 1.0f, {}, L"", true }, - Graph::ParameterDefinition{ L"ImageAH", L"float", 1.0f, 1.0f, 16384.0f, 1.0f, {}, L"", true }, - Graph::ParameterDefinition{ L"ImageBW", L"float", 1.0f, 1.0f, 16384.0f, 1.0f, {}, L"", true }, - Graph::ParameterDefinition{ L"ImageBH", L"float", 1.0f, 1.0f, 16384.0f, 1.0f, {}, L"", true }, }; m_effects.push_back(std::move(desc)); } diff --git a/Effects/SourceNodeFactory.cpp b/Effects/SourceNodeFactory.cpp index 006a46e..2225413 100644 --- a/Effects/SourceNodeFactory.cpp +++ b/Effects/SourceNodeFactory.cpp @@ -307,18 +307,31 @@ namespace ShaderLab::Effects // --- Image source --- if (node.shaderPath.has_value() && !node.effectClsid.has_value()) { + // Reuse the decoded bitmap unless the FILE changed. `node.dirty` + // means "re-evaluate downstream", not "reload from disk": + // MarkAllDirty() fires on node add/remove, preview switch, + // display-profile change and every capture, and the render tick + // only calls in here when the node is dirty -- so keying the + // decode on that flag re-ran a full WIC decode + colour- + // management pass on each of those events (~98 ms on a 4K JPEG, + // and it dominated the frame). + const auto& path = node.shaderPath.value(); auto it = m_bitmapCache.find(node.id); - if (it != m_bitmapCache.end() && !node.dirty) + auto pathIt = m_bitmapPathCache.find(node.id); + if (it != m_bitmapCache.end() && it->second && + pathIt != m_bitmapPathCache.end() && pathIt->second == path) { node.cachedOutput = it->second.get(); + node.dirty = false; return; } - auto bitmap = m_imageLoader.LoadFromFile(node.shaderPath.value(), dc); + auto bitmap = m_imageLoader.LoadFromFile(path, dc); if (bitmap) { node.cachedOutput = bitmap.get(); m_bitmapCache[node.id] = std::move(bitmap); + m_bitmapPathCache[node.id] = path; node.dirty = false; } return; @@ -368,6 +381,7 @@ namespace ShaderLab::Effects void SourceNodeFactory::ReleaseCache() { m_bitmapCache.clear(); + m_bitmapPathCache.clear(); m_floodCache.clear(); m_videoCache.clear(); m_dxgiCaptureCache.clear(); diff --git a/Effects/SourceNodeFactory.h b/Effects/SourceNodeFactory.h index a5510e0..b7e1817 100644 --- a/Effects/SourceNodeFactory.h +++ b/Effects/SourceNodeFactory.h @@ -95,6 +95,11 @@ namespace ShaderLab::Effects // Cached loaded bitmaps: nodeId → bitmap. std::unordered_map> m_bitmapCache; + // Path the cached bitmap was decoded from: nodeId → path. The decode + // is invalidated by the FILE changing, not by node.dirty -- see the + // image branch of PrepareSourceNode. + std::unordered_map m_bitmapPathCache; + // Cached flood effects: nodeId → flood effect. std::unordered_map> m_floodCache; diff --git a/Engine/Mcp/EngineMcpRoutes.cpp b/Engine/Mcp/EngineMcpRoutes.cpp index 903af05..2f774f0 100644 --- a/Engine/Mcp/EngineMcpRoutes.cpp +++ b/Engine/Mcp/EngineMcpRoutes.cpp @@ -1046,6 +1046,12 @@ namespace ShaderLab::Mcp else if (want == L"bool") node->properties[key] = (sval == L"true" || sval == L"1"); else node->properties[key] = sval; } + // Not-a-number: keep the raw string rather than + // dropping the write. Note this lands back in + // the broken state the coercion above exists to + // prevent (a wstring in a numeric slot), so it + // should only ever be reached for a genuinely + // non-numeric value the caller sent by mistake. catch (...) { node->properties[key] = sval; } break; } @@ -1231,6 +1237,9 @@ namespace ShaderLab::Mcp else if (want == L"bool") node.properties[key] = (sval == L"true" || sval == L"1"); else node.properties[key] = sval; } + // See the matching note in /graph/set-property: + // keeping the raw string preserves the write but + // lands back in the state the coercion prevents. catch (...) { node.properties[key] = sval; } break; } diff --git a/Engine/Mcp/McpRouter.cpp b/Engine/Mcp/McpRouter.cpp index 7c14dc4..d2866c1 100644 --- a/Engine/Mcp/McpRouter.cpp +++ b/Engine/Mcp/McpRouter.cpp @@ -99,7 +99,13 @@ namespace ShaderLab if (cb) { try { cb("POST", L"/", resp.statusCode, "session"); } - catch (...) {} + catch (...) + { + // Deliberate swallow: this callback only drives a host UI + // activity indicator. A throwing indicator must never turn + // an otherwise-successful MCP request into a failure, and + // the response is already built at this point. + } } } return resp; diff --git a/Engine/Mcp/McpSessionClient.cpp b/Engine/Mcp/McpSessionClient.cpp index 4426e1c..a7f9e2a 100644 --- a/Engine/Mcp/McpSessionClient.cpp +++ b/Engine/Mcp/McpSessionClient.cpp @@ -68,7 +68,27 @@ namespace ShaderLab::Mcp McpRouter& router; SessionClientOptions opts; std::atomic stop{ false }; - std::atomic pipe{ INVALID_HANDLE_VALUE }; + + // `pipe` and `runnerThread` are guarded by ioMutex. + // + // The pipe is opened WITHOUT FILE_FLAG_OVERLAPPED, so every read and + // write on it is synchronous. Per the Win32 cancellation rules that + // means Stop() must unblock the session thread with + // CancelSynchronousIo() -- CancelIo/CancelIoEx only + // cancel ASYNCHRONOUS operations -- and it must NOT close the handle, + // because the session thread is concurrently inside ReadFile / + // WriteFile on it. Closing a handle out from under in-flight I/O is + // undefined: a Debug build raises STATUS_INVALID_HANDLE (0xC0000008), + // and once the value is recycled by any other thread in the process + // the session thread can write MCP bytes into an unrelated object. + // ServeOnce (the owning thread) does the close. + // + // ioMutex is only ever held around publishing / cancelling / closing + // these handles -- never across blocking I/O -- so Stop() cannot be + // delayed by an in-flight request. + std::mutex ioMutex; + HANDLE pipe{ INVALID_HANDLE_VALUE }; + HANDLE runnerThread{ nullptr }; // owned duplicate // Per-channel acceptor state. struct Channel @@ -92,13 +112,29 @@ namespace ShaderLab::Mcp 0, nullptr, OPEN_EXISTING, 0, nullptr); if (h == INVALID_HANDLE_VALUE) return; - pipe.store(h); + { + // Publish under the lock, and bail if Stop() fired while we + // were connecting -- otherwise this handle is one Stop() never + // saw and the session keeps serving after being disabled. + std::lock_guard lock(ioMutex); + if (stop.load()) + { + CloseHandle(h); + return; + } + pipe = h; + } // Verify hub identity + pairing before registering. auto self = ResolveProcessIdentity(GetCurrentProcessId()); auto hub = ResolvePipeServerIdentity(h); uint64_t outSeq = 1; - bool ok = self && hub; + // `stop` is checked at each handshake step, not just in the serve + // loop below. Disabling MCP on a freshly launched window lands + // here -- the client is still connecting / exchanging hello -- and + // without these checks the handshake ran to completion against a + // cancelled pipe before anyone noticed the toggle. + bool ok = self && hub && !stop.load(); if (ok) { auto myBuild = WideToUtf8(LocalBuildId()); @@ -110,7 +146,7 @@ namespace ShaderLab::Mcp } std::vector acc; - if (ok) + if (ok && !stop.load()) { Frame ack; if (ReadFrameBlocking(h, acc, ack) && ack.header.channelId == 0) @@ -135,7 +171,13 @@ namespace ShaderLab::Mcp } } - HANDLE cur = pipe.exchange(INVALID_HANDLE_VALUE); + // This thread owns the close -- see the ioMutex note above. + HANDLE cur = INVALID_HANDLE_VALUE; + { + std::lock_guard lock(ioMutex); + cur = pipe; + pipe = INVALID_HANDLE_VALUE; + } if (cur != INVALID_HANDLE_VALUE) CloseHandle(cur); } @@ -229,6 +271,23 @@ namespace ShaderLab::Mcp void McpSessionClient::Run() { + // Publish a real handle to this thread so Stop() can cancel our + // blocking synchronous pipe I/O. GetCurrentThread() is a pseudo-handle + // that only means "me" in the thread that calls it, so it has to be + // duplicated into something another thread can pass to + // CancelSynchronousIo (which needs THREAD_TERMINATE access -- + // DUPLICATE_SAME_ACCESS on the pseudo-handle grants it). + { + HANDLE dup = nullptr; + if (DuplicateHandle(GetCurrentProcess(), GetCurrentThread(), + GetCurrentProcess(), &dup, + 0, FALSE, DUPLICATE_SAME_ACCESS)) + { + std::lock_guard lock(m_impl->ioMutex); + m_impl->runnerThread = dup; + } + } + uint32_t backoffMs = 250; while (!m_impl->stop.load()) { @@ -239,13 +298,36 @@ namespace ShaderLab::Mcp Sleep(backoffMs); backoffMs = std::min(backoffMs * 2, 4000); } + + // Retire the thread handle before returning: once we are gone a + // late Stop() must not hand a dead thread to CancelSynchronousIo. + HANDLE dup = nullptr; + { + std::lock_guard lock(m_impl->ioMutex); + dup = m_impl->runnerThread; + m_impl->runnerThread = nullptr; + } + if (dup) + CloseHandle(dup); } void McpSessionClient::Stop() { m_impl->stop.store(true); - HANDLE h = m_impl->pipe.exchange(INVALID_HANDLE_VALUE); - if (h != INVALID_HANDLE_VALUE) - CloseHandle(h); // unblocks a pending blocking ReadFile + + // Unblock the session thread's pending SYNCHRONOUS pipe I/O. The pipe + // is opened without FILE_FLAG_OVERLAPPED, so CancelIo / CancelIoEx do + // not apply -- those cancel asynchronous operations. CancelSynchronousIo + // takes the handle of the *blocked thread*, which Run() publishes. + // + // We deliberately do NOT close the pipe here: the session thread is + // concurrently inside ReadFile / WriteFile on that handle. See the + // ioMutex note on Impl for why closing it from this thread is a crash. + // + // A 0 return with ERROR_NOT_FOUND just means nothing was pending -- + // the thread will observe `stop` at its next check either way. + std::lock_guard lock(m_impl->ioMutex); + if (m_impl->runnerThread) + CancelSynchronousIo(m_impl->runnerThread); } } diff --git a/Engine/Mcp/McpToolCatalog.cpp b/Engine/Mcp/McpToolCatalog.cpp index 15834da..b46f88f 100644 --- a/Engine/Mcp/McpToolCatalog.cpp +++ b/Engine/Mcp/McpToolCatalog.cpp @@ -82,16 +82,16 @@ namespace ShaderLab::Mcp R"JSON({"name":"set_preview_node","description":"Set which node is previewed","inputSchema":{"type":"object","properties":{"nodeId":{"type":"number"}},"required":["nodeId"]}})JSON", L"POST", L"/render/preview-node", M::BodyPassthrough }, { "render_capture", - R"JSON({"name":"render_capture","description":"Capture preview as PNG. Note: HDR values clipped to SDR.","inputSchema":{"type":"object","properties":{}}})JSON", + R"JSON({"name":"render_capture","description":"Capture preview as PNG. HDR values are CLIPPED to SDR, so do not judge HDR content from this image -- it shows you a tone-mapped guess, not the pipeline output. Use analysis nodes (Nit Map, Luminance Heatmap, Gamut Highlight) for what is actually above SDR white, and read_pixel_region / read_analysis_output for numbers.","inputSchema":{"type":"object","properties":{}}})JSON", L"GET", L"/render/capture", M::NoBody }, { "render_capture_node", - R"JSON({"name":"render_capture_node","description":"Capture any node's resolved output as PNG -- full frame, aspect preserved (FORCES a render frame so dirty nodes evaluate). With inline=true returns the image as MCP image content (base64). maxDim fits the longer edge to that many px (default 2048); use a small value (e.g. 512) for a low-res preview = fewer tokens, or larger for full detail. 404 if node missing; 409 with notReady=true if the node is dirty / has unconnected inputs.","inputSchema":{"type":"object","properties":{"nodeId":{"type":"number"},"inline":{"type":"boolean"},"maxDim":{"type":"number","description":"Longer-edge cap in px, aspect preserved. Small=preview/fewer tokens, large=full detail. Default 2048."}},"required":["nodeId"]}})JSON", + R"JSON({"name":"render_capture_node","description":"Capture any node's resolved output as PNG -- full frame, aspect preserved (FORCES a render frame so dirty nodes evaluate). PNG is 8-bit SDR: anything above scRGB 1.0 (80 nits) is CLIPPED and wide-gamut negatives are lost, so this image CANNOT be used to judge HDR or wide-gamut correctness -- capture a diagnostic node instead (Nit Map / Luminance Heatmap / Gamut Highlight / CIE Chromaticity Plot / Delta E Comparator in Heatmap mode), which encode those facts into SDR-visible form, and say so when reporting what you saw. With inline=true returns the image as MCP image content (base64). maxDim fits the longer edge to that many px (default 2048); use a small value (e.g. 512) for a low-res preview = fewer tokens, or larger for full detail. 404 if node missing; 409 with notReady=true if the node is dirty / has unconnected inputs.","inputSchema":{"type":"object","properties":{"nodeId":{"type":"number"},"inline":{"type":"boolean"},"maxDim":{"type":"number","description":"Longer-edge cap in px, aspect preserved. Small=preview/fewer tokens, large=full detail. Default 2048."}},"required":["nodeId"]}})JSON", L"POST", L"/render/capture-node", M::BodyPassthrough, nullptr, /*imageInline=*/true }, { "read_analysis_output", - R"JSON({"name":"read_analysis_output","description":"Read typed analysis output fields from a compute/analysis node","inputSchema":{"type":"object","properties":{"nodeId":{"type":"number"}},"required":["nodeId"]}})JSON", + R"JSON({"name":"read_analysis_output","description":"Read typed analysis output fields from a compute/analysis node. This is the trustworthy path for HDR judgement -- values are full-range scRGB computed on GPU, unlike a captured PNG which clips to SDR. Pair with Delta E Comparator (Method=dE ITP for HDR/WCG) + Luminance Statistics for measured color difference.","inputSchema":{"type":"object","properties":{"nodeId":{"type":"number"}},"required":["nodeId"]}})JSON", L"GET", L"/analysis/{}", M::PathNumber, L"nodeId" }, { "read_pixel_region", - R"JSON({"name":"read_pixel_region","description":"Read a small w x h region of FP32 RGBA pixels from a node's output (scRGB linear-light). Region is capped at 32x32 (1024 pixels) and per-axis at 64. Pixels are returned row-major as a flat float array (RGBARGBA...).","inputSchema":{"type":"object","properties":{"nodeId":{"type":"number"},"x":{"type":"number"},"y":{"type":"number"},"w":{"type":"number"},"h":{"type":"number"}},"required":["nodeId","x","y","w","h"]}})JSON", + R"JSON({"name":"read_pixel_region","description":"Read a small w x h region of FP32 RGBA pixels from a node's output (scRGB linear-light). Full range and unclipped -- values above 1.0 (brighter than 80-nit SDR white) and negative components (wide-gamut chroma) are preserved, so this is the ground truth for HDR checks where a captured PNG would lie. Region is capped at 32x32 (1024 pixels) and per-axis at 64. Pixels are returned row-major as a flat float array (RGBARGBA...).","inputSchema":{"type":"object","properties":{"nodeId":{"type":"number"},"x":{"type":"number"},"y":{"type":"number"},"w":{"type":"number"},"h":{"type":"number"}},"required":["nodeId","x","y","w","h"]}})JSON", L"POST", L"/render/pixel-region", M::BodyPassthrough }, { "read_pixel_trace", R"JSON({"name":"read_pixel_trace","description":"Run pixel trace at normalized coordinates, returns per-node pixel values and analysis outputs","inputSchema":{"type":"object","properties":{"nodeId":{"type":"number"},"x":{"type":"number","description":"Normalized X (0-1)"},"y":{"type":"number","description":"Normalized Y (0-1)"}},"required":["nodeId","x","y"]}})JSON", diff --git a/Graph/EffectGraph.cpp b/Graph/EffectGraph.cpp index b53a028..ae9e15b 100644 --- a/Graph/EffectGraph.cpp +++ b/Graph/EffectGraph.cpp @@ -1075,7 +1075,16 @@ namespace ShaderLab::Graph if (!node.customEffect->analysisFields.empty()) node.customEffect->analysisOutputType = AnalysisOutputType::Typed; } - catch (...) {} // Ignore malformed legacy data. + catch (...) + { + // Malformed legacy data. Keep loading -- the rest of the + // node is valid -- but say so: a silently dropped analysis + // field otherwise shows up later as a binding that cannot + // resolve, with nothing pointing back at the load. + node.runtimeError = + L"Legacy analysis-field metadata was malformed and " + L"could not be migrated; re-save this graph."; + } } // Migrate legacy propertyBindings from string property. @@ -1096,7 +1105,16 @@ namespace ShaderLab::Graph node.propertyBindings[targetProp] = std::move(binding); } } - catch (...) {} + catch (...) + { + // Partial migration: bindings parsed before the throw are + // kept, the rest are lost. Record it -- dropping a user's + // bindings silently on load is indistinguishable from + // never having authored them. + node.runtimeError = + L"Legacy property bindings were malformed; some " + L"bindings could not be migrated and must be re-made."; + } } node.dirty = true; diff --git a/MainWindow.McpRoutes.cpp b/MainWindow.McpRoutes.cpp index 9a3978b..66ba866 100644 --- a/MainWindow.McpRoutes.cpp +++ b/MainWindow.McpRoutes.cpp @@ -3,8 +3,11 @@ #include "Engine/Mcp/McpRouter.h" #include "Engine/Mcp/McpJsonRpc.h" #include "Engine/Mcp/McpTimeouts.h" +#include "Engine/Mcp/McpPeerIdentity.h" #include #include +#include +#include #include "Effects/CustomPixelShaderEffect.h" #include "Effects/CustomComputeShaderEffect.h" #include "Effects/ShaderLabEffects.h" @@ -194,6 +197,39 @@ namespace winrt::ShaderLab::implementation // keeps running (update-immune by design, stdio-migration Step 8). EnsureShimDistributed(); + // Summon the hub BEFORE the session client's first connect. The + // client only CONNECTS (capped ≤4 s backoff) — it never activates + // a hub. When no hub is running at app start (e.g. a dev rebuild + // killed it), this window's session sat unregistered until an + // external shim call happened to activate one — minutes of "no + // sessions" for MCP clients. Activation is idempotent: the hub is + // a singleton and a second activation is a no-op. Best-effort — + // on failure the old behavior (wait for a shim to summon the hub) + // still applies. + try + { + std::wstring pipeBase; + { + wchar_t env[256]{}; + if (GetEnvironmentVariableW(L"SHADERLAB_MCP_PIPE", env, ARRAYSIZE(env)) > 0) + pipeBase = env; + else + pipeBase = ::ShaderLab::Mcp::DefaultPipeBaseName(); + } + const std::wstring aumid = + std::wstring(winrt::Windows::ApplicationModel::Package::Current().Id().FamilyName()) + + L"!Hub"; + winrt::com_ptr mgr; + if (SUCCEEDED(CoCreateInstance(CLSID_ApplicationActivationManager, nullptr, + CLSCTX_LOCAL_SERVER, IID_PPV_ARGS(mgr.put())))) + { + const std::wstring args = std::format(L"--hub --pipe {}", pipeBase); + DWORD pid = 0; + mgr->ActivateApplication(aumid.c_str(), args.c_str(), AO_NONE, &pid); + } + } + catch (...) { /* unpackaged/dev edge — session client still retries */ } + if (m_mcpSessionId.empty()) { GUID g{}; @@ -762,14 +798,17 @@ namespace winrt::ShaderLab::implementation "\"endDrawFlushMs\":{:.2f}," "\"uiTickMs\":{:.2f},\"outputWindowsMs\":{:.2f},\"traceMs\":{:.2f}," "\"computeDispatches\":{}," - "\"framesSampled\":{},\"endDrawFailed\":{}}}", + "\"framesSampled\":{},\"endDrawFailed\":{}," + "\"cachedEffects\":{},\"cacheInvalidations\":{}}}", fps, t.totalUs / 1000.0, t.sourcesPrepUs / 1000.0, t.evaluateUs / 1000.0, t.deferredComputeUs / 1000.0, t.drawUs / 1000.0, t.endDrawFlushUs / 1000.0, t.uiTickUs / 1000.0, t.outputWindowsUs / 1000.0, t.traceUs / 1000.0, t.computeDispatches, - t.framesSampled, t.endDrawFailed) }; + t.framesSampled, t.endDrawFailed, + m_graphEvaluator.CachedEffectCount(), + m_graphEvaluator.CacheInvalidations()) }; }); // ===================================================================== diff --git a/MainWindow.RenderTick.cpp b/MainWindow.RenderTick.cpp index 6cb9620..b028e50 100644 --- a/MainWindow.RenderTick.cpp +++ b/MainWindow.RenderTick.cpp @@ -602,10 +602,16 @@ namespace winrt::ShaderLab::implementation auto tComputeEnd = std::chrono::high_resolution_clock::now(); uint32_t computeCount = static_cast(m_graphEvaluator.DeferredComputeCount()); - // Set DPI to 96 to match WinUI DIPs. + // Set DPI to 96 to match WinUI DIPs — but only when the context + // isn't already there: a real per-frame DPI flip invalidates every + // D2D1_PROPERTY_CACHED effect intermediate in the context. The + // render context is pinned at 96 (RenderEngine), so this is + // normally a no-op kept as a safety net. float oldDpiX, oldDpiY; dc->GetDpi(&oldDpiX, &oldDpiY); - dc->SetDpi(96.0f, 96.0f); + const bool dpiFlip = (oldDpiX != 96.0f || oldDpiY != 96.0f); + if (dpiFlip) + dc->SetDpi(96.0f, 96.0f); dc->Clear(D2D1::ColorF(D2D1::ColorF::Black)); @@ -619,7 +625,8 @@ namespace winrt::ShaderLab::implementation dc->DrawImage(previewImage); dc->SetTransform(D2D1::Matrix3x2F::Identity()); - dc->SetDpi(oldDpiX, oldDpiY); + if (dpiFlip) + dc->SetDpi(oldDpiX, oldDpiY); auto tDrawEnd = std::chrono::high_resolution_clock::now(); diff --git a/MainWindow.xaml.cpp b/MainWindow.xaml.cpp index c5ce4b8..c05ef3e 100644 --- a/MainWindow.xaml.cpp +++ b/MainWindow.xaml.cpp @@ -332,7 +332,25 @@ namespace winrt::ShaderLab::implementation }); NodeGraphContainer().IsTabStop(true); - SaveImageButton().Click({ this, &MainWindow::OnSaveImageClicked }); + // Save flyout: the reference frame of the saved file is an + // explicit choice, not an inference — a heuristic would darken + // the plain image→PNG round trip or wash presentation-graded + // output (see SaveReference in the header). + { + winrt::Microsoft::UI::Xaml::Controls::MenuFlyout saveFlyout; + auto addSaveItem = [this, &saveFlyout]( + winrt::hstring const& text, SaveReference ref) + { + winrt::Microsoft::UI::Xaml::Controls::MenuFlyoutItem item; + item.Text(text); + item.Click([this, ref](auto&&, auto&&) { SaveImageAsync(ref); }); + saveFlyout.Items().Append(item); + }; + addSaveItem(L"PNG (SDR) — from presentation white", SaveReference::PresentationPng); + addSaveItem(L"PNG (SDR) — file-referenced as-is", SaveReference::FilePng); + addSaveItem(L"JPEG XR (HDR) — scene-referred", SaveReference::HdrJxr); + SaveImageButton().Flyout(saveFlyout); + } EffectDesignerButton().Click([this](auto&&, auto&&) { OpenEffectDesigner(); }); // MCP server toggle. @@ -4998,13 +5016,6 @@ namespace winrt::ShaderLab::implementation } } - void MainWindow::OnSaveImageClicked( - winrt::Windows::Foundation::IInspectable const& /*sender*/, - winrt::Microsoft::UI::Xaml::RoutedEventArgs const& /*args*/) - { - SaveImageAsync(); - } - std::vector MainWindow::CapturePreviewAsPng() { auto* image = ResolveDisplayImage(m_previewNodeId); @@ -5267,9 +5278,10 @@ namespace winrt::ShaderLab::implementation m_nodeGraphController.SetPanOffset(panX, panY); } - winrt::fire_and_forget MainWindow::SaveImageAsync() + winrt::fire_and_forget MainWindow::SaveImageAsync(SaveReference ref) { auto strong = get_strong(); + const bool isJxr = (ref == SaveReference::HdrJxr); winrt::Windows::Storage::Pickers::FileSavePicker picker; picker.as<::IInitializeWithWindow>()->Initialize(m_hwnd); @@ -5283,8 +5295,10 @@ namespace winrt::ShaderLab::implementation if (ch == L'/' || ch == L'\\' || ch == L':' || ch == L'*' || ch == L'?' || ch == L'"' || ch == L'<' || ch == L'>' || ch == L'|') ch = L'_'; picker.SuggestedFileName(winrt::hstring(suggestedName)); - picker.FileTypeChoices().Insert(L"JPEG XR (HDR)", winrt::single_threaded_vector({ L".jxr" })); - picker.FileTypeChoices().Insert(L"PNG Image (SDR)", winrt::single_threaded_vector({ L".png" })); + if (isJxr) + picker.FileTypeChoices().Insert(L"JPEG XR (HDR)", winrt::single_threaded_vector({ L".jxr" })); + else + picker.FileTypeChoices().Insert(L"PNG Image (SDR)", winrt::single_threaded_vector({ L".png" })); auto file = co_await picker.PickSaveFileAsync(); if (!file) co_return; @@ -5299,6 +5313,40 @@ namespace winrt::ShaderLab::implementation auto* dc = m_renderEngine.D2DDeviceContext(); if (!dc) co_return; + // Presentation→file re-referencing: the scene is linear scRGB + // where the OS presents SDR reference white at SdrWhiteNits. + // An SDR file's 1.0 must mean "SDR reference white", so scale by + // 80/SdrWhiteNits in linear space before the sRGB encode; DWM + // multiplies it back on display. Respects a simulated profile + // (CachedCapabilities prefers it). + float presentationScale = 1.0f; + if (ref == SaveReference::PresentationPng) + { + const float sdrWhite = + m_displayMonitor.CachedCapabilities().sdrWhiteLevelNits; + if (sdrWhite > 80.0f) + presentationScale = 80.0f / sdrWhite; + } + winrt::com_ptr scaleFx; + winrt::com_ptr scaledImage; + ID2D1Image* imageToSave = previewImage; + if (presentationScale != 1.0f && + SUCCEEDED(dc->CreateEffect(CLSID_D2D1ColorMatrix, scaleFx.put()))) + { + scaleFx->SetInput(0, previewImage); + const float s = presentationScale; + D2D1_MATRIX_5X4_F m = D2D1::Matrix5x4F( + s, 0, 0, 0, + 0, s, 0, 0, + 0, 0, s, 0, + 0, 0, 0, 1, + 0, 0, 0, 0); + scaleFx->SetValue(D2D1_COLORMATRIX_PROP_COLOR_MATRIX, m); + scaleFx->GetOutput(scaledImage.put()); + if (scaledImage) + imageToSave = scaledImage.get(); + } + try { // Reset DPI/transform to ensure clean bounds measurement. @@ -5316,14 +5364,15 @@ namespace winrt::ShaderLab::implementation dc->SetDpi(oldDpiX, oldDpiY); if (w == 0 || h == 0) co_return; - auto fileExt = std::wstring(file.FileType().c_str()); - bool isJxr = (fileExt == L".jxr" || fileExt == L".wdp"); - - // JXR: render in FP16 scRGB for full HDR fidelity. - // PNG: render in 8-bit BGRA (SDR clamp). + // JXR: render in FP16 scRGB for full HDR fidelity (linear, + // scene-referred — no transfer encode wanted). + // PNG: render in 8-bit BGRA with the _SRGB variant so the + // scene's linear values are gamma-ENCODED on write; plain + // UNORM wrote linear bytes that viewers then sRGB-decoded, + // producing a crushed, far-too-dark image. DXGI_FORMAT renderFormat = isJxr ? DXGI_FORMAT_R16G16B16A16_FLOAT - : DXGI_FORMAT_B8G8R8A8_UNORM; + : DXGI_FORMAT_B8G8R8A8_UNORM_SRGB; D2D1_ALPHA_MODE alphaMode = D2D1_ALPHA_MODE_PREMULTIPLIED; winrt::com_ptr renderBitmap; @@ -5337,7 +5386,7 @@ namespace winrt::ShaderLab::implementation dc->SetTarget(renderBitmap.get()); dc->BeginDraw(); dc->Clear(D2D1::ColorF(0, 0, 0, 1.0f)); - dc->DrawImage(previewImage); + dc->DrawImage(imageToSave); dc->EndDraw(); dc->SetTarget(oldTarget.get()); diff --git a/MainWindow.xaml.h b/MainWindow.xaml.h index c3b1c86..c1d29f4 100644 --- a/MainWindow.xaml.h +++ b/MainWindow.xaml.h @@ -528,10 +528,19 @@ namespace winrt::ShaderLab::implementation winrt::fire_and_forget BrowseImageForSourceNode(uint32_t nodeId); winrt::fire_and_forget BrowseVideoForSourceNode(); winrt::fire_and_forget BrowseVideoForExistingNode(uint32_t nodeId); - void OnSaveImageClicked( - winrt::Windows::Foundation::IInspectable const& sender, - winrt::Microsoft::UI::Xaml::RoutedEventArgs const& args); - winrt::fire_and_forget SaveImageAsync(); + // How saved pixel values relate to the working space's scene- + // referred scRGB (1.0 = 80 nits). The save path owns the + // re-referencing so any node saves correctly in any format: + // PresentationPng — content's diffuse white sits at the OS SDR + // white level (a graph output meant to be looked at on the + // HDR desktop). Divides by SdrWhiteNits/80 in linear space, + // then sRGB-encodes: the file's 1.0 = SDR reference white, + // and DWM re-boosts it on display like any SDR file. + // FilePng — content is already file-referenced (white at 1.0, + // e.g. a pass-through of a loaded image). Encode only. + // HdrJxr — scene-referred FP16 scRGB, written as-is. + enum class SaveReference { PresentationPng, FilePng, HdrJxr }; + winrt::fire_and_forget SaveImageAsync(SaveReference ref); // Capture the current preview as a PNG byte buffer. // Returns empty vector on failure. diff --git a/Rendering/GraphEvaluator.cpp b/Rendering/GraphEvaluator.cpp index 5869b70..9302d9a 100644 --- a/Rendering/GraphEvaluator.cpp +++ b/Rendering/GraphEvaluator.cpp @@ -204,12 +204,19 @@ namespace ShaderLab::Rendering } } - if (node->dirty || bindingsChanged) + const bool wasDirty = node->dirty || bindingsChanged; + if (wasDirty) { ApplyProperties(effect, *node, effectiveProps); node->dirty = false; } + // Clean-subgraph caching: enable D2D's output cache; + // drop it when content changed (upstream in-place + // texture updates arrive as node->dirty via the dirty + // walks, which D2D itself cannot see). + UpdateEffectCachePolicy(effect, nodeId, wasDirty); + // The effect's output is an ID2D1Image. Take ownership so the // image survives D2D's internal pipeline churn (input toggles, // property reapply) until the effect itself is invalidated. @@ -287,26 +294,43 @@ namespace ShaderLab::Rendering // Image-producing compute: recompute when dirty or no cached output. // Analysis-only compute: also recompute if no analysis fields yet. // - // Phase 8c regression fix: when skip-readback is on, ALL - // compute nodes must redispatch every frame regardless - // of dirty state. Pre-skip-readback the per-frame - // upstream dirty-propagation in OnRenderTick lit up - // compute consumers via image edges, but inside this - // EvaluateNode the upstream's `dirty` has already been - // cleared (Source case clears it before downstream eval - // runs) so we can't use it here. The host's upstream - // propagation does set node->dirty for us when Source - // ticks, but ResolveBindings on the consumer of an - // analysis source (e.g. ICtCp <- LumStats.Mean) only - // dirties the consumer when CPU value changes -- which - // never happens when LumStats's Map() was throttled. - // Force redispatch for every compute node so the GPU - // dispatch fires and the structured-buffer / image- - // output texture are kept fresh. - const bool skipReadbackForcesRedispatch = - Performance::IsSkipUnneededCpuReadbackEnabled(); + // GPU-binding freshness chain (replaces the Phase 8c + // redispatch-every-frame-under-skip-readback rule): a + // compute consumer must redispatch when one of its + // binding SOURCES is queued for dispatch this cycle — + // its dispatch reads the source's analysis SRV, whose + // content is about to change. When no source is + // dispatching, the SRV retains last cycle's values and + // an identical redispatch would produce identical + // output, so dirty-gating is safe. (The old rule + // redispatched EVERY compute node EVERY frame, which + // both burned GPU on static graphs and — because each + // image-producing dispatch transitively dirties its + // downstream — permanently defeated clean-subgraph + // output caching.) The topological order includes + // binding dependencies, so sources are visited before + // their consumers. + bool bindingSourceQueued = false; + for (const auto& [bpName, b] : node->propertyBindings) + { + if (b.wholeArray) + { + if (m_queuedComputeThisEval.count(b.wholeArraySourceNodeId)) + { bindingSourceQueued = true; break; } + } + else + { + for (const auto& s : b.sources) + { + if (s.has_value() && + m_queuedComputeThisEval.count(s->sourceNodeId)) + { bindingSourceQueued = true; break; } + } + } + if (bindingSourceQueued) break; + } bool needsCompute = node->dirty || - skipReadbackForcesRedispatch || + bindingSourceQueued || (hasImageOutput && !node->cachedOutput) || (!hasImageOutput && node->analysisOutput.fields.empty()); if (primaryInput && needsCompute && !m_deferredComputeFrozen) @@ -331,6 +355,7 @@ namespace ShaderLab::Rendering } } m_deferredCompute.push_back({ nodeId, std::move(inputImages), std::move(preRendered) }); + m_queuedComputeThisEval.insert(nodeId); } node->dirty = false; if (!hasImageOutput) @@ -627,9 +652,19 @@ namespace ShaderLab::Rendering // bitmap may live inside e.g. a 4096x4096 atlas // even when its content rect is only 1920x1080. // Sampling [0,1] would otherwise read the padding. + // Only flip DPI when the context isn't already + // at 96 — a real DPI change here invalidates + // every D2D1_PROPERTY_CACHED intermediate in + // the context (caches are DPI-referenced), and + // this block runs per-eval for dims-declaring + // nodes. The render context is pinned at 96 + // (RenderEngine), so this is normally a no-op. float oldDpiX = 0, oldDpiY = 0; dc->GetDpi(&oldDpiX, &oldDpiY); - dc->SetDpi(96.0f, 96.0f); + const bool dpiFlip = + (oldDpiX != 96.0f || oldDpiY != 96.0f); + if (dpiFlip) + dc->SetDpi(96.0f, 96.0f); float unionLeft = (std::numeric_limits::max)(); float unionTop = (std::numeric_limits::max)(); float unionRight = -(std::numeric_limits::max)(); @@ -659,7 +694,8 @@ namespace ShaderLab::Rendering if (edge->destPin < perInputWH.size()) perInputWH[edge->destPin] = { bw, bh }; } - dc->SetDpi(oldDpiX, oldDpiY); + if (dpiFlip) + dc->SetDpi(oldDpiX, oldDpiY); if (anyValid) { float w = unionRight - unionLeft; @@ -771,7 +807,23 @@ namespace ShaderLab::Rendering effect->SetInput(0, m_dummySourceBitmap.get()); } - // Force D2D to re-render by toggling input 0. + // Custom effects upload their cbuffer directly to the + // GPU (bypassing the D2D property system), so D2D has + // no idea a re-render is needed when one changes. Two + // manual invalidation mechanisms, by mode: + // * caching ON: UpdateEffectCachePolicy drops this + // node's D2D1_PROPERTY_CACHED intermediate on + // wasDirty — the next pull finds no cache and must + // re-execute, picking up the fresh cbuffer. This + // touches ONLY this node's cache. + // * caching OFF: legacy input-0 detach/reattach + // toggle. NOT used when caching is on because + // detaching the consumer of an upstream effect's + // output releases that upstream's cached + // intermediate as collateral — one dirty custom + // node per frame (any animated graph) then defeats + // caching for its whole input chain. + if (wasDirty && !Performance::IsEffectOutputCachingEnabled()) { winrt::com_ptr savedInput; effect->GetInput(0, savedInput.put()); @@ -782,6 +834,8 @@ namespace ShaderLab::Rendering } } + UpdateEffectCachePolicy(effect, nodeId, wasDirty); + winrt::com_ptr output; effect->GetOutput(output.put()); m_outputCache[nodeId] = output; @@ -810,11 +864,13 @@ namespace ShaderLab::Rendering else { WireInputs(effect, *node, graph); - if (node->dirty) + const bool wasDirtyFallback = node->dirty; + if (wasDirtyFallback) { ApplyProperties(effect, *node, node->properties); node->dirty = false; } + UpdateEffectCachePolicy(effect, nodeId, wasDirtyFallback); winrt::com_ptr output; effect->GetOutput(output.put()); m_outputCache[nodeId] = output; @@ -879,6 +935,7 @@ namespace ShaderLab::Rendering if (n.type == NodeType::Source && !n.cachedOutput) { m_deferredCompute.clear(); + m_queuedComputeThisEval.clear(); return false; } } @@ -1093,6 +1150,7 @@ namespace ShaderLab::Rendering } m_deferredCompute.clear(); + m_queuedComputeThisEval.clear(); return true; } @@ -1690,6 +1748,8 @@ namespace ShaderLab::Rendering m_bridgeImplCache.clear(); m_sharedPreRenderCache.clear(); m_lastHintReadbackTime.clear(); + m_cacheEnabled.clear(); + m_queuedComputeThisEval.clear(); m_dummySourceBitmap = nullptr; // P7: also drop any deferred-compute entries that the previous @@ -1720,6 +1780,7 @@ namespace ShaderLab::Rendering m_outputCache.erase(nodeId); m_customImplCache.erase(nodeId); m_bridgeImplCache.erase(nodeId); + m_cacheEnabled.erase(nodeId); // Note: caller must also clear EffectNode::cachedOutput on the node // (the raw pointer it holds is now dangling). Prefer the graph-aware // overload below. @@ -1770,6 +1831,7 @@ namespace ShaderLab::Rendering { m_effectCache.erase(nodeId); m_bridgeImplCache.erase(nodeId); + m_cacheEnabled.erase(nodeId); return; } auto implIt = m_customImplCache.find(nodeId); @@ -1807,6 +1869,20 @@ namespace ShaderLab::Rendering implIt->second.computeImpl->SetThreadGroupSize( def.threadGroupX, def.threadGroupY, def.threadGroupZ); } + + // The effect instance survives the bytecode swap, so any cached + // output intermediate is now stale — drop it. The next Evaluate + // re-enables caching after the fresh render. + if (effectIt != m_effectCache.end()) + { + auto cacheIt = m_cacheEnabled.find(nodeId); + if (cacheIt != m_cacheEnabled.end() && cacheIt->second) + { + effectIt->second->SetValue(D2D1_PROPERTY_CACHED, FALSE); + cacheIt->second = false; + ++m_cacheInvalidations; + } + } } // ----------------------------------------------------------------------- @@ -2090,6 +2166,17 @@ namespace ShaderLab::Rendering UINT32 totalInputs = effect->GetInputCount(); std::vector connected(totalInputs, false); + // Re-setting an input D2D already holds may count as a topology + // change and drop the effect's D2D1_PROPERTY_CACHED intermediate, + // so only call SetInput when the pointer actually differs. + auto setInputIfChanged = [effect](UINT32 pin, ID2D1Image* desired) + { + winrt::com_ptr current; + effect->GetInput(pin, current.put()); + if (current.get() != desired) + effect->SetInput(pin, desired); + }; + for (const auto* edge : inputEdges) { if (edge->destPin >= totalInputs) @@ -2098,7 +2185,7 @@ namespace ShaderLab::Rendering const EffectNode* srcNode = graph.FindNode(edge->sourceNodeId); if (srcNode && srcNode->cachedOutput) { - effect->SetInput(edge->destPin, srcNode->cachedOutput); + setInputIfChanged(edge->destPin, srcNode->cachedOutput); connected[edge->destPin] = true; } } @@ -2107,7 +2194,60 @@ namespace ShaderLab::Rendering for (UINT32 i = 0; i < totalInputs; ++i) { if (!connected[i]) - effect->SetInput(i, nullptr); + setInputIfChanged(i, nullptr); + } + } + + // ----------------------------------------------------------------------- + // Clean-subgraph output caching policy + // ----------------------------------------------------------------------- + + void GraphEvaluator::UpdateEffectCachePolicy( + ID2D1Effect* effect, uint32_t nodeId, bool contentDirty) + { + if (!effect) + return; + + bool& enabled = m_cacheEnabled[nodeId]; // default-inserts false + + if (!Performance::IsEffectOutputCachingEnabled()) + { + // Kill switch: release any held intermediate and stop caching. + if (enabled) + { + effect->SetValue(D2D1_PROPERTY_CACHED, FALSE); + enabled = false; + } + return; + } + + if (contentDirty) + { + // Content changed through a channel D2D can't see (in-place + // texture update or direct cbuffer upload). Drop the cache and + // STAY uncached while dirty: an uncached effect re-executes on + // every pull, which both picks up the fresh cbuffer and — the + // hysteresis part — avoids touching the CACHED property again + // next frame. Measured: any per-frame property transition on a + // consumer (input toggle OR the CACHED off->on poke) causes + // D2D to drop its PRODUCERS' cached intermediates as + // collateral, so a per-frame-dirty node (an animated split) + // must generate ZERO property traffic to let its upstream + // caches survive. Cache re-enables one frame after the node + // goes clean. + if (enabled) + { + effect->SetValue(D2D1_PROPERTY_CACHED, FALSE); + enabled = false; + ++m_cacheInvalidations; + } + return; + } + + if (!enabled) + { + effect->SetValue(D2D1_PROPERTY_CACHED, TRUE); + enabled = true; } } @@ -2115,6 +2255,35 @@ namespace ShaderLab::Rendering // Property binding resolution // ----------------------------------------------------------------------- + // Exact-equality compare for PropertyValue. std::variant's operator== + // is unusable here because D2D1_MATRIX_5X4_F has no operator==; the + // WinRT numerics are compared componentwise for the same reason. + // Exact float compare is intentional: binding sources are + // deterministic frame to frame, so "unchanged" means bit-identical. + static bool PropertyValuesEqual( + const Graph::PropertyValue& a, const Graph::PropertyValue& b) + { + namespace num = winrt::Windows::Foundation::Numerics; + if (a.index() != b.index()) return false; + return std::visit([&b](const auto& av) -> bool + { + using T = std::decay_t; + const T* bv = std::get_if(&b); + if (!bv) return false; + if constexpr (std::is_same_v) + return std::memcmp(&av, bv, sizeof(T)) == 0; + else if constexpr (std::is_same_v) + return av.x == bv->x && av.y == bv->y; + else if constexpr (std::is_same_v) + return av.x == bv->x && av.y == bv->y && av.z == bv->z; + else if constexpr (std::is_same_v) + return av.x == bv->x && av.y == bv->y && + av.z == bv->z && av.w == bv->w; + else + return av == *bv; + }, a); + } + // Helper: resolve a single ComponentSource to a float value. static bool ResolveComponentSource( const ComponentSource& src, @@ -2219,8 +2388,17 @@ namespace ShaderLab::Rendering { if (fv.name == binding.wholeArraySourceFieldName && AnalysisFieldIsArray(fv.type)) { - effectiveProps[propName] = fv.arrayData; - anyChanged = true; + // Only report a change when the resolved value + // actually differs — "resolved every frame" used + // to mean "changed every frame", which re-applied + // properties and defeated output caching on every + // binding consumer even for static values. + PropertyValue resolvedArr = fv.arrayData; + if (!PropertyValuesEqual(propIt->second, resolvedArr)) + { + effectiveProps[propName] = std::move(resolvedArr); + anyChanged = true; + } break; } } @@ -2313,8 +2491,15 @@ namespace ShaderLab::Rendering if (resolved) { - effectiveProps[propName] = newVal; - anyChanged = true; + // Value-compare before reporting change (see whole-array + // note above): the resolved value equals the stored one on + // every frame where the source didn't move, and reporting + // "changed" then would dirty the consumer needlessly. + if (!PropertyValuesEqual(propIt->second, newVal)) + { + effectiveProps[propName] = newVal; + anyChanged = true; + } } } @@ -2385,31 +2570,19 @@ namespace ShaderLab::Rendering std::wstring(var.name.begin(), var.name.end())); if (propIt == effectiveProps.end()) continue; - std::visit([&](const auto& v) + // Typed pack: converts float-stored enum properties to the + // declared uint/int/bool HLSL slot type. The previous raw + // memcpy here wrote float bit patterns into uint slots, so + // `uint Mode` read 3.0f as 1077936128 and every uint-enum + // switch on the D2D pixel/compute-shader path silently fell + // through to its default branch for any non-zero value. + if (var.offset < cbData.size()) { - using T = std::decay_t; - if constexpr (std::is_same_v || std::is_same_v || - std::is_same_v || std::is_same_v) - { - if (var.offset + sizeof(T) <= cbData.size()) - memcpy(cbData.data() + var.offset, &v, sizeof(T)); - } - else if constexpr (std::is_same_v) - { - if (var.offset + sizeof(float) * 2 <= cbData.size()) - memcpy(cbData.data() + var.offset, &v, sizeof(float) * 2); - } - else if constexpr (std::is_same_v) - { - if (var.offset + sizeof(float) * 3 <= cbData.size()) - memcpy(cbData.data() + var.offset, &v, sizeof(float) * 3); - } - else if constexpr (std::is_same_v) - { - if (var.offset + sizeof(float) * 4 <= cbData.size()) - memcpy(cbData.data() + var.offset, &v, sizeof(float) * 4); - } - }, propIt->second); + Effects::PackPropertyToCBuffer( + cbData.data() + var.offset, + static_cast(cbData.size()) - var.offset, + var.type, var.columns, propIt->second); + } } // Set the packed cbuffer on the concrete impl. diff --git a/Rendering/GraphEvaluator.h b/Rendering/GraphEvaluator.h index 8f8247e..a616b0d 100644 --- a/Rendering/GraphEvaluator.h +++ b/Rendering/GraphEvaluator.h @@ -82,6 +82,15 @@ namespace ShaderLab::Rendering // Performance::CpuAnalysisHintThrottleMs (default 2000 ms, // 0.5 Hz). Re-selecting a node after it left interest treats // it as fresh. + // Clean-subgraph caching telemetry (see UpdateEffectCachePolicy). + size_t CachedEffectCount() const + { + size_t n = 0; + for (const auto& [id, on] : m_cacheEnabled) if (on) ++n; + return n; + } + uint64_t CacheInvalidations() const { return m_cacheInvalidations; } + void SetCpuAnalysisInterest(std::unordered_set ids) { // Drop the throttle timestamp for any node that left the @@ -137,6 +146,33 @@ namespace ShaderLab::Rendering // Effects are reused across frames; only properties are updated. std::unordered_map> m_effectCache; + // Clean-subgraph output caching (D2D1_PROPERTY_CACHED). + // + // Enables D2D's per-effect output cache on every effect-backed + // node so that pulling the terminal image does NOT re-execute + // pixel passes whose subtree is unchanged. D2D auto-invalidates + // on property/topology changes it can see, but our two hidden + // mutation channels — custom-effect cbuffer uploads (bypass the + // D2D property system entirely) and in-place texture updates + // (compute re-dispatch, video / live-capture uploads) — need the + // manual invalidation in UpdateEffectCachePolicy, keyed off the + // same wasDirty signal that gates property re-apply. + // + // m_cacheEnabled tracks the last state WE set so SetValue only + // fires on transitions (a redundant SetValue may itself count as + // a property change and drop the cache). + void UpdateEffectCachePolicy(ID2D1Effect* effect, uint32_t nodeId, + bool contentDirty); + std::unordered_map m_cacheEnabled; + uint64_t m_cacheInvalidations{ 0 }; + + // Compute nodes queued for dispatch in the current eval cycle. + // Used to chain GPU-binding freshness: a compute consumer whose + // binding SOURCE is queued must redispatch too (its dispatch + // reads the source's analysis SRV). Mirrors m_deferredCompute's + // lifetime — cleared where it is cleared. + std::unordered_set m_queuedComputeThisEval; + // Per-node owning reference to each effect's output image. // ID2D1Effect::GetOutput() returns an AddRef'd pointer; if we only stash // the raw pointer in EffectNode::cachedOutput, the local com_ptr releases diff --git a/Rendering/RenderEngine.cpp b/Rendering/RenderEngine.cpp index ab6b3b7..7fd1d05 100644 --- a/Rendering/RenderEngine.cpp +++ b/Rendering/RenderEngine.cpp @@ -47,7 +47,12 @@ namespace ShaderLab::Rendering auto panelNative = m_panel.as(); if (panelNative) panelNative->SetSwapChain(nullptr); } - catch (...) {} + catch (...) + { + // Deliberate swallow: teardown. The panel may already be torn + // down by XAML, and there is nothing useful to do about it -- + // we are releasing the swap chain on the next line regardless. + } } m_swapChain = nullptr; @@ -278,13 +283,17 @@ namespace ShaderLab::Rendering m_renderTarget = std::move(targetBitmap); m_d2dDeviceContext->SetTarget(m_renderTarget.get()); - // Set DPI to match the panel's composition scale. - float dpi = 96.0f; - if (m_panel) - { - dpi = 96.0f * m_panel.CompositionScaleX(); - } - m_d2dDeviceContext->SetDpi(dpi, dpi); + // The render context runs at a FIXED 96 DPI. It used to be set to + // the panel's composition scale (96 * CompositionScaleX, e.g. 144 + // at 150% display scaling), but post-P7 every render-side consumer + // — the offscreen preview draw, evaluator bounds math, captures, + // output sinks — immediately flipped it to 96 for its work and + // restored afterwards. Those per-frame DPI changes invalidated + // every D2D1_PROPERTY_CACHED effect intermediate in the context + // (caches are DPI-referenced), silently defeating clean-subgraph + // caching. Pixel-exact sizing is handled with explicit transforms, + // not context DPI; the UI thread blits through its own context. + m_d2dDeviceContext->SetDpi(96.0f, 96.0f); } void RenderEngine::ReleaseRenderTarget() diff --git a/ShaderLab.vcxproj b/ShaderLab.vcxproj index 882a699..ce11681 100644 --- a/ShaderLab.vcxproj +++ b/ShaderLab.vcxproj @@ -87,7 +87,7 @@ Level4 stdcpp20 4251;%(DisableSpecificWarnings) - %(AdditionalOptions) /bigobj + %(AdditionalOptions) /bigobj /utf-8
d3d11.lib;d2d1.lib;dxgi.lib;d3dcompiler.lib;dxguid.lib;windowscodecs.lib;mfplat.lib;mfreadwrite.lib;mfuuid.lib;%(AdditionalDependencies) diff --git a/ShaderLabEngine.vcxproj b/ShaderLabEngine.vcxproj index 7425764..80a0e63 100644 --- a/ShaderLabEngine.vcxproj +++ b/ShaderLabEngine.vcxproj @@ -75,7 +75,7 @@ true stdcpp20 4251;%(DisableSpecificWarnings) - %(AdditionalOptions) /bigobj + %(AdditionalOptions) /bigobj /utf-8 Windows diff --git a/ShaderLabHeadless.vcxproj b/ShaderLabHeadless.vcxproj index eff3d05..1cbcceb 100644 --- a/ShaderLabHeadless.vcxproj +++ b/ShaderLabHeadless.vcxproj @@ -73,7 +73,7 @@ true stdcpp20 4251;%(DisableSpecificWarnings) - %(AdditionalOptions) /bigobj + %(AdditionalOptions) /bigobj /utf-8 Console diff --git a/ShaderLabHeadless/Main.cpp b/ShaderLabHeadless/Main.cpp index 2ea9353..b139742 100644 --- a/ShaderLabHeadless/Main.cpp +++ b/ShaderLabHeadless/Main.cpp @@ -13,23 +13,33 @@ // ShaderLabHeadless --graph PATH --node ID --output PNG_PATH [options] // // Required arguments: -// --graph PATH .effectgraph JSON file (zip/embedded media not yet supported) +// --graph PATH .effectgraph archive or bare graph JSON. A ZIP archive +// has its embedded media/ extracted to a temp directory +// and "media://" tokens rewritten, then cleaned up on exit. // --node ID Numeric node id from the graph to render -// --output PATH PNG output path +// --output PATH Output image path. The extension picks the encoder: +// .jxr / .wdp -> JPEG XR, 64bpp RGBA half, lossless, HDR +// preserved (no clamp, no transfer encoding); +// anything else -> PNG, 8-bit sRGB, clamped to [0,1]. // // Options: // --width N Output width in pixels (default: 1024) // --height N Output height in pixels (default: 1024) // --adapter X 'warp' or 'default' (default: 'default'; CI uses warp) -// --mcp-session Register with the broker hub as an MCP session +// --pixels FP32 RGBA readback to stdout instead of a PNG // -// Exit code: 0 on success, non-zero on any failure. +// Batch / MCP modes (mutually exclusive with a plain --output render): +// --script PATH --script-output PATH +// JSON batch script of MCP-shaped ops; results written as JSON. +// --mcp-session [--pipe NAME] [--session-id ID] [--session-label TEXT] +// Register with the broker hub as an MCP session and serve +// requests until terminated. +// +// Other flags: --input-peak-nits / --output-peak-nits, --no-tonemap, +// --enable-gpu-bindings / --disable-gpu-bindings, --reap-shader-cache +// [--reap-shader-cache-stale-sec N], --clear-shader-cache, --help. // -// **Not yet implemented (queued for future work):** -// * .effectgraph zip archives with embedded media (only plain JSON for v1) -// * MCP HTTP server (the full move from MainWindow.McpRoutes.cpp is queued) -// * --script JSON file for batch parameter sweeps -// * HDR-preserving JXR output (PNG truncates above 1.0 scRGB) +// Exit code: 0 on success, non-zero on any failure. // // What it DOES prove: the engine, graph evaluator, custom-effect cache, // and pixel readback path all work without any UI thread or swap chain. @@ -46,6 +56,10 @@ #include "Effects/Performance.h" #include "Rendering/PixelReadback.h" #include "Rendering/PipelineFormat.h" +#include "Rendering/EffectGraphFile.h" + +// XMConvertFloatToHalf for the JXR (64bpp RGBA half) encode path. +#include #include "Engine/Mcp/McpRouter.h" #include "Engine/Mcp/McpJsonRpc.h" #include "Engine/Mcp/McpSessionClient.h" @@ -84,6 +98,12 @@ namespace // when the graph already produced SDR-range output and we // don't want HdrToneMap's mid-tone lift muddying the result. bool skipToneMap{ false }; + // Set when --input-peak-nits / --output-peak-nits was passed. A JXR + // output skips the tone map by default (see RunRender), but an + // explicit peak request means the caller wants tone mapping and is + // choosing the target peak -- e.g. 4000-nit content into a 1000-nit + // HDR deliverable -- so it must win over that default. + bool toneMapExplicit{ false }; // FP32 RGBA pixel-region readback (alternate output mode). // When set, --output is interpreted as a raw FP32 binary blob // (extension .bin / .raw) or a CSV file (extension .csv). No @@ -131,12 +151,17 @@ namespace void PrintUsage(const wchar_t* exeName) { std::wprintf( -L"Usage: %ls --graph PATH --node ID --output PNG_PATH [options]\n" +L"Usage: %ls --graph PATH --node ID --output IMAGE_PATH [options]\n" L"\n" L"Required:\n" -L" --graph PATH .effectgraph JSON file\n" +L" --graph PATH .effectgraph archive (ZIP; embedded media supported)\n" +L" or a bare graph JSON file\n" L" --node ID Numeric node id to render\n" -L" --output PATH PNG output path\n" +L" --output PATH Output image. The extension picks the encoder:\n" +L" .jxr/.wdp JPEG XR, 64bpp RGBA half, lossless, HDR\n" +L" preserved (implies --no-tonemap unless a\n" +L" peak is named explicitly)\n" +L" otherwise PNG, 8-bit sRGB, clamped to [0,1]\n" L"\n" L"Options:\n" L" --width N Output width (default: 1024)\n" @@ -212,8 +237,8 @@ L" through CPU readback (the pre-v1.6 path).\n", else if (a == L"--width") { auto v = needNext(L"--width"); if (!v) return false; out.width = static_cast(std::wcstoul(v, nullptr, 10)); } else if (a == L"--height") { auto v = needNext(L"--height"); if (!v) return false; out.height = static_cast(std::wcstoul(v, nullptr, 10)); } else if (a == L"--adapter") { auto v = needNext(L"--adapter"); if (!v) return false; out.useWarp = (std::wstring_view{v} == L"warp"); } - else if (a == L"--input-peak-nits") { auto v = needNext(L"--input-peak-nits"); if (!v) return false; out.inputPeakNits = static_cast(std::wcstod(v, nullptr)); } - else if (a == L"--output-peak-nits") { auto v = needNext(L"--output-peak-nits"); if (!v) return false; out.outputPeakNits = static_cast(std::wcstod(v, nullptr)); } + else if (a == L"--input-peak-nits") { auto v = needNext(L"--input-peak-nits"); if (!v) return false; out.inputPeakNits = static_cast(std::wcstod(v, nullptr)); out.toneMapExplicit = true; } + else if (a == L"--output-peak-nits") { auto v = needNext(L"--output-peak-nits"); if (!v) return false; out.outputPeakNits = static_cast(std::wcstod(v, nullptr)); out.toneMapExplicit = true; } else if (a == L"--no-tonemap") { out.skipToneMap = true; } else if (a == L"--pixels") { @@ -309,10 +334,127 @@ L" through CPU readback (the pre-v1.6 path).\n", return s; } + // Result of loading a graph from either container form. + struct LoadedGraph + { + ShaderLab::Graph::EffectGraph graph; + // Non-empty when the source was a zip: the temp directory holding + // extracted media. The graph's source-node paths point into it, so it + // must outlive rendering; RemoveExtractDir() clears it afterwards. + std::wstring extractDir; + bool ok{ false }; + int exitCode{ 0 }; // meaningful only when !ok + }; + + void RemoveExtractDir(const std::wstring& dir) + { + if (dir.empty()) return; + std::error_code ec; + std::filesystem::remove_all(dir, ec); // best effort: temp dir + } + + // Deletes the extracted-media temp directory on every exit path. + // Declare it BEFORE the evaluator / source factory so it destructs AFTER + // them -- those hold file handles into the directory while rendering. + struct ExtractDirGuard + { + std::wstring dir; + explicit ExtractDirGuard(std::wstring d) : dir(std::move(d)) {} + ~ExtractDirGuard() { RemoveExtractDir(dir); } + ExtractDirGuard(const ExtractDirGuard&) = delete; + ExtractDirGuard& operator=(const ExtractDirGuard&) = delete; + }; + + // Load a graph from either container form: + // * a .effectgraph ZIP (what the GUI's Save produces) -- graph.json plus + // optional embedded media under media/, and + // * a bare .json graph (what the test fixtures and older files are). + // + // Detected by the PKZIP local-file-header magic rather than by extension, + // because .effectgraph is used for both forms historically. + // + // Media handling mirrors MainWindow.GraphFileIo.cpp: source nodes carry a + // "media://" token which is rewritten to the extracted temp path, in + // BOTH shaderPath and the mirrored "shaderPath" property, so the existing + // image / video pipeline resolves them with no further special-casing. + LoadedGraph LoadGraphFromPath(const std::wstring& path) + { + LoadedGraph result; + + std::string raw = ReadFileUtf8(path); + if (raw.empty()) + { + std::wprintf(L"FATAL: could not read graph file '%ls'\n", path.c_str()); + result.exitCode = 4; + return result; + } + + std::wstring graphJsonW; + std::map mediaMap; + + const bool isZip = raw.size() >= 4 && raw[0] == 'P' && raw[1] == 'K' && + raw[2] == '\x03' && raw[3] == '\x04'; + if (isZip) + { + wchar_t tempRoot[MAX_PATH]{}; + GetTempPathW(MAX_PATH, tempRoot); + auto loaded = ShaderLab::Rendering::EffectGraphFile::Load(path, tempRoot); + if (!loaded.has_value()) + { + std::wprintf(L"FATAL: could not read graph from .effectgraph archive '%ls'\n", + path.c_str()); + result.exitCode = 4; + return result; + } + graphJsonW = loaded->graphJson; + mediaMap = std::move(loaded->mediaMap); + result.extractDir = loaded->extractDir; + } + else + { + int wcCount = MultiByteToWideChar(CP_UTF8, 0, raw.data(), + static_cast(raw.size()), nullptr, 0); + graphJsonW.resize(wcCount, L'\0'); + MultiByteToWideChar(CP_UTF8, 0, raw.data(), + static_cast(raw.size()), graphJsonW.data(), wcCount); + } + + try { + result.graph = ShaderLab::Graph::EffectGraph::FromJson(winrt::hstring(graphJsonW)); + } catch (winrt::hresult_error const& e) { + std::wprintf(L"FATAL: graph JSON parse failed (0x%08X): %ls\n", + static_cast(e.code()), e.message().c_str()); + RemoveExtractDir(result.extractDir); + result.extractDir.clear(); + result.exitCode = 5; + return result; + } + + if (!mediaMap.empty()) + { + auto& nodes = const_cast&>( + result.graph.Nodes()); + for (auto& n : nodes) + { + if (n.type != ShaderLab::Graph::NodeType::Source) continue; + if (!n.shaderPath.has_value()) continue; + auto it = mediaMap.find(*n.shaderPath); + if (it == mediaMap.end()) continue; + n.shaderPath = it->second; + auto pit = n.properties.find(L"shaderPath"); + if (pit != n.properties.end()) + pit->second = it->second; + } + } + + result.ok = true; + return result; + } + // Encode an FP32 RGBA buffer as PNG via WIC. PNG is 8-bit per channel; // values are gamma-encoded sRGB after a clamp to [0, 1]. This is lossy - // for HDR scRGB output (anything above 1.0 saturates to 255). For HDR - // fidelity, switch to JXR (D2D's native FP16 path) -- queued. + // for HDR scRGB output (anything above 1.0 saturates to 255) -- use a + // .jxr output path for HDR fidelity (SaveFp32AsJxr below). HRESULT SaveFp32AsPng(IWICImagingFactory* wic, const float* rgba, uint32_t w, uint32_t h, uint32_t pitchBytes, const std::wstring& path) @@ -371,6 +513,105 @@ L" through CPU readback (the pre-v1.6 path).\n", if (FAILED(hr)) return hr; return encoder->Commit(); } + + // Encode an FP32 RGBA buffer as JPEG XR (.jxr / .wdp) via WIC, preserving + // HDR. Unlike the PNG path there is NO clamp and NO transfer encoding: the + // pipeline's scRGB linear values are written as-is into a 64bpp RGBA-half + // frame, so values above 1.0 (above SDR white) and the negative components + // that express wide-gamut colour both survive the round trip. + // + // FP32 -> FP16 is not a precision loss in practice: the pipeline is + // R16G16B16A16_FLOAT, so these values originated as halves. + // + // Mirrors the GUI's OutputWindow::SaveImageAsync JXR branch + // (GUID_ContainerFormatWmp + Lossless), so a node saved from an output + // window and the same node captured headless produce the same file. + HRESULT SaveFp32AsJxr(IWICImagingFactory* wic, + const float* rgba, uint32_t w, uint32_t h, uint32_t pitchBytes, + const std::wstring& path) + { + using DirectX::PackedVector::XMConvertFloatToHalf; + + // 64bpp RGBA half, tightly packed. + std::vector halfRgba(static_cast(w) * h * 4); + for (uint32_t y = 0; y < h; ++y) + { + const float* srcRow = reinterpret_cast( + reinterpret_cast(rgba) + y * pitchBytes); + uint16_t* dstRow = halfRgba.data() + static_cast(y) * w * 4; + for (uint32_t x = 0; x < w * 4; ++x) + dstRow[x] = XMConvertFloatToHalf(srcRow[x]); + } + + winrt::com_ptr stream; + HRESULT hr = wic->CreateStream(stream.put()); + if (FAILED(hr)) return hr; + hr = stream->InitializeFromFilename(path.c_str(), GENERIC_WRITE); + if (FAILED(hr)) return hr; + + winrt::com_ptr encoder; + hr = wic->CreateEncoder(GUID_ContainerFormatWmp, nullptr, encoder.put()); + if (FAILED(hr)) return hr; + hr = encoder->Initialize(stream.get(), WICBitmapEncoderNoCache); + if (FAILED(hr)) return hr; + + winrt::com_ptr frame; + winrt::com_ptr encoderOptions; + hr = encoder->CreateNewFrame(frame.put(), encoderOptions.put()); + if (FAILED(hr)) return hr; + + // Lossless: the point of this path is fidelity, not file size. + if (encoderOptions) + { + PROPBAG2 option{}; + option.pstrName = const_cast(L"Lossless"); + VARIANT val{}; + val.vt = VT_BOOL; + val.boolVal = VARIANT_TRUE; + encoderOptions->Write(1, &option, &val); + } + + hr = frame->Initialize(encoderOptions.get()); + if (FAILED(hr)) return hr; + hr = frame->SetSize(w, h); + if (FAILED(hr)) return hr; + + WICPixelFormatGUID fmt = GUID_WICPixelFormat64bppRGBAHalf; + hr = frame->SetPixelFormat(&fmt); + if (FAILED(hr)) return hr; + // WIC may negotiate a different format; refuse rather than silently + // writing something that is not the half-float data we promised. + if (fmt != GUID_WICPixelFormat64bppRGBAHalf) return WINCODEC_ERR_UNSUPPORTEDPIXELFORMAT; + + const uint32_t stride = w * 4 * sizeof(uint16_t); + hr = frame->WritePixels(h, stride, stride * h, + reinterpret_cast(halfRgba.data())); + if (FAILED(hr)) return hr; + hr = frame->Commit(); + if (FAILED(hr)) return hr; + return encoder->Commit(); + } + + // True when the output path asks for the HDR-preserving encoder. + bool IsHdrOutputPath(const std::wstring& path) + { + auto dot = path.rfind(L'.'); + if (dot == std::wstring::npos) return false; + std::wstring ext = path.substr(dot); + for (auto& c : ext) c = static_cast(towlower(c)); + return ext == L".jxr" || ext == L".wdp"; + } + + // Pick the encoder from the output file's extension. PNG is the default + // for anything unrecognized, matching the historical behavior. + HRESULT SaveFp32Image(IWICImagingFactory* wic, + const float* rgba, uint32_t w, uint32_t h, uint32_t pitchBytes, + const std::wstring& path) + { + if (IsHdrOutputPath(path)) + return SaveFp32AsJxr(wic, rgba, w, h, pitchBytes, path); + return SaveFp32AsPng(wic, rgba, w, h, pitchBytes, path); + } } int wmain(int argc, wchar_t* argv[]); @@ -426,26 +667,12 @@ int RunRender(const Args& args) ShaderLab::Effects::RegisterEngineD2DEffects(factory1.get()); // ---- Load the graph ---------------------------------------------------- - auto graphJson = ReadFileUtf8(args.graphPath); - if (graphJson.empty()) { - std::wprintf(L"FATAL: could not read graph file '%ls'\n", args.graphPath.c_str()); - return 4; - } - // Convert UTF-8 to UTF-16 for FromJson (winrt::hstring). - int wcCount = MultiByteToWideChar(CP_UTF8, 0, graphJson.data(), - static_cast(graphJson.size()), nullptr, 0); - std::wstring graphJsonW(wcCount, L'\0'); - MultiByteToWideChar(CP_UTF8, 0, graphJson.data(), - static_cast(graphJson.size()), graphJsonW.data(), wcCount); - - ShaderLab::Graph::EffectGraph graph; - try { - graph = ShaderLab::Graph::EffectGraph::FromJson(winrt::hstring(graphJsonW)); - } catch (winrt::hresult_error const& e) { - std::wprintf(L"FATAL: graph JSON parse failed (0x%08X): %ls\n", - static_cast(e.code()), e.message().c_str()); - return 5; - } + // Handles both a .effectgraph ZIP (media extracted to a temp dir, which + // must survive until rendering is done) and a bare JSON graph. + auto loaded = LoadGraphFromPath(args.graphPath); + if (!loaded.ok) return loaded.exitCode; + auto& graph = loaded.graph; + ExtractDirGuard extractGuard{ loaded.extractDir }; if (!graph.FindNode(args.nodeId)) { std::wprintf(L"FATAL: node id %u not found in graph\n", args.nodeId); @@ -604,10 +831,24 @@ int RunRender(const Args& args) // visual-inspection output we want the lift; for raw scRGB pixel // sampling use --no-tonemap (or, future work, the FP16 readback // path tracked as p7-headless-fp16-pixel-readback). + // A .jxr / .wdp output exists to preserve HDR, so tone mapping to SDR + // would defeat it: the default HdrToneMap (OUTPUT_MAX_LUMINANCE = 80) + // maps a 800-nit source down to ~1.0 scRGB, and the encoder would then + // faithfully store an SDR image in an HDR container. So HDR output + // implies --no-tonemap, UNLESS the caller named a peak explicitly -- + // tone mapping INTO an HDR deliverable (e.g. 4000-nit source to a + // 1000-nit target) is a legitimate request and must still win. + const bool hdrOutput = IsHdrOutputPath(args.outputPath); + const bool skipToneMap = args.skipToneMap || (hdrOutput && !args.toneMapExplicit); + if (hdrOutput && !args.skipToneMap && !args.toneMapExplicit) + std::wprintf(L"NOTE: HDR output (%ls) -- skipping HdrToneMap to preserve " + L"values above 1.0. Pass --output-peak-nits to tone map anyway.\n", + args.outputPath.c_str()); + winrt::com_ptr toneMap; winrt::com_ptr toneMappedOut; ID2D1Image* renderInput = node->cachedOutput; - if (!args.skipToneMap) + if (!skipToneMap) { hr = dc->CreateEffect(CLSID_D2D1HdrToneMap, toneMap.put()); if (FAILED(hr)) { @@ -665,13 +906,13 @@ int RunRender(const Args& args) return 13; } - hr = SaveFp32AsPng(wic.get(), + hr = SaveFp32Image(wic.get(), reinterpret_cast(mapped.bits), args.width, args.height, mapped.pitch, args.outputPath); staging->Unmap(); if (FAILED(hr)) { - std::wprintf(L"FATAL: SaveFp32AsPng failed 0x%08X\n", static_cast(hr)); + std::wprintf(L"FATAL: image encode failed 0x%08X\n", static_cast(hr)); return 14; } @@ -777,25 +1018,11 @@ int RunScript(const Args& args) ShaderLab::Effects::RegisterEngineD2DEffects(factory1.get()); // ---- Load graph ------------------------------------------------------- - auto graphJson = ReadFileUtf8(args.graphPath); - if (graphJson.empty()) { - std::wprintf(L"FATAL: could not read graph file '%ls'\n", args.graphPath.c_str()); - return 4; - } - int wcCount = MultiByteToWideChar(CP_UTF8, 0, graphJson.data(), - static_cast(graphJson.size()), nullptr, 0); - std::wstring graphJsonW(wcCount, L'\0'); - MultiByteToWideChar(CP_UTF8, 0, graphJson.data(), - static_cast(graphJson.size()), graphJsonW.data(), wcCount); - - ShaderLab::Graph::EffectGraph graph; - try { - graph = ShaderLab::Graph::EffectGraph::FromJson(winrt::hstring(graphJsonW)); - } catch (winrt::hresult_error const& e) { - std::wprintf(L"FATAL: graph JSON parse failed (0x%08X): %ls\n", - static_cast(e.code()), e.message().c_str()); - return 5; - } + // Same dual-form loader as RunRender: .effectgraph ZIP or bare JSON. + auto loaded = LoadGraphFromPath(args.graphPath); + if (!loaded.ok) return loaded.exitCode; + auto& graph = loaded.graph; + ExtractDirGuard extractGuard{ loaded.extractDir }; ShaderLab::Effects::SourceNodeFactory sourceFactory; ShaderLab::Rendering::GraphEvaluator evaluator; diff --git a/ShaderLabMcpBroker.vcxproj b/ShaderLabMcpBroker.vcxproj index 183a670..3efa77c 100644 --- a/ShaderLabMcpBroker.vcxproj +++ b/ShaderLabMcpBroker.vcxproj @@ -81,7 +81,7 @@ stdcpp20 SHADERLAB_ENGINE_EXPORTS;%(PreprocessorDefinitions) 4251;%(DisableSpecificWarnings) - %(AdditionalOptions) /bigobj + %(AdditionalOptions) /bigobj /utf-8 Console diff --git a/ShaderLabTests.vcxproj b/ShaderLabTests.vcxproj index 736e691..f017f0e 100644 --- a/ShaderLabTests.vcxproj +++ b/ShaderLabTests.vcxproj @@ -73,7 +73,7 @@ true stdcpp20 4251;%(DisableSpecificWarnings) - %(AdditionalOptions) /bigobj + %(AdditionalOptions) /bigobj /utf-8 Console diff --git a/Tests/Math/ColorMatrixTests.cpp b/Tests/Math/ColorMatrixTests.cpp index 2a1f598..50b1929 100644 --- a/Tests/Math/ColorMatrixTests.cpp +++ b/Tests/Math/ColorMatrixTests.cpp @@ -150,20 +150,53 @@ namespace ShaderLab::Tests !r.empty() && r[0].x < 5e-3f); } - // ---- Negative scRGB protection ------------------------------------ - // ScRGBToICtCp clamps `max(rgb, 0)` before the XYZ matrix to keep - // the LMS path well-defined. Confirm: a slightly-negative input - // produces the same ICtCp as the all-zero input. + // ---- Wide-gamut (negative scRGB) survival -------------------------- + // scRGB carries wide-gamut colour as negative Rec.709 components, so + // the ICtCp round trip must preserve them. It previously clamped + // `max(rgb, 0)` on entry, which silently sRGB-clipped every + // wide-gamut pixel *before* any tone/gamut mapping ran. { + // BT.2020 and DCI-P3 primaries at 80 nits, expressed in scRGB. + // Each has at least one strongly negative component. auto r = bench.Run(R"( - float3 a = ScRGBToICtCp(float3(-0.1, -0.1, -0.1)); - float3 b = ScRGBToICtCp(float3( 0.0, 0.0, 0.0)); - float3 d = abs(a - b); - float maxErr = max(max(d.x, d.y), d.z); - Result[0] = float4(maxErr, a.x, a.y, a.z); + float3 wide[4] = { + float3(-0.8667, 1.0000, 0.0596), // BT.2020 green-ish + float3( 1.2484, -0.0479, -0.0184), // BT.2020 red-ish + float3(-0.1067, 1.0128, 0.0294), // P3 green-ish + float3( 1.0930, -0.2267, 0.0442) // P3 red-ish + }; + float maxErr = 0.0; + [unroll] + for (int i = 0; i < 4; ++i) { + float3 rt = ICtCpToScRGB(ScRGBToICtCp(wide[i])); + float3 d = abs(rt - wide[i]); + maxErr = max(maxErr, max(max(d.x, d.y), d.z)); + } + Result[0] = float4(maxErr, 0, 0, 0); )"); - TEST("ScRGBToICtCp negative-input clamp matches zero (max err < 1e-5)", - !r.empty() && r[0].x < 1e-5f); + TEST("ICtCp round trip preserves wide-gamut negatives (max err < 5e-3)", + !r.empty() && r[0].x < 5e-3f); + } + { + // The specific regression: a negative component must NOT collapse + // to zero. Pin the sign and rough magnitude explicitly so a + // reintroduced clamp fails loudly rather than drifting. + auto r = bench.Run(R"( + float3 rt = ICtCpToScRGB(ScRGBToICtCp(float3(-0.8667, 1.0, 0.0596))); + Result[0] = float4(rt, 0); + )"); + TEST("ICtCp round trip keeps R strongly negative (no sRGB clip on entry)", + !r.empty() && r[0].x < -0.80f && r[0].x > -0.93f); + } + { + // Signed PQ is symmetric about the origin, so below-black input + // round-trips rather than pinning to zero. + auto r = bench.Run(R"( + float3 rt = ICtCpToScRGB(ScRGBToICtCp(float3(-0.1, -0.1, -0.1))); + Result[0] = float4(rt, 0); + )"); + TEST("ICtCp round trip preserves below-black neutral (-0.1 stays negative)", + !r.empty() && r[0].x < -0.09f && r[0].x > -0.11f); } } } diff --git a/Tests/Math/DeltaETests.cpp b/Tests/Math/DeltaETests.cpp index faf85b1..cfe9f61 100644 --- a/Tests/Math/DeltaETests.cpp +++ b/Tests/Math/DeltaETests.cpp @@ -133,6 +133,84 @@ float DeltaE2000(float3 lab1, float3 lab2) { !r.empty() && Near(r[0].x, 11.180f, 1e-2f)); } + // ---- Delta E ITP (BT.2124) ---------------------------------------- + // DeltaEITP / DeltaEITPFromScRGB live in the SHARED colour-math + // library (unlike the Lab metrics above), so the bench gets them + // without kDeltaEHelpers. + { + auto r = bench.Run(R"( + float3 c = float3(0.5, 0.02, -0.03); + float3 a = float3(0.5, 0.02, -0.03); + float3 b = float3(0.4, -0.01, 0.05); + float ident = DeltaEITP(c, c); + float dab = DeltaEITP(a, b); + float dba = DeltaEITP(b, a); + Result[0] = float4(ident, dab, dba, abs(dab - dba)); + )", 1); + TEST("DeltaEITP(c, c) == 0", !r.empty() && Near(r[0].x, 0.0f, 1e-6f)); + TEST("DeltaEITP is symmetric", !r.empty() && Near(r[0].w, 0.0f, 1e-5f)); + } + { + // The 720 scale factor, isolated: a pure I difference of 0.001 + // must read 0.72 by definition of the metric. + auto r = bench.Run(R"( + float d = DeltaEITP(float3(0.0, 0.0, 0.0), float3(0.001, 0.0, 0.0)); + Result[0] = float4(d, 0, 0, 0); + )", 1); + TEST("DeltaEITP scale: dI=0.001 -> 0.72", !r.empty() && Near(r[0].x, 0.72f, 1e-4f)); + } + { + // BT.2124's defining asymmetry: T = 0.5*Ct while P = Cp, so an + // identical numeric step in Cp counts DOUBLE the same step in Ct. + // Dropping the 0.5 (or applying it to Cp) is the easy bug. + auto r = bench.Run(R"( + float dCt = DeltaEITP(float3(0,0,0), float3(0, 0.01, 0)); + float dCp = DeltaEITP(float3(0,0,0), float3(0, 0, 0.01)); + Result[0] = float4(dCt, dCp, dCp / max(dCt, 1e-9), 0); + )", 1); + TEST("DeltaEITP weights Ct at half of Cp (ratio == 2)", + !r.empty() && Near(r[0].z, 2.0f, 1e-4f)); + TEST("DeltaEITP Ct step: 0.01 -> 3.6", + !r.empty() && Near(r[0].x, 3.6f, 1e-3f)); + } + { + // Grounds the scale against a metric that IS valid in the SDR + // sRGB domain: for a small near-neutral step both are in their + // fitted range and ~1 unit = ~1 JND, so they must agree within a + // small factor. This is what would catch a wrong constant + // (720 vs 100 vs 1) that the pure-formula checks accept happily. + auto r = bench.Run(R"( + float3 rgbA = float3(0.5, 0.5, 0.5); + float3 rgbB = float3(0.52, 0.5, 0.5); + float itp = DeltaEITPFromScRGB(rgbA, rgbB); + float lab = DeltaE2000(ScRGBToLab(rgbA), ScRGBToLab(rgbB)); + Result[0] = float4(itp, lab, itp / max(lab, 1e-6), 0); + )", 1, kDeltaEHelpers); + bool sane = !r.empty() && r[0].x > 0.0f && r[0].y > 0.0f && + r[0].z > 0.2f && r[0].z < 5.0f; + if (!r.empty()) + std::printf(" dE ITP %.3f vs dE2000 %.3f (ratio %.2f) on a small SDR step\n", + r[0].x, r[0].y, r[0].z); + TEST("DeltaEITP magnitude agrees with dE2000 within 5x on a small SDR step", sane); + } + { + // Monotonic in separation, and meaningful where the Lab metrics + // are out of their domain: bright HDR neutrals. + // (scRGB 1.0 = 80 nits, so 12.5 = 1000 nits.) + auto r = bench.Run(R"( + float3 a = float3(12.5, 12.5, 12.5); + float3 b = float3(13.75, 13.75, 13.75); + float3 c = float3(25.0, 25.0, 25.0); + float dNear = DeltaEITPFromScRGB(a, b); + float dFar = DeltaEITPFromScRGB(a, c); + Result[0] = float4(dNear, dFar, dFar - dNear, 0); + )", 1); + TEST("DeltaEITP is non-zero at HDR levels (1000 vs 1100 nits)", + !r.empty() && r[0].x > 0.5f); + TEST("DeltaEITP grows with separation at HDR levels", + !r.empty() && r[0].z > 0.0f); + } + // ---- DeltaE2000 Sharma reference pairs ---------------------------- // From Sharma, Wu, Dalal: "The CIEDE2000 Color-Difference Formula: // Implementation Notes, Supplementary Test Data, and Mathematical diff --git a/Tests/Math/GamutTests.cpp b/Tests/Math/GamutTests.cpp index cf6211b..b4e71ec 100644 --- a/Tests/Math/GamutTests.cpp +++ b/Tests/Math/GamutTests.cpp @@ -196,5 +196,134 @@ namespace ShaderLab::Tests && Near(r[0].y, 0.0f, 1.0f) && Near(r[0].z, 0.0f, 1.0f)); } + + // ---- SoftCompressDistance (gamut soft roll-off) ------------------- + // Contract tests: these hold for any hardness p >= 1 of the ACES + // power curve (p=1 is exactly Reinhard). threshold=0.75, + // limit=1.5, power=1.2 (the ACES RGC default) unless stated. + { + // Identity zone: d <= threshold returns d exactly. + auto r = bench.Run(R"( + float a = SoftCompressDistance(0.30, 0.75, 1.5, 1.2); + float b = SoftCompressDistance(0.75, 0.75, 1.5, 1.2); + Result[0] = float4(a, b, 0, 0); + )"); + TEST("SoftCompressDistance: identity below threshold", + !r.empty() + && Near(r[0].x, 0.30f, 1e-6f) + && Near(r[0].y, 0.75f, 1e-5f)); + } + { + // Anchor: d == limit lands exactly on the boundary (1.0). + auto r = bench.Run(R"( + Result[0] = float4(SoftCompressDistance(1.5, 0.75, 1.5, 1.2), 0, 0, 0); + )"); + TEST("SoftCompressDistance: limit maps onto boundary (== 1.0)", + !r.empty() && Near(r[0].x, 1.0f, 1e-3f)); + } + { + // C1 join: slope ~= 1 just above threshold, so gradients + // crossing the knee don't kink. Central-difference slope over + // [t, t + 0.02] must be within 15% of 1. + auto r = bench.Run(R"( + float y0 = SoftCompressDistance(0.75, 0.75, 1.5, 1.2); + float y1 = SoftCompressDistance(0.77, 0.75, 1.5, 1.2); + Result[0] = float4((y1 - y0) / 0.02, 0, 0, 0); + )"); + TEST("SoftCompressDistance: slope ~= 1 entering the knee (C1)", + !r.empty() && Near(r[0].x, 1.0f, 0.15f)); + } + { + // Monotone + compressive: outputs strictly increase with d, + // and never exceed the input above the threshold. + auto r = bench.Run(R"( + float d[5] = { 0.8, 1.0, 1.2, 1.4, 1.5 }; + float prev = -1.0; + float mono = 1.0, comp = 1.0; + [unroll] + for (int i = 0; i < 5; ++i) { + float y = SoftCompressDistance(d[i], 0.75, 1.5, 1.2); + if (y <= prev) mono = 0.0; + if (y > d[i] + 1e-5) comp = 0.0; + prev = y; + } + Result[0] = float4(mono, comp, 0, 0); + )"); + TEST("SoftCompressDistance: monotone increasing and compressive", + !r.empty() + && Near(r[0].x, 1.0f, 1e-6f) + && Near(r[0].y, 1.0f, 1e-6f)); + } + { + // Softness proper: a point between threshold and limit must map + // strictly BELOW the hard boundary (that's the whole feature — + // headroom is reserved so d in (1, limit] stays ordered instead + // of flattening onto the shell). + auto r = bench.Run(R"( + Result[0] = float4(SoftCompressDistance(1.2, 0.75, 1.5, 1.2), 0, 0, 0); + )"); + TEST("SoftCompressDistance: interior of knee stays below boundary (soft, not clip)", + !r.empty() && r[0].x < 0.999f && r[0].x > 0.75f); + } + { + // Hardness ordering: higher power tracks identity longer, so at + // a fixed d inside the knee it must compress LESS than p=1 + // (Reinhard). Also pins p=1 == Reinhard closed form: + // s = (1-t)(l-t)/(l-1) = 0.375; y(1.0) = t + s*x/(s+x) = 0.90. + auto r = bench.Run(R"( + float soft = SoftCompressDistance(1.0, 0.75, 1.5, 1.0); + float hard = SoftCompressDistance(1.0, 0.75, 1.5, 3.0); + Result[0] = float4(soft, hard, 0, 0); + )"); + TEST("SoftCompressDistance: p=1 matches Reinhard; higher hardness compresses less", + !r.empty() + && Near(r[0].x, 0.90f, 1e-3f) + && r[0].y > r[0].x + 0.01f); + } + + // ---- 8-bit dither / quantize (screenshot output path) -------------- + { + // Zero strength must land exactly on the 8-bit grid. + auto r = bench.Run(R"( + float3 q = DitherQuantize(float3(0.5, 0.25, 0.75), float2(3, 7), 256.0, 0.0); + float3 grid = round(float3(0.5, 0.25, 0.75) * 255.0) / 255.0; + Result[0] = float4(abs(q - grid), 0); + )"); + TEST("DitherQuantize(strength 0) lands exactly on the 8-bit grid", + !r.empty() && r[0].x < 1e-6f && r[0].y < 1e-6f && r[0].z < 1e-6f); + } + { + // Triangular dither stays inside +-1 LSB across a pixel sweep. + auto r = bench.Run(R"( + float lo = 1e9, hi = -1e9; + [unroll] + for (int i = 0; i < 32; ++i) { + float d = TriangularDither(float2(i, i * 3 + 1)); + lo = min(lo, d); hi = max(hi, d); + } + Result[0] = float4(lo, hi, 0, 0); + )"); + TEST("TriangularDither stays within [-1, 1]", + !r.empty() && r[0].x >= -1.0f && r[0].y <= 1.0f && r[0].y > r[0].x); + } + { + // A value sitting between two codes must resolve to BOTH codes + // across pixels -- that is the whole mechanism by which dither + // trades banding for noise. 0.5 encodes to ~187.5/255. + auto r = bench.Run(R"( + float v = 187.5 / 255.0; + float lo = 1e9, hi = -1e9; + [unroll] + for (int i = 0; i < 32; ++i) { + float q = DitherQuantize(float3(v, v, v), float2(i, i * 5 + 2), 256.0, 1.0).x; + lo = min(lo, q); hi = max(hi, q); + } + Result[0] = float4(lo * 255.0, hi * 255.0, 0, 0); + )"); + TEST("DitherQuantize spreads a between-codes value across both codes", + !r.empty() + && Near(r[0].x, 187.0f, 0.51f) + && Near(r[0].y, 188.0f, 0.51f)); + } } } diff --git a/Tests/RunBrokerSmoke.ps1 b/Tests/RunBrokerSmoke.ps1 index 7a131d0..61ee93a 100644 --- a/Tests/RunBrokerSmoke.ps1 +++ b/Tests/RunBrokerSmoke.ps1 @@ -32,6 +32,8 @@ if (-not (Test-Path $exe)) { } $script:failures = 0 +# Server -> client notifications seen while waiting for responses (see Recv). +$script:notifications = @() function Check($name, $cond) { if ($cond) { Write-Host "[PASS] $name" -ForegroundColor Green } else { Write-Host "[FAIL] $name" -ForegroundColor Red; $script:failures++ } @@ -79,12 +81,36 @@ try { function SendRaw([string]$line) { $shim.StandardInput.WriteLine($line) } function Send($obj) { SendRaw ($obj | ConvertTo-Json -Depth 8 -Compress) } - function Recv($timeoutMs = 6000) { + # Raw single-line read. Returns $null on timeout WITHOUT resyncing, so + # never call this directly when a response is expected -- use Recv. + function RecvRaw($timeoutMs = 6000) { $task = $shim.StandardOutput.ReadLineAsync() if (-not $task.Wait($timeoutMs)) { return $null } return $task.Result } + # Reads the next JSON-RPC *response* (a message carrying an "id"), + # recording and skipping any server -> client notifications along the way. + # + # The shim advertises tools.listChanged in initialize and then emits + # notifications/tools/list_changed immediately after use_session splices + # the pinned session's catalog in -- correct MCP behavior. This harness + # used to treat every line as a reply, so that one unsolicited line + # shifted every subsequent read by one and cascaded into five failures, + # including a FALSE PASS on Session.GoneSurfacesDistinctError (it was + # reading the previous call's session_gone text). Correlate, don't count. + function Recv($timeoutMs = 6000) { + for ($i = 0; $i -lt 16; $i++) { + $line = RecvRaw $timeoutMs + if ($null -eq $line) { return $null } + $obj = $null + try { $obj = $line | ConvertFrom-Json } catch { return $line } # let the caller fail on garbage + if ($null -ne $obj.PSObject.Properties['id']) { return $line } + $script:notifications += @($obj.method) + } + return $null + } + Send @{ jsonrpc = '2.0'; id = 1; method = 'initialize'; params = @{ protocolVersion = '2025-06-18' } } $line = Recv $init = if ($line) { $line | ConvertFrom-Json } else { $null } @@ -150,6 +176,14 @@ try { $spliced = @(((Recv) | ConvertFrom-Json).result.tools | ForEach-Object name) Check "Session.ToolsListSpliced" (($spliced -contains 'list_sessions') -and ($spliced -contains 'graph_add_node') -and ($spliced -contains 'graph_overview')) + # Attaching changes the advertised tool set, so the shim must emit + # notifications/tools/list_changed (it advertises tools.listChanged + # in initialize). It is written straight after the use_session + # reply, so the Recv above is the first read to consume it -- + # assert only once that read has happened. + Check "Session.ToolsListChangedOnAttach" ` + ($script:notifications -contains 'notifications/tools/list_changed') + # Drive a real engine route end-to-end (sealed through the hub). Send @{ jsonrpc = '2.0'; id = 103; method = 'tools/call'; params = @{ name = 'graph_overview'; arguments = @{} } } $ov = (Recv) | ConvertFrom-Json diff --git a/Tests/RunHeadlessSmoke.ps1 b/Tests/RunHeadlessSmoke.ps1 index bcfb232..a29fe58 100644 --- a/Tests/RunHeadlessSmoke.ps1 +++ b/Tests/RunHeadlessSmoke.ps1 @@ -160,6 +160,86 @@ try { Write-Host "PASS: script batch ratio $('{0:N3}' -f $ratio) ~ 2.5 (graph-node analysis end-to-end)" Remove-Item $scriptPath, $scriptOut + # ---- JPEG XR (HDR-preserving) output --------------------------------- + # The fixture's Gamut Source emits Luminance nits / 80 as scRGB, so at + # 800 nits every in-gamut pixel lands near 10.0 -- far above the [0,1] + # PNG clamps to. A .jxr output must carry those values through: that is + # the entire reason the encoder exists, and it also pins the rule that + # HDR output skips the default SDR HdrToneMap. + $hdrGraph = Join-Path $env:TEMP "shaderlab_smoke_hdr_$([guid]::NewGuid().ToString('N')).json" + $g = Get-Content $fixture -Raw | ConvertFrom-Json + foreach ($p in $g.nodes[0].properties) { + if ($p.name -eq 'Luminance') { $p.value = 800.0 } + if ($p.name -eq 'OutputSize') { $p.value = 256.0 } + } + $g | ConvertTo-Json -Depth 64 | Set-Content $hdrGraph -Encoding UTF8 + + $jxrOut = Join-Path $env:TEMP "shaderlab_smoke_$([guid]::NewGuid().ToString('N')).jxr" + & $exe --graph $hdrGraph --node 1 --output $jxrOut --width 256 --height 256 --adapter warp + if ($LASTEXITCODE -ne 0) { + Remove-Item $hdrGraph, $jxrOut -ErrorAction SilentlyContinue + Write-Error "JXR render failed with exit code $LASTEXITCODE" + exit $LASTEXITCODE + } + $jb = [System.IO.File]::ReadAllBytes($jxrOut) + # JPEG XR container magic: 'II' + 0xBC 0x01. + if ($jb.Length -lt 4 -or $jb[0] -ne 0x49 -or $jb[1] -ne 0x49 -or $jb[2] -ne 0xBC -or $jb[3] -ne 0x01) { + Remove-Item $hdrGraph, $jxrOut -ErrorAction SilentlyContinue + Write-Error "Output is not a JPEG XR file (magic bytes wrong)" + exit 1 + } + + Add-Type -AssemblyName PresentationCore + $st = [System.IO.File]::OpenRead($jxrOut) + $fr = ([System.Windows.Media.Imaging.BitmapDecoder]::Create( + $st, 'PreservePixelFormat', 'OnLoad')).Frames[0] + if ($fr.Format.BitsPerPixel -ne 64) { + $st.Close(); Remove-Item $hdrGraph, $jxrOut -ErrorAction SilentlyContinue + Write-Error "JXR is $($fr.Format.BitsPerPixel)bpp, expected 64 (RGBA half)" + exit 1 + } + $raw = New-Object 'ushort[]' ($fr.PixelWidth * $fr.PixelHeight * 4) + $fr.CopyPixels($raw, $fr.PixelWidth * 8, 0) + $st.Close() + # Decode the red half at the image centre (inside the gamut triangle). + $i = (128 * $fr.PixelWidth + 128) * 4 + $hb = $raw[$i]; $e = ($hb -shr 10) -band 0x1F; $m = $hb -band 0x3FF + $red = if ($e -eq 0) { [math]::Pow(2, -14) * ($m / 1024) } + else { [math]::Pow(2, $e - 15) * (1 + $m / 1024) } + Remove-Item $hdrGraph, $jxrOut -ErrorAction SilentlyContinue + if ($red -lt 2.0) { + Write-Error "JXR centre red = $red; expected ~10 (HDR clamped or tone mapped away)" + exit 1 + } + Write-Host "PASS: JXR 64bpp half, centre red $('{0:N3}' -f $red) > 1.0 (HDR preserved)" + + # ---- .effectgraph ZIP container -------------------------------------- + # The GUI's Save writes a ZIP (graph.json + optional media/). Headless + # must read that form, not just bare JSON. + $zipGraph = Join-Path $env:TEMP "shaderlab_smoke_$([guid]::NewGuid().ToString('N')).effectgraph" + Add-Type -AssemblyName System.IO.Compression, System.IO.Compression.FileSystem + $zs = [System.IO.File]::Open($zipGraph, 'Create') + $za = New-Object System.IO.Compression.ZipArchive($zs, 'Create') + $entry = $za.CreateEntry('graph.json') + $sw = New-Object System.IO.StreamWriter($entry.Open()) + $sw.Write((Get-Content $fixture -Raw)); $sw.Dispose() + $za.Dispose(); $zs.Dispose() + + $zipOut = Join-Path $env:TEMP "shaderlab_smoke_$([guid]::NewGuid().ToString('N')).png" + & $exe --graph $zipGraph --node 1 --output $zipOut --width 128 --height 128 --adapter warp + $zipExit = $LASTEXITCODE + $zipOk = ($zipExit -eq 0) -and (Test-Path $zipOut) + if ($zipOk) { + $zb = [System.IO.File]::ReadAllBytes($zipOut) + $zipOk = $zb.Length -gt 8 -and $zb[0] -eq 0x89 -and $zb[1] -eq 0x50 + } + Remove-Item $zipGraph, $zipOut -ErrorAction SilentlyContinue + if (-not $zipOk) { + Write-Error ".effectgraph ZIP load failed (exit $zipExit) or produced no valid PNG" + exit 1 + } + Write-Host "PASS: .effectgraph ZIP container loaded and rendered" + exit 0 } finally { diff --git a/Tests/TestRunner.cpp b/Tests/TestRunner.cpp index e4fec3e..35ea3f6 100644 --- a/Tests/TestRunner.cpp +++ b/Tests/TestRunner.cpp @@ -20,6 +20,7 @@ #include "Engine/Mcp/McpCrypto.h" #include "Engine/Mcp/McpPeerIdentity.h" #include "Engine/Mcp/McpChannel.h" +#include "Engine/Mcp/McpSessionClient.h" #include #include @@ -162,6 +163,45 @@ namespace TEST("PropertyPreserved", propOk); } + // Documentation drift guard. + // + // The ShaderLab effect count is quoted in six places outside the code + // (docs/effects/builtin-catalog.md, docs/README.md, + // docs/development/project-structure.md, docs/architecture/engine-host-split.md, + // and twice in .github/copilot-instructions.md). Those quotes drifted to + // 33 and 35 while the registry held 36. Adding or removing an effect + // should fail here, as a reminder to update the catalog table and the + // counts alongside it -- not silently desync the docs again. + void TestEffectCatalogCount() + { + printf("\n=== Effect Catalog Count (doc drift guard) ===\n"); + + // Bump this together with the catalog table + the counts listed above. + constexpr size_t kExpectedShaderLabEffects = 36; + + const auto& all = ShaderLab::Effects::ShaderLabEffects::Instance().All(); + if (all.size() != kExpectedShaderLabEffects) + { + printf(" registry holds %zu effects, expected %zu -- update " + "docs/effects/builtin-catalog.md and the counts in " + "docs/README.md, docs/development/project-structure.md, " + "docs/architecture/engine-host-split.md and " + ".github/copilot-instructions.md, then bump " + "kExpectedShaderLabEffects.\n", + all.size(), kExpectedShaderLabEffects); + } + TEST("ShaderLab effect count matches the documented catalog", + all.size() == kExpectedShaderLabEffects); + + // effectId is the stable identity saved in graphs; a duplicate would + // make effectVersion upgrades ambiguous on load. + std::set ids; + bool unique = true; + for (const auto& e : all) + if (!ids.insert(e.effectId).second) unique = false; + TEST("every effectId is unique", unique); + } + void TestSourceEffects() { printf("\n=== Source Effects ===\n"); @@ -553,6 +593,90 @@ float4 main(float4 pos : SV_POSITION, float4 uv0 : TEXCOORD0) : SV_TARGET { macroPS, "test_param_gpu.hlsl", "main", "ps_5_0", { { "_SLPARAM_Exposure_GPU", "1" } }); TEST("ShaderLabParamsHlsli_GpuMode", gpuMode.succeeded); + + // ---- HDR Screenshot Tonemap: compiles + expected cbuffer ---------- + // The fused 8bpc screenshot path. Compile the real registry + // descriptor exactly as graph-load does so a broken shader fails the + // suite rather than surfacing as a black node in the app. + { + using namespace ShaderLab::Effects; + const auto* desc = ShaderLabEffects::Instance().FindById(L"HDR Screenshot Tonemap"); + TEST("ScreenshotTonemap_DescriptorExists", desc != nullptr); + if (desc) + { + std::string src(desc->hlslSource.begin(), desc->hlslSource.end()); + auto compiled = ShaderCompiler::CompileFromString( + src, "screenshot_tonemap.hlsl", "main", "ps_5_0"); + if (!compiled.succeeded && compiled.errors) + { + printf(" [info] compile errors: %.900s\n", + static_cast(compiled.errors->GetBufferPointer())); + } + TEST("ScreenshotTonemap_Compiles", compiled.succeeded); + if (compiled.succeeded) + { + auto refl = ShaderCompiler::Reflect(compiled.bytecode.get()); + TEST("ScreenshotTonemap_HasCbuffer", !refl.constantBuffers.empty()); + if (!refl.constantBuffers.empty()) + { + const auto& cb = refl.constantBuffers[0]; + auto has = [&](const wchar_t* n) { + for (const auto& v : cb.variables) if (v.name == n) return true; + return false; + }; + TEST("ScreenshotTonemap_HasSdrWhiteNits", has(L"SdrWhiteNits")); + TEST("ScreenshotTonemap_HasKneeRatio", has(L"KneeRatio")); + TEST("ScreenshotTonemap_HasChromaCorrect", has(L"ChromaCorrect")); + TEST("ScreenshotTonemap_HasDitherStrength",has(L"DitherStrength")); + TEST("ScreenshotTonemap_HasQuantize", has(L"Quantize")); + } + } + } + } + + // ---- ICtCp Gamut Map cbuffer layout (Soft Compress regression) ----- + // The Soft Compress params are the first cbuffer payload past byte + // 64 in any ShaderLab effect. Compile the real registry descriptor + // exactly as graph-load does and assert the reflected layout, so a + // packing/reflection size bug can't silently zero them again. + { + using namespace ShaderLab::Effects; + const auto* desc = ShaderLabEffects::Instance().FindById(L"ICtCp Gamut Map"); + TEST("ICtCpGamutMap_DescriptorExists", desc != nullptr); + if (desc) + { + std::string src(desc->hlslSource.begin(), desc->hlslSource.end()); + auto compiled = ShaderCompiler::CompileFromString( + src, "ictcp_gamut_map.hlsl", "main", "ps_5_0"); + TEST("ICtCpGamutMap_Compiles", compiled.succeeded); + if (compiled.succeeded) + { + auto refl = ShaderCompiler::Reflect(compiled.bytecode.get()); + TEST("ICtCpGamutMap_HasCbuffer", !refl.constantBuffers.empty()); + if (!refl.constantBuffers.empty()) + { + const auto& cb = refl.constantBuffers[0]; + printf(" [info] cbuffer '%ls' sizeBytes=%u vars=%zu\n", + cb.name.c_str(), cb.sizeBytes, cb.variables.size()); + auto findVar = [&](const wchar_t* n) -> const ShaderVariable* { + for (const auto& v : cb.variables) + if (v.name == n) return &v; + return nullptr; + }; + const auto* st = findVar(L"SoftThreshold"); + const auto* sl = findVar(L"SoftLimit"); + const auto* kh = findVar(L"KneeHardness"); + for (const auto& v : cb.variables) + printf(" [info] var %ls offset=%u size=%u\n", + v.name.c_str(), v.offset, v.size); + TEST("ICtCpGamutMap_CbufferSize80", cb.sizeBytes == 80); + TEST("ICtCpGamutMap_SoftThresholdAt64", st && st->offset == 64); + TEST("ICtCpGamutMap_SoftLimitAt68", sl && sl->offset == 68); + TEST("ICtCpGamutMap_KneeHardnessAt72", kh && kh->offset == 72); + } + } + } + } } // ------------------------------------------------------------------------ @@ -1715,6 +1839,128 @@ static void TestMcpJsonRpc() // McpFrame + McpCrypto (stdio-migration Step 4): wire codec and the // P-256 ECDH -> HKDF-SHA256 -> AES-256-GCM session stack, as pure units. // ============================================================================ +// Session-client start/stop lifecycle. +// +// Regression guard for the toolbar's "disable MCP" path. Stop() used to +// CloseHandle() the pipe from the UI thread while the session thread was +// inside a blocking ReadFile/WriteFile on that same handle -- undefined per +// Win32 (a Debug build raises STATUS_INVALID_HANDLE, and a recycled handle +// value lets the session thread write into an unrelated object). It now sets +// the stop flag and calls CancelSynchronousIo on the session thread, leaving +// the close to the thread that owns the handle. +// +// No hub is running here, so the client sits in its connect/backoff loop -- +// which is exactly the "freshly launched, not yet registered" window the +// crash was reported in. What this pins: Stop() is safe before the pipe is +// ever published, the duplicated thread handle is published and retired +// correctly, and Run() returns promptly rather than hanging the caller's +// join(). +static void TestMcpSessionClientLifecycle() +{ + using ShaderLab::Tests::TEST; + printf("\n=== McpSessionClient lifecycle ===\n"); + using namespace ShaderLab::Mcp; + + ShaderLab::McpRouter router; + + // Point at a pipe name nothing is serving so CreateFileW fails fast and + // the client stays in the pre-registration window. + auto makeOpts = [] { + SessionClientOptions o; + o.pipeBaseName = L"ShaderLab.mcp.unittest.nohub." + + std::to_wstring(GetCurrentProcessId()); + o.sessionId = L"{00000000-0000-0000-0000-00000000TEST}"; + o.label = L"unit-test-session"; + return o; + }; + + // Stop() immediately after launch, repeatedly. Any handle misuse here is + // what took the app down on a toggle click. + bool allJoined = true; + for (int i = 0; i < 25 && allJoined; ++i) + { + McpSessionClient client(router, makeOpts()); + std::thread t([&client] { client.Run(); }); + client.Stop(); // races the connect attempt + auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(10); + while (t.joinable() && std::chrono::steady_clock::now() < deadline) + { + t.join(); + break; + } + if (t.joinable()) { allJoined = false; t.detach(); } + } + TEST("Stop() during connect returns promptly (25x, no hang)", allJoined); + + // Stop() before Run() ever starts, and Stop() called twice, must both be + // no-ops rather than touching a handle that was never published. + bool safeEdges = true; + try + { + McpSessionClient neverRan(router, makeOpts()); + neverRan.Stop(); + neverRan.Stop(); + + McpSessionClient client(router, makeOpts()); + std::thread t([&client] { client.Run(); }); + client.Stop(); + client.Stop(); // second Stop after the first + t.join(); + } + catch (...) { safeEdges = false; } + TEST("Stop() is safe before Run() and when called twice", safeEdges); + + // The discriminating case. The two checks above never publish a pipe + // handle (nothing is listening), so the old CloseHandle path would pass + // them too. Here a stub pipe server accepts the connection and then + // deliberately never answers the hello, leaving the session thread parked + // in a blocking synchronous ReadFile -- precisely the state the UI thread + // used to close the handle out from under. + { + auto opts = makeOpts(); + opts.pipeBaseName = L"ShaderLab.mcp.unittest.stub." + + std::to_wstring(GetCurrentProcessId()); + const std::wstring pipePath = L"\\\\.\\pipe\\" + opts.pipeBaseName; + + std::atomic serverReady{ false }; + std::atomic serverStop{ false }; + std::thread server([&] { + HANDLE p = CreateNamedPipeW(pipePath.c_str(), PIPE_ACCESS_DUPLEX, + PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT, + 1, 64 * 1024, 64 * 1024, 0, nullptr); + serverReady.store(true); + if (p == INVALID_HANDLE_VALUE) return; + ConnectNamedPipe(p, nullptr); + while (!serverStop.load()) Sleep(20); // never reply + CloseHandle(p); + }); + while (!serverReady.load()) Sleep(5); + + McpSessionClient client(router, opts); + std::thread t([&client] { client.Run(); }); + Sleep(400); // connect + send hello + block reading the ack + + const auto t0 = std::chrono::steady_clock::now(); + client.Stop(); + t.join(); + const auto elapsedMs = std::chrono::duration_cast( + std::chrono::steady_clock::now() - t0).count(); + + serverStop.store(true); + // Release a server still parked in ConnectNamedPipe (only possible if + // the client never got there) so this join cannot hang the suite. + HANDLE poke = CreateFileW(pipePath.c_str(), GENERIC_READ | GENERIC_WRITE, + 0, nullptr, OPEN_EXISTING, 0, nullptr); + if (poke != INVALID_HANDLE_VALUE) CloseHandle(poke); + server.join(); + + printf(" Stop() while blocked in ReadFile returned in %lld ms\n", + static_cast(elapsedMs)); + TEST("Stop() unblocks a session parked in a synchronous read", + elapsedMs < 5000); + } +} + static void TestMcpFrameCrypto() { using ShaderLab::Tests::TEST; @@ -2082,6 +2328,7 @@ int main(int argc, char* argv[]) // Run tests. TestGraphOperations(); TestSerialization(); + TestEffectCatalogCount(); TestSourceEffects(); TestAnalysisEffects(); TestBuiltInD2DEffects(); @@ -2099,6 +2346,7 @@ int main(int argc, char* argv[]) TestRenderThreadDispatcher(); TestMcpRouter(); TestMcpJsonRpc(); + TestMcpSessionClientLifecycle(); TestMcpFrameCrypto(); TestMcpPeerIdentity(); TestMcpChannel(); diff --git a/docs/README.md b/docs/README.md index d06047f..2e7f13d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -25,7 +25,7 @@ Reference for "how does ShaderLab work under the hood". Reference for the effect catalog and per-effect mechanics. -- [Built-in Effect Catalog](effects/builtin-catalog.md) — the ~35 ShaderLab effects (Analysis, Color, Source, Tone Mapping, Parameter). +- [Built-in Effect Catalog](effects/builtin-catalog.md) — the 36 ShaderLab effects (Analysis, Color, Source, Tone Mapping, Parameter). - [Effect Versioning System](effects/effect-versioning.md) — how `effectVersion` bumps are detected on graph load. - [Effect Designer](effects/effect-designer.md) — the modal window for authoring custom pixel/compute shaders. - [Numeric Expression Node (ExprTk)](effects/numeric-expression.md) — single-input math expression parameter node. @@ -50,11 +50,11 @@ Reference for the effect catalog and per-effect mechanics. - [Build Instructions](development/build.md) — prerequisites, configurations, dependency map. - [Project Structure](development/project-structure.md) — full file tree with per-file descriptions. -- [MCP Migration: HTTP → stdio](development/mcp-stdio-migration.md) — **in-progress** implementation plan for replacing the embedded HTTP MCP server with stdio + a broker relay. Step-by-step, with the platform questions already settled by spike. +- [MCP Migration: HTTP → stdio](development/mcp-stdio-migration.md) — **complete** (all 9 steps; decision #71, engine ABI 3). The implementation plan and rationale for replacing the embedded HTTP MCP server with stdio + a broker relay, kept as the reference for how the transport works. ## History -- [Decision Log](history/decision-log.md) — chronological architectural decisions with rationale (60+ entries). +- [Decision Log](history/decision-log.md) — chronological architectural decisions with rationale (68 entries, ids up to #72). --- diff --git a/docs/architecture/engine-host-split.md b/docs/architecture/engine-host-split.md index df8d950..f396589 100644 --- a/docs/architecture/engine-host-split.md +++ b/docs/architecture/engine-host-split.md @@ -2,9 +2,9 @@ The codebase is divided between a host-agnostic engine DLL and one or more host applications: -- **`ShaderLabEngine.dll`** owns everything that doesn't need a UI thread or a swap chain: the `EffectGraph` model + JSON serialization, the `GraphEvaluator` (per-node D2D effect cache, dirty propagation, two-pass evaluate), `SourceNodeFactory` (image / video / DXGI / WGC sources), `EffectRegistry` (40+ wrapped D2D effects + 20+ ShaderLab effects with embedded HLSL), `DisplayMonitor` + ICC parsing, the `Effects/CustomPixelShaderEffect` / `CustomComputeShaderEffect` COM classes, the generic D3D11 compute dispatch helper (`Rendering/D3D11ComputeRunner.{h,cpp}`), the `ShaderCompiler` (D3DCompile + D3DReflect), and **the entire MCP protocol surface — the `McpRouter` route registry, JSON-RPC dispatcher, declarative tool catalog, session client, plus all 25 engine-pure routes** (`Engine/Mcp/{McpRouter,McpTypes,McpJsonRpc,McpToolCatalog,McpSessionClient,EngineMcpRoutes}`) and the broker plumbing (`McpFrame` codec, `McpCrypto` ECDH/GCM stack, `McpChannel`, `McpPeerIdentity` pairing). The transport is the broker (shim → hub → session over named pipes) — the embedded HTTP listener was deleted in stdio-migration Step 9. Engine-pure helpers extracted for reuse: `Rendering/PixelReadback.{h,cpp}` (FP32 RGBA region readback), `Rendering/CaptureNode.{h,cpp}` (D2D + WIC PNG encode), `Rendering/WorkingSpaceSync.{h,cpp}` (Working Space parameter node refresh). +- **`ShaderLabEngine.dll`** owns everything that doesn't need a UI thread or a swap chain: the `EffectGraph` model + JSON serialization, the `GraphEvaluator` (per-node D2D effect cache, dirty propagation, two-pass evaluate), `SourceNodeFactory` (image / video / DXGI / WGC sources), `EffectRegistry` (40+ wrapped D2D effects + 36 ShaderLab effects with embedded HLSL), `DisplayMonitor` + ICC parsing, the `Effects/CustomPixelShaderEffect` / `CustomComputeShaderEffect` COM classes, the generic D3D11 compute dispatch helper (`Rendering/D3D11ComputeRunner.{h,cpp}`), the `ShaderCompiler` (D3DCompile + D3DReflect), and **the entire MCP protocol surface — the `McpRouter` route registry, JSON-RPC dispatcher, declarative tool catalog, session client, plus all 25 engine-pure routes** (`Engine/Mcp/{McpRouter,McpTypes,McpJsonRpc,McpToolCatalog,McpSessionClient,EngineMcpRoutes}`) and the broker plumbing (`McpFrame` codec, `McpCrypto` ECDH/GCM stack, `McpChannel`, `McpPeerIdentity` pairing). The transport is the broker (shim → hub → session over named pipes) — the embedded HTTP listener was deleted in stdio-migration Step 9. Engine-pure helpers extracted for reuse: `Rendering/PixelReadback.{h,cpp}` (FP32 RGBA region readback), `Rendering/CaptureNode.{h,cpp}` (D2D + WIC PNG encode), `Rendering/WorkingSpaceSync.{h,cpp}` (Working Space parameter node refresh). -- **`ShaderLab.exe`** (the WinUI 3 host) keeps everything that genuinely needs WinUI: `MainWindow.xaml.{h,cpp}` (which itself is split into sibling partial TUs `MainWindow.WorkingSpace.cpp`, `MainWindow.GraphFileIo.cpp`, `MainWindow.RenderTick.cpp`, `MainWindow.McpRoutes.cpp` for the 18 app-side routes), `Controls/NodeGraphController` (canvas rendering), `Controls/OutputWindow` (per-Output OS window), `Controls/ShaderEditorController`, the Effect Designer modal window, and `RenderEngine` (D3D11 + D2D1 device stack, `SwapChainPanel` binding). +- **`ShaderLab.exe`** (the WinUI 3 host) keeps everything that genuinely needs WinUI: `MainWindow.xaml.{h,cpp}` (which itself is split into sibling partial TUs `MainWindow.WorkingSpace.cpp`, `MainWindow.GraphFileIo.cpp`, `MainWindow.RenderTick.cpp`, `MainWindow.McpRoutes.cpp` for the 16 app-side routes), `Controls/NodeGraphController` (canvas rendering), `Controls/OutputWindow` (per-Output OS window), `Controls/ShaderEditorController`, the Effect Designer modal window, and `RenderEngine` (D3D11 + D2D1 device stack, `SwapChainPanel` binding). - **`ShaderLabHeadless.exe`** (see below) reuses everything from the engine DLL with no WinUI dependency. diff --git a/docs/development/mcp-stdio-migration.md b/docs/development/mcp-stdio-migration.md index ff412f2..8df564f 100644 --- a/docs/development/mcp-stdio-migration.md +++ b/docs/development/mcp-stdio-migration.md @@ -14,7 +14,7 @@ deleted; the broker (shim → hub → session over named pipes, bodies sealed) i MCP transport. Engine ABI **3**. What remains before the migration can be called fully signed-off is the **manual verification sweep** at the end of this doc (WinUI window lifecycle, packaged install/activation, a real MCP client, the in-place-upgrade -sequence) — everything automatable is green: 261 unit tests, broker smoke 26/26, +sequence) — everything automatable is green: 292 unit tests, broker smoke 27/27, headless smoke, and the shim-driven `RunTests.ps1` at 40/40 (GUI) / 21/21 (headless) on WARP. Two throwaway spikes have already settled the platform questions; their results are recorded in [Settled by spike](#settled-by-spike) so they are not @@ -293,7 +293,7 @@ pinned session's catalog, merged as real JSON values). `ShaderLabHeadless --mcp-session [--session-id GUID] [--session-label] [--pipe]` wires it to the existing `HeadlessSink`; the session id is a persisted per-window GUID (generated when omitted), never an ordinal. Verified: 256 unit tests (+12 channel + pairing), -`RunBrokerSmoke` **26/26** now driving a real WARP headless session end-to-end +`RunBrokerSmoke` **27/27** now driving a real WARP headless session end-to-end (register → `use_session` → spliced `tools/list` → `graph_overview` + `graph_add_node` through the sealed relay → `session_gone` on session kill). @@ -443,7 +443,7 @@ Substitute your `` (`ARM64` / `x64`) and `` (`Debug` / `Releas Suites 1–4 need **no packaging and no desktop** — this is what CI runs: ```pwsh -# 1. Unit suite — 261 tests on WARP; self-contained (routing, crypto, frame codec, +# 1. Unit suite — 289 tests on WARP (the runner prints the total); self-contained (routing, crypto, frame codec, # per-channel handshake, dispatcher fail-fast, peer pairing, HLSL math bench). \\ShaderLabTests\ShaderLabTests.exe --adapter warp @@ -792,7 +792,7 @@ routes. This retires election, framing, crypto and reconnect risk. It does **not retire GUI integration risk: `HeadlessSink::Dispatch` is a direct synchronous call with no DispatcherQueue, no render dispatcher, no XAML, and all 8 event hooks are no-ops. -> **Done.** `RunBrokerSmoke.ps1` 26/26 (13 new session checks incl. the sealed +> **Done.** `RunBrokerSmoke.ps1` 27/27 (13 new session checks incl. the sealed > `graph_overview`/`graph_add_node` round-trips and `session_gone`), in CI on WARP. > As the plan predicts, this retires transport risk but NOT GUI-integration risk — > Step 7 wires the same `McpSessionClient` into `MainWindow` where `Dispatch` @@ -867,7 +867,7 @@ switch mid-request, clean shutdown, and every tool exercised. > shim keeps running (update-immune); (3) with **no hub running**, the distributed > shim activates the packaged hub, the GUI session then registers, and > `use_session` + `graph_add_node` drive end-to-end through the render worker; -> broker smoke 26/26 and the HTTP suite 40/40 unregressed. +> broker smoke 27/27 and the HTTP suite 40/40 unregressed. Note the shim IS `ShaderLabMcpBroker.exe --stdio` (one binary, two modes — Step 5); "the shim" below means a copy of that exe placed on a stable unpackaged path. diff --git a/docs/development/project-structure.md b/docs/development/project-structure.md index 5c6bb2b..bdc0d88 100644 --- a/docs/development/project-structure.md +++ b/docs/development/project-structure.md @@ -44,7 +44,7 @@ ShaderLab/ │ ├── EngineMcpRoutes.h / .cpp # 25 engine-pure routes + IEngineCommandSink + EngineContext │ ├── Tests/ # ShaderLabTests + smoke scripts -│ ├── TestRunner.cpp # 261 tests total (graph, evaluator, dispatcher [+fail-fast], snapshot, bytecode cache, router, JSON-RPC, frame/crypto/peer/channel, math bench) +│ ├── TestRunner.cpp # 289 tests (the runner prints the authoritative total) (graph, evaluator, dispatcher [+fail-fast], snapshot, bytecode cache, router, JSON-RPC, frame/crypto/peer/channel, math bench) │ ├── TestCommon.h # Shared TEST() macro across TUs │ ├── ShaderTestBench.h / .cpp # D3D11 compute test harness for HLSL math │ ├── Math/ # 51 HLSL math tests @@ -93,7 +93,7 @@ ShaderLab/ │ ├── MathExpression.h / .cpp # ExprTk-backed expression evaluator (PCH disabled on .cpp) │ ├── Effects/ # Engine: built-in effect wrappers + custom effect base -│ ├── ShaderLabEffects.h / .cpp # 35 ShaderLab effects (versioned) — embedded HLSL +│ ├── ShaderLabEffects.h / .cpp # 36 ShaderLab effects (versioned) — embedded HLSL │ ├── ColorMath.cpp # Shared HLSL color math library (extracted from ShaderLabEffects) │ ├── PropertyMetadata.h # Effect property metadata for UI generation │ ├── ImageLoader.h / .cpp # WIC HDR/SDR image loading diff --git a/docs/effects/builtin-catalog.md b/docs/effects/builtin-catalog.md index d85722a..1657d9e 100644 --- a/docs/effects/builtin-catalog.md +++ b/docs/effects/builtin-catalog.md @@ -1,6 +1,6 @@ # ShaderLab Built-in Effects -ShaderLab ships with **33 built-in ShaderLab effects** implemented in `Effects/ShaderLabEffects.h/.cpp`, on top of the **40+ wrapped built-in D2D effects** in `Effects/EffectRegistry.cpp`. Each ShaderLab effect has its HLSL embedded as a string constant, compiled at first use via `ShaderCompiler` (and cached on disk under `%LOCALAPPDATA%\ShaderLab\bytecode\` so subsequent sessions reuse the bytecode), and shares a common color math library (BT.709 / BT.2020 / DCI-P3 matrices, PQ / HLG transfer functions, CIE xy conversions, ICtCp). Every effect is versioned with `effectId` and `effectVersion` so saved graphs can be upgraded in place — see [Effect Versioning System](effect-versioning.md). +ShaderLab ships with **36 built-in ShaderLab effects** implemented in `Effects/ShaderLabEffects.h/.cpp`, on top of the **40+ wrapped built-in D2D effects** in `Effects/EffectRegistry.cpp`. Each ShaderLab effect has its HLSL embedded as a string constant, compiled at first use via `ShaderCompiler` (and cached on disk under `%LOCALAPPDATA%\ShaderLab\bytecode\` so subsequent sessions reuse the bytecode), and shares a common color math library (BT.709 / BT.2020 / DCI-P3 matrices, PQ / HLG transfer functions, CIE xy conversions, ICtCp). Every effect is versioned with `effectId` and `effectVersion` so saved graphs can be upgraded in place — see [Effect Versioning System](effect-versioning.md). The **Type** column below uses these abbreviations: @@ -17,7 +17,7 @@ False-color overlays on the input image. |--------|------|-------------| | Luminance Heatmap | CS-Img | False-color BT.709 luminance overlay (Turbo / Inferno gradients). | | Luminance Highlight | CS-Img | Highlights luminance bands above/below configurable thresholds. | -| Delta E Comparator | CS-Img | Two-input CIEDE2000 perceptual difference map (Heatmap or Grayscale dE). | +| Delta E Comparator | CS-Img | Two-input perceptual difference map (Heatmap or Grayscale dE). `Method` selects CIE76 / CIE94 / CIEDE2000 / **ΔE ITP (BT.2124)** — ITP is the default and the right choice for anything HDR or wide-gamut; the three Lab metrics were fit to reflective samples under SDR viewing and leave their domain above ~100 nits. Measured on a 1100 vs 1000 nit step: ITP 7.47, CIEDE2000 118.09, CIE76 253.26. One unit ≈ 1 JND in all four, so the numbers stay comparable when switching. | | Gamut Highlight | PS | Highlights pixels outside a target gamut (sRGB / P3 / BT.2020 / current monitor). | | Nit Map | PS | Display-referred nit visualization with configurable luminance bands. | @@ -52,6 +52,7 @@ HDR ↔ SDR operators built around BT.2100 ICtCp. The key property: I (intensity | ICtCp Inverse Tone Map (SDR → HDR) | CS-Img | Mirror of the above; inverse Reinhard expands SDR-anchored content into the HDR peak. | | ICtCp Saturation | PS | Per-pixel saturation gain in ICtCp (scales Ct/Cp around the I-axis). | | ICtCp Highlight Desaturation | CS-Img | Smoothly reduces saturation above a configurable I threshold (counteracts tone-map hue shifts at clipping). | +| HDR Screenshot Tonemap (8bpc) | PS | The display-referred screenshot path fused into one pass: knee tone map → chroma correction → soft gamut compression into sRGB → white-level normalise → sRGB OETF → TPDF dither → 8-bit quantize, then **decode back to linear scRGB** so downstream analysis measures exactly the damage the 8-bit handback would do. `KneeRatio` is a fraction of the SDR white level `W`, not absolute nits, so it keeps its meaning when the OS brightness slider moves. Fused rather than chained because the chained equivalent pays three ICtCp round trips over a 4K frame where this pays one. Known trade: the knee darkens SDR white by 15–20% whenever it engages. | ## Analysis → Gamut diff --git a/docs/hosts/headless.md b/docs/hosts/headless.md index 092bdc7..c9efd02 100644 --- a/docs/hosts/headless.md +++ b/docs/hosts/headless.md @@ -3,18 +3,26 @@ `ShaderLabHeadless.exe` is a console host for the engine DLL — a logged-out user can render an `.effectgraph`, sample full-accuracy FP32 pixels, or run a JSON batch script of MCP operations against a graph, all without a WinUI message pump or a swap chain. ``` -ShaderLabHeadless --graph PATH --node ID --output PNG_PATH [options] +ShaderLabHeadless --graph PATH --node ID --output IMAGE_PATH [options] ``` +`--graph` accepts either form of the container: a **`.effectgraph` ZIP** as the GUI's Save writes it (`graph.json` plus optional `media/`), or a **bare graph JSON**. The form is detected from the PKZIP magic rather than the extension, because `.effectgraph` has historically named both. For an archive, embedded media is extracted to a temp directory, each source node's `media://` token is rewritten to the extracted path, and the directory is deleted when the process exits. + ## Modes -- **PNG render** (default). Loads a graph, evaluates two passes, optionally pre-passes through `CLSID_D2D1HdrToneMap`, and writes a PNG. +- **Image render** (default). Loads a graph, evaluates two passes, optionally pre-passes through `CLSID_D2D1HdrToneMap`, and writes an image. **The `--output` extension picks the encoder:** + - `.jxr` / `.wdp` → **JPEG XR**, 64bpp RGBA half, lossless, written straight from the pipeline's linear scRGB with no clamp and no transfer encoding, so values above 1.0 and the negative components that express wide-gamut colour both survive. + - anything else → **PNG**, 8-bit sRGB-encoded, clamped to [0, 1]. - `--input-peak-nits N` (default 1000) - `--output-peak-nits N` (default 80 = SDR; >80 enables HDR display mode) - `--no-tonemap` skips the HdrToneMap pre-pass (raw scRGB → sRGB clamp) - `--width N` / `--height N` (default 1024×1024) - `--adapter warp|default` (CI uses warp) + > **HDR output implies `--no-tonemap`.** The default tone map targets 80 nits (SDR), so leaving it on would hand the JXR encoder an already-compressed SDR image and store it in an HDR container — the file would be HDR in name only. A `.jxr` / `.wdp` output therefore skips the tone map and says so on stdout. Naming a peak explicitly (`--input-peak-nits` / `--output-peak-nits`) overrides that, because tone mapping *into* an HDR deliverable — 4000-nit source down to a 1000-nit target — is a legitimate request. + > + > This matches the GUI: `OutputWindow`'s save flyout offers the same two formats and takes the same FP16-vs-8-bit branch. + - **Pixel-region readback** (`--pixels x,y,w,h`). FP32 RGBA samples from any node, no PNG / tonemap involved. Output extension drives format: `.csv` writes `x,y,r,g,b,a` rows; anything else writes packed binary (`uint32 W` + `uint32 H` header + `float[W*H*4]` row-major). Designed for MCP-driven full-accuracy color sampling and ΔE sweeps. - **Script batch** (`--script PATH [--script-output PATH]`). Loads a graph then walks an array of MCP-style operations through the engine route registry, accumulating one `{step, method, path, status, body}` entry per operation in a JSON response document (stdout if `--script-output` is omitted). Designed for parameter sweeps where the agent wants 50+ engine queries per session without HTTP round-trip overhead each one. @@ -68,11 +76,13 @@ The MCP route registry (`RegisterEngineRoutes`) is what backs every host — the ## Smoke coverage -`Tests/RunHeadlessSmoke.ps1` is wired into CI's `bootstrap-smoke` job and runs three checks at every commit boundary: +`Tests/RunHeadlessSmoke.ps1` is wired into CI's `bootstrap-smoke` job and runs five checks at every commit boundary: 1. **PNG capture** — render `Tests/fixtures/test_cli_basic.json` node 1 to PNG, verify exit code + valid PNG header. 2. **FP32 pixel readback** — same fixture, `--pixels 0,0,4,4`, verify exact blob size + header bytes. 3. **Script batch** — 7-step script that adds a `Luminance Statistics` node, connects it to the source, reads its `Mean` analysis field, mutates the source's `Luminance` property from 80 to 200, re-renders, and reads `Mean` again. The ratio must be 2.5× — exercises add-node + connect + set-property + dirty propagation + ProcessDeferredCompute + analysis readback end-to-end through the standard graph-node path (no special MCP routes). +4. **JPEG XR HDR round trip** — the fixture at `Luminance = 800` nits puts every in-gamut pixel near 10.0 scRGB. Renders to `.jxr`, asserts the JPEG XR container magic and 64bpp, then decodes the centre pixel's red half and requires it above 1.0 (measured ≈ 10.34 against a 10.345 source). Pins both the encoder and the HDR-implies-no-tonemap rule: before that rule existed this test read 0.99. +5. **`.effectgraph` ZIP container** — wraps the fixture's `graph.json` in a ZIP and renders from it, so the archive path cannot regress to JSON-only. --- diff --git a/docs/hosts/mcp-server.md b/docs/hosts/mcp-server.md index 972e682..b138e86 100644 --- a/docs/hosts/mcp-server.md +++ b/docs/hosts/mcp-server.md @@ -1,14 +1,14 @@ # MCP Server (AI Agent Integration) -ShaderLab includes an embedded HTTP server implementing the **Model Context Protocol (MCP)** JSON-RPC 2.0 for programmatic control by AI agents. The full protocol surface ships in the engine DLL: the route registry + HTTP listener (`Engine/Mcp/McpRouter.{h,cpp}`), the JSON-RPC dispatcher (`McpJsonRpc.{h,cpp}` — `initialize`, `tools/*`, `resources/*`, `ping`), the declarative 39-tool catalog (`McpToolCatalog.{h,cpp}`), and **25 engine-pure routes**. Both hosts get the identical dispatcher via `RegisterJsonRpcEndpoint`; `ShaderLabHeadless --serve` therefore answers `tools/call` with no GUI at all (see [Engine / Host Split](../architecture/engine-host-split.md)). A further **16 app-side routes** (view/preview/GPU tools, `/context`, `/perf`, node logs) live in `MainWindow.McpRoutes.cpp`; calling a tool whose backing route is absent on the answering host returns an `isError` "Tool not available on this host" result. Handlers receive `(path, query, body)`; the router owns the query split, so `?since=`-style parameters work identically over HTTP, the tools ladder, and headless scripts. +ShaderLab implements the **Model Context Protocol (MCP)** JSON-RPC 2.0 for programmatic control by AI agents, carried over **stdio via the broker** (shim → hub → session over named pipes). The full protocol surface ships in the engine DLL: the route registry (`Engine/Mcp/McpRouter.{h,cpp}`), the JSON-RPC dispatcher (`McpJsonRpc.{h,cpp}` — `initialize`, `tools/*`, `resources/*`, `ping`), the declarative 39-tool catalog (`McpToolCatalog.{h,cpp}`), and **25 engine-pure routes**. Both hosts get the identical dispatcher via `RegisterJsonRpcEndpoint`; `ShaderLabHeadless --mcp-session` therefore answers `tools/call` with no GUI at all (see [Engine / Host Split](../architecture/engine-host-split.md)). A further **16 app-side routes** (view/preview/GPU tools, `/context`, `/perf`, node logs) live in `MainWindow.McpRoutes.cpp`; calling a tool whose backing route is absent on the answering host returns an `isError` "Tool not available on this host" result. Handlers receive `(path, query, body)`; the router owns the query split, so `?since=`-style parameters work identically across the tools ladder and headless scripts. **Protocol version: `2025-06-18`.** Batch (JSON array) requests are rejected with `-32600` — 2025-06-18 removed batching from MCP, making it the first revision this server is actually conformant with. Responses are single-line JSON; notifications (absent `id`) produce no reply body (zero bytes on the wire). -> **Transport migration in progress.** The HTTP transport described here is being -> replaced by a stdio shim + named-pipe broker so multiple ShaderLab windows -> become individually addressable. Plan, rationale, and status: -> [mcp-stdio-migration.md](../development/mcp-stdio-migration.md). Everything on -> this page describes the **current** (HTTP) behaviour. +> **Transport migration complete.** The embedded HTTP listener was deleted in +> migration Step 9 (decision #71, engine ABI **3**); the stdio shim + named-pipe +> broker is now the only transport, which is what makes multiple ShaderLab windows +> individually addressable. Plan and rationale: +> [mcp-stdio-migration.md](../development/mcp-stdio-migration.md). ## Connection From 31a1a46b632474c1814225f1afee54e5a4ca52d3 Mon Sep 17 00:00:00 2001 From: David Spruill Date: Wed, 16 Sep 2026 18:22:37 -0400 Subject: [PATCH 6/6] Fix the CI test --- .claude/settings.json | 76 +++++++++ .claude/skills/shaderlab-build/SKILL.md | 165 ++++++++++++++++++++ .claude/skills/shaderlab-run/SKILL.md | 123 +++++++++++++++ CHANGELOG.md | 1 + CLAUDE.md | 199 ++++++++++++++++++++++++ Tests/RunTests.ps1 | 37 ++++- Tests/fixtures/test_cli_basic.json | 8 +- 7 files changed, 599 insertions(+), 10 deletions(-) create mode 100644 .claude/settings.json create mode 100644 .claude/skills/shaderlab-build/SKILL.md create mode 100644 .claude/skills/shaderlab-run/SKILL.md create mode 100644 CLAUDE.md diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..0417a9f --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,76 @@ +{ + "$schema": "https://json.schemastore.org/claude-code-settings.json", + "//": [ + "Project-wide Claude Code permissions, shared with every contributor.", + "Keep entries PORTABLE — relative paths and repo-local scripts only.", + "Machine-specific absolutes (your MSBuild location, your build platform)", + "belong in .claude/settings.local.json, which is gitignored." + ], + "permissions": { + "allow": [ + "Bash(git status:*)", + "Bash(git diff:*)", + "Bash(git log:*)", + "Bash(git show:*)", + "Bash(git branch:*)", + "Bash(git ls-files:*)", + "Bash(git submodule status:*)", + "PowerShell(git status:*)", + "PowerShell(git diff:*)", + "PowerShell(git log:*)", + "PowerShell(nuget restore:*)", + "PowerShell(.\\\\x64\\\\Debug\\\\ShaderLabTests\\\\ShaderLabTests.exe:*)", + "PowerShell(.\\\\x64\\\\Release\\\\ShaderLabTests\\\\ShaderLabTests.exe:*)", + "PowerShell(.\\\\ARM64\\\\Debug\\\\ShaderLabTests\\\\ShaderLabTests.exe:*)", + "PowerShell(.\\\\ARM64\\\\Release\\\\ShaderLabTests\\\\ShaderLabTests.exe:*)", + "PowerShell(.\\\\x64\\\\Debug\\\\ShaderLabHeadless\\\\ShaderLabHeadless.exe:*)", + "PowerShell(.\\\\x64\\\\Release\\\\ShaderLabHeadless\\\\ShaderLabHeadless.exe:*)", + "PowerShell(.\\\\ARM64\\\\Debug\\\\ShaderLabHeadless\\\\ShaderLabHeadless.exe:*)", + "PowerShell(.\\\\ARM64\\\\Release\\\\ShaderLabHeadless\\\\ShaderLabHeadless.exe:*)", + "PowerShell(pwsh -NoProfile -File .\\\\Tests\\\\RunTests.ps1:*)", + "PowerShell(pwsh -NoProfile -File .\\\\Tests\\\\RunHeadlessSmoke.ps1:*)", + "PowerShell(pwsh -NoProfile -File .\\\\Tests\\\\RunBrokerSmoke.ps1:*)", + "PowerShell(pwsh -NoProfile -File .\\\\Tests\\\\RunMathTests.ps1:*)", + "PowerShell(pwsh -NoProfile -File .\\\\Tests\\\\RunCliTests.ps1:*)", + "PowerShell(.\\\\Tests\\\\RunHeadlessSmoke.ps1:*)", + "PowerShell(.\\\\Tests\\\\RunBrokerSmoke.ps1:*)", + "PowerShell(Get-Process:*)", + "PowerShell(Get-AppxPackage:*)", + "PowerShell(Get-Content:*)", + "PowerShell(Select-String:*)", + "PowerShell(Select-Object:*)", + "PowerShell(ConvertFrom-Json:*)", + "PowerShell(Test-Path:*)", + "mcp__shaderlab__list_sessions", + "mcp__shaderlab__use_session", + "mcp__shaderlab__graph_overview", + "mcp__shaderlab__graph_get_node", + "mcp__shaderlab__graph_snapshot", + "mcp__shaderlab__graph_save_json", + "mcp__shaderlab__graph_get_view", + "mcp__shaderlab__list_effects", + "mcp__shaderlab__list_gpus", + "mcp__shaderlab__list_display_profiles", + "mcp__shaderlab__registry_get_effect", + "mcp__shaderlab__effect_get_hlsl", + "mcp__shaderlab__get_display_info", + "mcp__shaderlab__read_analysis_output", + "mcp__shaderlab__read_pixel_region", + "mcp__shaderlab__read_pixel_trace", + "mcp__shaderlab__preview_get_view", + "mcp__shaderlab__perf_timings", + "mcp__shaderlab__node_logs" + ], + "ask": [ + "PowerShell(Add-AppxPackage:*)", + "PowerShell(Remove-AppxPackage:*)", + "PowerShell(Stop-Process:*)", + "PowerShell(signtool:*)" + ], + "deny": [ + "Read(./**/*.pfx)", + "Read(./**/*.snk)", + "Read(./**/*.p12)" + ] + } +} diff --git a/.claude/skills/shaderlab-build/SKILL.md b/.claude/skills/shaderlab-build/SKILL.md new file mode 100644 index 0000000..5710c87 --- /dev/null +++ b/.claude/skills/shaderlab-build/SKILL.md @@ -0,0 +1,165 @@ +--- +name: shaderlab-build +description: Build and test ShaderLab (x64 or ARM64) — correct MSBuild selection, per-project builds, the test runner, headless probes, and release/MSIX packaging with its signing gotchas. Use when asked to build, compile, run tests, package a release, or when a build fails with PCH/toolset/packaging errors. +--- + +# Building & testing ShaderLab + +Windows only. **x64 and ARM64** both build; outputs land in +`\\\`. Needs VS 2022 17.8+ (2026 / v18 works), the Windows +App SDK 1.8 workload, Windows SDK 10.0.26100+, and `nuget.exe` on PATH. + +Resolve MSBuild rather than hardcoding a path — edition and version differ per machine: + +```pwsh +$vs = & "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" ` + -latest -products * -requires Microsoft.Component.MSBuild -property installationPath +$msb = "$vs\MSBuild\Current\Bin\MSBuild.exe" +# ARM64 HOST ONLY -- the default binary above will fail, see next section: +if ($env:PROCESSOR_ARCHITECTURE -eq 'ARM64') { $msb = "$vs\MSBuild\Current\Bin\arm64\MSBuild.exe" } +``` + +## On an ARM64 host, use the ARM64 MSBuild + +`MSBuild\Current\Bin\MSBuild.exe` is 32-bit and reports `PROCESSOR_ARCHITECTURE=x86` +under emulation, so toolset selection in `Microsoft.Cpp.ToolsetLocation.props` never +matches its ARM64 branch and falls through to the **32-bit** `bin\HostX86\arm64\cl.exe`. +That compiler exhausts its ~3 GB address space on the large generated translation +units and fails with: + +``` +C3859: Failed to create virtual memory for PCH +C1076: compiler limit: internal heap limit reached +``` + +`/p:PreferredToolArchitecture=x64` does **not** help — the props file declares +`TreatAsLocalProperty` and demotes it straight back to `x86`. The `arm64\` MSBuild +yields `VCToolArchitecture=NativeARM64` and builds clean. + +Also required: the `Microsoft.VisualStudio.Component.UWP.VC.ARM64` component. Without +it the packaged WinUI app has no ARM64 platform and fails with *"The +BaseOutputPath/OutputPath property is not set for project 'ShaderLab.vcxproj'"* — +while the plain desktop projects build fine, so the failure looks partial and +unrelated. + +The x64 CI matrix cross-compiles ARM64 from an x64 runner, so neither trap surfaces +there. The `native-arm64` job (`runs-on: windows-11-arm`) exists to catch the first +one, and fails loudly if the arm64 MSBuild is missing rather than falling through. + +## Build targets + +Build the narrowest project that answers the question — the full solution includes +the packaged app and is much slower. + +```pwsh +$plat = 'x64' # or 'ARM64' + +# Tests only (pulls in the engine). The usual inner loop. +& $msb ShaderLabTests.vcxproj /p:Configuration=Debug /p:Platform=$plat /m /v:m /nologo + +# Headless host (engine + console host, no WinUI). +& $msb ShaderLabHeadless.vcxproj /p:Configuration=Debug /p:Platform=$plat /m /v:m /nologo + +# Everything, including the MSIX-packaged app. +& $msb ShaderLab.slnx /p:Configuration=Debug /p:Platform=$plat /m /v:m /nologo +``` + +First build after a fresh clone: `nuget restore ShaderLab.slnx -SolutionDirectory .` +(packages.config style, not PackageReference). Submodules `exprtk` and `miniz` must be +present — `git submodule update --init --recursive`. + +The GUI locks `ShaderLab.exe` / `ShaderLabEngine.dll` in the layout and a lingering +hub locks the broker copy. Kill them before rebuilding: + +```pwsh +Get-Process ShaderLab, ShaderLabMcpBroker -ErrorAction SilentlyContinue | Stop-Process -Force +``` + +## Tests + +```pwsh +& ".\$plat\Debug\ShaderLabTests\ShaderLabTests.exe" --adapter warp +``` + +Ends with `ALL TESTS PASSED`; exit code is the failure count. `--adapter warp` +uses the software rasterizer — no GPU dependency, and what CI runs. Covers the graph +model, evaluator, bindings, bytecode cache, `GraphUiSnapshot`, dispatcher, MCP router ++ JSON-RPC contracts, broker frame codec / crypto / peer identity, GPU-binding +matrices, and the HLSL math bench. + +Other suites: `Tests\RunHeadlessSmoke.ps1`, `Tests\RunBrokerSmoke.ps1`, +`Tests\RunCliTests.ps1`, `Tests\RunMathTests.ps1`, and `Tests\RunTests.ps1` (MCP — +see the **shaderlab-run** skill). + +## Headless probes + +Cheapest way to answer a numeric question — no deploy, no GUI: + +```pwsh +$h = ".\$plat\Debug\ShaderLabHeadless\ShaderLabHeadless.exe" + +# Render one node. The --output extension picks the encoder: .jxr/.wdp is +# 64bpp half, HDR preserved (and implies --no-tonemap); anything else is +# 8-bit sRGB PNG. --graph takes a .effectgraph ZIP or a bare graph JSON. +& $h --graph Tests\fixtures\test_cli_basic.json --node 3 --output out.png --adapter warp +& $h --graph Tests\fixtures\test_cli_basic.json --node 3 --output out.jxr --adapter warp + +# FP32 pixel readback +& $h --graph --node --pixels --adapter warp + +# Batch parameter sweep +& $h --graph --script sweep.json --script-output result.json --adapter warp +``` + +Flags: `--width/--height` (default 1024), `--adapter warp|default`, +`--input-peak-nits` / `--output-peak-nits`, `--no-tonemap`, +`--enable/--disable-gpu-bindings`, `--reap-shader-cache` / `--clear-shader-cache`, +and `--mcp-session --pipe `. + +## Release / MSIX packaging + +```pwsh +& $msb ShaderLab.slnx /p:Configuration=Release /p:Platform=$plat ` + /p:AppxBundle=Never /p:UapAppxPackageBuildMode=SideloadOnly /p:GenerateAppxPackageOnBuild=true +``` + +Output: `AppPackages\ShaderLab\ShaderLab___Test\` (msix + `Dependencies\\`). + +**Unsigned** (what `release.yml` ships): inject the unsigned-namespace OID into +`Package.appxmanifest` (`Publisher="CN=ShaderLab"` → +`…, OID.2.25.311729368913984317654407730594956997722=1"`) and add +`/p:AppxPackageSigningEnabled=false`. Restore the plain manifest afterward **from a +byte-for-byte backup, not `git checkout`** — the working tree may hold uncommitted +manifest edits. + +**Signed for local install** (plain manifest, no OID): MSBuild's own signing fails +here (APPX0105/APPX0107 importing `ShaderLab_TemporaryKey.pfx`). Sign manually: + +```pwsh +# = your CN=ShaderLab dev cert in CurrentUser\My. Find it with: +# Get-ChildItem Cert:\CurrentUser\My | Where-Object Subject -eq 'CN=ShaderLab' +signtool sign /fd SHA256 /sha1 +``` + +`signtool` lives under `packages\Microsoft.Windows.SDK.BuildTools.*\bin\...\{arm64,x64}\`. + +Signing gotchas, each of which has cost real time: + +- **`0x8007000b` ("SignerSign() failed / unexpected internal error") means Publisher ≠ + cert subject** — not an ARM64 signtool bug. Usually the msix still carries the OID + Publisher while you sign with the plain `CN=ShaderLab` cert. Check the msix's + internal `AppxManifest.xml` Publisher first. +- **Incremental packaging can leave a stale msix.** After swapping the manifest + (OID ↔ plain) MSBuild may not re-pack, so you sign yesterday's package. Force a + re-pack by moving `\Release\ShaderLab\AppxManifest.xml` *and* the stale + `...Test\*.msix` aside, then rebuild. +- **A signed msix install needs the cert in `LocalMachine\TrustedPeople`** (admin, + one-time `Import-Certificate`). The dev cert is only in `CurrentUser\TrustedPeople`, + which suffices for `-Register` under Dev Mode but not for a signed install. +- **Unsigned + non-admin `Install.ps1` fails `0x80073D2B`.** ShaderLab is full-trust + (both `App` and `Hub` are `Windows.FullTrustApplication`), and an unsigned package + with executable activations requires an **elevated all-users** install — or a signed + release. This is a known release-process gap, recorded in `README.md`. + +Fastest path into the app to test something: `Add-AppxPackage -Register` (dev-mode, no +signature check) — see the **shaderlab-run** skill. diff --git a/.claude/skills/shaderlab-run/SKILL.md b/.claude/skills/shaderlab-run/SKILL.md new file mode 100644 index 0000000..c535691 --- /dev/null +++ b/.claude/skills/shaderlab-run/SKILL.md @@ -0,0 +1,123 @@ +--- +name: shaderlab-run +description: Deploy, launch, and drive the packaged ShaderLab app — MSIX registration, shell activation, enabling the MCP session, and running the MCP test suite against a live GUI or a headless session. Use when asked to run/launch/deploy ShaderLab, to see a change in the real app, to connect over MCP, or when an MCP session is missing or stale. +--- + +# Running ShaderLab + +Packaged WinUI 3 app. Deploy has several traps that fail in ways that look like app +bugs. Follow the order below. + +Prefer **headless** when the question is numeric — it skips all of this. See +`docs/hosts/headless.md`, and `CLAUDE.md` for the inner-loop table. + +## 0. Build first + +Use the **shaderlab-build** skill. The GUI locks `ShaderLab.exe` and +`ShaderLabEngine.dll` in the layout, and a lingering `ShaderLabMcpBroker` hub locks +the layout's broker copy — **kill both before rebuilding** or the copy step fails. + +```pwsh +Get-Process ShaderLab, ShaderLabMcpBroker -ErrorAction SilentlyContinue | Stop-Process -Force +``` + +A titleless lingering `ShaderLab.exe` is a hung shutdown — safe to kill. + +## 1. Register the package — from the layout root, never `AppX\` + +```pwsh +Add-AppxPackage -Register \Debug\ShaderLab\AppxManifest.xml # x64 or ARM64 +``` + +**Why the layout root matters:** an incremental build refreshes the layout root but +**not** `AppX\`. A registration pointing at `...\ShaderLab\AppX` runs stale binaries, +and new-exe/old-resources mixes abort at startup — which reads as an app crash, not a +deploy problem. + +Binary-only rebuilds need no re-register. When registration fails: + +| HRESULT | Meaning | Fix | +|---|---|---| +| `0x80073D02` | Package in use | Kill ShaderLab + broker processes, retry | +| `0x80073CFB` | Manifest content changed, version didn't | `Get-AppxPackage -Name ShaderLab \| Remove-AppxPackage`, then re-register. Config in `%LOCALAPPDATA%\ShaderLab` survives | +| `0x80070490` | Stale registration ("indexed state handler") | `Remove-AppxPackage` then re-register | +| — | Registration silently points at the old path | Re-registering the *same version* is a no-op that keeps the old path. `Remove-AppxPackage` first | + +## 2. Launch by shell activation — never the exe directly + +```pwsh +explorer.exe "shell:AppsFolder\ShaderLab_9v3yd384n9j18!App" +``` + +`Start-Process ShaderLab.exe` crashes with a `Debug Error! abort() has been called` +CRT dialog (packaged-app dependency resolution). The AUMIDs are +`ShaderLab_9v3yd384n9j18!App` and `…!Hub`. + +**Cold Debug start can take >45 s.** There is no HTTP port to poll — readiness means +the session appears in `list_sessions`. + +## 3. MCP + +MCP is enabled by `%LOCALAPPDATA%\ShaderLab\config.json` containing `{"mcp": true}`; +alternatives are the `--mcp` arg or the toolbar toggle. On launch the GUI copies the +shim to +`%LOCALAPPDATA%\ShaderLab\bin\ShaderLabMcpBroker.exe` and registers a session with the +hub, which the shim activates on demand. + +Transport is **shim → hub → session** over named pipes, bodies sealed end-to-end +(P-256 / HKDF / AES-GCM). There is no HTTP listener — it was deleted in stdio-migration +Step 9 (engine ABI 3). + +To use it from this session: `list_sessions`, then `use_session ` to pin a window. + +> **Note:** repo `.mcp.json` points the ShaderLab MCP server at +> `x64/Debug/ShaderLabMcpBroker/ShaderLabMcpBroker.exe` — the right default, since x64 +> is what CI and most contributors build. **On an ARM64 host that path is wrong**: it +> either does not exist or is a stale x64 build running under emulation, while your +> real shim is in `ARM64\Debug\`. `.mcp.json` is committed and has no per-platform +> form, so override it locally rather than editing it — and if MCP behaves oddly, +> check which binary is actually running before debugging the protocol. + +## 4. MCP test suite + +Against the running GUI (the shim activates the packaged hub): + +```pwsh +pwsh -NoProfile -File .\Tests\RunTests.ps1 -HubAumid 'ShaderLab_9v3yd384n9j18!Hub' +``` + +No-GUI, the way CI does it (GUI-only tests self-skip): + +```pwsh +$env:SHADERLAB_MCP_ALLOW_UNPACKAGED = '1' +$bin = '.\x64\Debug' # or .\ARM64\Debug +$pipe = "ShaderLab.mcp.dev.$([guid]::NewGuid().ToString('N'))" +$hub = Start-Process $bin\ShaderLabMcpBroker\ShaderLabMcpBroker.exe ` + -ArgumentList '--hub','--pipe',$pipe,'--idle-exit-sec','600' -PassThru +$sess = Start-Process $bin\ShaderLabHeadless\ShaderLabHeadless.exe ` + -ArgumentList '--graph','Tests\fixtures\test_cli_basic.json','--mcp-session', + '--pipe',$pipe,'--session-label','dev','--adapter','warp' -PassThru +try { pwsh -NoProfile -File .\Tests\RunTests.ps1 -Pipe $pipe -Adapter warp } +finally { Stop-Process -Id $sess.Id, $hub.Id -Force -ErrorAction SilentlyContinue } +``` + +Verifying **hub activation** specifically: the acceptance check is a real MCP client +connecting — its shim runs the shipped `ActivateHub()` path. Close and reopen the +client; it should reconnect with no cold ShaderLab restart (the hub outlives the +client's job object, which is why `IApplicationActivationManager` is used over +`CreateProcess`). A PowerShell/C# re-implementation of `ActivateHub` tests the OS API +rather than the shim and **can false-pass** — don't use it as the gate. + +## Screenshots and visual confirmation + +Capture through the graph rather than the screen: `render_capture_node` over MCP, or +headless `--output`. That captures the actual scRGB FP16 pipeline output; an OS +screenshot of an HDR window does not. + +**But a captured PNG is 8-bit SDR**, so it has clipped everything above scRGB 1.0 +(80 nits) and lost wide-gamut negatives — it cannot settle an HDR or gamut question. +Prefer numbers (`read_pixel_region`, `read_analysis_output`) and, when you need to +*look*, capture a diagnostic node whose output is SDR by construction — `Nit Map`, +`Luminance Heatmap`, `Gamut Highlight`, `CIE Chromaticity Plot`, `Delta E Comparator` +in Heatmap mode. Say which you used, and flag when a verdict is passing through a tone +map. Full rationale in `CLAUDE.md` §*Looking at HDR output*. diff --git a/CHANGELOG.md b/CHANGELOG.md index fbdbe77..28c8d98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/). ### Fixed +- **`RunTests.ps1` mis-read every response after `use_session`** — the same defect as `RunBrokerSmoke.ps1` below, in the MCP suite CI runs against a headless session, and the reason that step was red. Its `Rpc` helper read exactly one line per request and documented the assumption in a comment: *"the shim answers one line per request, so correlation is positional"*. It isn't — the shim emits `notifications/tools/list_changed` right after the `use_session` reply, so from the first call onward every response was the *previous* call's. Result: **3 passed, 18 failed**, with failure text that named the mismatch plainly once you knew to look — `Route.CatalogRoundTrip` reported `Unknown tool: image_stats` for a tool it never requested (that was `Route.ImageStatsRemoved`'s response, arriving one slot late). Broken since `5bc47bd` added the notification. `Rpc` now correlates on `id`, skips and records notifications, and **raises** on an id mismatch rather than tolerating it, so a future desync fails loudly at the source instead of producing eighteen misleading errors. **21/21 now pass** against a headless session via CI's exact invocation. Both stdio harnesses were audited; these two are the only scripts that read the shim, and both are now notification-aware. - **`RunBrokerSmoke.ps1` mis-read every response after `use_session`** — five checks failing, and one **false pass**. Its `Recv` read one line and assumed it was the reply to the request just sent. But the shim advertises `tools.listChanged` in `initialize` and correctly emits `notifications/tools/list_changed` immediately after the `use_session` reply, once the pinned session's catalog is spliced in. That one unsolicited server→client line shifted every subsequent read by one: `ToolsListSpliced` read the notification, `GraphOverviewRoundTrip` read the `tools/list` reply, `MutatingRouteRoundTrip` read the `graph_overview` reply, `NotificationSilent` and `ParseErrorShape` read the two before them — and `GoneSurfacesDistinctError` **passed for the wrong reason**, matching `session_gone` in the *previous* call's response. Nothing in the product was wrong; the suite had been reporting 5 failures and one bogus success against a correct shim. `Recv` now correlates: it returns the next message carrying an `id`, recording notifications as it skips them, and `RecvRaw` keeps the old behavior for the one place a raw line is wanted. A new `Session.ToolsListChangedOnAttach` check asserts the notification is actually emitted, so the behavior that broke the harness is now pinned rather than merely tolerated. All 28 checks pass, stable over three consecutive runs. - **Disabling MCP on a freshly launched window hung the app.** `McpSessionClient::Stop()` — called from the UI thread by the toolbar toggle and by `~MainWindow` — closed the pipe handle to "unblock a pending blocking ReadFile" while the session thread was concurrently inside `ReadFile` / `WriteFile` on that same handle. The pipe is opened without `FILE_FLAG_OVERLAPPED`, so its I/O is **synchronous**, and Win32 only cancels that via `CancelSynchronousIo()`; `CancelIo`/`CancelIoEx` cancel asynchronous operations, and closing a handle out from under in-flight I/O is undefined — a Debug build raises `STATUS_INVALID_HANDLE`, and once the value is recycled by another thread the session can write MCP bytes into an unrelated object. In practice the read did **not** reliably unblock, so `StopMcpSession`'s `m_sessionThread.join()` never returned and the UI thread deadlocked. Reproduced as a hang in the test suite by restoring the old `Stop()` — the run never completed; with the fix the same check returns in **0 ms**. The window is widest right after launch because the connect → hello → hello-ack handshake ran without consulting the stop flag at all, so a toggle during it was only noticed after the handshake finished. **Fix**: `Stop()` sets the flag and calls `CancelSynchronousIo` on a duplicated handle to the session thread that `Run()` publishes; the owning thread still does the close; the handle and thread handles are serialized by a small mutex held only around publish/cancel/close, never across I/O; and `stop` is now checked at each handshake step. This is what the migration plan already specified ("reject-new → `bye` → cancel → **join**") and matches the broker's own cancellation pattern. Three regression tests cover it, including a stub pipe server that parks the session in a blocking read so the close-from-another-thread path cannot come back. - **Saving an output window's image failed silently.** `OutputWindow::SaveImageAsync` wrapped its whole WIC/D2D body — every step of which is `check_hresult`'d — in a bare `catch (...) {}`. Any failure after the file picker (unsupported pixel format for the chosen container, a `Map` failure, a denied path) therefore did nothing at all: no dialog, no status text, no log line, while the success path wrote "Saved: ``" into the same status field. Now reports `Save failed: ` there and traces to the debugger, with a separate non-hresult fallback. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..06a17e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,199 @@ +# CLAUDE.md + +ShaderLab — a WinUI 3 / C++/WinRT desktop tool for authoring and debugging Direct2D +effects in an HDR / WCG pipeline, driven either by hand or by an AI agent over MCP. +The deep reference is [`docs/`](docs/README.md); this file is the part you need +*before* you read anything. + +## Hard rules + +- **C++/WinRT only. Never generate C#.** Direct COM access to `ID2D1EffectImpl`, + `ID2D1DrawTransform`, `ID2D1ComputeTransform` is the reason this project exists. +- **Every new `.cpp` starts with `#include "pch.h"`** (engine-side: `pch_engine.h`). + Precompiled headers are mandatory; anything else fails to build. +- **Docs are part of the change, not a follow-up.** A significant change updates the + relevant file under `docs/`, adds a `CHANGELOG.md` entry, and — for a choice whose + *why* isn't obvious from the code — a row in [`docs/history/decision-log.md`](docs/history/decision-log.md). + Root `README.md` stays slim (install + build + pointer to `docs/`). +- **Don't widen `MainWindow`.** It is already ~10k LOC across six files with ~89 + member fields. New UI behavior belongs in a `Controls/` controller; new + host-agnostic behavior belongs in the engine. + +## Build, test, run + +Windows only. **x64 and ARM64** are both supported; build outputs land in +`\\\`. Locate MSBuild with `vswhere` rather than assuming +an install path — editions and versions differ per machine: + +```pwsh +$vs = & "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" ` + -latest -products * -requires Microsoft.Component.MSBuild -property installationPath +$msb = "$vs\MSBuild\Current\Bin\MSBuild.exe" # x64 host +# On an ARM64 HOST, use the arm64-native binary instead -- see the trap below: +# $msb = "$vs\MSBuild\Current\Bin\arm64\MSBuild.exe" + +& $msb ShaderLabTests.vcxproj /p:Configuration=Debug /p:Platform=x64 /m /v:m /nologo +& '.\x64\Debug\ShaderLabTests\ShaderLabTests.exe' --adapter warp # ends "ALL TESTS PASSED" +``` + +> **Trap — building ARM64 *on* an ARM64 host needs the arm64 MSBuild.** +> `MSBuild\Current\Bin\MSBuild.exe` is 32-bit and reports +> `PROCESSOR_ARCHITECTURE=x86` under emulation, so toolset selection falls through to +> the 32-bit `HostX86\arm64\cl.exe`, which exhausts its ~3 GB address space on the big +> translation units and dies with `C3859: Failed to create virtual memory for PCH` + +> `C1076`. `/p:PreferredToolArchitecture=x64` does **not** help (the props file +> declares `TreatAsLocalProperty` and demotes it back). You also need the +> `Microsoft.VisualStudio.Component.UWP.VC.ARM64` component or the packaged app has no +> ARM64 platform. CI cross-compiles ARM64 from an x64 runner, so neither surfaces +> there — the `native-arm64` job exists to catch them. Full detail: +> [`docs/development/build.md`](docs/development/build.md). + +> **Trap — the Bash tool reports the wrong architecture on ARM64 hosts.** git-bash runs +> emulated and prints `PROCESSOR_ARCHITECTURE=AMD64`. Use PowerShell to branch on arch. + +Fastest inner loops, cheapest first: + +| Loop | Command | +|---|---| +| Pure math / shader-bench | build + run `ShaderLabTests.exe --adapter warp` (no GPU, no UI) | +| Headless render / pixel probe | `ARM64\Debug\ShaderLabHeadless\ShaderLabHeadless.exe --graph Tests\fixtures\test_cli_basic.json --node --output out.png --adapter warp` | +| Batch parameter sweep | same exe with `--script --script-output ` | +| Full app + MCP | see the **shaderlab-run** skill — packaged deploy has real gotchas | + +Prefer headless over the GUI when a question can be answered numerically: it needs no +deploy, no registration, and gives FP32 readback via `--pixels`. + +## The graph-access rule (read before touching `m_graph` from UI code) + +The render worker is the **single writer** of the live `EffectGraph` and writes +continuously — clock-node property inserts every tick, plus every MCP mutation. +Getting this wrong is a data race that surfaces as an access violation deep inside +`std::map`, **not** a compile error. Two such crashes shipped before the rule was +written down; both were `node->properties.find()` from the UI thread during paint. + +1. **UI-thread reads → the per-frame `GraphUiSnapshot`, never `m_graph`.** + `MainWindow::CurrentGraphSnapshot()` / `NodeGraphController::Snapshot()`. At most + one frame stale. Hold the `shared_ptr` for the whole read. +2. **Writes (any thread) → `RenderThreadDispatcher::DispatchSync`.** Never mutate + `m_graph` from a pointer or interaction handler. +3. **Layout computation** (`RebuildLayout` / `AutoLayout` / `ComputeNodeVisual`) → + live `m_graph`, **render thread only** (it must see post-mutation state). + UI callers go through `MainWindow::RunLayoutOnRenderThread`. + +**Lock order: `m_graphMutex` → `m_visualsMutex`.** Never hold `m_visualsMutex` across +a `DispatchSync`; don't hold references into `m_visuals` across a dispatch (copy by +value — they dangle if the worker rebuilds layout); don't read `m_visuals` inside a +dispatched closure (it runs on the render thread). Full contract: +[`docs/architecture/threading-model.md`](docs/architecture/threading-model.md). + +## Effect-authoring traps that cost real debugging time + +The full list — and it is worth reading before writing any D2D effect — is in +[`.github/copilot-instructions.md`](.github/copilot-instructions.md) §*D2D Custom +Effect Gotchas* and [`docs/architecture/d2d-d3d11-hybrid-compute.md`](docs/architecture/d2d-d3d11-hybrid-compute.md). +The ones that bite most often: + +- **`ProcessDeferredCompute` must run inside an active `BeginDraw`/`EndDraw`.** It + calls `dc->DrawImage` internally; outside a draw session that silently no-ops and + the compute reads black — Min/Max/Mean all 0, with no error anywhere. +- **D2D→D3D11 texture handoff needs an explicit `dc->Flush()`** between `DrawImage` + and any D3D11 read, or D3D11 reads zeros. +- **New D2D custom effects need two evaluation passes** before output is correct. +- **`D3DCOMPILE_WARNINGS_ARE_ERRORS` optimizes out cbuffer vars** not referenced on + *all* paths — read every cbuffer var at the top of `main()` before branching. +- **D2D `TEXCOORD` is pixel/scene space, not normalized [0,1].** +- **scRGB is signed on purpose.** Negative Rec.709 components are how wide-gamut + color is expressed (BT.2020 green ≈ `(-0.87, 1.00, 0.06)`). A `max(rgb, 0)` or + `saturate()` at the top of a color transform is a **gamut clip**, not a safety + net — this exact bug silently sRGB-clipped the whole ICtCp suite. Use the signed + PQ helpers in `Effects/ColorMath.cpp`. + +**MCP-specific:** untyped (`{}`-schema) tool args arrive from Claude Code as JSON +**strings** — `"203"`, `"true"`. `EngineMcpRoutes.cpp` coerces them to the parameter's +real type in both `/graph/set-property` and `/graph/apply`. Keep that coercion when +touching those paths; without it bindings break, shaders read 0, and clocks freeze. +Diagnostic tell: `graph_get_node` printing `"203"` (quoted = string, broken) vs +`203.000000`. + +## Looking at HDR output (you cannot, directly) + +Your vision input is 8-bit SDR. A captured PNG of an HDR frame has clipped +everything above scRGB 1.0 (80 nits) and lost the negative components that carry +wide-gamut chroma. **Seeing** an HDR image therefore requires tone mapping it — which +in this project is usually the thing under test. Judging a tone mapper from a +tone-mapped screenshot is circular. + +So, in order of preference: + +1. **Numbers first.** `read_pixel_region` / headless `--pixels` (FP32, unclipped) and + `read_analysis_output` on a Statistics node. These are ground truth. +2. **Measured difference:** `Delta E Comparator` with `Method = dE ITP (BT.2124)` → + `Luminance Statistics` → read Mean/p95/Max. Use ITP, not the CIE Lab metrics, for + anything HDR or wide-gamut — see the effect's catalog entry for why. +3. **Diagnostic renders when you need to *look*.** These encode HDR facts into an + SDR-visible image, so capturing them is legitimate: `Nit Map` and + `Luminance Heatmap` (where the energy is), `Gamut Highlight` and + `CIE Chromaticity Plot` (what is out of gamut), `ICtCp Boundary`, + `Delta E Comparator` in Heatmap mode (where two images differ). +4. **A raw capture of HDR content** is for composition and gross sanity only. + +**Always say which one you used.** "The Nit Map shows the highlights peaking around +1200 nits" is a claim you can support; "the image looks right" after capturing an HDR +node is not — flag that you are looking through a tone map, or that a judgement is a +taste call rather than a measurement. `render_capture` / `render_capture_node` clip to +SDR and their tool descriptions say so. + +For a full-range artifact, headless `--output foo.jxr` writes lossless 64bpp-half +JPEG XR with no clamp — but note **you still can't view it**; it is for archiving, +golden-image comparison, and feeding back in as an Image source. + +## Layout + +Four projects around one host-agnostic engine: + +- `ShaderLabEngine.dll` — `Graph/` (model + `GraphUiSnapshot`), `Rendering/` + (evaluator, `D3D11ComputeRunner`, `DisplayMonitor`, ICC, capture), `Effects/` + (built-in effect library with embedded HLSL, registry, compiler, sources), + `Engine/Mcp/` (router, JSON-RPC, tool catalog, engine-pure routes, broker crypto). + `SHADERLAB_ENGINE_ABI_VERSION` in `EngineExport.h` is bumped **manually** on ABI + breaks; a mismatch aborts host startup. +- `ShaderLab.exe` — WinUI 3 packaged app: XAML, `Controls/` controllers, + `RenderEngine`, the render worker, app-side MCP routes. +- `ShaderLabHeadless.exe` — console host, no WinUI. PNG render, FP32 readback, + script batch, MCP session. +- `ShaderLabMcpBroker.exe` — MCP transport. `--hub` is a blind relay (sealed bodies); + `--stdio` is the client shim. Does **not** link the engine. +- `ShaderLabTests.exe` — standalone runner, no WinUI, WARP-capable. + +Pipeline is **always scRGB FP16 linear light** (1.0 = 80 nits), no format switching, +no built-in tone-mapping pass — tone mappers are composed as graph effects (the ICtCp +suite is the preferred path) and validated empirically with `Delta E Comparator` + +`Luminance Statistics` + `Working Space`. + +## Conventions + +`ShaderLab::` namespaces mirror directories (`Graph`, `Rendering`, `Effects`, +`Controls`); XAML types live in `winrt::ShaderLab::implementation`. Members are +`m_`-prefixed, methods/types PascalCase. COM members are `winrt::com_ptr`; custom +D2D effects hand-roll `IUnknown` refcounting on a `LONG m_refCount`. Init paths use +`winrt::check_hresult`; hot paths use `SUCCEEDED`/`FAILED` with early return. + +New files go in the matching directory **and** into both `ShaderLab.vcxproj` (or the +right project) and `.vcxproj.filters`. + +**Don't add a bare `catch (...) {}`.** Record the failure somewhere a caller can see +it (`LastError()`, `runtimeError`, an MCP status field), or — when swallowing really is +correct (teardown, a best-effort UI indicator) — say so in a comment, so an intentional +swallow is distinguishable from an oversight. Of ~37 catch-alls in the engine +directories, the genuinely silent ones have been fixed or annotated; two cost real +debugging time before that: `DisplayMonitor::Initialize` swallowed a throw and served +SDR defaults on a 4000-nit HDR panel (a missing `Windows.System.DispatcherQueue`, not +an API bug), and `OutputWindow::SaveImageAsync` swallowed every WIC/D2D failure so +"Save image" silently did nothing. + +## Verifying work + +Claims about color or performance in this project are expected to be **measured**, +not asserted — that standard is visible throughout `CHANGELOG.md` and is the house +style. Before reporting a fix: run the tests, and where the change is numeric, probe +it headless (`--pixels` / `--script`) and quote the numbers. diff --git a/Tests/RunTests.ps1 b/Tests/RunTests.ps1 index 4725bad..e8c1978 100644 --- a/Tests/RunTests.ps1 +++ b/Tests/RunTests.ps1 @@ -36,6 +36,8 @@ param( $ErrorActionPreference = "Stop" $script:TestResults = @() +# Server -> client notifications seen while waiting for responses (see Rpc). +$script:Notifications = @() $script:TestDir = $PSScriptRoot $script:RepoRoot = Split-Path $script:TestDir -Parent $script:FixturesDir = Join-Path $script:TestDir "fixtures" @@ -84,14 +86,37 @@ function Stop-Shim { } } -# One JSON-RPC round-trip over the shim's stdio (serial: send then read the -# single response line -- notifications aside, the shim answers one line per -# request, so correlation is positional). +# One JSON-RPC round-trip over the shim's stdio. +# +# Correlates on "id" and skips server -> client notifications. It used to read +# exactly one line per request and treat position as correlation -- but the +# shim advertises tools.listChanged in initialize and emits +# notifications/tools/list_changed right after the use_session reply, once the +# pinned session's catalog is spliced in. That single unsolicited line shifted +# every later read by one and cascaded into ~18 bogus failures whose messages +# were simply the PREVIOUS call's response ("Unknown tool: image_stats" landing +# on a test that never asked for it). Correlate, never count. +# +# A mismatched id is raised rather than tolerated: it means the stream has +# desynced, and every later read would be silently wrong. function Rpc($obj, $timeoutMs = 35000) { + $expectedId = $obj['id'] $script:Shim.StandardInput.WriteLine(($obj | ConvertTo-Json -Depth 8 -Compress)) - $t = $script:Shim.StandardOutput.ReadLineAsync() - if (-not $t.Wait($timeoutMs)) { throw "shim did not respond in ${timeoutMs}ms" } - return ($t.Result | ConvertFrom-Json) + for ($i = 0; $i -lt 16; $i++) { + $t = $script:Shim.StandardOutput.ReadLineAsync() + if (-not $t.Wait($timeoutMs)) { throw "shim did not respond in ${timeoutMs}ms" } + $msg = $t.Result | ConvertFrom-Json + if ($null -eq $msg.PSObject.Properties['id']) { + # Notification: record it (some tests assert on these) and read on. + $script:Notifications += @($msg.method) + continue + } + if ($null -ne $expectedId -and $msg.id -ne $expectedId) { + throw "stream desync: got response id $($msg.id), expected $expectedId" + } + return $msg + } + throw "no response after 16 lines (notifications only?)" } function McpCall($toolName, $arguments = @{}) { diff --git a/Tests/fixtures/test_cli_basic.json b/Tests/fixtures/test_cli_basic.json index 10da2ad..2ed4775 100644 --- a/Tests/fixtures/test_cli_basic.json +++ b/Tests/fixtures/test_cli_basic.json @@ -7,8 +7,8 @@ "name": "Gamut Source", "type": "PixelShader", "position": [ - 60, - 60 + 0, + 0 ], "properties": [ { @@ -60,7 +60,7 @@ ], "customEffect": { "shaderType": 0, - "hlslSource": "\n// ---- ShaderLab Color Math Library ----\n\n// scRGB: linear Rec.709 primaries, 1.0 = 80 nits SDR white\n// Pipeline operates in FP16 scRGB throughout.\n\n// sRGB EOTF (decode gamma)\nfloat3 SRGBToLinear(float3 c) {\n return float3(\n c.r <= 0.04045 ? c.r / 12.92 : pow((c.r + 0.055) / 1.055, 2.4),\n c.g <= 0.04045 ? c.g / 12.92 : pow((c.g + 0.055) / 1.055, 2.4),\n c.b <= 0.04045 ? c.b / 12.92 : pow((c.b + 0.055) / 1.055, 2.4));\n}\n\n// sRGB inverse EOTF (encode gamma)\nfloat3 LinearToSRGB(float3 c) {\n return float3(\n c.r <= 0.0031308 ? c.r * 12.92 : 1.055 * pow(c.r, 1.0/2.4) - 0.055,\n c.g <= 0.0031308 ? c.g * 12.92 : 1.055 * pow(c.g, 1.0/2.4) - 0.055,\n c.b <= 0.0031308 ? c.b * 12.92 : 1.055 * pow(c.b, 1.0/2.4) - 0.055);\n}\n\n// scRGB (Rec.709 linear) -> CIE XYZ (D65)\nstatic const float3x3 REC709_TO_XYZ = float3x3(\n 0.4123908, 0.3575843, 0.1804808,\n 0.2126390, 0.7151687, 0.0721923,\n 0.0193308, 0.1191950, 0.9505322\n);\n\n// CIE XYZ (D65) -> scRGB (Rec.709 linear)\nstatic const float3x3 XYZ_TO_REC709 = float3x3(\n 3.2409699, -1.5373832, -0.4986108,\n -0.9692436, 1.8759675, 0.0415551,\n 0.0556301, -0.2039770, 1.0569715\n);\n\n// scRGB -> CIE XYZ\nfloat3 ScRGBToXYZ(float3 rgb) {\n return mul(REC709_TO_XYZ, rgb);\n}\n\n// CIE XYZ -> scRGB\nfloat3 XYZToScRGB(float3 xyz) {\n return mul(XYZ_TO_REC709, xyz);\n}\n\n// CIE XYZ -> CIE xyY\nfloat3 XYZToxyY(float3 xyz) {\n float sum = xyz.x + xyz.y + xyz.z;\n float2 xy = (sum < 1e-10) ? float2(0.3127, 0.3290) : float2(xyz.x / sum, xyz.y / sum);\n return float3(xy, xyz.y);\n}\n\n// CIE xyY -> CIE XYZ\nfloat3 xyYToXYZ(float3 xyY) {\n float X = (xyY.y < 1e-10) ? 0.0 : xyY.x * xyY.z / xyY.y;\n float Z = (xyY.y < 1e-10) ? 0.0 : (1.0 - xyY.x - xyY.y) * xyY.z / xyY.y;\n return float3(X, xyY.z, Z);\n}\n\n// Luminance in nits from scRGB (1.0 scRGB = 80 nits)\nfloat ScRGBToNits(float3 rgb) {\n return dot(rgb, float3(0.2126390, 0.7151687, 0.0721923)) * 80.0;\n}\n\n// Luminance in nits from scRGB (Y component, handles negative values)\nfloat ScRGBLuminanceNits(float3 rgb) {\n return max(0.0, dot(rgb, float3(0.2126390, 0.7151687, 0.0721923))) * 80.0;\n}\n\n// PQ (ST.2084) EOTF: PQ signal [0,1] -> linear nits [0,10000]\nfloat PQ_EOTF(float N) {\n float Np = pow(max(N, 0.0), 1.0 / 78.84375);\n float num = max(Np - 0.8359375, 0.0);\n float den = 18.8515625 - 18.6875 * Np;\n return 10000.0 * pow(num / max(den, 1e-10), 1.0 / 0.1593017578125);\n}\n\n// PQ inverse EOTF: linear nits [0,10000] -> PQ signal [0,1]\nfloat PQ_InvEOTF(float L) {\n float Lp = pow(max(L, 0.0) / 10000.0, 0.1593017578125);\n float num = 0.8359375 + 18.8515625 * Lp;\n float den = 1.0 + 18.6875 * Lp;\n return pow(num / den, 78.84375);\n}\n\n// Rec.2020 linear -> CIE XYZ\nstatic const float3x3 REC2020_TO_XYZ = float3x3(\n 0.6369580, 0.1446169, 0.1688810,\n 0.2627002, 0.6779981, 0.0593017,\n 0.0000000, 0.0280727, 1.0609851\n);\n\n// CIE XYZ -> Rec.2020 linear\nstatic const float3x3 XYZ_TO_REC2020 = float3x3(\n 1.7166512, -0.3556708, -0.2533663,\n -0.6666844, 1.6164812, 0.0157685,\n 0.0176399, -0.0427706, 0.9421031\n);\n\n// DCI-P3 (D65) linear -> CIE XYZ\nstatic const float3x3 P3D65_TO_XYZ = float3x3(\n 0.4865709, 0.2656677, 0.1982173,\n 0.2289746, 0.6917385, 0.0792869,\n 0.0000000, 0.0451134, 1.0439444\n);\n\n// CIE XYZ -> DCI-P3 (D65) linear\nstatic const float3x3 XYZ_TO_P3D65 = float3x3(\n 2.4934969, -0.9313836, -0.4027108,\n -0.8294890, 1.7626641, 0.0236247,\n 0.0358458, -0.0761724, 0.9568845\n);\n\n// Gamut primaries in CIE xy coordinates\n// Rec.709/sRGB\nstatic const float2 GAMUT_709_R = float2(0.64, 0.33);\nstatic const float2 GAMUT_709_G = float2(0.30, 0.60);\nstatic const float2 GAMUT_709_B = float2(0.15, 0.06);\n\n// DCI-P3 (D65)\nstatic const float2 GAMUT_P3_R = float2(0.680, 0.320);\nstatic const float2 GAMUT_P3_G = float2(0.265, 0.690);\nstatic const float2 GAMUT_P3_B = float2(0.150, 0.060);\n\n// Rec.2020\nstatic const float2 GAMUT_2020_R = float2(0.708, 0.292);\nstatic const float2 GAMUT_2020_G = float2(0.170, 0.797);\nstatic const float2 GAMUT_2020_B = float2(0.131, 0.046);\n\n// D65 white point\nstatic const float2 D65_WHITE = float2(0.3127, 0.3290);\n\n// Check if point p is inside triangle (a, b, c) using barycentric coordinates\nbool PointInTriangle(float2 p, float2 a, float2 b, float2 c) {\n float2 v0 = c - a, v1 = b - a, v2 = p - a;\n float d00 = dot(v0, v0);\n float d01 = dot(v0, v1);\n float d02 = dot(v0, v2);\n float d11 = dot(v1, v1);\n float d12 = dot(v1, v2);\n float inv = 1.0 / (d00 * d11 - d01 * d01);\n float u = (d11 * d02 - d01 * d12) * inv;\n float v = (d00 * d12 - d01 * d02) * inv;\n return (u >= 0) && (v >= 0) && (u + v <= 1.0);\n}\n\n// Turbo colormap approximation (for luminance heatmaps)\nfloat3 TurboColormap(float t) {\n t = saturate(t);\n float r = saturate(0.13572138 + t * (4.61539260 + t * (-42.66032258 + t * (132.13108234 + t * (-152.94239396 + t * 59.28637943)))));\n float g = saturate(0.09140261 + t * (2.19418839 + t * (4.84296658 + t * (-14.18503333 + t * (4.27729857 + t * 2.82956604)))));\n float b = saturate(0.10667330 + t * (12.64194608 + t * (-60.58204836 + t * (110.36276771 + t * (-89.90310912 + t * 27.34824973)))));\n return float3(r, g, b);\n}\n\n// D65 reference white in XYZ\nstatic const float3 D65_XYZ = float3(0.95047, 1.00000, 1.08883);\n\n// CIE Lab helper\nfloat LabF(float t) {\n // Signed extension: handle negative XYZ values from out-of-gamut scRGB.\n float at = abs(t);\n float ft = (at > 0.008856) ? pow(at, 1.0/3.0) : (7.787 * at + 16.0/116.0);\n return (t < 0.0) ? -ft : ft;\n}\n\n// CIE XYZ -> CIE L*a*b* (D65)\nfloat3 XYZToLab(float3 xyz) {\n float fx = LabF(xyz.x / D65_XYZ.x);\n float fy = LabF(xyz.y / D65_XYZ.y);\n float fz = LabF(xyz.z / D65_XYZ.z);\n float L = 116.0 * fy - 16.0;\n float a = 500.0 * (fx - fy);\n float b = 200.0 * (fy - fz);\n return float3(L, a, b);\n}\n\n// scRGB -> CIE L*a*b*\nfloat3 ScRGBToLab(float3 rgb) {\n return XYZToLab(ScRGBToXYZ(rgb));\n}\n\n// ---- ICtCp (BT.2100) ----\n// Pipeline: scRGB -> XYZ -> LMS (BT.2124 cross-talk) -> PQ encode -> ICtCp\n\n// XYZ to LMS (BT.2124 / Hunt-Pointer-Estevez with cross-talk)\nstatic const float3x3 XYZ_TO_LMS_ICTCP = float3x3(\n 0.3592832, 0.6976051, -0.0358916,\n -0.1920808, 1.1004768, 0.0753741,\n 0.0070797, 0.0748262, 0.8433009\n);\n\n// LMS to XYZ (inverse)\nstatic const float3x3 LMS_TO_XYZ_ICTCP = float3x3(\n 2.0701800, -1.3264569, 0.2066510,\n 0.3649882, 0.6805541, -0.0453723,\n -0.0496570, -0.0492033, 1.1880720\n);\n\n// PQ-encoded LMS to ICtCp\nstatic const float3x3 PQLMS_TO_ICTCP = float3x3(\n 2048.0/4096.0, 2048.0/4096.0, 0.0/4096.0,\n 6610.0/4096.0, -13613.0/4096.0, 7003.0/4096.0,\n 17933.0/4096.0, -17390.0/4096.0, -543.0/4096.0\n);\n\n// ICtCp to PQ-encoded LMS (inverse)\nstatic const float3x3 ICTCP_TO_PQLMS = float3x3(\n 1.0, 0.008609037, 0.111029625,\n 1.0, -0.008609037, -0.111029625,\n 1.0, 0.560031336, -0.320627175\n);\n\n// scRGB -> ICtCp\nfloat3 ScRGBToICtCp(float3 rgb) {\n // scRGB (1.0 = 80 nits) -> absolute luminance XYZ\n float3 xyz = ScRGBToXYZ(max(rgb, 0.0));\n // Scale to absolute nits for PQ (XYZ Y=1 = 80 nits in scRGB)\n xyz *= 80.0;\n float3 lms = mul(XYZ_TO_LMS_ICTCP, xyz);\n lms = max(lms, 0.0);\n // PQ encode each LMS component (input in nits, output [0,1])\n float3 pqLms = float3(\n PQ_InvEOTF(lms.x),\n PQ_InvEOTF(lms.y),\n PQ_InvEOTF(lms.z));\n return mul(PQLMS_TO_ICTCP, pqLms);\n}\n\n// ICtCp -> scRGB\nfloat3 ICtCpToScRGB(float3 ictcp) {\n float3 pqLms = mul(ICTCP_TO_PQLMS, ictcp);\n // Defensive clamp: PQ_EOTF is only defined for V in [0, 1]. Out-of-range\n // pqLms (which can happen when callers modify I-channel without rescaling\n // Ct/Cp, or with out-of-gamut chroma) produce NaN/Inf via the EOTF.\n pqLms = saturate(pqLms);\n // PQ decode to nits\n float3 lms = float3(\n PQ_EOTF(pqLms.x),\n PQ_EOTF(pqLms.y),\n PQ_EOTF(pqLms.z));\n float3 xyz = mul(LMS_TO_XYZ_ICTCP, lms);\n // Scale back from nits to scRGB (80 nits = 1.0)\n xyz /= 80.0;\n return XYZToScRGB(xyz);\n}\n\n// OKLab: linear sRGB -> OKLab\nfloat3 LinearToOKLab(float3 c) {\n float l = 0.4122214708 * c.r + 0.5363325363 * c.g + 0.0514459929 * c.b;\n float m = 0.2119034982 * c.r + 0.6806995451 * c.g + 0.1073969566 * c.b;\n float s = 0.0883024619 * c.r + 0.2817188376 * c.g + 0.6299787005 * c.b;\n float l_ = pow(max(l, 0.0), 1.0/3.0);\n float m_ = pow(max(m, 0.0), 1.0/3.0);\n float s_ = pow(max(s, 0.0), 1.0/3.0);\n return float3(\n 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_,\n 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_,\n 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_\n );\n}\n\n// ---- I-channel (PQ-encoded nits) helpers for ICtCp tone mapping ----\n// In BT.2100 ICtCp, I is a weighted PQ-encoded sum of LMS. For\n// chromaticity-preserving operations (compress / expand only I, leave\n// Ct/Cp), it is standard to treat I as if it were PQ(neutral_nits) and\n// design the curve in nits-via-PQ space. This is what BT.2390 does.\n\n// Convert a nit value to its corresponding I coordinate.\nfloat NitsToI(float nits) {\n return PQ_InvEOTF(max(nits, 0.0));\n}\n\n// Convert an I coordinate back to nits.\nfloat IToNits(float I) {\n return PQ_EOTF(I);\n}\n\n// Reinhard compression on I, expressed in I-space directly. Anchored\n// Mᅢᄊbius: maps 0 -> 0 and peakIn_I -> peakOut_I exactly, with f'(0)=1\n// (linear at the low end) and smooth rolloff near peakIn. Both peaks\n// are I coordinates (PQ values). For HDR -> SDR pass peakIn = HDR_I,\n// peakOut = SDR_I. Inputs above peakIn_I are clamped so the curve\n// can't walk past its anchor onto the rising branch beyond peakIn.\nfloat ReinhardCompressI(float I, float peakIn_I, float peakOut_I) {\n float pp = peakIn_I * peakOut_I;\n if (pp <= 1e-12) return 0.0;\n float Ic = clamp(I, 0.0, peakIn_I);\n float denom = pp + Ic * (peakIn_I - peakOut_I);\n return Ic * pp / max(denom, 1e-12);\n}\n\n// Inverse of ReinhardCompressI: given an I value in [0, peakOut_I]\n// returns the I in [0, peakIn_I] that would compress to it. Same\n// peakIn/peakOut convention as ReinhardCompressI: peakIn is the\n// *uncompressed* range, peakOut is the *compressed* range. For\n// SDR -> HDR expansion callers pass peakIn = HDR_I, peakOut = SDR_I.\n// Inputs above peakOut_I are clamped so the curve saturates at peakIn\n// rather than racing toward the asymptote.\nfloat ReinhardExpandI(float I, float peakIn_I, float peakOut_I) {\n float pp = peakIn_I * peakOut_I;\n if (pp <= 1e-12) return 0.0;\n float Ic = clamp(I, 0.0, peakOut_I);\n float denom = pp - Ic * (peakIn_I - peakOut_I);\n return Ic * pp / max(denom, 1e-12);\n}\n\n// Gamut Source - generates all colors within a selected color gamut\n// Fixed coordinate system centered on D65 white point. Scale fits Rec.2020\n// so all three gamuts share the same spatial mapping.\n//\n// Gamut modes:\n// 0 = Rec.709\n// 1 = DCI-P3\n// 2 = Rec.2020\n// 3 = Custom (uses RedPrimary/GreenPrimary/BluePrimary; bind to\n// Working Space.RedPrimary etc. for a monitor-matched source.)\n\ncbuffer constants : register(b0) {\n float Gamut;\n float Luminance; // nits (default 80.0, maps to scRGB 1.0)\n float OutputSize; // pixels (default 1024)\n float2 RedPrimary;\n float2 GreenPrimary;\n float2 BluePrimary;\n};\n\nfloat4 main(\n float4 pos : SV_POSITION,\n float4 uv0 : TEXCOORD0) : SV_TARGET\n{\n float size = max(OutputSize, 128.0);\n\n // Read all cbuffer vars at top to keep DXC from optimizing them out.\n float gamut = Gamut;\n float2 cR = RedPrimary;\n float2 cG = GreenPrimary;\n float2 cB = BluePrimary;\n\n // Select gamut primaries\n float2 r, g, b;\n if (gamut > 2.5) { r = cR; g = cG; b = cB; }\n else if (gamut > 1.5){ r = GAMUT_2020_R; g = GAMUT_2020_G; b = GAMUT_2020_B; }\n else if (gamut > 0.5){ r = GAMUT_P3_R; g = GAMUT_P3_G; b = GAMUT_P3_B; }\n else { r = GAMUT_709_R; g = GAMUT_709_G; b = GAMUT_709_B; }\n\n float2 center = D65_WHITE;\n float halfExtent = 0.50;\n\n float2 uv = uv0.xy / size;\n float2 xy;\n xy.x = center.x + (uv.x - 0.5) * 2.0 * halfExtent;\n xy.y = center.y - (uv.y - 0.5) * 2.0 * halfExtent;\n\n if (!PointInTriangle(xy, r, g, b))\n return float4(0, 0, 0, 1.0);\n\n float Y = Luminance / 80.0;\n float3 xyY_val = float3(xy.x, xy.y, Y);\n float3 xyz = xyYToXYZ(xyY_val);\n float3 rgb = XYZToScRGB(xyz);\n\n return float4(rgb, 1.0);\n}\n", + "hlslSource": "\n// ---- ShaderLab Color Math Library ----\n\n// scRGB: linear Rec.709 primaries, 1.0 = 80 nits SDR white\n// Pipeline operates in FP16 scRGB throughout.\n\n// sRGB EOTF (decode gamma)\nfloat3 SRGBToLinear(float3 c) {\n return float3(\n c.r <= 0.04045 ? c.r / 12.92 : pow((c.r + 0.055) / 1.055, 2.4),\n c.g <= 0.04045 ? c.g / 12.92 : pow((c.g + 0.055) / 1.055, 2.4),\n c.b <= 0.04045 ? c.b / 12.92 : pow((c.b + 0.055) / 1.055, 2.4));\n}\n\n// sRGB inverse EOTF (encode gamma)\nfloat3 LinearToSRGB(float3 c) {\n return float3(\n c.r <= 0.0031308 ? c.r * 12.92 : 1.055 * pow(c.r, 1.0/2.4) - 0.055,\n c.g <= 0.0031308 ? c.g * 12.92 : 1.055 * pow(c.g, 1.0/2.4) - 0.055,\n c.b <= 0.0031308 ? c.b * 12.92 : 1.055 * pow(c.b, 1.0/2.4) - 0.055);\n}\n\n// scRGB (Rec.709 linear) -> CIE XYZ (D65)\nstatic const float3x3 REC709_TO_XYZ = float3x3(\n 0.4123908, 0.3575843, 0.1804808,\n 0.2126390, 0.7151687, 0.0721923,\n 0.0193308, 0.1191950, 0.9505322\n);\n\n// CIE XYZ (D65) -> scRGB (Rec.709 linear)\nstatic const float3x3 XYZ_TO_REC709 = float3x3(\n 3.2409699, -1.5373832, -0.4986108,\n -0.9692436, 1.8759675, 0.0415551,\n 0.0556301, -0.2039770, 1.0569715\n);\n\n// scRGB -> CIE XYZ\nfloat3 ScRGBToXYZ(float3 rgb) {\n return mul(REC709_TO_XYZ, rgb);\n}\n\n// CIE XYZ -> scRGB\nfloat3 XYZToScRGB(float3 xyz) {\n return mul(XYZ_TO_REC709, xyz);\n}\n\n// CIE XYZ -> CIE xyY\nfloat3 XYZToxyY(float3 xyz) {\n float sum = xyz.x + xyz.y + xyz.z;\n float2 xy = (sum < 1e-10) ? float2(0.3127, 0.3290) : float2(xyz.x / sum, xyz.y / sum);\n return float3(xy, xyz.y);\n}\n\n// CIE xyY -> CIE XYZ\nfloat3 xyYToXYZ(float3 xyY) {\n float X = (xyY.y < 1e-10) ? 0.0 : xyY.x * xyY.z / xyY.y;\n float Z = (xyY.y < 1e-10) ? 0.0 : (1.0 - xyY.x - xyY.y) * xyY.z / xyY.y;\n return float3(X, xyY.z, Z);\n}\n\n// Luminance in nits from scRGB (1.0 scRGB = 80 nits)\nfloat ScRGBToNits(float3 rgb) {\n return dot(rgb, float3(0.2126390, 0.7151687, 0.0721923)) * 80.0;\n}\n\n// Luminance in nits from scRGB (Y component, handles negative values)\nfloat ScRGBLuminanceNits(float3 rgb) {\n return max(0.0, dot(rgb, float3(0.2126390, 0.7151687, 0.0721923))) * 80.0;\n}\n\n// PQ (ST.2084) EOTF: PQ signal [0,1] -> linear nits [0,10000]\nfloat PQ_EOTF(float N) {\n float Np = pow(max(N, 0.0), 1.0 / 78.84375);\n float num = max(Np - 0.8359375, 0.0);\n float den = 18.8515625 - 18.6875 * Np;\n return 10000.0 * pow(num / max(den, 1e-10), 1.0 / 0.1593017578125);\n}\n\n// PQ inverse EOTF: linear nits [0,10000] -> PQ signal [0,1]\nfloat PQ_InvEOTF(float L) {\n float Lp = pow(max(L, 0.0) / 10000.0, 0.1593017578125);\n float num = 0.8359375 + 18.8515625 * Lp;\n float den = 1.0 + 18.6875 * Lp;\n return pow(num / den, 78.84375);\n}\n\n// Rec.2020 linear -> CIE XYZ\nstatic const float3x3 REC2020_TO_XYZ = float3x3(\n 0.6369580, 0.1446169, 0.1688810,\n 0.2627002, 0.6779981, 0.0593017,\n 0.0000000, 0.0280727, 1.0609851\n);\n\n// CIE XYZ -> Rec.2020 linear\nstatic const float3x3 XYZ_TO_REC2020 = float3x3(\n 1.7166512, -0.3556708, -0.2533663,\n -0.6666844, 1.6164812, 0.0157685,\n 0.0176399, -0.0427706, 0.9421031\n);\n\n// DCI-P3 (D65) linear -> CIE XYZ\nstatic const float3x3 P3D65_TO_XYZ = float3x3(\n 0.4865709, 0.2656677, 0.1982173,\n 0.2289746, 0.6917385, 0.0792869,\n 0.0000000, 0.0451134, 1.0439444\n);\n\n// CIE XYZ -> DCI-P3 (D65) linear\nstatic const float3x3 XYZ_TO_P3D65 = float3x3(\n 2.4934969, -0.9313836, -0.4027108,\n -0.8294890, 1.7626641, 0.0236247,\n 0.0358458, -0.0761724, 0.9568845\n);\n\n// Gamut primaries in CIE xy coordinates\n// Rec.709/sRGB\nstatic const float2 GAMUT_709_R = float2(0.64, 0.33);\nstatic const float2 GAMUT_709_G = float2(0.30, 0.60);\nstatic const float2 GAMUT_709_B = float2(0.15, 0.06);\n\n// DCI-P3 (D65)\nstatic const float2 GAMUT_P3_R = float2(0.680, 0.320);\nstatic const float2 GAMUT_P3_G = float2(0.265, 0.690);\nstatic const float2 GAMUT_P3_B = float2(0.150, 0.060);\n\n// Rec.2020\nstatic const float2 GAMUT_2020_R = float2(0.708, 0.292);\nstatic const float2 GAMUT_2020_G = float2(0.170, 0.797);\nstatic const float2 GAMUT_2020_B = float2(0.131, 0.046);\n\n// D65 white point\nstatic const float2 D65_WHITE = float2(0.3127, 0.3290);\n\n// Check if point p is inside triangle (a, b, c) using barycentric coordinates\nbool PointInTriangle(float2 p, float2 a, float2 b, float2 c) {\n float2 v0 = c - a, v1 = b - a, v2 = p - a;\n float d00 = dot(v0, v0);\n float d01 = dot(v0, v1);\n float d02 = dot(v0, v2);\n float d11 = dot(v1, v1);\n float d12 = dot(v1, v2);\n float inv = 1.0 / (d00 * d11 - d01 * d01);\n float u = (d11 * d02 - d01 * d12) * inv;\n float v = (d00 * d12 - d01 * d02) * inv;\n return (u >= 0) && (v >= 0) && (u + v <= 1.0);\n}\n\n// Turbo colormap approximation (for luminance heatmaps)\nfloat3 TurboColormap(float t) {\n t = saturate(t);\n float r = saturate(0.13572138 + t * (4.61539260 + t * (-42.66032258 + t * (132.13108234 + t * (-152.94239396 + t * 59.28637943)))));\n float g = saturate(0.09140261 + t * (2.19418839 + t * (4.84296658 + t * (-14.18503333 + t * (4.27729857 + t * 2.82956604)))));\n float b = saturate(0.10667330 + t * (12.64194608 + t * (-60.58204836 + t * (110.36276771 + t * (-89.90310912 + t * 27.34824973)))));\n return float3(r, g, b);\n}\n\n// D65 reference white in XYZ\nstatic const float3 D65_XYZ = float3(0.95047, 1.00000, 1.08883);\n\n// CIE Lab helper\nfloat LabF(float t) {\n // Signed extension: handle negative XYZ values from out-of-gamut scRGB.\n float at = abs(t);\n float ft = (at > 0.008856) ? pow(at, 1.0/3.0) : (7.787 * at + 16.0/116.0);\n return (t < 0.0) ? -ft : ft;\n}\n\n// CIE XYZ -> CIE L*a*b* (D65)\nfloat3 XYZToLab(float3 xyz) {\n float fx = LabF(xyz.x / D65_XYZ.x);\n float fy = LabF(xyz.y / D65_XYZ.y);\n float fz = LabF(xyz.z / D65_XYZ.z);\n float L = 116.0 * fy - 16.0;\n float a = 500.0 * (fx - fy);\n float b = 200.0 * (fy - fz);\n return float3(L, a, b);\n}\n\n// scRGB -> CIE L*a*b*\nfloat3 ScRGBToLab(float3 rgb) {\n return XYZToLab(ScRGBToXYZ(rgb));\n}\n\n// ---- ICtCp (BT.2100) ----\n// Pipeline: scRGB -> XYZ -> LMS (BT.2124 cross-talk) -> PQ encode -> ICtCp\n\n// XYZ to LMS (BT.2124 / Hunt-Pointer-Estevez with cross-talk)\nstatic const float3x3 XYZ_TO_LMS_ICTCP = float3x3(\n 0.3592832, 0.6976051, -0.0358916,\n -0.1920808, 1.1004768, 0.0753741,\n 0.0070797, 0.0748262, 0.8433009\n);\n\n// LMS to XYZ (inverse)\nstatic const float3x3 LMS_TO_XYZ_ICTCP = float3x3(\n 2.0701800, -1.3264569, 0.2066510,\n 0.3649882, 0.6805541, -0.0453723,\n -0.0496570, -0.0492033, 1.1880720\n);\n\n// PQ-encoded LMS to ICtCp\nstatic const float3x3 PQLMS_TO_ICTCP = float3x3(\n 2048.0/4096.0, 2048.0/4096.0, 0.0/4096.0,\n 6610.0/4096.0, -13613.0/4096.0, 7003.0/4096.0,\n 17933.0/4096.0, -17390.0/4096.0, -543.0/4096.0\n);\n\n// ICtCp to PQ-encoded LMS (inverse)\nstatic const float3x3 ICTCP_TO_PQLMS = float3x3(\n 1.0, 0.008609037, 0.111029625,\n 1.0, -0.008609037, -0.111029625,\n 1.0, 0.560031336, -0.320627175\n);\n\n// PQ with a signed extension, mirroring the curve through the origin the\n// same way LabF does for CIE Lab. PQ itself is only defined for\n// non-negative light, but scRGB expresses wide-gamut colors as negative\n// Rec.709 components -- a BT.2020 green is (-0.87, +1.0, +0.06)-ish. A\n// hard clamp there is not a safety net, it is an sRGB gamut clip applied\n// before any colour science runs. Mirroring keeps the excursion\n// representable so the round trip is lossless.\nfloat PQ_InvEOTF_Signed(float L) {\n float v = PQ_InvEOTF(abs(L));\n return (L < 0.0) ? -v : v;\n}\n\nfloat PQ_EOTF_Signed(float N) {\n float v = PQ_EOTF(abs(N));\n return (N < 0.0) ? -v : v;\n}\n\n// scRGB -> ICtCp\nfloat3 ScRGBToICtCp(float3 rgb) {\n // scRGB (1.0 = 80 nits) -> absolute luminance XYZ. No clamp: negative\n // components carry wide-gamut chroma, and the LMS mixing below is\n // non-negative for every physically realizable colour anyway (the\n // BT.2124 cone primaries enclose the visible locus), so the signed PQ\n // only engages on genuinely out-of-locus or below-black input.\n float3 xyz = ScRGBToXYZ(rgb);\n // Scale to absolute nits for PQ (XYZ Y=1 = 80 nits in scRGB)\n xyz *= 80.0;\n float3 lms = mul(XYZ_TO_LMS_ICTCP, xyz);\n // PQ encode each LMS component (input in nits, output [-1,1])\n float3 pqLms = float3(\n PQ_InvEOTF_Signed(lms.x),\n PQ_InvEOTF_Signed(lms.y),\n PQ_InvEOTF_Signed(lms.z));\n return mul(PQLMS_TO_ICTCP, pqLms);\n}\n\n// ICtCp -> scRGB\nfloat3 ICtCpToScRGB(float3 ictcp) {\n float3 pqLms = mul(ICTCP_TO_PQLMS, ictcp);\n // Magnitude clamp: PQ_EOTF's rational form goes singular past |V| = 1\n // (the denominator crosses zero) and yields NaN/Inf, which callers can\n // reach by moving I without rescaling Ct/Cp. Clamp the magnitude and\n // keep the sign so wide-gamut excursions survive.\n pqLms = clamp(pqLms, -1.0, 1.0);\n // PQ decode to nits\n float3 lms = float3(\n PQ_EOTF_Signed(pqLms.x),\n PQ_EOTF_Signed(pqLms.y),\n PQ_EOTF_Signed(pqLms.z));\n float3 xyz = mul(LMS_TO_XYZ_ICTCP, lms);\n // Scale back from nits to scRGB (80 nits = 1.0)\n xyz /= 80.0;\n return XYZToScRGB(xyz);\n}\n\n// ---- Delta E ITP (ITU-R BT.2124) ----\n//\n// The HDR/WCG colour-difference metric. CIE Lab's dE76/94/2000 were derived\n// from reflective samples under SDR viewing and lose meaning above roughly\n// 100 nits and outside sRGB -- exactly where this pipeline operates -- so\n// dE ITP is the correct ruler for tone-mapping and gamut work here.\n//\n// dE_ITP = 720 * sqrt( dI^2 + dT^2 + dP^2 ), T = 0.5 * Ct, P = Cp\n//\n// The 0.5 on Ct converts BT.2100 ICtCp into the \"ITP\" difference space\n// (Ct's range is twice Cp's); the 720 scales one unit to approximately one\n// JND, so it is directly comparable to a dE2000 of 1.\n//\n// BT.2124 is defined on PQ-encoded ICtCp, which is what ScRGBToICtCp\n// produces. Takes ICtCp triples, not scRGB -- convert first.\nfloat DeltaEITP(float3 ictcp1, float3 ictcp2) {\n float dI = ictcp1.x - ictcp2.x;\n float dT = 0.5 * (ictcp1.y - ictcp2.y);\n float dP = ictcp1.z - ictcp2.z;\n return 720.0 * sqrt(dI * dI + dT * dT + dP * dP);\n}\n\n// Convenience: dE ITP straight from two scRGB colours.\nfloat DeltaEITPFromScRGB(float3 rgb1, float3 rgb2) {\n return DeltaEITP(ScRGBToICtCp(rgb1), ScRGBToICtCp(rgb2));\n}\n\n// OKLab: linear sRGB -> OKLab\nfloat3 LinearToOKLab(float3 c) {\n float l = 0.4122214708 * c.r + 0.5363325363 * c.g + 0.0514459929 * c.b;\n float m = 0.2119034982 * c.r + 0.6806995451 * c.g + 0.1073969566 * c.b;\n float s = 0.0883024619 * c.r + 0.2817188376 * c.g + 0.6299787005 * c.b;\n float l_ = pow(max(l, 0.0), 1.0/3.0);\n float m_ = pow(max(m, 0.0), 1.0/3.0);\n float s_ = pow(max(s, 0.0), 1.0/3.0);\n return float3(\n 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_,\n 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_,\n 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_\n );\n}\n\n// ---- I-channel (PQ-encoded nits) helpers for ICtCp tone mapping ----\n// In BT.2100 ICtCp, I is a weighted PQ-encoded sum of LMS. For\n// chromaticity-preserving operations (compress / expand only I, leave\n// Ct/Cp), it is standard to treat I as if it were PQ(neutral_nits) and\n// design the curve in nits-via-PQ space. This is what BT.2390 does.\n\n// Convert a nit value to its corresponding I coordinate.\nfloat NitsToI(float nits) {\n return PQ_InvEOTF(max(nits, 0.0));\n}\n\n// Convert an I coordinate back to nits.\nfloat IToNits(float I) {\n return PQ_EOTF(I);\n}\n\n// Reinhard compression on I, expressed in I-space directly. Anchored\n// Mᅢᄊbius: maps 0 -> 0 and peakIn_I -> peakOut_I exactly, with f'(0)=1\n// (linear at the low end) and smooth rolloff near peakIn. Both peaks\n// are I coordinates (PQ values). For HDR -> SDR pass peakIn = HDR_I,\n// peakOut = SDR_I. Inputs above peakIn_I are clamped so the curve\n// can't walk past its anchor onto the rising branch beyond peakIn.\nfloat ReinhardCompressI(float I, float peakIn_I, float peakOut_I) {\n float pp = peakIn_I * peakOut_I;\n if (pp <= 1e-12) return 0.0;\n float Ic = clamp(I, 0.0, peakIn_I);\n float denom = pp + Ic * (peakIn_I - peakOut_I);\n return Ic * pp / max(denom, 1e-12);\n}\n\n// Inverse of ReinhardCompressI: given an I value in [0, peakOut_I]\n// returns the I in [0, peakIn_I] that would compress to it. Same\n// peakIn/peakOut convention as ReinhardCompressI: peakIn is the\n// *uncompressed* range, peakOut is the *compressed* range. For\n// SDR -> HDR expansion callers pass peakIn = HDR_I, peakOut = SDR_I.\n// Inputs above peakOut_I are clamped so the curve saturates at peakIn\n// rather than racing toward the asymptote.\nfloat ReinhardExpandI(float I, float peakIn_I, float peakOut_I) {\n float pp = peakIn_I * peakOut_I;\n if (pp <= 1e-12) return 0.0;\n float Ic = clamp(I, 0.0, peakOut_I);\n float denom = pp - Ic * (peakIn_I - peakOut_I);\n return Ic * pp / max(denom, 1e-12);\n}\n\n// Soft gamut-distance compression (1D). `d` is a pixel's chroma radius\n// normalized so the gamut boundary sits at 1.0 (d < 1 in-gamut, d > 1\n// out). Returns the remapped radius. Contract:\n// - d <= threshold -> returned unchanged (identity zone)\n// - d == limit -> maps exactly to 1.0 (the boundary)\n// - monotone increasing, C1 at d == threshold (slope 1 where the\n// curve meets the identity segment, so gradients don't kink)\n// - d > limit -> may exceed 1.0 slightly (ACES-style;\n// callers pick `limit` to cover their expected source range)\n// threshold in [0, 1): where compression starts, e.g. 0.75.\n// limit > 1: the source radius that lands exactly on the boundary.\n// power >= 1: knee hardness. 1 reduces exactly to Reinhard; higher\n// values track identity longer and turn harder near the boundary\n// (less desaturation of legal colors, more crowding of illegal ones).\n// ACES RGC ships 1.2.\n// ---- 8-bit display-referred output helpers -----------------------------\n// These exist for the screenshot path, where the handback is 8bpc sRGB and\n// we therefore own the quantizer. The above-white band survives the tone\n// curve inside a very small number of codes (a 0.7*W knee leaves roughly 33\n// of 256 after the sRGB OETF), so quantizing without dither bands visibly\n// in exactly the smooth HDR gradients the feature exists to preserve.\n\n// Interleaved Gradient Noise (Jimenez 2014). Cheap, deterministic, and\n// spectrally much better behaved than a hash-based white noise, which makes\n// it a reasonable dither source when a blue-noise texture isn't available.\n// Expects integer pixel coordinates; returns [0, 1).\nfloat InterleavedGradientNoise(float2 p) {\n return frac(52.9829189 * frac(dot(p, float2(0.06711056, 0.00583715))));\n}\n\n// Triangular-PDF dither, [-1, 1] LSB. The sum of two independent uniforms\n// decorrelates the quantization error from the signal; plain uniform dither\n// leaves a residual pattern modulated by the signal itself.\nfloat TriangularDither(float2 p) {\n float n1 = InterleavedGradientNoise(p);\n float n2 = InterleavedGradientNoise(p + 5.588238);\n return n1 + n2 - 1.0;\n}\n\n// Quantize an already-encoded [0,1] value to `levels` steps with dither.\n// `strength` scales the dither in LSBs (1.0 = standard TPDF, 0 = none).\nfloat3 DitherQuantize(float3 encoded, float2 p, float levels, float strength) {\n float maxCode = max(levels - 1.0, 1.0);\n float3 d = TriangularDither(p) * 0.5 * strength;\n return saturate(round(saturate(encoded) * maxCode + d) / maxCode);\n}\n\nfloat SoftCompressDistance(float d, float threshold, float limit, float power) {\n float t = clamp(threshold, 0.0, 0.99);\n float l = max(limit, 1.01);\n float p = clamp(power, 1.0, 8.0);\n if (d <= t) return d;\n // ACES-RGC-style power curve y = t + x / (1 + (x/s)^p)^(1/p), with\n // the scale s solved from the anchor f(l - t) == 1 - t, so d == l\n // lands exactly on the boundary. f'(0) == 1 keeps the join C1.\n float x = d - t;\n float s = (l - t) / pow(pow((l - t) / (1.0 - t), p) - 1.0, 1.0 / p);\n return t + x / pow(1.0 + pow(x / s, p), 1.0 / p);\n}\n\n// Gamut Source - generates all colors within a selected color gamut\n// Fixed coordinate system centered on D65 white point. Scale fits Rec.2020\n// so all three gamuts share the same spatial mapping.\n//\n// Gamut modes:\n// 0 = Rec.709\n// 1 = DCI-P3\n// 2 = Rec.2020\n// 3 = Custom (uses RedPrimary/GreenPrimary/BluePrimary; bind to\n// Working Space.RedPrimary etc. for a monitor-matched source.)\n\ncbuffer constants : register(b0) {\n float Gamut;\n float Luminance; // nits (default 80.0, maps to scRGB 1.0)\n float OutputSize; // pixels (default 1024)\n float2 RedPrimary;\n float2 GreenPrimary;\n float2 BluePrimary;\n};\n\nfloat4 main(\n float4 pos : SV_POSITION,\n float4 uv0 : TEXCOORD0) : SV_TARGET\n{\n float size = max(OutputSize, 128.0);\n\n // Read all cbuffer vars at top to keep DXC from optimizing them out.\n float gamut = Gamut;\n float2 cR = RedPrimary;\n float2 cG = GreenPrimary;\n float2 cB = BluePrimary;\n\n // Select gamut primaries\n float2 r, g, b;\n if (gamut > 2.5) { r = cR; g = cG; b = cB; }\n else if (gamut > 1.5){ r = GAMUT_2020_R; g = GAMUT_2020_G; b = GAMUT_2020_B; }\n else if (gamut > 0.5){ r = GAMUT_P3_R; g = GAMUT_P3_G; b = GAMUT_P3_B; }\n else { r = GAMUT_709_R; g = GAMUT_709_G; b = GAMUT_709_B; }\n\n float2 center = D65_WHITE;\n float halfExtent = 0.50;\n\n float2 uv = uv0.xy / size;\n float2 xy;\n xy.x = center.x + (uv.x - 0.5) * 2.0 * halfExtent;\n xy.y = center.y - (uv.y - 0.5) * 2.0 * halfExtent;\n\n if (!PointInTriangle(xy, r, g, b))\n return float4(0, 0, 0, 1.0);\n\n float Y = Luminance / 80.0;\n float3 xyY_val = float3(xy.x, xy.y, Y);\n float3 xyz = xyYToXYZ(xyY_val);\n float3 rgb = XYZToScRGB(xyz);\n\n return float4(rgb, 1.0);\n}\n", "inputNames": [], "parameters": [ { @@ -102,7 +102,7 @@ "default": { "name": "OutputSize", "type": "float", - "value": 512 + "value": 1024 } }, {