Initial commit: AIGame Unity client with URP, HybridCLR, YooAsset and tooling.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+120
@@ -0,0 +1,120 @@
|
||||
using UnityEngine;
|
||||
using Unity.Pipeline.CodeReload;
|
||||
|
||||
namespace Unity.Pipeline
|
||||
{
|
||||
/// <summary>
|
||||
/// Minimal on-screen code-reload status for players: what is happening now (compiling → applied,
|
||||
/// or failed) and how many method overrides are active. Deliberately IMGUI — no canvas,
|
||||
/// EventSystem or render-pipeline dependency, and it is development-only anyway (the body
|
||||
/// compiles out of release players; the type itself stays so AddComponent call sites compile).
|
||||
/// Added automatically by <see cref="RuntimePipelineDriver"/> when its
|
||||
/// reload-overlay flag is on. Hidden entirely while idle with no overrides.
|
||||
/// </summary>
|
||||
[DisallowMultipleComponent]
|
||||
class CodeReloadStatusOverlay : MonoBehaviour
|
||||
{
|
||||
#if UNITY_EDITOR || DEVELOPMENT_BUILD || ENABLE_RUNTIME_PIPELINE
|
||||
// How long the "applied" confirmation stays up before falling back to the count line.
|
||||
private const int AppliedFlashMs = 3500;
|
||||
// A "compiling…" with no follow-up payload or failure notice goes stale (editor died,
|
||||
// connection dropped) — stop showing it after this.
|
||||
private const int CompilingStaleMs = 15000;
|
||||
private const float StatsPollSeconds = 1f;
|
||||
private const int MaxMessageLength = 96;
|
||||
|
||||
private int m_OverrideCount;
|
||||
private int m_CallsPerSecond;
|
||||
private long m_LastCallCount;
|
||||
private float m_LastPollTime;
|
||||
private float m_NextStatsPoll;
|
||||
private GUIStyle m_Style;
|
||||
|
||||
private void Update()
|
||||
{
|
||||
// GetStats() allocates; poll at 1 Hz instead of per frame.
|
||||
if (Time.unscaledTime >= m_NextStatsPoll)
|
||||
{
|
||||
m_NextStatsPoll = Time.unscaledTime + StatsPollSeconds;
|
||||
m_OverrideCount = CodeReloadRegistry.GetStats().ActiveOverrideCount;
|
||||
|
||||
long calls = CodeReloadActivity.OverrideCallCount;
|
||||
float elapsed = Time.unscaledTime - m_LastPollTime;
|
||||
if (m_LastPollTime > 0 && elapsed > 0)
|
||||
m_CallsPerSecond = (int)((calls - m_LastCallCount) / elapsed);
|
||||
m_LastCallCount = calls;
|
||||
m_LastPollTime = Time.unscaledTime;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnGUI()
|
||||
{
|
||||
var phase = CodeReloadActivity.Snapshot(out var message, out var ageMs);
|
||||
|
||||
string activity = null;
|
||||
Color activityColor = Color.white;
|
||||
switch (phase)
|
||||
{
|
||||
case CodeReloadActivity.Phase.Compiling when ageMs < CompilingStaleMs:
|
||||
activity = $"compiling {Truncate(message)}…";
|
||||
activityColor = new Color(1f, 0.8f, 0.25f); // amber
|
||||
break;
|
||||
case CodeReloadActivity.Phase.Applied when ageMs < AppliedFlashMs:
|
||||
activity = Truncate(message);
|
||||
activityColor = new Color(0.45f, 1f, 0.45f); // green
|
||||
break;
|
||||
case CodeReloadActivity.Phase.Failed:
|
||||
activity = Truncate(message); // sticky until the next reload attempt
|
||||
activityColor = new Color(1f, 0.45f, 0.45f); // red
|
||||
break;
|
||||
}
|
||||
|
||||
if (activity == null && m_OverrideCount == 0)
|
||||
return; // nothing to show
|
||||
|
||||
m_Style ??= new GUIStyle(GUI.skin.label) { fontSize = 12, wordWrap = false };
|
||||
|
||||
var lines = activity != null
|
||||
? new[] { ($"CodeReload: {activity}", activityColor), (CountLine(), Color.white) }
|
||||
: new[] { (CountLine(), Color.white) };
|
||||
|
||||
float width = 0, height = 4;
|
||||
foreach (var l in lines)
|
||||
{
|
||||
var s = m_Style.CalcSize(new GUIContent(l.Item1));
|
||||
if (s.x > width) width = s.x;
|
||||
height += s.y;
|
||||
}
|
||||
|
||||
var box = new Rect(8, 8, width + 12, height + 4);
|
||||
GUI.color = new Color(0f, 0f, 0f, 0.6f);
|
||||
GUI.DrawTexture(box, Texture2D.whiteTexture);
|
||||
|
||||
float y = box.y + 4;
|
||||
foreach (var l in lines)
|
||||
{
|
||||
var size = m_Style.CalcSize(new GUIContent(l.Item1));
|
||||
var r = new Rect(box.x + 6, y, size.x, size.y);
|
||||
// 1px shadow keeps it readable over bright scenes.
|
||||
GUI.color = new Color(0f, 0f, 0f, 0.9f);
|
||||
GUI.Label(new Rect(r.x + 1, r.y + 1, r.width, r.height), l.Item1, m_Style);
|
||||
GUI.color = l.Item2;
|
||||
GUI.Label(r, l.Item1, m_Style);
|
||||
y += size.y;
|
||||
}
|
||||
GUI.color = Color.white;
|
||||
}
|
||||
|
||||
// "N overrides · 0 calls/s" is a diagnostic in itself: bound but never dispatched.
|
||||
private string CountLine() => $"{m_OverrideCount} override(s) active · {m_CallsPerSecond} calls/s";
|
||||
|
||||
private static string Truncate(string s)
|
||||
{
|
||||
if (string.IsNullOrEmpty(s)) return "";
|
||||
int nl = s.IndexOf('\n');
|
||||
if (nl >= 0) s = s.Substring(0, nl); // failures can carry multi-line diagnostics
|
||||
return s.Length <= MaxMessageLength ? s : s.Substring(0, MaxMessageLength) + "…";
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d38c4fa15aa73074793fc9d4f45cb451
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,225 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Unity.Profiling;
|
||||
using UnityEngine;
|
||||
#if UNITY_6000_5_OR_NEWER
|
||||
using Unity.Scripting.LifecycleManagement;
|
||||
#endif
|
||||
|
||||
namespace Unity.Pipeline.Runtime.Telemetry
|
||||
{
|
||||
/// <summary>
|
||||
/// Samples per-frame timing on the main thread so the runtime telemetry command can report fps and
|
||||
/// frame-time statistics over a rolling window.
|
||||
///
|
||||
/// A Player build has no Editor profiler window to read from and no <c>EditorApplication.update</c>
|
||||
/// to drive sampling, so <see cref="RuntimePipelineDriver"/> owns one shared instance and feeds it
|
||||
/// every frame from its <c>Update</c>. The sampler is deliberately allocation-free on the hot path:
|
||||
/// each frame time is written into a fixed ring buffer and only reduced to a <see cref="FrameStatsSnapshot"/>
|
||||
/// on demand when a telemetry request arrives (far rarer than the frame rate).
|
||||
///
|
||||
/// Sampling uses <c>Time.unscaledDeltaTime</c> so reported fps reflects real wall-clock frame pacing
|
||||
/// independent of <c>Time.timeScale</c> — a paused or slow-motion game still reports its true fps.
|
||||
/// </summary>
|
||||
#if UNITY_6000_5_OR_NEWER
|
||||
[NoAutoStaticsCleanup]
|
||||
#endif
|
||||
sealed class FrameStatsSampler : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// The process-wide sampler fed by the active <see cref="RuntimePipelineDriver"/>. Null when no
|
||||
/// driver is present (e.g. EditMode tests, or a Player build without Pipeline enabled). Telemetry consumers
|
||||
/// must treat a null sampler as "frame stats unavailable" rather than failing — memory and counter
|
||||
/// data that do not depend on per-frame sampling can still be reported without it.
|
||||
/// </summary>
|
||||
public static FrameStatsSampler Shared { get; set; }
|
||||
|
||||
private readonly float[] m_FrameTimesMs;
|
||||
private int m_Head;
|
||||
private int m_Count;
|
||||
private float m_LastFrameTimeMs;
|
||||
|
||||
// ProfilerRecorders pull counters straight from the profiler stream. They are started lazily on
|
||||
// the first snapshot (creation must happen on the main thread) and disposed with the sampler.
|
||||
// Recorders that do not resolve on this platform/Unity version report Valid == false and are
|
||||
// simply skipped when building a snapshot.
|
||||
private readonly List<CounterRecorder> m_Counters = new List<CounterRecorder>();
|
||||
private bool m_CountersStarted;
|
||||
|
||||
private struct CounterRecorder
|
||||
{
|
||||
public string Name;
|
||||
public ProfilerRecorder Recorder;
|
||||
}
|
||||
|
||||
/// <summary>Number of frames the rolling window can hold.</summary>
|
||||
public int Capacity => m_FrameTimesMs.Length;
|
||||
|
||||
/// <summary>Number of frames currently recorded (ramps up to <see cref="Capacity"/>).</summary>
|
||||
public int SampleCount => m_Count;
|
||||
|
||||
/// <summary>Create a sampler with the given rolling-window size.</summary>
|
||||
/// <param name="windowFrames">Number of frames the rolling window holds (minimum 1).</param>
|
||||
public FrameStatsSampler(int windowFrames = 120)
|
||||
{
|
||||
if (windowFrames < 1)
|
||||
windowFrames = 1;
|
||||
m_FrameTimesMs = new float[windowFrames];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Record one completed frame. Call once per frame from the main thread.
|
||||
/// <paramref name="deltaSeconds"/> is the unscaled delta time of the frame just completed
|
||||
/// (typically <c>Time.unscaledDeltaTime</c>).
|
||||
/// </summary>
|
||||
/// <param name="deltaSeconds">Unscaled delta time of the frame just completed.</param>
|
||||
public void Sample(float deltaSeconds)
|
||||
{
|
||||
var ms = deltaSeconds * 1000f;
|
||||
m_LastFrameTimeMs = ms;
|
||||
m_FrameTimesMs[m_Head] = ms;
|
||||
m_Head = (m_Head + 1) % m_FrameTimesMs.Length;
|
||||
if (m_Count < m_FrameTimesMs.Length)
|
||||
m_Count++;
|
||||
|
||||
// FrameTimingManager only yields data for frames in which timings were captured, so the
|
||||
// capture call has to happen on the per-frame path, not when a snapshot is requested.
|
||||
FrameTimingManager.CaptureFrameTimings();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reduce the current window to an immutable snapshot. Must be called on the main thread (it reads
|
||||
/// profiler recorders and the frame timing manager).
|
||||
/// </summary>
|
||||
/// <returns>A point-in-time snapshot of the current window.</returns>
|
||||
public FrameStatsSnapshot GetSnapshot()
|
||||
{
|
||||
var snap = new FrameStatsSnapshot();
|
||||
|
||||
if (m_Count > 0)
|
||||
{
|
||||
float sum = 0f, min = float.MaxValue, max = 0f;
|
||||
for (int i = 0; i < m_Count; i++)
|
||||
{
|
||||
var v = m_FrameTimesMs[i];
|
||||
sum += v;
|
||||
if (v < min) min = v;
|
||||
if (v > max) max = v;
|
||||
}
|
||||
|
||||
var avg = sum / m_Count;
|
||||
snap.Available = true;
|
||||
snap.SampleWindow = m_Count;
|
||||
snap.AverageFrameTimeMs = avg;
|
||||
snap.MinFrameTimeMs = min;
|
||||
snap.MaxFrameTimeMs = max;
|
||||
snap.LastFrameTimeMs = m_LastFrameTimeMs;
|
||||
snap.Fps = avg > 0f ? 1000f / avg : 0f;
|
||||
}
|
||||
|
||||
ReadFrameTimingManager(snap);
|
||||
ReadCounters(snap);
|
||||
return snap;
|
||||
}
|
||||
|
||||
// CPU/GPU frame time from FrameTimingManager. Only populated when the platform reports timings
|
||||
// (requires "Frame Timing Stats" in Player settings / -enable-frame-timing-stats); otherwise the
|
||||
// snapshot's GpuTimingAvailable stays false rather than reporting misleading zeros.
|
||||
private static void ReadFrameTimingManager(FrameStatsSnapshot snap)
|
||||
{
|
||||
var timings = new FrameTiming[1];
|
||||
var captured = FrameTimingManager.GetLatestTimings(1, timings);
|
||||
if (captured > 0)
|
||||
{
|
||||
snap.GpuTimingAvailable = true;
|
||||
snap.CpuFrameTimeMs = timings[0].cpuFrameTime;
|
||||
snap.GpuFrameTimeMs = timings[0].gpuFrameTime;
|
||||
}
|
||||
}
|
||||
|
||||
private void ReadCounters(FrameStatsSnapshot snap)
|
||||
{
|
||||
EnsureCountersStarted();
|
||||
var counters = new Dictionary<string, double>();
|
||||
foreach (var c in m_Counters)
|
||||
{
|
||||
if (c.Recorder.Valid)
|
||||
counters[c.Name] = c.Recorder.LastValue;
|
||||
}
|
||||
snap.Counters = counters;
|
||||
}
|
||||
|
||||
private void EnsureCountersStarted()
|
||||
{
|
||||
if (m_CountersStarted)
|
||||
return;
|
||||
m_CountersStarted = true;
|
||||
|
||||
// Common render/memory counters. Stat names that do not exist on the running platform resolve
|
||||
// to invalid recorders and are filtered out at snapshot time.
|
||||
TryAddCounter("DrawCalls", ProfilerCategory.Render, "Draw Calls Count");
|
||||
TryAddCounter("SetPassCalls", ProfilerCategory.Render, "SetPass Calls Count");
|
||||
TryAddCounter("Triangles", ProfilerCategory.Render, "Triangles Count");
|
||||
TryAddCounter("Vertices", ProfilerCategory.Render, "Vertices Count");
|
||||
TryAddCounter("GcAllocInFrame", ProfilerCategory.Memory, "GC Allocated In Frame");
|
||||
}
|
||||
|
||||
private void TryAddCounter(string reportedName, ProfilerCategory category, string statName)
|
||||
{
|
||||
m_Counters.Add(new CounterRecorder
|
||||
{
|
||||
Name = reportedName,
|
||||
Recorder = ProfilerRecorder.StartNew(category, statName)
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Dispose the underlying profiler recorders.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var c in m_Counters)
|
||||
{
|
||||
if (c.Recorder.Valid)
|
||||
c.Recorder.Dispose();
|
||||
}
|
||||
m_Counters.Clear();
|
||||
m_CountersStarted = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Immutable point-in-time reduction of <see cref="FrameStatsSampler"/>'s rolling window plus the
|
||||
/// counters/frame-timing read at snapshot time. Serialized as the frame-stats portion of the runtime
|
||||
/// telemetry response.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
class FrameStatsSnapshot
|
||||
{
|
||||
/// <summary>True when at least one frame has been sampled (i.e. a manager is feeding the sampler).</summary>
|
||||
public bool Available { get; set; }
|
||||
|
||||
/// <summary>Number of frames included in the averages.</summary>
|
||||
public int SampleWindow { get; set; }
|
||||
|
||||
/// <summary>Frames per second derived from the average frame time over the window.</summary>
|
||||
public float Fps { get; set; }
|
||||
|
||||
/// <summary>Average frame time over the window, in milliseconds.</summary>
|
||||
public float AverageFrameTimeMs { get; set; }
|
||||
/// <summary>Minimum frame time over the window, in milliseconds.</summary>
|
||||
public float MinFrameTimeMs { get; set; }
|
||||
/// <summary>Maximum frame time over the window, in milliseconds.</summary>
|
||||
public float MaxFrameTimeMs { get; set; }
|
||||
/// <summary>Most recently sampled frame's time, in milliseconds.</summary>
|
||||
public float LastFrameTimeMs { get; set; }
|
||||
|
||||
/// <summary>True when FrameTimingManager returned CPU/GPU timings for a recent frame.</summary>
|
||||
public bool GpuTimingAvailable { get; set; }
|
||||
/// <summary>CPU frame time from FrameTimingManager, in milliseconds. Only valid when <see cref="GpuTimingAvailable"/> is true.</summary>
|
||||
public double CpuFrameTimeMs { get; set; }
|
||||
/// <summary>GPU frame time from FrameTimingManager, in milliseconds. Only valid when <see cref="GpuTimingAvailable"/> is true.</summary>
|
||||
public double GpuFrameTimeMs { get; set; }
|
||||
|
||||
/// <summary>Valid profiler counters by reported name (e.g. DrawCalls, Triangles).</summary>
|
||||
public Dictionary<string, double> Counters { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0e2b7696d6f845cba4f7c776006707d1
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
using System.Collections.Generic;
|
||||
using Unity.Pipeline.Config;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unity.Pipeline
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates the runtime Pipeline driver automatically when a Player boots (or Play Mode is
|
||||
/// entered in the Editor) — no scene setup required. Reads RuntimePipelineConfig (JSON-backed
|
||||
/// in the Editor, a build-baked Resources asset in a Player) and the build-baked
|
||||
/// RuntimePipelineBuildInfo from Resources; does nothing if no config exists or the config
|
||||
/// has the server disabled.
|
||||
/// </summary>
|
||||
public static class RuntimePipelineBootstrap
|
||||
{
|
||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
|
||||
static void RuntimeBootstrap()
|
||||
{
|
||||
Bootstrap();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The driver created by <see cref="Bootstrap"/>, or null if it has not run yet (or created
|
||||
/// nothing because there was no usable config). Reachable for tests and manual control.
|
||||
/// </summary>
|
||||
public static RuntimePipelineDriver Instance { get; internal set; }
|
||||
|
||||
/// <summary>
|
||||
/// Core bootstrap logic, split out from the attributed entry point so tests can invoke it
|
||||
/// directly instead of waiting for a real domain/Player boot. Returns the created driver,
|
||||
/// or null if the server should not start (no config asset, or disabled). Idempotent: a
|
||||
/// second call returns the existing <see cref="Instance"/> rather than creating a duplicate.
|
||||
/// </summary>
|
||||
/// <returns>The created (or already-existing) driver, or null if the server should not start.</returns>
|
||||
public static RuntimePipelineDriver Bootstrap()
|
||||
{
|
||||
if (Instance != null)
|
||||
return Instance;
|
||||
|
||||
var config = RuntimePipelineConfig.Load();
|
||||
if (config == null || !config.enableInBuilds)
|
||||
{
|
||||
#if UNITY_EDITOR
|
||||
// The runtime server is a player-build feature, but in-editor code reload still
|
||||
// needs the [CodeReload] targets registered for this Play session — the driver
|
||||
// would do it in Awake; with no driver, do it here so a save can bind.
|
||||
RuntimePipelineDriver.RegisterDiscoveredCodeReloadMethods();
|
||||
#endif
|
||||
return null;
|
||||
}
|
||||
|
||||
var buildInfo = RuntimePipelineBuildInfo.Load();
|
||||
IReadOnlyList<string> roots = buildInfo != null
|
||||
? (IReadOnlyList<string>)buildInfo.allowedReloadRoots
|
||||
: System.Array.Empty<string>();
|
||||
// Empty when there's no build info (e.g. play mode never built): the driver then rejects
|
||||
// every push, which is the correct strict default outside a real signed build.
|
||||
string pushPublicKey = buildInfo != null ? buildInfo.pushPublicKey : "";
|
||||
|
||||
var go = new GameObject("Pipeline Runtime Driver") { hideFlags = HideFlags.DontSave };
|
||||
var driver = go.AddComponent<RuntimePipelineDriver>();
|
||||
driver.Initialize(config, roots, pushPublicKey);
|
||||
Instance = driver;
|
||||
return driver;
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: da4d1b70b51a29841938b3c1361b8300
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unity.Pipeline.Config
|
||||
{
|
||||
/// <summary>
|
||||
/// Build-only, generated companion to <see cref="RuntimePipelineConfig"/>: holds the absolute,
|
||||
/// machine-specific code-reload roots baked in by PipelineRuntimeBuildProcessor. Created fresh
|
||||
/// before each build and deleted immediately after — never checked into source control, since
|
||||
/// its contents are meaningless outside the machine that produced them. Absent outside of a
|
||||
/// real build (e.g. Editor Play Mode without ever having built) is not an error;
|
||||
/// RuntimePipelineBootstrap simply has no roots to allow in that case.
|
||||
/// </summary>
|
||||
public class RuntimePipelineBuildInfo : ScriptableObject
|
||||
{
|
||||
/// <summary>Resource name this asset must be saved under inside a Resources folder for <see cref="Load"/> to find it.</summary>
|
||||
public const string ResourceName = "RuntimePipelineBuildInfo";
|
||||
|
||||
/// <summary>Absolute paths of the project's Assets folder and every loaded package, baked in at build time.</summary>
|
||||
public List<string> allowedReloadRoots = new List<string>();
|
||||
|
||||
/// <summary>Public half of the project's push-signing key (from Library/Pipeline/signing.key).
|
||||
/// A player build rejects every code-reload push not signed by the matching private key; empty
|
||||
/// means reject all pushes (strict policy — there is deliberately no unsigned fallback).</summary>
|
||||
public string pushPublicKey = "";
|
||||
|
||||
/// <summary>Load the asset from Resources, or null if this is not a real build.</summary>
|
||||
/// <returns>The build-time-baked info, or null if this is not a real build.</returns>
|
||||
public static RuntimePipelineBuildInfo Load() => Resources.Load<RuntimePipelineBuildInfo>(ResourceName);
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: bffff28fa8e3a084d86374beef94b84d
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,222 @@
|
||||
using System;
|
||||
#if UNITY_EDITOR
|
||||
using System.IO;
|
||||
#endif
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unity.Pipeline.Config
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration for Pipeline server functionality in Unity Player builds. Authored settings
|
||||
/// live in a JSON file under ProjectSettings/ (see <see cref="Save"/>/<see cref="Load"/>) —
|
||||
/// never as an asset under Assets/, so nothing about this package appears in the user's
|
||||
/// Project window. At real Player build time, PipelineRuntimeBuildProcessor bakes the current
|
||||
/// settings into a transient Resources asset (deleted again after the build) so the Player
|
||||
/// can find them via Resources.Load.
|
||||
/// </summary>
|
||||
public class RuntimePipelineConfig : ScriptableObject
|
||||
{
|
||||
/// <summary>Master switch. The server only starts when true. Off by default for safety.</summary>
|
||||
[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;
|
||||
|
||||
/// <summary>Listen port. 0 auto-assigns from 7900-7999.</summary>
|
||||
[Header("Server")]
|
||||
[Tooltip("HTTP port for Pipeline server. Use 0 for auto-assignment from range 7900-7999.")]
|
||||
public int port = 0;
|
||||
|
||||
/// <summary>Per-request timeout, in milliseconds.</summary>
|
||||
[Tooltip("Request timeout in milliseconds. Higher values allow longer-running commands.")]
|
||||
[Range(1000, 60000)]
|
||||
public int requestTimeoutMs = 30000;
|
||||
|
||||
/// <summary>Log remote requests for auditing.</summary>
|
||||
[Header("Runtime Behavior")]
|
||||
[Tooltip("Enable detailed logging of all remote requests for security auditing.")]
|
||||
public bool enableAuditLogging = true;
|
||||
|
||||
/// <summary>Start the server automatically when the Player boots (or Play Mode is entered).</summary>
|
||||
[Tooltip("Start the server automatically when the Player boots (or Play Mode is entered).")]
|
||||
public bool autoStart = true;
|
||||
|
||||
/// <summary>Dispatcher work items processed per frame.</summary>
|
||||
[Tooltip("Maximum work items the dispatcher processes per frame to maintain performance.")]
|
||||
[Range(1, 50)]
|
||||
public int maxWorkItemsPerFrame = 10;
|
||||
|
||||
// Plain int field (not [Range]) so the Inspector shows a text field rather than an
|
||||
// unusable 0-65535 slider; the bound is still enforced here, same pattern as
|
||||
// EditorPipelineManager.OnValidate's m_WatchdogIntervalSeconds clamp.
|
||||
private void OnValidate()
|
||||
{
|
||||
port = Mathf.Clamp(port, 0, 65535);
|
||||
}
|
||||
|
||||
/// <summary>Resource name the build-time-generated transient asset is saved under for <see cref="Load"/> to find in a Player build.</summary>
|
||||
public const string ResourceName = "RuntimePipelineConfig";
|
||||
|
||||
#if UNITY_EDITOR
|
||||
/// <summary>Project-relative path of the authored settings file (never under Assets/).</summary>
|
||||
private const string SettingsFilePath = "ProjectSettings/Packages/com.unity.pipeline/RuntimePipelineConfig.json";
|
||||
#endif
|
||||
|
||||
/// <summary>
|
||||
/// Load the current settings, or null if none exist yet. In the Editor (including Play
|
||||
/// Mode) this reads the authored ProjectSettings/ JSON file directly; in a real Player
|
||||
/// build it reads the build-time-generated transient Resources asset.
|
||||
/// </summary>
|
||||
/// <returns>The current settings, or null if none exist yet.</returns>
|
||||
public static RuntimePipelineConfig Load()
|
||||
{
|
||||
#if UNITY_EDITOR
|
||||
var path = GetAbsoluteSettingsPath();
|
||||
if (!File.Exists(path))
|
||||
return null;
|
||||
|
||||
var config = CreateInstance<RuntimePipelineConfig>();
|
||||
try
|
||||
{
|
||||
JsonUtility.FromJsonOverwrite(File.ReadAllText(path), config);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogWarning($"Pipeline: could not read runtime settings at '{path}' ({ex.Message}); using no config. Delete the file to reset.");
|
||||
DestroyImmediate(config);
|
||||
return null;
|
||||
}
|
||||
return config;
|
||||
#else
|
||||
return Resources.Load<RuntimePipelineConfig>(ResourceName);
|
||||
#endif
|
||||
}
|
||||
|
||||
#if UNITY_EDITOR
|
||||
private static int s_LiveMaxWorkItemsPerFrameCache = 10;
|
||||
private static DateTime s_LiveMaxWorkItemsPerFrameCacheFileTime;
|
||||
|
||||
/// <summary>
|
||||
/// Cheap, per-frame-callable read of just maxWorkItemsPerFrame from the authored settings
|
||||
/// file, for RuntimePipelineDriver.Update() to poll without paying Load()'s full
|
||||
/// CreateInstance+JSON-parse cost every frame. Re-parses only when the file's last-write
|
||||
/// time changes, so a Project Settings edit (including while in Play Mode) takes effect on
|
||||
/// the very next frame, while an idle frame costs only a single file-timestamp check.
|
||||
/// Editor-only: a Player's config is frozen at build time, so there is nothing to poll for.
|
||||
/// </summary>
|
||||
public static int GetLiveMaxWorkItemsPerFrame(int fallback)
|
||||
{
|
||||
var path = GetAbsoluteSettingsPath();
|
||||
if (!File.Exists(path))
|
||||
return fallback;
|
||||
|
||||
var writeTime = File.GetLastWriteTimeUtc(path);
|
||||
if (writeTime != s_LiveMaxWorkItemsPerFrameCacheFileTime)
|
||||
{
|
||||
var config = Load();
|
||||
if (config != null)
|
||||
{
|
||||
s_LiveMaxWorkItemsPerFrameCache = config.maxWorkItemsPerFrame;
|
||||
DestroyImmediate(config);
|
||||
}
|
||||
s_LiveMaxWorkItemsPerFrameCacheFileTime = writeTime;
|
||||
}
|
||||
|
||||
return s_LiveMaxWorkItemsPerFrameCache;
|
||||
}
|
||||
|
||||
/// <summary>Persist this instance's current field values to the ProjectSettings/ JSON file.</summary>
|
||||
public void Save()
|
||||
{
|
||||
var path = GetAbsoluteSettingsPath();
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path));
|
||||
var previousWrite = File.Exists(path) ? File.GetLastWriteTimeUtc(path) : DateTime.MinValue;
|
||||
File.WriteAllText(path, JsonUtility.ToJson(this, true));
|
||||
|
||||
// GetLiveMaxWorkItemsPerFrame and the settings page detect changes by last-write time.
|
||||
// Windows stamps writes with the system clock tick (~15 ms), so two saves in quick
|
||||
// succession can share a stamp and the second one goes unnoticed. Make every save
|
||||
// observable by moving the stamp forward when the file system did not.
|
||||
if (File.GetLastWriteTimeUtc(path) <= previousWrite)
|
||||
File.SetLastWriteTimeUtc(path, previousWrite.AddMilliseconds(1));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Last-write-time of the authored settings file (UTC), or DateTime.MinValue if it doesn't
|
||||
/// exist yet. Lets a long-lived caller (the Project Settings page) detect that the file
|
||||
/// changed underneath it — e.g. a build's "Disable Pipeline" security-dialog choice, or a
|
||||
/// set_runtime_pipeline_settings CLI call — without reloading from disk on every frame.
|
||||
/// </summary>
|
||||
public static DateTime GetSettingsFileWriteTimeUtc()
|
||||
{
|
||||
var path = GetAbsoluteSettingsPath();
|
||||
return File.Exists(path) ? File.GetLastWriteTimeUtc(path) : DateTime.MinValue;
|
||||
}
|
||||
|
||||
/// <summary>Delete the authored settings file, if any. For test cleanup only.</summary>
|
||||
public static void DeleteSettingsFileForTesting()
|
||||
{
|
||||
var path = GetAbsoluteSettingsPath();
|
||||
if (File.Exists(path))
|
||||
File.Delete(path);
|
||||
}
|
||||
|
||||
private static string GetAbsoluteSettingsPath()
|
||||
{
|
||||
// Application.dataPath is ".../Assets"; ProjectSettings is a sibling directory.
|
||||
var projectRoot = Path.GetDirectoryName(Application.dataPath);
|
||||
return Path.Combine(projectRoot, SettingsFilePath);
|
||||
}
|
||||
#endif
|
||||
|
||||
/// <summary>
|
||||
/// Validate the configuration for correctness.
|
||||
/// Called by build processor to ensure safe deployment.
|
||||
/// </summary>
|
||||
/// <returns>The validation result.</returns>
|
||||
public ValidationResult Validate()
|
||||
{
|
||||
if (!enableInBuilds)
|
||||
return ValidationResult.Success("Runtime Pipeline disabled");
|
||||
|
||||
// Port validation
|
||||
if (port != 0 && (port < 7900 || port > 7999))
|
||||
{
|
||||
return ValidationResult.Warning(
|
||||
$"Port {port} is outside recommended runtime range 7900-7999. May conflict with Editor instances.");
|
||||
}
|
||||
|
||||
return ValidationResult.Success("Configuration is valid");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result from configuration validation.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class ValidationResult
|
||||
{
|
||||
/// <summary>False only for <see cref="Error"/> results.</summary>
|
||||
public bool IsValid { get; set; }
|
||||
/// <summary>"success", "warning", or "error".</summary>
|
||||
public string Level { get; set; } // "success", "warning", "error"
|
||||
/// <summary>Human-readable validation message.</summary>
|
||||
public string Message { get; set; }
|
||||
|
||||
/// <summary>Create a successful validation result.</summary>
|
||||
/// <param name="message">Human-readable message.</param>
|
||||
/// <returns>A valid, "success"-level result.</returns>
|
||||
public static ValidationResult Success(string message = "Valid") =>
|
||||
new ValidationResult { IsValid = true, Level = "success", Message = message };
|
||||
|
||||
/// <summary>Create a valid-but-noteworthy validation result.</summary>
|
||||
/// <param name="message">Human-readable message.</param>
|
||||
/// <returns>A valid, "warning"-level result.</returns>
|
||||
public static ValidationResult Warning(string message) =>
|
||||
new ValidationResult { IsValid = true, Level = "warning", Message = message };
|
||||
|
||||
/// <summary>Create a failed validation result.</summary>
|
||||
/// <param name="message">Human-readable message.</param>
|
||||
/// <returns>An invalid, "error"-level result.</returns>
|
||||
public static ValidationResult Error(string message) =>
|
||||
new ValidationResult { IsValid = false, Level = "error", Message = message };
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0fd19cf31d54e3848a1075f001aed64f
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,664 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
using Unity.Pipeline.Config;
|
||||
using Unity.Pipeline.Commands;
|
||||
using Unity.Pipeline.CodeReload;
|
||||
using Unity.Pipeline.Runtime.Telemetry;
|
||||
using UnityEngine;
|
||||
#if (UNITY_EDITOR || DEVELOPMENT_BUILD || ENABLE_RUNTIME_PIPELINE)
|
||||
using UnityEngine.Networking.PlayerConnection;
|
||||
#endif
|
||||
|
||||
namespace Unity.Pipeline
|
||||
{
|
||||
/// <summary>
|
||||
/// Drives the runtime Pipeline server. Created exactly once, in code, by
|
||||
/// <see cref="RuntimePipelineBootstrap"/> — never authored in a scene. Hidden from Add
|
||||
/// Component (nothing to configure via the Inspector; see <see cref="Initialize"/> instead)
|
||||
/// and guarded against stacking multiple instances on one GameObject, in case it's ever added
|
||||
/// manually or via a script despite that.
|
||||
/// </summary>
|
||||
[AddComponentMenu("")]
|
||||
[DisallowMultipleComponent]
|
||||
public class RuntimePipelineDriver : MonoBehaviour
|
||||
{
|
||||
// Not yet exposed via RuntimePipelineConfig/the runtime settings page — always on. Wire up
|
||||
// through m_Config alongside the other Runtime Behavior settings if a project needs to
|
||||
// disable the overlay.
|
||||
private const bool ShowReloadOverlay = true;
|
||||
|
||||
private RuntimePipelineServer m_Server;
|
||||
private RuntimePipelineConfig m_Config;
|
||||
private IReadOnlyList<string> m_AllowedReloadRoots = Array.Empty<string>();
|
||||
|
||||
// Public half of the project's push-signing key. Baked at build time into the
|
||||
// RuntimePipelineBuildInfo asset (from Library/Pipeline/signing.key in the editor) and
|
||||
// supplied via Initialize — a player build rejects every code-reload push that is not signed
|
||||
// by the matching private key; empty means "reject all pushes" (strict policy, no fallback).
|
||||
private string m_PushPublicKey = "";
|
||||
private bool m_OwnsSampler;
|
||||
|
||||
// Restored by StopServer so Play Mode exit doesn't leave the user's setting overridden.
|
||||
private bool m_PreviousRunInBackground;
|
||||
|
||||
// True only between something overriding Application.runInBackground (StartServer, or
|
||||
// DrainPendingReloads reacting to a code-reload push that arrived with no HTTP server
|
||||
// running) and StopServer restoring it. Both writers check this before saving
|
||||
// m_PreviousRunInBackground, so whichever gets there first "owns" the saved value — the
|
||||
// other must not clobber it with the already-overridden "true". Gating the restore on this
|
||||
// (rather than m_Server.IsRunning) also means it still happens even if the listener already
|
||||
// died on its own before StopServer runs — otherwise runInBackground would stay stuck on,
|
||||
// reproducing a narrower version of the bug this ownership move fixed in the first place.
|
||||
private bool m_RunInBackgroundOverridden;
|
||||
|
||||
/// <summary>Get the runtime server instance if it's running.</summary>
|
||||
public RuntimePipelineServer Server => m_Server;
|
||||
|
||||
/// <summary>Whether the server is currently running.</summary>
|
||||
public bool IsServerRunning => m_Server != null && m_Server.IsRunning;
|
||||
|
||||
/// <summary>Get the actual port the server is running on.</summary>
|
||||
public int ActualPort => m_Server?.Port ?? 0;
|
||||
|
||||
/// <summary>The configuration this driver was initialized with.</summary>
|
||||
public RuntimePipelineConfig Config => m_Config;
|
||||
|
||||
/// <summary>Absolute roots this build may code reload source files from. Baked at build time.</summary>
|
||||
public IReadOnlyList<string> AllowedReloadRoots => m_AllowedReloadRoots;
|
||||
|
||||
/// <summary>
|
||||
/// Supply configuration and baked code-reload roots. Must be called once, immediately after
|
||||
/// AddComponent (before the end of the current frame, i.e. before Start runs).
|
||||
/// </summary>
|
||||
/// <param name="config">The configuration to run with.</param>
|
||||
/// <param name="allowedReloadRoots">Absolute roots this build may code reload source files from.</param>
|
||||
/// <param name="pushPublicKey">Public half of the push-signing key pushes must be signed with; empty rejects all pushes.</param>
|
||||
public void Initialize(RuntimePipelineConfig config, IReadOnlyList<string> allowedReloadRoots,
|
||||
string pushPublicKey = "")
|
||||
{
|
||||
m_Config = config;
|
||||
m_AllowedReloadRoots = allowedReloadRoots ?? Array.Empty<string>();
|
||||
m_PushPublicKey = pushPublicKey ?? "";
|
||||
}
|
||||
|
||||
/// <summary>Public half of the project push-signing key. Baked at build time.</summary>
|
||||
public string PushPublicKey => m_PushPublicKey;
|
||||
|
||||
void Awake()
|
||||
{
|
||||
// Persistent across scene loads; exactly one of these ever exists (created by
|
||||
// RuntimePipelineBootstrap), so there is no duplicate-instance case to defend against.
|
||||
// DontDestroyOnLoad only functions (and is only legal to call) in Play Mode; invoking it
|
||||
// from an Edit Mode context (e.g. an EditMode test, or this driver created via eval)
|
||||
// throws, which would otherwise abort the rest of Awake below.
|
||||
if (Application.isPlaying)
|
||||
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 reloadable methods. Code reload requires this driver to
|
||||
// be present (it owns the communication workflow), so discovery lives here rather than in
|
||||
// each gameplay script's Awake.
|
||||
RegisterDiscoveredCodeReloadMethods();
|
||||
|
||||
// Own the process-wide frame-stats sampler. A Player has no profiler window or
|
||||
// EditorApplication.update to drive sampling, so the driver 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. Only claim
|
||||
// ownership if nothing already created one — a second, transient driver (e.g. a test creating
|
||||
// its own driver alongside one RuntimeInitializeOnLoadMethod already created) must not steal or
|
||||
// later tear down a sibling driver's sampler.
|
||||
if (FrameStatsSampler.Shared == null)
|
||||
{
|
||||
FrameStatsSampler.Shared = new FrameStatsSampler();
|
||||
m_OwnsSampler = true;
|
||||
}
|
||||
}
|
||||
|
||||
void Start()
|
||||
{
|
||||
if (m_Config != null && m_Config.autoStart && m_Config.enableInBuilds)
|
||||
{
|
||||
StartServer();
|
||||
}
|
||||
|
||||
#if (UNITY_EDITOR || DEVELOPMENT_BUILD || ENABLE_RUNTIME_PIPELINE)
|
||||
// Independent of the HTTP server: code-reload IL pushed from the editor arrives over
|
||||
// PlayerConnection (the Profiler's channel — no open port needed, tunnels over USB).
|
||||
RegisterReloadReceiver();
|
||||
|
||||
if (ShowReloadOverlay && GetComponent<CodeReloadStatusOverlay>() == null)
|
||||
gameObject.AddComponent<CodeReloadStatusOverlay>();
|
||||
#endif
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scan loaded user assemblies for methods tagged [CodeReload] and register them as reload targets.
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// Scan loaded user assemblies for [CodeReload] methods and register them as reload
|
||||
/// targets. Runs from Awake; RuntimePipelineBootstrap also calls it in the Editor when the
|
||||
/// runtime server is disabled and no driver is created.
|
||||
/// </summary>
|
||||
internal static void RegisterDiscoveredCodeReloadMethods()
|
||||
{
|
||||
const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic
|
||||
| BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly;
|
||||
|
||||
int registered = 0;
|
||||
|
||||
foreach (var assembly in PipelineUtils.GetLoadedAssemblies())
|
||||
{
|
||||
if (!ShouldScanForCodeReload(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 reloadable = method.GetCustomAttribute<CodeReloadAttribute>();
|
||||
if (reloadable == null)
|
||||
continue;
|
||||
|
||||
// The weaver keys dispatch on TypeName.MethodName, so register with the
|
||||
// default id (ignore any custom Id) to match.
|
||||
CodeReloadRegistry.RegisterReloadableMethod(
|
||||
method, new CodeReloadAttribute { RequireMainThread = reloadable.RequireMainThread });
|
||||
registered++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The count only matters once someone tries to reload, so it joins the first-reload
|
||||
// summary instead of the startup noise (this runs on every Play Mode entry). That holds
|
||||
// for zero too: the summary prints on the first reload ATTEMPT, so a project that never
|
||||
// reloads stays quiet, and one that does sees the stripping/attribute hint right next to
|
||||
// the "target not registered" reasons.
|
||||
CodeReloadRegistry.NoteForFirstReload($"{registered} reloadable method(s) registered" +
|
||||
(registered == 0
|
||||
? " (if methods were expected: are the [CodeReload] attributes in this build, and did the owning assembly survive stripping?)"
|
||||
: ""));
|
||||
}
|
||||
|
||||
/// <summary>Skip engine/framework assemblies that cannot contain user code-reload methods.</summary>
|
||||
private static bool ShouldScanForCodeReload(Assembly assembly)
|
||||
{
|
||||
if (assembly.IsDynamic)
|
||||
return false;
|
||||
|
||||
var name = assembly.GetName().Name;
|
||||
if (string.IsNullOrEmpty(name))
|
||||
return false;
|
||||
|
||||
foreach (var prefix in s_CodeReloadSkipPrefixes)
|
||||
{
|
||||
if (name.StartsWith(prefix, StringComparison.Ordinal))
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static readonly string[] s_CodeReloadSkipPrefixes =
|
||||
{
|
||||
"System", "mscorlib", "netstandard", "Microsoft", "Mono.", "nunit",
|
||||
"UnityEngine", "UnityEditor", "Unity.Collections", "Unity.Burst",
|
||||
"Unity.Mathematics", "Newtonsoft", "log4net", "ICSharpCode",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// The maxWorkItemsPerFrame value actually governing the dispatcher right now — live from
|
||||
/// the settings file in the Editor, frozen to the build-time value in a Player. Exposed
|
||||
/// (rather than reading m_Config.maxWorkItemsPerFrame directly) so status reporting and
|
||||
/// Update() can never disagree about which value is actually in effect.
|
||||
/// </summary>
|
||||
public int CurrentMaxWorkItemsPerFrame
|
||||
{
|
||||
get
|
||||
{
|
||||
var fallback = m_Config != null ? m_Config.maxWorkItemsPerFrame : 10;
|
||||
#if UNITY_EDITOR
|
||||
return RuntimePipelineConfig.GetLiveMaxWorkItemsPerFrame(fallback);
|
||||
#else
|
||||
return fallback;
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
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(CurrentMaxWorkItemsPerFrame);
|
||||
|
||||
// Drive the watchdog (player builds have no EditorApplication.update). No-op unless the
|
||||
// server's watchdog is enabled and armed.
|
||||
m_Server?.WatchdogTick();
|
||||
|
||||
#if (UNITY_EDITOR || DEVELOPMENT_BUILD || ENABLE_RUNTIME_PIPELINE)
|
||||
DrainPendingReloads();
|
||||
#endif
|
||||
}
|
||||
|
||||
void OnApplicationQuit()
|
||||
{
|
||||
StopServer();
|
||||
}
|
||||
|
||||
void OnDestroy()
|
||||
{
|
||||
StopServer();
|
||||
|
||||
if (RuntimePipelineBootstrap.Instance == this)
|
||||
RuntimePipelineBootstrap.Instance = null;
|
||||
|
||||
// Only the driver that actually created the shared sampler tears it down — see Awake().
|
||||
if (m_OwnsSampler)
|
||||
{
|
||||
FrameStatsSampler.Shared?.Dispose();
|
||||
FrameStatsSampler.Shared = null;
|
||||
}
|
||||
#if (UNITY_EDITOR || DEVELOPMENT_BUILD || ENABLE_RUNTIME_PIPELINE)
|
||||
UnregisterReloadReceiver();
|
||||
#endif
|
||||
}
|
||||
|
||||
#if (UNITY_EDITOR || DEVELOPMENT_BUILD || ENABLE_RUNTIME_PIPELINE)
|
||||
// ---- PlayerConnection code-reload receiver (editor pushes compiled override IL to this player) ----
|
||||
|
||||
// Pushes arrive on the connection message pump (not guaranteed main thread); enqueue here and
|
||||
// apply in Update() where the interpreter + Unity APIs are safe to touch.
|
||||
private readonly List<byte[]> m_PendingReloads = new List<byte[]>();
|
||||
private bool m_ReloadReceiverRegistered;
|
||||
|
||||
// Push-signing session state. The nonce is fresh per receiver lifetime and announced to the
|
||||
// editor via HandshakeMsg; every accepted push must be signed over it (see PushEnvelope).
|
||||
private byte[] m_PushSessionNonce;
|
||||
private PushVerifier m_PushVerifier;
|
||||
|
||||
private void RegisterReloadReceiver()
|
||||
{
|
||||
try
|
||||
{
|
||||
PlayerConnection.instance.Register(PipelineCodeReloadConnect.ApplyReloadMsg, OnApplyReloadMessage);
|
||||
PlayerConnection.instance.Register(PipelineCodeReloadConnect.ReloadPendingMsg, OnReloadPendingMessage);
|
||||
PlayerConnection.instance.Register(PipelineCodeReloadConnect.ReloadFailedMsg, OnReloadFailedMessage);
|
||||
PlayerConnection.instance.Register(PipelineCodeReloadConnect.RequestHandshakeMsg, OnRequestHandshakeMessage);
|
||||
PlayerConnection.instance.RegisterConnection(OnPushPeerConnected);
|
||||
m_ReloadReceiverRegistered = true;
|
||||
|
||||
m_PushSessionNonce = PushEnvelope.CreateNonce();
|
||||
#if UNITY_EDITOR
|
||||
// Editor play mode is not a network surface for pushes (they originate in this same
|
||||
// process, and remote access goes through the token-authenticated HTTP server), so
|
||||
// signing is not enforced here — see DrainPendingReloads.
|
||||
CodeReloadRegistry.NoteForFirstReload("PlayerConnection receiver ready (editor play mode, push signing not enforced)");
|
||||
#else
|
||||
if (!string.IsNullOrEmpty(m_PushPublicKey))
|
||||
{
|
||||
m_PushVerifier = new PushVerifier(m_PushPublicKey, m_PushSessionNonce);
|
||||
var fingerprint = PushEnvelope.Fingerprint(m_PushPublicKey);
|
||||
if (PushEnvelope.VerifySelfTest(m_PushPublicKey, out var selfTestError))
|
||||
Debug.Log($"Pipeline: PlayerConnection code-reload receiver ready — signed pushes only (key {fingerprint}, verify self-test OK). Push IL from the editor via 'reload_file_player_interpreter'.");
|
||||
else
|
||||
Debug.LogError($"Pipeline: push-signature verify self-test FAILED on this platform ({selfTestError}) — every push will be rejected as CryptoUnavailable.");
|
||||
}
|
||||
else
|
||||
{
|
||||
// Strict policy: a receiver without a baked key accepts nothing. The only way to
|
||||
// get here is a build whose RuntimePipelineBuildInfo carries no key (e.g. an older
|
||||
// build, or one produced without the build processor running).
|
||||
Debug.LogWarning("Pipeline: code-reload push receiver has NO baked signing key — all pushes will be rejected. " +
|
||||
"Rebuild with the Pipeline build processor enabled (it generates the key and bakes the public half into RuntimePipelineBuildInfo).");
|
||||
}
|
||||
#endif
|
||||
SendPushHandshake(); // the editor may already be connected (e.g. autoconnect raced us)
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogWarning($"Pipeline: PlayerConnection register failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void UnregisterReloadReceiver()
|
||||
{
|
||||
if (!m_ReloadReceiverRegistered) return;
|
||||
try
|
||||
{
|
||||
PlayerConnection.instance.Unregister(PipelineCodeReloadConnect.ApplyReloadMsg, OnApplyReloadMessage);
|
||||
PlayerConnection.instance.Unregister(PipelineCodeReloadConnect.ReloadPendingMsg, OnReloadPendingMessage);
|
||||
PlayerConnection.instance.Unregister(PipelineCodeReloadConnect.ReloadFailedMsg, OnReloadFailedMessage);
|
||||
PlayerConnection.instance.Unregister(PipelineCodeReloadConnect.RequestHandshakeMsg, OnRequestHandshakeMessage);
|
||||
PlayerConnection.instance.UnregisterConnection(OnPushPeerConnected);
|
||||
}
|
||||
catch { }
|
||||
m_ReloadReceiverRegistered = false;
|
||||
}
|
||||
|
||||
/// <summary>Announce this session (protocol version, expected key fingerprint, session nonce)
|
||||
/// to the editor. Sent on receiver start, on every new connection, and on RequestHandshakeMsg.</summary>
|
||||
private void SendPushHandshake()
|
||||
{
|
||||
try
|
||||
{
|
||||
var fingerprint = string.IsNullOrEmpty(m_PushPublicKey) ? "" : PushEnvelope.Fingerprint(m_PushPublicKey);
|
||||
PlayerConnection.instance.Send(PipelineCodeReloadConnect.HandshakeMsg,
|
||||
PipelineCodeReloadConnect.EncodeHandshake(PushEnvelope.ProtocolVersion, fingerprint, m_PushSessionNonce));
|
||||
}
|
||||
catch { /* no connection yet — the connect callback will re-announce */ }
|
||||
}
|
||||
|
||||
// Both may fire on the connection thread; SendPushHandshake only touches immutable session state.
|
||||
private void OnRequestHandshakeMessage(MessageEventArgs args) => SendPushHandshake();
|
||||
private void OnPushPeerConnected(int playerId) => SendPushHandshake();
|
||||
|
||||
private void OnApplyReloadMessage(MessageEventArgs args)
|
||||
{
|
||||
if (args?.data == null) return;
|
||||
lock (m_PendingReloads) m_PendingReloads.Add(args.data);
|
||||
}
|
||||
|
||||
// Connection-thread handlers: CodeReloadActivity is thread-safe, no marshalling needed.
|
||||
private void OnReloadPendingMessage(MessageEventArgs args) =>
|
||||
CodeReloadActivity.ReportCompileStarted(PipelineCodeReloadConnect.DecodeText(args?.data));
|
||||
|
||||
private void OnReloadFailedMessage(MessageEventArgs args) =>
|
||||
CodeReloadActivity.ReportFailed(PipelineCodeReloadConnect.DecodeText(args?.data));
|
||||
|
||||
private void DrainPendingReloads()
|
||||
{
|
||||
byte[][] batch;
|
||||
lock (m_PendingReloads)
|
||||
{
|
||||
if (m_PendingReloads.Count == 0) return;
|
||||
batch = m_PendingReloads.ToArray();
|
||||
m_PendingReloads.Clear();
|
||||
}
|
||||
|
||||
// A player that received a code-reload push must keep ticking while unfocused, otherwise
|
||||
// Update() (which drains + applies these pushes, and runs the reloaded code) pauses the
|
||||
// moment the window loses focus. Force it on once, on first receipt — reusing StartServer/
|
||||
// StopServer's own save/restore bookkeeping (rather than writing Application.runInBackground
|
||||
// directly) so OnDestroy's StopServer() still restores the user's original value even when
|
||||
// the HTTP server itself was never started (PlayerConnection needs no open port).
|
||||
if (!m_RunInBackgroundOverridden)
|
||||
{
|
||||
m_PreviousRunInBackground = Application.runInBackground;
|
||||
Application.runInBackground = true;
|
||||
m_RunInBackgroundOverridden = true;
|
||||
Debug.Log("Pipeline: forced Application.runInBackground = true (code-reload push received)");
|
||||
}
|
||||
|
||||
foreach (var data in batch)
|
||||
{
|
||||
bool ok = false;
|
||||
string summary;
|
||||
try
|
||||
{
|
||||
// ---- authentication gate (before anything touches the bytes) ----
|
||||
byte[] payload = null;
|
||||
string rejectAck = null; // generic wire ack; the device log carries the detail
|
||||
#if UNITY_EDITOR
|
||||
// Editor play mode: strip a signed envelope without verification (no key is baked
|
||||
// in play mode), accept legacy raw payloads as-is. Not a security gate — pushes to
|
||||
// the editor originate from the editor itself.
|
||||
if (PushEnvelope.LooksLikeEnvelope(data))
|
||||
{
|
||||
if (!PushEnvelope.TryParse(data, out _, out _, out payload))
|
||||
rejectAck = "malformed code-reload push";
|
||||
}
|
||||
else
|
||||
{
|
||||
payload = data;
|
||||
}
|
||||
#else
|
||||
if (m_PushVerifier == null)
|
||||
{
|
||||
LogPushReject("no signing key baked in this build — rebuild with the Pipeline build processor enabled");
|
||||
rejectAck = "push rejected (authentication)";
|
||||
}
|
||||
else
|
||||
{
|
||||
var status = m_PushVerifier.Verify(data, out payload);
|
||||
if (status != PushVerifyStatus.Ok)
|
||||
{
|
||||
// Q10 diagnostics: a legacy (pre-signing) editor sends a raw payload, which
|
||||
// is the one failure the sender can actually fix without touching the build.
|
||||
if (status == PushVerifyStatus.Malformed && !PushEnvelope.LooksLikeEnvelope(data))
|
||||
{
|
||||
LogPushReject("unsigned legacy push — the sending editor's com.unity.pipeline package predates push signing");
|
||||
rejectAck = "push rejected: unsigned legacy push — update the com.unity.pipeline package in the editor";
|
||||
}
|
||||
else
|
||||
{
|
||||
LogPushReject($"{status} (session nonce/counter and signature details are in this device log only)");
|
||||
rejectAck = "push rejected (authentication)";
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
if (rejectAck != null)
|
||||
{
|
||||
summary = rejectAck;
|
||||
}
|
||||
else if (!PipelineCodeReloadConnect.TryDecode(payload, out var typeName, out var methodNames, out var il))
|
||||
{
|
||||
summary = "malformed code-reload push";
|
||||
}
|
||||
else
|
||||
{
|
||||
var registered = Unity.Pipeline.Compilation.InterpreterCodeReloadExecutor.Register(
|
||||
il, typeName, methodNames, out var skipped, out var warnings);
|
||||
CodeReloadRegistry.InvokeReloadCallbacks(registered);
|
||||
// Full warning text (unbound host members) goes to the device log; the ack
|
||||
// summary the editor shows carries just the count so it stays one line.
|
||||
foreach (var w in warnings)
|
||||
Debug.LogWarning($"Pipeline: code-reload {typeName} — {w}");
|
||||
// Same for per-method skip reasons: the summary carries only the count, but a
|
||||
// method that didn't apply (e.g. SetupUI referencing a member the running
|
||||
// build stripped) is only diagnosable if the reason reaches the device log —
|
||||
// "skipped 1" alone gives the user nothing to act on.
|
||||
foreach (var s in skipped)
|
||||
Debug.LogWarning($"Pipeline: code-reload {typeName} — skipped {s}");
|
||||
ok = registered.Count > 0;
|
||||
summary = ok
|
||||
? $"applied {registered.Count} method(s) on {typeName}" +
|
||||
(skipped.Count > 0 ? $"; skipped {skipped.Count}" : "") +
|
||||
(warnings.Count > 0 ? $"; {warnings.Count} binding warning(s)" : "")
|
||||
: $"no methods applied on {typeName}" +
|
||||
(skipped.Count > 0 ? $": {string.Join("; ", skipped)}" : "");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// The interpreter binds via reflection, so the real cause (e.g.
|
||||
// ExecutionEngineException from a generic instantiation IL2CPP never
|
||||
// AOT-compiled) arrives wrapped in TargetInvocationException, whose Message is
|
||||
// just "Exception has been thrown by the target of an invocation." Unwrap for
|
||||
// the ack the editor shows; keep the full chain + stack in the device log.
|
||||
var root = ex;
|
||||
while (root is System.Reflection.TargetInvocationException && root.InnerException != null)
|
||||
root = root.InnerException;
|
||||
Debug.LogException(ex);
|
||||
summary = $"apply failed: {root.GetType().Name}: {root.Message}";
|
||||
}
|
||||
|
||||
Debug.Log($"Pipeline: code-reload push — {summary}");
|
||||
if (ok) CodeReloadActivity.ReportApplied(summary);
|
||||
else CodeReloadActivity.ReportFailed(summary);
|
||||
try
|
||||
{
|
||||
PlayerConnection.instance.Send(
|
||||
PipelineCodeReloadConnect.ResultMsg, PipelineCodeReloadConnect.EncodeResult(ok, summary));
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
|
||||
// Reject-log rate limiting: full detail for the first few failures, then one summary line per
|
||||
// minute — an attacker probing the port must not be able to spam the device log into
|
||||
// uselessness, but "signature failures are happening" stays visible (it IS the intrusion signal).
|
||||
private const int RejectLogBurst = 10;
|
||||
private const float RejectWindowSeconds = 60f;
|
||||
private int m_RejectsInWindow;
|
||||
private float m_RejectWindowStart = float.NegativeInfinity;
|
||||
private int m_SuppressedRejects;
|
||||
private float m_LastRejectSummaryTime = float.NegativeInfinity;
|
||||
|
||||
private void LogPushReject(string detail)
|
||||
{
|
||||
float now = Time.realtimeSinceStartup;
|
||||
if (now - m_RejectWindowStart > RejectWindowSeconds)
|
||||
{
|
||||
m_RejectWindowStart = now;
|
||||
m_RejectsInWindow = 0;
|
||||
}
|
||||
|
||||
m_RejectsInWindow++;
|
||||
if (m_RejectsInWindow <= RejectLogBurst)
|
||||
{
|
||||
Debug.LogWarning($"Pipeline: code-reload push rejected — {detail}");
|
||||
return;
|
||||
}
|
||||
|
||||
m_SuppressedRejects++;
|
||||
if (now - m_LastRejectSummaryTime >= RejectWindowSeconds)
|
||||
{
|
||||
Debug.LogWarning($"Pipeline: {m_SuppressedRejects} further code-reload push(es) rejected in the last minute " +
|
||||
$"(possible probing on this network); most recent: {detail}");
|
||||
m_LastRejectSummaryTime = now;
|
||||
m_SuppressedRejects = 0;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
/// <summary>Manually start the Pipeline server.</summary>
|
||||
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 RuntimePipelineDriver...");
|
||||
|
||||
if (m_Config == null || !m_Config.enableInBuilds)
|
||||
{
|
||||
Debug.LogWarning("Pipeline: Runtime server disabled in configuration (enableInBuilds = false)");
|
||||
return;
|
||||
}
|
||||
|
||||
var validation = m_Config.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}");
|
||||
}
|
||||
|
||||
m_Server = new RuntimePipelineServer(m_Config);
|
||||
m_Server.Start(m_Config.port);
|
||||
|
||||
if (m_Server.IsRunning)
|
||||
{
|
||||
// Keeps Update -> Dispatcher.ProcessWorkQueue/WatchdogTick running while unfocused.
|
||||
// Guarded on m_RunInBackgroundOverridden (rather than unconditional) because
|
||||
// DrainPendingReloads can already have overridden it — a code-reload push over
|
||||
// PlayerConnection needs no HTTP server, so it can arrive and force this on before
|
||||
// StartServer ever runs. Saving unconditionally here would clobber the real
|
||||
// original value with the already-overridden "true", and StopServer would then
|
||||
// restore to "true" instead of the user's setting.
|
||||
if (!m_RunInBackgroundOverridden)
|
||||
{
|
||||
m_PreviousRunInBackground = Application.runInBackground;
|
||||
Application.runInBackground = true;
|
||||
m_RunInBackgroundOverridden = true;
|
||||
}
|
||||
|
||||
// The code-reload registry marshals main-thread overrides through a dispatcher
|
||||
// when reloaded code is invoked from a background thread at runtime. Inject
|
||||
// this server's dispatcher here (a player has exactly one driver), so editor and
|
||||
// per-test servers never clobber the global registry's dispatcher.
|
||||
CodeReloadRegistry.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.
|
||||
CodeReloadRegistry.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}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Manually stop the Pipeline server.</summary>
|
||||
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 (CodeReloadRegistry.Dispatcher == m_Server.Dispatcher)
|
||||
CodeReloadRegistry.Dispatcher = null;
|
||||
m_Server.Stop(); // Stop() shuts down the server's own dispatcher.
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogError($"Pipeline: Runtime shutdown error: {ex.Message}");
|
||||
}
|
||||
|
||||
// Independent of whether the listener was still alive above: if we overrode
|
||||
// runInBackground, restore it regardless, so a listener that already died on its own
|
||||
// (e.g. the watchdog never got to it) can't leave the setting stuck on.
|
||||
if (m_RunInBackgroundOverridden)
|
||||
{
|
||||
Application.runInBackground = m_PreviousRunInBackground;
|
||||
m_RunInBackgroundOverridden = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Validate the current configuration.</summary>
|
||||
/// <returns>The validation result.</returns>
|
||||
public ValidationResult ValidateConfiguration()
|
||||
{
|
||||
return m_Config != null ? m_Config.Validate() : ValidationResult.Error("No RuntimePipelineConfig assigned");
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 962b2a2812855af4b8ef33c0edccd103
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,98 @@
|
||||
using System;
|
||||
using Unity.Pipeline.Config;
|
||||
using Unity.Pipeline.Models;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unity.Pipeline
|
||||
{
|
||||
/// <summary>The Player-side <see cref="BasePipelineServer"/>, owned by a <see cref="RuntimePipelineDriver"/>.</summary>
|
||||
public class RuntimePipelineServer : BasePipelineServer
|
||||
{
|
||||
private RuntimePipelineConfig m_Config;
|
||||
private RuntimeInstanceDescriptor m_InstanceDescriptor;
|
||||
|
||||
/// <summary>
|
||||
/// Guards mutate-then-write of m_InstanceDescriptor: concurrent /api/status probes both
|
||||
/// reach UpdateHeartBeat now, and RuntimeInstanceDescriptor.WriteToWorkingDirectory's own
|
||||
/// lock only covers the write, not the field mutation feeding it.
|
||||
/// </summary>
|
||||
private readonly object m_HeartbeatLock = new object();
|
||||
|
||||
/// <summary>UTC time this server instance started listening.</summary>
|
||||
public override DateTime StartedAt => m_InstanceDescriptor == null ? new DateTime() : m_InstanceDescriptor.StartedAt;
|
||||
|
||||
/// <summary>Create a server bound to the given configuration.</summary>
|
||||
/// <param name="config">The runtime pipeline configuration.</param>
|
||||
public RuntimePipelineServer(RuntimePipelineConfig config)
|
||||
{
|
||||
m_Config = config;
|
||||
}
|
||||
|
||||
/// <summary>Runtime servers use ports 7900-7949 (test runtime servers use 7950-7999).</summary>
|
||||
/// <returns>The inclusive port range to try when binding the listener.</returns>
|
||||
protected override (int basePort, int maxPort) GetPortRange()
|
||||
{
|
||||
return (7900, 7949); // Runtime production (test runtime servers use 7950-7999)
|
||||
}
|
||||
|
||||
/// <summary>Write the runtime instance descriptor to the working directory for CLI discovery.</summary>
|
||||
protected override void CreateInstanceDescriptor()
|
||||
{
|
||||
// Create and write instance descriptor for CLI discovery
|
||||
m_InstanceDescriptor = RuntimeInstanceDescriptor.CreateCurrent(Port, m_Config);
|
||||
RuntimeInstanceDescriptor.WriteToWorkingDirectory(m_InstanceDescriptor);
|
||||
}
|
||||
|
||||
/// <summary>Remove the runtime instance descriptor on shutdown.</summary>
|
||||
protected override void DeleteInstanceDescriptor()
|
||||
{
|
||||
// Clean up instance descriptor file
|
||||
if (m_InstanceDescriptor != null)
|
||||
{
|
||||
RuntimeInstanceDescriptor.RemoveFromWorkingDirectory();
|
||||
}
|
||||
m_InstanceDescriptor = null;
|
||||
}
|
||||
|
||||
/// <summary>Refresh the descriptor's heartbeat timestamp.</summary>
|
||||
protected override void UpdateHeartBeat()
|
||||
{
|
||||
// Update heartbeat in instance descriptor
|
||||
if (m_InstanceDescriptor == null)
|
||||
return;
|
||||
|
||||
lock (m_HeartbeatLock)
|
||||
{
|
||||
m_InstanceDescriptor.LastHeartbeat = DateTime.UtcNow;
|
||||
try
|
||||
{
|
||||
RuntimeInstanceDescriptor.WriteToWorkingDirectory(m_InstanceDescriptor);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogWarning($"Failed to update instance descriptor: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Build the Player-specific status/heartbeat payload for /api/status.</summary>
|
||||
/// <returns>The status payload.</returns>
|
||||
protected override object GetServerStatus()
|
||||
{
|
||||
UpdateHeartBeat();
|
||||
return new
|
||||
{
|
||||
status = m_InstanceDescriptor == null ? "error" : "ready",
|
||||
lastHeartbeat = m_InstanceDescriptor?.LastHeartbeat,
|
||||
capabilities = Capabilities
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>The bearer token clients must present to authenticate requests.</summary>
|
||||
/// <returns>The current token.</returns>
|
||||
protected override string GetToken()
|
||||
{
|
||||
return m_InstanceDescriptor.EvalToken;
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4599450d651dea74cb4b90289360aa3e
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Reference in New Issue
Block a user