diff --git a/.github/workflows/plugins.yml b/.github/workflows/plugins.yml new file mode 100644 index 000000000..73c3264b1 --- /dev/null +++ b/.github/workflows/plugins.yml @@ -0,0 +1,20 @@ +name: Plugin contracts +on: + push: + paths: ['VisualPinball.Plugins/**', 'VisualPinball.Plugins.Test/**', '.github/workflows/plugins.yml'] + pull_request: + paths: ['VisualPinball.Plugins/**', 'VisualPinball.Plugins.Test/**', '.github/workflows/plugins.yml'] +permissions: + contents: read +jobs: + test: + strategy: + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + - run: dotnet test VisualPinball.Plugins.Test/VisualPinball.Plugins.Test.csproj -c Release diff --git a/VisualPinball.Engine.sln b/VisualPinball.Engine.sln index 9ddfa4d66..83b11fa12 100644 --- a/VisualPinball.Engine.sln +++ b/VisualPinball.Engine.sln @@ -7,24 +7,28 @@ EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "VisualPinball.Engine.Test", "VisualPinball.Engine.Test\VisualPinball.Engine.Test.csproj", "{3D642DA1-C750-4EB0-AE33-E2A2CDA3DDEB}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution", "Solution", "{E74E1502-B527-475D-BCEC-B8F60699BA5F}" -ProjectSection(SolutionItems) = preProject - LICENSE = LICENSE - README.md = README.md - .editorconfig = .editorconfig - .gitignore = .gitignore - CHANGELOG.md = CHANGELOG.md - CONTRIBUTING.md = CONTRIBUTING.md - package.json = package.json -EndProjectSection + ProjectSection(SolutionItems) = preProject + LICENSE = LICENSE + README.md = README.md + .editorconfig = .editorconfig + .gitignore = .gitignore + CHANGELOG.md = CHANGELOG.md + CONTRIBUTING.md = CONTRIBUTING.md + package.json = package.json + EndProjectSection EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Workflows", "Workflows", "{E6A28E8F-1DE7-4CF4-ADB7-A55A6E6467AF}" -ProjectSection(SolutionItems) = preProject - .github\workflows\build.yml = .github\workflows\build.yml - .github\workflows\dependents.yml = .github\workflows\dependents.yml - .github\workflows\documentation.yml = .github\workflows\documentation.yml - .github\workflows\publish.yml = .github\workflows\publish.yml - .github\workflows\release.yml = .github\workflows\release.yml -EndProjectSection + ProjectSection(SolutionItems) = preProject + .github\workflows\build.yml = .github\workflows\build.yml + .github\workflows\dependents.yml = .github\workflows\dependents.yml + .github\workflows\documentation.yml = .github\workflows\documentation.yml + .github\workflows\publish.yml = .github\workflows\publish.yml + .github\workflows\release.yml = .github\workflows\release.yml + EndProjectSection +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VisualPinball.Plugins", "VisualPinball.Plugins\VisualPinball.Plugins.csproj", "{7FFF4D1E-9DD7-4E69-B312-2E12929ABAE9}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VisualPinball.Plugins.Test", "VisualPinball.Plugins.Test\VisualPinball.Plugins.Test.csproj", "{CABEBB0E-FB15-49E5-A85E-78EB7FAC2822}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -36,42 +40,69 @@ Global Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution + {EFE73640-F311-4109-9F3E-1573F86990BB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EFE73640-F311-4109-9F3E-1573F86990BB}.Debug|Any CPU.Build.0 = Debug|Any CPU {EFE73640-F311-4109-9F3E-1573F86990BB}.Debug|x64.ActiveCfg = Debug|Any CPU {EFE73640-F311-4109-9F3E-1573F86990BB}.Debug|x64.Build.0 = Debug|Any CPU {EFE73640-F311-4109-9F3E-1573F86990BB}.Debug|x86.ActiveCfg = Debug|Any CPU {EFE73640-F311-4109-9F3E-1573F86990BB}.Debug|x86.Build.0 = Debug|Any CPU - {EFE73640-F311-4109-9F3E-1573F86990BB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {EFE73640-F311-4109-9F3E-1573F86990BB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EFE73640-F311-4109-9F3E-1573F86990BB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EFE73640-F311-4109-9F3E-1573F86990BB}.Release|Any CPU.Build.0 = Release|Any CPU {EFE73640-F311-4109-9F3E-1573F86990BB}.Release|x64.ActiveCfg = Release|x64 {EFE73640-F311-4109-9F3E-1573F86990BB}.Release|x64.Build.0 = Release|x64 {EFE73640-F311-4109-9F3E-1573F86990BB}.Release|x86.ActiveCfg = Release|x86 {EFE73640-F311-4109-9F3E-1573F86990BB}.Release|x86.Build.0 = Release|x86 - {EFE73640-F311-4109-9F3E-1573F86990BB}.Release|Any CPU.ActiveCfg = Release|Any CPU - {EFE73640-F311-4109-9F3E-1573F86990BB}.Release|Any CPU.Build.0 = Release|Any CPU + {8A259010-3234-4402-BE50-980B4887902C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8A259010-3234-4402-BE50-980B4887902C}.Debug|Any CPU.Build.0 = Debug|Any CPU {8A259010-3234-4402-BE50-980B4887902C}.Debug|x64.ActiveCfg = Debug|Any CPU {8A259010-3234-4402-BE50-980B4887902C}.Debug|x64.Build.0 = Debug|Any CPU {8A259010-3234-4402-BE50-980B4887902C}.Debug|x86.ActiveCfg = Debug|Any CPU {8A259010-3234-4402-BE50-980B4887902C}.Debug|x86.Build.0 = Debug|Any CPU - {8A259010-3234-4402-BE50-980B4887902C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {8A259010-3234-4402-BE50-980B4887902C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8A259010-3234-4402-BE50-980B4887902C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8A259010-3234-4402-BE50-980B4887902C}.Release|Any CPU.Build.0 = Release|Any CPU {8A259010-3234-4402-BE50-980B4887902C}.Release|x64.ActiveCfg = Release|Any CPU {8A259010-3234-4402-BE50-980B4887902C}.Release|x64.Build.0 = Release|Any CPU {8A259010-3234-4402-BE50-980B4887902C}.Release|x86.ActiveCfg = Release|Any CPU {8A259010-3234-4402-BE50-980B4887902C}.Release|x86.Build.0 = Release|Any CPU - {8A259010-3234-4402-BE50-980B4887902C}.Release|Any CPU.ActiveCfg = Release|Any CPU - {8A259010-3234-4402-BE50-980B4887902C}.Release|Any CPU.Build.0 = Release|Any CPU + {3D642DA1-C750-4EB0-AE33-E2A2CDA3DDEB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3D642DA1-C750-4EB0-AE33-E2A2CDA3DDEB}.Debug|Any CPU.Build.0 = Debug|Any CPU {3D642DA1-C750-4EB0-AE33-E2A2CDA3DDEB}.Debug|x64.ActiveCfg = Debug|Any CPU {3D642DA1-C750-4EB0-AE33-E2A2CDA3DDEB}.Debug|x64.Build.0 = Debug|Any CPU {3D642DA1-C750-4EB0-AE33-E2A2CDA3DDEB}.Debug|x86.ActiveCfg = Debug|Any CPU {3D642DA1-C750-4EB0-AE33-E2A2CDA3DDEB}.Debug|x86.Build.0 = Debug|Any CPU - {3D642DA1-C750-4EB0-AE33-E2A2CDA3DDEB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {3D642DA1-C750-4EB0-AE33-E2A2CDA3DDEB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3D642DA1-C750-4EB0-AE33-E2A2CDA3DDEB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3D642DA1-C750-4EB0-AE33-E2A2CDA3DDEB}.Release|Any CPU.Build.0 = Release|Any CPU {3D642DA1-C750-4EB0-AE33-E2A2CDA3DDEB}.Release|x64.ActiveCfg = Release|Any CPU {3D642DA1-C750-4EB0-AE33-E2A2CDA3DDEB}.Release|x64.Build.0 = Release|Any CPU {3D642DA1-C750-4EB0-AE33-E2A2CDA3DDEB}.Release|x86.ActiveCfg = Release|Any CPU {3D642DA1-C750-4EB0-AE33-E2A2CDA3DDEB}.Release|x86.Build.0 = Release|Any CPU - {3D642DA1-C750-4EB0-AE33-E2A2CDA3DDEB}.Release|Any CPU.ActiveCfg = Release|Any CPU - {3D642DA1-C750-4EB0-AE33-E2A2CDA3DDEB}.Release|Any CPU.Build.0 = Release|Any CPU + {7FFF4D1E-9DD7-4E69-B312-2E12929ABAE9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7FFF4D1E-9DD7-4E69-B312-2E12929ABAE9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7FFF4D1E-9DD7-4E69-B312-2E12929ABAE9}.Debug|x64.ActiveCfg = Debug|Any CPU + {7FFF4D1E-9DD7-4E69-B312-2E12929ABAE9}.Debug|x64.Build.0 = Debug|Any CPU + {7FFF4D1E-9DD7-4E69-B312-2E12929ABAE9}.Debug|x86.ActiveCfg = Debug|Any CPU + {7FFF4D1E-9DD7-4E69-B312-2E12929ABAE9}.Debug|x86.Build.0 = Debug|Any CPU + {7FFF4D1E-9DD7-4E69-B312-2E12929ABAE9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7FFF4D1E-9DD7-4E69-B312-2E12929ABAE9}.Release|Any CPU.Build.0 = Release|Any CPU + {7FFF4D1E-9DD7-4E69-B312-2E12929ABAE9}.Release|x64.ActiveCfg = Release|Any CPU + {7FFF4D1E-9DD7-4E69-B312-2E12929ABAE9}.Release|x64.Build.0 = Release|Any CPU + {7FFF4D1E-9DD7-4E69-B312-2E12929ABAE9}.Release|x86.ActiveCfg = Release|Any CPU + {7FFF4D1E-9DD7-4E69-B312-2E12929ABAE9}.Release|x86.Build.0 = Release|Any CPU + {CABEBB0E-FB15-49E5-A85E-78EB7FAC2822}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CABEBB0E-FB15-49E5-A85E-78EB7FAC2822}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CABEBB0E-FB15-49E5-A85E-78EB7FAC2822}.Debug|x64.ActiveCfg = Debug|Any CPU + {CABEBB0E-FB15-49E5-A85E-78EB7FAC2822}.Debug|x64.Build.0 = Debug|Any CPU + {CABEBB0E-FB15-49E5-A85E-78EB7FAC2822}.Debug|x86.ActiveCfg = Debug|Any CPU + {CABEBB0E-FB15-49E5-A85E-78EB7FAC2822}.Debug|x86.Build.0 = Debug|Any CPU + {CABEBB0E-FB15-49E5-A85E-78EB7FAC2822}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CABEBB0E-FB15-49E5-A85E-78EB7FAC2822}.Release|Any CPU.Build.0 = Release|Any CPU + {CABEBB0E-FB15-49E5-A85E-78EB7FAC2822}.Release|x64.ActiveCfg = Release|Any CPU + {CABEBB0E-FB15-49E5-A85E-78EB7FAC2822}.Release|x64.Build.0 = Release|Any CPU + {CABEBB0E-FB15-49E5-A85E-78EB7FAC2822}.Release|x86.ActiveCfg = Release|Any CPU + {CABEBB0E-FB15-49E5-A85E-78EB7FAC2822}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution {E6A28E8F-1DE7-4CF4-ADB7-A55A6E6467AF} = {E74E1502-B527-475D-BCEC-B8F60699BA5F} diff --git a/VisualPinball.Native~/NativeWindows/Desktop/CMakeLists.txt b/VisualPinball.Native~/NativeWindows/Desktop/CMakeLists.txt new file mode 100644 index 000000000..a82d79deb --- /dev/null +++ b/VisualPinball.Native~/NativeWindows/Desktop/CMakeLists.txt @@ -0,0 +1,64 @@ +cmake_minimum_required(VERSION 3.22) +if(APPLE) + set(CMAKE_OSX_DEPLOYMENT_TARGET "14.0" CACHE STRING "Minimum macOS version") + set(CMAKE_OSX_ARCHITECTURES "arm64;x86_64" CACHE STRING "macOS architectures (universal binary by default)") +endif() +project(VisualPinballNativeWindows LANGUAGES C CXX) +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_POSITION_INDEPENDENT_CODE ON) +set(CMAKE_CXX_VISIBILITY_PRESET hidden) +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE) +endif() +set(UNITY_PLUGIN_API "" CACHE PATH "Installed Unity Editor Data/PluginAPI directory") +option(VPNW_BUILD_TESTS "Build native window fixtures" ON) +option(VPNW_DEPLOY "Copy the built library into VisualPinball.Unity/Plugins" ON) +if(NOT EXISTS "${UNITY_PLUGIN_API}/IUnityInterface.h") + message(FATAL_ERROR "Set UNITY_PLUGIN_API to the installed Unity PluginAPI directory.") +endif() +get_filename_component(VPNW_API_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/.." ABSOLUTE) +get_filename_component(VPNW_PACKAGE_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../../.." ABSOLUTE) +set(SOURCES WindowRegistry.cpp) +if(APPLE) + enable_language(OBJCXX) + list(APPEND SOURCES Mac/MetalWindows.mm) + set(PLATFORM_LIBRARIES "-framework Cocoa" "-framework QuartzCore" "-framework Metal" "-framework ApplicationServices") + set(VPNW_PLUGIN_DIRECTORY "${VPNW_PACKAGE_ROOT}/VisualPinball.Unity/Plugins/osx" CACHE PATH "Deployment directory") +elseif(UNIX) + find_package(Vulkan REQUIRED) + find_package(X11 REQUIRED) + if(NOT X11_Xrender_FOUND) + message(FATAL_ERROR "The X Rendering Extension development files (libxrender-dev) are required.") + endif() + list(APPEND SOURCES Linux/X11Windows.cpp Linux/VulkanWindows.cpp) + set(PLATFORM_LIBRARIES Vulkan::Vulkan X11::X11 X11::Xrender dl pthread m) + set(VPNW_PLUGIN_DIRECTORY "${VPNW_PACKAGE_ROOT}/VisualPinball.Unity/Plugins/linux-x64" CACHE PATH "Deployment directory") +else() + message(FATAL_ERROR "This CMake project builds the macOS and Linux library; Windows uses VisualPinball.Native~/NativeWindows/Windows/Build.ps1.") +endif() +add_library(VisualPinballNativeWindows SHARED ${SOURCES}) +target_include_directories(VisualPinballNativeWindows PRIVATE "${UNITY_PLUGIN_API}" "${VPNW_API_DIRECTORY}" "${CMAKE_CURRENT_SOURCE_DIR}") +target_link_libraries(VisualPinballNativeWindows PRIVATE ${PLATFORM_LIBRARIES}) +target_compile_options(VisualPinballNativeWindows PRIVATE -Wall -Wextra -Werror -Wno-missing-field-initializers) +if(APPLE) + target_compile_options(VisualPinballNativeWindows PRIVATE -fobjc-arc) + set_target_properties(VisualPinballNativeWindows PROPERTIES PREFIX "lib" SUFFIX ".dylib") +endif() +if(VPNW_DEPLOY) + add_custom_command(TARGET VisualPinballNativeWindows POST_BUILD + COMMAND "${CMAKE_COMMAND}" -E make_directory "${VPNW_PLUGIN_DIRECTORY}" + COMMAND "${CMAKE_COMMAND}" -E copy_if_different "$" "${VPNW_PLUGIN_DIRECTORY}/" + COMMENT "Deploying VisualPinballNativeWindows to ${VPNW_PLUGIN_DIRECTORY}") +endif() +if(VPNW_BUILD_TESTS) + enable_testing() + add_executable(NativeWindowTests DesktopTests.cpp) + if(APPLE) + set_source_files_properties(DesktopTests.cpp PROPERTIES LANGUAGE OBJCXX) + target_compile_options(NativeWindowTests PRIVATE -fobjc-arc) + endif() + target_include_directories(NativeWindowTests PRIVATE "${UNITY_PLUGIN_API}" "${VPNW_API_DIRECTORY}" "${CMAKE_CURRENT_SOURCE_DIR}") + target_link_libraries(NativeWindowTests PRIVATE VisualPinballNativeWindows ${PLATFORM_LIBRARIES}) + add_test(NAME NativeWindows COMMAND NativeWindowTests) +endif() diff --git a/VisualPinball.Native~/NativeWindows/Desktop/DesktopLayout.h b/VisualPinball.Native~/NativeWindows/Desktop/DesktopLayout.h new file mode 100644 index 000000000..165190174 --- /dev/null +++ b/VisualPinball.Native~/NativeWindows/Desktop/DesktopLayout.h @@ -0,0 +1,49 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// SPDX-License-Identifier: MIT +// Licensed under MIT. See https://opensource.org/license/mit. + +#pragma once +#include +#include +#include +#include + +namespace vpnw +{ + struct DesktopRect { double x, y, width, height; }; + struct Monitor { DesktopRect logical; double pixelWidth, pixelHeight; }; + // Anchor the primary display at (0,0), then preserve monitor adjacency in physical pixels. + // Scaling every global origin by the primary DPI creates gaps/overlaps on mixed-DPI desktops. + inline std::vector PhysicalLayout(const std::vector& monitors) + { + std::vector result(monitors.size()); if (monitors.empty()) return result; + std::vector placed(monitors.size(), false); placed[0] = true; + result[0] = { 0, 0, monitors[0].pixelWidth, monitors[0].pixelHeight }; + for (std::size_t count = 1; count < monitors.size(); count++) + { + std::size_t target = 0, anchor = 0; double nearest = std::numeric_limits::infinity(), shared = -1; + for (std::size_t i = 0; i < monitors.size(); i++) if (!placed[i]) + for (std::size_t j = 0; j < monitors.size(); j++) if (placed[j]) + { + auto a = monitors[j].logical, b = monitors[i].logical; + double dx = std::max({ a.x - b.x - b.width, b.x - a.x - a.width, 0.0 }); + double dy = std::max({ a.y - b.y - b.height, b.y - a.y - a.height, 0.0 }); + double distance = dx * dx + dy * dy; + double overlap = std::max(0.0, std::min(a.x + a.width, b.x + b.width) - std::max(a.x, b.x)) + + std::max(0.0, std::min(a.y + a.height, b.y + b.height) - std::max(a.y, b.y)); + if (distance < nearest || (distance == nearest && overlap > shared)) { nearest = distance; shared = overlap; target = i; anchor = j; } + } + auto parent = monitors[anchor].logical, child = monitors[target].logical, pixels = result[anchor]; + double sx = monitors[target].pixelWidth / child.width, sy = monitors[target].pixelHeight / child.height; + DesktopRect rectangle{ pixels.x + (child.x - parent.x) * sx, pixels.y + (child.y - parent.y) * sy, monitors[target].pixelWidth, monitors[target].pixelHeight }; + if (child.x >= parent.x + parent.width) rectangle.x = pixels.x + pixels.width + (child.x - parent.x - parent.width) * sx; + else if (child.x + child.width <= parent.x) rectangle.x = pixels.x - (parent.x - child.x - child.width) * sx - rectangle.width; + else if (child.y >= parent.y + parent.height) rectangle.y = pixels.y + pixels.height + (child.y - parent.y - parent.height) * sy; + else if (child.y + child.height <= parent.y) rectangle.y = pixels.y - (parent.y - child.y - child.height) * sy - rectangle.height; + result[target] = rectangle; placed[target] = true; + } + return result; + } +} diff --git a/VisualPinball.Native~/NativeWindows/Desktop/DesktopTests.cpp b/VisualPinball.Native~/NativeWindows/Desktop/DesktopTests.cpp new file mode 100644 index 000000000..1f6cbc732 --- /dev/null +++ b/VisualPinball.Native~/NativeWindows/Desktop/DesktopTests.cpp @@ -0,0 +1,279 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// SPDX-License-Identifier: MIT +// Licensed under MIT. See https://opensource.org/license/mit. + +#include "IUnityGraphics.h" +#if !defined(__APPLE__) +#define VK_USE_PLATFORM_XLIB_KHR +#include "IUnityGraphicsVulkan.h" +#include +#include +#else +#import +#import +#include "IUnityGraphicsMetal.h" +#endif +#include "VisualPinballNativeWindows.h" +#include "DesktopLayout.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ + std::string Error() + { + std::vector text(static_cast(VPNW_GetLastError(nullptr, 0))); + VPNW_GetLastError(text.data(), static_cast(text.size())); return text.data(); + } + void Require(bool value, const char* message) { if (!value) throw std::runtime_error(std::string(message) + ": " + Error()); } + IUnityGraphics graphics{}; + IUnityInterfaces interfaces{}; + bool loaded = false; + bool gpuTested = false; + void UNITY_INTERFACE_API Register(IUnityGraphicsDeviceEventCallback) {} + void UNITY_INTERFACE_API Unregister(IUnityGraphicsDeviceEventCallback) {} + int UNITY_INTERFACE_API Reserve(int) { return 49000; } + +#if !defined(__APPLE__) + IUnityGraphicsVulkan graphicsVulkan{}; + UnityVulkanInstance native{}; + UnityVulkanImage image{}; + UnityGfxRenderer UNITY_INTERFACE_API Renderer() { return kUnityGfxRendererVulkan; } + UnityVulkanInstance UNITY_INTERFACE_API Instance() { return native; } + void UNITY_INTERFACE_API Configure(int event, const UnityVulkanPluginEventConfig* config) + { + if (event == VPNW_GetPresentEventId()) Require(config->graphicsQueueAccess == kUnityVulkanGraphicsQueueAccess_Allow && (config->flags & kUnityVulkanEventConfigFlag_FlushCommandBuffers), "Presentation requires flushed queue access"); + } + bool UNITY_INTERFACE_API Access(void* texture, const VkImageSubresource*, VkImageLayout layout, VkPipelineStageFlags, VkAccessFlags, UnityVulkanResourceAccessMode, UnityVulkanImage* output) + { + if (texture != &image) return false; + *output = image; output->layout = layout; return true; + } + void Check(VkResult result) { if (result != VK_SUCCESS) throw std::runtime_error("Vulkan fixture: " + std::to_string(result)); } + void StartGraphics() + { + const char* extensions[] = { VK_KHR_SURFACE_EXTENSION_NAME, VK_KHR_XLIB_SURFACE_EXTENSION_NAME }; + VkApplicationInfo application{ VK_STRUCTURE_TYPE_APPLICATION_INFO }; application.apiVersion = VK_API_VERSION_1_1; + VkInstanceCreateInfo create{ VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO }; create.pApplicationInfo = &application; + create.enabledExtensionCount = 2; create.ppEnabledExtensionNames = extensions; + Check(vkCreateInstance(&create, nullptr, &native.instance)); std::uint32_t count = 0; + Check(vkEnumeratePhysicalDevices(native.instance, &count, nullptr)); Require(count > 0, "A Vulkan device is required"); + std::vector devices(count); Check(vkEnumeratePhysicalDevices(native.instance, &count, devices.data())); native.physicalDevice = devices.front(); + vkGetPhysicalDeviceQueueFamilyProperties(native.physicalDevice, &count, nullptr); std::vector families(count); + vkGetPhysicalDeviceQueueFamilyProperties(native.physicalDevice, &count, families.data()); + for (std::uint32_t i = 0; i < count; i++) if (families[i].queueFlags & VK_QUEUE_GRAPHICS_BIT) { native.queueFamilyIndex = i; break; } + float priority = 1; VkDeviceQueueCreateInfo queue{ VK_STRUCTURE_TYPE_DEVICE_QUEUE_CREATE_INFO }; + queue.queueFamilyIndex = native.queueFamilyIndex; queue.queueCount = 1; queue.pQueuePriorities = &priority; + const char* deviceExtensions[] = { VK_KHR_SWAPCHAIN_EXTENSION_NAME }; + VkDeviceCreateInfo device{ VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO }; device.queueCreateInfoCount = 1; device.pQueueCreateInfos = &queue; + device.enabledExtensionCount = 1; device.ppEnabledExtensionNames = deviceExtensions; + Check(vkCreateDevice(native.physicalDevice, &device, nullptr, &native.device)); vkGetDeviceQueue(native.device, native.queueFamilyIndex, 0, &native.graphicsQueue); + native.getInstanceProcAddr = vkGetInstanceProcAddr; + graphicsVulkan.ConfigureEvent = Configure; graphicsVulkan.Instance = Instance; graphicsVulkan.AccessTexture = Access; + } + VkDeviceMemory memory = VK_NULL_HANDLE; + void* Source(bool srgb = false) + { + UnityVulkanImage previous = image; VkDeviceMemory previousMemory = memory; + VkImageCreateInfo create{ VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO }; + create.imageType = VK_IMAGE_TYPE_2D; create.format = srgb ? VK_FORMAT_R8G8B8A8_SRGB : VK_FORMAT_R8G8B8A8_UNORM; create.extent = { 64, 32, 1 }; + create.mipLevels = create.arrayLayers = 1; create.samples = VK_SAMPLE_COUNT_1_BIT; create.tiling = VK_IMAGE_TILING_OPTIMAL; + create.usage = VK_IMAGE_USAGE_TRANSFER_SRC_BIT | VK_IMAGE_USAGE_TRANSFER_DST_BIT; create.sharingMode = VK_SHARING_MODE_EXCLUSIVE; + Check(vkCreateImage(native.device, &create, nullptr, &image.image)); VkMemoryRequirements requirement{}; + vkGetImageMemoryRequirements(native.device, image.image, &requirement); VkPhysicalDeviceMemoryProperties properties{}; + vkGetPhysicalDeviceMemoryProperties(native.physicalDevice, &properties); VkMemoryAllocateInfo allocation{ VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO }; + allocation.allocationSize = requirement.size; + for (std::uint32_t i = 0; i < properties.memoryTypeCount; i++) if (requirement.memoryTypeBits & (1u << i)) { allocation.memoryTypeIndex = i; break; } + Check(vkAllocateMemory(native.device, &allocation, nullptr, &memory)); Check(vkBindImageMemory(native.device, image.image, memory, 0)); + VkCommandPool pool = VK_NULL_HANDLE; VkCommandPoolCreateInfo poolInfo{ VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO }; poolInfo.queueFamilyIndex = native.queueFamilyIndex; + Check(vkCreateCommandPool(native.device, &poolInfo, nullptr, &pool)); VkCommandBuffer command; + VkCommandBufferAllocateInfo buffers{ VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO }; buffers.commandPool = pool; buffers.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY; buffers.commandBufferCount = 1; + Check(vkAllocateCommandBuffers(native.device, &buffers, &command)); VkCommandBufferBeginInfo begin{ VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO }; Check(vkBeginCommandBuffer(command, &begin)); + VkImageMemoryBarrier barrier{ VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER }; barrier.image = image.image; + barrier.oldLayout = VK_IMAGE_LAYOUT_UNDEFINED; barrier.newLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL; barrier.dstAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; + barrier.srcQueueFamilyIndex = barrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; barrier.subresourceRange = { VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1 }; + vkCmdPipelineBarrier(command, VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 0, nullptr, 0, nullptr, 1, &barrier); + VkClearColorValue green{ { 0, 128.0f / 255, 0, 128.0f / 255 } }; + if (previous.image) + { + VkImageCopy copy{}; copy.srcSubresource = { VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1 }; copy.dstSubresource = copy.srcSubresource; copy.extent = create.extent; + vkCmdCopyImage(command, previous.image, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, image.image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, ©); + } + else vkCmdClearColorImage(command, image.image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, &green, 1, &barrier.subresourceRange); + barrier.oldLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL; barrier.newLayout = VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL; + barrier.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; barrier.dstAccessMask = VK_ACCESS_TRANSFER_READ_BIT; + vkCmdPipelineBarrier(command, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 0, nullptr, 0, nullptr, 1, &barrier); + Check(vkEndCommandBuffer(command)); VkSubmitInfo submit{ VK_STRUCTURE_TYPE_SUBMIT_INFO }; submit.commandBufferCount = 1; submit.pCommandBuffers = &command; + Check(vkQueueSubmit(native.graphicsQueue, 1, &submit, VK_NULL_HANDLE)); Check(vkQueueWaitIdle(native.graphicsQueue)); vkDestroyCommandPool(native.device, pool, nullptr); + if (previous.image) { vkDestroyImage(native.device, previous.image, nullptr); vkFreeMemory(native.device, previousMemory, nullptr); } + image.layout = VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL; image.extent = create.extent; image.format = create.format; image.samples = VK_SAMPLE_COUNT_1_BIT; + image.aspect = VK_IMAGE_ASPECT_COLOR_BIT; image.usage = create.usage; image.layers = image.mipCount = 1; return ℑ + } + void Flush() { Check(vkQueueWaitIdle(native.graphicsQueue)); } + void StopGraphics() { vkDestroyImage(native.device, image.image, nullptr); vkFreeMemory(native.device, memory, nullptr); vkDestroyDevice(native.device, nullptr); vkDestroyInstance(native.instance, nullptr); } +#else + IUnityGraphicsMetal graphicsMetal{}; + id device = nil; id queue = nil; id command = nil; id texture = nil; + UnityGfxRenderer UNITY_INTERFACE_API Renderer() { return kUnityGfxRendererMetal; } + id UNITY_INTERFACE_API Device() { return device; } + id UNITY_INTERFACE_API Queue() { return queue; } + id UNITY_INTERFACE_API Command() { return command; } + void UNITY_INTERFACE_API EndEncoder() {} + void StartGraphics() + { + [NSApplication sharedApplication]; device = MTLCreateSystemDefaultDevice(); queue = [device newCommandQueue]; + graphicsMetal.MetalDevice = Device; graphicsMetal.CurrentCommandBuffer = Command; graphicsMetal.EndCurrentCommandEncoder = EndEncoder; + } + void* Source(bool srgb = false) + { + MTLTextureDescriptor* description = [MTLTextureDescriptor texture2DDescriptorWithPixelFormat:srgb ? MTLPixelFormatRGBA8Unorm_sRGB : MTLPixelFormatRGBA8Unorm width:64 height:32 mipmapped:NO]; + description.storageMode = MTLStorageModeShared; description.usage = MTLTextureUsageShaderRead; + texture = [device newTextureWithDescriptor:description]; std::vector bytes(64 * 32 * 4); + for (std::size_t i = 0; i < bytes.size(); i += 4) { bytes[i + 1] = bytes[i + 3] = 128; } + [texture replaceRegion:MTLRegionMake2D(0, 0, 64, 32) mipmapLevel:0 withBytes:bytes.data() bytesPerRow:64 * 4]; return (__bridge void*)texture; + } + void Flush() { [command commit]; [command waitUntilCompleted]; Require(command.status != MTLCommandBufferStatusError, "Metal GPU work completed without an error"); command = nil; } + void StopGraphics() { texture = nil; queue = nil; device = nil; } +#endif + IUnityInterface* UNITY_INTERFACE_API Interface(UnityInterfaceGUID guid) + { + auto same = [&](UnityInterfaceGUID other) { return guid.m_GUIDHigh == other.m_GUIDHigh && guid.m_GUIDLow == other.m_GUIDLow; }; + if (same(GetUnityInterfaceGUID())) return &graphics; +#if !defined(__APPLE__) + if (same(GetUnityInterfaceGUID())) return &graphicsVulkan; +#else + if (same(GetUnityInterfaceGUID())) return &graphicsMetal; +#endif + return nullptr; + } + void Coordinates() + { + auto layout = vpnw::PhysicalLayout({ {{0, 0, 1920, 1080}, 3840, 2160}, {{-1920, 0, 1920, 1080}, 1920, 1080}, {{1920, 0, 1920, 1080}, 1920, 1080}, {{0, -1080, 1920, 1080}, 1920, 1080} }); + Require(layout[1].x == -1920 && layout[1].width == 1920, "Standard-DPI monitor left of Retina primary"); + Require(layout[2].x == 3840 && layout[2].width == 1920, "Standard-DPI monitor right of Retina primary"); + Require(layout[3].y == -1080 && layout[3].height == 1080, "Monitor above primary uses negative physical origin"); + } +#if !defined(__APPLE__) + // Test-only lookup and readback of the plugin's own window; production code never reads pixels back. + std::string Title(Display* connection, Window window) + { + char* name = nullptr; std::string result; + if (XFetchName(connection, window, &name) && name) { result = name; XFree(name); } + return result; + } + Window FindTitled(Display* connection, Window parent, const std::string& wanted, int depth) + { + if (depth > 16) return None; + Window root = None, actualParent = None, *children = nullptr; unsigned int count = 0; + if (!XQueryTree(connection, parent, &root, &actualParent, &children, &count)) return None; + Window found = None; + for (unsigned int i = 0; i < count && !found; i++) + found = Title(connection, children[i]) == wanted ? children[i] : FindTitled(connection, children[i], wanted, depth + 1); + if (children) XFree(children); + return found; + } + unsigned char Channel(unsigned long pixel, unsigned long mask) + { + if (!mask) return 0; + unsigned int shift = 0; while ((mask & 1) == 0) { mask >>= 1; shift++; } + return static_cast(((pixel >> shift) & mask) * 255 / mask); + } + // Reads the BGRA centre pixel of the plugin's window through a separate X11 connection. + bool CenterPixel(unsigned char (&output)[4]) + { + Display* connection = XOpenDisplay(nullptr); Require(connection != nullptr, "Open fixture X11 connection"); + bool captured = false; + Window window = FindTitled(connection, DefaultRootWindow(connection), "Visual Pinball Window", 0); + XWindowAttributes attributes{}; + if (window && XGetWindowAttributes(connection, window, &attributes) && attributes.map_state == IsViewable) + { + XImage* image = XGetImage(connection, window, attributes.width / 2, attributes.height / 2, 1, 1, AllPlanes, ZPixmap); + if (image) + { + unsigned long pixel = XGetPixel(image, 0, 0); + output[0] = Channel(pixel, image->blue_mask); output[1] = Channel(pixel, image->green_mask); output[2] = Channel(pixel, image->red_mask); + output[3] = attributes.depth == 32 ? static_cast(pixel >> 24) : 255; + XDestroyImage(image); captured = true; + } + } + XCloseDisplay(connection); return captured; + } +#endif + void Pixels() + { +#if !defined(__APPLE__) + // Some virtual X servers (Xvfb, WSLg) do not expose Vulkan-presented contents to XGetImage. + if (const char* skip = std::getenv("VPNW_SKIP_PIXEL_CHECK"); skip && *skip == '1') { std::cout << "Pixel readback skipped (VPNW_SKIP_PIXEL_CHECK=1).\n"; return; } + unsigned char pixel[4]{}; + for (int attempt = 0; attempt < 100; attempt++) + { + Require(CenterPixel(pixel), "Find the native window by title and capture its presented GPU pixels"); + if (pixel[1] != 0) break; + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + const std::string observed = "GPU output preserves encoded green and alpha bytes (BGRA " + std::to_string(pixel[0]) + "," + + std::to_string(pixel[1]) + "," + std::to_string(pixel[2]) + "," + std::to_string(pixel[3]) + ")"; + Require(pixel[0] == 0 && pixel[1] == 128 && pixel[2] == 0 && pixel[3] == 128, observed.c_str()); +#endif + } + void Windows() + { + StartGraphics(); +#if defined(__APPLE__) + if (!device) { std::cout << "Metal GPU unavailable on this host; coordinate fixtures still execute.\n"; return; } +#endif + graphics.GetRenderer = Renderer; graphics.RegisterDeviceEventCallback = Register; graphics.UnregisterDeviceEventCallback = Unregister; graphics.ReserveEventIDRange = Reserve; + interfaces.GetInterface = Interface; UnityPluginLoad(&interfaces); loaded = true; + const std::uint32_t capabilities = VPNW_CapabilityWindows | VPNW_CapabilityPlacement | VPNW_CapabilityTopmost | VPNW_CapabilityTransparency; + Require(VPNW_GetCapabilities() == capabilities, "Window, placement, topmost and transparency capabilities"); + int id = VPNW_CreateWindow(-80, 24, 64, 32); Require(id > 0, "Create borderless window at negative origin"); + VPNW_Diagnostics diagnostics{}; diagnostics.structBytes = sizeof(diagnostics); + Require(VPNW_GetDiagnostics(id, &diagnostics) && diagnostics.left == -80 && diagnostics.top == 24 && diagnostics.visible == 0, "Physical geometry and initially hidden window"); + Require(VPNW_ConfigureWindow(id, 20, 24, 96, 48, 1, 1), "Position, resize, visibility, and topmost"); + auto render = reinterpret_cast(VPNW_GetRenderEventFunc()); + for (bool srgb : { false, true }) + { + Require(VPNW_SetTexture(id, Source(srgb), 0), "Bind native GPU texture"); + for (int frame = 0; frame < 8; frame++) + { +#if defined(__APPLE__) + command = [queue commandBuffer]; +#endif + render(VPNW_GetDrawEventId()); render(VPNW_GetPresentEventId()); Flush(); VPNW_Pump(); + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + Require(VPNW_SetPlacementMode(id, 1) && VPNW_SetPlacementMode(id, 0), "Toggle native placement interaction"); + Require(VPNW_GetDiagnostics(id, &diagnostics), "Read presentation diagnostics"); + std::cout << "srgb=" << srgb << " renderer=" << diagnostics.graphicsRenderer << " visible=" << diagnostics.visible << " dpi=" << diagnostics.dpi << + " rect=" << diagnostics.left << ',' << diagnostics.top << ' ' << diagnostics.windowWidth << 'x' << diagnostics.windowHeight << + " presents=" << diagnostics.presentedFrames << " skips=" << diagnostics.skippedFrames << " result=" << diagnostics.lastResult << " error='" << Error() << "'" << std::endl; + Pixels(); + } + Require(VPNW_GetDiagnostics(id, &diagnostics) && diagnostics.presentedFrames > 0 && diagnostics.premultipliedAlpha == 1, "GPU submission and premultiplied transparency"); + Require(diagnostics.sourceWidth == 64 && diagnostics.sourceHeight == 32, "Native source dimensions"); + Require(VPNW_SetZOrder(id, 0) && VPNW_SetZOrder(id, 1) && VPNW_SetZOrder(id, 3), "Back/front/topmost operations"); + Require(VPNW_SetTexture(id, nullptr, 0), "Detach native texture"); Require(VPNW_DestroyWindow(id) && VPNW_DestroyWindow(id), "Idempotent destruction"); + render(VPNW_GetPresentEventId()); Require(VPNW_GetPendingResourceCount() == 0, "Deferred GPU retirement"); + UnityPluginUnload(); loaded = false; StopGraphics(); + gpuTested = true; + } +} + +int main() +{ + try + { + Coordinates(); Windows(); + std::cout << "Native coordinate fixtures passed. " << (gpuTested ? "GPU window fixtures passed.\n" : "GPU window fixtures skipped.\n"); return 0; + } + catch (const std::exception& error) { std::cerr << error.what() << '\n'; if (loaded) { UnityPluginUnload(); StopGraphics(); } return 1; } +} diff --git a/VisualPinball.Native~/NativeWindows/Desktop/Linux/VulkanWindows.cpp b/VisualPinball.Native~/NativeWindows/Desktop/Linux/VulkanWindows.cpp new file mode 100644 index 000000000..30c343a24 --- /dev/null +++ b/VisualPinball.Native~/NativeWindows/Desktop/Linux/VulkanWindows.cpp @@ -0,0 +1,367 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// SPDX-License-Identifier: MIT +// Licensed under MIT. See https://opensource.org/license/mit. + +#define VK_USE_PLATFORM_XLIB_KHR +#include "X11Windows.h" +#include "IUnityGraphicsVulkan.h" +#include +#include +#include +#include +#include +#include + +namespace +{ + IUnityGraphicsVulkan* vulkan = nullptr; + UnityVulkanInstance instance{}; + PFN_vkGetInstanceProcAddr loader = nullptr; + VKAPI_ATTR VkResult VKAPI_CALL CreateInstance(const VkInstanceCreateInfo* requested, const VkAllocationCallbacks* allocator, VkInstance* result) + { + try + { + VkInstanceCreateInfo create = *requested; + std::vector extensions(requested->ppEnabledExtensionNames, requested->ppEnabledExtensionNames + requested->enabledExtensionCount); + for (const char* name : { VK_KHR_SURFACE_EXTENSION_NAME, VK_KHR_XLIB_SURFACE_EXTENSION_NAME }) + if (std::none_of(extensions.begin(), extensions.end(), [&](const char* value) { return std::strcmp(value, name) == 0; })) extensions.push_back(name); + create.enabledExtensionCount = static_cast(extensions.size()); create.ppEnabledExtensionNames = extensions.data(); + return reinterpret_cast(loader(VK_NULL_HANDLE, "vkCreateInstance"))(&create, allocator, result); + } + catch (...) { return VK_ERROR_OUT_OF_HOST_MEMORY; } + } + VKAPI_ATTR PFN_vkVoidFunction VKAPI_CALL GetInstanceProcAddr(VkInstance value, const char* name) + { + if (!value && std::strcmp(name, "vkCreateInstance") == 0) return reinterpret_cast(CreateInstance); + return loader(value, name); + } + PFN_vkGetInstanceProcAddr UNITY_INTERFACE_API Intercept(PFN_vkGetInstanceProcAddr original, void*) { loader = original; return GetInstanceProcAddr; } + void Check(VkResult result, const char* operation) + { + if (result != VK_SUCCESS) throw std::runtime_error(std::string(operation) + ": Vulkan result " + std::to_string(result)); + } + struct Frame + { + VkCommandBuffer command = VK_NULL_HANDLE; + VkFence fence = VK_NULL_HANDLE; + VkSemaphore acquired = VK_NULL_HANDLE; + VkImage raw = VK_NULL_HANDLE; + VkDeviceMemory memory = VK_NULL_HANDLE; + VkExtent2D size{}; + VkFormat format = VK_FORMAT_UNDEFINED; + bool initialized = false; + }; + class VulkanWindow final : public vpnw::WindowBackend + { + std::mutex mutex; + Window window = None; + VkSurfaceKHR surface = VK_NULL_HANDLE; + VkSwapchainKHR swapchain = VK_NULL_HANDLE; + VkCommandPool pool = VK_NULL_HANDLE; + std::array frames{}; + std::vector images; + // Present semaphores are owned by swapchain images, so reacquisition proves their prior wait has finished. + std::vector presented; + std::vector initialized; + VkExtent2D extent{}; + VkFormat format = VK_FORMAT_UNDEFINED; + void* texture = nullptr; + UnityVulkanImage source{}; + bool sourceReady = false, flip = false, alive = true, visible = false, topmost = false, rebuild = true, placement = false; + int left = 0, top = 0, width = 1, height = 1; + std::size_t nextFrame = 0; + std::uint64_t submitted = 0, skipped = 0; + int lastResult = 0; + + bool FramesReady() const + { + for (const auto& frame : frames) if (frame.fence && vkGetFenceStatus(instance.device, frame.fence) == VK_NOT_READY) return false; + return true; + } + void ReleaseSwapchain() + { + if (!swapchain) return; + // This runs only on resize, retirement, or graphics shutdown, after copy fences finish. + // Queue idling also retires presentation waits; ordinary frames never wait for GPU completion. + VkResult idle = vkQueueWaitIdle(instance.graphicsQueue); + if (idle != VK_ERROR_DEVICE_LOST) Check(idle, "Retire desktop presentation"); + for (auto semaphore : presented) if (semaphore) vkDestroySemaphore(instance.device, semaphore, nullptr); + presented.clear(); images.clear(); initialized.clear(); + vkDestroySwapchainKHR(instance.device, swapchain, nullptr); swapchain = VK_NULL_HANDLE; + } + void BuildSwapchain() + { + VkSurfaceCapabilitiesKHR capabilities{}; + Check(vkGetPhysicalDeviceSurfaceCapabilitiesKHR(instance.physicalDevice, surface, &capabilities), "Query desktop surface"); + if (!(capabilities.supportedUsageFlags & VK_IMAGE_USAGE_TRANSFER_DST_BIT)) throw std::runtime_error("The desktop surface cannot receive GPU texture copies."); + std::uint32_t count = 0; + Check(vkGetPhysicalDeviceSurfaceFormatsKHR(instance.physicalDevice, surface, &count, nullptr), "Query desktop formats"); + std::vector formats(count); + Check(vkGetPhysicalDeviceSurfaceFormatsKHR(instance.physicalDevice, surface, &count, formats.data()), "Query desktop formats"); + VkSurfaceFormatKHR selected{ VK_FORMAT_UNDEFINED, VK_COLOR_SPACE_SRGB_NONLINEAR_KHR }; + for (const auto& candidate : formats) + if ((candidate.format == VK_FORMAT_B8G8R8A8_UNORM || candidate.format == VK_FORMAT_R8G8B8A8_UNORM) && candidate.colorSpace == VK_COLOR_SPACE_SRGB_NONLINEAR_KHR) { selected = candidate; break; } + if (selected.format == VK_FORMAT_UNDEFINED) throw std::runtime_error("The desktop surface has no encoded SDR UNORM format."); + VkCompositeAlphaFlagBitsKHR alpha = VK_COMPOSITE_ALPHA_PRE_MULTIPLIED_BIT_KHR; + if (!(capabilities.supportedCompositeAlpha & alpha)) + { + if (capabilities.supportedCompositeAlpha & VK_COMPOSITE_ALPHA_INHERIT_BIT_KHR) alpha = VK_COMPOSITE_ALPHA_INHERIT_BIT_KHR; + else throw std::runtime_error("The desktop compositor cannot preserve premultiplied transparency."); + } + VkExtent2D size = capabilities.currentExtent; + if (size.width == UINT32_MAX) + { + size.width = std::clamp(static_cast(width), capabilities.minImageExtent.width, capabilities.maxImageExtent.width); + size.height = std::clamp(static_cast(height), capabilities.minImageExtent.height, capabilities.maxImageExtent.height); + } + std::uint32_t imageCount = capabilities.minImageCount + 1; + if (capabilities.maxImageCount && imageCount > capabilities.maxImageCount) imageCount = capabilities.maxImageCount; + VkPresentModeKHR mode = VK_PRESENT_MODE_FIFO_KHR; + Check(vkGetPhysicalDeviceSurfacePresentModesKHR(instance.physicalDevice, surface, &count, nullptr), "Query presentation modes"); + std::vector modes(count); + Check(vkGetPhysicalDeviceSurfacePresentModesKHR(instance.physicalDevice, surface, &count, modes.data()), "Query presentation modes"); + for (auto candidate : modes) if (candidate == VK_PRESENT_MODE_IMMEDIATE_KHR) mode = candidate; + VkSwapchainCreateInfoKHR create{ VK_STRUCTURE_TYPE_SWAPCHAIN_CREATE_INFO_KHR }; + create.surface = surface; create.minImageCount = imageCount; create.imageFormat = selected.format; create.imageColorSpace = selected.colorSpace; + create.imageExtent = size; create.imageArrayLayers = 1; create.imageUsage = VK_IMAGE_USAGE_TRANSFER_DST_BIT; + create.imageSharingMode = VK_SHARING_MODE_EXCLUSIVE; create.preTransform = capabilities.currentTransform; + create.compositeAlpha = alpha; create.presentMode = mode; create.clipped = VK_TRUE; + Check(vkCreateSwapchainKHR(instance.device, &create, nullptr, &swapchain), "Create desktop swapchain"); + Check(vkGetSwapchainImagesKHR(instance.device, swapchain, &count, nullptr), "Query swapchain images"); + images.resize(count); presented.resize(count); initialized.assign(count, false); + Check(vkGetSwapchainImagesKHR(instance.device, swapchain, &count, images.data()), "Query swapchain images"); + VkSemaphoreCreateInfo semaphore{ VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO }; + for (auto& ready : presented) Check(vkCreateSemaphore(instance.device, &semaphore, nullptr, &ready), "Create presentation semaphore"); + extent = size; format = selected.format; rebuild = false; + } + VkImage EncodedSource(Frame& frame) + { + if (source.format != VK_FORMAT_R8G8B8A8_SRGB && source.format != VK_FORMAT_B8G8R8A8_SRGB) return source.image; + VkFormat rawFormat = source.format == VK_FORMAT_R8G8B8A8_SRGB ? VK_FORMAT_R8G8B8A8_UNORM : VK_FORMAT_B8G8R8A8_UNORM; + if (!frame.raw || frame.size.width != source.extent.width || frame.size.height != source.extent.height || frame.format != rawFormat) + { + if (frame.raw) vkDestroyImage(instance.device, frame.raw, nullptr); + if (frame.memory) vkFreeMemory(instance.device, frame.memory, nullptr); + frame.raw = VK_NULL_HANDLE; frame.memory = VK_NULL_HANDLE; frame.initialized = false; + VkImageCreateInfo create{ VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO }; + create.imageType = VK_IMAGE_TYPE_2D; create.format = rawFormat; create.extent = source.extent; + create.mipLevels = create.arrayLayers = 1; create.samples = VK_SAMPLE_COUNT_1_BIT; create.tiling = VK_IMAGE_TILING_OPTIMAL; + create.usage = VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_TRANSFER_SRC_BIT; create.sharingMode = VK_SHARING_MODE_EXCLUSIVE; + Check(vkCreateImage(instance.device, &create, nullptr, &frame.raw), "Create encoded desktop snapshot"); + VkMemoryRequirements requirements{}; vkGetImageMemoryRequirements(instance.device, frame.raw, &requirements); + VkPhysicalDeviceMemoryProperties properties{}; vkGetPhysicalDeviceMemoryProperties(instance.physicalDevice, &properties); + VkMemoryAllocateInfo allocation{ VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO }; allocation.allocationSize = requirements.size; + bool selected = false; + for (std::uint32_t i = 0; i < properties.memoryTypeCount; i++) + if (requirements.memoryTypeBits & (1u << i)) + { + if (!selected || (properties.memoryTypes[i].propertyFlags & VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT)) allocation.memoryTypeIndex = i; + selected = true; if (properties.memoryTypes[i].propertyFlags & VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT) break; + } + if (!selected) throw std::runtime_error("No device memory for encoded desktop snapshot."); + Check(vkAllocateMemory(instance.device, &allocation, nullptr, &frame.memory), "Allocate encoded desktop snapshot"); + Check(vkBindImageMemory(instance.device, frame.raw, frame.memory, 0), "Bind encoded desktop snapshot"); + frame.size = { source.extent.width, source.extent.height }; frame.format = rawFormat; + } + VkImageMemoryBarrier barrier{ VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER }; barrier.image = frame.raw; + barrier.oldLayout = frame.initialized ? VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL : VK_IMAGE_LAYOUT_UNDEFINED; + barrier.newLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL; barrier.srcAccessMask = frame.initialized ? VK_ACCESS_TRANSFER_READ_BIT : 0; + barrier.dstAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; barrier.srcQueueFamilyIndex = barrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + barrier.subresourceRange = { VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1 }; + vkCmdPipelineBarrier(frame.command, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 0, nullptr, 0, nullptr, 1, &barrier); + VkImageCopy copy{}; copy.srcSubresource = { VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1 }; copy.dstSubresource = copy.srcSubresource; copy.extent = source.extent; + // Copy preserves raw sRGB bytes; a direct sRGB blit would decode them into linear color. + vkCmdCopyImage(frame.command, source.image, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, frame.raw, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, ©); + barrier.oldLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL; barrier.newLayout = VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL; + barrier.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; barrier.dstAccessMask = VK_ACCESS_TRANSFER_READ_BIT; + vkCmdPipelineBarrier(frame.command, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 0, nullptr, 0, nullptr, 1, &barrier); + frame.initialized = true; return frame.raw; + } + public: + VulkanWindow(int x, int y, int w, int h) : left(x), top(y), width(w), height(h) + { + try + { + if (!vulkan || !instance.device) throw std::runtime_error("Unity Vulkan is required for Linux desktop output."); + window = vpnw::XCreateBorderless(x, y, w, h); + VkXlibSurfaceCreateInfoKHR create{ VK_STRUCTURE_TYPE_XLIB_SURFACE_CREATE_INFO_KHR }; + create.dpy = vpnw::XDisplay(); create.window = window; + Check(vkCreateXlibSurfaceKHR(instance.instance, &create, nullptr, &surface), "Create X11 Vulkan surface"); + VkBool32 supported = VK_FALSE; + Check(vkGetPhysicalDeviceSurfaceSupportKHR(instance.physicalDevice, instance.queueFamilyIndex, surface, &supported), "Query presentation queue"); + if (!supported) throw std::runtime_error("Unity's Vulkan queue cannot present to this X11 display."); + VkCommandPoolCreateInfo poolInfo{ VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO }; + poolInfo.flags = VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT; poolInfo.queueFamilyIndex = instance.queueFamilyIndex; + Check(vkCreateCommandPool(instance.device, &poolInfo, nullptr, &pool), "Create desktop command pool"); + std::array buffers{}; + VkCommandBufferAllocateInfo allocation{ VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO }; + allocation.commandPool = pool; allocation.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY; allocation.commandBufferCount = 3; + Check(vkAllocateCommandBuffers(instance.device, &allocation, buffers.data()), "Allocate desktop commands"); + VkFenceCreateInfo fence{ VK_STRUCTURE_TYPE_FENCE_CREATE_INFO }; fence.flags = VK_FENCE_CREATE_SIGNALED_BIT; + VkSemaphoreCreateInfo semaphore{ VK_STRUCTURE_TYPE_SEMAPHORE_CREATE_INFO }; + for (std::size_t i = 0; i < frames.size(); i++) + { + frames[i].command = buffers[i]; + Check(vkCreateFence(instance.device, &fence, nullptr, &frames[i].fence), "Create desktop frame fence"); + Check(vkCreateSemaphore(instance.device, &semaphore, nullptr, &frames[i].acquired), "Create acquisition semaphore"); + } + } + catch (...) { Cleanup(); throw; } + } + ~VulkanWindow() override + { + Cleanup(); + } + void Cleanup() noexcept + { + if (swapchain) { vkQueueWaitIdle(instance.graphicsQueue); for (auto semaphore : presented) if (semaphore) vkDestroySemaphore(instance.device, semaphore, nullptr); vkDestroySwapchainKHR(instance.device, swapchain, nullptr); } + for (const auto& frame : frames) + { + if (frame.fence) vkDestroyFence(instance.device, frame.fence, nullptr); + if (frame.acquired) vkDestroySemaphore(instance.device, frame.acquired, nullptr); + if (frame.raw) vkDestroyImage(instance.device, frame.raw, nullptr); + if (frame.memory) vkFreeMemory(instance.device, frame.memory, nullptr); + } + if (pool) vkDestroyCommandPool(instance.device, pool, nullptr); + if (surface) vkDestroySurfaceKHR(instance.instance, surface, nullptr); + if (window) { try { vpnw::XDestroyBorderless(window); } catch (...) {} } + } + bool Configure(int x, int y, int w, int h, bool show, bool stayOnTop) override + { + std::lock_guard lock(mutex); + rebuild |= width != w || height != h; left = x; top = y; width = w; height = h; visible = show; topmost = stayOnTop; + return vpnw::XConfigure(window, x, y, w, h, show, stayOnTop); + } + bool SetOrder(int order) override + { + std::lock_guard lock(mutex); if (order >= 2) topmost = order == 2; return vpnw::XOrder(window, order); + } + bool SetTexture(void* value, bool flipY) override + { + std::lock_guard lock(mutex); texture = value; flip = flipY; sourceReady = false; return true; + } + bool SetPlacementMode(bool enabled) override { std::lock_guard lock(mutex); placement = enabled; return vpnw::XSetPlacementMode(window, enabled); } + void Retire() override + { + std::lock_guard lock(mutex); alive = visible = false; texture = nullptr; sourceReady = false; + vpnw::XConfigure(window, left, top, width, height, false, topmost); + } + bool Collect() override + { + std::lock_guard lock(mutex); if (lastResult == 0 && !FramesReady()) return false; ReleaseSwapchain(); return true; + } + void Draw() override + { + std::lock_guard lock(mutex); sourceReady = false; + if (!alive || !visible || !texture || !vulkan) return; + sourceReady = vulkan->AccessTexture(texture, UnityVulkanWholeImage, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, + VK_PIPELINE_STAGE_TRANSFER_BIT, VK_ACCESS_TRANSFER_READ_BIT, kUnityVulkanResourceAccess_PipelineBarrier, &source); + if (sourceReady && (source.samples != VK_SAMPLE_COUNT_1_BIT || source.layers != 1 || source.extent.depth != 1 || + (source.format != VK_FORMAT_R8G8B8A8_UNORM && source.format != VK_FORMAT_B8G8R8A8_UNORM && source.format != VK_FORMAT_R8G8B8A8_SRGB && source.format != VK_FORMAT_B8G8R8A8_SRGB))) + { + sourceReady = false; vpnw::SetError("Linux desktop output requires a single-layer, single-sample RGBA8/BGRA8 texture."); + } + } + void Present() override + { + std::lock_guard lock(mutex); + VPNW_Diagnostics bounds{}; + if (alive && placement && vpnw::XDiagnostics(window, bounds)) { + rebuild |= width != bounds.windowWidth || height != bounds.windowHeight; + left = bounds.left; top = bounds.top; width = bounds.windowWidth; height = bounds.windowHeight; + } + if (!alive || !visible || !sourceReady) { skipped++; return; } + try + { + if (rebuild) { if (!FramesReady()) { skipped++; return; } ReleaseSwapchain(); BuildSwapchain(); } + auto& frame = frames[nextFrame]; + if (vkGetFenceStatus(instance.device, frame.fence) == VK_NOT_READY) { skipped++; return; } + std::uint32_t index = 0; + VkResult result = vkAcquireNextImageKHR(instance.device, swapchain, 0, frame.acquired, VK_NULL_HANDLE, &index); + if (result == VK_ERROR_OUT_OF_DATE_KHR) { rebuild = true; skipped++; return; } + if (result == VK_NOT_READY || result == VK_TIMEOUT) { skipped++; return; } + if (result != VK_SUBOPTIMAL_KHR) Check(result, "Acquire desktop image"); + Check(vkResetCommandBuffer(frame.command, 0), "Reset desktop command"); + VkCommandBufferBeginInfo begin{ VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO }; begin.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT; + Check(vkBeginCommandBuffer(frame.command, &begin), "Begin desktop copy"); + VkImageMemoryBarrier barrier{ VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER }; + barrier.oldLayout = initialized[index] ? VK_IMAGE_LAYOUT_PRESENT_SRC_KHR : VK_IMAGE_LAYOUT_UNDEFINED; + barrier.newLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL; barrier.dstAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; + barrier.srcQueueFamilyIndex = barrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; barrier.image = images[index]; + barrier.subresourceRange = { VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1 }; + vkCmdPipelineBarrier(frame.command, VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 0, nullptr, 0, nullptr, 1, &barrier); + VkImageBlit copy{}; copy.srcSubresource = { VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1 }; copy.dstSubresource = copy.srcSubresource; + copy.srcOffsets[0] = { 0, flip ? static_cast(source.extent.height) : 0, 0 }; + copy.srcOffsets[1] = { static_cast(source.extent.width), flip ? 0 : static_cast(source.extent.height), 1 }; + copy.dstOffsets[1] = { static_cast(extent.width), static_cast(extent.height), 1 }; + VkImage encoded = EncodedSource(frame); + vkCmdBlitImage(frame.command, encoded, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, images[index], VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, ©, VK_FILTER_LINEAR); + barrier.oldLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL; barrier.newLayout = VK_IMAGE_LAYOUT_PRESENT_SRC_KHR; + barrier.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; barrier.dstAccessMask = 0; + vkCmdPipelineBarrier(frame.command, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT, 0, 0, nullptr, 0, nullptr, 1, &barrier); + Check(vkEndCommandBuffer(frame.command), "End desktop copy"); + Check(vkResetFences(instance.device, 1, &frame.fence), "Reset desktop fence"); + VkPipelineStageFlags stage = VK_PIPELINE_STAGE_TRANSFER_BIT; + VkSubmitInfo submit{ VK_STRUCTURE_TYPE_SUBMIT_INFO }; + submit.waitSemaphoreCount = 1; submit.pWaitSemaphores = &frame.acquired; submit.pWaitDstStageMask = &stage; + submit.commandBufferCount = 1; submit.pCommandBuffers = &frame.command; submit.signalSemaphoreCount = 1; submit.pSignalSemaphores = &presented[index]; + Check(vkQueueSubmit(instance.graphicsQueue, 1, &submit, frame.fence), "Submit desktop copy"); + VkPresentInfoKHR present{ VK_STRUCTURE_TYPE_PRESENT_INFO_KHR }; + present.waitSemaphoreCount = 1; present.pWaitSemaphores = &presented[index]; present.swapchainCount = 1; present.pSwapchains = &swapchain; present.pImageIndices = &index; + result = vkQueuePresentKHR(instance.graphicsQueue, &present); + if (result == VK_ERROR_OUT_OF_DATE_KHR || result == VK_SUBOPTIMAL_KHR) rebuild = true; else Check(result, "Present desktop image"); + initialized[index] = true; nextFrame = (nextFrame + 1) % frames.size(); submitted++; lastResult = 0; + } + catch (const std::exception& error) { lastResult = -1; alive = false; skipped++; vpnw::SetError(error.what()); } + } + VPNW_Diagnostics Diagnostics() override + { + std::lock_guard lock(mutex); + VPNW_Diagnostics result{}; result.structBytes = sizeof(result); result.graphicsRenderer = kUnityGfxRendererVulkan; + result.deviceReady = instance.device ? 1 : 0; result.alive = alive ? 1 : 0; result.topmost = topmost ? 1 : 0; + result.left = left; result.top = top; result.windowWidth = width; result.windowHeight = height; result.visible = visible ? 1 : 0; + vpnw::XDiagnostics(window, result); result.sourceWidth = source.extent.width; result.sourceHeight = source.extent.height; + result.sourceFormat = static_cast(source.format); result.presentedFrames = submitted; result.skippedFrames = skipped; result.lastResult = lastResult; result.premultipliedAlpha = 1; return result; + } + }; +} + +void vpnw::PreloadGraphics(IUnityInterfaces* interfaces) +{ + // Retain XWayland presentation when Unity itself selects a Wayland surface. + const char* display = std::getenv("DISPLAY"); + auto* api = interfaces->Get(); + if (!display || !*display) return; + // The hook only works when the library is preloaded, before Unity creates its Vulkan instance (see the .so importer). + if (!api || !api->AddInterceptInitialization(Intercept, nullptr, 100)) + SetError("Could not register the Vulkan initialization hook; native windows need the library preloaded at startup."); +} +void vpnw::UnloadGraphics(IUnityInterfaces* interfaces) +{ + auto* api = interfaces->Get(); + if (api) api->RemoveInterceptInitialization(Intercept); +} +void vpnw::InitializeGraphics(IUnityInterfaces* interfaces, UnityGfxRenderer renderer) +{ + if (renderer != kUnityGfxRendererVulkan) { SetError("Linux native windows require Unity Vulkan."); return; } + vulkan = interfaces->Get(); + if (!vulkan) throw std::runtime_error("Unity's Vulkan native interface is unavailable."); + instance = vulkan->Instance(); + UnityVulkanPluginEventConfig draw{}; + draw.renderPassPrecondition = kUnityVulkanRenderPass_EnsureOutside; + draw.graphicsQueueAccess = kUnityVulkanGraphicsQueueAccess_DontCare; + draw.flags = kUnityVulkanEventConfigFlag_EnsurePreviousFrameSubmission; + vulkan->ConfigureEvent(DrawEvent, &draw); + UnityVulkanPluginEventConfig present{}; + present.renderPassPrecondition = kUnityVulkanRenderPass_DontCare; + present.graphicsQueueAccess = kUnityVulkanGraphicsQueueAccess_Allow; + present.flags = kUnityVulkanEventConfigFlag_EnsurePreviousFrameSubmission | kUnityVulkanEventConfigFlag_FlushCommandBuffers | kUnityVulkanEventConfigFlag_SyncWorkerThreads; + vulkan->ConfigureEvent(PresentEvent, &present); +} +void vpnw::ShutdownGraphics() { if (instance.device) vkDeviceWaitIdle(instance.device); vulkan = nullptr; } +std::shared_ptr vpnw::CreateWindowBackend(int x, int y, int width, int height) +{ + return std::make_shared(x, y, width, height); +} diff --git a/VisualPinball.Native~/NativeWindows/Desktop/Linux/X11Windows.cpp b/VisualPinball.Native~/NativeWindows/Desktop/Linux/X11Windows.cpp new file mode 100644 index 000000000..d0d943c32 --- /dev/null +++ b/VisualPinball.Native~/NativeWindows/Desktop/Linux/X11Windows.cpp @@ -0,0 +1,164 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// SPDX-License-Identifier: MIT +// Licensed under MIT. See https://opensource.org/license/mit. + +#include "X11Windows.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ + std::mutex displayMutex; + Display* display = nullptr; + std::mutex initializationMutex; + std::unordered_map colormaps; + std::unordered_set placementWindows; + XErrorHandler previousError = nullptr; + int dpi = 0; + int Error(Display* connection, XErrorEvent* event) + { + if (connection != display) return previousError ? previousError(connection, event) : 0; + char message[256]{}; XGetErrorText(connection, event->error_code, message, sizeof(message)); + vpnw::SetError(std::string("X11: ") + message); return 0; + } + Atom Property(const char* name) { return XInternAtom(vpnw::XDisplay(), name, False); } + void State(Window window, int action, Atom first, Atom second = None) + { + XEvent event{}; event.xclient.type = ClientMessage; event.xclient.window = window; + event.xclient.message_type = Property("_NET_WM_STATE"); event.xclient.format = 32; + event.xclient.data.l[0] = action; event.xclient.data.l[1] = static_cast(first); + event.xclient.data.l[2] = static_cast(second); event.xclient.data.l[3] = 1; + XSendEvent(vpnw::XDisplay(), DefaultRootWindow(vpnw::XDisplay()), False, SubstructureRedirectMask | SubstructureNotifyMask, &event); + } + // Desktop scale as published by the session (Xft.dpi in RESOURCE_MANAGER); X11 itself has no per-window DPI. + int DesktopDpi(Display* connection) + { + if (dpi) return dpi; + dpi = 96; + if (const char* resources = XResourceManagerString(connection)) + { + XrmInitialize(); + if (XrmDatabase database = XrmGetStringDatabase(resources)) + { + char* type = nullptr; XrmValue value{}; + if (XrmGetResource(database, "Xft.dpi", "Xft.Dpi", &type, &value) && value.addr) + { + const double parsed = std::atof(value.addr); + if (parsed > 0 && parsed <= 960) dpi = static_cast(std::lround(parsed)); + } + XrmDestroyDatabase(database); + } + } + return dpi; + } +} + +Display* vpnw::XDisplay() +{ + std::lock_guard lock(initializationMutex); + if (!display) + { + XInitThreads(); display = XOpenDisplay(nullptr); + if (!display) throw std::runtime_error("An X11/XWayland DISPLAY is required for native desktop windows. Wayland toplevel placement is compositor controlled."); + previousError = XSetErrorHandler(Error); + } + return display; +} +std::mutex& vpnw::XMutex() { return displayMutex; } +Window vpnw::XCreateBorderless(int x, int y, int width, int height) +{ + std::lock_guard lock(displayMutex); Display* connection = XDisplay(); + XVisualInfo query{}; query.screen = DefaultScreen(connection); query.depth = 32; query.c_class = TrueColor; + int count = 0; XVisualInfo* visuals = XGetVisualInfo(connection, VisualScreenMask | VisualDepthMask | VisualClassMask, &query, &count); + Visual* visual = nullptr; + for (int i = 0; i < count; i++) { auto* format = XRenderFindVisualFormat(connection, visuals[i].visual); if (format && format->direct.alphaMask) { visual = visuals[i].visual; break; } } + if (visuals) XFree(visuals); + if (!visual) throw std::runtime_error("The X11 display has no 32-bit ARGB visual for transparent windows."); + XSetWindowAttributes attributes{}; attributes.colormap = XCreateColormap(connection, DefaultRootWindow(connection), visual, AllocNone); + attributes.border_pixel = 0; attributes.background_pixel = 0; attributes.event_mask = StructureNotifyMask | ExposureMask | ButtonPressMask; + Window window = XCreateWindow(connection, DefaultRootWindow(connection), x, y, static_cast(width), static_cast(height), 0, 32, InputOutput, visual, + CWColormap | CWBorderPixel | CWBackPixel | CWEventMask, &attributes); + colormaps.emplace(window, attributes.colormap); + unsigned long motif[] = { 2, 0, 0, 0, 0 }; + XChangeProperty(connection, window, Property("_MOTIF_WM_HINTS"), Property("_MOTIF_WM_HINTS"), 32, PropModeReplace, reinterpret_cast(motif), 5); + XStoreName(connection, window, "Visual Pinball Window"); + XClassHint classHint{}; char className[] = "VisualPinball.NativeWindow"; classHint.res_name = className; classHint.res_class = className; + XSetClassHint(connection, window, &classHint); + XWMHints hints{}; hints.flags = InputHint; hints.input = False; XSetWMHints(connection, window, &hints); + Atom states[] = { Property("_NET_WM_STATE_SKIP_TASKBAR"), Property("_NET_WM_STATE_SKIP_PAGER") }; + XChangeProperty(connection, window, Property("_NET_WM_STATE"), XA_ATOM, 32, PropModeReplace, reinterpret_cast(states), 2); + XFlush(connection); return window; +} +void vpnw::XDestroyBorderless(Window window) +{ + std::lock_guard lock(displayMutex); + XDestroyWindow(XDisplay(), window); + placementWindows.erase(window); + auto found = colormaps.find(window); + if (found != colormaps.end()) { XFreeColormap(XDisplay(), found->second); colormaps.erase(found); } + XFlush(XDisplay()); +} +bool vpnw::XConfigure(Window window, int x, int y, int width, int height, bool visible, bool topmost) +{ + std::lock_guard lock(displayMutex); + XMoveResizeWindow(XDisplay(), window, x, y, static_cast(width), static_cast(height)); + State(window, topmost ? 1 : 0, Property("_NET_WM_STATE_ABOVE")); + if (visible) XMapWindow(XDisplay(), window); else XUnmapWindow(XDisplay(), window); + XFlush(XDisplay()); return true; +} +bool vpnw::XOrder(Window window, int order) +{ + std::lock_guard lock(displayMutex); + if (order == 0) XLowerWindow(XDisplay(), window); else if (order == 1) XRaiseWindow(XDisplay(), window); + else State(window, order == 2 ? 1 : 0, Property("_NET_WM_STATE_ABOVE")); + XFlush(XDisplay()); return true; +} +bool vpnw::XSetPlacementMode(Window window, bool enabled) +{ + std::lock_guard lock(displayMutex); + if (enabled) placementWindows.insert(window); else placementWindows.erase(window); + return true; +} +bool vpnw::XDiagnostics(Window window, VPNW_Diagnostics& output) +{ + std::lock_guard lock(displayMutex); XWindowAttributes attributes{}; + if (!XGetWindowAttributes(XDisplay(), window, &attributes)) return false; + Window child = None; XTranslateCoordinates(XDisplay(), window, DefaultRootWindow(XDisplay()), 0, 0, &output.left, &output.top, &child); + output.windowWidth = attributes.width; output.windowHeight = attributes.height; output.visible = attributes.map_state == IsViewable ? 1 : 0; + output.dpi = static_cast(DesktopDpi(XDisplay())); return true; +} +void vpnw::PumpPlatform() +{ + std::lock_guard lock(displayMutex); + if (!display) return; + while (XPending(display)) { + XEvent event{}; XNextEvent(display, &event); + if (event.type != ButtonPress || event.xbutton.button != Button1 || !placementWindows.count(event.xbutton.window)) continue; + XWindowAttributes bounds{}; if (!XGetWindowAttributes(display, event.xbutton.window, &bounds)) continue; + bool left = event.xbutton.x < 10, right = event.xbutton.x >= bounds.width - 10; + bool top = event.xbutton.y < 10, bottom = event.xbutton.y >= bounds.height - 10; + int direction = top ? (left ? 0 : right ? 2 : 1) : bottom ? (left ? 6 : right ? 4 : 5) : left ? 7 : right ? 3 : 8; + XUngrabPointer(display, CurrentTime); + XEvent request{}; request.xclient.type = ClientMessage; request.xclient.window = event.xbutton.window; + request.xclient.message_type = Property("_NET_WM_MOVERESIZE"); request.xclient.format = 32; + request.xclient.data.l[0] = event.xbutton.x_root; request.xclient.data.l[1] = event.xbutton.y_root; + request.xclient.data.l[2] = direction; request.xclient.data.l[3] = Button1; request.xclient.data.l[4] = 1; + XSendEvent(display, DefaultRootWindow(display), False, SubstructureRedirectMask | SubstructureNotifyMask, &request); + XFlush(display); + } +} +std::uint32_t vpnw::PlatformCapabilities() +{ + const char* setting = std::getenv("DISPLAY"); + return setting && *setting ? VPNW_CapabilityWindows | VPNW_CapabilityPlacement | VPNW_CapabilityTopmost | VPNW_CapabilityTransparency : 0u; +} diff --git a/VisualPinball.Native~/NativeWindows/Desktop/Linux/X11Windows.h b/VisualPinball.Native~/NativeWindows/Desktop/Linux/X11Windows.h new file mode 100644 index 000000000..d5d675b4a --- /dev/null +++ b/VisualPinball.Native~/NativeWindows/Desktop/Linux/X11Windows.h @@ -0,0 +1,22 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// SPDX-License-Identifier: MIT +// Licensed under MIT. See https://opensource.org/license/mit. + +#pragma once +#include "WindowBackend.h" +#include +#include + +namespace vpnw +{ + Display* XDisplay(); + std::mutex& XMutex(); + Window XCreateBorderless(int x, int y, int width, int height); + void XDestroyBorderless(Window window); + bool XConfigure(Window window, int x, int y, int width, int height, bool visible, bool topmost); + bool XOrder(Window window, int order); + bool XSetPlacementMode(Window window, bool enabled); + bool XDiagnostics(Window window, VPNW_Diagnostics& output); +} diff --git a/VisualPinball.Native~/NativeWindows/Desktop/Mac/MacCoordinates.h b/VisualPinball.Native~/NativeWindows/Desktop/Mac/MacCoordinates.h new file mode 100644 index 000000000..43e05c478 --- /dev/null +++ b/VisualPinball.Native~/NativeWindows/Desktop/Mac/MacCoordinates.h @@ -0,0 +1,63 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// SPDX-License-Identifier: MIT +// Licensed under MIT. See https://opensource.org/license/mit. + +#pragma once +#import +#import +#include +#include +#include "DesktopLayout.h" + +namespace vpnw +{ + // Desktop coordinates use top-left origins and physical pixels; Cocoa uses bottom-left points. + inline NSRect PhysicalScreen(NSScreen* screen) + { + NSArray* screens = NSScreen.screens; std::vector monitors; + for (NSScreen* entry in screens) + { + CGDirectDisplayID display = [entry.deviceDescription[@"NSScreenNumber"] unsignedIntValue]; CGRect bounds = CGDisplayBounds(display); + monitors.push_back({ { bounds.origin.x, bounds.origin.y, bounds.size.width, bounds.size.height }, static_cast(CGDisplayPixelsWide(display)), static_cast(CGDisplayPixelsHigh(display)) }); + } + auto layout = PhysicalLayout(monitors); NSUInteger index = [screens indexOfObject:screen]; + if (index == NSNotFound) return NSZeroRect; + auto rectangle = layout[index]; return NSMakeRect(rectangle.x, rectangle.y, rectangle.width, rectangle.height); + } + inline NSScreen* ScreenForPixels(int x, int y, int width, int height) + { + NSScreen* selected = NSScreen.screens.firstObject; double largest = -1; + NSRect requested = NSMakeRect(x, y, width, height); + for (NSScreen* screen in NSScreen.screens) + { + NSRect intersection = NSIntersectionRect(requested, PhysicalScreen(screen)); + double area = intersection.size.width * intersection.size.height; + if (area > largest) { largest = area; selected = screen; } + } + return selected; + } + inline NSRect CocoaFrame(int x, int y, int width, int height) + { + NSScreen* screen = ScreenForPixels(x, y, width, height); + if (!screen) return NSMakeRect(x, y, width, height); + NSRect physical = PhysicalScreen(screen); NSRect logical = screen.frame; + double sx = physical.size.width / logical.size.width, sy = physical.size.height / logical.size.height; + return NSMakeRect(logical.origin.x + (x - physical.origin.x) / sx, + NSMaxY(logical) - (y - physical.origin.y + height) / sy, width / sx, height / sy); + } + inline NSRect PixelFrame(NSWindow* window) + { + NSScreen* screen = window.screen ?: NSScreen.screens.firstObject; + if (!screen) return window.frame; + NSRect physical = PhysicalScreen(screen), frame = window.frame, logical = screen.frame; + double sx = physical.size.width / logical.size.width, sy = physical.size.height / logical.size.height; + return NSMakeRect(physical.origin.x + (frame.origin.x - logical.origin.x) * sx, + physical.origin.y + (NSMaxY(logical) - NSMaxY(frame)) * sy, frame.size.width * sx, frame.size.height * sy); + } + inline void MainThread(dispatch_block_t action) + { + if (NSThread.isMainThread) action(); else dispatch_sync(dispatch_get_main_queue(), action); + } +} diff --git a/VisualPinball.Native~/NativeWindows/Desktop/Mac/MetalWindows.mm b/VisualPinball.Native~/NativeWindows/Desktop/Mac/MetalWindows.mm new file mode 100644 index 000000000..b22261619 --- /dev/null +++ b/VisualPinball.Native~/NativeWindows/Desktop/Mac/MetalWindows.mm @@ -0,0 +1,232 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// SPDX-License-Identifier: MIT +// Licensed under MIT. See https://opensource.org/license/mit. + +#include "WindowBackend.h" +#include "MacCoordinates.h" +#include "IUnityGraphicsMetal.h" +#import +#import +#include +#include +#include + +namespace +{ + IUnityGraphicsMetal* metal = nullptr; + id device = nil; + id pipeline = nil; + constexpr const char* Shader = R"( +#include +using namespace metal; +struct Varying { float4 position [[position]]; float2 uv; }; +vertex Varying vpnw_vertex(uint id [[vertex_id]], constant uint& flip [[buffer(0)]]) { + float2 uv = float2((id << 1) & 2, id & 2); + Varying output; output.position = float4(uv * float2(2, -2) + float2(-1, 1), 0, 1); + output.uv = float2(uv.x, flip ? 1 - uv.y : uv.y); return output; +} +fragment float4 vpnw_fragment(Varying input [[stage_in]], texture2d source [[texture(0)]]) { + constexpr sampler sample(filter::linear, address::clamp_to_edge); + return source.sample(sample, input.uv); +})"; + void Pipeline() + { + if (pipeline) return; + NSError* error = nil; + id library = [device newLibraryWithSource:[NSString stringWithUTF8String:Shader] options:nil error:&error]; + if (!library) throw std::runtime_error(error.localizedDescription.UTF8String ?: "Could not compile desktop Metal shaders."); + MTLRenderPipelineDescriptor* description = [[MTLRenderPipelineDescriptor alloc] init]; + description.vertexFunction = [library newFunctionWithName:@"vpnw_vertex"]; + description.fragmentFunction = [library newFunctionWithName:@"vpnw_fragment"]; + description.colorAttachments[0].pixelFormat = MTLPixelFormatBGRA8Unorm; + description.colorAttachments[0].blendingEnabled = NO; + pipeline = [device newRenderPipelineStateWithDescriptor:description error:&error]; + if (!pipeline) throw std::runtime_error(error.localizedDescription.UTF8String ?: "Could not create desktop Metal pipeline."); + } + class MetalWindow final : public vpnw::WindowBackend, public std::enable_shared_from_this + { + std::mutex mutex; + NSWindow* window = nil; + CAMetalLayer* layer = nil; + id texture = nil; + id rawSnapshot = nil; + // Keep the drawable at the window's pixel size when the user resizes it or it moves to another display. + id resizeObserver = nil, scaleObserver = nil; + std::atomic inFlight{ 0 }; + bool alive = true, visible = false, topmost = false, flip = false, closing = false; + int left, top, width, height; + std::uint64_t presented = 0, skipped = 0; + public: + MetalWindow(int x, int y, int w, int h) : left(x), top(y), width(w), height(h) + { + if (!metal || !device) throw std::runtime_error("Unity Metal is required for macOS desktop output."); + vpnw::MainThread(^{ + window = [[NSWindow alloc] initWithContentRect:vpnw::CocoaFrame(x, y, w, h) styleMask:NSWindowStyleMaskBorderless backing:NSBackingStoreBuffered defer:NO]; + window.releasedWhenClosed = NO; window.opaque = NO; window.backgroundColor = NSColor.clearColor; + window.hasShadow = NO; window.title = @"Visual Pinball Window"; + window.collectionBehavior = NSWindowCollectionBehaviorCanJoinAllSpaces | NSWindowCollectionBehaviorFullScreenAuxiliary | NSWindowCollectionBehaviorStationary; + layer = [CAMetalLayer layer]; layer.device = device; layer.pixelFormat = MTLPixelFormatBGRA8Unorm; + CGColorSpaceRef sdr = CGColorSpaceCreateWithName(kCGColorSpaceSRGB); + layer.colorspace = sdr; CGColorSpaceRelease(sdr); + layer.opaque = NO; layer.framebufferOnly = YES; layer.maximumDrawableCount = 3; + layer.allowsNextDrawableTimeout = YES; layer.displaySyncEnabled = NO; + layer.drawableSize = CGSizeMake(w, h); layer.contentsScale = window.backingScaleFactor; + window.contentView.wantsLayer = YES; window.contentView.layer = layer; + // Runs on the main thread without the window mutex: Diagnostics holds it while waiting for the main thread. + __weak NSWindow* weakWindow = window; __weak CAMetalLayer* weakLayer = layer; + void (^refresh)(NSNotification*) = ^(NSNotification*) { + NSWindow* resized = weakWindow; CAMetalLayer* target = weakLayer; + if (!resized || !target) return; + NSRect pixels = vpnw::PixelFrame(resized); + target.contentsScale = resized.backingScaleFactor; + target.drawableSize = CGSizeMake(std::round(pixels.size.width), std::round(pixels.size.height)); + }; + NSNotificationCenter* center = NSNotificationCenter.defaultCenter; + resizeObserver = [center addObserverForName:NSWindowDidResizeNotification object:window queue:NSOperationQueue.mainQueue usingBlock:refresh]; + scaleObserver = [center addObserverForName:NSWindowDidChangeBackingPropertiesNotification object:window queue:NSOperationQueue.mainQueue usingBlock:refresh]; + }); + } + void StopObserving() + { + id resize = resizeObserver, scale = scaleObserver; resizeObserver = scaleObserver = nil; + if (resize || scale) dispatch_async(dispatch_get_main_queue(), ^{ + if (resize) [NSNotificationCenter.defaultCenter removeObserver:resize]; + if (scale) [NSNotificationCenter.defaultCenter removeObserver:scale]; + }); + } + ~MetalWindow() override + { + StopObserving(); + NSWindow* owned = window; + if (owned) dispatch_async(dispatch_get_main_queue(), ^{ [owned orderOut:nil]; [owned close]; }); + } + bool Configure(int x, int y, int w, int h, bool show, bool stayOnTop) override + { + std::lock_guard lock(mutex); + left = x; top = y; width = w; height = h; visible = show; topmost = stayOnTop; + vpnw::MainThread(^{ + [window setFrame:vpnw::CocoaFrame(x, y, w, h) display:NO]; + window.level = stayOnTop ? NSFloatingWindowLevel : NSNormalWindowLevel; + layer.contentsScale = window.backingScaleFactor; layer.drawableSize = CGSizeMake(w, h); + if (show) [window orderFrontRegardless]; else [window orderOut:nil]; + }); + return true; + } + bool SetOrder(int order) override + { + std::lock_guard lock(mutex); + if (order >= 2) topmost = order == 2; + vpnw::MainThread(^{ + if (order == 0) [window orderBack:nil]; else if (order == 1) [window orderFrontRegardless]; + else window.level = order == 2 ? NSFloatingWindowLevel : NSNormalWindowLevel; + }); return true; + } + bool SetTexture(void* value, bool flipY) override + { + std::lock_guard lock(mutex); texture = (__bridge id)value; rawSnapshot = nil; flip = flipY; return true; + } + bool SetPlacementMode(bool enabled) override + { + vpnw::MainThread(^{ + window.movableByWindowBackground = enabled; + window.styleMask = NSWindowStyleMaskBorderless | (enabled ? NSWindowStyleMaskResizable : 0); + }); + return true; + } + void Retire() override + { + std::lock_guard lock(mutex); alive = visible = false; texture = nil; + NSWindow* owned = window; dispatch_async(dispatch_get_main_queue(), ^{ [owned orderOut:nil]; }); + } + bool Collect() override + { + std::lock_guard lock(mutex); + if (inFlight.load() != 0) return false; + if (!closing) + { + closing = true; StopObserving(); NSWindow* owned = window; window = nil; layer = nil; + dispatch_async(dispatch_get_main_queue(), ^{ [owned close]; }); + } + return true; + } + void Draw() override {} + void Present() override + { + std::lock_guard lock(mutex); + if (!alive || !visible || !texture || !metal || inFlight.load() >= 2) { skipped++; return; } + @autoreleasepool + { + Pipeline(); id drawable = [layer nextDrawable]; + if (!drawable) { skipped++; return; } + metal->EndCurrentCommandEncoder(); id command = metal->CurrentCommandBuffer(); + if (!command) { skipped++; return; } + id source = texture; + // A raw GPU copy avoids sampling the sRGB view and preserves encoded SDR bytes. + if (texture.pixelFormat == MTLPixelFormatBGRA8Unorm_sRGB || texture.pixelFormat == MTLPixelFormatRGBA8Unorm_sRGB) + { + MTLPixelFormat format = texture.pixelFormat == MTLPixelFormatBGRA8Unorm_sRGB ? MTLPixelFormatBGRA8Unorm : MTLPixelFormatRGBA8Unorm; + if (!rawSnapshot || rawSnapshot.width != texture.width || rawSnapshot.height != texture.height) + { + MTLTextureDescriptor* description = [MTLTextureDescriptor texture2DDescriptorWithPixelFormat:format width:texture.width height:texture.height mipmapped:NO]; + description.storageMode = MTLStorageModePrivate; description.usage = MTLTextureUsageShaderRead; + rawSnapshot = [device newTextureWithDescriptor:description]; + } + id copy = [command blitCommandEncoder]; + [copy copyFromTexture:texture sourceSlice:0 sourceLevel:0 sourceOrigin:MTLOriginMake(0, 0, 0) sourceSize:MTLSizeMake(texture.width, texture.height, 1) + toTexture:rawSnapshot destinationSlice:0 destinationLevel:0 destinationOrigin:MTLOriginMake(0, 0, 0)]; [copy endEncoding]; source = rawSnapshot; + } + MTLRenderPassDescriptor* pass = [MTLRenderPassDescriptor renderPassDescriptor]; + pass.colorAttachments[0].texture = drawable.texture; pass.colorAttachments[0].loadAction = MTLLoadActionDontCare; pass.colorAttachments[0].storeAction = MTLStoreActionStore; + id encoder = [command renderCommandEncoderWithDescriptor:pass]; + [encoder setRenderPipelineState:pipeline]; [encoder setFragmentTexture:source atIndex:0]; + std::uint32_t inversion = flip ? 1 : 0; [encoder setVertexBytes:&inversion length:sizeof(inversion) atIndex:0]; + [encoder drawPrimitives:MTLPrimitiveTypeTriangle vertexStart:0 vertexCount:3]; [encoder endEncoding]; + [command presentDrawable:drawable]; inFlight++; + auto owner = shared_from_this(); id retainedSource = source, retainedInput = texture; + [command addCompletedHandler:^(id completed) { + (void)retainedSource; (void)retainedInput; + if (completed.status == MTLCommandBufferStatusError) vpnw::SetError(completed.error.localizedDescription.UTF8String ?: "Desktop Metal command failed."); + owner->inFlight--; + }]; + presented++; + } + } + VPNW_Diagnostics Diagnostics() override + { + std::lock_guard lock(mutex); VPNW_Diagnostics result{}; + result.structBytes = sizeof(result); result.graphicsRenderer = kUnityGfxRendererMetal; result.deviceReady = device ? 1 : 0; + result.alive = alive ? 1 : 0; result.visible = visible ? 1 : 0; result.topmost = topmost ? 1 : 0; + vpnw::MainThread(^{ + NSRect bounds = vpnw::PixelFrame(window); + left = static_cast(std::round(bounds.origin.x)); top = static_cast(std::round(bounds.origin.y)); + width = static_cast(std::round(bounds.size.width)); height = static_cast(std::round(bounds.size.height)); + layer.contentsScale = window.backingScaleFactor; layer.drawableSize = CGSizeMake(width, height); + }); + result.left = left; result.top = top; result.windowWidth = width; result.windowHeight = height; result.dpi = 96; + result.sourceWidth = static_cast(texture.width); result.sourceHeight = static_cast(texture.height); + result.sourceFormat = static_cast(texture.pixelFormat); result.presentedFrames = presented; result.skippedFrames = skipped; result.premultipliedAlpha = 1; return result; + } + }; +} + +void vpnw::PreloadGraphics(IUnityInterfaces*) {} +void vpnw::UnloadGraphics(IUnityInterfaces*) {} +void vpnw::InitializeGraphics(IUnityInterfaces* interfaces, UnityGfxRenderer renderer) +{ + if (renderer != kUnityGfxRendererMetal) { SetError("macOS native windows require Unity Metal."); return; } + metal = interfaces->Get(); + if (!metal) throw std::runtime_error("Unity's Metal native interface is unavailable."); + device = metal->MetalDevice(); +} +void vpnw::ShutdownGraphics() { pipeline = nil; device = nil; metal = nullptr; } +void vpnw::PumpPlatform() {} +std::uint32_t vpnw::PlatformCapabilities() +{ + return VPNW_CapabilityWindows | VPNW_CapabilityPlacement | VPNW_CapabilityTopmost | VPNW_CapabilityTransparency; +} +std::shared_ptr vpnw::CreateWindowBackend(int x, int y, int width, int height) +{ + return std::make_shared(x, y, width, height); +} diff --git a/VisualPinball.Native~/NativeWindows/Desktop/WindowBackend.h b/VisualPinball.Native~/NativeWindows/Desktop/WindowBackend.h new file mode 100644 index 000000000..577f94cdb --- /dev/null +++ b/VisualPinball.Native~/NativeWindows/Desktop/WindowBackend.h @@ -0,0 +1,41 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// SPDX-License-Identifier: MIT +// Licensed under MIT. See https://opensource.org/license/mit. + +#pragma once +#include "VisualPinballNativeWindows.h" +#include "IUnityGraphics.h" +#include "IUnityInterface.h" +#include +#include + +namespace vpnw +{ + extern int DrawEvent; + extern int PresentEvent; + void SetError(const std::string& message); + void PreloadGraphics(IUnityInterfaces* interfaces); + void UnloadGraphics(IUnityInterfaces* interfaces); + void InitializeGraphics(IUnityInterfaces* interfaces, UnityGfxRenderer renderer); + void ShutdownGraphics(); + void PumpPlatform(); + // VPNW_Capability bits. + std::uint32_t PlatformCapabilities(); + class WindowBackend + { + public: + virtual ~WindowBackend() = default; + virtual bool Configure(int x, int y, int width, int height, bool visible, bool topmost) = 0; + virtual bool SetOrder(int order) = 0; + virtual bool SetPlacementMode(bool enabled) = 0; + virtual bool SetTexture(void* texture, bool flipY) = 0; + virtual void Retire() = 0; + virtual bool Collect() = 0; + virtual void Draw() = 0; + virtual void Present() = 0; + virtual VPNW_Diagnostics Diagnostics() = 0; + }; + std::shared_ptr CreateWindowBackend(int x, int y, int width, int height); +} diff --git a/VisualPinball.Native~/NativeWindows/Desktop/WindowRegistry.cpp b/VisualPinball.Native~/NativeWindows/Desktop/WindowRegistry.cpp new file mode 100644 index 000000000..437c6490a --- /dev/null +++ b/VisualPinball.Native~/NativeWindows/Desktop/WindowRegistry.cpp @@ -0,0 +1,168 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// SPDX-License-Identifier: MIT +// Licensed under MIT. See https://opensource.org/license/mit. + +#include "WindowBackend.h" +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ + std::mutex registryMutex, errorMutex; + std::unordered_map> windows; + std::vector> retired; + std::string lastError; + int nextId = 1; + IUnityInterfaces* unity = nullptr; + IUnityGraphics* graphics = nullptr; + std::atomic renderer{ kUnityGfxRendererNull }; + + std::shared_ptr Find(int id) + { + std::lock_guard lock(registryMutex); + auto found = windows.find(id); + return found == windows.end() ? nullptr : found->second; + } + void UNITY_INTERFACE_API OnDevice(UnityGfxDeviceEventType event) + { + try + { + if (event == kUnityGfxDeviceEventInitialize) + { + renderer = graphics->GetRenderer(); + vpnw::InitializeGraphics(unity, static_cast(renderer.load())); + } + else if (event == kUnityGfxDeviceEventShutdown) + { + VPNW_DestroyAll(); + vpnw::ShutdownGraphics(); + std::lock_guard lock(registryMutex); + retired.clear(); renderer = kUnityGfxRendererNull; + } + } + catch (const std::exception& error) { vpnw::SetError(error.what()); } + } + void UNITY_INTERFACE_API OnRender(int event) + { + try + { + std::vector> active, pending; + { + std::lock_guard lock(registryMutex); + for (auto& pair : windows) active.push_back(pair.second); + pending = retired; + } + for (auto& window : active) + if (event == vpnw::DrawEvent) window->Draw(); else if (event == vpnw::PresentEvent) window->Present(); + if (event == vpnw::PresentEvent) for (auto& window : pending) + if (window->Collect()) + { + std::lock_guard lock(registryMutex); + retired.erase(std::remove(retired.begin(), retired.end(), window), retired.end()); + } + } + catch (const std::exception& error) { vpnw::SetError(error.what()); } + } + template int Attempt(Function function) + { + try { return function() ? 1 : 0; } + catch (const std::exception& error) { vpnw::SetError(error.what()); return 0; } + } +} + +int vpnw::DrawEvent = 0; +int vpnw::PresentEvent = 1; + +void vpnw::SetError(const std::string& message) +{ + std::lock_guard lock(errorMutex); lastError = message; +} +extern "C" void UNITY_INTERFACE_EXPORT UNITY_INTERFACE_API UnityPluginLoad(IUnityInterfaces* interfaces) +{ + unity = interfaces; graphics = unity->Get(); + if (!graphics) { vpnw::SetError("Unity graphics interface is unavailable."); return; } + vpnw::DrawEvent = graphics->ReserveEventIDRange(2); + vpnw::PresentEvent = vpnw::DrawEvent + 1; + vpnw::PreloadGraphics(unity); + graphics->RegisterDeviceEventCallback(OnDevice); OnDevice(kUnityGfxDeviceEventInitialize); +} +extern "C" void UNITY_INTERFACE_EXPORT UNITY_INTERFACE_API UnityPluginUnload() +{ + OnDevice(kUnityGfxDeviceEventShutdown); + if (graphics) graphics->UnregisterDeviceEventCallback(OnDevice); + if (unity) vpnw::UnloadGraphics(unity); + unity = nullptr; graphics = nullptr; +} + +int VPNW_CreateWindow(int x, int y, int width, int height) +{ + int id = 0; + Attempt([&] { + if (width <= 0 || height <= 0 || width > 32768 || height > 32768) return false; + auto window = vpnw::CreateWindowBackend(x, y, width, height); + if (!window) return false; + std::lock_guard lock(registryMutex); + if (nextId == std::numeric_limits::max()) return false; + id = nextId++; windows.emplace(id, window); return true; + }); + return id; +} +int VPNW_ConfigureWindow(int id, int x, int y, int width, int height, int visible, int topmost) +{ + return Attempt([&] { auto window = Find(id); return window && width > 0 && height > 0 && width <= 32768 && height <= 32768 && window->Configure(x, y, width, height, visible != 0, topmost != 0); }); +} +int VPNW_SetZOrder(int id, int order) +{ + return Attempt([&] { auto window = Find(id); return window && order >= 0 && order <= 3 && window->SetOrder(order); }); +} +int VPNW_SetPlacementMode(int id, int enabled) +{ + return Attempt([&] { auto window = Find(id); return window && window->SetPlacementMode(enabled != 0); }); +} +int VPNW_SetTexture(int id, void* texture, int flipY) +{ + return Attempt([&] { auto window = Find(id); return window && window->SetTexture(texture, flipY != 0); }); +} +int VPNW_DestroyWindow(int id) +{ + return Attempt([&] { + std::shared_ptr window; + { + std::lock_guard lock(registryMutex); + auto found = windows.find(id); if (found == windows.end()) return true; + window = found->second; windows.erase(found); retired.push_back(window); + } + window->Retire(); return true; + }); +} +void VPNW_DestroyAll() +{ + std::vector ids; + { std::lock_guard lock(registryMutex); for (auto& pair : windows) ids.push_back(pair.first); } + for (int id : ids) VPNW_DestroyWindow(id); +} +void VPNW_Pump() { vpnw::PumpPlatform(); } +void* VPNW_GetRenderEventFunc() { return reinterpret_cast(OnRender); } +int VPNW_GetGraphicsRenderer() { return renderer.load(); } +int VPNW_GetDrawEventId() { return vpnw::DrawEvent; } +int VPNW_GetPresentEventId() { return vpnw::PresentEvent; } +int VPNW_GetPendingResourceCount() { std::lock_guard lock(registryMutex); return static_cast(retired.size()); } +int VPNW_GetDiagnostics(int id, VPNW_Diagnostics* output) +{ + return Attempt([&] { auto window = Find(id); if (!window || !output || output->structBytes != sizeof(VPNW_Diagnostics)) return false; *output = window->Diagnostics(); return true; }); +} +int VPNW_GetLastError(char* output, int capacity) +{ + std::lock_guard lock(errorMutex); + if (output && capacity > 0) { int count = std::min(capacity - 1, static_cast(lastError.size())); std::memcpy(output, lastError.data(), count); output[count] = 0; } + return static_cast(lastError.size()) + 1; +} +std::uint32_t VPNW_GetCapabilities() { return vpnw::PlatformCapabilities(); } diff --git a/VisualPinball.Native~/NativeWindows/README.md b/VisualPinball.Native~/NativeWindows/README.md new file mode 100644 index 000000000..2d651e637 --- /dev/null +++ b/VisualPinball.Native~/NativeWindows/README.md @@ -0,0 +1,107 @@ +# Native windows + +`VisualPinballNativeWindows` presents a Unity texture in independent borderless desktop windows, entirely on the GPU. Player plugins such as PinUP and the B2S backglass use it to put a premultiplied RGBA RenderTexture on a second or third monitor, with transparent areas showing the desktop behind. Production code never reads texture pixels back to the CPU. + +| Platform | Library | Unity graphics | Presentation | +| --- | --- | --- | --- | +| Windows x64 | `VisualPinballNativeWindows.dll` | Direct3D11, Direct3D12 (v7 interface) | DirectComposition, premultiplied-alpha flip-model swapchain | +| macOS (arm64 + x86_64) | `libVisualPinballNativeWindows.dylib` | Metal | Borderless `NSWindow` with a `CAMetalLayer` | +| Linux x64 | `libVisualPinballNativeWindows.so` | Vulkan | X11/XWayland window with a 32-bit ARGB visual and a Vulkan swapchain | + +All platforms export the same C API (`VisualPinballNativeWindows.h`, prefix `VPNW_`), so the managed side is a single P/Invoke class: `NativeWindowSurface` in the `VisualPinball.Unity` assembly. + +## Layout + +| Path | Content | +| --- | --- | +| `VisualPinballNativeWindows.h` | Public C API shared by all platforms. | +| `Windows/VisualPinballNativeWindows.cpp` | Dedicated Win32 message thread, registry IDs, DirectComposition windows, GPU scaling and flip, Direct3D11 state isolation. | +| `Windows/D3D12CopyBridge.cpp` | Unity resource-state declarations, private GPU snapshots, same-queue D3D11On12 presentation and frame-fence retirement. | +| `Windows/NativeWindowTests.cpp` | Standalone real-device fixture with a minimal Unity graphics-interface host. | +| `Desktop/WindowRegistry.cpp` | Shared macOS/Linux registry, Unity plugin entry points and render events. | +| `Desktop/Mac/MetalWindows.mm` | Metal presentation; `MacCoordinates.h` and `DesktopLayout.h` map physical pixels to Cocoa points on mixed-DPI desktops. | +| `Desktop/Linux/VulkanWindows.cpp` | Vulkan swapchain presentation; `X11Windows.cpp` creates, places, orders and pumps the X11 windows. | +| `Desktop/DesktopTests.cpp` | Standalone fixture for macOS and Linux. | + +## Design + +Unity calls `UnityPluginLoad`, where the plugin reserves two event IDs: a draw event and a present event. Both operate on all registered windows, so a host submits one pair per frame after its GPU work, not one pair per window. Window handles are registry IDs that are never reused while the plugin is loaded, so queued render events can outlive a destroyed window safely. + +On Windows the D3D11 path swaps the entire immediate-context state through `ID3D11DeviceContext1` and restores Unity's state afterwards. Typeless resources use typed views; typed sRGB resources get a cached compatible UNORM GPU copy, which avoids an illegal view and preserves the stored SDR values. The D3D12 path submits a snapshot copy through `IUnityGraphicsD3D12v7.ExecuteCommandList`, so Unity owns the source's barriers and ordering. The present event runs on Unity's submission thread after worker flushes and draws the snapshot into DirectComposition swapchain buffers through D3D11On12 on Unity's own queue. Retired snapshots and destroyed presentation resources stay alive until their frame fence completes; `PendingResourceCount` reports them, and the host keeps issuing event pairs until it is zero. + +The HWNDs live on a plugin-owned message thread with per-monitor DPI awareness; Unity's process DPI mode is not changed. On macOS, window operations are dispatched to the main thread. On Linux, X11 events (placement drags) are processed by `VPNW_Pump`, which the host calls once per frame on the main thread. The Vulkan backend intercepts Unity's instance creation to add the Xlib surface extensions and keeps XWayland presentation when Unity itself runs on Wayland. + +Present never waits for the display: Windows uses `DXGI_PRESENT_DO_NOT_WAIT`, Metal skips a frame when two are in flight, and Vulkan acquires with a zero timeout and prefers immediate mode. Skipped frames are counted in the diagnostics. + +## C API + +| Function | Purpose | +| --- | --- | +| `VPNW_CreateWindow(x, y, width, height)` | Creates a hidden borderless window; returns its ID or 0. | +| `VPNW_ConfigureWindow(id, x, y, width, height, visible, topmost)` | Sets rectangle, visibility and topmost state. | +| `VPNW_SetZOrder(id, order)` | 0 back, 1 front, 2 topmost, 3 non-topmost, without moving or activating. | +| `VPNW_SetPlacementMode(id, enabled)` | Lets the user drag the body and resize at the edges. | +| `VPNW_SetTexture(id, texture, flipY)` | Assigns Unity's native texture pointer; null detaches. | +| `VPNW_DestroyWindow(id)` / `VPNW_DestroyAll()` | Idempotent destruction; GPU resources retire through later events. | +| `VPNW_Pump()` | Processes platform window events (Linux); no-op on Windows and macOS. | +| `VPNW_GetRenderEventFunc()`, `VPNW_GetDrawEventId()`, `VPNW_GetPresentEventId()` | Unity render callback and its two event IDs. | +| `VPNW_GetPendingResourceCount()` | Retired GPU objects waiting for later events. | +| `VPNW_GetGraphicsRenderer()` | Unity's `UnityGfxRenderer` as seen by the plugin. | +| `VPNW_GetDiagnostics(id, diagnostics)` | Geometry, DPI, source, frame counters and last result (80-byte struct). | +| `VPNW_GetLastError(buffer, capacity)` | Last error as UTF-8; returns the required size. | +| `VPNW_GetCapabilities()` | Bits: 1 windows, 2 placement, 4 topmost, 8 transparency. | + +Functions returning `int` return nonzero on success. Coordinates and sizes are signed physical desktop pixels; monitors left of or above the primary monitor have negative origins. + +## Using it from a plugin + +Create a `NativeWindowSurface`, give it the native pointer of the RenderTexture you render into, configure it, and register it with the shared `NativeWindowHost`. The host is a single hidden `DontDestroyOnLoad` object shared by all plugins; it issues one draw/present event pair per frame in `LateUpdate`, pumps platform events, and keeps running until deferred GPU releases have drained. + +```csharp +if (!NativeWindowSurface.IsSupported) { + return; // Missing library or unsupported graphics backend; never throws. +} +var texture = new RenderTexture(width, height, 0, RenderTextureFormat.ARGB32); +texture.Create(); +var surface = new NativeWindowSurface(x, y, width, height); // hidden +surface.SetTexture(texture.GetNativeTexturePtr()); +surface.Configure(x, y, width, height, visible: true, topmost: false); +var lease = NativeWindowHost.Register(surface); + +// Shutdown: unregister, destroy the window, then release the texture. +lease.Dispose(); +surface.Dispose(); +texture.Release(); +``` + +Render premultiplied RGBA in the SDR encoding you want on screen. `Configure` caches its last arguments, so calling it every frame is cheap. Resubmit `GetNativeTexturePtr()` whenever the RenderTexture is recreated or resized, and dispose the surface before releasing its texture. `SendToBack`, `BringToFront` and `SetTopmost` change stacking without moving or activating the window. `SetPlacementMode(true)` lets the user move and resize the window while a settings screen is open; read the result from `Diagnostics`. `Diagnostics` crosses into the window system, so query it for troubleshooting, not every frame. `NativeWindowSurface.DestroyAll()` is available for host shutdown or domain-reload cleanup. + +## Build and test + +### Windows + +Run `Windows/Build.ps1` from PowerShell with the Visual Studio C++ x64 toolchain and a Windows SDK installed. `-UnityEditor` accepts the installed Editor directory containing `Data/PluginAPI` (default: 6000.5.0f1). The script compiles with C++17, `/W4 /WX`, writes `VisualPinball.Unity/Plugins/win-x64/VisualPinballNativeWindows.dll` and keeps intermediates in `Windows/obj`. Use `-OutputPath` to stage a build elsewhere when Unity has loaded and locked the DLL. + +`./Build.ps1 -Test` builds and runs the fixture. It checks real D3D11 and D3D12 presentation, exact signed coordinates, DPI, hidden creation, placement hit-testing, z-order commands (including that `Configure` can undo a topmost state set through z-order), resize, source detach, idempotent destruction, nested modal message loops, pending D3D12 retirement before the frame fence, four-corner orientation with and without flip, and a test-only GPU readback of an exact premultiplied green128/alpha128 pixel for BGRA UNORM, RGBA/BGRA typeless and RGBA/BGRA sRGB sources. + +### macOS and Linux + +The `Desktop` folder is a CMake project (3.22+). Point `UNITY_PLUGIN_API` at the Unity Editor's `PluginAPI` directory. With `VPNW_DEPLOY` (default on), the library is copied to `VisualPinball.Unity/Plugins/osx` or `VisualPinball.Unity/Plugins/linux-x64`. + +```sh +cmake -S VisualPinball.Native~/NativeWindows/Desktop -B VisualPinball.Native~/NativeWindows/Desktop/obj -DUNITY_PLUGIN_API="/Applications/Unity/Hub/Editor/6000.5.0f1/Unity.app/Contents/PluginAPI" +cmake --build VisualPinball.Native~/NativeWindows/Desktop/obj --config Release +VisualPinball.Native~/NativeWindows/Desktop/obj/NativeWindowTests +``` + +On macOS the default `CMAKE_OSX_ARCHITECTURES` is `arm64;x86_64`, producing a universal `libVisualPinballNativeWindows.dylib` (deployment target 14.0). Create its Unity `.meta` (Editor OSX and Standalone OSXUniversal enabled) when the binary is first added. + +On Linux install `cmake g++ libvulkan-dev libx11-dev libxrender-dev`. The fixture needs an X11 display (`DISPLAY`) and a Vulkan driver; it creates a window at a negative origin, checks geometry, capabilities, presentation through Unity's event pair for UNORM and sRGB sources, placement, z-order, detach, idempotent destruction and deferred retirement. It then reads back the presented centre pixel through a separate X11 connection. Virtual X servers such as Xvfb or WSLg may not expose Vulkan-presented contents to `XGetImage`; set `VPNW_SKIP_PIXEL_CHECK=1` to skip only that readback there. The window reports DPI from the session's `Xft.dpi` resource, or 96. + +## Limits + +Windows requires Windows 10 or later and Direct3D feature level 11.0; Direct3D12 also requires Unity's v7 native graphics interface and D3D11On12. macOS requires Unity Metal; Linux requires Unity Vulkan and an X11 or XWayland display, since Wayland toplevel placement is compositor controlled. Texture arrays, XR surfaces and arbitrary MSAA sources are not covered. Output is SDR 8-bit BGRA without tone mapping or color management. A desktop compositor or another topmost application can still affect visible stacking. + +## References + +The plugin uses the installed Unity SDK interfaces and the [official Unity native rendering sample](https://github.com/Unity-Technologies/NativeRenderingPlugin). Microsoft documents [full D3D11 context-state swapping](https://learn.microsoft.com/en-us/windows/win32/api/d3d11_1/nf-d3d11_1-id3d11devicecontext1-swapdevicecontextstate), [D3D11On12 wrapped resources](https://learn.microsoft.com/en-us/windows/win32/api/d3d11on12/nf-d3d11on12-id3d11on12device-createwrappedresource), and [DirectComposition swapchain creation](https://learn.microsoft.com/en-us/windows/win32/api/dxgi1_2/nf-dxgi1_2-idxgifactory2-createswapchainforcomposition). diff --git a/VisualPinball.Native~/NativeWindows/VisualPinballNativeWindows.h b/VisualPinball.Native~/NativeWindows/VisualPinballNativeWindows.h new file mode 100644 index 000000000..c0532f5a5 --- /dev/null +++ b/VisualPinball.Native~/NativeWindows/VisualPinballNativeWindows.h @@ -0,0 +1,86 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// SPDX-License-Identifier: MIT +// Licensed under MIT. See https://opensource.org/license/mit. + +// Public C API of VisualPinballNativeWindows. Identical on Windows (D3D11/D3D12), macOS (Metal) and Linux (Vulkan/X11). +#pragma once +#include + +#if defined(_WIN32) +#define VPNW_CALL __cdecl +#if defined(VPNW_BUILD) +#define VPNW_API extern "C" __declspec(dllexport) +#else +#define VPNW_API extern "C" __declspec(dllimport) +#endif +#else +#define VPNW_CALL +#define VPNW_API extern "C" __attribute__((visibility("default"))) +#endif + +// All coordinates and sizes are physical desktop pixels, including negative monitor origins. +// Handles are registry IDs, never HWNDs or pointers. IDs are never reused during a plugin load. +struct VPNW_Diagnostics +{ + std::uint32_t structBytes; + std::int32_t graphicsRenderer; + std::int32_t deviceReady; + std::int32_t alive; + std::int32_t visible; + std::int32_t topmost; + std::int32_t left; + std::int32_t top; + std::int32_t windowWidth; + std::int32_t windowHeight; + std::uint32_t dpi; + std::uint32_t sourceWidth; + std::uint32_t sourceHeight; + std::uint32_t sourceFormat; + std::uint64_t presentedFrames; + std::uint64_t skippedFrames; + // Windows: last presentation HRESULT. macOS/Linux: 0 on success, negative after a presentation failure. + std::int32_t lastResult; + std::int32_t premultipliedAlpha; +}; +static_assert(sizeof(VPNW_Diagnostics) == 80, "Managed diagnostics ABI must remain stable."); + +// Bits returned by VPNW_GetCapabilities. +enum VPNW_Capability : std::uint32_t +{ + VPNW_CapabilityWindows = 1, // Borderless GPU output windows can be created. + VPNW_CapabilityPlacement = 2, // VPNW_SetPlacementMode is functional. + VPNW_CapabilityTopmost = 4, // Topmost/non-topmost requests are honoured. + VPNW_CapabilityTransparency = 8 // Premultiplied alpha reaches the desktop compositor. +}; + +// Creates an initially hidden borderless window. Returns 0 on failure (see VPNW_GetLastError). +VPNW_API int VPNW_CALL VPNW_CreateWindow(int x, int y, int width, int height); +// Identical repeated calls do not reposition or reshow the window. +VPNW_API int VPNW_CALL VPNW_ConfigureWindow(int id, int x, int y, int width, int height, int visible, int topmost); +// order: 0 back, 1 front, 2 topmost, 3 non-topmost. Geometry and activation remain unchanged. +VPNW_API int VPNW_CALL VPNW_SetZOrder(int id, int order); +// Settings-only interaction: drag the body and resize at the edges. Playback windows remain fixed. +VPNW_API int VPNW_CALL VPNW_SetPlacementMode(int id, int enabled); +// Texture is Unity's native texture pointer (ID3D11Texture2D, ID3D12Resource, MTLTexture or VkImage*), +// with premultiplied RGBA in the desired SDR encoding. nullptr detaches the source. +VPNW_API int VPNW_CALL VPNW_SetTexture(int id, void* texture, int flipY); +// Idempotent. GPU resources are retired and collected by later render events. +VPNW_API int VPNW_CALL VPNW_DestroyWindow(int id); +VPNW_API void VPNW_CALL VPNW_DestroyAll(); +// Processes platform window events (X11 placement drags). No-op on Windows and macOS. Call from the main thread. +VPNW_API void VPNW_CALL VPNW_Pump(); +VPNW_API void* VPNW_CALL VPNW_GetRenderEventFunc(); +VPNW_API int VPNW_CALL VPNW_GetGraphicsRenderer(); +// Issue the draw event and then the present event once per frame; both operate on all registered windows. +VPNW_API int VPNW_CALL VPNW_GetDrawEventId(); +VPNW_API int VPNW_CALL VPNW_GetPresentEventId(); +// Pending retired source/presentation objects. Issue later event pairs until this reaches zero. +VPNW_API int VPNW_CALL VPNW_GetPendingResourceCount(); +// diagnostics->structBytes must be sizeof(VPNW_Diagnostics). +VPNW_API int VPNW_CALL VPNW_GetDiagnostics(int id, VPNW_Diagnostics* diagnostics); +// UTF-8, null terminated when capacity > 0. Return value is the required byte count, including the terminator. +VPNW_API int VPNW_CALL VPNW_GetLastError(char* destination, int capacity); +// VPNW_Capability bits for the current platform and session. +VPNW_API std::uint32_t VPNW_CALL VPNW_GetCapabilities(); diff --git a/VisualPinball.Native~/NativeWindows/Windows/Build.ps1 b/VisualPinball.Native~/NativeWindows/Windows/Build.ps1 new file mode 100644 index 000000000..99f63eb73 --- /dev/null +++ b/VisualPinball.Native~/NativeWindows/Windows/Build.ps1 @@ -0,0 +1,51 @@ +param( + [string] $UnityEditor = 'C:\Program Files\Unity\Hub\Editor\6000.5.0f1\Editor', + [string] $OutputPath, + [switch] $Test +) +$ErrorActionPreference = 'Stop' +$nativeRoot = $PSScriptRoot +$apiRoot = [System.IO.Path]::GetFullPath((Join-Path $nativeRoot '..')) +$packageRoot = [System.IO.Path]::GetFullPath((Join-Path $nativeRoot '..\..\..')) +$sdkHeaders = Join-Path $UnityEditor 'Data\PluginAPI' +if (!(Test-Path -LiteralPath (Join-Path $sdkHeaders 'IUnityGraphicsD3D11.h'))) { + throw "Unity native plugin SDK headers were not found under $sdkHeaders. Supply -UnityEditor with the installed Editor directory." +} +$vswherePath = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe' +$visualStudioRoot = & $vswherePath -latest -products '*' -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath +if (!$visualStudioRoot) { throw 'Install the Visual Studio C++ x64 toolchain before building.' } +$msvcRoot = Get-ChildItem -LiteralPath (Join-Path $visualStudioRoot 'VC\Tools\MSVC') -Directory | Sort-Object Name -Descending | Select-Object -First 1 -ExpandProperty FullName +$windowsSdkRoot = (Get-ItemProperty -LiteralPath 'HKLM:\SOFTWARE\Microsoft\Windows Kits\Installed Roots').KitsRoot10 +$sdkVersion = Get-ChildItem -LiteralPath (Join-Path $windowsSdkRoot 'Include') -Directory | Where-Object { Test-Path -LiteralPath (Join-Path $_.FullName 'um\dcomp.h') } | Sort-Object Name -Descending | Select-Object -First 1 -ExpandProperty Name +$compiler = Join-Path $msvcRoot 'bin\Hostx64\x64\cl.exe' +$linker = Join-Path $msvcRoot 'bin\Hostx64\x64\link.exe' +# Intermediates go to obj/, which the repository ignores. +$buildDirectory = Join-Path $nativeRoot 'obj' +$pluginDirectory = Join-Path $packageRoot 'VisualPinball.Unity\Plugins\win-x64' +New-Item -ItemType Directory -Force -Path $buildDirectory, $pluginDirectory | Out-Null +$includePaths = @((Join-Path $msvcRoot 'include'), $sdkHeaders, $apiRoot) +foreach ($part in @('um', 'shared', 'ucrt', 'winrt')) { $includePaths += Join-Path $windowsSdkRoot "Include\$sdkVersion\$part" } +$libraryPaths = @((Join-Path $msvcRoot 'lib\x64'), (Join-Path $windowsSdkRoot "Lib\$sdkVersion\um\x64"), (Join-Path $windowsSdkRoot "Lib\$sdkVersion\ucrt\x64")) +$source = Join-Path $nativeRoot $(if ($Test) { 'NativeWindowTests.cpp' } else { 'VisualPinballNativeWindows.cpp' }) +$object = Join-Path $buildDirectory $(if ($Test) { 'NativeWindowTests.obj' } else { 'VisualPinballNativeWindows.obj' }) +$output = $(if ($OutputPath) { [System.IO.Path]::GetFullPath($OutputPath) } elseif ($Test) { Join-Path $buildDirectory 'NativeWindowTests.exe' } else { Join-Path $pluginDirectory 'VisualPinballNativeWindows.dll' }) +New-Item -ItemType Directory -Force -Path (Split-Path -Parent $output) | Out-Null +$compileArguments = @('/nologo', '/c', '/std:c++17', '/permissive-', '/EHsc', '/W4', '/WX', '/O2', '/MD', '/utf-8', '/DUNICODE', '/D_UNICODE', '/D_WIN32_WINNT=0x0A00', "/Fo$object") +foreach ($path in $includePaths) { $compileArguments += "/I$path" } +& $compiler @compileArguments $source +if (!$Test -and $LASTEXITCODE -eq 0) { + $bridgeObject = Join-Path $buildDirectory 'D3D12CopyBridge.obj' + $bridgeArguments = $compileArguments | Where-Object { !($_ -like '/Fo*') } + & $compiler @bridgeArguments "/Fo$bridgeObject" (Join-Path $nativeRoot 'D3D12CopyBridge.cpp') +} +if ($LASTEXITCODE -ne 0) { throw "C++ compilation failed with exit code $LASTEXITCODE." } +$linkArguments = @('/NOLOGO', '/MACHINE:X64', '/INCREMENTAL:NO', '/OPT:REF', '/OPT:ICF', "/OUT:$output", "/IMPLIB:$(Join-Path $buildDirectory 'VisualPinballNativeWindows.lib')", $object, 'd3d11.lib', 'd3d12.lib', 'dxgi.lib', 'dcomp.lib', 'd3dcompiler.lib', 'user32.lib') +foreach ($path in $libraryPaths) { $linkArguments += "/LIBPATH:$path" } +if (!$Test) { $linkArguments += @('/DLL', $bridgeObject) } +& $linker @linkArguments +if ($LASTEXITCODE -ne 0) { throw "C++ linking failed with exit code $LASTEXITCODE." } +Write-Host "Built $output" +if ($Test) { + & $output + if ($LASTEXITCODE -ne 0) { throw "Native integration tests failed with exit code $LASTEXITCODE." } +} diff --git a/VisualPinball.Native~/NativeWindows/Windows/D3D12CopyBridge.cpp b/VisualPinball.Native~/NativeWindows/Windows/D3D12CopyBridge.cpp new file mode 100644 index 000000000..28623caf2 --- /dev/null +++ b/VisualPinball.Native~/NativeWindows/Windows/D3D12CopyBridge.cpp @@ -0,0 +1,204 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// SPDX-License-Identifier: MIT +// Licensed under MIT. See https://opensource.org/license/mit. + +#include "D3D12CopyBridge.h" +#include +#include +#include + +using Microsoft::WRL::ComPtr; + +namespace vpnw +{ + namespace + { + void Check12(HRESULT result, const char* operation) + { + if (FAILED(result)) { + std::ostringstream message; + message << operation << " failed (HRESULT 0x" << std::hex << static_cast(result) << ")."; + throw std::runtime_error(message.str()); + } + } + DXGI_FORMAT TypedFormat(DXGI_FORMAT format) + { + switch (format) { + case DXGI_FORMAT_R8G8B8A8_TYPELESS: case DXGI_FORMAT_R8G8B8A8_UNORM_SRGB: return DXGI_FORMAT_R8G8B8A8_UNORM; + case DXGI_FORMAT_B8G8R8A8_TYPELESS: case DXGI_FORMAT_B8G8R8A8_UNORM_SRGB: return DXGI_FORMAT_B8G8R8A8_UNORM; + case DXGI_FORMAT_R16G16B16A16_TYPELESS: return DXGI_FORMAT_R16G16B16A16_FLOAT; + case DXGI_FORMAT_R32G32B32A32_TYPELESS: return DXGI_FORMAT_R32G32B32A32_FLOAT; + default: return format; + } + } + D3D12_RESOURCE_BARRIER Barrier(ID3D12Resource* resource, D3D12_RESOURCE_STATES before, D3D12_RESOURCE_STATES after) + { + D3D12_RESOURCE_BARRIER barrier{}; + barrier.Type = D3D12_RESOURCE_BARRIER_TYPE_TRANSITION; + barrier.Transition.pResource = resource; + barrier.Transition.Subresource = D3D12_RESOURCE_BARRIER_ALL_SUBRESOURCES; + barrier.Transition.StateBefore = before; + barrier.Transition.StateAfter = after; + return barrier; + } + } + + void D3D12CopyBridge::InitializeDevice() + { + if (_device) return; + if (!_unity || !_unity->GetDevice()) throw std::runtime_error("Unity D3D12 v7 device is unavailable."); + _device = _unity->GetDevice(); + } + + void D3D12CopyBridge::InitializePresentation(ID3D11Device** device, ID3D11DeviceContext** context) + { + InitializeDevice(); + ID3D12CommandQueue* queue = _unity->GetCommandQueue(); + if (!queue) throw std::runtime_error("D3D12 presentation must execute in the configured submission-thread event."); + IUnknown* queues[] = {queue}; + const D3D_FEATURE_LEVEL levels[] = {D3D_FEATURE_LEVEL_11_0}; + Check12(D3D11On12CreateDevice(_device.Get(), D3D11_CREATE_DEVICE_BGRA_SUPPORT, + levels, 1, queues, 1, 0, device, context, nullptr), "Create same-queue D3D11On12 presenter"); + Check12((*device)->QueryInterface(IID_PPV_ARGS(&_on12)), "Query D3D11On12 interface"); + } + + std::shared_ptr D3D12CopyBridge::Source(void* nativeTexture) + { + if (!nativeTexture) return nullptr; + auto result = std::make_shared(); + Check12(static_cast(nativeTexture)->QueryInterface(IID_PPV_ARGS(&result->source)), "Acquire Unity D3D12 texture"); + const auto description = result->source->GetDesc(); + if (description.Dimension != D3D12_RESOURCE_DIMENSION_TEXTURE2D || description.DepthOrArraySize != 1 || + description.Width > 16384 || description.Height > 16384) + throw std::invalid_argument("D3D12 output requires a single 2D texture no larger than16384 pixels."); + result->width = static_cast(description.Width); result->height = description.Height; + result->format = description.Format; + return result; + } + + void D3D12CopyBridge::Prepare(D3D12CopySource& source) + { + if (source.snapshot) return; + ComPtr owner; + Check12(source.source->GetDevice(IID_PPV_ARGS(&owner)), "Read Unity texture device"); + if (owner.Get() != _device.Get()) throw std::invalid_argument("Texture belongs to a different D3D12 device."); + auto description = source.source->GetDesc(); + description.SampleDesc.Count = 1; description.SampleDesc.Quality = 0; + description.MipLevels = 1; description.Flags = D3D12_RESOURCE_FLAG_NONE; + // Compatible format-family copies retain the stored bytes. A typed UNORM snapshot + // avoids illegal UNORM views on typed sRGB resources, including Unity ARGB32 targets. + description.Format = TypedFormat(description.Format); + D3D12_HEAP_PROPERTIES heap{}; + heap.Type = D3D12_HEAP_TYPE_DEFAULT; heap.CreationNodeMask = 1; heap.VisibleNodeMask = 1; + Check12(_device->CreateCommittedResource(&heap, D3D12_HEAP_FLAG_NONE, &description, + D3D12_RESOURCE_STATE_COMMON, nullptr, IID_PPV_ARGS(&source.snapshot)), "Create private GPU window snapshot"); + std::array slots; + for (auto& slot : slots) { + Check12(_device->CreateCommandAllocator(D3D12_COMMAND_LIST_TYPE_DIRECT, IID_PPV_ARGS(&slot.allocator)), "Create GPU copy allocator"); + Check12(_device->CreateCommandList(0, D3D12_COMMAND_LIST_TYPE_DIRECT, slot.allocator.Get(), nullptr, IID_PPV_ARGS(&slot.commands)), "Create GPU copy command list"); + Check12(slot.commands->Close(), "Close initial copy command list"); + } + source.slots = std::move(slots); + source.unityState.resource = source.source.Get(); + source.unityState.expected = source.unityState.current = source.source->GetDesc().SampleDesc.Count > 1 + ? D3D12_RESOURCE_STATE_RESOLVE_SOURCE : D3D12_RESOURCE_STATE_COPY_SOURCE; + } + + bool D3D12CopyBridge::RecordCopy(D3D12CopySource& source) + { + InitializeDevice(); + try { Prepare(source); } catch (...) { source.snapshot.Reset(); throw; } + const UINT64 completed = _unity->GetFrameFence()->GetCompletedValue(); + D3D12CopySource::CommandSlot* available = nullptr; + for (auto& slot : source.slots) if (slot.fence <= completed) { available = &slot; break; } + if (!available) return false; // Never block Unity waiting for a busy copy allocator. + Check12(available->allocator->Reset(), "Reset GPU copy allocator"); + Check12(available->commands->Reset(available->allocator.Get(), nullptr), "Reset GPU copy command list"); + const bool multisampled = source.source->GetDesc().SampleDesc.Count > 1; + const auto destinationState = multisampled ? D3D12_RESOURCE_STATE_RESOLVE_DEST : D3D12_RESOURCE_STATE_COPY_DEST; + auto before = Barrier(source.snapshot.Get(), D3D12_RESOURCE_STATE_COMMON, destinationState); + available->commands->ResourceBarrier(1, &before); + if (multisampled) { + available->commands->ResolveSubresource(source.snapshot.Get(), 0, source.source.Get(), 0, TypedFormat(source.format)); + } + else { + D3D12_TEXTURE_COPY_LOCATION destination{}, input{}; + destination.pResource = source.snapshot.Get(); destination.Type = D3D12_TEXTURE_COPY_TYPE_SUBRESOURCE_INDEX; + input.pResource = source.source.Get(); input.Type = D3D12_TEXTURE_COPY_TYPE_SUBRESOURCE_INDEX; + available->commands->CopyTextureRegion(&destination, 0, 0, 0, &input, nullptr); + } + auto after = Barrier(source.snapshot.Get(), destinationState, D3D12_RESOURCE_STATE_COMMON); + available->commands->ResourceBarrier(1, &after); + Check12(available->commands->Close(), "Close GPU copy command list"); + // Source state declaration is immutable after preparation; queued Unity workers may still read it. + // Unity orders this after preceding compositor work and inserts/tracks the source barriers. + available->fence = _unity->ExecuteCommandList(available->commands.Get(), 1, &source.unityState); + source.lastFence = (std::max)(source.lastFence, available->fence); + source.copied = true; + return true; + } + + ID3D11Resource* D3D12CopyBridge::Acquire(D3D12CopySource& source) + { + if (!_on12 || !source.copied) return nullptr; + source.lastFence = (std::max)(source.lastFence, FrameFenceValue()); + if (!source.wrapped) { + D3D11_RESOURCE_FLAGS flags{}; flags.BindFlags = D3D11_BIND_SHADER_RESOURCE; + Check12(_on12->CreateWrappedResource(source.snapshot.Get(), &flags, + D3D12_RESOURCE_STATE_COMMON, D3D12_RESOURCE_STATE_COMMON, IID_PPV_ARGS(&source.wrapped)), "Wrap private GPU snapshot"); + } + ID3D11Resource* resource = source.wrapped.Get(); + _on12->AcquireWrappedResources(&resource, 1); + return resource; + } + + void D3D12CopyBridge::Release(D3D12CopySource& source) + { + if (!_on12 || !source.wrapped) return; + ID3D11Resource* resource = source.wrapped.Get(); + _on12->ReleaseWrappedResources(&resource, 1); + } + + void D3D12CopyBridge::Retire(std::shared_ptr source) + { + if (!source) return; + std::lock_guard lock(_retiredMutex); + _retired.push_back(std::move(source)); + } + + void D3D12CopyBridge::Collect() + { + if (!_unity) return; + const UINT64 completed = _unity->GetFrameFence()->GetCompletedValue(); + std::lock_guard lock(_retiredMutex); + for (auto item = _retired.begin(); item != _retired.end();) { + if ((*item)->lastFence <= completed) item = _retired.erase(item); + else ++item; + } + } + + int D3D12CopyBridge::PendingResourceCount() + { + std::lock_guard lock(_retiredMutex); + return static_cast(_retired.size()); + } + + void D3D12CopyBridge::Shutdown() + { + std::lock_guard lock(_retiredMutex); + _retired.clear(); _on12.Reset(); _device.Reset(); + } + + ComPtr D3D12CopyBridge::WrapTarget(ID3D12Resource* resource) + { + ComPtr result; + D3D11_RESOURCE_FLAGS flags{}; flags.BindFlags = D3D11_BIND_RENDER_TARGET; + Check12(_on12->CreateWrappedResource(resource, &flags, D3D12_RESOURCE_STATE_COMMON, + D3D12_RESOURCE_STATE_COMMON, IID_PPV_ARGS(&result)), "Wrap composition backbuffer"); + return result; + } + void D3D12CopyBridge::AcquireTarget(ID3D11Resource* resource) { _on12->AcquireWrappedResources(&resource, 1); } + void D3D12CopyBridge::ReleaseTarget(ID3D11Resource* resource) { _on12->ReleaseWrappedResources(&resource, 1); } +} diff --git a/VisualPinball.Native~/NativeWindows/Windows/D3D12CopyBridge.h b/VisualPinball.Native~/NativeWindows/Windows/D3D12CopyBridge.h new file mode 100644 index 000000000..4240259f7 --- /dev/null +++ b/VisualPinball.Native~/NativeWindows/Windows/D3D12CopyBridge.h @@ -0,0 +1,70 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// SPDX-License-Identifier: MIT +// Licensed under MIT. See https://opensource.org/license/mit. + +#pragma once +#include +#include +#include +#include +#include "IUnityGraphicsD3D12.h" +#include +#include +#include +#include + +namespace vpnw +{ + // A source and its private GPU snapshot. Copies are submitted through Unity's state tracker. + // The snapshot is then read by D3D11On12 on the same queue after Unity flushes its workers. + struct D3D12CopySource + { + Microsoft::WRL::ComPtr source; + Microsoft::WRL::ComPtr snapshot; + Microsoft::WRL::ComPtr wrapped; + struct CommandSlot + { + Microsoft::WRL::ComPtr allocator; + Microsoft::WRL::ComPtr commands; + UINT64 fence = 0; + }; + std::array slots; + UINT64 lastFence = 0; + UINT width = 0, height = 0; + DXGI_FORMAT format = DXGI_FORMAT_UNKNOWN; + bool copied = false; + UnityGraphicsD3D12ResourceState unityState{}; + }; + + class D3D12CopyBridge + { + IUnityGraphicsD3D12v7* _unity = nullptr; + Microsoft::WRL::ComPtr _device; + Microsoft::WRL::ComPtr _on12; + std::mutex _retiredMutex; + std::vector> _retired; + void Prepare(D3D12CopySource& source); + public: + void SetUnity(IUnityGraphicsD3D12v7* unity) { _unity = unity; } + bool Supported() const { return _unity != nullptr; } + bool DeviceReady() const { return _device != nullptr; } + void InitializeDevice(); // Render thread: no direct queue access. + void InitializePresentation(ID3D11Device** device, ID3D11DeviceContext** context); // Submission thread only. + std::shared_ptr Source(void* nativeTexture); + bool RecordCopy(D3D12CopySource& source); + ID3D11Resource* Acquire(D3D12CopySource& source); + void Release(D3D12CopySource& source); + IUnknown* Queue() { return _unity->GetCommandQueue(); } // Submission-thread event only. + Microsoft::WRL::ComPtr WrapTarget(ID3D12Resource* resource); + void AcquireTarget(ID3D11Resource* resource); + void ReleaseTarget(ID3D11Resource* resource); + UINT64 FrameFenceValue() { return _unity->GetNextFrameFenceValue(); } + bool Completed(UINT64 value) { return _unity->GetFrameFence()->GetCompletedValue() >= value; } + void Retire(std::shared_ptr source); + int PendingResourceCount(); + void Collect(); // Rendering/submission thread only. + void Shutdown(); + }; +} diff --git a/VisualPinball.Native~/NativeWindows/Windows/NativeWindowTests.cpp b/VisualPinball.Native~/NativeWindows/Windows/NativeWindowTests.cpp new file mode 100644 index 000000000..2496ad24a --- /dev/null +++ b/VisualPinball.Native~/NativeWindows/Windows/NativeWindowTests.cpp @@ -0,0 +1,323 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// SPDX-License-Identifier: MIT +// Licensed under MIT. See https://opensource.org/license/mit. + +// Standalone integration checks against the same production source, with a small Unity-interface fixture. +// Owns temporary output windows and GPU devices only; it does not alter display settings. +#include "VisualPinballNativeWindows.cpp" +#include "D3D12CopyBridge.cpp" +#include + +using namespace vpnw; + +namespace Test +{ + UnityGfxRenderer renderer = kUnityGfxRendererD3D11; + ComPtr device11; + ComPtr device12; + ComPtr queue12; + ComPtr fence12; + UINT64 nextFence = 1; + std::vector> transitionAllocators; + std::vector> transitionLists; + std::unordered_map states; + + UnityGfxRenderer UNITY_INTERFACE_API GetRenderer() { return renderer; } + void UNITY_INTERFACE_API Register(IUnityGraphicsDeviceEventCallback) {} + void UNITY_INTERFACE_API Unregister(IUnityGraphicsDeviceEventCallback) {} + int UNITY_INTERFACE_API Reserve(int) { return 10000; } + ID3D11Device* UNITY_INTERFACE_API Device11() { return device11.Get(); } + ID3D12Device* UNITY_INTERFACE_API Device12() { return device12.Get(); } + ID3D12CommandQueue* UNITY_INTERFACE_API Queue12() { return queue12.Get(); } + ID3D12Fence* UNITY_INTERFACE_API Fence12() { return fence12.Get(); } + UINT64 UNITY_INTERFACE_API NextFence() { return nextFence; } + void UNITY_INTERFACE_API Configure(int, const UnityD3D12PluginEventConfig*) {} + UINT64 UNITY_INTERFACE_API Execute(ID3D12GraphicsCommandList* commands, int count, UnityGraphicsD3D12ResourceState* requested) + { + ComPtr allocator; + ComPtr preparation; + Check12(device12->CreateCommandAllocator(D3D12_COMMAND_LIST_TYPE_DIRECT, IID_PPV_ARGS(&allocator)), "Create fixture state allocator"); + Check12(device12->CreateCommandList(0, D3D12_COMMAND_LIST_TYPE_DIRECT, allocator.Get(), nullptr, IID_PPV_ARGS(&preparation)), "Create fixture state command list"); + for (int index = 0; index < count; index++) { + auto& state = requested[index]; + if (states[state.resource] != state.expected) { + auto barrier = Barrier(state.resource, states[state.resource], state.expected); + preparation->ResourceBarrier(1, &barrier); + } + states[state.resource] = state.current; + } + Check12(preparation->Close(), "Close fixture preparation"); + ID3D12CommandList* lists[] = {preparation.Get(), commands}; + queue12->ExecuteCommandLists(2, lists); + transitionAllocators.push_back(allocator); transitionLists.push_back(preparation); + return nextFence; + } + IUnityGraphics graphics{}; + IUnityGraphicsD3D11 graphics11{}; + IUnityGraphicsD3D12v7 graphics12{}; + IUnityInterface* UNITY_INTERFACE_API Interface(UnityInterfaceGUID guid) + { + auto matches = [guid](UnityInterfaceGUID candidate) { return guid.m_GUIDHigh == candidate.m_GUIDHigh && guid.m_GUIDLow == candidate.m_GUIDLow; }; + if (matches(GetUnityInterfaceGUID())) return &graphics; + if (renderer == kUnityGfxRendererD3D11 && matches(GetUnityInterfaceGUID())) return &graphics11; + if (renderer == kUnityGfxRendererD3D12 && matches(GetUnityInterfaceGUID())) return &graphics12; + return nullptr; + } + void Assert(bool condition, const char* description) + { + if (!condition) throw std::runtime_error(std::string(description) + ": " + LastError); + } + void Wait12() + { + Check12(queue12->Signal(fence12.Get(), nextFence), "Signal fixture frame fence"); + HANDLE completed = CreateEventW(nullptr, FALSE, FALSE, nullptr); + Check12(fence12->SetEventOnCompletion(nextFence++, completed), "Wait for fixture GPU work"); + Assert(WaitForSingleObject(completed, 10000) == WAIT_OBJECT_0, "GPU fixture completion"); + CloseHandle(completed); + transitionAllocators.clear(); transitionLists.clear(); + } + ComPtr Texture11(DXGI_FORMAT format = DXGI_FORMAT_B8G8R8A8_UNORM, bool corners = false) + { + const std::uint32_t pixels[4] = {corners ? 0xffff0000u : 0x80008000u, corners ? 0xff00ff00u : 0x80008000u, corners ? 0xff0000ffu : 0x80008000u, corners ? 0xffffff00u : 0x80008000u}; + D3D11_TEXTURE2D_DESC description{}; + description.Width = description.Height = 2; description.ArraySize = description.MipLevels = 1; + description.Format = format; description.SampleDesc.Count = 1; + description.Usage = D3D11_USAGE_IMMUTABLE; description.BindFlags = D3D11_BIND_SHADER_RESOURCE; + D3D11_SUBRESOURCE_DATA data{pixels, 8, 0}; + ComPtr texture; + Check(device11->CreateTexture2D(&description, &data, &texture), "Create fixture RGBA texture"); + return texture; + } + ComPtr Texture12(DXGI_FORMAT format = DXGI_FORMAT_B8G8R8A8_UNORM, bool corners = false) + { + D3D12_RESOURCE_DESC description{}; + description.Dimension = D3D12_RESOURCE_DIMENSION_TEXTURE2D; description.Width = description.Height = 2; + description.DepthOrArraySize = description.MipLevels = 1; + description.Format = format; description.SampleDesc.Count = 1; + D3D12_HEAP_PROPERTIES heap{}; heap.Type = D3D12_HEAP_TYPE_DEFAULT; + ComPtr texture; + Check12(device12->CreateCommittedResource(&heap, D3D12_HEAP_FLAG_NONE, &description, + D3D12_RESOURCE_STATE_COPY_DEST, nullptr, IID_PPV_ARGS(&texture)), "Create D3D12 fixture texture"); + D3D12_PLACED_SUBRESOURCE_FOOTPRINT footprint{}; UINT64 bytes; + device12->GetCopyableFootprints(&description, 0, 1, 0, &footprint, nullptr, nullptr, &bytes); + D3D12_RESOURCE_DESC uploadDescription{}; + uploadDescription.Dimension = D3D12_RESOURCE_DIMENSION_BUFFER; uploadDescription.Width = bytes; + uploadDescription.Height = uploadDescription.DepthOrArraySize = uploadDescription.MipLevels = 1; + uploadDescription.SampleDesc.Count = 1; uploadDescription.Layout = D3D12_TEXTURE_LAYOUT_ROW_MAJOR; + heap.Type = D3D12_HEAP_TYPE_UPLOAD; + ComPtr upload; + Check12(device12->CreateCommittedResource(&heap, D3D12_HEAP_FLAG_NONE, &uploadDescription, + D3D12_RESOURCE_STATE_GENERIC_READ, nullptr, IID_PPV_ARGS(&upload)), "Create fixture upload"); + void* pointer; Check12(upload->Map(0, nullptr, &pointer), "Map fixture upload"); + const std::uint32_t pixels[4] = {0xffff0000u, 0xff00ff00u, 0xff0000ffu, 0xffffff00u}; + for (UINT y = 0; y < 2; y++) for (UINT x = 0; x < 2; x++) + *reinterpret_cast(static_cast(pointer) + y * footprint.Footprint.RowPitch + x * 4) = corners ? pixels[y * 2 + x] : 0x80008000; + upload->Unmap(0, nullptr); + ComPtr allocator; ComPtr commands; + Check12(device12->CreateCommandAllocator(D3D12_COMMAND_LIST_TYPE_DIRECT, IID_PPV_ARGS(&allocator)), "Create fixture upload allocator"); + Check12(device12->CreateCommandList(0, D3D12_COMMAND_LIST_TYPE_DIRECT, allocator.Get(), nullptr, IID_PPV_ARGS(&commands)), "Create fixture upload commands"); + D3D12_TEXTURE_COPY_LOCATION output{}, input{}; + output.pResource = texture.Get(); input.pResource = upload.Get(); input.Type = D3D12_TEXTURE_COPY_TYPE_PLACED_FOOTPRINT; + input.PlacedFootprint = footprint; + commands->CopyTextureRegion(&output, 0, 0, 0, &input, nullptr); + Check12(commands->Close(), "Close fixture upload"); + ID3D12CommandList* list = commands.Get(); queue12->ExecuteCommandLists(1, &list); + states[texture.Get()] = D3D12_RESOURCE_STATE_COPY_DEST; + Wait12(); + return texture; + } + void Frame() + { + RenderEvent(VPNW_GetDrawEventId()); + RenderEvent(VPNW_GetPresentEventId()); + if (renderer == kUnityGfxRendererD3D12) Wait12(); + } + void NestedWindowWork(HWND ownWindow, int id) + { + std::promise entered; + auto started = entered.get_future(); + std::atomic finish{false}; + auto nested = std::async(std::launch::async, [&] { + GetWindowThread()->Invoke([&] { + entered.set_value(); + while (!finish.load()) { + MSG message{}; + if (GetMessageW(&message, nullptr, 0, 0) > 0) DispatchMessageW(&message); + } + }); + }); + started.get(); + auto queued = std::async(std::launch::async, [=] { return VPNW_SetZOrder(id, 1); }); + const bool responsive = queued.wait_for(std::chrono::seconds(2)) == std::future_status::ready; + finish.store(true); PostMessageW(ownWindow, WM_NULL, 0, 0); + nested.get(); + Assert(responsive && queued.get() != 0, "Window work completes inside a nested modal message loop"); + } + void CheckPresentedPixels(int id, bool corners = false, bool flipped = false) + { + // One-off fixture readback proves GPU shader/color/alpha output. Production never reads pixels. + auto surface = Require(id); + RenderSurface(surface, false); + if (renderer == kUnityGfxRendererD3D12) Wait12(); + ComPtr rendered; + surface->targetView->GetResource(&rendered); + ComPtr texture; + Check(rendered.As(&texture), "Read fixture backbuffer type"); + D3D11_TEXTURE2D_DESC description{}; texture->GetDesc(&description); + description.Usage = D3D11_USAGE_STAGING; description.BindFlags = 0; + description.CPUAccessFlags = D3D11_CPU_ACCESS_READ; description.MiscFlags = 0; + ComPtr staging; + Check(Device->CreateTexture2D(&description, nullptr, &staging), "Create test-only pixel readback"); + if (renderer == kUnityGfxRendererD3D12) CopyBridge.AcquireTarget(rendered.Get()); + Context->CopyResource(staging.Get(), rendered.Get()); + if (renderer == kUnityGfxRendererD3D12) { + CopyBridge.ReleaseTarget(rendered.Get()); Context->Flush(); Wait12(); + } + D3D11_MAPPED_SUBRESOURCE mapped{}; + Check(Context->Map(staging.Get(), 0, D3D11_MAP_READ, 0, &mapped), "Map test-only pixel readback"); + const auto pixel = *reinterpret_cast(static_cast(mapped.pData) + + (description.Height / 2) * mapped.RowPitch + (description.Width / 2) * 4); + bool correct = pixel == 0x80008000; + if (corners) { + const std::uint32_t expected[4] = {0xffff0000u, 0xff00ff00u, 0xff0000ffu, 0xffffff00u}; + correct = true; + for (UINT y = 0; y < 2; y++) for (UINT x = 0; x < 2; x++) { + const auto observed = *reinterpret_cast(static_cast(mapped.pData) + + (y ? description.Height - 8 : 8) * mapped.RowPitch + (x ? description.Width - 8 : 8) * 4); + correct &= observed == expected[(flipped ? 1 - y : y) * 2 + x]; + } + } + Context->Unmap(staging.Get(), 0); + Assert(correct, corners ? "Native output preserves four-corner orientation and explicit vertical flip" : "GPU output retains exact stored SDR green128/alpha128 premultiplied bytes"); + } + void Roundtrip(IUnityInterfaces& interfaces, IUnknown* texture) + { + UnityPluginLoad(&interfaces); + const int id = VPNW_CreateWindow(-160, -96, 320, 240); + Assert(id != 0, "Create borderless window"); + Assert(VPNW_ConfigureWindow(id, -160, -96, 320, 240, 0, 1) != 0, "Configure signed desktop rectangle"); + VPNW_Diagnostics diagnostics{}; diagnostics.structBytes = sizeof(diagnostics); + Assert(VPNW_GetDiagnostics(id, &diagnostics) != 0, "Read hidden window diagnostics"); + Assert(diagnostics.left == -160 && diagnostics.top == -96 && diagnostics.windowWidth == 320 && diagnostics.windowHeight == 240, + "Exact physical signed window rectangle"); + std::cout << "hidden dpi=" << diagnostics.dpi << " visible=" << diagnostics.visible << " topmost=" << diagnostics.topmost << "\n"; + Assert(diagnostics.dpi >= 96 && diagnostics.visible == 0, "DPI/visibility/z-order"); + Assert(VPNW_SetPlacementMode(id, 1) != 0, "Enable native placement interaction"); + const HWND ownWindow = Require(id)->window.load(); + GetWindowThread()->Invoke([&] { + RECT bounds{}; GetWindowRect(ownWindow, &bounds); + Assert(SendMessageW(ownWindow, WM_NCHITTEST, 0, MAKELPARAM((bounds.left + bounds.right) / 2, (bounds.top + bounds.bottom) / 2)) == HTCAPTION, "Drag the placement window body"); + Assert(SendMessageW(ownWindow, WM_NCHITTEST, 0, MAKELPARAM(bounds.left + 2, bounds.top + 2)) == HTTOPLEFT, "Resize the placement window corner"); + }); + Assert(VPNW_SetPlacementMode(id, 0) != 0, "Disable placement during normal playback"); + Assert(VPNW_SetTexture(id, texture, 0) != 0, "Retain GPU source texture"); + Assert(VPNW_ConfigureWindow(id, 20, 20, 64, 64, 1, 0) != 0, "Show temporary own output window"); + Assert(VPNW_SetPlacementMode(id, 1) && VPNW_SetPlacementMode(id, 0) && IsWindowVisible(ownWindow), "Placement toggles preserve visible window style"); + NestedWindowWork(ownWindow, id); + Assert(VPNW_SetZOrder(id, 2) != 0 && VPNW_GetDiagnostics(id, &diagnostics) != 0 && diagnostics.topmost == 1, "Topmost visible output"); + Assert(VPNW_ConfigureWindow(id, 20, 20, 64, 64, 1, 0) != 0 && VPNW_GetDiagnostics(id, &diagnostics) != 0 && diagnostics.topmost == 0, + "Configure clears a topmost state set through z-order"); + Assert(VPNW_SetZOrder(id, 2) != 0 && VPNW_GetDiagnostics(id, &diagnostics) != 0 && diagnostics.topmost == 1, "Topmost visible output again"); + Assert(VPNW_SetZOrder(id, 3) != 0 && VPNW_GetDiagnostics(id, &diagnostics) != 0 && diagnostics.topmost == 0, "Clear topmost visible output"); + Assert(VPNW_SetZOrder(id, 0) != 0 && VPNW_SetZOrder(id, 1) != 0, "Send back and bring front"); + for (int attempt = 0; attempt < 20; attempt++) { + Frame(); + Assert(VPNW_GetDiagnostics(id, &diagnostics) != 0, "Read nonblocking presentation attempt"); + if (diagnostics.presentedFrames > 0) break; + Sleep(10); // A busy DWM may reject an initial DO_NOT_WAIT present; bounded fixture retry only. + } + Assert(VPNW_GetDiagnostics(id, &diagnostics) != 0, "Read presented output diagnostics"); + std::cout << "presentation renderer=" << diagnostics.graphicsRenderer << " ready=" << diagnostics.deviceReady << + " visible=" << diagnostics.visible << " source=" << diagnostics.sourceWidth << 'x' << diagnostics.sourceHeight << + " presents=" << diagnostics.presentedFrames << " skips=" << diagnostics.skippedFrames << " hr=" << diagnostics.lastResult << "\n"; + Assert((diagnostics.presentedFrames > 0 || diagnostics.lastResult == DXGI_STATUS_OCCLUDED) && + diagnostics.sourceWidth == 2 && diagnostics.sourceHeight == 2, "GPU texture presented or desktop explicitly occluded"); + Assert(diagnostics.premultipliedAlpha == 1 && diagnostics.lastResult >= 0, "DirectComposition premultiplied-alpha output"); + CheckPresentedPixels(id); + Assert(VPNW_ConfigureWindow(id, 20, 20, 96, 48, 1, 0) != 0, "Resize own output"); + Frame(); Frame(); + Assert(VPNW_GetDiagnostics(id, &diagnostics) != 0 && diagnostics.windowWidth == 96 && diagnostics.windowHeight == 48, "Resize presentation path"); + const auto before = diagnostics.presentedFrames; + Assert(VPNW_SetTexture(id, nullptr, 0) != 0, "Detach source"); + Frame(); + Assert(VPNW_GetDiagnostics(id, &diagnostics) != 0 && diagnostics.presentedFrames == before, "No texture means no presentation"); + Assert(VPNW_DestroyWindow(id) != 0 && VPNW_DestroyWindow(id) != 0, "Idempotent destruction"); + Frame(); // Queued events can outlive the surface; no recycled ID/pointer is dereferenced. + if (renderer == kUnityGfxRendererD3D12) { + const int pending = VPNW_CreateWindow(20, 20, 32, 32); + Assert(pending != 0 && VPNW_SetTexture(pending, texture, 0) != 0 && + VPNW_ConfigureWindow(pending, 20, 20, 32, 32, 1, 0) != 0, "Create pending-frame destruction fixture"); + RenderEvent(VPNW_GetDrawEventId()); RenderEvent(VPNW_GetPresentEventId()); + Assert(VPNW_SetTexture(pending, nullptr, 0) != 0 && VPNW_DestroyWindow(pending) != 0, "Detach and destroy before GPU frame fence"); + Assert(!RetiredSurfaces.empty(), "Detached presentation resources retained until fence"); + Assert(VPNW_GetPendingResourceCount() >= 2, "Pending source and presentation resource diagnostics"); + Wait12(); Frame(); + Assert(RetiredSurfaces.empty(), "Completed presentation resources collected"); + Assert(VPNW_GetPendingResourceCount() == 0, "Cleanup events drain pending resource diagnostics"); + } + UnityPluginUnload(); + std::cout << "Passed native backend " << static_cast(renderer) << " roundtrip/pixel output; presents=" << before << ".\n"; + } + void Orientation(IUnityInterfaces& interfaces, IUnknown* texture) + { + UnityPluginLoad(&interfaces); + const int id = VPNW_CreateWindow(20, 20, 64, 64); + Assert(id != 0 && VPNW_ConfigureWindow(id, 20, 20, 64, 64, 1, 0), "Create orientation fixture"); + for (int flip : {0, 1}) { + Assert(VPNW_SetTexture(id, texture, flip) != 0, "Set corner texture"); + Frame(); CheckPresentedPixels(id, true, flip != 0); + } + VPNW_DestroyWindow(id); Frame(); UnityPluginUnload(); + std::cout << "Passed four-corner orientation for backend " << static_cast(renderer) << ".\n"; + } +} + +int main() +{ + try { + Test::graphics.GetRenderer = Test::GetRenderer; Test::graphics.RegisterDeviceEventCallback = Test::Register; + Test::graphics.UnregisterDeviceEventCallback = Test::Unregister; Test::graphics.ReserveEventIDRange = Test::Reserve; + Test::graphics11.GetDevice = Test::Device11; + Test::graphics12.GetDevice = Test::Device12; Test::graphics12.GetCommandQueue = Test::Queue12; + Test::graphics12.GetFrameFence = Test::Fence12; Test::graphics12.GetNextFrameFenceValue = Test::NextFence; + Test::graphics12.ExecuteCommandList = Test::Execute; Test::graphics12.ConfigureEvent = Test::Configure; + IUnityInterfaces interfaces{}; interfaces.GetInterface = Test::Interface; + const std::uint32_t capabilities = VPNW_CapabilityWindows | VPNW_CapabilityPlacement | VPNW_CapabilityTopmost | VPNW_CapabilityTransparency; + Test::Assert(VPNW_GetCapabilities() == capabilities, "Windows reports window, placement, topmost and transparency capabilities"); + VPNW_Pump(); // No-op on Windows; must be safe to call before any window exists. + Check(D3D11CreateDevice(nullptr, D3D_DRIVER_TYPE_HARDWARE, nullptr, D3D11_CREATE_DEVICE_BGRA_SUPPORT, + nullptr, 0, D3D11_SDK_VERSION, &Test::device11, nullptr, nullptr), "Create D3D11 test device"); + auto texture11 = Test::Texture11(); + Test::Roundtrip(interfaces, texture11.Get()); + auto corners11 = Test::Texture11(DXGI_FORMAT_B8G8R8A8_UNORM, true); + Test::Orientation(interfaces, corners11.Get()); + for (auto format : {DXGI_FORMAT_R8G8B8A8_TYPELESS, DXGI_FORMAT_B8G8R8A8_TYPELESS, DXGI_FORMAT_R8G8B8A8_UNORM_SRGB, DXGI_FORMAT_B8G8R8A8_UNORM_SRGB}) { + std::cout << "D3D11 source format=" << static_cast(format) << "\n"; + auto typedFixture = Test::Texture11(format); + Test::Roundtrip(interfaces, typedFixture.Get()); + } + Test::renderer = kUnityGfxRendererD3D12; + Check12(D3D12CreateDevice(nullptr, D3D_FEATURE_LEVEL_11_0, IID_PPV_ARGS(&Test::device12)), "Create D3D12 test device"); + D3D12_COMMAND_QUEUE_DESC queue{}; queue.Type = D3D12_COMMAND_LIST_TYPE_DIRECT; + Check12(Test::device12->CreateCommandQueue(&queue, IID_PPV_ARGS(&Test::queue12)), "Create D3D12 test queue"); + Check12(Test::device12->CreateFence(0, D3D12_FENCE_FLAG_NONE, IID_PPV_ARGS(&Test::fence12)), "Create D3D12 test fence"); + auto texture12 = Test::Texture12(); + Test::Roundtrip(interfaces, texture12.Get()); + auto corners12 = Test::Texture12(DXGI_FORMAT_B8G8R8A8_UNORM, true); + Test::Orientation(interfaces, corners12.Get()); + for (auto format : {DXGI_FORMAT_R8G8B8A8_TYPELESS, DXGI_FORMAT_B8G8R8A8_TYPELESS, DXGI_FORMAT_R8G8B8A8_UNORM_SRGB, DXGI_FORMAT_B8G8R8A8_UNORM_SRGB}) { + std::cout << "D3D12 source format=" << static_cast(format) << "\n"; + auto typedFixture = Test::Texture12(format); + Test::Roundtrip(interfaces, typedFixture.Get()); + } + return 0; + } + catch (const std::exception& error) { + std::cerr << error.what() << "\n"; + VPNW_DestroyAll(); + UnityPluginUnload(); + return 1; + } +} diff --git a/VisualPinball.Native~/NativeWindows/Windows/VisualPinballNativeWindows.cpp b/VisualPinball.Native~/NativeWindows/Windows/VisualPinballNativeWindows.cpp new file mode 100644 index 000000000..fb9ff9dfe --- /dev/null +++ b/VisualPinball.Native~/NativeWindows/Windows/VisualPinballNativeWindows.cpp @@ -0,0 +1,855 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// SPDX-License-Identifier: MIT +// Licensed under MIT. See https://opensource.org/license/mit. + +#define VPNW_BUILD +#define WIN32_LEAN_AND_MEAN +#define NOMINMAX +#include "VisualPinballNativeWindows.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "IUnityInterface.h" +#include "IUnityGraphics.h" +#include "IUnityGraphicsD3D11.h" +#include "D3D12CopyBridge.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using Microsoft::WRL::ComPtr; +extern "C" IMAGE_DOS_HEADER __ImageBase; + +namespace vpnw +{ + constexpr wchar_t WindowClass[] = L"VisualPinball.NativeWindow"; + constexpr wchar_t WindowTitle[] = L"Visual Pinball Window"; + constexpr wchar_t PlacementProperty[] = L"VisualPinball.NativeWindow.Placement"; + std::mutex ErrorMutex; + std::string LastError; + IUnityInterfaces* UnityInterfaces = nullptr; + IUnityGraphics* UnityGraphics = nullptr; + std::atomic Renderer{-1}; + D3D12CopyBridge CopyBridge; + int DrawEventId = 0, PresentEventId = 0; + + void RecordError(const std::string& message) + { + std::lock_guard lock(ErrorMutex); + LastError = message; + OutputDebugStringA(("VisualPinballNativeWindows: " + message + "\n").c_str()); + } + + void Check(HRESULT result, const char* operation) + { + if (FAILED(result)) { + std::ostringstream text; + text << operation << " failed (HRESULT 0x" << std::hex << static_cast(result) << ")."; + throw std::runtime_error(text.str()); + } + } + + void CheckWindow(bool success, const char* operation) + { + if (!success) + throw std::runtime_error(std::string(operation) + " failed (Win32 " + std::to_string(GetLastError()) + ")."); + } + + template int Guard(Function operation) + { + try { return operation(); } + catch (const std::exception& error) { RecordError(error.what()); } + catch (...) { RecordError("Unexpected native exception."); } + return 0; + } + + void ValidateRectangle(int x, int y, int width, int height) + { + if (width < 1 || height < 1 || width > D3D11_REQ_TEXTURE2D_U_OR_V_DIMENSION || height > D3D11_REQ_TEXTURE2D_U_OR_V_DIMENSION) + throw std::invalid_argument("Window dimensions must be between 1 and 16384 physical pixels."); + if (static_cast(x) + width > INT_MAX || static_cast(y) + height > INT_MAX) + throw std::invalid_argument("Desktop rectangle exceeds signed Win32 coordinate range."); + } + + constexpr UINT WindowTasksMessage = WM_APP + 1; + void DrainWindowTasks(void* thread); + LRESULT CALLBACK WindowProcedure(HWND window, UINT message, WPARAM wParam, LPARAM lParam) + { + if (message == WM_NCCREATE) SetWindowLongPtrW(window, GWLP_USERDATA, reinterpret_cast(reinterpret_cast(lParam)->lpCreateParams)); + if (message == WindowTasksMessage) { + if (auto thread = reinterpret_cast(GetWindowLongPtrW(window, GWLP_USERDATA))) DrainWindowTasks(thread); + return 0; + } + if (GetPropW(window, PlacementProperty)) { + if (message == WM_NCCALCSIZE) return 0; + if (message == WM_NCHITTEST) { + RECT bounds{}; GetWindowRect(window, &bounds); + const int x = GET_X_LPARAM(lParam), y = GET_Y_LPARAM(lParam); + const bool left = x < bounds.left + 10, right = x >= bounds.right - 10; + const bool top = y < bounds.top + 10, bottom = y >= bounds.bottom - 10; + if (top) return left ? HTTOPLEFT : right ? HTTOPRIGHT : HTTOP; + if (bottom) return left ? HTBOTTOMLEFT : right ? HTBOTTOMRIGHT : HTBOTTOM; + return left ? HTLEFT : right ? HTRIGHT : HTCAPTION; + } + if (message == WM_GETMINMAXINFO) { + auto* limits = reinterpret_cast(lParam); + limits->ptMinTrackSize = { 64, 32 }; return 0; + } + } + switch (message) { + case WM_PAINT: ValidateRect(window, nullptr); return 0; + case WM_ERASEBKGND: return 1; + case WM_MOUSEACTIVATE: return MA_NOACTIVATE; + case WM_CLOSE: ShowWindow(window, SW_HIDE); return 0; + // The application owns physical-pixel rectangles. Do not substitute Windows' suggested logical size. + case WM_DPICHANGED: return 0; + default: return DefWindowProcW(window, message, wParam, lParam); + } + } + + // HWND creation, positioning and destruction stay on one message-pumping thread. + // This thread's DPI context changes; the Unity process-wide DPI policy does not. + class WindowThread + { + HANDLE _wake = nullptr; + std::thread _thread; + std::atomic _stop{false}; + DWORD _threadId = 0; + HWND _dispatcher = nullptr; + std::mutex _mutex; + std::deque> _tasks; + public: + WindowThread() + { + _wake = CreateEventW(nullptr, FALSE, FALSE, nullptr); + CheckWindow(_wake != nullptr, "CreateEvent"); + std::promise started; + auto future = started.get_future(); + _thread = std::thread([this, ready = std::move(started)]() mutable { + _threadId = GetCurrentThreadId(); + try { + CheckWindow(SetThreadDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2) != nullptr, "SetThreadDpiAwarenessContext"); + WNDCLASSEXW description{}; + description.cbSize = sizeof(description); + description.lpfnWndProc = WindowProcedure; + description.hInstance = reinterpret_cast(&__ImageBase); + description.hCursor = LoadCursorW(nullptr, IDC_ARROW); + description.lpszClassName = WindowClass; + CheckWindow(RegisterClassExW(&description) != 0, "RegisterClassEx"); + _dispatcher = CreateWindowExW(0, WindowClass, L"", 0, 0, 0, 0, 0, HWND_MESSAGE, nullptr, description.hInstance, this); + CheckWindow(_dispatcher != nullptr, "Create window-task dispatcher"); + ready.set_value(); + } + catch (...) { ready.set_exception(std::current_exception()); return; } + while (!_stop.load()) { + MsgWaitForMultipleObjects(1, &_wake, FALSE, INFINITE, QS_ALLINPUT); + DrainTasks(); + MSG message{}; + while (PeekMessageW(&message, nullptr, 0, 0, PM_REMOVE)) { + TranslateMessage(&message); + DispatchMessageW(&message); + } + } + DestroyWindow(_dispatcher); + UnregisterClassW(WindowClass, reinterpret_cast(&__ImageBase)); + }); + try { future.get(); } + catch (...) { _thread.join(); CloseHandle(_wake); throw; } + } + ~WindowThread() + { + _stop.store(true); + SetEvent(_wake); + _thread.join(); + CloseHandle(_wake); + } + template auto Invoke(Function operation) -> decltype(operation()) + { + if (GetCurrentThreadId() == _threadId) return operation(); + using Result = decltype(operation()); + auto task = std::make_shared>(std::move(operation)); + auto future = task->get_future(); + { std::lock_guard lock(_mutex); _tasks.emplace_back([task] { (*task)(); }); } + SetEvent(_wake); + // Windows' nested move/resize loop dispatches window messages, but does not + // wait on our wake event. A message-only HWND keeps queued calls responsive there. + PostMessageW(_dispatcher, WindowTasksMessage, 0, 0); + return future.get(); + } + void DrainTasks() + { + std::deque> tasks; + { std::lock_guard lock(_mutex); tasks.swap(_tasks); } + for (auto& task : tasks) task(); + } + }; + void DrainWindowTasks(void* thread) { static_cast(thread)->DrainTasks(); } + + struct Surface + { + std::mutex mutex; + std::atomic alive{true}; + std::atomic window{nullptr}; + ComPtr source; + std::shared_ptr source12; + ComPtr resolved; + ComPtr sourceView; + bool copyStoredValues = false; + ComPtr swapChain; + ComPtr targetView; + std::array, 2> targetViews12; + std::array, 2> targetResources12; + ID3D11Resource* acquiredTarget12 = nullptr; + UINT64 lastFence12 = 0; + ComPtr composition; + ComPtr compositionTarget; + ComPtr visual; + UINT width = 0, height = 0; + bool flipY = false; + // Configuration cache is owned exclusively by the HWND message thread. + bool configured = false, requestedVisible = false, requestedTopmost = false; + int requestedX = 0, requestedY = 0, requestedWidth = 0, requestedHeight = 0; + std::uint64_t presented = 0, skipped = 0; + HRESULT lastResult = S_OK; + + void ReleasePresentation() + { + if (compositionTarget) compositionTarget->SetRoot(nullptr); + if (visual) visual->SetContent(nullptr); + if (composition) composition->Commit(); + visual.Reset(); compositionTarget.Reset(); composition.Reset(); + targetView.Reset(); + for (auto& view : targetViews12) view.Reset(); + for (auto& resource : targetResources12) resource.Reset(); + swapChain.Reset(); + width = height = 0; + } + }; + + std::mutex RegistryMutex; + std::unordered_map> Surfaces; + std::uint64_t NextId = 1; + std::mutex RetiredSurfaceMutex; + std::vector> RetiredSurfaces; + std::mutex WindowServiceMutex; + std::shared_ptr WindowService; + + std::shared_ptr GetWindowThread() + { + std::lock_guard lock(WindowServiceMutex); + if (!WindowService) WindowService = std::make_shared(); + return WindowService; + } + + std::shared_ptr Find(int id) + { + std::lock_guard lock(RegistryMutex); + auto item = Surfaces.find(id); + return item == Surfaces.end() ? nullptr : item->second; + } + + std::shared_ptr Require(int id) + { + auto surface = Find(id); + if (!surface) throw std::invalid_argument("Unknown or destroyed native window ID."); + return surface; + } + + std::mutex DeviceMutex; + ComPtr Device; + ComPtr Context; + ComPtr Context1; + ComPtr PluginContextState; + ComPtr VertexShader; + ComPtr PixelShader; + ComPtr Sampler; + ComPtr Rasterizer; + ComPtr Blend; + ComPtr Depth; + ComPtr Parameters; + + constexpr char ShaderSource[] = R"( +cbuffer Parameters : register(b0) { float flipY; float3 padding; }; +struct Vertex { float4 position : SV_Position; float2 uv : TEXCOORD0; }; +Vertex VertexMain(uint id : SV_VertexID) { + Vertex result; + result.uv = float2((id << 1) & 2, id & 2); + result.position = float4(result.uv * float2(2, -2) + float2(-1, 1), 0, 1); + return result; +} +Texture2D scene : register(t0); +SamplerState sceneSampler : register(s0); +float4 PixelMain(Vertex input) : SV_Target { + float2 uv = input.uv; + if (flipY != 0) uv.y = 1 - uv.y; + return scene.Sample(sceneSampler, uv); +})"; + + void CreatePipeline() + { + ComPtr vertex, pixel, errors; + Check(D3DCompile(ShaderSource, sizeof(ShaderSource) - 1, "VisualPinballNativeWindow", nullptr, nullptr, + "VertexMain", "vs_5_0", D3DCOMPILE_OPTIMIZATION_LEVEL3, 0, &vertex, &errors), "Compile native vertex shader"); + Check(D3DCompile(ShaderSource, sizeof(ShaderSource) - 1, "VisualPinballNativeWindow", nullptr, nullptr, + "PixelMain", "ps_5_0", D3DCOMPILE_OPTIMIZATION_LEVEL3, 0, &pixel, &errors), "Compile native pixel shader"); + Check(Device->CreateVertexShader(vertex->GetBufferPointer(), vertex->GetBufferSize(), nullptr, &VertexShader), "CreateVertexShader"); + Check(Device->CreatePixelShader(pixel->GetBufferPointer(), pixel->GetBufferSize(), nullptr, &PixelShader), "CreatePixelShader"); + D3D11_SAMPLER_DESC sampler{}; + sampler.Filter = D3D11_FILTER_MIN_MAG_MIP_LINEAR; + sampler.AddressU = sampler.AddressV = sampler.AddressW = D3D11_TEXTURE_ADDRESS_CLAMP; + sampler.MaxLOD = D3D11_FLOAT32_MAX; + Check(Device->CreateSamplerState(&sampler, &Sampler), "CreateSamplerState"); + D3D11_RASTERIZER_DESC rasterizer{}; + rasterizer.FillMode = D3D11_FILL_SOLID; rasterizer.CullMode = D3D11_CULL_NONE; rasterizer.DepthClipEnable = TRUE; + Check(Device->CreateRasterizerState(&rasterizer, &Rasterizer), "CreateRasterizerState"); + D3D11_BLEND_DESC blend{}; + blend.RenderTarget[0].RenderTargetWriteMask = D3D11_COLOR_WRITE_ENABLE_ALL; + blend.RenderTarget[0].SrcBlend = blend.RenderTarget[0].SrcBlendAlpha = D3D11_BLEND_ONE; + blend.RenderTarget[0].DestBlend = blend.RenderTarget[0].DestBlendAlpha = D3D11_BLEND_ZERO; + blend.RenderTarget[0].BlendOp = blend.RenderTarget[0].BlendOpAlpha = D3D11_BLEND_OP_ADD; + Check(Device->CreateBlendState(&blend, &Blend), "CreateBlendState"); + D3D11_DEPTH_STENCIL_DESC depth{}; + depth.DepthFunc = D3D11_COMPARISON_ALWAYS; + depth.StencilReadMask = depth.StencilWriteMask = 255; + depth.FrontFace.StencilFailOp = depth.FrontFace.StencilDepthFailOp = depth.FrontFace.StencilPassOp = D3D11_STENCIL_OP_KEEP; + depth.FrontFace.StencilFunc = D3D11_COMPARISON_ALWAYS; + depth.BackFace = depth.FrontFace; + Check(Device->CreateDepthStencilState(&depth, &Depth), "CreateDepthStencilState"); + D3D11_BUFFER_DESC buffer{}; + buffer.ByteWidth = 16; buffer.Usage = D3D11_USAGE_DEFAULT; buffer.BindFlags = D3D11_BIND_CONSTANT_BUFFER; + Check(Device->CreateBuffer(&buffer, nullptr, &Parameters), "CreateBuffer"); + } + + // D3D11.1 swaps the entire context state, including UAVs and shader resources which + // resource hazards could implicitly unbind. This avoids partial-state restoration bugs. + class SavedContext + { + ID3D11DeviceContext1* _context; + ComPtr _previous; + public: + explicit SavedContext(ID3D11DeviceContext1* context) : _context(context) + { + _context->SwapDeviceContextState(PluginContextState.Get(), &_previous); + } + ~SavedContext() { _context->SwapDeviceContextState(_previous.Get(), nullptr); } + }; + + DXGI_FORMAT SamplingFormat(DXGI_FORMAT format) + { + // Present the stored SDR RGB values without an implicit sRGB decoding step. + switch (format) { + case DXGI_FORMAT_R8G8B8A8_TYPELESS: case DXGI_FORMAT_R8G8B8A8_UNORM_SRGB: return DXGI_FORMAT_R8G8B8A8_UNORM; + case DXGI_FORMAT_B8G8R8A8_TYPELESS: case DXGI_FORMAT_B8G8R8A8_UNORM_SRGB: return DXGI_FORMAT_B8G8R8A8_UNORM; + case DXGI_FORMAT_R16G16B16A16_TYPELESS: return DXGI_FORMAT_R16G16B16A16_FLOAT; + case DXGI_FORMAT_R32G32B32A32_TYPELESS: return DXGI_FORMAT_R32G32B32A32_FLOAT; + default: return format; + } + } + + void EnsureSourceView(Surface& surface) + { + if (surface.sourceView) return; + D3D11_TEXTURE2D_DESC description{}; + surface.source->GetDesc(&description); + if (description.ArraySize != 1) throw std::invalid_argument("Native output requires a single 2D texture, not an array or XR surface."); + ID3D11Texture2D* texture = surface.source.Get(); + const bool typedSrgb = description.Format == DXGI_FORMAT_R8G8B8A8_UNORM_SRGB || description.Format == DXGI_FORMAT_B8G8R8A8_UNORM_SRGB; + if (description.SampleDesc.Count > 1 || typedSrgb) { + auto resolved = description; + resolved.SampleDesc.Count = 1; resolved.SampleDesc.Quality = 0; + resolved.MipLevels = 1; + resolved.Usage = D3D11_USAGE_DEFAULT; resolved.CPUAccessFlags = 0; + resolved.BindFlags = D3D11_BIND_SHADER_RESOURCE; resolved.MiscFlags = 0; + resolved.Format = SamplingFormat(description.Format); + Check(Device->CreateTexture2D(&resolved, nullptr, &surface.resolved), "Create typed presentation source texture"); + surface.copyStoredValues = description.SampleDesc.Count == 1; + texture = surface.resolved.Get(); + } + D3D11_SHADER_RESOURCE_VIEW_DESC view{}; + view.Format = SamplingFormat(description.Format); + view.ViewDimension = D3D11_SRV_DIMENSION_TEXTURE2D; + view.Texture2D.MipLevels = 1; + Check(Device->CreateShaderResourceView(texture, &view, &surface.sourceView), "Create source texture view"); + } + + bool EnsurePresentation(Surface& surface, UINT width, UINT height) + { + if (!surface.swapChain) { + ComPtr dxgiDevice; Check(Device.As(&dxgiDevice), "Query DXGI device"); + ComPtr factory; Check(CreateDXGIFactory1(IID_PPV_ARGS(&factory)), "Create DXGI factory"); + DXGI_SWAP_CHAIN_DESC1 description{}; + description.Width = width; description.Height = height; + description.Format = DXGI_FORMAT_B8G8R8A8_UNORM; + description.SampleDesc.Count = 1; description.BufferUsage = DXGI_USAGE_RENDER_TARGET_OUTPUT; + description.BufferCount = 2; description.Scaling = DXGI_SCALING_STRETCH; + description.SwapEffect = DXGI_SWAP_EFFECT_FLIP_SEQUENTIAL; + description.AlphaMode = DXGI_ALPHA_MODE_PREMULTIPLIED; + Check(factory->CreateSwapChainForComposition(Renderer.load() == kUnityGfxRendererD3D12 ? CopyBridge.Queue() : Device.Get(), &description, nullptr, &surface.swapChain), "Create composition swapchain"); + Check(DCompositionCreateDevice(dxgiDevice.Get(), IID_PPV_ARGS(&surface.composition)), "Create DirectComposition device"); + Check(surface.composition->CreateTargetForHwnd(surface.window.load(), TRUE, &surface.compositionTarget), "Create composition window target"); + Check(surface.composition->CreateVisual(&surface.visual), "Create composition visual"); + Check(surface.visual->SetContent(surface.swapChain.Get()), "Attach composition swapchain"); + Check(surface.compositionTarget->SetRoot(surface.visual.Get()), "Attach composition root"); + Check(surface.composition->Commit(), "Commit window composition"); + } + else if (surface.width != width || surface.height != height) { + if (Renderer.load() == kUnityGfxRendererD3D12 && !CopyBridge.Completed(surface.lastFence12)) return false; + surface.targetView.Reset(); + for (auto& view : surface.targetViews12) view.Reset(); + for (auto& resource : surface.targetResources12) resource.Reset(); + Check(surface.swapChain->ResizeBuffers(2, width, height, DXGI_FORMAT_B8G8R8A8_UNORM, 0), "Resize composition swapchain"); + } + if (Renderer.load() == kUnityGfxRendererD3D12) { + for (UINT index = 0; index < 2; index++) if (!surface.targetViews12[index]) { + ComPtr buffer; + Check(surface.swapChain->GetBuffer(index, IID_PPV_ARGS(&buffer)), "Get D3D12 composition backbuffer"); + surface.targetResources12[index] = CopyBridge.WrapTarget(buffer.Get()); + Check(Device->CreateRenderTargetView(surface.targetResources12[index].Get(), nullptr, + &surface.targetViews12[index]), "Create wrapped composition render target"); + } + ComPtr swap3; Check(surface.swapChain.As(&swap3), "Query composition buffer index"); + UINT index = swap3->GetCurrentBackBufferIndex(); + surface.targetView = surface.targetViews12[index]; + surface.acquiredTarget12 = surface.targetResources12[index].Get(); + CopyBridge.AcquireTarget(surface.acquiredTarget12); + } + else if (!surface.targetView) { + ComPtr buffer; + Check(surface.swapChain->GetBuffer(0, IID_PPV_ARGS(&buffer)), "Get composition backbuffer"); + Check(Device->CreateRenderTargetView(buffer.Get(), nullptr, &surface.targetView), "Create composition render target"); + } + surface.width = width; surface.height = height; + return true; + } + + void RenderSurface(const std::shared_ptr& surface, bool present = true) + { + std::lock_guard lock(surface->mutex); + HWND window = surface->window.load(); + if (!surface->alive.load() || !window || (!surface->source && !surface->source12) || !IsWindowVisible(window)) return; + RECT client{}; + if (!GetClientRect(window, &client) || client.right < 1 || client.bottom < 1) return; + bool acquiredSource = false; + try { + if (surface->source12) { + auto wrapped = CopyBridge.Acquire(*surface->source12); + if (!wrapped) return; + acquiredSource = true; + ComPtr texture; Check(wrapped->QueryInterface(IID_PPV_ARGS(&texture)), "Query wrapped source texture"); + if (surface->source.Get() != texture.Get()) { + surface->source = texture; surface->sourceView.Reset(); surface->resolved.Reset(); + } + } + EnsureSourceView(*surface); + if (!EnsurePresentation(*surface, static_cast(client.right), static_cast(client.bottom))) { + surface->skipped++; + if (acquiredSource) CopyBridge.Release(*surface->source12); + return; + } + if (surface->resolved) { + D3D11_TEXTURE2D_DESC description{}; surface->resolved->GetDesc(&description); + if (surface->copyStoredValues) Context->CopySubresourceRegion(surface->resolved.Get(), 0, 0, 0, 0, surface->source.Get(), 0, nullptr); + else Context->ResolveSubresource(surface->resolved.Get(), 0, surface->source.Get(), 0, description.Format); + } + ID3D11RenderTargetView* target = surface->targetView.Get(); + Context->OMSetRenderTargets(1, &target, nullptr); + Context->OMSetBlendState(Blend.Get(), nullptr, 0xffffffff); + Context->OMSetDepthStencilState(Depth.Get(), 0); + Context->RSSetState(Rasterizer.Get()); + D3D11_VIEWPORT viewport{0, 0, static_cast(surface->width), static_cast(surface->height), 0, 1}; + Context->RSSetViewports(1, &viewport); + Context->IASetInputLayout(nullptr); Context->IASetPrimitiveTopology(D3D11_PRIMITIVE_TOPOLOGY_TRIANGLELIST); + Context->VSSetShader(VertexShader.Get(), nullptr, 0); Context->PSSetShader(PixelShader.Get(), nullptr, 0); + Context->GSSetShader(nullptr, nullptr, 0); Context->HSSetShader(nullptr, nullptr, 0); Context->DSSetShader(nullptr, nullptr, 0); + Context->PSSetShaderResources(0, 1, surface->sourceView.GetAddressOf()); + Context->PSSetSamplers(0, 1, Sampler.GetAddressOf()); + const float parameters[4] = {surface->flipY ? 1.f : 0.f, 0, 0, 0}; + Context->UpdateSubresource(Parameters.Get(), 0, nullptr, parameters, 0, 0); + Context->PSSetConstantBuffers(0, 1, Parameters.GetAddressOf()); + Context->Draw(3, 0); + ID3D11RenderTargetView* noTarget = nullptr; + Context->OMSetRenderTargets(1, &noTarget, nullptr); + ID3D11ShaderResourceView* noSource = nullptr; + Context->PSSetShaderResources(0, 1, &noSource); + if (surface->acquiredTarget12) { + CopyBridge.ReleaseTarget(surface->acquiredTarget12); surface->acquiredTarget12 = nullptr; + } + if (acquiredSource) { CopyBridge.Release(*surface->source12); acquiredSource = false; } + if (Renderer.load() == kUnityGfxRendererD3D12) { + Context->Flush(); // Same queue, after Unity's configured flush/submission event. + surface->lastFence12 = CopyBridge.FrameFenceValue(); + } + // A fixture can inspect the rendered buffer before a flip discards its contents. + if (!present) return; + // Never stall Unity's render thread waiting for a desktop display's refresh rate. + surface->lastResult = surface->swapChain->Present(0, DXGI_PRESENT_DO_NOT_WAIT); + if (surface->lastResult == DXGI_ERROR_WAS_STILL_DRAWING || surface->lastResult == DXGI_STATUS_OCCLUDED) surface->skipped++; + else { Check(surface->lastResult, "Present composition swapchain"); surface->presented++; } + } + catch (const std::exception& error) { + if (surface->acquiredTarget12) { + CopyBridge.ReleaseTarget(surface->acquiredTarget12); surface->acquiredTarget12 = nullptr; + } + if (acquiredSource) CopyBridge.Release(*surface->source12); + surface->skipped++; + if (SUCCEEDED(surface->lastResult)) surface->lastResult = E_FAIL; + surface->ReleasePresentation(); + RecordError(error.what()); + } + } + + void ReleaseDevice() + { + std::lock_guard registry(RegistryMutex); + for (auto& entry : Surfaces) { + std::lock_guard surface(entry.second->mutex); + entry.second->ReleasePresentation(); + entry.second->sourceView.Reset(); entry.second->resolved.Reset(); entry.second->source.Reset(); entry.second->source12.reset(); + } + { std::lock_guard retired(RetiredSurfaceMutex); RetiredSurfaces.clear(); } + CopyBridge.Shutdown(); + Parameters.Reset(); Depth.Reset(); Blend.Reset(); Rasterizer.Reset(); Sampler.Reset(); + PixelShader.Reset(); VertexShader.Reset(); PluginContextState.Reset(); Context1.Reset(); Context.Reset(); Device.Reset(); + } + + void InitializeDevice() + { + if (Device || !UnityInterfaces) return; + if (Renderer.load() == kUnityGfxRendererD3D12) { CopyBridge.InitializeDevice(); return; } + if (Renderer.load() != kUnityGfxRendererD3D11) return; + auto graphics = UnityInterfaces->Get(); + if (!graphics || !graphics->GetDevice()) throw std::runtime_error("Unity's Direct3D11 device is not ready."); + Device = graphics->GetDevice(); + Device->GetImmediateContext(&Context); + Check(Context.As(&Context1), "Query D3D11.1 immediate context"); + ComPtr device1; Check(Device.As(&device1), "Query D3D11.1 device"); + D3D_FEATURE_LEVEL level = Device->GetFeatureLevel(); + if (level < D3D_FEATURE_LEVEL_11_0) throw std::runtime_error("Native output requires Direct3D feature level11_0 or newer."); + UINT flags = (Device->GetCreationFlags() & D3D11_CREATE_DEVICE_SINGLETHREADED) != 0 + ? D3D11_1_CREATE_DEVICE_CONTEXT_STATE_SINGLETHREADED : 0; + Check(device1->CreateDeviceContextState(flags, &level, 1, D3D11_SDK_VERSION, __uuidof(ID3D11Device), nullptr, &PluginContextState), "Create isolated presentation context state"); + CreatePipeline(); + } + + void UNITY_INTERFACE_API GraphicsEvent(UnityGfxDeviceEventType event) + { + std::lock_guard lock(DeviceMutex); + try { + if (event == kUnityGfxDeviceEventShutdown || event == kUnityGfxDeviceEventBeforeReset) ReleaseDevice(); + else if (event == kUnityGfxDeviceEventInitialize || event == kUnityGfxDeviceEventAfterReset) { + Renderer.store(UnityGraphics ? UnityGraphics->GetRenderer() : -1); + InitializeDevice(); + } + } + catch (const std::exception& error) { ReleaseDevice(); RecordError(error.what()); } + } + + void UNITY_INTERFACE_API RenderEvent(int id) + { + std::lock_guard lock(DeviceMutex); + try { + InitializeDevice(); + thread_local std::vector> surfaces; + surfaces.clear(); + { std::lock_guard registry(RegistryMutex); for (auto& entry : Surfaces) surfaces.push_back(entry.second); } + if (Renderer.load() == kUnityGfxRendererD3D12) { + CopyBridge.Collect(); + { std::lock_guard retired(RetiredSurfaceMutex); + for (auto item = RetiredSurfaces.begin(); item != RetiredSurfaces.end();) { + if (CopyBridge.Completed((*item)->lastFence12)) { (*item)->ReleasePresentation(); item = RetiredSurfaces.erase(item); } + else ++item; + } + } + if (id == DrawEventId) { + for (auto& surface : surfaces) { + std::lock_guard state(surface->mutex); + if (surface->alive.load() && surface->source12 && IsWindowVisible(surface->window.load())) + if (!CopyBridge.RecordCopy(*surface->source12)) surface->skipped++; + } + } + else if (id == PresentEventId) { + if (!Device) { CopyBridge.InitializePresentation(&Device, &Context); CreatePipeline(); } + for (auto& surface : surfaces) RenderSurface(surface); + Context->Flush(); + } + } + else if (Device && id != PresentEventId) { + SavedContext saved(Context1.Get()); + if (id == DrawEventId || id == 0) for (auto& surface : surfaces) RenderSurface(surface); + else if (auto surface = Find(id)) RenderSurface(surface); + } + else if (!Device) RecordError("Native output requires a supported Windows Direct3D11 or Direct3D12 backend."); + surfaces.clear(); + } + catch (const std::exception& error) { RecordError(error.what()); } + catch (...) { RecordError("Unexpected native rendering exception."); } + } + +} + +using namespace vpnw; + +VPNW_API int VPNW_CALL VPNW_CreateWindow(int x, int y, int width, int height) +{ + return Guard([=] { + ValidateRectangle(x, y, width, height); + if (Renderer.load() != kUnityGfxRendererD3D11 && (Renderer.load() != kUnityGfxRendererD3D12 || !CopyBridge.Supported())) + throw std::runtime_error("Native windows require Unity Direct3D11 or Direct3D12 with the v7 native graphics interface."); + auto surface = std::make_shared(); + int id; + { std::lock_guard lock(RegistryMutex); + if (NextId > INT_MAX) throw std::runtime_error("Native window ID range exhausted."); + id = static_cast(NextId++); + } + GetWindowThread()->Invoke([=] { + HWND window = CreateWindowExW(WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE | WS_EX_NOREDIRECTIONBITMAP, + WindowClass, WindowTitle, WS_POPUP, x, y, width, height, nullptr, nullptr, + reinterpret_cast(&__ImageBase), nullptr); + CheckWindow(window != nullptr, "Create borderless output window"); + surface->window.store(window); + }); + { std::lock_guard lock(RegistryMutex); Surfaces.emplace(id, surface); } + return id; + }); +} + +VPNW_API int VPNW_CALL VPNW_ConfigureWindow(int id, int x, int y, int width, int height, int visible, int topmost) +{ + return Guard([=] { + ValidateRectangle(x, y, width, height); + auto surface = Require(id); + GetWindowThread()->Invoke([=] { + if (!surface->alive.load()) throw std::runtime_error("Window was destroyed before configuration."); + HWND window = surface->window.load(); + bool changedRectangle = !surface->configured || surface->requestedX != x || surface->requestedY != y || + surface->requestedWidth != width || surface->requestedHeight != height; + bool changedVisibility = !surface->configured || surface->requestedVisible != (visible != 0); + bool changedTopmost = !surface->configured || surface->requestedTopmost != (topmost != 0); + if (changedVisibility) ShowWindow(window, visible ? SW_SHOWNOACTIVATE : SW_HIDE); + // Apply z-order after showing: Windows need not mark a hidden window topmost. + if (changedRectangle || changedTopmost || (changedVisibility && visible)) { + UINT flags = SWP_NOACTIVATE; + if (!changedRectangle) flags |= SWP_NOMOVE | SWP_NOSIZE; + CheckWindow(SetWindowPos(window, topmost ? HWND_TOPMOST : HWND_NOTOPMOST, x, y, width, height, flags) != FALSE, "Set physical desktop rectangle"); + } + surface->configured = true; surface->requestedX = x; surface->requestedY = y; + surface->requestedWidth = width; surface->requestedHeight = height; + surface->requestedVisible = visible != 0; surface->requestedTopmost = topmost != 0; + }); + return 1; + }); +} + +VPNW_API int VPNW_CALL VPNW_SetPlacementMode(int id, int enabled) +{ + return Guard([=] { + auto surface = Require(id); + GetWindowThread()->Invoke([=] { + HWND window = surface->window.load(); + if (enabled) CheckWindow(SetPropW(window, PlacementProperty, reinterpret_cast(1)) != FALSE, "Enable placement interaction"); + else RemovePropW(window, PlacementProperty); + const auto style = GetWindowLongPtrW(window, GWL_STYLE); + SetWindowLongPtrW(window, GWL_STYLE, enabled ? style | WS_THICKFRAME : style & ~WS_THICKFRAME); + CheckWindow(SetWindowPos(window, nullptr, 0, 0, 0, 0, SWP_FRAMECHANGED | SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE | SWP_NOZORDER) != FALSE, "Update placement frame"); + }); + return 1; + }); +} + +VPNW_API int VPNW_CALL VPNW_SetZOrder(int id, int order) +{ + return Guard([=] { + if (order < 0 || order > 3) throw std::invalid_argument("Z-order must be Back0, Front1, Topmost2 or NonTopmost3."); + auto surface = Require(id); + GetWindowThread()->Invoke([=] { + if (!surface->alive.load()) throw std::runtime_error("Window was destroyed before z-order update."); + const HWND positions[] = {HWND_BOTTOM, HWND_TOP, HWND_TOPMOST, HWND_NOTOPMOST}; + CheckWindow(SetWindowPos(surface->window.load(), positions[order], 0, 0, 0, 0, + SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE) != FALSE, "Update window z-order"); + // Keep the configuration cache truthful, so a later Configure can restore the other topmost state. + if (order >= 2) surface->requestedTopmost = order == 2; + }); + return 1; + }); +} + +VPNW_API int VPNW_CALL VPNW_SetTexture(int id, void* texture, int flipY) +{ + return Guard([=] { + auto surface = Require(id); + std::lock_guard device(DeviceMutex); + std::lock_guard lock(surface->mutex); + if (!surface->alive.load()) throw std::runtime_error("Window was destroyed before texture assignment."); + if (Renderer.load() == kUnityGfxRendererD3D12) { + if (!surface->source12 || surface->source12->source.Get() != texture) { + auto replacement = CopyBridge.Source(texture); + CopyBridge.Retire(std::move(surface->source12)); surface->source12 = std::move(replacement); + surface->source.Reset(); surface->sourceView.Reset(); surface->resolved.Reset(); + } + } + else { + ComPtr source; + if (texture) Check(static_cast(texture)->QueryInterface(IID_PPV_ARGS(&source)), "Acquire Unity texture"); + if (source && Device) { + ComPtr owner; source->GetDevice(&owner); + if (owner.Get() != Device.Get()) throw std::invalid_argument("Texture belongs to a different Direct3D11 device."); + } + if (surface->source.Get() != source.Get()) { + surface->source = source; surface->sourceView.Reset(); surface->resolved.Reset(); + } + } + surface->flipY = flipY != 0; + return 1; + }); +} + +VPNW_API int VPNW_CALL VPNW_DestroyWindow(int id) +{ + return Guard([=] { + std::shared_ptr surface; + { std::lock_guard lock(RegistryMutex); + auto item = Surfaces.find(id); + if (item == Surfaces.end()) return 1; // Idempotent cleanup, including after Unity device shutdown. + surface = item->second; Surfaces.erase(item); + } + HWND window; + { std::lock_guard lock(surface->mutex); + surface->alive.store(false); window = surface->window.exchange(nullptr); + if (Renderer.load() == kUnityGfxRendererD3D12) { + if (surface->source12) { + surface->lastFence12 = (std::max)(surface->lastFence12, surface->source12->lastFence); + CopyBridge.Retire(std::move(surface->source12)); + } + // Detaching the source does not imply that submitted backbuffer work has completed. + if (surface->compositionTarget) surface->compositionTarget->SetRoot(nullptr); + if (surface->composition) surface->composition->Commit(); + std::lock_guard retired(RetiredSurfaceMutex); RetiredSurfaces.push_back(surface); + } + else surface->ReleasePresentation(); + surface->sourceView.Reset(); surface->resolved.Reset(); surface->source.Reset(); + } + GetWindowThread()->Invoke([=] { if (window) CheckWindow(DestroyWindow(window) != FALSE, "Destroy output window"); }); + return 1; + }); +} + +VPNW_API void VPNW_CALL VPNW_DestroyAll() +{ + std::vector ids; + { std::lock_guard lock(RegistryMutex); for (auto& entry : Surfaces) ids.push_back(entry.first); } + for (int id : ids) VPNW_DestroyWindow(id); +} + +// The HWNDs live on the plugin's own message thread; Unity's main thread has nothing to pump. +VPNW_API void VPNW_CALL VPNW_Pump() {} +VPNW_API std::uint32_t VPNW_CALL VPNW_GetCapabilities() +{ + return VPNW_CapabilityWindows | VPNW_CapabilityPlacement | VPNW_CapabilityTopmost | VPNW_CapabilityTransparency; +} +VPNW_API void* VPNW_CALL VPNW_GetRenderEventFunc() { return reinterpret_cast(RenderEvent); } +VPNW_API int VPNW_CALL VPNW_GetGraphicsRenderer() { return Renderer.load(); } +VPNW_API int VPNW_CALL VPNW_GetDrawEventId() { return DrawEventId; } +VPNW_API int VPNW_CALL VPNW_GetPresentEventId() { return PresentEventId; } + +VPNW_API int VPNW_CALL VPNW_GetPendingResourceCount() +{ + std::lock_guard device(DeviceMutex); + const int sources = CopyBridge.PendingResourceCount(); + std::lock_guard retired(RetiredSurfaceMutex); + return sources + static_cast(RetiredSurfaces.size()); +} + +VPNW_API int VPNW_CALL VPNW_GetDiagnostics(int id, VPNW_Diagnostics* diagnostics) +{ + return Guard([=] { + if (!diagnostics || diagnostics->structBytes != sizeof(VPNW_Diagnostics)) throw std::invalid_argument("Unexpected native diagnostics structure size."); + auto surface = Require(id); + std::lock_guard device(DeviceMutex); + std::lock_guard lock(surface->mutex); + HWND window = surface->window.load(); + VPNW_Diagnostics result{}; + result.structBytes = sizeof(result); result.graphicsRenderer = Renderer.load(); result.deviceReady = Device ? 1 : 0; + result.alive = surface->alive.load() ? 1 : 0; result.visible = IsWindowVisible(window) ? 1 : 0; + result.topmost = (GetWindowLongPtrW(window, GWL_EXSTYLE) & WS_EX_TOPMOST) != 0 ? 1 : 0; + // The HWND thread can be inside Windows' modal drag/resize loop. Read geometry + // directly with a temporary thread DPI context so diagnostics never wait for that loop. + const auto previousDpi = SetThreadDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2); + RECT rectangle{}; + const bool readRectangle = GetWindowRect(window, &rectangle) != FALSE; + if (previousDpi) SetThreadDpiAwarenessContext(previousDpi); + CheckWindow(readRectangle, "Read physical output window rectangle"); + result.left = rectangle.left; result.top = rectangle.top; + result.windowWidth = rectangle.right - rectangle.left; result.windowHeight = rectangle.bottom - rectangle.top; + result.dpi = GetDpiForWindow(window); + if (surface->source12) { + result.sourceWidth = surface->source12->width; result.sourceHeight = surface->source12->height; result.sourceFormat = surface->source12->format; + } + else if (surface->source) { + D3D11_TEXTURE2D_DESC description{}; surface->source->GetDesc(&description); + result.sourceWidth = description.Width; result.sourceHeight = description.Height; result.sourceFormat = description.Format; + } + result.presentedFrames = surface->presented; result.skippedFrames = surface->skipped; + result.lastResult = surface->lastResult; result.premultipliedAlpha = 1; + *diagnostics = result; + return 1; + }); +} + +VPNW_API int VPNW_CALL VPNW_GetLastError(char* destination, int capacity) +{ + std::lock_guard lock(ErrorMutex); + if (destination && capacity > 0) strncpy_s(destination, static_cast(capacity), LastError.c_str(), _TRUNCATE); + return static_cast(LastError.size() + 1); +} + +extern "C" void UNITY_INTERFACE_EXPORT UNITY_INTERFACE_API UnityPluginLoad(IUnityInterfaces* interfaces) +{ + UnityInterfaces = interfaces; + UnityGraphics = interfaces ? interfaces->Get() : nullptr; + if (UnityGraphics) { + Renderer.store(UnityGraphics->GetRenderer()); + UnityGraphics->RegisterDeviceEventCallback(GraphicsEvent); + DrawEventId = UnityGraphics->ReserveEventIDRange(2); PresentEventId = DrawEventId + 1; + auto graphics12 = interfaces->Get(); + CopyBridge.SetUnity(graphics12); + if (graphics12 && Renderer.load() == kUnityGfxRendererD3D12) { + UnityD3D12PluginEventConfig draw{}; + draw.graphicsQueueAccess = kUnityD3D12GraphicsQueueAccess_DontCare; + draw.flags = kUnityD3D12EventConfigFlag_SyncWorkerThreads; + graphics12->ConfigureEvent(DrawEventId, &draw); + UnityD3D12PluginEventConfig present{}; + present.graphicsQueueAccess = kUnityD3D12GraphicsQueueAccess_Allow; + present.flags = kUnityD3D12EventConfigFlag_FlushCommandBuffers | kUnityD3D12EventConfigFlag_SyncWorkerThreads; + graphics12->ConfigureEvent(PresentEventId, &present); + } + } + // Device access is deferred to the graphics event/render callback, both on Unity's rendering thread. +} + +extern "C" void UNITY_INTERFACE_EXPORT UNITY_INTERFACE_API UnityPluginUnload() +{ + if (UnityGraphics) UnityGraphics->UnregisterDeviceEventCallback(GraphicsEvent); + VPNW_DestroyAll(); + { std::lock_guard device(DeviceMutex); ReleaseDevice(); } + { std::lock_guard windows(WindowServiceMutex); WindowService.reset(); } + UnityGraphics = nullptr; UnityInterfaces = nullptr; Renderer.store(-1); +} diff --git a/VisualPinball.Plugins.Test/CapabilityFamilyTests.cs b/VisualPinball.Plugins.Test/CapabilityFamilyTests.cs new file mode 100644 index 000000000..0214c0fa2 --- /dev/null +++ b/VisualPinball.Plugins.Test/CapabilityFamilyTests.cs @@ -0,0 +1,77 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// SPDX-License-Identifier: MIT +// Licensed under MIT. See https://opensource.org/license/mit. + +using System; +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using VisualPinball.Plugins; + +namespace VisualPinball.Plugins.Test +{ + [TestFixture] + public sealed class CapabilityFamilyTests + { + [Test] + public void AudioReplacementKeepsOriginalStreamAvailableAndRestoresPlayback() + { + using var source = new AudioResourceSource(); var registry = new PluginResourceRegistry(); + using var publication = registry.Publish(new PluginResourceDescriptor("rom/audio", "rom", "controller", "ROM mix", "rom-mix", ResourceKind.Audio, 1, 1), source); + using var binding = registry.Bind(new PluginResourceSelection(ResourceKind.Audio) { Controller = "controller", Session = 1 }); + int count = 0; using var subscription = binding.Connect(audio => audio.Subscribe(frame => { count++; Assert.That(frame.Channels, Is.EqualTo(2)); })); + using var replacement = ((IPluginAudioPlaybackControl)binding.Source!).AcquirePlayback("ssf"); + Assert.That(source.PlaybackReplaced, Is.True); + source.Publish(new PluginPcmFrame(new byte[16], PcmEncoding.Float32, 48000, 2, 2, 1)); + Assert.That(count, Is.EqualTo(1)); Assert.Throws(() => source.AcquirePlayback("other")); + replacement.Dispose(); Assert.That(source.PlaybackReplaced, Is.False); + } + [Test] + public void InputFiltersHaveDeterministicOrderAndReleaseTheirStage() + { + var pipeline = new PluginInputPipeline(); + using var add = pipeline.Register("add", 20, new InputFilter(value => value + 3)); + using var scale = pipeline.Register("scale", 10, new InputFilter(value => value * 2)); + Assert.That(pipeline.Apply(new PluginInputEvent("nudge-x", 1, 10)).Value, Is.EqualTo(5)); + scale.Dispose(); Assert.That(pipeline.Apply(new PluginInputEvent("nudge-x", 1, 10)).Value, Is.EqualTo(4)); + } + [Test] + public void SegmentAndAuxiliarySurfaceProvidersUseOnlyVpeContracts() + { + var registry = new PluginResourceRegistry(); using var source = new SegmentResourceSource(); + using var publication = registry.Publish(new PluginResourceDescriptor("score", "backglass", "controller", "Score", "score", ResourceKind.Segments, 1, 1), source); + using var surface = registry.Publish(new PluginResourceDescriptor("backglass", "backglass", "controller", "Backglass", "backglass", ResourceKind.Surface, 1, 1), new Surface()); + using var binding = registry.Bind(new PluginResourceSelection(ResourceKind.Segments) { Session = 1 }); + string layout = ""; using var subscription = binding.Connect(segments => segments.Subscribe(frame => layout = frame.Layout)); + source.Publish(new PluginSegmentFrame("vpe.segment.mask16.v1", 4, 16, new float[64])); + Assert.That(layout, Is.EqualTo("vpe.segment.mask16.v1")); Assert.That(registry.Snapshot().Count, Is.EqualTo(2)); + } + [Test] + public void RequiredCapabilityAmbiguityAndOrderingCyclesAreExplicit() + { + var first = new PluginDescriptor("first", "First", "") { Provides = new[] { "cap" } }; + var second = new PluginDescriptor("second", "Second", "") { Provides = new[] { "cap" } }; + var consumer = new PluginDescriptor("consumer", "Consumer", "") { Requires = new[] { "cap" } }; + Assert.Throws(() => PluginOrderResolver.Resolve(new[] { first, second, consumer }, PluginPhase.Start)); + Assert.That(PluginOrderResolver.Resolve(new[] { first, second, consumer }, PluginPhase.Start, preferredProviders: new Dictionary { ["cap"] = "second" }).Select(plugin => plugin.Id).ToList().IndexOf("second"), Is.LessThan(2)); + first.DependsOn = new[] { "second" }; second.DependsOn = new[] { "first" }; + Assert.That(Assert.Throws(() => PluginOrderResolver.Resolve(new[] { first, second }, PluginPhase.Start))!.Message, Does.Contain("first")); + } + [Test] + public void ReleaseRunsEveryActionEvenWhenOneFails() + { + var scope = new ResourceScope(); var calls = new List(); + scope.Own(new Subscription(() => calls.Add(1))); scope.Own(new Subscription(() => throw new InvalidOperationException())); scope.Own(new Subscription(() => calls.Add(3))); + Assert.Throws(scope.Dispose); Assert.That(calls, Is.EqualTo(new[] { 3, 1 })); Assert.DoesNotThrow(scope.Dispose); + } + private sealed class InputFilter : IPluginInputFilter + { + private readonly Func _transform; + internal InputFilter(Func transform) { _transform = transform; } + public PluginInputEvent Filter(in PluginInputEvent input) => new PluginInputEvent(input.Action, _transform(input.Value), input.TimestampUs); + } + private sealed class Surface : IPluginSurface { public ResourceKind Kind => ResourceKind.Surface; public int Width => 1920; public int Height => 1080; public string Backend => "test"; } + } +} diff --git a/VisualPinball.Plugins.Test/CapabilityFamilyTests.cs.meta b/VisualPinball.Plugins.Test/CapabilityFamilyTests.cs.meta new file mode 100644 index 000000000..9032e9744 --- /dev/null +++ b/VisualPinball.Plugins.Test/CapabilityFamilyTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: af8831d651125914caaa4a368e290136 \ No newline at end of file diff --git a/VisualPinball.Plugins.Test/ConfigurationTests.cs b/VisualPinball.Plugins.Test/ConfigurationTests.cs new file mode 100644 index 000000000..a582d049e --- /dev/null +++ b/VisualPinball.Plugins.Test/ConfigurationTests.cs @@ -0,0 +1,174 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// SPDX-License-Identifier: MIT +// Licensed under MIT. See https://opensource.org/license/mit. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using VisualPinball.Plugins; + +namespace VisualPinball.Plugins.Test +{ + [TestFixture] + public sealed class ConfigurationTests + { + private string _directory = null!; + private readonly PluginDescriptor _plugin = new PluginDescriptor("test.config", "Settings", "Test"); + private readonly GameIdentity _game = new GameIdentity("pinmame", "t2_l8"); + private PluginSettingsSchema Schema() => new PluginSettingsSchema(1, new PluginSettingsGroup("Test", "", + new PluginField("root", "Root", SettingKind.Folder) { Default = "default" }, + new PluginField("variant", "Variant", SettingKind.Choice) { Scope = ConfigurationScope.GlobalAndGame, Default = "auto", Provider = "variants" }, + new PluginField("layout", "Layout", SettingKind.Collection) { Scope = ConfigurationScope.GlobalAndGame, Default = new JArray("default") })); + [SetUp] public void SetUp() => _directory = Path.Combine(Path.GetTempPath(), "vpe-plugin-tests", Guid.NewGuid().ToString("N")); + [TearDown] public void TearDown() { if (Directory.Exists(_directory)) Directory.Delete(_directory, true); } + [Test] + public void GlobalDefaultsAndSparseGameOverridesRemainIndependent() + { + var store = new PluginConfigurationStore(_directory); var schema = Schema(); + store.Save(_plugin, schema, new PluginSettingsSnapshot(1, JObject.Parse("{'root':'global','variant':'one','layout':['a','b']}"))); + store.Save(_plugin, schema, new PluginSettingsSnapshot(1, JObject.Parse("{'variant':'two','layout':['c']}")), _game); + Assert.That(store.Effective(_plugin, schema, _game).String("root"), Is.EqualTo("global")); + Assert.That(store.Effective(_plugin, schema, _game).String("variant"), Is.EqualTo("two")); + Assert.That(store.Effective(_plugin, schema, _game).Value("layout")!.ToString(), Is.EqualTo(new JArray("c").ToString())); + store.Save(_plugin, schema, new PluginSettingsSnapshot(1), _game); + Assert.That(store.Effective(_plugin, schema, _game).String("variant"), Is.EqualTo("one")); + } + [Test] + public void ManuallyStoredValuesCannotOverrideFieldsOutsideTheirDeclaredScope() + { + var schema = Schema(); + var resolved = PluginConfigurationStore.Resolve(schema, + new PluginSettingsSnapshot(1, JObject.Parse("{'root':'global','variant':'one'}")), + new PluginSettingsSnapshot(1, JObject.Parse("{'root':'wrong','variant':'two','unknown':'kept'}"))); + Assert.That(resolved.String("root"), Is.EqualTo("global")); + Assert.That(resolved.String("variant"), Is.EqualTo("two")); + Assert.That(resolved.String("unknown"), Is.EqualTo("kept")); + } + [Test] + public void UnknownFieldsSurviveSaveAndFutureVersionIsNeverOverwritten() + { + var store = new PluginConfigurationStore(_directory); var schema = Schema(); string path = store.SettingsPath(_plugin); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, "{'schemaVersion':1,'futureTop':42,'values':{'unknown':'kept','root':'old'}}"); + store.Save(_plugin, schema, new PluginSettingsSnapshot(1, JObject.Parse("{'root':'new'}"))); + var saved = JObject.Parse(File.ReadAllText(path)); + Assert.That(saved["values"]!["unknown"]!.Value(), Is.EqualTo("kept")); + Assert.That(saved.Value("futureTop"), Is.EqualTo(42)); + saved["schemaVersion"] = 9; File.WriteAllText(path, saved.ToString()); var before = File.ReadAllText(path); + Assert.Throws(() => store.Save(_plugin, schema, new PluginSettingsSnapshot(1))); + Assert.That(File.ReadAllText(path), Is.EqualTo(before)); + } + [Test] + public void MigrationsOperateOnDetachedTreeAndPersistOnlyOnApply() + { + var store = new PluginConfigurationStore(_directory); string path = store.SettingsPath(_plugin); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); File.WriteAllText(path, "{'schemaVersion':1,'values':{'old':'value'}}"); + var schema = new PluginSettingsSchema(2, new PluginSettingsGroup("Migrated", "", new PluginField("new", "New", SettingKind.Text))) { + Migrations = new Dictionary> { [1] = tree => { tree["new"] = tree["old"]; tree.Remove("old"); return tree; } } + }; + Assert.That(store.Read(_plugin, schema).String("new"), Is.EqualTo("value")); + Assert.That(JObject.Parse(File.ReadAllText(path)).Value("schemaVersion"), Is.EqualTo(1)); + store.Save(_plugin, schema, store.Read(_plugin, schema)); + Assert.That(JObject.Parse(File.ReadAllText(path)).Value("schemaVersion"), Is.EqualTo(2)); + } + [Test] + public async Task ApplyRechecksAvailabilityAndDoesNotReplaceMissingChoice() + { + var store = new PluginConfigurationStore(_directory); var provider = new CatalogProvider(); + var factory = new ConfigFactory(_plugin, Schema(), provider); + using var session = new PluginSettingsSession(factory, store, "windows", _game); + session.Set("variant", "one"); Assert.That((await session.ApplyAsync()).Success, Is.True); + provider.Available = false; + Assert.That((await session.ApplyAsync()).Success, Is.False); + Assert.That(store.Read(_plugin, Schema(), _game).String("variant"), Is.EqualTo("one")); + session.Set("variant", null); Assert.That(session.Effective.String("variant"), Is.EqualTo("auto")); + } + [Test] + public void ScreenLayoutsAllowSignedDesktopCoordinatesAndRejectAmbiguousOrInvalidHomes() + { + var schema = new PluginSettingsSchema(1, new PluginSettingsGroup("Displays", "", new PluginField("screens", "Screens", SettingKind.ScreenLayout))); + var valid = JObject.Parse("{'screens':[{'screen':'backglass','x':-1920,'y':0,'width':1920,'height':1080}]}"); + Assert.That(schema.Validate(new PluginSettingsSnapshot(1, valid)), Is.Empty); + ((JArray)valid["screens"]!).Add(valid["screens"]![0]!.DeepClone()); + Assert.That(schema.Validate(new PluginSettingsSnapshot(1, valid)), Has.Count.EqualTo(1)); + valid["screens"] = JArray.Parse("[{'screen':'backglass','x':0,'y':0,'width':0,'height':1080}]"); + Assert.That(schema.Validate(new PluginSettingsSnapshot(1, valid)), Has.Count.EqualTo(1)); + } + [Test] + public void MultipleChoicesAreListsOfIds() + { + var schema = new PluginSettingsSchema(1, new PluginSettingsGroup("Test", "", + new PluginField("output", "Output", SettingKind.Choice) { Provider = "outputs", Multiple = true, Default = new JArray("native") }, + new PluginField("single", "Single", SettingKind.Choice) { Provider = "outputs", Default = "native" })); + Assert.That(schema.Validate(new PluginSettingsSnapshot(1, JObject.Parse("{'output':['native','scene'],'single':'scene'}"))), Is.Empty); + Assert.That(schema.Validate(new PluginSettingsSnapshot(1, JObject.Parse("{'output':[]}"))), Is.Empty, "picking none is valid"); + Assert.That(schema.Validate(new PluginSettingsSnapshot(1, JObject.Parse("{'output':'native'}"))), Has.Count.EqualTo(1)); + Assert.That(schema.Validate(new PluginSettingsSnapshot(1, JObject.Parse("{'output':['native',3]}"))), Has.Count.EqualTo(1)); + Assert.That(schema.Validate(new PluginSettingsSnapshot(1, JObject.Parse("{'single':['native']}"))), Has.Count.EqualTo(1)); + var values = new PluginSettingsSnapshot(1, JObject.Parse("{'output':['native',1,'scene'],'single':'scene'}")); + Assert.That(values.Strings("output"), Is.EqualTo(new[] { "native", "scene" })); + Assert.That(values.Strings("single"), Is.Empty); + Assert.That(values.Strings("missing"), Is.Empty); + Assert.That(schema.Defaults().Strings("output"), Is.EqualTo(new[] { "native" })); + } + [Test] + public void NestedRecordValidationChecksDeclaredTypesAndRanges() + { + var item = new PluginField("devices", "Devices", SettingKind.Collection) { Children = new[] { new PluginField("gain", "Gain", SettingKind.Number) { Minimum = 0, Maximum = 1 } } }; + var schema = new PluginSettingsSchema(1, new PluginSettingsGroup("Output", "", item)); + Assert.That(schema.Validate(new PluginSettingsSnapshot(1, JObject.Parse("{'devices':[{'gain':0.5}]}"))), Is.Empty); + Assert.That(schema.Validate(new PluginSettingsSnapshot(1, JObject.Parse("{'devices':[{'gain':2.0}]}"))), Has.Count.EqualTo(1)); + Assert.That(schema.Validate(new PluginSettingsSnapshot(1, JObject.Parse("{'devices':['invalid']}"))), Has.Count.EqualTo(1)); + } + [Test] + public async Task ScreenApplyRechecksAvailabilityWithoutReplacingAnExistingHome() + { + var field = new PluginField("screens", "Screens", SettingKind.ScreenLayout) { Provider = "variants", Scope = ConfigurationScope.GlobalAndGame }; + var schema = new PluginSettingsSchema(1, new PluginSettingsGroup("Displays", "", field)); + var store = new PluginConfigurationStore(_directory); var provider = new CatalogProvider(); + var factory = new ConfigFactory(_plugin, schema, provider); + using var session = new PluginSettingsSession(factory, store, "windows", _game); + session.Set("screens", JArray.Parse("[{'screen':'one','x':-640,'y':0,'width':640,'height':480}]")); + Assert.That((await session.ApplyAsync()).Success, Is.True); + provider.Available = false; + Assert.That((await session.ApplyAsync()).Success, Is.False); + Assert.That(store.Read(_plugin, schema, _game).Value("screens")![0]!["x"]!.Value(), Is.EqualTo(-640)); + } + [Test] + public async Task NestedChoiceAvailabilityIsRecheckedBeforeSavingRecords() + { + var field = new PluginField("devices", "Devices", SettingKind.Collection) { + Children = new[] { new PluginField("variant", "Variant", SettingKind.Choice) { Provider = "variants", Default = "auto" } } + }; + var schema = new PluginSettingsSchema(1, new PluginSettingsGroup("Output", "", field)); + var store = new PluginConfigurationStore(_directory); var provider = new CatalogProvider(); + using var session = new PluginSettingsSession(new ConfigFactory(_plugin, schema, provider), store, "windows"); + session.Set("devices", JArray.Parse("[{'variant':'one'}]")); + Assert.That((await session.ApplyAsync()).Success, Is.True); + provider.Available = false; + Assert.That((await session.ApplyAsync()).Success, Is.False); + Assert.That(store.Read(_plugin, schema).Value("devices")![0]!["variant"]!.Value(), Is.EqualTo("one")); + } + private sealed class CatalogProvider : IPluginSettingsProvider + { + internal bool Available = true; + public Task DiscoverAsync(string provider, PluginChoiceRequest request, CancellationToken token) => Task.FromResult(new PluginChoiceCatalog(Available.ToString(), new[] { new PluginChoice("auto", "Automatic"), new PluginChoice("one", "One", available: Available, unavailableReason: "Removed") })); + public Task RunActionAsync(string action, PluginChoiceRequest request, CancellationToken token) => Task.FromResult(new PluginActionResult(true, "")); + public IReadOnlyList Validate(PluginChoiceRequest request) => Array.Empty(); + } + private sealed class ConfigFactory : IPlayerPluginFactory + { + public PluginDescriptor Descriptor { get; } + public PluginSettingsSchema Schema { get; } + public IPluginSettingsProvider? SettingsProvider { get; } + internal ConfigFactory(PluginDescriptor descriptor, PluginSettingsSchema schema, IPluginSettingsProvider provider) { Descriptor = descriptor; Schema = schema; SettingsProvider = provider; } + public IPlayerPlugin Create(PluginApplicationContext context) => throw new NotSupportedException(); + } + } +} diff --git a/VisualPinball.Plugins.Test/ConfigurationTests.cs.meta b/VisualPinball.Plugins.Test/ConfigurationTests.cs.meta new file mode 100644 index 000000000..9181d8855 --- /dev/null +++ b/VisualPinball.Plugins.Test/ConfigurationTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a14fe21226595ad4b935330f07654e2c \ No newline at end of file diff --git a/VisualPinball.Plugins.Test/GlobalSettingsTests.cs b/VisualPinball.Plugins.Test/GlobalSettingsTests.cs new file mode 100644 index 000000000..ff7a76d3e --- /dev/null +++ b/VisualPinball.Plugins.Test/GlobalSettingsTests.cs @@ -0,0 +1,106 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// SPDX-License-Identifier: MIT +// Licensed under MIT. See https://opensource.org/license/mit. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json.Linq; +using NUnit.Framework; + +namespace VisualPinball.Plugins.Test +{ + [TestFixture] + public sealed class GlobalSettingsTests + { + private string _directory = null!; + private PluginConfigurationStore _store = null!; + [SetUp] public void SetUp() { _directory = Path.Combine(Path.GetTempPath(), "vpe-mixer-tests", Guid.NewGuid().ToString("N")); _store = new PluginConfigurationStore(_directory); } + [TearDown] public void TearDown() { if (Directory.Exists(_directory)) Directory.Delete(_directory, true); } + private static PluginField Volume(string id = "value") => new PluginField(id, "Volume", SettingKind.Integer) { + Default = 100, Minimum = 0, Maximum = 100, Change = ConfigurationChange.Live, GlobalSetting = new PluginGlobalSetting(PluginGlobalSections.VolumeMixer) + }; + private static TestFactory Factory(string id = "sound") => new TestFactory(id) { + Schema = new PluginSettingsSchema(1, new PluginSettingsGroup("Sound", "", Volume(), new PluginField("path", "Library", SettingKind.Text) { Default = "old" })) + }; + private PluginHost Host(params IPlayerPluginFactory[] factories) => new PluginHost(factories, _store, new OwnerScheduler(), new PluginCapabilities(), new PluginResourceRegistry(), "windows"); + [Test] + public void OnlyExplicitSupportedVisibleAndCompatibleFieldsAppear() + { + var first = Factory("first"); first.Schema.Fields.First().GlobalSetting!.Order = 20; + var second = Factory("second"); second.Schema.Fields.First().GlobalSetting!.Order = 10; + var unsupported = Factory("unsupported"); unsupported.Descriptor.Platforms = new[] { "linux" }; + var game = Factory("game"); game.Schema.Fields.First().Scope = ConfigurationScope.GameOnly; + var unbounded = Factory("unbounded"); unbounded.Schema.Fields.First().Maximum = null; + var absent = new TestFactory("absent"); var broken = Factory("broken"); + Directory.CreateDirectory(_store.DirectoryFor(broken.Descriptor)); File.WriteAllText(_store.SettingsPath(broken.Descriptor), "bad json"); + var diagnostics = new List(); + var found = PluginGlobalSettingBinding.Discover(new[] { first, second, unsupported, game, unbounded, absent, broken }, _store, "windows", PluginGlobalSections.VolumeMixer, diagnostics.Add); + Assert.That(found.Select(item => item.Id), Is.EqualTo(new[] { "second/value", "first/value" })); + Assert.That(diagnostics.Count, Is.EqualTo(3)); + Assert.That(PluginGlobalSettingBinding.Discover(new[] { first }, _store, "windows", "unknown"), Is.Empty); + } + [Test] + public void OrdinaryDraftCannotOverwriteANewerMixerValueAndResetIsExplicit() + { + var factory = Factory(); var host = Host(factory); + using var session = new PluginSettingsSession(factory, _store, "windows"); + session.Set("path", "new"); + PluginHostTests.Run(host, host.CommitGlobalSettingAsync(factory, "value", new JValue(37))); + var apply = session.ApplyAsync(host: host); PluginHostTests.Run(host, apply); + Assert.That(apply.Result.Success, Is.True); + Assert.That(_store.Effective(factory.Descriptor, factory.Schema).Integer("value"), Is.EqualTo(37)); + Assert.That(_store.Effective(factory.Descriptor, factory.Schema).String("path"), Is.EqualTo("new")); + session.Reset(); PluginHostTests.Run(host, session.ApplyAsync(host: host)); + Assert.That(_store.Read(factory.Descriptor, factory.Schema).Values, Is.Empty); + Assert.That(_store.Effective(factory.Descriptor, factory.Schema).Integer("value"), Is.EqualTo(100)); + } + [Test] + public void AliasRefreshKeepsDirtyFieldsAndInvalidValuesAreNeverPersisted() + { + var factory = Factory(); var host = Host(factory); + using var session = new PluginSettingsSession(factory, _store, "windows"); + session.Set("path", "draft"); + var latest = new PluginSettingsSnapshot(1); latest.Set("value", 30); latest.Set("path", "external"); + session.RefreshUnedited(latest, new[] { "value", "path" }); + Assert.That(session.Effective.Integer("value"), Is.EqualTo(30)); + Assert.That(session.HasEdits("value"), Is.False); Assert.That(session.Draft.String("path"), Is.EqualTo("draft")); + var invalid = host.CommitGlobalSettingAsync(factory, "value", new JValue(101)); PluginHostTests.Run(host, invalid); + Assert.That(invalid.Result.Success, Is.False); Assert.That(File.Exists(_store.SettingsPath(factory.Descriptor)), Is.False); + } + [Test] + public void MandatorySettingsServiceIgnoresLegacyDisablePolicies() + { + var factory = Factory(); factory.Descriptor.CanDisable = false; + Directory.CreateDirectory(_store.DirectoryFor(factory.Descriptor)); File.WriteAllText(Path.Combine(_store.DirectoryFor(factory.Descriptor), "policy.json"), "{'enabled':false}"); + Assert.That(_store.Enabled(factory.Descriptor), Is.True); + Assert.Throws(() => _store.SetEnabled(factory.Descriptor, false)); + var host = Host(factory); PluginHostTests.Run(host, host.PrepareAndStartAsync(new GameIdentity("test", "game"), "test", "Test", "controller", 1, default)); + Assert.That(factory.Constructions, Is.EqualTo(1)); PluginHostTests.Run(host, host.StopGameAsync()); + } + [Test] + public void FailedDiskReplaceRollsBackGainWithoutSuppressingTicks() + { + if (!System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(System.Runtime.InteropServices.OSPlatform.Windows)) Assert.Ignore("Windows file-sharing semantics are required for this persistence fault injection."); + var factory = Factory(); factory.SynchronousApply = true; + var initial = new PluginSettingsSnapshot(1); initial.Set("value", 5); _store.Save(factory.Descriptor, factory.Schema, initial); + var host = Host(factory); PluginHostTests.Run(host, host.PrepareAndStartAsync(new GameIdentity("test", "game"), "test", "Test", "controller", 1, default)); + FileStream? held = null; + factory.ApplyAction = level => { if (level == 42) held = new FileStream(_store.SettingsPath(factory.Descriptor), FileMode.Open, FileAccess.Read, FileShare.Read); }; + var apply = host.CommitGlobalSettingAsync(factory, "value", new JValue(42)); + try { + var deadline = DateTime.UtcNow.AddSeconds(5); + while (!apply.IsCompleted && DateTime.UtcNow < deadline) { host.Scheduler.Pump(); if (held != null) host.Tick(.016); Thread.Sleep(1); } + PluginHostTests.Run(host, apply); + Assert.That(apply.Result.Success, Is.False); Assert.That(factory.Calls, Does.Contain("sound:apply:5")); + Assert.That(factory.Calls, Does.Contain("sound:tick")); + Assert.That(_store.Effective(factory.Descriptor, factory.Schema).Integer("value"), Is.EqualTo(5)); + } finally { held?.Dispose(); PluginHostTests.Run(host, host.StopGameAsync()); } + } + } +} diff --git a/VisualPinball.Plugins.Test/GlobalSettingsTests.cs.meta b/VisualPinball.Plugins.Test/GlobalSettingsTests.cs.meta new file mode 100644 index 000000000..dcdd925f8 --- /dev/null +++ b/VisualPinball.Plugins.Test/GlobalSettingsTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4bb5bd368acbd12459ffd17b38e8fc2b \ No newline at end of file diff --git a/VisualPinball.Plugins.Test/OutputResourceTests.cs b/VisualPinball.Plugins.Test/OutputResourceTests.cs new file mode 100644 index 000000000..5391ac529 --- /dev/null +++ b/VisualPinball.Plugins.Test/OutputResourceTests.cs @@ -0,0 +1,91 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// SPDX-License-Identifier: MIT +// Licensed under MIT. See https://opensource.org/license/mit. + +using NUnit.Framework; +using VisualPinball.Plugins; + +namespace VisualPinball.Plugins.Test +{ + [TestFixture] + public sealed class OutputResourceTests + { + [Test] + public void NativeOutputsLeaveRolelessControllerBindingsUnambiguous() + { + var registry = new PluginResourceRegistry(); + using var mapped = new ControllerResourceSource(); + using var native = new ControllerResourceSource(ResourceKind.Outputs); + using var mappedPublication = registry.Publish(new PluginResourceDescriptor("pinmame", "pinmame", "pinmame", "PinMAME", "controller", ResourceKind.Controller, 1, 1), mapped); + using var nativePublication = registry.Publish(new PluginResourceDescriptor("pinmame/outputs", "pinmame", "pinmame", "PinMAME outputs", "outputs", ResourceKind.Outputs, 1, 2), native); + using var controller = registry.Bind(new PluginResourceSelection(ResourceKind.Controller) { Controller = "pinmame", Session = 1 }); + using var outputs = registry.Bind(new PluginResourceSelection(ResourceKind.Outputs) { Controller = "pinmame", Session = 1 }); + Assert.That(controller.State, Is.EqualTo(ResourceBindingState.Bound)); + Assert.That(controller.Source, Is.SameAs(mapped)); + Assert.That(outputs.State, Is.EqualTo(ResourceBindingState.Bound)); + Assert.That(outputs.Source, Is.SameAs(native)); + int number = 0; + using var subscription = outputs.Connect(source => source.Subscribe(change => number = change.Number)); + native.Publish(new PluginControllerEvent(ControllerEventKind.Lamp, "81", 128, 1, 0, 81, ControllerValueSemantics.Physical, 255)); + Assert.That(number, Is.EqualTo(81)); + } + + [Test] + public void SegmentDisplaysAnnounceTheirPlaceBeforeTheFirstFrame() + { + var registry = new PluginResourceRegistry(); + using var source = new SegmentResourceSource(1, 7, "seg14", "vfd-green"); + using var publication = registry.Publish(new PluginResourceDescriptor("pinmame/segments/display1", "pinmame", "pinmame", "display1", "segments", ResourceKind.Segments, 1, 1), source); + using var binding = registry.Bind(new PluginResourceSelection(ResourceKind.Segments) { Session = 1 }); + Assert.That(binding.Source!.Order, Is.EqualTo(1)); + Assert.That(binding.Source.Elements, Is.EqualTo(7)); + Assert.That(binding.Source.Layout, Is.EqualTo("seg14")); + Assert.That(binding.Source.Hardware, Is.EqualTo("vfd-green")); + } + + [Test] + public void AChosenDisplayIsPresentedThroughItsDerivedView() + { + var registry = new PluginResourceRegistry(); + const string original = "pinmame/controller/0/display/dmd0", presentation = original + "/presentation/org.visualpinball.dmd"; + const DisplayEncoding formats = DisplayEncoding.Gray4 | DisplayEncoding.Rgb24; + using var originalSource = new DisplayResourceSource(); + using var originalPublication = registry.Publish(new PluginResourceDescriptor(original, "pinmame", "pinmame/controller/0", "dmd0", "dmd", ResourceKind.Display, 1, 1, ResourceView.Original, formats), originalSource); + var selection = new PluginResourceSelection(ResourceKind.Display) { ResourceId = original, Session = 1, Intent = ResourceView.Presentation, AllowOriginalFallback = true }; + using var binding = registry.Bind(selection); + // Without a colorizer the chosen display itself is used. + Assert.That(binding.Descriptor!.Id, Is.EqualTo(original)); + // Once a plugin presents it, the chosen display is shown through that view. + using var presentationSource = new DisplayResourceSource(); + using (registry.Publish(new PluginResourceDescriptor(presentation, "org.visualpinball.dmd", "pinmame/controller/0", "dmd0 colorized", "dmd", ResourceKind.Display, 1, 2, ResourceView.Presentation, formats, original), presentationSource)) { + Assert.That(binding.Descriptor!.Id, Is.EqualTo(presentation)); + } + Assert.That(binding.Descriptor!.Id, Is.EqualTo(original)); + } + + [Test] + public void AChosenDisplayIgnoresOtherDisplaysPresentations() + { + var registry = new PluginResourceRegistry(); + const string chosen = "pinmame/controller/0/display/dmd0", other = "pinmame/controller/0/display/dmd1"; + const DisplayEncoding formats = DisplayEncoding.Gray4 | DisplayEncoding.Rgb24; + using var chosenSource = new DisplayResourceSource(); + using var otherSource = new DisplayResourceSource(); + using var presentationSource = new DisplayResourceSource(); + using var chosenPublication = registry.Publish(new PluginResourceDescriptor(chosen, "pinmame", "pinmame/controller/0", "dmd0", "dmd", ResourceKind.Display, 1, 1, ResourceView.Original, formats), chosenSource); + using var otherPublication = registry.Publish(new PluginResourceDescriptor(other, "pinmame", "pinmame/controller/0", "dmd1", "dmd", ResourceKind.Display, 1, 2, ResourceView.Original, formats), otherSource); + using var presentationPublication = registry.Publish(new PluginResourceDescriptor(other + "/presentation/x", "x", "pinmame/controller/0", "dmd1 colorized", "dmd", ResourceKind.Display, 1, 3, ResourceView.Presentation, formats, other), presentationSource); + using var binding = registry.Bind(new PluginResourceSelection(ResourceKind.Display) { ResourceId = chosen, Session = 1, Intent = ResourceView.Presentation, AllowOriginalFallback = true }); + Assert.That(binding.State, Is.EqualTo(ResourceBindingState.Bound)); + Assert.That(binding.Descriptor!.Id, Is.EqualTo(chosen)); + } + + [Test] + public void ControllerSourcesCarryOnlyControllerKinds() + { + Assert.Throws(() => new ControllerResourceSource(ResourceKind.Display)); + } + } +} diff --git a/VisualPinball.Plugins.Test/OutputResourceTests.cs.meta b/VisualPinball.Plugins.Test/OutputResourceTests.cs.meta new file mode 100644 index 000000000..ec3cad58a --- /dev/null +++ b/VisualPinball.Plugins.Test/OutputResourceTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 36dd34cbc22a45f0bb32aa153c713f65 diff --git a/VisualPinball.Plugins.Test/PluginHostTests.cs b/VisualPinball.Plugins.Test/PluginHostTests.cs new file mode 100644 index 000000000..6e86f2a9a --- /dev/null +++ b/VisualPinball.Plugins.Test/PluginHostTests.cs @@ -0,0 +1,329 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// SPDX-License-Identifier: MIT +// Licensed under MIT. See https://opensource.org/license/mit. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using VisualPinball.Plugins; + +namespace VisualPinball.Plugins.Test +{ + [TestFixture] + public sealed class PluginHostTests + { + private string _directory = null!; + private readonly GameIdentity _game = new GameIdentity("test", "game"); + [SetUp] public void SetUp() => _directory = Path.Combine(Path.GetTempPath(), "vpe-plugin-tests", Guid.NewGuid().ToString("N")); + [TearDown] public void TearDown() { if (Directory.Exists(_directory)) Directory.Delete(_directory, true); } + + [Test] + public void DisabledGamePolicyPreventsConstruction() + { + var factory = new TestFactory("test.disabled"); + var host = Host(factory); + host.Configuration.SetEnabled(factory.Descriptor, false, _game); + Run(host, host.PrepareAndStartAsync(_game, "test.vpe", "Test", "controller", 1, default)); + Assert.That(factory.Constructions, Is.Zero); + Assert.That(host.Statuses[factory.Descriptor.Id].State, Is.EqualTo(PluginState.Disabled)); + Run(host, host.StopGameAsync()); + Run(host, host.PrepareAndStartAsync(new GameIdentity("test", "other"), "test.vpe", "Other", "controller", 2, default)); + Assert.That(factory.Constructions, Is.EqualTo(1)); + Run(host, host.StopGameAsync()); + } + + [Test] + public void RequiredProviderPrecedesConsumerAndCleanupReversesStart() + { + var calls = new List(); + var provider = new TestFactory("provider", calls); provider.Descriptor.Provides = new[] { "service" }; provider.Descriptor.Order = 20; + var consumer = new TestFactory("consumer", calls); consumer.Descriptor.Requires = new[] { "service" }; consumer.Descriptor.Order = -20; + var host = Host(consumer, provider); + Run(host, host.PrepareAndStartAsync(_game, "test", "Test", "controller", 1, default)); + Assert.That(calls.Where(call => call.EndsWith(":start")), Is.EqualTo(new[] { "provider:start", "consumer:start" })); + Run(host, host.StopGameAsync()); + Assert.That(calls.Where(call => call.EndsWith(":stop")), Is.EqualTo(new[] { "consumer:stop", "provider:stop" })); + Assert.That(calls.Count(call => call.EndsWith(":dispose")), Is.EqualTo(2)); + } + + [TestCase("initialize")] + [TestCase("prepare")] + [TestCase("start")] + public void ProviderFailureBlocksDependentsWhileIndependentPluginRuns(string failAt) + { + var calls = new List(); + var provider = new TestFactory("provider", calls) { FailAt = failAt }; + var consumer = new TestFactory("consumer", calls); consumer.Descriptor.DependsOn = new[] { "provider" }; + var independent = new TestFactory("independent", calls); + var host = Host(consumer, provider, independent); + Run(host, host.PrepareAndStartAsync(_game, "test", "Test", "controller", 1, default)); + Assert.That(host.Statuses["provider"].State, Is.EqualTo(PluginState.Faulted)); + Assert.That(host.Statuses["consumer"].State, Is.EqualTo(PluginState.Blocked)); + Assert.That(host.Statuses["independent"].State, Is.EqualTo(PluginState.Running)); + Assert.That(calls.Count(call => call == "provider:dispose"), Is.EqualTo(1)); + Run(host, host.StopGameAsync()); + Assert.That(calls.Count(call => call == "provider:dispose"), Is.EqualTo(1)); + } + + [Test] + public void ReadyFailureIsContainedAndResourcesAreReleased() + { + var calls = new List(); + var host = Host(new TestFactory("failed", calls) { FailAt = "ready" }, new TestFactory("healthy", calls)); + Run(host, host.PrepareAndStartAsync(_game, "test", "Test", "controller", 1, default)); + Assert.DoesNotThrow(host.GameReady); + Assert.That(calls, Does.Contain("healthy:ready")); + Assert.That(calls, Does.Contain("failed:release")); + Run(host, host.StopGameAsync()); + } + + [Test] + public void WorkerContinuationReturnsToOwnerBeforeNextHook() + { + int owner = Thread.CurrentThread.ManagedThreadId; + var factory = new TestFactory("async") { AsyncInitialize = true }; + var host = Host(factory); + Run(host, host.PrepareAndStartAsync(_game, "test", "Test", "controller", 1, default)); + Assert.That(factory.PrepareThread, Is.EqualTo(owner)); + Run(host, host.StopGameAsync()); + } + + [Test] + public void CancellationUnwindsWithFreshCleanupToken() + { + var calls = new List(); + using var cancel = new CancellationTokenSource(); + var factory = new TestFactory("cancel", calls) { CancelDuringStart = cancel }; + var host = Host(factory); + Assert.Throws(() => Run(host, host.PrepareAndStartAsync(_game, "test", "Test", "controller", 1, cancel.Token))); + Assert.That(calls, Does.Contain("cancel:stop")); + Assert.That(factory.CleanupWasCanceled, Is.False); + } + + private PluginHost Host(params IPlayerPluginFactory[] factories) => new PluginHost(factories, new PluginConfigurationStore(_directory), new OwnerScheduler(), new PluginCapabilities(), new PluginResourceRegistry(), "windows"); + [Test] + public void HealthyPluginKeepsTickingWhileAnotherPluginsCleanupWaits() + { + var calls = new List(); var completion = new TaskCompletionSource(); + var host = Host(new TestFactory("failed", calls) { FailAt = "ready", StopCompletion = completion.Task }, new TestFactory("healthy", calls)); + Run(host, host.PrepareAndStartAsync(_game, "test", "Test", "controller", 1, default)); + host.GameReady(); Assert.That(host.IsTransitioning, Is.True); + host.Tick(.016); Assert.That(calls, Does.Contain("healthy:tick")); + completion.SetResult(true); Run(host, host.StopGameAsync()); + } + [Test] + public void HungLiveApplyDoesNotPersistOrBlockRelaunch() + { + var factory = new TestFactory("live") { ApplyCompletion = new TaskCompletionSource().Task, + Schema = new PluginSettingsSchema(1, new PluginSettingsGroup("Test", "", new PluginField("value", "Value", SettingKind.Integer) { Default = 5, Change = ConfigurationChange.Live })) }; + var host = Host(factory); host.StartupTimeout = TimeSpan.FromMilliseconds(30); + Run(host, host.PrepareAndStartAsync(_game, "test", "Test", "controller", 1, default)); + using var session = new PluginSettingsSession(factory, host.Configuration, "windows"); session.Set("value", 42); + var apply = session.ApplyAsync(host: host); Run(host, apply); + Assert.That(apply.Result.Success, Is.False); Assert.That(File.Exists(host.Configuration.SettingsPath(factory.Descriptor)), Is.False); + Run(host, host.StopGameAsync()); + Run(host, host.PrepareAndStartAsync(_game, "test", "Test", "controller", 2, default)); + Assert.That(host.Statuses["live"].State, Is.EqualTo(PluginState.Unresponsive)); + Assert.That(factory.Constructions, Is.EqualTo(1)); + Run(host, host.StopGameAsync()); + } + [Test] + public void AbandonedStartupIsQuarantinedUntilItsHookAndShutdownFinish() + { + var completion = new TaskCompletionSource(); + var factory = new TestFactory("late") { InitializeCompletion = completion.Task }; + var host = Host(factory); host.StartupTimeout = TimeSpan.FromMilliseconds(30); + Run(host, host.PrepareAndStartAsync(_game, "test", "Test", "controller", 1, default)); + Assert.That(host.Statuses["late"].State, Is.EqualTo(PluginState.Unresponsive)); + Run(host, host.StopGameAsync()); + Run(host, host.PrepareAndStartAsync(_game, "test", "Test", "controller", 2, default)); + Assert.That(factory.Constructions, Is.EqualTo(1)); + completion.SetResult(true); + var until = DateTime.UtcNow.AddSeconds(2); + while (host.Statuses["late"].State == PluginState.Unresponsive && DateTime.UtcNow < until) { host.Scheduler.Pump(); Thread.Sleep(1); } + Assert.That(factory.Calls, Does.Contain("late:dispose")); + Run(host, host.PrepareAndStartAsync(_game, "test", "Test", "controller", 3, default)); + Assert.That(factory.Constructions, Is.EqualTo(2)); Run(host, host.StopGameAsync()); + } + [Test] + public void AResourceAcquiredAfterScopeClosureIsReleasedImmediately() + { + var scope = new ResourceScope(); scope.Dispose(); bool released = false; + Assert.Throws(() => scope.Own(new Subscription(() => released = true))); + Assert.That(released, Is.True); + } + [Test] + public void PauseAndResumeDuringFaultCleanupKeepTheLatestState() + { + var calls = new List(); + var blockedStop = new TaskCompletionSource(); + var factory = new TestFactory("failed", calls) { FailAt = "ready", StopCompletion = blockedStop.Task }; + var host = Host(factory, new TestFactory("healthy", calls)); + Run(host, host.PrepareAndStartAsync(_game, "test", "Test", "controller", 1, default)); + host.GameReady(); + host.PauseChanged(true); host.PauseChanged(false); + blockedStop.SetResult(true); + while (host.IsTransitioning) { host.Scheduler.Pump(); Thread.Sleep(1); } + Assert.That(calls, Does.Contain("healthy:resume")); + Assert.That(calls, Does.Not.Contain("healthy:pause")); + Run(host, host.StopGameAsync()); + } + [Test] + public void FutureDocumentsAreRejectedBeforeTouchingLiveSettings() + { + var calls = new List(); + var factory = new TestFactory("live", calls) { Schema = new PluginSettingsSchema(1, new PluginSettingsGroup("Test", "", + new PluginField("value", "Value", SettingKind.Integer) { Default = 5, Change = ConfigurationChange.Live })) }; + var host = Host(factory); + Run(host, host.PrepareAndStartAsync(_game, "test", "Test", "controller", 1, default)); + string path = host.Configuration.SettingsPath(factory.Descriptor); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + const string future = "{'schemaVersion':9,'values':{'value':5,'future':'kept'}}"; + File.WriteAllText(path, future); + using var session = new PluginSettingsSession(factory, host.Configuration, "windows"); + session.Set("value", 42); + Assert.Throws(() => Run(host, session.ApplyAsync(host: host))); + Assert.That(calls, Does.Not.Contain("live:apply:42")); + Assert.That(File.ReadAllText(path), Is.EqualTo(future)); + Run(host, host.StopGameAsync()); + } + [Test] + public void UncooperativeCleanupDoesNotBlockOtherPluginsOrExit() + { + var calls = new List(); + var stop = new TaskCompletionSource(); + var factory = new TestFactory("stuck", calls) { StopCompletion = stop.Task }; + var host = Host(factory, new TestFactory("healthy", calls)); + host.CleanupTimeout = TimeSpan.FromMilliseconds(30); + Run(host, host.PrepareAndStartAsync(_game, "test", "Test", "controller", 1, default)); + Run(host, host.StopGameAsync()); + Assert.That(host.Statuses["stuck"].State, Is.EqualTo(PluginState.Unresponsive)); + Assert.That(calls, Does.Contain("healthy:stop")); + Assert.That(host.IsRunning, Is.False); + // The hung hook may still use its resources, so they stay owned until it returns. + Assert.That(calls, Does.Not.Contain("stuck:release")); + stop.SetResult(true); + Run(host, Task.Run(async () => { while (!calls.Contains("stuck:release")) await Task.Delay(5); })); + Assert.That(calls, Does.Contain("stuck:dispose")); + Assert.That(host.Statuses["stuck"].State, Is.EqualTo(PluginState.Available)); + } + [Test] + public void EmergencyStoppedWorkersKeepTheirResourcesAndBlockTheNextInstance() + { + var calls = new List(); + var quiet = new TaskCompletionSource(); + var factory = new TestFactory("busy", calls) { QuiescenceTask = quiet.Task }; + var host = Host(factory); + host.CleanupTimeout = TimeSpan.FromMilliseconds(30); + Run(host, host.PrepareAndStartAsync(_game, "test", "Test", "controller", 1, default)); + host.EmergencyStop(); + // Even well past the cleanup timeout, a running worker keeps its resources and blocks a replacement. + Run(host, Task.Delay(100)); + Assert.That(calls, Does.Not.Contain("busy:release")); + Run(host, host.PrepareAndStartAsync(_game, "test", "Test", "controller", 1, default)); + Assert.That(factory.Constructions, Is.EqualTo(1)); + Assert.That(host.Statuses["busy"].State, Is.EqualTo(PluginState.Unresponsive)); + Run(host, host.StopGameAsync()); + quiet.SetResult(true); + Run(host, Task.Run(async () => { while (!calls.Contains("busy:release")) await Task.Delay(5); })); + Run(host, Task.Delay(20)); + Assert.That(host.Statuses["busy"].State, Is.EqualTo(PluginState.Available)); + Run(host, host.PrepareAndStartAsync(_game, "test", "Test", "controller", 1, default)); + Assert.That(factory.Constructions, Is.EqualTo(2)); + Run(host, host.StopGameAsync()); + } + [Test] + public void EmergencyStopDuringAHungHookKeepsResourcesUntilTheHookReturns() + { + var calls = new List(); + var initialize = new TaskCompletionSource(); + var factory = new TestFactory("hung", calls) { InitializeCompletion = initialize.Task }; + var host = Host(factory); + host.StartupTimeout = TimeSpan.FromSeconds(30); + var start = host.PrepareAndStartAsync(_game, "test", "Test", "controller", 1, default); + Run(host, Task.Run(async () => { while (!calls.Contains("hung:initialize")) await Task.Delay(5); })); + // Quiescence is complete; only the initialization hook is still running. + host.EmergencyStop(); + Run(host, Task.Delay(50)); + Assert.That(calls, Does.Not.Contain("hung:release")); + initialize.SetResult(true); + Run(host, Task.Run(async () => { while (!calls.Contains("hung:release")) await Task.Delay(5); })); + Run(host, Task.WhenAny(start, Task.Delay(1000))); + } + [Test] + public void BadPolicyAndOrderingCycleOnlyBlockAffectedPlugins() + { + var bad = new TestFactory("bad"); var a = new TestFactory("cycle.a"); var b = new TestFactory("cycle.b"); var healthy = new TestFactory("healthy"); + a.Descriptor.After = new[] { b.Descriptor.Id }; b.Descriptor.After = new[] { a.Descriptor.Id }; + var host = Host(bad, a, b, healthy); + Directory.CreateDirectory(host.Configuration.DirectoryFor(bad.Descriptor)); + File.WriteAllText(Path.Combine(host.Configuration.DirectoryFor(bad.Descriptor), "policy.json"), "invalid JSON"); + Run(host, host.PrepareAndStartAsync(_game, "test", "Test", "controller", 1, default)); + Assert.That(host.Statuses["bad"].State, Is.EqualTo(PluginState.Faulted)); + Assert.That(host.Statuses["cycle.a"].State, Is.EqualTo(PluginState.Blocked)); + Assert.That(host.Statuses["cycle.b"].State, Is.EqualTo(PluginState.Blocked)); + Assert.That(host.Statuses["healthy"].State, Is.EqualTo(PluginState.Running)); + Run(host, host.StopGameAsync()); + } + internal static void Run(PluginHost host, Task operation) + { + var deadline = DateTime.UtcNow.AddSeconds(5); + while (!operation.IsCompleted && DateTime.UtcNow < deadline) { host.Scheduler.Pump(); Thread.Sleep(1); } + Assert.That(operation.IsCompleted, Is.True, "The owner scheduler did not complete the operation."); + operation.GetAwaiter().GetResult(); + } + } + + internal sealed class TestFactory : IPlayerPluginFactory + { + public PluginDescriptor Descriptor { get; } + public PluginSettingsSchema Schema { get; set; } = new PluginSettingsSchema(1); + public IPluginSettingsProvider? SettingsProvider { get; set; } + internal readonly List Calls; + internal string FailAt = ""; + internal int Constructions; + /// Work the plugin reports as still running after its hooks returned. + internal Task? QuiescenceTask; + internal int PrepareThread; + internal bool AsyncInitialize; + internal bool CleanupWasCanceled; + internal bool NeverFinishStop; + internal Task? StopCompletion; + internal Task? InitializeCompletion; + internal Task? ApplyCompletion; + internal Action? ApplyAction; + internal bool SynchronousApply; + internal CancellationTokenSource? CancelDuringStart; + internal TestFactory(string id, List? calls = null) { Descriptor = new PluginDescriptor(id, id, "Test") { EnabledByDefault = true }; Calls = calls ?? new List(); } + public IPlayerPlugin Create(PluginApplicationContext context) { Constructions++; context.Lifetime.Own(new Subscription(() => Calls.Add(Descriptor.Id + ":release"))); return SynchronousApply ? new SynchronousTestPlugin(this) : new TestPlugin(this); } + private sealed class SynchronousTestPlugin : TestPlugin, IPluginSynchronousReconfiguration + { + internal SynchronousTestPlugin(TestFactory factory) : base(factory) { } + public PluginActionResult Apply(PluginSettingsSnapshot settings) => ApplyAsync(settings, default).GetAwaiter().GetResult(); + } + private class TestPlugin : PlayerPlugin, IPluginReconfiguration, IPluginTick, IPluginDeferredCleanup + { + public Task Quiescence => _factory.QuiescenceTask ?? Task.CompletedTask; + private readonly TestFactory _factory; + internal TestPlugin(TestFactory factory) { _factory = factory; } + private void Call(string phase) { _factory.Calls.Add(_factory.Descriptor.Id + ":" + phase); if (_factory.FailAt == phase) throw new InvalidOperationException("Test failure"); } + public override async Task InitializeAsync(CancellationToken token) { Call("initialize"); if (_factory.AsyncInitialize) await Task.Run(() => Thread.Sleep(5), token).ConfigureAwait(false); if (_factory.InitializeCompletion != null) await _factory.InitializeCompletion; } + public override Task PrepareGameAsync(PluginGameContext game, CancellationToken token) { _factory.PrepareThread = Thread.CurrentThread.ManagedThreadId; Call("prepare"); return Task.CompletedTask; } + public override Task StartGameAsync(CancellationToken token) { Call("start"); _factory.CancelDuringStart?.Cancel(); token.ThrowIfCancellationRequested(); return Task.CompletedTask; } + public override void GameReady() => Call("ready"); + public override void PauseChanged(bool paused) => Call(paused ? "pause" : "resume"); + public void Tick(double delta) => Call("tick"); + public Task ApplyAsync(PluginSettingsSnapshot settings, CancellationToken cancellation) { Call("apply:" + settings.Integer("value")); _factory.ApplyAction?.Invoke(settings.Integer("value")); return _factory.ApplyCompletion ?? Task.FromResult(new PluginActionResult(true, "")); } + public override Task StopGameAsync(CancellationToken token) { _factory.CleanupWasCanceled = token.IsCancellationRequested; Call("stop"); return _factory.StopCompletion ?? (_factory.NeverFinishStop ? new TaskCompletionSource().Task : Task.CompletedTask); } + public override Task ShutdownAsync(CancellationToken token) { Call("shutdown"); return Task.CompletedTask; } + public override void Dispose() => Call("dispose"); + } + } +} diff --git a/VisualPinball.Plugins.Test/PluginHostTests.cs.meta b/VisualPinball.Plugins.Test/PluginHostTests.cs.meta new file mode 100644 index 000000000..067110357 --- /dev/null +++ b/VisualPinball.Plugins.Test/PluginHostTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 6df2c88707f18c342bb587998b0060c9 \ No newline at end of file diff --git a/VisualPinball.Plugins.Test/ResourceTests.cs b/VisualPinball.Plugins.Test/ResourceTests.cs new file mode 100644 index 000000000..5f895560e --- /dev/null +++ b/VisualPinball.Plugins.Test/ResourceTests.cs @@ -0,0 +1,164 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// SPDX-License-Identifier: MIT +// Licensed under MIT. See https://opensource.org/license/mit. + +using System; +using System.Collections.Generic; +using NUnit.Framework; +using VisualPinball.Plugins; + +namespace VisualPinball.Plugins.Test +{ + [TestFixture] + public sealed class ResourceTests + { + private static PluginResourceDescriptor Display(string id, long instance = 1, string controller = "game", ResourceView view = ResourceView.Original, params string[] parents) + => new PluginResourceDescriptor(id, "test", controller, id, "dmd", ResourceKind.Display, 1, instance, view, DisplayEncoding.Gray2 | DisplayEncoding.Gray4 | DisplayEncoding.Rgb24, parents); + private static PluginResourceSelection Selection(ResourceView view = ResourceView.Identification) => new PluginResourceSelection(ResourceKind.Display) { Controller = "game", Session = 1, Intent = view }; + private static PluginDisplayFrame Frame(long instance, DisplayEncoding format = DisplayEncoding.Gray4) => new PluginDisplayFrame(new byte[3 * 2 * (format == DisplayEncoding.Rgb24 ? 3 : 1)], 3, 2, 3 * (format == DisplayEncoding.Rgb24 ? 3 : 1), format, 1, 0, instance); + + [Test] + public void LatePublicationNegotiatesForEachConsumerAndReleaseDoesNotReleaseAnother() + { + int leases = 0; + var registry = new PluginResourceRegistry(); + using var first = registry.Bind(Selection()); + using var second = registry.Bind(Selection()); + Assert.That(first.State, Is.EqualTo(ResourceBindingState.Waiting)); + using var source = new DisplayResourceSource((view, formats) => { leases++; return new Subscription(() => leases--); }); + using var publication = registry.Publish(Display("raw"), source); + Assert.That(leases, Is.EqualTo(2)); + first.Dispose(); Assert.That(leases, Is.EqualTo(1)); + second.Dispose(); Assert.That(leases, Is.Zero); + } + [Test] + public void LateParentFromAnotherSessionCannotMakeDerivedResourceReachable() + { + var registry = new PluginResourceRegistry(); + Assert.Throws(() => registry.Publish(Display("colored", view: ResourceView.Presentation, parents: new[] { "raw" }), new DisplayResourceSource())); + using var raw = registry.Publish(new PluginResourceDescriptor("raw", "test", "game", "raw", "dmd", ResourceKind.Display, 2, 9, + ResourceView.Original, DisplayEncoding.Gray4), new DisplayResourceSource()); + Assert.That(registry.Snapshot(Selection(ResourceView.Presentation)), Is.Empty); + } + [Test] + public void RuntimeSourceChoicesAreControllerRelativeAndDeferredWhenTheGameIsStopped() + { + var game = new GameIdentity("pinmame", "game"); + var idle = PluginResourceChoices.Displays(new PluginChoiceRequest(game, new PluginSettingsSnapshot(1), "windows"), DisplayEncoding.Gray4); + Assert.That(idle.IsComplete, Is.False); + string controller = game.Id + "/controller/0"; + var descriptors = new[] { + new PluginResourceDescriptor(controller, "pinmame", controller, "Controller", "controller", ResourceKind.Controller, 1, 1), + Display(controller + "/display/dmd0", controller: controller) + }; + var live = PluginResourceChoices.Displays(new PluginChoiceRequest(game, new PluginSettingsSnapshot(1), "windows", descriptors), DisplayEncoding.Gray4); + Assert.That(live.IsComplete, Is.True); + Assert.That(live.Find("display/dmd0"), Is.Not.Null); + Assert.That(PluginResourceChoices.Resolve(controller, "display/dmd0"), Is.EqualTo(controller + "/display/dmd0")); + var global = PluginResourceChoices.Displays(new PluginChoiceRequest(null, new PluginSettingsSnapshot(1), "windows", descriptors), DisplayEncoding.Gray4); + Assert.That(global.Choices.Count, Is.EqualTo(1)); + } + [Test] + public void IdentificationConsumersHoldNativePrecisionUntilLastLeaseLeaves() + { + var requests = new List(); + using var negotiation = new DisplayFormatNegotiator(DisplayEncoding.Gray2 | DisplayEncoding.Gray4 | DisplayEncoding.Luminance8, DisplayEncoding.Luminance8, requests.Add); + using var first = negotiation.Acquire(ResourceView.Identification, DisplayEncoding.Gray2 | DisplayEncoding.Gray4); + using var second = negotiation.Acquire(ResourceView.Identification, DisplayEncoding.Gray2 | DisplayEncoding.Gray4); + first.Dispose(); + Assert.That(requests, Is.EqualTo(new[] { DisplayEncoding.Gray4 })); + second.Dispose(); + Assert.That(requests, Is.EqualTo(new[] { DisplayEncoding.Gray4, DisplayEncoding.Luminance8 })); + } + [Test] + public void AutoBlocksAmbiguityAndManualMissingNeverFallsBack() + { + var registry = new PluginResourceRegistry(); + using var source = new DisplayResourceSource(); + using var raw = registry.Publish(Display("one"), source); + using var auto = registry.Bind(Selection()); + using var other = registry.Publish(Display("two"), new DisplayResourceSource()); + Assert.That(auto.State, Is.EqualTo(ResourceBindingState.Blocked)); + var explicitChoice = Selection(); explicitChoice.ResourceId = "missing"; + using var manual = registry.Bind(explicitChoice); + Assert.That(manual.State, Is.EqualTo(ResourceBindingState.Waiting)); + other.Dispose(); Assert.That(auto.State, Is.EqualTo(ResourceBindingState.Bound)); + } + [Test] + public void PresentationChoosesDerivedResourceWhileIdentificationPreservesRaw() + { + var registry = new PluginResourceRegistry(); + using var raw = registry.Publish(Display("raw"), new DisplayResourceSource()); + using var unrelated = registry.Publish(Display("unrelated", controller: "other"), new DisplayResourceSource()); + using var transformed = registry.Publish(Display("colored", view: ResourceView.Presentation, parents: new[] { "raw" }), new DisplayResourceSource()); + using var identification = registry.Bind(Selection()); + var choice = Selection(ResourceView.Presentation); choice.AllowOriginalFallback = true; + using var presentation = registry.Bind(choice); + Assert.That(identification.Descriptor!.Id, Is.EqualTo("raw")); + Assert.That(presentation.Descriptor!.Id, Is.EqualTo("colored")); + } + [Test] + public void FirstFrameReportsActualDepthAndOldGenerationIsRejected() + { + var registry = new PluginResourceRegistry(); + using var binding = registry.Bind(Selection()); + var encodings = new List(); + using var subscription = binding.SubscribeFrames(frame => encodings.Add(frame.Encoding)); + using var old = new DisplayResourceSource(); + using var first = registry.Publish(Display("raw"), old); + old.Publish(Frame(1)); Assert.That(binding.ActualEncoding, Is.EqualTo(DisplayEncoding.Gray4)); + first.Dispose(); + using var current = new DisplayResourceSource(); + using var second = registry.Publish(Display("raw", 2), current); + old.Publish(Frame(1)); current.Publish(Frame(1)); current.Publish(Frame(2, DisplayEncoding.Gray2)); + Assert.That(encodings, Is.EqualTo(new[] { DisplayEncoding.Gray4, DisplayEncoding.Gray2 })); + } + [Test] + public void IncompatibleConcreteFormatDoesNotReachConsumer() + { + var registry = new PluginResourceRegistry(); var choice = Selection(); choice.Accepts = DisplayEncoding.Gray2; + using var source = new DisplayResourceSource(); using var published = registry.Publish(Display("raw"), source); + using var binding = registry.Bind(choice); + int count = 0; using var subscription = binding.SubscribeFrames(_ => count++); + source.Publish(Frame(1)); source.Publish(Frame(1, DisplayEncoding.Gray2)); + Assert.That(count, Is.EqualTo(1)); + } + [Test] + public void ThrowingObserverCannotLeakPublication() + { + var faults = new List(); var registry = new PluginResourceRegistry(faults.Add); + using var observer = registry.Observe(_ => throw new InvalidOperationException()); + using var source = new DisplayResourceSource(); var lease = registry.Publish(Display("raw"), source); + lease.Dispose(); Assert.That(registry.Snapshot(Selection()), Is.Empty); Assert.That(faults.Count, Is.EqualTo(2)); + } + [Test] + public void OriginalBindingsStayBoundWhenAConsumerPublishesPresentation() + { + var registry = new PluginResourceRegistry(); using var raw = registry.Publish(Display("raw"), new DisplayResourceSource()); + using var binding = registry.Bind(Selection(ResourceView.Original)); + using var derived = registry.Publish(Display("colored", view: ResourceView.Presentation, parents: new[] { "raw" }), new DisplayResourceSource()); + Assert.That(binding.State, Is.EqualTo(ResourceBindingState.Bound)); Assert.That(binding.Descriptor!.Id, Is.EqualTo("raw")); + } + [Test] + public void DerivedResourcesCannotSurviveTheirParentGeneration() + { + var registry = new PluginResourceRegistry(); var raw = registry.Publish(Display("raw"), new DisplayResourceSource()); + using var derived = registry.Publish(Display("colored", view: ResourceView.Presentation, parents: new[] { "raw" }), new DisplayResourceSource()); + using var binding = registry.Bind(Selection(ResourceView.Presentation)); + Assert.That(binding.State, Is.EqualTo(ResourceBindingState.Bound)); raw.Dispose(); + Assert.That(binding.State, Is.EqualTo(ResourceBindingState.Waiting)); + using var replacement = registry.Publish(Display("raw", 2), new DisplayResourceSource()); + Assert.That(binding.State, Is.EqualTo(ResourceBindingState.Waiting)); + } + [Test] + public void PhysicalOutputHasOneOwnerAndCanBeReacquiredAfterCleanup() + { + var outputs = new PluginOutputLeases(); using var first = outputs.Acquire("serial:COM3", "one"); + Assert.Throws(() => outputs.Acquire("serial:COM3", "two")); + first.Dispose(); using var second = outputs.Acquire("serial:COM3", "two"); + Assert.That(outputs.Owner("serial:COM3"), Is.EqualTo("two")); + } + } +} diff --git a/VisualPinball.Plugins.Test/ResourceTests.cs.meta b/VisualPinball.Plugins.Test/ResourceTests.cs.meta new file mode 100644 index 000000000..30a6e99dd --- /dev/null +++ b/VisualPinball.Plugins.Test/ResourceTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 33ed990c98d58ca408a075b29e5858b4 \ No newline at end of file diff --git a/VisualPinball.Plugins.Test/SettingsAndNegotiationTests.cs b/VisualPinball.Plugins.Test/SettingsAndNegotiationTests.cs new file mode 100644 index 000000000..7f2264e21 --- /dev/null +++ b/VisualPinball.Plugins.Test/SettingsAndNegotiationTests.cs @@ -0,0 +1,195 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// SPDX-License-Identifier: MIT +// Licensed under MIT. See https://opensource.org/license/mit. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using NUnit.Framework; +using VisualPinball.Plugins; + +namespace VisualPinball.Plugins.Test +{ + [TestFixture] + public sealed class SettingsAndNegotiationTests + { + private string _directory = null!; + private readonly PluginDescriptor _plugin = new PluginDescriptor("test.apply", "Apply", "Test"); + + [SetUp] public void SetUp() => _directory = Path.Combine(Path.GetTempPath(), "vpe-plugin-tests", Guid.NewGuid().ToString("N")); + [TearDown] public void TearDown() { if (Directory.Exists(_directory)) Directory.Delete(_directory, true); } + + [Test] + public async Task EditsMadeWhileApplyingStayPending() + { + var schema = new PluginSettingsSchema(1, new PluginSettingsGroup("General", "", + new PluginField("a", "A", SettingKind.Text) { Default = "" }, + new PluginField("b", "B", SettingKind.Text) { Default = "" })); + var store = new PluginConfigurationStore(_directory); + using var session = new PluginSettingsSession(new Factory(_plugin, schema), store, "windows"); + session.Set("a", "one"); + // The draft is frozen synchronously when Apply starts; these edits come after. + var apply = session.ApplyAsync(); + session.Set("a", "two"); + session.Set("b", "later"); + Assert.That((await apply).Success, Is.True); + + var saved = store.Read(_plugin, schema); + Assert.That(saved.String("a"), Is.EqualTo("one")); + Assert.That(saved.String("b"), Is.EqualTo("")); + Assert.That(session.Draft.String("a"), Is.EqualTo("two")); + Assert.That(session.Draft.String("b"), Is.EqualTo("later")); + Assert.That(session.HasEdits("a"), Is.True); + Assert.That(session.HasEdits("b"), Is.True); + } + + [Test] + public async Task UnchangedFieldsFollowTheSavedProfileAfterApply() + { + var schema = new PluginSettingsSchema(1, new PluginSettingsGroup("General", "", + new PluginField("a", "A", SettingKind.Text) { Default = "" })); + var store = new PluginConfigurationStore(_directory); + using var session = new PluginSettingsSession(new Factory(_plugin, schema), store, "windows"); + session.Set("a", "one"); + Assert.That((await session.ApplyAsync()).Success, Is.True); + Assert.That(session.Draft.String("a"), Is.EqualTo("one")); + Assert.That(session.HasEdits("a"), Is.False); + } + + [Test] + public void IdentificationEncodingIsOneTheConsumerAccepts() + { + var requested = new List(); + using var negotiator = new DisplayFormatNegotiator(DisplayEncoding.Gray2 | DisplayEncoding.Gray4 | DisplayEncoding.Luminance8, DisplayEncoding.Luminance8, requested.Add); + var gray2Only = negotiator.Acquire(ResourceView.Identification, DisplayEncoding.Gray2); + Assert.That(requested, Is.EqualTo(new[] { DisplayEncoding.Gray2 })); + // A second consumer must be able to read the encoding already being delivered. + Assert.Throws(() => negotiator.Acquire(ResourceView.Identification, DisplayEncoding.Gray4)); + var both = negotiator.Acquire(ResourceView.Identification, DisplayEncoding.Gray2 | DisplayEncoding.Gray4); + Assert.That(requested, Has.Count.EqualTo(1)); + gray2Only.Dispose(); + both.Dispose(); + Assert.That(requested[requested.Count - 1], Is.EqualTo(DisplayEncoding.Luminance8)); + // With the group idle again, a Gray4 consumer gets Gray4. + using var gray4 = negotiator.Acquire(ResourceView.Identification, DisplayEncoding.Gray4); + Assert.That(requested[requested.Count - 1], Is.EqualTo(DisplayEncoding.Gray4)); + } + + [Test] + public void RefusedDisplayBindingRetriesWhenTheConflictingLeaseEnds() + { + var registry = new PluginResourceRegistry(); + const string id = "pinmame/controller/0/display/dmd0"; + using var negotiator = new DisplayFormatNegotiator(DisplayEncoding.Gray2 | DisplayEncoding.Gray4 | DisplayEncoding.Luminance8, DisplayEncoding.Luminance8, + _ => { }, () => registry.NotifyChanged(id)); + using var source = new DisplayResourceSource(negotiator.Acquire); + using var publication = registry.Publish(new PluginResourceDescriptor(id, "pinmame", "pinmame/controller/0", "dmd0", "dmd", ResourceKind.Display, 1, 1, ResourceView.Original, DisplayEncoding.Gray2 | DisplayEncoding.Gray4 | DisplayEncoding.Luminance8), source); + var gray2 = registry.Bind(new PluginResourceSelection(ResourceKind.Display) { ResourceId = id, Session = 1, Intent = ResourceView.Identification, Accepts = DisplayEncoding.Gray2 }); + using var gray4 = registry.Bind(new PluginResourceSelection(ResourceKind.Display) { ResourceId = id, Session = 1, Intent = ResourceView.Identification, Accepts = DisplayEncoding.Gray4 }); + Assert.That(gray2.State, Is.EqualTo(ResourceBindingState.Bound)); + Assert.That(gray4.State, Is.EqualTo(ResourceBindingState.Blocked)); + // Stopping the first integration frees the encoding without restarting the game. + gray2.Dispose(); + Assert.That(gray4.State, Is.EqualTo(ResourceBindingState.Bound)); + } + + [Test] + public void AFailingRetryStaysBlockedInsteadOfLooping() + { + var registry = new PluginResourceRegistry(); + const string id = "pinmame/controller/0/display/dmd0"; + using var negotiator = new DisplayFormatNegotiator(DisplayEncoding.Gray2 | DisplayEncoding.Gray4 | DisplayEncoding.Luminance8, DisplayEncoding.Luminance8, + _ => { }, () => registry.NotifyChanged(id)); + using var source = new DisplayResourceSource(negotiator.Acquire); + using var publication = registry.Publish(new PluginResourceDescriptor(id, "pinmame", "pinmame/controller/0", "dmd0", "dmd", ResourceKind.Display, 1, 1, ResourceView.Original, DisplayEncoding.Gray2 | DisplayEncoding.Gray4 | DisplayEncoding.Luminance8), source); + var gray2 = registry.Bind(new PluginResourceSelection(ResourceKind.Display) { ResourceId = id, Session = 1, Intent = ResourceView.Identification, Accepts = DisplayEncoding.Gray2 }); + using var gray4 = registry.Bind(new PluginResourceSelection(ResourceKind.Display) { ResourceId = id, Session = 1, Intent = ResourceView.Identification, Accepts = DisplayEncoding.Gray4 }); + int attempts = 0; + using var connection = gray4.Connect(_ => { attempts++; throw new InvalidOperationException("receiver failed"); }); + // The retry acquires the lease, the receiver throws, and the lease rollback must not trigger the same retry again. + Assert.DoesNotThrow(() => gray2.Dispose()); + Assert.That(attempts, Is.LessThanOrEqualTo(2)); + Assert.That(gray4.State, Is.EqualTo(ResourceBindingState.Blocked)); + } + + [Test] + public void AFailingRetryOnASharedLeaseStaysBlockedInsteadOfLooping() + { + var registry = new PluginResourceRegistry(); + const string first = "pinmame/controller/0/display/dmd0", second = "pinmame/controller/0/display/dmd1"; + const DisplayEncoding formats = DisplayEncoding.Gray2 | DisplayEncoding.Gray4 | DisplayEncoding.Luminance8; + // One negotiator for the whole engine notifies every display when its lease ends, as GamelogicPluginResources does. + using var negotiator = new DisplayFormatNegotiator(formats, DisplayEncoding.Luminance8, _ => { }, () => { registry.NotifyChanged(first); registry.NotifyChanged(second); }); + using var firstSource = new DisplayResourceSource(negotiator.Acquire); + using var secondSource = new DisplayResourceSource(negotiator.Acquire); + using var firstPublication = registry.Publish(new PluginResourceDescriptor(first, "pinmame", "pinmame/controller/0", "dmd0", "dmd", ResourceKind.Display, 1, 1, ResourceView.Original, formats), firstSource); + using var secondPublication = registry.Publish(new PluginResourceDescriptor(second, "pinmame", "pinmame/controller/0", "dmd1", "dmd", ResourceKind.Display, 1, 2, ResourceView.Original, formats), secondSource); + var gray2 = registry.Bind(new PluginResourceSelection(ResourceKind.Display) { ResourceId = first, Session = 1, Intent = ResourceView.Identification, Accepts = DisplayEncoding.Gray2 }); + using var gray4 = registry.Bind(new PluginResourceSelection(ResourceKind.Display) { ResourceId = first, Session = 1, Intent = ResourceView.Identification, Accepts = DisplayEncoding.Gray4 }); + int attempts = 0; + using var connection = gray4.Connect(_ => { attempts++; throw new InvalidOperationException("receiver failed"); }); + Assert.DoesNotThrow(() => gray2.Dispose()); + Assert.That(attempts, Is.LessThanOrEqualTo(2)); + Assert.That(gray4.State, Is.EqualTo(ResourceBindingState.Blocked)); + } + + [Test] + public void DisposingABindingFromAChangeHandlerIsFinal() + { + var registry = new PluginResourceRegistry(); + const string id = "pinmame/controller/0/display/dmd0"; + using var negotiator = new DisplayFormatNegotiator(DisplayEncoding.Gray2 | DisplayEncoding.Gray4 | DisplayEncoding.Luminance8, DisplayEncoding.Luminance8, + _ => { }, () => registry.NotifyChanged(id)); + using var source = new DisplayResourceSource(negotiator.Acquire); + using var publication = registry.Publish(new PluginResourceDescriptor(id, "pinmame", "pinmame/controller/0", "dmd0", "dmd", ResourceKind.Display, 1, 1, ResourceView.Original, DisplayEncoding.Gray2 | DisplayEncoding.Gray4 | DisplayEncoding.Luminance8), source); + var gray2 = registry.Bind(new PluginResourceSelection(ResourceKind.Display) { ResourceId = id, Session = 1, Intent = ResourceView.Identification, Accepts = DisplayEncoding.Gray2 }); + var gray4 = registry.Bind(new PluginResourceSelection(ResourceKind.Display) { ResourceId = id, Session = 1, Intent = ResourceView.Identification, Accepts = DisplayEncoding.Gray4 }); + gray4.Changed += () => gray4.Dispose(); + gray2.Dispose(); + Assert.That(gray4.State, Is.EqualTo(ResourceBindingState.Disposed)); + } + + [Test] + public void ChangesDuringAReleaseAreNotLost() + { + var registry = new PluginResourceRegistry(); + const string first = "pinmame/controller/0/display/dmd0", second = "pinmame/controller/0/display/dmd1", third = "pinmame/controller/0/display/dmd2"; + const DisplayEncoding formats = DisplayEncoding.Gray2 | DisplayEncoding.Gray4 | DisplayEncoding.Luminance8; + using var negotiator = new DisplayFormatNegotiator(formats, DisplayEncoding.Luminance8, _ => { }, () => registry.NotifyChanged(first)); + using var source = new DisplayResourceSource(negotiator.Acquire); + using var publication = registry.Publish(new PluginResourceDescriptor(first, "pinmame", "pinmame/controller/0", "dmd0", "dmd", ResourceKind.Display, 1, 1, ResourceView.Original, formats), source); + // Bound to the only display, holding Gray2. + using var any = registry.Bind(new PluginResourceSelection(ResourceKind.Display) { Session = 1, Intent = ResourceView.Identification, Accepts = DisplayEncoding.Gray2 }); + // Refused on the same display until Gray2 is released; once it gets in, it publishes a third display. + using var thirdSource = new DisplayResourceSource(); + IDisposable? thirdPublication = null; + using var gray4 = registry.Bind(new PluginResourceSelection(ResourceKind.Display) { ResourceId = first, Session = 1, Intent = ResourceView.Identification, Accepts = DisplayEncoding.Gray4 }); + gray4.Changed += () => { + if (gray4.State == ResourceBindingState.Bound && thirdPublication == null) + thirdPublication = registry.Publish(new PluginResourceDescriptor(third, "pinmame", "pinmame/controller/0", "dmd2", "dmd", ResourceKind.Display, 1, 3, ResourceView.Original, formats), thirdSource); + }; + Assert.That(any.State, Is.EqualTo(ResourceBindingState.Bound)); + Assert.That(gray4.State, Is.EqualTo(ResourceBindingState.Blocked)); + // A second display makes the first binding ambiguous. Releasing its view lets the Gray4 binding in, which publishes a + // third display while the first binding is still releasing; that publication must not be lost. + using var secondSource = new DisplayResourceSource(); + using var secondPublication = registry.Publish(new PluginResourceDescriptor(second, "pinmame", "pinmame/controller/0", "dmd1", "dmd", ResourceKind.Display, 1, 2, ResourceView.Original, formats), secondSource); + Assert.That(gray4.State, Is.EqualTo(ResourceBindingState.Bound)); + Assert.That(thirdPublication, Is.Not.Null); + Assert.That(any.Diagnostic, Does.Contain(third)); + thirdPublication!.Dispose(); + } + + private sealed class Factory : IPlayerPluginFactory + { + public PluginDescriptor Descriptor { get; } + public PluginSettingsSchema Schema { get; } + public IPluginSettingsProvider? SettingsProvider => null; + internal Factory(PluginDescriptor descriptor, PluginSettingsSchema schema) { Descriptor = descriptor; Schema = schema; } + public IPlayerPlugin Create(PluginApplicationContext context) => throw new NotSupportedException(); + } + } +} diff --git a/VisualPinball.Plugins.Test/SettingsAndNegotiationTests.cs.meta b/VisualPinball.Plugins.Test/SettingsAndNegotiationTests.cs.meta new file mode 100644 index 000000000..6c9f03eb8 --- /dev/null +++ b/VisualPinball.Plugins.Test/SettingsAndNegotiationTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 15a7104bc7914bcfb617d9aeb94373fd diff --git a/VisualPinball.Plugins.Test/VisualPinball.Plugins.Test.asmdef b/VisualPinball.Plugins.Test/VisualPinball.Plugins.Test.asmdef new file mode 100644 index 000000000..3c0843135 --- /dev/null +++ b/VisualPinball.Plugins.Test/VisualPinball.Plugins.Test.asmdef @@ -0,0 +1,10 @@ +{ + "name": "VisualPinball.Plugins.Test", + "references": ["VisualPinball.Plugins", "Newtonsoft.Json"], + "overrideReferences": true, + "precompiledReferences": ["nunit.framework.dll", "Newtonsoft.Json.dll"], + "includePlatforms": ["Editor"], + "optionalUnityReferences": ["TestAssemblies"], + "autoReferenced": false, + "defineConstraints": ["UNITY_INCLUDE_TESTS"] +} diff --git a/VisualPinball.Plugins.Test/VisualPinball.Plugins.Test.asmdef.meta b/VisualPinball.Plugins.Test/VisualPinball.Plugins.Test.asmdef.meta new file mode 100644 index 000000000..74d6541a8 --- /dev/null +++ b/VisualPinball.Plugins.Test/VisualPinball.Plugins.Test.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 9a9b6d3aca94a0f4c916bc2af7beda90 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Plugins.Test/VisualPinball.Plugins.Test.csproj b/VisualPinball.Plugins.Test/VisualPinball.Plugins.Test.csproj new file mode 100644 index 000000000..212f6a503 --- /dev/null +++ b/VisualPinball.Plugins.Test/VisualPinball.Plugins.Test.csproj @@ -0,0 +1,16 @@ + + + net10.0 + 9.0 + enable + true + false + + + + + + + + + diff --git a/VisualPinball.Plugins.Test/VisualPinball.Plugins.Test.csproj.meta b/VisualPinball.Plugins.Test/VisualPinball.Plugins.Test.csproj.meta new file mode 100644 index 000000000..540b08821 --- /dev/null +++ b/VisualPinball.Plugins.Test/VisualPinball.Plugins.Test.csproj.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 83cddfc6a256d734c9f6df015b7e1f39 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Plugins/Api.meta b/VisualPinball.Plugins/Api.meta new file mode 100644 index 000000000..4902f15bb --- /dev/null +++ b/VisualPinball.Plugins/Api.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 8b1489d83b525a046b98397ef0dc3adf +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Plugins/Api/GameIdentityProvider.cs b/VisualPinball.Plugins/Api/GameIdentityProvider.cs new file mode 100644 index 000000000..95f59418b --- /dev/null +++ b/VisualPinball.Plugins/Api/GameIdentityProvider.cs @@ -0,0 +1,122 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// SPDX-License-Identifier: MIT +// Licensed under MIT. See https://opensource.org/license/mit. + +using System; +using System.Collections.Generic; + +namespace VisualPinball.Plugins +{ + /// + /// Implemented by a gamelogic engine that knows which game it runs, so the Player can tell plugins which game is being + /// played without opening the table package. + /// + /// + /// The identity selects per-game plugin settings and lets plugins find matching media, such as a PinUP pack or a + /// backglass for the ROM. The Player asks the loaded table's engine first and only falls back to reading the package + /// (see ) when the engine cannot answer. + /// + /// A ROM-based engine reports its ROM name: + /// + /// public class MyRomEngine : MonoBehaviour, IGamelogicEngine, IGameIdentityProvider + /// { + /// public string romId = "t2_l8"; + /// GameIdentity? IGameIdentityProvider.GameIdentity => string.IsNullOrWhiteSpace(romId) ? null : new GameIdentity("myrom", romId); + /// } + /// + /// + /// + public interface IGameIdentityProvider + { + /// + /// The game this engine is configured to run, or null when it cannot tell yet (for example before a ROM is + /// selected). Must be cheap and side-effect free: the Player reads it while preparing a game, before the engine starts. + /// + GameIdentity? GameIdentity { get; } + } + + /// + /// Reads a game identity from one component's serialized data inside a table package, without loading the table. + /// + /// + /// The Player uses readers to identify a game from the package alone, for instance to show per-game plugin settings in + /// the table browser before anything is loaded. Each reader handles one component type; the Player walks the package's + /// components, hands every payload of a matching type to its reader, and uses the single identity found. Without any + /// identity the package's content hash identifies the game. + /// + /// A reader must only parse the payload. It must not initialize the component's native runtime (no emulator, no + /// device), since it runs on a worker thread for packages that may never be played. + /// + /// + /// The PinMAME integration reads the ROM name from its engine component: + /// + /// internal sealed class PinMamePackageIdentityReader : IPackagedGameIdentityReader + /// { + /// public string ComponentType => "PinMameGamelogicEngine"; + /// public GameIdentity? Read(ReadOnlyMemory<byte> payload) + /// { + /// var values = JObject.Parse(Encoding.UTF8.GetString(payload.Span)); + /// var rom = values.Value<string>("RomId"); + /// return string.IsNullOrWhiteSpace(rom) ? null : new GameIdentity("pinmame", rom); + /// } + /// } + /// + /// + /// + public interface IPackagedGameIdentityReader + { + /// + /// Name of the component type whose payloads this reader understands, as stored in the package (the component's + /// type name, such as PinMameGamelogicEngine). Used as the registry key, so it must be unique. + /// + string ComponentType { get; } + + /// + /// Extracts the game identity from one serialized component payload, or returns null when the payload does not name + /// a game (for example an engine with no ROM selected). Called on a worker thread; must not touch Unity. + /// + /// The component's raw serialized data, borrowed for the duration of the call. + GameIdentity? Read(ReadOnlyMemory payload); + } + + /// + /// Registry of s, keyed by component type, that the Player consults when it + /// identifies a table package. + /// + /// + /// Integration packages register their reader once at startup, typically from a + /// [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)] method. The registry is not + /// thread-safe: register on the main thread before packages are read. + /// + public static class PackagedGameIdentityReaders + { + private static readonly Dictionary Readers = new Dictionary(StringComparer.Ordinal); + + /// + /// Removes all registered readers. The host calls this when the scripting domain starts, so readers registered by a + /// previous play session in the editor do not linger; plugins have no reason to call it. + /// + public static void Reset() => Readers.Clear(); + + /// Adds a reader for its . + /// + /// + /// + /// [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)] + /// private static void Register() => PackagedGameIdentityReaders.Register(new PinMamePackageIdentityReader()); + /// + /// + /// + /// A reader for the same component type is already registered. + public static void Register(IPackagedGameIdentityReader reader) => Readers.Add(reader.ComponentType, reader); + + /// + /// Returns the reader for a component type, or null when no integration understands that component. The Player calls + /// this for every component in a package and skips the ones without a reader. + /// + /// The component type name as stored in the package. + public static IPackagedGameIdentityReader? Find(string componentType) => Readers.TryGetValue(componentType, out var reader) ? reader : null; + } +} diff --git a/VisualPinball.Plugins/Api/GameIdentityProvider.cs.meta b/VisualPinball.Plugins/Api/GameIdentityProvider.cs.meta new file mode 100644 index 000000000..3e0664b50 --- /dev/null +++ b/VisualPinball.Plugins/Api/GameIdentityProvider.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3b793c3929bc93f458f85916e7b5ef12 \ No newline at end of file diff --git a/VisualPinball.Plugins/Api/PluginDescriptor.cs b/VisualPinball.Plugins/Api/PluginDescriptor.cs new file mode 100644 index 000000000..6bacf6551 --- /dev/null +++ b/VisualPinball.Plugins/Api/PluginDescriptor.cs @@ -0,0 +1,394 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// SPDX-License-Identifier: MIT +// Licensed under MIT. See https://opensource.org/license/mit. + +using System; +using System.Collections.Generic; + +namespace VisualPinball.Plugins +{ + /// + /// The lifecycle steps the drives a plugin through. Each value names one hook of + /// or of an optional hook interface. + /// + /// + /// Phases serve two purposes. They select the ordering rules in , so a plugin + /// can, for example, render after another plugin without also starting after it. They are also reported in + /// , so the Player can say in which step a plugin failed. The host orders the startup + /// and dispatch phases ( to ); cleanup does not use ordering rules, it runs + /// in reverse start order. + /// + public enum PluginPhase + { + /// , right after the host created the instance. + Initialize, + /// , where a plugin sets up everything it needs for the game. + Prepare, + /// , after every plugin has been prepared. + Start, + /// , when the table's player has started. + Ready, + /// with true, when the game is paused. + Pause, + /// with false, when the game resumes. + Resume, + /// , when the application gains or loses focus. + Focus, + /// , once per frame. + Tick, + /// , once per frame after all frame updates. + Render, + /// + /// . Reported in while a plugin stops; not + /// used for ordering, since cleanup runs in reverse start order. + /// + Stop, + /// + /// . Not used for ordering, since cleanup runs in reverse start order, and + /// the host does not currently report it in . + /// + Shutdown + } + + /// The runtime state of one installed plugin, as kept by the . + /// + /// The Player's plugin settings page reads it from and shows the state and + /// when a plugin is , , + /// or . A plugin cannot set its own state; the host derives it from + /// configuration and from how the plugin's hooks complete. + /// + public enum PluginState + { + /// + /// Installed and selected, or cleanly stopped. This is the initial state and the state after a game ends normally. + /// + Available, + /// Disabled by the user, globally or for the current game, so the host did not create an instance. + Disabled, + /// + /// Not created because the current platform is not listed in . + /// + Unsupported, + /// + /// Not started or stopped early because a dependency or required capability is missing or ambiguous, its ordering + /// rules form a cycle, or the host was still busy with a previous game. says which. + /// + Blocked, + /// + /// A startup hook (initialize, prepare or start) is running. says which one. + /// + Starting, + /// + /// completed. Only running plugins receive ready, pause, focus, tick and + /// render calls. + /// + Running, + /// The host is running the plugin's stop and shutdown hooks. + Stopping, + /// + /// A hook threw, or the plugin's settings were invalid at startup. holds the error. + /// The instance has been cleaned up and plugins that depend on it are blocked. The next game selects it again. + /// + Faulted, + /// + /// A hook did not finish before its deadline, or the plugin's workers have not yet quiesced after an emergency stop + /// (see ). The old instance keeps its resources until it drains, and new + /// instances of the plugin are blocked until then; the state returns to afterwards. + /// + Unresponsive + } + + /// Where a setting is stored and where the user can edit it. + /// + /// Set per field through . The keeps one global + /// settings.json per plugin and one profile per game that holds only explicitly changed values; the scope decides + /// which of the two a field may live in. For example the B2S plugin makes its window placement + /// so a backglass can sit on a different monitor for one table. + /// + public enum ConfigurationScope + { + /// + /// Stored only in the global settings. Game profiles never hold or override it, and it is not shown on a game's + /// settings page. This is the default for . + /// + GlobalOnly, + /// + /// Stored only in game profiles. It is not shown on the global settings page and cannot be exposed as a global + /// setting such as a volume mixer slider. + /// + GameOnly, + /// + /// A global value that a game profile may override. On a game's settings page the Player shows an override toggle + /// for it; the game value wins when present. + /// + GlobalAndGame + } + + /// When a changed setting reaches a running plugin. + /// + /// Set per field through . When the user commits settings while a game runs, the host + /// pushes only fields to the running plugin, through + /// or . Everything else is saved and read when the next game is prepared. PinMAME + /// and PinUP, for example, make their volume so the volume mixer takes effect immediately. + /// + public enum ConfigurationChange + { + /// + /// Applied to the running plugin as soon as it is committed. The plugin must implement + /// or ; otherwise the host refuses + /// to save a changed live field that affects the running game. + /// + Live, + /// + /// Saved immediately and used from the next game on. This is the default for . + /// + NextGame, + /// + /// Only takes effect after the application restarts. The host currently treats it like : the + /// value is saved and nothing is pushed to the running plugin. + /// + ApplicationRestart + } + + /// Ordering rules for one , overriding or extending a descriptor's general rules. + /// + /// Stored in . Use it when a plugin must run in a different position in one + /// phase only, for example to render after another plugin that it otherwise starts before. + /// + /// + /// descriptor.PhaseOrder = new Dictionary<PluginPhase, PluginOrder> { + /// [PluginPhase.Render] = new PluginOrder { Priority = 50, After = new[] { "org.visualpinball.dmd" } } + /// }; + /// + /// + /// + public sealed class PluginOrder + { + /// + /// Replaces for this phase. Lower values run earlier among plugins that no + /// ordering rule constrains; ties are broken by plugin ID. Defaults to 0, so set it explicitly when the plugin's + /// general should still apply in this phase. + /// + public int Priority { get; set; } + /// + /// IDs of plugins this plugin must run before in this phase, in addition to . + /// IDs of plugins that are not selected are ignored. + /// + public IReadOnlyList Before { get; set; } = Array.Empty(); + /// + /// IDs of plugins this plugin must run after in this phase, in addition to . + /// IDs of plugins that are not selected are ignored. + /// + public IReadOnlyList After { get; set; } = Array.Empty(); + } + + /// + /// Static description of a plugin: its identity, how the Player lists it, whether it is enabled by default, and how it + /// is ordered relative to other plugins. + /// + /// + /// Every exposes one descriptor. The host reads it before any plugin instance exists: + /// to decide whether the plugin is enabled and supported for the game, to check its dependencies and required + /// capabilities, and to order its hooks against other plugins. The Player's plugin page shows its + /// , , and . Set all properties + /// when the descriptor is created and do not change them afterwards; the host reads them at every game start. + /// + /// The PinUP integration describes itself like this: + /// + /// public PluginDescriptor Descriptor { get; } = new PluginDescriptor("org.visualpinball.pinup", "PinUp", + /// "PUP packs, layered video and native screens, configured here instead of inside your tables.") + /// { Category = "Media", Order = 10 }; + /// + /// A plugin that needs another plugin's service and only works on Windows: + /// + /// new PluginDescriptor("org.example.cabinet-lights", "Cabinet lights", "Drives cabinet LEDs from the game's lamps.") { + /// Category = "Hardware", Order = 40, Requires = new[] { "vpe.outputs.v1" }, Platforms = new[] { "windows" } + /// }; + /// + /// + /// + public sealed class PluginDescriptor + { + /// + /// The plugin API version this build of the host implements. rejects + /// plugins whose differs. + /// + public const int HostApiVersion = 1; + /// + /// Stable, unique plugin ID, such as org.visualpinball.pinup. Only lowercase letters, digits, dots and + /// hyphens. It keys the registry, the plugin's statuses and ordering references, and names the plugin's configuration + /// folder, so it must never change between releases. + /// + public string Id { get; } + /// Display name shown in the Player's plugin list, which is sorted by it. + public string Name { get; } + /// One or two sentences shown on the plugin's settings page, telling the user what the plugin does. + public string Description { get; } + /// + /// The plugin's own version, shown as v followed by this value on its card in the Player. Informational only; + /// configuration compatibility is handled by . Defaults to 0.1.0. + /// + public string Version { get; set; } = "0.1.0"; + /// + /// The plugin API version the plugin was built against. Leave the default: it is as + /// compiled into the plugin, so a plugin built against another API version is rejected at registration. + /// + public int ApiVersion { get; set; } = HostApiVersion; + /// + /// Whether the plugin runs when the user has not enabled or disabled it, globally or for the game. Defaults to + /// false, so a new plugin stays off until the user enables it. + /// + public bool EnabledByDefault { get; set; } + /// + /// Whether the user can disable the plugin. Defaults to true. Set it to false for configuration services that an + /// application-owned engine relies on, such as the PinMAME plugin that adjusts ROM audio: such a plugin is always + /// enabled, the Player hides its enable toggle, and + /// refuses to disable it. + /// + public bool CanDisable { get; set; } = true; + /// + /// Group label shown on the plugin's card in the Player, such as Media, Displays or Gamelogic. + /// Defaults to Integrations. + /// + public string Category { get; set; } = "Integrations"; + /// + /// Default ordering priority in every phase. Lower values run earlier among plugins that no dependency or + /// / rule constrains; ties are broken by . A + /// entry replaces it for that phase. The bundled plugins use PinMAME 5, PinUP 10, DMD 20 and + /// B2S 30. + /// + public int Order { get; set; } + /// + /// IDs of plugins that must be selected and healthy for this one to run. They run before this plugin in every + /// phase. If one is disabled, absent or fails, this plugin is . + /// + public IReadOnlyList DependsOn { get; set; } = Array.Empty(); + /// + /// IDs of plugins this plugin runs before in every phase. Unlike it does not require them; + /// IDs of plugins that are not selected are ignored. + /// + public IReadOnlyList Before { get; set; } = Array.Empty(); + /// + /// IDs of plugins this plugin runs after in every phase. Unlike it does not require them; + /// IDs of plugins that are not selected are ignored. + /// + public IReadOnlyList After { get; set; } = Array.Empty(); + /// + /// Capability IDs this plugin offers to others. Plugins that list one in are ordered after this + /// plugin. Declaring a capability does not publish it: the plugin publishes its service itself through + /// . + /// + public IReadOnlyList Provides { get; set; } = Array.Empty(); + /// + /// Capability IDs this plugin needs, such as vpe.outputs.v1. Each must be published by the host when the game + /// starts or provided by exactly one selected plugin, which then runs first. When none or several selected plugins + /// provide it, this plugin is . + /// + public IReadOnlyList Requires { get; set; } = Array.Empty(); + /// + /// Per-phase ordering overrides. An entry's replaces for that + /// phase, and its before/after lists are added to and . Empty by default. + /// + public IReadOnlyDictionary PhaseOrder { get; set; } = new Dictionary(); + /// + /// Platforms the plugin supports, compared with . The Player uses windows, + /// macos and linux, and Unity's lowercase platform name elsewhere. Empty, the default, means every + /// platform; on an unlisted platform the plugin is . + /// + public IReadOnlyList Platforms { get; set; } = Array.Empty(); + + /// Creates a descriptor with the plugin's identity and the texts the Player shows. + /// The stable plugin ID; see . + /// The display name; see . + /// The short description; see . + /// + /// is empty, contains characters other than lowercase letters, digits, dots and hyphens, or is + /// . or ... + /// + public PluginDescriptor(string id, string name, string description) + { + if (string.IsNullOrWhiteSpace(id)) throw new ArgumentException("A plugin needs a stable ID.", nameof(id)); + foreach (char character in id) + if (!(character >= 'a' && character <= 'z') && !(character >= '0' && character <= '9') && character != '.' && character != '-') + throw new ArgumentException("Plugin IDs use lowercase letters, digits, dots and hyphens.", nameof(id)); + if (id == "." || id == "..") throw new ArgumentException("Invalid plugin ID.", nameof(id)); + Id = id; + Name = name; + Description = description; + } + } + + /// The current state of one installed plugin, updated by the . + /// + /// Read it from . The host changes it on the owner thread, so read it there too. The + /// Player's plugin settings page uses it to explain why a plugin is not running. + /// + public sealed class PluginStatus + { + /// The plugin's lifecycle state. Starts as . + public PluginState State { get; internal set; } = PluginState.Available; + /// + /// Human-readable detail for , such as the error of a faulted hook or the missing provider of a + /// blocked plugin. Never null; empty until the host sets it. + /// + public string Message { get; internal set; } = ""; + /// The phase the state refers to, such as the hook that failed, or null when it is not tied to one. + public PluginPhase? Phase { get; internal set; } + } + + /// + /// Identifies the game being played, independently of the table package, as a provider namespace plus a key within it. + /// + /// + /// The Player gets it from the table's gamelogic engine (see ) or, failing that, + /// from the package (see ), and hands it to plugins in + /// . Per-game plugin settings and enablement are stored under its + /// , and plugins use it to find matching media: the DMD plugin picks a colorization by + /// , and the B2S plugin matches backglasses by ROM name when is + /// pinmame. Provider and key are trimmed and lowercased, so identities compare case-insensitively. + /// + /// + /// var identity = new GameIdentity("pinmame", "T2_L8"); + /// // identity.Key == "t2_l8", identity.Id == "pinmame:t2_l8" + /// string rom = identity.Provider == "pinmame" ? identity.Key : ""; + /// + /// + /// + public readonly struct GameIdentity : IEquatable + { + /// + /// Namespace of , lowercased: pinmame for ROM names, package for the content hash the + /// Player falls back to when nothing else identifies the game. + /// + public string Provider { get; } + /// The game within , lowercased, such as the ROM name t2_l8. + public string Key { get; } + /// + /// and joined by a colon, such as pinmame:t2_l8. Used as the stable + /// key for per-game settings and as the prefix of the game's controller ID. + /// + public string Id => Provider + ":" + Key; + /// Creates an identity, trimming and lowercasing both parts. + /// The namespace of the key, such as pinmame. + /// The game within that namespace, such as a ROM name. + /// or is null or blank. + public GameIdentity(string provider, string key) + { + if (string.IsNullOrWhiteSpace(provider) || string.IsNullOrWhiteSpace(key)) throw new ArgumentException("A game identity needs a provider and key."); + Provider = provider.Trim().ToLowerInvariant(); + Key = key.Trim().ToLowerInvariant(); + } + /// Whether both identities have the same and . + /// The identity to compare with. + public bool Equals(GameIdentity other) => Provider == other.Provider && Key == other.Key; + /// Whether is a with the same provider and key. + /// The object to compare with. + public override bool Equals(object? other) => other is GameIdentity identity && Equals(identity); + /// Returns a hash of , consistent with . + public override int GetHashCode() => StringComparer.Ordinal.GetHashCode(Id); + /// Returns , such as pinmame:t2_l8. + public override string ToString() => Id; + } +} diff --git a/VisualPinball.Plugins/Api/PluginDescriptor.cs.meta b/VisualPinball.Plugins/Api/PluginDescriptor.cs.meta new file mode 100644 index 000000000..d2bfa712b --- /dev/null +++ b/VisualPinball.Plugins/Api/PluginDescriptor.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: cab68cf30e70f9d41bf86665c536c321 \ No newline at end of file diff --git a/VisualPinball.Plugins/Api/PluginRuntime.cs b/VisualPinball.Plugins/Api/PluginRuntime.cs new file mode 100644 index 000000000..b142a48f2 --- /dev/null +++ b/VisualPinball.Plugins/Api/PluginRuntime.cs @@ -0,0 +1,513 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// SPDX-License-Identifier: MIT +// Licensed under MIT. See https://opensource.org/license/mit. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace VisualPinball.Plugins +{ + /// + /// Entry point of a Player plugin: describes the plugin and its settings, and creates plugin instances when a game + /// starts. + /// + /// + /// An integration package registers one factory with at startup. The + /// reads and without creating the plugin, for + /// example to show the Player's plugin settings page while no game runs. When a game starts and the plugin is enabled, + /// supported and has its dependencies, the host validates the settings and calls . PinUP, the B2S + /// backglass, DMD and PinMAME packages each ship one factory. + /// + /// A minimal factory with one live setting, registered before the first scene loads: + /// + /// internal sealed class VolumePluginFactory : IPlayerPluginFactory + /// { + /// public PluginDescriptor Descriptor { get; } = + /// new PluginDescriptor("org.example.volume", "Volume", "Sets the ROM volume.") { EnabledByDefault = true }; + /// public PluginSettingsSchema Schema { get; } = new PluginSettingsSchema(1, new PluginSettingsGroup("Audio", "", + /// new PluginField("Volume", "Volume", SettingKind.Integer) { + /// Default = 100, Minimum = 0, Maximum = 100, Change = ConfigurationChange.Live + /// })); + /// public IPluginSettingsProvider? SettingsProvider => null; + /// public IPlayerPlugin Create(PluginApplicationContext context) => new VolumePlugin(context); + /// + /// [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)] + /// private static void Register() => PlayerPluginRegistry.Register(new VolumePluginFactory()); + /// } + /// + /// + /// + public interface IPlayerPluginFactory + { + /// + /// The plugin's identity, presentation and ordering rules. Must return the same instance every time; the host reads it + /// whenever it selects or orders plugins. + /// + PluginDescriptor Descriptor { get; } + /// + /// The plugin's settings: fields, defaults, scopes and migrations. The Player builds the settings page from it, the + /// stores values by it, and the host validates the effective settings against + /// it before creating the plugin. Must return the same instance every time. + /// + PluginSettingsSchema Schema { get; } + /// + /// Optional source of dynamic choices, actions and extra validation for the settings page, or null when the schema is + /// self-contained. The host also calls its before creating the plugin; + /// any issue faults the plugin for that game. + /// + IPluginSettingsProvider? SettingsProvider { get; } + /// + /// Creates a plugin instance. The host calls it on the owner thread once per game, for each enabled and selected + /// plugin, and then drives the instance through its lifecycle. Keep it cheap and do + /// asynchronous work in . An exception faults the plugin for that game. + /// + /// + /// Host services for the instance, valid until it is disposed. Keep it; the game hooks do not receive it again. + /// + IPlayerPlugin Create(PluginApplicationContext context); + } + + /// A plugin instance, driven by the through the lifecycle of one game. + /// + /// The host creates a new instance for every game through and calls, in + /// order: , , , then + /// , , and the optional + /// and hooks while the game runs, and finally + /// , and . Plugins are ordered by + /// their in each startup and dispatch phase; cleanup runs in reverse start order. + /// + /// Every hook is called on the owner thread (Unity's main thread in the Player). An asynchronous hook that awaits + /// worker tasks must switch back with await context.Scheduler.Switch() before touching Unity or other + /// owner-thread state. Startup hooks get a token that is cancelled when the game is cancelled or after + /// ; cleanup hooks get one that is cancelled after + /// . A hook that misses its deadline makes the plugin + /// : the host calls and keeps the + /// instance's resources until the hook finishes. An exception from any hook except the cleanup hooks makes the plugin + /// ; the host cleans it up and blocks plugins that depend on it, while other plugins + /// keep running. + /// + /// Derive from to implement only the hooks you need. + /// + public interface IPlayerPlugin : IDisposable + { + /// + /// First hook after , for setup that does not depend on the game, such as + /// loading native libraries. Called once per instance, in initialize order, before any plugin is prepared. + /// + /// Cancelled when the game is cancelled or the startup deadline expires. + Task InitializeAsync(CancellationToken cancellation); + /// + /// Sets up everything the plugin needs for the game: reads , finds the table + /// through a capability, binds or publishes resources, opens windows. Register everything that must be released + /// when the game ends with . Called once, in prepare order; PinUP loads the + /// matching PUP pack and binds the DMD here, and B2S loads the backglass and creates its windows. + /// + /// The game being started and the services scoped to it. + /// Cancelled when the game is cancelled or the startup deadline expires. + /// is called even when this hook throws or is cancelled. + Task PrepareGameAsync(PluginGameContext game, CancellationToken cancellation); + /// + /// Starts the plugin's game activity after every plugin has been prepared, so resources published by other plugins + /// during preparation are available. When it completes the plugin is , and cleanup + /// later runs in reverse of the order in which plugins were started. + /// + /// Cancelled when the game is cancelled or the startup deadline expires. + Task StartGameAsync(CancellationToken cancellation); + /// + /// Called once when the table's player has started and the game is playable. If that happens while the host is still + /// starting plugins, the call is delivered once startup finishes. Only running plugins receive it. + /// + void GameReady(); + /// + /// Called when the game is paused or resumed, for example when the Player opens or closes its in-game menu. If the + /// state changes several times while the host is busy, only the latest state is delivered. Only running plugins + /// receive it. + /// + /// True when the game was paused, false when it resumed. + void PauseChanged(bool paused); + /// + /// Called when the application window gains or loses focus. If focus changes several times while the host is busy, + /// only the latest state is delivered. Only running plugins receive it. + /// + /// True when the application has focus. + void FocusChanged(bool focused); + /// + /// Ends the plugin's game activity: stop workers and playback and wait until they no longer use game resources. + /// Called when the game stops, or earlier when the plugin faults or is blocked, but only if + /// was called. Afterwards the host disposes . + /// Exceptions are logged and cleanup continues. + /// + /// Cancelled after . + Task StopGameAsync(CancellationToken cancellation); + /// + /// Releases what set up. Called after for every created + /// instance, even when initialization failed. Afterwards the host disposes + /// and then the plugin itself. Exceptions are logged and cleanup + /// continues. + /// + /// Cancelled after . + Task ShutdownAsync(CancellationToken cancellation); + } + + /// Optional hook for plugins that need a per-frame update on the owner thread. + /// + /// The Player calls it from its frame update, so it keeps running while the game is paused. B2S advances lamp and reel + /// animations here, and DMD pumps its presentation destinations. Only running plugins are ticked, and a plugin is + /// skipped while its asynchronous live reconfiguration is in progress. + /// + public interface IPluginTick + { + /// Advances the plugin by one frame. + /// Real time since the previous frame in seconds, unaffected by Unity's time scale. + void Tick(double unscaledSeconds); + } + + /// Optional hook for plugins that must update their output after all frame updates and before rendering. + /// + /// The Player calls it from LateUpdate, once per frame. B2S composes the backglass and updates its windows and + /// surfaces here, and PinUP renders its screen outputs. Only running plugins receive it. + /// + public interface IPluginRender + { + /// Updates the plugin's visual output for the coming frame. Called on the owner thread. + void BeforeRender(); + } + + /// Optional hook for stopping a plugin immediately, without awaiting anything. + /// + /// The host calls it when there is no time for an orderly stop: when the application quits, when stopping the game + /// cannot wait for a pending operation within , and when one of the plugin's + /// hooks misses its deadline. Normal and + /// may not run afterwards, but still does. + /// + public interface IPluginEmergencyStop + { + /// + /// Stops playback, workers and device output synchronously on the owner thread. Must not block or throw, and must be + /// safe to call more than once and after the plugin has already stopped. Pair it with + /// when native workers need time to finish. + /// + void EmergencyStop(); + } + + /// + /// Optional hook for plugins whose native workers may still use the plugin's resources after an emergency stop. + /// + /// + /// After , or after abandoning an unresponsive hook, the host reads + /// . Until it completes the host keeps the instance's resource scopes (native handles, device + /// leases) alive and marks the plugin , so no new instance can start against + /// the same hardware. The DMD plugin returns the completion of its native bridge shutdown here, PinUP that of its + /// media player. + /// + public interface IPluginDeferredCleanup + { + /// + /// Completes when native workers can no longer use the plugin's resources. Read after an emergency stop; return a + /// completed task when nothing is pending. A fault is logged and treated as completion. + /// + Task Quiescence { get; } + } + + /// + /// Optional hook for applying settings to a running plugin asynchronously. + /// + /// + /// When the user commits settings that change a live field for the running game, the host calls this hook before saving. + /// If the plugin also implements , that one is used instead. While the + /// call runs the plugin is skipped for tick, render and other dispatched calls. If saving fails afterwards, the host + /// calls it again with the previous settings to roll back. + /// + public interface IPluginReconfiguration + { + /// + /// Applies new settings. Reconfiguration is transactional: on failure or cancellation the previous runtime state must + /// remain intact. Called on the owner thread. + /// + /// + /// The plugin's current runtime settings with the changed live fields replaced. Fields that are not live keep their + /// values from game start. Keep this snapshot as the plugin's current settings. + /// + /// Cancelled by the caller or after . + /// + /// Success, or a failure whose the Player shows; on failure nothing is saved. + /// + Task ApplyAsync(PluginSettingsSnapshot settings, CancellationToken cancellation); + } + + /// + /// Optional hook for applying settings to a running plugin synchronously. + /// + /// + /// Preferred over when a plugin implements both. Use it for fast updates such as a + /// volume change: PinMAME sets the engine's output gain here, so the Player's volume mixer takes effect immediately. + /// + /// + /// public PluginActionResult Apply(PluginSettingsSnapshot settings) + /// { + /// _application.Scheduler.VerifyOwner(); + /// if (_engine != null) _engine.OutputGain = Mathf.Clamp(settings.Integer("Volume", 100), 0, 100) / 100f; + /// return new PluginActionResult(true, ""); + /// } + /// + /// + /// + public interface IPluginSynchronousReconfiguration + { + /// + /// Applies new settings on the owner thread. On failure the previous runtime state must remain intact. If saving fails + /// afterwards, the host calls it again with the previous settings to roll back. + /// + /// + /// The plugin's current runtime settings with the changed live fields replaced. Keep this snapshot as the plugin's + /// current settings. + /// + /// + /// Success, or a failure whose the Player shows; on failure nothing is saved. + /// + PluginActionResult Apply(PluginSettingsSnapshot settings); + } + + /// + /// Base class for plugins with no-op implementations of every hook, so a plugin overrides + /// only what it needs. The host still controls its lifetime. + /// + /// + /// All bundled plugins derive from it and add optional interfaces such as or + /// . + /// + /// A plugin that applies a volume setting to the table's PinMAME engine: + /// + /// internal sealed class VolumePlugin : PlayerPlugin, IPluginSynchronousReconfiguration + /// { + /// private readonly PluginApplicationContext _application; + /// private PinMameGamelogicEngine? _engine; + /// public VolumePlugin(PluginApplicationContext application) => _application = application; + /// + /// public override Task PrepareGameAsync(PluginGameContext game, CancellationToken cancellation) + /// { + /// var scene = game.Capabilities.Require<IUnityGameScene>(UnityPluginCapability.GameScene); + /// _engine = scene.Table.GetComponentInChildren<PinMameGamelogicEngine>(true); + /// Apply(game.Settings); + /// return Task.CompletedTask; + /// } + /// + /// public PluginActionResult Apply(PluginSettingsSnapshot settings) + /// { + /// if (_engine != null) _engine.OutputGain = settings.Integer("Volume", 100) / 100f; + /// return new PluginActionResult(true, ""); + /// } + /// } + /// + /// + /// + public abstract class PlayerPlugin : IPlayerPlugin + { + /// + /// Does nothing by default. + public virtual Task InitializeAsync(CancellationToken cancellation) => Task.CompletedTask; + /// + /// Does nothing by default. + public virtual Task PrepareGameAsync(PluginGameContext game, CancellationToken cancellation) => Task.CompletedTask; + /// + /// Does nothing by default. + public virtual Task StartGameAsync(CancellationToken cancellation) => Task.CompletedTask; + /// + /// Does nothing by default. + public virtual void GameReady() { } + /// + /// Does nothing by default. + public virtual void PauseChanged(bool paused) { } + /// + /// Does nothing by default. + public virtual void FocusChanged(bool focused) { } + /// + /// Does nothing by default. + public virtual Task StopGameAsync(CancellationToken cancellation) => Task.CompletedTask; + /// + /// Does nothing by default. + public virtual Task ShutdownAsync(CancellationToken cancellation) => Task.CompletedTask; + /// + /// Releases whatever the plugin still holds. The host calls it last, on the owner thread, also after an emergency + /// stop where and did not run, so it must release + /// everything and tolerate an already stopped plugin. Does nothing by default. + /// + public virtual void Dispose() { } + } + + /// Host services handed to a plugin instance when it is created, valid for the instance's whole life. + /// + /// The host builds it and passes it to ; plugins keep it in a field. Since the + /// host creates a new instance for every game, "application" here means the instance's life, from creation until + /// completes; game-specific services come with + /// . + /// + public sealed class PluginApplicationContext + { + /// This plugin's descriptor, for example to pass as a resource owner. + public PluginDescriptor Plugin { get; } + /// + /// The settings store. Plugins can use for their own files; the + /// DMD plugin stages colorization caches there. + /// + public PluginConfigurationStore Configuration { get; } + /// + /// The owner-thread scheduler. Await to return to the owner thread after worker + /// code, to queue work there, and to assert + /// that a call is on it. The host pumps it at the start of every , which the Player calls + /// every frame. + /// + public OwnerScheduler Scheduler { get; } + /// + /// Versioned services published by the host and by other plugins. Same instance as + /// . + /// + public PluginCapabilities Capabilities { get; } + /// + /// The registry for binding and publishing displays, controllers, surfaces and other resources. Same instance as + /// . + /// + public PluginResourceRegistry Resources { get; } + /// + /// Resources owned for the instance's whole life. The host disposes it after + /// and before the plugin itself, in reverse order of registration; for game-specific resources use + /// . + /// + public ResourceScope Lifetime { get; } + /// + /// Writes a diagnostic line, prefixed with the plugin ID. The Player sends it to the Unity log; use it for + /// conditions the user may need to see, such as a missing PUP pack. + /// + public Action Log { get; } + /// Creates the context. Called by the ; plugins only receive it. + /// The descriptor of the plugin being created. + /// The settings store. + /// The owner-thread scheduler. + /// The shared capability registry. + /// The shared resource registry. + /// The scope disposed after the instance shuts down. + /// The diagnostic sink for this plugin. + public PluginApplicationContext(PluginDescriptor plugin, PluginConfigurationStore configuration, OwnerScheduler scheduler, + PluginCapabilities capabilities, PluginResourceRegistry resources, ResourceScope lifetime, Action log) + { Plugin = plugin; Configuration = configuration; Scheduler = scheduler; Capabilities = capabilities; Resources = resources; Lifetime = lifetime; Log = log; } + } + + /// The game being started and the services scoped to it, passed to . + /// + /// It tells the plugin which game runs (, , ), how + /// the plugin is configured for it (), and how to find the game's resources + /// (, ). Keep it in a field if later hooks need it; it stays valid until + /// completes. + /// + /// Binding the game's DMD, as PinUP does: + /// + /// var selection = new PluginResourceSelection(ResourceKind.Display) { + /// Controller = game.ControllerId, Role = "dmd", Session = game.Session + /// }; + /// var dmd = game.Lifetime.Own(game.Resources.Bind<IPluginDisplaySource>(selection)); + /// + /// + /// + public sealed class PluginGameContext + { + /// The game being played, used to find matching media such as a PUP pack, backglass or colorization. + public GameIdentity Identity { get; } + /// Full path of the table package being played. The B2S plugin uses it, with the title, to match a backglass. + public string PackagePath { get; } + /// The table's title as shown in the Player. + public string Title { get; } + /// + /// ID of the game's controller, such as pinmame:t2_l8/controller/0. Select resources of this controller with + /// it, and use it as the prefix of IDs of resources the plugin publishes for this game. + /// + public string ControllerId { get; } + /// + /// Number of the game session, increasing with every game the Player starts. Pass it in resource selections and + /// descriptors so they never match resources of a previous game. + /// + public long Session { get; } + /// + /// The plugin's effective settings for this game when it was prepared: defaults, global values and the game's + /// overrides merged. It does not change afterwards; live changes arrive through + /// or . + /// + public PluginSettingsSnapshot Settings { get; } + /// + /// Versioned services, including those the Player publishes for the game such as the table scene. Same instance as + /// . + /// + public PluginCapabilities Capabilities { get; } + /// + /// The registry for binding and publishing resources. Same instance as + /// . + /// + public PluginResourceRegistry Resources { get; } + /// + /// Resources owned for this game, such as bindings, subscriptions and created game objects. The host disposes it + /// right after , in reverse order of registration. + /// + public ResourceScope Lifetime { get; } + /// + /// Cancelled when the game stops or its startup is cancelled. Use it for background work tied to the game; it is + /// not the per-hook deadline token. + /// + public CancellationToken Cancellation { get; } + /// Creates the context. Called by the ; plugins only receive it. + /// The game being played. + /// Full path of the table package. + /// The table's title. + /// ID of the game's controller. + /// The game session number. + /// The plugin's effective settings for the game. + /// The shared capability registry. + /// The shared resource registry. + /// The scope disposed after the game stops. + /// The game's cancellation token. + public PluginGameContext(GameIdentity identity, string packagePath, string title, string controllerId, long session, + PluginSettingsSnapshot settings, PluginCapabilities capabilities, PluginResourceRegistry resources, ResourceScope lifetime, CancellationToken cancellation) + { Identity = identity; PackagePath = packagePath; Title = title; ControllerId = controllerId; Session = session; Settings = settings; Capabilities = capabilities; Resources = resources; Lifetime = lifetime; Cancellation = cancellation; } + } + + /// Global list of installed plugin factories, which the Player hands to its . + /// + /// Integration packages register their factory once at startup, from a + /// [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)] method. The Player creates its + /// host from afterwards, and the host copies the list, so later registrations are not seen by + /// an existing host. The registry is not thread-safe: register on the main thread. + /// + public static class PlayerPluginRegistry + { + private static readonly Dictionary Factories = new Dictionary(StringComparer.Ordinal); + /// The registered factories, as a live view in no particular order. + public static IReadOnlyCollection Installed => Factories.Values; + /// + /// Removes all registered factories. VPE calls this when the scripting domain starts, so factories registered + /// by a previous play session in the editor do not linger; plugins have no reason to call it. + /// + public static void Reset() => Factories.Clear(); + /// Adds a factory under its . + /// The factory to install. + /// + /// + /// + /// [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)] + /// private static void Register() => PlayerPluginRegistry.Register(new PinUpPluginFactory()); + /// + /// + /// + /// + /// The factory's differs from , + /// or a factory with the same ID is already registered. + /// + public static void Register(IPlayerPluginFactory factory) + { + if (factory.Descriptor.ApiVersion != PluginDescriptor.HostApiVersion) throw new InvalidOperationException("Plugin API version is incompatible: " + factory.Descriptor.Id); + if (Factories.ContainsKey(factory.Descriptor.Id)) throw new InvalidOperationException("Duplicate plugin ID: " + factory.Descriptor.Id); + Factories.Add(factory.Descriptor.Id, factory); + } + } +} diff --git a/VisualPinball.Plugins/Api/PluginRuntime.cs.meta b/VisualPinball.Plugins/Api/PluginRuntime.cs.meta new file mode 100644 index 000000000..c85d62899 --- /dev/null +++ b/VisualPinball.Plugins/Api/PluginRuntime.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: ff27c3a59a165c14784ea0e1c17a9c20 \ No newline at end of file diff --git a/VisualPinball.Plugins/Configuration.meta b/VisualPinball.Plugins/Configuration.meta new file mode 100644 index 000000000..692572554 --- /dev/null +++ b/VisualPinball.Plugins/Configuration.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 6b4415fc3ad430744adc70ce2443a8f1 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Plugins/Configuration/PluginConfigurationStore.cs b/VisualPinball.Plugins/Configuration/PluginConfigurationStore.cs new file mode 100644 index 000000000..ad12f0acb --- /dev/null +++ b/VisualPinball.Plugins/Configuration/PluginConfigurationStore.cs @@ -0,0 +1,312 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// SPDX-License-Identifier: MIT +// Licensed under MIT. See https://opensource.org/license/mit. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace VisualPinball.Plugins +{ + /// One plugin owns one folder; game overrides contain only explicitly changed values. + /// + /// The store keeps every plugin's saved settings and enablement as JSON files below : + /// + /// <plugin id>/settings.json: the global values. + /// <plugin id>/games/<hash>.json: one game's overrides, named by of the + /// . + /// <plugin id>/policy.json: whether the plugin is enabled, globally and per game. + /// + /// Each settings file holds schemaVersion, values and, for game files, the game it belongs to. Values the + /// current schema does not know are kept, so a newer plugin version can still find them. + /// + /// The Player creates one store at <user config directory>/plugins and hands it to every plugin as + /// . The host and the settings page read the effective values from it before a + /// game starts and write edits back through it. Plugins rarely need more than : the B2S plugin and the DMD + /// plugin keep their decoded caches under their own folder. + /// + /// + /// Reading is lock-free and may happen on any thread. Writes are atomic (a temporary file replaces the old one), and + /// and are serialized per store instance. Because each call touches the file system, the + /// host and the Player call these methods on worker threads. + /// + /// + /// A plugin keeping a cache next to its settings: + /// + /// string cache = Path.Combine(context.Configuration.DirectoryFor(context.Plugin), "cache", "pictures"); + /// + /// + /// + public sealed class PluginConfigurationStore + { + private readonly object _writes = new object(); + /// The absolute directory that holds one folder per plugin. + public string Root { get; } + /// Creates a store rooted at a directory. Nothing is created on disk until the first write. + /// The configuration directory, made absolute with . + public PluginConfigurationStore(string root) { Root = Path.GetFullPath(root); } + /// + /// The folder that belongs to a plugin, Root/<plugin id>. It may not exist yet. Plugins may keep their own files in + /// it, such as caches, as long as they avoid settings.json, policy.json and games/. + /// + /// The plugin whose folder to return. + public string DirectoryFor(PluginDescriptor plugin) => Path.Combine(Root, plugin.Id); + /// + /// The path of a plugin's global settings file, or of one game's override file when is given. The file may + /// not exist yet. + /// + /// The plugin whose settings file to return. + /// The game whose overrides to locate, or null for the global settings. + public string SettingsPath(PluginDescriptor plugin, GameIdentity? game = null) => game.HasValue + ? Path.Combine(DirectoryFor(plugin), "games", Hash(game.Value.Id) + ".json") : Path.Combine(DirectoryFor(plugin), "settings.json"); + /// + /// Reads one stored layer as it is saved: the global values, or only the overrides of one game. Defaults and the other layer are + /// not applied; use for the values a plugin runs with. + /// + /// + /// A missing file returns an empty snapshot at the schema's version. Stored values are upgraded with + /// ; a file written by a newer schema is returned unchanged with its newer + /// , which callers can compare with . + /// + /// The plugin whose settings to read. + /// The plugin's schema, used to upgrade older files. + /// The game whose overrides to read, or null for the global values. + /// A new, detached snapshot the caller may modify. + /// The game file names a different game. + /// The file is not valid JSON. + /// An upgrade step is missing or failed. + public PluginSettingsSnapshot Read(PluginDescriptor plugin, PluginSettingsSchema schema, GameIdentity? game = null) + { + var document = ReadDocument(SettingsPath(plugin, game)); + if (game.HasValue && document["game"] != null && document.Value("game") != game.Value.Id) + throw new InvalidDataException("The saved profile belongs to another game."); + return schema.Upgrade(new PluginSettingsSnapshot(document.Value("schemaVersion") ?? schema.Version, (JObject?)document["values"]?.DeepClone() ?? new JObject())); + } + /// + /// Reads the values a plugin runs with: schema defaults, then the saved global values, then the game's overrides when + /// is given (see ). + /// + /// + /// The host calls this when it prepares a game and passes the result to the plugin as . + /// The volume mixer calls it without a game to show global values. + /// + /// The plugin whose settings to read. + /// The plugin's schema. + /// The game being played or configured, or null for the global values only. + /// A new, detached snapshot. + /// The game file names a different game. + /// A file is not valid JSON. + public PluginSettingsSnapshot Effective(PluginDescriptor plugin, PluginSettingsSchema schema, GameIdentity? game = null) + { + var global = Read(plugin, schema); + return Resolve(schema, global, game.HasValue ? Read(plugin, schema, game) : null); + } + /// Known fields are restricted to their declared scope; unknown values remain available for compatible upgrades. + /// + /// Combines schema defaults, the global layer and the optional game layer into effective values, without touching the disk. Before + /// merging, fields are removed from the global layer and + /// fields from the game layer, so a stray value in the wrong file has no effect. Later + /// layers replace whole top-level values; missing defaults inside composite and collection records are filled in afterwards. + /// The settings session uses this to preview a draft that is not saved yet. + /// + /// The plugin's schema, which supplies defaults and scopes. + /// The global values, as returned by without a game. + /// The game's overrides, or null to resolve global values only. + /// A new snapshot at . + public static PluginSettingsSnapshot Resolve(PluginSettingsSchema schema, PluginSettingsSnapshot global, PluginSettingsSnapshot? game = null) + { + PluginSettingsSnapshot Scoped(PluginSettingsSnapshot values, bool gameScope) + { + var scoped = values.Copy(); + foreach (var field in schema.Fields) + if (gameScope ? field.Scope == ConfigurationScope.GlobalOnly : field.Scope == ConfigurationScope.GameOnly) scoped.Values.Remove(field.Id); + return scoped; + } + var resolved = game == null ? PluginSettingsSnapshot.Merge(schema.Defaults(), Scoped(global, false)) + : PluginSettingsSnapshot.Merge(schema.Defaults(), Scoped(global, false), Scoped(game, true)); + PluginSettingsSchema.FillRecordDefaults(schema.Fields, resolved.Values); + return resolved; + } + /// + /// Replaces one stored layer with : every field allowed in that layer takes the value from the snapshot, + /// or is removed from the file when the snapshot has none (for a game layer, removing means "inherit the global value"). + /// + /// + /// Fields outside the layer's scope, and , and + /// fields, are never written. Stored values the schema does not know are kept. The values are + /// not validated here; the host and the settings session validate before they save. To change only some fields without + /// overwriting concurrent edits to others, use . + /// + /// The plugin whose settings to write. + /// The plugin's schema. + /// The complete layer to store. + /// The game whose overrides to write, or null for the global values. + /// The file was written by a newer schema version and is left untouched. + /// The game file names a different game. + /// The file could not be written. + public void Save(PluginDescriptor plugin, PluginSettingsSchema schema, PluginSettingsSnapshot values, GameIdentity? game = null) + { + lock (_writes) SaveCore(plugin, schema, values, game); + } + private void SaveCore(PluginDescriptor plugin, PluginSettingsSchema schema, PluginSettingsSnapshot values, GameIdentity? game) + { + string path = SettingsPath(plugin, game); + var document = ReadDocument(path); + if ((document.Value("schemaVersion") ?? schema.Version) > schema.Version) + throw new InvalidOperationException("This configuration was written by a newer plugin. It is preserved and cannot be overwritten."); + var stored = Read(plugin, schema, game).Values; + foreach (var field in schema.Fields) { + bool allowed = game.HasValue ? field.Scope != ConfigurationScope.GlobalOnly : field.Scope != ConfigurationScope.GameOnly; + if (!allowed || field.Kind == SettingKind.Action || field.Kind == SettingKind.Status || field.Kind == SettingKind.Preview) continue; + if (values.Values.TryGetValue(field.Id, out var value)) stored[field.Id] = value.DeepClone(); + else stored.Remove(field.Id); + } + document["schemaVersion"] = schema.Version; + document["values"] = stored; + if (game.HasValue) document["game"] = game.Value.Id; + WriteDocument(path, document); + } + /// Merges a scoped edit into the latest profile without replacing unrelated fields. + /// + /// Reads the stored layer and copies over only the fields named in that are allowed in that layer and + /// hold a value (not , or ). A + /// changed field that does not contain is removed, which resets it to the inherited value. Nothing is + /// written; the host uses the result to validate an edit before stores it. + /// + /// The plugin whose settings to read. + /// The plugin's schema. + /// The edited values; only the fields in are taken from it. + /// IDs of the top-level fields the user changed. + /// The game whose overrides to merge into, or null for the global values. + /// The stored layer with the edit applied, as a new snapshot. + /// The stored layer was written by a newer schema version. + public PluginSettingsSnapshot MergePatch(PluginDescriptor plugin, PluginSettingsSchema schema, PluginSettingsSnapshot values, IReadOnlyCollection changed, GameIdentity? game = null) + { + var latest = Read(plugin, schema, game); + if (latest.SchemaVersion > schema.Version) throw new InvalidOperationException("This configuration was written by a newer plugin and cannot be overwritten."); + foreach (var field in schema.Fields) { + if (!changed.Contains(field.Id) || (game.HasValue ? field.Scope == ConfigurationScope.GlobalOnly : field.Scope == ConfigurationScope.GameOnly) + || field.Kind == SettingKind.Action || field.Kind == SettingKind.Status || field.Kind == SettingKind.Preview) continue; + latest.Set(field.Id, values.Value(field.Id)); + } + return latest; + } + /// + /// Saves only the fields named in into the latest stored layer, leaving every other stored value as it + /// is on disk. The read and the write happen under the store's write lock, so two edits of different fields do not overwrite each + /// other. + /// + /// + /// This is how the Player saves: the settings page saves the fields the user edited, and the volume mixer saves one field. See + /// for which fields are taken. + /// + /// The plugin whose settings to write. + /// The plugin's schema. + /// The edited values. + /// IDs of the top-level fields to save. + /// The game whose overrides to write, or null for the global values. + /// The stored layer was written by a newer schema version. + /// The file could not be written. + public void SavePatch(PluginDescriptor plugin, PluginSettingsSchema schema, PluginSettingsSnapshot values, IReadOnlyCollection changed, GameIdentity? game = null) + { + lock (_writes) SaveCore(plugin, schema, MergePatch(plugin, schema, values, changed, game), game); + } + /// + /// Whether the plugin should run, globally or for one game. A plugin with false is always + /// enabled. Otherwise a per-game choice wins, then the global choice, then . + /// + /// + /// The host checks this for every plugin when it selects the plugins for a game; the Player's settings page shows it as the + /// "Enabled by default" or "Enabled for this game" toggle. + /// + /// The plugin to check. + /// The game to check, or null for the global choice. + /// policy.json is not valid JSON. + public bool Enabled(PluginDescriptor plugin, GameIdentity? game = null) + { + if (!plugin.CanDisable) return true; + var policy = ReadDocument(Path.Combine(DirectoryFor(plugin), "policy.json")); + if (game.HasValue && policy["games"] is JObject games && games[game.Value.Id]?.Type == JTokenType.Boolean) return games.Value(game.Value.Id); + return policy.Value("enabled") ?? plugin.EnabledByDefault; + } + /// + /// Saves whether a plugin is enabled, globally or for one game, in its policy.json. The change takes effect on the next game. + /// + /// + /// For a plugin with false nothing is written, and asking to disable it throws. + /// Unlike , this does not take the store's write lock. + /// + /// The plugin to change. + /// True or false to enable or disable; null removes the choice, so the game follows the global choice and the + /// global choice follows . + /// The game to change, or null for the global choice. + /// is false for a plugin that cannot be disabled. + public void SetEnabled(PluginDescriptor plugin, bool? enabled, GameIdentity? game = null) + { + if (!plugin.CanDisable) { + if (enabled == false) throw new InvalidOperationException("This configuration service cannot be disabled."); + return; + } + string path = Path.Combine(DirectoryFor(plugin), "policy.json"); + var policy = ReadDocument(path); + if (game.HasValue) { + var games = policy["games"] as JObject ?? new JObject(); + if (enabled.HasValue) games[game.Value.Id] = enabled.Value; else games.Remove(game.Value.Id); + policy["games"] = games; + } else if (enabled.HasValue) policy["enabled"] = enabled.Value; else policy.Remove("enabled"); + WriteDocument(path, policy); + } + /// + /// The per-game enablement saved for , or null when the game follows the global choice. Use it to tell + /// whether a game overrides the global policy; gives the resulting decision. Does not consider + /// . + /// + /// The plugin to check. + /// The game to check. + public bool? EnableOverride(PluginDescriptor plugin, GameIdentity game) => ReadDocument(Path.Combine(DirectoryFor(plugin), "policy.json"))["games"]?[game.Id]?.Value(); + + internal static JObject ReadDocument(string path) + { + if (!File.Exists(path)) return new JObject(); + using var stream = new StreamReader(new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete)); + using var reader = new JsonTextReader(stream) { DateParseHandling = DateParseHandling.None, MaxDepth = 64, FloatParseHandling = FloatParseHandling.Double }; + return JObject.Load(reader); + } + internal static void WriteDocument(string path, JObject document) + { + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + string pending = path + "." + Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture) + ".tmp"; + try { + using (var file = new StreamWriter(pending, false, new UTF8Encoding(false))) + using (var writer = new JsonTextWriter(file) { Formatting = Formatting.Indented }) document.WriteTo(writer); + for (int attempt = 0; ; attempt++) { + try { if (File.Exists(path)) File.Replace(pending, path, null); else File.Move(pending, path); break; } + catch (IOException error) when (attempt < 3 && ((error.HResult & 0xffff) == 32 || (error.HResult & 0xffff) == 33)) { Thread.Sleep(20 * (attempt + 1)); } + } + } finally { if (File.Exists(pending)) File.Delete(pending); } + } + /// + /// Turns a game identity into a file name: the lowercase hexadecimal SHA-256 of its UTF-8 bytes. Used for the per-game settings + /// files, so any identity yields a safe, fixed-length name. + /// + /// The text to hash, normally a such as pinmame:t2_l8. + /// 64 lowercase hexadecimal characters. + public static string Hash(string identity) + { + using var sha = SHA256.Create(); + var bytes = sha.ComputeHash(Encoding.UTF8.GetBytes(identity)); + var result = new StringBuilder(bytes.Length * 2); + foreach (byte value in bytes) result.Append(value.ToString("x2", CultureInfo.InvariantCulture)); + return result.ToString(); + } + } +} diff --git a/VisualPinball.Plugins/Configuration/PluginConfigurationStore.cs.meta b/VisualPinball.Plugins/Configuration/PluginConfigurationStore.cs.meta new file mode 100644 index 000000000..78ded1e00 --- /dev/null +++ b/VisualPinball.Plugins/Configuration/PluginConfigurationStore.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 15f5d564abf8bd0428332a46d3435ea9 \ No newline at end of file diff --git a/VisualPinball.Plugins/Configuration/PluginGlobalSetting.cs b/VisualPinball.Plugins/Configuration/PluginGlobalSetting.cs new file mode 100644 index 000000000..c5c536558 --- /dev/null +++ b/VisualPinball.Plugins/Configuration/PluginGlobalSetting.cs @@ -0,0 +1,156 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// SPDX-License-Identifier: MIT +// Licensed under MIT. See https://opensource.org/license/mit. + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace VisualPinball.Plugins +{ + /// + /// How the Player draws the editor of a numeric field ( or ). + /// + /// + /// Set through . Other kinds ignore it. Volume fields shown on the global volume mixer use + /// , as the PinMAME and PinUP volume settings do. + /// + public enum PluginFieldPresentation + { + /// A plain number box. This is the default. + Input, + /// + /// A slider with step buttons between and , with the value and + /// printed next to it. Give the field both bounds when you use it. + /// + Slider + } + /// + /// Identifiers of the shared Player pages that plugin fields can appear on through . + /// + /// + /// A shared page collects one kind of setting from every installed plugin, so the user can adjust them in one place instead of + /// visiting each plugin's settings page. + /// + public static class PluginGlobalSections + { + /// + /// The Player's global volume mixer, which lists the table's mechanical sounds next to every plugin's volume. Fields exposed here + /// must be or with finite and + /// (minimum below maximum); skips any other field + /// with a diagnostic. PinMAME's ROM volume and PinUP's media volume appear here. + /// + public const string VolumeMixer = "audio.mixer"; + } + /// + /// Asks the Player to also show a plugin field on a shared page, such as the global volume mixer, in addition to the plugin's own + /// settings page. + /// + /// + /// The value is not copied: the shared page reads and writes the same global value of the same field in the plugin's own + /// configuration (see ). Only the global value is shown there, so the field must not use + /// . Combine it with so that moving the mixer slider + /// is heard immediately in a running game. + /// + /// The PinMAME integration puts its ROM volume on the mixer: + /// + /// new PluginField("Volume", "Volume", SettingKind.Integer) { + /// Default = 100, Minimum = 0, Maximum = 100, Unit = "%", + /// Change = ConfigurationChange.Live, Presentation = PluginFieldPresentation.Slider, + /// GlobalSetting = new PluginGlobalSetting(PluginGlobalSections.VolumeMixer) { Order = 20 } + /// } + /// + /// + /// + public sealed class PluginGlobalSetting + { + /// The shared page the field appears on, one of the constants. + public string Section { get; } + /// + /// Label shown on the shared page. Empty (the default) uses the plugin's , which reads better + /// on the mixer than a generic field label such as "Volume". + /// + public string Label { get; set; } = ""; + /// + /// Sort key on the shared page; lower values come first, ties are sorted by . Defaults + /// to 0. PinMAME uses 20 and PinUP 30. + /// + public int Order { get; set; } + /// Creates an exposure on the given shared page. + /// One of the constants. + /// is null. + public PluginGlobalSetting(string section) => Section = section ?? throw new ArgumentNullException(nameof(section)); + } + /// An alias identifies a field in its owning factory, never a second copy of its value. + /// + /// One binding connects an entry on a shared Player page to the plugin field that declared a . + /// The Player's volume mixer builds its sliders from , reads each value from the plugin's effective global + /// settings, and writes edits back with , which saves the plugin's global value + /// and, for fields, applies it to the running plugin. Plugins never create bindings; they only + /// declare . + /// + public sealed class PluginGlobalSettingBinding + { + /// The plugin that owns and stores the field. + public IPlayerPluginFactory Factory { get; } + /// The exposed field. Its is never null. + public PluginField Field { get; } + /// + /// Stable key of this entry, pluginId/fieldId (for example org.visualpinball.pinup/Volume). Unique across plugins; + /// the Player keys its pending writes and controls by it. + /// + public string Id => Factory.Descriptor.Id + "/" + Field.Id; + /// + /// Text shown on the shared page: , or the plugin's + /// when that is empty. + /// + public string Label => Field.GlobalSetting!.Label.Length == 0 ? Factory.Descriptor.Name : Field.GlobalSetting.Label; + internal PluginGlobalSettingBinding(IPlayerPluginFactory factory, PluginField field) { Factory = factory; Field = field; } + /// + /// Finds every field the given plugins expose on a shared page, in display order. + /// + /// + /// Plugins that do not run on are skipped. A field is included only when it is visible under the + /// plugin's effective global settings (see ). A field with , + /// or a field on that is not a bounded number, is reported to + /// and skipped. A plugin whose saved settings cannot be read is reported and skipped as well, so one + /// broken file does not empty the page. The result is sorted by , then by + /// (ordinal). + /// + /// This reads configuration files, so the Player runs it on a worker thread. + /// + /// + /// The installed plugin factories, for example . + /// The store the plugins' settings are read from. + /// + /// The current platform ID (windows, macos, linux), matched against . + /// + /// The shared page, one of the constants. + /// Optional receiver of messages about skipped plugins and invalid exposures. + /// The bindings to show, possibly empty. + public static IReadOnlyList Discover(IEnumerable factories, PluginConfigurationStore store, string platform, string section, Action? diagnostic = null) + { + var bindings = new List(); + foreach (var factory in factories) { + if (factory.Descriptor.Platforms.Count > 0 && !factory.Descriptor.Platforms.Contains(platform)) continue; + var fields = factory.Schema.Fields.Where(field => field.GlobalSetting?.Section == section).ToArray(); + if (fields.Length == 0) continue; + PluginSettingsSnapshot effective; + try { effective = store.Effective(factory.Descriptor, factory.Schema); } + catch (Exception error) { diagnostic?.Invoke(factory.Descriptor.Name + ": " + error.Message); continue; } + foreach (var field in fields) { + if (!field.Visible(effective)) continue; + if (field.Scope == ConfigurationScope.GameOnly || section == PluginGlobalSections.VolumeMixer && !BoundedNumber(field)) { + diagnostic?.Invoke("Invalid global setting exposure: " + factory.Descriptor.Id + "/" + field.Id); continue; + } + bindings.Add(new PluginGlobalSettingBinding(factory, field)); + } + } + return bindings.OrderBy(binding => binding.Field.GlobalSetting!.Order).ThenBy(binding => binding.Id, StringComparer.Ordinal).ToArray(); + } + private static bool BoundedNumber(PluginField field) => (field.Kind == SettingKind.Integer || field.Kind == SettingKind.Number) + && field.Minimum.HasValue && field.Maximum.HasValue && double.IsFinite(field.Minimum.Value) && double.IsFinite(field.Maximum.Value) && field.Minimum < field.Maximum; + } +} diff --git a/VisualPinball.Plugins/Configuration/PluginGlobalSetting.cs.meta b/VisualPinball.Plugins/Configuration/PluginGlobalSetting.cs.meta new file mode 100644 index 000000000..9eaabc950 --- /dev/null +++ b/VisualPinball.Plugins/Configuration/PluginGlobalSetting.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 2b444ea492aa2e146943444dafa74ead \ No newline at end of file diff --git a/VisualPinball.Plugins/Configuration/PluginSchema.cs b/VisualPinball.Plugins/Configuration/PluginSchema.cs new file mode 100644 index 000000000..752d73604 --- /dev/null +++ b/VisualPinball.Plugins/Configuration/PluginSchema.cs @@ -0,0 +1,775 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// SPDX-License-Identifier: MIT +// Licensed under MIT. See https://opensource.org/license/mit. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json.Linq; + +namespace VisualPinball.Plugins +{ + /// + /// The type of a : which JSON value it stores, how checks it, and + /// which editor the Player shows for it. + /// + /// + /// , and store nothing: they are never validated or saved. + /// + public enum SettingKind + { + /// A JSON true or false, shown as a toggle. + Boolean, + /// + /// A JSON integer, shown as a number box or, with , a slider. Checked against + /// and . + /// + Integer, + /// + /// A JSON number (integer or floating point), shown like and checked against the same bounds. NaN and + /// infinity are rejected. + /// + Number, + /// A JSON string, shown as a text box. + Text, + /// A directory path stored as a JSON string, shown as a text box with a folder picker. + Folder, + /// A file path stored as a JSON string, shown as a text box with a file picker. + File, + /// + /// The of one item from the catalog named by , stored as a JSON + /// string. The Player asks for the catalog, shows up to five choices as buttons + /// and more as a dropdown, and refuses to apply a selection that the catalog marks unavailable or, when the catalog is + /// , no longer lists. + /// + Choice, + /// + /// A color stored as a JSON string, such as #ff8000 as used by the DMD plugin. Only checked to be text; the Player edits it + /// as text. + /// + Color, + /// + /// A JSON object with numeric x, y, width and height, where width and height are positive. The B2S + /// plugin uses it for the DMD area on a backglass. + /// + Rectangle, + /// + /// A JSON array. With , each item is a record (JSON object) of those fields, validated like a + /// ; without children, the Player edits the items as text. The B2S plugin uses it for its list of + /// backglass folders. + /// + Collection, + /// Read-only text showing the plugin's current runtime status message. Stores nothing. + Status, + /// + /// A button that runs with as the action + /// name, such as PinUP's "Find installed media libraries". Stores nothing; the action may return values for the draft. + /// + Action, + /// A JSON object whose properties are the fields, edited and validated as one record. + Composite, + /// + /// A JSON array of window placements, each an object with a unique screen identifier (string or integer) and integer + /// x, y, width and height in desktop pixels (sizes from 1 to 16384). The Player shows a placement + /// editor that opens a window per screen; the screen identifiers are checked against the catalog named by + /// . PinUP and B2S use it for their output windows. + /// + ScreenLayout, + /// + /// A live preview area. The Player opens it from the plugin's Unity preview provider with as the + /// preview name, such as PinUP's pack preview. Stores nothing. + /// + Preview + } + + /// + /// One setting a plugin declares: its stored key, its type, how the Player presents it, where it can be configured and when a + /// change takes effect. + /// + /// + /// Fields are declared once, in the plugin factory's . The Player builds the plugin's + /// settings page from them, the store saves their values under , and the plugin reads the effective values from + /// when a game is prepared. Set the optional properties in an object initializer and do + /// not change a field after the schema is handed to the host. + /// + /// Fields from the B2S plugin: a game-only choice, a folder list, and two integers that share a row and only show for one mode: + /// + /// new PluginField("Backglass", "Backglass", SettingKind.Choice) { + /// Default = "auto", Scope = ConfigurationScope.GameOnly, Provider = "backglasses" + /// }, + /// new PluginField("Folders", "Backglass folders", SettingKind.Collection) { + /// Default = new JArray(), FullWidth = true, + /// Children = new[] { new PluginField("Path", "Folder", SettingKind.Folder) { Default = "" } } + /// }, + /// new PluginField("FadeOn", "Fade in (ms)", SettingKind.Integer) { + /// Default = 20, Minimum = 0, Maximum = 1000, Row = "fade", VisibleWhen = "LampResponse", VisibleValue = "smooth" + /// }, + /// new PluginField("FadeOff", "Fade out (ms)", SettingKind.Integer) { + /// Default = 40, Minimum = 0, Maximum = 1000, Row = "fade", VisibleWhen = "LampResponse", VisibleValue = "smooth" + /// }, + /// + /// + /// + public sealed class PluginField + { + /// + /// The key the value is stored under. Unique among a schema's top-level fields, or among the of one field. + /// Keep it stable: renaming it loses saved values unless a step moves them. + /// + public string Id { get; } + /// The name shown next to the editor; for an field, the button text. + public string Label { get; } + /// The value type and editor of the field. + public SettingKind Kind { get; } + /// Help text shown below the editor. Empty (the default) shows none. + public string Description { get; set; } = ""; + /// Places the label above the editor for long values; ordinary fields render inline. + /// + /// Collection, composite, screen layout and preview fields always use the full width. Paths are a typical use, such as PinUP's + /// PUPVideos library folder. + /// + public bool FullWidth { get; set; } + /// Consecutive fields with the same non-empty hint share a row. The Player owns their sizing and appearance. + /// The B2S plugin puts its fade-in and fade-out times on one row with Row = "fade". + public string Row { get; set; } = ""; + /// + /// The editor style for and fields. Defaults to a number box. + /// + public PluginFieldPresentation Presentation { get; set; } + /// + /// Unit text appended to the value shown by a , such as %. Empty by default. + /// Put units of other fields in the . + /// + public string Unit { get; set; } = ""; + /// Opt-in appearance on a shared Player page; storage still belongs to this plugin. + /// Null (the default) shows the field only on the plugin's own page. See . + public PluginGlobalSetting? GlobalSetting { get; set; } + /// + /// Where the field can be set: only globally (the default), only per game, or globally with optional per-game overrides. The store + /// ignores a value saved in a layer the scope does not allow. + /// + public ConfigurationScope Scope { get; set; } = ConfigurationScope.GlobalOnly; + /// + /// When an applied edit reaches the plugin. Defaults to : the edit is saved and the + /// plugin sees it in when the next game is prepared. With + /// , when the plugin is running the game the edit applies to, the host first passes the new + /// values to it through or and only saves them + /// if that succeeds. + /// + public ConfigurationChange Change { get; set; } = ConfigurationChange.NextGame; + /// + /// The value used when nothing is saved, in the JSON shape of . Null means no default: the value is absent and + /// the typed accessors return their fallback. Defaults of fill missing + /// properties inside composite and collection records. + /// + public JToken? Default { get; set; } + /// + /// Inclusive lower bound for and values, or null for none. Also + /// the low end of a slider. + /// + public double? Minimum { get; set; } + /// + /// Inclusive upper bound for and values, or null for none. Also + /// the high end of a slider. + /// + public double? Maximum { get; set; } + /// + /// The name the Player passes to the plugin's providers for this field: the catalog name for + /// ( and ), + /// the action name for (), or the preview name + /// (). Empty for other kinds. The names are private to the plugin, such as PinUP's + /// packs and presets catalogs. + /// + public string Provider { get; set; } = ""; + /// + /// For fields, lets the user pick any number of the catalog's items instead of exactly one. The + /// Player shows every item as a button that toggles on and off, and the value is a JSON array of s, + /// empty when none is picked. Read it with . Not supported inside composite or + /// collection records. False by default. + /// + /// + /// The B2S plugin lets the user show the backglass in a window, on the table's backglass, or both: + /// + /// new PluginField("Output", "Output", SettingKind.Choice) { Provider = "outputs", Multiple = true, Default = new JArray("native") } + /// + /// + public bool Multiple { get; set; } + /// + /// The of another field that controls whether this one is shown and validated; empty (the default) always shows + /// it. Inside a composite or collection record it names a sibling in the same record. A hidden field keeps its saved value. + /// See . + /// + public string VisibleWhen { get; set; } = ""; + /// + /// The value must hold for this field to show, compared with . + /// Null (the default) means true, which suits a boolean toggle. + /// + public JToken? VisibleValue { get; set; } + /// + /// The fields of one record, for and fields. Empty by + /// default. Inside records the Player uses plain editors (no sliders, screen layouts or previews), and , + /// and only apply to top-level fields. + /// + public IReadOnlyList Children { get; set; } = Array.Empty(); + /// + /// Optional extra check of this field's value. It receives the raw value (null when absent) and returns an error message to show, + /// or null when the value is valid. When it returns a message, the built-in checks for are skipped. + /// + /// + /// It runs during every , including on the Player's main thread, so keep it fast and + /// free of side effects. Checks that need other fields or the file system belong in . + /// + /// + /// new PluginField("Name", "Name", SettingKind.Text) { + /// Validate = value => value?.ToString().Length > 32 ? "Use at most 32 characters." : null + /// } + /// + /// + /// + public Func? Validate { get; set; } + /// Creates a field. Set the optional properties with an object initializer. + /// The stable storage key; see . + /// The name shown to the user. + /// The value type and editor. + public PluginField(string id, string label, SettingKind kind) { Id = id; Label = label; Kind = kind; } + /// + /// Whether the field is shown and validated under the given values: true when is empty or the controlling + /// field's value equals (or true). A controlling field without a value hides the field. + /// + /// The values to test, normally the effective settings or, inside a record, that record's values. + public bool Visible(PluginSettingsSnapshot snapshot) => VisibleWhen.Length == 0 || JToken.DeepEquals(snapshot.Value(VisibleWhen), VisibleValue ?? new JValue(true)); + } + + /// A titled section of a plugin's settings page that groups related fields. + /// + /// Groups only affect presentation; field IDs must be unique across all groups of a schema. The Player hides a group when none of + /// its fields is visible in the scope being edited, for example a group of global-only fields while a game is selected. + /// + public sealed class PluginSettingsGroup + { + /// The section heading. + public string Title { get; } + /// Help text shown below the heading, or empty for none. + public string Description { get; } + /// The fields in display order. + public IReadOnlyList Fields { get; } + /// Creates a group. + /// The section heading. + /// Help text below the heading; pass an empty string for none. + /// The fields in display order. The array is used as is, not copied. + public PluginSettingsGroup(string title, string description, params PluginField[] fields) { Title = title; Description = description; Fields = fields; } + } + + /// A problem found in a settings value, reported to the user before settings are applied or a game starts. + /// + /// Returned by and . When the user applies + /// settings, issues block saving and are shown as field: message; when the host prepares a game, issues keep the plugin from + /// starting for that game. + /// + public readonly struct PluginValidationIssue + { + /// + /// The of the offending field. Choice checks inside records use a path such as + /// AnimationSlowdowns[0].Animation. + /// + public string Field { get; } + /// A short message for the user that says what to fix. + public string Message { get; } + /// Creates an issue. + /// The ID of the offending field. + /// The message shown to the user. + public PluginValidationIssue(string field, string message) { Field = field; Message = message; } + } + + /// + /// The complete description of a plugin's settings: its fields, grouped for the settings page, and the version of their stored + /// layout. + /// + /// + /// Every plugin factory exposes one schema as . The Player builds the plugin's settings + /// page from it, uses it to resolve defaults and scopes and to upgrade old files, and the host + /// validates the effective values with it before the plugin starts a game. Build it once, typically in a static method of the + /// factory, and do not change it afterwards. + /// + /// When the stored layout changes (a field is renamed, split or changes its JSON shape), increase and add a + /// step from the previous version. Adding a field with a default needs no new version. + /// + /// + /// A small schema, as the PinMAME integration declares it: + /// + /// public PluginSettingsSchema Schema { get; } = new PluginSettingsSchema(1, + /// new PluginSettingsGroup("ROM audio", "Controls PinMAME playback without changing ROM settings.", + /// new PluginField("Volume", "Volume", SettingKind.Integer) { + /// Default = 100, Minimum = 0, Maximum = 100, Unit = "%", Change = ConfigurationChange.Live, + /// Presentation = PluginFieldPresentation.Slider, + /// GlobalSetting = new PluginGlobalSetting(PluginGlobalSections.VolumeMixer) { Order = 20 } + /// })); + /// + /// + /// + public sealed class PluginSettingsSchema + { + /// The version of the stored layout, 1 or higher. Saved with every settings file and used to pick migrations. + public int Version { get; } + /// The groups of the settings page, in display order. + public IReadOnlyList Groups { get; } + /// Each entry upgrades a detached values tree from its key to the next version. + /// + /// The entry for key n turns values saved by version n into values for version n + 1. It may modify and + /// return the tree it receives, which is a copy, and must not return null. Upgrades happen when settings are read; the upgraded + /// values are only written back when the user next saves. Empty by default. + /// + /// Renaming a field in version 2: + /// + /// Migrations = new Dictionary<int, Func<JObject, JObject>> { + /// [1] = values => { values["new"] = values["old"]; values.Remove("old"); return values; } + /// } + /// + /// + /// + public IReadOnlyDictionary> Migrations { get; set; } = new Dictionary>(); + /// All top-level fields of all groups, in display order. Children of composite and collection fields are not included. + public IEnumerable Fields => Groups.SelectMany(group => group.Fields); + /// Creates a schema. + /// The version of the stored layout; start at 1. + /// The groups of the settings page, in display order. The array is used as is, not copied. + /// is less than 1. + /// Two top-level fields share an . + public PluginSettingsSchema(int version, params PluginSettingsGroup[] groups) + { + if (version < 1) throw new ArgumentOutOfRangeException(nameof(version)); + Version = version; + Groups = groups; + var seen = new HashSet(StringComparer.Ordinal); + foreach (var field in Fields) if (!seen.Add(field.Id)) throw new ArgumentException("Duplicate plugin field " + field.Id + "."); + } + /// + /// Brings values saved by an older version up to by running each step in turn. + /// + /// + /// The input snapshot is not modified. Values from the current version are returned as a copy. Values from a newer version are + /// returned unchanged with their newer ; the store then refuses to overwrite + /// them and the host refuses to start the plugin with them. + /// + /// The saved values and the version they were written with. + /// A new snapshot. + /// A step needed for the upgrade is missing or returned null. + public PluginSettingsSnapshot Upgrade(PluginSettingsSnapshot snapshot) + { + var values = (JObject)snapshot.Values.DeepClone(); + for (int version = snapshot.SchemaVersion; version < Version; version++) { + if (!Migrations.TryGetValue(version, out var migrate)) throw new InvalidOperationException("No configuration migration from version " + version + "."); + values = migrate(values) ?? throw new InvalidOperationException("Configuration migration returned no values."); + } + return new PluginSettingsSnapshot(Math.Max(snapshot.SchemaVersion, Version), values); + } + /// + /// Returns the default values: every field's , plus a record of child defaults for each + /// field. Fields without a default are absent. + /// + /// A new snapshot at . + public PluginSettingsSnapshot Defaults() + { + var values = new JObject(); + foreach (var field in Fields) if (field.Default != null) values[field.Id] = field.Default.DeepClone(); + FillRecordDefaults(Fields, values); + return new PluginSettingsSnapshot(Version, values); + } + internal static void FillRecordDefaults(IEnumerable fields, JObject values) + { + foreach (var field in fields) { + if (values[field.Id] == null && field.Default != null) values[field.Id] = field.Default.DeepClone(); + if (field.Kind == SettingKind.Composite) { + if (values[field.Id] == null) values[field.Id] = new JObject(); + if (values[field.Id] is JObject record) FillRecordDefaults(field.Children, record); + } else if (field.Kind == SettingKind.Collection && field.Children.Count > 0 && values[field.Id] is JArray records) { + foreach (var item in records.OfType()) FillRecordDefaults(field.Children, item); + } + } + } + /// + /// Checks values against the declared fields and returns every problem found. + /// + /// + /// Only visible fields are checked (see ), and , + /// and fields are skipped. For each field, its + /// runs first; if it reports nothing and a value is present, the value is checked against + /// and, for numbers, and . An + /// absent value is valid. Records of composite and collection fields are checked against their , + /// and their problems are reported as one issue on the parent field. + /// + /// Pass effective values (defaults and layers resolved), as the host does before a game starts and the settings session does on + /// apply. Plugin-specific checks of choices, files and combinations of fields belong in + /// , which callers run in addition. + /// + /// + /// The values to check. + /// The issues found; empty when the values are valid. + public IReadOnlyList Validate(PluginSettingsSnapshot snapshot) + { + var issues = new List(); + foreach (var field in Fields) { + if (!field.Visible(snapshot) || field.Kind == SettingKind.Status || field.Kind == SettingKind.Action || field.Kind == SettingKind.Preview) continue; + var value = snapshot.Value(field.Id); + string? error = field.Validate?.Invoke(value); + if (error == null && value != null) { + if (field.Kind == SettingKind.Boolean && value.Type != JTokenType.Boolean) error = "Choose on or off."; + if (field.Kind == SettingKind.Integer && value.Type != JTokenType.Integer) error = "Enter a whole number."; + if (field.Kind == SettingKind.Number || field.Kind == SettingKind.Integer) { + if (value.Type != JTokenType.Float && value.Type != JTokenType.Integer) error = "Enter a number."; + else { + double number = value.Value(); + if (double.IsNaN(number) || double.IsInfinity(number) || field.Minimum.HasValue && number < field.Minimum || field.Maximum.HasValue && number > field.Maximum) + error = "Value is outside the supported range."; + } + } + if (field.Kind == SettingKind.Collection && value.Type != JTokenType.Array) error = "Expected a list."; + if (field.Kind == SettingKind.Composite && !(value is JObject)) error = "Expected a settings record."; + if (field.Children.Count > 0 && error == null) { + var nested = new PluginSettingsSchema(Version, new PluginSettingsGroup(field.Label, "", field.Children.ToArray())); + if (field.Kind == SettingKind.Composite && value is JObject record) { + var failures = nested.Validate(new PluginSettingsSnapshot(Version, record)); + if (failures.Count > 0) error = string.Join("; ", failures.Select(issue => issue.Field + ": " + issue.Message)); + } + if (field.Kind == SettingKind.Collection && value is JArray records) foreach (var item in records) { + if (!(item is JObject row)) { error = "Expected a list of settings records."; break; } + var failures = nested.Validate(new PluginSettingsSnapshot(Version, row)); + if (failures.Count > 0) { error = string.Join("; ", failures.Select(issue => issue.Field + ": " + issue.Message)); break; } + } + } + if (field.Kind == SettingKind.ScreenLayout) error = ScreenLayoutError(value); + if ((field.Kind == SettingKind.Text || field.Kind == SettingKind.File || field.Kind == SettingKind.Folder || field.Kind == SettingKind.Choice && !field.Multiple || field.Kind == SettingKind.Color) && value.Type != JTokenType.String) error = "Expected text."; + if (field.Kind == SettingKind.Choice && field.Multiple && (!(value is JArray picked) || picked.Any(item => item.Type != JTokenType.String))) error = "Expected a list of choices."; + if (field.Kind == SettingKind.Rectangle) { + if (!(value is JObject rectangle)) error = "Expected a rectangle."; + else foreach (string dimension in new[] { "x", "y", "width", "height" }) { + var coordinate = rectangle[dimension]; + if (coordinate == null || coordinate.Type != JTokenType.Integer && coordinate.Type != JTokenType.Float || !double.IsFinite(coordinate.Value()) || (dimension == "width" || dimension == "height") && coordinate.Value() <= 0) error = "Enter valid coordinates and positive dimensions."; + } + } + } + if (error != null) issues.Add(new PluginValidationIssue(field.Id, error)); + } + return issues; + } + private static string? ScreenLayoutError(JToken value) + { + if (!(value is JArray screens)) return "Expected a screen layout."; + var ids = new HashSet(StringComparer.Ordinal); + foreach (var item in screens) { + if (!(item is JObject screen) || screen["screen"] == null || screen["screen"]!.Type != JTokenType.String && screen["screen"]!.Type != JTokenType.Integer || string.IsNullOrWhiteSpace(screen["screen"]!.ToString()) || !ids.Add(screen["screen"]!.ToString())) return "Each screen needs a unique identifier."; + foreach (string axis in new[] { "x", "y", "width", "height" }) { + var number = screen[axis]; + if (number?.Type != JTokenType.Integer || !int.TryParse(number.ToString(), out int coordinate) || (axis == "width" || axis == "height") && (coordinate < 1 || coordinate > 16384)) return "Use integer coordinates and dimensions from 1 to 16384 pixels."; + } + } + return null; + } + } + + /// + /// A set of settings values keyed by , together with the schema version they follow. + /// + /// + /// Snapshots are what plugins read their configuration from: holds the effective values for + /// the game being prepared, and and receive + /// new ones for live changes. The same type also carries a single stored layer, a draft on the settings page, or the values an + /// action returns. Read values with the typed accessors, which never throw and return the fallback when the value is absent or of + /// another JSON type. + /// + /// A snapshot is a mutable JSON tree and is not thread-safe. Treat snapshots you are handed as read-only and use + /// before changing one. + /// + /// + /// Reading settings in a plugin, with keys from PinUP's schema: + /// + /// public override Task PrepareGameAsync(PluginGameContext game, CancellationToken cancellation) + /// { + /// string root = game.Settings.String("VideosRoot"); + /// int volume = game.Settings.Integer("Volume", 100); + /// bool hardware = game.Settings.Boolean("HardwareDecoding", true); + /// ... + /// } + /// + /// + /// + public sealed class PluginSettingsSnapshot + { + /// The the values follow. + public int SchemaVersion { get; } + /// + /// The values as a JSON object, one property per field ID. This is the live tree, not a copy. Values the current schema does not + /// know may be present. + /// + public JObject Values { get; } + /// Creates a snapshot. + /// The schema version the values follow. + /// The values, used as is (not copied); null starts empty. + public PluginSettingsSnapshot(int schemaVersion, JObject? values = null) { SchemaVersion = schemaVersion; Values = values ?? new JObject(); } + /// + /// Returns the raw value of a field, or null when it is absent. The token is part of ; do not modify it. + /// + /// The field ID. + public JToken? Value(string id) => Values[id]; + /// Returns a field's value if it is a JSON string, otherwise . + /// The field ID. + /// The value to return when the field is absent or not a string. + public string String(string id, string fallback = "") => Value(id)?.Type == JTokenType.String ? Value(id)!.Value() ?? fallback : fallback; + /// Returns a field's value if it is a JSON boolean, otherwise . + /// The field ID. + /// The value to return when the field is absent or not a boolean. + public bool Boolean(string id, bool fallback = false) => Value(id)?.Type == JTokenType.Boolean ? Value(id)!.Value() : fallback; + /// + /// Returns the strings of a field whose value is a JSON array, such as the choice IDs of a + /// choice; other items are skipped. Empty when the field is absent or not an array. + /// + /// The field ID. + public IReadOnlyList Strings(string id) => Value(id) is JArray items + ? items.Where(item => item.Type == JTokenType.String).Select(item => item.Value() ?? "").ToArray() + : Array.Empty(); + /// + /// Returns a field's value if it is a JSON integer that fits in an , otherwise . + /// Floating-point values are not converted. + /// + /// The field ID. + /// The value to return when the field is absent, not an integer or out of range. + public int Integer(string id, int fallback = 0) => Value(id)?.Type == JTokenType.Integer && int.TryParse(Value(id)!.ToString(), System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out int number) ? number : fallback; + /// Returns a field's value if it is a JSON integer or floating-point number, otherwise . + /// The field ID. + /// The value to return when the field is absent or not a number. + public double Number(string id, double fallback = 0) => Value(id)?.Type == JTokenType.Integer || Value(id)?.Type == JTokenType.Float ? Value(id)!.Value() : fallback; + /// Returns a deep copy that can be changed without affecting this snapshot. + public PluginSettingsSnapshot Copy() => new PluginSettingsSnapshot(SchemaVersion, (JObject)Values.DeepClone()); + /// + /// Stores a copy of for a field, or removes the field when is null. In a game + /// layer, removing a field means it inherits the global value; in a global layer, the default. + /// + /// The field ID. + /// The new value, or null to remove it. + public void Set(string id, JToken? value) { if (value == null) Values.Remove(id); else Values[id] = value.DeepClone(); } + /// + /// Combines layers into a new snapshot: a copy of , with each top-level value of each layer replacing + /// the one before it. Values are replaced whole, not merged property by property. + /// + /// The base values; its becomes the result's. + /// The layers to apply in order, such as the global values and then a game's overrides. + /// A new snapshot; the inputs are not modified. + public static PluginSettingsSnapshot Merge(PluginSettingsSnapshot defaults, params PluginSettingsSnapshot[] layers) + { + var merged = defaults.Copy(); + foreach (var layer in layers) foreach (var value in layer.Values.Properties()) merged.Values[value.Name] = value.Value.DeepClone(); + return merged; + } + } + + /// One item a field can select, returned in a . + /// + /// Only the is saved, so it must stay stable while labels may change. PinUP lists one choice per pack folder, B2S + /// one per .directb2s file it found, and one per display of the running game. + /// + public sealed class PluginChoice + { + /// The value stored in the field when this item is selected. + public string Id { get; } + /// The text shown in the picker. When several choices share a label, the Player appends their IDs. + public string Label { get; } + /// Optional longer text, shown as a tooltip. + public string Description { get; } + /// + /// False for an item that exists but cannot be used now. The Player does not offer unavailable items, and applying settings that + /// select one fails with . + /// + public bool Available { get; } + /// Why the item cannot be used, shown to the user when a saved selection points to it. Empty for available items. + public string UnavailableReason { get; } + /// Creates a choice. + /// The stable value to store. + /// The text shown in the picker. + /// Optional tooltip text. + /// False when the item cannot be selected now. + /// Why it cannot be selected, when is false. + public PluginChoice(string id, string label, string description = "", bool available = true, string unavailableReason = "") + { Id = id; Label = label; Description = description; Available = available; UnavailableReason = unavailableReason; } + } + + /// The table package a game-scoped request is about, for catalogs that look next to it. + /// + /// The B2S plugin, for example, looks for a .directb2s file next to the package and named after the table file or its + /// folder, and compares the title with file names. + /// + public sealed class PluginPackageReference + { + /// Full path of the table package. + public string Path { get; } + /// The table's display title, or empty when unknown. + public string Title { get; } + /// Creates a reference. Null arguments become empty strings. + /// Full path of the table package. + /// The table's display title. + public PluginPackageReference(string path, string title) { Path = path ?? ""; Title = title ?? ""; } + } + + /// + /// The context passed to an : which game and table are being configured, the settings to + /// work with, the platform and the resources that are currently published. + /// + /// + /// The settings session builds a request for every catalog lookup, action and validation; the host builds one to validate a + /// plugin's settings before a game starts. Providers only read it. + /// + public sealed class PluginChoiceRequest + { + /// The game being configured, or null when global settings are edited. + public GameIdentity? Game { get; } + /// The selected table package, when the request concerns one; null for global settings. + public PluginPackageReference? Package { get; set; } + /// + /// The effective settings to work with, including unsaved edits from the settings page. Use these, not the saved values, so that + /// catalogs follow what the user just changed (for example PinUP lists the packs of the library folder in the draft). + /// + public PluginSettingsSnapshot Settings { get; } + /// The platform ID: windows, macos or linux in the Player. + public string Platform { get; } + /// + /// The resources published in the plugin resource registry at the time of the request, such as the displays of a running game. + /// Filled for catalog lookups and the check made when settings are applied; empty for actions, for validation and when nothing + /// runs. Never null. + /// + public IReadOnlyList Resources { get; } + /// Creates a request. + /// The game being configured, or null for global settings. + /// The effective settings to work with. + /// The platform ID. + /// The currently published resources, or null for none. + public PluginChoiceRequest(GameIdentity? game, PluginSettingsSnapshot settings, string platform, IReadOnlyList? resources = null) + { Game = game; Settings = settings; Platform = platform; Resources = resources ?? Array.Empty(); } + } + + /// The items a or field can select right now. + /// + /// Returned by . The Player fills the picker from it, and the settings session + /// checks each saved selection against it before applying settings. + /// + public sealed class PluginChoiceCatalog + { + /// + /// An opaque string that changes when the list changes. Providers in this codebase join the choice IDs with |. The host does + /// not interpret it. + /// + public string Revision { get; } + /// The items in display order, including unavailable ones. + public IReadOnlyList Choices { get; } + /// False when a runtime-only catalog cannot be inspected while its game is stopped. + /// + /// A saved selection missing from an incomplete catalog is kept and not reported as an error. + /// returns an incomplete catalog when the game's controller is not running, because its displays are unknown then. + /// + public bool IsComplete { get; } + /// Creates a catalog. + /// An opaque string that identifies this version of the list. + /// The items in display order. + /// False when the list may lack items that exist only while the game runs. + public PluginChoiceCatalog(string revision, IReadOnlyList choices, bool isComplete = true) { Revision = revision; Choices = choices; IsComplete = isComplete; } + /// Returns the first item with the given ID, or null when the catalog has none. + /// The to look for. + public PluginChoice? Find(string id) => Choices.FirstOrDefault(choice => choice.Id == id); + } + + /// The outcome of a settings action, a reconfiguration or a settings commit. + /// + /// Plugins return it from , and + /// . The host and the settings session return it when they save settings. + /// + public readonly struct PluginActionResult + { + /// Whether the operation succeeded. A failed reconfiguration must leave the plugin as it was. + public bool Success { get; } + /// A short message for the user, shown on the settings page in both cases. + public string Message { get; } + /// + /// For an action: values to put into the settings draft when is true, or null for none. Only fields + /// editable in the current scope are taken, and the user still has to apply them. Ignored in other results. + /// + public PluginSettingsSnapshot? Changes { get; } + /// Creates a result. + /// Whether the operation succeeded. + /// A short message for the user. + /// For an action, values to put into the draft; otherwise null. + public PluginActionResult(bool success, string message, PluginSettingsSnapshot? changes = null) { Success = success; Message = message; Changes = changes; } + } + + /// + /// Implemented by plugins whose settings need dynamic content: catalogs for choice fields, buttons that run actions, and checks + /// that the schema alone cannot express. + /// + /// + /// A factory returns it from , or null when the schema is enough (PinMAME's + /// factory returns null). The PinUP and B2S factories implement it themselves and return this. Fields name what they need + /// through ; for example PinUP's Pack field asks for the packs catalog, which lists + /// the pack folders of the configured library. + /// + /// The provider is used while no plugin instance exists, so it must not rely on runtime state other than what the request carries. + /// Calls can arrive on the main thread or on a worker thread: do not touch Unity objects, and move file system work off the + /// calling thread, as PinUP does with Task.Run. + /// + /// + /// + /// public Task<PluginChoiceCatalog> DiscoverAsync(string provider, PluginChoiceRequest request, CancellationToken cancellation) + /// => Task.Run(() => { + /// var choices = new List<PluginChoice> { new PluginChoice("auto", "Automatic") }; + /// string root = request.Settings.String("Library"); + /// if (provider == "items" && Directory.Exists(root)) + /// foreach (string name in Directory.EnumerateDirectories(root).Select(Path.GetFileName)) + /// choices.Add(new PluginChoice(name, name)); + /// return new PluginChoiceCatalog(string.Join("|", choices.Select(choice => choice.Id)), choices); + /// }, cancellation); + /// + /// public Task<PluginActionResult> RunActionAsync(string action, PluginChoiceRequest request, CancellationToken cancellation) + /// => Task.FromResult(new PluginActionResult(false, "Unknown action.")); + /// + /// public IReadOnlyList<PluginValidationIssue> Validate(PluginChoiceRequest request) + /// => Directory.Exists(request.Settings.String("Library")) ? Array.Empty<PluginValidationIssue>() + /// : new[] { new PluginValidationIssue("Library", "This folder does not exist.") }; + /// + /// + /// + public interface IPluginSettingsProvider + { + /// + /// Lists the items for a or field. Called when the settings + /// page shows the field, and again when settings are applied to check the saved selections. + /// + /// The field's . + /// The game, table, draft settings and published resources to list items for. + /// + /// Cancelled when the page is refreshed or closed or the apply is cancelled; stop work and throw + /// . + /// + /// The catalog; mark it incomplete when items may exist that cannot be seen now. + Task DiscoverAsync(string provider, PluginChoiceRequest request, CancellationToken cancellation); + /// + /// Runs the action of a field when the user presses its button, such as PinUP locating an + /// installed LibVLC or B2S rescanning its folders. + /// + /// The field's . + /// The game, table and draft settings; is empty. + /// Cancelled when the page is refreshed or closed. + /// + /// The result to show; on success, may carry values for the draft, which the user then + /// applies. + /// + Task RunActionAsync(string action, PluginChoiceRequest request, CancellationToken cancellation); + /// + /// Checks settings beyond what the schema checks, such as folders that must exist or fields that depend on each other. Called + /// with the effective settings when the user applies them and when the host prepares a game; issues block saving and keep the + /// plugin from starting that game. In the Player it runs synchronously on the main thread, so keep it fast. + /// + /// The game, table and effective settings to check. + /// The issues found; empty when the settings are valid. + IReadOnlyList Validate(PluginChoiceRequest request); + } +} diff --git a/VisualPinball.Plugins/Configuration/PluginSchema.cs.meta b/VisualPinball.Plugins/Configuration/PluginSchema.cs.meta new file mode 100644 index 000000000..80062cfcb --- /dev/null +++ b/VisualPinball.Plugins/Configuration/PluginSchema.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d54a7d1915fd76d40ab751f55035e8b4 \ No newline at end of file diff --git a/VisualPinball.Plugins/Configuration/PluginSettingsSession.cs b/VisualPinball.Plugins/Configuration/PluginSettingsSession.cs new file mode 100644 index 000000000..03b656bab --- /dev/null +++ b/VisualPinball.Plugins/Configuration/PluginSettingsSession.cs @@ -0,0 +1,309 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// SPDX-License-Identifier: MIT +// Licensed under MIT. See https://opensource.org/license/mit. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json.Linq; + +namespace VisualPinball.Plugins +{ + /// + /// An editable, unsaved copy of one plugin's settings in one scope, either the global settings or one game's profile. Edit the + /// draft, preview the effective values, query catalogs and run actions, then apply. + /// + /// + /// The session is detached from the store: edits stay in until saves them, and + /// nothing reaches a running plugin before that. A game session's draft holds only that game's overrides; fields absent from it + /// inherit the global value. Applying saves only the fields edited in this session, so values changed elsewhere in the + /// meantime, such as by the volume mixer, are kept. Before saving, apply checks again that every selected catalog item, such as + /// a PinUP pack or a B2S backglass file, still exists and is available. + /// + /// The Player's plugin settings page opens one session for the selected plugin and scope, disposes it when the user switches + /// plugin or scope or leaves the page, and applies through its so live settings reach the running + /// game. A session is not thread-safe: use it from one thread, the owner thread in the Player. Only + /// guards against overlapping calls. + /// + /// + /// + /// using var session = new PluginSettingsSession(factory, host.Configuration, host.Platform, game, host.Resources.Snapshot); + /// session.Package = new PluginPackageReference(packagePath, title); + /// session.Set("Volume", 80); + /// var result = await session.ApplyAsync(cancellation, host); + /// ShowFeedback(result.Message); + /// + /// + /// + public sealed class PluginSettingsSession : IDisposable + { + private readonly IPlayerPluginFactory _factory; + private readonly PluginConfigurationStore _store; + private readonly string _platform; + private readonly Func> _resources; + private readonly SemaphoreSlim _apply = new SemaphoreSlim(1, 1); + private readonly CancellationTokenSource _lifetime = new CancellationTokenSource(); + private PluginSettingsSnapshot _baseline; + private readonly HashSet _resetFields = new HashSet(StringComparer.Ordinal); + /// + /// Whether a field has a pending change in this session: its draft value differs from the value loaded from, or last saved + /// to, the store, or cleared it. Only pending fields are saved by . + /// + /// The top-level field ID. + public bool HasEdits(string field) => _resetFields.Contains(field) || !JToken.DeepEquals(_baseline.Value(field), Draft.Value(field)); + private string[] ChangedFields() => _factory.Schema.Fields.Where(field => HasEdits(field.Id)).Select(field => field.Id).ToArray(); + /// The game whose profile this session edits, or null when it edits the global settings. Fixed for the session. + public GameIdentity? Game { get; } + /// + /// The table package being configured, passed to catalogs, actions and validation as . + /// Null by default and for global sessions. The Player sets it for game sessions, so that the B2S plugin, for example, can + /// look for a backglass next to the package. + /// + public PluginPackageReference? Package { get; set; } + /// + /// The edited layer as it would be stored: only the game's overrides in a game session, the global values in a global + /// session. A field absent from it inherits the global value or the default. , and + /// a successful replace the instance, so read the property again rather than keeping the snapshot. + /// Change values through , which also enforces each field's scope. + /// + public PluginSettingsSnapshot Draft { get; private set; } + /// + /// The values the plugin would run with if the draft were saved: schema defaults, then the saved global values and the draft + /// in a game session, or the draft in a global session. Computed as a new snapshot on every read; in a game session that + /// includes reading the global settings file, so avoid reading it in a loop. + /// + public PluginSettingsSnapshot Effective => Game.HasValue + ? PluginConfigurationStore.Resolve(_factory.Schema, _store.Read(_factory.Descriptor, _factory.Schema), Draft) + : PluginConfigurationStore.Resolve(_factory.Schema, Draft); + /// + /// Opens a session and loads the stored layer of the given scope as the draft. The file is read synchronously on the calling + /// thread. + /// + /// The plugin whose settings to edit. + /// + /// The settings store to read from, and to save to when runs without a host. The Player passes + /// . + /// + /// The platform ID passed to settings providers; the Player passes . + /// The game whose profile to edit, or null for the global settings. + /// + /// Returns the currently published resources, for catalog lookups and the availability check on apply. The Player passes + /// . Null means no resources. + /// + /// The stored game profile belongs to another game. + public PluginSettingsSession(IPlayerPluginFactory factory, PluginConfigurationStore store, string platform, GameIdentity? game = null, Func>? resources = null) + { _factory = factory; _store = store; _platform = platform; Game = game; _resources = resources ?? (() => Array.Empty()); Draft = store.Read(factory.Descriptor, factory.Schema, game); _baseline = Draft.Copy(); } + /// Changes a field in the draft and marks it as edited. Nothing is saved until . + /// The ID of a top-level field of the plugin's schema. + /// + /// The new value, copied into the draft. Null removes the field from the draft: in a game session it inherits the global + /// value again, in a global session it falls back to the default. + /// + /// + /// The schema has no field with that ID, or the field's does not allow editing it in this + /// session's scope. + /// + public void Set(string fieldId, JToken? value) + { + var field = _factory.Schema.Fields.First(field => field.Id == fieldId); + if (Game.HasValue ? field.Scope == ConfigurationScope.GlobalOnly : field.Scope == ConfigurationScope.GameOnly) throw new InvalidOperationException("This field is not configurable in the selected scope."); + Draft.Set(fieldId, value); + _resetFields.Remove(fieldId); + } + /// + /// Discards every pending edit and reset by reloading the stored layer from the store; the file is read synchronously. The + /// Player's Revert button calls it. + /// + public void Revert() { Draft = _store.Read(_factory.Descriptor, _factory.Schema, Game); _baseline = Draft.Copy(); _resetFields.Clear(); } + /// + /// Takes over values that were saved elsewhere while the session is open, for fields without pending edits, so the page shows + /// them without discarding the user's own edits. The Player calls it from , for + /// example when the volume mixer changed a global setting that the open settings page also shows. + /// + /// The freshly read stored layer of this session's scope. + /// IDs of the top-level fields that were saved. Fields with pending edits are skipped. + public void RefreshUnedited(PluginSettingsSnapshot saved, IEnumerable fields) + { + foreach (string id in fields) { + if (HasEdits(id)) continue; + Draft.Set(id, saved.Value(id)); _baseline.Set(id, saved.Value(id)); + } + } + /// + /// Empties the draft, so every field goes back to its inherited value (the global value in a game session, the default in a + /// global session), and marks every field editable in this scope as pending, so removes their stored + /// values. Nothing is saved until then. The Player's Reset button calls it. + /// + public void Reset() + { + Draft = new PluginSettingsSnapshot(_factory.Schema.Version); + foreach (var field in _factory.Schema.Fields) + if (Game.HasValue ? field.Scope != ConfigurationScope.GlobalOnly : field.Scope != ConfigurationScope.GameOnly) _resetFields.Add(field.Id); + } + /// + /// Checks against the schema and the plugin's . Catalog + /// selections are not checked here; checks them in addition. + /// + /// The issues found; empty when the settings are valid. + public IReadOnlyList Validate() + { + var effective = Effective; + return _factory.Schema.Validate(effective).Concat(_factory.SettingsProvider?.Validate(Request(effective)) ?? Array.Empty()).ToArray(); + } + private PluginChoiceRequest Request(PluginSettingsSnapshot settings, IReadOnlyList? resources = null) + => new PluginChoiceRequest(Game, settings, _platform, resources) { Package = Package }; + /// + /// Lists the items a choice or screen layout field can select, through the plugin's + /// , based on the draft's effective values, and the + /// currently published resources. The task may complete on a worker thread. + /// + /// The field's . + /// Passed to the provider; cancel it when the field is no longer shown. + /// The provider's catalog, or an empty, complete catalog when the plugin has no settings provider. + public Task DiscoverAsync(string provider, CancellationToken cancellation) + => _factory.SettingsProvider?.DiscoverAsync(provider, Request(Effective, _resources()), cancellation) ?? Task.FromResult(new PluginChoiceCatalog("empty", Array.Empty())); + /// + /// Runs the action of an action field through , such as PinUP locating + /// LibVLC. When it succeeds and returns , the values of the fields editable in this + /// scope are put into the draft as pending edits; the user still has to apply them. + /// + /// The field's . + /// Passed to the provider. + /// The provider's result, or a failure when the plugin has no settings provider. + /// + /// was cancelled; the draft is then left unchanged. + /// + public async Task RunActionAsync(string action, CancellationToken cancellation) + { + if (_factory.SettingsProvider == null) return new PluginActionResult(false, "This action is unavailable."); + var result = await _factory.SettingsProvider.RunActionAsync(action, Request(Effective), cancellation); + cancellation.ThrowIfCancellationRequested(); + if (result.Success && result.Changes != null) foreach (var field in _factory.Schema.Fields) { + if (Game.HasValue ? field.Scope == ConfigurationScope.GlobalOnly : field.Scope == ConfigurationScope.GameOnly) continue; + if (result.Changes.Values.TryGetValue(field.Id, out var value)) Set(field.Id, value); + } + return result; + } + /// + /// Validates the pending edits and saves them. Only fields with pending edits (see ) are written; other + /// stored values, including ones changed elsewhere since the session opened, are kept. + /// + /// + /// The draft is frozen when the call starts. Apply merges it into the latest stored layer, validates the effective result + /// against the schema and , and asks the provider for a fresh catalog for every + /// visible choice or screen layout field, including those inside composite and collection records, to check that each + /// selected item still exists and is available. A selection missing from an incomplete catalog is accepted. Any issue fails + /// the apply and nothing is saved. + /// + /// With a , the edits are committed through , which also + /// applies live fields to a running plugin and raises . Without one they are saved + /// straight to the store and take effect with the next game. After a successful save the draft is reloaded from the store; + /// edits the user made while the apply ran stay pending. Calls are serialized: a second apply waits for the first. + /// + /// + /// Cancels the apply. cancels it too. + /// + /// The host to commit through, so that a running game picks up live settings. The Player always passes it. When given, the + /// task completes on the host's owner thread. + /// + /// + /// Success, or a failure whose message says why nothing was saved. Validation issues are listed one per line as + /// field: message; a selection inside a record is named by its path, such as Items[0].Pack. + /// + /// + /// The apply was cancelled, or the session disposed, before the settings were committed. + /// + /// + /// The stored settings were written by a newer plugin version and cannot be overwritten. + /// + public async Task ApplyAsync(CancellationToken cancellation = default, PluginHost? host = null) + { + using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellation, _lifetime.Token); + await _apply.WaitAsync(linked.Token).ConfigureAwait(false); + try { + // Freeze the draft before any asynchronous discovery so editing cannot change the validated transaction. + var draft = Draft.Copy(); + var frozenResets = new HashSet(_resetFields, StringComparer.Ordinal); + var changed = ChangedFields(); + var resources = _resources(); + var effective = await Task.Run(() => { + var latest = _store.MergePatch(_factory.Descriptor, _factory.Schema, draft, changed, Game); + return Game.HasValue ? PluginConfigurationStore.Resolve(_factory.Schema, _store.Read(_factory.Descriptor, _factory.Schema), latest) : PluginConfigurationStore.Resolve(_factory.Schema, latest); + }, linked.Token).ConfigureAwait(false); + if (host != null) await host.Scheduler.Switch(); + var request = Request(effective, resources); + var issues = _factory.Schema.Validate(effective).Concat(_factory.SettingsProvider?.Validate(request) ?? Array.Empty()).ToList(); + var catalogs = new Dictionary(StringComparer.Ordinal); + foreach (var binding in ChoiceBindings(_factory.Schema.Fields, effective)) { + var field = binding.Field; + var selections = field.Kind == SettingKind.ScreenLayout ? (binding.Value as JArray)?.OfType().Select(screen => screen["screen"]?.ToString() ?? "").ToArray() ?? Array.Empty() + : field.Multiple ? (binding.Value as JArray)?.Select(item => item.ToString()).ToArray() ?? Array.Empty() + : new[] { binding.Value?.ToString() ?? "" }; + if (selections.Length == 0 || _factory.SettingsProvider == null) continue; + if (!catalogs.TryGetValue(field.Provider, out var catalog)) { + catalog = await _factory.SettingsProvider.DiscoverAsync(field.Provider, request, linked.Token).ConfigureAwait(false); + catalogs.Add(field.Provider, catalog); + } + foreach (string selected in selections) { + if (selected.Length == 0) continue; + var choice = catalog.Find(selected); + if (choice != null && !choice.Available || choice == null && catalog.IsComplete) issues.Add(new PluginValidationIssue(binding.Path, choice?.UnavailableReason ?? "Selected item is missing: " + selected)); + } + } + linked.Token.ThrowIfCancellationRequested(); + if (issues.Count > 0) return new PluginActionResult(false, string.Join("\n", issues.Select(issue => issue.Field + ": " + issue.Message))); + if (host != null) { + var result = await host.CommitSettingsAsync(_factory, draft, Game, linked.Token, changed).ConfigureAwait(false); + await host.Scheduler.Switch(); + if (result.Success) Rebase(draft, frozenResets); + return result; + } + await Task.Run(() => _store.SavePatch(_factory.Descriptor, _factory.Schema, draft, changed, Game)).ConfigureAwait(false); + Rebase(draft, frozenResets); + return new PluginActionResult(true, "Saved. Changes take effect on the next game."); + } finally { _apply.Release(); } + } + /// + /// Reloads the saved profile after a successful apply, then re-applies fields edited after + /// was taken, so changes made while the apply ran stay pending instead of being discarded. + /// + private void Rebase(PluginSettingsSnapshot frozen, HashSet frozenResets) + { + var current = Draft; + var currentResets = new HashSet(_resetFields, StringComparer.Ordinal); + Revert(); + foreach (var field in _factory.Schema.Fields) { + string id = field.Id; + bool reset = currentResets.Contains(id); + if (JToken.DeepEquals(frozen.Value(id), current.Value(id)) && reset == frozenResets.Contains(id)) continue; + Draft.Set(id, current.Value(id)); + if (reset) _resetFields.Add(id); + } + } + private static IEnumerable<(PluginField Field, JToken? Value, string Path)> ChoiceBindings(IEnumerable fields, PluginSettingsSnapshot snapshot, string prefix = "") + { + foreach (var field in fields) { + if (!field.Visible(snapshot)) continue; + var value = snapshot.Value(field.Id); string path = prefix + field.Id; + if ((field.Kind == SettingKind.Choice || field.Kind == SettingKind.ScreenLayout) && field.Provider.Length > 0) yield return (field, value, path); + if (field.Children.Count == 0) continue; + if (field.Kind == SettingKind.Composite && value is JObject record) + foreach (var child in ChoiceBindings(field.Children, new PluginSettingsSnapshot(snapshot.SchemaVersion, record), path + ".")) yield return child; + if (field.Kind == SettingKind.Collection && value is JArray records) for (int index = 0; index < records.Count; index++) { + if (!(records[index] is JObject item)) continue; + foreach (var child in ChoiceBindings(field.Children, new PluginSettingsSnapshot(snapshot.SchemaVersion, item), path + "[" + index + "].")) yield return child; + } + } + } + /// + /// Cancels a running and makes later calls to it fail with . + /// An apply that has already started saving still completes, and the other members keep working. The Player disposes the + /// session when the user switches plugin or scope or leaves the settings page. + /// + public void Dispose() { _lifetime.Cancel(); } + } +} diff --git a/VisualPinball.Plugins/Configuration/PluginSettingsSession.cs.meta b/VisualPinball.Plugins/Configuration/PluginSettingsSession.cs.meta new file mode 100644 index 000000000..bcad47816 --- /dev/null +++ b/VisualPinball.Plugins/Configuration/PluginSettingsSession.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 232fe61a30136af4f980e21193b2d61d \ No newline at end of file diff --git a/VisualPinball.Plugins/LICENSE b/VisualPinball.Plugins/LICENSE new file mode 100644 index 000000000..d0efc58e0 --- /dev/null +++ b/VisualPinball.Plugins/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Visual Pinball Engine contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/VisualPinball.Plugins/LICENSE.meta b/VisualPinball.Plugins/LICENSE.meta new file mode 100644 index 000000000..dd54ac3f2 --- /dev/null +++ b/VisualPinball.Plugins/LICENSE.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 26a7165f639bb1b48a7f9e19cfc501ca +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Plugins/README.md b/VisualPinball.Plugins/README.md new file mode 100644 index 000000000..1846f8d12 --- /dev/null +++ b/VisualPinball.Plugins/README.md @@ -0,0 +1,51 @@ +# Player plugin contracts + +This MIT-licensed .NET Standard 2.1 library defines the player plugin API without a Unity dependency. Unity compiles the same source through `VisualPinball.Plugins.asmdef`; portable tests run through `dotnet test VisualPinball.Plugins.Test/VisualPinball.Plugins.Test.csproj`. + +An optional integration package registers an `IPlayerPluginFactory`. Its descriptor declares a stable ID, default enablement, platforms, hard dependencies, required/provided capabilities, numeric order, and optional before/after relationships. Its schema and settings provider describe configuration without loading native libraries. The shared player creates the settings UI and constructs runtime instances only for enabled, supported plugins. + +## Lifecycle + +The host resolves dependencies before numeric order, checks every phase for cycles, and blocks affected plugins while independent integrations continue. Hooks start on the owner thread. An asynchronous hook returning from worker code must switch through `PluginApplicationContext.Scheduler` before accessing Unity. Prepare and Start run while the restored table is inactive, before its gamelogic can emit its first event. Ready follows `Player.OnPlayerStarted`; pause, resume, focus, tick and render use their own phase order. + +Instances live for one game. The application context scope covers initialization through shutdown; the game scope covers preparation through stop. Own subscriptions, publications and handles in these scopes. Cleanup unwinds actual startup order in reverse, uses fresh cancellation deadlines, and continues after failures. An unresponsive asynchronous hook cannot hold the host forever. Its plugin ID remains quarantined until the abandoned hook, deferred native work and shutdown finish, preventing overlapping instances. Healthy plugins keep ticking and rendering while an unrelated integration is being reconfigured or cleaned up. `IPluginEmergencyStop` must close event admission immediately and arrange safe deferred native disposal when a worker is still active. Native devices must retain exclusive output leases until their worker has stopped. Implement `IPluginDeferredCleanup.Quiescence` to report that lifetime to the host. + +Static Unity registries reset at SubsystemRegistration. Player applications claim ownership before automatic bridges run, so removing or disabling an adapter cannot resurrect an old watcher. Table authoring projects keep their existing automatic integration behavior. + +## Configuration + +Each plugin owns a directory under the player's `plugins` configuration root. `settings.json` stores global values; `policy.json` stores default and per-game enablement; hashed files under `games` store sparse overrides associated with a provider-qualified `GameIdentity`. This configuration does not enter `player-config.json`. + +Fields declare GlobalOnly, GameOnly or GlobalAndGame scope and Live, NextGame or ApplicationRestart behavior. Known fields stored in the wrong scope cannot affect runtime settings. Unknown values and document properties survive saves. Migrations operate on detached JSON trees; files from a future schema remain readable and cannot be overwritten. No persisted value names a CLR type. + +`PluginSettingsSession` owns a detached draft. Apply freezes and validates it, rechecks dynamic catalogs, and persists atomically. With a host, live reconfiguration and persistence share the lifecycle gate. `IPluginReconfiguration` must implement transactional updates: a failure or cancellation leaves the previous runtime state intact. If persistence fails after a live update, the host requests the previous runtime settings. Other values take effect at the next game or application restart. + +Dynamic choices use stable IDs, readable labels, availability reasons and a catalog revision. Missing explicit asset selections remain visible and require user action. A runtime-only catalog may declare `IsComplete = false` while its game is stopped; this keeps an existing source selection without claiming it is available. Explicit display selections are game-specific and controller-relative. `PluginResourceChoices` supplies the shared discovery and resolution rules. + +The Player owns every configuration control, including standard fields, typed records/lists, choice buttons, screen placement, previews, inheritance, Apply/Revert/Reset and runtime diagnostics. Plugins declare labels, types, defaults, scope, validation and dynamic providers through `PluginField`; they do not supply UIElements, templates, styles or configuration shaders. + +Ordinary fields place their label and editor on one line. `PluginField.FullWidth` requests a label above a wide editor, for example a PAC key or library path. Consecutive fields with the same non-empty `Row` hint share a line, such as left/top or width/height. These are presentation hints; they neither change saved values nor require a schema migration. The Player owns spacing, styling and the concrete controls. + +An optional `PluginField.GlobalSetting` exposes the same canonical field on a shared Player page. This is independent of its storage scope: `GlobalOnly` alone does not expose a field. The first section, `PluginGlobalSections.VolumeMixer` (`audio.mixer`), accepts bounded numeric fields with a global scope. The Player discovers installed, supported integrations, orders their entries by the exposure's `Order`, and supplies the controls. `Presentation = PluginFieldPresentation.Slider` and `Unit = "%"` request the same numeric slider in the ordinary plugin form. A `GameOnly` field cannot be globally exposed. Unknown sections are ignored; invalid exposures and unreadable profiles are reported without removing other integrations from the mixer. + +The mixer commits individual field patches through the host. Ordinary plugin drafts also save only fields edited since opening, merged into the latest profile before validation, so an open form cannot overwrite a newer mixer value accidentally. Reset explicitly removes the scoped values. `SettingsCommitted` lets a view refresh unedited fields while preserving its draft. The application owns and coalesces mixer writes; leaving a page flushes them, and game preparation waits for that flush. There is no second copy of plugin volume in Player preferences. + +Implement `IPluginSynchronousReconfiguration` for fast owner-thread updates such as gain changes; the host applies these without suspending tick/render callbacks. The asynchronous interface remains available for operations that need it, suspending only the target integration during its live hook. Persistence runs on a worker, preserves tick/render, and cannot be cancelled after its atomic write begins. A failed save restores the previous live settings. `PluginDescriptor.CanDisable = false` is intended for settings adapters attached to an existing gamelogic engine: legacy disable policies are ignored and the Player hides disable controls. It does not move ownership of the engine into the plugin host. + +`SettingKind.ScreenLayout` describes an array of homes with a stable `screen` identifier and integer `x`, `y`, `width`, `height` in physical desktop pixels. Signed coordinates support multiple monitors. Its `Provider` discovers the available screen IDs and names; Apply rechecks their availability. String IDs are preferred; legacy integer IDs remain accepted. Native placement creates a sparse game override when an inherited home is moved. The shared Player renders aspect-ratio guides against the full window bounds. `IUnitySettingsWindowProvider` adapts a plugin's native output transport; the Player owns the placeholder pixels and controls. + +`SettingKind.Preview` declares a preview provider without persisting UI state. `IUnitySettingsPreviewProvider` returns an owned preview with textures, normalized layer rectangles, visibility and canvas dimensions through VPE-owned contracts. The Player owns the viewport, buttons and presentation. Partial startup must be released on cancellation; hiding a settings page disposes its preview. Composite fields and collection records use `Children` to declare nested data controls, reusing the same Player rendering. + +## Cross-plugin resources + +VPE owns the payload contracts for original and presentation DMD frames, controller events, PCM audio, segment brightness, input, triggers and GPU surfaces. Descriptors identify logical source, provider, controller, session, concrete instance, role, supported formats and derivation parents. Publish a parent before its derived resource. A parent replacement invalidates the old derived lineage; consumers cannot use frames from an earlier instance. + +Bindings follow logical resources across publication/removal. Auto selects the unique compatible source and blocks ambiguity. An explicit missing source waits for that source and never substitutes a different one. Identification always uses a parentless original source. Presentation prefers a derived surface; original fallback is opt-in. Identification leases preserve native 2/4-bit shade indices and hold provider-wide raw mode until the last consumer leaves. Frames report their actual encoding; incompatible concrete frames are rejected with a diagnostic. + +Display pixels, PCM samples and segment buffers are borrowed during synchronous callbacks. Copy only when crossing a thread boundary. Display/controller bridges deliver on the Unity owner thread; native PCM can be delivered on its producer thread, and its source synchronizes subscriptions. Consumers must keep producer callbacks bounded. Native destinations use `PluginOutputLeases` to reserve physical outputs. Input stages use `PluginInputPipeline`; a producer applies its ordered filters before dispatching the resulting input event. + +## Package composition and validation + +`VisualPinball.Plugins` contains contracts, configuration and the host. `VisualPinball.Unity/VisualPinball.Unity/Plugins` bridges scene identities, gamelogic resources and settings media transports. The shared Player renders configuration and owns transitions. Each integration's normal Unity package contains its settings schema, discovery providers and runtime plugin assembly. There is no additional Player package for PinUp or DMD. HDRP/URP shells choose optional integration packages in their manifests; shared Player code has no dependency on them. + +The portable suite covers failure isolation, ordering, cancellation/deadlines, sparse profiles, migrations, future versions, missing choices, raw/presentation routing, generation changes, output exclusivity, audio replacement, input ordering and representative resource families. Native desktop and hardware behavior must also be tested on the target platform; portable tests do not validate a Metal compositor, Linux desktop or attached cabinet device. diff --git a/VisualPinball.Plugins/README.md.meta b/VisualPinball.Plugins/README.md.meta new file mode 100644 index 000000000..447c8a893 --- /dev/null +++ b/VisualPinball.Plugins/README.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 6a9b7cfd10bad004fa4d0160a2f14f0a +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Plugins/Resources.meta b/VisualPinball.Plugins/Resources.meta new file mode 100644 index 000000000..60793bf19 --- /dev/null +++ b/VisualPinball.Plugins/Resources.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 582b4fdee8188da43828d73b3c2a65a0 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Plugins/Resources/DisplayFormatNegotiator.cs b/VisualPinball.Plugins/Resources/DisplayFormatNegotiator.cs new file mode 100644 index 000000000..3fa2780a1 --- /dev/null +++ b/VisualPinball.Plugins/Resources/DisplayFormatNegotiator.cs @@ -0,0 +1,107 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// SPDX-License-Identifier: MIT +// Licensed under MIT. See https://opensource.org/license/mit. + +using System; + +namespace VisualPinball.Plugins +{ + /// + /// Decides which encoding a display producer delivers when consumers need raw frames for identification. Provider-group + /// raw mode is held until the last identification consumer leaves. + /// + /// + /// A producer normally delivers a processed default encoding (PinMAME: ). + /// Consumers that recognize content, such as PinUP's DMD triggers or the DMD plugin's colorization, need the + /// controller's raw or shades instead. When the + /// first of them acquires an identification view, the negotiator asks the producer for raw frames (Gray4 when allowed, + /// otherwise Gray2); later consumers join if they accept that encoding; when the last one leaves, it asks for the + /// default again. Consumers of other views get no say, so while raw mode is held they receive raw frames too. + /// + /// VPE creates one per display, or a single one for all displays when the engine switches all of them at once (PinMAME + /// does), and passes its method to as the view negotiation. + /// It is not thread-safe: acquire and release leases on the owner thread, as bindings do. It assumes the producer starts + /// in the default encoding and does not request it on creation. + /// + /// + /// + /// var negotiator = new DisplayFormatNegotiator(DisplayEncoding.Gray2 | DisplayEncoding.Gray4 | DisplayEncoding.Luminance8, + /// DisplayEncoding.Luminance8, encoding => engine.RequestFrameFormat(encoding), + /// () => registry.NotifyChanged(displayResourceId)); + /// var source = new DisplayResourceSource(negotiator.Acquire, Debug.LogException); + /// + /// + /// + public sealed class DisplayFormatNegotiator : IDisposable + { + private readonly DisplayEncoding _default; + private readonly DisplayEncoding _supported; + private readonly Action _request; + private readonly Action? _released; + private int _identification; + private DisplayEncoding _active; + private bool _disposed; + /// Creates a negotiator for one producer or group of displays. + /// Every encoding the producer can deliver, as flags. + /// The single encoding the producer delivers when no identification consumer is attached. + /// + /// Asks the producer to switch to one encoding. Called synchronously from , from disposing a + /// lease and from , on the caller's thread. + /// + /// + /// Optional. Called after the last identification lease was disposed and the default was requested, so consumers + /// that were refused because of the raw encoding just given up can try again. VPE passes a callback that calls + /// for the affected displays. Not called by . + /// + public DisplayFormatNegotiator(DisplayEncoding supported, DisplayEncoding defaultFormat, Action request, Action? released = null) + { _supported = supported; _default = defaultFormat; _request = request; _released = released; } + /// + /// Registers a consumer's view. Only changes anything: the first such + /// consumer switches the producer to raw (or + /// when Gray4 is not both supported and accepted), and further ones join that encoding. Other views get a lease that + /// does nothing. Matches . + /// + /// The view the consumer wants. + /// The encodings the consumer can read. + /// + /// A lease. Disposing the last identification lease switches the producer back to the default encoding and then + /// calls the released callback; disposing after the negotiator was disposed does nothing. + /// + /// The negotiator was disposed. + /// + /// The producer cannot deliver a raw encoding the consumer accepts, or raw frames are already delivered in an encoding + /// this consumer does not accept. + /// + public IDisposable Acquire(ResourceView intent, DisplayEncoding accepted) + { + if (_disposed) throw new ObjectDisposedException(nameof(DisplayFormatNegotiator)); + if (intent != ResourceView.Identification) return new Subscription(() => { }); + var raw = _supported & accepted & (DisplayEncoding.Gray2 | DisplayEncoding.Gray4); + if (raw == DisplayEncoding.None) throw new InvalidOperationException("This source cannot provide unmodified identification frames."); + if (_identification > 0) { + // The provider group delivers one raw encoding at a time; a consumer that cannot read it cannot join. + if ((raw & _active) == DisplayEncoding.None) throw new InvalidOperationException("Identification frames are already delivered as " + _active + ", which this consumer does not accept."); + _identification++; + } else { + var encoding = (raw & DisplayEncoding.Gray4) != 0 ? DisplayEncoding.Gray4 : DisplayEncoding.Gray2; + _request(encoding); + _active = encoding; + _identification = 1; + } + return new Subscription(() => { + if (_disposed || --_identification != 0) return; + _active = DisplayEncoding.None; + _request(_default); + // Consumers that were refused because of the encoding we just gave up can try again. + _released?.Invoke(); + }); + } + /// + /// Stops negotiating. If identification consumers were still attached, the producer is switched back to the default + /// encoding; leases disposed afterwards do nothing. Calling it again has no effect. + /// + public void Dispose() { if (_disposed) return; _disposed = true; if (_identification > 0) _request(_default); _identification = 0; } + } +} diff --git a/VisualPinball.Plugins/Resources/DisplayFormatNegotiator.cs.meta b/VisualPinball.Plugins/Resources/DisplayFormatNegotiator.cs.meta new file mode 100644 index 000000000..46c0a103b --- /dev/null +++ b/VisualPinball.Plugins/Resources/DisplayFormatNegotiator.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a1cb27766373724468867a22e2e89211 \ No newline at end of file diff --git a/VisualPinball.Plugins/Resources/DisplayResourceSource.cs b/VisualPinball.Plugins/Resources/DisplayResourceSource.cs new file mode 100644 index 000000000..717b1a684 --- /dev/null +++ b/VisualPinball.Plugins/Resources/DisplayResourceSource.cs @@ -0,0 +1,189 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// SPDX-License-Identifier: MIT +// Licensed under MIT. See https://opensource.org/license/mit. + +using System; +using System.Collections.Generic; + +namespace VisualPinball.Plugins +{ + /// + /// Ready-made for code that produces display frames: publish it in the + /// , then call for every frame. + /// + /// + /// + /// The Unity integration wraps every DMD of a running gamelogic engine, such as PinMAME, in one of these, and the DMD + /// plugin publishes its colorized output through one as a resource derived from + /// the original. Consumers such as the B2S and PinUP plugins bind to it through . + /// + /// + /// Delivery is synchronous and reuses a cached subscriber array, so publishing a frame allocates nothing once + /// subscriptions are stable. The class does no locking: subscribe, publish and dispose on the owner thread. Stamp every + /// frame with the the source was published with, because + /// drops frames of any other instance. + /// + /// + /// The DMD plugin's presentation output: + /// + /// _source = new DisplayResourceSource(fault: error => application.Log(error.Message)); + /// _publication = application.Resources.Publish(new PluginResourceDescriptor(original.Id + "/presentation/" + application.Plugin.Id, + /// application.Plugin.Id, original.Controller, original.Label + " · DMD presentation", original.Role, ResourceKind.Display, + /// original.Session, _instance, ResourceView.Presentation, DisplayEncoding.Rgb24, original.Id), _source); + /// // each frame, on the owner thread: + /// _source.Publish(new PluginDisplayFrame(pixels, width, height, width * 3, DisplayEncoding.Rgb24, ++_sequence, timestampUs, _instance)); + /// + /// + /// + public sealed class DisplayResourceSource : IPluginDisplaySource, IDisposable + { + private readonly List> _subscribers = new List>(); + private Action[] _dispatch = Array.Empty>(); + private readonly Func? _negotiate; + private readonly Action? _fault; + private bool _disposed; + /// Always . + public ResourceKind Kind => ResourceKind.Display; + /// Creates a display source. + /// + /// Implements : called with the consumer's intent and accepted formats, returns a lease that + /// holds the producer's output format until disposed, and throws when the producer cannot satisfy the request. The + /// Unity integration passes , which switches the engine to raw Gray2/Gray4 + /// frames while identification consumers are bound. When null, every view request succeeds with a lease that does nothing. + /// + /// + /// Receives exceptions thrown by subscribers, and delivery continues with the next subscriber. When null, a subscriber's + /// exception propagates out of and the remaining subscribers miss that frame. + /// + public DisplayResourceSource(Func? negotiate = null, Action? fault = null) + { _negotiate = negotiate; _fault = fault; } + /// + /// Adds a frame receiver until the returned handle is disposed. Consumers normally go through a + /// and instead of calling this. + /// + /// Called synchronously for every published frame; the pixels are only valid during the call. + /// A handle that removes the receiver. + /// The source has been disposed. + public IDisposable Subscribe(Action receive) + { + if (_disposed) throw new ObjectDisposedException(nameof(DisplayResourceSource)); + _subscribers.Add(receive); + _dispatch = _subscribers.ToArray(); + return new Subscription(() => { _subscribers.Remove(receive); _dispatch = _subscribers.ToArray(); }); + } + /// + /// Asks the producer for a view of the display, delegating to the negotiate function given to the constructor. + /// A binding calls it when it binds and disposes the lease when it unbinds. + /// + /// What the consumer wants, such as for unmodified raw frames. + /// The encodings the consumer can read. + /// A lease that keeps the negotiated format until disposed. + /// Thrown by the negotiator when the producer cannot provide the requested view. + public IDisposable AcquireView(ResourceView intent, DisplayEncoding acceptedFormats) => _negotiate?.Invoke(intent, acceptedFormats) ?? new Subscription(() => { }); + /// + /// Delivers a frame synchronously to every subscriber, in subscription order. The frame's pixels may be reused as soon + /// as the call returns. Does nothing after . + /// + /// The frame; its must match the published descriptor. + public void Publish(in PluginDisplayFrame frame) + { + if (_disposed) return; + foreach (var receive in _dispatch) + try { receive(frame); } catch (Exception error) { if (_fault == null) throw; _fault(error); } + } + /// + /// Drops all subscribers and stops delivery; later calls throw. Does not withdraw the + /// publication: dispose the handle returned by as well. + /// + public void Dispose() { _disposed = true; _subscribers.Clear(); _dispatch = Array.Empty>(); } + } + + /// + /// Ready-made for code that produces controller events (switches, coils, lamps, GI): + /// publish it in the , then call for every change. + /// + /// + /// + /// The Unity integration publishes two per running gamelogic engine: a source with + /// the outputs the table maps, which PinUP forwards to its PUP pack, and, when the engine reports them, a + /// source with every output by native number, which the B2S plugin prefers for + /// backglass lamps. + /// + /// + /// Delivery is synchronous and allocation-free once subscriptions are stable. The class does no locking: subscribe, + /// publish and dispose on the owner thread, and post events from other threads with . + /// + /// + /// + /// var source = scope.Own(new ControllerResourceSource(fault)); + /// scope.Own(registry.Publish(new PluginResourceDescriptor(controllerId, provider, controllerId, engine.Name, "controller", + /// ResourceKind.Controller, session, instance), source)); + /// source.Publish(new PluginControllerEvent(ControllerEventKind.Coil, "28", 1, ++sequence, timestampUs)); + /// + /// + /// + public sealed class ControllerResourceSource : IPluginControllerSource, IDisposable + { + private readonly Action? _fault; + private readonly List> _subscribers = new List>(); + private Action[] _dispatch = Array.Empty>(); + private bool _disposed; + /// + /// or , as chosen at construction. Must match the + /// descriptor this source is published with. + /// + public ResourceKind Kind { get; } + /// Creates a source of mapped controller events (). + /// + /// Receives exceptions thrown by subscribers, and delivery continues. When null, a subscriber's exception propagates out + /// of . + /// + public ControllerResourceSource(Action? fault = null) : this(ResourceKind.Controller, fault) { } + /// Creates a controller event source of the given kind. + /// + /// for mapped outputs, for native ones. + /// + /// + /// Receives exceptions thrown by subscribers, and delivery continues. When null, a subscriber's exception propagates out + /// of . + /// + /// + /// is neither nor . + /// + public ControllerResourceSource(ResourceKind kind, Action? fault = null) + { + if (kind != ResourceKind.Controller && kind != ResourceKind.Outputs) throw new ArgumentException("A controller source carries controller or output events.", nameof(kind)); + Kind = kind; _fault = fault; + } + /// + /// Adds an event receiver until the returned handle is disposed. Consumers normally connect through a + /// instead of calling this directly. + /// + /// Called synchronously on the publishing (owner) thread for every event. + /// A handle that removes the receiver. + /// The source has been disposed. + public IDisposable Subscribe(Action receive) + { + if (_disposed) throw new ObjectDisposedException(nameof(ControllerResourceSource)); + _subscribers.Add(receive); _dispatch = _subscribers.ToArray(); + return new Subscription(() => { _subscribers.Remove(receive); _dispatch = _subscribers.ToArray(); }); + } + /// + /// Delivers an event synchronously to every subscriber, in subscription order. Does nothing after . + /// + /// The event. + public void Publish(in PluginControllerEvent value) + { + if (_disposed) return; + foreach (var receive in _dispatch) + try { receive(value); } catch (Exception error) { if (_fault == null) throw; _fault(error); } + } + /// + /// Drops all subscribers and stops delivery; later calls throw. Does not withdraw the + /// publication: dispose the handle returned by as well. + /// + public void Dispose() { _disposed = true; _subscribers.Clear(); _dispatch = Array.Empty>(); } + } +} diff --git a/VisualPinball.Plugins/Resources/DisplayResourceSource.cs.meta b/VisualPinball.Plugins/Resources/DisplayResourceSource.cs.meta new file mode 100644 index 000000000..bf666d5e0 --- /dev/null +++ b/VisualPinball.Plugins/Resources/DisplayResourceSource.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 80a214d041804d64e8ef8230fe0de094 \ No newline at end of file diff --git a/VisualPinball.Plugins/Resources/PluginResourceChoices.cs b/VisualPinball.Plugins/Resources/PluginResourceChoices.cs new file mode 100644 index 000000000..bd96ebb42 --- /dev/null +++ b/VisualPinball.Plugins/Resources/PluginResourceChoices.cs @@ -0,0 +1,77 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// SPDX-License-Identifier: MIT +// Licensed under MIT. See https://opensource.org/license/mit. + +using System; +using System.Linq; + +namespace VisualPinball.Plugins +{ + /// + /// Helpers for a settings choice that picks one of the game's displays as a plugin's input, such as "DMD source". The + /// saved value is relative to the game's controller, so the same choice keeps working across sessions and can be kept + /// while the game is stopped. + /// + /// + /// The DMD, PinUP and B2S plugins each declare a choice field whose provider is sources. Their + /// answers that provider with , and their + /// prepare hook turns the saved value into a resource ID with . + /// + /// + /// // settings provider, may run on a worker: + /// if (provider == "sources") return PluginResourceChoices.Displays(request, DisplayEncoding.Gray2 | DisplayEncoding.Gray4); + /// + /// // PrepareGameAsync, owner thread: + /// string source = game.Settings.String("Source", "auto"); + /// if (source != "auto") selection.ResourceId = PluginResourceChoices.Resolve(game.ControllerId, source); + /// + /// + /// + public static class PluginResourceChoices + { + /// + /// Builds the choice catalog of displays a plugin can read: auto ("Automatic · this game's DMD") followed by every + /// original DMD of the request's game that offers at least one of . + /// + /// + /// Only root resources (no parents) with role dmd, view and the game's + /// controller <game id>/controller/0 are listed; each choice ID is the resource ID relative to that + /// controller, such as display/dmd0. Without a game (global settings) only auto is offered. The catalog is + /// marked incomplete ( false) unless the game's controller is currently + /// published, so a saved display is not rejected as missing while the game is stopped. Pure function of the request; + /// safe on any thread. + /// + /// The settings request; its snapshot supplies the displays. + /// The encodings the plugin can consume; displays offering none of them are left out. + /// The catalog; its revision changes whenever the listed IDs change. + public static PluginChoiceCatalog Displays(PluginChoiceRequest request, DisplayEncoding formats) + { + var automatic = new PluginChoice("auto", "Automatic · this game's DMD"); + if (!request.Game.HasValue) return new PluginChoiceCatalog("global", new[] { automatic }); + string controller = request.Game.Value.Id + "/controller/0"; + var choices = request.Resources.Where(resource => resource.Kind == ResourceKind.Display && resource.View == ResourceView.Original + && resource.Parents.Count == 0 && resource.Controller == controller && resource.Role == "dmd" && (resource.Formats & formats) != 0) + .Select(resource => new PluginChoice(resource.Id.Substring(controller.Length + 1), resource.Label)).Prepend(automatic).ToArray(); + return new PluginChoiceCatalog(string.Join("|", choices.Select(choice => choice.Id)), choices, request.Resources.Any(resource => resource.Kind == ResourceKind.Controller && resource.Controller == controller)); + } + /// + /// Turns a saved relative display choice into the absolute resource ID to put in + /// . Do not pass auto; leave the selection's ID empty instead. + /// + /// The running game's controller ID, . + /// The saved choice, such as display/dmd0. + /// and joined by a slash. + /// + /// does not start with display/ or contains .., : or a backslash, so a + /// tampered setting cannot point at another game's or another kind of resource. + /// + public static string Resolve(string controller, string relative) + { + if (!relative.StartsWith("display/", StringComparison.Ordinal) || relative.Contains("..") || relative.Contains(":") || relative.Contains("\\")) + throw new ArgumentException("Select a display belonging to this game's controller."); + return controller + "/" + relative; + } + } +} diff --git a/VisualPinball.Plugins/Resources/PluginResourceChoices.cs.meta b/VisualPinball.Plugins/Resources/PluginResourceChoices.cs.meta new file mode 100644 index 000000000..b4bf44592 --- /dev/null +++ b/VisualPinball.Plugins/Resources/PluginResourceChoices.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 1f1eb7ad8e27f344599ed1486450e5fe \ No newline at end of file diff --git a/VisualPinball.Plugins/Resources/PluginResourceRegistry.cs b/VisualPinball.Plugins/Resources/PluginResourceRegistry.cs new file mode 100644 index 000000000..8b5f86045 --- /dev/null +++ b/VisualPinball.Plugins/Resources/PluginResourceRegistry.cs @@ -0,0 +1,398 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// SPDX-License-Identifier: MIT +// Licensed under MIT. See https://opensource.org/license/mit. + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace VisualPinball.Plugins +{ + /// + /// The directory of live resources (displays, controller events, audio, segment displays, surfaces…) that producers + /// publish and consumers bind to by description rather than by reference. + /// + /// + /// + /// The Player owns one registry and passes it to plugins as and + /// . For each game the Unity integration publishes the gamelogic engine's + /// controller, native outputs, audio, DMDs and segment displays under <game id>/controller/0. Plugins + /// publish their own: the DMD plugin a colorized presentation of a DMD, PinUP a surface per screen, the B2S plugin its + /// backglass surface. Consumers describe what they want with a and + /// to it; the binding follows resources as they come and go, so producers and consumers can start + /// in any order. + /// + /// + /// A resource can be derived from others (). It stays visible only while + /// every parent is still published with the same and the same + /// , so a presentation built on a DMD disappears when that DMD is replaced. + /// + /// Not thread-safe: publish, bind and observe on the owner thread. + /// + /// A plugin publishes a surface for the running game and withdraws it with the game: + /// + /// game.Lifetime.Own(game.Resources.Publish(new PluginResourceDescriptor(game.ControllerId + "/surface/my-screen", _application.Plugin.Id, + /// game.ControllerId, "My screen", "backglass", ResourceKind.Surface, game.Session, Interlocked.Increment(ref _instances)), surface)); + /// + /// + /// + public sealed class PluginResourceRegistry + { + internal sealed class Entry + { + internal readonly PluginResourceDescriptor Descriptor; + internal readonly IPluginResourceSource Source; + internal readonly Dictionary ParentInstances; + internal Entry(PluginResourceDescriptor descriptor, IPluginResourceSource source, Dictionary parents) { Descriptor = descriptor; Source = source; ParentInstances = parents; } + } + private readonly Dictionary _entries = new Dictionary(StringComparer.Ordinal); + private readonly List> _observers = new List>(); + private readonly Action _fault; + /// Creates an empty registry. The Player creates one for the application. + /// + /// Receives exceptions thrown by observers, binding Changed handlers and attachment cleanup, which are caught so + /// one faulty plugin cannot break the others. When null, they are silently dropped. + /// + public PluginResourceRegistry(Action? fault = null) { _fault = fault ?? (_ => { }); } + internal void Report(Exception error) => _fault(error); + /// + /// Returns the descriptors of all visible resources, sorted by ID. Derived resources whose parents are gone or were + /// replaced are left out. + /// + /// + /// The Player passes this list to settings providers as , so choices such as + /// can list the running game's displays. + /// + public IReadOnlyList Snapshot() => _entries.Values.Where(entry => Reachable(entry, new HashSet())).Select(entry => entry.Descriptor).OrderBy(descriptor => descriptor.Id, StringComparer.Ordinal).ToArray(); + /// + /// Returns the descriptors a binding with would consider, sorted by ID: none means a + /// binding would wait, more than one means it would be blocked until the user picks one. + /// + /// + /// Useful to probe before binding. The B2S plugin checks whether the controller reports native outputs and binds to + /// if so, otherwise to the mapped events. + /// Unlike a binding, this does not filter by source interface. + /// + /// What to look for. + public IReadOnlyList Snapshot(PluginResourceSelection selection) => Candidates(selection).Select(entry => entry.Descriptor).ToArray(); + internal IReadOnlyList Candidates(PluginResourceSelection selection) + { + var found = _entries.Values.Where(entry => selection.Matches(entry.Descriptor) && Reachable(entry, new HashSet())).OrderBy(entry => entry.Descriptor.Id, StringComparer.Ordinal).ToList(); + if (selection.Intent == ResourceView.Presentation && found.Any(entry => entry.Descriptor.View == ResourceView.Presentation)) + found.RemoveAll(entry => entry.Descriptor.View != ResourceView.Presentation); + return found; + } + /// + /// Publishes a resource until the returned handle is disposed, and notifies observers and bindings with + /// . Disposing the handle removes it again with + /// . + /// + /// + /// The source must implement the payload contract of the descriptor's kind: for + /// , for + /// and , , , + /// , or for the others. + /// Parents must be published first. To replace a resource, dispose the old publication and publish again with a new + /// , so bindings rebind and derived resources built on the old one + /// disappear. Publishing does not take ownership of the source; dispose it separately. + /// + /// The source type. + /// Identity and metadata of the resource. Its ID must be unique in the registry. + /// The object consumers receive; its must equal the descriptor's. + /// The publication handle; disposing it more than once does nothing. + /// + /// The source's kind differs from the descriptor's, or the source does not implement the kind's contract. + /// + /// + /// The ID is already published, a parent is not published, or the parents would form a cycle. + /// + public IDisposable Publish(PluginResourceDescriptor descriptor, T source) where T : IPluginResourceSource + { + if (descriptor.Kind != source.Kind) throw new ArgumentException("Resource kind and VPE source interface disagree."); + if (descriptor.Kind == ResourceKind.Display && !(source is IPluginDisplaySource) + || (descriptor.Kind == ResourceKind.Controller || descriptor.Kind == ResourceKind.Outputs) && !(source is IPluginControllerSource) + || descriptor.Kind == ResourceKind.Audio && !(source is IPluginAudioSource) + || descriptor.Kind == ResourceKind.Segments && !(source is IPluginSegmentSource) + || descriptor.Kind == ResourceKind.Input && !(source is IPluginInputSource) + || descriptor.Kind == ResourceKind.Surface && !(source is IPluginSurface) + || descriptor.Kind == ResourceKind.Triggers && !(source is IPluginTriggerSource)) throw new ArgumentException("The resource must implement its VPE payload contract."); + if (_entries.ContainsKey(descriptor.Id)) throw new InvalidOperationException("Resource already published: " + descriptor.Id); + foreach (string parent in descriptor.Parents) + if (!_entries.ContainsKey(parent)) throw new InvalidOperationException("Publish the source before its derived resource: " + parent); + foreach (string parent in descriptor.Parents) + if (parent == descriptor.Id || Reaches(parent, descriptor.Id, new HashSet())) throw new InvalidOperationException("Resource derivation cycle: " + descriptor.Id); + var parentInstances = descriptor.Parents.ToDictionary(id => id, id => _entries.TryGetValue(id, out var parent) ? parent.Descriptor.Instance : 0L, StringComparer.Ordinal); + var entry = new Entry(descriptor, source, parentInstances); + _entries.Add(descriptor.Id, entry); + Notify(new PluginResourceChange(ResourceChangeKind.Added, descriptor)); + return new Subscription(() => { + if (_entries.TryGetValue(descriptor.Id, out var current) && ReferenceEquals(current, entry)) { + _entries.Remove(descriptor.Id); Notify(new PluginResourceChange(ResourceChangeKind.Removed, descriptor)); + } + }); + } + private bool Reaches(string from, string target, HashSet visited) => visited.Add(from) && _entries.TryGetValue(from, out var entry) + && entry.Descriptor.Parents.Any(parent => parent == target || Reaches(parent, target, visited)); + private bool Reachable(Entry entry, HashSet path) + { + if (!path.Add(entry.Descriptor.Id)) return false; + foreach (string parentId in entry.Descriptor.Parents) { + if (!_entries.TryGetValue(parentId, out var parent) || !Reachable(parent, path)) return false; + if (parent.Descriptor.Session != entry.Descriptor.Session) return false; + long expected = entry.ParentInstances[parentId]; + if (expected != parent.Descriptor.Instance) return false; + path.Remove(parentId); + } + return true; + } + /// + /// Tells observers and bindings that a published resource changed in a way that may let a refused consumer bind + /// now, for example when a display gave up the identification encoding another consumer could not accept. + /// Bindings that are blocked or waiting on the resource retry; bound bindings are unaffected. + /// + /// The resource's ; unknown IDs are ignored. + public void NotifyChanged(string resourceId) + { + if (_entries.TryGetValue(resourceId, out var entry)) Notify(new PluginResourceChange(ResourceChangeKind.Changed, entry.Descriptor)); + } + /// + /// Calls on every publication, withdrawal and change notice until the returned handle is + /// disposed: , , and + /// from . Existing resources + /// are not replayed; read first. + /// + /// + /// Bindings use this internally. Use it directly to follow a varying number of resources, as the B2S plugin does to + /// attach to every segment display of the game. Exceptions from the callback go to the registry's fault handler. + /// + /// Called synchronously on the publishing thread. + /// A handle that stops the observation. + public IDisposable Observe(Action changed) + { + _observers.Add(changed); + return new Subscription(() => _observers.Remove(changed)); + } + private void Notify(PluginResourceChange change) { foreach (var observer in _observers.ToArray()) try { observer(change); } catch (Exception error) { Report(error); } } + /// + /// Creates a binding that tracks the single resource matching whose source implements + /// . The binding resolves immediately and again on every registry change; dispose it to stop. + /// + /// The payload contract the consumer needs, such as . + /// What to bind to. It is copied, so later changes to it have no effect. + /// The binding; own it by the game's . + /// + /// + /// PinUP binds the game's DMD for identification frames: + /// + /// var displays = new PluginResourceSelection(ResourceKind.Display) { Controller = game.ControllerId, Role = "dmd", Session = game.Session, + /// Intent = ResourceView.Identification, Accepts = DisplayEncoding.Gray2 | DisplayEncoding.Gray4 }; + /// var dmd = game.Lifetime.Own(game.Resources.Bind<IPluginDisplaySource>(displays)); + /// game.Lifetime.Own(dmd.SubscribeFrames(frame => RenderDmdShades(frame))); + /// + /// + /// + public PluginResourceBinding Bind(PluginResourceSelection selection) where T : class, IPluginResourceSource => new PluginResourceBinding(this, selection); + } + + /// + /// A consumer's live connection to the one resource that matches a selection. It rebinds automatically when that resource + /// is published, replaced or withdrawn, and for displays it holds the negotiated view while bound. + /// + /// + /// + /// Created by . The binding is + /// while nothing matches, when exactly one source matches, + /// when several match (the user must choose one) or the source refused the + /// requested view, and after . + /// explains the non-bound states; plugins log it or show it in their status. + /// + /// + /// When a display binds, it calls with the selection's + /// and and keeps the lease + /// until it unbinds. For an identification binding on a PinMAME DMD this switches the ROM's frames to raw shades. + /// + /// Not thread-safe: use it on the owner thread, where registry changes and frames arrive. + /// + /// The B2S plugin connects to controller events and the source is re-attached whenever the binding rebinds: + /// + /// var binding = game.Lifetime.Own(game.Resources.Bind<IPluginControllerSource>(selection)); + /// game.Lifetime.Own(binding.Connect(source => source.Subscribe(ControllerEvent))); + /// + /// + /// + /// The payload contract the consumer uses. + public sealed class PluginResourceBinding : IDisposable where T : class, IPluginResourceSource + { + private readonly PluginResourceRegistry _registry; + private readonly PluginResourceSelection _selection; + private readonly IDisposable _watch; + private IDisposable? _view; + private readonly List> _receivers = new List>(); + private readonly List _attached = new List(); + /// The bound source, or null unless is . + public T? Source { get; private set; } + /// + /// Descriptor of the bound resource, or null unless bound. The DMD plugin reads it to derive its presentation resource + /// from the bound DMD. + /// + public PluginResourceDescriptor? Descriptor { get; private set; } + /// Whether the binding is waiting, bound, blocked or disposed; see the class remarks. + public ResourceBindingState State { get; private set; } + /// + /// Human-readable reason when the binding is not bound or the bound display sends frames in an encoding the selection + /// does not accept, such as Select a resource: … or Missing resource: …. Empty when everything is fine. + /// + public string Diagnostic { get; private set; } = ""; + /// + /// Encoding of the most recent frame received through , whether it + /// was accepted or not. before the first frame and after unbinding. + /// + public DisplayEncoding ActualEncoding { get; private set; } + /// The encodings this binding accepts, from . + public DisplayEncoding AcceptedFormats => _selection.Accepts; + /// + /// Raised on the owner thread when the binding re-resolves (bound, unbound, blocked, rebound to a new instance) and when + /// or change because of a frame. Handler exceptions go to the + /// registry's fault handler. All handlers are dropped on . + /// + /// + /// The B2S plugin clears the live DMD on the glass here, so a lost source does not leave its last frame behind; the DMD + /// plugin logs when the binding is not bound. + /// + public event Action? Changed; + internal PluginResourceBinding(PluginResourceRegistry registry, PluginResourceSelection selection) + { + _registry = registry; _selection = selection.Copy(); + _watch = registry.Observe(Refresh); + Refresh(); + } + internal bool AcceptFrame(in PluginDisplayFrame frame) + { + if (State != ResourceBindingState.Bound || Descriptor == null || frame.Instance != Descriptor.Instance) return false; + bool compatible = (frame.Encoding & _selection.Accepts) != 0; + string diagnostic = compatible ? "" : "Source is producing " + frame.Encoding + "; accepted formats: " + _selection.Accepts + "."; + if (ActualEncoding != frame.Encoding || Diagnostic != diagnostic) { + ActualEncoding = frame.Encoding; Diagnostic = diagnostic; + try { Changed?.Invoke(); } catch (Exception error) { _registry.Report(error); } + } + return compatible; + } + /// + /// Attaches a receiver to whatever source is bound, now and after every rebind. is called + /// with the current source and returns the subscription to dispose when that source goes away. + /// + /// + /// Connecting or disconnecting a receiver re-attaches all receivers of this binding. For display frames use + /// , which also checks the frame's instance and encoding. + /// + /// Subscribes to a source and returns the handle that undoes it. + /// A handle that detaches the receiver. + /// The binding has been disposed. + public IDisposable Connect(Func subscribe) + { + if (State == ResourceBindingState.Disposed) throw new ObjectDisposedException(nameof(PluginResourceBinding)); + _receivers.Add(subscribe); + RefreshAttachments(); + return new Subscription(() => { _receivers.Remove(subscribe); RefreshAttachments(); }); + } + private bool _refreshing, _refreshAgain; + // ID of the resource whose view this binding is releasing, while it does. + private string? _releasingId; + private void Refresh(PluginResourceChange change) + { + // A change notice for a resource this binding does not select cannot affect it, and the one caused by releasing its + // own view says nothing new to it. Retrying on either would repeat a failed attempt forever, for example when an + // engine-wide format lease notifies every display of the engine. Publications and withdrawals always count. + if (change.Kind == ResourceChangeKind.Changed && (change.Descriptor.Id == _releasingId || !_selection.Matches(change.Descriptor))) return; + Refresh(); + } + private void Refresh() + { + // Notices during a pass are handled by one more pass afterwards, which reads the candidates again. + if (_refreshing) { _refreshAgain = true; return; } + _refreshing = true; + try { + do { _refreshAgain = false; RefreshOnce(); } while (_refreshAgain && State != ResourceBindingState.Disposed); + } finally { _refreshing = false; } + } + private void RefreshOnce() + { + if (State == ResourceBindingState.Disposed) return; + var candidates = _registry.Candidates(_selection).Where(entry => entry.Source is T).ToArray(); + if (candidates.Length == 1 && ReferenceEquals(Source, candidates[0].Source) && Descriptor?.Instance == candidates[0].Descriptor.Instance) return; + Detach(); + // Releasing the old view runs observers, which may have disposed this binding. + if (State == ResourceBindingState.Disposed) return; + if (candidates.Length == 0) { State = ResourceBindingState.Waiting; Diagnostic = _selection.ResourceId.Length == 0 ? "Waiting for a compatible resource." : "Missing resource: " + _selection.ResourceId; } + else if (candidates.Length > 1) { State = ResourceBindingState.Blocked; Diagnostic = "Select a resource: " + string.Join(", ", candidates.Select(entry => entry.Descriptor.Id)); } + else { + Source = (T)candidates[0].Source; + Descriptor = candidates[0].Descriptor; + try { + _view = (Source as IPluginDisplaySource)?.AcquireView(_selection.Intent, _selection.Accepts); + State = ResourceBindingState.Bound; Diagnostic = ""; RefreshAttachments(); + } catch (Exception error) { + Detach(); + if (State == ResourceBindingState.Disposed) return; + State = ResourceBindingState.Blocked; Diagnostic = error.Message; + } + } + try { Changed?.Invoke(); } catch (Exception error) { _registry.Report(error); } + } + private void RefreshAttachments() + { + ClearAttachments(); + if (Source != null && State != ResourceBindingState.Disposed) + foreach (var receive in _receivers) _attached.Add(receive(Source)); + } + private void Detach() + { + ClearAttachments(); + _releasingId = Descriptor?.Id; + try { _view?.Dispose(); } catch (Exception error) { _registry.Report(error); } finally { _releasingId = null; } + _view = null; Source = null; Descriptor = null; ActualEncoding = DisplayEncoding.None; + } + private void ClearAttachments() + { + foreach (var attached in _attached) try { attached.Dispose(); } catch (Exception error) { _registry.Report(error); } + _attached.Clear(); + } + /// + /// Stops following the registry, detaches all receivers, releases the view lease and sets to + /// . Does not raise . Later calls do nothing. + /// + public void Dispose() + { + if (State == ResourceBindingState.Disposed) return; + // Disposed first, so a refresh that is running further up the stack does not revive the binding. + State = ResourceBindingState.Disposed; + _watch.Dispose(); Detach(); _receivers.Clear(); Changed = null; + } + } + + /// Display-specific helpers for . + public static class DisplayBindingExtensions + { + /// + /// Receives the frames of whatever display the binding is bound to, across rebinds. Frames from a previous instance and + /// frames in an encoding the selection does not accept are dropped; the latter set . + /// + /// + /// The frame and its pixels are borrowed: copy what you need before returning. Called synchronously on the thread that + /// publishes the frame, the owner thread for VPE's sources. + /// + /// + /// game.Lifetime.Own(binding.SubscribeFrames(frame => _bridge.FeedFrame(frame, displayId))); + /// + /// + /// + /// A display binding. + /// Called for every accepted frame. + /// A handle that stops delivery; own it by the same scope as the binding. + /// The binding has been disposed. + public static IDisposable SubscribeFrames(this PluginResourceBinding binding, Action receive) + => binding.Connect(source => source.Subscribe(frame => { if (binding.AcceptFrame(frame)) receive(frame); })); + } +} diff --git a/VisualPinball.Plugins/Resources/PluginResourceRegistry.cs.meta b/VisualPinball.Plugins/Resources/PluginResourceRegistry.cs.meta new file mode 100644 index 000000000..1ee824411 --- /dev/null +++ b/VisualPinball.Plugins/Resources/PluginResourceRegistry.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 264d29b3f89386e47937efa2917ce357 \ No newline at end of file diff --git a/VisualPinball.Plugins/Resources/ResourceContracts.cs b/VisualPinball.Plugins/Resources/ResourceContracts.cs new file mode 100644 index 000000000..1a4c8a043 --- /dev/null +++ b/VisualPinball.Plugins/Resources/ResourceContracts.cs @@ -0,0 +1,965 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// SPDX-License-Identifier: MIT +// Licensed under MIT. See https://opensource.org/license/mit. + +using System; +using System.Collections.Generic; + +namespace VisualPinball.Plugins +{ + /// + /// The type of data a resource carries. It decides which source interface the published object must implement and + /// is the first thing a filters on. + /// + /// + /// rejects a source that does not implement the interface of its kind. + /// VPE publishes the game's controller, outputs, displays, segment displays and ROM audio when a game is prepared; + /// plugins publish surfaces (PinUP screens, the B2S backglass) and derived displays (the DMD plugin's colorized view). + /// + /// carries every controller output by its native number through . + /// It is separate from , the outputs as the table maps them, so a controller binding without a + /// role stays unambiguous. + /// + /// + public enum ResourceKind + { + /// + /// The game controller's switch, coil, lamp and GI events as the table maps them, addressed by the engine's IDs. + /// The source implements . VPE publishes one per game with the controller's ID + /// and the role controller; PinUP turns its events into PUP triggers. + /// + Controller, + /// + /// A dot-matrix or video display delivering s through . + /// VPE publishes each DMD as <controller>/display/<id> with the role dmd. + /// + Display, + /// + /// A segment (alphanumeric or numeric) display delivering s through + /// . VPE publishes each one as <controller>/segments/<id> with + /// the role segments; the B2S backglass draws them as its score digits. + /// + Segments, + /// + /// A PCM audio stream delivering s through . VPE publishes + /// an engine's own sound (PinMAME's ROM audio) as <controller>/audio/rom with the role rom-mix. + /// + Audio, + /// + /// Player input actions delivering s through . Nothing + /// in VPE publishes this kind yet. + /// + Input, + /// + /// An image other plugins can show, described by . PinUP publishes each PUP screen and + /// the B2S plugin its composed backglass as a surface. + /// + Surface, + /// + /// Named trigger events delivered as s through . + /// Nothing in VPE publishes this kind yet. + /// + Triggers, + /// + /// Every controller output by its native number, including outputs the table does not map, delivered through + /// . VPE publishes it as <controller>/outputs with the role + /// outputs for engines that report native outputs, such as PinMAME; the B2S backglass prefers it over + /// to drive ROM lamps and solenoids. + /// + Outputs + } + + /// + /// Which version of a resource's content is meant: what the controller produced, frames suited to recognizing that + /// content, or a version processed for showing. + /// + /// + /// A declares its view in , and a + /// states the view it wants in . The + /// difference matters for displays: PinUP and the DMD plugin's colorization need the controller's raw shades + /// (), while the B2S live DMD wants the colorized frames the DMD plugin publishes + /// (), or the original when there are none. + /// + public enum ResourceView + { + /// + /// As a descriptor view: the unmodified output of its producer, in whatever encoding the producer currently + /// delivers. As an intent: match only original resources that are not derived from another resource. This is the + /// default for both. + /// + Original, + /// + /// As an intent: match original, non-derived resources and, for displays, request unmodified + /// or frames through + /// for as long as the binding holds the source. Used to identify + /// content, such as PinUP triggers on DMD frames or the DMD plugin's colorization matching. As a descriptor view it + /// marks a resource whose frames are meant for identification; no VPE producer publishes that view yet. + /// + Identification, + /// + /// As a descriptor view: frames processed for showing, typically derived from an original resource listed in + /// , such as the DMD plugin's colorized RGB view of a ROM's DMD. As an + /// intent: match presentation resources, and original ones too when + /// is set. + /// + Presentation + } + + /// The state of a , readable through its State property. + public enum ResourceBindingState + { + /// + /// No published resource matches the selection yet. The binding attaches automatically when one is published. + /// + Waiting, + /// Exactly one resource matches and the binding's receivers are connected to it. + Bound, + /// + /// The binding cannot attach: either several resources match and the selection must name one (for example with + /// ), or the source refused the requested view. The binding's + /// Diagnostic says which. + /// + Blocked, + /// The binding was disposed and no longer follows the registry. + Disposed + } + + /// What happened to a resource, as reported to callbacks. + public enum ResourceChangeKind + { + /// The resource was published. + Added, + /// + /// The resource is still published but changed in a way that may let a refused consumer bind now, such as a display + /// giving up the raw encoding another consumer could not accept. Raised by + /// . A resource that is replaced is reported as + /// followed by instead. + /// + Changed, + /// The resource's publication was disposed. + Removed + } + + /// + /// Pixel encodings of display frames. As flags it lists the encodings a source can deliver or a consumer accepts; a + /// single always carries exactly one of them. + /// + /// + /// Producers advertise their encodings in , and consumers state theirs in + /// . PinMAME, for example, advertises + /// Gray2 | Gray4 | Luminance8, delivers by default and switches to raw + /// or while an identification consumer such as PinUP is bound. + /// + [Flags] + public enum DisplayEncoding + { + /// No encoding. Used for "nothing advertised" and "nothing negotiated"; never valid for a frame. + None = 0, + /// One byte per pixel holding a raw shade from 0 to 3, as a 2-bit DMD controller produces it. + Gray2 = 1, + /// One byte per pixel holding a raw shade from 0 to 15, as a 4-bit DMD controller produces it. + Gray4 = 2, + /// One byte per pixel holding a brightness from 0 to 255. + Luminance8 = 4, + /// Three bytes per pixel, in red, green, blue order, each from 0 to 255. + Rgb24 = 8, + /// + /// Four bytes per pixel, in red, green, blue, alpha order, each from 0 to 255. How alpha applies is given by + /// . + /// + Rgba32 = 16 + } + + /// The color space of a display frame's color values. + public enum FrameColorSpace + { + /// Values are linear light intensities. + Linear, + /// Values are sRGB-encoded. This is the default of . + Srgb + } + + /// How the alpha channel of a frame is to be interpreted. + public enum FrameAlpha + { + /// The frame has no meaningful alpha; treat it as opaque. This is the default. + None, + /// Color values are not multiplied by alpha. + Straight, + /// Color values are already multiplied by alpha. + Premultiplied + } + + /// The sample format of a . + public enum PcmEncoding + { + /// Signed 16-bit integer samples, little endian, two bytes each. + Int16LittleEndian, + /// 32-bit IEEE float samples, nominally from -1 to 1, four bytes each. PinMAME's ROM audio uses this. + Float32 + } + + /// What a reports. + public enum ControllerEventKind + { + /// A switch changed; the value is 1 when closed and 0 when open. + Switch, + /// A coil (solenoid) changed. + Coil, + /// A lamp changed. + Lamp, + /// A general illumination string changed. + GeneralIllumination, + /// Reserved for controller triggers; VPE does not emit it yet. + Trigger, + /// Reserved for controller state values; VPE does not emit it yet. + State, + /// Raw position of an emulated mech, as the controller reports it. + Mech, + /// Ends one controller poll; consumers that batch per poll apply what they collected. + PollEnd + } + /// What a controller event's raw value means. + /// + /// One controller can mix encodings within one output kind, so each event of an + /// resource carries its own semantics. PinMAME, for instance, reports physically faded lamps on some hardware + /// generations, binary lamps on others, and GI as levels from 0 to 8 on WPC. + /// + public enum ControllerValueSemantics + { + /// 0 or 1. + Binary, + /// Discrete levels from 0 to the event's maximum, such as WPC GI 0..8. + Levels, + /// Already faded physical brightness from 0 to the event's maximum. + Physical, + /// A position, such as a mech step; not a brightness. + Position + } + + /// + /// Describes one published resource: its stable ID, who published it, which game controller it belongs to, what it is + /// for and which version of the content it carries. + /// + /// + /// A producer creates a descriptor and passes it with its source to ; + /// consumers see descriptors in , in change notifications and on a bound + /// . VPE publishes the game's controller, outputs, displays, segment displays and + /// audio with IDs below the controller ID, such as pinmame:t2_l8/controller/0/display/dmd0. Descriptors are + /// immutable; to change one, dispose the publication and publish again with a new . + /// + /// The B2S plugin publishes its composed backglass for other plugins: + /// + /// game.Lifetime.Own(game.Resources.Publish(new PluginResourceDescriptor(game.ControllerId + "/surface/b2s-backglass", + /// _application.Plugin.Id, game.ControllerId, "B2S backglass", "backglass", ResourceKind.Surface, game.Session, 1), _surface)); + /// + /// + /// + public sealed class PluginResourceDescriptor + { + /// + /// Stable logical ID, unique among the resources published at the same time. By convention it starts with the + /// controller ID (see ), so saved choices can store the part after it. + /// A selection names a resource by this ID in . + /// + public string Id { get; } + /// + /// Who published the resource: the game identity's provider (such as pinmame) for resources VPE publishes + /// from the gamelogic engine, the plugin ID for resources a plugin publishes. Informational; selections do not + /// filter on it. + /// + public string Provider { get; } + /// + /// ID of the game controller the resource belongs to, normally . Selections + /// filter on it with . + /// + public string Controller { get; } + /// Human-readable name shown in settings, such as the display choices of the DMD and PinUP plugins. + public string Label { get; } + /// + /// What the resource is for, such as controller, outputs, dmd, segments, + /// rom-mix or backglass. Selections filter on it with . + /// + public string Role { get; } + /// The type of data the resource carries; the published source must implement the matching interface. + public ResourceKind Kind { get; } + /// + /// The game session the resource belongs to (see ). Selections filter on it, + /// and a derived resource is only visible while its parents belong to the same session. + /// + public long Session { get; } + /// + /// Generation of this publication. Use a new value every time a resource is published, including when the same + /// is published again; VPE uses a process-wide counter. Display frames carry it in + /// so bindings drop frames of an earlier publication, and a derived + /// resource is hidden when a parent is republished with another instance. + /// + public long Instance { get; } + /// Version of the resource contract the descriptor follows. Currently always 1. + public int ContractVersion { get; } = 1; + /// Which version of the content the resource carries; by default. + public ResourceView View { get; } + /// + /// For resources, every encoding the source can deliver. A display selection only + /// matches when it accepts at least one of them. for other kinds. + /// + public DisplayEncoding Formats { get; } + /// + /// IDs of the resources this one is derived from, such as the original DMD a colorized view is computed from; empty + /// for original resources. Parents must be published first. The registry hides a derived resource while any parent + /// is missing, has been republished with another or belongs to another . + /// + public IReadOnlyList Parents { get; } + /// Creates a descriptor. The parent list is copied. + /// The stable logical ID; see . + /// The publisher; see . + /// The controller ID the resource belongs to. + /// The human-readable name. + /// What the resource is for; see . + /// The type of data; must match the source's . + /// The game session. + /// The generation of this publication. + /// Which version of the content the resource carries. + /// For displays, the encodings the source can deliver. + /// IDs of the resources this one is derived from. + /// is null, empty or white space. + public PluginResourceDescriptor(string id, string provider, string controller, string label, string role, ResourceKind kind, + long session, long instance, ResourceView view = ResourceView.Original, DisplayEncoding formats = DisplayEncoding.None, params string[] parents) + { + if (string.IsNullOrWhiteSpace(id)) throw new ArgumentException("A resource needs a stable logical ID.", nameof(id)); + Id = id; Provider = provider; Controller = controller; Label = label; Role = role; Kind = kind; + Session = session; Instance = instance; View = view; Formats = formats; Parents = Array.AsReadOnly((string[])parents.Clone()); + } + } + + /// + /// Describes which resource a consumer wants. Empty strings and a zero session match anything, so a selection names + /// only what it cares about. + /// + /// + /// Pass it to to follow the matching resource as it is published, replaced + /// and removed, or to to list the current + /// matches. Bind copies the selection, so changing it afterwards has no effect on the binding. A binding attaches only + /// when exactly one resource matches; when several do, set . + /// + /// PinUP binds the game's DMD for raw shades, honouring a display the user picked: + /// + /// var displays = new PluginResourceSelection(ResourceKind.Display) { + /// Controller = game.ControllerId, Role = "dmd", Session = game.Session, + /// Intent = ResourceView.Identification, Accepts = DisplayEncoding.Gray2 | DisplayEncoding.Gray4 + /// }; + /// string selected = game.Settings.String("DmdSource", "auto"); + /// if (selected != "auto") displays.ResourceId = PluginResourceChoices.Resolve(game.ControllerId, selected); + /// var dmd = game.Lifetime.Own(game.Resources.Bind<IPluginDisplaySource>(displays)); + /// + /// + /// + public sealed class PluginResourceSelection + { + /// The kind of resource wanted. Always matched. + public ResourceKind Kind { get; } + /// + /// The exact wanted, or empty (the default) for any. Use it to pick one of + /// several matching resources, for example from a saved choice through . + /// With , it names the original display and also matches + /// the presentation views derived from it, which are then preferred, so a chosen display still shows colorized frames. + /// + public string ResourceId { get; set; } = ""; + /// The controller ID wanted, normally , or empty for any. + public string Controller { get; set; } = ""; + /// The wanted, such as dmd, or empty for any. + public string Role { get; set; } = ""; + /// + /// The game session wanted, normally , or 0 (the default) for any. Set it so a + /// binding never attaches to a resource of a previous game. + /// + public long Session { get; set; } + /// + /// The view wanted; by default. matches + /// original resources without parents. matches resources that are neither + /// presentation views nor derived, and makes a display binding request raw frames from the source. + /// matches presentation views, plus originals when + /// is set. + /// + public ResourceView Intent { get; set; } = ResourceView.Original; + /// + /// For selections, the encodings the consumer can read; all five concrete + /// encodings by default. A display only matches when its overlap. + /// A binding also passes it to , and + /// drops frames in other encodings. Ignored for other kinds. + /// + public DisplayEncoding Accepts { get; set; } = DisplayEncoding.Gray2 | DisplayEncoding.Gray4 | DisplayEncoding.Luminance8 | DisplayEncoding.Rgb24 | DisplayEncoding.Rgba32; + /// + /// With a intent, also match original resources. When no + /// is set and presentation views exist, the registry still prefers them, so the consumer + /// switches between a processed and the original version as producers come and go. The B2S live DMD uses it to show + /// the DMD plugin's colorized frames when available and the ROM's frames otherwise. False by default. + /// + public bool AllowOriginalFallback { get; set; } + /// Creates a selection that matches any resource of a kind; narrow it with the other properties. + /// The kind of resource wanted. + public PluginResourceSelection(ResourceKind kind) { Kind = kind; } + /// Returns an independent copy with the same criteria. + public PluginResourceSelection Copy() => new PluginResourceSelection(Kind) { + ResourceId = ResourceId, Controller = Controller, Role = Role, Session = Session, + Intent = Intent, Accepts = Accepts, AllowOriginalFallback = AllowOriginalFallback + }; + /// A presentation view derived from the chosen resource, wanted when the intent is presentation. + private bool PresentsChosen(PluginResourceDescriptor descriptor) + { + if (Intent != ResourceView.Presentation || descriptor.View != ResourceView.Presentation) return false; + foreach (string parent in descriptor.Parents) if (parent == ResourceId) return true; + return false; + } + internal bool Matches(PluginResourceDescriptor descriptor) + { + if (descriptor.Kind != Kind || ResourceId.Length > 0 && descriptor.Id != ResourceId && !PresentsChosen(descriptor) || Controller.Length > 0 && descriptor.Controller != Controller + || Role.Length > 0 && descriptor.Role != Role || Session != 0 && descriptor.Session != Session) return false; + if (Kind == ResourceKind.Display && (descriptor.Formats & Accepts) == 0) return false; + if (Intent == ResourceView.Identification) return descriptor.View != ResourceView.Presentation && descriptor.Parents.Count == 0; + if (Intent == ResourceView.Presentation) return descriptor.View == ResourceView.Presentation || AllowOriginalFallback && descriptor.View == ResourceView.Original; + return descriptor.View == ResourceView.Original && descriptor.Parents.Count == 0; + } + } + + /// A resource was published, changed or removed, as reported to callbacks. + /// + /// The B2S plugin observes the registry to attach every segment display of the game as soon as it is published, since + /// segment displays can appear after the game has started. + /// + public readonly struct PluginResourceChange + { + /// Whether the resource was added, changed or removed. + public ResourceChangeKind Kind { get; } + /// The descriptor of the resource that changed. + public PluginResourceDescriptor Descriptor { get; } + /// Creates a change notification. Called by the registry. + /// What happened. + /// The resource it happened to. + public PluginResourceChange(ResourceChangeKind kind, PluginResourceDescriptor descriptor) { Kind = kind; Descriptor = descriptor; } + } + + /// + /// Base of every resource source. A published object implements the interface of its kind, such as + /// , which derives from this one. + /// + /// + /// Plugins rarely implement the kind interfaces themselves: , + /// , , , + /// and handle subscriptions and dispatch. + /// Surfaces have no ready-made implementation; PinUP and the B2S plugin implement . + /// + public interface IPluginResourceSource + { + /// + /// The kind of data this source carries. Must equal the it is published with. + /// + ResourceKind Kind { get; } + } + + /// One display frame. Borrowed row-major frame memory is valid only during the synchronous subscriber call. + /// + /// Producers usually reuse the pixel buffer for the next frame, so a consumer that needs the pixels later must copy them + /// before returning, as the DMD plugin does when it hands frames to its LibDmd worker. The constructor validates that + /// the buffer is large enough for the declared shape. + /// + /// Publishing a 128x32 frame of 4-bit shades: + /// + /// source.Publish(new PluginDisplayFrame(shades, 128, 32, 128, DisplayEncoding.Gray4, ++_sequence, timestampUs, descriptor.Instance)); + /// + /// + /// + public readonly struct PluginDisplayFrame + { + /// + /// The pixels, row by row, each row starting bytes after the previous one. Borrowed for the + /// duration of the subscriber call only. + /// + public ReadOnlyMemory Pixels { get; } + /// Width in pixels; greater than zero. + public int Width { get; } + /// Height in pixels; greater than zero. + public int Height { get; } + /// Bytes from the start of one row to the start of the next; at least the width times the bytes per pixel. + public int Stride { get; } + /// The single concrete encoding of . + public DisplayEncoding Encoding { get; } + /// The color space of the values; by default. + public FrameColorSpace ColorSpace { get; } + /// How to interpret alpha for ; by default. + public FrameAlpha Alpha { get; } + /// + /// Increasing number assigned by the producer. VPE's gamelogic resources share one counter across all their events, + /// so gaps do not mean dropped frames. + /// + public long Sequence { get; } + /// + /// When the frame was produced, in microseconds of a monotonic clock (VPE uses Stopwatch). Not wall-clock + /// time; compare only values of the same producer. + /// + public long TimestampUs { get; } + /// + /// The of the publication that produced the frame. + /// drops frames whose instance differs from the bound one. + /// + public long Instance { get; } + /// Creates a frame over a borrowed buffer. + /// The pixel buffer; at least times bytes. + /// Width in pixels. + /// Height in pixels. + /// Bytes per row, including any padding. + /// Exactly one concrete encoding. + /// The producer's increasing frame number. + /// Monotonic time in microseconds. + /// The publication's . + /// The color space of the values. + /// How to interpret alpha. + /// + /// The encoding is not a single concrete encoding, or the size, stride or buffer length do not fit together. + /// + public PluginDisplayFrame(ReadOnlyMemory pixels, int width, int height, int stride, DisplayEncoding encoding, + long sequence, long timestampUs, long instance, FrameColorSpace colorSpace = FrameColorSpace.Srgb, FrameAlpha alpha = FrameAlpha.None) + { + int bytes = encoding == DisplayEncoding.Rgb24 ? 3 : encoding == DisplayEncoding.Rgba32 ? 4 : 1; + if (encoding != DisplayEncoding.Gray2 && encoding != DisplayEncoding.Gray4 && encoding != DisplayEncoding.Luminance8 + && encoding != DisplayEncoding.Rgb24 && encoding != DisplayEncoding.Rgba32) throw new ArgumentException("A frame needs one concrete VPE encoding."); + if (width <= 0 || height <= 0 || stride < checked(width * bytes) || pixels.Length < checked(stride * height)) throw new ArgumentException("Display frame shape does not match its buffer."); + Pixels = pixels; Width = width; Height = height; Stride = stride; Encoding = encoding; Sequence = sequence; + TimestampUs = timestampUs; Instance = instance; ColorSpace = colorSpace; Alpha = alpha; + } + } + + /// A source of display frames, published as . + /// + /// VPE publishes each DMD of the game's engine; the DMD plugin publishes a colorized + /// view derived from it. Producers normally use . Consumers should bind through + /// and receive frames with + /// , which also checks encoding and instance. + /// + /// + /// var dmd = game.Lifetime.Own(game.Resources.Bind<IPluginDisplaySource>(selection)); + /// game.Lifetime.Own(dmd.SubscribeFrames(frame => Render(frame.Pixels.Span, frame.Width, frame.Height, frame.Stride))); + /// + /// + /// + public interface IPluginDisplaySource : IPluginResourceSource + { + /// + /// Registers a receiver for every frame. It is called synchronously on the producer's thread (the main thread for + /// VPE's displays) and must not keep the borrowed pixels. + /// + /// Called with each frame. + /// Disposing it stops the receiver. + IDisposable Subscribe(Action receive); + /// + /// Declares the view a consumer needs while it is attached; bindings call it for you. Acquiring an identification + /// view owns format negotiation until the returned lease is disposed: the source switches to raw shades the consumer + /// accepts (see ). Other views do not change the source. + /// + /// The view the consumer wants. + /// The encodings the consumer can read. + /// A lease; disposing it releases the request. + /// + /// The source cannot deliver an identification view in an accepted encoding, for example because another consumer + /// already holds it in an encoding this one does not accept. A binding then becomes + /// . + /// + IDisposable AcquireView(ResourceView intent, DisplayEncoding acceptedFormats); + } + + /// One event of a game controller: a switch, coil, lamp, GI, mech or end-of-poll notification. + /// + /// Events of a resource are the table's mapped events: is the + /// engine's ID of the switch, coil or lamp, is -1 and is the engine's value + /// (0 or 1 for switches, the 0..1 strength for coils, the engine's own range for lamps such as 0..255 for PinMAME). + /// + /// Events of an resource address outputs by the controller's own numbering + /// () and describe their raw value per address, because one controller can mix binary and + /// physically faded outputs of the same kind. Each poll ends with a event. + /// + /// + /// The B2S backglass normalizes a native lamp to 0..1: + /// + /// if (value.Kind == ControllerEventKind.Lamp && value.Number >= 0) SetLamp(value.Number, (float)(value.Value / value.Maximum)); + /// + /// + /// + public readonly struct PluginControllerEvent + { + /// What the event reports. + public ControllerEventKind Kind { get; } + /// + /// The output's ID: the engine's switch, coil or lamp ID for mapped events, the native number as text for native + /// ones, and empty for . + /// + public string Id { get; } + /// The raw value; see the type remarks for mapped events and for native ones. + public double Value { get; } + /// + /// Increasing number assigned by the producer. VPE shares one counter across all events of a game's resources. + /// + public long Sequence { get; } + /// When the event was emitted, in microseconds of a monotonic clock; not wall-clock time. + public long TimestampUs { get; } + /// Controller-native output number, or -1 when the event is addressed by only. + public int Number { get; } + /// + /// What means. Only meaningful for native events; mapped events are created with + /// whatever their actual range. + /// + public ControllerValueSemantics Semantics { get; } + /// + /// Raw value of a fully on output (or the largest level); 1 for binary outputs. Always greater than zero: the + /// constructor replaces zero or negative values with 1. + /// + public double Maximum { get; } + /// Creates a mapped event, addressed by ID, with no native number, binary semantics and a maximum of 1. + /// What the event reports. + /// The engine's ID of the output. + /// The engine's value. + /// The producer's increasing event number. + /// Monotonic time in microseconds. + public PluginControllerEvent(ControllerEventKind kind, string id, double value, long sequence, long timestampUs) + : this(kind, id, value, sequence, timestampUs, -1, ControllerValueSemantics.Binary, 1) { } + /// Creates an event addressed by native number, with its value semantics. + /// What the event reports. + /// The output's ID, usually the number as text. + /// The raw value. + /// The producer's increasing event number. + /// Monotonic time in microseconds. + /// The controller-native output number, or -1. + /// What means. + /// The value of a fully on output; values of zero or below become 1. + public PluginControllerEvent(ControllerEventKind kind, string id, double value, long sequence, long timestampUs, int number, ControllerValueSemantics semantics, double maximum) + { Kind = kind; Id = id; Value = value; Sequence = sequence; TimestampUs = timestampUs; Number = number; Semantics = semantics; Maximum = maximum > 0 ? maximum : 1; } + } + /// + /// A source of controller events, published as (mapped events) or + /// (native outputs). + /// + /// + /// VPE publishes both for the game's engine using . PinUP subscribes to the mapped + /// events to fire PUP triggers; the B2S backglass prefers the native outputs and falls back to mapped events. + /// + /// + /// var controller = game.Lifetime.Own(game.Resources.Bind<IPluginControllerSource>( + /// new PluginResourceSelection(ResourceKind.Controller) { Controller = game.ControllerId, Session = game.Session })); + /// game.Lifetime.Own(controller.Connect(source => source.Subscribe(OnControllerEvent))); + /// + /// + /// + public interface IPluginControllerSource : IPluginResourceSource + { + /// + /// Registers a receiver for every event. It is called synchronously on the producer's thread; VPE publishes on the + /// main thread. + /// + /// Called with each event. + /// Disposing it stops the receiver. + IDisposable Subscribe(Action receive); + } + + /// One block of PCM audio. The sample memory is borrowed for the synchronous subscriber call only. + /// + /// Samples are interleaved: each sample frame holds one sample per channel. PinMAME publishes every block its sound + /// board renders as , from the emulator's audio callback rather than the main thread. + /// + /// + /// int length = frames * channels * sizeof(float); + /// source.Publish(new PluginPcmFrame(buffer.AsMemory(0, length), PcmEncoding.Float32, 48000, channels, frames, ++_sequence)); + /// + /// + /// + public readonly struct PluginPcmFrame + { + /// The interleaved samples, exactly times samples long. + public ReadOnlyMemory Samples { get; } + /// The sample format. + public PcmEncoding Encoding { get; } + /// Sample frames per second, in Hz; greater than zero. + public int SampleRate { get; } + /// Number of interleaved channels; greater than zero. + public int Channels { get; } + /// Number of sample frames in the block (samples per channel); zero or more. + public int Frames { get; } + /// Increasing block number assigned by the producer. + public long Sequence { get; } + /// When the block was produced, in microseconds of a monotonic clock, or 0 when unknown (PinMAME leaves it 0). + public long TimestampUs { get; } + /// Free-form description of the channel order, or empty when unspecified. + public string ChannelLayout { get; } + /// Creates a block over borrowed samples. + /// The interleaved samples. + /// The sample format. + /// Sample frames per second. + /// Number of interleaved channels. + /// Number of sample frames. + /// The producer's increasing block number. + /// Monotonic time in microseconds, or 0 when unknown. + /// Description of the channel order, or empty. + /// + /// The rate or channel count is not positive, the frame count is negative, or the sample length does not equal + /// frames times channels times bytes per sample. + /// + public PluginPcmFrame(ReadOnlyMemory samples, PcmEncoding encoding, int sampleRate, int channels, int frames, long sequence, long timestampUs = 0, string channelLayout = "") + { + if (sampleRate <= 0 || channels <= 0 || frames < 0 || samples.Length != checked(frames * channels * (encoding == PcmEncoding.Float32 ? 4 : 2))) + throw new ArgumentException("PCM frame shape does not match its samples."); + Samples = samples; Encoding = encoding; SampleRate = sampleRate; Channels = channels; Frames = frames; Sequence = sequence; TimestampUs = timestampUs; ChannelLayout = channelLayout; + } + } + /// A source of PCM audio, published as . + /// + /// VPE publishes the engine's own sound, such as PinMAME's ROM audio, as <controller>/audio/rom with the + /// role rom-mix. Producers normally use , which also implements + /// . + /// + public interface IPluginAudioSource : IPluginResourceSource + { + /// + /// Registers a receiver for every block. It is called synchronously on the producer's thread, which for PinMAME is + /// the emulator's audio thread, so it must be fast, thread-safe and copy any samples it keeps. + /// + /// Called with each block. + /// Disposing it stops the receiver. + IDisposable Subscribe(Action receive); + } + /// An exclusive lease replaces built-in playback while leaving the original PCM stream available. + /// + /// Implemented by audio sources whose producer can stop playing its sound itself, such as + /// . A plugin that plays or processes the sound on its own (for example to send it to a + /// different device) casts the bound source to this interface and holds a lease for as long as it plays; PinMAME then + /// silences its own output but keeps publishing PCM. + /// + /// + /// var audio = game.Lifetime.Own(game.Resources.Bind<IPluginAudioSource>(new PluginResourceSelection(ResourceKind.Audio) { + /// Controller = game.ControllerId, Role = "rom-mix", Session = game.Session })); + /// if (audio.Source is IPluginAudioPlaybackControl control) game.Lifetime.Own(control.AcquirePlayback(_application.Plugin.Id)); + /// + /// + /// + public interface IPluginAudioPlaybackControl + { + /// Takes over playback until the returned lease is disposed, which restores the producer's own playback. + /// ID of the plugin taking over, reported to a plugin that tries to take over as well. + /// The lease. + /// Another plugin already holds the lease. + IDisposable AcquirePlayback(string pluginId); + } + /// A segment display. Its place and size are known when it is published, before any frame arrives. + /// + /// VPE publishes one per segment display the engine announces, as <controller>/segments/<id> with the + /// role segments, using . The B2S backglass concatenates the digits of all + /// displays in onto its score digits, so it needs every display's position and size before the + /// first frame of any of them arrives. + /// + public interface IPluginSegmentSource : IPluginResourceSource + { + /// Position among the controller's segment displays, in the order the controller announced them, from 0. + int Order { get; } + /// + /// Number of elements (digits) the controller announced; frames carry the same count. 0 when the producer did not + /// announce a size, in which case consumers learn it from the first frame. + /// + int Elements { get; } + /// Segment arrangement of the elements, as in . + string Layout { get; } + /// + /// Display technology hint, as in : a free-form, case-sensitive string made + /// of tokens. It may hold a technology token such as plasma, vfd-green, vfd-blue or led, + /// and/or gottlieb for upright Gottlieb System 80 digits. Empty means unknown. Match tokens with + /// Contains or StartsWith, not equality. + /// + string Hardware { get; } + /// + /// Registers a receiver for every frame. It is called synchronously on the producer's thread; VPE publishes on the + /// main thread. + /// + /// Called with each frame. + /// Disposing it stops the receiver. + IDisposable Subscribe(Action receive); + } + /// The state of every segment of a segment display at one moment. + /// + /// names the segment arrangement of the elements: seg7 (a-g and a comma at bit 7), + /// seg9 (seven segments plus split verticals at bits 8 and 9), seg14 (a-f, g1, comma, h, i, j, g2, + /// k, l, m, dot) or seg16. is a free-form hint about the display technology. Brightness + /// is borrowed for the synchronous callback only. + /// + /// VPE publishes PinMAME's segment displays with 16 segments per element, one per bit of PinMAME's 16-bit segment mask, + /// each fully on (1) or off (0). + /// + /// + public readonly struct PluginSegmentFrame + { + /// The segment arrangement: seg7, seg9, seg14 or seg16; see the type remarks. + public string Layout { get; } + /// Number of elements (digits); greater than zero. + public int Elements { get; } + /// + /// Number of brightness values per element; greater than zero. VPE always sends 16, and the B2S backglass ignores + /// frames with another count. + /// + public int SegmentsPerElement { get; } + /// + /// Brightness of each segment from 0 (off) to 1 (fully lit), element by element: segment s of element + /// e is at index e * SegmentsPerElement + s. Borrowed for the duration of the subscriber call only. + /// + public ReadOnlyMemory Brightness { get; } + /// + /// Display technology hint: a free-form, case-sensitive string made of tokens. It may hold a technology token such + /// as plasma, vfd-green, vfd-blue or led, and/or gottlieb for upright Gottlieb + /// System 80 digits. Empty (never null) means unknown. Match tokens with Contains or StartsWith, not + /// equality. PinMAME sends gottlieb for Gottlieb System 80 and an empty string otherwise; the B2S backglass + /// picks its style from a vfd-green, vfd-blue or led prefix (plasma otherwise) and draws + /// upright digits when the hint contains gottlieb. + /// + public string Hardware { get; } + /// Creates a frame without a hardware hint. + /// The segment arrangement. + /// Number of elements. + /// Number of brightness values per element. + /// The borrowed brightness values, exactly elements times segments per element long. + /// A count is not positive or the brightness length does not match. + public PluginSegmentFrame(string layout, int elements, int segmentsPerElement, ReadOnlyMemory brightness) : this(layout, elements, segmentsPerElement, brightness, "") { } + /// Creates a frame with a hardware hint. + /// The segment arrangement. + /// Number of elements. + /// Number of brightness values per element. + /// The borrowed brightness values, exactly elements times segments per element long. + /// The technology hint; null becomes empty. + /// A count is not positive or the brightness length does not match. + public PluginSegmentFrame(string layout, int elements, int segmentsPerElement, ReadOnlyMemory brightness, string hardware) + { + if (elements <= 0 || segmentsPerElement <= 0 || brightness.Length != checked(elements * segmentsPerElement)) throw new ArgumentException("Segment layout does not match its state."); + Layout = layout; Elements = elements; SegmentsPerElement = segmentsPerElement; Brightness = brightness; Hardware = hardware ?? ""; + } + } + /// One player input action, such as a nudge axis or a button. + /// + /// Carried by and transformed by s in a + /// . Nothing in VPE produces input events for plugins yet. + /// + public readonly struct PluginInputEvent + { + /// Name of the action, such as nudge-x. + public string Action { get; } + /// The action's value, such as an axis position or 1 and 0 for a pressed and released button. + public float Value { get; } + /// When the input happened, in microseconds of a monotonic clock. + public long TimestampUs { get; } + /// Creates an input event. + /// Name of the action. + /// The action's value. + /// Monotonic time in microseconds. + public PluginInputEvent(string action, float value, long timestampUs) { Action = action; Value = value; TimestampUs = timestampUs; } + } + /// + /// A source of player input events, published as . Producers can use + /// . + /// + public interface IPluginInputSource : IPluginResourceSource + { + /// Registers a receiver for every event, called synchronously on the producer's thread. + /// Called with each event. + /// Disposing it stops the receiver. + IDisposable Subscribe(Action receive); + } + /// Transforms an input event before it reaches the game, registered in a . + /// + /// Filters run by the order given at registration, then by plugin ID; each receives the previous filter's result. + /// + /// A filter that halves nudge strength: + /// + /// public PluginInputEvent Filter(in PluginInputEvent input) + /// => input.Action == "nudge-x" ? new PluginInputEvent(input.Action, input.Value * 0.5f, input.TimestampUs) : input; + /// + /// + /// + public interface IPluginInputFilter + { + /// Returns the event to pass on; return unchanged to let it through. + /// The event as the previous filter left it. + PluginInputEvent Filter(in PluginInputEvent input); + } + /// An image other plugins can show, published as . + /// + /// The interface only describes the image; tells consumers which richer interface gives access to + /// it. PinUP publishes each PUP screen and the B2S plugin its composed backglass with the backend + /// unity-render-texture, whose sources implement the Unity integration's IUnityTextureResource. + /// + public interface IPluginSurface : IPluginResourceSource + { + /// Current width in pixels; 0 while the producer has no image yet. + int Width { get; } + /// Current height in pixels; 0 while the producer has no image yet. + int Height { get; } + /// + /// How the image is accessed, such as unity-render-texture. Consumers check it before casting the source to + /// the matching interface. + /// + string Backend { get; } + } + /// + /// Well-known values of resources: the + /// cabinet screens a table can show in the scene. + /// + /// + /// A table marks a renderer with VPE's CabinetScreenComponent and one of these roles. While a game runs, VPE shows + /// the surface published with that role on it; when several plugins publish the same role, the user's provider order + /// decides. Plugins may publish other roles, which tables can name as custom roles. + /// + /// PinUP publishes PUP screen 0 as the topper: + /// + /// game.Resources.Publish(new PluginResourceDescriptor(game.ControllerId + "/pinup/screen/0", plugin.Id, game.ControllerId, + /// "PUP screen 0", SurfaceRoles.Topper, ResourceKind.Surface, game.Session, instance), surface); + /// + /// + /// + public static class SurfaceRoles + { + /// The backglass. + public const string Backglass = "backglass"; + /// The screen of a topper above the backbox. + public const string Topper = "topper"; + /// + /// The DMD in the backbox. In the scene, a DMD the game's own gamelogic engine announces takes precedence, so a + /// plugin's DMD surface only shows when the engine has none. + /// + public const string Dmd = "dmd"; + /// A large DMD-sized screen in the backbox, PinUP's "FullDMD". + public const string FullDmd = "full-dmd"; + } + /// One named trigger with an integer value, carried by . + /// Nothing in VPE publishes trigger resources yet. + public readonly struct PluginTriggerEvent + { + /// Name of the trigger. + public string Id { get; } + /// The trigger's value. + public int Value { get; } + /// Increasing number assigned by the producer. + public long Sequence { get; } + /// When the trigger fired, in microseconds of a monotonic clock. + public long TimestampUs { get; } + /// Creates a trigger event. + /// Name of the trigger. + /// The trigger's value. + /// The producer's increasing event number. + /// Monotonic time in microseconds. + public PluginTriggerEvent(string id, int value, long sequence, long timestampUs) { Id = id; Value = value; Sequence = sequence; TimestampUs = timestampUs; } + } + /// + /// A source of trigger events, published as . Producers can use + /// . + /// + public interface IPluginTriggerSource : IPluginResourceSource + { + /// Registers a receiver for every event, called synchronously on the producer's thread. + /// Called with each event. + /// Disposing it stops the receiver. + IDisposable Subscribe(Action receive); + } +} diff --git a/VisualPinball.Plugins/Resources/ResourceContracts.cs.meta b/VisualPinball.Plugins/Resources/ResourceContracts.cs.meta new file mode 100644 index 000000000..79217f9d4 --- /dev/null +++ b/VisualPinball.Plugins/Resources/ResourceContracts.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9bba39e37954afe4ab5781464468f07d \ No newline at end of file diff --git a/VisualPinball.Plugins/Resources/ResourceSignal.cs b/VisualPinball.Plugins/Resources/ResourceSignal.cs new file mode 100644 index 000000000..472a16176 --- /dev/null +++ b/VisualPinball.Plugins/Resources/ResourceSignal.cs @@ -0,0 +1,229 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// SPDX-License-Identifier: MIT +// Licensed under MIT. See https://opensource.org/license/mit. + +using System; +using System.Collections.Generic; +using System.Threading; + +namespace VisualPinball.Plugins +{ + /// + /// Thread-safe fan-out of values to subscribers. Subscription changes take a lock; synchronous producer dispatch reads a + /// cached array, so publishing allocates nothing and may happen on any thread, such as an emulator's audio thread. + /// + internal sealed class ResourceSignal : IDisposable where T : struct + { + private readonly List> _subscribers = new List>(); + private readonly Action _fault; + private Action[] _dispatch = Array.Empty>(); + private bool _disposed; + /// Receives exceptions thrown by subscribers; null ignores them. + internal ResourceSignal(Action? fault = null) { _fault = fault ?? (_ => { }); } + /// Whether at least one subscriber is registered. Safe to read from any thread. + internal bool HasSubscribers => Volatile.Read(ref _dispatch).Length > 0; + /// Adds a subscriber; disposing the result removes it. + /// The signal was disposed. + internal IDisposable Subscribe(Action receive) + { + lock (_subscribers) { + if (_disposed) throw new ObjectDisposedException(nameof(ResourceSignal)); + _subscribers.Add(receive); Volatile.Write(ref _dispatch, _subscribers.ToArray()); + } + return new Subscription(() => { lock (_subscribers) { _subscribers.Remove(receive); Volatile.Write(ref _dispatch, _subscribers.ToArray()); } }); + } + /// Calls every subscriber on the calling thread; an exception goes to the fault handler and dispatch continues. + internal void Publish(in T frame) { foreach (var receive in Volatile.Read(ref _dispatch)) try { receive(frame); } catch (Exception error) { _fault(error); } } + /// Drops all subscribers and refuses new ones. + public void Dispose() { lock (_subscribers) { _disposed = true; _subscribers.Clear(); Volatile.Write(ref _dispatch, Array.Empty>()); } } + } + /// + /// Ready-made source for producers: it dispatches s to + /// subscribers and lets one plugin take over playback. + /// + /// + /// Publishing and subscribing are thread-safe, so a producer can publish from its audio thread. PinMAME owns one, + /// exposes it through the Unity integration's IGamelogicAudioResourceProvider so VPE publishes it as + /// <controller>/audio/rom, copies samples only when is true, and mutes its own + /// output while is true. The owner disposes it; disposing does not unpublish it. + /// + /// + /// private readonly AudioResourceSource _audio = new AudioResourceSource(error => Debug.LogException(error)); + /// + /// private void OnAudio(byte[] buffer, int length, int sampleRate, int channels, int frames) + /// { + /// if (_audio.HasSubscribers) + /// _audio.Publish(new PluginPcmFrame(buffer.AsMemory(0, length), PcmEncoding.Float32, sampleRate, channels, frames, ++_sequence)); + /// if (!_audio.PlaybackReplaced) PlayLocally(buffer, length); + /// } + /// + /// + /// + public sealed class AudioResourceSource : IPluginAudioSource, IPluginAudioPlaybackControl, IDisposable + { + private readonly ResourceSignal _signal; + private readonly PluginOutputLeases _output = new PluginOutputLeases(); + /// Always . + public ResourceKind Kind => ResourceKind.Audio; + /// + /// Whether at least one receiver is subscribed. Producers check it to skip copying samples nobody reads. Safe to read + /// from any thread. + /// + public bool HasSubscribers => _signal.HasSubscribers; + /// + /// Whether a plugin currently holds the playback lease from . While true, the producer + /// should stop playing the sound itself but keep publishing it. Safe to read from any thread. + /// + public bool PlaybackReplaced => _output.Owner("playback") != null; + /// Creates the source. + /// + /// Receives exceptions thrown by a receiver, on the publishing thread; the remaining receivers still get the block. + /// Null ignores them. + /// + public AudioResourceSource(Action? fault = null) { _signal = new ResourceSignal(fault); } + /// Registers a receiver for every block; it is called on the publishing thread. Thread-safe. + /// Called with each block. + /// Disposing it stops the receiver. + /// The source was disposed. + public IDisposable Subscribe(Action receive) => _signal.Subscribe(receive); + /// + /// Takes over playback until the returned lease is disposed; is true meanwhile. Only + /// one plugin can hold it at a time. + /// + /// ID of the plugin taking over, named in the error another plugin gets. + /// The lease. + /// Another plugin already holds the lease. + public IDisposable AcquirePlayback(string pluginId) => _output.Acquire("playback", pluginId); + /// + /// Delivers a block to every receiver, synchronously on the calling thread. Safe to call from any thread; does + /// nothing after . + /// + /// The block; its samples only need to stay valid until the call returns. + public void Publish(in PluginPcmFrame frame) => _signal.Publish(frame); + /// + /// Drops all receivers and refuses new ones. It does not unpublish the resource or release a playback lease. + /// + public void Dispose() => _signal.Dispose(); + } + /// + /// Ready-made source for producers: it carries the display's announced place and + /// size and dispatches s to subscribers. + /// + /// + /// VPE creates one per segment display the gamelogic engine announces, numbered in announcement order, with the layout + /// and hardware hint the engine declares (PinMAME maps its display types onto seg7, seg9, seg14 and + /// seg16). Publishing and subscribing are thread-safe. The owner disposes it; disposing does not unpublish it. + /// + /// + /// var source = new SegmentResourceSource(order: 0, elements: 16, layout: "seg14", hardware: "", fault: Debug.LogException); + /// var publication = registry.Publish(new PluginResourceDescriptor(controllerId + "/segments/display0", "pinmame", controllerId, + /// "display0", "segments", ResourceKind.Segments, session, instance), source); + /// source.Publish(new PluginSegmentFrame("seg14", 16, 16, brightness, "")); + /// + /// + /// + public sealed class SegmentResourceSource : IPluginSegmentSource, IDisposable + { + private readonly ResourceSignal _signal; + /// Always . + public ResourceKind Kind => ResourceKind.Segments; + /// + public int Order { get; } + /// + public int Elements { get; } + /// + public string Layout { get; } + /// + public string Hardware { get; } + /// + /// Creates a source that announces nothing: order 0, no elements, and empty layout and hardware. Consumers then learn + /// the size from the first frame. Prefer the constructor that announces the display. + /// + /// Receives exceptions thrown by a receiver; null ignores them. + public SegmentResourceSource(Action? fault = null) : this(0, 0, "", "", fault) { } + /// Creates a source that announces the display's place, size and appearance. + /// Position among the controller's segment displays, from 0. + /// Number of elements (digits). + /// Segment arrangement, such as seg7; null becomes empty. + /// Technology hint as in ; null becomes empty. + /// + /// Receives exceptions thrown by a receiver, on the publishing thread; the remaining receivers still get the frame. + /// Null ignores them. + /// + public SegmentResourceSource(int order, int elements, string layout, string hardware, Action? fault = null) + { + Order = order; Elements = elements; Layout = layout ?? ""; Hardware = hardware ?? ""; + _signal = new ResourceSignal(fault); + } + /// Registers a receiver for every frame; it is called on the publishing thread. Thread-safe. + /// Called with each frame. + /// Disposing it stops the receiver. + /// The source was disposed. + public IDisposable Subscribe(Action receive) => _signal.Subscribe(receive); + /// + /// Delivers a frame to every receiver, synchronously on the calling thread; does nothing after . + /// + /// The frame; its brightness only needs to stay valid until the call returns. + public void Publish(in PluginSegmentFrame frame) => _signal.Publish(frame); + /// Drops all receivers and refuses new ones. It does not unpublish the resource. + public void Dispose() => _signal.Dispose(); + } + /// + /// Ready-made source for producers of s. Publishing and + /// subscribing are thread-safe. Nothing in VPE publishes input resources yet. + /// + public sealed class InputResourceSource : IPluginInputSource, IDisposable + { + private readonly ResourceSignal _signal; + /// Always . + public ResourceKind Kind => ResourceKind.Input; + /// Creates the source. + /// + /// Receives exceptions thrown by a receiver, on the publishing thread; the remaining receivers still get the event. + /// Null ignores them. + /// + public InputResourceSource(Action? fault = null) { _signal = new ResourceSignal(fault); } + /// Registers a receiver for every event; it is called on the publishing thread. Thread-safe. + /// Called with each event. + /// Disposing it stops the receiver. + /// The source was disposed. + public IDisposable Subscribe(Action receive) => _signal.Subscribe(receive); + /// + /// Delivers an event to every receiver, synchronously on the calling thread; does nothing after . + /// + /// The event. + public void Publish(in PluginInputEvent value) => _signal.Publish(value); + /// Drops all receivers and refuses new ones. It does not unpublish the resource. + public void Dispose() => _signal.Dispose(); + } + /// + /// Ready-made source for producers of s. Publishing + /// and subscribing are thread-safe. Nothing in VPE publishes trigger resources yet. + /// + public sealed class TriggerResourceSource : IPluginTriggerSource, IDisposable + { + private readonly ResourceSignal _signal; + /// Always . + public ResourceKind Kind => ResourceKind.Triggers; + /// Creates the source. + /// + /// Receives exceptions thrown by a receiver, on the publishing thread; the remaining receivers still get the event. + /// Null ignores them. + /// + public TriggerResourceSource(Action? fault = null) { _signal = new ResourceSignal(fault); } + /// Registers a receiver for every event; it is called on the publishing thread. Thread-safe. + /// Called with each event. + /// Disposing it stops the receiver. + /// The source was disposed. + public IDisposable Subscribe(Action receive) => _signal.Subscribe(receive); + /// + /// Delivers an event to every receiver, synchronously on the calling thread; does nothing after . + /// + /// The event. + public void Publish(in PluginTriggerEvent value) => _signal.Publish(value); + /// Drops all receivers and refuses new ones. It does not unpublish the resource. + public void Dispose() => _signal.Dispose(); + } +} diff --git a/VisualPinball.Plugins/Resources/ResourceSignal.cs.meta b/VisualPinball.Plugins/Resources/ResourceSignal.cs.meta new file mode 100644 index 000000000..47466c4c3 --- /dev/null +++ b/VisualPinball.Plugins/Resources/ResourceSignal.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c22e71bda21f38f4ebc548e93cca3cb6 \ No newline at end of file diff --git a/VisualPinball.Plugins/Runtime.meta b/VisualPinball.Plugins/Runtime.meta new file mode 100644 index 000000000..a372b03c1 --- /dev/null +++ b/VisualPinball.Plugins/Runtime.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 7393f22928505a84eb4c5247093c13b7 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Plugins/Runtime/OwnerScheduler.cs b/VisualPinball.Plugins/Runtime/OwnerScheduler.cs new file mode 100644 index 000000000..68729a870 --- /dev/null +++ b/VisualPinball.Plugins/Runtime/OwnerScheduler.cs @@ -0,0 +1,102 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// SPDX-License-Identifier: MIT +// Licensed under MIT. See https://opensource.org/license/mit. + +using System; +using System.Collections.Concurrent; +using System.Runtime.CompilerServices; +using System.Threading; + +namespace VisualPinball.Plugins +{ + /// + /// The plugin host's owner thread: a queue of work that runs on the thread that created the scheduler, which plugins use + /// to get back to that thread after background work. + /// + /// + /// The Player creates one scheduler on Unity's main thread and hands it to every plugin through + /// . Plugin hooks, resource events and Unity calls all belong on this + /// thread. Queued work runs when the host pumps the queue, which does once per frame, so + /// await Switch() resumes on the next frame at the latest. The scheduler does not depend on Unity's + /// synchronization context, so it also works after ConfigureAwait(false) and in tests without Unity. + /// + /// The B2S plugin reads the backglass file on a worker and returns to the owner thread before touching Unity: + /// + /// var backglass = await Task.Run(() => DirectB2SReader.Read(path), cancellation); + /// await _application.Scheduler.Switch(); + /// var view = new B2SBackglassView(backglass, options); // owner thread again + /// + /// + /// + public sealed class OwnerScheduler + { + private readonly int _thread = Thread.CurrentThread.ManagedThreadId; + private readonly ConcurrentQueue _pending = new ConcurrentQueue(); + /// + /// True when the calling thread is the owner thread, the thread that constructed this scheduler. Safe to read from any thread. + /// + public bool IsOwner => Thread.CurrentThread.ManagedThreadId == _thread; + /// + /// Throws unless called on the owner thread. Plugins call it at the top of callbacks that must not run on a worker, for + /// example the DMD plugin's presentation factory. + /// + /// The calling thread is not the owner thread. + public void VerifyOwner() { if (!IsOwner) throw new InvalidOperationException("This plugin operation belongs on the owner thread."); } + /// + /// Queues an action to run on the owner thread during the next . Thread-safe; actions run in the order + /// they were posted. Unlike , the action is always queued, even when called on the owner thread. + /// + /// + /// An exception thrown by the action escapes (and so ), so catch inside + /// the action. GamelogicPluginResources posts controller events raised off the owner thread this way; the DMD plugin + /// posts the release of its native resources once its worker has stopped. + /// + /// The work to run on the owner thread. + public void Post(Action action) => _pending.Enqueue(action); + /// + /// Runs queued actions on the owner thread, at most of them, so a flood of posts cannot stall a + /// frame. Actions left over run on the next call. The host calls this from ; plugins do not + /// need to call it. + /// + /// The maximum number of actions to run in this call. + /// The calling thread is not the owner thread. + public void Pump(int budget = 4096) + { + VerifyOwner(); + for (int i = 0; i < budget && _pending.TryDequeue(out var action); i++) action(); + } + /// + /// Returns an awaitable that continues on the owner thread. On the owner thread it completes immediately; elsewhere the + /// continuation is posted and runs during the next . + /// + /// + /// Use it after every await that may resume on a worker (Task.Run, ConfigureAwait(false)) before + /// touching plugin state, resources or Unity. If nothing pumps the scheduler, the continuation never runs. + /// + public OwnerAwaitable Switch() => new OwnerAwaitable(this); + /// The awaitable returned by . Use it only with await. + public readonly struct OwnerAwaitable + { + private readonly OwnerScheduler _scheduler; + internal OwnerAwaitable(OwnerScheduler scheduler) { _scheduler = scheduler; } + /// Returns the awaiter the compiler uses for await. + public Awaiter GetAwaiter() => new Awaiter(_scheduler); + /// Awaiter that resumes on the scheduler's owner thread. Called by compiler-generated code, not directly. + public readonly struct Awaiter : INotifyCompletion + { + private readonly OwnerScheduler _scheduler; + internal Awaiter(OwnerScheduler scheduler) { _scheduler = scheduler; } + /// True on the owner thread, in which case the await continues synchronously without queueing. + public bool IsCompleted => _scheduler.IsOwner; + /// Posts the continuation to the owner queue; it runs during the next . + /// The rest of the awaiting method. + public void OnCompleted(Action continuation) => _scheduler.Post(continuation); + /// Ends the await, checking that execution really is on the owner thread. + /// The continuation was resumed on another thread. + public void GetResult() => _scheduler.VerifyOwner(); + } + } + } +} diff --git a/VisualPinball.Plugins/Runtime/OwnerScheduler.cs.meta b/VisualPinball.Plugins/Runtime/OwnerScheduler.cs.meta new file mode 100644 index 000000000..a6a732e88 --- /dev/null +++ b/VisualPinball.Plugins/Runtime/OwnerScheduler.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 0a862295931df97488382c4cb1508673 \ No newline at end of file diff --git a/VisualPinball.Plugins/Runtime/PluginCapabilities.cs b/VisualPinball.Plugins/Runtime/PluginCapabilities.cs new file mode 100644 index 000000000..3ebca4376 --- /dev/null +++ b/VisualPinball.Plugins/Runtime/PluginCapabilities.cs @@ -0,0 +1,82 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// SPDX-License-Identifier: MIT +// Licensed under MIT. See https://opensource.org/license/mit. + +using System; +using System.Collections.Generic; + +namespace VisualPinball.Plugins +{ + /// + /// Directory of shared services, each published under a versioned string ID, through which the Player and plugins hand + /// each other objects. Concrete display, audio and controller streams go through + /// instead. + /// + /// + /// A capability ID names a contract and its version, such as vpe.unity.game-scene.v1, so a provider can publish a + /// new version beside an old one. The Player publishes vpe.events.v1 () and + /// vpe.outputs.v1 () for its lifetime, and the Unity game scene for each game + /// before plugins are prepared. Plugins declare what they consume in and what they + /// offer in ; the host uses those lists and the IDs published here to order and + /// block plugins. + /// Not thread-safe: publish and look up on the owner thread. + /// + /// The DMD plugin takes the output leases while preparing a game: + /// + /// var scene = game.Capabilities.Require<IUnityGameScene>(UnityPluginCapability.GameScene); + /// var outputs = game.Capabilities.Require<PluginOutputLeases>(UnityPluginCapability.Outputs); + /// + /// + /// + public sealed class PluginCapabilities + { + private readonly Dictionary _services = new Dictionary(StringComparer.Ordinal); + /// + /// IDs of the currently published capabilities. The host reads them when a game starts to tell which + /// entries the application already satisfies. + /// + public IEnumerable Ids => _services.Keys; + /// + /// Publishes a service under an ID until the returned handle is disposed. Disposing removes the service only if it is + /// still the one registered under that ID; disposing twice does nothing. + /// + /// The contract type consumers look the service up by. + /// Versioned capability ID, compared ordinally. + /// The service instance shared with every consumer. + /// A handle that withdraws the service; put it in a tied to the service's lifetime. + /// A service is already published under . + /// + /// + /// The Player publishes the game scene for one game: + /// + /// _gameScope.Own(Host.Capabilities.Publish<IUnityGameScene>(UnityPluginCapability.GameScene, new UnityGameScene(table, engine))); + /// + /// + /// + public IDisposable Publish(string id, T service) where T : class + { + if (_services.ContainsKey(id)) throw new InvalidOperationException("Capability already provided: " + id); + _services.Add(id, service); + return new Subscription(() => { if (_services.TryGetValue(id, out var current) && ReferenceEquals(current, service)) _services.Remove(id); }); + } + /// + /// Returns the service published under , or null when nothing is published there or the service + /// does not implement . Use it for optional capabilities. + /// + /// The contract type to cast the service to. + /// Versioned capability ID. + public T? Find(string id) where T : class => _services.TryGetValue(id, out var value) ? value as T : null; + /// + /// Returns the service published under , for capabilities a plugin cannot work without. List the + /// ID in as well, so the host blocks the plugin up front when nothing provides it. + /// + /// The contract type to cast the service to. + /// Versioned capability ID. + /// + /// Nothing is published under , or the service does not implement . + /// + public T Require(string id) where T : class => Find(id) ?? throw new InvalidOperationException("Required capability is unavailable: " + id); + } +} diff --git a/VisualPinball.Plugins/Runtime/PluginCapabilities.cs.meta b/VisualPinball.Plugins/Runtime/PluginCapabilities.cs.meta new file mode 100644 index 000000000..c720b8248 --- /dev/null +++ b/VisualPinball.Plugins/Runtime/PluginCapabilities.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f12964f9611c06d47b0d75e8cf8af734 \ No newline at end of file diff --git a/VisualPinball.Plugins/Runtime/PluginEvents.cs b/VisualPinball.Plugins/Runtime/PluginEvents.cs new file mode 100644 index 000000000..5dc493479 --- /dev/null +++ b/VisualPinball.Plugins/Runtime/PluginEvents.cs @@ -0,0 +1,94 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// SPDX-License-Identifier: MIT +// Licensed under MIT. See https://opensource.org/license/mit. + +using System; +using System.Collections.Generic; +using System.Threading; + +namespace VisualPinball.Plugins +{ + /// + /// Typed publish/subscribe bus for small control notifications between plugins, delivered on the host's owner thread. + /// One channel exists per struct type: subscribers of T receive every published T. + /// + /// + /// The Player publishes one instance as the vpe.events.v1 capability (UnityPluginCapability.Events), with + /// subscriber exceptions going to Unity's log. Plugins define their own notification structs and agree on them; none of the + /// bundled plugins uses the bus yet. Streams of frames or samples do not belong here: they are borrowed buffers and go + /// through the resource contracts in . + /// + /// A plugin-defined notification: + /// + /// public readonly struct AttractModeChanged { public bool Active { get; } public AttractModeChanged(bool active) => Active = active; } + /// + /// var events = game.Capabilities.Require<PluginEvents>(UnityPluginCapability.Events); + /// game.Lifetime.Own(events.Subscribe<AttractModeChanged>(change => _attract = change.Active)); + /// events.Post(new AttractModeChanged(true), game.Cancellation); // from any thread + /// + /// + /// + public sealed class PluginEvents + { + private interface IChannel { } + private sealed class Channel : IChannel where T : struct + { + internal readonly List> Subscribers = new List>(); + internal Action[] Dispatch = Array.Empty>(); + } + private readonly Dictionary _channels = new Dictionary(); + private readonly OwnerScheduler _scheduler; + private readonly Action _fault; + /// Creates an event bus bound to an owner thread. Created by the Player; plugins get it as a capability. + /// The owner thread that subscriptions, publishing and delivery belong to. + /// + /// Receives exceptions thrown by subscribers, so one failing subscriber does not stop delivery to the others. When null, + /// such exceptions are silently dropped. + /// + public PluginEvents(OwnerScheduler scheduler, Action? fault = null) { _scheduler = scheduler; _fault = fault ?? (_ => { }); } + /// + /// Starts receiving every published until the returned handle is disposed. Owner thread only. A + /// subscriber added while an event is being delivered receives the next one, not the current one. + /// + /// The notification type; a struct, so each subscriber gets its own copy. + /// Called on the owner thread for each event. + /// + /// A handle that unsubscribes. Dispose it on the owner thread, typically through . + /// + /// Called off the owner thread. + public IDisposable Subscribe(Action receive) where T : struct + { + _scheduler.VerifyOwner(); + if (!_channels.TryGetValue(typeof(T), out var found)) _channels.Add(typeof(T), found = new Channel()); + var channel = (Channel)found; + channel.Subscribers.Add(receive); channel.Dispatch = channel.Subscribers.ToArray(); + return new Subscription(() => { _scheduler.VerifyOwner(); channel.Subscribers.Remove(receive); channel.Dispatch = channel.Subscribers.ToArray(); }); + } + /// + /// Delivers an event synchronously to the current subscribers of , in subscription order. Owner + /// thread only; use from other threads. Subscriber exceptions go to the fault handler. + /// + /// The notification type. + /// The event to deliver. + /// Called off the owner thread. + public void Publish(in T value) where T : struct + { + _scheduler.VerifyOwner(); + if (!_channels.TryGetValue(typeof(T), out var found)) return; + foreach (var receive in ((Channel)found).Dispatch) try { receive(value); } catch (Exception error) { _fault(error); } + } + /// + /// Queues an event for delivery on the owner thread during the next scheduler pump. Thread-safe. The event is dropped if + /// is cancelled by the time it would be delivered, so pass the game's cancellation token to + /// keep a stopped game's events from reaching the next one. + /// + /// The notification type. + /// The event to deliver. + /// Cancels delivery if it has not happened yet; the default never cancels. + public void Post(T value, CancellationToken lifetime = default) where T : struct => _scheduler.Post(() => { + if (!lifetime.IsCancellationRequested) Publish(value); + }); + } +} diff --git a/VisualPinball.Plugins/Runtime/PluginEvents.cs.meta b/VisualPinball.Plugins/Runtime/PluginEvents.cs.meta new file mode 100644 index 000000000..cfca6fdd2 --- /dev/null +++ b/VisualPinball.Plugins/Runtime/PluginEvents.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7c77b47fea6f63f4a8ec2c72fa24662a \ No newline at end of file diff --git a/VisualPinball.Plugins/Runtime/PluginHost.Settings.cs b/VisualPinball.Plugins/Runtime/PluginHost.Settings.cs new file mode 100644 index 000000000..d95b17857 --- /dev/null +++ b/VisualPinball.Plugins/Runtime/PluginHost.Settings.cs @@ -0,0 +1,156 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// SPDX-License-Identifier: MIT +// Licensed under MIT. See https://opensource.org/license/mit. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json.Linq; + +namespace VisualPinball.Plugins +{ + public sealed partial class PluginHost + { + /// + /// Raised on the owner thread after settings were saved through or + /// . Arguments: the plugin ID, the IDs of the saved fields, and the game whose + /// per-game settings changed, or null for global settings. + /// + /// + /// Lets one settings view follow edits made elsewhere: the Player's plugin settings page refreshes the fields the user + /// has not touched when the volume mixer saves a value. Exceptions thrown by a handler are logged and do not reach other + /// handlers or the committing caller. Not raised when the commit fails or is cancelled. + /// + public event Action, GameIdentity?>? SettingsCommitted; + /// + /// Saves one field's global value, applying it to the running plugin first when the field is + /// . This is the write path for fields exposed on shared Player pages through + /// , such as the volume mixer. + /// + /// + /// + /// The call waits behind any game start, stop or other settings commit instead of failing, so an edit made while a game + /// is loading is applied once loading finishes rather than discarded. Only is validated and + /// written; other saved fields are left as they are. + /// + /// + /// When a game is running, the field is and its runtime value changes, the host + /// passes the plugin its current settings with the new value through or + /// and saves only if that succeeds. If the plugin implements neither, the commit + /// fails and nothing is saved. If the running game overrides the field per game, the global edit is saved but the + /// runtime value, and so the plugin, stays on the game's value. Other fields take effect on the next game. + /// + /// + /// The Player queues these calls from its volume mixer and sends only the latest value per field. + /// + /// + /// The installed plugin whose setting changes. + /// ID of a field that declares and is not game-only. + /// The new value, in the field's JSON form. + /// + /// Cancels the commit. While waiting for the lifecycle gate it cancels the returned task; afterwards it yields a failed + /// result and any live change is rolled back. Once saving has started the save completes. + /// + /// + /// Completes on the owner thread with success and Saved., or failure and a message to show next to the control + /// (validation issues, a rejected live change, or Settings change cancelled.). + /// + /// + /// Thrown immediately when the field does not exist, is not exposed as a global setting or is game-only; the task faults + /// with it when is not installed in this host. + /// + public Task CommitGlobalSettingAsync(IPlayerPluginFactory factory, string fieldId, JToken value, CancellationToken cancellation = default) + { + var field = factory.Schema.Fields.FirstOrDefault(item => item.Id == fieldId); + if (field?.GlobalSetting == null || field.Scope == ConfigurationScope.GameOnly) throw new ArgumentException("The field is not exposed as a global setting."); + var patch = new PluginSettingsSnapshot(factory.Schema.Version); patch.Set(fieldId, value); + return CommitSettingsCoreAsync(factory, patch, null, cancellation, new[] { fieldId }, field); + } + private async Task CommitSettingsCoreAsync(IPlayerPluginFactory factory, PluginSettingsSnapshot draft, GameIdentity? game, CancellationToken cancellation, IReadOnlyCollection? edited, PluginField? single = null) + { + if (!_factories.TryGetValue(factory.Descriptor.Id, out var installed) || installed != factory) throw new ArgumentException("The factory is not installed in this host."); + var patch = draft.Copy(); var fields = (edited ?? factory.Schema.Fields.Select(field => field.Id).ToArray()).ToArray(); + await _operation.WaitAsync(cancellation).ConfigureAwait(false); + await Scheduler.Switch(); + Instance? entry = null; + PluginSettingsSnapshot? previous = null; + IPluginSynchronousReconfiguration? synchronous = null; + IPluginReconfiguration? asynchronous = null; + bool applied = false; + try { + entry = _entered.FirstOrDefault(candidate => candidate.Factory == factory && IsHealthy(candidate) && Available(candidate)); + GameIdentity? active = _activeGame; + var prepared = await Task.Run(() => { + var latest = Configuration.MergePatch(factory.Descriptor, factory.Schema, patch, fields, game); + var global = game.HasValue ? Configuration.Read(factory.Descriptor, factory.Schema) : latest; + var effective = game.HasValue ? PluginConfigurationStore.Resolve(factory.Schema, global, latest) : PluginConfigurationStore.Resolve(factory.Schema, global); + var runtime = active.HasValue && (!game.HasValue || game.Value.Equals(active.Value)) + ? PluginConfigurationStore.Resolve(factory.Schema, global, game.HasValue ? latest : Configuration.Read(factory.Descriptor, factory.Schema, active)) : effective; + return (Effective: effective, Runtime: runtime); + }, cancellation).ConfigureAwait(false); + await Scheduler.Switch(); cancellation.ThrowIfCancellationRequested(); + var validation = single == null ? factory.Schema : new PluginSettingsSchema(factory.Schema.Version, new PluginSettingsGroup("", "", single)); + var issues = validation.Validate(prepared.Effective); + if (issues.Count > 0) return new PluginActionResult(false, string.Join("\n", issues.Select(issue => issue.Field + ": " + issue.Message))); + if (single != null && !single.Visible(prepared.Effective)) return new PluginActionResult(false, "This setting is currently unavailable."); + previous = entry?.Settings; + PluginSettingsSnapshot? live = null; + if (entry != null && previous != null && active.HasValue && (!game.HasValue || game.Value.Equals(active.Value))) { + var changed = factory.Schema.Fields.Where(field => fields.Contains(field.Id) && field.Change == ConfigurationChange.Live && !JToken.DeepEquals(previous.Value(field.Id), prepared.Runtime.Value(field.Id))).ToArray(); + if (changed.Length > 0) { + live = previous.Copy(); foreach (var field in changed) live.Set(field.Id, prepared.Runtime.Value(field.Id)); + synchronous = entry.Plugin as IPluginSynchronousReconfiguration; + asynchronous = entry.Plugin as IPluginReconfiguration; + PluginActionResult result; + if (synchronous != null) result = synchronous.Apply(live); + else if (asynchronous != null) { + entry.Busy = true; + try { + using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellation); deadline.CancelAfter(StartupTimeout); + var hook = asynchronous.ApplyAsync(live, deadline.Token); + await AwaitHookAsync(entry, hook, deadline.Token).ConfigureAwait(false); await Scheduler.Switch(); result = await hook.ConfigureAwait(false); + } catch (HookDeadlineException error) { await Scheduler.Switch(); Abandon(entry, error.Hook); return new PluginActionResult(false, "Live configuration did not finish; settings were not saved."); } + finally { await Scheduler.Switch(); entry.Busy = false; } + } else return new PluginActionResult(false, "The plugin does not implement live configuration."); + if (!result.Success) return result; + applied = true; entry.Settings = live; + } + } + cancellation.ThrowIfCancellationRequested(); + // Once persistence starts, join it. Cancelling the waiter could otherwise allow a late write after rollback. + await Task.Run(() => Configuration.SavePatch(factory.Descriptor, factory.Schema, patch, fields, game)).ConfigureAwait(false); + await Scheduler.Switch(); + NotifySettings(factory.Descriptor.Id, fields, game); + return new PluginActionResult(true, "Saved."); + } catch (Exception error) { + await Scheduler.Switch(); + if (applied && previous != null && entry != null && !entry.Cleaned) { + try { + PluginActionResult rollback; + if (synchronous != null) rollback = synchronous.Apply(previous); + else { + entry.Busy = true; using var deadline = new CancellationTokenSource(StartupTimeout); + var hook = asynchronous!.ApplyAsync(previous, deadline.Token); + await AwaitHookAsync(entry, hook, deadline.Token).ConfigureAwait(false); await Scheduler.Switch(); rollback = await hook.ConfigureAwait(false); + } + if (!rollback.Success) Fault(factory.Descriptor.Id, PluginPhase.Prepare, new InvalidOperationException("Runtime settings rollback failed.")); + else entry.Settings = previous; + } catch (HookDeadlineException failure) { Abandon(entry, failure.Hook); } + catch (Exception failure) { Fault(factory.Descriptor.Id, PluginPhase.Prepare, failure); } + finally { await Scheduler.Switch(); entry.Busy = false; } + } + return new PluginActionResult(false, error is OperationCanceledException ? "Settings change cancelled." : error.Message); + } finally { _operation.Release(); ReplayState(); } + } + private void NotifySettings(string id, IReadOnlyCollection fields, GameIdentity? game) + { + if (SettingsCommitted == null) return; + foreach (Action, GameIdentity?> listener in SettingsCommitted.GetInvocationList()) + try { listener(id, fields, game); } catch (Exception error) { _log("Settings observer: " + error.Message); } + } + } +} diff --git a/VisualPinball.Plugins/Runtime/PluginHost.Settings.cs.meta b/VisualPinball.Plugins/Runtime/PluginHost.Settings.cs.meta new file mode 100644 index 000000000..ebb8ac265 --- /dev/null +++ b/VisualPinball.Plugins/Runtime/PluginHost.Settings.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c6ededed44c0a584a8067e1a51a15331 \ No newline at end of file diff --git a/VisualPinball.Plugins/Runtime/PluginHost.cs b/VisualPinball.Plugins/Runtime/PluginHost.cs new file mode 100644 index 000000000..3d94b067d --- /dev/null +++ b/VisualPinball.Plugins/Runtime/PluginHost.cs @@ -0,0 +1,687 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// SPDX-License-Identifier: MIT +// Licensed under MIT. See https://opensource.org/license/mit. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json.Linq; + +namespace VisualPinball.Plugins +{ + /// + /// Runs the installed Player plugins for one game at a time: decides which plugins run, creates them, drives their lifecycle + /// hooks in dependency order and cleans them up in reverse, so that a failing plugin does not take the others down. + /// + /// + /// The Player creates one host at startup from and keeps it for the application's + /// life. For every table it calls , forwards frames and state changes (, + /// , , , ) while the game + /// runs, calls when the user leaves the table, and when the application + /// quits. Its settings page edits settings through a that commits through the host, so live + /// fields reach the running plugin. Plugins never see the host; they get its services through + /// and . + /// + /// The host belongs to the thread that created its (Unity's main thread in the Player). Plugin hooks + /// run there, and the host's own asynchronous operations resume there only when the scheduler is pumped, which + /// does. Call every frame, also while no game runs, or pending starts, stops and settings + /// commits stall. + /// + /// + /// Starting a game, stopping it, committing settings and cleaning up after a fault are serialized behind one lifecycle gate + /// (see ). A plugin whose hook throws becomes , is cleaned up, and + /// blocks the plugins that depend on it; the others keep running. A plugin whose hook misses its deadline + /// ( or ), or whose workers are still busy after + /// , is quarantined: it gets , becomes + /// , and keeps its resource scopes (native handles, device leases) until every hook the + /// host started on it and its have completed. Then the host shuts it down (after + /// a missed deadline) and disposes it. Meanwhile no new instance of that plugin is created; the host itself never waits for a + /// quarantined instance. + /// + /// + /// How the Player drives the host (simplified from its plugin controller and main menu): + /// + /// // on the main thread, once: + /// var host = new PluginHost(PlayerPluginRegistry.Installed, new PluginConfigurationStore(root), new OwnerScheduler(), + /// capabilities, new PluginResourceRegistry(Debug.LogException), "windows", message => Debug.Log("[Plugins] " + message)); + /// + /// // per table: + /// await host.PrepareAndStartAsync(identity, packagePath, title, identity.Id + "/controller/0", ++session, cancellation); + /// + /// // every frame: + /// private void Update() => host.Tick(Time.unscaledDeltaTime); + /// private void LateUpdate() => host.BeforeRender(); + /// + /// // leaving the table, and quitting: + /// await host.StopGameAsync(); + /// host.EmergencyStop(); + /// + /// + /// + public sealed partial class PluginHost + { + private sealed class HookDeadlineException : TimeoutException + { + internal Task Hook { get; } + internal HookDeadlineException(Task hook) : base("Plugin hook did not finish before its cancellation deadline.") { Hook = hook; } + } + private sealed class Instance + { + internal readonly IPlayerPluginFactory Factory; + internal readonly IPlayerPlugin Plugin; + internal readonly ResourceScope ApplicationScope; + internal readonly ResourceScope GameScope = new ResourceScope(); + internal bool Prepared; + internal bool Cleaned; + internal bool Abandoned; + internal bool Busy; + private readonly List _hooks = new List(); + /// Records a lifecycle hook until it completes, so a quarantine can wait for it even after its deadline. + internal void Track(Task hook) + { + if (hook.IsCompleted) return; + lock (_hooks) _hooks.Add(hook); + hook.ContinueWith(done => { lock (_hooks) _hooks.Remove(done); }, CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); + } + /// Completes when every hook running now has finished. + internal Task RunningHooks() { lock (_hooks) return _hooks.Count == 0 ? Task.CompletedTask : Task.WhenAll(_hooks.ToArray()); } + internal PluginSettingsSnapshot? Settings; + internal Instance(IPlayerPluginFactory factory, IPlayerPlugin plugin, ResourceScope applicationScope) { Factory = factory; Plugin = plugin; ApplicationScope = applicationScope; } + } + private readonly Dictionary _factories; + private readonly Dictionary _statuses; + private readonly List _entered = new List(); + private readonly List _startOrder = new List(); + private readonly SemaphoreSlim _operation = new SemaphoreSlim(1, 1); + private readonly Dictionary _abandoned = new Dictionary(); + private readonly Action _log; + private CancellationTokenSource? _gameCancellation; + private bool _running; + private bool _ready; + private bool? _paused; + private bool? _focused; + private GameIdentity? _activeGame; + private string[] _hostCapabilities = Array.Empty(); + private readonly Dictionary _dispatch = new Dictionary(); + private Task _faultCleanup = Task.CompletedTask; + /// + /// The owner-thread scheduler passed to the constructor, handed to every plugin as + /// . pumps it. Code that awaited a host operation can + /// return to the owner thread with await host.Scheduler.Switch(), as the Player's settings page does. + /// + public OwnerScheduler Scheduler { get; } + /// + /// The store the host reads plugin policy and settings from when it starts a game, and saves committed settings to. Handed + /// to every plugin as ; the Player's settings page and volume mixer read + /// through it as well. + /// + public PluginConfigurationStore Configuration { get; } + /// + /// The capability registry shared with every plugin. The Player publishes its own services here (events, output leases and + /// the current game's table scene). takes a snapshot of the published capability IDs to + /// check , so publish host capabilities before starting a game. + /// + public PluginCapabilities Capabilities { get; } + /// + /// The resource registry shared with every plugin. The Player publishes the game's controller, displays and audio here + /// before starting plugins, and passes the registry's snapshot to settings catalogs. + /// + public PluginResourceRegistry Resources { get; } + /// + /// The platform ID the host runs on: windows, macos or linux in the Player, or Unity's lowercase + /// platform name elsewhere. Plugins whose do not list it are + /// . It is also passed to settings providers as . + /// + public string Platform { get; } + /// + /// The status of every installed plugin, keyed by , with an entry for each factory from the + /// start, also for plugins that never ran. The status objects are updated in place on the owner thread; read them there. + /// The Player's settings page shows them to explain why a plugin is not running. + /// + public IReadOnlyDictionary Statuses => _statuses; + /// + /// The installed plugin factories passed to the constructor, in no particular order. Factories registered later are not + /// included. The Player lists them on its settings page and in its volume mixer. + /// + public IReadOnlyCollection Factories => _factories.Values; + /// + /// True from the end of until the game is stopped by , + /// or the next start. It is also true when some or all plugins failed to start; check + /// for individual plugins. + /// + public bool IsRunning => _running; + /// + /// True while a lifecycle operation holds the host's gate: starting or stopping a game, committing settings, or cleaning up + /// a plugin that faulted during a dispatched call. Meanwhile , and + /// are held and delivered (latest state only) when the operation ends. and + /// still reach running plugins while settings are committed or a faulted plugin is cleaned up. + /// + public bool IsTransitioning => _operation.CurrentCount == 0; + /// + /// How long each cleanup hook (, ) may run + /// before its plugin is quarantined as , and how long waits + /// for a running operation before it falls back to . Also bounds the deferred shutdown of a + /// quarantined instance. Defaults to 10 seconds. + /// + public TimeSpan CleanupTimeout { get; set; } = TimeSpan.FromSeconds(10); + /// + /// How long each startup hook (, , + /// ) and each may run before its + /// plugin is quarantined as , and how long waits for + /// a running operation to finish. Defaults to 30 seconds. + /// + public TimeSpan StartupTimeout { get; set; } = TimeSpan.FromSeconds(30); + /// + /// Creates a host for a fixed set of plugins. No plugin instance is created before , and + /// every plugin starts as . + /// + /// The installed plugins, usually . The host copies them. + /// The store for plugin policy and settings. + /// + /// The owner-thread scheduler. Create it on the thread that drives the host; plugin hooks run on that thread. + /// + /// The capability registry shared with plugins. + /// The resource registry shared with plugins. + /// The platform ID, compared with ; see . + /// + /// Receives diagnostics: plugin log lines (prefixed with the plugin ID), faults and cleanup errors. Null discards them. + /// + /// Two factories have the same . + public PluginHost(IEnumerable factories, PluginConfigurationStore configuration, OwnerScheduler scheduler, + PluginCapabilities capabilities, PluginResourceRegistry resources, string platform, Action? log = null) + { + _factories = factories.ToDictionary(factory => factory.Descriptor.Id, StringComparer.Ordinal); + _statuses = _factories.Keys.ToDictionary(id => id, _ => new PluginStatus()); + Configuration = configuration; Scheduler = scheduler; Capabilities = capabilities; Resources = resources; Platform = platform; _log = log ?? (_ => { }); + } + + /// + /// Stops the current game's plugins, if any, then selects, creates and starts the plugins for a new game. Plugin failures do + /// not throw: they are reported in , and the game runs with the remaining plugins. + /// + /// + /// The host first decides which plugins run. Plugins that are disabled for the game, unsupported on , + /// still quarantined from an earlier instance, missing a dependency or required capability, or caught in an ordering cycle + /// are not created and get the matching . For the others, in initialize order, it validates the + /// effective settings against the schema and , creates the instance and calls + /// . Then it calls on all of them in + /// prepare order, and in start order. A plugin that fails a step is cleaned up and + /// the plugins depending on it are blocked before the next step. When the task completes, the started plugins are + /// and is true; a , pause or focus change + /// that arrived meanwhile is delivered then. + /// + /// The Player calls it on the main thread after publishing the game's table scene capability and its controller and display + /// resources, so plugins find them while they prepare. + /// + /// + /// + /// The game to start. Selects per-game enablement and settings, and is passed to plugins as + /// . + /// + /// + /// Full path of the table package, passed to plugins as and to settings validation. + /// + /// The table's title, passed to plugins as . + /// + /// ID of the game's controller resource, passed to plugins as . The Player uses + /// identity.Id + "/controller/0". + /// + /// + /// The game session number, increasing with every game, passed to plugins as . + /// + /// + /// Cancels the startup: the plugins started so far are stopped and the task is cancelled. The token is also linked to + /// , so cancelling it after startup signals the running plugins' game token, but + /// does not stop them. + /// + /// + /// A task that completes when startup has finished. It also completes, without starting anything, when another operation + /// still holds the host after ; every plugin is then . + /// + /// + /// was cancelled, or the game was stopped by or + /// while it was starting. + /// + public async Task PrepareAndStartAsync(GameIdentity identity, string packagePath, string title, string controllerId, long session, CancellationToken cancellation) + { + using var gateDeadline = CancellationTokenSource.CreateLinkedTokenSource(cancellation); + gateDeadline.CancelAfter(StartupTimeout); + try { await _operation.WaitAsync(gateDeadline.Token).ConfigureAwait(false); } + catch (OperationCanceledException) when (!cancellation.IsCancellationRequested) { + await Scheduler.Switch(); + foreach (var factory in _factories.Values) SetStatus(factory.Descriptor.Id, PluginState.Blocked, "Plugin host is still stopping a previous operation."); + return; + } + await Scheduler.Switch(); + try { + await StopCoreAsync(CancellationToken.None).ConfigureAwait(false); + await Scheduler.Switch(); + _gameCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellation); + _activeGame = identity; + _hostCapabilities = Capabilities.Ids.ToArray(); + var gameToken = _gameCancellation.Token; + var selected = Select(identity); + foreach (var descriptor in PluginOrderResolver.Resolve(selected, PluginPhase.Initialize, _hostCapabilities)) { + gameToken.ThrowIfCancellationRequested(); + if (MissingProvider(descriptor, selected) is string missing) { SetStatus(descriptor.Id, PluginState.Blocked, missing); continue; } + var factory = _factories[descriptor.Id]; + var application = new ResourceScope(); + try { + var settings = Configuration.Effective(descriptor, factory.Schema, identity); + var issues = factory.Schema.Validate(settings).Concat(factory.SettingsProvider?.Validate(new PluginChoiceRequest(identity, settings, Platform) { Package = new PluginPackageReference(packagePath, title) }) ?? Array.Empty()).ToArray(); + if (issues.Length > 0) throw new InvalidOperationException(string.Join("; ", issues.Select(issue => issue.Field + ": " + issue.Message))); + if (Configuration.Read(descriptor, factory.Schema).SchemaVersion > factory.Schema.Version || Configuration.Read(descriptor, factory.Schema, identity).SchemaVersion > factory.Schema.Version) throw new InvalidOperationException("Configuration requires a newer plugin version."); + var plugin = factory.Create(new PluginApplicationContext(descriptor, Configuration, Scheduler, Capabilities, Resources, application, message => _log(descriptor.Id + ": " + message))); + var entry = new Instance(factory, plugin, application); + _entered.Add(entry); + await InvokeAsync(entry, PluginPhase.Initialize, token => plugin.InitializeAsync(token), gameToken).ConfigureAwait(false); + await Scheduler.Switch(); + } catch (OperationCanceledException) when (gameToken.IsCancellationRequested) { throw; } + catch (Exception error) { + await Scheduler.Switch(); + if (!(error is HookDeadlineException)) Fault(descriptor.Id, PluginPhase.Initialize, error); + var failed = _entered.FirstOrDefault(entry => entry.ApplicationScope == application); + if (failed == null) SafeDispose(application); else await CleanupAsync(failed, CancellationToken.None).ConfigureAwait(false); + await Scheduler.Switch(); + } + } + foreach (var phase in new[] { PluginPhase.Prepare, PluginPhase.Start }) { + await BlockDependentsAsync().ConfigureAwait(false); await Scheduler.Switch(); + var active = _entered.Where(Available).Select(entry => entry.Factory.Descriptor).ToArray(); + foreach (var descriptor in PluginOrderResolver.Resolve(active, phase, _hostCapabilities)) { + gameToken.ThrowIfCancellationRequested(); + var entry = _entered.First(instance => instance.Factory.Descriptor.Id == descriptor.Id); + if (!Available(entry)) continue; + try { + var settings = Configuration.Effective(descriptor, entry.Factory.Schema, identity); + entry.Settings = settings; + var context = new PluginGameContext(identity, packagePath, title, controllerId, session, settings, Capabilities, Resources, entry.GameScope, gameToken); + if (phase == PluginPhase.Prepare) entry.Prepared = true; + if (phase == PluginPhase.Start) _startOrder.Add(entry); + await InvokeAsync(entry, phase, token => phase == PluginPhase.Prepare ? entry.Plugin.PrepareGameAsync(context, token) : entry.Plugin.StartGameAsync(token), gameToken).ConfigureAwait(false); + await Scheduler.Switch(); + if (phase == PluginPhase.Start) SetStatus(descriptor.Id, PluginState.Running, "Running", phase); + } catch (OperationCanceledException) when (gameToken.IsCancellationRequested) { throw; } + catch (Exception error) { + await Scheduler.Switch(); + if (!(error is HookDeadlineException)) Fault(descriptor.Id, phase, error); + await CleanupAsync(entry, CancellationToken.None).ConfigureAwait(false); + await BlockDependentsAsync().ConfigureAwait(false); await Scheduler.Switch(); + } + } + } + _running = true; + RebuildDispatch(); + } catch { + await Scheduler.Switch(); + await StopCoreAsync(CancellationToken.None).ConfigureAwait(false); + await Scheduler.Switch(); + throw; + } finally { _operation.Release(); ReplayState(); } + } + + private IReadOnlyList Select(GameIdentity game) + { + var selected = new List(); + foreach (var factory in _factories.Values) { + var descriptor = factory.Descriptor; + try { + if (_abandoned.ContainsKey(descriptor.Id)) SetStatus(descriptor.Id, PluginState.Unresponsive, "Waiting for the previous instance to finish; restart the application if it remains blocked."); + else if (!Configuration.Enabled(descriptor, game)) SetStatus(descriptor.Id, PluginState.Disabled, "Disabled for this game"); + else if (descriptor.Platforms.Count > 0 && !descriptor.Platforms.Contains(Platform)) SetStatus(descriptor.Id, PluginState.Unsupported, "Unavailable on " + Platform); + else { selected.Add(descriptor); SetStatus(descriptor.Id, PluginState.Available, "Ready"); } + } catch (Exception error) { Fault(descriptor.Id, PluginPhase.Initialize, error); } + } + bool changed; + do { + changed = false; + foreach (var descriptor in selected.ToArray()) { + string? missing = MissingProvider(descriptor, selected); + if (missing != null) { selected.Remove(descriptor); SetStatus(descriptor.Id, PluginState.Blocked, "Missing required provider: " + missing); changed = true; } + } + foreach (var phase in new[] { PluginPhase.Initialize, PluginPhase.Prepare, PluginPhase.Start, PluginPhase.Ready, PluginPhase.Pause, PluginPhase.Resume, PluginPhase.Focus, PluginPhase.Tick, PluginPhase.Render }) { + try { PluginOrderResolver.Resolve(selected, phase, _hostCapabilities); } + catch (PluginOrderException error) { + foreach (string id in error.Plugins) { + selected.RemoveAll(descriptor => descriptor.Id == id); + SetStatus(id, PluginState.Blocked, error.Message, phase); + } + changed = true; + } + } + } while (changed); + return selected; + } + private bool Available(Instance entry) => !entry.Cleaned && _statuses[entry.Factory.Descriptor.Id].State != PluginState.Faulted && _statuses[entry.Factory.Descriptor.Id].State != PluginState.Blocked && _statuses[entry.Factory.Descriptor.Id].State != PluginState.Unresponsive; + private string? MissingProvider(PluginDescriptor descriptor, IEnumerable candidates) + { + var active = candidates.Where(candidate => _statuses[candidate.Id].State != PluginState.Faulted && _statuses[candidate.Id].State != PluginState.Blocked && _statuses[candidate.Id].State != PluginState.Unresponsive).ToArray(); + string? missing = descriptor.DependsOn.FirstOrDefault(id => active.All(candidate => candidate.Id != id)); + return missing ?? descriptor.Requires.FirstOrDefault(id => !_hostCapabilities.Contains(id) && active.All(candidate => !candidate.Provides.Contains(id))); + } + private async Task BlockDependentsAsync() + { + bool changed; + do { + changed = false; + foreach (var entry in _entered.Where(Available).ToArray()) { + string? missing = MissingProvider(entry.Factory.Descriptor, _entered.Where(Available).Select(instance => instance.Factory.Descriptor)); + if (missing == null) continue; + SetStatus(entry.Factory.Descriptor.Id, PluginState.Blocked, "Missing required provider: " + missing); + await CleanupAsync(entry, CancellationToken.None).ConfigureAwait(false); await Scheduler.Switch(); changed = true; + } + } while (changed); + } + private bool IsHealthy(Instance entry) => _statuses[entry.Factory.Descriptor.Id].State == PluginState.Running; + private async Task InvokeAsync(Instance entry, PluginPhase phase, Func invoke, CancellationToken cancellation) + { + SetStatus(entry.Factory.Descriptor.Id, PluginState.Starting, phase.ToString(), phase); + using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellation); + deadline.CancelAfter(StartupTimeout); + try { await AwaitHookAsync(entry, invoke(deadline.Token), deadline.Token).ConfigureAwait(false); } + catch (HookDeadlineException error) { + await Scheduler.Switch(); Abandon(entry, error.Hook); + if (cancellation.IsCancellationRequested) throw new OperationCanceledException(cancellation); + throw; + } + await Scheduler.Switch(); + } + private async Task AwaitHookAsync(Instance entry, Task hook, CancellationToken deadline) + { + entry.Track(hook); + if (await Task.WhenAny(hook, Task.Delay(Timeout.Infinite, deadline)).ConfigureAwait(false) != hook) { + _ = hook.ContinueWith(task => { var ignored = task.Exception; }, CancellationToken.None, TaskContinuationOptions.OnlyOnFaulted, TaskScheduler.Default); + throw new HookDeadlineException(hook); + } + await hook.ConfigureAwait(false); + } + private void Abandon(Instance entry, Task hook) + { + string id = entry.Factory.Descriptor.Id; + SetStatus(id, PluginState.Unresponsive, "Waiting for an unresponsive plugin; its next instance is blocked."); + // Stop the plugin now, but keep its scopes (native handles, device leases) until its hook and workers are done. + Emergency(entry); + if (entry.Abandoned) return; + Quarantine(entry, Task.WhenAll(hook, entry.RunningHooks(), Quiescence(entry)), shutdown: true); + } + private static Task Quiescence(Instance entry) => (entry.Plugin as IPluginDeferredCleanup)?.Quiescence ?? Task.CompletedTask; + /// + /// Keeps a stopped instance and its scopes (native handles, device leases) until its pending work finishes, however + /// long that takes, and blocks new instances of the plugin meanwhile. The host itself never waits for it. + /// + private void Quarantine(Instance entry, Task pending, bool shutdown) + { + string id = entry.Factory.Descriptor.Id; + entry.Abandoned = entry.Cleaned = true; + SetStatus(id, PluginState.Unresponsive, "Waiting for an unresponsive plugin; its next instance is blocked."); + // Another instance of the same plugin may still be draining; the next one waits for both. + _abandoned[id] = _abandoned.TryGetValue(id, out var earlier) ? Task.WhenAll(earlier, pending) : pending; + _ = FinishQuarantineAsync(entry, pending, shutdown); + } + private async Task FinishQuarantineAsync(Instance entry, Task pending, bool shutdown) + { + try { await pending.ConfigureAwait(false); } catch (Exception error) { _log("Abandoned hook: " + error.Message); } + await Scheduler.Switch(); + if (shutdown) { + // This old instance is quarantined independently of the active game's lifecycle gate. + using var deadline = new CancellationTokenSource(CleanupTimeout); + try { await entry.Plugin.ShutdownAsync(deadline.Token).ConfigureAwait(false); } + catch (Exception error) { _log("Deferred shutdown: " + error.Message); } + await Scheduler.Switch(); + } + SafeDispose(entry.GameScope); SafeDispose(entry.ApplicationScope); SafeDispose(entry.Plugin); + string id = entry.Factory.Descriptor.Id; + if (!_abandoned.TryGetValue(id, out var remaining)) return; + // Unblock the plugin only when every abandoned instance has drained, not just this one. + try { await remaining.ConfigureAwait(false); } catch { /* logged by each instance */ } + await Scheduler.Switch(); + if (!_abandoned.TryGetValue(id, out var current) || current != remaining) return; + _abandoned.Remove(id); + if (_statuses[id].State == PluginState.Unresponsive) SetStatus(id, PluginState.Available, "Previous instance stopped."); + } + private void SetStatus(string id, PluginState state, string message, PluginPhase? phase = null) { var status = _statuses[id]; status.State = state; status.Message = message; status.Phase = phase; } + private void Fault(string id, PluginPhase phase, Exception error) { SetStatus(id, PluginState.Faulted, error.Message, phase); _log(id + " / " + phase + ": " + error.Message); } + private void SafeDispose(IDisposable resource) { try { resource.Dispose(); } catch (Exception error) { _log("Plugin cleanup: " + error.Message); } } + + /// + /// Stops the current game's plugins and releases their game and instance resources. Safe to call when no game runs. + /// + /// + /// Cancels at once, then waits up to for a running + /// operation, such as a startup still in progress, to finish. Plugins are then cleaned up one at a time: partially started + /// ones first, then started ones in reverse start order. Each gets (if it was + /// prepared), disposal of , , disposal of + /// , and finally . Exceptions from these + /// steps are logged and cleanup continues; a hook that misses its deadline quarantines its plugin (see + /// ) and the host moves on to the next one. If the running operation does not finish in time, the + /// host falls back to . + /// + /// + /// Cuts cleanup short. It cancels the token each cleanup hook receives, in addition to ; if it + /// fires while the host waits for a running operation, the host falls back to . + /// + /// A task that completes when every plugin of the game is stopped or quarantined. + public async Task StopGameAsync(CancellationToken cleanup = default) + { + _gameCancellation?.Cancel(); + using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cleanup); + deadline.CancelAfter(CleanupTimeout); + try { await _operation.WaitAsync(deadline.Token).ConfigureAwait(false); } + catch (OperationCanceledException) { await Scheduler.Switch(); EmergencyStop(); return; } + await Scheduler.Switch(); + try { await StopCoreAsync(cleanup).ConfigureAwait(false); await Scheduler.Switch(); } + finally { _operation.Release(); ReplayState(); } + } + private async Task StopCoreAsync(CancellationToken cleanup) + { + _running = false; _dispatch.Clear(); _ready = false; _paused = null; _focused = null; + _gameCancellation?.Cancel(); + // Partially prepared instances unwind first, then started instances in their actual start order. + var reverse = _entered.Where(entry => !_startOrder.Contains(entry)).Reverse().Concat(_startOrder.AsEnumerable().Reverse()).ToArray(); + foreach (var entry in reverse) { + await CleanupAsync(entry, cleanup).ConfigureAwait(false); await Scheduler.Switch(); + } + _startOrder.Clear(); _entered.Clear(); _gameCancellation?.Dispose(); _gameCancellation = null; _activeGame = null; + } + /// + /// Validates and saves edited settings of one plugin and, when they change fields of + /// the running game, applies them to the running plugin first. Both happen inside the host's lifecycle gate, so a commit + /// never overlaps a game start or stop. + /// + /// + /// calls this after its own checks; plugins do not call it. The steps are: + /// + /// + /// Merge the edited fields into the latest stored layer (global, or the game's profile) and validate the effective result + /// against the schema. Provider validation and catalog checks are left to the settings session. + /// + /// + /// If the plugin is running and the edit affects the active game (a global edit, or an edit of that game's profile), apply + /// the changed live fields through or + /// . A plugin implementing neither, a failed result, or an asynchronous + /// apply that misses (which quarantines the plugin) ends the commit without saving. + /// + /// + /// Save only the edited fields with , then raise + /// . + /// + /// + /// If saving fails after a live apply, the host applies the previous settings again, so plugins must keep reconfiguration + /// reversible. A rollback that fails faults the plugin. + /// + /// The plugin whose settings change; must be one of . + /// + /// The edited layer: the game's overrides when is set, the global values otherwise. Only the fields + /// in are taken; a listed field that the draft lacks is removed from the stored layer and inherits + /// again. The host copies the draft. + /// + /// The game whose profile to change, or null for the global settings. + /// + /// Cancels waiting for the gate and the live apply. Once saving has started, it runs to completion. + /// + /// IDs of the top-level fields to commit, or null for every field of the schema. + /// + /// Success with the message Saved., or a failure whose message lists the validation issues or says why nothing was + /// saved. Errors and cancellation after the gate was entered are reported as failures, not thrown. + /// + /// is not installed in this host. + /// fired while waiting for the gate. + public Task CommitSettingsAsync(IPlayerPluginFactory factory, PluginSettingsSnapshot draft, GameIdentity? game, CancellationToken cancellation, IReadOnlyCollection? edited = null) + => CommitSettingsCoreAsync(factory, draft, game, cancellation, edited); + private async Task CleanupAsync(Instance entry, CancellationToken cleanup) + { + if (entry.Cleaned) return; + entry.Cleaned = true; + string id = entry.Factory.Descriptor.Id; + bool keepDiagnostic = _statuses[id].State == PluginState.Faulted || _statuses[id].State == PluginState.Blocked || _statuses[id].State == PluginState.Unresponsive; + if (!keepDiagnostic) SetStatus(id, PluginState.Stopping, "Stopping", PluginPhase.Stop); + if (entry.Prepared) { + using var stopDeadline = CancellationTokenSource.CreateLinkedTokenSource(cleanup); + stopDeadline.CancelAfter(CleanupTimeout); + try { await AwaitHookAsync(entry, entry.Plugin.StopGameAsync(stopDeadline.Token), stopDeadline.Token).ConfigureAwait(false); await Scheduler.Switch(); } + catch (HookDeadlineException error) { await Scheduler.Switch(); Abandon(entry, error.Hook); return; } + catch (OperationCanceledException) when (stopDeadline.IsCancellationRequested) { + await Scheduler.Switch(); Abandon(entry, Task.CompletedTask); return; + } + catch (Exception error) { await Scheduler.Switch(); _log(id + " / Stop: " + error.Message); } + } + SafeDispose(entry.GameScope); + using var shutdownDeadline = CancellationTokenSource.CreateLinkedTokenSource(cleanup); + shutdownDeadline.CancelAfter(CleanupTimeout); + try { await AwaitHookAsync(entry, entry.Plugin.ShutdownAsync(shutdownDeadline.Token), shutdownDeadline.Token).ConfigureAwait(false); await Scheduler.Switch(); } + catch (HookDeadlineException error) { await Scheduler.Switch(); Abandon(entry, error.Hook); return; } + catch (Exception error) { await Scheduler.Switch(); _log(id + " / Shutdown: " + error.Message); } + SafeDispose(entry.ApplicationScope); SafeDispose(entry.Plugin); + if (!keepDiagnostic) SetStatus(id, PluginState.Available, "Stopped"); + } + private void RebuildDispatch() + { + _dispatch.Clear(); + var active = _entered.Where(IsHealthy).Where(Available).ToDictionary(entry => entry.Factory.Descriptor.Id); + foreach (var phase in new[] { PluginPhase.Ready, PluginPhase.Pause, PluginPhase.Resume, PluginPhase.Focus, PluginPhase.Tick, PluginPhase.Render }) + _dispatch[phase] = PluginOrderResolver.Resolve(active.Values.Select(entry => entry.Factory.Descriptor), phase, _hostCapabilities).Select(descriptor => active[descriptor.Id]).ToArray(); + } + private void Dispatch(PluginPhase phase, double delta = 0, bool state = false) + { + Scheduler.VerifyOwner(); + bool continuous = phase == PluginPhase.Tick || phase == PluginPhase.Render; + if (!_running || !continuous && IsTransitioning || !_dispatch.TryGetValue(phase, out var entries)) return; + bool failed = false; + foreach (var entry in entries) { + if (entry.Busy) continue; + if (!Available(entry)) { failed = true; continue; } + if (failed && MissingProvider(entry.Factory.Descriptor, _entered.Where(Available).Select(candidate => candidate.Factory.Descriptor)) is string missing) { + SetStatus(entry.Factory.Descriptor.Id, PluginState.Blocked, "Missing required provider: " + missing); continue; + } + try { + switch (phase) { + case PluginPhase.Ready: entry.Plugin.GameReady(); break; + case PluginPhase.Pause: case PluginPhase.Resume: entry.Plugin.PauseChanged(state); break; + case PluginPhase.Focus: entry.Plugin.FocusChanged(state); break; + case PluginPhase.Tick: (entry.Plugin as IPluginTick)?.Tick(delta); break; + case PluginPhase.Render: (entry.Plugin as IPluginRender)?.BeforeRender(); break; + } + } catch (Exception error) { Fault(entry.Factory.Descriptor.Id, phase, error); failed = true; } + } + if (failed && _faultCleanup.IsCompleted) _faultCleanup = CleanupFaultsAsync(); + } + private async Task CleanupFaultsAsync() + { + await _operation.WaitAsync().ConfigureAwait(false); await Scheduler.Switch(); + try { + await BlockDependentsAsync().ConfigureAwait(false); await Scheduler.Switch(); + while (_entered.Any(entry => !Available(entry) && !entry.Cleaned)) + foreach (var entry in _entered.Where(entry => !Available(entry) && !entry.Cleaned).Reverse().ToArray()) { await CleanupAsync(entry, CancellationToken.None).ConfigureAwait(false); await Scheduler.Switch(); } + RebuildDispatch(); + } catch (Exception error) { _log("Plugin recovery: " + error.Message); } + finally { _operation.Release(); ReplayState(); } + } + private void ReplayState() + { + if (_ready) { _ready = false; Dispatch(PluginPhase.Ready); } + if (_paused.HasValue) { bool paused = _paused.Value; _paused = null; Dispatch(paused ? PluginPhase.Pause : PluginPhase.Resume, state: paused); } + if (_focused.HasValue) { bool focused = _focused.Value; _focused = null; Dispatch(PluginPhase.Focus, state: focused); } + } + /// + /// Tells the running plugins that the table's player has started and the game is playable; they receive + /// in ready order. Call it on the owner thread. Ignored when no game is running or + /// starting. While the host is busy () the call is held and delivered when the operation ends. + /// The Player calls it from the table player's OnPlayerStarted event. + /// + public void GameReady() { if (!_running && !IsTransitioning) return; _ready = true; if (!IsTransitioning) ReplayState(); } + /// + /// Tells the running plugins that the game was paused or resumed; they receive in + /// pause or resume order. Call it on the owner thread. Ignored when no game is running or starting. While the host is busy, + /// only the latest state is delivered when the operation ends. The Player calls it when its in-game menu opens and closes. + /// + /// True when the game was paused, false when it resumed. + public void PauseChanged(bool paused) { if (!_running && !IsTransitioning) return; _paused = paused; if (!IsTransitioning) ReplayState(); } + /// + /// Tells the running plugins that the application gained or lost focus; they receive + /// in focus order. Call it on the owner thread. Ignored when no game is running or + /// starting. While the host is busy, only the latest state is delivered when the operation ends. The Player calls it from + /// Unity's OnApplicationFocus. + /// + /// True when the application has focus. + public void FocusChanged(bool focused) { if (!_running && !IsTransitioning) return; _focused = focused; if (!IsTransitioning) ReplayState(); } + /// + /// Runs one frame: first runs the work queued on , such as continuations of the host's own operations + /// and of plugin hooks, then calls on every running plugin in tick order. + /// + /// + /// Call it on the owner thread every frame, also while the game is paused and while no game runs, since starting, stopping + /// and committing settings depend on the pump. Plugins keep being ticked while the host commits settings or cleans up a + /// faulted plugin, but not while a game starts or stops, and a plugin is skipped while its own asynchronous + /// reconfiguration runs. A plugin whose tick throws becomes and is cleaned up in the + /// background. The Player calls it from its Update with Time.unscaledDeltaTime. + /// + /// Real time since the previous frame in seconds, passed on to . + /// Called from a thread other than the owner thread. + public void Tick(double delta) { Scheduler.Pump(); Dispatch(PluginPhase.Tick, delta); } + /// + /// Calls on every running plugin in render order. Call it on the owner thread once + /// per frame, after all frame updates; the Player calls it from LateUpdate. Like , it reaches + /// plugins while the host commits settings or cleans up a faulted plugin, and a plugin whose call throws is faulted. It + /// does not pump the scheduler. + /// + /// Called from a thread other than the owner thread. + public void BeforeRender() => Dispatch(PluginPhase.Render); + /// + /// Stops all plugins of the current game immediately and synchronously, without awaiting any hook. Use it when there is no + /// time for : the Player calls it when the application quits, and + /// falls back to it when a running operation does not finish within . + /// + /// + /// Cancels and stops all dispatched calls. Then, in reverse order of creation, + /// it calls on every instance (exceptions are logged) and, unless the + /// instance was already cleaned up, disposes its , + /// and the plugin right away. and + /// are not called. An instance that still has a hook running, or whose + /// has not completed, is quarantined instead: it keeps its scopes until both + /// are done, and the plugin stays , blocking new instances, until then. + /// + /// Called from a thread other than the owner thread. + public void EmergencyStop() + { + Scheduler.VerifyOwner(); + _running = false; _gameCancellation?.Cancel(); _dispatch.Clear(); + foreach (var entry in _entered.AsEnumerable().Reverse()) { + Emergency(entry); + if (entry.Cleaned) continue; + entry.Cleaned = true; + // Hooks and workers still running keep their resources until they are done; everything else goes now. + var pending = Task.WhenAll(entry.RunningHooks(), Quiescence(entry)); + if (!pending.IsCompleted) { Quarantine(entry, pending, shutdown: false); continue; } + SafeDispose(entry.GameScope); SafeDispose(entry.ApplicationScope); SafeDispose(entry.Plugin); + } + } + private void Emergency(Instance entry) + { + try { (entry.Plugin as IPluginEmergencyStop)?.EmergencyStop(); } catch (Exception error) { _log(error.Message); } + } + } +} diff --git a/VisualPinball.Plugins/Runtime/PluginHost.cs.meta b/VisualPinball.Plugins/Runtime/PluginHost.cs.meta new file mode 100644 index 000000000..5cfe6ab33 --- /dev/null +++ b/VisualPinball.Plugins/Runtime/PluginHost.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: cfc149ebe13e609418c14fdcc83cfa9e \ No newline at end of file diff --git a/VisualPinball.Plugins/Runtime/PluginInputPipeline.cs b/VisualPinball.Plugins/Runtime/PluginInputPipeline.cs new file mode 100644 index 000000000..cec17ff6e --- /dev/null +++ b/VisualPinball.Plugins/Runtime/PluginInputPipeline.cs @@ -0,0 +1,71 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// SPDX-License-Identifier: MIT +// Licensed under MIT. See https://opensource.org/license/mit. + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace VisualPinball.Plugins +{ + /// + /// A chain of plugin input filters: each passes through every registered + /// in a deterministic order, and the owner of the pipeline forwards the final value. + /// + /// + /// Filters let plugins adjust input before the game sees it, for example to scale or dead-zone an analog nudge axis. The + /// order is order ascending, then plugin ID, so the result does not depend on registration order. Each plugin can + /// register one filter per pipeline. The pipeline itself does no threading, so register, unregister and apply on the + /// same thread. The Player does not create a pipeline yet; the type is a building block for an input owner. + /// + /// + /// sealed class NudgeScale : IPluginInputFilter + /// { + /// public PluginInputEvent Filter(in PluginInputEvent input) => input.Action == "nudge-x" + /// ? new PluginInputEvent(input.Action, input.Value * 2, input.TimestampUs) : input; + /// } + /// + /// using var stage = pipeline.Register("my.nudge", 10, new NudgeScale()); + /// var filtered = pipeline.Apply(new PluginInputEvent("nudge-x", 0.25f, timestampUs)); + /// + /// + /// + public sealed class PluginInputPipeline + { + private sealed class Stage + { + internal string Id = ""; + internal int Order; + internal IPluginInputFilter Filter = null!; + } + private readonly List _stages = new List(); + private Stage[] _dispatch = Array.Empty(); + /// Adds a plugin's filter to the chain until the returned handle is disposed. + /// The registering plugin's ID; also the tie-breaker between equal orders. + /// Position in the chain; lower values run first. + /// The filter to run on every event. + /// A handle that removes the filter; dispose it with the plugin's game or application scope. + /// A filter is already registered for . + public IDisposable Register(string pluginId, int order, IPluginInputFilter filter) + { + if (_stages.Any(stage => stage.Id == pluginId)) throw new InvalidOperationException("Input filter already registered: " + pluginId); + var entry = new Stage { Id = pluginId, Order = order, Filter = filter }; _stages.Add(entry); Rebuild(); + return new Subscription(() => { _stages.Remove(entry); Rebuild(); }); + } + private void Rebuild() => _dispatch = _stages.OrderBy(stage => stage.Order).ThenBy(stage => stage.Id, StringComparer.Ordinal).ToArray(); + /// + /// Runs an event through every filter in order, each receiving the previous filter's output, and returns the result. + /// With no filters registered the input is returned unchanged. A filter's exception propagates to the caller. + /// + /// The raw input event. + /// The filtered event to deliver to the game. + public PluginInputEvent Apply(in PluginInputEvent input) + { + var result = input; + foreach (var stage in _dispatch) result = stage.Filter.Filter(result); + return result; + } + } +} diff --git a/VisualPinball.Plugins/Runtime/PluginInputPipeline.cs.meta b/VisualPinball.Plugins/Runtime/PluginInputPipeline.cs.meta new file mode 100644 index 000000000..7d6fae5ca --- /dev/null +++ b/VisualPinball.Plugins/Runtime/PluginInputPipeline.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 73ede895eba38114994b9ffb19eff75a \ No newline at end of file diff --git a/VisualPinball.Plugins/Runtime/PluginOrderResolver.cs b/VisualPinball.Plugins/Runtime/PluginOrderResolver.cs new file mode 100644 index 000000000..3e072fe66 --- /dev/null +++ b/VisualPinball.Plugins/Runtime/PluginOrderResolver.cs @@ -0,0 +1,141 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// SPDX-License-Identifier: MIT +// Licensed under MIT. See https://opensource.org/license/mit. + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace VisualPinball.Plugins +{ + /// + /// Thrown by when a set of plugins cannot be ordered: a dependency is missing, a + /// required capability has no provider or more than one, or the ordering constraints form a cycle. + /// + /// + /// The host catches it while selecting plugins for a game and marks every plugin in as + /// with the exception's message, then resolves the rest again. Plugin authors see the + /// message in the Player's plugin status, such as my.plugin: vpe.example.v1 has multiple providers; select one. + /// + public sealed class PluginOrderException : InvalidOperationException + { + /// + /// IDs of the plugins at fault: the consumer whose dependency or capability is unsatisfied, or every plugin in the + /// cycle, in cycle order. + /// + public IReadOnlyList Plugins { get; } + /// Creates the exception with a user-facing message and the plugins to block. + /// Explanation shown in the plugin's status. + /// IDs of the plugins at fault. + public PluginOrderException(string message, params string[] plugins) : base(message) { Plugins = plugins; } + } + + /// + /// Computes the order in which plugins run one lifecycle phase: dependencies and capability providers run before the + /// plugins that need them, explicit before/after constraints are honored, and priority then plugin ID break ties. + /// + /// + /// The host resolves an order for every phase from the descriptors' declarations, so plugin authors influence ordering + /// only through : , + /// /, , + /// , and per-phase + /// . Stopping runs in reverse start order. + /// + /// A plugin that runs after the DMD plugin in every phase, and early in the render phase: + /// + /// new PluginDescriptor("my.overlay", "Overlay", "Draws on top of the DMD") { + /// After = new[] { "org.visualpinball.dmd" }, + /// PhaseOrder = new Dictionary<PluginPhase, PluginOrder> { [PluginPhase.Render] = new PluginOrder { Priority = -10 } } + /// }; + /// + /// + /// + public static class PluginOrderResolver + { + /// + /// Orders for one and returns them in execution order. + /// + /// + /// Rules, applied together: + /// + /// Every entry must be in the set and runs first. + /// + /// Each entry not offered by the host and not provided by the plugin itself needs + /// exactly one other plugin in the set that provides it, which then runs first. + /// narrows several providers down to the selected one. + /// + /// + /// and , plus the phase's + /// and , add constraints; IDs not in the set are ignored. + /// + /// + /// Among plugins free to run, the lowest of the phase goes first, or + /// when the plugin has no entry for the phase; equal values go by ID (ordinal). + /// + /// + /// + /// The enabled plugins to order. IDs must be unique. + /// The phase whose entry applies. + /// + /// Capability IDs the application already offers (see ); null for none. + /// + /// + /// Capability ID to the ID of the plugin chosen to provide it, when several could; null for none. + /// + /// The same descriptors, in the order the phase should run them. + /// + /// A dependency or required capability is unsatisfied or ambiguous, or the constraints form a cycle. + /// + /// Two descriptors share an ID. + public static IReadOnlyList Resolve(IEnumerable plugins, PluginPhase phase, + IEnumerable? hostCapabilities = null, IReadOnlyDictionary? preferredProviders = null) + { + var nodes = plugins.ToDictionary(plugin => plugin.Id, StringComparer.Ordinal); + var host = new HashSet(hostCapabilities ?? Array.Empty(), StringComparer.Ordinal); + var incoming = nodes.Keys.ToDictionary(id => id, _ => new HashSet(StringComparer.Ordinal)); + foreach (var plugin in nodes.Values) { + foreach (string dependency in plugin.DependsOn) { + if (!nodes.ContainsKey(dependency)) throw new PluginOrderException(plugin.Id + " requires disabled or absent plugin " + dependency + ".", plugin.Id); + incoming[plugin.Id].Add(dependency); + } + foreach (string capability in plugin.Requires) { + if (host.Contains(capability) || plugin.Provides.Contains(capability)) continue; + var candidates = nodes.Values.Where(provider => provider.Id != plugin.Id && provider.Provides.Contains(capability)).ToList(); + if (preferredProviders != null && preferredProviders.TryGetValue(capability, out var selected)) + candidates.RemoveAll(candidate => candidate.Id != selected); + if (candidates.Count != 1) throw new PluginOrderException(plugin.Id + ": " + capability + (candidates.Count == 0 ? " is unavailable." : " has multiple providers; select one."), plugin.Id); + incoming[plugin.Id].Add(candidates[0].Id); + } + plugin.PhaseOrder.TryGetValue(phase, out var order); + foreach (string before in plugin.Before.Concat(order?.Before ?? Array.Empty())) + if (nodes.ContainsKey(before)) incoming[before].Add(plugin.Id); + foreach (string after in plugin.After.Concat(order?.After ?? Array.Empty())) + if (nodes.ContainsKey(after)) incoming[plugin.Id].Add(after); + } + var resolved = new List(nodes.Count); + while (nodes.Count > 0) { + var next = nodes.Values.Where(plugin => incoming[plugin.Id].Count == 0) + .OrderBy(plugin => plugin.PhaseOrder.TryGetValue(phase, out var order) ? order.Priority : plugin.Order) + .ThenBy(plugin => plugin.Id, StringComparer.Ordinal).FirstOrDefault(); + if (next == null) { + var cycle = Cycle(incoming, nodes.Keys.First()); + throw new PluginOrderException("Plugin ordering cycle: " + string.Join(" -> ", cycle.Concat(new[] { cycle[0] })) + ".", cycle); + } + resolved.Add(next); + nodes.Remove(next.Id); + foreach (var dependencies in incoming.Values) dependencies.Remove(next.Id); + } + return resolved; + } + + private static string[] Cycle(Dictionary> incoming, string first) + { + var path = new List(); + string current = first; + while (!path.Contains(current)) { path.Add(current); current = incoming[current].First(); } + return path.Skip(path.IndexOf(current)).ToArray(); + } + } +} diff --git a/VisualPinball.Plugins/Runtime/PluginOrderResolver.cs.meta b/VisualPinball.Plugins/Runtime/PluginOrderResolver.cs.meta new file mode 100644 index 000000000..029bc459b --- /dev/null +++ b/VisualPinball.Plugins/Runtime/PluginOrderResolver.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 2fbf1c5d3c2f88a4190c146701374266 \ No newline at end of file diff --git a/VisualPinball.Plugins/Runtime/PluginOutputLeases.cs b/VisualPinball.Plugins/Runtime/PluginOutputLeases.cs new file mode 100644 index 000000000..c80957636 --- /dev/null +++ b/VisualPinball.Plugins/Runtime/PluginOutputLeases.cs @@ -0,0 +1,54 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// SPDX-License-Identifier: MIT +// Licensed under MIT. See https://opensource.org/license/mit. + +using System; +using System.Collections.Generic; + +namespace VisualPinball.Plugins +{ + /// + /// Exclusive leases on physical output destinations, so two plugins never open the same device, such as a real DMD on a + /// USB or serial port. + /// + /// + /// The Player publishes one instance as the vpe.outputs.v1 capability (UnityPluginCapability.Outputs). A + /// plugin acquires a lease for each device before opening it and keeps the lease until the device is closed. Leases + /// compare destination IDs as plain strings, so agree on stable IDs: the DMD plugin uses usb:zedmd:auto, + /// usb:pin2dmd:auto or serial:COM3 (port upper-cased). Thread-safe. + /// + /// The DMD plugin leases its configured devices while preparing a game and releases them once its native worker stopped: + /// + /// var outputs = game.Capabilities.Require<PluginOutputLeases>(UnityPluginCapability.Outputs); + /// foreach (var output in OutputIds(settings)) _nativeResources.Own(outputs.Acquire(output, _application.Plugin.Id)); + /// + /// + /// + public sealed class PluginOutputLeases + { + private readonly Dictionary _owners = new Dictionary(StringComparer.Ordinal); + /// + /// Takes exclusive ownership of a destination until the returned lease is disposed. Fails instead of waiting when + /// another plugin holds it; a failed acquisition in a prepare hook faults the plugin with the message naming the owner. + /// + /// Stable device ID, compared ordinally. + /// ID of the acquiring plugin, reported by and in conflict messages. + /// The lease. Dispose it after the device is closed, from any thread; disposing twice does nothing. + /// is null, empty or whitespace. + /// The destination is already leased. + public IDisposable Acquire(string destination, string pluginId) + { + if (string.IsNullOrWhiteSpace(destination)) throw new ArgumentException("A destination needs a stable ID.", nameof(destination)); + lock (_owners) { + if (_owners.TryGetValue(destination, out var owner)) throw new InvalidOperationException(destination + " is already owned by " + owner + "."); + _owners.Add(destination, pluginId); + } + return new Subscription(() => { lock (_owners) _owners.Remove(destination); }); + } + /// Returns the ID of the plugin holding a destination, or null when it is free. + /// Stable device ID, compared ordinally. + public string? Owner(string destination) { lock (_owners) return _owners.TryGetValue(destination, out var owner) ? owner : null; } + } +} diff --git a/VisualPinball.Plugins/Runtime/PluginOutputLeases.cs.meta b/VisualPinball.Plugins/Runtime/PluginOutputLeases.cs.meta new file mode 100644 index 000000000..ace3636ab --- /dev/null +++ b/VisualPinball.Plugins/Runtime/PluginOutputLeases.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9e15f9a2067979a4fb8d4f1660492433 \ No newline at end of file diff --git a/VisualPinball.Plugins/Runtime/ResourceScope.cs b/VisualPinball.Plugins/Runtime/ResourceScope.cs new file mode 100644 index 000000000..7450e2905 --- /dev/null +++ b/VisualPinball.Plugins/Runtime/ResourceScope.cs @@ -0,0 +1,91 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// SPDX-License-Identifier: MIT +// Licensed under MIT. See https://opensource.org/license/mit. + +using System; +using System.Collections.Generic; + +namespace VisualPinball.Plugins +{ + /// + /// Owns a set of disposables and releases them together, in reverse order of acquisition, continuing past failures. + /// + /// + /// The host gives each plugin instance two scopes: , released right after + /// , and , released after + /// . A plugin that stops responding keeps both until its pending work finishes. + /// Handing subscriptions, bindings, publications and GameObjects to a scope instead of tracking them by hand means a + /// failed or abandoned plugin still cleans up. Plugins and the Player also create + /// their own scopes for narrower lifetimes, such as PinUP's scope per player screen or the DMD plugin's scope for native + /// device leases. Not thread-safe: use one scope from one thread. + /// + /// + /// var dmd = game.Lifetime.Own(game.Resources.Bind<IPluginDisplaySource>(selection)); + /// game.Lifetime.Own(dmd.SubscribeFrames(frame => Render(frame))); + /// game.Lifetime.Own(new Subscription(() => { if (_root != null) UnityEngine.Object.Destroy(_root); })); + /// + /// + /// + public sealed class ResourceScope : IDisposable + { + private readonly List _resources = new List(); + private bool _disposed; + /// + /// Adds a resource to release when the scope is disposed, and returns it so the call can wrap a constructor or + /// Publish call inline. If the scope is already disposed, the resource is disposed at once and the call throws, + /// so late work cannot leak. + /// + /// The resource type, kept so the result needs no cast. + /// The resource to own. + /// . + /// The scope has already been disposed. + public T Own(T resource) where T : IDisposable + { + if (_disposed) { resource.Dispose(); throw new ObjectDisposedException(nameof(ResourceScope)); } + _resources.Add(resource); + return resource; + } + /// + /// Disposes every owned resource, last owned first. Every resource is disposed even when one throws; afterwards the + /// collected failures are rethrown together. Later calls do nothing. + /// + /// One or more resources threw while being disposed. + public void Dispose() + { + if (_disposed) return; + _disposed = true; + List? errors = null; + for (int i = _resources.Count - 1; i >= 0; i--) + try { _resources[i].Dispose(); } catch (Exception error) { (errors ??= new List()).Add(error); } + _resources.Clear(); + if (errors != null) throw new AggregateException("Some plugin resources could not be released.", errors); + } + } + + /// + /// An that runs a release action once. Returned by most plugin APIs as the handle that undoes a + /// subscription, publication or lease, and handy for turning any cleanup into something a can own. + /// + /// + /// + /// PinUP unhooks its screen events when the game ends: + /// + /// game.Lifetime.Own(new Subscription(() => { _player.ScreenCreated -= ScreenCreated; _player.ScreenDestroyed -= ScreenDestroyed; })); + /// + /// + /// + public sealed class Subscription : IDisposable + { + private Action? _release; + /// Wraps a release action. + /// Runs on the first , on the disposing thread. + public Subscription(Action release) { _release = release; } + /// + /// Runs the release action on the first call; later calls, including concurrent ones, do nothing. An exception from the + /// action propagates to the caller and the action is not retried. + /// + public void Dispose() => System.Threading.Interlocked.Exchange(ref _release, null)?.Invoke(); + } +} diff --git a/VisualPinball.Plugins/Runtime/ResourceScope.cs.meta b/VisualPinball.Plugins/Runtime/ResourceScope.cs.meta new file mode 100644 index 000000000..596564349 --- /dev/null +++ b/VisualPinball.Plugins/Runtime/ResourceScope.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 34133e0d2f52bc74a869e80af211e56a \ No newline at end of file diff --git a/VisualPinball.Plugins/VisualPinball.Plugins.asmdef b/VisualPinball.Plugins/VisualPinball.Plugins.asmdef new file mode 100644 index 000000000..3400ccefc --- /dev/null +++ b/VisualPinball.Plugins/VisualPinball.Plugins.asmdef @@ -0,0 +1,8 @@ +{ + "name": "VisualPinball.Plugins", + "rootNamespace": "VisualPinball.Plugins", + "references": ["Newtonsoft.Json"], + "overrideReferences": false, + "precompiledReferences": [], + "noEngineReferences": true +} diff --git a/VisualPinball.Plugins/VisualPinball.Plugins.asmdef.meta b/VisualPinball.Plugins/VisualPinball.Plugins.asmdef.meta new file mode 100644 index 000000000..baba096aa --- /dev/null +++ b/VisualPinball.Plugins/VisualPinball.Plugins.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 87ac0b1c56dbfee48ab1850ba3068e01 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Plugins/VisualPinball.Plugins.csproj b/VisualPinball.Plugins/VisualPinball.Plugins.csproj new file mode 100644 index 000000000..59299b324 --- /dev/null +++ b/VisualPinball.Plugins/VisualPinball.Plugins.csproj @@ -0,0 +1,13 @@ + + + netstandard2.1 + 9.0 + enable + MIT + true + + + + + + diff --git a/VisualPinball.Plugins/VisualPinball.Plugins.csproj.meta b/VisualPinball.Plugins/VisualPinball.Plugins.csproj.meta new file mode 100644 index 000000000..2f67ab4c8 --- /dev/null +++ b/VisualPinball.Plugins/VisualPinball.Plugins.csproj.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 763792b723e118c4ba2b83646d035dc8 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/Assets/Shaders/Srp/Insert.meta b/VisualPinball.Unity/Assets/Shaders/Srp/Insert.meta new file mode 100644 index 000000000..b4e0c4305 --- /dev/null +++ b/VisualPinball.Unity/Assets/Shaders/Srp/Insert.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 8e9836e19c38a8a439b5155365ab3f99 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/Assets/Shaders/Srp/Insert/Insert Cover.shadergraph b/VisualPinball.Unity/Assets/Shaders/Srp/Insert/Insert Cover.shadergraph new file mode 100644 index 000000000..ddf45cbd1 --- /dev/null +++ b/VisualPinball.Unity/Assets/Shaders/Srp/Insert/Insert Cover.shadergraph @@ -0,0 +1,4613 @@ +{ + "m_SGVersion": 3, + "m_Type": "UnityEditor.ShaderGraph.GraphData", + "m_ObjectId": "7398bbf56a1159b68fe38f2d28dc845f", + "m_Properties": [ + { + "m_Id": "ad06f3fb3b0c594a95419141a6275219" + }, + { + "m_Id": "fae01e6a006851718b1e68199e602c03" + }, + { + "m_Id": "2097df164c9e5fb1947791cdfa588dd1" + }, + { + "m_Id": "f611e7ae978c57538ba217fb9b8c077a" + }, + { + "m_Id": "5b38cbf426f45f719881c428caf2ff6f" + }, + { + "m_Id": "ca43925be4165b178d1f5bc49e3651ca" + }, + { + "m_Id": "6afc15978a595c2d8813b1230b602de2" + }, + { + "m_Id": "5ee46cc3aef851a28108df86cdbda282" + }, + { + "m_Id": "4e2ee789d2ea5b25b2c2a9618c1b8751" + }, + { + "m_Id": "41b0c9f64d6e57c8bef5ef55302a84e7" + }, + { + "m_Id": "e48982adfc4a5689b1b479e31ba05ead" + }, + { + "m_Id": "beaf92fa297c5f0f8cfffa63a937cc10" + }, + { + "m_Id": "e369cdcd58465c1ea4f76f73dfc2dc40" + }, + { + "m_Id": "73ee4f83fb3f55998eaa8615d946fd00" + }, + { + "m_Id": "a40e81823ec95013b379358b4dc225df" + }, + { + "m_Id": "8a7f5ba32f7354ccae9f44d72abefd82" + }, + { + "m_Id": "bc2becce24e8513a8434c08c2fbf42b9" + }, + { + "m_Id": "597db27618c157c99116431fe62014ac" + }, + { + "m_Id": "e34e3001a11f5de799da15122a2efb3d" + }, + { + "m_Id": "ea281cf402cf56caa413b6c3334f283e" + }, + { + "m_Id": "d3e87f63665b5fcba90acf09dc7ca55b" + } + ], + "m_Keywords": [], + "m_Dropdowns": [], + "m_CategoryData": [ + { + "m_Id": "7b398cb0bb835563b2574124a2b247d5" + } + ], + "m_Nodes": [ + { + "m_Id": "6c3f79c836b45d7ca783db0b58c8a0a2" + }, + { + "m_Id": "1e4ff86c89cf52158fa52f50c68b7622" + }, + { + "m_Id": "293d868c32ac5454bc5baa18d95958d0" + }, + { + "m_Id": "bb541da4cd8f5bce9d7fb6758b047c46" + }, + { + "m_Id": "5cab6e924ebf5fbd98435c85181187d6" + }, + { + "m_Id": "f9847ebe2d1c5ab4a9726ad4fc67938d" + }, + { + "m_Id": "46b690f690045f93a980c40192b8334a" + }, + { + "m_Id": "f3861acedd695d2baffd0f91a051783d" + }, + { + "m_Id": "ac0e9b9ab4275ebf8452ae7fffface7f" + }, + { + "m_Id": "d7e5b5935814588d9e58aa447ec825af" + }, + { + "m_Id": "6eedf44ab7a85b48934617d05064c2c5" + }, + { + "m_Id": "ca03b11231c25e069a7ac8ce3ffec4ad" + }, + { + "m_Id": "203e1b17561857a5884e227dd94e2a70" + }, + { + "m_Id": "34e79bb6392457019e43f53374ac48f9" + }, + { + "m_Id": "a93f8b0ecc385c40b55e136c1768652f" + }, + { + "m_Id": "c2ba7fad87fc5a7cbddeda15d70be245" + }, + { + "m_Id": "258469db3724556da3ce5aa5103917ab" + }, + { + "m_Id": "93eb8464ca0b54ddb97499e11a6c674f" + }, + { + "m_Id": "2cb25b426b3e5c90836f456a1beda794" + }, + { + "m_Id": "55bdf08e49c45b8eb889aa7ac58378cb" + }, + { + "m_Id": "86439196d3665c5c939ed992d61d38db" + }, + { + "m_Id": "84d0067015ba51dd9ecbac43815457ee" + }, + { + "m_Id": "957e9ed0c7c75a15b6f95d921b90f7c5" + }, + { + "m_Id": "3a23644eca7454818fdf008ee8debf7a" + }, + { + "m_Id": "fc79f245f21e57e691f820bd183097dc" + }, + { + "m_Id": "e39b393c054c5627b0a7dc680763496e" + }, + { + "m_Id": "ae6fbc0cd86a5367a59330cce4ee6483" + }, + { + "m_Id": "a9efd782c94655aeaf5d15772153e39b" + }, + { + "m_Id": "d5cb3ef25c845329974239d959f0af4f" + }, + { + "m_Id": "9eaf027f14a05caaa4525c402bf52b36" + }, + { + "m_Id": "4a4c94b2e0b75a19ab193ec933ea1466" + }, + { + "m_Id": "53fade0153a85229ba074a003b4e5d88" + }, + { + "m_Id": "5b1791466d87517792a0ff4cac505750" + }, + { + "m_Id": "8c8812e0868258fda47f84f843fb6f89" + }, + { + "m_Id": "be026746f05e5466ba9548f5e3cc5d38" + }, + { + "m_Id": "d4705b78c4255faabcebf85cadf8ab7f" + }, + { + "m_Id": "3cb544b2383a52a08d6dfa42b0182fc5" + }, + { + "m_Id": "970b3b62e1625d8784503fc213668716" + }, + { + "m_Id": "c97ff5e9b87140c691321a3e8fc99e68" + }, + { + "m_Id": "b99248f4c5a34783878a33a7e2bc8fb9" + } + ], + "m_GroupDatas": [], + "m_StickyNoteDatas": [], + "m_Edges": [ + { + "m_OutputSlot": { + "m_Node": { + "m_Id": "a93f8b0ecc385c40b55e136c1768652f" + }, + "m_SlotId": 6 + }, + "m_InputSlot": { + "m_Node": { + "m_Id": "5cab6e924ebf5fbd98435c85181187d6" + }, + "m_SlotId": 0 + } + }, + { + "m_OutputSlot": { + "m_Node": { + "m_Id": "a93f8b0ecc385c40b55e136c1768652f" + }, + "m_SlotId": 7 + }, + "m_InputSlot": { + "m_Node": { + "m_Id": "46b690f690045f93a980c40192b8334a" + }, + "m_SlotId": 0 + } + }, + { + "m_OutputSlot": { + "m_Node": { + "m_Id": "a93f8b0ecc385c40b55e136c1768652f" + }, + "m_SlotId": 8 + }, + "m_InputSlot": { + "m_Node": { + "m_Id": "d7e5b5935814588d9e58aa447ec825af" + }, + "m_SlotId": 0 + } + }, + { + "m_OutputSlot": { + "m_Node": { + "m_Id": "a93f8b0ecc385c40b55e136c1768652f" + }, + "m_SlotId": 10 + }, + "m_InputSlot": { + "m_Node": { + "m_Id": "bb541da4cd8f5bce9d7fb6758b047c46" + }, + "m_SlotId": 0 + } + }, + { + "m_OutputSlot": { + "m_Node": { + "m_Id": "a93f8b0ecc385c40b55e136c1768652f" + }, + "m_SlotId": 27 + }, + "m_InputSlot": { + "m_Node": { + "m_Id": "6eedf44ab7a85b48934617d05064c2c5" + }, + "m_SlotId": 0 + } + }, + { + "m_OutputSlot": { + "m_Node": { + "m_Id": "a93f8b0ecc385c40b55e136c1768652f" + }, + "m_SlotId": 28 + }, + "m_InputSlot": { + "m_Node": { + "m_Id": "ca03b11231c25e069a7ac8ce3ffec4ad" + }, + "m_SlotId": 0 + } + }, + { + "m_OutputSlot": { + "m_Node": { + "m_Id": "a93f8b0ecc385c40b55e136c1768652f" + }, + "m_SlotId": 29 + }, + "m_InputSlot": { + "m_Node": { + "m_Id": "203e1b17561857a5884e227dd94e2a70" + }, + "m_SlotId": 0 + } + }, + { + "m_OutputSlot": { + "m_Node": { + "m_Id": "a93f8b0ecc385c40b55e136c1768652f" + }, + "m_SlotId": 30 + }, + "m_InputSlot": { + "m_Node": { + "m_Id": "34e79bb6392457019e43f53374ac48f9" + }, + "m_SlotId": 0 + } + }, + { + "m_OutputSlot": { + "m_Node": { + "m_Id": "c2ba7fad87fc5a7cbddeda15d70be245" + }, + "m_SlotId": 0 + }, + "m_InputSlot": { + "m_Node": { + "m_Id": "a93f8b0ecc385c40b55e136c1768652f" + }, + "m_SlotId": 0 + } + }, + { + "m_OutputSlot": { + "m_Node": { + "m_Id": "258469db3724556da3ce5aa5103917ab" + }, + "m_SlotId": 0 + }, + "m_InputSlot": { + "m_Node": { + "m_Id": "a93f8b0ecc385c40b55e136c1768652f" + }, + "m_SlotId": 1 + } + }, + { + "m_OutputSlot": { + "m_Node": { + "m_Id": "93eb8464ca0b54ddb97499e11a6c674f" + }, + "m_SlotId": 0 + }, + "m_InputSlot": { + "m_Node": { + "m_Id": "a93f8b0ecc385c40b55e136c1768652f" + }, + "m_SlotId": 2 + } + }, + { + "m_OutputSlot": { + "m_Node": { + "m_Id": "2cb25b426b3e5c90836f456a1beda794" + }, + "m_SlotId": 0 + }, + "m_InputSlot": { + "m_Node": { + "m_Id": "a93f8b0ecc385c40b55e136c1768652f" + }, + "m_SlotId": 3 + } + }, + { + "m_OutputSlot": { + "m_Node": { + "m_Id": "55bdf08e49c45b8eb889aa7ac58378cb" + }, + "m_SlotId": 0 + }, + "m_InputSlot": { + "m_Node": { + "m_Id": "a93f8b0ecc385c40b55e136c1768652f" + }, + "m_SlotId": 4 + } + }, + { + "m_OutputSlot": { + "m_Node": { + "m_Id": "86439196d3665c5c939ed992d61d38db" + }, + "m_SlotId": 0 + }, + "m_InputSlot": { + "m_Node": { + "m_Id": "a93f8b0ecc385c40b55e136c1768652f" + }, + "m_SlotId": 5 + } + }, + { + "m_OutputSlot": { + "m_Node": { + "m_Id": "84d0067015ba51dd9ecbac43815457ee" + }, + "m_SlotId": 0 + }, + "m_InputSlot": { + "m_Node": { + "m_Id": "a93f8b0ecc385c40b55e136c1768652f" + }, + "m_SlotId": 9 + } + }, + { + "m_OutputSlot": { + "m_Node": { + "m_Id": "957e9ed0c7c75a15b6f95d921b90f7c5" + }, + "m_SlotId": 0 + }, + "m_InputSlot": { + "m_Node": { + "m_Id": "a93f8b0ecc385c40b55e136c1768652f" + }, + "m_SlotId": 23 + } + }, + { + "m_OutputSlot": { + "m_Node": { + "m_Id": "3a23644eca7454818fdf008ee8debf7a" + }, + "m_SlotId": 0 + }, + "m_InputSlot": { + "m_Node": { + "m_Id": "a93f8b0ecc385c40b55e136c1768652f" + }, + "m_SlotId": 24 + } + }, + { + "m_OutputSlot": { + "m_Node": { + "m_Id": "fc79f245f21e57e691f820bd183097dc" + }, + "m_SlotId": 0 + }, + "m_InputSlot": { + "m_Node": { + "m_Id": "a93f8b0ecc385c40b55e136c1768652f" + }, + "m_SlotId": 25 + } + }, + { + "m_OutputSlot": { + "m_Node": { + "m_Id": "e39b393c054c5627b0a7dc680763496e" + }, + "m_SlotId": 0 + }, + "m_InputSlot": { + "m_Node": { + "m_Id": "a93f8b0ecc385c40b55e136c1768652f" + }, + "m_SlotId": 26 + } + }, + { + "m_OutputSlot": { + "m_Node": { + "m_Id": "ae6fbc0cd86a5367a59330cce4ee6483" + }, + "m_SlotId": 0 + }, + "m_InputSlot": { + "m_Node": { + "m_Id": "a93f8b0ecc385c40b55e136c1768652f" + }, + "m_SlotId": 11 + } + }, + { + "m_OutputSlot": { + "m_Node": { + "m_Id": "a9efd782c94655aeaf5d15772153e39b" + }, + "m_SlotId": 0 + }, + "m_InputSlot": { + "m_Node": { + "m_Id": "a93f8b0ecc385c40b55e136c1768652f" + }, + "m_SlotId": 14 + } + }, + { + "m_OutputSlot": { + "m_Node": { + "m_Id": "d5cb3ef25c845329974239d959f0af4f" + }, + "m_SlotId": 0 + }, + "m_InputSlot": { + "m_Node": { + "m_Id": "a93f8b0ecc385c40b55e136c1768652f" + }, + "m_SlotId": 15 + } + }, + { + "m_OutputSlot": { + "m_Node": { + "m_Id": "9eaf027f14a05caaa4525c402bf52b36" + }, + "m_SlotId": 0 + }, + "m_InputSlot": { + "m_Node": { + "m_Id": "a93f8b0ecc385c40b55e136c1768652f" + }, + "m_SlotId": 16 + } + }, + { + "m_OutputSlot": { + "m_Node": { + "m_Id": "4a4c94b2e0b75a19ab193ec933ea1466" + }, + "m_SlotId": 0 + }, + "m_InputSlot": { + "m_Node": { + "m_Id": "a93f8b0ecc385c40b55e136c1768652f" + }, + "m_SlotId": 19 + } + }, + { + "m_OutputSlot": { + "m_Node": { + "m_Id": "53fade0153a85229ba074a003b4e5d88" + }, + "m_SlotId": 0 + }, + "m_InputSlot": { + "m_Node": { + "m_Id": "a93f8b0ecc385c40b55e136c1768652f" + }, + "m_SlotId": 17 + } + }, + { + "m_OutputSlot": { + "m_Node": { + "m_Id": "5b1791466d87517792a0ff4cac505750" + }, + "m_SlotId": 0 + }, + "m_InputSlot": { + "m_Node": { + "m_Id": "a93f8b0ecc385c40b55e136c1768652f" + }, + "m_SlotId": 20 + } + }, + { + "m_OutputSlot": { + "m_Node": { + "m_Id": "8c8812e0868258fda47f84f843fb6f89" + }, + "m_SlotId": 0 + }, + "m_InputSlot": { + "m_Node": { + "m_Id": "a93f8b0ecc385c40b55e136c1768652f" + }, + "m_SlotId": 18 + } + }, + { + "m_OutputSlot": { + "m_Node": { + "m_Id": "be026746f05e5466ba9548f5e3cc5d38" + }, + "m_SlotId": 0 + }, + "m_InputSlot": { + "m_Node": { + "m_Id": "a93f8b0ecc385c40b55e136c1768652f" + }, + "m_SlotId": 21 + } + }, + { + "m_OutputSlot": { + "m_Node": { + "m_Id": "d4705b78c4255faabcebf85cadf8ab7f" + }, + "m_SlotId": 0 + }, + "m_InputSlot": { + "m_Node": { + "m_Id": "a93f8b0ecc385c40b55e136c1768652f" + }, + "m_SlotId": 22 + } + }, + { + "m_OutputSlot": { + "m_Node": { + "m_Id": "3cb544b2383a52a08d6dfa42b0182fc5" + }, + "m_SlotId": 0 + }, + "m_InputSlot": { + "m_Node": { + "m_Id": "a93f8b0ecc385c40b55e136c1768652f" + }, + "m_SlotId": 12 + } + }, + { + "m_OutputSlot": { + "m_Node": { + "m_Id": "970b3b62e1625d8784503fc213668716" + }, + "m_SlotId": 0 + }, + "m_InputSlot": { + "m_Node": { + "m_Id": "a93f8b0ecc385c40b55e136c1768652f" + }, + "m_SlotId": 13 + } + }, + { + "m_OutputSlot": { + "m_Node": { + "m_Id": "a93f8b0ecc385c40b55e136c1768652f" + }, + "m_SlotId": 31 + }, + "m_InputSlot": { + "m_Node": { + "m_Id": "ac0e9b9ab4275ebf8452ae7fffface7f" + }, + "m_SlotId": 0 + } + }, + { + "m_OutputSlot": { + "m_Node": { + "m_Id": "c97ff5e9b87140c691321a3e8fc99e68" + }, + "m_SlotId": 0 + }, + "m_InputSlot": { + "m_Node": { + "m_Id": "a93f8b0ecc385c40b55e136c1768652f" + }, + "m_SlotId": 32 + } + }, + { + "m_OutputSlot": { + "m_Node": { + "m_Id": "b99248f4c5a34783878a33a7e2bc8fb9" + }, + "m_SlotId": 0 + }, + "m_InputSlot": { + "m_Node": { + "m_Id": "a93f8b0ecc385c40b55e136c1768652f" + }, + "m_SlotId": 33 + } + } + ], + "m_VertexContext": { + "m_Position": { + "x": 800, + "y": 0 + }, + "m_Blocks": [ + { + "m_Id": "6c3f79c836b45d7ca783db0b58c8a0a2" + }, + { + "m_Id": "1e4ff86c89cf52158fa52f50c68b7622" + }, + { + "m_Id": "293d868c32ac5454bc5baa18d95958d0" + } + ] + }, + "m_FragmentContext": { + "m_Position": { + "x": 800, + "y": 220 + }, + "m_Blocks": [ + { + "m_Id": "bb541da4cd8f5bce9d7fb6758b047c46" + }, + { + "m_Id": "5cab6e924ebf5fbd98435c85181187d6" + }, + { + "m_Id": "f9847ebe2d1c5ab4a9726ad4fc67938d" + }, + { + "m_Id": "46b690f690045f93a980c40192b8334a" + }, + { + "m_Id": "f3861acedd695d2baffd0f91a051783d" + }, + { + "m_Id": "ac0e9b9ab4275ebf8452ae7fffface7f" + }, + { + "m_Id": "d7e5b5935814588d9e58aa447ec825af" + }, + { + "m_Id": "6eedf44ab7a85b48934617d05064c2c5" + }, + { + "m_Id": "ca03b11231c25e069a7ac8ce3ffec4ad" + }, + { + "m_Id": "203e1b17561857a5884e227dd94e2a70" + }, + { + "m_Id": "34e79bb6392457019e43f53374ac48f9" + } + ] + }, + "m_PreviewData": { + "serializedMesh": { + "m_SerializedMesh": "{\"mesh\":{\"instanceID\":0}}", + "m_Guid": "" + }, + "preventRotation": false + }, + "m_Path": "Pinball", + "m_GraphPrecision": 1, + "m_PreviewMode": 2, + "m_OutputNode": { + "m_Id": "" + }, + "m_SubDatas": [], + "m_ActiveTargets": [ + { + "m_Id": "bc89bc9885ee5168bdca3b4d6021e8ed" + }, + { + "m_Id": "3c06f88b3ab44722a6d9d8e8794df1db" + } + ] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.Rendering.HighDefinition.ShaderGraph.HDTarget", + "m_ObjectId": "bc89bc9885ee5168bdca3b4d6021e8ed", + "m_ActiveSubTarget": { + "m_Id": "1634ba568d095374a4ac279291a809e1" + }, + "m_Datas": [ + { + "m_Id": "ba7d7e011193580a94c2da240c2e42f6" + }, + { + "m_Id": "dbe2ed00baa650899b438cb84a5ee443" + }, + { + "m_Id": "78608e610afe598d9c19ac2fce925736" + }, + { + "m_Id": "5efee24d3d145df7b6a2de4cf8200ad0" + } + ], + "m_CustomEditorGUI": "VisualPinball.Engine.Unity.Hdrp.Editor.InsertCoverGUI", + "m_SupportVFX": false, + "m_SupportLineRendering": false +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.Rendering.HighDefinition.ShaderGraph.HDLitSubTarget", + "m_ObjectId": "1634ba568d095374a4ac279291a809e1" +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.Rendering.HighDefinition.ShaderGraph.SystemData", + "m_ObjectId": "ba7d7e011193580a94c2da240c2e42f6", + "m_MaterialNeedsUpdateHash": 0, + "m_SurfaceType": 1, + "m_RenderingPass": 4, + "m_BlendMode": 0, + "m_ZTest": 4, + "m_ZWrite": false, + "m_TransparentCullMode": 2, + "m_OpaqueCullMode": 2, + "m_SortPriority": 0, + "m_AlphaTest": false, + "m_ExcludeFromTUAndAA": false, + "m_TransparentDepthPrepass": false, + "m_TransparentDepthPostpass": false, + "m_SupportLodCrossFade": false, + "m_DoubleSidedMode": 0, + "m_DOTSInstancing": false, + "m_CustomVelocity": false, + "m_Tessellation": false, + "m_TessellationMode": 0, + "m_TessellationFactorMinDistance": 20.0, + "m_TessellationFactorMaxDistance": 50.0, + "m_TessellationFactorTriangleSize": 100.0, + "m_TessellationShapeFactor": 0.75, + "m_TessellationBackFaceCullEpsilon": -0.25, + "m_TessellationMaxDisplacement": 0.009999999776482582, + "m_DebugSymbols": false, + "m_Version": 2, + "inspectorFoldoutMask": 9 +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.Rendering.HighDefinition.ShaderGraph.BuiltinData", + "m_ObjectId": "dbe2ed00baa650899b438cb84a5ee443", + "m_Distortion": false, + "m_DistortionMode": 0, + "m_DistortionDepthTest": true, + "m_AddPrecomputedVelocity": false, + "m_TransparentWritesMotionVec": false, + "m_DepthOffset": false, + "m_ConservativeDepthOffset": false, + "m_TransparencyFog": true, + "m_AlphaTestShadow": false, + "m_BackThenFrontRendering": false, + "m_TransparentDepthPrepass": false, + "m_TransparentDepthPostpass": false, + "m_TransparentPerPixelSorting": false, + "m_SupportLodCrossFade": false +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.Rendering.HighDefinition.ShaderGraph.LightingData", + "m_ObjectId": "78608e610afe598d9c19ac2fce925736", + "m_NormalDropOffSpace": 0, + "m_BlendPreserveSpecular": true, + "m_ReceiveDecals": true, + "m_ReceiveSSR": true, + "m_ReceiveSSRTransparent": false, + "m_SpecularAA": true, + "m_SpecularOcclusionMode": 1, + "m_OverrideBakedGI": false +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.Rendering.HighDefinition.ShaderGraph.HDLitData", + "m_ObjectId": "5efee24d3d145df7b6a2de4cf8200ad0", + "m_RayTracing": false, + "m_MaterialType": 0, + "m_MaterialTypeMask": 2, + "m_RefractionModel": 1, + "m_SSSTransmission": true, + "m_EnergyConservingSpecular": true, + "m_ClearCoat": false +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.BlockNode", + "m_ObjectId": "6c3f79c836b45d7ca783db0b58c8a0a2", + "m_Group": { + "m_Id": "" + }, + "m_Name": "VertexDescription.Position", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": 0.0, + "y": 0.0, + "width": 0.0, + "height": 0.0 + } + }, + "m_Slots": [ + { + "m_Id": "31b8be0ab2ea5d30830d210d2b9200e1" + } + ], + "synonyms": [], + "m_Precision": 0, + "m_PreviewExpanded": true, + "m_DismissedVersion": 0, + "m_PreviewMode": 0, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_SerializedDescriptor": "VertexDescription.Position" +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.PositionMaterialSlot", + "m_ObjectId": "31b8be0ab2ea5d30830d210d2b9200e1", + "m_Id": 0, + "m_DisplayName": "Position", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "Position", + "m_StageCapability": 1, + "m_Value": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "m_DefaultValue": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "m_Labels": [], + "m_Space": 0 +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.BlockNode", + "m_ObjectId": "1e4ff86c89cf52158fa52f50c68b7622", + "m_Group": { + "m_Id": "" + }, + "m_Name": "VertexDescription.Normal", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": 0.0, + "y": 0.0, + "width": 0.0, + "height": 0.0 + } + }, + "m_Slots": [ + { + "m_Id": "bb48a1080fa85b1d87db616b9285e870" + } + ], + "synonyms": [], + "m_Precision": 0, + "m_PreviewExpanded": true, + "m_DismissedVersion": 0, + "m_PreviewMode": 0, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_SerializedDescriptor": "VertexDescription.Normal" +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.NormalMaterialSlot", + "m_ObjectId": "bb48a1080fa85b1d87db616b9285e870", + "m_Id": 0, + "m_DisplayName": "Normal", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "Normal", + "m_StageCapability": 1, + "m_Value": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "m_DefaultValue": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "m_Labels": [], + "m_Space": 0 +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.BlockNode", + "m_ObjectId": "293d868c32ac5454bc5baa18d95958d0", + "m_Group": { + "m_Id": "" + }, + "m_Name": "VertexDescription.Tangent", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": 0.0, + "y": 0.0, + "width": 0.0, + "height": 0.0 + } + }, + "m_Slots": [ + { + "m_Id": "4295929831725f29beb7f2abb6a036f5" + } + ], + "synonyms": [], + "m_Precision": 0, + "m_PreviewExpanded": true, + "m_DismissedVersion": 0, + "m_PreviewMode": 0, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_SerializedDescriptor": "VertexDescription.Tangent" +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.TangentMaterialSlot", + "m_ObjectId": "4295929831725f29beb7f2abb6a036f5", + "m_Id": 0, + "m_DisplayName": "Tangent", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "Tangent", + "m_StageCapability": 1, + "m_Value": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "m_DefaultValue": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "m_Labels": [], + "m_Space": 0 +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.BlockNode", + "m_ObjectId": "bb541da4cd8f5bce9d7fb6758b047c46", + "m_Group": { + "m_Id": "" + }, + "m_Name": "SurfaceDescription.BaseColor", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": -44.999969482421875, + "y": 155.49998474121094, + "width": 199.99998474121094, + "height": 41.00001525878906 + } + }, + "m_Slots": [ + { + "m_Id": "9481e78455e45a4a88e5a3ea53f3c6fc" + } + ], + "synonyms": [], + "m_Precision": 0, + "m_PreviewExpanded": true, + "m_DismissedVersion": 0, + "m_PreviewMode": 0, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_SerializedDescriptor": "SurfaceDescription.BaseColor" +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.ColorRGBMaterialSlot", + "m_ObjectId": "9481e78455e45a4a88e5a3ea53f3c6fc", + "m_Id": 0, + "m_DisplayName": "Base Color", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "BaseColor", + "m_StageCapability": 2, + "m_Value": { + "x": 0.5, + "y": 0.5, + "z": 0.5 + }, + "m_DefaultValue": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "m_Labels": [], + "m_ColorMode": 0, + "m_DefaultColor": { + "r": 0.5, + "g": 0.5, + "b": 0.5, + "a": 1.0 + } +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.BlockNode", + "m_ObjectId": "5cab6e924ebf5fbd98435c85181187d6", + "m_Group": { + "m_Id": "" + }, + "m_Name": "SurfaceDescription.NormalTS", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": -46.499935150146484, + "y": 466.0, + "width": 199.99990844726562, + "height": 40.99993896484375 + } + }, + "m_Slots": [ + { + "m_Id": "a40600d7e614588ca04d65f6cd2b0536" + } + ], + "synonyms": [], + "m_Precision": 0, + "m_PreviewExpanded": true, + "m_DismissedVersion": 0, + "m_PreviewMode": 0, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_SerializedDescriptor": "SurfaceDescription.NormalTS" +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.NormalMaterialSlot", + "m_ObjectId": "a40600d7e614588ca04d65f6cd2b0536", + "m_Id": 0, + "m_DisplayName": "Normal (Tangent Space)", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "NormalTS", + "m_StageCapability": 2, + "m_Value": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "m_DefaultValue": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "m_Labels": [], + "m_Space": 3 +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.BlockNode", + "m_ObjectId": "f9847ebe2d1c5ab4a9726ad4fc67938d", + "m_Group": { + "m_Id": "" + }, + "m_Name": "SurfaceDescription.Metallic", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": -43.499900817871094, + "y": 278.5000305175781, + "width": 199.99996948242188, + "height": 41.0 + } + }, + "m_Slots": [ + { + "m_Id": "abfee700dc8a59e8a531f95b6f50074e" + } + ], + "synonyms": [], + "m_Precision": 0, + "m_PreviewExpanded": true, + "m_DismissedVersion": 0, + "m_PreviewMode": 0, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_SerializedDescriptor": "SurfaceDescription.Metallic" +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "abfee700dc8a59e8a531f95b6f50074e", + "m_Id": 0, + "m_DisplayName": "Metallic", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "Metallic", + "m_StageCapability": 2, + "m_Value": 0, + "m_DefaultValue": 0.0, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.BlockNode", + "m_ObjectId": "46b690f690045f93a980c40192b8334a", + "m_Group": { + "m_Id": "" + }, + "m_Name": "SurfaceDescription.Smoothness", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": -44.0000114440918, + "y": 317.4999694824219, + "width": 200.0000457763672, + "height": 41.0 + } + }, + "m_Slots": [ + { + "m_Id": "1a74e104ca1d561db59265980ea80129" + } + ], + "synonyms": [], + "m_Precision": 0, + "m_PreviewExpanded": true, + "m_DismissedVersion": 0, + "m_PreviewMode": 0, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_SerializedDescriptor": "SurfaceDescription.Smoothness" +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "1a74e104ca1d561db59265980ea80129", + "m_Id": 0, + "m_DisplayName": "Smoothness", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "Smoothness", + "m_StageCapability": 2, + "m_Value": 0.5, + "m_DefaultValue": 0.5, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.BlockNode", + "m_ObjectId": "f3861acedd695d2baffd0f91a051783d", + "m_Group": { + "m_Id": "" + }, + "m_Name": "SurfaceDescription.Occlusion", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": -47.499977111816406, + "y": 280.5000305175781, + "width": 199.99996948242188, + "height": 41.0 + } + }, + "m_Slots": [ + { + "m_Id": "0e9e6902c6c35c5aa945368cdbb34e00" + } + ], + "synonyms": [], + "m_Precision": 0, + "m_PreviewExpanded": true, + "m_DismissedVersion": 0, + "m_PreviewMode": 0, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_SerializedDescriptor": "SurfaceDescription.Occlusion" +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "0e9e6902c6c35c5aa945368cdbb34e00", + "m_Id": 0, + "m_DisplayName": "Ambient Occlusion", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "Occlusion", + "m_StageCapability": 2, + "m_Value": 1, + "m_DefaultValue": 1.0, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.BlockNode", + "m_ObjectId": "ac0e9b9ab4275ebf8452ae7fffface7f", + "m_Group": { + "m_Id": "" + }, + "m_Name": "SurfaceDescription.Emission", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": -44.500038146972656, + "y": 101.99987030029297, + "width": 200.00003051757812, + "height": 41.00000762939453 + } + }, + "m_Slots": [ + { + "m_Id": "e99a9f19d628587796972f50117e5275" + } + ], + "synonyms": [], + "m_Precision": 0, + "m_PreviewExpanded": true, + "m_DismissedVersion": 0, + "m_PreviewMode": 0, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_SerializedDescriptor": "SurfaceDescription.Emission" +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.ColorRGBMaterialSlot", + "m_ObjectId": "e99a9f19d628587796972f50117e5275", + "m_Id": 0, + "m_DisplayName": "Emission", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "Emission", + "m_StageCapability": 2, + "m_Value": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "m_DefaultValue": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "m_Labels": [], + "m_ColorMode": 1, + "m_DefaultColor": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + } +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.BlockNode", + "m_ObjectId": "d7e5b5935814588d9e58aa447ec825af", + "m_Group": { + "m_Id": "" + }, + "m_Name": "SurfaceDescription.Alpha", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": -49.00001525878906, + "y": 100.99995422363281, + "width": 199.99998474121094, + "height": 41.00001525878906 + } + }, + "m_Slots": [ + { + "m_Id": "49677f402c0d512391c56a1e65768ae0" + } + ], + "synonyms": [], + "m_Precision": 0, + "m_PreviewExpanded": true, + "m_DismissedVersion": 0, + "m_PreviewMode": 0, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_SerializedDescriptor": "SurfaceDescription.Alpha" +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "49677f402c0d512391c56a1e65768ae0", + "m_Id": 0, + "m_DisplayName": "Alpha", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "Alpha", + "m_StageCapability": 2, + "m_Value": 1.0, + "m_DefaultValue": 1.0, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.BlockNode", + "m_ObjectId": "6eedf44ab7a85b48934617d05064c2c5", + "m_Group": { + "m_Id": "" + }, + "m_Name": "SurfaceDescription.RefractionIndex", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": -49.00001525878906, + "y": 100.99995422363281, + "width": 199.99998474121094, + "height": 41.00001525878906 + } + }, + "m_Slots": [ + { + "m_Id": "3576fa1886775c80afd25e93b1ffb229" + } + ], + "synonyms": [], + "m_Precision": 0, + "m_PreviewExpanded": true, + "m_DismissedVersion": 0, + "m_PreviewMode": 0, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_SerializedDescriptor": "SurfaceDescription.RefractionIndex" +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "3576fa1886775c80afd25e93b1ffb229", + "m_Id": 0, + "m_DisplayName": "Index Of Refraction", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "RefractionIndex", + "m_StageCapability": 2, + "m_Value": 1.49, + "m_DefaultValue": 1.49, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.BlockNode", + "m_ObjectId": "ca03b11231c25e069a7ac8ce3ffec4ad", + "m_Group": { + "m_Id": "" + }, + "m_Name": "SurfaceDescription.RefractionColor", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": -44.999969482421875, + "y": 155.49998474121094, + "width": 199.99998474121094, + "height": 41.00001525878906 + } + }, + "m_Slots": [ + { + "m_Id": "49f23d7f40395047a128dadacaaab892" + } + ], + "synonyms": [], + "m_Precision": 0, + "m_PreviewExpanded": true, + "m_DismissedVersion": 0, + "m_PreviewMode": 0, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_SerializedDescriptor": "SurfaceDescription.RefractionColor" +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.ColorRGBMaterialSlot", + "m_ObjectId": "49f23d7f40395047a128dadacaaab892", + "m_Id": 0, + "m_DisplayName": "Transmittance Color", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "RefractionColor", + "m_StageCapability": 2, + "m_Value": { + "x": 1.0, + "y": 1.0, + "z": 1.0 + }, + "m_DefaultValue": { + "x": 1.0, + "y": 1.0, + "z": 1.0 + }, + "m_Labels": [], + "m_ColorMode": 0, + "m_DefaultColor": { + "r": 1.0, + "g": 1.0, + "b": 1.0, + "a": 1.0 + } +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.BlockNode", + "m_ObjectId": "203e1b17561857a5884e227dd94e2a70", + "m_Group": { + "m_Id": "" + }, + "m_Name": "SurfaceDescription.RefractionDistance", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": -49.00001525878906, + "y": 100.99995422363281, + "width": 199.99998474121094, + "height": 41.00001525878906 + } + }, + "m_Slots": [ + { + "m_Id": "6f384109d5e8553caa38e0f082105943" + } + ], + "synonyms": [], + "m_Precision": 0, + "m_PreviewExpanded": true, + "m_DismissedVersion": 0, + "m_PreviewMode": 0, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_SerializedDescriptor": "SurfaceDescription.RefractionDistance" +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "6f384109d5e8553caa38e0f082105943", + "m_Id": 0, + "m_DisplayName": "Absorption Distance", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "RefractionDistance", + "m_StageCapability": 2, + "m_Value": 0.003, + "m_DefaultValue": 0.003, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.BlockNode", + "m_ObjectId": "34e79bb6392457019e43f53374ac48f9", + "m_Group": { + "m_Id": "" + }, + "m_Name": "SurfaceDescription.Thickness", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": -49.00001525878906, + "y": 100.99995422363281, + "width": 199.99998474121094, + "height": 41.00001525878906 + } + }, + "m_Slots": [ + { + "m_Id": "7d5e1866e51b58b8a9025ffbac353d66" + } + ], + "synonyms": [], + "m_Precision": 0, + "m_PreviewExpanded": true, + "m_DismissedVersion": 0, + "m_PreviewMode": 0, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_SerializedDescriptor": "SurfaceDescription.Thickness" +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "7d5e1866e51b58b8a9025ffbac353d66", + "m_Id": 0, + "m_DisplayName": "Thickness", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "Thickness", + "m_StageCapability": 2, + "m_Value": 0.004, + "m_DefaultValue": 0.004, + "m_Labels": [] +} + +{ + "m_SGVersion": 1, + "m_Type": "UnityEditor.ShaderGraph.CustomFunctionNode", + "m_ObjectId": "a93f8b0ecc385c40b55e136c1768652f", + "m_Group": { + "m_Id": "" + }, + "m_Name": "Playfield Normal", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": 200, + "y": 120, + "width": 210, + "height": 100 + } + }, + "m_Slots": [ + { + "m_Id": "942cd156886f5477ad0c60d7d930133d" + }, + { + "m_Id": "f577f7016b7e50719c77e987245bf314" + }, + { + "m_Id": "3b4b7df63b1754fcb0eb8edf6aadf167" + }, + { + "m_Id": "c243c383b68a593eb2ace677c9636ecb" + }, + { + "m_Id": "0b02084c78405f8f9792f8157485493a" + }, + { + "m_Id": "fd202d99c1d95dd8b021771927ede26f" + }, + { + "m_Id": "5867d7e8b70252e58618057a12ecb96a" + }, + { + "m_Id": "dcf1cd9cfb1c5080818a7163488725ac" + }, + { + "m_Id": "779260f5a50151d1aa78cd531254d0f4" + }, + { + "m_Id": "940c3496569356ddab5164220103f1fd" + }, + { + "m_Id": "361c9eeec046524fb4289b4a0c567e21" + }, + { + "m_Id": "82f91f0f83d15b27810f9cdb5eb076bb" + }, + { + "m_Id": "73ee302899d1508fb3f8221c03c4e859" + }, + { + "m_Id": "7feec6ffee2a564d9a265e239544b3ce" + }, + { + "m_Id": "5e0979d772795d8e98f48bb10f57a0a0" + }, + { + "m_Id": "0c0d768f11f457a7be67ff836afe1257" + }, + { + "m_Id": "a8b71cef2dcc50fbbfca093ef4da42d4" + }, + { + "m_Id": "9165bace26a75858978c8aa955e2a01f" + }, + { + "m_Id": "a0a44d465c905f8bb7d855f78f8937c0" + }, + { + "m_Id": "096a35b00c2452f28f04fabb17d132a8" + }, + { + "m_Id": "433f0a0561f75e86b4fb1e51c9df3a04" + }, + { + "m_Id": "1e892521a4ab56f7860d8a09d38a0665" + }, + { + "m_Id": "1d797c095e245d6b967a06fefeeff9f5" + }, + { + "m_Id": "d5ea220ccb7d5ab38031f80de430bbfb" + }, + { + "m_Id": "1b4e269462095c009d850a9c6b9bd369" + }, + { + "m_Id": "9f6505240c0d52a2bbff0e1f044f4597" + }, + { + "m_Id": "2401b0003ef35ead9766c7718f9ba685" + }, + { + "m_Id": "8caf0367ca0f5b41bc0b2495df74d388" + }, + { + "m_Id": "85d374ad9c0f5c2f935774e79ca5ae27" + }, + { + "m_Id": "5c4157811d9b5d0e95abbc40c2e19208" + }, + { + "m_Id": "55842f485b1a531ba4aa38098affd9c4" + }, + { + "m_Id": "3c5d50ef72f6418bab1e3d025b27d44d" + }, + { + "m_Id": "0a08c13e7bf14b60b5f8a82785e8628d" + }, + { + "m_Id": "d94a3a7c51ea4367b125945816d2e4f0" + } + ], + "synonyms": [ + "code", + "HLSL" + ], + "m_Precision": 1, + "m_PreviewExpanded": false, + "m_DismissedVersion": 0, + "m_PreviewMode": 0, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_SourceType": 0, + "m_FunctionName": "InsertCover", + "m_FunctionSource": "a279abca59990fb4ca6f814dd1527731", + "m_FunctionBody": "" +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector2MaterialSlot", + "m_ObjectId": "942cd156886f5477ad0c60d7d930133d", + "m_Id": 0, + "m_DisplayName": "UV", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "UV", + "m_StageCapability": 3, + "m_Value": { + "x": 0.0, + "y": 0.0 + }, + "m_DefaultValue": { + "x": 0.0, + "y": 0.0 + }, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Texture2DInputMaterialSlot", + "m_ObjectId": "f577f7016b7e50719c77e987245bf314", + "m_Id": 1, + "m_DisplayName": "NormalMap", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "NormalMap", + "m_StageCapability": 3, + "m_BareResource": false, + "m_Texture": { + "m_SerializedTexture": "", + "m_Guid": "" + }, + "m_DefaultType": 0 +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "3b4b7df63b1754fcb0eb8edf6aadf167", + "m_Id": 2, + "m_DisplayName": "PlayfieldStrength", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "PlayfieldStrength", + "m_StageCapability": 3, + "m_Value": 0.0, + "m_DefaultValue": 0.0, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "c243c383b68a593eb2ace677c9636ecb", + "m_Id": 3, + "m_DisplayName": "Strength", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "Strength", + "m_StageCapability": 3, + "m_Value": 0.0, + "m_DefaultValue": 0.0, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "0b02084c78405f8f9792f8157485493a", + "m_Id": 4, + "m_DisplayName": "Smoothness", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "Smoothness", + "m_StageCapability": 3, + "m_Value": 0.0, + "m_DefaultValue": 0.0, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "fd202d99c1d95dd8b021771927ede26f", + "m_Id": 5, + "m_DisplayName": "Opacity", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "Opacity", + "m_StageCapability": 3, + "m_Value": 0.0, + "m_DefaultValue": 0.0, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector3MaterialSlot", + "m_ObjectId": "5867d7e8b70252e58618057a12ecb96a", + "m_Id": 6, + "m_DisplayName": "NormalTS", + "m_SlotType": 1, + "m_Hidden": false, + "m_ShaderOutputName": "NormalTS", + "m_StageCapability": 3, + "m_Value": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "m_DefaultValue": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "dcf1cd9cfb1c5080818a7163488725ac", + "m_Id": 7, + "m_DisplayName": "SurfaceSmoothness", + "m_SlotType": 1, + "m_Hidden": false, + "m_ShaderOutputName": "SurfaceSmoothness", + "m_StageCapability": 3, + "m_Value": 0.0, + "m_DefaultValue": 0.0, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "779260f5a50151d1aa78cd531254d0f4", + "m_Id": 8, + "m_DisplayName": "Alpha", + "m_SlotType": 1, + "m_Hidden": false, + "m_ShaderOutputName": "Alpha", + "m_StageCapability": 3, + "m_Value": 0.0, + "m_DefaultValue": 0.0, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector3MaterialSlot", + "m_ObjectId": "940c3496569356ddab5164220103f1fd", + "m_Id": 9, + "m_DisplayName": "HazeColor", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "HazeColor", + "m_StageCapability": 3, + "m_Value": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "m_DefaultValue": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector3MaterialSlot", + "m_ObjectId": "361c9eeec046524fb4289b4a0c567e21", + "m_Id": 10, + "m_DisplayName": "BaseColor", + "m_SlotType": 1, + "m_Hidden": false, + "m_ShaderOutputName": "BaseColor", + "m_StageCapability": 3, + "m_Value": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "m_DefaultValue": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector3MaterialSlot", + "m_ObjectId": "82f91f0f83d15b27810f9cdb5eb076bb", + "m_Id": 11, + "m_DisplayName": "LampColor", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "LampColor", + "m_StageCapability": 3, + "m_Value": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "m_DefaultValue": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "73ee302899d1508fb3f8221c03c4e859", + "m_Id": 12, + "m_DisplayName": "LampLevel", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "LampLevel", + "m_StageCapability": 3, + "m_Value": 0.0, + "m_DefaultValue": 0.0, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "7feec6ffee2a564d9a265e239544b3ce", + "m_Id": 13, + "m_DisplayName": "LampTint", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "LampTint", + "m_StageCapability": 3, + "m_Value": 0.0, + "m_DefaultValue": 0.0, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector3MaterialSlot", + "m_ObjectId": "5e0979d772795d8e98f48bb10f57a0a0", + "m_Id": 14, + "m_DisplayName": "Position", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "Position", + "m_StageCapability": 3, + "m_Value": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "m_DefaultValue": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector4MaterialSlot", + "m_ObjectId": "0c0d768f11f457a7be67ff836afe1257", + "m_Id": 15, + "m_DisplayName": "LampA", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "LampA", + "m_StageCapability": 3, + "m_Value": { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 0.0 + }, + "m_DefaultValue": { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 0.0 + }, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector4MaterialSlot", + "m_ObjectId": "a8b71cef2dcc50fbbfca093ef4da42d4", + "m_Id": 16, + "m_DisplayName": "LampB", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "LampB", + "m_StageCapability": 3, + "m_Value": { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 0.0 + }, + "m_DefaultValue": { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 0.0 + }, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector4MaterialSlot", + "m_ObjectId": "9165bace26a75858978c8aa955e2a01f", + "m_Id": 17, + "m_DisplayName": "LampC", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "LampC", + "m_StageCapability": 3, + "m_Value": { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 0.0 + }, + "m_DefaultValue": { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 0.0 + }, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector4MaterialSlot", + "m_ObjectId": "a0a44d465c905f8bb7d855f78f8937c0", + "m_Id": 18, + "m_DisplayName": "LampD", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "LampD", + "m_StageCapability": 3, + "m_Value": { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 0.0 + }, + "m_DefaultValue": { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 0.0 + }, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector4MaterialSlot", + "m_ObjectId": "096a35b00c2452f28f04fabb17d132a8", + "m_Id": 19, + "m_DisplayName": "DriveB", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "DriveB", + "m_StageCapability": 3, + "m_Value": { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 0.0 + }, + "m_DefaultValue": { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 0.0 + }, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector4MaterialSlot", + "m_ObjectId": "433f0a0561f75e86b4fb1e51c9df3a04", + "m_Id": 20, + "m_DisplayName": "DriveC", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "DriveC", + "m_StageCapability": 3, + "m_Value": { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 0.0 + }, + "m_DefaultValue": { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 0.0 + }, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector4MaterialSlot", + "m_ObjectId": "1e892521a4ab56f7860d8a09d38a0665", + "m_Id": 21, + "m_DisplayName": "DriveD", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "DriveD", + "m_StageCapability": 3, + "m_Value": { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 0.0 + }, + "m_DefaultValue": { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 0.0 + }, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "1d797c095e245d6b967a06fefeeff9f5", + "m_Id": 22, + "m_DisplayName": "Spread", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "Spread", + "m_StageCapability": 3, + "m_Value": 0.0, + "m_DefaultValue": 0.0, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "d5ea220ccb7d5ab38031f80de430bbfb", + "m_Id": 23, + "m_DisplayName": "Ior", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "Ior", + "m_StageCapability": 3, + "m_Value": 0.0, + "m_DefaultValue": 0.0, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector3MaterialSlot", + "m_ObjectId": "1b4e269462095c009d850a9c6b9bd369", + "m_Id": 24, + "m_DisplayName": "Tint", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "Tint", + "m_StageCapability": 3, + "m_Value": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "m_DefaultValue": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "9f6505240c0d52a2bbff0e1f044f4597", + "m_Id": 25, + "m_DisplayName": "TintDepth", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "TintDepth", + "m_StageCapability": 3, + "m_Value": 0.0, + "m_DefaultValue": 0.0, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "2401b0003ef35ead9766c7718f9ba685", + "m_Id": 26, + "m_DisplayName": "Thickness", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "Thickness", + "m_StageCapability": 3, + "m_Value": 0.0, + "m_DefaultValue": 0.0, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "8caf0367ca0f5b41bc0b2495df74d388", + "m_Id": 27, + "m_DisplayName": "RefractionIndex", + "m_SlotType": 1, + "m_Hidden": false, + "m_ShaderOutputName": "RefractionIndex", + "m_StageCapability": 3, + "m_Value": 0.0, + "m_DefaultValue": 0.0, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector3MaterialSlot", + "m_ObjectId": "85d374ad9c0f5c2f935774e79ca5ae27", + "m_Id": 28, + "m_DisplayName": "TransmittanceColor", + "m_SlotType": 1, + "m_Hidden": false, + "m_ShaderOutputName": "TransmittanceColor", + "m_StageCapability": 3, + "m_Value": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "m_DefaultValue": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "5c4157811d9b5d0e95abbc40c2e19208", + "m_Id": 29, + "m_DisplayName": "AbsorptionDistance", + "m_SlotType": 1, + "m_Hidden": false, + "m_ShaderOutputName": "AbsorptionDistance", + "m_StageCapability": 3, + "m_Value": 0.0, + "m_DefaultValue": 0.0, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "55842f485b1a531ba4aa38098affd9c4", + "m_Id": 30, + "m_DisplayName": "SlabThickness", + "m_SlotType": 1, + "m_Hidden": false, + "m_ShaderOutputName": "SlabThickness", + "m_StageCapability": 3, + "m_Value": 0.0, + "m_DefaultValue": 0.0, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.UVNode", + "m_ObjectId": "c2ba7fad87fc5a7cbddeda15d70be245", + "m_Group": { + "m_Id": "" + }, + "m_Name": "UV", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": -100, + "y": 0, + "width": 210, + "height": 100 + } + }, + "m_Slots": [ + { + "m_Id": "a0978221bdab5bc0b38ef310df71b821" + } + ], + "synonyms": [ + "texcoords", + "coords", + "coordinates" + ], + "m_Precision": 1, + "m_PreviewExpanded": false, + "m_DismissedVersion": 0, + "m_PreviewMode": 0, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_OutputChannel": 0 +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector4MaterialSlot", + "m_ObjectId": "a0978221bdab5bc0b38ef310df71b821", + "m_Id": 0, + "m_DisplayName": "Out", + "m_SlotType": 1, + "m_Hidden": false, + "m_ShaderOutputName": "Out", + "m_StageCapability": 3, + "m_Value": { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 0.0 + }, + "m_DefaultValue": { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 0.0 + }, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Internal.Texture2DShaderProperty", + "m_ObjectId": "ad06f3fb3b0c594a95419141a6275219", + "m_Guid": { + "m_GuidSerialized": "ad06f3fb-3b0c-594a-9541-9141a6275219" + }, + "m_Name": "Normal Map", + "m_DefaultRefNameVersion": 1, + "m_RefNameGeneratedByDisplayName": "Playfield Normal", + "m_DefaultReferenceName": "_CoverNormalMap", + "m_OverrideReferenceName": "_CoverNormalMap", + "m_GeneratePropertyBlock": true, + "m_UseCustomSlotLabel": false, + "m_CustomSlotLabel": "", + "m_DismissedVersion": 0, + "m_Precision": 0, + "overrideHLSLDeclaration": false, + "hlslDeclarationOverride": 0, + "m_Hidden": false, + "m_Value": { + "m_SerializedTexture": "", + "m_Guid": "" + }, + "isMainTexture": false, + "useTilingAndOffset": true, + "useTexelSize": false, + "m_Modifiable": true, + "m_DefaultType": 3 +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.PropertyNode", + "m_ObjectId": "258469db3724556da3ce5aa5103917ab", + "m_Group": { + "m_Id": "" + }, + "m_Name": "Property", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": -500, + "y": 80, + "width": 210, + "height": 100 + } + }, + "m_Slots": [ + { + "m_Id": "18b8e4a7c2a75c8bb7e736c5423cd6b1" + } + ], + "synonyms": [], + "m_Precision": 1, + "m_PreviewExpanded": false, + "m_DismissedVersion": 0, + "m_PreviewMode": 0, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_Property": { + "m_Id": "ad06f3fb3b0c594a95419141a6275219" + } +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Texture2DMaterialSlot", + "m_ObjectId": "18b8e4a7c2a75c8bb7e736c5423cd6b1", + "m_Id": 0, + "m_DisplayName": "Out", + "m_SlotType": 1, + "m_Hidden": false, + "m_ShaderOutputName": "Out", + "m_StageCapability": 3, + "m_BareResource": false +} + +{ + "m_SGVersion": 1, + "m_Type": "UnityEditor.ShaderGraph.Internal.Vector1ShaderProperty", + "m_ObjectId": "fae01e6a006851718b1e68199e602c03", + "m_Guid": { + "m_GuidSerialized": "fae01e6a-0068-5171-8b1e-68199e602c03" + }, + "m_Name": "Playfield Normal Strength", + "m_DefaultRefNameVersion": 1, + "m_RefNameGeneratedByDisplayName": "Playfield Normal Strength", + "m_DefaultReferenceName": "_PlayfieldNormalStrength", + "m_OverrideReferenceName": "_PlayfieldNormalStrength", + "m_GeneratePropertyBlock": true, + "m_UseCustomSlotLabel": false, + "m_CustomSlotLabel": "", + "m_DismissedVersion": 0, + "m_Precision": 0, + "overrideHLSLDeclaration": false, + "hlslDeclarationOverride": 0, + "m_Hidden": true, + "m_Value": 1, + "m_FloatType": 0, + "m_RangeValues": { + "x": 1.0, + "y": 64.0 + }, + "m_SliderType": 0, + "m_SliderPower": 3.0, + "m_EnumType": 0, + "m_CSharpEnumString": "", + "m_EnumNames": [], + "m_EnumValues": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.PropertyNode", + "m_ObjectId": "93eb8464ca0b54ddb97499e11a6c674f", + "m_Group": { + "m_Id": "" + }, + "m_Name": "Property", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": -500, + "y": 160, + "width": 210, + "height": 100 + } + }, + "m_Slots": [ + { + "m_Id": "e4527a36f8715829af8508c4841d0a9b" + } + ], + "synonyms": [], + "m_Precision": 1, + "m_PreviewExpanded": false, + "m_DismissedVersion": 0, + "m_PreviewMode": 0, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_Property": { + "m_Id": "fae01e6a006851718b1e68199e602c03" + } +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "e4527a36f8715829af8508c4841d0a9b", + "m_Id": 0, + "m_DisplayName": "Out", + "m_SlotType": 1, + "m_Hidden": false, + "m_ShaderOutputName": "Out", + "m_StageCapability": 3, + "m_Value": 0.0, + "m_DefaultValue": 0.0, + "m_Labels": [] +} + +{ + "m_SGVersion": 1, + "m_Type": "UnityEditor.ShaderGraph.Internal.Vector1ShaderProperty", + "m_ObjectId": "2097df164c9e5fb1947791cdfa588dd1", + "m_Guid": { + "m_GuidSerialized": "2097df16-4c9e-5fb1-9477-91cdfa588dd1" + }, + "m_Name": "Normal Strength", + "m_DefaultRefNameVersion": 1, + "m_RefNameGeneratedByDisplayName": "Normal Strength", + "m_DefaultReferenceName": "_CoverNormalStrength", + "m_OverrideReferenceName": "_CoverNormalStrength", + "m_GeneratePropertyBlock": true, + "m_UseCustomSlotLabel": false, + "m_CustomSlotLabel": "", + "m_DismissedVersion": 0, + "m_Precision": 0, + "overrideHLSLDeclaration": false, + "hlslDeclarationOverride": 0, + "m_Hidden": false, + "m_Value": 1, + "m_FloatType": 1, + "m_RangeValues": { + "x": 0, + "y": 2 + }, + "m_SliderType": 0, + "m_SliderPower": 3.0, + "m_EnumType": 0, + "m_CSharpEnumString": "", + "m_EnumNames": [], + "m_EnumValues": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.PropertyNode", + "m_ObjectId": "2cb25b426b3e5c90836f456a1beda794", + "m_Group": { + "m_Id": "" + }, + "m_Name": "Property", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": -500, + "y": 240, + "width": 210, + "height": 100 + } + }, + "m_Slots": [ + { + "m_Id": "4f063c67e7e8519196f4c8223897fc72" + } + ], + "synonyms": [], + "m_Precision": 1, + "m_PreviewExpanded": false, + "m_DismissedVersion": 0, + "m_PreviewMode": 0, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_Property": { + "m_Id": "2097df164c9e5fb1947791cdfa588dd1" + } +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "4f063c67e7e8519196f4c8223897fc72", + "m_Id": 0, + "m_DisplayName": "Out", + "m_SlotType": 1, + "m_Hidden": false, + "m_ShaderOutputName": "Out", + "m_StageCapability": 3, + "m_Value": 0.0, + "m_DefaultValue": 0.0, + "m_Labels": [] +} + +{ + "m_SGVersion": 1, + "m_Type": "UnityEditor.ShaderGraph.Internal.Vector1ShaderProperty", + "m_ObjectId": "f611e7ae978c57538ba217fb9b8c077a", + "m_Guid": { + "m_GuidSerialized": "f611e7ae-978c-5753-8ba2-17fb9b8c077a" + }, + "m_Name": "Smoothness", + "m_DefaultRefNameVersion": 1, + "m_RefNameGeneratedByDisplayName": "Smoothness", + "m_DefaultReferenceName": "_Smoothness", + "m_OverrideReferenceName": "_Smoothness", + "m_GeneratePropertyBlock": true, + "m_UseCustomSlotLabel": false, + "m_CustomSlotLabel": "", + "m_DismissedVersion": 0, + "m_Precision": 0, + "overrideHLSLDeclaration": false, + "hlslDeclarationOverride": 0, + "m_Hidden": false, + "m_Value": 0.96, + "m_FloatType": 1, + "m_RangeValues": { + "x": 0, + "y": 1 + }, + "m_SliderType": 0, + "m_SliderPower": 3.0, + "m_EnumType": 0, + "m_CSharpEnumString": "", + "m_EnumNames": [], + "m_EnumValues": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.PropertyNode", + "m_ObjectId": "55bdf08e49c45b8eb889aa7ac58378cb", + "m_Group": { + "m_Id": "" + }, + "m_Name": "Property", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": -500, + "y": 320, + "width": 210, + "height": 100 + } + }, + "m_Slots": [ + { + "m_Id": "c9a46acab96a5fbb97b8a5c1f236d181" + } + ], + "synonyms": [], + "m_Precision": 1, + "m_PreviewExpanded": false, + "m_DismissedVersion": 0, + "m_PreviewMode": 0, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_Property": { + "m_Id": "f611e7ae978c57538ba217fb9b8c077a" + } +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "c9a46acab96a5fbb97b8a5c1f236d181", + "m_Id": 0, + "m_DisplayName": "Out", + "m_SlotType": 1, + "m_Hidden": false, + "m_ShaderOutputName": "Out", + "m_StageCapability": 3, + "m_Value": 0.0, + "m_DefaultValue": 0.0, + "m_Labels": [] +} + +{ + "m_SGVersion": 1, + "m_Type": "UnityEditor.ShaderGraph.Internal.Vector1ShaderProperty", + "m_ObjectId": "5b38cbf426f45f719881c428caf2ff6f", + "m_Guid": { + "m_GuidSerialized": "5b38cbf4-26f4-5f71-9881-c428caf2ff6f" + }, + "m_Name": "Opacity", + "m_DefaultRefNameVersion": 1, + "m_RefNameGeneratedByDisplayName": "Opacity", + "m_DefaultReferenceName": "_CoverOpacity", + "m_OverrideReferenceName": "_CoverOpacity", + "m_GeneratePropertyBlock": true, + "m_UseCustomSlotLabel": false, + "m_CustomSlotLabel": "", + "m_DismissedVersion": 0, + "m_Precision": 0, + "overrideHLSLDeclaration": false, + "hlslDeclarationOverride": 0, + "m_Hidden": false, + "m_Value": 0.01, + "m_FloatType": 1, + "m_RangeValues": { + "x": 0, + "y": 0.5 + }, + "m_SliderType": 0, + "m_SliderPower": 3.0, + "m_EnumType": 0, + "m_CSharpEnumString": "", + "m_EnumNames": [], + "m_EnumValues": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.PropertyNode", + "m_ObjectId": "86439196d3665c5c939ed992d61d38db", + "m_Group": { + "m_Id": "" + }, + "m_Name": "Property", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": -500, + "y": 400, + "width": 210, + "height": 100 + } + }, + "m_Slots": [ + { + "m_Id": "9cf77c9759645b3b95e1c3e7f82754f4" + } + ], + "synonyms": [], + "m_Precision": 1, + "m_PreviewExpanded": false, + "m_DismissedVersion": 0, + "m_PreviewMode": 0, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_Property": { + "m_Id": "5b38cbf426f45f719881c428caf2ff6f" + } +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "9cf77c9759645b3b95e1c3e7f82754f4", + "m_Id": 0, + "m_DisplayName": "Out", + "m_SlotType": 1, + "m_Hidden": false, + "m_ShaderOutputName": "Out", + "m_StageCapability": 3, + "m_Value": 0.0, + "m_DefaultValue": 0.0, + "m_Labels": [] +} + +{ + "m_SGVersion": 3, + "m_Type": "UnityEditor.ShaderGraph.Internal.ColorShaderProperty", + "m_ObjectId": "ca43925be4165b178d1f5bc49e3651ca", + "m_Guid": { + "m_GuidSerialized": "ca43925b-e416-5b17-8d1f-5bc49e3651ca" + }, + "m_Name": "Haze Color", + "m_DefaultRefNameVersion": 1, + "m_RefNameGeneratedByDisplayName": "Haze Color", + "m_DefaultReferenceName": "_HazeColor", + "m_OverrideReferenceName": "_HazeColor", + "m_GeneratePropertyBlock": true, + "m_UseCustomSlotLabel": false, + "m_CustomSlotLabel": "", + "m_DismissedVersion": 0, + "m_Precision": 0, + "overrideHLSLDeclaration": false, + "hlslDeclarationOverride": 0, + "m_Hidden": false, + "m_Value": { + "r": 0.8, + "g": 0.8, + "b": 0.8, + "a": 1 + }, + "isMainColor": false, + "m_ColorMode": 0 +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.PropertyNode", + "m_ObjectId": "84d0067015ba51dd9ecbac43815457ee", + "m_Group": { + "m_Id": "" + }, + "m_Name": "Property", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": -500, + "y": 480, + "width": 210, + "height": 100 + } + }, + "m_Slots": [ + { + "m_Id": "938b3d114ddb5b93b607a2293e0ae825" + } + ], + "synonyms": [], + "m_Precision": 1, + "m_PreviewExpanded": false, + "m_DismissedVersion": 0, + "m_PreviewMode": 0, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_Property": { + "m_Id": "ca43925be4165b178d1f5bc49e3651ca" + } +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector4MaterialSlot", + "m_ObjectId": "938b3d114ddb5b93b607a2293e0ae825", + "m_Id": 0, + "m_DisplayName": "Out", + "m_SlotType": 1, + "m_Hidden": false, + "m_ShaderOutputName": "Out", + "m_StageCapability": 3, + "m_Value": { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 0.0 + }, + "m_DefaultValue": { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 0.0 + }, + "m_Labels": [] +} + +{ + "m_SGVersion": 1, + "m_Type": "UnityEditor.ShaderGraph.Internal.Vector1ShaderProperty", + "m_ObjectId": "6afc15978a595c2d8813b1230b602de2", + "m_Guid": { + "m_GuidSerialized": "6afc1597-8a59-5c2d-8813-b1230b602de2" + }, + "m_Name": "Index of Refraction", + "m_DefaultRefNameVersion": 1, + "m_RefNameGeneratedByDisplayName": "Index of Refraction", + "m_DefaultReferenceName": "_CoverIor", + "m_OverrideReferenceName": "_CoverIor", + "m_GeneratePropertyBlock": true, + "m_UseCustomSlotLabel": false, + "m_CustomSlotLabel": "", + "m_DismissedVersion": 0, + "m_Precision": 0, + "overrideHLSLDeclaration": false, + "hlslDeclarationOverride": 0, + "m_Hidden": false, + "m_Value": 1.49, + "m_FloatType": 1, + "m_RangeValues": { + "x": 1, + "y": 2 + }, + "m_SliderType": 0, + "m_SliderPower": 3.0, + "m_EnumType": 0, + "m_CSharpEnumString": "", + "m_EnumNames": [], + "m_EnumValues": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.PropertyNode", + "m_ObjectId": "957e9ed0c7c75a15b6f95d921b90f7c5", + "m_Group": { + "m_Id": "" + }, + "m_Name": "Property", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": -500, + "y": 560, + "width": 210, + "height": 100 + } + }, + "m_Slots": [ + { + "m_Id": "387e2958b0695e8a8853a8ae77c58775" + } + ], + "synonyms": [], + "m_Precision": 1, + "m_PreviewExpanded": false, + "m_DismissedVersion": 0, + "m_PreviewMode": 0, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_Property": { + "m_Id": "6afc15978a595c2d8813b1230b602de2" + } +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "387e2958b0695e8a8853a8ae77c58775", + "m_Id": 0, + "m_DisplayName": "Out", + "m_SlotType": 1, + "m_Hidden": false, + "m_ShaderOutputName": "Out", + "m_StageCapability": 3, + "m_Value": 0.0, + "m_DefaultValue": 0.0, + "m_Labels": [] +} + +{ + "m_SGVersion": 3, + "m_Type": "UnityEditor.ShaderGraph.Internal.ColorShaderProperty", + "m_ObjectId": "5ee46cc3aef851a28108df86cdbda282", + "m_Guid": { + "m_GuidSerialized": "5ee46cc3-aef8-51a2-8108-df86cdbda282" + }, + "m_Name": "Tint (through the plastic)", + "m_DefaultRefNameVersion": 1, + "m_RefNameGeneratedByDisplayName": "Tint (through the plastic)", + "m_DefaultReferenceName": "_CoverTint", + "m_OverrideReferenceName": "_CoverTint", + "m_GeneratePropertyBlock": true, + "m_UseCustomSlotLabel": false, + "m_CustomSlotLabel": "", + "m_DismissedVersion": 0, + "m_Precision": 0, + "overrideHLSLDeclaration": false, + "hlslDeclarationOverride": 0, + "m_Hidden": false, + "m_Value": { + "r": 1, + "g": 1, + "b": 1, + "a": 1 + }, + "isMainColor": false, + "m_ColorMode": 0 +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.PropertyNode", + "m_ObjectId": "3a23644eca7454818fdf008ee8debf7a", + "m_Group": { + "m_Id": "" + }, + "m_Name": "Property", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": -500, + "y": 640, + "width": 210, + "height": 100 + } + }, + "m_Slots": [ + { + "m_Id": "22da729fd0e059c0a3c52b14d58affab" + } + ], + "synonyms": [], + "m_Precision": 1, + "m_PreviewExpanded": false, + "m_DismissedVersion": 0, + "m_PreviewMode": 0, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_Property": { + "m_Id": "5ee46cc3aef851a28108df86cdbda282" + } +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector4MaterialSlot", + "m_ObjectId": "22da729fd0e059c0a3c52b14d58affab", + "m_Id": 0, + "m_DisplayName": "Out", + "m_SlotType": 1, + "m_Hidden": false, + "m_ShaderOutputName": "Out", + "m_StageCapability": 3, + "m_Value": { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 0.0 + }, + "m_DefaultValue": { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 0.0 + }, + "m_Labels": [] +} + +{ + "m_SGVersion": 1, + "m_Type": "UnityEditor.ShaderGraph.Internal.Vector1ShaderProperty", + "m_ObjectId": "4e2ee789d2ea5b25b2c2a9618c1b8751", + "m_Guid": { + "m_GuidSerialized": "4e2ee789-d2ea-5b25-b2c2-a9618c1b8751" + }, + "m_Name": "Tint Depth (mm)", + "m_DefaultRefNameVersion": 1, + "m_RefNameGeneratedByDisplayName": "Tint Depth (mm)", + "m_DefaultReferenceName": "_CoverTintDepth", + "m_OverrideReferenceName": "_CoverTintDepth", + "m_GeneratePropertyBlock": true, + "m_UseCustomSlotLabel": false, + "m_CustomSlotLabel": "", + "m_DismissedVersion": 0, + "m_Precision": 0, + "overrideHLSLDeclaration": false, + "hlslDeclarationOverride": 0, + "m_Hidden": false, + "m_Value": 3, + "m_FloatType": 1, + "m_RangeValues": { + "x": 0.1, + "y": 20 + }, + "m_SliderType": 0, + "m_SliderPower": 3.0, + "m_EnumType": 0, + "m_CSharpEnumString": "", + "m_EnumNames": [], + "m_EnumValues": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.PropertyNode", + "m_ObjectId": "fc79f245f21e57e691f820bd183097dc", + "m_Group": { + "m_Id": "" + }, + "m_Name": "Property", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": -500, + "y": 720, + "width": 210, + "height": 100 + } + }, + "m_Slots": [ + { + "m_Id": "848bf7c4287355369ba9a83fc1891f33" + } + ], + "synonyms": [], + "m_Precision": 1, + "m_PreviewExpanded": false, + "m_DismissedVersion": 0, + "m_PreviewMode": 0, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_Property": { + "m_Id": "4e2ee789d2ea5b25b2c2a9618c1b8751" + } +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "848bf7c4287355369ba9a83fc1891f33", + "m_Id": 0, + "m_DisplayName": "Out", + "m_SlotType": 1, + "m_Hidden": false, + "m_ShaderOutputName": "Out", + "m_StageCapability": 3, + "m_Value": 0.0, + "m_DefaultValue": 0.0, + "m_Labels": [] +} + +{ + "m_SGVersion": 1, + "m_Type": "UnityEditor.ShaderGraph.Internal.Vector1ShaderProperty", + "m_ObjectId": "41b0c9f64d6e57c8bef5ef55302a84e7", + "m_Guid": { + "m_GuidSerialized": "41b0c9f6-4d6e-57c8-bef5-ef55302a84e7" + }, + "m_Name": "Thickness (runtime, mm)", + "m_DefaultRefNameVersion": 1, + "m_RefNameGeneratedByDisplayName": "Thickness (runtime, mm)", + "m_DefaultReferenceName": "_CoverThickness", + "m_OverrideReferenceName": "_CoverThickness", + "m_GeneratePropertyBlock": true, + "m_UseCustomSlotLabel": false, + "m_CustomSlotLabel": "", + "m_DismissedVersion": 0, + "m_Precision": 0, + "overrideHLSLDeclaration": false, + "hlslDeclarationOverride": 0, + "m_Hidden": true, + "m_Value": 4, + "m_FloatType": 1, + "m_RangeValues": { + "x": 0.1, + "y": 20 + }, + "m_SliderType": 0, + "m_SliderPower": 3.0, + "m_EnumType": 0, + "m_CSharpEnumString": "", + "m_EnumNames": [], + "m_EnumValues": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.PropertyNode", + "m_ObjectId": "e39b393c054c5627b0a7dc680763496e", + "m_Group": { + "m_Id": "" + }, + "m_Name": "Property", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": -500, + "y": 800, + "width": 210, + "height": 100 + } + }, + "m_Slots": [ + { + "m_Id": "e1072f0ff9ae5b2f9fe12dfc85cc9109" + } + ], + "synonyms": [], + "m_Precision": 1, + "m_PreviewExpanded": false, + "m_DismissedVersion": 0, + "m_PreviewMode": 0, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_Property": { + "m_Id": "41b0c9f64d6e57c8bef5ef55302a84e7" + } +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "e1072f0ff9ae5b2f9fe12dfc85cc9109", + "m_Id": 0, + "m_DisplayName": "Out", + "m_SlotType": 1, + "m_Hidden": false, + "m_ShaderOutputName": "Out", + "m_StageCapability": 3, + "m_Value": 0.0, + "m_DefaultValue": 0.0, + "m_Labels": [] +} + +{ + "m_SGVersion": 1, + "m_Type": "UnityEditor.ShaderGraph.Internal.Vector4ShaderProperty", + "m_ObjectId": "e48982adfc4a5689b1b479e31ba05ead", + "m_Guid": { + "m_GuidSerialized": "e48982ad-fc4a-5689-b1b4-79e31ba05ead" + }, + "m_Name": "Lamp RGB (linear, runtime)", + "m_DefaultRefNameVersion": 1, + "m_RefNameGeneratedByDisplayName": "Lamp RGB (linear, runtime)", + "m_DefaultReferenceName": "_InsertLampColor", + "m_OverrideReferenceName": "_InsertLampColor", + "m_GeneratePropertyBlock": true, + "m_UseCustomSlotLabel": false, + "m_CustomSlotLabel": "", + "m_DismissedVersion": 0, + "m_Precision": 0, + "overrideHLSLDeclaration": false, + "hlslDeclarationOverride": 0, + "m_Hidden": true, + "m_Value": { + "x": 1, + "y": 1, + "z": 1, + "w": 1 + } +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.PropertyNode", + "m_ObjectId": "ae6fbc0cd86a5367a59330cce4ee6483", + "m_Group": { + "m_Id": "" + }, + "m_Name": "Property", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": -500, + "y": 880, + "width": 210, + "height": 100 + } + }, + "m_Slots": [ + { + "m_Id": "d5a288ef59cf5879b4e08d4ffcaf52dd" + } + ], + "synonyms": [], + "m_Precision": 1, + "m_PreviewExpanded": false, + "m_DismissedVersion": 0, + "m_PreviewMode": 0, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_Property": { + "m_Id": "e48982adfc4a5689b1b479e31ba05ead" + } +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector4MaterialSlot", + "m_ObjectId": "d5a288ef59cf5879b4e08d4ffcaf52dd", + "m_Id": 0, + "m_DisplayName": "Out", + "m_SlotType": 1, + "m_Hidden": false, + "m_ShaderOutputName": "Out", + "m_StageCapability": 3, + "m_Value": { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 0.0 + }, + "m_DefaultValue": { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 0.0 + }, + "m_Labels": [] +} + +{ + "m_SGVersion": 1, + "m_Type": "UnityEditor.ShaderGraph.PositionNode", + "m_ObjectId": "a9efd782c94655aeaf5d15772153e39b", + "m_Group": { + "m_Id": "" + }, + "m_Name": "Position", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": -100, + "y": 900, + "width": 210, + "height": 100 + } + }, + "m_Slots": [ + { + "m_Id": "d4e08638c5ee5c43a31b789c661aa522" + } + ], + "synonyms": [ + "location" + ], + "m_Precision": 1, + "m_PreviewExpanded": false, + "m_DismissedVersion": 0, + "m_PreviewMode": 2, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_Space": 0, + "m_PositionSource": 0 +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector3MaterialSlot", + "m_ObjectId": "d4e08638c5ee5c43a31b789c661aa522", + "m_Id": 0, + "m_DisplayName": "Out", + "m_SlotType": 1, + "m_Hidden": false, + "m_ShaderOutputName": "Out", + "m_StageCapability": 3, + "m_Value": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "m_DefaultValue": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "m_Labels": [] +} + +{ + "m_SGVersion": 1, + "m_Type": "UnityEditor.ShaderGraph.Internal.Vector4ShaderProperty", + "m_ObjectId": "beaf92fa297c5f0f8cfffa63a937cc10", + "m_Guid": { + "m_GuidSerialized": "beaf92fa-297c-5f0f-8cff-fa63a937cc10" + }, + "m_Name": "Virtual Lamp (runtime)", + "m_DefaultRefNameVersion": 1, + "m_RefNameGeneratedByDisplayName": "Virtual Lamp (runtime)", + "m_DefaultReferenceName": "_OpticalLamp", + "m_OverrideReferenceName": "_OpticalLamp", + "m_GeneratePropertyBlock": true, + "m_UseCustomSlotLabel": false, + "m_CustomSlotLabel": "", + "m_DismissedVersion": 0, + "m_Precision": 0, + "overrideHLSLDeclaration": false, + "hlslDeclarationOverride": 0, + "m_Hidden": true, + "m_Value": { + "x": 0, + "y": -0.015, + "z": 0, + "w": 0.008 + } +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.PropertyNode", + "m_ObjectId": "d5cb3ef25c845329974239d959f0af4f", + "m_Group": { + "m_Id": "" + }, + "m_Name": "Property", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": -500, + "y": 960, + "width": 210, + "height": 100 + } + }, + "m_Slots": [ + { + "m_Id": "2ca2a87a01535897a00f2ca103b16976" + } + ], + "synonyms": [], + "m_Precision": 1, + "m_PreviewExpanded": false, + "m_DismissedVersion": 0, + "m_PreviewMode": 0, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_Property": { + "m_Id": "beaf92fa297c5f0f8cfffa63a937cc10" + } +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector4MaterialSlot", + "m_ObjectId": "2ca2a87a01535897a00f2ca103b16976", + "m_Id": 0, + "m_DisplayName": "Out", + "m_SlotType": 1, + "m_Hidden": false, + "m_ShaderOutputName": "Out", + "m_StageCapability": 3, + "m_Value": { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 0.0 + }, + "m_DefaultValue": { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 0.0 + }, + "m_Labels": [] +} + +{ + "m_SGVersion": 1, + "m_Type": "UnityEditor.ShaderGraph.Internal.Vector4ShaderProperty", + "m_ObjectId": "e369cdcd58465c1ea4f76f73dfc2dc40", + "m_Guid": { + "m_GuidSerialized": "e369cdcd-5846-5c1e-a4f7-6f73dfc2dc40" + }, + "m_Name": "Virtual Lamp B (runtime)", + "m_DefaultRefNameVersion": 1, + "m_RefNameGeneratedByDisplayName": "Virtual Lamp B (runtime)", + "m_DefaultReferenceName": "_OpticalLampB", + "m_OverrideReferenceName": "_OpticalLampB", + "m_GeneratePropertyBlock": true, + "m_UseCustomSlotLabel": false, + "m_CustomSlotLabel": "", + "m_DismissedVersion": 0, + "m_Precision": 0, + "overrideHLSLDeclaration": false, + "hlslDeclarationOverride": 0, + "m_Hidden": true, + "m_Value": { + "x": 0, + "y": 0, + "z": 0, + "w": 0 + } +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.PropertyNode", + "m_ObjectId": "9eaf027f14a05caaa4525c402bf52b36", + "m_Group": { + "m_Id": "" + }, + "m_Name": "Property", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": -500, + "y": 1040, + "width": 210, + "height": 100 + } + }, + "m_Slots": [ + { + "m_Id": "37ead9122de653408cc5d243743845d5" + } + ], + "synonyms": [], + "m_Precision": 1, + "m_PreviewExpanded": false, + "m_DismissedVersion": 0, + "m_PreviewMode": 0, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_Property": { + "m_Id": "e369cdcd58465c1ea4f76f73dfc2dc40" + } +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector4MaterialSlot", + "m_ObjectId": "37ead9122de653408cc5d243743845d5", + "m_Id": 0, + "m_DisplayName": "Out", + "m_SlotType": 1, + "m_Hidden": false, + "m_ShaderOutputName": "Out", + "m_StageCapability": 3, + "m_Value": { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 0.0 + }, + "m_DefaultValue": { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 0.0 + }, + "m_Labels": [] +} + +{ + "m_SGVersion": 1, + "m_Type": "UnityEditor.ShaderGraph.Internal.Vector4ShaderProperty", + "m_ObjectId": "73ee4f83fb3f55998eaa8615d946fd00", + "m_Guid": { + "m_GuidSerialized": "73ee4f83-fb3f-5599-8eaa-8615d946fd00" + }, + "m_Name": "Lamp RGB and level (runtime), bulb B", + "m_DefaultRefNameVersion": 1, + "m_RefNameGeneratedByDisplayName": "Lamp RGB and level (runtime), bulb B", + "m_DefaultReferenceName": "_InsertLampColorB", + "m_OverrideReferenceName": "_InsertLampColorB", + "m_GeneratePropertyBlock": true, + "m_UseCustomSlotLabel": false, + "m_CustomSlotLabel": "", + "m_DismissedVersion": 0, + "m_Precision": 0, + "overrideHLSLDeclaration": false, + "hlslDeclarationOverride": 0, + "m_Hidden": true, + "m_Value": { + "x": 1, + "y": 1, + "z": 1, + "w": 0 + } +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.PropertyNode", + "m_ObjectId": "4a4c94b2e0b75a19ab193ec933ea1466", + "m_Group": { + "m_Id": "" + }, + "m_Name": "Property", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": -500, + "y": 1120, + "width": 210, + "height": 100 + } + }, + "m_Slots": [ + { + "m_Id": "960b1ebc111e522da93b6ce909dfb45f" + } + ], + "synonyms": [], + "m_Precision": 1, + "m_PreviewExpanded": false, + "m_DismissedVersion": 0, + "m_PreviewMode": 0, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_Property": { + "m_Id": "73ee4f83fb3f55998eaa8615d946fd00" + } +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector4MaterialSlot", + "m_ObjectId": "960b1ebc111e522da93b6ce909dfb45f", + "m_Id": 0, + "m_DisplayName": "Out", + "m_SlotType": 1, + "m_Hidden": false, + "m_ShaderOutputName": "Out", + "m_StageCapability": 3, + "m_Value": { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 0.0 + }, + "m_DefaultValue": { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 0.0 + }, + "m_Labels": [] +} + +{ + "m_SGVersion": 1, + "m_Type": "UnityEditor.ShaderGraph.Internal.Vector4ShaderProperty", + "m_ObjectId": "a40e81823ec95013b379358b4dc225df", + "m_Guid": { + "m_GuidSerialized": "a40e8182-3ec9-5013-b379-358b4dc225df" + }, + "m_Name": "Virtual Lamp C (runtime)", + "m_DefaultRefNameVersion": 1, + "m_RefNameGeneratedByDisplayName": "Virtual Lamp C (runtime)", + "m_DefaultReferenceName": "_OpticalLampC", + "m_OverrideReferenceName": "_OpticalLampC", + "m_GeneratePropertyBlock": true, + "m_UseCustomSlotLabel": false, + "m_CustomSlotLabel": "", + "m_DismissedVersion": 0, + "m_Precision": 0, + "overrideHLSLDeclaration": false, + "hlslDeclarationOverride": 0, + "m_Hidden": true, + "m_Value": { + "x": 0, + "y": 0, + "z": 0, + "w": 0 + } +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.PropertyNode", + "m_ObjectId": "53fade0153a85229ba074a003b4e5d88", + "m_Group": { + "m_Id": "" + }, + "m_Name": "Property", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": -500, + "y": 1200, + "width": 210, + "height": 100 + } + }, + "m_Slots": [ + { + "m_Id": "5c4edaf8dd2d500dbc7ccc231319e9b6" + } + ], + "synonyms": [], + "m_Precision": 1, + "m_PreviewExpanded": false, + "m_DismissedVersion": 0, + "m_PreviewMode": 0, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_Property": { + "m_Id": "a40e81823ec95013b379358b4dc225df" + } +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector4MaterialSlot", + "m_ObjectId": "5c4edaf8dd2d500dbc7ccc231319e9b6", + "m_Id": 0, + "m_DisplayName": "Out", + "m_SlotType": 1, + "m_Hidden": false, + "m_ShaderOutputName": "Out", + "m_StageCapability": 3, + "m_Value": { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 0.0 + }, + "m_DefaultValue": { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 0.0 + }, + "m_Labels": [] +} + +{ + "m_SGVersion": 1, + "m_Type": "UnityEditor.ShaderGraph.Internal.Vector4ShaderProperty", + "m_ObjectId": "8a7f5ba32f7354ccae9f44d72abefd82", + "m_Guid": { + "m_GuidSerialized": "8a7f5ba3-2f73-54cc-ae9f-44d72abefd82" + }, + "m_Name": "Lamp RGB and level (runtime), bulb C", + "m_DefaultRefNameVersion": 1, + "m_RefNameGeneratedByDisplayName": "Lamp RGB and level (runtime), bulb C", + "m_DefaultReferenceName": "_InsertLampColorC", + "m_OverrideReferenceName": "_InsertLampColorC", + "m_GeneratePropertyBlock": true, + "m_UseCustomSlotLabel": false, + "m_CustomSlotLabel": "", + "m_DismissedVersion": 0, + "m_Precision": 0, + "overrideHLSLDeclaration": false, + "hlslDeclarationOverride": 0, + "m_Hidden": true, + "m_Value": { + "x": 1, + "y": 1, + "z": 1, + "w": 0 + } +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.PropertyNode", + "m_ObjectId": "5b1791466d87517792a0ff4cac505750", + "m_Group": { + "m_Id": "" + }, + "m_Name": "Property", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": -500, + "y": 1280, + "width": 210, + "height": 100 + } + }, + "m_Slots": [ + { + "m_Id": "81e57c53bd5156a48b933061a5ce149a" + } + ], + "synonyms": [], + "m_Precision": 1, + "m_PreviewExpanded": false, + "m_DismissedVersion": 0, + "m_PreviewMode": 0, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_Property": { + "m_Id": "8a7f5ba32f7354ccae9f44d72abefd82" + } +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector4MaterialSlot", + "m_ObjectId": "81e57c53bd5156a48b933061a5ce149a", + "m_Id": 0, + "m_DisplayName": "Out", + "m_SlotType": 1, + "m_Hidden": false, + "m_ShaderOutputName": "Out", + "m_StageCapability": 3, + "m_Value": { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 0.0 + }, + "m_DefaultValue": { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 0.0 + }, + "m_Labels": [] +} + +{ + "m_SGVersion": 1, + "m_Type": "UnityEditor.ShaderGraph.Internal.Vector4ShaderProperty", + "m_ObjectId": "bc2becce24e8513a8434c08c2fbf42b9", + "m_Guid": { + "m_GuidSerialized": "bc2becce-24e8-513a-8434-c08c2fbf42b9" + }, + "m_Name": "Virtual Lamp D (runtime)", + "m_DefaultRefNameVersion": 1, + "m_RefNameGeneratedByDisplayName": "Virtual Lamp D (runtime)", + "m_DefaultReferenceName": "_OpticalLampD", + "m_OverrideReferenceName": "_OpticalLampD", + "m_GeneratePropertyBlock": true, + "m_UseCustomSlotLabel": false, + "m_CustomSlotLabel": "", + "m_DismissedVersion": 0, + "m_Precision": 0, + "overrideHLSLDeclaration": false, + "hlslDeclarationOverride": 0, + "m_Hidden": true, + "m_Value": { + "x": 0, + "y": 0, + "z": 0, + "w": 0 + } +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.PropertyNode", + "m_ObjectId": "8c8812e0868258fda47f84f843fb6f89", + "m_Group": { + "m_Id": "" + }, + "m_Name": "Property", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": -500, + "y": 1360, + "width": 210, + "height": 100 + } + }, + "m_Slots": [ + { + "m_Id": "f89014afc4225d548cfb931c58c5bba8" + } + ], + "synonyms": [], + "m_Precision": 1, + "m_PreviewExpanded": false, + "m_DismissedVersion": 0, + "m_PreviewMode": 0, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_Property": { + "m_Id": "bc2becce24e8513a8434c08c2fbf42b9" + } +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector4MaterialSlot", + "m_ObjectId": "f89014afc4225d548cfb931c58c5bba8", + "m_Id": 0, + "m_DisplayName": "Out", + "m_SlotType": 1, + "m_Hidden": false, + "m_ShaderOutputName": "Out", + "m_StageCapability": 3, + "m_Value": { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 0.0 + }, + "m_DefaultValue": { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 0.0 + }, + "m_Labels": [] +} + +{ + "m_SGVersion": 1, + "m_Type": "UnityEditor.ShaderGraph.Internal.Vector4ShaderProperty", + "m_ObjectId": "597db27618c157c99116431fe62014ac", + "m_Guid": { + "m_GuidSerialized": "597db276-18c1-57c9-9116-431fe62014ac" + }, + "m_Name": "Lamp RGB and level (runtime), bulb D", + "m_DefaultRefNameVersion": 1, + "m_RefNameGeneratedByDisplayName": "Lamp RGB and level (runtime), bulb D", + "m_DefaultReferenceName": "_InsertLampColorD", + "m_OverrideReferenceName": "_InsertLampColorD", + "m_GeneratePropertyBlock": true, + "m_UseCustomSlotLabel": false, + "m_CustomSlotLabel": "", + "m_DismissedVersion": 0, + "m_Precision": 0, + "overrideHLSLDeclaration": false, + "hlslDeclarationOverride": 0, + "m_Hidden": true, + "m_Value": { + "x": 1, + "y": 1, + "z": 1, + "w": 0 + } +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.PropertyNode", + "m_ObjectId": "be026746f05e5466ba9548f5e3cc5d38", + "m_Group": { + "m_Id": "" + }, + "m_Name": "Property", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": -500, + "y": 1440, + "width": 210, + "height": 100 + } + }, + "m_Slots": [ + { + "m_Id": "1d156b09528b5d7c9904a2223032dd3b" + } + ], + "synonyms": [], + "m_Precision": 1, + "m_PreviewExpanded": false, + "m_DismissedVersion": 0, + "m_PreviewMode": 0, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_Property": { + "m_Id": "597db27618c157c99116431fe62014ac" + } +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector4MaterialSlot", + "m_ObjectId": "1d156b09528b5d7c9904a2223032dd3b", + "m_Id": 0, + "m_DisplayName": "Out", + "m_SlotType": 1, + "m_Hidden": false, + "m_ShaderOutputName": "Out", + "m_StageCapability": 3, + "m_Value": { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 0.0 + }, + "m_DefaultValue": { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 0.0 + }, + "m_Labels": [] +} + +{ + "m_SGVersion": 1, + "m_Type": "UnityEditor.ShaderGraph.Internal.Vector1ShaderProperty", + "m_ObjectId": "e34e3001a11f5de799da15122a2efb3d", + "m_Guid": { + "m_GuidSerialized": "e34e3001-a11f-5de7-99da-15122a2efb3d" + }, + "m_Name": "Lamp Spread (runtime, mm)", + "m_DefaultRefNameVersion": 1, + "m_RefNameGeneratedByDisplayName": "Lamp Spread (runtime, mm)", + "m_DefaultReferenceName": "_OpticalSpread", + "m_OverrideReferenceName": "_OpticalSpread", + "m_GeneratePropertyBlock": true, + "m_UseCustomSlotLabel": false, + "m_CustomSlotLabel": "", + "m_DismissedVersion": 0, + "m_Precision": 0, + "overrideHLSLDeclaration": false, + "hlslDeclarationOverride": 0, + "m_Hidden": true, + "m_Value": 0, + "m_FloatType": 1, + "m_RangeValues": { + "x": 0, + "y": 100 + }, + "m_SliderType": 0, + "m_SliderPower": 3.0, + "m_EnumType": 0, + "m_CSharpEnumString": "", + "m_EnumNames": [], + "m_EnumValues": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.PropertyNode", + "m_ObjectId": "d4705b78c4255faabcebf85cadf8ab7f", + "m_Group": { + "m_Id": "" + }, + "m_Name": "Property", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": -500, + "y": 1520, + "width": 210, + "height": 100 + } + }, + "m_Slots": [ + { + "m_Id": "db07f42c7bb650e3ac8ea4d83a99afd6" + } + ], + "synonyms": [], + "m_Precision": 1, + "m_PreviewExpanded": false, + "m_DismissedVersion": 0, + "m_PreviewMode": 0, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_Property": { + "m_Id": "e34e3001a11f5de799da15122a2efb3d" + } +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "db07f42c7bb650e3ac8ea4d83a99afd6", + "m_Id": 0, + "m_DisplayName": "Out", + "m_SlotType": 1, + "m_Hidden": false, + "m_ShaderOutputName": "Out", + "m_StageCapability": 3, + "m_Value": 0.0, + "m_DefaultValue": 0.0, + "m_Labels": [] +} + +{ + "m_SGVersion": 1, + "m_Type": "UnityEditor.ShaderGraph.Internal.Vector1ShaderProperty", + "m_ObjectId": "ea281cf402cf56caa413b6c3334f283e", + "m_Guid": { + "m_GuidSerialized": "ea281cf4-02cf-56ca-a413-b6c3334f283e" + }, + "m_Name": "Intensity (runtime)", + "m_DefaultRefNameVersion": 1, + "m_RefNameGeneratedByDisplayName": "Intensity (runtime)", + "m_DefaultReferenceName": "_InsertLampLevel", + "m_OverrideReferenceName": "_InsertLampLevel", + "m_GeneratePropertyBlock": true, + "m_UseCustomSlotLabel": false, + "m_CustomSlotLabel": "", + "m_DismissedVersion": 0, + "m_Precision": 0, + "overrideHLSLDeclaration": false, + "hlslDeclarationOverride": 0, + "m_Hidden": true, + "m_Value": 0, + "m_FloatType": 1, + "m_RangeValues": { + "x": 0, + "y": 1 + }, + "m_SliderType": 0, + "m_SliderPower": 3.0, + "m_EnumType": 0, + "m_CSharpEnumString": "", + "m_EnumNames": [], + "m_EnumValues": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.PropertyNode", + "m_ObjectId": "3cb544b2383a52a08d6dfa42b0182fc5", + "m_Group": { + "m_Id": "" + }, + "m_Name": "Property", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": -500, + "y": 1600, + "width": 210, + "height": 100 + } + }, + "m_Slots": [ + { + "m_Id": "e747aacbfeeb52ffac3638a80981fafa" + } + ], + "synonyms": [], + "m_Precision": 1, + "m_PreviewExpanded": false, + "m_DismissedVersion": 0, + "m_PreviewMode": 0, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_Property": { + "m_Id": "ea281cf402cf56caa413b6c3334f283e" + } +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "e747aacbfeeb52ffac3638a80981fafa", + "m_Id": 0, + "m_DisplayName": "Out", + "m_SlotType": 1, + "m_Hidden": false, + "m_ShaderOutputName": "Out", + "m_StageCapability": 3, + "m_Value": 0.0, + "m_DefaultValue": 0.0, + "m_Labels": [] +} + +{ + "m_SGVersion": 1, + "m_Type": "UnityEditor.ShaderGraph.Internal.Vector1ShaderProperty", + "m_ObjectId": "d3e87f63665b5fcba90acf09dc7ca55b", + "m_Guid": { + "m_GuidSerialized": "d3e87f63-665b-5fcb-a90a-cf09dc7ca55b" + }, + "m_Name": "Lamp Tint (runtime)", + "m_DefaultRefNameVersion": 1, + "m_RefNameGeneratedByDisplayName": "Lamp Tint (runtime)", + "m_DefaultReferenceName": "_InsertLampTint", + "m_OverrideReferenceName": "_InsertLampTint", + "m_GeneratePropertyBlock": true, + "m_UseCustomSlotLabel": false, + "m_CustomSlotLabel": "", + "m_DismissedVersion": 0, + "m_Precision": 0, + "overrideHLSLDeclaration": false, + "hlslDeclarationOverride": 0, + "m_Hidden": true, + "m_Value": 0, + "m_FloatType": 1, + "m_RangeValues": { + "x": 0, + "y": 1 + }, + "m_SliderType": 0, + "m_SliderPower": 3.0, + "m_EnumType": 0, + "m_CSharpEnumString": "", + "m_EnumNames": [], + "m_EnumValues": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.PropertyNode", + "m_ObjectId": "970b3b62e1625d8784503fc213668716", + "m_Group": { + "m_Id": "" + }, + "m_Name": "Property", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": -500, + "y": 1680, + "width": 210, + "height": 100 + } + }, + "m_Slots": [ + { + "m_Id": "7c6eb7d2ed8553a58bbe567d204a2f12" + } + ], + "synonyms": [], + "m_Precision": 1, + "m_PreviewExpanded": false, + "m_DismissedVersion": 0, + "m_PreviewMode": 0, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_Property": { + "m_Id": "d3e87f63665b5fcba90acf09dc7ca55b" + } +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "7c6eb7d2ed8553a58bbe567d204a2f12", + "m_Id": 0, + "m_DisplayName": "Out", + "m_SlotType": 1, + "m_Hidden": false, + "m_ShaderOutputName": "Out", + "m_StageCapability": 3, + "m_Value": 0.0, + "m_DefaultValue": 0.0, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.CategoryData", + "m_ObjectId": "7b398cb0bb835563b2574124a2b247d5", + "m_Name": "", + "m_ChildObjectList": [ + { + "m_Id": "ad06f3fb3b0c594a95419141a6275219" + }, + { + "m_Id": "fae01e6a006851718b1e68199e602c03" + }, + { + "m_Id": "2097df164c9e5fb1947791cdfa588dd1" + }, + { + "m_Id": "f611e7ae978c57538ba217fb9b8c077a" + }, + { + "m_Id": "5b38cbf426f45f719881c428caf2ff6f" + }, + { + "m_Id": "ca43925be4165b178d1f5bc49e3651ca" + }, + { + "m_Id": "6afc15978a595c2d8813b1230b602de2" + }, + { + "m_Id": "5ee46cc3aef851a28108df86cdbda282" + }, + { + "m_Id": "4e2ee789d2ea5b25b2c2a9618c1b8751" + }, + { + "m_Id": "41b0c9f64d6e57c8bef5ef55302a84e7" + }, + { + "m_Id": "e48982adfc4a5689b1b479e31ba05ead" + }, + { + "m_Id": "beaf92fa297c5f0f8cfffa63a937cc10" + }, + { + "m_Id": "e369cdcd58465c1ea4f76f73dfc2dc40" + }, + { + "m_Id": "73ee4f83fb3f55998eaa8615d946fd00" + }, + { + "m_Id": "a40e81823ec95013b379358b4dc225df" + }, + { + "m_Id": "8a7f5ba32f7354ccae9f44d72abefd82" + }, + { + "m_Id": "bc2becce24e8513a8434c08c2fbf42b9" + }, + { + "m_Id": "597db27618c157c99116431fe62014ac" + }, + { + "m_Id": "e34e3001a11f5de799da15122a2efb3d" + }, + { + "m_Id": "ea281cf402cf56caa413b6c3334f283e" + }, + { + "m_Id": "d3e87f63665b5fcba90acf09dc7ca55b" + } + ] +} + +{ + "m_SGVersion": 1, + "m_Type": "UnityEditor.Rendering.Universal.ShaderGraph.UniversalTarget", + "m_ObjectId": "3c06f88b3ab44722a6d9d8e8794df1db", + "m_Datas": [], + "m_ActiveSubTarget": { + "m_Id": "4b4a46faeeab4e6da17eb93882746027" + }, + "m_AllowMaterialOverride": false, + "m_SurfaceType": 1, + "m_ZTestMode": 4, + "m_ZWriteControl": 2, + "m_AlphaMode": 0, + "m_RenderFace": 2, + "m_AlphaClip": false, + "m_CastShadows": false, + "m_ReceiveShadows": true, + "m_CustomEditorGUI": "VisualPinball.Unity.Urp.InsertShaderGUI", + "m_SupportVFX": false +} + +{ + "m_SGVersion": 2, + "m_Type": "UnityEditor.Rendering.Universal.ShaderGraph.UniversalLitSubTarget", + "m_ObjectId": "4b4a46faeeab4e6da17eb93882746027", + "m_WorkflowMode": 1, + "m_NormalDropOffSpace": 0, + "m_ClearCoat": false, + "m_BlendModePreserveSpecular": true +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector3MaterialSlot", + "m_ObjectId": "3c5d50ef72f6418bab1e3d025b27d44d", + "m_Id": 31, + "m_DisplayName": "Emission", + "m_SlotType": 1, + "m_Hidden": false, + "m_ShaderOutputName": "Emission", + "m_StageCapability": 3, + "m_Value": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "m_DefaultValue": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.TangentVectorNode", + "m_ObjectId": "c97ff5e9b87140c691321a3e8fc99e68", + "m_Group": { + "m_Id": "" + }, + "m_Name": "Tangent Vector", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": -200, + "y": 900, + "width": 210, + "height": 100 + } + }, + "m_Slots": [ + { + "m_Id": "5821f697c1b64c319dda20b17c8962d2" + } + ], + "synonyms": [], + "m_Precision": 1, + "m_PreviewExpanded": false, + "m_DismissedVersion": 0, + "m_PreviewMode": 2, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_Space": 0 +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector3MaterialSlot", + "m_ObjectId": "5821f697c1b64c319dda20b17c8962d2", + "m_Id": 0, + "m_DisplayName": "Out", + "m_SlotType": 1, + "m_Hidden": false, + "m_ShaderOutputName": "Out", + "m_StageCapability": 3, + "m_Value": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "m_DefaultValue": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector3MaterialSlot", + "m_ObjectId": "0a08c13e7bf14b60b5f8a82785e8628d", + "m_Id": 32, + "m_DisplayName": "TangentOS", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "TangentOS", + "m_StageCapability": 3, + "m_Value": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "m_DefaultValue": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.BitangentVectorNode", + "m_ObjectId": "b99248f4c5a34783878a33a7e2bc8fb9", + "m_Group": { + "m_Id": "" + }, + "m_Name": "Bitangent Vector", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": -200, + "y": 1050, + "width": 210, + "height": 100 + } + }, + "m_Slots": [ + { + "m_Id": "e76f6d80e6cd40aa9db94757c6fe84f0" + } + ], + "synonyms": [ + "binormal" + ], + "m_Precision": 1, + "m_PreviewExpanded": false, + "m_DismissedVersion": 0, + "m_PreviewMode": 2, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_Space": 0 +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector3MaterialSlot", + "m_ObjectId": "e76f6d80e6cd40aa9db94757c6fe84f0", + "m_Id": 0, + "m_DisplayName": "Out", + "m_SlotType": 1, + "m_Hidden": false, + "m_ShaderOutputName": "Out", + "m_StageCapability": 3, + "m_Value": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "m_DefaultValue": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector3MaterialSlot", + "m_ObjectId": "d94a3a7c51ea4367b125945816d2e4f0", + "m_Id": 33, + "m_DisplayName": "BitangentOS", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "BitangentOS", + "m_StageCapability": 3, + "m_Value": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "m_DefaultValue": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "m_Labels": [] +} diff --git a/VisualPinball.Unity/Assets/Shaders/Srp/Insert/Insert Cover.shadergraph.meta b/VisualPinball.Unity/Assets/Shaders/Srp/Insert/Insert Cover.shadergraph.meta new file mode 100644 index 000000000..f28cd7a1f --- /dev/null +++ b/VisualPinball.Unity/Assets/Shaders/Srp/Insert/Insert Cover.shadergraph.meta @@ -0,0 +1,20 @@ +fileFormatVersion: 2 +guid: e23e1e894fc3bcd47ae3d63d8abc3cd0 +ScriptedImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 2 + userData: + assetBundleName: + assetBundleVariant: + script: {fileID: 11500000, guid: 625f186215c104763be7675aa2d941aa, type: 3} + useAsTemplate: 0 + exposeTemplateAsShader: 0 + indexedData: {instanceID: 0} + template: + name: + category: + description: + icon: {instanceID: 0} + thumbnail: {instanceID: 0} + order: 0 diff --git a/VisualPinball.Unity/Assets/Shaders/Srp/Insert/Insert Hybrid.shadergraph b/VisualPinball.Unity/Assets/Shaders/Srp/Insert/Insert Hybrid.shadergraph new file mode 100644 index 000000000..c03e7a214 --- /dev/null +++ b/VisualPinball.Unity/Assets/Shaders/Srp/Insert/Insert Hybrid.shadergraph @@ -0,0 +1,7354 @@ +{ + "m_SGVersion": 3, + "m_Type": "UnityEditor.ShaderGraph.GraphData", + "m_ObjectId": "db4e2d3aa9a45bc48f43594a6b04696b", + "m_Properties": [ + { + "m_Id": "f5cccd49a27950b79db69a09be9d7a89" + }, + { + "m_Id": "9c58618dfaf852e18f694a70f847b2cc" + }, + { + "m_Id": "80a6c7d4fa675c1f97d62030c40e284b" + }, + { + "m_Id": "cbaacef800f25aed9377b6affe122eb3" + }, + { + "m_Id": "b32e0a9968715785b711d9c950e67e70" + }, + { + "m_Id": "4f866e7a0d8653f1b37d5caa4190d4e5" + }, + { + "m_Id": "fb72f27f05ae538280d3a75b2cd757eb" + }, + { + "m_Id": "87bc8c4be8665adf8fb006ef67285c5d" + }, + { + "m_Id": "cebcb51b748d5b6889bc9956d0d479a3" + }, + { + "m_Id": "f2aa93755f9f5663be6045d6be0c61b9" + }, + { + "m_Id": "e28afaaa8fe053bd91f0e818b549fb77" + }, + { + "m_Id": "c6bcc2966afc5ae488259f75fc7b836d" + }, + { + "m_Id": "48504b8bc994589c80868e82996c56f2" + }, + { + "m_Id": "b032f322f53d557897aa5f0dd7e22c40" + }, + { + "m_Id": "5caa794e9c8856c8a296d48eaaa129e7" + }, + { + "m_Id": "3a765b8511e35e8fb3bd93b9f474c3d6" + }, + { + "m_Id": "79282d2d1f9f59beae1442cb5aec9d4a" + }, + { + "m_Id": "76b742d448275dac96c5a785ed5aa046" + }, + { + "m_Id": "c55299604c8f58ddb1951c3fccf48b0f" + }, + { + "m_Id": "79ce1dea7425576c927c76564474bdaa" + }, + { + "m_Id": "8f2697a2ad035fd283e96674f6099e87" + }, + { + "m_Id": "47031cac033a52d290a3f7d58ebeb0e5" + }, + { + "m_Id": "afd2ca8cba535099b1257c3f27290983" + }, + { + "m_Id": "d8f0b44d8ff45f8f9b7a97b64a7a5db0" + }, + { + "m_Id": "4c5ab3de0c985513bf7247b33879f300" + }, + { + "m_Id": "7550e938d31e5c399795602e29d14c3a" + }, + { + "m_Id": "391f5b4513d65194a4ba42d431bd8c93" + }, + { + "m_Id": "eddfdb9e051d5865834eb933671aaff4" + }, + { + "m_Id": "d1d92de60b5b50679d958d5415cb1bce" + }, + { + "m_Id": "7d4e3081cedd5c919f3bc85431603a72" + }, + { + "m_Id": "d6e78c4734055e67b1986e96afc59726" + }, + { + "m_Id": "975f88b1cbd05c3690f7e57ac7b2eb89" + }, + { + "m_Id": "ab903b8243655d0ea0911669c7c3432b" + }, + { + "m_Id": "d63fb2c8f30356c3aae3606059477dcd" + }, + { + "m_Id": "1e80a80e3298577f8a09c329c878fc93" + }, + { + "m_Id": "0868c71889275f43b8ad9867b67f2b93" + }, + { + "m_Id": "7745a741a46459bc84e174ad710431f3" + }, + { + "m_Id": "b1118c1270965d32a84bc58296cea0d5" + }, + { + "m_Id": "44276acd6332572585d8c5ea51c2b692" + }, + { + "m_Id": "ff36167b8ae75487bb5eccb8dd152070" + }, + { + "m_Id": "e188a4e6b18b54d89908031069759967" + }, + { + "m_Id": "5180ea7940775616b68bd2de94b6fd74" + }, + { + "m_Id": "a144dcb8a49459c19a2080583d6a71d3" + }, + { + "m_Id": "b4462b630ce1577fbdb18f40505fc0b7" + }, + { + "m_Id": "7b66c53699fb4e329b7b4ff523715868" + } + ], + "m_Keywords": [], + "m_Dropdowns": [], + "m_CategoryData": [ + { + "m_Id": "efcb6c45f55553f89edd4456c2a91dad" + } + ], + "m_Nodes": [ + { + "m_Id": "8b17db6110675180842cf69d50d4be33" + }, + { + "m_Id": "1416733beee65259aa05cc935db9583f" + }, + { + "m_Id": "50981c22d63c5fe9be71f306eab3aa8b" + }, + { + "m_Id": "f1c30112c2ac57528b85dd7db4a02b19" + }, + { + "m_Id": "af8e062f44565d54b5f377c2829f4bf3" + }, + { + "m_Id": "67ea0d7b089d573492b036727e05ce99" + }, + { + "m_Id": "90f29fe6214e5becb77e139ba2a1b2a6" + }, + { + "m_Id": "5d1129f3ab125ae794f880badfe3b76f" + }, + { + "m_Id": "8b2ba8d4c746548e8f3a638475ef2a12" + }, + { + "m_Id": "eb08b67e7dc45dba82c148f72e5dd340" + }, + { + "m_Id": "b57bfa9c48e8548695944203dd7264fa" + }, + { + "m_Id": "77adde5a503255bc8730b099ed03c298" + }, + { + "m_Id": "00654fd1b821567b946f725c6879acc6" + }, + { + "m_Id": "5774dd3dc28e51158e12894808bccd68" + }, + { + "m_Id": "181b1e1da978581b981f173bd21e7ed6" + }, + { + "m_Id": "6d910a0a0fbc5419b374f6d76193b271" + }, + { + "m_Id": "a7dea086444d58c09eb5c70fdac63f41" + }, + { + "m_Id": "c74254a56fae556ab408262a4ee977ae" + }, + { + "m_Id": "8e3e2aaf2c8558d9937179b50257456f" + }, + { + "m_Id": "38a0027d064f5a39b3b966d8859ef5f9" + }, + { + "m_Id": "b13762a60757541eb340dac8055f5f35" + }, + { + "m_Id": "9254815eb565514fb64170a48b987901" + }, + { + "m_Id": "93bfa3c99f135970a74b4e3ba8428d7d" + }, + { + "m_Id": "8c40c59aad9650a5bbb772fbe4e38b96" + }, + { + "m_Id": "0c02ad2153675de98a7d0fe17c4d4c4e" + }, + { + "m_Id": "6ddcfef30bf05ecdaf4b8b68419e8db6" + }, + { + "m_Id": "20589895c6be54ea8a175ab190d10bf6" + }, + { + "m_Id": "bfa4def9778953faa67d6315312a64e9" + }, + { + "m_Id": "39c18e46011b582e81b386af722fb317" + }, + { + "m_Id": "5a54035f9b50583e9369005068a9330c" + }, + { + "m_Id": "54220b1a481651f38db03e60c90c40f8" + }, + { + "m_Id": "428c2f754584503890fda944c30ff28f" + }, + { + "m_Id": "d7c11bfbd1bd52608d3a654f476cb993" + }, + { + "m_Id": "c0d964a73077504cbc5b4b2d0f8408c1" + }, + { + "m_Id": "4b32d57f8adf5f11bbc862e34fa6d0c8" + }, + { + "m_Id": "756bb71064fa5a0c8be319a413b19390" + }, + { + "m_Id": "f841f17bc1fb5cf4a403c21690a12576" + }, + { + "m_Id": "af3da35925e55b4682bf83cb054d2db9" + }, + { + "m_Id": "7b2587fa2d6f5838bcc0940478c93467" + }, + { + "m_Id": "72c929a4c9295f2dbfba96ef32f2c945" + }, + { + "m_Id": "648cdf0c4ccd56dab79e0839510f9850" + }, + { + "m_Id": "432b49b887d35d83bf88a081723b160e" + }, + { + "m_Id": "7a917aa723ee5ee4a58985dcfa8fb709" + }, + { + "m_Id": "eb3e21d0b35e5384817d0c6b2ecba57a" + }, + { + "m_Id": "312a376a2c9d5949a63495eb43da788a" + }, + { + "m_Id": "1686d3f1b0615565aa5a86324933c761" + }, + { + "m_Id": "d8f56c5855f75c5a9f96ef67e55b4205" + }, + { + "m_Id": "89a60d00f1fd5348973c297d5401ec5a" + }, + { + "m_Id": "1704bd811b1c5a35bedfb8dda5462598" + }, + { + "m_Id": "bfcc339d0cb456c987457755aabfb04d" + }, + { + "m_Id": "233c9907b34555cd80459f4a0921d1c3" + }, + { + "m_Id": "65db75acf0cc5d4f9d00d1246dc2acff" + }, + { + "m_Id": "9a23f1d133965f8c8ca9b90a7633c2a2" + }, + { + "m_Id": "f46c2964302e54489e6557a72029b007" + }, + { + "m_Id": "925bd03a2d6855e098beab765a34e9e5" + }, + { + "m_Id": "ca9058a29ffb5a5eaa1c0d8a29ef029e" + }, + { + "m_Id": "ec2fa20232095a95b7abe4dfc9a905ec" + }, + { + "m_Id": "b55bcb0aba7950528b1d1b566bd97877" + }, + { + "m_Id": "0229938f88d85671b777b9408b22ed4e" + }, + { + "m_Id": "8f121541bc7a5534828e4315a9d06bd6" + }, + { + "m_Id": "fb36717fa96f4d248390595746a431e8" + } + ], + "m_GroupDatas": [], + "m_StickyNoteDatas": [], + "m_Edges": [ + { + "m_OutputSlot": { + "m_Node": { + "m_Id": "eb08b67e7dc45dba82c148f72e5dd340" + }, + "m_SlotId": 24 + }, + "m_InputSlot": { + "m_Node": { + "m_Id": "f1c30112c2ac57528b85dd7db4a02b19" + }, + "m_SlotId": 0 + } + }, + { + "m_OutputSlot": { + "m_Node": { + "m_Id": "eb08b67e7dc45dba82c148f72e5dd340" + }, + "m_SlotId": 25 + }, + "m_InputSlot": { + "m_Node": { + "m_Id": "af8e062f44565d54b5f377c2829f4bf3" + }, + "m_SlotId": 0 + } + }, + { + "m_OutputSlot": { + "m_Node": { + "m_Id": "eb08b67e7dc45dba82c148f72e5dd340" + }, + "m_SlotId": 26 + }, + "m_InputSlot": { + "m_Node": { + "m_Id": "8b2ba8d4c746548e8f3a638475ef2a12" + }, + "m_SlotId": 0 + } + }, + { + "m_OutputSlot": { + "m_Node": { + "m_Id": "b57bfa9c48e8548695944203dd7264fa" + }, + "m_SlotId": 0 + }, + "m_InputSlot": { + "m_Node": { + "m_Id": "eb08b67e7dc45dba82c148f72e5dd340" + }, + "m_SlotId": 0 + } + }, + { + "m_OutputSlot": { + "m_Node": { + "m_Id": "77adde5a503255bc8730b099ed03c298" + }, + "m_SlotId": 0 + }, + "m_InputSlot": { + "m_Node": { + "m_Id": "eb08b67e7dc45dba82c148f72e5dd340" + }, + "m_SlotId": 1 + } + }, + { + "m_OutputSlot": { + "m_Node": { + "m_Id": "00654fd1b821567b946f725c6879acc6" + }, + "m_SlotId": 0 + }, + "m_InputSlot": { + "m_Node": { + "m_Id": "eb08b67e7dc45dba82c148f72e5dd340" + }, + "m_SlotId": 2 + } + }, + { + "m_OutputSlot": { + "m_Node": { + "m_Id": "5774dd3dc28e51158e12894808bccd68" + }, + "m_SlotId": 0 + }, + "m_InputSlot": { + "m_Node": { + "m_Id": "eb08b67e7dc45dba82c148f72e5dd340" + }, + "m_SlotId": 27 + } + }, + { + "m_OutputSlot": { + "m_Node": { + "m_Id": "181b1e1da978581b981f173bd21e7ed6" + }, + "m_SlotId": 0 + }, + "m_InputSlot": { + "m_Node": { + "m_Id": "eb08b67e7dc45dba82c148f72e5dd340" + }, + "m_SlotId": 28 + } + }, + { + "m_OutputSlot": { + "m_Node": { + "m_Id": "6d910a0a0fbc5419b374f6d76193b271" + }, + "m_SlotId": 0 + }, + "m_InputSlot": { + "m_Node": { + "m_Id": "eb08b67e7dc45dba82c148f72e5dd340" + }, + "m_SlotId": 29 + } + }, + { + "m_OutputSlot": { + "m_Node": { + "m_Id": "a7dea086444d58c09eb5c70fdac63f41" + }, + "m_SlotId": 0 + }, + "m_InputSlot": { + "m_Node": { + "m_Id": "eb08b67e7dc45dba82c148f72e5dd340" + }, + "m_SlotId": 3 + } + }, + { + "m_OutputSlot": { + "m_Node": { + "m_Id": "c74254a56fae556ab408262a4ee977ae" + }, + "m_SlotId": 0 + }, + "m_InputSlot": { + "m_Node": { + "m_Id": "eb08b67e7dc45dba82c148f72e5dd340" + }, + "m_SlotId": 4 + } + }, + { + "m_OutputSlot": { + "m_Node": { + "m_Id": "8e3e2aaf2c8558d9937179b50257456f" + }, + "m_SlotId": 0 + }, + "m_InputSlot": { + "m_Node": { + "m_Id": "eb08b67e7dc45dba82c148f72e5dd340" + }, + "m_SlotId": 5 + } + }, + { + "m_OutputSlot": { + "m_Node": { + "m_Id": "38a0027d064f5a39b3b966d8859ef5f9" + }, + "m_SlotId": 0 + }, + "m_InputSlot": { + "m_Node": { + "m_Id": "eb08b67e7dc45dba82c148f72e5dd340" + }, + "m_SlotId": 6 + } + }, + { + "m_OutputSlot": { + "m_Node": { + "m_Id": "b13762a60757541eb340dac8055f5f35" + }, + "m_SlotId": 0 + }, + "m_InputSlot": { + "m_Node": { + "m_Id": "eb08b67e7dc45dba82c148f72e5dd340" + }, + "m_SlotId": 37 + } + }, + { + "m_OutputSlot": { + "m_Node": { + "m_Id": "9254815eb565514fb64170a48b987901" + }, + "m_SlotId": 0 + }, + "m_InputSlot": { + "m_Node": { + "m_Id": "eb08b67e7dc45dba82c148f72e5dd340" + }, + "m_SlotId": 38 + } + }, + { + "m_OutputSlot": { + "m_Node": { + "m_Id": "93bfa3c99f135970a74b4e3ba8428d7d" + }, + "m_SlotId": 0 + }, + "m_InputSlot": { + "m_Node": { + "m_Id": "eb08b67e7dc45dba82c148f72e5dd340" + }, + "m_SlotId": 39 + } + }, + { + "m_OutputSlot": { + "m_Node": { + "m_Id": "8c40c59aad9650a5bbb772fbe4e38b96" + }, + "m_SlotId": 0 + }, + "m_InputSlot": { + "m_Node": { + "m_Id": "eb08b67e7dc45dba82c148f72e5dd340" + }, + "m_SlotId": 7 + } + }, + { + "m_OutputSlot": { + "m_Node": { + "m_Id": "0c02ad2153675de98a7d0fe17c4d4c4e" + }, + "m_SlotId": 0 + }, + "m_InputSlot": { + "m_Node": { + "m_Id": "eb08b67e7dc45dba82c148f72e5dd340" + }, + "m_SlotId": 8 + } + }, + { + "m_OutputSlot": { + "m_Node": { + "m_Id": "6ddcfef30bf05ecdaf4b8b68419e8db6" + }, + "m_SlotId": 0 + }, + "m_InputSlot": { + "m_Node": { + "m_Id": "eb08b67e7dc45dba82c148f72e5dd340" + }, + "m_SlotId": 9 + } + }, + { + "m_OutputSlot": { + "m_Node": { + "m_Id": "20589895c6be54ea8a175ab190d10bf6" + }, + "m_SlotId": 0 + }, + "m_InputSlot": { + "m_Node": { + "m_Id": "eb08b67e7dc45dba82c148f72e5dd340" + }, + "m_SlotId": 10 + } + }, + { + "m_OutputSlot": { + "m_Node": { + "m_Id": "bfa4def9778953faa67d6315312a64e9" + }, + "m_SlotId": 0 + }, + "m_InputSlot": { + "m_Node": { + "m_Id": "eb08b67e7dc45dba82c148f72e5dd340" + }, + "m_SlotId": 11 + } + }, + { + "m_OutputSlot": { + "m_Node": { + "m_Id": "39c18e46011b582e81b386af722fb317" + }, + "m_SlotId": 0 + }, + "m_InputSlot": { + "m_Node": { + "m_Id": "eb08b67e7dc45dba82c148f72e5dd340" + }, + "m_SlotId": 15 + } + }, + { + "m_OutputSlot": { + "m_Node": { + "m_Id": "5a54035f9b50583e9369005068a9330c" + }, + "m_SlotId": 0 + }, + "m_InputSlot": { + "m_Node": { + "m_Id": "eb08b67e7dc45dba82c148f72e5dd340" + }, + "m_SlotId": 17 + } + }, + { + "m_OutputSlot": { + "m_Node": { + "m_Id": "54220b1a481651f38db03e60c90c40f8" + }, + "m_SlotId": 0 + }, + "m_InputSlot": { + "m_Node": { + "m_Id": "eb08b67e7dc45dba82c148f72e5dd340" + }, + "m_SlotId": 18 + } + }, + { + "m_OutputSlot": { + "m_Node": { + "m_Id": "428c2f754584503890fda944c30ff28f" + }, + "m_SlotId": 0 + }, + "m_InputSlot": { + "m_Node": { + "m_Id": "eb08b67e7dc45dba82c148f72e5dd340" + }, + "m_SlotId": 19 + } + }, + { + "m_OutputSlot": { + "m_Node": { + "m_Id": "d7c11bfbd1bd52608d3a654f476cb993" + }, + "m_SlotId": 0 + }, + "m_InputSlot": { + "m_Node": { + "m_Id": "eb08b67e7dc45dba82c148f72e5dd340" + }, + "m_SlotId": 20 + } + }, + { + "m_OutputSlot": { + "m_Node": { + "m_Id": "c0d964a73077504cbc5b4b2d0f8408c1" + }, + "m_SlotId": 0 + }, + "m_InputSlot": { + "m_Node": { + "m_Id": "eb08b67e7dc45dba82c148f72e5dd340" + }, + "m_SlotId": 21 + } + }, + { + "m_OutputSlot": { + "m_Node": { + "m_Id": "4b32d57f8adf5f11bbc862e34fa6d0c8" + }, + "m_SlotId": 0 + }, + "m_InputSlot": { + "m_Node": { + "m_Id": "eb08b67e7dc45dba82c148f72e5dd340" + }, + "m_SlotId": 22 + } + }, + { + "m_OutputSlot": { + "m_Node": { + "m_Id": "756bb71064fa5a0c8be319a413b19390" + }, + "m_SlotId": 0 + }, + "m_InputSlot": { + "m_Node": { + "m_Id": "eb08b67e7dc45dba82c148f72e5dd340" + }, + "m_SlotId": 23 + } + }, + { + "m_OutputSlot": { + "m_Node": { + "m_Id": "f841f17bc1fb5cf4a403c21690a12576" + }, + "m_SlotId": 0 + }, + "m_InputSlot": { + "m_Node": { + "m_Id": "eb08b67e7dc45dba82c148f72e5dd340" + }, + "m_SlotId": 30 + } + }, + { + "m_OutputSlot": { + "m_Node": { + "m_Id": "af3da35925e55b4682bf83cb054d2db9" + }, + "m_SlotId": 0 + }, + "m_InputSlot": { + "m_Node": { + "m_Id": "eb08b67e7dc45dba82c148f72e5dd340" + }, + "m_SlotId": 31 + } + }, + { + "m_OutputSlot": { + "m_Node": { + "m_Id": "7b2587fa2d6f5838bcc0940478c93467" + }, + "m_SlotId": 0 + }, + "m_InputSlot": { + "m_Node": { + "m_Id": "eb08b67e7dc45dba82c148f72e5dd340" + }, + "m_SlotId": 33 + } + }, + { + "m_OutputSlot": { + "m_Node": { + "m_Id": "72c929a4c9295f2dbfba96ef32f2c945" + }, + "m_SlotId": 0 + }, + "m_InputSlot": { + "m_Node": { + "m_Id": "eb08b67e7dc45dba82c148f72e5dd340" + }, + "m_SlotId": 34 + } + }, + { + "m_OutputSlot": { + "m_Node": { + "m_Id": "648cdf0c4ccd56dab79e0839510f9850" + }, + "m_SlotId": 0 + }, + "m_InputSlot": { + "m_Node": { + "m_Id": "eb08b67e7dc45dba82c148f72e5dd340" + }, + "m_SlotId": 35 + } + }, + { + "m_OutputSlot": { + "m_Node": { + "m_Id": "432b49b887d35d83bf88a081723b160e" + }, + "m_SlotId": 0 + }, + "m_InputSlot": { + "m_Node": { + "m_Id": "eb08b67e7dc45dba82c148f72e5dd340" + }, + "m_SlotId": 36 + } + }, + { + "m_OutputSlot": { + "m_Node": { + "m_Id": "7a917aa723ee5ee4a58985dcfa8fb709" + }, + "m_SlotId": 0 + }, + "m_InputSlot": { + "m_Node": { + "m_Id": "eb08b67e7dc45dba82c148f72e5dd340" + }, + "m_SlotId": 43 + } + }, + { + "m_OutputSlot": { + "m_Node": { + "m_Id": "eb3e21d0b35e5384817d0c6b2ecba57a" + }, + "m_SlotId": 0 + }, + "m_InputSlot": { + "m_Node": { + "m_Id": "eb08b67e7dc45dba82c148f72e5dd340" + }, + "m_SlotId": 44 + } + }, + { + "m_OutputSlot": { + "m_Node": { + "m_Id": "312a376a2c9d5949a63495eb43da788a" + }, + "m_SlotId": 0 + }, + "m_InputSlot": { + "m_Node": { + "m_Id": "eb08b67e7dc45dba82c148f72e5dd340" + }, + "m_SlotId": 45 + } + }, + { + "m_OutputSlot": { + "m_Node": { + "m_Id": "1686d3f1b0615565aa5a86324933c761" + }, + "m_SlotId": 0 + }, + "m_InputSlot": { + "m_Node": { + "m_Id": "eb08b67e7dc45dba82c148f72e5dd340" + }, + "m_SlotId": 46 + } + }, + { + "m_OutputSlot": { + "m_Node": { + "m_Id": "d8f56c5855f75c5a9f96ef67e55b4205" + }, + "m_SlotId": 0 + }, + "m_InputSlot": { + "m_Node": { + "m_Id": "eb08b67e7dc45dba82c148f72e5dd340" + }, + "m_SlotId": 47 + } + }, + { + "m_OutputSlot": { + "m_Node": { + "m_Id": "89a60d00f1fd5348973c297d5401ec5a" + }, + "m_SlotId": 0 + }, + "m_InputSlot": { + "m_Node": { + "m_Id": "eb08b67e7dc45dba82c148f72e5dd340" + }, + "m_SlotId": 48 + } + }, + { + "m_OutputSlot": { + "m_Node": { + "m_Id": "1704bd811b1c5a35bedfb8dda5462598" + }, + "m_SlotId": 0 + }, + "m_InputSlot": { + "m_Node": { + "m_Id": "eb08b67e7dc45dba82c148f72e5dd340" + }, + "m_SlotId": 49 + } + }, + { + "m_OutputSlot": { + "m_Node": { + "m_Id": "bfcc339d0cb456c987457755aabfb04d" + }, + "m_SlotId": 0 + }, + "m_InputSlot": { + "m_Node": { + "m_Id": "eb08b67e7dc45dba82c148f72e5dd340" + }, + "m_SlotId": 50 + } + }, + { + "m_OutputSlot": { + "m_Node": { + "m_Id": "233c9907b34555cd80459f4a0921d1c3" + }, + "m_SlotId": 0 + }, + "m_InputSlot": { + "m_Node": { + "m_Id": "eb08b67e7dc45dba82c148f72e5dd340" + }, + "m_SlotId": 51 + } + }, + { + "m_OutputSlot": { + "m_Node": { + "m_Id": "65db75acf0cc5d4f9d00d1246dc2acff" + }, + "m_SlotId": 0 + }, + "m_InputSlot": { + "m_Node": { + "m_Id": "eb08b67e7dc45dba82c148f72e5dd340" + }, + "m_SlotId": 12 + } + }, + { + "m_OutputSlot": { + "m_Node": { + "m_Id": "9a23f1d133965f8c8ca9b90a7633c2a2" + }, + "m_SlotId": 0 + }, + "m_InputSlot": { + "m_Node": { + "m_Id": "eb08b67e7dc45dba82c148f72e5dd340" + }, + "m_SlotId": 32 + } + }, + { + "m_OutputSlot": { + "m_Node": { + "m_Id": "f46c2964302e54489e6557a72029b007" + }, + "m_SlotId": 0 + }, + "m_InputSlot": { + "m_Node": { + "m_Id": "eb08b67e7dc45dba82c148f72e5dd340" + }, + "m_SlotId": 13 + } + }, + { + "m_OutputSlot": { + "m_Node": { + "m_Id": "925bd03a2d6855e098beab765a34e9e5" + }, + "m_SlotId": 0 + }, + "m_InputSlot": { + "m_Node": { + "m_Id": "eb08b67e7dc45dba82c148f72e5dd340" + }, + "m_SlotId": 14 + } + }, + { + "m_OutputSlot": { + "m_Node": { + "m_Id": "ca9058a29ffb5a5eaa1c0d8a29ef029e" + }, + "m_SlotId": 0 + }, + "m_InputSlot": { + "m_Node": { + "m_Id": "eb08b67e7dc45dba82c148f72e5dd340" + }, + "m_SlotId": 16 + } + }, + { + "m_OutputSlot": { + "m_Node": { + "m_Id": "ec2fa20232095a95b7abe4dfc9a905ec" + }, + "m_SlotId": 0 + }, + "m_InputSlot": { + "m_Node": { + "m_Id": "eb08b67e7dc45dba82c148f72e5dd340" + }, + "m_SlotId": 40 + } + }, + { + "m_OutputSlot": { + "m_Node": { + "m_Id": "b55bcb0aba7950528b1d1b566bd97877" + }, + "m_SlotId": 0 + }, + "m_InputSlot": { + "m_Node": { + "m_Id": "eb08b67e7dc45dba82c148f72e5dd340" + }, + "m_SlotId": 41 + } + }, + { + "m_OutputSlot": { + "m_Node": { + "m_Id": "0229938f88d85671b777b9408b22ed4e" + }, + "m_SlotId": 0 + }, + "m_InputSlot": { + "m_Node": { + "m_Id": "eb08b67e7dc45dba82c148f72e5dd340" + }, + "m_SlotId": 42 + } + }, + { + "m_OutputSlot": { + "m_Node": { + "m_Id": "8f121541bc7a5534828e4315a9d06bd6" + }, + "m_SlotId": 0 + }, + "m_InputSlot": { + "m_Node": { + "m_Id": "90f29fe6214e5becb77e139ba2a1b2a6" + }, + "m_SlotId": 0 + } + }, + { + "m_OutputSlot": { + "m_Node": { + "m_Id": "fb36717fa96f4d248390595746a431e8" + }, + "m_SlotId": 0 + }, + "m_InputSlot": { + "m_Node": { + "m_Id": "eb08b67e7dc45dba82c148f72e5dd340" + }, + "m_SlotId": 52 + } + } + ], + "m_VertexContext": { + "m_Position": { + "x": 800, + "y": 0 + }, + "m_Blocks": [ + { + "m_Id": "8b17db6110675180842cf69d50d4be33" + }, + { + "m_Id": "1416733beee65259aa05cc935db9583f" + }, + { + "m_Id": "50981c22d63c5fe9be71f306eab3aa8b" + } + ] + }, + "m_FragmentContext": { + "m_Position": { + "x": 800, + "y": 220 + }, + "m_Blocks": [ + { + "m_Id": "f1c30112c2ac57528b85dd7db4a02b19" + }, + { + "m_Id": "af8e062f44565d54b5f377c2829f4bf3" + }, + { + "m_Id": "67ea0d7b089d573492b036727e05ce99" + }, + { + "m_Id": "90f29fe6214e5becb77e139ba2a1b2a6" + }, + { + "m_Id": "5d1129f3ab125ae794f880badfe3b76f" + }, + { + "m_Id": "8b2ba8d4c746548e8f3a638475ef2a12" + } + ] + }, + "m_PreviewData": { + "serializedMesh": { + "m_SerializedMesh": "{\"mesh\":{\"instanceID\":0}}", + "m_Guid": "" + }, + "preventRotation": false + }, + "m_Path": "Pinball", + "m_GraphPrecision": 1, + "m_PreviewMode": 2, + "m_OutputNode": { + "m_Id": "" + }, + "m_SubDatas": [], + "m_ActiveTargets": [ + { + "m_Id": "63d004576abf5dd38e98f2999bc80c7d" + }, + { + "m_Id": "8474feff245c45cbbf6d350ea7c5b6d6" + } + ] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.Rendering.HighDefinition.ShaderGraph.HDTarget", + "m_ObjectId": "63d004576abf5dd38e98f2999bc80c7d", + "m_ActiveSubTarget": { + "m_Id": "223cb5dcc04250f88082d38f029d41bd" + }, + "m_Datas": [ + { + "m_Id": "29b58c7f26f65e47888b4747a0943e90" + }, + { + "m_Id": "9551d9a4301d56e0925d60044c340292" + }, + { + "m_Id": "95181d072e69577290beadc7698e1e5f" + }, + { + "m_Id": "82a37421177050b8b4f15561181b130e" + } + ], + "m_CustomEditorGUI": "VisualPinball.Engine.Unity.Hdrp.Editor.InsertHybridGUI", + "m_SupportVFX": false, + "m_SupportLineRendering": false +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.Rendering.HighDefinition.ShaderGraph.HDLitSubTarget", + "m_ObjectId": "223cb5dcc04250f88082d38f029d41bd" +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.Rendering.HighDefinition.ShaderGraph.SystemData", + "m_ObjectId": "29b58c7f26f65e47888b4747a0943e90", + "m_MaterialNeedsUpdateHash": 0, + "m_SurfaceType": 0, + "m_RenderingPass": 1, + "m_BlendMode": 0, + "m_ZTest": 4, + "m_ZWrite": false, + "m_TransparentCullMode": 2, + "m_OpaqueCullMode": 2, + "m_SortPriority": 0, + "m_AlphaTest": false, + "m_ExcludeFromTUAndAA": false, + "m_TransparentDepthPrepass": false, + "m_TransparentDepthPostpass": false, + "m_SupportLodCrossFade": false, + "m_DoubleSidedMode": 0, + "m_DOTSInstancing": false, + "m_CustomVelocity": false, + "m_Tessellation": false, + "m_TessellationMode": 0, + "m_TessellationFactorMinDistance": 20.0, + "m_TessellationFactorMaxDistance": 50.0, + "m_TessellationFactorTriangleSize": 100.0, + "m_TessellationShapeFactor": 0.75, + "m_TessellationBackFaceCullEpsilon": -0.25, + "m_TessellationMaxDisplacement": 0.009999999776482582, + "m_DebugSymbols": false, + "m_Version": 2, + "inspectorFoldoutMask": 9 +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.Rendering.HighDefinition.ShaderGraph.BuiltinData", + "m_ObjectId": "9551d9a4301d56e0925d60044c340292", + "m_Distortion": false, + "m_DistortionMode": 0, + "m_DistortionDepthTest": true, + "m_AddPrecomputedVelocity": false, + "m_TransparentWritesMotionVec": false, + "m_DepthOffset": false, + "m_ConservativeDepthOffset": false, + "m_TransparencyFog": true, + "m_AlphaTestShadow": false, + "m_BackThenFrontRendering": false, + "m_TransparentDepthPrepass": false, + "m_TransparentDepthPostpass": false, + "m_TransparentPerPixelSorting": false, + "m_SupportLodCrossFade": false +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.Rendering.HighDefinition.ShaderGraph.LightingData", + "m_ObjectId": "95181d072e69577290beadc7698e1e5f", + "m_NormalDropOffSpace": 0, + "m_BlendPreserveSpecular": true, + "m_ReceiveDecals": true, + "m_ReceiveSSR": true, + "m_ReceiveSSRTransparent": false, + "m_SpecularAA": true, + "m_SpecularOcclusionMode": 1, + "m_OverrideBakedGI": false +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.Rendering.HighDefinition.ShaderGraph.HDLitData", + "m_ObjectId": "82a37421177050b8b4f15561181b130e", + "m_RayTracing": false, + "m_MaterialType": 0, + "m_MaterialTypeMask": 2, + "m_RefractionModel": 0, + "m_SSSTransmission": true, + "m_EnergyConservingSpecular": true, + "m_ClearCoat": false +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.BlockNode", + "m_ObjectId": "8b17db6110675180842cf69d50d4be33", + "m_Group": { + "m_Id": "" + }, + "m_Name": "VertexDescription.Position", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": 0.0, + "y": 0.0, + "width": 0.0, + "height": 0.0 + } + }, + "m_Slots": [ + { + "m_Id": "c9ace2bb1e7654bab64d83894aad7a58" + } + ], + "synonyms": [], + "m_Precision": 0, + "m_PreviewExpanded": true, + "m_DismissedVersion": 0, + "m_PreviewMode": 0, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_SerializedDescriptor": "VertexDescription.Position" +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.PositionMaterialSlot", + "m_ObjectId": "c9ace2bb1e7654bab64d83894aad7a58", + "m_Id": 0, + "m_DisplayName": "Position", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "Position", + "m_StageCapability": 1, + "m_Value": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "m_DefaultValue": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "m_Labels": [], + "m_Space": 0 +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.BlockNode", + "m_ObjectId": "1416733beee65259aa05cc935db9583f", + "m_Group": { + "m_Id": "" + }, + "m_Name": "VertexDescription.Normal", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": 0.0, + "y": 0.0, + "width": 0.0, + "height": 0.0 + } + }, + "m_Slots": [ + { + "m_Id": "b97acd89ebd25297b33ae7ba020ab828" + } + ], + "synonyms": [], + "m_Precision": 0, + "m_PreviewExpanded": true, + "m_DismissedVersion": 0, + "m_PreviewMode": 0, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_SerializedDescriptor": "VertexDescription.Normal" +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.NormalMaterialSlot", + "m_ObjectId": "b97acd89ebd25297b33ae7ba020ab828", + "m_Id": 0, + "m_DisplayName": "Normal", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "Normal", + "m_StageCapability": 1, + "m_Value": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "m_DefaultValue": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "m_Labels": [], + "m_Space": 0 +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.BlockNode", + "m_ObjectId": "50981c22d63c5fe9be71f306eab3aa8b", + "m_Group": { + "m_Id": "" + }, + "m_Name": "VertexDescription.Tangent", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": 0.0, + "y": 0.0, + "width": 0.0, + "height": 0.0 + } + }, + "m_Slots": [ + { + "m_Id": "904e58126e345a33bafe9263d0c27e37" + } + ], + "synonyms": [], + "m_Precision": 0, + "m_PreviewExpanded": true, + "m_DismissedVersion": 0, + "m_PreviewMode": 0, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_SerializedDescriptor": "VertexDescription.Tangent" +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.TangentMaterialSlot", + "m_ObjectId": "904e58126e345a33bafe9263d0c27e37", + "m_Id": 0, + "m_DisplayName": "Tangent", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "Tangent", + "m_StageCapability": 1, + "m_Value": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "m_DefaultValue": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "m_Labels": [], + "m_Space": 0 +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.BlockNode", + "m_ObjectId": "f1c30112c2ac57528b85dd7db4a02b19", + "m_Group": { + "m_Id": "" + }, + "m_Name": "SurfaceDescription.BaseColor", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": -44.999969482421875, + "y": 155.49998474121094, + "width": 199.99998474121094, + "height": 41.00001525878906 + } + }, + "m_Slots": [ + { + "m_Id": "4a335b0d1d3853fd9166fb865d9b13f3" + } + ], + "synonyms": [], + "m_Precision": 0, + "m_PreviewExpanded": true, + "m_DismissedVersion": 0, + "m_PreviewMode": 0, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_SerializedDescriptor": "SurfaceDescription.BaseColor" +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.ColorRGBMaterialSlot", + "m_ObjectId": "4a335b0d1d3853fd9166fb865d9b13f3", + "m_Id": 0, + "m_DisplayName": "Base Color", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "BaseColor", + "m_StageCapability": 2, + "m_Value": { + "x": 0.5, + "y": 0.5, + "z": 0.5 + }, + "m_DefaultValue": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "m_Labels": [], + "m_ColorMode": 0, + "m_DefaultColor": { + "r": 0.5, + "g": 0.5, + "b": 0.5, + "a": 1.0 + } +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.BlockNode", + "m_ObjectId": "af8e062f44565d54b5f377c2829f4bf3", + "m_Group": { + "m_Id": "" + }, + "m_Name": "SurfaceDescription.NormalTS", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": -46.499935150146484, + "y": 466.0, + "width": 199.99990844726562, + "height": 40.99993896484375 + } + }, + "m_Slots": [ + { + "m_Id": "e8aedce237ae5cb5a3ffd1b2adeab1aa" + } + ], + "synonyms": [], + "m_Precision": 0, + "m_PreviewExpanded": true, + "m_DismissedVersion": 0, + "m_PreviewMode": 0, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_SerializedDescriptor": "SurfaceDescription.NormalTS" +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.NormalMaterialSlot", + "m_ObjectId": "e8aedce237ae5cb5a3ffd1b2adeab1aa", + "m_Id": 0, + "m_DisplayName": "Normal (Tangent Space)", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "NormalTS", + "m_StageCapability": 2, + "m_Value": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "m_DefaultValue": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "m_Labels": [], + "m_Space": 3 +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.BlockNode", + "m_ObjectId": "67ea0d7b089d573492b036727e05ce99", + "m_Group": { + "m_Id": "" + }, + "m_Name": "SurfaceDescription.Metallic", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": -43.499900817871094, + "y": 278.5000305175781, + "width": 199.99996948242188, + "height": 41.0 + } + }, + "m_Slots": [ + { + "m_Id": "67442a39fa4f5645b4b268d8a583d568" + } + ], + "synonyms": [], + "m_Precision": 0, + "m_PreviewExpanded": true, + "m_DismissedVersion": 0, + "m_PreviewMode": 0, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_SerializedDescriptor": "SurfaceDescription.Metallic" +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "67442a39fa4f5645b4b268d8a583d568", + "m_Id": 0, + "m_DisplayName": "Metallic", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "Metallic", + "m_StageCapability": 2, + "m_Value": 0, + "m_DefaultValue": 0.0, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.BlockNode", + "m_ObjectId": "90f29fe6214e5becb77e139ba2a1b2a6", + "m_Group": { + "m_Id": "" + }, + "m_Name": "SurfaceDescription.Smoothness", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": -44.0000114440918, + "y": 317.4999694824219, + "width": 200.0000457763672, + "height": 41.0 + } + }, + "m_Slots": [ + { + "m_Id": "b0737480c53c51b29470d18b137fbfd3" + } + ], + "synonyms": [], + "m_Precision": 0, + "m_PreviewExpanded": true, + "m_DismissedVersion": 0, + "m_PreviewMode": 0, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_SerializedDescriptor": "SurfaceDescription.Smoothness" +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "b0737480c53c51b29470d18b137fbfd3", + "m_Id": 0, + "m_DisplayName": "Smoothness", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "Smoothness", + "m_StageCapability": 2, + "m_Value": 0.5, + "m_DefaultValue": 0.5, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.BlockNode", + "m_ObjectId": "5d1129f3ab125ae794f880badfe3b76f", + "m_Group": { + "m_Id": "" + }, + "m_Name": "SurfaceDescription.Occlusion", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": -47.499977111816406, + "y": 280.5000305175781, + "width": 199.99996948242188, + "height": 41.0 + } + }, + "m_Slots": [ + { + "m_Id": "9de962b7e65e5b74b848794e12d3c95b" + } + ], + "synonyms": [], + "m_Precision": 0, + "m_PreviewExpanded": true, + "m_DismissedVersion": 0, + "m_PreviewMode": 0, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_SerializedDescriptor": "SurfaceDescription.Occlusion" +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "9de962b7e65e5b74b848794e12d3c95b", + "m_Id": 0, + "m_DisplayName": "Ambient Occlusion", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "Occlusion", + "m_StageCapability": 2, + "m_Value": 1, + "m_DefaultValue": 1.0, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.BlockNode", + "m_ObjectId": "8b2ba8d4c746548e8f3a638475ef2a12", + "m_Group": { + "m_Id": "" + }, + "m_Name": "SurfaceDescription.Emission", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": -44.500038146972656, + "y": 101.99987030029297, + "width": 200.00003051757812, + "height": 41.00000762939453 + } + }, + "m_Slots": [ + { + "m_Id": "dd66ff01568955279b360352bb9849ff" + } + ], + "synonyms": [], + "m_Precision": 0, + "m_PreviewExpanded": true, + "m_DismissedVersion": 0, + "m_PreviewMode": 0, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_SerializedDescriptor": "SurfaceDescription.Emission" +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.ColorRGBMaterialSlot", + "m_ObjectId": "dd66ff01568955279b360352bb9849ff", + "m_Id": 0, + "m_DisplayName": "Emission", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "Emission", + "m_StageCapability": 2, + "m_Value": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "m_DefaultValue": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "m_Labels": [], + "m_ColorMode": 1, + "m_DefaultColor": { + "r": 0.0, + "g": 0.0, + "b": 0.0, + "a": 1.0 + } +} + +{ + "m_SGVersion": 1, + "m_Type": "UnityEditor.ShaderGraph.CustomFunctionNode", + "m_ObjectId": "eb08b67e7dc45dba82c148f72e5dd340", + "m_Group": { + "m_Id": "" + }, + "m_Name": "Insert Optics", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": 220, + "y": 120, + "width": 210, + "height": 100 + } + }, + "m_Slots": [ + { + "m_Id": "349cc9eabecd599382ebbd378fd29254" + }, + { + "m_Id": "096ed94099fc537db0950acfee5c159c" + }, + { + "m_Id": "b55f348e6e355bcfb4066fc0d91e43d9" + }, + { + "m_Id": "f040780cb64955a181c7ffc5b38eecb2" + }, + { + "m_Id": "77573de2de1752d68777f9e0c2b26566" + }, + { + "m_Id": "c7236a0a71fe57949a649c4c9497ddff" + }, + { + "m_Id": "6e5d42d4ccce58e5beeacb9b93b18748" + }, + { + "m_Id": "15efee64218d5653af847cefc074d692" + }, + { + "m_Id": "4a5addb296ea5b01a6048162e5217e4d" + }, + { + "m_Id": "4023118b3fcf569fa105156112335b24" + }, + { + "m_Id": "4c64b263fb8754e78d4471667f5e8304" + }, + { + "m_Id": "6cefc178a91a59cab55582de0e4892ac" + }, + { + "m_Id": "16bd98decd925116bc5e4036a06393c3" + }, + { + "m_Id": "0e17e141e860535b81c1296572c12df5" + }, + { + "m_Id": "73ee8fe32066573592f1c3c442857811" + }, + { + "m_Id": "adcb191defa959dd97dea4af352f6026" + }, + { + "m_Id": "18be98d47193584787b1ce14d9eb7114" + }, + { + "m_Id": "1fd93a8c1dcb5c0b960ceca73e3f7f0c" + }, + { + "m_Id": "c3fb31d333dd5e808328175c4f8212c0" + }, + { + "m_Id": "47c0ca35524656509a569372529826b3" + }, + { + "m_Id": "e5dd52e5322e5a5d8ca79c158847b461" + }, + { + "m_Id": "f79854d76c145df2a10d15668a6ca8e0" + }, + { + "m_Id": "4cef7078b6ac55a2982dcc2f99a7e097" + }, + { + "m_Id": "dd0e89a840515532b74099f83591ec6b" + }, + { + "m_Id": "788e350cd48a522dac618260257c6c61" + }, + { + "m_Id": "83eb7fbb69125ceba13c99a182f87d93" + }, + { + "m_Id": "a349eff288675fc787ee780e1dd41801" + }, + { + "m_Id": "3fcc5202d8215989be23e4e51389c428" + }, + { + "m_Id": "b7c11fad70935da7864950676628f85d" + }, + { + "m_Id": "3ad72e845172537ea7d4fabb056d443e" + }, + { + "m_Id": "2e2e8e16613550f0b4f0d0dfd6831c4e" + }, + { + "m_Id": "933ea600871e594e850ab4134cb03c62" + }, + { + "m_Id": "b16effb8ba9952f59c7d35132d74ea6c" + }, + { + "m_Id": "b49d4801febf551c987ad69f01725288" + }, + { + "m_Id": "42b04fc04b4355658365cfbb1e55b879" + }, + { + "m_Id": "084993f5ee235d8daaf5c0270f42b15d" + }, + { + "m_Id": "06fbe54e2af85e9991a3834390f754fe" + }, + { + "m_Id": "accddb20717d5d878f3f50bed0b770c6" + }, + { + "m_Id": "9f2d58bb59665fb2bc695fc7cace5e75" + }, + { + "m_Id": "b14f1734cadd509898bde8cbc5e27073" + }, + { + "m_Id": "a1db37c3ce6f58708f531cf5de57ba59" + }, + { + "m_Id": "67b199d1b7765fd58deab824c473b2e9" + }, + { + "m_Id": "d6daaf0dadaa5c3c828d0fefc3a8702f" + }, + { + "m_Id": "fc726896899e55d4b9012d09d870eaff" + }, + { + "m_Id": "a98ee0b5e24d5158a27aec5d9b491f1c" + }, + { + "m_Id": "faf7421bd7e95ad19e3d01066c85ecd4" + }, + { + "m_Id": "c2396ef7b4ba5cd3956efdc91cdfaa9e" + }, + { + "m_Id": "c95f5cc10bd453cc9048168e06355c6e" + }, + { + "m_Id": "dc667998690b5cfca746930e9b4159de" + }, + { + "m_Id": "a0c74ffb1cec5401a4a0e5e8ae24db9a" + }, + { + "m_Id": "cb40e5e58af55278ac83f6a9b40e8b06" + }, + { + "m_Id": "68eca89dea7d51c5b76294121dfa66bb" + }, + { + "m_Id": "7d8273afe0e04afeb102e010845dea8b" + } + ], + "synonyms": [ + "code", + "HLSL" + ], + "m_Precision": 1, + "m_PreviewExpanded": false, + "m_DismissedVersion": 0, + "m_PreviewMode": 0, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_SourceType": 0, + "m_FunctionName": "InsertOptics", + "m_FunctionSource": "a8e1d31204aa95b40a3e523f0b2b266a", + "m_FunctionBody": "" +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector3MaterialSlot", + "m_ObjectId": "349cc9eabecd599382ebbd378fd29254", + "m_Id": 0, + "m_DisplayName": "Position", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "Position", + "m_StageCapability": 3, + "m_Value": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "m_DefaultValue": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector3MaterialSlot", + "m_ObjectId": "096ed94099fc537db0950acfee5c159c", + "m_Id": 1, + "m_DisplayName": "View", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "View", + "m_StageCapability": 3, + "m_Value": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "m_DefaultValue": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector2MaterialSlot", + "m_ObjectId": "b55f348e6e355bcfb4066fc0d91e43d9", + "m_Id": 2, + "m_DisplayName": "UV", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "UV", + "m_StageCapability": 3, + "m_Value": { + "x": 0.0, + "y": 0.0 + }, + "m_DefaultValue": { + "x": 0.0, + "y": 0.0 + }, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Texture2DInputMaterialSlot", + "m_ObjectId": "f040780cb64955a181c7ffc5b38eecb2", + "m_Id": 3, + "m_DisplayName": "OpticalNormal", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "OpticalNormal", + "m_StageCapability": 3, + "m_BareResource": false, + "m_Texture": { + "m_SerializedTexture": "", + "m_Guid": "" + }, + "m_DefaultType": 0 +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Texture2DInputMaterialSlot", + "m_ObjectId": "77573de2de1752d68777f9e0c2b26566", + "m_Id": 4, + "m_DisplayName": "Response", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "Response", + "m_StageCapability": 3, + "m_BareResource": false, + "m_Texture": { + "m_SerializedTexture": "", + "m_Guid": "" + }, + "m_DefaultType": 0 +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Texture2DInputMaterialSlot", + "m_ObjectId": "c7236a0a71fe57949a649c4c9497ddff", + "m_Id": 5, + "m_DisplayName": "SurfaceNormal", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "SurfaceNormal", + "m_StageCapability": 3, + "m_BareResource": false, + "m_Texture": { + "m_SerializedTexture": "", + "m_Guid": "" + }, + "m_DefaultType": 0 +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector4MaterialSlot", + "m_ObjectId": "6e5d42d4ccce58e5beeacb9b93b18748", + "m_Id": 6, + "m_DisplayName": "Lamp", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "Lamp", + "m_StageCapability": 3, + "m_Value": { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 0.0 + }, + "m_DefaultValue": { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 0.0 + }, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "15efee64218d5653af847cefc074d692", + "m_Id": 7, + "m_DisplayName": "Ior", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "Ior", + "m_StageCapability": 3, + "m_Value": 0.0, + "m_DefaultValue": 0.0, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "4a5addb296ea5b01a6048162e5217e4d", + "m_Id": 8, + "m_DisplayName": "Scatter", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "Scatter", + "m_StageCapability": 3, + "m_Value": 0.0, + "m_DefaultValue": 0.0, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "4023118b3fcf569fa105156112335b24", + "m_Id": 9, + "m_DisplayName": "EdgeGlow", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "EdgeGlow", + "m_StageCapability": 3, + "m_Value": 0.0, + "m_DefaultValue": 0.0, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "4c64b263fb8754e78d4471667f5e8304", + "m_Id": 10, + "m_DisplayName": "LampGain", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "LampGain", + "m_StageCapability": 3, + "m_Value": 0.0, + "m_DefaultValue": 0.0, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "6cefc178a91a59cab55582de0e4892ac", + "m_Id": 11, + "m_DisplayName": "NormalStrength", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "NormalStrength", + "m_StageCapability": 3, + "m_Value": 0.0, + "m_DefaultValue": 0.0, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector3MaterialSlot", + "m_ObjectId": "16bd98decd925116bc5e4036a06393c3", + "m_Id": 12, + "m_DisplayName": "Tint", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "Tint", + "m_StageCapability": 3, + "m_Value": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "m_DefaultValue": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector3MaterialSlot", + "m_ObjectId": "0e17e141e860535b81c1296572c12df5", + "m_Id": 13, + "m_DisplayName": "LampColor", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "LampColor", + "m_StageCapability": 3, + "m_Value": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "m_DefaultValue": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector3MaterialSlot", + "m_ObjectId": "73ee8fe32066573592f1c3c442857811", + "m_Id": 14, + "m_DisplayName": "DriveColor", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "DriveColor", + "m_StageCapability": 3, + "m_Value": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "m_DefaultValue": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "adcb191defa959dd97dea4af352f6026", + "m_Id": 15, + "m_DisplayName": "Level", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "Level", + "m_StageCapability": 3, + "m_Value": 0.0, + "m_DefaultValue": 0.0, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector4MaterialSlot", + "m_ObjectId": "18be98d47193584787b1ce14d9eb7114", + "m_Id": 16, + "m_DisplayName": "Starburst", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "Starburst", + "m_StageCapability": 3, + "m_Value": { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 0.0 + }, + "m_DefaultValue": { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 0.0 + }, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "1fd93a8c1dcb5c0b960ceca73e3f7f0c", + "m_Id": 17, + "m_DisplayName": "Contrast", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "Contrast", + "m_StageCapability": 3, + "m_Value": 0.0, + "m_DefaultValue": 0.0, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "c3fb31d333dd5e808328175c4f8212c0", + "m_Id": 18, + "m_DisplayName": "Variation", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "Variation", + "m_StageCapability": 3, + "m_Value": 0.0, + "m_DefaultValue": 0.0, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "47c0ca35524656509a569372529826b3", + "m_Id": 19, + "m_DisplayName": "Seed", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "Seed", + "m_StageCapability": 3, + "m_Value": 0.0, + "m_DefaultValue": 0.0, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "e5dd52e5322e5a5d8ca79c158847b461", + "m_Id": 20, + "m_DisplayName": "Brightness", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "Brightness", + "m_StageCapability": 3, + "m_Value": 0.0, + "m_DefaultValue": 0.0, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "f79854d76c145df2a10d15668a6ca8e0", + "m_Id": 21, + "m_DisplayName": "MottleSize", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "MottleSize", + "m_StageCapability": 3, + "m_Value": 0.0, + "m_DefaultValue": 0.0, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "4cef7078b6ac55a2982dcc2f99a7e097", + "m_Id": 22, + "m_DisplayName": "Grain", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "Grain", + "m_StageCapability": 3, + "m_Value": 0.0, + "m_DefaultValue": 0.0, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "dd0e89a840515532b74099f83591ec6b", + "m_Id": 23, + "m_DisplayName": "MinBrightness", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "MinBrightness", + "m_StageCapability": 3, + "m_Value": 0.0, + "m_DefaultValue": 0.0, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector3MaterialSlot", + "m_ObjectId": "788e350cd48a522dac618260257c6c61", + "m_Id": 24, + "m_DisplayName": "BaseColor", + "m_SlotType": 1, + "m_Hidden": false, + "m_ShaderOutputName": "BaseColor", + "m_StageCapability": 3, + "m_Value": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "m_DefaultValue": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector3MaterialSlot", + "m_ObjectId": "83eb7fbb69125ceba13c99a182f87d93", + "m_Id": 25, + "m_DisplayName": "NormalTS", + "m_SlotType": 1, + "m_Hidden": false, + "m_ShaderOutputName": "NormalTS", + "m_StageCapability": 3, + "m_Value": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "m_DefaultValue": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector3MaterialSlot", + "m_ObjectId": "a349eff288675fc787ee780e1dd41801", + "m_Id": 26, + "m_DisplayName": "Emission", + "m_SlotType": 1, + "m_Hidden": false, + "m_ShaderOutputName": "Emission", + "m_StageCapability": 3, + "m_Value": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "m_DefaultValue": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector3MaterialSlot", + "m_ObjectId": "3fcc5202d8215989be23e4e51389c428", + "m_Id": 27, + "m_DisplayName": "WorldNormal", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "WorldNormal", + "m_StageCapability": 3, + "m_Value": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "m_DefaultValue": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector3MaterialSlot", + "m_ObjectId": "b7c11fad70935da7864950676628f85d", + "m_Id": 28, + "m_DisplayName": "WorldTangent", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "WorldTangent", + "m_StageCapability": 3, + "m_Value": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "m_DefaultValue": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector3MaterialSlot", + "m_ObjectId": "3ad72e845172537ea7d4fabb056d443e", + "m_Id": 29, + "m_DisplayName": "WorldBitangent", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "WorldBitangent", + "m_StageCapability": 3, + "m_Value": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "m_DefaultValue": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "2e2e8e16613550f0b4f0d0dfd6831c4e", + "m_Id": 30, + "m_DisplayName": "Flatten", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "Flatten", + "m_StageCapability": 3, + "m_Value": 0.0, + "m_DefaultValue": 0.0, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "933ea600871e594e850ab4134cb03c62", + "m_Id": 31, + "m_DisplayName": "Clarity", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "Clarity", + "m_StageCapability": 3, + "m_Value": 0.0, + "m_DefaultValue": 0.0, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector3MaterialSlot", + "m_ObjectId": "b16effb8ba9952f59c7d35132d74ea6c", + "m_Id": 32, + "m_DisplayName": "CavityColor", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "CavityColor", + "m_StageCapability": 3, + "m_Value": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "m_DefaultValue": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "b49d4801febf551c987ad69f01725288", + "m_Id": 33, + "m_DisplayName": "CavitySpread", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "CavitySpread", + "m_StageCapability": 3, + "m_Value": 0.0, + "m_DefaultValue": 0.0, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "42b04fc04b4355658365cfbb1e55b879", + "m_Id": 34, + "m_DisplayName": "CavityContrast", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "CavityContrast", + "m_StageCapability": 3, + "m_Value": 0.0, + "m_DefaultValue": 0.0, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "084993f5ee235d8daaf5c0270f42b15d", + "m_Id": 35, + "m_DisplayName": "LampTint", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "LampTint", + "m_StageCapability": 3, + "m_Value": 0.0, + "m_DefaultValue": 0.0, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "06fbe54e2af85e9991a3834390f754fe", + "m_Id": 36, + "m_DisplayName": "Spread", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "Spread", + "m_StageCapability": 3, + "m_Value": 0.0, + "m_DefaultValue": 0.0, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector4MaterialSlot", + "m_ObjectId": "accddb20717d5d878f3f50bed0b770c6", + "m_Id": 37, + "m_DisplayName": "LampB", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "LampB", + "m_StageCapability": 3, + "m_Value": { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 0.0 + }, + "m_DefaultValue": { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 0.0 + }, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector4MaterialSlot", + "m_ObjectId": "9f2d58bb59665fb2bc695fc7cace5e75", + "m_Id": 38, + "m_DisplayName": "LampC", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "LampC", + "m_StageCapability": 3, + "m_Value": { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 0.0 + }, + "m_DefaultValue": { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 0.0 + }, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector4MaterialSlot", + "m_ObjectId": "b14f1734cadd509898bde8cbc5e27073", + "m_Id": 39, + "m_DisplayName": "LampD", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "LampD", + "m_StageCapability": 3, + "m_Value": { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 0.0 + }, + "m_DefaultValue": { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 0.0 + }, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector4MaterialSlot", + "m_ObjectId": "a1db37c3ce6f58708f531cf5de57ba59", + "m_Id": 40, + "m_DisplayName": "DriveB", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "DriveB", + "m_StageCapability": 3, + "m_Value": { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 0.0 + }, + "m_DefaultValue": { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 0.0 + }, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector4MaterialSlot", + "m_ObjectId": "67b199d1b7765fd58deab824c473b2e9", + "m_Id": 41, + "m_DisplayName": "DriveC", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "DriveC", + "m_StageCapability": 3, + "m_Value": { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 0.0 + }, + "m_DefaultValue": { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 0.0 + }, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector4MaterialSlot", + "m_ObjectId": "d6daaf0dadaa5c3c828d0fefc3a8702f", + "m_Id": 42, + "m_DisplayName": "DriveD", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "DriveD", + "m_StageCapability": 3, + "m_Value": { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 0.0 + }, + "m_DefaultValue": { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 0.0 + }, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "fc726896899e55d4b9012d09d870eaff", + "m_Id": 43, + "m_DisplayName": "SurfaceGrain", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "SurfaceGrain", + "m_StageCapability": 3, + "m_Value": 0.0, + "m_DefaultValue": 0.0, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "a98ee0b5e24d5158a27aec5d9b491f1c", + "m_Id": 44, + "m_DisplayName": "SurfaceGrainSize", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "SurfaceGrainSize", + "m_StageCapability": 3, + "m_Value": 0.0, + "m_DefaultValue": 0.0, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "faf7421bd7e95ad19e3d01066c85ecd4", + "m_Id": 45, + "m_DisplayName": "Output", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "Output", + "m_StageCapability": 3, + "m_Value": 0.0, + "m_DefaultValue": 0.0, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "c2396ef7b4ba5cd3956efdc91cdfaa9e", + "m_Id": 46, + "m_DisplayName": "Roughness", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "Roughness", + "m_StageCapability": 3, + "m_Value": 0.0, + "m_DefaultValue": 0.0, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "c95f5cc10bd453cc9048168e06355c6e", + "m_Id": 47, + "m_DisplayName": "Flecks", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "Flecks", + "m_StageCapability": 3, + "m_Value": 0.0, + "m_DefaultValue": 0.0, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "dc667998690b5cfca746930e9b4159de", + "m_Id": 48, + "m_DisplayName": "FleckSize", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "FleckSize", + "m_StageCapability": 3, + "m_Value": 0.0, + "m_DefaultValue": 0.0, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "a0c74ffb1cec5401a4a0e5e8ae24db9a", + "m_Id": 49, + "m_DisplayName": "RoomReturn", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "RoomReturn", + "m_StageCapability": 3, + "m_Value": 0.0, + "m_DefaultValue": 0.0, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "cb40e5e58af55278ac83f6a9b40e8b06", + "m_Id": 50, + "m_DisplayName": "RoomBlur", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "RoomBlur", + "m_StageCapability": 3, + "m_Value": 0.0, + "m_DefaultValue": 0.0, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "68eca89dea7d51c5b76294121dfa66bb", + "m_Id": 51, + "m_DisplayName": "RoomReturnEV", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "RoomReturnEV", + "m_StageCapability": 3, + "m_Value": 0.0, + "m_DefaultValue": 0.0, + "m_Labels": [] +} + +{ + "m_SGVersion": 1, + "m_Type": "UnityEditor.ShaderGraph.PositionNode", + "m_ObjectId": "b57bfa9c48e8548695944203dd7264fa", + "m_Group": { + "m_Id": "" + }, + "m_Name": "Position", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": -100, + "y": 0, + "width": 210, + "height": 100 + } + }, + "m_Slots": [ + { + "m_Id": "58635655d4eb5c8e9e085a4a0d57cfb6" + } + ], + "synonyms": [ + "location" + ], + "m_Precision": 1, + "m_PreviewExpanded": false, + "m_DismissedVersion": 0, + "m_PreviewMode": 2, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_Space": 0, + "m_PositionSource": 0 +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector3MaterialSlot", + "m_ObjectId": "58635655d4eb5c8e9e085a4a0d57cfb6", + "m_Id": 0, + "m_DisplayName": "Out", + "m_SlotType": 1, + "m_Hidden": false, + "m_ShaderOutputName": "Out", + "m_StageCapability": 3, + "m_Value": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "m_DefaultValue": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "m_Labels": [] +} + +{ + "m_SGVersion": 1, + "m_Type": "UnityEditor.ShaderGraph.ViewDirectionNode", + "m_ObjectId": "77adde5a503255bc8730b099ed03c298", + "m_Group": { + "m_Id": "" + }, + "m_Name": "View Direction", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": -100, + "y": 160, + "width": 210, + "height": 100 + } + }, + "m_Slots": [ + { + "m_Id": "8f7b534471a85ac2adb98e42bec8e9d5" + } + ], + "synonyms": [ + "eye direction" + ], + "m_Precision": 1, + "m_PreviewExpanded": false, + "m_DismissedVersion": 0, + "m_PreviewMode": 2, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_Space": 0 +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector3MaterialSlot", + "m_ObjectId": "8f7b534471a85ac2adb98e42bec8e9d5", + "m_Id": 0, + "m_DisplayName": "Out", + "m_SlotType": 1, + "m_Hidden": false, + "m_ShaderOutputName": "Out", + "m_StageCapability": 3, + "m_Value": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "m_DefaultValue": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.UVNode", + "m_ObjectId": "00654fd1b821567b946f725c6879acc6", + "m_Group": { + "m_Id": "" + }, + "m_Name": "UV", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": -100, + "y": 320, + "width": 210, + "height": 100 + } + }, + "m_Slots": [ + { + "m_Id": "f6ab1af3f9955912bbe1a9084aa04c89" + } + ], + "synonyms": [ + "texcoords", + "coords", + "coordinates" + ], + "m_Precision": 1, + "m_PreviewExpanded": false, + "m_DismissedVersion": 0, + "m_PreviewMode": 0, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_OutputChannel": 0 +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector4MaterialSlot", + "m_ObjectId": "f6ab1af3f9955912bbe1a9084aa04c89", + "m_Id": 0, + "m_DisplayName": "Out", + "m_SlotType": 1, + "m_Hidden": false, + "m_ShaderOutputName": "Out", + "m_StageCapability": 3, + "m_Value": { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 0.0 + }, + "m_DefaultValue": { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 0.0 + }, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.NormalVectorNode", + "m_ObjectId": "5774dd3dc28e51158e12894808bccd68", + "m_Group": { + "m_Id": "" + }, + "m_Name": "Normal Vector", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": -100, + "y": 4320, + "width": 210, + "height": 100 + } + }, + "m_Slots": [ + { + "m_Id": "c0f1d72859b7571e9556ff4a690cfe3f" + } + ], + "synonyms": [ + "surface direction" + ], + "m_Precision": 1, + "m_PreviewExpanded": false, + "m_DismissedVersion": 0, + "m_PreviewMode": 2, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_Space": 2 +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector3MaterialSlot", + "m_ObjectId": "c0f1d72859b7571e9556ff4a690cfe3f", + "m_Id": 0, + "m_DisplayName": "Out", + "m_SlotType": 1, + "m_Hidden": false, + "m_ShaderOutputName": "Out", + "m_StageCapability": 3, + "m_Value": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "m_DefaultValue": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.TangentVectorNode", + "m_ObjectId": "181b1e1da978581b981f173bd21e7ed6", + "m_Group": { + "m_Id": "" + }, + "m_Name": "Tangent Vector", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": -100, + "y": 4480, + "width": 210, + "height": 100 + } + }, + "m_Slots": [ + { + "m_Id": "55ed95ab62f55e63bb7e9b9de55d3a61" + } + ], + "synonyms": [], + "m_Precision": 1, + "m_PreviewExpanded": false, + "m_DismissedVersion": 0, + "m_PreviewMode": 2, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_Space": 2 +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector3MaterialSlot", + "m_ObjectId": "55ed95ab62f55e63bb7e9b9de55d3a61", + "m_Id": 0, + "m_DisplayName": "Out", + "m_SlotType": 1, + "m_Hidden": false, + "m_ShaderOutputName": "Out", + "m_StageCapability": 3, + "m_Value": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "m_DefaultValue": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.BitangentVectorNode", + "m_ObjectId": "6d910a0a0fbc5419b374f6d76193b271", + "m_Group": { + "m_Id": "" + }, + "m_Name": "Bitangent Vector", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": -100, + "y": 4640, + "width": 210, + "height": 100 + } + }, + "m_Slots": [ + { + "m_Id": "9bd6a301a28651d989efc3302ced0339" + } + ], + "synonyms": [ + "binormal" + ], + "m_Precision": 1, + "m_PreviewExpanded": false, + "m_DismissedVersion": 0, + "m_PreviewMode": 2, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_Space": 2 +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector3MaterialSlot", + "m_ObjectId": "9bd6a301a28651d989efc3302ced0339", + "m_Id": 0, + "m_DisplayName": "Out", + "m_SlotType": 1, + "m_Hidden": false, + "m_ShaderOutputName": "Out", + "m_StageCapability": 3, + "m_Value": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "m_DefaultValue": { + "x": 0.0, + "y": 0.0, + "z": 0.0 + }, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Internal.Texture2DShaderProperty", + "m_ObjectId": "f5cccd49a27950b79db69a09be9d7a89", + "m_Guid": { + "m_GuidSerialized": "f5cccd49-a279-50b7-9db6-9a09be9d7a89" + }, + "m_Name": "Optical Normals (baked)", + "m_DefaultRefNameVersion": 1, + "m_RefNameGeneratedByDisplayName": "Optical Normals (baked)", + "m_DefaultReferenceName": "_OpticalNormalMap", + "m_OverrideReferenceName": "_OpticalNormalMap", + "m_GeneratePropertyBlock": true, + "m_UseCustomSlotLabel": false, + "m_CustomSlotLabel": "", + "m_DismissedVersion": 0, + "m_Precision": 0, + "overrideHLSLDeclaration": false, + "hlslDeclarationOverride": 0, + "m_Hidden": false, + "m_Value": { + "m_SerializedTexture": "", + "m_Guid": "" + }, + "isMainTexture": false, + "useTilingAndOffset": false, + "useTexelSize": false, + "m_Modifiable": true, + "m_DefaultType": 0 +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.PropertyNode", + "m_ObjectId": "a7dea086444d58c09eb5c70fdac63f41", + "m_Group": { + "m_Id": "" + }, + "m_Name": "Property", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": -500, + "y": 80, + "width": 210, + "height": 100 + } + }, + "m_Slots": [ + { + "m_Id": "d245568ceaad54da932104d46e28971f" + } + ], + "synonyms": [], + "m_Precision": 1, + "m_PreviewExpanded": false, + "m_DismissedVersion": 0, + "m_PreviewMode": 0, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_Property": { + "m_Id": "f5cccd49a27950b79db69a09be9d7a89" + } +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Texture2DMaterialSlot", + "m_ObjectId": "d245568ceaad54da932104d46e28971f", + "m_Id": 0, + "m_DisplayName": "Out", + "m_SlotType": 1, + "m_Hidden": false, + "m_ShaderOutputName": "Out", + "m_StageCapability": 3, + "m_BareResource": false +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Internal.Texture2DShaderProperty", + "m_ObjectId": "9c58618dfaf852e18f694a70f847b2cc", + "m_Guid": { + "m_GuidSerialized": "9c58618d-faf8-52e1-8f69-4a70f847b2cc" + }, + "m_Name": "Internal Response (baked)", + "m_DefaultRefNameVersion": 1, + "m_RefNameGeneratedByDisplayName": "Internal Response (baked)", + "m_DefaultReferenceName": "_OpticalResponseMap", + "m_OverrideReferenceName": "_OpticalResponseMap", + "m_GeneratePropertyBlock": true, + "m_UseCustomSlotLabel": false, + "m_CustomSlotLabel": "", + "m_DismissedVersion": 0, + "m_Precision": 0, + "overrideHLSLDeclaration": false, + "hlslDeclarationOverride": 0, + "m_Hidden": false, + "m_Value": { + "m_SerializedTexture": "", + "m_Guid": "" + }, + "isMainTexture": false, + "useTilingAndOffset": false, + "useTexelSize": false, + "m_Modifiable": true, + "m_DefaultType": 0 +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.PropertyNode", + "m_ObjectId": "c74254a56fae556ab408262a4ee977ae", + "m_Group": { + "m_Id": "" + }, + "m_Name": "Property", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": -500, + "y": 160, + "width": 210, + "height": 100 + } + }, + "m_Slots": [ + { + "m_Id": "cc618f5753ce59e4a058d993d36d0cbd" + } + ], + "synonyms": [], + "m_Precision": 1, + "m_PreviewExpanded": false, + "m_DismissedVersion": 0, + "m_PreviewMode": 0, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_Property": { + "m_Id": "9c58618dfaf852e18f694a70f847b2cc" + } +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Texture2DMaterialSlot", + "m_ObjectId": "cc618f5753ce59e4a058d993d36d0cbd", + "m_Id": 0, + "m_DisplayName": "Out", + "m_SlotType": 1, + "m_Hidden": false, + "m_ShaderOutputName": "Out", + "m_StageCapability": 3, + "m_BareResource": false +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Internal.Texture2DShaderProperty", + "m_ObjectId": "80a6c7d4fa675c1f97d62030c40e284b", + "m_Guid": { + "m_GuidSerialized": "80a6c7d4-fa67-5c1f-97d6-2030c40e284b" + }, + "m_Name": "Surface Normal (baked)", + "m_DefaultRefNameVersion": 1, + "m_RefNameGeneratedByDisplayName": "Surface Normal (baked)", + "m_DefaultReferenceName": "_NormalMap", + "m_OverrideReferenceName": "_NormalMap", + "m_GeneratePropertyBlock": true, + "m_UseCustomSlotLabel": false, + "m_CustomSlotLabel": "", + "m_DismissedVersion": 0, + "m_Precision": 0, + "overrideHLSLDeclaration": false, + "hlslDeclarationOverride": 0, + "m_Hidden": false, + "m_Value": { + "m_SerializedTexture": "", + "m_Guid": "" + }, + "isMainTexture": false, + "useTilingAndOffset": false, + "useTexelSize": false, + "m_Modifiable": true, + "m_DefaultType": 3 +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.PropertyNode", + "m_ObjectId": "8e3e2aaf2c8558d9937179b50257456f", + "m_Group": { + "m_Id": "" + }, + "m_Name": "Property", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": -500, + "y": 240, + "width": 210, + "height": 100 + } + }, + "m_Slots": [ + { + "m_Id": "21a6c2332caf5e2ab09966a543798133" + } + ], + "synonyms": [], + "m_Precision": 1, + "m_PreviewExpanded": false, + "m_DismissedVersion": 0, + "m_PreviewMode": 0, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_Property": { + "m_Id": "80a6c7d4fa675c1f97d62030c40e284b" + } +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Texture2DMaterialSlot", + "m_ObjectId": "21a6c2332caf5e2ab09966a543798133", + "m_Id": 0, + "m_DisplayName": "Out", + "m_SlotType": 1, + "m_Hidden": false, + "m_ShaderOutputName": "Out", + "m_StageCapability": 3, + "m_BareResource": false +} + +{ + "m_SGVersion": 1, + "m_Type": "UnityEditor.ShaderGraph.Internal.Vector4ShaderProperty", + "m_ObjectId": "cbaacef800f25aed9377b6affe122eb3", + "m_Guid": { + "m_GuidSerialized": "cbaacef8-00f2-5aed-9377-b6affe122eb3" + }, + "m_Name": "Virtual Lamp (local XYZ, radius W, metres)", + "m_DefaultRefNameVersion": 1, + "m_RefNameGeneratedByDisplayName": "Virtual Lamp (local XYZ, radius W, metres)", + "m_DefaultReferenceName": "_OpticalLamp", + "m_OverrideReferenceName": "_OpticalLamp", + "m_GeneratePropertyBlock": true, + "m_UseCustomSlotLabel": false, + "m_CustomSlotLabel": "", + "m_DismissedVersion": 0, + "m_Precision": 0, + "overrideHLSLDeclaration": false, + "hlslDeclarationOverride": 0, + "m_Hidden": false, + "m_Value": { + "x": 0, + "y": -0.015, + "z": 0, + "w": 0.008 + } +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.PropertyNode", + "m_ObjectId": "38a0027d064f5a39b3b966d8859ef5f9", + "m_Group": { + "m_Id": "" + }, + "m_Name": "Property", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": -500, + "y": 320, + "width": 210, + "height": 100 + } + }, + "m_Slots": [ + { + "m_Id": "c52d8d8ab335533eb4d41f60457f940d" + } + ], + "synonyms": [], + "m_Precision": 1, + "m_PreviewExpanded": false, + "m_DismissedVersion": 0, + "m_PreviewMode": 0, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_Property": { + "m_Id": "cbaacef800f25aed9377b6affe122eb3" + } +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector4MaterialSlot", + "m_ObjectId": "c52d8d8ab335533eb4d41f60457f940d", + "m_Id": 0, + "m_DisplayName": "Out", + "m_SlotType": 1, + "m_Hidden": false, + "m_ShaderOutputName": "Out", + "m_StageCapability": 3, + "m_Value": { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 0.0 + }, + "m_DefaultValue": { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 0.0 + }, + "m_Labels": [] +} + +{ + "m_SGVersion": 1, + "m_Type": "UnityEditor.ShaderGraph.Internal.Vector4ShaderProperty", + "m_ObjectId": "b32e0a9968715785b711d9c950e67e70", + "m_Guid": { + "m_GuidSerialized": "b32e0a99-6871-5785-b711-d9c950e67e70" + }, + "m_Name": "Virtual Lamp 2 (local XYZ, radius W, metres)", + "m_DefaultRefNameVersion": 1, + "m_RefNameGeneratedByDisplayName": "Virtual Lamp 2 (local XYZ, radius W, metres)", + "m_DefaultReferenceName": "_OpticalLampB", + "m_OverrideReferenceName": "_OpticalLampB", + "m_GeneratePropertyBlock": true, + "m_UseCustomSlotLabel": false, + "m_CustomSlotLabel": "", + "m_DismissedVersion": 0, + "m_Precision": 0, + "overrideHLSLDeclaration": false, + "hlslDeclarationOverride": 0, + "m_Hidden": false, + "m_Value": { + "x": 0, + "y": 0, + "z": 0, + "w": 0 + } +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.PropertyNode", + "m_ObjectId": "b13762a60757541eb340dac8055f5f35", + "m_Group": { + "m_Id": "" + }, + "m_Name": "Property", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": -500, + "y": 400, + "width": 210, + "height": 100 + } + }, + "m_Slots": [ + { + "m_Id": "c76d733023185cfa8f25b145998baf66" + } + ], + "synonyms": [], + "m_Precision": 1, + "m_PreviewExpanded": false, + "m_DismissedVersion": 0, + "m_PreviewMode": 0, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_Property": { + "m_Id": "b32e0a9968715785b711d9c950e67e70" + } +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector4MaterialSlot", + "m_ObjectId": "c76d733023185cfa8f25b145998baf66", + "m_Id": 0, + "m_DisplayName": "Out", + "m_SlotType": 1, + "m_Hidden": false, + "m_ShaderOutputName": "Out", + "m_StageCapability": 3, + "m_Value": { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 0.0 + }, + "m_DefaultValue": { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 0.0 + }, + "m_Labels": [] +} + +{ + "m_SGVersion": 1, + "m_Type": "UnityEditor.ShaderGraph.Internal.Vector4ShaderProperty", + "m_ObjectId": "4f866e7a0d8653f1b37d5caa4190d4e5", + "m_Guid": { + "m_GuidSerialized": "4f866e7a-0d86-53f1-b37d-5caa4190d4e5" + }, + "m_Name": "Virtual Lamp 3 (local XYZ, radius W, metres)", + "m_DefaultRefNameVersion": 1, + "m_RefNameGeneratedByDisplayName": "Virtual Lamp 3 (local XYZ, radius W, metres)", + "m_DefaultReferenceName": "_OpticalLampC", + "m_OverrideReferenceName": "_OpticalLampC", + "m_GeneratePropertyBlock": true, + "m_UseCustomSlotLabel": false, + "m_CustomSlotLabel": "", + "m_DismissedVersion": 0, + "m_Precision": 0, + "overrideHLSLDeclaration": false, + "hlslDeclarationOverride": 0, + "m_Hidden": false, + "m_Value": { + "x": 0, + "y": 0, + "z": 0, + "w": 0 + } +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.PropertyNode", + "m_ObjectId": "9254815eb565514fb64170a48b987901", + "m_Group": { + "m_Id": "" + }, + "m_Name": "Property", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": -500, + "y": 480, + "width": 210, + "height": 100 + } + }, + "m_Slots": [ + { + "m_Id": "862fe2e5b763553da4c9ed165c00fef7" + } + ], + "synonyms": [], + "m_Precision": 1, + "m_PreviewExpanded": false, + "m_DismissedVersion": 0, + "m_PreviewMode": 0, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_Property": { + "m_Id": "4f866e7a0d8653f1b37d5caa4190d4e5" + } +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector4MaterialSlot", + "m_ObjectId": "862fe2e5b763553da4c9ed165c00fef7", + "m_Id": 0, + "m_DisplayName": "Out", + "m_SlotType": 1, + "m_Hidden": false, + "m_ShaderOutputName": "Out", + "m_StageCapability": 3, + "m_Value": { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 0.0 + }, + "m_DefaultValue": { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 0.0 + }, + "m_Labels": [] +} + +{ + "m_SGVersion": 1, + "m_Type": "UnityEditor.ShaderGraph.Internal.Vector4ShaderProperty", + "m_ObjectId": "fb72f27f05ae538280d3a75b2cd757eb", + "m_Guid": { + "m_GuidSerialized": "fb72f27f-05ae-5382-80d3-a75b2cd757eb" + }, + "m_Name": "Virtual Lamp 4 (local XYZ, radius W, metres)", + "m_DefaultRefNameVersion": 1, + "m_RefNameGeneratedByDisplayName": "Virtual Lamp 4 (local XYZ, radius W, metres)", + "m_DefaultReferenceName": "_OpticalLampD", + "m_OverrideReferenceName": "_OpticalLampD", + "m_GeneratePropertyBlock": true, + "m_UseCustomSlotLabel": false, + "m_CustomSlotLabel": "", + "m_DismissedVersion": 0, + "m_Precision": 0, + "overrideHLSLDeclaration": false, + "hlslDeclarationOverride": 0, + "m_Hidden": false, + "m_Value": { + "x": 0, + "y": 0, + "z": 0, + "w": 0 + } +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.PropertyNode", + "m_ObjectId": "93bfa3c99f135970a74b4e3ba8428d7d", + "m_Group": { + "m_Id": "" + }, + "m_Name": "Property", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": -500, + "y": 560, + "width": 210, + "height": 100 + } + }, + "m_Slots": [ + { + "m_Id": "5023b3f3ac4b55b6a907dfff83a7c3db" + } + ], + "synonyms": [], + "m_Precision": 1, + "m_PreviewExpanded": false, + "m_DismissedVersion": 0, + "m_PreviewMode": 0, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_Property": { + "m_Id": "fb72f27f05ae538280d3a75b2cd757eb" + } +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector4MaterialSlot", + "m_ObjectId": "5023b3f3ac4b55b6a907dfff83a7c3db", + "m_Id": 0, + "m_DisplayName": "Out", + "m_SlotType": 1, + "m_Hidden": false, + "m_ShaderOutputName": "Out", + "m_StageCapability": 3, + "m_Value": { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 0.0 + }, + "m_DefaultValue": { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 0.0 + }, + "m_Labels": [] +} + +{ + "m_SGVersion": 1, + "m_Type": "UnityEditor.ShaderGraph.Internal.Vector1ShaderProperty", + "m_ObjectId": "87bc8c4be8665adf8fb006ef67285c5d", + "m_Guid": { + "m_GuidSerialized": "87bc8c4b-e866-5adf-8fb0-06ef67285c5d" + }, + "m_Name": "Index of Refraction", + "m_DefaultRefNameVersion": 1, + "m_RefNameGeneratedByDisplayName": "Index of Refraction", + "m_DefaultReferenceName": "_OpticalIor", + "m_OverrideReferenceName": "_OpticalIor", + "m_GeneratePropertyBlock": true, + "m_UseCustomSlotLabel": false, + "m_CustomSlotLabel": "", + "m_DismissedVersion": 0, + "m_Precision": 0, + "overrideHLSLDeclaration": false, + "hlslDeclarationOverride": 0, + "m_Hidden": false, + "m_Value": 1.49, + "m_FloatType": 1, + "m_RangeValues": { + "x": 1.01, + "y": 2 + }, + "m_SliderType": 0, + "m_SliderPower": 3.0, + "m_EnumType": 0, + "m_CSharpEnumString": "", + "m_EnumNames": [], + "m_EnumValues": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.PropertyNode", + "m_ObjectId": "8c40c59aad9650a5bbb772fbe4e38b96", + "m_Group": { + "m_Id": "" + }, + "m_Name": "Property", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": -500, + "y": 640, + "width": 210, + "height": 100 + } + }, + "m_Slots": [ + { + "m_Id": "2cfcc5ffeb725cc5aafd345272e0530d" + } + ], + "synonyms": [], + "m_Precision": 1, + "m_PreviewExpanded": false, + "m_DismissedVersion": 0, + "m_PreviewMode": 0, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_Property": { + "m_Id": "87bc8c4be8665adf8fb006ef67285c5d" + } +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "2cfcc5ffeb725cc5aafd345272e0530d", + "m_Id": 0, + "m_DisplayName": "Out", + "m_SlotType": 1, + "m_Hidden": false, + "m_ShaderOutputName": "Out", + "m_StageCapability": 3, + "m_Value": 0.0, + "m_DefaultValue": 0.0, + "m_Labels": [] +} + +{ + "m_SGVersion": 1, + "m_Type": "UnityEditor.ShaderGraph.Internal.Vector1ShaderProperty", + "m_ObjectId": "cebcb51b748d5b6889bc9956d0d479a3", + "m_Guid": { + "m_GuidSerialized": "cebcb51b-748d-5b68-89bc-9956d0d479a3" + }, + "m_Name": "Baked Scatter", + "m_DefaultRefNameVersion": 1, + "m_RefNameGeneratedByDisplayName": "Baked Scatter", + "m_DefaultReferenceName": "_OpticalScatter", + "m_OverrideReferenceName": "_OpticalScatter", + "m_GeneratePropertyBlock": true, + "m_UseCustomSlotLabel": false, + "m_CustomSlotLabel": "", + "m_DismissedVersion": 0, + "m_Precision": 0, + "overrideHLSLDeclaration": false, + "hlslDeclarationOverride": 0, + "m_Hidden": false, + "m_Value": 0.1, + "m_FloatType": 1, + "m_RangeValues": { + "x": 0, + "y": 2 + }, + "m_SliderType": 0, + "m_SliderPower": 3.0, + "m_EnumType": 0, + "m_CSharpEnumString": "", + "m_EnumNames": [], + "m_EnumValues": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.PropertyNode", + "m_ObjectId": "0c02ad2153675de98a7d0fe17c4d4c4e", + "m_Group": { + "m_Id": "" + }, + "m_Name": "Property", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": -500, + "y": 720, + "width": 210, + "height": 100 + } + }, + "m_Slots": [ + { + "m_Id": "98f8530c68955f2186f1b4eb9f3b3b06" + } + ], + "synonyms": [], + "m_Precision": 1, + "m_PreviewExpanded": false, + "m_DismissedVersion": 0, + "m_PreviewMode": 0, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_Property": { + "m_Id": "cebcb51b748d5b6889bc9956d0d479a3" + } +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "98f8530c68955f2186f1b4eb9f3b3b06", + "m_Id": 0, + "m_DisplayName": "Out", + "m_SlotType": 1, + "m_Hidden": false, + "m_ShaderOutputName": "Out", + "m_StageCapability": 3, + "m_Value": 0.0, + "m_DefaultValue": 0.0, + "m_Labels": [] +} + +{ + "m_SGVersion": 1, + "m_Type": "UnityEditor.ShaderGraph.Internal.Vector1ShaderProperty", + "m_ObjectId": "f2aa93755f9f5663be6045d6be0c61b9", + "m_Guid": { + "m_GuidSerialized": "f2aa9375-5f9f-5663-be60-45d6be0c61b9" + }, + "m_Name": "Edge Glow", + "m_DefaultRefNameVersion": 1, + "m_RefNameGeneratedByDisplayName": "Edge Glow", + "m_DefaultReferenceName": "_OpticalEdgeGlow", + "m_OverrideReferenceName": "_OpticalEdgeGlow", + "m_GeneratePropertyBlock": true, + "m_UseCustomSlotLabel": false, + "m_CustomSlotLabel": "", + "m_DismissedVersion": 0, + "m_Precision": 0, + "overrideHLSLDeclaration": false, + "hlslDeclarationOverride": 0, + "m_Hidden": false, + "m_Value": 0.035, + "m_FloatType": 1, + "m_RangeValues": { + "x": 0, + "y": 3 + }, + "m_SliderType": 0, + "m_SliderPower": 3.0, + "m_EnumType": 0, + "m_CSharpEnumString": "", + "m_EnumNames": [], + "m_EnumValues": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.PropertyNode", + "m_ObjectId": "6ddcfef30bf05ecdaf4b8b68419e8db6", + "m_Group": { + "m_Id": "" + }, + "m_Name": "Property", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": -500, + "y": 800, + "width": 210, + "height": 100 + } + }, + "m_Slots": [ + { + "m_Id": "e4fe6f49b4fe5c95a922bfed0585e734" + } + ], + "synonyms": [], + "m_Precision": 1, + "m_PreviewExpanded": false, + "m_DismissedVersion": 0, + "m_PreviewMode": 0, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_Property": { + "m_Id": "f2aa93755f9f5663be6045d6be0c61b9" + } +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "e4fe6f49b4fe5c95a922bfed0585e734", + "m_Id": 0, + "m_DisplayName": "Out", + "m_SlotType": 1, + "m_Hidden": false, + "m_ShaderOutputName": "Out", + "m_StageCapability": 3, + "m_Value": 0.0, + "m_DefaultValue": 0.0, + "m_Labels": [] +} + +{ + "m_SGVersion": 1, + "m_Type": "UnityEditor.ShaderGraph.Internal.Vector1ShaderProperty", + "m_ObjectId": "e28afaaa8fe053bd91f0e818b549fb77", + "m_Guid": { + "m_GuidSerialized": "e28afaaa-8fe0-53bd-91f0-e818b549fb77" + }, + "m_Name": "Virtual Lamp Strength", + "m_DefaultRefNameVersion": 1, + "m_RefNameGeneratedByDisplayName": "Virtual Lamp Strength", + "m_DefaultReferenceName": "_OpticalLampGain", + "m_OverrideReferenceName": "_OpticalLampGain", + "m_GeneratePropertyBlock": true, + "m_UseCustomSlotLabel": false, + "m_CustomSlotLabel": "", + "m_DismissedVersion": 0, + "m_Precision": 0, + "overrideHLSLDeclaration": false, + "hlslDeclarationOverride": 0, + "m_Hidden": false, + "m_Value": 1.5, + "m_FloatType": 1, + "m_RangeValues": { + "x": 0, + "y": 5 + }, + "m_SliderType": 0, + "m_SliderPower": 3.0, + "m_EnumType": 0, + "m_CSharpEnumString": "", + "m_EnumNames": [], + "m_EnumValues": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.PropertyNode", + "m_ObjectId": "20589895c6be54ea8a175ab190d10bf6", + "m_Group": { + "m_Id": "" + }, + "m_Name": "Property", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": -500, + "y": 880, + "width": 210, + "height": 100 + } + }, + "m_Slots": [ + { + "m_Id": "4844225f17825b9f93c82596114b2624" + } + ], + "synonyms": [], + "m_Precision": 1, + "m_PreviewExpanded": false, + "m_DismissedVersion": 0, + "m_PreviewMode": 0, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_Property": { + "m_Id": "e28afaaa8fe053bd91f0e818b549fb77" + } +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "4844225f17825b9f93c82596114b2624", + "m_Id": 0, + "m_DisplayName": "Out", + "m_SlotType": 1, + "m_Hidden": false, + "m_ShaderOutputName": "Out", + "m_StageCapability": 3, + "m_Value": 0.0, + "m_DefaultValue": 0.0, + "m_Labels": [] +} + +{ + "m_SGVersion": 1, + "m_Type": "UnityEditor.ShaderGraph.Internal.Vector1ShaderProperty", + "m_ObjectId": "c6bcc2966afc5ae488259f75fc7b836d", + "m_Guid": { + "m_GuidSerialized": "c6bcc296-6afc-5ae4-8825-9f75fc7b836d" + }, + "m_Name": "Surface Normal Strength", + "m_DefaultRefNameVersion": 1, + "m_RefNameGeneratedByDisplayName": "Surface Normal Strength", + "m_DefaultReferenceName": "_NormalStrength", + "m_OverrideReferenceName": "_NormalStrength", + "m_GeneratePropertyBlock": true, + "m_UseCustomSlotLabel": false, + "m_CustomSlotLabel": "", + "m_DismissedVersion": 0, + "m_Precision": 0, + "overrideHLSLDeclaration": false, + "hlslDeclarationOverride": 0, + "m_Hidden": false, + "m_Value": 0.15, + "m_FloatType": 1, + "m_RangeValues": { + "x": 0, + "y": 2 + }, + "m_SliderType": 0, + "m_SliderPower": 3.0, + "m_EnumType": 0, + "m_CSharpEnumString": "", + "m_EnumNames": [], + "m_EnumValues": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.PropertyNode", + "m_ObjectId": "bfa4def9778953faa67d6315312a64e9", + "m_Group": { + "m_Id": "" + }, + "m_Name": "Property", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": -500, + "y": 960, + "width": 210, + "height": 100 + } + }, + "m_Slots": [ + { + "m_Id": "7ef1a2078e745b4d9b3fd08aac01ebae" + } + ], + "synonyms": [], + "m_Precision": 1, + "m_PreviewExpanded": false, + "m_DismissedVersion": 0, + "m_PreviewMode": 0, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_Property": { + "m_Id": "c6bcc2966afc5ae488259f75fc7b836d" + } +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "7ef1a2078e745b4d9b3fd08aac01ebae", + "m_Id": 0, + "m_DisplayName": "Out", + "m_SlotType": 1, + "m_Hidden": false, + "m_ShaderOutputName": "Out", + "m_StageCapability": 3, + "m_Value": 0.0, + "m_DefaultValue": 0.0, + "m_Labels": [] +} + +{ + "m_SGVersion": 1, + "m_Type": "UnityEditor.ShaderGraph.Internal.Vector1ShaderProperty", + "m_ObjectId": "48504b8bc994589c80868e82996c56f2", + "m_Guid": { + "m_GuidSerialized": "48504b8b-c994-589c-8086-8e82996c56f2" + }, + "m_Name": "Intensity (runtime)", + "m_DefaultRefNameVersion": 1, + "m_RefNameGeneratedByDisplayName": "Intensity (runtime)", + "m_DefaultReferenceName": "_InsertLampLevel", + "m_OverrideReferenceName": "_InsertLampLevel", + "m_GeneratePropertyBlock": true, + "m_UseCustomSlotLabel": false, + "m_CustomSlotLabel": "", + "m_DismissedVersion": 0, + "m_Precision": 0, + "overrideHLSLDeclaration": false, + "hlslDeclarationOverride": 0, + "m_Hidden": true, + "m_Value": 1, + "m_FloatType": 1, + "m_RangeValues": { + "x": 0, + "y": 1 + }, + "m_SliderType": 0, + "m_SliderPower": 3.0, + "m_EnumType": 0, + "m_CSharpEnumString": "", + "m_EnumNames": [], + "m_EnumValues": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.PropertyNode", + "m_ObjectId": "39c18e46011b582e81b386af722fb317", + "m_Group": { + "m_Id": "" + }, + "m_Name": "Property", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": -500, + "y": 1040, + "width": 210, + "height": 100 + } + }, + "m_Slots": [ + { + "m_Id": "33bdeb73b1bc5f1fb4c33e77e09d8c1e" + } + ], + "synonyms": [], + "m_Precision": 1, + "m_PreviewExpanded": false, + "m_DismissedVersion": 0, + "m_PreviewMode": 0, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_Property": { + "m_Id": "48504b8bc994589c80868e82996c56f2" + } +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "33bdeb73b1bc5f1fb4c33e77e09d8c1e", + "m_Id": 0, + "m_DisplayName": "Out", + "m_SlotType": 1, + "m_Hidden": false, + "m_ShaderOutputName": "Out", + "m_StageCapability": 3, + "m_Value": 0.0, + "m_DefaultValue": 0.0, + "m_Labels": [] +} + +{ + "m_SGVersion": 1, + "m_Type": "UnityEditor.ShaderGraph.Internal.Vector1ShaderProperty", + "m_ObjectId": "b032f322f53d557897aa5f0dd7e22c40", + "m_Guid": { + "m_GuidSerialized": "b032f322-f53d-5578-97aa-5f0dd7e22c40" + }, + "m_Name": "Facet Contrast", + "m_DefaultRefNameVersion": 1, + "m_RefNameGeneratedByDisplayName": "Facet Contrast", + "m_DefaultReferenceName": "_OpticalContrast", + "m_OverrideReferenceName": "_OpticalContrast", + "m_GeneratePropertyBlock": true, + "m_UseCustomSlotLabel": false, + "m_CustomSlotLabel": "", + "m_DismissedVersion": 0, + "m_Precision": 0, + "overrideHLSLDeclaration": false, + "hlslDeclarationOverride": 0, + "m_Hidden": false, + "m_Value": 0.8, + "m_FloatType": 1, + "m_RangeValues": { + "x": 0, + "y": 1 + }, + "m_SliderType": 0, + "m_SliderPower": 3.0, + "m_EnumType": 0, + "m_CSharpEnumString": "", + "m_EnumNames": [], + "m_EnumValues": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.PropertyNode", + "m_ObjectId": "5a54035f9b50583e9369005068a9330c", + "m_Group": { + "m_Id": "" + }, + "m_Name": "Property", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": -500, + "y": 1120, + "width": 210, + "height": 100 + } + }, + "m_Slots": [ + { + "m_Id": "df3daf7e9d135533ac32a50f358f8282" + } + ], + "synonyms": [], + "m_Precision": 1, + "m_PreviewExpanded": false, + "m_DismissedVersion": 0, + "m_PreviewMode": 0, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_Property": { + "m_Id": "b032f322f53d557897aa5f0dd7e22c40" + } +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "df3daf7e9d135533ac32a50f358f8282", + "m_Id": 0, + "m_DisplayName": "Out", + "m_SlotType": 1, + "m_Hidden": false, + "m_ShaderOutputName": "Out", + "m_StageCapability": 3, + "m_Value": 0.0, + "m_DefaultValue": 0.0, + "m_Labels": [] +} + +{ + "m_SGVersion": 1, + "m_Type": "UnityEditor.ShaderGraph.Internal.Vector1ShaderProperty", + "m_ObjectId": "5caa794e9c8856c8a296d48eaaa129e7", + "m_Guid": { + "m_GuidSerialized": "5caa794e-9c88-56c8-a296-d48eaaa129e7" + }, + "m_Name": "Mottle Amount", + "m_DefaultRefNameVersion": 1, + "m_RefNameGeneratedByDisplayName": "Mottle Amount", + "m_DefaultReferenceName": "_OpticalVariation", + "m_OverrideReferenceName": "_OpticalVariation", + "m_GeneratePropertyBlock": true, + "m_UseCustomSlotLabel": false, + "m_CustomSlotLabel": "", + "m_DismissedVersion": 0, + "m_Precision": 0, + "overrideHLSLDeclaration": false, + "hlslDeclarationOverride": 0, + "m_Hidden": false, + "m_Value": 0.4, + "m_FloatType": 1, + "m_RangeValues": { + "x": 0, + "y": 1 + }, + "m_SliderType": 0, + "m_SliderPower": 3.0, + "m_EnumType": 0, + "m_CSharpEnumString": "", + "m_EnumNames": [], + "m_EnumValues": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.PropertyNode", + "m_ObjectId": "54220b1a481651f38db03e60c90c40f8", + "m_Group": { + "m_Id": "" + }, + "m_Name": "Property", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": -500, + "y": 1200, + "width": 210, + "height": 100 + } + }, + "m_Slots": [ + { + "m_Id": "308708b0ac74527fb220326ccc0661fa" + } + ], + "synonyms": [], + "m_Precision": 1, + "m_PreviewExpanded": false, + "m_DismissedVersion": 0, + "m_PreviewMode": 0, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_Property": { + "m_Id": "5caa794e9c8856c8a296d48eaaa129e7" + } +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "308708b0ac74527fb220326ccc0661fa", + "m_Id": 0, + "m_DisplayName": "Out", + "m_SlotType": 1, + "m_Hidden": false, + "m_ShaderOutputName": "Out", + "m_StageCapability": 3, + "m_Value": 0.0, + "m_DefaultValue": 0.0, + "m_Labels": [] +} + +{ + "m_SGVersion": 1, + "m_Type": "UnityEditor.ShaderGraph.Internal.Vector1ShaderProperty", + "m_ObjectId": "3a765b8511e35e8fb3bd93b9f474c3d6", + "m_Guid": { + "m_GuidSerialized": "3a765b85-11e3-5e8f-b3bd-93b9f474c3d6" + }, + "m_Name": "Variation Seed", + "m_DefaultRefNameVersion": 1, + "m_RefNameGeneratedByDisplayName": "Variation Seed", + "m_DefaultReferenceName": "_OpticalSeed", + "m_OverrideReferenceName": "_OpticalSeed", + "m_GeneratePropertyBlock": true, + "m_UseCustomSlotLabel": false, + "m_CustomSlotLabel": "", + "m_DismissedVersion": 0, + "m_Precision": 0, + "overrideHLSLDeclaration": false, + "hlslDeclarationOverride": 0, + "m_Hidden": false, + "m_Value": 7, + "m_FloatType": 0, + "m_RangeValues": { + "x": 1.0, + "y": 64.0 + }, + "m_SliderType": 0, + "m_SliderPower": 3.0, + "m_EnumType": 0, + "m_CSharpEnumString": "", + "m_EnumNames": [], + "m_EnumValues": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.PropertyNode", + "m_ObjectId": "428c2f754584503890fda944c30ff28f", + "m_Group": { + "m_Id": "" + }, + "m_Name": "Property", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": -500, + "y": 1280, + "width": 210, + "height": 100 + } + }, + "m_Slots": [ + { + "m_Id": "f44bd3629c5e5cec9c1761fca230a5c2" + } + ], + "synonyms": [], + "m_Precision": 1, + "m_PreviewExpanded": false, + "m_DismissedVersion": 0, + "m_PreviewMode": 0, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_Property": { + "m_Id": "3a765b8511e35e8fb3bd93b9f474c3d6" + } +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "f44bd3629c5e5cec9c1761fca230a5c2", + "m_Id": 0, + "m_DisplayName": "Out", + "m_SlotType": 1, + "m_Hidden": false, + "m_ShaderOutputName": "Out", + "m_StageCapability": 3, + "m_Value": 0.0, + "m_DefaultValue": 0.0, + "m_Labels": [] +} + +{ + "m_SGVersion": 1, + "m_Type": "UnityEditor.ShaderGraph.Internal.Vector1ShaderProperty", + "m_ObjectId": "79282d2d1f9f59beae1442cb5aec9d4a", + "m_Guid": { + "m_GuidSerialized": "79282d2d-1f9f-59be-ae14-42cb5aec9d4a" + }, + "m_Name": "Max Brightness", + "m_DefaultRefNameVersion": 1, + "m_RefNameGeneratedByDisplayName": "Max Brightness", + "m_DefaultReferenceName": "_InsertLuminosity", + "m_OverrideReferenceName": "_InsertLuminosity", + "m_GeneratePropertyBlock": true, + "m_UseCustomSlotLabel": false, + "m_CustomSlotLabel": "", + "m_DismissedVersion": 0, + "m_Precision": 0, + "overrideHLSLDeclaration": false, + "hlslDeclarationOverride": 0, + "m_Hidden": false, + "m_Value": 1, + "m_FloatType": 0, + "m_RangeValues": { + "x": 1.0, + "y": 64.0 + }, + "m_SliderType": 0, + "m_SliderPower": 3.0, + "m_EnumType": 0, + "m_CSharpEnumString": "", + "m_EnumNames": [], + "m_EnumValues": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.PropertyNode", + "m_ObjectId": "d7c11bfbd1bd52608d3a654f476cb993", + "m_Group": { + "m_Id": "" + }, + "m_Name": "Property", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": -500, + "y": 1360, + "width": 210, + "height": 100 + } + }, + "m_Slots": [ + { + "m_Id": "2bee6001693050ecbc2066679ec51425" + } + ], + "synonyms": [], + "m_Precision": 1, + "m_PreviewExpanded": false, + "m_DismissedVersion": 0, + "m_PreviewMode": 0, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_Property": { + "m_Id": "79282d2d1f9f59beae1442cb5aec9d4a" + } +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "2bee6001693050ecbc2066679ec51425", + "m_Id": 0, + "m_DisplayName": "Out", + "m_SlotType": 1, + "m_Hidden": false, + "m_ShaderOutputName": "Out", + "m_StageCapability": 3, + "m_Value": 0.0, + "m_DefaultValue": 0.0, + "m_Labels": [] +} + +{ + "m_SGVersion": 1, + "m_Type": "UnityEditor.ShaderGraph.Internal.Vector1ShaderProperty", + "m_ObjectId": "76b742d448275dac96c5a785ed5aa046", + "m_Guid": { + "m_GuidSerialized": "76b742d4-4827-5dac-96c5-a785ed5aa046" + }, + "m_Name": "Mottle Size (mm)", + "m_DefaultRefNameVersion": 1, + "m_RefNameGeneratedByDisplayName": "Mottle Size (mm)", + "m_DefaultReferenceName": "_OpticalMottleSize", + "m_OverrideReferenceName": "_OpticalMottleSize", + "m_GeneratePropertyBlock": true, + "m_UseCustomSlotLabel": false, + "m_CustomSlotLabel": "", + "m_DismissedVersion": 0, + "m_Precision": 0, + "overrideHLSLDeclaration": false, + "hlslDeclarationOverride": 0, + "m_Hidden": false, + "m_Value": 1.4, + "m_FloatType": 1, + "m_RangeValues": { + "x": 0.2, + "y": 5 + }, + "m_SliderType": 0, + "m_SliderPower": 3.0, + "m_EnumType": 0, + "m_CSharpEnumString": "", + "m_EnumNames": [], + "m_EnumValues": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.PropertyNode", + "m_ObjectId": "c0d964a73077504cbc5b4b2d0f8408c1", + "m_Group": { + "m_Id": "" + }, + "m_Name": "Property", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": -500, + "y": 1440, + "width": 210, + "height": 100 + } + }, + "m_Slots": [ + { + "m_Id": "22d158e3a3a6564a9326c5fe351982f8" + } + ], + "synonyms": [], + "m_Precision": 1, + "m_PreviewExpanded": false, + "m_DismissedVersion": 0, + "m_PreviewMode": 0, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_Property": { + "m_Id": "76b742d448275dac96c5a785ed5aa046" + } +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "22d158e3a3a6564a9326c5fe351982f8", + "m_Id": 0, + "m_DisplayName": "Out", + "m_SlotType": 1, + "m_Hidden": false, + "m_ShaderOutputName": "Out", + "m_StageCapability": 3, + "m_Value": 0.0, + "m_DefaultValue": 0.0, + "m_Labels": [] +} + +{ + "m_SGVersion": 1, + "m_Type": "UnityEditor.ShaderGraph.Internal.Vector1ShaderProperty", + "m_ObjectId": "c55299604c8f58ddb1951c3fccf48b0f", + "m_Guid": { + "m_GuidSerialized": "c5529960-4c8f-58dd-b195-1c3fccf48b0f" + }, + "m_Name": "Grain", + "m_DefaultRefNameVersion": 1, + "m_RefNameGeneratedByDisplayName": "Grain", + "m_DefaultReferenceName": "_OpticalGrain", + "m_OverrideReferenceName": "_OpticalGrain", + "m_GeneratePropertyBlock": true, + "m_UseCustomSlotLabel": false, + "m_CustomSlotLabel": "", + "m_DismissedVersion": 0, + "m_Precision": 0, + "overrideHLSLDeclaration": false, + "hlslDeclarationOverride": 0, + "m_Hidden": false, + "m_Value": 0.25, + "m_FloatType": 1, + "m_RangeValues": { + "x": 0, + "y": 1 + }, + "m_SliderType": 0, + "m_SliderPower": 3.0, + "m_EnumType": 0, + "m_CSharpEnumString": "", + "m_EnumNames": [], + "m_EnumValues": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.PropertyNode", + "m_ObjectId": "4b32d57f8adf5f11bbc862e34fa6d0c8", + "m_Group": { + "m_Id": "" + }, + "m_Name": "Property", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": -500, + "y": 1520, + "width": 210, + "height": 100 + } + }, + "m_Slots": [ + { + "m_Id": "6149e17b147c5a4184d467bb5564de3e" + } + ], + "synonyms": [], + "m_Precision": 1, + "m_PreviewExpanded": false, + "m_DismissedVersion": 0, + "m_PreviewMode": 0, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_Property": { + "m_Id": "c55299604c8f58ddb1951c3fccf48b0f" + } +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "6149e17b147c5a4184d467bb5564de3e", + "m_Id": 0, + "m_DisplayName": "Out", + "m_SlotType": 1, + "m_Hidden": false, + "m_ShaderOutputName": "Out", + "m_StageCapability": 3, + "m_Value": 0.0, + "m_DefaultValue": 0.0, + "m_Labels": [] +} + +{ + "m_SGVersion": 1, + "m_Type": "UnityEditor.ShaderGraph.Internal.Vector1ShaderProperty", + "m_ObjectId": "79ce1dea7425576c927c76564474bdaa", + "m_Guid": { + "m_GuidSerialized": "79ce1dea-7425-576c-927c-76564474bdaa" + }, + "m_Name": "Min Brightness", + "m_DefaultRefNameVersion": 1, + "m_RefNameGeneratedByDisplayName": "Min Brightness", + "m_DefaultReferenceName": "_InsertMinBrightness", + "m_OverrideReferenceName": "_InsertMinBrightness", + "m_GeneratePropertyBlock": true, + "m_UseCustomSlotLabel": false, + "m_CustomSlotLabel": "", + "m_DismissedVersion": 0, + "m_Precision": 0, + "overrideHLSLDeclaration": false, + "hlslDeclarationOverride": 0, + "m_Hidden": false, + "m_Value": 0.04, + "m_FloatType": 1, + "m_RangeValues": { + "x": 0, + "y": 1 + }, + "m_SliderType": 0, + "m_SliderPower": 3.0, + "m_EnumType": 0, + "m_CSharpEnumString": "", + "m_EnumNames": [], + "m_EnumValues": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.PropertyNode", + "m_ObjectId": "756bb71064fa5a0c8be319a413b19390", + "m_Group": { + "m_Id": "" + }, + "m_Name": "Property", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": -500, + "y": 1600, + "width": 210, + "height": 100 + } + }, + "m_Slots": [ + { + "m_Id": "8a45a8e2233c5b989cf76d70c9776590" + } + ], + "synonyms": [], + "m_Precision": 1, + "m_PreviewExpanded": false, + "m_DismissedVersion": 0, + "m_PreviewMode": 0, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_Property": { + "m_Id": "79ce1dea7425576c927c76564474bdaa" + } +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "8a45a8e2233c5b989cf76d70c9776590", + "m_Id": 0, + "m_DisplayName": "Out", + "m_SlotType": 1, + "m_Hidden": false, + "m_ShaderOutputName": "Out", + "m_StageCapability": 3, + "m_Value": 0.0, + "m_DefaultValue": 0.0, + "m_Labels": [] +} + +{ + "m_SGVersion": 1, + "m_Type": "UnityEditor.ShaderGraph.Internal.Vector1ShaderProperty", + "m_ObjectId": "8f2697a2ad035fd283e96674f6099e87", + "m_Guid": { + "m_GuidSerialized": "8f2697a2-ad03-5fd2-83e9-6674f6099e87" + }, + "m_Name": "Facet Flatten", + "m_DefaultRefNameVersion": 1, + "m_RefNameGeneratedByDisplayName": "Facet Flatten", + "m_DefaultReferenceName": "_InsertFacetFlatten", + "m_OverrideReferenceName": "_InsertFacetFlatten", + "m_GeneratePropertyBlock": true, + "m_UseCustomSlotLabel": false, + "m_CustomSlotLabel": "", + "m_DismissedVersion": 0, + "m_Precision": 0, + "overrideHLSLDeclaration": false, + "hlslDeclarationOverride": 0, + "m_Hidden": false, + "m_Value": 0, + "m_FloatType": 1, + "m_RangeValues": { + "x": 0, + "y": 1 + }, + "m_SliderType": 0, + "m_SliderPower": 3.0, + "m_EnumType": 0, + "m_CSharpEnumString": "", + "m_EnumNames": [], + "m_EnumValues": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.PropertyNode", + "m_ObjectId": "f841f17bc1fb5cf4a403c21690a12576", + "m_Group": { + "m_Id": "" + }, + "m_Name": "Property", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": -500, + "y": 1680, + "width": 210, + "height": 100 + } + }, + "m_Slots": [ + { + "m_Id": "3b4d66be05cc5400b9c25c64610c0034" + } + ], + "synonyms": [], + "m_Precision": 1, + "m_PreviewExpanded": false, + "m_DismissedVersion": 0, + "m_PreviewMode": 0, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_Property": { + "m_Id": "8f2697a2ad035fd283e96674f6099e87" + } +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "3b4d66be05cc5400b9c25c64610c0034", + "m_Id": 0, + "m_DisplayName": "Out", + "m_SlotType": 1, + "m_Hidden": false, + "m_ShaderOutputName": "Out", + "m_StageCapability": 3, + "m_Value": 0.0, + "m_DefaultValue": 0.0, + "m_Labels": [] +} + +{ + "m_SGVersion": 1, + "m_Type": "UnityEditor.ShaderGraph.Internal.Vector1ShaderProperty", + "m_ObjectId": "47031cac033a52d290a3f7d58ebeb0e5", + "m_Guid": { + "m_GuidSerialized": "47031cac-033a-52d2-90a3-f7d58ebeb0e5" + }, + "m_Name": "Clarity", + "m_DefaultRefNameVersion": 1, + "m_RefNameGeneratedByDisplayName": "Clarity", + "m_DefaultReferenceName": "_InsertClarity", + "m_OverrideReferenceName": "_InsertClarity", + "m_GeneratePropertyBlock": true, + "m_UseCustomSlotLabel": false, + "m_CustomSlotLabel": "", + "m_DismissedVersion": 0, + "m_Precision": 0, + "overrideHLSLDeclaration": false, + "hlslDeclarationOverride": 0, + "m_Hidden": false, + "m_Value": 0, + "m_FloatType": 1, + "m_RangeValues": { + "x": 0, + "y": 1 + }, + "m_SliderType": 0, + "m_SliderPower": 3.0, + "m_EnumType": 0, + "m_CSharpEnumString": "", + "m_EnumNames": [], + "m_EnumValues": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.PropertyNode", + "m_ObjectId": "af3da35925e55b4682bf83cb054d2db9", + "m_Group": { + "m_Id": "" + }, + "m_Name": "Property", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": -500, + "y": 1760, + "width": 210, + "height": 100 + } + }, + "m_Slots": [ + { + "m_Id": "46c3773c6bb45b699113239d1bd976aa" + } + ], + "synonyms": [], + "m_Precision": 1, + "m_PreviewExpanded": false, + "m_DismissedVersion": 0, + "m_PreviewMode": 0, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_Property": { + "m_Id": "47031cac033a52d290a3f7d58ebeb0e5" + } +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "46c3773c6bb45b699113239d1bd976aa", + "m_Id": 0, + "m_DisplayName": "Out", + "m_SlotType": 1, + "m_Hidden": false, + "m_ShaderOutputName": "Out", + "m_StageCapability": 3, + "m_Value": 0.0, + "m_DefaultValue": 0.0, + "m_Labels": [] +} + +{ + "m_SGVersion": 1, + "m_Type": "UnityEditor.ShaderGraph.Internal.Vector1ShaderProperty", + "m_ObjectId": "afd2ca8cba535099b1257c3f27290983", + "m_Guid": { + "m_GuidSerialized": "afd2ca8c-ba53-5099-b125-7c3f27290983" + }, + "m_Name": "Cavity Spread", + "m_DefaultRefNameVersion": 1, + "m_RefNameGeneratedByDisplayName": "Cavity Spread", + "m_DefaultReferenceName": "_InsertCavitySpread", + "m_OverrideReferenceName": "_InsertCavitySpread", + "m_GeneratePropertyBlock": true, + "m_UseCustomSlotLabel": false, + "m_CustomSlotLabel": "", + "m_DismissedVersion": 0, + "m_Precision": 0, + "overrideHLSLDeclaration": false, + "hlslDeclarationOverride": 0, + "m_Hidden": false, + "m_Value": 4, + "m_FloatType": 1, + "m_RangeValues": { + "x": 1, + "y": 8 + }, + "m_SliderType": 0, + "m_SliderPower": 3.0, + "m_EnumType": 0, + "m_CSharpEnumString": "", + "m_EnumNames": [], + "m_EnumValues": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.PropertyNode", + "m_ObjectId": "7b2587fa2d6f5838bcc0940478c93467", + "m_Group": { + "m_Id": "" + }, + "m_Name": "Property", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": -500, + "y": 1840, + "width": 210, + "height": 100 + } + }, + "m_Slots": [ + { + "m_Id": "b3d8ce720f20555f9208d3efbce3f615" + } + ], + "synonyms": [], + "m_Precision": 1, + "m_PreviewExpanded": false, + "m_DismissedVersion": 0, + "m_PreviewMode": 0, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_Property": { + "m_Id": "afd2ca8cba535099b1257c3f27290983" + } +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "b3d8ce720f20555f9208d3efbce3f615", + "m_Id": 0, + "m_DisplayName": "Out", + "m_SlotType": 1, + "m_Hidden": false, + "m_ShaderOutputName": "Out", + "m_StageCapability": 3, + "m_Value": 0.0, + "m_DefaultValue": 0.0, + "m_Labels": [] +} + +{ + "m_SGVersion": 1, + "m_Type": "UnityEditor.ShaderGraph.Internal.Vector1ShaderProperty", + "m_ObjectId": "d8f0b44d8ff45f8f9b7a97b64a7a5db0", + "m_Guid": { + "m_GuidSerialized": "d8f0b44d-8ff4-5f8f-9b7a-97b64a7a5db0" + }, + "m_Name": "Cavity Contrast", + "m_DefaultRefNameVersion": 1, + "m_RefNameGeneratedByDisplayName": "Cavity Contrast", + "m_DefaultReferenceName": "_InsertCavityContrast", + "m_OverrideReferenceName": "_InsertCavityContrast", + "m_GeneratePropertyBlock": true, + "m_UseCustomSlotLabel": false, + "m_CustomSlotLabel": "", + "m_DismissedVersion": 0, + "m_Precision": 0, + "overrideHLSLDeclaration": false, + "hlslDeclarationOverride": 0, + "m_Hidden": false, + "m_Value": 0.3, + "m_FloatType": 1, + "m_RangeValues": { + "x": 0, + "y": 1 + }, + "m_SliderType": 0, + "m_SliderPower": 3.0, + "m_EnumType": 0, + "m_CSharpEnumString": "", + "m_EnumNames": [], + "m_EnumValues": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.PropertyNode", + "m_ObjectId": "72c929a4c9295f2dbfba96ef32f2c945", + "m_Group": { + "m_Id": "" + }, + "m_Name": "Property", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": -500, + "y": 1920, + "width": 210, + "height": 100 + } + }, + "m_Slots": [ + { + "m_Id": "d1f410f050055e9ba1de2c6e1f45a2c6" + } + ], + "synonyms": [], + "m_Precision": 1, + "m_PreviewExpanded": false, + "m_DismissedVersion": 0, + "m_PreviewMode": 0, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_Property": { + "m_Id": "d8f0b44d8ff45f8f9b7a97b64a7a5db0" + } +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "d1f410f050055e9ba1de2c6e1f45a2c6", + "m_Id": 0, + "m_DisplayName": "Out", + "m_SlotType": 1, + "m_Hidden": false, + "m_ShaderOutputName": "Out", + "m_StageCapability": 3, + "m_Value": 0.0, + "m_DefaultValue": 0.0, + "m_Labels": [] +} + +{ + "m_SGVersion": 1, + "m_Type": "UnityEditor.ShaderGraph.Internal.Vector1ShaderProperty", + "m_ObjectId": "4c5ab3de0c985513bf7247b33879f300", + "m_Guid": { + "m_GuidSerialized": "4c5ab3de-0c98-5513-bf72-47b33879f300" + }, + "m_Name": "Lamp Tint (runtime)", + "m_DefaultRefNameVersion": 1, + "m_RefNameGeneratedByDisplayName": "Lamp Tint (runtime)", + "m_DefaultReferenceName": "_InsertLampTint", + "m_OverrideReferenceName": "_InsertLampTint", + "m_GeneratePropertyBlock": true, + "m_UseCustomSlotLabel": false, + "m_CustomSlotLabel": "", + "m_DismissedVersion": 0, + "m_Precision": 0, + "overrideHLSLDeclaration": false, + "hlslDeclarationOverride": 0, + "m_Hidden": true, + "m_Value": 0, + "m_FloatType": 1, + "m_RangeValues": { + "x": 0, + "y": 1 + }, + "m_SliderType": 0, + "m_SliderPower": 3.0, + "m_EnumType": 0, + "m_CSharpEnumString": "", + "m_EnumNames": [], + "m_EnumValues": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.PropertyNode", + "m_ObjectId": "648cdf0c4ccd56dab79e0839510f9850", + "m_Group": { + "m_Id": "" + }, + "m_Name": "Property", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": -500, + "y": 2000, + "width": 210, + "height": 100 + } + }, + "m_Slots": [ + { + "m_Id": "040777c80eb2592294dc0664aeb99af1" + } + ], + "synonyms": [], + "m_Precision": 1, + "m_PreviewExpanded": false, + "m_DismissedVersion": 0, + "m_PreviewMode": 0, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_Property": { + "m_Id": "4c5ab3de0c985513bf7247b33879f300" + } +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "040777c80eb2592294dc0664aeb99af1", + "m_Id": 0, + "m_DisplayName": "Out", + "m_SlotType": 1, + "m_Hidden": false, + "m_ShaderOutputName": "Out", + "m_StageCapability": 3, + "m_Value": 0.0, + "m_DefaultValue": 0.0, + "m_Labels": [] +} + +{ + "m_SGVersion": 1, + "m_Type": "UnityEditor.ShaderGraph.Internal.Vector1ShaderProperty", + "m_ObjectId": "7550e938d31e5c399795602e29d14c3a", + "m_Guid": { + "m_GuidSerialized": "7550e938-d31e-5c39-9795-602e29d14c3a" + }, + "m_Name": "Lamp Spread (mm)", + "m_DefaultRefNameVersion": 1, + "m_RefNameGeneratedByDisplayName": "Lamp Spread (mm)", + "m_DefaultReferenceName": "_OpticalSpread", + "m_OverrideReferenceName": "_OpticalSpread", + "m_GeneratePropertyBlock": true, + "m_UseCustomSlotLabel": false, + "m_CustomSlotLabel": "", + "m_DismissedVersion": 0, + "m_Precision": 0, + "overrideHLSLDeclaration": false, + "hlslDeclarationOverride": 0, + "m_Hidden": false, + "m_Value": 0, + "m_FloatType": 1, + "m_RangeValues": { + "x": 0, + "y": 100 + }, + "m_SliderType": 0, + "m_SliderPower": 3.0, + "m_EnumType": 0, + "m_CSharpEnumString": "", + "m_EnumNames": [], + "m_EnumValues": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.PropertyNode", + "m_ObjectId": "432b49b887d35d83bf88a081723b160e", + "m_Group": { + "m_Id": "" + }, + "m_Name": "Property", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": -500, + "y": 2080, + "width": 210, + "height": 100 + } + }, + "m_Slots": [ + { + "m_Id": "ea2cc2cbc7d052d6973f5c750abb8b26" + } + ], + "synonyms": [], + "m_Precision": 1, + "m_PreviewExpanded": false, + "m_DismissedVersion": 0, + "m_PreviewMode": 0, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_Property": { + "m_Id": "7550e938d31e5c399795602e29d14c3a" + } +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "ea2cc2cbc7d052d6973f5c750abb8b26", + "m_Id": 0, + "m_DisplayName": "Out", + "m_SlotType": 1, + "m_Hidden": false, + "m_ShaderOutputName": "Out", + "m_StageCapability": 3, + "m_Value": 0.0, + "m_DefaultValue": 0.0, + "m_Labels": [] +} + +{ + "m_SGVersion": 1, + "m_Type": "UnityEditor.ShaderGraph.Internal.Vector1ShaderProperty", + "m_ObjectId": "391f5b4513d65194a4ba42d431bd8c93", + "m_Guid": { + "m_GuidSerialized": "391f5b45-13d6-5194-a4ba-42d431bd8c93" + }, + "m_Name": "Surface Grain", + "m_DefaultRefNameVersion": 1, + "m_RefNameGeneratedByDisplayName": "Surface Grain", + "m_DefaultReferenceName": "_SurfaceGrain", + "m_OverrideReferenceName": "_SurfaceGrain", + "m_GeneratePropertyBlock": true, + "m_UseCustomSlotLabel": false, + "m_CustomSlotLabel": "", + "m_DismissedVersion": 0, + "m_Precision": 0, + "overrideHLSLDeclaration": false, + "hlslDeclarationOverride": 0, + "m_Hidden": false, + "m_Value": 0, + "m_FloatType": 1, + "m_RangeValues": { + "x": 0, + "y": 1 + }, + "m_SliderType": 0, + "m_SliderPower": 3.0, + "m_EnumType": 0, + "m_CSharpEnumString": "", + "m_EnumNames": [], + "m_EnumValues": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.PropertyNode", + "m_ObjectId": "7a917aa723ee5ee4a58985dcfa8fb709", + "m_Group": { + "m_Id": "" + }, + "m_Name": "Property", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": -500, + "y": 2160, + "width": 210, + "height": 100 + } + }, + "m_Slots": [ + { + "m_Id": "dd1c976db49c5fa69e965e6fae64ba96" + } + ], + "synonyms": [], + "m_Precision": 1, + "m_PreviewExpanded": false, + "m_DismissedVersion": 0, + "m_PreviewMode": 0, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_Property": { + "m_Id": "391f5b4513d65194a4ba42d431bd8c93" + } +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "dd1c976db49c5fa69e965e6fae64ba96", + "m_Id": 0, + "m_DisplayName": "Out", + "m_SlotType": 1, + "m_Hidden": false, + "m_ShaderOutputName": "Out", + "m_StageCapability": 3, + "m_Value": 0.0, + "m_DefaultValue": 0.0, + "m_Labels": [] +} + +{ + "m_SGVersion": 1, + "m_Type": "UnityEditor.ShaderGraph.Internal.Vector1ShaderProperty", + "m_ObjectId": "eddfdb9e051d5865834eb933671aaff4", + "m_Guid": { + "m_GuidSerialized": "eddfdb9e-051d-5865-834e-b933671aaff4" + }, + "m_Name": "Surface Grain Size (mm)", + "m_DefaultRefNameVersion": 1, + "m_RefNameGeneratedByDisplayName": "Surface Grain Size (mm)", + "m_DefaultReferenceName": "_SurfaceGrainSize", + "m_OverrideReferenceName": "_SurfaceGrainSize", + "m_GeneratePropertyBlock": true, + "m_UseCustomSlotLabel": false, + "m_CustomSlotLabel": "", + "m_DismissedVersion": 0, + "m_Precision": 0, + "overrideHLSLDeclaration": false, + "hlslDeclarationOverride": 0, + "m_Hidden": false, + "m_Value": 0.75, + "m_FloatType": 1, + "m_RangeValues": { + "x": 0.1, + "y": 5 + }, + "m_SliderType": 0, + "m_SliderPower": 3.0, + "m_EnumType": 0, + "m_CSharpEnumString": "", + "m_EnumNames": [], + "m_EnumValues": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.PropertyNode", + "m_ObjectId": "eb3e21d0b35e5384817d0c6b2ecba57a", + "m_Group": { + "m_Id": "" + }, + "m_Name": "Property", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": -500, + "y": 2240, + "width": 210, + "height": 100 + } + }, + "m_Slots": [ + { + "m_Id": "c2d25bb1638b5ffab3d45e2a88d67b54" + } + ], + "synonyms": [], + "m_Precision": 1, + "m_PreviewExpanded": false, + "m_DismissedVersion": 0, + "m_PreviewMode": 0, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_Property": { + "m_Id": "eddfdb9e051d5865834eb933671aaff4" + } +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "c2d25bb1638b5ffab3d45e2a88d67b54", + "m_Id": 0, + "m_DisplayName": "Out", + "m_SlotType": 1, + "m_Hidden": false, + "m_ShaderOutputName": "Out", + "m_StageCapability": 3, + "m_Value": 0.0, + "m_DefaultValue": 0.0, + "m_Labels": [] +} + +{ + "m_SGVersion": 1, + "m_Type": "UnityEditor.ShaderGraph.Internal.Vector1ShaderProperty", + "m_ObjectId": "d1d92de60b5b50679d958d5415cb1bce", + "m_Guid": { + "m_GuidSerialized": "d1d92de6-0b5b-5067-9d95-8d5415cb1bce" + }, + "m_Name": "Lamp Output (runtime)", + "m_DefaultRefNameVersion": 1, + "m_RefNameGeneratedByDisplayName": "Lamp Output (runtime)", + "m_DefaultReferenceName": "_InsertLampOutput", + "m_OverrideReferenceName": "_InsertLampOutput", + "m_GeneratePropertyBlock": true, + "m_UseCustomSlotLabel": false, + "m_CustomSlotLabel": "", + "m_DismissedVersion": 0, + "m_Precision": 0, + "overrideHLSLDeclaration": false, + "hlslDeclarationOverride": 0, + "m_Hidden": true, + "m_Value": 1, + "m_FloatType": 1, + "m_RangeValues": { + "x": 0, + "y": 10 + }, + "m_SliderType": 0, + "m_SliderPower": 3.0, + "m_EnumType": 0, + "m_CSharpEnumString": "", + "m_EnumNames": [], + "m_EnumValues": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.PropertyNode", + "m_ObjectId": "312a376a2c9d5949a63495eb43da788a", + "m_Group": { + "m_Id": "" + }, + "m_Name": "Property", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": -500, + "y": 2320, + "width": 210, + "height": 100 + } + }, + "m_Slots": [ + { + "m_Id": "14e794fdbbaf535fb1098c9909fc5eec" + } + ], + "synonyms": [], + "m_Precision": 1, + "m_PreviewExpanded": false, + "m_DismissedVersion": 0, + "m_PreviewMode": 0, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_Property": { + "m_Id": "d1d92de60b5b50679d958d5415cb1bce" + } +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "14e794fdbbaf535fb1098c9909fc5eec", + "m_Id": 0, + "m_DisplayName": "Out", + "m_SlotType": 1, + "m_Hidden": false, + "m_ShaderOutputName": "Out", + "m_StageCapability": 3, + "m_Value": 0.0, + "m_DefaultValue": 0.0, + "m_Labels": [] +} + +{ + "m_SGVersion": 1, + "m_Type": "UnityEditor.ShaderGraph.Internal.Vector1ShaderProperty", + "m_ObjectId": "7d4e3081cedd5c919f3bc85431603a72", + "m_Guid": { + "m_GuidSerialized": "7d4e3081-cedd-5c91-9f3b-c85431603a72" + }, + "m_Name": "Roughness", + "m_DefaultRefNameVersion": 1, + "m_RefNameGeneratedByDisplayName": "Roughness", + "m_DefaultReferenceName": "_InsertRoughness", + "m_OverrideReferenceName": "_InsertRoughness", + "m_GeneratePropertyBlock": true, + "m_UseCustomSlotLabel": false, + "m_CustomSlotLabel": "", + "m_DismissedVersion": 0, + "m_Precision": 0, + "overrideHLSLDeclaration": false, + "hlslDeclarationOverride": 0, + "m_Hidden": false, + "m_Value": 0, + "m_FloatType": 1, + "m_RangeValues": { + "x": 0, + "y": 1 + }, + "m_SliderType": 0, + "m_SliderPower": 3.0, + "m_EnumType": 0, + "m_CSharpEnumString": "", + "m_EnumNames": [], + "m_EnumValues": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.PropertyNode", + "m_ObjectId": "1686d3f1b0615565aa5a86324933c761", + "m_Group": { + "m_Id": "" + }, + "m_Name": "Property", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": -500, + "y": 2400, + "width": 210, + "height": 100 + } + }, + "m_Slots": [ + { + "m_Id": "f09cb08aa32754268eee7015e35d765a" + } + ], + "synonyms": [], + "m_Precision": 1, + "m_PreviewExpanded": false, + "m_DismissedVersion": 0, + "m_PreviewMode": 0, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_Property": { + "m_Id": "7d4e3081cedd5c919f3bc85431603a72" + } +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "f09cb08aa32754268eee7015e35d765a", + "m_Id": 0, + "m_DisplayName": "Out", + "m_SlotType": 1, + "m_Hidden": false, + "m_ShaderOutputName": "Out", + "m_StageCapability": 3, + "m_Value": 0.0, + "m_DefaultValue": 0.0, + "m_Labels": [] +} + +{ + "m_SGVersion": 1, + "m_Type": "UnityEditor.ShaderGraph.Internal.Vector1ShaderProperty", + "m_ObjectId": "d6e78c4734055e67b1986e96afc59726", + "m_Guid": { + "m_GuidSerialized": "d6e78c47-3405-5e67-b198-6e96afc59726" + }, + "m_Name": "Flecks", + "m_DefaultRefNameVersion": 1, + "m_RefNameGeneratedByDisplayName": "Flecks", + "m_DefaultReferenceName": "_OpticalFlecks", + "m_OverrideReferenceName": "_OpticalFlecks", + "m_GeneratePropertyBlock": true, + "m_UseCustomSlotLabel": false, + "m_CustomSlotLabel": "", + "m_DismissedVersion": 0, + "m_Precision": 0, + "overrideHLSLDeclaration": false, + "hlslDeclarationOverride": 0, + "m_Hidden": false, + "m_Value": 0, + "m_FloatType": 1, + "m_RangeValues": { + "x": 0, + "y": 1 + }, + "m_SliderType": 0, + "m_SliderPower": 3.0, + "m_EnumType": 0, + "m_CSharpEnumString": "", + "m_EnumNames": [], + "m_EnumValues": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.PropertyNode", + "m_ObjectId": "d8f56c5855f75c5a9f96ef67e55b4205", + "m_Group": { + "m_Id": "" + }, + "m_Name": "Property", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": -500, + "y": 2480, + "width": 210, + "height": 100 + } + }, + "m_Slots": [ + { + "m_Id": "109de4825faa5012b398cef020fe383a" + } + ], + "synonyms": [], + "m_Precision": 1, + "m_PreviewExpanded": false, + "m_DismissedVersion": 0, + "m_PreviewMode": 0, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_Property": { + "m_Id": "d6e78c4734055e67b1986e96afc59726" + } +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "109de4825faa5012b398cef020fe383a", + "m_Id": 0, + "m_DisplayName": "Out", + "m_SlotType": 1, + "m_Hidden": false, + "m_ShaderOutputName": "Out", + "m_StageCapability": 3, + "m_Value": 0.0, + "m_DefaultValue": 0.0, + "m_Labels": [] +} + +{ + "m_SGVersion": 1, + "m_Type": "UnityEditor.ShaderGraph.Internal.Vector1ShaderProperty", + "m_ObjectId": "975f88b1cbd05c3690f7e57ac7b2eb89", + "m_Guid": { + "m_GuidSerialized": "975f88b1-cbd0-5c36-90f7-e57ac7b2eb89" + }, + "m_Name": "Fleck Size (mm)", + "m_DefaultRefNameVersion": 1, + "m_RefNameGeneratedByDisplayName": "Fleck Size (mm)", + "m_DefaultReferenceName": "_OpticalFleckSize", + "m_OverrideReferenceName": "_OpticalFleckSize", + "m_GeneratePropertyBlock": true, + "m_UseCustomSlotLabel": false, + "m_CustomSlotLabel": "", + "m_DismissedVersion": 0, + "m_Precision": 0, + "overrideHLSLDeclaration": false, + "hlslDeclarationOverride": 0, + "m_Hidden": false, + "m_Value": 1, + "m_FloatType": 1, + "m_RangeValues": { + "x": 0.1, + "y": 5 + }, + "m_SliderType": 0, + "m_SliderPower": 3.0, + "m_EnumType": 0, + "m_CSharpEnumString": "", + "m_EnumNames": [], + "m_EnumValues": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.PropertyNode", + "m_ObjectId": "89a60d00f1fd5348973c297d5401ec5a", + "m_Group": { + "m_Id": "" + }, + "m_Name": "Property", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": -500, + "y": 2560, + "width": 210, + "height": 100 + } + }, + "m_Slots": [ + { + "m_Id": "ec39e58529e057f599adde491f16c101" + } + ], + "synonyms": [], + "m_Precision": 1, + "m_PreviewExpanded": false, + "m_DismissedVersion": 0, + "m_PreviewMode": 0, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_Property": { + "m_Id": "975f88b1cbd05c3690f7e57ac7b2eb89" + } +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "ec39e58529e057f599adde491f16c101", + "m_Id": 0, + "m_DisplayName": "Out", + "m_SlotType": 1, + "m_Hidden": false, + "m_ShaderOutputName": "Out", + "m_StageCapability": 3, + "m_Value": 0.0, + "m_DefaultValue": 0.0, + "m_Labels": [] +} + +{ + "m_SGVersion": 1, + "m_Type": "UnityEditor.ShaderGraph.Internal.Vector1ShaderProperty", + "m_ObjectId": "ab903b8243655d0ea0911669c7c3432b", + "m_Guid": { + "m_GuidSerialized": "ab903b82-4365-5d0e-a091-1669c7c3432b" + }, + "m_Name": "Room Return", + "m_DefaultRefNameVersion": 1, + "m_RefNameGeneratedByDisplayName": "Room Return", + "m_DefaultReferenceName": "_InsertRoomReturn", + "m_OverrideReferenceName": "_InsertRoomReturn", + "m_GeneratePropertyBlock": true, + "m_UseCustomSlotLabel": false, + "m_CustomSlotLabel": "", + "m_DismissedVersion": 0, + "m_Precision": 0, + "overrideHLSLDeclaration": false, + "hlslDeclarationOverride": 0, + "m_Hidden": false, + "m_Value": 0, + "m_FloatType": 1, + "m_RangeValues": { + "x": 0, + "y": 1 + }, + "m_SliderType": 0, + "m_SliderPower": 3.0, + "m_EnumType": 0, + "m_CSharpEnumString": "", + "m_EnumNames": [], + "m_EnumValues": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.PropertyNode", + "m_ObjectId": "1704bd811b1c5a35bedfb8dda5462598", + "m_Group": { + "m_Id": "" + }, + "m_Name": "Property", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": -500, + "y": 2640, + "width": 210, + "height": 100 + } + }, + "m_Slots": [ + { + "m_Id": "a6fdee34f83d555e9b7e626f540f9f5e" + } + ], + "synonyms": [], + "m_Precision": 1, + "m_PreviewExpanded": false, + "m_DismissedVersion": 0, + "m_PreviewMode": 0, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_Property": { + "m_Id": "ab903b8243655d0ea0911669c7c3432b" + } +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "a6fdee34f83d555e9b7e626f540f9f5e", + "m_Id": 0, + "m_DisplayName": "Out", + "m_SlotType": 1, + "m_Hidden": false, + "m_ShaderOutputName": "Out", + "m_StageCapability": 3, + "m_Value": 0.0, + "m_DefaultValue": 0.0, + "m_Labels": [] +} + +{ + "m_SGVersion": 1, + "m_Type": "UnityEditor.ShaderGraph.Internal.Vector1ShaderProperty", + "m_ObjectId": "d63fb2c8f30356c3aae3606059477dcd", + "m_Guid": { + "m_GuidSerialized": "d63fb2c8-f303-56c3-aae3-606059477dcd" + }, + "m_Name": "Room Blur", + "m_DefaultRefNameVersion": 1, + "m_RefNameGeneratedByDisplayName": "Room Blur", + "m_DefaultReferenceName": "_InsertRoomBlur", + "m_OverrideReferenceName": "_InsertRoomBlur", + "m_GeneratePropertyBlock": true, + "m_UseCustomSlotLabel": false, + "m_CustomSlotLabel": "", + "m_DismissedVersion": 0, + "m_Precision": 0, + "overrideHLSLDeclaration": false, + "hlslDeclarationOverride": 0, + "m_Hidden": false, + "m_Value": 3, + "m_FloatType": 1, + "m_RangeValues": { + "x": 0, + "y": 6 + }, + "m_SliderType": 0, + "m_SliderPower": 3.0, + "m_EnumType": 0, + "m_CSharpEnumString": "", + "m_EnumNames": [], + "m_EnumValues": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.PropertyNode", + "m_ObjectId": "bfcc339d0cb456c987457755aabfb04d", + "m_Group": { + "m_Id": "" + }, + "m_Name": "Property", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": -500, + "y": 2720, + "width": 210, + "height": 100 + } + }, + "m_Slots": [ + { + "m_Id": "3f6966c2363e5d669c866481c1253bd8" + } + ], + "synonyms": [], + "m_Precision": 1, + "m_PreviewExpanded": false, + "m_DismissedVersion": 0, + "m_PreviewMode": 0, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_Property": { + "m_Id": "d63fb2c8f30356c3aae3606059477dcd" + } +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "3f6966c2363e5d669c866481c1253bd8", + "m_Id": 0, + "m_DisplayName": "Out", + "m_SlotType": 1, + "m_Hidden": false, + "m_ShaderOutputName": "Out", + "m_StageCapability": 3, + "m_Value": 0.0, + "m_DefaultValue": 0.0, + "m_Labels": [] +} + +{ + "m_SGVersion": 1, + "m_Type": "UnityEditor.ShaderGraph.Internal.Vector1ShaderProperty", + "m_ObjectId": "1e80a80e3298577f8a09c329c878fc93", + "m_Guid": { + "m_GuidSerialized": "1e80a80e-3298-577f-8a09-c329c878fc93" + }, + "m_Name": "Room Return Exposure (EV)", + "m_DefaultRefNameVersion": 1, + "m_RefNameGeneratedByDisplayName": "Room Return Exposure (EV)", + "m_DefaultReferenceName": "_InsertRoomReturnEV", + "m_OverrideReferenceName": "_InsertRoomReturnEV", + "m_GeneratePropertyBlock": true, + "m_UseCustomSlotLabel": false, + "m_CustomSlotLabel": "", + "m_DismissedVersion": 0, + "m_Precision": 0, + "overrideHLSLDeclaration": false, + "hlslDeclarationOverride": 0, + "m_Hidden": false, + "m_Value": 0, + "m_FloatType": 1, + "m_RangeValues": { + "x": -4, + "y": 6 + }, + "m_SliderType": 0, + "m_SliderPower": 3.0, + "m_EnumType": 0, + "m_CSharpEnumString": "", + "m_EnumNames": [], + "m_EnumValues": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.PropertyNode", + "m_ObjectId": "233c9907b34555cd80459f4a0921d1c3", + "m_Group": { + "m_Id": "" + }, + "m_Name": "Property", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": -500, + "y": 2800, + "width": 210, + "height": 100 + } + }, + "m_Slots": [ + { + "m_Id": "995c1ca53b545700b13ded64ef5c855e" + } + ], + "synonyms": [], + "m_Precision": 1, + "m_PreviewExpanded": false, + "m_DismissedVersion": 0, + "m_PreviewMode": 0, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_Property": { + "m_Id": "1e80a80e3298577f8a09c329c878fc93" + } +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "995c1ca53b545700b13ded64ef5c855e", + "m_Id": 0, + "m_DisplayName": "Out", + "m_SlotType": 1, + "m_Hidden": false, + "m_ShaderOutputName": "Out", + "m_StageCapability": 3, + "m_Value": 0.0, + "m_DefaultValue": 0.0, + "m_Labels": [] +} + +{ + "m_SGVersion": 3, + "m_Type": "UnityEditor.ShaderGraph.Internal.ColorShaderProperty", + "m_ObjectId": "0868c71889275f43b8ad9867b67f2b93", + "m_Guid": { + "m_GuidSerialized": "0868c718-8927-5f43-b8ad-9867b67f2b93" + }, + "m_Name": "Plastic Tint (off)", + "m_DefaultRefNameVersion": 1, + "m_RefNameGeneratedByDisplayName": "Plastic Tint (off)", + "m_DefaultReferenceName": "_BaseColor", + "m_OverrideReferenceName": "_BaseColor", + "m_GeneratePropertyBlock": true, + "m_UseCustomSlotLabel": false, + "m_CustomSlotLabel": "", + "m_DismissedVersion": 0, + "m_Precision": 0, + "overrideHLSLDeclaration": false, + "hlslDeclarationOverride": 0, + "m_Hidden": false, + "m_Value": { + "r": 0.16, + "g": 0.13, + "b": 0.25, + "a": 1 + }, + "isMainColor": false, + "m_ColorMode": 0 +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.PropertyNode", + "m_ObjectId": "65db75acf0cc5d4f9d00d1246dc2acff", + "m_Group": { + "m_Id": "" + }, + "m_Name": "Property", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": -500, + "y": 2880, + "width": 210, + "height": 100 + } + }, + "m_Slots": [ + { + "m_Id": "af466680550f52cc991288443a538a00" + } + ], + "synonyms": [], + "m_Precision": 1, + "m_PreviewExpanded": false, + "m_DismissedVersion": 0, + "m_PreviewMode": 0, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_Property": { + "m_Id": "0868c71889275f43b8ad9867b67f2b93" + } +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector4MaterialSlot", + "m_ObjectId": "af466680550f52cc991288443a538a00", + "m_Id": 0, + "m_DisplayName": "Out", + "m_SlotType": 1, + "m_Hidden": false, + "m_ShaderOutputName": "Out", + "m_StageCapability": 3, + "m_Value": { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 0.0 + }, + "m_DefaultValue": { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 0.0 + }, + "m_Labels": [] +} + +{ + "m_SGVersion": 3, + "m_Type": "UnityEditor.ShaderGraph.Internal.ColorShaderProperty", + "m_ObjectId": "7745a741a46459bc84e174ad710431f3", + "m_Guid": { + "m_GuidSerialized": "7745a741-a464-59bc-84e1-74ad710431f3" + }, + "m_Name": "Cavity Color (clear, under the insert)", + "m_DefaultRefNameVersion": 1, + "m_RefNameGeneratedByDisplayName": "Cavity Color (clear, under the insert)", + "m_DefaultReferenceName": "_InsertCavityColor", + "m_OverrideReferenceName": "_InsertCavityColor", + "m_GeneratePropertyBlock": true, + "m_UseCustomSlotLabel": false, + "m_CustomSlotLabel": "", + "m_DismissedVersion": 0, + "m_Precision": 0, + "overrideHLSLDeclaration": false, + "hlslDeclarationOverride": 0, + "m_Hidden": false, + "m_Value": { + "r": 0.9, + "g": 0.9, + "b": 0.9, + "a": 1 + }, + "isMainColor": false, + "m_ColorMode": 0 +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.PropertyNode", + "m_ObjectId": "9a23f1d133965f8c8ca9b90a7633c2a2", + "m_Group": { + "m_Id": "" + }, + "m_Name": "Property", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": -500, + "y": 2960, + "width": 210, + "height": 100 + } + }, + "m_Slots": [ + { + "m_Id": "5dab705379695bae85632b8e8485ad00" + } + ], + "synonyms": [], + "m_Precision": 1, + "m_PreviewExpanded": false, + "m_DismissedVersion": 0, + "m_PreviewMode": 0, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_Property": { + "m_Id": "7745a741a46459bc84e174ad710431f3" + } +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector4MaterialSlot", + "m_ObjectId": "5dab705379695bae85632b8e8485ad00", + "m_Id": 0, + "m_DisplayName": "Out", + "m_SlotType": 1, + "m_Hidden": false, + "m_ShaderOutputName": "Out", + "m_StageCapability": 3, + "m_Value": { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 0.0 + }, + "m_DefaultValue": { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 0.0 + }, + "m_Labels": [] +} + +{ + "m_SGVersion": 3, + "m_Type": "UnityEditor.ShaderGraph.Internal.ColorShaderProperty", + "m_ObjectId": "b1118c1270965d32a84bc58296cea0d5", + "m_Guid": { + "m_GuidSerialized": "b1118c12-7096-5d32-a84b-c58296cea0d5" + }, + "m_Name": "Lamp Emission (HDR)", + "m_DefaultRefNameVersion": 1, + "m_RefNameGeneratedByDisplayName": "Lamp Emission (HDR)", + "m_DefaultReferenceName": "_EmissiveColor", + "m_OverrideReferenceName": "_EmissiveColor", + "m_GeneratePropertyBlock": true, + "m_UseCustomSlotLabel": false, + "m_CustomSlotLabel": "", + "m_DismissedVersion": 0, + "m_Precision": 0, + "overrideHLSLDeclaration": false, + "hlslDeclarationOverride": 0, + "m_Hidden": false, + "m_Value": { + "r": 600, + "g": 600, + "b": 600, + "a": 1 + }, + "isMainColor": false, + "m_ColorMode": 1 +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.PropertyNode", + "m_ObjectId": "f46c2964302e54489e6557a72029b007", + "m_Group": { + "m_Id": "" + }, + "m_Name": "Property", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": -500, + "y": 3040, + "width": 210, + "height": 100 + } + }, + "m_Slots": [ + { + "m_Id": "4d36a3a292d450a4aa97f1ffe7e532dc" + } + ], + "synonyms": [], + "m_Precision": 1, + "m_PreviewExpanded": false, + "m_DismissedVersion": 0, + "m_PreviewMode": 0, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_Property": { + "m_Id": "b1118c1270965d32a84bc58296cea0d5" + } +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector4MaterialSlot", + "m_ObjectId": "4d36a3a292d450a4aa97f1ffe7e532dc", + "m_Id": 0, + "m_DisplayName": "Out", + "m_SlotType": 1, + "m_Hidden": false, + "m_ShaderOutputName": "Out", + "m_StageCapability": 3, + "m_Value": { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 0.0 + }, + "m_DefaultValue": { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 0.0 + }, + "m_Labels": [] +} + +{ + "m_SGVersion": 1, + "m_Type": "UnityEditor.ShaderGraph.Internal.Vector4ShaderProperty", + "m_ObjectId": "44276acd6332572585d8c5ea51c2b692", + "m_Guid": { + "m_GuidSerialized": "44276acd-6332-5725-85d8-c5ea51c2b692" + }, + "m_Name": "Lamp RGB (linear, runtime)", + "m_DefaultRefNameVersion": 1, + "m_RefNameGeneratedByDisplayName": "Lamp RGB (linear, runtime)", + "m_DefaultReferenceName": "_InsertLampColor", + "m_OverrideReferenceName": "_InsertLampColor", + "m_GeneratePropertyBlock": true, + "m_UseCustomSlotLabel": false, + "m_CustomSlotLabel": "", + "m_DismissedVersion": 0, + "m_Precision": 0, + "overrideHLSLDeclaration": false, + "hlslDeclarationOverride": 0, + "m_Hidden": true, + "m_Value": { + "x": 1, + "y": 1, + "z": 1, + "w": 1 + } +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.PropertyNode", + "m_ObjectId": "925bd03a2d6855e098beab765a34e9e5", + "m_Group": { + "m_Id": "" + }, + "m_Name": "Property", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": -500, + "y": 3120, + "width": 210, + "height": 100 + } + }, + "m_Slots": [ + { + "m_Id": "c0788727c130571c88cb80869663934b" + } + ], + "synonyms": [], + "m_Precision": 1, + "m_PreviewExpanded": false, + "m_DismissedVersion": 0, + "m_PreviewMode": 0, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_Property": { + "m_Id": "44276acd6332572585d8c5ea51c2b692" + } +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector4MaterialSlot", + "m_ObjectId": "c0788727c130571c88cb80869663934b", + "m_Id": 0, + "m_DisplayName": "Out", + "m_SlotType": 1, + "m_Hidden": false, + "m_ShaderOutputName": "Out", + "m_StageCapability": 3, + "m_Value": { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 0.0 + }, + "m_DefaultValue": { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 0.0 + }, + "m_Labels": [] +} + +{ + "m_SGVersion": 1, + "m_Type": "UnityEditor.ShaderGraph.Internal.Vector4ShaderProperty", + "m_ObjectId": "ff36167b8ae75487bb5eccb8dd152070", + "m_Guid": { + "m_GuidSerialized": "ff36167b-8ae7-5487-bb5e-ccb8dd152070" + }, + "m_Name": "Starburst (baked)", + "m_DefaultRefNameVersion": 1, + "m_RefNameGeneratedByDisplayName": "Starburst (baked)", + "m_DefaultReferenceName": "_OpticalStarburst", + "m_OverrideReferenceName": "_OpticalStarburst", + "m_GeneratePropertyBlock": true, + "m_UseCustomSlotLabel": false, + "m_CustomSlotLabel": "", + "m_DismissedVersion": 0, + "m_Precision": 0, + "overrideHLSLDeclaration": false, + "hlslDeclarationOverride": 0, + "m_Hidden": true, + "m_Value": { + "x": 0, + "y": 0, + "z": 0, + "w": 0 + } +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.PropertyNode", + "m_ObjectId": "ca9058a29ffb5a5eaa1c0d8a29ef029e", + "m_Group": { + "m_Id": "" + }, + "m_Name": "Property", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": -500, + "y": 3200, + "width": 210, + "height": 100 + } + }, + "m_Slots": [ + { + "m_Id": "b24f9bbdbdc356b09a92e6f2bc44eeda" + } + ], + "synonyms": [], + "m_Precision": 1, + "m_PreviewExpanded": false, + "m_DismissedVersion": 0, + "m_PreviewMode": 0, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_Property": { + "m_Id": "ff36167b8ae75487bb5eccb8dd152070" + } +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector4MaterialSlot", + "m_ObjectId": "b24f9bbdbdc356b09a92e6f2bc44eeda", + "m_Id": 0, + "m_DisplayName": "Out", + "m_SlotType": 1, + "m_Hidden": false, + "m_ShaderOutputName": "Out", + "m_StageCapability": 3, + "m_Value": { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 0.0 + }, + "m_DefaultValue": { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 0.0 + }, + "m_Labels": [] +} + +{ + "m_SGVersion": 1, + "m_Type": "UnityEditor.ShaderGraph.Internal.Vector4ShaderProperty", + "m_ObjectId": "e188a4e6b18b54d89908031069759967", + "m_Guid": { + "m_GuidSerialized": "e188a4e6-b18b-54d8-9908-031069759967" + }, + "m_Name": "Lamp RGB and level (runtime), bulb B", + "m_DefaultRefNameVersion": 1, + "m_RefNameGeneratedByDisplayName": "Lamp RGB and level (runtime), bulb B", + "m_DefaultReferenceName": "_InsertLampColorB", + "m_OverrideReferenceName": "_InsertLampColorB", + "m_GeneratePropertyBlock": true, + "m_UseCustomSlotLabel": false, + "m_CustomSlotLabel": "", + "m_DismissedVersion": 0, + "m_Precision": 0, + "overrideHLSLDeclaration": false, + "hlslDeclarationOverride": 0, + "m_Hidden": true, + "m_Value": { + "x": 1, + "y": 1, + "z": 1, + "w": 0 + } +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.PropertyNode", + "m_ObjectId": "ec2fa20232095a95b7abe4dfc9a905ec", + "m_Group": { + "m_Id": "" + }, + "m_Name": "Property", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": -500, + "y": 3280, + "width": 210, + "height": 100 + } + }, + "m_Slots": [ + { + "m_Id": "12133ebf88e751c5b496c1d21169298d" + } + ], + "synonyms": [], + "m_Precision": 1, + "m_PreviewExpanded": false, + "m_DismissedVersion": 0, + "m_PreviewMode": 0, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_Property": { + "m_Id": "e188a4e6b18b54d89908031069759967" + } +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector4MaterialSlot", + "m_ObjectId": "12133ebf88e751c5b496c1d21169298d", + "m_Id": 0, + "m_DisplayName": "Out", + "m_SlotType": 1, + "m_Hidden": false, + "m_ShaderOutputName": "Out", + "m_StageCapability": 3, + "m_Value": { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 0.0 + }, + "m_DefaultValue": { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 0.0 + }, + "m_Labels": [] +} + +{ + "m_SGVersion": 1, + "m_Type": "UnityEditor.ShaderGraph.Internal.Vector4ShaderProperty", + "m_ObjectId": "5180ea7940775616b68bd2de94b6fd74", + "m_Guid": { + "m_GuidSerialized": "5180ea79-4077-5616-b68b-d2de94b6fd74" + }, + "m_Name": "Lamp RGB and level (runtime), bulb C", + "m_DefaultRefNameVersion": 1, + "m_RefNameGeneratedByDisplayName": "Lamp RGB and level (runtime), bulb C", + "m_DefaultReferenceName": "_InsertLampColorC", + "m_OverrideReferenceName": "_InsertLampColorC", + "m_GeneratePropertyBlock": true, + "m_UseCustomSlotLabel": false, + "m_CustomSlotLabel": "", + "m_DismissedVersion": 0, + "m_Precision": 0, + "overrideHLSLDeclaration": false, + "hlslDeclarationOverride": 0, + "m_Hidden": true, + "m_Value": { + "x": 1, + "y": 1, + "z": 1, + "w": 0 + } +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.PropertyNode", + "m_ObjectId": "b55bcb0aba7950528b1d1b566bd97877", + "m_Group": { + "m_Id": "" + }, + "m_Name": "Property", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": -500, + "y": 3360, + "width": 210, + "height": 100 + } + }, + "m_Slots": [ + { + "m_Id": "d71c74226c995447871b8a93808b6e76" + } + ], + "synonyms": [], + "m_Precision": 1, + "m_PreviewExpanded": false, + "m_DismissedVersion": 0, + "m_PreviewMode": 0, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_Property": { + "m_Id": "5180ea7940775616b68bd2de94b6fd74" + } +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector4MaterialSlot", + "m_ObjectId": "d71c74226c995447871b8a93808b6e76", + "m_Id": 0, + "m_DisplayName": "Out", + "m_SlotType": 1, + "m_Hidden": false, + "m_ShaderOutputName": "Out", + "m_StageCapability": 3, + "m_Value": { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 0.0 + }, + "m_DefaultValue": { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 0.0 + }, + "m_Labels": [] +} + +{ + "m_SGVersion": 1, + "m_Type": "UnityEditor.ShaderGraph.Internal.Vector4ShaderProperty", + "m_ObjectId": "a144dcb8a49459c19a2080583d6a71d3", + "m_Guid": { + "m_GuidSerialized": "a144dcb8-a494-59c1-9a20-80583d6a71d3" + }, + "m_Name": "Lamp RGB and level (runtime), bulb D", + "m_DefaultRefNameVersion": 1, + "m_RefNameGeneratedByDisplayName": "Lamp RGB and level (runtime), bulb D", + "m_DefaultReferenceName": "_InsertLampColorD", + "m_OverrideReferenceName": "_InsertLampColorD", + "m_GeneratePropertyBlock": true, + "m_UseCustomSlotLabel": false, + "m_CustomSlotLabel": "", + "m_DismissedVersion": 0, + "m_Precision": 0, + "overrideHLSLDeclaration": false, + "hlslDeclarationOverride": 0, + "m_Hidden": true, + "m_Value": { + "x": 1, + "y": 1, + "z": 1, + "w": 0 + } +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.PropertyNode", + "m_ObjectId": "0229938f88d85671b777b9408b22ed4e", + "m_Group": { + "m_Id": "" + }, + "m_Name": "Property", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": -500, + "y": 3440, + "width": 210, + "height": 100 + } + }, + "m_Slots": [ + { + "m_Id": "c949863c7e955b9a88f457fcca2a1d40" + } + ], + "synonyms": [], + "m_Precision": 1, + "m_PreviewExpanded": false, + "m_DismissedVersion": 0, + "m_PreviewMode": 0, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_Property": { + "m_Id": "a144dcb8a49459c19a2080583d6a71d3" + } +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector4MaterialSlot", + "m_ObjectId": "c949863c7e955b9a88f457fcca2a1d40", + "m_Id": 0, + "m_DisplayName": "Out", + "m_SlotType": 1, + "m_Hidden": false, + "m_ShaderOutputName": "Out", + "m_StageCapability": 3, + "m_Value": { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 0.0 + }, + "m_DefaultValue": { + "x": 0.0, + "y": 0.0, + "z": 0.0, + "w": 0.0 + }, + "m_Labels": [] +} + +{ + "m_SGVersion": 1, + "m_Type": "UnityEditor.ShaderGraph.Internal.Vector1ShaderProperty", + "m_ObjectId": "b4462b630ce1577fbdb18f40505fc0b7", + "m_Guid": { + "m_GuidSerialized": "b4462b63-0ce1-577f-bdb1-8f40505fc0b7" + }, + "m_Name": "Smoothness", + "m_DefaultRefNameVersion": 1, + "m_RefNameGeneratedByDisplayName": "Smoothness", + "m_DefaultReferenceName": "_Smoothness", + "m_OverrideReferenceName": "_Smoothness", + "m_GeneratePropertyBlock": true, + "m_UseCustomSlotLabel": false, + "m_CustomSlotLabel": "", + "m_DismissedVersion": 0, + "m_Precision": 0, + "overrideHLSLDeclaration": false, + "hlslDeclarationOverride": 0, + "m_Hidden": false, + "m_Value": 0.93, + "m_FloatType": 1, + "m_RangeValues": { + "x": 0, + "y": 1 + }, + "m_SliderType": 0, + "m_SliderPower": 3.0, + "m_EnumType": 0, + "m_CSharpEnumString": "", + "m_EnumNames": [], + "m_EnumValues": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.PropertyNode", + "m_ObjectId": "8f121541bc7a5534828e4315a9d06bd6", + "m_Group": { + "m_Id": "" + }, + "m_Name": "Property", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": -500, + "y": 3520, + "width": 210, + "height": 100 + } + }, + "m_Slots": [ + { + "m_Id": "c6c0963ecc8e5cc19c427af32c84b57d" + } + ], + "synonyms": [], + "m_Precision": 1, + "m_PreviewExpanded": false, + "m_DismissedVersion": 0, + "m_PreviewMode": 0, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_Property": { + "m_Id": "b4462b630ce1577fbdb18f40505fc0b7" + } +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "c6c0963ecc8e5cc19c427af32c84b57d", + "m_Id": 0, + "m_DisplayName": "Out", + "m_SlotType": 1, + "m_Hidden": false, + "m_ShaderOutputName": "Out", + "m_StageCapability": 3, + "m_Value": 0.0, + "m_DefaultValue": 0.0, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.CategoryData", + "m_ObjectId": "efcb6c45f55553f89edd4456c2a91dad", + "m_Name": "", + "m_ChildObjectList": [ + { + "m_Id": "f5cccd49a27950b79db69a09be9d7a89" + }, + { + "m_Id": "9c58618dfaf852e18f694a70f847b2cc" + }, + { + "m_Id": "80a6c7d4fa675c1f97d62030c40e284b" + }, + { + "m_Id": "cbaacef800f25aed9377b6affe122eb3" + }, + { + "m_Id": "b32e0a9968715785b711d9c950e67e70" + }, + { + "m_Id": "4f866e7a0d8653f1b37d5caa4190d4e5" + }, + { + "m_Id": "fb72f27f05ae538280d3a75b2cd757eb" + }, + { + "m_Id": "87bc8c4be8665adf8fb006ef67285c5d" + }, + { + "m_Id": "cebcb51b748d5b6889bc9956d0d479a3" + }, + { + "m_Id": "f2aa93755f9f5663be6045d6be0c61b9" + }, + { + "m_Id": "e28afaaa8fe053bd91f0e818b549fb77" + }, + { + "m_Id": "c6bcc2966afc5ae488259f75fc7b836d" + }, + { + "m_Id": "48504b8bc994589c80868e82996c56f2" + }, + { + "m_Id": "b032f322f53d557897aa5f0dd7e22c40" + }, + { + "m_Id": "5caa794e9c8856c8a296d48eaaa129e7" + }, + { + "m_Id": "3a765b8511e35e8fb3bd93b9f474c3d6" + }, + { + "m_Id": "79282d2d1f9f59beae1442cb5aec9d4a" + }, + { + "m_Id": "76b742d448275dac96c5a785ed5aa046" + }, + { + "m_Id": "c55299604c8f58ddb1951c3fccf48b0f" + }, + { + "m_Id": "79ce1dea7425576c927c76564474bdaa" + }, + { + "m_Id": "8f2697a2ad035fd283e96674f6099e87" + }, + { + "m_Id": "47031cac033a52d290a3f7d58ebeb0e5" + }, + { + "m_Id": "afd2ca8cba535099b1257c3f27290983" + }, + { + "m_Id": "d8f0b44d8ff45f8f9b7a97b64a7a5db0" + }, + { + "m_Id": "4c5ab3de0c985513bf7247b33879f300" + }, + { + "m_Id": "7550e938d31e5c399795602e29d14c3a" + }, + { + "m_Id": "391f5b4513d65194a4ba42d431bd8c93" + }, + { + "m_Id": "eddfdb9e051d5865834eb933671aaff4" + }, + { + "m_Id": "d1d92de60b5b50679d958d5415cb1bce" + }, + { + "m_Id": "7d4e3081cedd5c919f3bc85431603a72" + }, + { + "m_Id": "d6e78c4734055e67b1986e96afc59726" + }, + { + "m_Id": "975f88b1cbd05c3690f7e57ac7b2eb89" + }, + { + "m_Id": "ab903b8243655d0ea0911669c7c3432b" + }, + { + "m_Id": "d63fb2c8f30356c3aae3606059477dcd" + }, + { + "m_Id": "1e80a80e3298577f8a09c329c878fc93" + }, + { + "m_Id": "0868c71889275f43b8ad9867b67f2b93" + }, + { + "m_Id": "7745a741a46459bc84e174ad710431f3" + }, + { + "m_Id": "b1118c1270965d32a84bc58296cea0d5" + }, + { + "m_Id": "44276acd6332572585d8c5ea51c2b692" + }, + { + "m_Id": "ff36167b8ae75487bb5eccb8dd152070" + }, + { + "m_Id": "e188a4e6b18b54d89908031069759967" + }, + { + "m_Id": "5180ea7940775616b68bd2de94b6fd74" + }, + { + "m_Id": "a144dcb8a49459c19a2080583d6a71d3" + }, + { + "m_Id": "b4462b630ce1577fbdb18f40505fc0b7" + } + ] +} + +{ + "m_SGVersion": 1, + "m_Type": "UnityEditor.Rendering.Universal.ShaderGraph.UniversalTarget", + "m_ObjectId": "8474feff245c45cbbf6d350ea7c5b6d6", + "m_Datas": [], + "m_ActiveSubTarget": { + "m_Id": "4e2c46213dcd4e63bb84020b96385c6e" + }, + "m_AllowMaterialOverride": false, + "m_SurfaceType": 0, + "m_ZTestMode": 4, + "m_ZWriteControl": 0, + "m_AlphaMode": 0, + "m_RenderFace": 2, + "m_AlphaClip": false, + "m_CastShadows": true, + "m_ReceiveShadows": true, + "m_CustomEditorGUI": "VisualPinball.Unity.Urp.InsertShaderGUI", + "m_SupportVFX": false +} + +{ + "m_SGVersion": 2, + "m_Type": "UnityEditor.Rendering.Universal.ShaderGraph.UniversalLitSubTarget", + "m_ObjectId": "4e2c46213dcd4e63bb84020b96385c6e", + "m_WorkflowMode": 1, + "m_NormalDropOffSpace": 0, + "m_ClearCoat": false, + "m_BlendModePreserveSpecular": true +} + +{ + "m_SGVersion": 1, + "m_Type": "UnityEditor.ShaderGraph.Internal.Vector1ShaderProperty", + "m_ObjectId": "7b66c53699fb4e329b7b4ff523715868", + "m_Guid": { + "m_GuidSerialized": "7b66c536-99fb-4e32-9b7b-4ff523715868" + }, + "m_Name": "URP Emission Scale", + "m_DefaultRefNameVersion": 1, + "m_RefNameGeneratedByDisplayName": "URP Emission Scale", + "m_DefaultReferenceName": "_InsertUrpEmissionScale", + "m_OverrideReferenceName": "_InsertUrpEmissionScale", + "m_GeneratePropertyBlock": true, + "m_UseCustomSlotLabel": false, + "m_CustomSlotLabel": "", + "m_DismissedVersion": 0, + "m_Precision": 0, + "overrideHLSLDeclaration": false, + "hlslDeclarationOverride": 0, + "m_Hidden": true, + "m_Value": 0.013020833333333334, + "m_FloatType": 0, + "m_RangeValues": { + "x": 0, + "y": 6 + }, + "m_SliderType": 0, + "m_SliderPower": 3.0, + "m_EnumType": 0, + "m_CSharpEnumString": "", + "m_EnumNames": [], + "m_EnumValues": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.PropertyNode", + "m_ObjectId": "fb36717fa96f4d248390595746a431e8", + "m_Group": { + "m_Id": "" + }, + "m_Name": "Property", + "m_DrawState": { + "m_Expanded": true, + "m_Position": { + "serializedVersion": "2", + "x": -500, + "y": 2720, + "width": 210, + "height": 100 + } + }, + "m_Slots": [ + { + "m_Id": "0422f3ca27d9472dbb6f97bd42fbd5f1" + } + ], + "synonyms": [], + "m_Precision": 1, + "m_PreviewExpanded": false, + "m_DismissedVersion": 0, + "m_PreviewMode": 0, + "m_CustomColors": { + "m_SerializableColors": [] + }, + "m_Property": { + "m_Id": "7b66c53699fb4e329b7b4ff523715868" + } +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "0422f3ca27d9472dbb6f97bd42fbd5f1", + "m_Id": 0, + "m_DisplayName": "Out", + "m_SlotType": 1, + "m_Hidden": false, + "m_ShaderOutputName": "Out", + "m_StageCapability": 3, + "m_Value": 0.013020833333333334, + "m_DefaultValue": 0.013020833333333334, + "m_Labels": [] +} + +{ + "m_SGVersion": 0, + "m_Type": "UnityEditor.ShaderGraph.Vector1MaterialSlot", + "m_ObjectId": "7d8273afe0e04afeb102e010845dea8b", + "m_Id": 52, + "m_DisplayName": "UrpEmissionScale", + "m_SlotType": 0, + "m_Hidden": false, + "m_ShaderOutputName": "UrpEmissionScale", + "m_StageCapability": 3, + "m_Value": 0.013020833333333334, + "m_DefaultValue": 0.013020833333333334, + "m_Labels": [] +} diff --git a/VisualPinball.Unity/Assets/Shaders/Srp/Insert/Insert Hybrid.shadergraph.meta b/VisualPinball.Unity/Assets/Shaders/Srp/Insert/Insert Hybrid.shadergraph.meta new file mode 100644 index 000000000..24b48934b --- /dev/null +++ b/VisualPinball.Unity/Assets/Shaders/Srp/Insert/Insert Hybrid.shadergraph.meta @@ -0,0 +1,20 @@ +fileFormatVersion: 2 +guid: c5fc2955b016708429b0cb03d5011b07 +ScriptedImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 2 + userData: + assetBundleName: + assetBundleVariant: + script: {fileID: 11500000, guid: 625f186215c104763be7675aa2d941aa, type: 3} + useAsTemplate: 0 + exposeTemplateAsShader: 0 + indexedData: {instanceID: 0} + template: + name: + category: + description: + icon: {instanceID: 0} + thumbnail: {instanceID: 0} + order: 0 diff --git a/VisualPinball.Unity/Assets/Shaders/Srp/Insert/InsertCover.hlsl b/VisualPinball.Unity/Assets/Shaders/Srp/Insert/InsertCover.hlsl new file mode 100644 index 000000000..3a31806bd --- /dev/null +++ b/VisualPinball.Unity/Assets/Shaders/Srp/Insert/InsertCover.hlsl @@ -0,0 +1,195 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// SPDX-License-Identifier: GPL-3.0-or-later +#ifndef VPE_INSERT_COVER_INCLUDED +#define VPE_INSERT_COVER_INCLUDED + +#if defined(UNIVERSAL_PIPELINE_CORE_INCLUDED) && !defined(SHADERGRAPH_PREVIEW) +#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/DeclareOpaqueTexture.hlsl" +#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/DeclareDepthTexture.hlsl" +float _VpeInsertSceneColorAvailable; +#endif + +// Cover UVs and their tangent frame follow the referenced playfield's XZ +// projection. Its texture and source strength are supplied per material slot. +// The haze is the colour the cover blends in at its opacity: a light veil that +// softens the ribs underneath the way a worn, slightly frosted top does. +// +// The cover is the top of a slab of plastic as thick as the insert is deep, +// and what shows through it is refracted as through that slab: the view ray +// bends at the top by the index, the floor with its ribs is seen where the +// bent ray meets it, a third nearer than it lies, and the light on its way up +// takes the plastic's tint, deeper the longer its path, so a red insert is +// red for being red plastic rather than a red-lit tray, darkest towards its +// edges and at grazing views. Without the slab the body sat at the bottom of +// an open tray under a pane of glass, and looked hollow. +// Hue, saturation and value, hue as a turn of the wheel in 0..1. +float3 InsertCoverRgbToHsv(float3 c) +{ + float4 K = float4(0.0, -1.0 / 3.0, 2.0 / 3.0, -1.0); + float4 p = lerp(float4(c.bg, K.wz), float4(c.gb, K.xy), step(c.b, c.g)); + float4 q = lerp(float4(p.xyw, c.r), float4(c.r, p.yzx), step(p.x, c.r)); + float d = q.x - min(q.w, q.y); + float e = 1.0e-10; + return float3(abs(q.z + (q.w - q.y) / (6.0 * d + e)), d / (q.x + e), q.x); +} + +float3 InsertCoverHsvToRgb(float3 c) +{ + float4 K = float4(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0); + float3 p = abs(frac(c.xxx + K.xyz) * 6.0 - K.www); + return c.z * lerp(K.xxx, saturate(p - K.xxx), c.y); +} + +// Turns the colour's hue towards the lamp's, and its saturation towards the +// lamp's, never below its own, both by the amount; the value is never touched, so a grey haze comes out the lamp's +// colour, as bright as the grey was, and never darker. An RGB lamp carries +// its intensity in its colour, and a dim lamp is still its colour: the amount +// is full from a tenth of full brightness up, and only a lamp that is off, +// its channels all down, leaves the colour untouched. A white lamp has no hue +// to give. +float3 InsertCoverTowardsLampHue(float3 color, float3 lamp, float amount) +{ + float3 hsv = InsertCoverRgbToHsv(max(color, 0.0)); + float3 lampHsv = InsertCoverRgbToHsv(max(lamp, 0.0)); + amount = saturate(amount) * saturate(lampHsv.z * 10.0); + // The hue turns the short way round the wheel, by the lamp's share of the + // saturation the mix ends up with: a grey, which has no hue of its own, + // takes the lamp's hue at any amount, its saturation growing with it, + // instead of a hue part of the way round the wheel, so a grey haze half + // tinted by a blue lamp is a paler blue, never pink; two colours as + // saturated as each other turn by the amount itself. + float turn = frac(lampHsv.x - hsv.x + 0.5) - 0.5; + float share = saturate(amount * lampHsv.y / max(lerp(hsv.y, lampHsv.y, amount), 1e-5)); + hsv.x = frac(hsv.x + turn * share); + hsv.y = lerp(hsv.y, max(hsv.y, lampHsv.y), amount); + return InsertCoverHsvToRgb(hsv); +} + +// The colour the bulbs send to a point, the same the body's glow takes: every +// bulb's light summed, each reaching by the inverse square of the distance +// over its spread, bulbs that are off adding nothing and taking nothing away, +// so a bulb fading in colours every point it reaches in proportion to its +// level; and in w how far that colour reaches, a bulb counting as on from a +// tenth of full up, so a bulb fading in tints its whole reach at once instead +// of creeping outwards with its light. Each lamp is a position in insert +// space with its radius in w, off at zero; each drive a linear colour with +// the level in w, the first split in two. +float4 InsertCoverLampMix(float2 position, float4 lampA, float4 lampB, float4 lampC, float4 lampD, + float3 colorA, float levelA, float4 driveB, float4 driveC, float4 driveD, float spread) +{ + float4 lamps[4] = { lampA, lampB, lampC, lampD }; + float3 drives[4] = { max(colorA, 0.0) * saturate(levelA), max(driveB.rgb, 0.0) * saturate(driveB.w), + max(driveC.rgb, 0.0) * saturate(driveC.w), max(driveD.rgb, 0.0) * saturate(driveD.w) }; + float3 mixed = 0.0; + float weight = 0.0; + float tinted = 0.0; + [unroll] + for (int i = 0; i < 4; i++) { + if (i > 0 && lamps[i].w <= 0.0) continue; + float radius = max(abs(lamps[i].w), 0.0001); + float2 apart = position - lamps[i].xz; + // The body weighs each bulb by its lamp spread as well: the same here, + // or the two would tint a point differently. + float leak = 1.0; + if (spread > 0.0) { + float2 away = apart / (spread * 0.001); + leak = rcp(1.0 + dot(away, away)); + } + float level = max(drives[i].r, max(drives[i].g, drives[i].b)); + mixed += leak * drives[i]; + weight += leak * level; + tinted += saturate(leak * 10.0) * saturate(level * 10.0); + } + return float4(mixed / max(weight, 1e-6), tinted); +} + +void InsertCover_float(float2 UV, UnityTexture2D NormalMap, float PlayfieldStrength, + float Strength, float Smoothness, float Opacity, float3 HazeColor, + float3 LampColor, float LampLevel, float LampTint, + float3 Position, float4 LampA, float4 LampB, float4 LampC, float4 LampD, + float4 DriveB, float4 DriveC, float4 DriveD, float Spread, + float Ior, float3 Tint, float TintDepth, float Thickness, float3 TangentOS, float3 BitangentOS, + out float3 NormalTS, out float SurfaceSmoothness, out float Alpha, out float3 BaseColor, + out float RefractionIndex, out float3 TransmittanceColor, out float AbsorptionDistance, out float SlabThickness, + out float3 Emission) +{ +#if defined(SHADERPASS) && ((defined(SHADERPASS_SHADOWS) && (SHADERPASS == SHADERPASS_SHADOWS)) || (defined(SHADERPASS_SHADOWCASTER) && (SHADERPASS == SHADERPASS_SHADOWCASTER))) + clip(-1); +#endif + Emission = 0.0; + float3 normal = UnpackNormal(SAMPLE_TEXTURE2D(NormalMap.tex, NormalMap.samplerstate, UV * NormalMap.scaleTranslate.xy + NormalMap.scaleTranslate.zw)); + NormalTS = normalize(float3(normal.xy * max(PlayfieldStrength * Strength, 0.0), normal.z)); + SurfaceSmoothness = saturate(Smoothness); + Alpha = saturate(Opacity); + // The haze's hue and saturation turn to the light's, the bulbs' colours + // summed, as far as the insert light asks and their light reaches, its + // value untouched, so a coloured lamp is not milked by a grey veil, a + // banana lit red, green and blue along its length is hazed red, green and + // blue with the mixes between, and lamps that are off leave the haze its + // own colour, as bright as ever. + float4 mixed = InsertCoverLampMix(Position.xz, LampA, LampB, LampC, LampD, LampColor, LampLevel, DriveB, DriveC, DriveD, Spread); + float3 untinted = max(HazeColor, 0.0); + BaseColor = InsertCoverTowardsLampHue(untinted, mixed.rgb, saturate(LampTint) * saturate(mixed.w)); + // Turning a grey haze to a colour keeps its value, not its brightness: a + // red as light as the grey was has a fifth of its luminance, so a veil + // over the body went darker as the lamp came up, where the lamp itself + // had little to add yet. While the lamp is weak the haze keeps enough of + // its grey to hold three fifths of its brightness, pink rather than dark + // red, and gives it up as the lamp's own glow takes the body over. + float3 lumWeights = float3(0.2126, 0.7152, 0.0722); + float lumBefore = dot(untinted, lumWeights); + float lumAfter = dot(BaseColor, lumWeights); + // The bulbs' effective drive, as the body counts it: colour times level. + float litNow = max(max(LampColor.r, max(LampColor.g, LampColor.b)) * saturate(LampLevel), + max(LampB.w > 0.0 ? max(DriveB.r, max(DriveB.g, DriveB.b)) * saturate(DriveB.w) : 0.0, + max(LampC.w > 0.0 ? max(DriveC.r, max(DriveC.g, DriveC.b)) * saturate(DriveC.w) : 0.0, + LampD.w > 0.0 ? max(DriveD.r, max(DriveD.g, DriveD.b)) * saturate(DriveD.w) : 0.0))); + // A clear cover only: a coloured cover is its colour already. + float clearCover = smoothstep(0.7, 0.95, min(Tint.r, min(Tint.g, Tint.b))); + float floorLum = 0.6 * lumBefore * (1.0 - smoothstep(0.1, 0.5, litNow)) * clearCover; + if (lumAfter < floorLum && lumBefore > lumAfter + 1e-5) { + BaseColor = lerp(BaseColor, untinted, saturate((floorLum - lumAfter) / (lumBefore - lumAfter))); + } + // The slab: its index, the colour the light has taken once it has come + // the tint depth through it, and its thickness, both given in millimetres + // and wanted in metres. The insert writes the thickness for its own depth. + RefractionIndex = max(Ior, 1.0); + TransmittanceColor = saturate(Tint); + AbsorptionDistance = max(TintDepth, 0.01) * 0.001; + SlabThickness = max(Thickness, 0.1) * 0.001; +#if defined(UNIVERSAL_PIPELINE_CORE_INCLUDED) && !defined(SHADERGRAPH_PREVIEW) && (!defined(SHADERPASS_META) || (SHADERPASS != SHADERPASS_META)) + if (_VpeInsertSceneColorAvailable > 0.5 && RefractionIndex > 1.0001) { + float3 positionWS = TransformObjectToWorld(Position); + float3 viewOS = normalize(TransformWorldToObjectDir(GetWorldSpaceNormalizeViewDir(positionWS))); + // Use the cover's projected tangent frame, so a rotated insert keeps + // the same normal-map distortion as the surrounding playfield. + float3 normalOS = normalize(TangentOS * NormalTS.x + BitangentOS * NormalTS.y + float3(0.0, NormalTS.z, 0.0)); + float3 rayOS = refract(-viewOS, normalOS, rcp(RefractionIndex)); + float path = SlabThickness / max(-rayOS.y, 0.05); + // Displace relative to the unrefracted ray at the slab floor. This + // makes the relief appear shallower, rather than translating it. + float3 straightOS = -viewOS * (SlabThickness / max(viewOS.y, 0.05)); + float3 offsetWS = TransformObjectToWorld(Position + rayOS * path - straightOS); + float4 screen = ComputeScreenPos(TransformWorldToHClip(positionWS)); + float4 shifted = ComputeScreenPos(TransformWorldToHClip(offsetWS)); + float2 uv = screen.xy / max(screen.w, 1e-5); + float2 refractedUV = shifted.xy / max(shifted.w, 1e-5); + float rawDepth = SampleSceneDepth(saturate(refractedUV)); + float behind = unity_OrthoParams.w > 0.5 ? LinearDepthToEyeDepth(rawDepth) : LinearEyeDepth(rawDepth, _ZBufferParams); + float coverDepth = -TransformWorldToView(positionWS).z; + // A ball in front of the cover, or an off-screen sample, must not + // be pulled into the insert. Use its undisplaced background there. + if (behind < coverDepth - 0.0001 || any(refractedUV < 0.0) || any(refractedUV > 1.0)) { + refractedUV = uv; + } + float3 transmission = pow(max(TransmittanceColor, 1e-5), path / AbsorptionDistance); + // Scene colour already has URP's lighting units. Only the haze's + // diffuse contribution is scaled; surface highlights stay intact. + Emission = SampleSceneColor(saturate(refractedUV)) * transmission * (1.0 - Alpha); + BaseColor *= Alpha; + Alpha = 1.0; + } +#endif +} +#endif diff --git a/VisualPinball.Unity/Assets/Shaders/Srp/Insert/InsertCover.hlsl.meta b/VisualPinball.Unity/Assets/Shaders/Srp/Insert/InsertCover.hlsl.meta new file mode 100644 index 000000000..91e6171d3 --- /dev/null +++ b/VisualPinball.Unity/Assets/Shaders/Srp/Insert/InsertCover.hlsl.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: a279abca59990fb4ca6f814dd1527731 +ShaderIncludeImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/Assets/Shaders/Srp/Insert/InsertOptics.hlsl b/VisualPinball.Unity/Assets/Shaders/Srp/Insert/InsertOptics.hlsl new file mode 100644 index 000000000..30097cd4a --- /dev/null +++ b/VisualPinball.Unity/Assets/Shaders/Srp/Insert/InsertOptics.hlsl @@ -0,0 +1,701 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// SPDX-License-Identifier: GPL-3.0-or-later +#ifndef VPE_INSERT_OPTICS_INCLUDED +#define VPE_INSERT_OPTICS_INCLUDED + +// Read the sky in both pipelines, rather than a local reflection probe. +// Decode URP's environment texture before comparing room brightness. +float3 InsertSampleRoom(float3 direction, float mip) +{ +#if defined(SHADERGRAPH_PREVIEW) + return 1.0; +#elif defined(UNIVERSAL_PIPELINE_CORE_INCLUDED) + float4 encoded = SAMPLE_TEXTURECUBE_LOD(_GlossyEnvironmentCubeMap, sampler_GlossyEnvironmentCubeMap, direction, mip); + return DecodeHDREnvironment(encoded, _GlossyEnvironmentCubeMap_HDR); +#else + return SampleSkyTexture(direction, mip, 0).rgb; +#endif +} + +float3 InsertFacetRandom(float sector, float seed) +{ + return frac(sin((sector + seed * 17.13) * float3(127.1, 311.7, 74.7)) * 43758.5453); +} + +float3 InsertNoiseHash(float3 p) +{ + float3 h = frac(p * float3(0.1031, 0.1030, 0.0973)); + h += dot(h, h.yxz + 33.33); + return frac((h.xxy + h.yxx) * h.zyx); +} + +// Soft nearest-cell values: small cloudy variations without hard Voronoi +// borders. Coordinates are local metres, in all three axes, so atlas seams +// cannot split them and no slope stretches them: a starburst's facets stand +// steep, and a noise laid out over the floor with a share of the height +// mixed in was sheared along one slope of every ridge and squeezed on the +// other, into streaks along the facets seen on every other pleat. Noise in +// three dimensions is the same on any face, walls included. +float InsertCellular(float3 p) +{ + float3 cell = floor(p); + float3 local = frac(p); + float value = 0.0; + float weight = 0.0; + [unroll] for (int z = -1; z <= 1; z++) { + [unroll] for (int y = -1; y <= 1; y++) { + [unroll] for (int x = -1; x <= 1; x++) { + float3 offset = float3(x, y, z); + float3 random = InsertNoiseHash(cell + offset); + float3 delta = offset + 0.15 + 0.7 * random - local; + float w = exp2(-8.0 * dot(delta, delta)); + value += InsertNoiseHash(cell + offset + 17.0).x * w; + weight += w; + } + } + } + return value / max(weight, 1e-5); +} + +float InsertGrain(float3 p) +{ + float3 cell = floor(p); + float3 t = frac(p); + t = t * t * (3.0 - 2.0 * t); + float4 below = float4(InsertNoiseHash(cell).z, InsertNoiseHash(cell + float3(1, 0, 0)).z, + InsertNoiseHash(cell + float3(0, 1, 0)).z, InsertNoiseHash(cell + float3(1, 1, 0)).z); + float4 above = float4(InsertNoiseHash(cell + float3(0, 0, 1)).z, InsertNoiseHash(cell + float3(1, 0, 1)).z, + InsertNoiseHash(cell + float3(0, 1, 1)).z, InsertNoiseHash(cell + 1.0).z); + float4 layer = lerp(below, above, t.z); + return lerp(lerp(layer.x, layer.y, t.x), lerp(layer.z, layer.w, t.x), t.y); +} + +// The height of the surface grain, two octaves of the value noise like the +// bake's, in cells. +float InsertSurfaceGrainHeight(float3 cells) +{ + return InsertGrain(cells) + 0.5 * InsertGrain(cells * 2.07 + 17.3); +} + +// Ray tracing stages have no screen-space derivatives, so the detail stays +// fully visible there. +float InsertDetailVisibility(float3 p) +{ +#if defined(SHADER_STAGE_RAY_TRACING) + return 1.0; +#else + float footprint = max(length(ddx(p)), length(ddy(p))); + return 1.0 - smoothstep(0.35, 1.0, footprint); +#endif +} + +float InsertPixelWidth(float x) +{ +#if defined(SHADER_STAGE_RAY_TRACING) + return 0.0; +#else + return fwidth(x); +#endif +} + +// All positions and directions are in insert local space, in metres. This +// works per eye and at arbitrary camera angles without a scene-colour buffer. +// Hue, saturation and value, hue as a turn of the wheel in 0..1. +float3 InsertOpticsRgbToHsv(float3 c) +{ + float4 K = float4(0.0, -1.0 / 3.0, 2.0 / 3.0, -1.0); + float4 p = lerp(float4(c.bg, K.wz), float4(c.gb, K.xy), step(c.b, c.g)); + float4 q = lerp(float4(p.xyw, c.r), float4(c.r, p.yzx), step(p.x, c.r)); + float d = q.x - min(q.w, q.y); + float e = 1.0e-10; + return float3(abs(q.z + (q.w - q.y) / (6.0 * d + e)), d / (q.x + e), q.x); +} + +float3 InsertOpticsHsvToRgb(float3 c) +{ + float4 K = float4(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0); + float3 p = abs(frac(c.xxx + K.xyz) * 6.0 - K.www); + return c.z * lerp(K.xxx, saturate(p - K.xxx), c.y); +} + +// Turns the colour's hue towards the lamp's, and its saturation towards the +// lamp's, never below its own, both by the amount; the value is never touched, so a grey haze comes out the lamp's +// colour, as bright as the grey was, and never darker. An RGB lamp carries +// its intensity in its colour, and a dim lamp is still its colour: the amount +// is full from a tenth of full brightness up, and only a lamp that is off, +// its channels all down, leaves the colour untouched. A white lamp has no hue +// to give. +float3 InsertOpticsTowardsLampHue(float3 color, float3 lamp, float amount) +{ + float3 hsv = InsertOpticsRgbToHsv(max(color, 0.0)); + float3 lampHsv = InsertOpticsRgbToHsv(max(lamp, 0.0)); + amount = saturate(amount) * saturate(lampHsv.z * 10.0); + // The hue turns the short way round the wheel, by the lamp's share of the + // saturation the mix ends up with: a grey, which has no hue of its own, + // takes the lamp's hue at any amount, its saturation growing with it, + // instead of a hue part of the way round the wheel, so a grey haze half + // tinted by a blue lamp is a paler blue, never pink; two colours as + // saturated as each other turn by the amount itself. + float turn = frac(lampHsv.x - hsv.x + 0.5) - 0.5; + float share = saturate(amount * lampHsv.y / max(lerp(hsv.y, lampHsv.y, amount), 1e-5)); + hsv.x = frac(hsv.x + turn * share); + hsv.y = lerp(hsv.y, max(hsv.y, lampHsv.y), amount); + return InsertOpticsHsvToRgb(hsv); +} + +// One virtual bulb's share of a point: the halo its facet shows (x), how much +// of the cavity under it a clear body's face looks onto (y), how far the point +// sits from it for the spread (z, one without a spread), and the tight image +// of the bulb itself (w), kept apart from the halo so it can take a colour of +// its own. Position and the refracted view ray are in insert space, the lamp +// its position in metres with its radius in w. +float4 InsertOpticsBulb(float4 lamp, float3 position, float3 ray, float faceGain, float fresnel, float exit, + float responseB, float cavitySpread, float spread) +{ + float distance = max(position.y - lamp.y, 0.0) / max(-ray.y, 0.12); + float2 hit = position.xz + ray.xz * distance; + float radius = max(abs(lamp.w), 0.0001); + // Keep a common source behind every face. Moving the source independently + // per sector turns the bulb into unrelated gradients and destroys depth. + float2 delta = (hit - lamp.xz) / radius; + float direct = exp2(-2.0 * dot(delta, delta)); + // A tighter image of the bulb inside its halo, as strong as the looks + // always had it. + float bulb = exp2(-12.0 * dot(delta, delta)) * 3.7; + // A wider reflected image stands in for a second internal bounce. + float2 reflectedHit = position.xz - ray.xz * distance * 0.65; + float2 reflectedDelta = (reflectedHit - lamp.xz) / (radius * 1.5); + float bounced = exp2(-2.0 * dot(reflectedDelta, reflectedDelta)); + // The tight source image survives even on the less efficient slopes; + // the broad transmitted component still carries face-to-face contrast. + // Both are what the facet lets through, none at all once it traps the + // ray; the bounce is what it throws back and stays. + float passed = responseB * (1.0 - 0.65 * fresnel); + float transmitted = passed * (direct * exit + 0.17 * bounced) * faceGain; + float image = passed * bulb * exit * lerp(1.0, faceGain, 0.25); + // Each face shows the part of the cavity its refracted view ray lands on, + // spread wide and kept soft, so the ribs shift with the view instead of + // alternating with the lights. + float wide = radius * max(cavitySpread, 0.1); + float2 cavityDelta = (hit - lamp.xz) / wide; + float2 cavityBounce = (reflectedHit - lamp.xz) / (wide * 1.5); + float seen = exit * exp2(-dot(cavityDelta, cavityDelta)) + 0.3 * exp2(-dot(cavityBounce, cavityBounce)); + // Every facet tilted towards the lamp shows its halo, however far from the + // lamp it sits, and the baked glow reaches the whole body alike, so raising + // the brightness floods the body. A real insert is brightest around its + // bulb and dims away from it, the light spreading out from the bulb as + // from a point, by the inverse square of the distance: the spread, in + // millimetres, is the distance from the lamp at which the lit look has + // fallen to half, a fifth at twice that and a tenth at three times, a long + // reach so that one bulb of a banana still shows at its far end and two + // bulbs meet in a soft blend, never a seam. Zero leaves it everywhere alike. + float leak = 1.0; + if (spread > 0.0) { + float2 away = (position.xz - lamp.xz) / (spread * 0.001); + leak = rcp(1.0 + dot(away, away)); + } + return float4(transmitted, seen, leak, image); +} + +// What a facet reflects of the light inside the plastic, seen from the dense +// side: Schlick on the angle in the air beyond it, and all of it once the +// facet traps the ray. +float InsertInternalReflectance(float cosInside, float eta, float reflectance0) +{ + float sinOutside = eta * sqrt(saturate(1.0 - cosInside * cosInside)); + if (sinOutside >= 1.0) { + return 1.0; + } + float cosOutside = sqrt(saturate(1.0 - sinOutside * sinOutside)); + return reflectance0 + (1.0 - reflectance0) * pow(1.0 - cosOutside, 5.0); +} + +// Whether a ray inside the plastic, pointing up, gets out through the flat +// top: not past the critical angle, faded over the last tenth so nothing +// jumps as the eye moves. +float InsertEscapes(float3 ray, float eta) +{ + float sinOutside2 = eta * eta * (1.0 - ray.y * ray.y); + return step(0.0, ray.y) * (1.0 - smoothstep(0.8, 1.0, sinOutside2)); +} + +// The room, as a ray leaving through the top sees it: bent out of the +// plastic, turned to world space, and read from the sky's reflection cubemap +// at a blurred mip, which is what a moulded, slightly rough groove returns. +float3 InsertRoom(float3 ray, float eta, float blur) +{ + float3 outside = refract(ray, float3(0.0, -1.0, 0.0), eta); + float3 world = TransformObjectToWorldDir(outside + float3(0.0, 1e-5, 0.0)); + return InsertSampleRoom(world, blur); +} + +// The sky's average brightness round the insert: its most blurred mip +// straight up and to the four sides, the hemisphere a groove returns. +float InsertSkyAverage() +{ + float3 lum = float3(0.2126, 0.7152, 0.0722); + float sum = dot(InsertSampleRoom(float3(0.0, 1.0, 0.0), 6.0), lum) + + dot(InsertSampleRoom(float3(1.0, 0.0, 0.0), 6.0), lum) + + dot(InsertSampleRoom(float3(-1.0, 0.0, 0.0), 6.0), lum) + + dot(InsertSampleRoom(float3(0.0, 0.0, 1.0), 6.0), lum) + + dot(InsertSampleRoom(float3(0.0, 0.0, -1.0), 6.0), lum); + return max(sum * 0.2, 1e-6); +} + +// How much of the room a ray going down into a groove brings back out +// through the top, and, where the sky may be read, the room it brings. The +// groove is the facet the ray meets first and its mirrored neighbour. After +// each facet the ray goes on to the other one if it heads into it, which is +// when it climbs more slowly than that facet rises; otherwise it goes up to +// the top, where what gets out is the room seen that way and the rest comes +// back down, under total internal reflection all of it, onto whichever +// facet it heads into. A groove near a right angle returns the ray off the +// two facets, a corner reflector; a shallower one throws it up to the top, +// which sends it down onto the other facet, and that one out. Each facet +// takes its Fresnel share; four facets are as far as it goes. Where the ray +// comes down from the top as squarely onto one facet as onto the other, the +// choice between them would flip the rest of its path from one side of the +// room to the other within a facet: what comes back after such a choice is +// faded in as the choice firms up, so nothing steps. The room is +// the sky over its average brightness: how much brighter or darker it is +// that way than all round. Where the sky isn't read, it is the same all round. +float InsertRoomPaths(float3 ray, float3 bent, float3 neighbour, float eta, float reflectance0, float blur, bool readRoom, out float3 room) +{ + room = float3(0.0, 0.0, 0.0); + float back = 0.0; + float carried = 1.0; + // How firmly the path's facet choices were made, fading what follows a near tie. + float firm = 1.0; + float3 d = ray; + bool onBent = true; + for (int i = 0; i < 4; i++) { + float3 facet = onBent ? bent : neighbour; + float3 other = onBent ? neighbour : bent; + carried *= InsertInternalReflectance(saturate(dot(-d, facet)), eta, reflectance0); + d = reflect(d, facet); + if (dot(-d, other) > 0.0) { + onBent = !onBent; + continue; + } + if (d.y <= 0.0) { + break; + } + float escaping = InsertEscapes(d, eta) * (1.0 - InsertInternalReflectance(saturate(d.y), eta, reflectance0)); + float leaving = carried * escaping; + carried -= leaving; + leaving *= firm; + back += leaving; + if (readRoom && leaving > 0.0) { + room += leaving * InsertRoom(d, eta, blur); + } else if (!readRoom) { + room += leaving; + } + d.y = -d.y; + float towardsBent = dot(-d, bent); + float towardsNeighbour = dot(-d, neighbour); + if (max(towardsBent, towardsNeighbour) <= 0.0) { + break; + } + onBent = towardsBent >= towardsNeighbour; + firm *= smoothstep(0.0, 0.05, abs(towardsBent - towardsNeighbour)); + } + if (readRoom && back > 0.0) { + room /= InsertSkyAverage(); + } + return back; +} + +void InsertOptics_float(float3 Position, float3 View, float2 UV, + UnityTexture2D OpticalNormal, UnityTexture2D Response, UnityTexture2D SurfaceNormal, + float4 Lamp, float Ior, float Scatter, float EdgeGlow, float LampGain, + float NormalStrength, float3 Tint, float3 LampColor, float3 DriveColor, float Level, + float4 Starburst, float Contrast, float Variation, float Seed, float Brightness, + float MottleSize, float Grain, float MinBrightness, + float3 WorldNormal, float3 WorldTangent, float3 WorldBitangent, float Flatten, + float Clarity, float3 CavityColor, float CavitySpread, float CavityContrast, + float LampTint, float Spread, float4 LampB, float4 LampC, float4 LampD, + float4 DriveB, float4 DriveC, float4 DriveD, + float SurfaceGrain, float SurfaceGrainSize, float Output, float Roughness, + float Flecks, float FleckSize, float RoomReturn, float RoomBlur, float RoomReturnEV, float UrpEmissionScale, + out float3 BaseColor, out float3 NormalTS, out float3 Emission) +{ + // How rough the structure inside the plastic is: smooth facets and + // channels couple the lamp into crisp ribs and rays, rough ones diffuse + // it, so the ribs soften and the lamp's rays and image go into the glow. + // The top's own finish is the cover's business, not this. + float wear = saturate(Roughness); + Contrast *= 1.0 - 0.7 * wear; + float3 rawNormal = SAMPLE_TEXTURE2D(OpticalNormal.tex, OpticalNormal.samplerstate, UV).rgb * 2.0 - 1.0; + float3 n = normalize(rawNormal + float3(0, 1e-5, 0)); + float3 response = SAMPLE_TEXTURE2D(Response.tex, Response.samplerstate, UV).rgb; + float3 v = normalize(View + float3(0, 1e-5, 0)); + // The facets the bake laid out, found again from the position: a wheel's + // sectors round its centre, ribs' strips across their direction, which + // the bake tells apart by a half pitch made negative in z, or rings' + // strips out from their centre, the half pitch pushed past minus a + // thousand. Strips are counted from far off, so the index stays positive + // and the neighbours either side need no wrapping round. A gem, its sides + // pushed past two thousand, has facets no angle alone finds again, so its + // variation turns round the centre smoothly, one value a side, with no + // step to land in the middle of a facet. + bool ringed = Starburst.z <= -1000.0; + bool ribbed = Starburst.z < 0.0 && !ringed; + bool gem = Starburst.z >= 2000.0; + float sectors = Starburst.z < 0.0 ? 1e6 : gem ? max(Starburst.z - 2000.0, 1.0) : max(Starburst.z, 1.0); + float coordinate; + if (ringed) { + coordinate = length(Position.xz - Starburst.xy) / max(-Starburst.z - 1000.0, 1e-6) + 4096.0; + } else if (ribbed) { + coordinate = dot(Position.xz, Starburst.xy) / -Starburst.z + 4096.0; + } else { + float2 radial = Position.xz - Starburst.xy; + float angle = atan2(radial.y, radial.x); + coordinate = frac(angle * (1.0 / 6.28318530718) + 1.0) * sectors; + } + float sector = floor(coordinate); + float3 random = InsertFacetRandom(sector, Seed); + // Fade the random coefficients across less than one screen pixel at a + // crease. Their identity comes from the fan, never quantized bake noise. + float width = gem ? 0.5 : min(0.45, max(InsertPixelWidth(coordinate), 0.01)); + float part = frac(coordinate); + float previous = fmod(sector + sectors - 1.0, sectors); + float next = fmod(sector + 1.0, sectors); + random = lerp(InsertFacetRandom(previous, Seed), random, smoothstep(-width, width, part)); + random = lerp(random, InsertFacetRandom(next, Seed), smoothstep(1.0 - width, 1.0 + width, part)); + float floorMask = (Starburst.z < 0.0 ? 1.0 : step(4.0, Starburst.z)) * smoothstep(0.35, 0.7, response.b) + * smoothstep(Starburst.w - 0.00015, Starburst.w + 0.00015, Position.y); + float3 cells = Position / max(MottleSize * 0.001, 0.0001) + Seed * float3(7.13, 3.71, 5.29); + float mottle = (InsertCellular(cells) - 0.5) * InsertDetailVisibility(cells); + float3 grainPosition = Position * 4000.0 + Seed * float3(13.1, 17.7, 11.3); + float grain = (InsertGrain(grainPosition) - 0.5) * InsertDetailVisibility(grainPosition); + float finish = 1.0 + grain * Grain; + // The flecks of a moulded insert: the ridges between the facets never + // come out of the mould clean, and under room light every nick and + // every bit of flash along them catches the light as a bright dash, + // stretched along the rib it sits on, while the faces between stay + // plain. Sparse cells of the cloudy noise, drawn long along the ribs + // and gathered to the creases. Each is a small tilted face in the + // plastic: its slope tilts the shading normal, so a fleck lights up + // when a light or the room reflects off it and goes quiet when none + // does, and only a little of it brightens the plastic flat, lit and + // unlit, so the dashes are there in any light. Zero leaves a clean + // moulding. + float2 fleckSlope = float2(0.0, 0.0); + if (Flecks > 0.0) { + float2 radial = Position.xz - Starburst.xy; + radial *= rsqrt(max(dot(radial, radial), 1e-10)); + float2 along = ringed ? float2(-radial.y, radial.x) + : ribbed ? normalize(float2(-Starburst.y, Starburst.x) + float2(1e-6, 0.0)) + : gem ? float2(1.0, 0.0) : radial; + float2 across = float2(-along.y, along.x); + float size = max(FleckSize * 0.001, 1e-5); + // Across a radial rib the position has no component of its own, so + // the facet's index moves each rib to cells of its own. + float3 fleckCells = float3(dot(Position.xz, along) / (size * 4.0), Position.y / size, dot(Position.xz, across) / size + sector * 3.7) + + Seed * float3(5.1, 2.3, 8.7); + float crease = 1.0 - smoothstep(0.0, 0.4, min(part, 1.0 - part)); + float weight = lerp(0.5, 1.0, crease) * floorMask * InsertDetailVisibility(fleckCells); + // The value noise, not the cloudy cellular one: the three samples + // here cost a fraction of the cellular's twenty-seven cells each, + // and thresholded they give the same dashes. + float here = smoothstep(0.42, 0.6, InsertGrain(fleckCells)); + // The slope of the dashes along the tangent and bitangent the normal + // map is read in, a tenth of a cell apart, in the stretched cells. + float3 tangentOS = normalize(TransformWorldToObjectDir(WorldTangent) + float3(1e-6, 0.0, 0.0)); + float3 bitangentOS = normalize(TransformWorldToObjectDir(WorldBitangent) + float3(0.0, 1e-6, 0.0)); + const float stepCells = 0.1; + float3 alongT = stepCells * float3(dot(tangentOS.xz, along) * 0.25, tangentOS.y, dot(tangentOS.xz, across)); + float3 alongB = stepCells * float3(dot(bitangentOS.xz, along) * 0.25, bitangentOS.y, dot(bitangentOS.xz, across)); + float heightT = smoothstep(0.42, 0.6, InsertGrain(fleckCells + alongT)); + float heightB = smoothstep(0.42, 0.6, InsertGrain(fleckCells + alongB)); + fleckSlope = float2(heightT - here, heightB - here) / stepCells * weight; + finish *= 1.0 + 0.35 * here * weight * saturate(Flecks); + } + // The facets bend the ray as the bake found them. Flattening steep walls + // keeps this deliberately artistic ray pointed down. + n = normalize(float3(n.x, max(abs(n.y), 0.25), n.z)); + // The view ray enters the plastic through its flat top, bending towards + // the vertical, and leaves it through the facet underneath into the air + // of the cavity, bending back the other way, the facet a prism, before it + // runs on down to the bulb. Traced so, the bulb sits its full depth below + // the insert: it shifts against the body by the whole of the depth times + // the view's tilt as the eye moves, and each eye of a headset sees it + // where its own ray meets it. One refraction with the facet's normal kept + // the steeper in-plastic ray all the way down, half the parallax, and bent + // it the wrong way round the facet. What the facet lets through falls + // with the exit's Fresnel reflectance. + // + // A facet steep enough traps the ray, total internal reflection, or lets + // it out so near the grazing that it runs off sideways and never meets + // the lamp: a starburst of narrow spokes over a tall ridge has such + // facets everywhere, and seen along its length, where the in-plastic ray + // crosses every facet at its full tilt, the whole bulb went missing while + // it showed from the side. Through a V groove that steep a trapped ray + // leaves by the facet next door, thrown out to the side, so a mirrored + // path would not find the bulb either. What such a facet would trap or + // throw away goes straight on instead, as through a flat bottom, blended + // in as the facet's exit nears the critical angle so nothing jumps: the + // bulb keeps its place and its parallax from every angle, and the facets + // keep their prism wherever they pass the ray. + float eta = max(Ior, 1.001); + float3 inside = refract(-v, float3(0.0, 1.0, 0.0), rcp(eta)); + float reflectance0 = (eta - 1.0) / (eta + 1.0); + reflectance0 *= reflectance0; + // The sine of the facet's exit angle, past one where it traps the ray. + float cosInside = saturate(dot(-inside, n)); + float sinFacet = eta * sqrt(saturate(1.0 - cosInside * cosInside)); + float passing = 1.0 - smoothstep(0.75, 1.0, sinFacet); + float3 facetRay = passing > 0.0 ? refract(inside, n, eta) : -v; + float cosFacet = saturate(dot(facetRay, -n)); + float facetExit = 1.0 - (reflectance0 + (1.0 - reflectance0) * pow(1.0 - cosFacet, 5.0)); + float flatExit = 1.0 - (reflectance0 + (1.0 - reflectance0) * pow(1.0 - saturate(v.y), 5.0)); + float3 ray = normalize(lerp(-v, facetRay, passing)); + float exit = lerp(flatExit, facetExit, passing); + float facing = saturate(dot(v, n)); + float fresnel = 0.04 + 0.96 * pow(1.0 - facing, 5.0); + // Opposite slopes couple very differently to the same internal light. + // This contrast fills the face instead of outlining its rounded edge. + float2 bias = v.xz + float2(0.38, -0.21); + bias *= rsqrt(max(dot(bias, bias), 1e-5)); + float coupling = saturate(0.5 + 1.8 * dot(n.xz, bias)); + float faceGain = lerp(1.0, 0.12 + 1.8 * pow(coupling, 1.3), Contrast * floorMask); + faceGain *= 1.0 + Variation * floorMask * ((random.z - 0.5) * 0.18 + mottle * 0.7); + // An insert has one bulb or a few, a banana its three or four in a row: + // every bulb whose radius is set adds its own halo, each fading with its + // own spread; the baked glow, one for the whole body, fades with the + // nearest bulb, and a clear body's face looks onto whichever cavity its + // ray lands on. + float4 bulbA = InsertOpticsBulb(Lamp, Position, ray, faceGain, fresnel, exit, response.b, CavitySpread, Spread); + float4 bulbB = LampB.w > 0.0 ? InsertOpticsBulb(LampB, Position, ray, faceGain, fresnel, exit, response.b, CavitySpread, Spread) : float4(0.0, 0.0, 0.0, 0.0); + float4 bulbC = LampC.w > 0.0 ? InsertOpticsBulb(LampC, Position, ray, faceGain, fresnel, exit, response.b, CavitySpread, Spread) : float4(0.0, 0.0, 0.0, 0.0); + float4 bulbD = LampD.w > 0.0 ? InsertOpticsBulb(LampD, Position, ray, faceGain, fresnel, exit, response.b, CavitySpread, Spread) : float4(0.0, 0.0, 0.0, 0.0); + // Each bulb is driven on its own, the first by the lamp colour and level, + // the others by a colour with the level in w: its halo carries its own + // colour, and the baked glow, one for the whole body, is the plain sum of + // every bulb's light, each reaching as far as its spread lets it, colour + // and all. So one bulb of a banana lights its end and fades along the + // body, undimmed by the bulbs beside it that are off, two bulbs on add up + // where they meet, and a bulb fading in colours every point it reaches in + // proportion to its level, from the first glimmer on, instead of its + // colour creeping in from the bulb once it is bright enough to win. + // One kind of bulb under the insert: an LED kit gives more light than + // the filaments it replaced, at the same level and colour. + float output = max(Output, 0.0); + float3 driveA = max(DriveColor, 0.0) * saturate(Level) * output; + float3 driveB = max(DriveB.rgb, 0.0) * saturate(DriveB.w) * output; + float3 driveC = max(DriveC.rgb, 0.0) * saturate(DriveC.w) * output; + float3 driveD = max(DriveD.rgb, 0.0) * saturate(DriveD.w) * output; + float3 transmitted = bulbA.x * bulbA.z * driveA + bulbB.x * bulbB.z * driveB + bulbC.x * bulbC.z * driveC + bulbD.x * bulbD.z * driveD; + float3 image = bulbA.w * bulbA.z * driveA + bulbB.w * bulbB.z * driveB + bulbC.w * bulbC.z * driveC + bulbD.w * bulbD.z * driveD; + float4 level = float4(max(driveA.r, max(driveA.g, driveA.b)), max(driveB.r, max(driveB.g, driveB.b)), + max(driveC.r, max(driveC.g, driveC.b)), max(driveD.r, max(driveD.g, driveD.b))); + float4 reach = float4(bulbA.z, bulbB.z, bulbC.z, bulbD.z); + float3 glowDrive = reach.x * driveA + reach.y * driveB + reach.z * driveC + reach.w * driveD; + // The light's colour at unit level, for the tint's hue. + float3 chroma = glowDrive / max(dot(reach, level), 1e-6); + // How far the bulbs' colour reaches for the tint: as the light does, full + // to a tenth of the bulb's own and fading beyond, from a bulb that counts + // as on from a tenth of full up, so a bulb fading in tints its whole + // reach at once and grows brighter within it, never creeping outwards + // from the bulb as the light itself climbs. + float tinted = dot(saturate(reach * 10.0), saturate(level * 10.0)); + float seen = saturate(bulbA.y + bulbB.y + bulbC.y + bulbD.y); + float glow = Scatter * response.r * lerp(1.0, faceGain, floorMask * 0.65) + + EdgeGlow * response.g * (0.25 + 0.75 * response.r); + // The material sets full-intensity brightness and its emission colour; ROM + // events only change each bulb's normalized level and RGB through the + // renderer's property block. + float3 emission = max(LampColor, 0.0); + // A rough structure scatters the rays and the bulb's image into the glow. + float sharp = 1.0 - wear; + Emission = max(Brightness, 0.0) * emission * (glow * glowDrive * (1.0 + 0.5 * wear) + LampGain * (transmitted + image) * sharp) * finish; + // Darken the unlit plastic without keeping the bulb or spill light on. + // Fade back to the authored albedo as the ROM raises lamp intensity. + // Any bulb that is on lights the plastic, not only the first: a multi-bulb + // insert with its first bulb off and another on is as bright as with the + // first on. Only bulbs that exist count. + float bulbsUp = max(saturate(Level), max(LampB.w > 0.0 ? saturate(DriveB.w) : 0.0, + max(LampC.w > 0.0 ? saturate(DriveC.w) : 0.0, LampD.w > 0.0 ? saturate(DriveD.w) : 0.0))); + float bodyBrightness = lerp(saturate(MinBrightness), 1.0, bulbsUp); + float cavity = 0.35 + 0.65 * response.r; + float3 tintedBody = Tint * cavity * bodyBrightness; + // Clear plastic absorbs nothing: an off clear insert shows the bright lamp + // board or reflector under it, lit by room light that came in through the + // top, lamp or no lamp. Each face shows the part of that cavity its + // refracted view ray lands on, as the bulbs found above. The walls and the + // channel get their share by internal reflection: the baked irradiance. + float cavityView = lerp(1.0, seen, saturate(CavityContrast)); + // Room light reaches the whole cavity, so its irradiance shapes the clear + // body less than it darkens the tinted one. + float3 clearBody = CavityColor * (0.6 + 0.4 * response.r) * lerp(0.6, cavityView, floorMask); + BaseColor = lerp(tintedBody, Tint * clearBody, saturate(Clarity)) * finish; + // Moulded plastic is never evenly clear: under room light it is cloudy, + // blotches of it lighter and darker than the rest, up to two fifths + // either way at full Variation. The cellular noise averages its + // neighbours, so it is widened to its full range first. A blotch + // brightens no further than the albedo's brightest channel can go. + float mottleGain = 1.0 + Variation * 0.8 * clamp(mottle * 1.4, -0.5, 0.5); + float bodyPeak = max(BaseColor.r, max(BaseColor.g, BaseColor.b)); + BaseColor *= min(mottleGain, max(1.0, rcp(max(bodyPeak, 1e-5)))); + // Light that came in through the top and went down to a groove does not + // all leave into the cavity: the facet throws part of it, or under total + // internal reflection all of it, across to the facet next door, which + // throws it back up and out through the top, the way a corner reflector + // returns a beam. So a clear insert in a room shows the room above it in + // its ribs, brighter from a low view where the top reflects more, and the + // ribs leaning one way return a different part of the room than those + // leaning the other, exchanging their brightness as the eye moves round: + // the movement under the top that tells clear plastic from paint. The + // path is the view ray refracted in at the top, reflected off the facet + // the bake found, its mirrored neighbour and the top as the groove's + // angle sends it, each reflection weighed by the plastic's own Fresnel, + // then out through the top into the sky's reflection cubemap. What comes + // back takes the place of as much of the diffuse cavity as it weighs, in + // the albedo: the room is only as bright as the light that falls on the + // insert, a directional light, the probes or the sky's own, so the sky + // gives the room its shape, brighter one way and darker another, and the + // scene's lighting its brightness, in a dark room as in a bright one. + float returning = 0.0; + if (RoomReturn > 0.0 && Clarity > 0.0) { + float3 facet = normalize(rawNormal + float3(0.0, 1e-5, 0.0)); + facet.y = abs(facet.y); + // The rib runs radially from the wheel's centre, across its own + // direction for ribs, round the centre for rings; the neighbouring + // facet is this one mirrored across the rib, its lean along the rib + // kept. A moulded facet is never quite flat: a slight bend across it, + // a degree and a half from one crease to the other, lets a broad + // highlight sweep within a stripe instead of switching whole. + float2 radialDir = Position.xz - Starburst.xy; + radialDir *= rsqrt(max(dot(radialDir, radialDir), 1e-10)); + float2 ribDir = ringed ? float2(-radialDir.y, radialDir.x) + : ribbed ? normalize(float2(-Starburst.y, Starburst.x) + float2(1e-6, 0.0)) + : gem ? float2(1.0, 0.0) : radialDir; + float3 along = float3(ribDir.x, 0.0, ribDir.y); + // The bend dies out at the creases so nothing jumps there. + float bend = (part - 0.5) * 0.0524 * smoothstep(0.0, 0.25, min(part, 1.0 - part)); + float3 bent = facet * cos(bend) + cross(along, facet) * sin(bend) + along * dot(along, facet) * (1.0 - cos(bend)); + float3 across = float3(-along.z, 0.0, along.x); + float3 neighbour = normalize(bent - 2.0 * dot(bent, across) * across); + // The lightmap pass doesn't read the sky; to it the room is the same + // all round. +#if defined(SHADERPASS) && ((defined(SHADERPASS_LIGHT_TRANSPORT) && (SHADERPASS == SHADERPASS_LIGHT_TRANSPORT)) || (defined(SHADERPASS_META) && (SHADERPASS == SHADERPASS_META))) + bool readRoom = false; +#else + bool readRoom = true; +#endif + // The return switches from one facet's path to its neighbour's at the + // crease, texel by texel; faded over a couple of screen pixels either + // side, as the facet coefficients are, the switch no longer stairs. + float creaseFade = smoothstep(0.0, 2.5 * width, min(part, 1.0 - part)); + float share = saturate(RoomReturn) * saturate(Clarity) * floorMask * flatExit * creaseFade; + float3 room = float3(0.0, 0.0, 0.0); + float back = 0.0; + if (share > 0.0) { + back = InsertRoomPaths(inside, bent, neighbour, eta, reflectance0, RoomBlur, readRoom, room); + } + // The room comes back whether the lamp is on or off, as it does on + // the real thing: a lit insert still reflects the room above it. But + // inside a body flooded with the lamp's colour a white reflection + // reads wrong, so the returned room takes the colour of the bulbs + // that are on, the more the brighter they are, and a bulb barely on + // leaves it white rather than glowing coloured. Only bulbs that exist + // count: a bulb switched off keeps its last drive. + float lit = max(level.x, max(LampB.w > 0.0 ? level.y : 0.0, max(LampC.w > 0.0 ? level.z : 0.0, LampD.w > 0.0 ? level.w : 0.0))); + // The bulb colour is first lifted to the luminance of white, capped so + // a saturated bulb does not blow out, and the tint then runs from + // white to that: blending first and lifting after kept the white half + // of a half-made tint and snapped to the colour only at the end. + float3 lampTint = chroma * min(rcp(max(dot(chroma, float3(0.2126, 0.7152, 0.0722)), 0.05)), 4.0); + float3 roomTint = lerp(float3(1.0, 1.0, 1.0), lampTint, smoothstep(0.02, 0.3, lit)); + returning = share * back; + // The lamp's own light takes the same paths: what a facet throws back + // of the room it throws back of the lamp below, so the returning + // facets stay lighter than their neighbours however bright the lamp + // gets, instead of drowning in it, and in the tonemapper's shoulder + // they go the lighter, paler colour a brighter lamp does. + Emission *= 1.0 + returning * saturate(lit); + // An albedo can't pass one, however bright the room is that way. + BaseColor = saturate(BaseColor * (1.0 - returning) + share * exp2(RoomReturnEV) * room * roomTint); + } + // A grey body under a coloured lamp milks its colour. Tinted with the lamp, + // the body's hue and saturation turn to the light's, the bulbs' colours + // summed as the glow sums them, as far as their light reaches, its value + // untouched, so nothing of the lamps stays once they are off. + float3 untinted = BaseColor; + BaseColor = InsertOpticsTowardsLampHue(BaseColor, chroma, saturate(LampTint) * saturate(tinted)); + // Turning a grey to a colour keeps its value, not its brightness: a red + // as light as the grey was has a fifth of its luminance, so the plastic + // went darker as the lamp came up, where the lamp itself had little to + // add yet. The room light the plastic scatters does not go away for the + // lamp colouring it, so the luminance the albedo lost comes back in the + // albedo, its hue kept and its value raised, as far as the hue lets it: + // lit by whatever lights the insert, as the returned room is, and lifted + // as the returned room is. + // An albedo can be no brighter than its colour allows, a pure red a + // fifth of a grey, so while the lamp is weak the plastic keeps enough of + // its grey to hold three fifths of its brightness, pink rather than dark + // red, and gives it up as the lamp's own glow takes the body over. + float3 lumWeights = float3(0.2126, 0.7152, 0.0722); + float lumBefore = dot(untinted, lumWeights); + float lumAfter = dot(BaseColor, lumWeights); + float litNow = max(level.x, max(LampB.w > 0.0 ? level.y : 0.0, max(LampC.w > 0.0 ? level.z : 0.0, LampD.w > 0.0 ? level.w : 0.0))); + // Clear plastic only: a tinted body is its colour already, lit or not. + float floorLum = 0.6 * lumBefore * (1.0 - smoothstep(0.1, 0.5, litNow)) * saturate(Clarity); + if (lumAfter < floorLum && lumBefore > lumAfter + 1e-5) { + BaseColor = lerp(BaseColor, untinted, saturate((floorLum - lumAfter) / (lumBefore - lumAfter))); + } + float lost = max(lumBefore - dot(BaseColor, lumWeights), 0.0) * saturate(Clarity) * saturate(RoomReturn); + if (lost > 0.0) { + float lum = max(dot(BaseColor, lumWeights), 1e-5); + float peak = max(BaseColor.r, max(BaseColor.g, BaseColor.b)); + BaseColor *= max(min((lum + lost * exp2(RoomReturnEV)) / lum, rcp(max(peak, 1e-5))), 1.0); + } + float4 packedNormal = SAMPLE_TEXTURE2D(SurfaceNormal.tex, SurfaceNormal.samplerstate, UV); + float3 surface = UnpackNormal(packedNormal); + float3 shading = normalize(float3(surface.xy * NormalStrength, lerp(1.0, surface.z, saturate(NormalStrength)))); + // Room light enters a clear insert through its flat top, and the facets + // underneath only redistribute what is already inside. To the lights and + // reflection probes the surface is that flat top, so the shading normal is + // flattened towards it, written in the frame the tangent-space normal is + // read back in. The optical normal that refracts the lamp stays as baked. + float3 upWS = TransformObjectToWorldNormal(float3(0.0, 1.0, 0.0)); + // The interpolated frame need not be orthonormal, so this is the frame's + // proper inverse, not three dot products. + float3 upTS = TransformWorldToTangent(upWS, float3x3(WorldTangent, WorldBitangent, WorldNormal)); + float3 normalTS = lerp(shading, upTS, saturate(Flatten)); + // A facet that returns the room is a mirror inside the plastic to the + // scene's lights as well: to them it keeps its own relief whatever the + // flattening, so a spot or the sun glints off it where the sky alone, + // which carries none of the table's lights, would not. The diffuse stays. + normalTS = lerp(normalTS, shading, saturate(returning)); + // The fine texture of the moulded plastic, in the reflections: a bump of + // noise in insert space, as the bake used to put into the normal map, + // drawn here instead so that it is as fine at any zoom and on any map + // size, and stays continuous over UV seams. Its slope along the tangent + // and bitangent, taken as differences a tenth of a cell apart, tilts the + // shading normal after the flattening, which is the top it sits on. It + // fades out as the cells shrink below a pixel, before it can shimmer. + if (SurfaceGrain > 0.0) { + float3 cellsPerMetre = 1.0 / max(SurfaceGrainSize * 0.001, 1e-5); + float3 grainCells = Position * cellsPerMetre + Seed * float3(3.7, 9.1, 5.3); + float3 tangentOS = normalize(TransformWorldToObjectDir(WorldTangent) + float3(1e-6, 0.0, 0.0)); + float3 bitangentOS = normalize(TransformWorldToObjectDir(WorldBitangent) + float3(0.0, 1e-6, 0.0)); + const float step = 0.1; + float here = InsertSurfaceGrainHeight(grainCells); + float alongT = InsertSurfaceGrainHeight(grainCells + tangentOS * step); + float alongB = InsertSurfaceGrainHeight(grainCells + bitangentOS * step); + float2 slope = float2(alongT - here, alongB - here) / step; + float visible = InsertDetailVisibility(grainCells); + normalTS.xy -= slope * (0.3 * saturate(SurfaceGrain) * visible); + } + // The flecks' tilt, after the flattening, on the top they are cut into. + normalTS.xy -= fleckSlope * (0.25 * saturate(Flecks)); + NormalTS = normalize(normalTS + float3(0.0, 0.0, 1e-5)); +#if defined(UNIVERSAL_PIPELINE_CORE_INCLUDED) + // Preserve the authored nits and lamp property blocks. Only the final + // emission changes units; the resolver supplies UrpPhysicalUnits' scale. + Emission *= UrpEmissionScale; +#endif +} +#endif diff --git a/VisualPinball.Unity/Assets/Shaders/Srp/Insert/InsertOptics.hlsl.meta b/VisualPinball.Unity/Assets/Shaders/Srp/Insert/InsertOptics.hlsl.meta new file mode 100644 index 000000000..27d5b5a01 --- /dev/null +++ b/VisualPinball.Unity/Assets/Shaders/Srp/Insert/InsertOptics.hlsl.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: a8e1d31204aa95b40a3e523f0b2b266a +ShaderIncludeImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/Plugins/linux-x64/libVisualPinballNativeWindows.so.meta b/VisualPinball.Unity/Plugins/linux-x64/libVisualPinballNativeWindows.so.meta new file mode 100644 index 000000000..60ff2f34a --- /dev/null +++ b/VisualPinball.Unity/Plugins/linux-x64/libVisualPinballNativeWindows.so.meta @@ -0,0 +1,33 @@ +fileFormatVersion: 2 +guid: 2553238478f6451cb296432662f93032 +PluginImporter: + externalObjects: {} + serializedVersion: 3 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 1 + isOverridable: 1 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + Android: + enabled: 0 + settings: + Is16KbAligned: false + Any: + enabled: 0 + settings: {} + Editor: + enabled: 1 + settings: + CPU: x86_64 + DefaultValueInitialized: true + OS: Linux + Linux64: + enabled: 1 + settings: + CPU: x86_64 + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/Plugins/win-x64/VisualPinballNativeWindows.dll.meta b/VisualPinball.Unity/Plugins/win-x64/VisualPinballNativeWindows.dll.meta new file mode 100644 index 000000000..47c16d857 --- /dev/null +++ b/VisualPinball.Unity/Plugins/win-x64/VisualPinballNativeWindows.dll.meta @@ -0,0 +1,50 @@ +fileFormatVersion: 2 +guid: f47f7ca3675a47fbae9454acef61d63c +PluginImporter: + externalObjects: {} + serializedVersion: 3 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 1 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + Any: + enabled: 0 + settings: + Exclude Editor: 0 + Exclude Linux64: 1 + Exclude OSXUniversal: 1 + Exclude Win: 1 + Exclude Win64: 0 + Editor: + enabled: 1 + settings: + CPU: x86_64 + DefaultValueInitialized: true + OS: Windows + Linux64: + enabled: 0 + settings: + CPU: x86_64 + OSXUniversal: + enabled: 0 + settings: + CPU: None + Win: + enabled: 0 + settings: + CPU: None + Win64: + enabled: 1 + settings: + CPU: x86_64 + WindowsStoreApps: + enabled: 0 + settings: + CPU: x64 + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Assets.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/Assets.meta new file mode 100644 index 000000000..81ea14387 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Assets.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 5460014938a34cd4bb7b7305458b22d2 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Assets/studio_small_08_1k.exr b/VisualPinball.Unity/VisualPinball.Unity.Editor/Assets/studio_small_08_1k.exr new file mode 100644 index 000000000..be55db450 Binary files /dev/null and b/VisualPinball.Unity/VisualPinball.Unity.Editor/Assets/studio_small_08_1k.exr differ diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Assets/studio_small_08_1k.exr.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/Assets/studio_small_08_1k.exr.meta new file mode 100644 index 000000000..9172c7b93 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Assets/studio_small_08_1k.exr.meta @@ -0,0 +1,117 @@ +fileFormatVersion: 2 +guid: 37d54450f7eb444085b253cd6d150f83 +TextureImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 1 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 0 + wrapV: 0 + wrapW: 0 + nPOTScale: 1 + lightmap: 0 + compressionQuality: 50 + spriteMode: 0 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 0 + spriteTessellationDetail: -1 + textureType: 0 + textureShape: 2 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: {} + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Display/CabinetScreenInspector.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Display/CabinetScreenInspector.cs new file mode 100644 index 000000000..bb4b8a3bf --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Display/CabinetScreenInspector.cs @@ -0,0 +1,92 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using UnityEditor; +using UnityEngine; + +namespace VisualPinball.Unity.Editor +{ + [CustomEditor(typeof(CabinetScreenComponent)), CanEditMultipleObjects] + public class CabinetScreenInspector : ItemInspector + { + private SerializedProperty _roleProperty; + private SerializedProperty _customRoleProperty; + private SerializedProperty _targetProperty; + private SerializedProperty _materialIndexProperty; + private SerializedProperty _textureProperty; + private SerializedProperty _hideRootProperty; + + protected override MonoBehaviour UndoTarget => target as MonoBehaviour; + + protected override void OnEnable() + { + base.OnEnable(); + + _roleProperty = serializedObject.FindProperty(nameof(CabinetScreenComponent.Role)); + _customRoleProperty = serializedObject.FindProperty(nameof(CabinetScreenComponent.CustomRole)); + _targetProperty = serializedObject.FindProperty(nameof(CabinetScreenComponent.Target)); + _materialIndexProperty = serializedObject.FindProperty(nameof(CabinetScreenComponent.MaterialIndex)); + _textureProperty = serializedObject.FindProperty(nameof(CabinetScreenComponent.TextureProperty)); + _hideRootProperty = serializedObject.FindProperty(nameof(CabinetScreenComponent.HideRoot)); + } + + public override void OnInspectorGUI() + { + BeginEditing(); + + PropertyField(_roleProperty); + if (_roleProperty.hasMultipleDifferentValues || _roleProperty.enumValueIndex == (int)CabinetScreenRole.Custom) { + PropertyField(_customRoleProperty, "Custom Role"); + } + PropertyField(_targetProperty); + PropertyField(_materialIndexProperty, "Material Slot"); + PropertyField(_textureProperty, "Texture Property"); + PropertyField(_hideRootProperty, "Hide When Unused"); + + if (!serializedObject.isEditingMultipleObjects && target is CabinetScreenComponent screen) { + Validate(screen); + } + + base.OnInspectorGUI(); + + EndEditing(); + } + + private static void Validate(CabinetScreenComponent screen) + { + var renderer = screen.TargetRenderer; + if (renderer == null) { + EditorGUILayout.HelpBox("Set a target renderer, or add this component to a GameObject with a renderer.", MessageType.Warning); + return; + } + var materials = renderer.sharedMaterials; + if (screen.MaterialIndex < 0 || screen.MaterialIndex >= materials.Length) { + EditorGUILayout.HelpBox($"\"{renderer.name}\" has no material slot {screen.MaterialIndex}.", MessageType.Warning); + return; + } + var property = CabinetScreenBinder.TextureProperty(materials[screen.MaterialIndex], screen.TextureProperty); + if (property == null) { + EditorGUILayout.HelpBox("The material has no texture property to show the screen in.", MessageType.Warning); + return; + } + if (screen.Role == CabinetScreenRole.Custom && string.IsNullOrWhiteSpace(screen.CustomRole)) { + EditorGUILayout.HelpBox("Set the role name the plugin publishes the screen with.", MessageType.Warning); + return; + } + EditorGUILayout.HelpBox($"The screen is shown in {property}.", MessageType.None); + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Display/CabinetScreenInspector.cs.meta b/VisualPinball.Unity/VisualPinball.Unity.Editor/Display/CabinetScreenInspector.cs.meta new file mode 100644 index 000000000..b71af5afd --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Display/CabinetScreenInspector.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 90b40b7891cd8f44d972c8e22fb04c85 \ No newline at end of file diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/PackageScreenshotGenerator.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/PackageScreenshotGenerator.cs index 28e95d483..95fe76669 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/PackageScreenshotGenerator.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/PackageScreenshotGenerator.cs @@ -18,6 +18,7 @@ using System.Collections; using System.Collections.Generic; using System.IO; +using System.Threading.Tasks; using Unity.Mathematics; using UnityEditor; using UnityEngine; @@ -51,7 +52,7 @@ internal static class PackageScreenshotGenerator internal const int PortraitHeight = 3840; internal const TextureFormat ScreenshotTextureFormat = TextureFormat.RGBA32; internal const float FieldOfView = 3f; - internal const string DefaultHdriAssetPath = "Packages/org.visualpinball.engine.unity/VisualPinball.Unity/VisualPinball.Unity.Editor/Assets/studio_small_08_1k.exr"; + internal const string HdriAssetPath = "Packages/org.visualpinball.engine.unity/VisualPinball.Unity/VisualPinball.Unity.Editor/Assets/studio_small_08_1k.exr"; private const string CameraPrefabPath = "Packages/org.visualpinball.engine.unity.hdrp/Assets/EditorResources/Prefabs/Screenshot/Camera.prefab"; private static readonly Quaternion TopDownRotation = Quaternion.Euler(90f, 0f, 0f); @@ -61,11 +62,11 @@ internal static class PackageScreenshotGenerator private const float MinFarPadding = 0.5f; private const byte BackgroundTolerance = 4; - // Shot 1: env (directional) + HDRI, playfield lamps on (flashers off). + // Shot 1: env (directional) + HDRI, inserts lit, every other lamp (GI, bulbs, flashers) off. internal const string FilenameLightsOn = "table-lights-on.png"; - // Shot 2: env (directional) + HDRI, all playfield lamps off (incl. faux bulb). + // Shot 2: env (directional) + HDRI, all playfield lamps and inserts off (incl. faux bulb). internal const string FilenameLightsOff = "table-lights-off.png"; - // Shot 3: HDRI only, no directional, playfield lamps on (flashers off). + // Shot 3: HDRI only, no directional, all playfield lamps and inserts off. internal const string FilenameHdriOnly = "table-hdri.png"; // The generated screenshot files the packager pulls into the .vpe (in shot order). @@ -89,7 +90,50 @@ internal static class PackageScreenshotGenerator // Generate asynchronous: the result arrives via onComplete. private const int SettleFrames = 3; - internal static void Generate(TableComponent tableComponent, string outputFolderPath, Cubemap hdriCubemap, float hdriExposure, + // Every table is shot in the same environment, so the player's table images look alike. Not configurable. + internal const float HdriExposure = 4.5f; + + /// The bundled studio HDRI all package screenshots are lit with. + /// The HDRI is missing from the package. + private static Cubemap LoadHdri() + { + var hdri = AssetDatabase.LoadAssetAtPath(HdriAssetPath); + if (!hdri) { + throw new InvalidOperationException($"The screenshot HDRI is missing at {HdriAssetPath}."); + } + return hdri; + } + + /// + /// Removes previously generated screenshots and their bounds, so a package never picks up images of an + /// earlier state of the table. + /// + internal static void DeleteGenerated(string outputFolderPath) + { + foreach (var fileName in ScreenshotFileNames) { + AssetDatabase.DeleteAsset($"{outputFolderPath}/{fileName}"); + } + AssetDatabase.DeleteAsset($"{outputFolderPath}/{FilenameBounds}"); + } + + /// + /// as a task, completing once all shots are written. Await it on the main thread + /// without blocking it, since the shots render across several editor frames. + /// + internal static Task GenerateAsync(TableComponent tableComponent, string outputFolderPath) + { + var completion = new TaskCompletionSource(); + try { + Generate(tableComponent, outputFolderPath, + result => completion.TrySetResult(result), + error => completion.TrySetException(error)); + } catch (Exception error) { + completion.TrySetException(error); + } + return completion.Task; + } + + internal static void Generate(TableComponent tableComponent, string outputFolderPath, Action onComplete, Action onError = null) { if (!tableComponent) { @@ -105,11 +149,7 @@ internal static void Generate(TableComponent tableComponent, string outputFolder throw new InvalidOperationException("No PlayfieldComponent was found under the selected table."); } - if (!hdriCubemap) { - throw new ArgumentNullException(nameof(hdriCubemap), "A HDRI cubemap is required."); - } - - var routine = GenerateRoutine(tableComponent, outputFolderPath, hdriCubemap, hdriExposure, playfield, onComplete); + var routine = GenerateRoutine(tableComponent, outputFolderPath, LoadHdri(), HdriExposure, playfield, onComplete); EditorApplication.CallbackFunction driver = null; driver = () => { bool moveNext; @@ -194,6 +234,10 @@ private static IEnumerator GenerateRoutine(TableComponent tableComponent, string var camera = InstantiateCamera(); try { + // The render target is portrait, but a fresh camera takes its aspect from the Game view. With the + // prefab's gate-fitted physical lens the field of view depends on the aspect, so fix it before + // framing; otherwise the table shrinks whenever the Game view is landscape. + camera.aspect = PortraitWidth / (float)PortraitHeight; var cameraDistance = CalculateTopDownDistance(playfieldWidth, playfieldHeight, PortraitWidth / (float)PortraitHeight, camera); var cameraPosition = new Vector3(playfieldCenter.x, playfieldCenter.y + cameraDistance, playfieldCenter.z); camera.transform.SetPositionAndRotation(cameraPosition, TopDownRotation); @@ -269,9 +313,9 @@ private static IEnumerator GenerateRoutine(TableComponent tableComponent, string private enum PlayfieldLightMode { - // Non-flasher lamps (inserts + GI) on, flashers off. + // Inserts on; GI, bulbs and flashers off. On, - // All lamps off and their bulbs hidden. + // All lamps and inserts off, their bulbs hidden. Off, } @@ -315,6 +359,8 @@ private readonly struct Entry { public readonly LightComponent Lamp; public readonly bool IsFlasher; + // A lamp under a playfield insert (a VPX-style insert mesh, or a hybrid insert it drives). + public readonly bool IsInsert; public readonly Light[] Lights; public readonly bool[] LightEnabled; // Emissive meshes that LightComponent.Enabled drives (the faux bulbs), @@ -322,10 +368,11 @@ private readonly struct Entry public readonly Renderer[] EmissiveRenderers; public readonly bool[] RendererActive; - public Entry(LightComponent lamp, bool isFlasher, Light[] lights, bool[] lightEnabled, Renderer[] emissiveRenderers, bool[] rendererActive) + public Entry(LightComponent lamp, bool isFlasher, bool isInsert, Light[] lights, bool[] lightEnabled, Renderer[] emissiveRenderers, bool[] rendererActive) { Lamp = lamp; IsFlasher = isFlasher; + IsInsert = isInsert; Lights = lights; LightEnabled = lightEnabled; EmissiveRenderers = emissiveRenderers; @@ -335,6 +382,29 @@ public Entry(LightComponent lamp, bool isFlasher, Light[] lights, bool[] lightEn private readonly List _entries = new(); + // Hybrid inserts show their authored intensity outside the game, per bulb. + private readonly struct InsertEntry + { + public readonly InsertLightComponent Insert; + public readonly float Intensity; + public readonly float[] ExtraIntensities; + // Per bulb (0 is the main one): driven by a flasher, so it stays off in every shot. + public readonly bool[] Flasher; + + public InsertEntry(InsertLightComponent insert, bool[] flasher) + { + Insert = insert; + Flasher = flasher; + Intensity = insert.Intensity; + ExtraIntensities = new float[insert.ExtraBulbs?.Length ?? 0]; + for (var i = 0; i < ExtraIntensities.Length; i++) { + ExtraIntensities[i] = insert.ExtraBulbs[i]?.Intensity ?? 0f; + } + } + } + + private readonly List _inserts = new(); + public PlayfieldLightSnapshot(TableComponent tableComponent) { // Flashers are identified via the lamp mapping (IsCoil), resolving @@ -346,11 +416,17 @@ public PlayfieldLightSnapshot(TableComponent tableComponent) // non-deterministic since the uncovered set shifts with table state). var flashers = new HashSet(); var flasherDevices = new HashSet(); + var insertBulbs = new Dictionary(); var lamps = tableComponent.MappingConfig?.Lamps; if (lamps != null) { var resolved = new List(); foreach (var lampMapping in lamps) { - if (lampMapping == null || !lampMapping.IsCoil || lampMapping.Device == null) { + if (lampMapping == null || lampMapping.Device == null) { + continue; + } + // Every mapping that reaches an insert bulb directly takes that bulb over, lamp or coil. + ResolveInsertBulbs(lampMapping.Device, lampMapping.DeviceItem, lampMapping.IsCoil, insertBulbs); + if (!lampMapping.IsCoil) { continue; } if (lampMapping.Device is Component devComp && devComp) { @@ -376,6 +452,22 @@ public PlayfieldLightSnapshot(TableComponent tableComponent) AddEntry(lamp, isFlasher); } + foreach (var insert in tableComponent.GetComponentsInChildren(true)) { + if (!insert) { + continue; + } + var mapping = insertBulbs.TryGetValue(insert, out var found) ? found : new InsertBulbMapping(); + var flasher = (bool[])mapping.Flasher.Clone(); + // A lamp above the insert drives its main bulb only, and only while no mapping takes that bulb + // over (the extra bulbs follow their own mappings), as InsertLightComponent does in the game. + if (!mapping.Mapped[0]) { + var parentLamp = insert.GetComponentInParent(true); + flasher[0] = parentLamp && (flashers.Contains(parentLamp) || parentLamp.name.StartsWith("F_", StringComparison.Ordinal)) + || insert.name.StartsWith("F_", StringComparison.Ordinal); + } + _inserts.Add(new InsertEntry(insert, flasher)); + } + // Group lamps (LightGroupComponent) can drive raw Lights + faux-bulb meshes // that are NOT wrapped in their own LightComponent (e.g. L51's two skull-eye // sub-lamps). The LightComponent pass above misses those, leaving their bulbs @@ -415,7 +507,8 @@ private void AddEntry(LightComponent lamp, bool isFlasher) rendererActive[i] = emissiveRenderers[i].gameObject.activeSelf; } - _entries.Add(new Entry(lamp, isFlasher, lights, lightEnabled, emissiveRenderers, rendererActive)); + var isInsert = lamp.GetComponentInChildren(true) || lamp.GetComponentInChildren(true); + _entries.Add(new Entry(lamp, isFlasher, isInsert, lights, lightEnabled, emissiveRenderers, rendererActive)); } // A LightGroupComponent whose member sub-lamps have no LightComponent of their @@ -427,8 +520,10 @@ private void AddOrphanGroupEntry(LightGroupComponent group, bool isFlasher) var converter = RenderPipeline.Current?.MaterialConverter; var lightList = new List(); + // Inserts under the group (such as L51's eye inserts) are switched by the insert pass; leave + // their spill lights and meshes alone, or this would turn them off again in the lit shot. foreach (var l in group.GetComponentsInChildren(true)) { - if (l && l.GetComponentInParent(true) == null) { + if (l && l.GetComponentInParent(true) == null && !BelongsToInsert(l)) { lightList.Add(l); } } @@ -436,7 +531,7 @@ private void AddOrphanGroupEntry(LightGroupComponent group, bool isFlasher) var rendererList = new List(); if (converter != null) { foreach (var mr in group.GetComponentsInChildren(true)) { - if (mr && converter.GetEmissiveIntensity(mr.sharedMaterial) > 0f && mr.GetComponentInParent(true) == null) { + if (mr && converter.GetEmissiveIntensity(mr.sharedMaterial) > 0f && mr.GetComponentInParent(true) == null && !BelongsToInsert(mr)) { rendererList.Add(mr); } } @@ -457,11 +552,45 @@ private void AddOrphanGroupEntry(LightGroupComponent group, bool isFlasher) rendererActive[i] = emissiveRenderers[i].gameObject.activeSelf; } - _entries.Add(new Entry(null, isFlasher, lights, lightEnabled, emissiveRenderers, rendererActive)); + _entries.Add(new Entry(null, isFlasher, false, lights, lightEnabled, emissiveRenderers, rendererActive)); } // Resolve a mapped lamp device to the concrete LightComponents it drives, // expanding LightGroupComponent references recursively. + private static bool BelongsToInsert(Component component) => + component.GetComponentInParent(true) || component.GetComponentInParent(true); + + // Which bulbs of an insert mappings drive directly, and which of those are flashers (coil mappings). + private sealed class InsertBulbMapping + { + public readonly bool[] Mapped = new bool[InsertLightComponent.MaxBulbs]; + public readonly bool[] Flasher = new bool[InsertLightComponent.MaxBulbs]; + } + + // Record the insert bulb a mapping drives directly: the bulb of an insert mapped itself, or of an insert + // taking part in a mapped group. A mapped lamp above an insert is resolved per insert instead. + private static void ResolveInsertBulbs(ILampDeviceComponent device, string deviceItem, bool isCoil, Dictionary result) + { + switch (device) { + case InsertLightComponent insert when insert: + if (!result.TryGetValue(insert, out var mapping)) { + result[insert] = mapping = new InsertBulbMapping(); + } + var bulb = InsertLightComponent.BulbIndex(deviceItem); + mapping.Mapped[bulb] = true; + mapping.Flasher[bulb] |= isCoil; + break; + case LightGroupComponent group when group: + // Each member takes part with one of its lamps, such as a single bulb of an insert. + for (var index = 0; index < group._lights.Length; index++) { + if (group._lights[index] && group._lights[index] is ILampDeviceComponent child) { + ResolveInsertBulbs(child, group.LampId(index), isCoil, result); + } + } + break; + } + } + private static void ResolveLightComponents(ILampDeviceComponent device, List result) { switch (device) { @@ -481,9 +610,9 @@ private static void ResolveLightComponents(ILampDeviceComponent device, ListThe folder the package screenshots are read from, or null for a package without screenshots. + internal string ScreenshotFolder => _screenshotFolder; + + /// The table being exported. + internal GameObject Table => _table; + public PackageWriter( GameObject table, bool runtimeCompressSideChannelTextures = true, diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/VpeExportRunner.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/VpeExportRunner.cs index e6fa0c999..880951a07 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/VpeExportRunner.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/Packaging/VpeExportRunner.cs @@ -62,6 +62,10 @@ public static async Task RunAsync(PackageWriter writer, string path, strin }); try { + if (!await RenderScreenshotsAsync(writer, title)) { + Logger.Info("Export canceled: screenshots could not be rendered."); + return false; + } await writer.WritePackageAsync(path, progress, cts.Token); return true; @@ -80,5 +84,41 @@ public static async Task RunAsync(PackageWriter writer, string path, strin EditorUtility.ClearProgressBar(); } } + + /// + /// Renders the package screenshots afresh, so the player's table images always show the table as exported. + /// Earlier screenshots are removed first; if rendering fails, the user decides whether to export without them. + /// Returns false when the export should stop. + /// + private static async Task RenderScreenshotsAsync(PackageWriter writer, string title) + { + var folder = writer.ScreenshotFolder; + var table = writer.Table ? writer.Table.GetComponent() : null; + if (string.IsNullOrEmpty(folder) || !table) { + return true; + } + EditorUtility.DisplayProgressBar(title, "Rendering screenshots…", 0f); + string failure; + try { + PackageScreenshotGenerator.DeleteGenerated(folder); + await PackageScreenshotGenerator.GenerateAsync(table, folder); + return true; + } catch (Exception ex) { + Logger.Error(ex, "Rendering the package screenshots failed."); + // Shots are written one by one; drop the ones that made it, so a partial set is never packed. + try { + PackageScreenshotGenerator.DeleteGenerated(folder); + } catch (Exception cleanup) { + Logger.Warn(cleanup, "Could not remove the partial screenshots."); + } + Debug.LogException(ex); + failure = ex.Message; + } finally { + EditorUtility.ClearProgressBar(); + } + return EditorUtility.DisplayDialog(title, + $"The table screenshots could not be rendered:\n{failure}\n\nWithout them, the player shows no image of the table.", + "Export Without Screenshots", "Cancel"); + } } } diff --git a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Table/TableInspector.cs b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Table/TableInspector.cs index 4f6b2454c..7e4f673ad 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Table/TableInspector.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Editor/VPT/Table/TableInspector.cs @@ -37,18 +37,11 @@ public class TableInspector : ItemInspector private bool _packageFoldout; private bool _runtimeCompressSideChannelTextures = true; private bool _runtimeCompressNormalMaps = true; - private string _screenshotHdriPath; - private Cubemap _screenshotHdri; - private float _screenshotHdriExposure; private const string RuntimeCompressSideChannelTexturesKey = "VisualPinball.Unity.Editor.TableInspector.RuntimeCompressSideChannelTextures"; private const string RuntimeCompressNormalMapsKey = "VisualPinball.Unity.Editor.TableInspector.RuntimeCompressNormalMaps"; - private const string ScreenshotHdriPathKey = - "VisualPinball.Unity.Editor.TableInspector.ScreenshotHdriPath"; - private const string ScreenshotHdriExposureKey = - "VisualPinball.Unity.Editor.TableInspector.ScreenshotHdriExposure"; protected override void OnEnable() { @@ -59,13 +52,6 @@ protected override void OnEnable() _metadataProperty = serializedObject.FindProperty(nameof(TableComponent.Metadata)); _runtimeCompressSideChannelTextures = EditorPrefs.GetBool(RuntimeCompressSideChannelTexturesKey, true); _runtimeCompressNormalMaps = EditorPrefs.GetBool(RuntimeCompressNormalMapsKey, true); - _screenshotHdriPath = EditorPrefs.GetString(ScreenshotHdriPathKey, PackageScreenshotGenerator.DefaultHdriAssetPath); - _screenshotHdri = AssetDatabase.LoadAssetAtPath(_screenshotHdriPath); - _screenshotHdriExposure = EditorPrefs.GetFloat(ScreenshotHdriExposureKey, 4f); - if (!_screenshotHdri && _screenshotHdriPath != PackageScreenshotGenerator.DefaultHdriAssetPath) { - _screenshotHdriPath = PackageScreenshotGenerator.DefaultHdriAssetPath; - _screenshotHdri = AssetDatabase.LoadAssetAtPath(_screenshotHdriPath); - } } public override void OnInspectorGUI() @@ -94,17 +80,6 @@ public override void OnInspectorGUI() $"Screenshot preset: {PackageScreenshotGenerator.PortraitWidth}x{PackageScreenshotGenerator.PortraitHeight} PNG using a computed top-down shot at {PackageScreenshotGenerator.FieldOfView:F1} deg FOV.", MessageType.None); EditorGUI.BeginChangeCheck(); - _screenshotHdri = (Cubemap)EditorGUILayout.ObjectField("Screenshot HDRI", _screenshotHdri, typeof(Cubemap), false); - if (EditorGUI.EndChangeCheck()) { - _screenshotHdriPath = _screenshotHdri ? AssetDatabase.GetAssetPath(_screenshotHdri) : string.Empty; - EditorPrefs.SetString(ScreenshotHdriPathKey, _screenshotHdriPath); - } - EditorGUI.BeginChangeCheck(); - _screenshotHdriExposure = EditorGUILayout.FloatField("Screenshot HDRI Exposure", _screenshotHdriExposure); - if (EditorGUI.EndChangeCheck()) { - EditorPrefs.SetFloat(ScreenshotHdriExposureKey, _screenshotHdriExposure); - } - EditorGUI.BeginChangeCheck(); _runtimeCompressSideChannelTextures = EditorGUILayout.ToggleLeft( "Compress sidecar textures (Unity runtime compression)", _runtimeCompressSideChannelTextures); @@ -119,44 +94,37 @@ public override void OnInspectorGUI() EditorPrefs.SetBool(RuntimeCompressNormalMapsKey, _runtimeCompressNormalMaps); } if (GUILayout.Button("Generate Screenshot")) { - if (!_screenshotHdri) { - EditorUtility.DisplayDialog("Generate Screenshot Failed", - "Please assign a screenshot HDRI cubemap before generating a screenshot.", "OK"); - } else { - // Run the capture *outside* OnInspectorGUI. Rendering - // (SubmitRenderRequest/ReadPixels) inside the IMGUI event uses the - // GUI frame's stale culling, so the per-shot lamp/bulb toggles - // don't take effect and later shots leak the first shot's state. - var table = tableComponent; - var hdri = _screenshotHdri; - var exposure = _screenshotHdriExposure; - EditorApplication.delayCall += () => { - try { - // Generate runs across several editor frames (it lets HDRP settle - // lamp state between shots), so the result arrives via callback. - PackageScreenshotGenerator.Generate(table, @"Assets/Screenshots", hdri, exposure, - result => { - Debug.Log( - $"Generated package screenshot at {result.AssetPath} " + - $"(camera Y: {result.CameraPosition.y:F3}m, distance: {result.CameraDistance:F3}m, " + - table); + // Run the capture *outside* OnInspectorGUI. Rendering + // (SubmitRenderRequest/ReadPixels) inside the IMGUI event uses the + // GUI frame's stale culling, so the per-shot lamp/bulb toggles + // don't take effect and later shots leak the first shot's state. + var table = tableComponent; + EditorApplication.delayCall += () => { + try { + // Generate runs across several editor frames (it lets HDRP settle + // lamp state between shots), so the result arrives via callback. + PackageScreenshotGenerator.Generate(table, @"Assets/Screenshots", + result => { + Debug.Log( + $"Generated package screenshot at {result.AssetPath} " + + $"(camera Y: {result.CameraPosition.y:F3}m, distance: {result.CameraDistance:F3}m, " + + table); - var screenshot = AssetDatabase.LoadAssetAtPath(result.AssetPath); - if (screenshot) { - Selection.activeObject = screenshot; - EditorGUIUtility.PingObject(screenshot); - } - }, - exception => { - Debug.LogException(exception, table); - EditorUtility.DisplayDialog("Generate Screenshot Failed", exception.Message, "OK"); - }); - } catch (Exception exception) { - Debug.LogException(exception, table); - EditorUtility.DisplayDialog("Generate Screenshot Failed", exception.Message, "OK"); - } - }; - } + var screenshot = AssetDatabase.LoadAssetAtPath(result.AssetPath); + if (screenshot) { + Selection.activeObject = screenshot; + EditorGUIUtility.PingObject(screenshot); + } + }, + exception => { + Debug.LogException(exception, table); + EditorUtility.DisplayDialog("Generate Screenshot Failed", exception.Message, "OK"); + }); + } catch (Exception exception) { + Debug.LogException(exception, table); + EditorUtility.DisplayDialog("Generate Screenshot Failed", exception.Message, "OK"); + } + }; } if (GUILayout.Button($"Save as .{ext}")) { var tableContainer = tableComponent.TableContainer; diff --git a/VisualPinball.Unity/VisualPinball.Unity.Test/Packaging/PackagedContentTests.cs b/VisualPinball.Unity/VisualPinball.Unity.Test/Packaging/PackagedContentTests.cs index 2b85f267f..7b7104a07 100644 --- a/VisualPinball.Unity/VisualPinball.Unity.Test/Packaging/PackagedContentTests.cs +++ b/VisualPinball.Unity/VisualPinball.Unity.Test/Packaging/PackagedContentTests.cs @@ -32,6 +32,47 @@ public void TearDown() } } + [Test] + public void ReadsInterleavedCompressedAndStoredEntries() + { + var package = Path.Combine(_root, "streams.vpe"); + var compressed = Enumerable.Range(0, 65536).Select(index => (byte)(index % 251)).ToArray(); + var stored = Enumerable.Range(0, 65536).Select(index => (byte)(index % 197)).ToArray(); + var manager = new VisualPinball.Unity.Editor.SharpZipStorageManager(); + using (var output = manager.CreateStorage(package)) { + output.AddFile("compressed.bin").SetData(compressed); + output.AddFile("stored.bin").SetData(stored, PackageCompression.Stored); + } + using var input = manager.OpenStorage(package); + Assert.That(input.TryGetFile("compressed.bin", out var compressedFile), Is.True); + Assert.That(input.TryGetFile("stored.bin", out var storedFile), Is.True); + using var first = compressedFile.AsStream(); + using var second = storedFile.AsStream(); + var firstResult = new MemoryStream(); + var secondResult = new MemoryStream(); + var buffer = new byte[113]; + int a, b; + do { + a = first.Read(buffer, 0, buffer.Length); firstResult.Write(buffer, 0, a); + b = second.Read(buffer, 0, buffer.Length); secondResult.Write(buffer, 0, b); + } while (a != 0 || b != 0); + Assert.That(firstResult.ToArray(), Is.EqualTo(compressed)); + Assert.That(secondResult.ToArray(), Is.EqualTo(stored)); + } + + [TestCase(System.IO.Compression.CompressionLevel.Optimal)] + [TestCase(System.IO.Compression.CompressionLevel.NoCompression)] + public void ReadsMetadataFromRuntimeZipArchive(System.IO.Compression.CompressionLevel compression) + { + var package = Path.Combine(_root, "metadata.vpe"); + using (var archive = new System.IO.Compression.ZipArchive(File.Create(package), System.IO.Compression.ZipArchiveMode.Create)) { + using var writer = new StreamWriter(archive.CreateEntry("table/table.json", compression).Open()); + writer.Write("{\"TableName\":\"ZIP fixture\"}"); + } + Assert.That(VpeTableMetadataReader.TryRead(package, out var metadata), Is.True); + Assert.That(metadata.Title, Is.EqualTo("ZIP fixture")); + } + [Test] public async Task RoundTripsNestedTreeInEditorAndRuntimeResolver() { @@ -152,6 +193,7 @@ public async Task StartupRemovesOrphanTempAndUnmarkedFinalIsReplaced() Directory.CreateDirectory(orphan); Directory.CreateDirectory(partial); File.WriteAllText(Path.Combine(orphan, ".complete"), contentRef.ContentHash); + Directory.SetLastWriteTimeUtc(orphan, DateTime.UtcNow.AddDays(-1)); File.WriteAllText(Path.Combine(partial, "partial"), "bad"); var resolver = new PackagedContentResolver(package, CacheOptions(cache)); diff --git a/VisualPinball.Unity/VisualPinball.Unity/Display/CabinetScreenComponent.cs b/VisualPinball.Unity/VisualPinball.Unity/Display/CabinetScreenComponent.cs new file mode 100644 index 000000000..90e7ef351 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Display/CabinetScreenComponent.cs @@ -0,0 +1,116 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using UnityEngine; +using VisualPinball.Plugins; + +namespace VisualPinball.Unity +{ + /// + /// The cabinet screens a can show, matching the . + /// + public enum CabinetScreenRole + { + Backglass, + Topper, + Dmd, + FullDmd, + Custom, + } + + /// + /// Marks a surface of the cabinet, such as the backglass or the topper screen, on which a plugin's screen is shown + /// while the game runs. + /// + /// + /// VPE shows the surface a plugin publishes for on the renderer, by writing it + /// into a texture property of the target's material. The authored material therefore defines the look of the screen, + /// and is what remains when no plugin provides the screen. In that case, is deactivated if set, + /// so for example a topper disappears entirely when there is no PUP pack. The material slot's property block belongs to + /// the screen, so don't drive the same slot from other components such as lights. + /// + /// The role only shows a plugin's DMD when the gamelogic engine announces no DMD + /// of its own. The engine's DMD counts as the screen's source, so the hide root of a DMD screen may contain the table's + /// . + /// + [PackAs("CabinetScreen")] + [AddComponentMenu("Pinball/Display/Cabinet Screen")] + public class CabinetScreenComponent : MonoBehaviour, IPackable + { + [Tooltip("Which cabinet screen is shown on this surface.")] + public CabinetScreenRole Role = CabinetScreenRole.Backglass; + + [Tooltip("The role name a plugin publishes the screen with, such as \"pup-screen-8\". Only used when Role is Custom.")] + public string CustomRole = string.Empty; + + [Tooltip("The renderer showing the screen. Defaults to the renderer of this GameObject.")] + public Renderer Target; + + [Tooltip("The material slot of the target renderer that shows the screen.")] + public int MaterialIndex; + + [Tooltip("The texture property of the material the screen is written to. Leave empty to use the unlit color map, the emissive color map if the material uses one, or the base color map.")] + public string TextureProperty = string.Empty; + + [Tooltip("Deactivated when no plugin provides this screen, such as the whole topper. Leave empty to keep the authored material instead.")] + public GameObject HideRoot; + + /// + /// The of the surfaces shown here. + /// + public string RoleName => Role switch { + CabinetScreenRole.Backglass => SurfaceRoles.Backglass, + CabinetScreenRole.Topper => SurfaceRoles.Topper, + CabinetScreenRole.Dmd => SurfaceRoles.Dmd, + CabinetScreenRole.FullDmd => SurfaceRoles.FullDmd, + _ => CustomRole?.Trim() ?? string.Empty, + }; + + /// + /// The renderer showing the screen: , or the renderer of this GameObject. + /// + public Renderer TargetRenderer => Target != null ? Target : GetComponent(); + + /// + /// Sets and from a role name. + /// + public void SetRoleName(string role) + { + switch (role) { + case SurfaceRoles.Backglass: Role = CabinetScreenRole.Backglass; break; + case SurfaceRoles.Topper: Role = CabinetScreenRole.Topper; break; + case SurfaceRoles.Dmd: Role = CabinetScreenRole.Dmd; break; + case SurfaceRoles.FullDmd: Role = CabinetScreenRole.FullDmd; break; + default: + Role = CabinetScreenRole.Custom; + CustomRole = role ?? string.Empty; + break; + } + } + + #region Packaging + + public byte[] Pack() => CabinetScreenPackable.Pack(this); + + public byte[] PackReferences(Transform root, PackagedRefs refs, PackagedFiles files) => CabinetScreenReferencesPackable.Pack(this, refs); + + public void Unpack(byte[] bytes) => CabinetScreenPackable.Unpack(bytes, this); + + public void UnpackReferences(byte[] data, Transform root, PackagedRefs refs, PackagedFiles files) => CabinetScreenReferencesPackable.Unpack(data, this, refs); + + #endregion + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/Display/CabinetScreenComponent.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Display/CabinetScreenComponent.cs.meta new file mode 100644 index 000000000..856848e41 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Display/CabinetScreenComponent.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: eed12cfe991430e4d87a21b73bd6207e \ No newline at end of file diff --git a/VisualPinball.Unity/VisualPinball.Unity/Display/CabinetScreenPackable.cs b/VisualPinball.Unity/VisualPinball.Unity/Display/CabinetScreenPackable.cs new file mode 100644 index 000000000..6d585c57d --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Display/CabinetScreenPackable.cs @@ -0,0 +1,87 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +// ReSharper disable MemberCanBePrivate.Global + +using UnityEngine; + +namespace VisualPinball.Unity +{ + public struct CabinetScreenPackable + { + public string Role; + public int MaterialIndex; + public string TextureProperty; + + public static byte[] Pack(CabinetScreenComponent comp) + { + return PackageApi.Packer.Pack(new CabinetScreenPackable { + Role = comp.RoleName, + MaterialIndex = comp.MaterialIndex, + TextureProperty = comp.TextureProperty, + }); + } + + public static void Unpack(byte[] bytes, CabinetScreenComponent comp) + { + var data = PackageApi.Packer.Unpack(bytes); + comp.SetRoleName(data.Role); + comp.MaterialIndex = data.MaterialIndex; + comp.TextureProperty = data.TextureProperty ?? string.Empty; + } + } + + /// + /// The target renderer and the hide root, as node IDs. Renderers and plain GameObjects carry no packed type name, so + /// they are referenced by their node only. + /// + public struct CabinetScreenReferencesPackable + { + public string TargetNode; + public string HideRootNode; + + public static byte[] Pack(CabinetScreenComponent comp, PackagedRefs refs) + { + return PackageApi.Packer.Pack(new CabinetScreenReferencesPackable { + TargetNode = NodeId(comp.Target != null ? comp.Target.transform : null, refs), + HideRootNode = NodeId(comp.HideRoot != null ? comp.HideRoot.transform : null, refs), + }); + } + + public static void Unpack(byte[] bytes, CabinetScreenComponent comp, PackagedRefs refs) + { + var data = PackageApi.Packer.Unpack(bytes); + var target = Node(data.TargetNode, refs); + comp.Target = target != null ? target.GetComponent() : null; + var hideRoot = Node(data.HideRootNode, refs); + comp.HideRoot = hideRoot != null ? hideRoot.gameObject : null; + } + + private static string NodeId(Transform transform, PackagedRefs refs) + { + if (transform == null) { + return null; + } + var id = refs.GetNodeId(transform); + if (id == null) { + Debug.LogWarning($"Cabinet screen reference to '{transform.name}' points to a non-exported object; writing null reference."); + } + return id; + } + + private static Transform Node(string id, PackagedRefs refs) => string.IsNullOrEmpty(id) ? null : refs.GetNode(id); + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/Display/CabinetScreenPackable.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Display/CabinetScreenPackable.cs.meta new file mode 100644 index 000000000..d46f4917c --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Display/CabinetScreenPackable.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: fa2b91a61465fe64fb7667cf32445bb3 \ No newline at end of file diff --git a/VisualPinball.Unity/VisualPinball.Unity/Game/CameraTranslateAndOrbit.cs b/VisualPinball.Unity/VisualPinball.Unity/Game/CameraTranslateAndOrbit.cs index 803a79f92..6ee821a51 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Game/CameraTranslateAndOrbit.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Game/CameraTranslateAndOrbit.cs @@ -33,12 +33,14 @@ public class CameraTranslateAndOrbit : MonoBehaviour public Player player; private const float RadiusMin = 0.5f; + private const float RadiusMax = 20f; // unbounded zoom-out pushes the camera to float-precision distances (broken raycasts) private Transform _transformCache; private GameObject _dummyForRotation; private Transform _dummyTransform; private float _radius; + private float _radiusMax; private Vector3 _focusPoint; private float _radiusCurrent = 15f; @@ -81,6 +83,7 @@ private void Start() _transformCache = transform; _radius = Vector3.Distance(Vector3.zero, _transformCache.position); + _radiusMax = Mathf.Max(RadiusMax, _radius); _focusPoint = -_transformCache.forward * _radius; _positionOffsetCurrent = positionOffset; _radiusCurrent = _radius; @@ -137,6 +140,13 @@ private void Update() } } + private void OnDisable() + { + // a button released while disabled is never seen, so drop any drag in progress + _isTrackingMouse0 = false; + _isTrackingMouse1 = false; + } + private void LateUpdate() { if (lockTarget == null) return; @@ -281,11 +291,11 @@ private void UpdateMouse() float deltaScroll = _mouse.scroll.y.ReadValue() / 300f * zoomSpeed; /* --------------- UpdateMouse() – ZOOM in lock --------------- */ if (lockTarget != null && _isTrackingBall) { // ★ only follow-lock zooms - float r = Mathf.Max(_ballFollowOffsetTarget.magnitude * (1f - deltaScroll), RadiusMin); + float r = Mathf.Clamp(_ballFollowOffsetTarget.magnitude * (1f - deltaScroll), RadiusMin, _radiusMax); _ballFollowOffsetTarget = _ballFollowOffsetTarget.normalized * r; } else if (lockTarget == null) { /* original zoom path (unchanged) */ - _radius = Mathf.Max(_radius - deltaScroll * _radius, RadiusMin); + _radius = Mathf.Clamp(_radius - deltaScroll * _radius, RadiusMin, _radiusMax); } } diff --git a/VisualPinball.Unity/VisualPinball.Unity/NativeWindows.meta b/VisualPinball.Unity/VisualPinball.Unity/NativeWindows.meta new file mode 100644 index 000000000..2ce75ddfc --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/NativeWindows.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 220b67a7c6d84202bb866fa284052954 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/NativeWindows/NativeWindowCapabilities.cs b/VisualPinball.Unity/VisualPinball.Unity/NativeWindows/NativeWindowCapabilities.cs new file mode 100644 index 000000000..0895389d1 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/NativeWindows/NativeWindowCapabilities.cs @@ -0,0 +1,45 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// SPDX-License-Identifier: MIT +// Licensed under MIT. See https://opensource.org/license/mit. + +using System; + +namespace VisualPinball.Unity +{ + /// + /// Window features reported by the native library, matching the native VPNW_Capability bits. + /// + /// + /// Read the current set from . The bits describe the platform and the + /// desktop session, not the graphics backend; combines + /// with the backend check and is what a plugin should test before creating a window. The other + /// bits let a plugin explain degraded behaviour, for example that a compositor ignores topmost requests. + /// + /// + /// var capabilities = NativeWindowSurface.Capabilities; + /// if ((capabilities & NativeWindowCapabilities.Transparency) == 0) { + /// log("Transparent areas of the backglass will not show the desktop."); + /// } + /// + /// + /// + [Flags] + public enum NativeWindowCapabilities : uint + { + /// No feature is available, or the native library could not be loaded. + None = 0, + /// Borderless GPU output windows can be created. + Windows = 1, + /// + /// Placement mode (mouse dragging and edge resizing, see ) is + /// functional. + /// + Placement = 2, + /// Topmost and non-topmost requests are honoured. + Topmost = 4, + /// Premultiplied alpha reaches the desktop compositor, so transparent pixels show the desktop behind. + Transparency = 8, + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/NativeWindows/NativeWindowCapabilities.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/NativeWindows/NativeWindowCapabilities.cs.meta new file mode 100644 index 000000000..fba932e3d --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/NativeWindows/NativeWindowCapabilities.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f39c01809b8a402182a0059d5584a900 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/NativeWindows/NativeWindowDiagnostics.cs b/VisualPinball.Unity/VisualPinball.Unity/NativeWindows/NativeWindowDiagnostics.cs new file mode 100644 index 000000000..0a06070f4 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/NativeWindows/NativeWindowDiagnostics.cs @@ -0,0 +1,76 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// SPDX-License-Identifier: MIT +// Licensed under MIT. See https://opensource.org/license/mit. + +using System.Runtime.InteropServices; + +namespace VisualPinball.Unity +{ + /// + /// Snapshot of a native window's state, matching the native VPNW_Diagnostics layout (80 bytes). + /// + /// + /// Coordinates and sizes are physical desktop pixels. Boolean fields are 0 or 1. + /// + /// Returned by , which crosses into the window system, so query it for + /// troubleshooting or occasional polling, not every frame. Besides logging, the settings windows of PinUP and the B2S + /// backglass read the actual position and size from it after the user dragged or resized a window in placement mode. + /// + /// + /// + /// var state = surface.Diagnostics; + /// var bounds = new RectInt(state.Left, state.Top, state.WindowWidth, state.WindowHeight); + /// Debug.Log($"{state.PresentedFrames} frames presented, {state.SkippedFrames} skipped, last result 0x{state.LastResult:x8}"); + /// + /// + /// + [StructLayout(LayoutKind.Sequential)] + public struct NativeWindowDiagnostics + { + /// + /// Size of this structure in bytes. fills it in before the native call, + /// which rejects a size other than 80. + /// + public uint StructBytes; + /// UnityGfxRenderer value seen by the native plugin. + public int GraphicsRenderer; + /// 1 when the native plugin has a graphics device to present with, otherwise 0. + public int DeviceReady; + /// 1 while the native window can present; 0 on Linux after a presentation failure stopped it. + public int Alive; + /// 1 when the window is shown, otherwise 0. + public int Visible; + /// 1 when the window stays above normal windows, otherwise 0. + public int Topmost; + /// Left edge of the window in physical desktop pixels; negative left of the primary monitor. + public int Left; + /// Top edge of the window in physical desktop pixels; negative above the primary monitor. + public int Top; + /// Actual window width in physical pixels. + public int WindowWidth; + /// Actual window height in physical pixels. + public int WindowHeight; + /// DPI of the monitor the window is on (96 is 100 % scaling). + public uint Dpi; + /// Width of the source texture in pixels, or 0 when no texture is assigned. + public uint SourceWidth; + /// Height of the source texture in pixels, or 0 when no texture is assigned. + public uint SourceHeight; + /// Platform texture format: DXGI_FORMAT, MTLPixelFormat or VkFormat. + public uint SourceFormat; + /// Number of frames presented to the window since it was created. + public ulong PresentedFrames; + /// + /// Number of present events that did not reach the window, for example because the display was not ready yet. + /// Presentation never waits for the display, so a count that keeps rising while the window is visible and has a source + /// means it cannot keep up with Unity's frame rate. + /// + public ulong SkippedFrames; + /// Windows: last presentation HRESULT. macOS/Linux: 0, or negative after a presentation failure. + public int LastResult; + /// 1 when the window composes its content with premultiplied alpha, otherwise 0. + public int PremultipliedAlpha; + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/NativeWindows/NativeWindowDiagnostics.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/NativeWindows/NativeWindowDiagnostics.cs.meta new file mode 100644 index 000000000..7a35b659c --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/NativeWindows/NativeWindowDiagnostics.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f6175021f04f4bf2add7104325ff17e8 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/NativeWindows/NativeWindowHost.cs b/VisualPinball.Unity/VisualPinball.Unity/NativeWindows/NativeWindowHost.cs new file mode 100644 index 000000000..411508a9a --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/NativeWindows/NativeWindowHost.cs @@ -0,0 +1,139 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// SPDX-License-Identifier: MIT +// Licensed under MIT. See https://opensource.org/license/mit. + +using System; +using System.Collections.Generic; +using NLog; +using UnityEngine; +using Logger = NLog.Logger; + +namespace VisualPinball.Unity +{ + /// + /// Submits one pair of native rendering events per frame after all rendering, for every registered + /// , and keeps doing so until deferred GPU releases have drained. + /// + /// + /// A single hidden instance is shared by all users (PinUP, B2S, ...). It is created on the first + /// and removes itself once no surface is registered and no GPU resource is pending. + /// + /// Native windows draw and present on Unity's render thread, triggered by + /// and . Both events operate on all windows, so they must be issued once + /// per frame in total, not once per window; this component does that from LateUpdate (execution order 10000) + /// and also calls . A plugin only registers its surface and keeps the returned + /// lease. In play mode the presenter survives scene loads; it runs LateUpdate only while the application is + /// playing. + /// + /// + /// When the presenter is destroyed (for example when play mode ends), it disposes every surface still registered. + /// + /// + /// + /// var surface = new NativeWindowSurface(x, y, width, height); // hidden + /// surface.SetTexture(texture.GetNativeTexturePtr()); + /// surface.Configure(x, y, width, height, visible: true, topmost: false); + /// var lease = NativeWindowHost.Register(surface); + /// + /// // Shutdown: unregister, destroy the window, then release the texture. + /// lease.Dispose(); + /// surface.Dispose(); + /// texture.Release(); + /// + /// + /// + [DefaultExecutionOrder(10000)] + public sealed class NativeWindowHost : MonoBehaviour + { + private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); + + private static NativeWindowHost _instance; + private readonly HashSet _surfaces = new HashSet(); + + /// + /// Registers a surface for render-thread presentation. Dispose the returned lease before disposing the surface. + /// + /// + /// Call on the main thread; the first call creates the hidden presenter object. Registering the same surface twice + /// has no further effect, and one lease removes it. Disposing the lease more than once is harmless. After the last + /// lease ends the presenter keeps issuing events until is zero, + /// then removes itself. + /// + /// The window to present. The caller keeps ownership and disposes it after the lease. + /// A lease that unregisters the surface when disposed. + /// is null. + public static IDisposable Register(NativeWindowSurface surface) + { + if (surface == null) { + throw new ArgumentNullException(nameof(surface)); + } + if (_instance == null) { + var owner = new GameObject("Native Window Presenter") { hideFlags = HideFlags.HideAndDontSave }; + if (Application.isPlaying) { + DontDestroyOnLoad(owner); + } + _instance = owner.AddComponent(); + } + _instance._surfaces.Add(surface); + return new PresentationLease(surface); + } + + private static void Unregister(NativeWindowSurface surface) + { + if (_instance != null) { + _instance._surfaces.Remove(surface); + } + } + + private void LateUpdate() + { + NativeWindowSurface.Pump(); + if (_surfaces.Count == 0 && NativeWindowSurface.PendingResourceCount == 0) { + // Detach first, so a Register() before the deferred destruction creates a fresh presenter. + if (_instance == this) { + _instance = null; + } + Destroy(gameObject); + return; + } + var callback = NativeWindowSurface.RenderEventFunction; + GL.IssuePluginEvent(callback, NativeWindowSurface.DrawEventId); + GL.IssuePluginEvent(callback, NativeWindowSurface.PresentEventId); + } + + private void OnDestroy() + { + foreach (var surface in _surfaces) { + try { + surface.Dispose(); + } catch (Exception e) { + Logger.Error(e, "Could not destroy native window."); + } + } + _surfaces.Clear(); + if (_instance == this) { + _instance = null; + } + } + + private sealed class PresentationLease : IDisposable + { + private NativeWindowSurface _surface; + + internal PresentationLease(NativeWindowSurface surface) + { + _surface = surface; + } + + public void Dispose() + { + if (_surface != null) { + Unregister(_surface); + } + _surface = null; + } + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/NativeWindows/NativeWindowHost.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/NativeWindows/NativeWindowHost.cs.meta new file mode 100644 index 000000000..cef512123 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/NativeWindows/NativeWindowHost.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: aa47f2ca77a940c0acecec29922e8c3d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/NativeWindows/NativeWindowSurface.cs b/VisualPinball.Unity/VisualPinball.Unity/NativeWindows/NativeWindowSurface.cs new file mode 100644 index 000000000..29c766ad0 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/NativeWindows/NativeWindowSurface.cs @@ -0,0 +1,420 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// SPDX-License-Identifier: MIT +// Licensed under MIT. See https://opensource.org/license/mit. + +using System; +using System.Runtime.InteropServices; +using System.Text; + +namespace VisualPinball.Unity +{ + /// + /// A borderless desktop window presenting a Unity texture on the GPU: Direct3D11/12 through DirectComposition on + /// Windows, Metal on macOS, and Vulkan on X11/XWayland on Linux. + /// + /// + /// Coordinates are signed physical desktop pixels; monitors left of or above the primary monitor have negative + /// origins. A new window is hidden until shows it. Register the surface with + /// , which issues and once + /// per frame for all surfaces. Dispose the surface before releasing its RenderTexture. The wrapper has explicit + /// ownership and no finalizer. + /// + /// Plugins use it to put a premultiplied RGBA RenderTexture on a second or third monitor, with transparent areas + /// showing the desktop behind: PinUP opens one per PUP screen, the B2S plugin one for the backglass and one for the + /// B2S DMD, and both open one in placement mode for the Player's screen layout editor (see + /// ). The texture never leaves the GPU. Use the instance from the main thread, as + /// all VPE users do. Instance members throw after , and + /// carrying when the native call fails. + /// + /// + /// + /// if (!NativeWindowSurface.IsSupported) { + /// return; // Missing library or unsupported graphics backend; never throws. + /// } + /// var texture = new RenderTexture(width, height, 0, RenderTextureFormat.ARGB32); + /// texture.Create(); + /// var surface = new NativeWindowSurface(x, y, width, height); // hidden + /// surface.SetTexture(texture.GetNativeTexturePtr()); + /// surface.Configure(x, y, width, height, visible: true, topmost: false); + /// var lease = NativeWindowHost.Register(surface); + /// + /// // Shutdown: unregister, destroy the window, then release the texture. + /// lease.Dispose(); + /// surface.Dispose(); + /// texture.Release(); + /// + /// + /// + public sealed class NativeWindowSurface : IDisposable + { + internal const string LibraryName = "VisualPinballNativeWindows"; + + // UnityGfxRenderer values of the supported graphics backends. + private const int RendererD3D11 = 2; + private const int RendererMetal = 16; + private const int RendererD3D12 = 18; + private const int RendererVulkan = 21; + + /// Native registry ID; zero after disposal. IDs are never reused while the plugin is loaded. + public int Id { get; private set; } + + private bool _configured, _visible, _topmost; + private int _x, _y, _width, _height; + + private static bool? _libraryAvailable; + + #region Static API + + /// + /// True when the native library is present and Unity runs a graphics backend the platform's native windows + /// support: Direct3D11/12 on Windows, Metal on macOS, Vulkan with an X11 display on Linux. + /// + /// Never throws; a missing native library reports false. + public static bool IsSupported + { + get { + if (IntPtr.Size != 8 || !LibraryAvailable || (Capabilities & NativeWindowCapabilities.Windows) == 0) { + return false; + } + var renderer = GraphicsRenderer; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { + return renderer == RendererD3D11 || renderer == RendererD3D12; + } + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) { + return renderer == RendererMetal; + } + return RuntimeInformation.IsOSPlatform(OSPlatform.Linux) && renderer == RendererVulkan; + } + } + + /// + /// Unity's UnityGfxRenderer as seen by the native plugin, or -1 when the library is unavailable. The supported values + /// are 2 (Direct3D11), 16 (Metal), 18 (Direct3D12) and 21 (Vulkan). Useful in log messages when + /// is false. + /// + public static int GraphicsRenderer => LibraryAvailable ? Native.GetGraphicsRenderer() : -1; + + /// + /// Window features of the current platform and session, or when the + /// library is unavailable. + /// + public static NativeWindowCapabilities Capabilities => LibraryAvailable + ? (NativeWindowCapabilities)Native.GetCapabilities() + : NativeWindowCapabilities.None; + + /// + /// Unity rendering callback for GL.IssuePluginEvent, or when the library is + /// unavailable. Issue and then with it. Plugins do not need it: + /// issues the events. + /// + public static IntPtr RenderEventFunction => LibraryAvailable ? Native.GetRenderEventFunc() : IntPtr.Zero; + + /// + /// Event ID of the per-frame draw step for all windows. Submit after the frame's GPU work, followed immediately by + /// . Operates on all surfaces. 0 when the library is unavailable. + /// + public static int DrawEventId => LibraryAvailable ? Native.GetDrawEventId() : 0; + + /// + /// Event ID that presents all surfaces; submit right after , once per frame. 0 when the + /// library is unavailable. + /// + public static int PresentEventId => LibraryAvailable ? Native.GetPresentEventId() : 0; + + /// + /// Retired GPU source and presentation objects awaiting later render events. Keep issuing event pairs until zero. + /// does this after its last surface is unregistered. 0 when the library is unavailable. + /// + public static int PendingResourceCount => LibraryAvailable ? Native.GetPendingResourceCount() : 0; + + /// + /// Processes platform window events (Linux placement dragging). Call once per frame from the main thread. A no-op on + /// Windows and macOS and when the library is unavailable. calls it every frame. + /// + public static void Pump() + { + if (LibraryAvailable) { + Native.Pump(); + } + } + + /// Destroys all native windows, for shutdown or domain-reload cleanup by the host. + /// + /// This affects the windows of every plugin. Existing instances keep their + /// , so their other members then fail; stays safe because native destruction is + /// idempotent. GPU resources still retire through later render events. Plugins should dispose their own surfaces + /// instead. + /// + public static void DestroyAll() + { + if (LibraryAvailable) { + Native.DestroyAll(); + } + } + + /// + /// The last native error message (UTF-8), for diagnostics, or a message naming the missing library when it is + /// unavailable. The native library keeps one message for all windows, so read it right after a failure. + /// + public static string LastError + { + get { + if (!LibraryAvailable) { + return $"The native library {LibraryName} is not available."; + } + var bytes = new byte[System.Math.Max(1, Native.GetLastError(null, 0))]; + Native.GetLastError(bytes, bytes.Length); + var length = Array.IndexOf(bytes, (byte)0); + return Encoding.UTF8.GetString(bytes, 0, length < 0 ? bytes.Length : length); + } + } + + private static bool LibraryAvailable + { + get { + if (_libraryAvailable.HasValue) { + return _libraryAvailable.Value; + } + try { + Native.GetCapabilities(); + _libraryAvailable = true; + + } catch (DllNotFoundException) { + _libraryAvailable = false; + + } catch (EntryPointNotFoundException) { + _libraryAvailable = false; + + } catch (BadImageFormatException) { + _libraryAvailable = false; + } + return _libraryAvailable.Value; + } + } + + #endregion + + /// Creates an initially hidden window, using signed physical desktop pixel coordinates. + /// + /// Check first: the constructor only verifies that the library loads, not that Unity's + /// graphics backend can present. Then assign a texture, call to show the window and register + /// it with . + /// + /// Left edge in physical desktop pixels; negative left of the primary monitor. + /// Top edge in physical desktop pixels; negative above the primary monitor. + /// Width in physical pixels. + /// Height in physical pixels. + /// The native library is missing or the player is not 64-bit. + /// The native window could not be created; see . + public NativeWindowSurface(int x, int y, int width, int height) + { + if (IntPtr.Size != 8) { + throw new PlatformNotSupportedException("Native windows require a 64-bit player."); + } + if (!LibraryAvailable) { + throw new PlatformNotSupportedException($"The native library {LibraryName} is not available on this platform."); + } + Id = Native.CreateWindow(x, y, width, height); + if (Id == 0) { + throw Failure("Create native window"); + } + } + + /// Sets the physical desktop rectangle, visibility and topmost state. Identical calls are cached and skipped. + /// + /// The cache makes calling it every frame cheap. It compares with the last values passed here (and the topmost state + /// set by ), not with the window's current geometry, so repeating the previous call does not + /// undo a move the user made in placement mode. + /// + /// Left edge in physical desktop pixels; negative left of the primary monitor. + /// Top edge in physical desktop pixels; negative above the primary monitor. + /// Width in physical pixels. + /// Height in physical pixels. + /// True shows the window, false hides it. + /// True keeps the window above normal windows. + /// The surface was disposed. + /// The native call failed; the message includes . + public void Configure(int x, int y, int width, int height, bool visible, bool topmost) + { + RequireAlive(); + if (_configured && x == _x && y == _y && width == _width && height == _height && visible == _visible && topmost == _topmost) { + return; + } + if (Native.ConfigureWindow(Id, x, y, width, height, visible ? 1 : 0, topmost ? 1 : 0) == 0) { + throw Failure("Configure native window"); + } + _configured = true; + _x = x; + _y = y; + _width = width; + _height = height; + _visible = visible; + _topmost = topmost; + } + + /// + /// Assigns the source texture, typically RenderTexture.GetNativeTexturePtr(), containing premultiplied RGBA + /// in the desired SDR encoding. + /// + /// + /// IntPtr.Zero detaches the source. Resubmit the pointer whenever Unity recreates or resizes the texture. The pointer + /// is an ID3D11Texture2D, ID3D12Resource, MTLTexture or VkImage* depending on the + /// backend; the texture must be created and stay alive until it is detached or the surface is disposed. It is scaled + /// to the window size on the GPU. + /// + /// The native texture pointer, or to show nothing. + /// True flips the image vertically when it is presented. VPE's own callers pass false. + /// The surface was disposed. + /// The native call failed; the message includes . + public void SetTexture(IntPtr texture, bool flipY = false) + { + RequireAlive(); + if (Native.SetTexture(Id, texture, flipY ? 1 : 0) == 0) { + throw Failure("Assign native window texture"); + } + } + + /// Allows mouse dragging and edge resizing while the user places the window. Playback windows stay fixed. + /// + /// Read the result from . Only functional when includes + /// ; on Linux the drags are processed by . + /// + /// True lets the user move and resize the window; false fixes it again. + /// The surface was disposed. + /// The native call failed; the message includes . + public void SetPlacementMode(bool enabled) + { + RequireAlive(); + if (Native.SetPlacementMode(Id, enabled ? 1 : 0) == 0) { + throw Failure("Set native placement mode"); + } + } + + /// Moves the window behind other windows without moving, resizing or activating it. + /// The surface was disposed. + /// The native call failed; the message includes . + public void SendToBack() => SetZOrder(0); + + /// Moves the window in front of other windows without moving, resizing or activating it. + /// The surface was disposed. + /// The native call failed; the message includes . + public void BringToFront() => SetZOrder(1); + + /// Sets or clears the topmost state without moving, resizing or activating the window. + /// The new state also updates the cache of . + /// True keeps the window above normal windows; false makes it a normal window again. + /// The surface was disposed. + /// The native call failed; the message includes . + public void SetTopmost(bool topmost) => SetZOrder(topmost ? 2 : 3); + + /// Actual window geometry, DPI, source and presentation counters. Query for troubleshooting, not every frame. + /// + /// Each read crosses into the window system. The settings windows of PinUP and the B2S plugin also poll it to pick up + /// the rectangle after the user moved or resized the window in placement mode. + /// + /// The surface was disposed. + /// The native call failed; the message includes . + public NativeWindowDiagnostics Diagnostics + { + get { + RequireAlive(); + var result = new NativeWindowDiagnostics { StructBytes = (uint)Marshal.SizeOf() }; + if (Native.GetDiagnostics(Id, ref result) == 0) { + throw Failure("Read native window diagnostics"); + } + return result; + } + } + + /// Destroys the window. GPU resources are released by later render events; see . + /// + /// Dispose the lease first and release the source texture afterwards. Calling it again + /// does nothing. is zero afterwards even when the native call fails. + /// + /// The native window could not be destroyed; see . + public void Dispose() + { + if (Id == 0) { + return; + } + var id = Id; + Id = 0; + if (Native.DestroyWindow(id) == 0) { + throw Failure("Destroy native window"); + } + } + + private void SetZOrder(int order) + { + RequireAlive(); + if (Native.SetZOrder(Id, order) == 0) { + throw Failure("Set native window z-order"); + } + if (order >= 2) { + _topmost = order == 2; + } + } + + private void RequireAlive() + { + if (Id == 0) { + throw new ObjectDisposedException(nameof(NativeWindowSurface)); + } + } + + private static InvalidOperationException Failure(string operation) => new InvalidOperationException(operation + ": " + LastError); + + private static class Native + { + [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl, EntryPoint = "VPNW_CreateWindow")] + internal static extern int CreateWindow(int x, int y, int width, int height); + + [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl, EntryPoint = "VPNW_ConfigureWindow")] + internal static extern int ConfigureWindow(int id, int x, int y, int width, int height, int visible, int topmost); + + [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl, EntryPoint = "VPNW_SetZOrder")] + internal static extern int SetZOrder(int id, int order); + + [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl, EntryPoint = "VPNW_SetPlacementMode")] + internal static extern int SetPlacementMode(int id, int enabled); + + [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl, EntryPoint = "VPNW_SetTexture")] + internal static extern int SetTexture(int id, IntPtr texture, int flipY); + + [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl, EntryPoint = "VPNW_DestroyWindow")] + internal static extern int DestroyWindow(int id); + + [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl, EntryPoint = "VPNW_DestroyAll")] + internal static extern void DestroyAll(); + + [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl, EntryPoint = "VPNW_Pump")] + internal static extern void Pump(); + + [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl, EntryPoint = "VPNW_GetRenderEventFunc")] + internal static extern IntPtr GetRenderEventFunc(); + + [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl, EntryPoint = "VPNW_GetGraphicsRenderer")] + internal static extern int GetGraphicsRenderer(); + + [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl, EntryPoint = "VPNW_GetDrawEventId")] + internal static extern int GetDrawEventId(); + + [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl, EntryPoint = "VPNW_GetPresentEventId")] + internal static extern int GetPresentEventId(); + + [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl, EntryPoint = "VPNW_GetPendingResourceCount")] + internal static extern int GetPendingResourceCount(); + + [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl, EntryPoint = "VPNW_GetDiagnostics")] + internal static extern int GetDiagnostics(int id, ref NativeWindowDiagnostics diagnostics); + + [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl, EntryPoint = "VPNW_GetLastError")] + internal static extern int GetLastError([Out] byte[] destination, int capacity); + + [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl, EntryPoint = "VPNW_GetCapabilities")] + internal static extern uint GetCapabilities(); + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/NativeWindows/NativeWindowSurface.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/NativeWindows/NativeWindowSurface.cs.meta new file mode 100644 index 000000000..b2d3692aa --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/NativeWindows/NativeWindowSurface.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a84c7df2a3274902aa080cad39fd27a3 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/Packaging/RuntimePackageReader.cs b/VisualPinball.Unity/VisualPinball.Unity/Packaging/RuntimePackageReader.cs index 98b9eee4f..62b8f9168 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Packaging/RuntimePackageReader.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Packaging/RuntimePackageReader.cs @@ -72,6 +72,16 @@ public async Task ImportIntoScene( Transform parent, IProgress progress, CancellationToken cancellationToken = default) + { + return await ImportIntoScene(parent, progress, null, cancellationToken); + } + + /// The callback sees a fully restored inactive table and completes before game logic activation. + public async Task ImportIntoScene( + Transform parent, + IProgress progress, + Func beforeActivation, + CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(_vpePath)) { throw new ArgumentException("No .vpe path was provided."); @@ -270,6 +280,8 @@ await ReadPackablesAsync( foreach (var consumer in _table.GetComponentsInChildren(true).OfType()) { consumer.SetPackagedContentResolver(ContentResolver); } + if (beforeActivation != null) await beforeActivation(_table, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); loadSucceeded = true; } finally { diff --git a/VisualPinball.Unity/VisualPinball.Unity/Packaging/SharpZipLibStorage.cs b/VisualPinball.Unity/VisualPinball.Unity/Packaging/SharpZipLibStorage.cs index 82be60a3f..aeb4f0e3b 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Packaging/SharpZipLibStorage.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Packaging/SharpZipLibStorage.cs @@ -17,6 +17,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.IO.Compression; using ICSharpCode.SharpZipLib.Zip; using NLog; @@ -48,7 +49,8 @@ internal class ZipPackageStorage : IPackageStorage private readonly bool _isWriteMode; private ZipOutputStream _zipOutputStream; - private ZipFile _zipFile; + private ZipArchive _readArchive; + private readonly object _readGate = new object(); // A root folder abstraction to keep track of the top-level. private readonly ZipPackageFolder _rootFolder; @@ -72,11 +74,15 @@ public ZipPackageStorage(string path, bool writeMode) } else { // Open read-only - _zipFile = new ZipFile(File.OpenRead(path)); + // Use the runtime ZIP reader for standalone Mono compatibility. + var input = File.OpenRead(path); + try { _readArchive = new ZipArchive(input, ZipArchiveMode.Read); } + catch { input.Dispose(); throw; } // Build an in-memory tree of folders/files for quick lookup _rootFolder = new ZipPackageFolder("", null, this, false); - BuildFolderTree(); + try { BuildFolderTree(); } + catch { _readArchive.Dispose(); throw; } } } @@ -113,11 +119,7 @@ public void Close() } } else { - // If reading, close the ZipFile - if (_zipFile != null) { - _zipFile.Close(); - _zipFile = null; - } + lock (_readGate) { _readArchive?.Dispose(); _readArchive = null; } } } @@ -131,24 +133,43 @@ public ZipOutputStream GetZipOutputStream() return _zipOutputStream; } - public ZipFile GetZipFile() + internal Stream OpenReadEntry(ZipArchiveEntry entry) { - if (_isWriteMode) { - throw new InvalidOperationException("Cannot get ZipFile in write mode."); + lock (_readGate) { + if (_readArchive == null) throw new ObjectDisposedException(nameof(ZipPackageStorage)); + return new LockedEntryStream(entry.Open(), _readGate); } - return _zipFile; + } + // Entry substreams share the archive's file cursor. Serialize each read/seek operation, not complete file copies. + private sealed class LockedEntryStream : Stream + { + private readonly Stream _inner; + private readonly object _gate; + internal LockedEntryStream(Stream inner, object gate) { _inner = inner; _gate = gate; } + public override bool CanRead => _inner.CanRead; + public override bool CanSeek => _inner.CanSeek; + public override bool CanWrite => false; + public override long Length { get { lock (_gate) return _inner.Length; } } + public override long Position { get { lock (_gate) return _inner.Position; } set { lock (_gate) _inner.Position = value; } } + public override int Read(byte[] buffer, int offset, int count) { lock (_gate) return _inner.Read(buffer, offset, count); } + public override int Read(Span buffer) { lock (_gate) return _inner.Read(buffer); } + public override long Seek(long offset, SeekOrigin origin) { lock (_gate) return _inner.Seek(offset, origin); } + public override void Flush() { } + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + protected override void Dispose(bool disposing) { if (disposing) { lock (_gate) _inner.Dispose(); } base.Dispose(disposing); } } /// - /// In read mode, we build an internal folder/file structure from the existing ZipFile. + /// In read mode, build a folder/file index from the archive directory. /// private void BuildFolderTree() { // Iterate over every entry in the zip and place it into the folder structure - foreach (ZipEntry entry in _zipFile) { + foreach (var entry in _readArchive.Entries) { - var entryName = entry.Name; - var isDirectory = entry.IsDirectory; + var entryName = entry.FullName; + var isDirectory = entryName.EndsWith("/", StringComparison.Ordinal); // Split path by forward slash var parts = entryName.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries); @@ -281,7 +302,7 @@ public IPackageFile AddFile(string name, string ext = null) return file; } - internal void AddFileInternal(string name, ZipEntry zipEntry) + internal void AddFileInternal(string name, ZipArchiveEntry zipEntry) { // For read mode, we store a reference to the existing entry if (!_files.TryGetValue(name, out var file)) { @@ -363,7 +384,7 @@ internal class ZipPackageFile : IPackageFile { private readonly ZipPackageFolder _parentFolder; private readonly ZipPackageStorage _storage; - private readonly ZipEntry _existingEntry; // non-null if read-only + private readonly ZipArchiveEntry _existingEntry; // non-null if read-only public ZipPackageFile(string fileName, ZipPackageFolder parentFolder, ZipPackageStorage storage) { @@ -374,7 +395,7 @@ public ZipPackageFile(string fileName, ZipPackageFolder parentFolder, ZipPackage } public ZipPackageFile(string fileName, ZipPackageFolder parentFolder, ZipPackageStorage storage, - ZipEntry existingEntry) + ZipArchiveEntry existingEntry) { Name = fileName; _parentFolder = parentFolder; @@ -387,7 +408,7 @@ public ZipPackageFile(string fileName, ZipPackageFolder parentFolder, ZipPackage public Stream AsStream() { // In write mode, return the ZipOutputStream for this entry. - // In read mode, return the ZipFile's InputStream for this entry. + // In read mode, return a synchronized archive entry stream. if (_storage == null) { throw new InvalidOperationException("Storage reference lost."); } @@ -410,8 +431,7 @@ public Stream AsStream() } // read mode - var zf = _storage.GetZipFile(); - return zf.GetInputStream(_existingEntry); + return _storage.OpenReadEntry(_existingEntry); } public void SetData(byte[] data, PackageCompression compression = PackageCompression.Default) diff --git a/VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeTableMetadataReader.cs b/VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeTableMetadataReader.cs index cd327fb6c..4bba3e6bb 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeTableMetadataReader.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Packaging/VpeTableMetadataReader.cs @@ -18,9 +18,9 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; +using System.IO.Compression; using System.Linq; using System.Threading.Tasks; -using ICSharpCode.SharpZipLib.Zip; using Newtonsoft.Json; namespace VisualPinball.Unity @@ -78,14 +78,14 @@ public static bool TryRead(string vpePath, out VpeTableMetadataSummary metadata) } try { - using var zip = new ZipFile(File.OpenRead(fileInfo.FullName)); + using var zip = new ZipArchive(File.OpenRead(fileInfo.FullName), ZipArchiveMode.Read); var entry = FindMetadataEntry(zip); if (entry == null) { UpdateCache(fileInfo, false, null); return false; } - using var stream = zip.GetInputStream(entry); + using var stream = entry.Open(); using var reader = new StreamReader(stream); using var jsonReader = new JsonTextReader(reader); var tableMetadata = JsonSerializer.CreateDefault().Deserialize(jsonReader); @@ -186,14 +186,14 @@ private static void UpdateCache(FileInfo fileInfo, bool hasMetadata, VpeTableMet // Reads screenshots/table-bounds.json from the already-open zip and returns the crop aspect // (cropWidth / cropHeight). Null when the entry is missing or unparseable. Cheap — the bounds // file is a handful of bytes, so this adds almost nothing to the metadata read. - private static float? TryReadScreenshotAspect(ZipFile zip) + private static float? TryReadScreenshotAspect(ZipArchive zip) { var entry = zip.GetEntry(ScreenshotBoundsEntryPath); if (entry == null) { return null; } try { - using var stream = zip.GetInputStream(entry); + using var stream = entry.Open(); using var reader = new StreamReader(stream); using var jsonReader = new JsonTextReader(reader); var bounds = JsonSerializer.CreateDefault().Deserialize(jsonReader); @@ -214,15 +214,15 @@ private sealed class ScreenshotBoundsData public int cropHeight; } - private static ZipEntry FindMetadataEntry(ZipFile zip) + private static ZipArchiveEntry FindMetadataEntry(ZipArchive zip) { var exact = zip.GetEntry(TableMetadataEntryPath); if (exact != null) { return exact; } - foreach (ZipEntry entry in zip) { - if (!entry.IsDirectory && string.Equals(entry.Name, TableMetadataEntryPath, StringComparison.OrdinalIgnoreCase)) { + foreach (var entry in zip.Entries) { + if (string.Equals(entry.FullName, TableMetadataEntryPath, StringComparison.OrdinalIgnoreCase)) { return entry; } } diff --git a/VisualPinball.Unity/VisualPinball.Unity/Plugins.meta b/VisualPinball.Unity/VisualPinball.Unity/Plugins.meta new file mode 100644 index 000000000..2150e2655 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Plugins.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: e3c777d0ece34e04397877b907924d04 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/VisualPinball.Unity/VisualPinball.Unity/Plugins/CabinetScreenBinder.cs b/VisualPinball.Unity/VisualPinball.Unity/Plugins/CabinetScreenBinder.cs new file mode 100644 index 000000000..0f345385a --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Plugins/CabinetScreenBinder.cs @@ -0,0 +1,322 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// SPDX-License-Identifier: MIT +// Licensed under MIT. See https://opensource.org/license/mit. + +using System; +using System.Collections.Generic; +using System.Linq; +using UnityEngine; +using VisualPinball.Plugins; + +namespace VisualPinball.Unity +{ + /// + /// Shows the surfaces plugins publish on the table's s while a game runs. + /// + /// + /// + /// Every cabinet screen follows the published for its role. When several plugins + /// publish the same role, the one listed first in the provider order wins; providers not listed come after, by ID. + /// The surface's texture is written into the screen's material with a property block, and the screen's hide root is + /// activated. Without a surface, or before its texture exists, the material's own texture is put back and the hide + /// root is deactivated, so screens without a provider are hidden from the start when the binder is created before the + /// table is activated. The property block of the screen's material slot belongs to the screen: it takes the place of + /// renderer-wide overrides while a surface is shown, and is removed when the surface goes away. + /// + /// + /// The game's own DMD takes precedence: a screen only shows a surface once the gamelogic + /// engine has started without announcing a DMD, and drops it again if the engine announces one later. Until then, and + /// while the engine's DMD is shown, its hide root stays active, since the engine's display component may live under it. + /// + /// Not thread-safe: create, use and dispose it on the main thread, where plugins publish their surfaces. + /// + public sealed class CabinetScreenBinder : IDisposable + { + /// + /// How long after the gamelogic engine started a DMD it announces still counts as the engine's own, in seconds. + /// Engines such as PinMAME announce their displays right after starting. + /// + private const float DmdAnnouncementGrace = 0.5f; + + private static readonly string[] UnlitProperties = { "_UnlitColorMap" }; + private static readonly string[] BaseProperties = { "_BaseColorMap", "_BaseMap", "_MainTex" }; + + private sealed class Screen + { + internal readonly CabinetScreenComponent Component; + internal readonly Renderer Renderer; + internal readonly int MaterialIndex; + internal readonly string Role; + internal readonly int Property; + internal readonly GameObject HideRoot; + internal bool Applied; + internal PluginResourceBinding Binding; + internal IDisposable Connection; + internal string ResourceId; + + internal Screen(CabinetScreenComponent component, Renderer renderer, int property) + { + Component = component; + Renderer = renderer; + MaterialIndex = component.MaterialIndex; + Role = component.RoleName; + Property = property; + HideRoot = component.HideRoot; + } + } + + private readonly List _screens = new List(); + private readonly IGamelogicEngine _engine; + private readonly PluginResourceRegistry _registry; + private readonly string _controller; + private readonly long _session; + private readonly IReadOnlyList _providers; + private readonly MaterialPropertyBlock _properties = new MaterialPropertyBlock(); + private readonly IDisposable _observation; + private bool _engineDmd; + private bool _dmdDecided; + private bool _disposed; + + /// + /// Starts showing surfaces on the cabinet screens of , including inactive ones. + /// + /// The table's root object. + /// The table's gamelogic engine, or null if it has none. + /// The registry plugins publish their surfaces to. + /// The game's controller ID; only surfaces of this controller are shown. + /// The game session; only surfaces of this session are shown. + /// Plugin IDs in order of preference, for roles several plugins provide. + public CabinetScreenBinder(GameObject table, IGamelogicEngine engine, PluginResourceRegistry registry, string controller, long session, IReadOnlyList providers) + { + _engine = engine; + _registry = registry; + _controller = controller ?? string.Empty; + _session = session; + _providers = providers?.ToArray() ?? Array.Empty(); + + foreach (var component in table.GetComponentsInChildren(true)) { + var renderer = component.TargetRenderer; + if (renderer == null) { + Debug.LogWarning($"Cabinet screen \"{component.name}\" has no renderer to show the screen on."); + continue; + } + if (string.IsNullOrEmpty(component.RoleName)) { + Debug.LogWarning($"Cabinet screen \"{component.name}\" has no role."); + continue; + } + var materials = renderer.sharedMaterials; + var material = component.MaterialIndex >= 0 && component.MaterialIndex < materials.Length ? materials[component.MaterialIndex] : null; + var property = TextureProperty(material, component.TextureProperty); + if (property == null) { + Debug.LogWarning($"Cabinet screen \"{component.name}\": material slot {component.MaterialIndex} of \"{renderer.name}\" has no texture property to show the screen in."); + continue; + } + _screens.Add(new Screen(component, renderer, Shader.PropertyToID(property))); + } + if (_screens.Count == 0) { + return; + } + + _dmdDecided = engine == null; + if (engine != null) { + engine.OnDisplaysRequested += DisplaysRequested; + engine.OnStarted += EngineStarted; + } + _observation = registry.Observe(RegistryChanged); + foreach (var screen in _screens) { + Present(screen, null); + } + Refresh(); + } + + /// + /// Picks the texture property of a material: the given one if the material has it, otherwise the unlit color map, the + /// emissive color map if the material samples it, or the base color map. + /// + public static string TextureProperty(Material material, string requested) + { + if (material == null) { + return null; + } + if (!string.IsNullOrWhiteSpace(requested)) { + return material.HasProperty(requested.Trim()) ? requested.Trim() : null; + } + foreach (var property in UnlitProperties) { + if (material.HasProperty(property)) { + return property; + } + } + if (material.HasProperty("_EmissiveColorMap") && material.IsKeywordEnabled("_EMISSIVE_COLOR_MAP")) { + return "_EmissiveColorMap"; + } + foreach (var property in BaseProperties) { + if (material.HasProperty(property)) { + return property; + } + } + return null; + } + + private void RegistryChanged(PluginResourceChange change) + { + if (change.Descriptor.Kind == ResourceKind.Surface && change.Kind != ResourceChangeKind.Changed) { + Refresh(); + } + } + + private void DisplaysRequested(object sender, RequestedDisplays displays) + { + if (_engineDmd || displays?.Displays == null || !displays.Displays.Any(display => display != null && display.Height > 1)) { + return; + } + _engineDmd = true; + Refresh(); + } + + private async void EngineStarted(object sender, EventArgs args) + { + if (_dmdDecided) { + return; + } + try { + await Awaitable.WaitForSecondsAsync(DmdAnnouncementGrace); + } catch (OperationCanceledException) { + return; + } + if (_disposed || _dmdDecided) { + return; + } + _dmdDecided = true; + Refresh(); + } + + /// + /// Binds every screen to the surface it should show now, and rebinds those whose choice changed. + /// + private void Refresh() + { + if (_disposed) { + return; + } + foreach (var screen in _screens) { + var resourceId = Choose(screen.Role); + if (resourceId == screen.ResourceId) { + Present(screen, screen.Binding?.Source); // the engine's DMD may have changed the screen's visibility + continue; + } + Release(screen); + screen.ResourceId = resourceId; + if (resourceId == null) { + Present(screen, null); + continue; + } + var binding = _registry.Bind(new PluginResourceSelection(ResourceKind.Surface) { + ResourceId = resourceId, Controller = _controller, Session = _session, + }); + screen.Binding = binding; + binding.Changed += () => Present(screen, binding.Source); + screen.Connection = binding.Connect(source => { + Action changed = _ => Present(screen, source); + source.Changed += changed; + return new Subscription(() => source.Changed -= changed); + }); + Present(screen, binding.Source); + } + } + + /// + /// The ID of the surface a role shows: the one of the most preferred provider, or null for none. + /// + private string Choose(string role) + { + if (role == SurfaceRoles.Dmd && (_engineDmd || !_dmdDecided)) { + return null; + } + var candidates = _registry.Snapshot(new PluginResourceSelection(ResourceKind.Surface) { + Role = role, Controller = _controller, Session = _session, + }); + return candidates + .OrderBy(descriptor => Rank(descriptor.Provider)) + .ThenBy(descriptor => descriptor.Provider, StringComparer.Ordinal) + .ThenBy(descriptor => descriptor.Id, StringComparer.Ordinal) + .FirstOrDefault()?.Id; + } + + private int Rank(string provider) + { + for (var i = 0; i < _providers.Count; i++) { + if (string.Equals(_providers[i], provider, StringComparison.Ordinal)) { + return i; + } + } + return int.MaxValue; + } + + /// + /// Shows the source's texture on the screen, or restores the authored texture and hides the screen when there is + /// none. A DMD screen stays visible while the engine's DMD may still claim it, so the engine's display component + /// under its hide root is active when the table activates and keeps receiving frames. + /// + private void Present(Screen screen, IUnityTextureResource source) + { + if (_disposed || screen.Renderer == null) { + return; + } + var texture = source?.Texture; + if (texture != null) { + screen.Renderer.GetPropertyBlock(_properties, screen.MaterialIndex); + _properties.SetTexture(screen.Property, texture); + screen.Renderer.SetPropertyBlock(_properties, screen.MaterialIndex); + screen.Applied = true; + SetVisible(screen, true); + return; + } + if (screen.Applied) { + // The slot's block belongs to the screen; removing it brings back the material's current texture, such as + // the one a DisplayComponent assigns when the engine takes the DMD over, and renderer-wide overrides. + screen.Renderer.SetPropertyBlock(null, screen.MaterialIndex); + screen.Applied = false; + } + SetVisible(screen, screen.Role == SurfaceRoles.Dmd && (_engineDmd || !_dmdDecided)); + } + + private static void SetVisible(Screen screen, bool visible) + { + if (screen.HideRoot != null && screen.HideRoot.activeSelf != visible) { + screen.HideRoot.SetActive(visible); + } + } + + private static void Release(Screen screen) + { + screen.Connection?.Dispose(); + screen.Connection = null; + screen.Binding?.Dispose(); + screen.Binding = null; + screen.ResourceId = null; + } + + /// + /// Stops following the surfaces. Screens go back to their authored texture, and hide roots of screens without the + /// engine's DMD are hidden. + /// + public void Dispose() + { + if (_disposed) { + return; + } + foreach (var screen in _screens) { + Release(screen); + Present(screen, null); + } + _disposed = true; + _observation?.Dispose(); + if (_engine != null && _screens.Count > 0) { + _engine.OnDisplaysRequested -= DisplaysRequested; + _engine.OnStarted -= EngineStarted; + } + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/Plugins/CabinetScreenBinder.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Plugins/CabinetScreenBinder.cs.meta new file mode 100644 index 000000000..66107ce50 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Plugins/CabinetScreenBinder.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c6f55a0e16f82b2409390e12d472ec5f \ No newline at end of file diff --git a/VisualPinball.Unity/VisualPinball.Unity/Plugins/GamelogicPluginResources.cs b/VisualPinball.Unity/VisualPinball.Unity/Plugins/GamelogicPluginResources.cs new file mode 100644 index 000000000..c4bca810a --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Plugins/GamelogicPluginResources.cs @@ -0,0 +1,353 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// SPDX-License-Identifier: MIT +// Licensed under MIT. See https://opensource.org/license/mit. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading; +using VisualPinball.Plugins; +using VisualPinball.Engine.Game.Engines; +using UnityEngine; + +namespace VisualPinball.Unity +{ + /// + /// Publishes a table's gamelogic engine to plugins: its controller events, displays, segment displays, native outputs and audio + /// become resources in the , where plugins such as PinUP, the B2S backglass and DMD bind them. + /// + /// + /// The Player creates one per game after loading the table, before the table starts and before + /// , so the resources exist when plugins prepare. It owns the instance in its game + /// scope and disposes it after the plugins have stopped. Every resource carries the game session, and its ID starts with + /// : + /// + /// Resource IDKind, role and when it is published + /// + /// <controller> + /// + /// , role controller: coil, lamp, GI and switch changes by mapped ID. Always. + /// + /// + /// + /// <controller>/outputs + /// + /// , role outputs: every output by native number, when the engine implements + /// . + /// + /// + /// + /// <controller>/audio/rom + /// + /// , role rom-mix, when the engine implements . + /// + /// + /// + /// <controller>/display/<id> + /// + /// , role dmd: one per in the table, and one + /// per dot-matrix display the engine announces through . + /// + /// + /// + /// <controller>/segments/<id> + /// + /// , role segments: one per segment display the engine announces (a display with a + /// height of 1 or less), with its layout from . + /// + /// + /// + /// Display frames are converted with ; frames of unknown displays or formats are dropped. The display + /// encodings come from . Without it, displays offer + /// and , plus + /// and when the engine implements , through which + /// a consumer's request for raw identification frames is forwarded. + /// + /// Create and dispose it on the owner thread. The engine must raise its display events there (they throw otherwise); coil, + /// lamp, switch and native output changes raised on other threads are posted to the owner thread. + /// + /// + /// From the Player's plugin controller: + /// + /// if (engine != null) + /// _gameScope.Own(new GamelogicPluginResources(table, engine, identity, session, Host.Scheduler, Host.Resources, Debug.LogException)); + /// await Host.PrepareAndStartAsync(identity, path, title, identity.Id + "/controller/0", session, cancellation); + /// + /// + /// + public sealed class GamelogicPluginResources : IDisposable + { + private sealed class Display : IDisposable + { + internal readonly DisplayResourceSource Source; + internal readonly IDisposable Publication; + internal readonly int Width; + internal readonly int Height; + internal readonly long Instance; + private readonly IDisposable _format; + internal Display(DisplayResourceSource source, IDisposable publication, int width, int height, long instance, IDisposable format) + { Source = source; Publication = publication; Width = width; Height = height; Instance = instance; _format = format; } + public void Dispose() { Publication.Dispose(); Source.Dispose(); _format?.Dispose(); } + } + /// A segment display published as . + private sealed class Segments : IDisposable + { + internal readonly SegmentResourceSource Source; + internal readonly IDisposable Publication; + internal readonly string Layout; + internal readonly string Hardware; + internal float[] Brightness = Array.Empty(); + internal Segments(SegmentResourceSource source, IDisposable publication, string layout, string hardware) + { Source = source; Publication = publication; Layout = layout; Hardware = hardware; } + public void Dispose() { Publication.Dispose(); Source.Dispose(); } + } + /// Forwards native outputs without exposing the listener on the public type. + private sealed class NativeOutputs : INativeOutputListener + { + private readonly GamelogicPluginResources _owner; + internal NativeOutputs(GamelogicPluginResources owner) { _owner = owner; } + public void NativeOutputChanged(ControllerEventKind kind, int number, double value, ControllerValueSemantics semantics, double maximum) => _owner.EmitNative(kind, number, value, semantics, maximum); + public void NativeOutputPollEnd() => _owner.EmitNative(ControllerEventKind.PollEnd, -1, 0, ControllerValueSemantics.Binary, 1); + } + private static long _instance; + private readonly IGamelogicEngine _engine; + private readonly Dictionary _segments = new Dictionary(StringComparer.Ordinal); + private readonly ControllerResourceSource _native; + private readonly string[] _numberNames = new string[1024]; + private readonly OwnerScheduler _scheduler; + private readonly PluginResourceRegistry _registry; + private readonly Dictionary _displays = new Dictionary(StringComparer.Ordinal); + // What the engine's mapped lamp values mean, from the table's lamp mapping (the same rules LampPlayer applies). + private readonly Dictionary<(LampSource source, bool isCoil, string id), (ControllerValueSemantics semantics, double maximum)> _lampSemantics + = new Dictionary<(LampSource, bool, string), (ControllerValueSemantics, double)>(); + private readonly ResourceScope _scope = new ResourceScope(); + private readonly ControllerResourceSource _controller; + private readonly DisplayFormatNegotiator _group; + private readonly string _controllerId; + private readonly string _provider; + private readonly long _session; + private readonly Action _fault; + private readonly DisplayEncoding _formats; + private readonly DisplayEncoding _defaultFormat; + private long _sequence; + private bool _disposed; + /// + /// Publishes the engine's controller, native outputs and audio and the table's dot-matrix displays, and subscribes to the + /// engine's events. Displays and segment displays that the engine announces later are published when it announces them. + /// + /// + /// The table's root object. Every under it, including inactive ones, is published up + /// front, so plugins can bind the DMD before the engine announces its displays. + /// + /// The table's gamelogic engine. + /// + /// The game. Its becomes the resources' provider, and its + /// the start of . + /// + /// The game session number, the same one passed to . + /// The host's owner-thread scheduler, used to check for and post to the owner thread. + /// The registry to publish into, normally . + /// + /// Receives exceptions thrown by subscribers of the published sources, and errors for display frames whose buffer is too + /// small. The Player passes Debug.LogException. + /// + public GamelogicPluginResources(GameObject table, IGamelogicEngine engine, GameIdentity identity, long session, + OwnerScheduler scheduler, PluginResourceRegistry registry, Action fault) + { + _engine = engine; _scheduler = scheduler; _registry = registry; _provider = identity.Provider; _controllerId = identity.Id + "/controller/0"; _session = session; _fault = fault; + _controller = new ControllerResourceSource(fault); + var mapping = table != null ? table.GetComponentInChildren(true)?.MappingConfig : null; + if (mapping != null) foreach (var lamp in mapping.Lamps) AddLampSemantics(lamp); + var metadata = engine as IDisplayResourceMetadata; + _formats = metadata?.DisplayFormats ?? (engine is IDisplayFrameFormatPreference + ? DisplayEncoding.Gray2 | DisplayEncoding.Gray4 | DisplayEncoding.Luminance8 | DisplayEncoding.Rgb24 + : DisplayEncoding.Luminance8 | DisplayEncoding.Rgb24); + _defaultFormat = metadata?.DefaultDisplayFormat ?? DisplayEncoding.Luminance8; + if (metadata?.FormatAppliesToAllDisplays == true) _group = _scope.Own(new DisplayFormatNegotiator(_formats, _defaultFormat, + format => (engine as IDisplayFrameFormatPreference)?.RequestDisplayFrameFormat("", ToLegacy(format)), + () => { foreach (string display in _displays.Keys.ToArray()) _registry.NotifyChanged(DisplayResourceId(display)); })); + _scope.Own(_controller); + _scope.Own(registry.Publish(new PluginResourceDescriptor(_controllerId, _provider, _controllerId, engine.Name, "controller", ResourceKind.Controller, session, Interlocked.Increment(ref _instance)), _controller)); + if (engine is IGamelogicNativeOutputs native) { + // Every output by native number, for consumers such as backglasses that address ROM lamps directly. + _native = _scope.Own(new ControllerResourceSource(ResourceKind.Outputs, fault)); + _scope.Own(registry.Publish(new PluginResourceDescriptor(_controllerId + "/outputs", _provider, _controllerId, engine.Name + " outputs", "outputs", ResourceKind.Outputs, session, Interlocked.Increment(ref _instance)), _native)); + native.SetNativeOutputListener(new NativeOutputs(this)); + _scope.Own(new Subscription(() => native.SetNativeOutputListener(null))); + } + if (engine is IGamelogicAudioResourceProvider audio) _scope.Own(registry.Publish(new PluginResourceDescriptor(_controllerId + "/audio/rom", _provider, _controllerId, engine.Name + " audio", "rom-mix", ResourceKind.Audio, session, Interlocked.Increment(ref _instance)), audio.AudioSource)); + engine.OnDisplaysRequested += DisplaysRequested; + engine.OnDisplayUpdateFrame += DisplayFrame; + engine.OnCoilChanged += Coil; + engine.OnLampChanged += Lamp; + engine.OnLampsChanged += Lamps; + engine.OnSwitchChanged += Switch; + foreach (var display in table.GetComponentsInChildren(true)) AddDisplay(display.Id, display.Width, display.Height); + } + /// + /// ID of the published controller resource and prefix of every other resource ID: the game's + /// followed by /controller/0, such as pinmame:t2_l8/controller/0. It matches the + /// the Player passes to plugins. + /// + public string ControllerId => _controllerId; + private void DisplaysRequested(object sender, RequestedDisplays args) + { + _scheduler.VerifyOwner(); + foreach (var display in args.Displays) { + if (display.Height > 1) AddDisplay(display.Id, display.Width, display.Height); + else if (display.Width > 0) AddSegments(display.Id, display.Width); + } + } + private void AddSegments(string id, int elements) + { + if (_disposed || _segments.ContainsKey(id)) return; + string layout = "seg16", hardware = ""; + if (_engine is ISegmentDisplayMetadata metadata && metadata.TryGetSegmentLayout(id, out var declared, out var declaredHardware)) { layout = declared; hardware = declaredHardware; } + // Displays are numbered in announcement order, so consumers can lay out digits before any frame arrives. + var source = new SegmentResourceSource(_segments.Count, elements, layout, hardware, _fault); + var descriptor = new PluginResourceDescriptor(_controllerId + "/segments/" + id, _provider, _controllerId, id, "segments", ResourceKind.Segments, _session, Interlocked.Increment(ref _instance)); + _segments.Add(id, new Segments(source, _registry.Publish(descriptor, source), layout, hardware)); + } + private void SegmentFrame(Segments display, DisplayFrameData args) + { + // One 16-bit segment mask per element, as PinMAME reports it. + if (args.Data == null || args.Data.Length < 2) return; + int elements = args.Data.Length / 2; + if (display.Brightness.Length != elements * 16) display.Brightness = new float[elements * 16]; + var brightness = display.Brightness; + for (int element = 0; element < elements; element++) { + int mask = args.Data[element * 2] | args.Data[element * 2 + 1] << 8; + for (int bit = 0; bit < 16; bit++) brightness[element * 16 + bit] = (mask >> bit & 1) != 0 ? 1f : 0f; + } + display.Source.Publish(new PluginSegmentFrame(display.Layout, elements, 16, brightness, display.Hardware)); + } + private void AddDisplay(string id, int width, int height) + { + if (_disposed || width <= 0 || height <= 0) return; + if (_displays.TryGetValue(id, out var existing)) { + if (existing.Width == width && existing.Height == height) return; + existing.Dispose(); _displays.Remove(id); + } + var format = _group ?? new DisplayFormatNegotiator(_formats, _defaultFormat, + encoding => (_engine as IDisplayFrameFormatPreference)?.RequestDisplayFrameFormat(id, ToLegacy(encoding)), + () => _registry.NotifyChanged(DisplayResourceId(id))); + var source = new DisplayResourceSource(format.Acquire, _fault); + long instance = Interlocked.Increment(ref _instance); + var descriptor = new PluginResourceDescriptor(DisplayResourceId(id), _provider, _controllerId, id, "dmd", ResourceKind.Display, _session, instance, ResourceView.Original, _formats); + _displays.Add(id, new Display(source, _registry.Publish(descriptor, source), width, height, instance, _group == null ? format : null)); + } + private string DisplayResourceId(string display) => _controllerId + "/display/" + display; + private void DisplayFrame(object sender, DisplayFrameData args) + { + _scheduler.VerifyOwner(); + if (_disposed) return; + if (args.Format == DisplayFrameFormat.Segment && _segments.TryGetValue(args.Id, out var segments)) { SegmentFrame(segments, args); return; } + if (!_displays.TryGetValue(args.Id, out var display)) return; + var format = FromLegacy(args.Format); + if (format == DisplayEncoding.None) return; + int stride = display.Width * (format == DisplayEncoding.Rgb24 ? 3 : 1); + if (args.Data == null || args.Data.Length < stride * display.Height) { _fault(new ArgumentException("Invalid frame buffer for " + args.Id)); return; } + display.Source.Publish(new PluginDisplayFrame(args.Data, display.Width, display.Height, stride, format, ++_sequence, Timestamp(), display.Instance)); + } + private void Emit(ControllerEventKind kind, string id, double value, ControllerValueSemantics semantics = ControllerValueSemantics.Binary, double maximum = 1d) + { + if (_disposed) return; + if (!_scheduler.IsOwner) { _scheduler.Post(() => Emit(kind, id, value, semantics, maximum)); return; } + _controller.Publish(new PluginControllerEvent(kind, id, value, ++_sequence, Timestamp(), -1, semantics, maximum)); + } + private void EmitNative(ControllerEventKind kind, int number, double value, ControllerValueSemantics semantics, double maximum) + { + if (_disposed || _native == null) return; + if (!_scheduler.IsOwner) { _scheduler.Post(() => EmitNative(kind, number, value, semantics, maximum)); return; } + _native.Publish(new PluginControllerEvent(kind, NumberName(number), value, ++_sequence, Timestamp(), number, semantics, maximum)); + } + private string NumberName(int number) + { + if (number < 0) return ""; + if (number >= _numberNames.Length) return number.ToString(System.Globalization.CultureInfo.InvariantCulture); + return _numberNames[number] ??= number.ToString(System.Globalization.CultureInfo.InvariantCulture); + } + // Mapped coils stay binary: a normalized 0..1 value does not say whether the provider integrates it. Modulated coils + // are described by engines that report native outputs. + private void Coil(object sender, CoilEventArgs args) => Emit(ControllerEventKind.Coil, args.Id, args.Value); + private void Lamp(object sender, LampEventArgs args) + { + var (semantics, maximum) = _lampSemantics.TryGetValue((args.Source, args.IsCoil, args.Id), out var known) ? known : (ControllerValueSemantics.Binary, 1d); + Emit(args.Source == LampSource.GI ? ControllerEventKind.GeneralIllumination : ControllerEventKind.Lamp, args.Id, args.Value, semantics, maximum); + } + /// + /// Records what a mapped lamp's raw values mean, as normalizes them: on/off lamps are binary, + /// fading lamps run to their fading steps (a few steps are levels, such as WPC GI 0..8; one step is a normalized 0..1 + /// brightness and many are physical brightness), and RGB channels run to 255. Numeric IDs are also registered without + /// leading zeros, as LampPlayer does. When several mappings share an ID, the widest range wins. + /// + private void AddLampSemantics(LampMapping lamp) + { + if (string.IsNullOrEmpty(lamp.Id)) return; + (ControllerValueSemantics, double) meaning = lamp.Type switch { + LampType.SingleFading when lamp.FadingSteps > 0 => (lamp.FadingSteps >= 2 && lamp.FadingSteps <= 9 ? ControllerValueSemantics.Levels : ControllerValueSemantics.Physical, (double)lamp.FadingSteps), + LampType.Rgb => (ControllerValueSemantics.Physical, 255d), + LampType.RgbMulti => (ControllerValueSemantics.Physical, 255d), + _ => (ControllerValueSemantics.Binary, 1d) + }; + AddLampSemantics(lamp.Source, lamp.IsCoil, lamp.Id, meaning); + if (int.TryParse(lamp.Id, out int number) && number.ToString() != lamp.Id) AddLampSemantics(lamp.Source, lamp.IsCoil, number.ToString(), meaning); + } + private void AddLampSemantics(LampSource source, bool isCoil, string id, (ControllerValueSemantics semantics, double maximum) meaning) + { + var key = (source, isCoil, id); + if (!_lampSemantics.TryGetValue(key, out var existing) || meaning.maximum > existing.maximum) _lampSemantics[key] = meaning; + } + private void Lamps(object sender, LampsEventArgs args) { foreach (var lamp in args.LampsChanged) Lamp(sender, lamp); } + private void Switch(object sender, SwitchEventArgs2 args) => Emit(ControllerEventKind.Switch, args.Id, args.IsEnabled ? 1 : 0); + private static long Timestamp() => (long)(Stopwatch.GetTimestamp() * (1000000d / Stopwatch.Frequency)); + /// + /// Converts a gamelogic engine's to the plugin : + /// to , to + /// , to , + /// and and to . + /// + /// The engine's frame format. + /// + /// The matching encoding, or for the segment, alphanumeric and numeric formats, which are + /// not published as display frames. + /// + public static DisplayEncoding FromLegacy(DisplayFrameFormat format) => format switch { + DisplayFrameFormat.Dmd2 => DisplayEncoding.Gray2, DisplayFrameFormat.Dmd4 => DisplayEncoding.Gray4, + DisplayFrameFormat.Dmd8 => DisplayEncoding.Luminance8, DisplayFrameFormat.Dmd24 => DisplayEncoding.Rgb24, + DisplayFrameFormat.Video24 => DisplayEncoding.Rgb24, _ => DisplayEncoding.None + }; + /// + /// Converts a plugin to the to request from a gamelogic engine + /// through : to + /// , to , + /// to , and anything else to + /// . The DMD plugin also uses it to hand frames to its native pipeline. + /// + /// + /// A single encoding. , , + /// and combined flags all map to . + /// + /// The matching engine frame format. + public static DisplayFrameFormat ToLegacy(DisplayEncoding format) => format switch { + DisplayEncoding.Gray2 => DisplayFrameFormat.Dmd2, DisplayEncoding.Gray4 => DisplayFrameFormat.Dmd4, + DisplayEncoding.Rgb24 => DisplayFrameFormat.Dmd24, _ => DisplayFrameFormat.Dmd8 + }; + /// + /// Unsubscribes from the engine, unpublishes every resource, removes the native output listener and, if raw identification + /// frames were requested, asks the engine for its default display format again. Safe to call more than once. Call it on the + /// owner thread after the plugins have stopped, as the Player does when it disposes its game scope. + /// + public void Dispose() + { + if (_disposed) return; _disposed = true; + _engine.OnDisplaysRequested -= DisplaysRequested; _engine.OnDisplayUpdateFrame -= DisplayFrame; + _engine.OnCoilChanged -= Coil; _engine.OnLampChanged -= Lamp; _engine.OnLampsChanged -= Lamps; _engine.OnSwitchChanged -= Switch; + foreach (var display in _displays.Values) display.Dispose(); _displays.Clear(); + foreach (var segments in _segments.Values) segments.Dispose(); _segments.Clear(); _scope.Dispose(); + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/Plugins/GamelogicPluginResources.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Plugins/GamelogicPluginResources.cs.meta new file mode 100644 index 000000000..18d593878 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Plugins/GamelogicPluginResources.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 2a5a7b8cf4efce94f8a0e50242200ba9 \ No newline at end of file diff --git a/VisualPinball.Unity/VisualPinball.Unity/Plugins/PackagedGameInformation.cs b/VisualPinball.Unity/VisualPinball.Unity/Plugins/PackagedGameInformation.cs new file mode 100644 index 000000000..a121043e3 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Plugins/PackagedGameInformation.cs @@ -0,0 +1,90 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// SPDX-License-Identifier: MIT +// Licensed under MIT. See https://opensource.org/license/mit. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Security.Cryptography; +using System.Threading; +using System.Threading.Tasks; +using VisualPinball.Plugins; + +namespace VisualPinball.Unity +{ + /// + /// Identifies the game in a table package without loading the table, for per-game plugin settings and for plugins that look for + /// matching media. + /// + /// + /// The Player uses it when the loaded table's gamelogic engine does not implement , and its + /// plugin settings page uses it to open the profile of the game selected in the library before anything is loaded. The + /// package's components are handed to the registered s (see + /// ); PinMAME's reader yields the ROM name. When no reader finds an identity, the game + /// is identified by the SHA-256 hash of the package file (provider package). That identity survives moving or renaming + /// the file, but changes when its content changes, for example when the table is exported again, which detaches it from the + /// settings saved for the old hash. + /// + /// Results are cached per full path and read again when the file's size or last write time changes. + /// + /// + /// + /// var identity = (engine as IGameIdentityProvider)?.GameIdentity ?? await PackagedGameInformation.ReadAsync(path, cancellation); + /// + /// + /// + public static class PackagedGameInformation + { + private sealed class Cached + { + internal long Size; + internal DateTime Modified; + internal GameIdentity Identity; + } + // Ordinal on every platform: Linux paths are case-sensitive, and elsewhere a differently cased path only costs a second read. + private static readonly Dictionary Cache = new Dictionary(StringComparer.Ordinal); + /// + /// Reads the game identity of a table package on a worker thread. Safe to call from any thread, also concurrently. The + /// identity readers run on the worker thread. + /// + /// Path of the table package; a relative path is resolved against the current directory. + /// Cancels the read, including hashing a large package. + /// + /// The single identity found by the registered readers, or a package identity whose key is the lowercase hexadecimal + /// SHA-256 hash of the file. Errors opening or reading the file fault the task. + /// + /// The readers found more than one distinct identity in the package. + /// was cancelled. + public static Task ReadAsync(string path, CancellationToken cancellation = default) => Task.Run(() => Read(path, cancellation), cancellation); + private static GameIdentity Read(string path, CancellationToken cancellation) + { + path = Path.GetFullPath(path); + var file = new FileInfo(path); + lock (Cache) if (Cache.TryGetValue(path, out var cached) && cached.Size == file.Length && cached.Modified == file.LastWriteTimeUtc) return cached.Identity; + var found = new HashSet(); + using (var storage = PackageApi.StorageManager.OpenStorage(path)) { + var table = storage.GetFolder(PackageApi.TableFolder); + if (table.TryGetFolder("items", out var items)) items.VisitFolders(node => node.VisitFolders(component => { + cancellation.ThrowIfCancellationRequested(); + var reader = PackagedGameIdentityReaders.Find(component.Name); + if (reader == null) return; + component.VisitFiles(payload => { var identity = reader.Read(payload.GetData()); if (identity.HasValue) found.Add(identity.Value); }); + })); + } + if (found.Count > 1) throw new InvalidDataException("The table has multiple controller identities. Select a controller profile explicitly."); + GameIdentity result; + if (found.Count == 1) { using var iterator = found.GetEnumerator(); iterator.MoveNext(); result = iterator.Current; } + else { + using var stream = File.OpenRead(path); using var sha = SHA256.Create(); + var buffer = new byte[64 * 1024]; int read; + while ((read = stream.Read(buffer, 0, buffer.Length)) > 0) { cancellation.ThrowIfCancellationRequested(); sha.TransformBlock(buffer, 0, read, buffer, 0); } + sha.TransformFinalBlock(Array.Empty(), 0, 0); + result = new GameIdentity("package", BitConverter.ToString(sha.Hash).Replace("-", "").ToLowerInvariant()); + } + lock (Cache) Cache[path] = new Cached { Size = file.Length, Modified = file.LastWriteTimeUtc, Identity = result }; + return result; + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/Plugins/PackagedGameInformation.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Plugins/PackagedGameInformation.cs.meta new file mode 100644 index 000000000..042620e18 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Plugins/PackagedGameInformation.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e12aba9f38f9db94b96fa1b09cd49d45 \ No newline at end of file diff --git a/VisualPinball.Unity/VisualPinball.Unity/Plugins/PlayerIntegrationMode.cs b/VisualPinball.Unity/VisualPinball.Unity/Plugins/PlayerIntegrationMode.cs new file mode 100644 index 000000000..ee36c407a --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Plugins/PlayerIntegrationMode.cs @@ -0,0 +1,72 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// SPDX-License-Identifier: MIT +// Licensed under MIT. See https://opensource.org/license/mit. + +using UnityEngine; +using VisualPinball.Plugins; + +namespace VisualPinball.Unity +{ + /// + /// Tells integration packages whether a player application manages them through the plugin host. Player applications + /// claim integration ownership before automatic native bridges can start. + /// + /// + /// Some integrations start themselves when VPE runs a table in the editor or in a standalone build. The DMD package, + /// for example, adds a bridge to every table it finds so DMD frames reach physical displays. In + /// the VPE Player the same work is done by plugins under the user's settings, so the Player calls + /// and the automatic bridges stand down by checking . + /// + /// Unity's load order makes this work: the state is reset at SubsystemRegistration, the Player claims at + /// AfterAssembliesLoaded, and bridges start at BeforeSceneLoad. + /// + /// + /// An integration that starts itself checks the mode first: + /// + /// [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)] + /// private static void StartWatcher() + /// { + /// if (PlayerIntegrationMode.HostManaged) return; // the Player's plugin does this job + /// var gameObject = new GameObject("My bridge"); + /// Object.DontDestroyOnLoad(gameObject); + /// gameObject.AddComponent<MyBridgeWatcher>(); + /// } + /// + /// + /// + public static class PlayerIntegrationMode + { + /// + /// True when a player application has called in this run. Integrations must then not start + /// their own bridges. False by default, in the editor and in builds without a plugin host. + /// + public static bool HostManaged { get; private set; } + /// + /// Marks integrations as managed by the host for the rest of the run. Call it on the main thread from a + /// [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterAssembliesLoaded)] method, so it runs after + /// the reset and before integrations start. Only player applications call this; plugins have no reason to. + /// + /// + /// + /// + /// [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterAssembliesLoaded)] + /// private static void ClaimIntegrations() => PlayerIntegrationMode.Claim(); + /// + /// + /// + public static void Claim() => HostManaged = true; + /// + /// Clears the claim and the plugin and identity-reader registries at the start of every run, including each play mode + /// entry in the editor with domain reload disabled, so state from a previous play session does not linger. + /// + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] + private static void Reset() + { + HostManaged = false; + PlayerPluginRegistry.Reset(); + PackagedGameIdentityReaders.Reset(); + } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/Plugins/PlayerIntegrationMode.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Plugins/PlayerIntegrationMode.cs.meta new file mode 100644 index 000000000..98b682da5 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Plugins/PlayerIntegrationMode.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 6139831b064c6194b8eeb1de5e6044c0 \ No newline at end of file diff --git a/VisualPinball.Unity/VisualPinball.Unity/Plugins/UnityPluginContracts.cs b/VisualPinball.Unity/VisualPinball.Unity/Plugins/UnityPluginContracts.cs new file mode 100644 index 000000000..0d9d76df2 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Plugins/UnityPluginContracts.cs @@ -0,0 +1,260 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// SPDX-License-Identifier: MIT +// Licensed under MIT. See https://opensource.org/license/mit. + +using UnityEngine; +using VisualPinball.Plugins; + +namespace VisualPinball.Unity +{ + /// + /// The Unity scene of the game being played: the table's root object and its gamelogic engine. Plugins that need to + /// add objects to the table or talk to a specific engine get it from the game's capabilities. + /// + /// + /// The Player publishes one instance under for each game, before plugins + /// receive , and withdraws it when the game stops. The PinMAME plugin uses + /// it to find the PinMameGamelogicEngine under the table, the DMD plugin parents its bridge object to + /// , and the B2S plugin attaches its in-scene backglass to it. All access belongs on the owner + /// (main) thread. + /// + /// + /// public override Task PrepareGameAsync(PluginGameContext game, CancellationToken cancellation) + /// { + /// var scene = game.Capabilities.Require<IUnityGameScene>(UnityPluginCapability.GameScene); + /// _root = new GameObject("My plugin"); + /// _root.transform.SetParent(scene.Table.transform, false); + /// game.Lifetime.Own(new Subscription(() => { if (_root != null) UnityEngine.Object.Destroy(_root); })); + /// return Task.CompletedTask; + /// } + /// + /// + /// + public interface IUnityGameScene + { + /// + /// Root object of the loaded table. Valid for the duration of the game; objects a plugin adds under it are destroyed + /// with the table unless the plugin removes them first. + /// + GameObject Table { get; } + /// + /// The table's gamelogic engine, found on the same object as its component, or null when the + /// table has no Player or no engine. Cast it to an optional interface (such as ) + /// or a concrete engine type to reach engine-specific features. + /// + IGamelogicEngine Gamelogic { get; } + } + /// + /// Default immutable , created by the Player for each game it prepares. + /// + /// + /// Plugins consume and have no reason to create this type; a host or test that publishes + /// itself can use it. + /// + /// How the Player publishes the scene for the game's lifetime: + /// + /// _gameScope.Own(Host.Capabilities.Publish<IUnityGameScene>(UnityPluginCapability.GameScene, new UnityGameScene(table, engine))); + /// + /// + /// + public sealed class UnityGameScene : IUnityGameScene + { + /// + public GameObject Table { get; } + /// + public IGamelogicEngine Gamelogic { get; } + /// Creates a scene description. Neither argument is validated. + /// Root object of the loaded table. + /// The table's gamelogic engine, or null when it has none. + public UnityGameScene(GameObject table, IGamelogicEngine gamelogic) { Table = table; Gamelogic = gamelogic; } + } + /// + /// Implemented by a gamelogic engine to declare which display frame encodings it can produce, so VPE publishes its + /// display resources with the right formats and negotiates raw frames correctly. + /// + /// + /// reads this when a game is prepared. The formats are advertised in each display's + /// . When a consumer binds a display for + /// (for example the DMD plugin matching colorization frames), VPE requests raw + /// or frames through + /// and returns to when the last such + /// consumer leaves. Native display negotiation may apply to the entire provider, even when the API accepts a display ID; + /// declares that. + /// + /// Without this interface VPE assumes Gray2 | Gray4 | Luminance8 | Rgb24 for engines that implement + /// and Luminance8 | Rgb24 otherwise, with + /// as default and per-display negotiation. + /// + /// + /// PinMAME switches the frame format of all its displays at once: + /// + /// DisplayEncoding IDisplayResourceMetadata.DisplayFormats => DisplayEncoding.Gray2 | DisplayEncoding.Gray4 | DisplayEncoding.Luminance8; + /// DisplayEncoding IDisplayResourceMetadata.DefaultDisplayFormat => DisplayEncoding.Luminance8; + /// bool IDisplayResourceMetadata.FormatAppliesToAllDisplays => true; + /// + /// + /// + public interface IDisplayResourceMetadata + { + /// All encodings the engine can deliver for its dot-matrix displays, as flags. + DisplayEncoding DisplayFormats { get; } + /// + /// The single encoding the engine delivers when no consumer asks for raw frames. VPE requests it again when the last + /// identification consumer leaves, or when the game's resources are disposed while raw frames were requested. + /// + DisplayEncoding DefaultDisplayFormat { get; } + /// + /// True when a format request changes every display of the engine, not just the one named. VPE then negotiates once + /// for all displays and calls with an empty + /// display ID. False negotiates each display separately. + /// + bool FormatAppliesToAllDisplays { get; } + } + /// + /// Implemented by a gamelogic engine that produces its own sound (such as ROM audio), so VPE can publish it as a plugin + /// audio resource. + /// + /// + /// When a game is prepared, publishes + /// as a resource with the + /// role rom-mix and the ID <controller>/audio/rom, where plugins can bind it. PinMAME exposes its emulated sound board this way. + /// + /// + /// private readonly AudioResourceSource _pluginAudio = new AudioResourceSource(error => Debug.LogException(error)); + /// IPluginAudioSource IGamelogicAudioResourceProvider.AudioSource => _pluginAudio; + /// + /// + /// + public interface IGamelogicAudioResourceProvider + { + /// + /// The engine's audio stream. Read once when the game's resources are created; must not be null. The engine owns the + /// source and publishes frames to it. + /// + IPluginAudioSource AudioSource { get; } + } + + /// + /// Receives every controller output in the controller's own numbering, on Unity's main thread. + /// + /// + /// VPE implements this interface and hands it to engines through + /// ; engine authors call it, plugins do not implement it. + /// VPE republishes each call as a on the game's + /// resource, where the B2S backglass reads ROM lamps and solenoids by number. Calls made from another thread are posted + /// to the owner thread. + /// + public interface INativeOutputListener + { + /// Reports the new value of one output. + /// + /// The output type: , , + /// or . + /// + /// The output's number as the controller defines it, such as a PinMAME lamp number. + /// The raw value, interpreted through and . + /// What means (on/off, levels, faded brightness or position). + /// The value that means fully on, or the top of the range; 1 for binary outputs. + void NativeOutputChanged(ControllerEventKind kind, int number, double value, ControllerValueSemantics semantics, double maximum); + /// + /// The controller finished one poll. VPE publishes it as so consumers can + /// apply the outputs they collected in one batch. Call it after the poll's last . + /// + void NativeOutputPollEnd(); + } + + /// + /// A gamelogic engine that reports all lamps, solenoids, GI strings and mechs by native number, independent of the + /// table's mapping. Backglass-only outputs (match, tilt, ball in play) are usually not mapped to anything on the + /// playfield, so the mapped events alone miss them. + /// + /// + /// When an engine implements this interface, publishes an extra + /// resource (<controller>/outputs) and installs a listener for the + /// game's duration. The B2S plugin prefers that resource over the mapped controller events when it exists. + /// + /// PinMAME forwards each poll's changes after dispatching its mapped events: + /// + /// public void SetNativeOutputListener(INativeOutputListener listener) => _nativeOutputs = listener; + /// + /// private void DispatchNativeOutputs() + /// { + /// var listener = _nativeOutputs; + /// if (listener == null) return; + /// foreach (var lamp in _changedLamps) { + /// listener.NativeOutputChanged(ControllerEventKind.Lamp, lamp.Id, lamp.Value, ControllerValueSemantics.Binary, 1); + /// } + /// listener.NativeOutputPollEnd(); + /// } + /// + /// + /// + public interface IGamelogicNativeOutputs + { + /// + /// Sets the single listener, or null. Changes are delivered from the engine's main-thread poll. VPE sets a listener + /// when the game's resources are created and passes null when they are disposed; the engine should then drop any + /// queued outputs. + /// + /// The listener to call for every output change, or null to stop reporting. + void SetNativeOutputListener(INativeOutputListener listener); + } + + /// + /// Implemented by a gamelogic engine to declare the layout and technology of its segment displays, as published in + /// . + /// + /// + /// When an engine announces a segment display (a requested display with a height of 1), + /// asks this interface for its layout and publishes a resource. Without an answer + /// the layout is seg16 with no hardware hint. The B2S backglass uses the layout and hint to draw its segment + /// digits. PinMAME maps its display types onto the layouts and reports gottlieb for upright Gottlieb + /// System 80 glass displays. + /// + public interface ISegmentDisplayMetadata + { + /// + /// Returns the layout (seg7, seg9, seg14, seg16) and hardware hint of a display. + /// Called on the main thread when the display is announced, so the engine must know the layout by then. + /// + /// The display ID the engine used when it announced the display. + /// The segment layout; see . + /// The technology hint, or an empty string; see . + /// True when the engine knows the display; false to use the defaults. + bool TryGetSegmentLayout(string displayId, out string layout, out string hardware); + } + /// + /// Keys of the capabilities the Unity Player publishes to plugins through . + /// + /// + /// Plugins read them from or + /// (the same object) with , which throws when the host does not provide the + /// capability, or with , which returns null. + /// + /// + /// var scene = game.Capabilities.Require<IUnityGameScene>(UnityPluginCapability.GameScene); + /// var outputs = game.Capabilities.Require<PluginOutputLeases>(UnityPluginCapability.Outputs); + /// + /// + /// + public static class UnityPluginCapability + { + /// + /// The current game's . Published per game before plugins prepare it and removed when + /// the game stops. + /// + public const string GameScene = "vpe.unity.game-scene.v1"; + /// + /// The application-wide bus for typed notifications between plugins, delivered on the owner + /// thread. + /// + public const string Events = "vpe.events.v1"; + /// + /// The application-wide , which gives one plugin exclusive use of a physical output + /// device. The DMD plugin leases its ZeDMD, PinDMD3 and Pixelcade ports through it. + /// + public const string Outputs = "vpe.outputs.v1"; + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/Plugins/UnityPluginContracts.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Plugins/UnityPluginContracts.cs.meta new file mode 100644 index 000000000..dc63c748d --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Plugins/UnityPluginContracts.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 59959f06114aeb7469564553b173ee22 \ No newline at end of file diff --git a/VisualPinball.Unity/VisualPinball.Unity/Plugins/UnitySettingsMedia.cs b/VisualPinball.Unity/VisualPinball.Unity/Plugins/UnitySettingsMedia.cs new file mode 100644 index 000000000..ecb555453 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Plugins/UnitySettingsMedia.cs @@ -0,0 +1,209 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// SPDX-License-Identifier: MIT +// Licensed under MIT. See https://opensource.org/license/mit. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using UnityEngine; +using VisualPinball.Plugins; + +namespace VisualPinball.Unity +{ + /// + /// A movable desktop window that a plugin opens for the Player's screen layout editor. Native output transport only: + /// the Player owns placement controls and test-pattern rendering. + /// + /// + /// When the user selects a screen in a field, the Player asks the plugin's + /// for a window at the stored rectangle, draws a test pattern into a texture + /// and hands it over with . While the window is open the Player polls + /// (about every 80 ms) to pick up the user's drags and resizes. PinUP and the B2S backglass implement it with a + /// in placement mode, so the test window is the same kind of window that shows the + /// game later. All members are called on the main thread while the Player is running. + /// + /// + /// internal sealed class MySettingsWindow : IUnitySettingsWindow + /// { + /// private readonly NativeWindowSurface _window; + /// private readonly IDisposable _presentation; + /// internal MySettingsWindow(RectInt bounds) + /// { + /// _window = new NativeWindowSurface(bounds.x, bounds.y, bounds.width, bounds.height); + /// _presentation = NativeWindowHost.Register(_window); + /// _window.SetPlacementMode(true); + /// SetBounds(bounds); + /// } + /// public RectInt Bounds { + /// get { var state = _window.Diagnostics; return new RectInt(state.Left, state.Top, state.WindowWidth, state.WindowHeight); } + /// } + /// public void SetBounds(RectInt bounds) => _window.Configure(bounds.x, bounds.y, bounds.width, bounds.height, true, true); + /// public void SetTexture(Texture texture) => _window.SetTexture(texture != null ? texture.GetNativeTexturePtr() : IntPtr.Zero); + /// public void Activate() => _window.BringToFront(); + /// public void Dispose() { _presentation.Dispose(); _window.Dispose(); } + /// } + /// + /// + /// + public interface IUnitySettingsWindow : IDisposable + { + /// + /// The window's actual rectangle in signed physical desktop pixels, read back from the window system, so it includes + /// moves and resizes the user made. Origins left of or above the primary monitor are negative. + /// + RectInt Bounds { get; } + /// + /// Moves and resizes the window to a rectangle in signed physical desktop pixels and shows it. The Player clamps the + /// size to at least 64 x 32 pixels before calling it. + /// + /// The new window rectangle. + void SetBounds(RectInt bounds); + /// + /// Sets the texture the window shows, or null to show nothing. The texture belongs to the caller and must stay alive + /// until it is replaced or the window is disposed. The Player passes null and then a new texture whenever the window + /// size changes. + /// + /// A created texture with premultiplied RGBA content, or null to detach. + void SetTexture(Texture texture); + /// Brings the window in front of other windows, for example when the user selects its screen again. + void Activate(); + } + /// + /// Implemented by a plugin factory whose plugin shows content on its own desktop windows, so the Player's screen layout + /// editor can open test windows for it. + /// + /// + /// The Player checks the of the plugin whose settings are shown for this interface. + /// PinUP and the B2S backglass implement it and report as + /// . + /// + /// + /// internal sealed class MyPluginFactory : IPlayerPluginFactory, IUnitySettingsWindowProvider + /// { + /// public bool Supported => NativeWindowSurface.IsSupported; + /// public IUnitySettingsWindow OpenWindow(RectInt bounds) => new MySettingsWindow(bounds); + /// // IPlayerPluginFactory members omitted. + /// } + /// + /// + /// + public interface IUnitySettingsWindowProvider + { + /// + /// True when windows can be opened on this platform and graphics backend. The Player does not call + /// when it is false and tells the user that placement is unavailable. + /// + bool Supported { get; } + /// + /// Opens a test window at a rectangle in signed physical desktop pixels; the Player calls + /// right after. Called on the main thread while the Player is running. The + /// caller owns and disposes the returned window. Exceptions are shown to the user. + /// + /// The stored placement of the screen being edited. + /// The new window. + IUnitySettingsWindow OpenWindow(RectInt bounds); + } + /// + /// One image of a settings preview: a texture and its normalized placement on the preview canvas. No UI objects or + /// styling cross this boundary. + /// + /// + /// The Player draws every layer of on its own canvas after each + /// . PinUP uses one layer per PUP screen, positioned relative to the + /// backglass screen; the B2S backglass uses a single full-canvas layer. The plugin owns the layer objects and updates + /// their properties on the main thread. + /// + /// + /// var layer = new UnitySettingsPreviewLayer("backglass"); + /// layer.Texture = compositor.SceneTexture; + /// layer.Rectangle = new Rect(0, 0, 1, 1); + /// layer.Visible = layer.Texture != null; + /// + /// + /// + public sealed class UnitySettingsPreviewLayer + { + /// + /// Stable key of the layer. The Player keeps one image per ID and removes it when no layer with that ID is listed + /// any more. + /// + public string Id { get; } + /// The texture to show, or null. The plugin owns it; the Player only displays it. + public Texture Texture { get; set; } + /// + /// Placement on the canvas, normalized to its size: x and y are the left and top edges from 0 to 1 (top-left origin), + /// width and height the fractions of the canvas width and height. Defaults to an empty rectangle. + /// + public Rect Rectangle { get; set; } + /// Whether the Player shows the layer. False (the default) hides it without removing it. + public bool Visible { get; set; } + /// Creates a hidden layer with no texture. + /// The layer's stable key; see . + public UnitySettingsPreviewLayer(string id) { Id = id; } + } + /// + /// A live preview that a plugin renders inside its settings page, such as a PUP pack or a backglass playing without a + /// game. + /// + /// + /// Returned by and owned by the Player, which calls + /// about every 16 ms on the main thread, then scales to fit its viewport + /// and draws on it. An exception from stops the preview and is shown to the + /// user. The Player disposes the preview when the user stops it, starts another one or leaves the page. + /// + public interface IUnitySettingsPreview : IDisposable + { + /// + /// Size of the canvas the layers are placed on, in any unit (typically pixels); only its aspect ratio matters to + /// the Player. Nothing is drawn while either component is zero or less. + /// + Vector2 CanvasSize { get; } + /// + /// The current layers. The Player reads the list after every ; layers may be added and removed + /// between calls. Layers are stacked in the order their IDs first appear. + /// + IReadOnlyList Layers { get; } + /// + /// Advances playback by one Player frame and refreshes and . Called on + /// the main thread. + /// + void Update(); + } + /// + /// Implemented by a plugin factory that can show a live preview in a settings field. + /// + /// + /// When the user presses Start on a preview field, the Player calls with the field's + /// and the page's effective settings. PinUP answers pack-preview by + /// playing the selected pack offscreen, and the B2S backglass answers backglass-preview by rendering the selected + /// backglass with a slow lamp test. + /// + /// + /// public Task<IUnitySettingsPreview> OpenPreviewAsync(string provider, PluginChoiceRequest request, CancellationToken cancellation) + /// => provider == "backglass-preview" + /// ? MyPreview.OpenAsync(request, cancellation) + /// : throw new ArgumentException("Unknown preview provider.", nameof(provider)); + /// + /// + /// + public interface IUnitySettingsPreviewProvider + { + /// + /// Called on Unity's owning thread. Return an owned preview; cancellation must release partial startup. Loading may + /// continue asynchronously; the Player disposes a preview that completes after it was cancelled or replaced. + /// + /// The of the preview field, selecting which preview to open. + /// + /// The game, the page's effective settings, the platform, the published resources and the selected package. + /// + /// Cancelled when the user stops the preview or leaves the page before it is ready. + /// The new preview, owned by the caller. + /// Typically thrown for an unknown . + /// was cancelled during startup. + /// Any other exception's message is shown to the user as the preview status. + Task OpenPreviewAsync(string provider, PluginChoiceRequest request, CancellationToken cancellation); + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/Plugins/UnitySettingsMedia.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Plugins/UnitySettingsMedia.cs.meta new file mode 100644 index 000000000..5eddbb1e9 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Plugins/UnitySettingsMedia.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e18b52b309b87e049b69724534e8b760 \ No newline at end of file diff --git a/VisualPinball.Unity/VisualPinball.Unity/Plugins/UnityTextureResource.cs b/VisualPinball.Unity/VisualPinball.Unity/Plugins/UnityTextureResource.cs new file mode 100644 index 000000000..c8665687f --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Plugins/UnityTextureResource.cs @@ -0,0 +1,59 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// SPDX-License-Identifier: MIT +// Licensed under MIT. See https://opensource.org/license/mit. + +using System; +using UnityEngine; +using VisualPinball.Plugins; + +namespace VisualPinball.Unity +{ + /// + /// A resource backed by a borrowed Unity texture. Its producer owns it; consumers may + /// sample it until or resource removal. + /// + /// + /// Plugins that render an image other plugins can show publish it as a surface with this interface, so consumers in the + /// same Unity process can use the texture directly on the GPU. PinUP publishes the render texture of each PUP screen + /// that shows video (role topper for screen 0, dmd for screen 1, backglass for screen 2, + /// full-dmd for screen 5, pup-screen-N otherwise), and the B2S plugin publishes its composed backglass + /// with the role backglass; see . Both report unity-render-texture as + /// . shows these surfaces on the table's + /// s. Consumers must not modify, resize or release the texture, and must not keep + /// using it after the resource is removed. + /// + /// Publishing a render texture: + /// + /// private sealed class MySurface : IUnityTextureResource + /// { + /// private readonly RenderTexture _texture; + /// public MySurface(RenderTexture texture) { _texture = texture; } + /// public ResourceKind Kind => ResourceKind.Surface; + /// public int Width => _texture.width; + /// public int Height => _texture.height; + /// public string Backend => "unity-render-texture"; + /// public Texture Texture => _texture; + /// public event Action<Texture> Changed; + /// } + /// + /// game.Lifetime.Own(game.Resources.Publish(new PluginResourceDescriptor(game.ControllerId + "/surface/mine", _application.Plugin.Id, + /// game.ControllerId, "My surface", SurfaceRoles.Backglass, ResourceKind.Surface, game.Session, 1), new MySurface(texture))); + /// + /// + /// + public interface IUnityTextureResource : IPluginSurface + { + /// + /// The current texture, or null while the producer has not created it yet. Read on the main thread. Keep using a + /// texture only until announces another one. + /// + Texture Texture { get; } + /// + /// Raised on the main thread when the producer creates, replaces (for example after a resize) or releases its texture. + /// The argument is the new texture, or null when there is none any more. + /// + event Action Changed; + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/Plugins/UnityTextureResource.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Plugins/UnityTextureResource.cs.meta new file mode 100644 index 000000000..afdeb9f1f --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Plugins/UnityTextureResource.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9715411effe95c44ea6a0b2851a7ab17 \ No newline at end of file diff --git a/VisualPinball.Unity/VisualPinball.Unity/Sound/MechanicalSoundGain.cs b/VisualPinball.Unity/VisualPinball.Unity/Sound/MechanicalSoundGain.cs new file mode 100644 index 000000000..87feb2bad --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Sound/MechanicalSoundGain.cs @@ -0,0 +1,53 @@ +// Visual Pinball Engine +// Copyright (C) 2026 freezy and VPE Team +// +// SPDX-License-Identifier: MIT +// Licensed under MIT. See https://opensource.org/license/mit. + +using System; +using System.Collections.Generic; +using UnityEngine; + +namespace VisualPinball.Unity +{ + /// Applies a user gain while keeping mechanical requests and fades in their authored units. + public static class MechanicalSoundGain + { + private static readonly Dictionary Levels = new(); + private static readonly List Destroyed = new(); + private static float _gain = 1; + public static float Gain { + get => _gain; + set { + if (float.IsNaN(value) || float.IsInfinity(value)) throw new ArgumentOutOfRangeException(nameof(value)); + _gain = Mathf.Clamp01(value); + Destroyed.Clear(); + foreach (var pair in Levels) { + if (pair.Key == null) Destroyed.Add(pair.Key); + else pair.Key.volume = pair.Value * _gain; + } + foreach (var source in Destroyed) Levels.Remove(source); + Destroyed.Clear(); + } + } + internal static void Register(AudioSource source) + { + Levels[source] = source.volume; + source.volume *= _gain; + } + internal static void Unregister(AudioSource source) + { + // Unity's destroyed-object null check must not prevent removing its managed key. + if (!ReferenceEquals(source, null)) Levels.Remove(source); + } + public static float AuthoredLevel(AudioSource source) => Levels.TryGetValue(source, out float level) ? level : source.volume; + public static void SetAuthoredLevel(AudioSource source, float level) + { + level = Mathf.Clamp01(level); + if (Levels.ContainsKey(source)) { Levels[source] = level; source.volume = level * _gain; } + else source.volume = level; + } + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] + private static void Reset() { Levels.Clear(); Destroyed.Clear(); _gain = 1; } + } +} diff --git a/VisualPinball.Unity/VisualPinball.Unity/Sound/MechanicalSoundGain.cs.meta b/VisualPinball.Unity/VisualPinball.Unity/Sound/MechanicalSoundGain.cs.meta new file mode 100644 index 000000000..483c34546 --- /dev/null +++ b/VisualPinball.Unity/VisualPinball.Unity/Sound/MechanicalSoundGain.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 617582555a436984182cb2d9ae48a14f \ No newline at end of file diff --git a/VisualPinball.Unity/VisualPinball.Unity/Sound/SoundEffectAsset.cs b/VisualPinball.Unity/VisualPinball.Unity/Sound/SoundEffectAsset.cs index 518fb1c39..510a49bed 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/Sound/SoundEffectAsset.cs +++ b/VisualPinball.Unity/VisualPinball.Unity/Sound/SoundEffectAsset.cs @@ -112,7 +112,12 @@ public async Task Play( try { ConfigureAudioSource(audioSource); - audioSource.volume *= volume; + bool mechanical = Application.isPlaying && _type == SoundEffectType.Mechanical; +#if UNITY_EDITOR + mechanical &= !EditorSceneManager.IsPreviewScene(audioObj.scene); +#endif + if (mechanical) MechanicalSoundGain.Register(audioSource); + MechanicalSoundGain.SetAuthoredLevel(audioSource, MechanicalSoundGain.AuthoredLevel(audioSource) * volume); audioSource.Play(); using var eitherCts = CancellationTokenSource.CreateLinkedTokenSource( allowFadeOutCt, @@ -127,7 +132,7 @@ public async Task Play( await FadeOrFinish( audioSource, 0f, - audioSource.volume, + MechanicalSoundGain.AuthoredLevel(audioSource), _fadeInTime, eitherCts.Token ); @@ -151,7 +156,7 @@ await FadeOrFinish( { await FadeOrFinish( audioSource, - audioSource.volume, + MechanicalSoundGain.AuthoredLevel(audioSource), 0f, _fadeOutTime, instantCt @@ -162,6 +167,7 @@ await FadeOrFinish( } finally { + MechanicalSoundGain.Unregister(audioSource); if (audioSource != null) { if (Application.isPlaying) @@ -213,7 +219,7 @@ CancellationToken ct while (progress < 1f && audioSource != null) { ct.ThrowIfCancellationRequested(); - audioSource.volume = Mathf.Lerp(fromVolume, toVolume, progress); + MechanicalSoundGain.SetAuthoredLevel(audioSource, Mathf.Lerp(fromVolume, toVolume, progress)); #if UNITY_EDITOR var deltaTime = (float)(EditorApplication.timeSinceStartup - lastTime); lastTime = EditorApplication.timeSinceStartup; diff --git a/VisualPinball.Unity/VisualPinball.Unity/VisualPinball.Unity.asmdef b/VisualPinball.Unity/VisualPinball.Unity/VisualPinball.Unity.asmdef index 768b51a73..8363e51d6 100644 --- a/VisualPinball.Unity/VisualPinball.Unity/VisualPinball.Unity.asmdef +++ b/VisualPinball.Unity/VisualPinball.Unity/VisualPinball.Unity.asmdef @@ -10,6 +10,7 @@ "Unity.Splines", "Unity.InputSystem", "VisualPinball.Engine", + "VisualPinball.Plugins", "com.bartofzo.nativetrees", "MemoryPack.Unity", "Newtonsoft.Json" diff --git a/package.json b/package.json index 2cdbd8347..52a29d107 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "org.visualpinball.engine.unity", - "version": "0.0.1-preview.211", + "version": "0.0.1-preview.212", "displayName": "Visual Pinball Engine", "description": "Main project of the Visual Pinball Engine.", "unity": "6000.5",