diff --git a/.gitmodules b/.gitmodules index 7bed141dcb..db0fe8c6f7 100644 --- a/.gitmodules +++ b/.gitmodules @@ -8,6 +8,3 @@ path = extern/FidelityFX-SDK url = https://github.com/alandtse/FidelityFX-SDK-DX11 branch = optiscaler-build -[submodule "extern/cpp-mcp"] - path = extern/cpp-mcp - url = https://github.com/hkr04/cpp-mcp.git diff --git a/CMakeLists.txt b/CMakeLists.txt index a070df5f27..2af9f61c22 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -2,6 +2,19 @@ cmake_minimum_required(VERSION 4.2) cmake_policy(SET CMP0116 NEW) set(CMAKE_POLICY_WARNING_CMP0116 OFF) +# devbench bridge — declared before project() so the vcpkg toolchain (loaded at project()) +# sees the manifest-feature selection. The devbench-api dependency lives behind the +# `devbench-bridge` vcpkg feature, so -DDEVBENCH_BRIDGE=OFF actually drops it (not just the +# find_package/link/define below). +option( + DEVBENCH_BRIDGE + "Register the plugin's tools into the devbench test bench (requires the devbench-api port; runtime no-op when no devbench host is present)" + ON +) +if(DEVBENCH_BRIDGE) + list(APPEND VCPKG_MANIFEST_FEATURES "devbench-bridge") +endif() + project( # gersemi: ignore CommunityShaders @@ -97,12 +110,14 @@ find_package(efsw CONFIG REQUIRED) find_path(EXPRTK_INCLUDE_DIRS "exprtk.hpp" REQUIRED) find_package(Tracy CONFIG REQUIRED) find_package(directx-headers CONFIG REQUIRED) +if(DEVBENCH_BRIDGE) + find_package(devbench-api CONFIG REQUIRED) +endif() add_subdirectory(${CMAKE_SOURCE_DIR}/cmake/Streamline) find_path(DETOURS_INCLUDE_DIRS "detours/detours.h") find_library(DETOURS_LIBRARY detours REQUIRED) include(FidelityFX-SDK) -include(cpp-mcp) target_compile_definitions( ${PROJECT_NAME} @@ -147,13 +162,19 @@ target_link_libraries( unordered_dense::unordered_dense efsw::efsw Tracy::TracyClient - cpp-mcp Streamline d3d12.lib Microsoft::DirectX-Headers ${DETOURS_LIBRARY} ) +# devbench bridge: opt-out via -DDEVBENCH_BRIDGE=OFF. When off, the port isn't required +# and DevBenchBridge.cpp compiles to an empty Install(). +if(DEVBENCH_BRIDGE) + target_link_libraries(${PROJECT_NAME} PRIVATE DevBench::API) + target_compile_definitions(${PROJECT_NAME} PRIVATE DEVBENCH_BRIDGE_ENABLED) +endif() + # Some third-party libs (e.g. FFX backend) are built with /GL. # In RelWithDebInfo, align linker options to avoid LNK4075/LNK1218 when warnings are treated as errors. if(MSVC) diff --git a/CMakeUserPresets.json.template b/CMakeUserPresets.json.template index a316db4344..3a995a58f3 100644 --- a/CMakeUserPresets.json.template +++ b/CMakeUserPresets.json.template @@ -5,7 +5,8 @@ "name": "ALL-WITH-AUTO-DEPLOYMENT", "binaryDir": "${sourceDir}/build/ALL", "cacheVariables": { - "AUTO_PLUGIN_DEPLOYMENT": "ON" + "AUTO_PLUGIN_DEPLOYMENT": "ON", + "DEVBENCH_BRIDGE": "ON" }, "environment": { "CommunityShadersOutputDir": "F:/MySkyrimModpack/mods/CommunityShaders;F:/SteamLibrary/steamapps/common/SkyrimVR/Data;F:/SteamLibrary/steamapps/common/Skyrim Special Edition/Data" diff --git a/cmake/cpp-mcp.cmake b/cmake/cpp-mcp.cmake deleted file mode 100644 index e218957891..0000000000 --- a/cmake/cpp-mcp.cmake +++ /dev/null @@ -1,107 +0,0 @@ -# Build cpp-mcp (https://github.com/hkr04/cpp-mcp) from its vendored -# submodule as a static library target. Upstream has no install rules -# (PR #12 still open), so we drive its build ourselves — same pattern -# we use for FidelityFX-SDK and Streamline. -# -# Only the server-side translation units are compiled; the bundled -# stdio/SSE *client* implementations are intentionally omitted because -# we are exclusively a server. -# -# nlohmann_json ABI alignment: -# cpp-mcp vendors nlohmann_json 3.11.3 in extern/cpp-mcp/common/json.hpp, -# while vcpkg ships 3.12.0. Both versions wrap their public API in an -# ABI-versioned inline namespace (`nlohmann::json_abi_v3_11_3` vs -# `nlohmann::json_abi_v3_12_0`), so even though both files share the -# same include guard (INCLUDE_NLOHMANN_JSON_HPP_), the symbol names -# differ. If cpp-mcp's own TUs picked up the vendored copy and our -# consumers picked up vcpkg's, `mcp::server::set_capabilities` and -# `register_tool` would link-fail (LNK2001) with two different -# ABI-tagged signatures. -# -# Fix: patch mcp_message.h at configure time to use -# `#include ` instead of `#include "json.hpp"`. -# The patched copy is written to a build-tree mirror; the submodule -# stays clean. Both cpp-mcp's own compilation and every consumer -# then resolve to vcpkg's 3.12.0 → single ABI namespace, symbols -# match, linker happy. - -set(CPP_MCP_DIR "${CMAKE_SOURCE_DIR}/extern/cpp-mcp") -set(CPP_MCP_PATCHED_INC "${CMAKE_BINARY_DIR}/cpp-mcp-patched/include") - -if(NOT EXISTS "${CPP_MCP_DIR}/src/mcp_server.cpp") - message(FATAL_ERROR - "cpp-mcp submodule missing. Run:\n" - " git submodule update --init --recursive extern/cpp-mcp") -endif() - -find_package(Threads REQUIRED) -find_package(nlohmann_json CONFIG REQUIRED) - -# Patch mcp_message.h to use vcpkg nlohmann_json (see header comment). -# All other cpp-mcp headers are copied verbatim into the patched mirror -# so they live next to the patched header and find each other. -file(MAKE_DIRECTORY "${CPP_MCP_PATCHED_INC}") -file(GLOB _cpp_mcp_headers CONFIGURE_DEPENDS "${CPP_MCP_DIR}/include/*.h") -foreach(_hdr IN LISTS _cpp_mcp_headers) - get_filename_component(_name "${_hdr}" NAME) - file(READ "${_hdr}" _content) - if(_name STREQUAL "mcp_message.h") - # Fail fast if the expected include vanishes upstream — otherwise the - # ABI mismatch would silently come back and only surface as an LNK2001 - # well into the link step. - string(FIND "${_content}" "#include \"json.hpp\"" _json_inc_pos) - if(_json_inc_pos EQUAL -1) - message(FATAL_ERROR - "cpp-mcp: expected `#include \"json.hpp\"` in mcp_message.h " - "but did not find it. Upstream may have changed the include; " - "review cmake/cpp-mcp.cmake and adjust the patch (see header " - "comment for the ABI-alignment rationale).") - endif() - string(REPLACE - "#include \"json.hpp\"" - "#include " - _content "${_content}") - endif() - file(WRITE "${CPP_MCP_PATCHED_INC}/${_name}" "${_content}") -endforeach() - -add_library(cpp-mcp STATIC - "${CPP_MCP_DIR}/src/mcp_message.cpp" - "${CPP_MCP_DIR}/src/mcp_resource.cpp" - "${CPP_MCP_DIR}/src/mcp_server.cpp" - "${CPP_MCP_DIR}/src/mcp_tool.cpp" -) - -# Order matters: patched mirror first so its mcp_message.h wins over the -# submodule's. `common/` is still needed for httplib.h (no ABI issue -# there — it's not shared with any vcpkg dep). -target_include_directories(cpp-mcp - PUBLIC "${CPP_MCP_PATCHED_INC}" - "${CPP_MCP_DIR}/common" -) - -target_compile_features(cpp-mcp PUBLIC cxx_std_17) - -target_compile_definitions(cpp-mcp PUBLIC - MCP_MAX_SESSIONS=10 - MCP_SESSION_TIMEOUT=30 - # cpp-mcp's vendored cpp-httplib pulls in . Skyrim/CLib's - # transitive defaults to the legacy , which - # conflicts (redefinition of sockaddr, WSAData, etc.). Tell Windows - # headers to skip the legacy winsock so winsock2.h is the only one - # in the build. PUBLIC so it propagates to every TU that links - # cpp-mcp (including the PCH compilation of CommunityShaders). - _WINSOCKAPI_ -) - -target_link_libraries(cpp-mcp PUBLIC - Threads::Threads - nlohmann_json::nlohmann_json -) - -if(MSVC) - target_compile_options(cpp-mcp PRIVATE /utf-8 /bigobj /W0) - target_compile_definitions(cpp-mcp PRIVATE _CRT_SECURE_NO_WARNINGS) -endif() - -set_target_properties(cpp-mcp PROPERTIES FOLDER "extern") diff --git a/cmake/ports/devbench-api/README.md b/cmake/ports/devbench-api/README.md new file mode 100644 index 0000000000..0130574727 --- /dev/null +++ b/cmake/ports/devbench-api/README.md @@ -0,0 +1,42 @@ +# devbench-api vcpkg port + +Vendor the devbench cross-plugin API (`DevBenchAPI.h` + `.cpp`) into your SKSE plugin +via vcpkg — **do not copy the files into your tree** (they drift). + +## Consume (once devbench is published) + +`vcpkg.json`: + +```json +{ "dependencies": ["devbench-api"] } +``` + +`CMakeLists.txt`: + +```cmake +find_package(devbench-api CONFIG REQUIRED) +target_link_libraries(YourPlugin PRIVATE DevBench::API) +``` + +Then, after SKSE sends `kPostLoad`: + +```cpp +#include +if (auto* dvb = DevBenchAPI::GetDevBenchInterface001()) { + dvb->RegisterTool("yourmod.dothing", R"({"description":"...","inputSchema":{...}})", + &YourHandler, yourCtx); +} +``` + +Linking `DevBench::API` puts `DevBenchAPI.h` on the include path and compiles +`DevBenchAPI.cpp` (the messaging-handshake helper) into your plugin. The API glue is +**MIT** (`DevBenchAPI.LICENSE.txt`); the devbench plugin itself is GPL-3.0. + +## Pinning / bumping + +`portfile.cmake` is pinned to a concrete devbench commit via `vcpkg_from_github` +(`REF` + `SHA512`) — no placeholders to fill in; the overlay works as-is. To pull a +newer API revision, update `REF` to the new commit and replace `SHA512` with the value +vcpkg reports on the first (failed) build, or precompute it with +`vcpkg hash `. The API header is ABI-versioned, so a bump is only +needed to adopt a new interface revision. diff --git a/cmake/ports/devbench-api/devbench-api-config.cmake b/cmake/ports/devbench-api/devbench-api-config.cmake new file mode 100644 index 0000000000..126e9a8135 --- /dev/null +++ b/cmake/ports/devbench-api/devbench-api-config.cmake @@ -0,0 +1,13 @@ +get_filename_component(_DEVBENCH_API_DIR "${CMAKE_CURRENT_LIST_FILE}" PATH) + +# DevBench::API — header-only-ish target. The consumer gets the include dir and +# DevBenchAPI.cpp compiled into it (the consumer-side helper that fetches the +# interface via SKSE messaging). Link this, then call +# DevBenchAPI::GetDevBenchInterface001() after kPostLoad. +if(NOT TARGET DevBench::API) + add_library(DevBench::API INTERFACE IMPORTED) + set_target_properties(DevBench::API PROPERTIES + INTERFACE_INCLUDE_DIRECTORIES "${_DEVBENCH_API_DIR}/../../include" + INTERFACE_SOURCES "${_DEVBENCH_API_DIR}/src/DevBenchAPI.cpp" + ) +endif() diff --git a/cmake/ports/devbench-api/portfile.cmake b/cmake/ports/devbench-api/portfile.cmake new file mode 100644 index 0000000000..312cb8c632 --- /dev/null +++ b/cmake/ports/devbench-api/portfile.cmake @@ -0,0 +1,29 @@ +# devbench-api — header-only cross-plugin API for SKSE consumers. +# Installs the MIT API header + its companion .cpp (compiled into the consumer via the +# config's INTERFACE_SOURCES). +# +# Pinned to a published commit. devbench-api isn't in the official vcpkg registry, so +# consumers add this directory to VCPKG_OVERLAY_PORTS (see README). To ship a newer API +# revision, bump REF to the new commit and SHA512 to its archive hash. + +vcpkg_from_github( + OUT_SOURCE_PATH SOURCE_PATH + REPO alandtse/devbench + REF c75e7d2ea51f1de5c7469124955bff8d39694d78 + SHA512 1dfcdb838a67b181ab21dd7cbcc818459563364a519e9ab6b3bce0a2c1bd29b46ee160f10dbfc8ff488fb5ec74c22723defd692d620f0df0fe788b1244bfb7a4 + HEAD_REF main +) + +# MIT API glue → header to include/, source to share/ (referenced by the config target). +file(INSTALL "${SOURCE_PATH}/include/DevBenchAPI.h" + DESTINATION "${CURRENT_PACKAGES_DIR}/include") +file(INSTALL "${SOURCE_PATH}/include/DevBenchAPI.cpp" + DESTINATION "${CURRENT_PACKAGES_DIR}/share/${PORT}/src") + +# CMake package config — defines DevBench::API. +file(INSTALL "${CMAKE_CURRENT_LIST_DIR}/devbench-api-config.cmake" + DESTINATION "${CURRENT_PACKAGES_DIR}/share/${PORT}") + +# The API glue is MIT (not the GPL-3.0 plugin) — ship that as the port copyright. +file(INSTALL "${SOURCE_PATH}/include/DevBenchAPI.LICENSE.txt" + DESTINATION "${CURRENT_PACKAGES_DIR}/share/${PORT}" RENAME copyright) diff --git a/cmake/ports/devbench-api/vcpkg.json b/cmake/ports/devbench-api/vcpkg.json new file mode 100644 index 0000000000..89198d15c7 --- /dev/null +++ b/cmake/ports/devbench-api/vcpkg.json @@ -0,0 +1,7 @@ +{ + "name": "devbench-api", + "version": "1.0.0", + "description": "devbench cross-plugin API header for SKSE plugin developers. Register MCP/REST tools and emit events into the devbench host. MIT-licensed glue; the devbench plugin itself is GPL-3.0.", + "homepage": "https://github.com/alandtse/devbench", + "license": "MIT" +} diff --git a/extern/cpp-mcp b/extern/cpp-mcp deleted file mode 160000 index a0eb22c98d..0000000000 --- a/extern/cpp-mcp +++ /dev/null @@ -1 +0,0 @@ -Subproject commit a0eb22c98dbd8ce8b3ef69679310c1a038905c08 diff --git a/src/Features/RemoteControl.cpp b/src/Features/RemoteControl.cpp index bb512ab538..da8542aadf 100644 --- a/src/Features/RemoteControl.cpp +++ b/src/Features/RemoteControl.cpp @@ -1,59 +1,53 @@ -// Remote Control feature: hosts an in-process Model Context Protocol (MCP) -// server inside CommunityShaders.dll, letting AI assistants query and mutate -// runtime state for A/B testing. Off by default and loopback-only. +// Remote Control: status panel for the devbench bridge. // -// Transport: HTTP+SSE (Streamable HTTP, MCP 2025-03-26). -// Endpoint: http://:/mcp (modern, single endpoint) -// http://:/sse (legacy SSE, also exposed by cpp-mcp) +// The plugin's Model Context Protocol tools register into the external devbench host (the +// devbench SKSE plugin, https://github.com/alandtse/devbench) via DevBenchBridge +// (src/Features/RemoteControl/DevBenchBridge.cpp), exposed over both MCP and REST. This feature is a read-only +// panel: it reports whether devbench is present, what was registered, and the port +// devbench bound, so users can confirm the integration without leaving the game. #include "Features/RemoteControl.h" -#include "Features/PerformanceOverlay/ABTesting/ABTesting.h" -#include "Features/RenderDoc.h" -#include "Features/ScreenshotFeature.h" +#include "Features/RemoteControl/DevBenchBridge.h" #include "Globals.h" -#include "ShaderCompileStatus.h" -#include "State.h" +#include "Menu.h" #include -#include +#include -#include -#include -#include -#include -#include -#include +#include +#include -// cpp-mcp headers. Kept inside the .cpp only so the vendored httplib/json -// in extern/cpp-mcp/common don't leak into other translation units. -#include "mcp_server.h" -#include "mcp_tool.h" +using json = nlohmann::json; + +#ifdef DEVBENCH_BRIDGE_ENABLED +# include +#endif namespace { - // The control endpoint is intentionally loopback-only — exposing it off-host - // would let any networked client toggle features and dispatch captures. - // Only accept literal loopback IPs: on Windows the hosts file (or a - // hijacked resolver) can map "localhost" to a routable address, which would - // silently break the loopback-only contract. - bool IsLoopbackAddress(const std::string& host) - { - return host == "127.0.0.1" || host == "::1"; - } + // devbench writes the host port it bound to here on startup. We only read it for + // display; the bridge itself talks to devbench in-process via the C-ABI, not the port. + constexpr const char* kRuntimeJsonPath = "Data/SKSE/Plugins/devbench/runtime.json"; - void NormalizeBindAddress(std::string& host) + // Returns the bound port from devbench's runtime.json, or 0 if absent/unreadable. + int ReadDevBenchPort() { - if (!IsLoopbackAddress(host)) { - logger::warn("Remote Control: non-loopback bindAddress '{}' rejected; forcing 127.0.0.1", host); - host = "127.0.0.1"; + std::error_code ec; + if (!std::filesystem::exists(kRuntimeJsonPath, ec)) + return 0; + try { + std::ifstream in(kRuntimeJsonPath); + if (!in) + return 0; + json runtime = json::parse(in, nullptr, /*allow_exceptions=*/false); + if (runtime.is_discarded() || !runtime.is_object()) + return 0; + return runtime.value("port", 0); + } catch (...) { + return 0; // malformed runtime.json is non-fatal — just hide the port } } - - int ClampPort(int port) - { - return std::clamp(port, 1024, 65535); - } } RemoteControl* RemoteControl::GetSingleton() @@ -61,1038 +55,69 @@ RemoteControl* RemoteControl::GetSingleton() return &globals::features::remoteControl; } -RemoteControl::RemoteControl() = default; - -RemoteControl::~RemoteControl() -{ - StopServer(); -} - -void RemoteControl::Load() -{ - // Settings have already been read in by the time Load() fires. - if (settings.enabled) { - StartServer(); - } -} - -void RemoteControl::Reset() -{ - // No per-frame state to reset. -} - -void RemoteControl::LoadSettings(json& o_json) -{ - settings.enabled = o_json.value("enabled", false); - settings.port = ClampPort(o_json.value("port", 8910)); - settings.bindAddress = o_json.value("bindAddress", std::string("127.0.0.1")); - NormalizeBindAddress(settings.bindAddress); -} - -void RemoteControl::SaveSettings(json& o_json) -{ - o_json["enabled"] = settings.enabled; - o_json["port"] = settings.port; - o_json["bindAddress"] = settings.bindAddress; -} - -void RemoteControl::RestoreDefaultSettings() +void RemoteControl::DataLoaded() { - settings = Settings{}; + // Register the plugin's tools into the devbench host. This feature owns the install; it runs at + // DataLoaded rather than Load because devbench publishes its cross-plugin interface at + // kPostLoad — by DataLoaded it's ready. Inert (logged) when no host is present or the + // bridge was built disabled; idempotent on the devbench side (re-registering replaces). + DevBenchBridge::Install(); } void RemoteControl::DrawSettings() { + const auto& theme = Menu::GetSingleton()->GetTheme().StatusPalette; + ImGui::TextWrapped( - "Exposes Community Shaders over Model Context Protocol (MCP) so AI " - "assistants such as Claude Code can drive A/B testing, toggle " - "features, and trigger captures. Off by default. The endpoint is " - "loopback-only — any non-loopback bind address is rejected at load " - "and bind time."); + "Registers graphics-feature, inspect, capture, shader-cache, and settings tools " + "into the external devbench host so AI assistants (Claude Code, Cursor, etc.) can " + "toggle features, inspect engine state, trigger captures, and save/load settings " + "over MCP and REST. There is no in-game server — install the devbench SKSE plugin " + "to enable the integration."); ImGui::Spacing(); - const bool wasEnabled = settings.enabled; - if (ImGui::Checkbox("Enable MCP server", &settings.enabled)) { - if (settings.enabled && !wasEnabled) { - StartServer(); - } else if (!settings.enabled && wasEnabled) { - StopServer(); - } - } - - // Port + bind address can only be edited while the server is stopped. - ImGui::BeginDisabled(IsRunning()); - ImGui::InputInt("Port", &settings.port); - settings.port = std::clamp(settings.port, 1024, 65535); - ImGui::InputText("Bind address", &settings.bindAddress); - ImGui::EndDisabled(); - if (IsRunning()) { - ImGui::SameLine(); - ImGui::TextDisabled("(stop the server to edit)"); - } - - if (!lastError.empty()) { - ImGui::TextColored(ImVec4(1.0f, 0.5f, 0.4f, 1.0f), - "Server error: %s", lastError.c_str()); - } - - if (IsRunning()) { - ImGui::TextColored(ImVec4(0.4f, 0.9f, 0.5f, 1.0f), - "Listening on %s:%d", settings.bindAddress.c_str(), activePort); - } - - ImGui::Separator(); - ImGui::Text("Connect from an MCP client (Claude Code, Cursor, etc.):"); - - if (ImGui::Button("Copy MCP client config to clipboard")) { - ImGui::SetClipboardText(BuildClientConfig().c_str()); - } - ImGui::SameLine(); - ImGui::TextDisabled("(?)"); - if (ImGui::IsItemHovered()) { - ImGui::SetTooltip( - "Paste the JSON into your Claude Code settings under " - "\"mcpServers\". Other MCP hosts (Cursor, Continue) accept the " - "same shape."); - } - - if (ImGui::CollapsingHeader("Config preview")) { - const auto preview = BuildClientConfig(); - ImGui::PushTextWrapPos(); - ImGui::TextUnformatted(preview.c_str()); - ImGui::PopTextWrapPos(); - } - - ImGui::Separator(); - DrawClientsTable(); -} - -void RemoteControl::DrawClientsTable() -{ - // Snapshot under the lock to keep the listener-thread updates from - // racing the draw. The snapshot is small (a handful of sessions at most). - std::vector rows; - { - std::lock_guard lock(sessionMutex); - rows.reserve(sessions.size()); - for (const auto& [_, info] : sessions) { - rows.push_back(info); - } - } - - const std::string headerLabel = std::format("Connected clients ({})##rc-clients", rows.size()); - if (!ImGui::CollapsingHeader(headerLabel.c_str(), ImGuiTreeNodeFlags_DefaultOpen)) { - return; - } - - if (!IsRunning()) { - ImGui::TextDisabled("Server not running."); - return; - } - if (rows.empty()) { - ImGui::TextDisabled( - "No clients connected. Paste the config above into " - "your MCP host and run a tool to populate this table."); - return; - } - - constexpr ImGuiTableFlags flags = ImGuiTableFlags_Borders | ImGuiTableFlags_Resizable | - ImGuiTableFlags_RowBg | ImGuiTableFlags_Sortable | - ImGuiTableFlags_SortMulti | ImGuiTableFlags_ScrollY; - - enum ColumnId : ImGuiID - { - ColSession = 0, - ColConnected, - ColIdle, - ColRequests, - ColLastTool, - }; - - if (ImGui::BeginTable("##rc-clients-table", 5, flags, ImVec2(0.0f, 120.0f))) { - ImGui::TableSetupColumn("Session", ImGuiTableColumnFlags_DefaultSort, 0.0f, ColSession); - ImGui::TableSetupColumn("Connected", 0, 0.0f, ColConnected); - ImGui::TableSetupColumn("Idle for", 0, 0.0f, ColIdle); - ImGui::TableSetupColumn("Requests", 0, 0.0f, ColRequests); - ImGui::TableSetupColumn("Last tool", 0, 0.0f, ColLastTool); - ImGui::TableSetupScrollFreeze(0, 1); - ImGui::TableHeadersRow(); - - if (auto* sortSpecs = ImGui::TableGetSortSpecs(); sortSpecs && sortSpecs->SpecsCount > 0) { - std::sort(rows.begin(), rows.end(), - [&](const SessionInfo& a, const SessionInfo& b) { - for (int i = 0; i < sortSpecs->SpecsCount; ++i) { - const auto& spec = sortSpecs->Specs[i]; - const bool desc = spec.SortDirection == ImGuiSortDirection_Descending; - int cmp = 0; - switch (static_cast(spec.ColumnUserID)) { - case ColSession: - cmp = a.id.compare(b.id); - break; - case ColConnected: - cmp = a.connected < b.connected ? -1 : (a.connected > b.connected ? 1 : 0); - break; - case ColIdle: - cmp = a.lastSeen < b.lastSeen ? -1 : (a.lastSeen > b.lastSeen ? 1 : 0); - break; - case ColRequests: - cmp = a.requestCount < b.requestCount ? -1 : (a.requestCount > b.requestCount ? 1 : 0); - break; - case ColLastTool: - cmp = a.lastTool.compare(b.lastTool); - break; - } - if (cmp != 0) { - return desc ? cmp > 0 : cmp < 0; - } - } - return false; - }); +#ifdef DEVBENCH_BRIDGE_ENABLED + auto* dvb = DevBenchAPI::GetDevBenchInterface001(); + if (dvb) { + ImGui::TextColored(theme.SuccessColor, "devbench host present (build %u)", dvb->GetBuildNumber()); + + // Cache the port — runtime.json I/O + JSON parse every frame would hitch the UI while + // the panel is open. Refresh on a coarse interval (devbench may bind after the panel + // first opens, so re-read periodically rather than only once). QPC, not std::chrono. + static int cachedPort = -1; // -1 = not yet read + static LONGLONG lastReadQpc = 0; // ticks at last read + LARGE_INTEGER freq, nowQpc; + QueryPerformanceFrequency(&freq); + QueryPerformanceCounter(&nowQpc); + if (cachedPort < 0 || nowQpc.QuadPart - lastReadQpc > 2 * freq.QuadPart) { // > 2s + cachedPort = ReadDevBenchPort(); + lastReadQpc = nowQpc.QuadPart; } - - const auto now = std::chrono::system_clock::now(); - const auto formatRelative = [](std::chrono::seconds sec) -> std::string { - const auto s = sec.count(); - if (s < 60) { - return std::format("{}s ago", s); - } - if (s < 3600) { - return std::format("{}m {}s ago", s / 60, s % 60); - } - return std::format("{}h {}m ago", s / 3600, (s % 3600) / 60); - }; - - for (const auto& info : rows) { - ImGui::TableNextRow(); - const auto connectedSec = std::chrono::duration_cast(now - info.connected); - const auto idleSec = std::chrono::duration_cast(now - info.lastSeen); - - ImGui::TableSetColumnIndex(0); - ImGui::TextUnformatted(info.id.c_str()); - ImGui::TableSetColumnIndex(1); - ImGui::TextUnformatted(formatRelative(connectedSec).c_str()); - ImGui::TableSetColumnIndex(2); - ImGui::TextUnformatted(formatRelative(idleSec).c_str()); - ImGui::TableSetColumnIndex(3); - ImGui::Text("%llu", static_cast(info.requestCount)); - ImGui::TableSetColumnIndex(4); - ImGui::TextUnformatted(info.lastTool.empty() ? "(none)" : info.lastTool.c_str()); + if (cachedPort > 0) { + ImGui::Text("Host bound on port %d (from %s)", cachedPort, kRuntimeJsonPath); + } else { + ImGui::TextDisabled( + "Port unknown — devbench writes it to %s once it binds.", + kRuntimeJsonPath); } - - ImGui::EndTable(); + } else { + ImGui::TextColored(theme.Warning, + "devbench host not detected. Install the devbench SKSE plugin; " + "the tools register automatically once it is present."); } + ImGui::Separator(); + ImGui::TextUnformatted("Tools exposed through devbench:"); + ImGui::BulletText("openshaders.feature — list / get / set / reset / toggle features"); + ImGui::BulletText("openshaders.inspect — engine state and shader-cache status"); + ImGui::BulletText("openshaders.shadercache — clear / delete the compiled cache"); + ImGui::BulletText("openshaders.capture — RenderDoc / screenshot capture"); + ImGui::BulletText("openshaders.settings — save / load / reset the global config"); ImGui::TextDisabled( - "To force-disconnect all clients, toggle 'Enable MCP server' off and back on. " - "Per-session kick is not exposed by cpp-mcp's public API."); -} - -std::string RemoteControl::BuildClientConfig() const -{ - // Streamable HTTP transport per the MCP 2025-03-26 spec. Same shape works - // for Claude Code, Cursor, Continue, and other MCP hosts. - // IPv6 literals must be bracketed in a URL authority (RFC 3986 §3.2.2), - // so the IPv6 loopback "::1" becomes "[::1]". IPv4 / hostnames pass - // through verbatim. - const std::string hostInUrl = (settings.bindAddress.find(':') != std::string::npos) ? "[" + settings.bindAddress + "]" : settings.bindAddress; - const json cfg = { - { "mcpServers", - { { "community-shaders", - { - { "type", "http" }, - { "url", std::format("http://{}:{}/mcp", - hostInUrl, settings.port) }, - } } } } - }; - return cfg.dump(4); -} - -void RemoteControl::StartServer() -{ - if (server) { - return; - } - lastError.clear(); - - try { - // Re-validate at bind time — settings may have been touched via the UI - // or hot-reload since LoadSettings ran. - NormalizeBindAddress(settings.bindAddress); - settings.port = ClampPort(settings.port); - - mcp::server::configuration cfg; - cfg.host = settings.bindAddress; - cfg.port = settings.port; - cfg.name = "Community Shaders"; - cfg.version = "0.1.0"; - - server = std::make_unique(cfg); - server->set_server_info(cfg.name, cfg.version); - server->set_capabilities({ { "tools", mcp::json::object() } }); - server->set_instructions( - "This server exposes the Skyrim Community Shaders plugin. " - "Use the tools to inspect engine state for performance " - "investigation and A/B testing of graphics features."); - - RegisterTools(); - - // Drop a session from the bookkeeping map on disconnect. cpp-mcp - // dispatches this from its listener thread when the SSE/HTTP - // connection tears down. - server->register_session_cleanup("remote-control-session-tracker", - [this](const std::string& sessionId) { - DropSession(sessionId); - }); - - if (!server->start(false)) { // false = non-blocking - throw std::runtime_error("server.start() returned false"); - } - activePort = settings.port; - logger::info("Remote Control: MCP server listening on {}:{}", - settings.bindAddress, activePort); - } catch (const std::exception& e) { - lastError = e.what(); - logger::error("Remote Control: failed to start MCP server: {}", - e.what()); - server.reset(); - activePort = 0; - } -} - -void RemoteControl::StopServer() -{ - if (!server) { - return; - } - try { - server->stop(); - } catch (...) { - // best-effort on shutdown - } - server.reset(); - activePort = 0; - { - std::lock_guard lock(sessionMutex); - sessions.clear(); - } - logger::info("Remote Control: MCP server stopped"); -} - -void RemoteControl::RecordToolCall(const std::string& sessionId, const std::string& toolName) -{ - const auto now = std::chrono::system_clock::now(); - std::lock_guard lock(sessionMutex); - auto& info = sessions[sessionId]; - if (info.requestCount == 0) { - info.id = sessionId; - info.connected = now; - } - info.lastSeen = now; - info.requestCount += 1; - info.lastTool = toolName; -} - -void RemoteControl::DropSession(const std::string& sessionId) -{ - std::lock_guard lock(sessionMutex); - sessions.erase(sessionId); -} - -// Helper: wrap a payload string in the MCP tool-result content envelope -// (an array of typed content items). Tools return application data as the -// "text" field of a single content item; consumers typically parse it as -// JSON. -static mcp::json TextResult(std::string text) -{ - return mcp::json::array({ mcp::json{ - { "type", "text" }, - { "text", std::move(text) } } }); -} - -// Helper: emit an error result. Convention: a single text content item -// containing a JSON object with "error" + optional context fields, so -// callers always get parseable JSON whether the call succeeded or not. -static mcp::json ErrorResult(std::string_view message, mcp::json context = {}) -{ - mcp::json obj = { { "error", message } }; - if (!context.is_null()) { - obj.update(context); - } - return mcp::json::array({ mcp::json{ - { "type", "text" }, - { "text", obj.dump() } } }); -} - -void RemoteControl::RegisterTools() -{ - // Five tools, each semantically rich. Reads vs writes vs lifecycles are - // separated by tool; within each tool, kind/action discriminates the - // specific operation. See agentic-renderdoc's "Why this design" notes — - // fewer rich tools outperform expansive suites because the agent reads - // fewer descriptions and each description carries the operational - // expertise (timing, gotchas, verification routes). - RegisterInspectTool(); // reads (non-feature engine state) - RegisterFeatureTool(); // all feature ops (list/get/set/reset/toggle) - RegisterConsoleTool(); // Skyrim console passthrough - RegisterCaptureTool(); // frame capture (renderdoc/screenshot) - RegisterAbtestTool(); // A/B test lifecycle -} - -// Helper used by both inspect(kind="state") and (potentially) future tools. -static mcp::json EngineStateBlob() -{ - const uint frames = globals::state ? globals::state->frameCountAtomic.load(std::memory_order_relaxed) : 0u; - const bool vr = REL::Module::IsVR(); - return mcp::json({ - { "plugin", "CommunityShaders" }, - { "frame_count", frames }, - { "vr", vr }, - }); -} - -// Helper used by inspect(kind="shadercache"): runtime shader (re)compile -// status, built entirely from existing thread-safe ShaderCache accessors (no -// added state). Hot-reloading an .hlsl clears its variants and requeues them, -// so completedTasks advances once the recompile lands — poll it against a -// pre-deploy snapshot to know the new shader is live. A rising failedTasks / -// currentFailedCount means a (re)compile failed (otherwise invisible). -static mcp::json ShaderCacheBlob() -{ - const uint frames = globals::state ? globals::state->frameCountAtomic.load(std::memory_order_relaxed) : 0u; - const SIE::ShaderCompileStatus s = SIE::GetShaderCompileStatus(); - if (!s.valid) { - return mcp::json({ - { "plugin", "CommunityShaders" }, - { "frame_count", frames }, - { "error", "shaderCache unavailable" }, - }); - } - return mcp::json({ - { "plugin", "CommunityShaders" }, - { "frame_count", frames }, - { "compiling", s.compiling }, - { "completedTasks", s.completedTasks }, - { "totalTasks", s.totalTasks }, - { "failedTasks", s.failedTasks }, - { "currentFailedCount", s.currentFailedCount }, - }); -} - -// Helper used by feature(action="list") to build one entry per feature. -static mcp::json FeatureEntry(Feature* f) -{ - mcp::json entry({ - { "name", f->GetName() }, - { "shortName", f->GetShortName() }, - { "loaded", f->loaded }, - { "version", f->version }, - { "category", std::string(f->GetCategory()) }, - { "isCore", f->IsCore() }, - { "supportsVR", f->SupportsVR() }, - { "inMenu", f->IsInMenu() }, - }); - - // Inline restart-gated metadata so `list` is the single tool that answers - // "what features exist", "which fields need a restart to apply", and - // "is anything currently pending". Each entry's `pending` is true when - // the live setting differs from the boot-latched value. - const auto fields = f->GetRestartRequiredFields(); - if (!fields.empty()) { - mcp::json restartFields = mcp::json::array(); - const auto* liveBase = reinterpret_cast(f->GetSettingsBlob()); - const size_t liveSize = f->GetSettingsBlobSize(); - for (const auto& field : fields) { - bool pending = false; - if (liveBase && field.jsonKey && field.size != 0 && - field.offset + field.size <= liveSize) { - const void* boot = f->GetBootValue(field.jsonKey); - if (boot && - std::memcmp(boot, liveBase + field.offset, field.size) != 0) { - pending = true; - } - } - restartFields.push_back(mcp::json({ - { "key", field.jsonKey ? field.jsonKey : "" }, - { "label", field.label ? field.label : "" }, - { "pending", pending }, - })); - } - entry["restartFields"] = restartFields; - } - - return entry; -} - -void RemoteControl::RegisterInspectTool() -{ - // Single read endpoint for non-feature engine state. Kind-discriminated - // so future engine reads (weather, cell, player, render targets) extend - // the same tool rather than spawning new top-level reads. For feature - // reads (list, get settings), use the `feature` tool with the - // corresponding action. - const auto tool = mcp::tool_builder("inspect") - .with_description( - "Read non-feature engine state. Kind-dispatched; the " - "response is always a JSON object delivered as the " - "text of a single content item.\n\n" - "Kinds:\n" - " state — { plugin, frame_count, vr }. Frame counter " - "monotonically increases each render tick; use as a " - "ground truth for verifying that deferred operations " - "(see `console`) have had time to run.\n" - " shadercache — { plugin, compiling, completedTasks, " - "totalTasks, failedTasks, currentFailedCount, " - "frame_count }. Poll completedTasks against a " - "pre-deploy snapshot to confirm a hot-reloaded " - "shader finished recompiling; a rising failedTasks " - "/ currentFailedCount means a compile failed.\n\n" - "For feature reads (enumerate / settings), use the " - "`feature` tool with action='list' or 'get'.") - .with_string_param("kind", - "'state' or 'shadercache'. New kinds will be added here " - "rather than as new tools.") - .build(); - server->register_tool(tool, - [this](const mcp::json& params, const std::string& session_id) -> mcp::json { - RecordToolCall(session_id, "inspect"); - const std::string kind = params.value("kind", std::string{}); - if (kind.empty()) { - return ErrorResult("missing required parameter 'kind'"); - } - if (kind == "state") { - return TextResult(EngineStateBlob().dump()); - } - if (kind == "shadercache") { - return TextResult(ShaderCacheBlob().dump()); - } - return ErrorResult("unknown kind", - { { "kind", kind }, - { "supported", mcp::json::array({ "state", "shadercache" }) } }); - }); -} - -void RemoteControl::RegisterFeatureTool() -{ - // One tool for all graphics-feature operations. Action-dispatched so the - // agent has a single description that documents the full feature - // vocabulary plus the gotchas across all five operations (silent no-op - // for missing overrides, listener-thread caveats, etc). - const auto tool = mcp::tool_builder("feature") - .with_description( - "All graphics-feature operations — enumerate, " - "inspect settings, mutate settings, restore defaults, " - "toggle on/off. Action-dispatched; each action takes " - "the parameters listed below.\n\n" - "Actions:\n" - " list — no other params. Returns a JSON array; " - "each entry has { name, shortName, loaded, version, " - "category, isCore, supportsVR, inMenu }. Features " - "with restart-gated settings also include " - "`restartFields: [{ key, label, pending }]` — " - "`pending=true` means the user has staged a change " - "that won't take effect until the next launch.\n" - " get — params: shortName. Returns the " - "Feature::SaveSettings(json) blob. May return null " - "if the feature has no SaveSettings/LoadSettings " - "override (e.g. LightLimitFix); set/reset will " - "silently no-op for these.\n" - " set — params: shortName, settings (object). " - "Calls Feature::LoadSettings on the listener thread. " - "Safe for value-assigning LoadSettings (the common " - "case) and for features that flip a recompileFlag " - "(ScreenSpaceGI, DynamicCubemaps) — the render loop " - "picks them up on the next frame. Settings that " - "synchronously rebuild GPU resources would race; " - "none in-tree currently do.\n" - " reset — params: shortName. Calls " - "Feature::RestoreDefaultSettings(). Distinct from " - "set({}) because RestoreDefaultSettings is " - "feature-specific reset logic (may release/recreate " - "state).\n" - " toggle — params: shortName, enabled (boolean). " - "Flips Feature::loaded. Disabled features are " - "skipped by ForEachLoadedFeature so their per-frame " - "rendering work doesn't run. GPU resources allocated " - "in SetupResources are NOT freed — A/B perf/quality, " - "not memory reclaim.\n\n" - "A/B testing pattern:\n" - " 1. feature(action='get', shortName='Skylighting') → snapshot\n" - " 2. feature(action='reset', shortName='Skylighting') → defaults\n" - " 3. capture + tracy capture → measure\n" - " 4. feature(action='set', shortName='Skylighting', settings=) → restore\n\n" - "Gotchas:\n" - " • Some features have no SaveSettings/LoadSettings " - "override. `get` returns null; `set` and `reset` " - "claim success but don't change anything. Confirmed " - "case: LightLimitFix.\n" - " • toggle keeps GPU resources alive. If a feature " - "still affects rendering after `enabled=false`, it " - "has a hook that isn't gated on `loaded` — file an " - "issue with the shortName.") - .with_string_param("action", - "One of: 'list', 'get', 'set', 'reset', 'toggle'.") - .with_string_param("shortName", - "Required for all actions except 'list'. From the " - "list response.", - /*required=*/false) - .with_object_param("settings", - "Required for action='set'. Shape that matches what " - "action='get' returned for the same feature.", - mcp::json::object(), - /*required=*/false) - .with_boolean_param("enabled", - "Required for action='toggle'.", - /*required=*/false) - .build(); - server->register_tool(tool, - [this](const mcp::json& params, const std::string& session_id) -> mcp::json { - RecordToolCall(session_id, "feature"); - const std::string action = params.value("action", std::string{}); - if (action.empty()) { - return ErrorResult("missing required parameter 'action'"); - } - - if (action == "list") { - mcp::json features = mcp::json::array(); - for (auto* f : Feature::GetFeatureList()) { - features.push_back(FeatureEntry(f)); - } - return TextResult(features.dump()); - } - - const std::string shortName = params.value("shortName", std::string{}); - - if (shortName.empty()) { - return ErrorResult("missing required parameter 'shortName'", - { { "action", action } }); - } - - if (action == "toggle") { - if (!params.contains("enabled") || !params["enabled"].is_boolean()) { - return ErrorResult("missing required boolean parameter 'enabled'"); - } - const bool desired = params["enabled"].get(); - // FindFeatureByShortName filters on loaded==true so it can't - // help re-enable; walk the full list ourselves. - Feature* target = nullptr; - for (auto* f : Feature::GetFeatureList()) { - if (f->GetShortName() == shortName) { - target = f; - break; - } - } - if (!target) { - return ErrorResult("feature not found", - { { "shortName", shortName } }); - } - // Marshal the write onto the main/render thread. Feature::loaded - // is read every frame by Feature::ForEachLoadedFeature without - // synchronization, so writing it directly from the MCP listener - // thread is a data race. AddTask runs the closure on the next - // tick. - auto* task = SKSE::GetTaskInterface(); - if (!task) { - return ErrorResult("SKSE TaskInterface unavailable"); - } - const bool previous = target->loaded; - const uint enqueuedFrame = globals::state ? globals::state->frameCountAtomic.load(std::memory_order_relaxed) : 0u; - task->AddTask([target, desired, shortName]() { - target->loaded = desired; - logger::info("Remote Control: feature(toggle, {}, {}) applied", - shortName, desired); - }); - return TextResult(mcp::json({ - { "action", "toggle" }, - { "shortName", shortName }, - { "previous", previous }, - { "requested", desired }, - { "queued", true }, - { "enqueued_at_frame", enqueuedFrame }, - }) - .dump()); - } - - auto* feature = Feature::FindFeatureByShortName(shortName); - if (!feature) { - return ErrorResult("feature not found or not loaded", - { { "shortName", shortName } }); - } - - if (action == "get") { - // SaveSettings uses nlohmann::json (unordered). Keep the - // intermediate value as plain json and dump as a string so - // we don't have to round-trip through mcp::json's ordered map. - ::json blob; - feature->SaveSettings(blob); - return TextResult(blob.dump()); - } - if (action == "set") { - if (!params.contains("settings") || !params["settings"].is_object()) { - return ErrorResult("missing required object parameter 'settings'"); - } - ::json blob; - try { - blob = ::json::parse(params["settings"].dump()); - } catch (const std::exception& e) { - return ErrorResult("settings is not valid JSON", - { { "detail", e.what() } }); - } - // Marshal LoadSettings onto the main thread. Many features - // mutate UI/render-thread-visible state inside LoadSettings - // (palettes, cached textures, settings JSON read elsewhere), - // so calling it from the MCP listener thread is racy. - auto* task = SKSE::GetTaskInterface(); - if (!task) { - return ErrorResult("SKSE TaskInterface unavailable"); - } - const uint enqueuedFrame = globals::state ? globals::state->frameCountAtomic.load(std::memory_order_relaxed) : 0u; - task->AddTask([feature, blob, shortName]() mutable { - try { - feature->LoadSettings(blob); - logger::info("Remote Control: feature(set, {}) applied", shortName); - } catch (const std::exception& e) { - logger::error("Remote Control: feature(set, {}) LoadSettings threw: {}", - shortName, e.what()); - } - }); - return TextResult(mcp::json({ - { "action", "set" }, - { "shortName", shortName }, - { "queued", true }, - { "enqueued_at_frame", enqueuedFrame }, - }) - .dump()); - } - if (action == "reset") { - // Same marshaling rationale as feature(set): RestoreDefaultSettings - // touches state that the render/UI threads read concurrently. - auto* task = SKSE::GetTaskInterface(); - if (!task) { - return ErrorResult("SKSE TaskInterface unavailable"); - } - const uint enqueuedFrame = globals::state ? globals::state->frameCountAtomic.load(std::memory_order_relaxed) : 0u; - task->AddTask([feature, shortName]() { - try { - feature->RestoreDefaultSettings(); - logger::info("Remote Control: feature(reset, {}) applied", shortName); - } catch (const std::exception& e) { - logger::error("Remote Control: feature(reset, {}) RestoreDefaultSettings threw: {}", - shortName, e.what()); - } - }); - return TextResult(mcp::json({ - { "action", "reset" }, - { "shortName", shortName }, - { "queued", true }, - { "enqueued_at_frame", enqueuedFrame }, - }) - .dump()); - } - - return ErrorResult("unknown action", - { { "action", action }, - { "supported", mcp::json::array({ "list", "get", "set", "reset", "toggle" }) } }); - }); -} - -void RemoteControl::RegisterAbtestTool() -{ - // Single tool for the entire A/B testing lifecycle. Action-dispatched - // rather than spawning start_abtest / stop_abtest / get_abtest_results - // / clear_abtest_snapshots / set_abtest_interval — fewer richer tools. - const auto tool = mcp::tool_builder("abtest") - .with_description( - "Drive the built-in A/B testing harness " - "(features/Performance Overlay/ABTesting). The " - "harness rotates between a USER configuration " - "(your current settings) and a TEST configuration " - "(typically a preset under test) on a fixed " - "interval, snapshots both in memory to avoid disk " - "I/O during swaps, and aggregates per-variant " - "frame timing so you can compare quality and perf.\n\n" - "Actions:\n" - " status — return enabled, usingTestConfig, " - "interval, hasCachedSnapshots.\n" - " start — Enable() the manager (begin rotating). " - "Optional `interval` parameter (seconds) is applied " - "first if provided.\n" - " stop — Disable() the manager. Snapshots are " - "retained.\n" - " clear — ClearCachedSnapshots(). Use to reset " - "before a fresh comparison.\n" - " diff — return the per-key diff list " - "(GetConfigDiffEntries) so callers know which " - "settings the rotation is actually toggling.\n\n" - "Setup of the TEST config itself lives in the " - "Performance Overlay UI — this tool only drives " - "the lifecycle, not the test-config authoring.") - .with_string_param("action", - "'status', 'start', 'stop', 'clear', or 'diff'.") - .with_number_param("interval", - "Seconds per variant when action='start'. " - "Default 0 (no change).", - /*required=*/false) - .build(); - server->register_tool(tool, - [this](const mcp::json& params, const std::string& session_id) -> mcp::json { - RecordToolCall(session_id, "abtest"); - const std::string action = params.value("action", std::string{}); - if (action.empty()) { - return ErrorResult("missing required parameter 'action'"); - } - auto* mgr = ABTestingManager::GetSingleton(); - if (!mgr) { - return ErrorResult("ABTestingManager singleton unavailable"); - } - - const auto statusBlob = [&]() { - return mcp::json({ - { "enabled", mgr->IsEnabled() }, - { "usingTestConfig", mgr->IsUsingTestConfig() }, - { "interval", mgr->GetTestInterval() }, - { "hasCachedSnapshots", mgr->HasCachedSnapshots() }, - }); - }; - - if (action == "status") { - // Read-only — safe from the listener thread; the only state we - // touch is the manager's atomic-ish status getters. - return TextResult(statusBlob().dump()); - } - - // Lifecycle actions (start/stop/clear) marshal onto the main thread: - // Enable/Disable swap configs via State::Load → JSON, and Menu::Load - // touches settings the menu/render thread also reads. Doing that - // from the listener thread is a race against the next frame's UI. - auto* task = SKSE::GetTaskInterface(); - if (!task) { - return ErrorResult("SKSE TaskInterface unavailable"); - } - const uint enqueuedFrame = globals::state ? globals::state->frameCountAtomic.load(std::memory_order_relaxed) : 0u; - const auto queuedResult = [&](const std::string& act) { - auto blob = statusBlob(); - blob["action"] = act; - blob["queued"] = true; - blob["enqueued_at_frame"] = enqueuedFrame; - return TextResult(blob.dump()); - }; - - if (action == "start") { - std::optional interval; - if (params.contains("interval") && params["interval"].is_number()) { - const auto secs = params["interval"].get(); - if (secs > 0) { - interval = static_cast(secs); - } - } - task->AddTask([mgr, interval]() { - if (interval) { - mgr->SetTestInterval(*interval); - } - mgr->Enable(); - logger::info("Remote Control: abtest(start) applied"); - }); - return queuedResult("start"); - } - if (action == "stop") { - task->AddTask([mgr]() { - mgr->Disable(); - logger::info("Remote Control: abtest(stop) applied"); - }); - return queuedResult("stop"); - } - if (action == "clear") { - task->AddTask([mgr]() { - mgr->ClearCachedSnapshots(); - logger::info("Remote Control: abtest(clear) applied"); - }); - return queuedResult("clear"); - } - if (action == "diff") { - mcp::json entries = mcp::json::array(); - for (const auto& entry : mgr->GetConfigDiffEntries()) { - // SettingsDiffEntry uses generic a/b labels (see - // Utils/FileSystem.h). For A/B testing semantics here, - // `a` is USER and `b` is TEST. - entries.push_back({ - { "path", entry.path }, - { "userValue", entry.aValue }, - { "testValue", entry.bValue }, - }); - } - return TextResult(mcp::json({ - { "hasCachedSnapshots", mgr->HasCachedSnapshots() }, - { "entries", std::move(entries) }, - }) - .dump()); - } - return ErrorResult("unknown action", - { { "action", action }, - { "supported", mcp::json::array({ "status", "start", "stop", "clear", "diff" }) } }); - }); -} - -void RemoteControl::RegisterCaptureTool() -{ - // One tool for all frame-capture kinds, kind-dispatched. Adding new - // capture types later (e.g. tracy snapshot, video clip) extends this - // tool's `kind` enum rather than spawning new top-level tools. - const auto tool = mcp::tool_builder("capture") - .with_description( - "Trigger a frame capture on the next render. Kind-" - "dispatched so all capture flavors live behind one " - "tool — see the agentic-renderdoc design notes.\n\n" - "Supported kinds:\n" - " renderdoc — RenderDoc multi-frame capture via " - "the in-application API. Honors the `frames` " - "parameter (default 1, max 120). RenderDoc must " - "be attached or the in-app DLL loaded; check " - "feature(action='list') for RenderDoc loaded=true. Output " - "lands in RenderDoc's configured captures dir.\n" - " screenshot — Lossless screenshot via the " - "Screenshot feature's non-blocking capture path. " - "The `frames` parameter is ignored. Output lands " - "in the game's Screenshots/ folder.\n\n" - "Fire-and-forget: the trigger flag is set " - "immediately and the render loop consumes it on " - "the next frame. No artifact path is returned " - "synchronously — for renderdoc, inspect the " - "captures directory; for screenshots, watch the " - "Screenshots folder.") - .with_string_param("kind", - "'renderdoc' or 'screenshot'.") - .with_number_param("frames", - "RenderDoc only: number of consecutive frames to " - "capture (1-120). Default 1. Ignored for " - "screenshot.", - /*required=*/false) - .build(); - server->register_tool(tool, - [this](const mcp::json& params, const std::string& session_id) -> mcp::json { - RecordToolCall(session_id, "capture"); - const std::string kind = params.value("kind", std::string{}); - if (kind.empty()) { - return ErrorResult("missing required parameter 'kind'"); - } - const uint enqueuedFrame = globals::state ? globals::state->frameCountAtomic.load(std::memory_order_relaxed) : 0u; - - if (kind == "renderdoc") { - auto* renderDoc = &globals::features::renderDoc; - if (!renderDoc->loaded) { - return ErrorResult("RenderDoc feature is not loaded", - { { "hint", "feature(action='list') shows RenderDoc.loaded" } }); - } - if (!renderDoc->IsAvailable()) { - return ErrorResult( - "RenderDoc API not available — attach RenderDoc or " - "load the in-app DLL"); - } - uint32_t frameCount = 1; - if (params.contains("frames") && params["frames"].is_number()) { - const auto raw = params["frames"].get(); - frameCount = static_cast(std::clamp(raw, 1, 120)); - } - if (frameCount == 1) { - renderDoc->TriggerCapture(); - } else { - renderDoc->TriggerMultiFrameCapture(frameCount); - } - logger::info("Remote Control: capture(renderdoc, {}) at frame {}", - frameCount, enqueuedFrame); - return TextResult(mcp::json({ - { "queued", true }, - { "kind", "renderdoc" }, - { "frames", frameCount }, - { "enqueued_at_frame", enqueuedFrame }, - }) - .dump()); - } - - if (kind == "screenshot") { - auto* shot = &globals::features::screenshotFeature; - if (!shot->loaded) { - return ErrorResult("Screenshot feature is not loaded"); - } - shot->captureRequested.store(true, std::memory_order_release); - logger::info("Remote Control: capture(screenshot) at frame {}", - enqueuedFrame); - return TextResult(mcp::json({ - { "queued", true }, - { "kind", "screenshot" }, - { "enqueued_at_frame", enqueuedFrame }, - }) - .dump()); - } - - return ErrorResult("unknown kind", - { { "kind", kind }, - { "supported", mcp::json::array({ "renderdoc", "screenshot" }) } }); - }); -} - -void RemoteControl::RegisterConsoleTool() -{ - // Singular tool for the entire console concern. Future console-related - // capabilities (history readout, command lookup, etc.) get added as - // optional parameters / additional response fields here rather than as - // separate tools — per the "fewer, semantically rich tools" philosophy. - const auto tool = mcp::tool_builder("console") - .with_description( - "Execute a Skyrim console command. Fire-and-forget: " - "the command is queued onto the main game thread via " - "SKSE's TaskInterface and runs on the next tick. " - "Returns immediately with the frame counter at the " - "moment of enqueue.\n\n" - "RE::Console::ExecuteCommand is `void` — there is " - "no per-command return value. RE::ConsoleLog is a " - "shared sink (engine + every SKSE plugin) with no " - "command-to-output correlation, and many useful " - "commands are silent (tcl, tfc, tg, tm, tlb…), so " - "scraping console output is unreliable and " - "intentionally NOT exposed.\n\n" - "To verify a state change, poll inspect(kind='state') " - "until frame_count > enqueued_at_frame (at least one tick " - "elapsed), then observe via side channels: tracy " - "captures for perf-affecting changes, " - "capture(kind='renderdoc'|'screenshot') for visual " - "confirmation, or future feature-specific get_* " - "tools that read RE:: state directly.\n\n" - "Common A/B-relevant commands:\n" - " tcl — toggle player collision\n" - " tfc [1] — free camera (1 = pause game)\n" - " tg — toggle grass\n" - " tm — toggle menus / HUD\n" - " tll <0..15> — toggle land LOD level\n" - " setweather — force weather (persistent)\n" - " fw — force weather (temporary)\n" - " coc — teleport to cell\n" - " set timescale to N — game-time multiplier\n") - .with_string_param("command", - "The console command, exactly as typed after the ~ key.") - .build(); - server->register_tool(tool, - [this](const mcp::json& params, const std::string& session_id) -> mcp::json { - RecordToolCall(session_id, "console"); - std::string command = params.value("command", std::string{}); - if (command.empty()) { - return ErrorResult("missing required parameter 'command'"); - } - auto* task = SKSE::GetTaskInterface(); - if (!task) { - return ErrorResult("SKSE TaskInterface unavailable"); - } - const uint enqueuedFrame = globals::state ? globals::state->frameCountAtomic.load(std::memory_order_relaxed) : 0u; - // Capture by value so the string outlives this lambda's scope. - task->AddTask([command]() { - RE::Console::ExecuteCommand(command.c_str()); - }); - logger::info("Remote Control: console({}) queued at frame {}", - command, enqueuedFrame); - return TextResult(mcp::json({ - { "queued", true }, - { "command", std::move(command) }, - { "enqueued_at_frame", enqueuedFrame }, - }) - .dump()); - }); + "Note: the console tool is provided by devbench itself, not this plugin."); +#else + ImGui::TextColored(theme.Warning, + "This build was compiled without the devbench bridge " + "(DEVBENCH_BRIDGE=OFF). No tools are registered."); +#endif } diff --git a/src/Features/RemoteControl.h b/src/Features/RemoteControl.h index 0c88ad8107..bf223e7ef3 100644 --- a/src/Features/RemoteControl.h +++ b/src/Features/RemoteControl.h @@ -2,117 +2,45 @@ #include "Feature.h" -#include -#include -#include -#include -#include #include -#include - -using json = nlohmann::json; - -// Forward declare cpp-mcp types so we don't leak its vendored -// httplib / json headers into consumers of this header. -namespace mcp -{ - class server; - struct tool; - // cpp-mcp's tool_handler is std::function - // where `json` is an alias for ordered_json — that can't be forward-declared - // cleanly without dragging the full vendored nlohmann/json header into this - // public header. Tool registration therefore stays in the .cpp where the - // real signature is in scope; only opaque pointers are exposed here. -} +// Status panel for the devbench bridge. The plugin's tools are registered into the external +// devbench host via DevBenchBridge (see src/Features/RemoteControl/DevBenchBridge.cpp); this feature surfaces, in the +// in-game menu, whether devbench is present, what was registered, and which port the host bound. class RemoteControl : public Feature { public: static RemoteControl* GetSingleton(); - // Feature overrides — see Feature.h for contracts. + // Feature overrides — see Feature.h for contracts. Only members that differ from the base + // defaults are overridden (the feature has no settings, shaders, or per-frame state). std::string GetName() override { return "Remote Control"; } std::string GetShortName() override { return "RemoteControl"; } std::string_view GetCategory() const override { return FeatureCategories::kUtility; } - bool IsCore() const override { return true; } - bool IsInMenu() const override { return true; } bool SupportsVR() override { return true; } - std::string_view GetShaderDefineName() override { return ""; } - bool HasShaderDefine(RE::BSShader::Type) override { return false; } std::pair> GetFeatureSummary() override { return { - "Expose Community Shaders to AI assistants over Model Context Protocol (MCP).", + "Expose Open Shaders to AI assistants through the external devbench host.", { - "Loopback-only JSON-RPC server, off by default", - "Pair with Claude Code / Cursor / Continue for A/B testing", - "One-click clipboard copy of MCP client config", + "Registers feature, inspect, shadercache, capture, and settings tools", + "Drivable over MCP and REST from the shared devbench bench", + "No in-game server — install the devbench plugin to enable", } }; } - // Lifecycle - void Load() override; - void Reset() override; - - // Settings persistence + // The bridge installs at DataLoaded (not Load): Load runs during SKSEPluginLoad, before + // devbench's kPostLoad init, so its cross-plugin interface isn't ready yet. + void DataLoaded() override; void DrawSettings() override; - void RestoreDefaultSettings() override; - void LoadSettings(json& o_json) override; - void SaveSettings(json& o_json) override; - - struct Settings - { - bool enabled = false; // opt-in - int port = 8910; // arbitrary high port - std::string bindAddress = "127.0.0.1"; // loopback by default - } settings; - RemoteControl(); - ~RemoteControl(); + RemoteControl() = default; + ~RemoteControl() = default; RemoteControl(const RemoteControl&) = delete; RemoteControl& operator=(const RemoteControl&) = delete; RemoteControl(RemoteControl&&) = delete; RemoteControl& operator=(RemoteControl&&) = delete; - - // Session bookkeeping for the ImGui "Connected clients" table. - // Updated on every tool invocation (listener thread) and on session - // cleanup (cpp-mcp callback). Read from the main thread when drawing. - struct SessionInfo - { - std::string id; - std::chrono::system_clock::time_point connected; - std::chrono::system_clock::time_point lastSeen; - uint64_t requestCount = 0; - std::string lastTool; - }; - -private: - void StartServer(); - void StopServer(); - bool IsRunning() const noexcept { return server != nullptr; } - std::string BuildClientConfig() const; - void RegisterTools(); - void RegisterInspectTool(); - void RegisterFeatureTool(); - void RegisterConsoleTool(); - void RegisterCaptureTool(); - void RegisterAbtestTool(); - - // Records a tool invocation against the per-session table. - // Safe to call from the cpp-mcp listener thread. - void RecordToolCall(const std::string& sessionId, const std::string& toolName); - // Drops a session from the table on disconnect. - void DropSession(const std::string& sessionId); - // Draws the connected-clients ImGui table. - void DrawClientsTable(); - - std::unique_ptr server; - int activePort = 0; - std::string lastError; - - mutable std::mutex sessionMutex; - std::unordered_map sessions; }; diff --git a/src/Features/RemoteControl/DevBenchBridge.cpp b/src/Features/RemoteControl/DevBenchBridge.cpp new file mode 100644 index 0000000000..bbda35d0af --- /dev/null +++ b/src/Features/RemoteControl/DevBenchBridge.cpp @@ -0,0 +1,533 @@ +#include "Features/RemoteControl/DevBenchBridge.h" + +// Registers our tools into the devbench test bench over its C-ABI. Gated by +// DEVBENCH_BRIDGE_ENABLED (set by CMake when the devbench-api port is available); +// otherwise this file compiles to an empty Install(). Inert at runtime when no +// devbench plugin is present (GetDevBenchInterface001() returns null). +// +// The openshaders.* tools below expose Open Shaders' graphics-feature, inspect, +// capture, shadercache, and settings operations through the single devbench +// host over both MCP and REST. Each is namespaced to avoid collisions in devbench's +// shared registry; the action / kind / inputSchema shapes are stable so clients can +// rely on them. + +#ifdef DEVBENCH_BRIDGE_ENABLED + +# include "Feature.h" +# include "Features/RenderDoc.h" +# include "Features/ScreenshotFeature.h" +# include "Globals.h" +# include "ShaderCache.h" +# include "State.h" + +# include +# include + +# include +# include +# include +# include +# include +# include +# include +# include + +namespace +{ + using json = nlohmann::json; + + // Current render frame, used as a coarse "enqueued at" stamp so callers can poll + // inspect(kind=state) until frame_count advances past it (i.e. a queued main-thread + // task has had at least one tick to run). Safe from any thread (atomic load). + uint EnqueuedFrame() + { + return globals::state ? globals::state->frameCountAtomic.load(std::memory_order_relaxed) : 0u; + } + + // Shared C-ABI handler body. The whole request — parse, dispatch, dump — is wrapped + // so NO exception ever crosses the DLL boundary, and a_write is called exactly once. + // `a_build` is a plain function pointer (no captures) so this composes with the + // captureless-handler contract devbench requires. JSON strings only across the ABI. + void RunHandler(json (*a_build)(const json&), const char* a_argsJson, void* a_sink, DevBenchAPI::WriteFn a_write) + { + json out; + try { + json args = json::object(); + if (a_argsJson && *a_argsJson) + args = json::parse(a_argsJson); // throws on malformed input + if (!args.is_object()) + throw std::runtime_error("arguments must be a JSON object"); + out = a_build(args); + } catch (const std::exception& e) { + out = json{ { "error", "invalid request" }, { "detail", e.what() } }; + } catch (...) { + out = json{ { "error", "unknown handler error" } }; + } + const std::string dumped = out.dump(); + a_write(a_sink, dumped.c_str()); + } + + // Run a task on the main thread and return its result. Bridge handlers run on devbench's + // listener thread; work that touches state the render/UI thread mutates (A/B fields, JSON + // snapshots, feature settings, capture triggers) must marshal or it races. Blocks the + // handler briefly, bounded so a stalled main thread (e.g. mid-load) can't hang it. + // + // The body may have SIDE EFFECTS (set/reset apply settings; capture triggers a frame), so a + // `cancelled` flag is checked at task entry: if we already gave up waiting, the task skips + // the body rather than mutating state after we reported a timeout. shared_ptr state so a + // task that runs after we return doesn't dangle. (Best-effort: a task that starts exactly at + // the timeout boundary can still run — the flag eliminates the common stalled-thread case.) + json RunOnMainThread(std::function a_run) + { + auto* task = SKSE::GetTaskInterface(); + if (!task) + return json{ { "error", "SKSE task interface unavailable" } }; + auto prom = std::make_shared>(); + auto cancelled = std::make_shared>(false); + auto fut = prom->get_future(); + task->AddTask([prom, cancelled, run = std::move(a_run)]() { + if (cancelled->load(std::memory_order_acquire)) + return; // handler already timed out and returned — don't run a side-effecting body late + try { + prom->set_value(run()); + } catch (const std::exception& e) { + prom->set_value(json{ { "error", "task threw on main thread" }, { "detail", e.what() } }); + } catch (...) { + prom->set_value(json{ { "error", "task threw on main thread" }, { "detail", "non-std exception" } }); + } + }); + if (fut.wait_for(std::chrono::milliseconds(5000)) != std::future_status::ready) { + cancelled->store(true, std::memory_order_release); + return json{ { "error", "main thread did not run within 5000ms (mid-load?)" } }; + } + return fut.get(); + } + + // ---- feature: list / get / set / reset / toggle ----------------------------------- + + // Build one feature entry, including restart-gated metadata so `list` answers + // "what exists", "which fields need a restart", and "is anything pending" in one read. + json FeatureEntry(Feature* f) + { + json entry{ + { "name", f->GetName() }, + { "shortName", f->GetShortName() }, + { "loaded", f->loaded }, + { "version", f->version }, + { "category", std::string(f->GetCategory()) }, + { "isCore", f->IsCore() }, + { "supportsVR", f->SupportsVR() }, + { "inMenu", f->IsInMenu() }, + }; + + const auto fields = f->GetRestartRequiredFields(); + if (!fields.empty()) { + json restartFields = json::array(); + const auto* liveBase = reinterpret_cast(f->GetSettingsBlob()); + const size_t liveSize = f->GetSettingsBlobSize(); + for (const auto& field : fields) { + bool pending = false; + // Compare against remaining bytes (not offset+size, which can overflow and turn + // a bad metadata entry into an out-of-bounds memcmp). + if (liveBase && field.jsonKey && field.size != 0 && + field.offset <= liveSize && field.size <= liveSize - field.offset) { + const void* boot = f->GetBootValue(field.jsonKey); + if (boot && std::memcmp(boot, liveBase + field.offset, field.size) != 0) + pending = true; + } + restartFields.push_back(json{ + { "key", field.jsonKey ? field.jsonKey : "" }, + { "label", field.label ? field.label : "" }, + { "pending", pending }, + }); + } + entry["restartFields"] = restartFields; + } + return entry; + } + + json BuildFeatureResult(const json& a_args) + { + const std::string action = a_args.value("action", std::string("list")); + + if (action == "list") { + // Marshal: FeatureEntry reads Feature::loaded and restart-gated settings bytes that + // main-thread toggles / settings-loads mutate. + return RunOnMainThread([]() { + json out = json::array(); + for (auto* f : Feature::GetFeatureList()) + out.push_back(FeatureEntry(f)); + return out; + }); + } + + const std::string shortName = a_args.value("shortName", std::string{}); + + if (action == "toggle") { + // Match over the full feature list (NOT FindFeatureByShortName, which only + // matches *loaded* features — that makes toggle one-way: you could disable a + // feature but never re-enable it). + Feature* target = nullptr; + if (!shortName.empty()) { + for (auto* f : Feature::GetFeatureList()) { + if (f->GetShortName() == shortName) { + target = f; + break; + } + } + } + if (!target) + return json{ { "error", "unknown or missing shortName" }, { "shortName", shortName } }; + auto* task = SKSE::GetTaskInterface(); + if (!task) + return json{ { "error", "SKSE task interface unavailable" }, { "shortName", shortName } }; + const uint frame = EnqueuedFrame(); + // If `enabled` is omitted we flip the CURRENT value — but that read must happen on + // the main thread INSIDE the task: computing !target->loaded here on the listener + // thread lets concurrent toggles all observe the same stale value and enqueue + // identical results. With an explicit `enabled`, apply it verbatim. + // Threading contract: Feature::loaded is a public flag the render pipeline reads + // per-frame via ForEachLoadedFeature without synchronization, hot-toggled by direct + // assignment — touch it ONLY on the main thread. The applied value is reported via + // the openshaders.feature.changed event (authoritative; the response can't know an + // implicit flip's result synchronously). + const bool hasExplicit = a_args.contains("enabled"); + const bool explicitVal = a_args.value("enabled", false); + task->AddTask([target, hasExplicit, explicitVal, shortName]() { + const bool applied = hasExplicit ? explicitVal : !target->loaded; + // Don't let a remote caller enable a VR-incompatible feature on a VR runtime: + // it bypasses the SupportsVR() gate and can destabilize the renderer. Reject + + // report (covers both an explicit enable and an implicit flip resolving to true). + if (applied && REL::Module::IsVR() && !target->SupportsVR()) { + if (auto* dvb = DevBenchAPI::GetDevBenchInterface001()) { + const std::string payload = json{ { "shortName", shortName }, { "error", "feature does not support VR; enable rejected" } }.dump(); + dvb->EmitEvent("openshaders.feature.changed", payload.c_str()); + } + logger::warn("DevBenchBridge: refused to enable VR-unsupported feature '{}' on a VR runtime", shortName); + return; + } + target->loaded = applied; + if (auto* dvb = DevBenchAPI::GetDevBenchInterface001()) { + const std::string payload = json{ { "shortName", shortName }, { "enabled", applied } }.dump(); + dvb->EmitEvent("openshaders.feature.changed", payload.c_str()); + } + }); + json r{ { "action", "toggle" }, { "shortName", shortName }, { "queued", true }, { "enqueued_at_frame", frame } }; + if (hasExplicit) + r["requested"] = explicitVal; // implicit flip's result arrives via the event + return r; + } + + if (shortName.empty()) + return json{ { "error", "missing required parameter 'shortName'" }, { "action", action } }; + + // get / set / reset operate on a loaded feature. FindFeatureByShortName filters on + // Feature::loaded, which queued toggle tasks mutate on the main thread — so resolve the + // target INSIDE the main-thread path for each action, never on the listener thread. + + if (action == "get") { + return RunOnMainThread([shortName]() -> json { + auto* feature = Feature::FindFeatureByShortName(shortName); + if (!feature) + return json{ { "error", "feature not found or not loaded" }, { "shortName", shortName } }; + json blob; + feature->SaveSettings(blob); + return blob; + }); + } + + // set / reset resolve + apply on the main thread and report the real outcome: an invalid + // shortName must NOT come back as a fake success. Synchronous (LoadSettings is fast), so + // the lookup race is avoided AND the caller learns whether the mutation actually applied. + if (action == "set") { + if (!a_args.contains("settings") || !a_args["settings"].is_object()) + return json{ { "error", "missing required object parameter 'settings'" } }; + json blob = a_args["settings"]; + return RunOnMainThread([blob, shortName]() mutable -> json { + auto* feature = Feature::FindFeatureByShortName(shortName); + if (!feature) + return json{ { "error", "feature not found or not loaded" }, { "shortName", shortName } }; + try { + feature->LoadSettings(blob); + logger::info("DevBenchBridge: feature(set, {}) applied", shortName); + return json{ { "action", "set" }, { "shortName", shortName }, { "applied", true } }; + } catch (const std::exception& e) { + return json{ { "error", "LoadSettings threw" }, { "shortName", shortName }, { "detail", e.what() } }; + } + }); + } + + if (action == "reset") { + return RunOnMainThread([shortName]() -> json { + auto* feature = Feature::FindFeatureByShortName(shortName); + if (!feature) + return json{ { "error", "feature not found or not loaded" }, { "shortName", shortName } }; + try { + feature->RestoreDefaultSettings(); + logger::info("DevBenchBridge: feature(reset, {}) applied", shortName); + return json{ { "action", "reset" }, { "shortName", shortName }, { "applied", true } }; + } catch (const std::exception& e) { + return json{ { "error", "RestoreDefaultSettings threw" }, { "shortName", shortName }, { "detail", e.what() } }; + } + }); + } + + return json{ { "error", "unknown action (list|get|set|reset|toggle)" }, { "action", action } }; + } + + void FeatureToolHandler(void*, const char* a_argsJson, void* a_sink, DevBenchAPI::WriteFn a_write) + { + RunHandler(&BuildFeatureResult, a_argsJson, a_sink, a_write); + } + + // ---- inspect: engine state / shader-cache status ---------------------------------- + + json BuildInspectResult(const json& a_args) + { + const std::string kind = a_args.value("kind", std::string{}); + if (kind.empty()) + return json{ { "error", "missing required parameter 'kind'" } }; + + if (kind == "state") { + return json{ + { "plugin", "CommunityShaders" }, + { "frame_count", EnqueuedFrame() }, + { "vr", REL::Module::IsVR() }, + }; + } + if (kind == "shadercache") { + // Built from thread-safe ShaderCache accessors. Poll completedTasks against a + // pre-deploy snapshot to know a hot-reloaded shader finished; a rising + // failedTasks / currentFailedCount surfaces an otherwise-invisible failed compile. + auto* cache = globals::shaderCache; + if (!cache) + return json{ { "error", "shader cache unavailable" } }; + return json{ + { "compiling", cache->IsCompiling() }, + { "completedTasks", cache->GetCompletedTasks() }, + { "totalTasks", cache->GetTotalTasks() }, + { "failedTasks", cache->GetFailedTasks() }, + { "currentFailedCount", cache->GetCurrentFailedCount() }, + { "frame_count", EnqueuedFrame() }, + }; + } + return json{ { "error", "unknown kind" }, { "kind", kind }, { "supported", json::array({ "state", "shadercache" }) } }; + } + + void InspectToolHandler(void*, const char* a_argsJson, void* a_sink, DevBenchAPI::WriteFn a_write) + { + RunHandler(&BuildInspectResult, a_argsJson, a_sink, a_write); + } + + // ---- shadercache: clear / delete the compiled cache ------------------------------- + + json BuildShadercacheResult(const json& a_args) + { + const std::string action = a_args.value("action", std::string{}); + auto* cache = globals::shaderCache; + if (!cache) + return json{ { "error", "shader cache unavailable" } }; + auto* task = SKSE::GetTaskInterface(); + if (!task) + return json{ { "error", "SKSE task interface unavailable" } }; + const uint frame = EnqueuedFrame(); + + // Mutating cache ops touch the live ShaderCache (and, for deleteDisk, the filesystem) + // — marshal to the main thread. Recompiles are observable via inspect(kind=shadercache) + // + the openshaders.shaderRecompiled event. NOTE clear vs deleteDisk: clear only drops + // the in-memory maps, so with the disk cache enabled shaders reload from Data/ShaderCache + // rather than recompiling — only deleteDisk guarantees a cold recompile. + if (action == "clear") { + task->AddTask([cache]() { cache->Clear(); }); + return json{ { "action", "clear" }, { "queued", true }, { "enqueued_at_frame", frame }, { "note", "in-memory cache dropped; shaders reload from the disk cache if present, else recompile (use deleteDisk to force a cold recompile)" } }; + } + if (action == "deleteDisk") { + // Delete on disk AND drop the in-memory cache — otherwise existing variants keep + // serving from memory and the promised full recompile never happens (mirrors + // PerformClearShaderCache and the ShaderCache invalidation path). + task->AddTask([cache]() { + cache->DeleteDiskCache(); + cache->Clear(); + }); + return json{ { "action", "deleteDisk" }, { "queued", true }, { "enqueued_at_frame", frame }, { "note", "on-disk + in-memory shader cache cleared; a full recompile follows (cold-compile benchmark)" } }; + } + return json{ { "error", "unknown action (clear|deleteDisk)" }, { "action", action } }; + } + + void ShadercacheToolHandler(void*, const char* a_argsJson, void* a_sink, DevBenchAPI::WriteFn a_write) + { + RunHandler(&BuildShadercacheResult, a_argsJson, a_sink, a_write); + } + + // ---- capture: renderdoc / screenshot ---------------------------------------------- + + json BuildCaptureResult(const json& a_args) + { + const std::string kind = a_args.value("kind", std::string{}); + if (kind.empty()) + return json{ { "error", "missing required parameter 'kind'" } }; + const uint frame = EnqueuedFrame(); + + if (kind == "renderdoc") { + uint32_t frameCount = 1; + if (a_args.contains("frames") && a_args["frames"].is_number()) + frameCount = static_cast(std::clamp(a_args["frames"].get(), 1, 120)); + // All on the main thread: Feature::loaded is mutated by queued toggle tasks there, + // and TriggerCapture invalidates RenderDoc's non-atomic capture-list cache the + // UI/render thread reads. + return RunOnMainThread([frameCount, frame]() -> json { + auto* renderDoc = &globals::features::renderDoc; + if (!renderDoc->loaded) + return json{ { "error", "RenderDoc feature is not loaded" }, { "hint", "openshaders.feature(action='list') shows RenderDoc.loaded" } }; + if (!renderDoc->IsAvailable()) + return json{ { "error", "RenderDoc API not available — attach RenderDoc or load the in-app DLL" } }; + if (frameCount == 1) + renderDoc->TriggerCapture(); + else + renderDoc->TriggerMultiFrameCapture(frameCount); + return json{ { "queued", true }, { "kind", "renderdoc" }, { "frames", frameCount }, { "enqueued_at_frame", frame } }; + }); + } + + if (kind == "screenshot") { + // loaded read on the main thread (toggle tasks mutate it); the request flag is atomic. + return RunOnMainThread([frame]() -> json { + auto* shot = &globals::features::screenshotFeature; + if (!shot->loaded) + return json{ { "error", "Screenshot feature is not loaded" } }; + shot->captureRequested.store(true, std::memory_order_release); + return json{ { "queued", true }, { "kind", "screenshot" }, { "enqueued_at_frame", frame } }; + }); + } + + return json{ { "error", "unknown kind" }, { "kind", kind }, { "supported", json::array({ "renderdoc", "screenshot" }) } }; + } + + void CaptureToolHandler(void*, const char* a_argsJson, void* a_sink, DevBenchAPI::WriteFn a_write) + { + RunHandler(&BuildCaptureResult, a_argsJson, a_sink, a_write); + } + + // ---- settings: save / load / reset the GLOBAL CS config --------------------------- + + json BuildSettingsResult(const json& a_args) + { + const std::string action = a_args.value("action", std::string{}); + if (action.empty()) + return json{ { "error", "missing required parameter 'action'" } }; + auto* state = globals::state; + if (!state) + return json{ { "error", "State singleton unavailable" } }; + auto* task = SKSE::GetTaskInterface(); + if (!task) + return json{ { "error", "SKSE task interface unavailable" } }; + const uint frame = EnqueuedFrame(); + + // State::Save/Load read and write the on-disk USER config and touch every feature's + // settings; both must run on the main thread for the same reason feature(set) does. + // Contain failures inside the task: RunHandler's guard no longer applies once this runs + // on SKSE's queue, so a malformed config / Save|Load throw would unwind on the game + // thread after we already replied "queued". Degrade to a logged error instead. + if (action == "save") { + task->AddTask([state]() { + try { + state->Save(State::ConfigMode::USER); + logger::info("DevBenchBridge: settings(save) applied"); + } catch (const std::exception& e) { + logger::error("DevBenchBridge: settings(save) failed: {}", e.what()); + } catch (...) { + logger::error("DevBenchBridge: settings(save) failed (unknown)"); + } + }); + return json{ { "action", "save" }, { "queued", true }, { "enqueued_at_frame", frame } }; + } + if (action == "load") { + task->AddTask([state]() { + try { + state->Load(State::ConfigMode::USER, /*allowReload=*/true); + logger::info("DevBenchBridge: settings(load) applied"); + } catch (const std::exception& e) { + logger::error("DevBenchBridge: settings(load) failed: {}", e.what()); + } catch (...) { + logger::error("DevBenchBridge: settings(load) failed (unknown)"); + } + }); + return json{ { "action", "load" }, { "queued", true }, { "enqueued_at_frame", frame } }; + } + if (action == "reset") { + // Restore every feature to its defaults, then persist. Mirrors what the UI's + // global reset does: per-feature RestoreDefaultSettings followed by a Save. + task->AddTask([state]() { + for (auto* f : Feature::GetFeatureList()) { + try { + f->RestoreDefaultSettings(); + } catch (const std::exception& e) { + logger::error("DevBenchBridge: settings(reset) {} threw: {}", f->GetShortName(), e.what()); + } + } + try { + state->Save(State::ConfigMode::USER); + logger::info("DevBenchBridge: settings(reset) applied"); + } catch (const std::exception& e) { + logger::error("DevBenchBridge: settings(reset) save failed: {}", e.what()); + } catch (...) { + logger::error("DevBenchBridge: settings(reset) save failed (unknown)"); + } + }); + return json{ { "action", "reset" }, { "queued", true }, { "enqueued_at_frame", frame } }; + } + return json{ { "error", "unknown action (save|load|reset)" }, { "action", action } }; + } + + void SettingsToolHandler(void*, const char* a_argsJson, void* a_sink, DevBenchAPI::WriteFn a_write) + { + RunHandler(&BuildSettingsResult, a_argsJson, a_sink, a_write); + } +} + +namespace DevBenchBridge +{ + void Install() + { + auto* dvb = DevBenchAPI::GetDevBenchInterface001(); + if (!dvb) { + logger::info("DevBenchBridge: devbench not present; CS tools not registered"); + return; + } + logger::info("DevBenchBridge: devbench build {} present — registering CS tools", dvb->GetBuildNumber()); + + // Namespaced tool names — devbench's registry is shared across plugins, so bare + // names ("feature", "inspect"…) could collide with devbench's own or another mod's. + // Descriptors preserve the actions/kinds/inputSchema of CS's former embedded server + // so existing MCP clients keep working under the new prefix. + + static constexpr const char* featureDesc = + R"({"description":"All Open Shaders graphics-feature operations — enumerate, inspect settings, mutate settings, restore defaults, toggle on/off. Action-dispatched. list: returns an array of {name,shortName,loaded,version,category,isCore,supportsVR,inMenu}; features with restart-gated settings also include restartFields:[{key,label,pending}]. get: params shortName, returns the SaveSettings blob (null if the feature has no override; set/reset then no-op). set: params shortName, settings (object). reset: params shortName, calls RestoreDefaultSettings. toggle: params shortName, enabled (boolean, OPTIONAL — omit to flip the current loaded state); flips Feature::loaded.","inputSchema":{"type":"object","properties":{"action":{"type":"string","enum":["list","get","set","reset","toggle"]},"shortName":{"type":"string"},"settings":{"type":"object"},"enabled":{"type":"boolean"}}}})"; + dvb->RegisterTool("openshaders.feature", featureDesc, &FeatureToolHandler, nullptr); + + static constexpr const char* inspectDesc = + R"({"description":"Read non-feature Open Shaders engine state. Kind-dispatched; response is a JSON object. kind=state -> {plugin,frame_count,vr}; frame_count increases each render tick, use it as ground truth that a queued operation has had time to run. kind=shadercache -> {compiling,completedTasks,totalTasks,failedTasks,currentFailedCount,frame_count}; poll completedTasks against a pre-deploy snapshot to know a hot-reloaded shader finished, and watch failedTasks/currentFailedCount for failed compiles. For feature reads use openshaders.feature(action=list|get).","readOnly":true,"inputSchema":{"type":"object","properties":{"kind":{"type":"string","enum":["state","shadercache"]}},"required":["kind"]}})"; + dvb->RegisterTool("openshaders.inspect", inspectDesc, &InspectToolHandler, nullptr); + + static constexpr const char* shadercacheDesc = + R"({"description":"Manage Open Shaders' compiled shader cache. Action-dispatched, fire-and-forget on the main thread. clear: drop the IN-MEMORY cache only; with the disk cache enabled shaders reload from Data/ShaderCache rather than recompiling, so this does NOT guarantee a recompile. deleteDisk: delete the on-disk cache AND drop the in-memory cache, forcing a full cold recompile (use this for compile benchmarks). Watch progress via openshaders.inspect kind=shadercache and the openshaders.shaderRecompiled event. Read-only status is openshaders.inspect kind=shadercache.","inputSchema":{"type":"object","properties":{"action":{"type":"string","enum":["clear","deleteDisk"]}},"required":["action"]}})"; + dvb->RegisterTool("openshaders.shadercache", shadercacheDesc, &ShadercacheToolHandler, nullptr); + + static constexpr const char* captureDesc = + R"({"description":"Trigger a frame capture on the next render. Kind-dispatched. kind=renderdoc: RenderDoc multi-frame capture via the in-app API, honors frames (1-120, default 1); RenderDoc must be attached/loaded (check openshaders.feature list for RenderDoc.loaded). kind=screenshot: lossless screenshot via the Screenshot feature; frames is ignored. Fire-and-forget — no artifact path is returned synchronously.","inputSchema":{"type":"object","properties":{"kind":{"type":"string","enum":["renderdoc","screenshot"]},"frames":{"type":"number"}},"required":["kind"]}})"; + dvb->RegisterTool("openshaders.capture", captureDesc, &CaptureToolHandler, nullptr); + + static constexpr const char* settingsDesc = + R"({"description":"Save, load, or reset the GLOBAL Open Shaders user configuration (Data/SKSE/Plugins/CommunityShaders/*.json). Action-dispatched, all fire-and-forget on the main thread. save: persist current settings (State::Save). load: re-read settings from disk and apply (State::Load). reset: restore every feature to its defaults then persist. Use after openshaders.feature set/reset to make changes durable, or to roll an A/B session back to the saved baseline.","inputSchema":{"type":"object","properties":{"action":{"type":"string","enum":["save","load","reset"]}},"required":["action"]}})"; + dvb->RegisterTool("openshaders.settings", settingsDesc, &SettingsToolHandler, nullptr); + } +} + +#else + +namespace DevBenchBridge +{ + void Install() {} // inert until built with DEVBENCH_BRIDGE_ENABLED +} + +#endif diff --git a/src/Features/RemoteControl/DevBenchBridge.h b/src/Features/RemoteControl/DevBenchBridge.h new file mode 100644 index 0000000000..19668f70f4 --- /dev/null +++ b/src/Features/RemoteControl/DevBenchBridge.h @@ -0,0 +1,16 @@ +#pragma once + +// Client-side bridge to the devbench host (https://github.com/alandtse/devbench). +// Registers Open Shaders' tools into devbench over its cross-plugin C-ABI so they are +// drivable from the shared bench (MCP + REST). +// +// The implementation compiles only with -DDEVBENCH_BRIDGE_ENABLED (set by CMake when the +// `devbench-api` port is available); otherwise this file compiles to an empty Install(). +// When built in, Install() is still a runtime no-op if no devbench host is present — so +// it is always safe to call. +namespace DevBenchBridge +{ + // Fetch the devbench interface (after kPostLoad) and register our tools. No-op if + // devbench is not present or the bridge was built disabled. Safe to call always. + void Install(); +} diff --git a/src/ShaderCache.cpp b/src/ShaderCache.cpp index 7c6408490a..c92a8430f6 100644 --- a/src/ShaderCache.cpp +++ b/src/ShaderCache.cpp @@ -4,6 +4,10 @@ #include "ShaderFileWatcher.h" #include "Util.h" +#ifdef DEVBENCH_BRIDGE_ENABLED +# include +#endif + #include #include "Deferred.h" @@ -3347,6 +3351,23 @@ namespace SIE // Log completion outside the lock if (shouldLogCompletion) { logger::debug("Compilation completed in {} ms", GetHumanTime(completionTimeMs)); + +#ifdef DEVBENCH_BRIDGE_ENABLED + // A compilation batch finished (initial build OR a hot-reload recompile). + // Emit one summary event so a benchmark scenario can split its A/B window + // precisely on the moment a recompiled shader went live, and detect failures + // without polling. Guarded on the devbench host being present. + if (auto* dvb = DevBenchAPI::GetDevBenchInterface001()) { + const nlohmann::json payload{ + { "completedTasks", completedTasks.load(std::memory_order_relaxed) }, + { "failedTasks", failedTasks.load(std::memory_order_relaxed) }, + { "totalTasks", totalTasks.load(std::memory_order_relaxed) }, + { "durationMs", completionTimeMs }, + }; + const std::string dumped = payload.dump(); + dvb->EmitEvent("openshaders.shaderRecompiled", dumped.c_str()); + } +#endif } conditionVariable.notify_one(); diff --git a/vcpkg.json b/vcpkg.json index 1c5a5b57b2..eb408701b8 100644 --- a/vcpkg.json +++ b/vcpkg.json @@ -6,6 +6,10 @@ "commonlibsse-ng": { "description": "Dependencies of clib-ng", "dependencies": ["catch2", "fmt", "directxtk", "rapidcsv", "spdlog"] + }, + "devbench-bridge": { + "description": "Register the plugin's tools into the devbench test bench (selected by the DEVBENCH_BRIDGE CMake option).", + "dependencies": ["devbench-api"] } }, "default-features": ["commonlibsse-ng"],