using System; using System.Collections.Generic; using System.Reflection; using Unity.Pipeline.Config; using Unity.Pipeline.Commands; using Unity.Pipeline.HotReload; using Unity.Pipeline.Runtime.Telemetry; using UnityEngine; namespace Unity.Pipeline { /// /// Unified Pipeline runtime manager that handles both server creation and event processing. /// Replaces the need for separate RuntimePipelineConfig and RuntimePipelineProcessor. /// /// Simply add this component to a GameObject in your scene to enable Pipeline runtime support. /// Only one instance should exist in your project. /// [AddComponentMenu("Pipeline/Runtime Pipeline Manager")] [DisallowMultipleComponent] public class RuntimePipelineManager : MonoBehaviour { [Header("Server Configuration")] [Tooltip("Enable Pipeline HTTP server in Player builds. SECURITY WARNING: Only enable in development/QA builds, never production without proper security measures.")] public bool enableInBuilds = false; [Tooltip("HTTP port for Pipeline server. Use 0 for auto-assignment from range 7900-7999.")] [Range(0, 65535)] public int port = 0; [Header("Advanced")] [Tooltip("Request timeout in milliseconds. Higher values allow longer-running commands.")] [Range(1000, 60000)] public int requestTimeoutMs = 30000; [Tooltip("Enable detailed logging of all remote requests for security auditing.")] public bool enableAuditLogging = true; [Header("Runtime")] [Tooltip("Enable automatic server startup when the component starts (for runtime builds only).")] public bool autoStart = true; [Tooltip("Maximum work items to process per frame to maintain performance.")] [Range(1, 50)] public int maxWorkItemsPerFrame = 10; // Absolute roots this build is allowed to hot reload source files from (Assets + loaded // package locations). Baked at build time by the build processor (a running Player cannot // resolve the project layout) and published to HotReloadFileScope when the server starts. // Hidden from the inspector: it is build-injected, not user-edited. [SerializeField, HideInInspector] private List m_AllowedReloadRoots = new List(); // Internal state private RuntimePipelineServer m_Server; private RuntimePipelineConfig m_RuntimeConfig; // True only for the instance that installed FrameStatsSampler.Shared, so a rejected duplicate's // OnDestroy never tears down the real sampler. private bool m_OwnsSampler; /// /// Get the runtime server instance if it's running. /// public RuntimePipelineServer Server => m_Server; /// /// Whether the server is currently running. /// public bool IsServerRunning => m_Server != null && m_Server.IsRunning; /// /// Get the actual port the server is running on. /// public int ActualPort => m_Server?.Port ?? 0; public RuntimePipelineConfig Config => m_RuntimeConfig; /// /// Absolute roots this build may hot reload source files from. Baked at build time. /// public IReadOnlyList AllowedReloadRoots => m_AllowedReloadRoots; /// /// Set the allowed hot reload roots. Called by the build processor to bake the project's /// Assets and package locations into the build. /// public void SetAllowedReloadRoots(IEnumerable roots) { m_AllowedReloadRoots = roots != null ? new List(roots) : new List(); } void Awake() { // Ensure only one instance exists var existing = PipelineUtils.FindObjectsByType(); if (existing.Length > 1) { Debug.LogWarning($"Pipeline: Multiple RuntimePipelineManager instances found. Destroying duplicate on '{name}'."); Destroy(this); return; } // Don't destroy on load for persistent operation across scenes DontDestroyOnLoad(gameObject); // Set up command discovery (the server owns its own dispatcher, initialized on Start). CommandRegistry.SetDiscovery(null); // Triggers reflection-based discovery // Auto-discover and register hot-reloadable methods. Hot reload requires this manager to // be present (it owns the communication workflow), so discovery lives here rather than in // each gameplay script's Awake. RegisterDiscoveredHotReloadMethods(); // Own the process-wide frame-stats sampler. A Player has no profiler window or // EditorApplication.update to drive sampling, so the manager feeds it from Update (below) and // the runtime_status telemetry command reads FrameStatsSampler.Shared. Created here (rather than // on server start) so fps history is already warm by the time an agent connects. FrameStatsSampler.Shared = new FrameStatsSampler(); m_OwnsSampler = true; } void Start() { if (autoStart && enableInBuilds) { StartServer(); } } /// /// Scan loaded user assemblies for methods tagged [HotReload] (in-place workflow) and /// [HotReloadWithOverrides] (helper workflow) and register them as reload targets. This replaces any /// per-script RegisterReloadableType call in Awake. /// private static void RegisterDiscoveredHotReloadMethods() { const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly; int registered = 0; foreach (var assembly in PipelineUtils.GetLoadedAssemblies()) { if (!ShouldScanForHotReload(assembly)) continue; Type[] types; try { types = assembly.GetTypes(); } catch (ReflectionTypeLoadException ex) { types = ex.Types; // best effort: keep the types that did load } foreach (var type in types) { if (type == null) continue; foreach (var method in type.GetMethods(flags)) { var inPlace = method.GetCustomAttribute(); if (inPlace != null) { // The weaver keys dispatch on TypeName.MethodName, so register with the // default id (no custom Id) to match. HotReloadRegistry.RegisterReloadableMethod( method, new HotReloadWithOverridesAttribute { RequireMainThread = inPlace.RequireMainThread }); registered++; continue; } var reloadable = method.GetCustomAttribute(); if (reloadable != null) { HotReloadRegistry.RegisterReloadableMethod(method, reloadable); registered++; } } } } if (registered > 0) Debug.Log($"Pipeline: Auto-discovered and registered {registered} hot-reloadable method(s)."); } /// /// Skip engine/framework assemblies that cannot contain user hot-reload methods. /// private static bool ShouldScanForHotReload(Assembly assembly) { if (assembly.IsDynamic) return false; var name = assembly.GetName().Name; if (string.IsNullOrEmpty(name)) return false; foreach (var prefix in s_HotReloadSkipPrefixes) { if (name.StartsWith(prefix, StringComparison.Ordinal)) return false; } return true; } private static readonly string[] s_HotReloadSkipPrefixes = { "System", "mscorlib", "netstandard", "Microsoft", "Mono.", "nunit", "UnityEngine", "UnityEditor", "Unity.Collections", "Unity.Burst", "Unity.Mathematics", "Newtonsoft", "log4net", "ICSharpCode", }; void Update() { // Feed the frame-stats sampler once per frame on the main thread. Uses unscaled delta so // reported fps reflects real frame pacing regardless of Time.timeScale. FrameStatsSampler.Shared?.Sample(Time.unscaledDeltaTime); // Pump this server's own dispatcher for main-thread operations (player builds have no // EditorApplication.update to auto-pump it). m_Server?.Dispatcher.ProcessWorkQueue(maxWorkItemsPerFrame); // Drive the watchdog (player builds have no EditorApplication.update). No-op unless the // server's watchdog is enabled and armed. m_Server?.WatchdogTick(); } void OnApplicationQuit() { StopServer(); } void OnDestroy() { // Dispose the sampler's profiler recorders and drop the shared reference, but only if this // manager is the one that installed it (a rejected duplicate must not tear down the real one). if (m_OwnsSampler) { FrameStatsSampler.Shared?.Dispose(); FrameStatsSampler.Shared = null; m_OwnsSampler = false; } } /// /// Manually start the Pipeline server. /// public void StartServer() { if (m_Server != null && m_Server.IsRunning) { Debug.LogWarning("Pipeline: Server already started or initialization attempted."); return; } try { System.Console.WriteLine("Pipeline: Starting runtime server from RuntimePipelineManager..."); if (!enableInBuilds) { Debug.LogWarning("Pipeline: Runtime server disabled in configuration (enableInBuilds = false)"); return; } // Create runtime config from component settings m_RuntimeConfig = CreateRuntimeConfig(); // Validate configuration var validation = m_RuntimeConfig.Validate(); if (!validation.IsValid) { Debug.LogError($"Pipeline: Invalid runtime configuration: {validation.Message}"); return; } if (validation.Level == "warning") { Debug.LogWarning($"Pipeline: Runtime configuration warning: {validation.Message}"); } // Create runtime server m_Server = new RuntimePipelineServer(m_RuntimeConfig); // Start server on configured port var serverPort = port == 0 ? 0 : port; m_Server.Start(serverPort); if (m_Server.IsRunning) { // The hot-reload registry marshals main-thread overrides through a dispatcher // when hot-reloaded code is invoked from a background thread at runtime. Inject // this server's dispatcher here (a player has exactly one manager), so editor and // per-test servers never clobber the global registry's dispatcher. Unity.Pipeline.HotReload.HotReloadRegistry.Dispatcher = m_Server.Dispatcher; // Publish the build-baked allowed roots so reload commands can validate that // incoming files are inside the project before compiling and injecting them. Unity.Pipeline.HotReload.HotReloadRegistry.AllowedReloadRoots = m_AllowedReloadRoots; System.Console.WriteLine($"Pipeline: Runtime server started successfully on port {m_Server.Port}"); } else { Debug.LogError("Pipeline: Failed to start runtime server"); } } catch (Exception ex) { Debug.LogError($"Pipeline: Runtime initialization failed: {ex.Message}"); Debug.LogError($"Pipeline: Stack trace: {ex.StackTrace}"); } } /// /// Manually stop the Pipeline server. /// public void StopServer() { try { if (m_Server != null && m_Server.IsRunning) { System.Console.WriteLine("Pipeline: Stopping runtime server..."); // Drop the registry's reference to this server's (about to be shut down) dispatcher. if (Unity.Pipeline.HotReload.HotReloadRegistry.Dispatcher == m_Server.Dispatcher) Unity.Pipeline.HotReload.HotReloadRegistry.Dispatcher = null; m_Server.Stop(); // Stop() shuts down the server's own dispatcher. } } catch (Exception ex) { Debug.LogError($"Pipeline: Runtime shutdown error: {ex.Message}"); } } /// /// Create a RuntimePipelineConfig from the component settings. /// private RuntimePipelineConfig CreateRuntimeConfig() { var config = ScriptableObject.CreateInstance(); // Copy settings from component to config config.enableInBuilds = this.enableInBuilds; config.port = this.port; config.requestTimeoutMs = this.requestTimeoutMs; config.enableAuditLogging = this.enableAuditLogging; return config; } /// /// Validate the current configuration. /// public ValidationResult ValidateConfiguration() { if (m_RuntimeConfig == null) m_RuntimeConfig = CreateRuntimeConfig(); return m_RuntimeConfig.Validate(); } } }