Initial commit: AIGame Unity client with URP, HybridCLR, YooAsset and tooling.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
udx
2026-09-21 14:21:07 +08:00
co-authored by Cursor
commit f3f66ab063
931 changed files with 112366 additions and 0 deletions
@@ -0,0 +1,400 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using UnityEngine;
#if UNITY_6000_3_OR_NEWER
using UnityEngine.Assemblies;
#endif
#if UNITY_6000_5_OR_NEWER
using Unity.Scripting.LifecycleManagement;
#endif
namespace Unity.Pipeline.Commands
{
/// <summary>
/// Registry for discovering and managing CLI commands via pluggable discovery mechanism.
/// Scans for methods marked with [CliCommand] attribute across all assemblies.
/// Based on unity-tools ToolRegistry patterns adapted for Pipeline requirements.
/// </summary>
/// <remarks>
/// [NoAutoStaticsCleanup] on purpose: m_DiscoveryLock and the injected discovery mechanism are
/// set up once per domain and must not be reset under the running server. The dynamic-command
/// table is the one static here with a shorter useful life, and PlayModeSessionEnded resets the
/// part of it that a Play session owns.
/// </remarks>
#if UNITY_6000_5_OR_NEWER
[NoAutoStaticsCleanup]
#endif
static class CommandRegistry
{
private static IReadOnlyList<CommandInfo> m_CachedCommands;
private static IReadOnlyList<CommandInfo> m_CachedAllCommands;
private static readonly Dictionary<string, CommandInfo> m_DynamicCommands =
new Dictionary<string, CommandInfo>(StringComparer.Ordinal);
// Names in m_DynamicCommands that predate the Play session currently running; null outside
// one. Lets PlayModeSessionEnded tell a registration made during the session apart from one
// that was already there. See PlayModeSessionStarted.
private static HashSet<string> m_PrePlayModeCommands;
private static ICommandDiscovery m_Discovery;
private static readonly object m_DiscoveryLock = new object();
/// <summary>
/// Set the command discovery mechanism.
/// Editor assembly provides TypeCache-based discovery, Runtime uses reflection fallback.
/// </summary>
/// <param name="discovery">The discovery mechanism to use.</param>
public static void SetDiscovery(ICommandDiscovery discovery)
{
m_Discovery = discovery;
ClearCache(); // Re-discover with new mechanism
}
/// <summary>
/// Discover every available command: the methods marked with [CliCommand], plus any
/// registered through <see cref="RegisterCommand"/>.
/// Attribute discovery uses the injected mechanism (TypeCache in Editor, reflection in
/// Runtime) and is cached until domain reload; the combined result is re-cached whenever
/// a dynamic command is registered or unregistered.
/// </summary>
/// <returns>All discovered commands.</returns>
public static IEnumerable<CommandInfo> DiscoverCommands()
{
var cached = m_CachedAllCommands;
if (cached != null)
{
return cached;
}
lock (m_DiscoveryLock)
{
if (m_CachedCommands == null)
{
m_CachedCommands = DiscoverCommandsInternal().ToList();
}
if (m_CachedAllCommands == null)
{
m_CachedAllCommands = m_DynamicCommands.Count == 0
? m_CachedCommands
: m_CachedCommands.Concat(m_DynamicCommands.Values).ToList();
}
}
return m_CachedAllCommands;
}
/// <summary>
/// Register a command at runtime, without a [CliCommand] attribute. The resulting command
/// is indistinguishable from an attribute-declared one: it is listed by <c>/api/commands</c>
/// and executable through <c>/api/exec</c> under <paramref name="name"/>.
///
/// Parameter metadata is read from the handler's own parameters exactly as it is for an
/// attribute-declared command — a [CliArg] on a parameter supplies its CLI name,
/// description, and required flag; a parameter without one falls back to its declared name
/// and default value. Note that a lambda cannot carry [CliArg], so registering a named
/// method is what yields full metadata.
///
/// Registrations live until they are unregistered or the domain reloads; they survive
/// <see cref="ClearCache"/> and <see cref="SetDiscovery"/>. One made while the Editor is in
/// Play Mode is additionally dropped when Play Mode exits — see
/// <see cref="PlayModeSessionEnded"/>.
/// </summary>
/// <param name="name">Unique command name for CLI execution.</param>
/// <param name="description">Human-readable description, shown in command listings.</param>
/// <param name="handler">
/// Method to invoke. An instance-method delegate keeps its receiver, so the command runs
/// against that instance; a static one runs with no receiver.
/// </param>
/// <param name="mainThreadRequired">Whether the command must run on the Unity main thread.</param>
/// <param name="runtimeOnly">Whether the command belongs to the Player command surface only.</param>
/// <param name="tags">Optional path-style tags used to group and browse the command.</param>
/// <exception cref="InvalidOperationException">
/// A command called <paramref name="name"/> is already registered. Attribute-declared
/// commands can never be replaced; a dynamic one must be unregistered first.
/// </exception>
public static void RegisterCommand(string name, string description, Delegate handler,
bool mainThreadRequired = true, bool runtimeOnly = false, string[] tags = null)
{
if (name == null)
throw new ArgumentNullException(nameof(name));
if (description == null)
throw new ArgumentNullException(nameof(description));
if (handler == null)
throw new ArgumentNullException(nameof(handler));
if (string.IsNullOrWhiteSpace(name))
throw new ArgumentException("Command name must not be empty or whitespace.", nameof(name));
var method = handler.Method;
var command = new CommandInfo(
name,
description,
mainThreadRequired,
method,
DiscoverParameters(method).ToList(),
runtimeOnly,
tags,
method.DeclaringType?.Assembly.GetName().Name,
handler.Target);
lock (m_DiscoveryLock)
{
if (m_DynamicCommands.ContainsKey(name))
{
throw new InvalidOperationException(
$"A command named '{name}' is already registered dynamically. " +
"Unregister it before registering it again.");
}
// Anything left matching here is attribute-declared: the dynamic table was just checked.
if (DiscoverCommands().Any(c => c.Name == name))
{
throw new InvalidOperationException(
$"A command named '{name}' is already declared with a [CliCommand] attribute " +
"and cannot be replaced.");
}
m_DynamicCommands[name] = command;
m_CachedAllCommands = null;
}
}
/// <summary>
/// Remove a command previously registered with <see cref="RegisterCommand"/>.
/// Attribute-declared commands are part of the code and are never removable.
/// </summary>
/// <param name="name">Name the command was registered under.</param>
/// <returns>
/// True if a dynamic command was removed; false if no command is registered under that
/// name dynamically, including when the name belongs to an attribute-declared command.
/// </returns>
public static bool UnregisterCommand(string name)
{
if (name == null)
throw new ArgumentNullException(nameof(name));
lock (m_DiscoveryLock)
{
if (!m_DynamicCommands.Remove(name))
return false;
m_CachedAllCommands = null;
return true;
}
}
/// <summary>
/// Note that a Play session is starting, so registrations made from here on can be told
/// apart from the ones that predate it. Called by the Editor on Play Mode entry, before any
/// user code runs.
/// </summary>
internal static void PlayModeSessionStarted()
{
lock (m_DiscoveryLock)
{
m_PrePlayModeCommands = new HashSet<string>(m_DynamicCommands.Keys, StringComparer.Ordinal);
}
}
/// <summary>
/// Drop every dynamic registration made during the Play session that just ended, the way a
/// domain reload would have. Registrations that predate the session are left alone: nothing
/// re-runs to restore them (an [InitializeOnLoadMethod] runs once per domain).
///
/// A script registering from Awake or OnEnable re-registers on every Play Mode entry, so
/// without this the second entry would throw "already registered dynamically" whenever no
/// domain reload happened in between — and any surviving entry would still hold a delegate
/// bound to the previous session's destroyed object. That covers Enter Play Mode with domain
/// reload disabled on any Editor version, and on 6000.5+ the class-wide
/// [NoAutoStaticsCleanup] means AutoStaticsCleanup does not step in either.
/// </summary>
internal static void PlayModeSessionEnded()
{
lock (m_DiscoveryLock)
{
// No recorded session start (e.g. a domain reload wiped it on the way in, having
// already cleared the registry): nothing is known to belong to the session, so
// removing anything would be guesswork.
if (m_PrePlayModeCommands == null)
return;
foreach (var name in m_DynamicCommands.Keys.Where(n => !m_PrePlayModeCommands.Contains(n)).ToList())
m_DynamicCommands.Remove(name);
m_PrePlayModeCommands = null;
m_CachedAllCommands = null;
}
}
/// <summary>
/// Clear the discovered-command cache, so the next discovery re-scans for [CliCommand]
/// methods. Called automatically on domain reload, and whenever the discovery mechanism
/// changes. Commands registered through <see cref="RegisterCommand"/> are not affected.
/// </summary>
public static void ClearCache()
{
lock (m_DiscoveryLock)
{
m_CachedCommands = null;
m_CachedAllCommands = null;
}
}
/// <summary>
/// Internal implementation of command discovery using injected discovery mechanism.
/// </summary>
private static IEnumerable<CommandInfo> DiscoverCommandsInternal()
{
var commands = new List<CommandInfo>();
try
{
// Use injected discovery mechanism if available, otherwise fallback to reflection
IEnumerable<MethodInfo> methods;
if (m_Discovery != null)
{
methods = m_Discovery.GetMethodsWithAttribute<CliCommandAttribute>();
}
else
{
// Fallback: Use reflection to scan loaded assemblies
methods = GetMethodsWithAttributeViaReflection<CliCommandAttribute>();
}
foreach (var method in methods)
{
try
{
var commandInfo = CreateCommandInfo(method);
if (commandInfo != null)
{
commands.Add(commandInfo);
}
}
catch (Exception ex)
{
Debug.LogWarning($"Failed to register command from method {method.DeclaringType?.Name}.{method.Name}: {ex.Message}");
}
}
}
catch (Exception ex)
{
Debug.LogError($"Failed to discover commands: {ex.Message}");
}
return commands;
}
/// <summary>
/// Fallback command discovery using reflection when TypeCache is not available.
/// </summary>
private static IEnumerable<MethodInfo> GetMethodsWithAttributeViaReflection<T>() where T : Attribute
{
var methods = new List<MethodInfo>();
try
{
var assemblies = PipelineUtils.GetLoadedAssemblies();
foreach (var assembly in assemblies)
{
// Skip system assemblies for performance
if (IsSystemAssembly(assembly))
continue;
try
{
foreach (var type in assembly.GetTypes())
{
foreach (var method in type.GetMethods(BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic))
{
if (method.GetCustomAttribute<T>() != null)
{
methods.Add(method);
}
}
}
}
catch (ReflectionTypeLoadException)
{
// Skip assemblies that can't be loaded
continue;
}
}
}
catch (Exception ex)
{
Debug.LogWarning($"Reflection-based command discovery failed: {ex.Message}");
}
return methods;
}
/// <summary>
/// Check if assembly is a system assembly that should be skipped during discovery.
/// </summary>
private static bool IsSystemAssembly(Assembly assembly)
{
var name = assembly.GetName().Name;
return name.StartsWith("System.") ||
name.StartsWith("Microsoft.") ||
name.StartsWith("mscorlib") ||
name.StartsWith("netstandard") ||
name.Equals("UnityEngine") ||
name.Equals("UnityEditor");
}
/// <summary>
/// Create CommandInfo from a method with CliCommand attribute.
/// </summary>
private static CommandInfo CreateCommandInfo(MethodInfo method)
{
var commandAttr = method.GetCustomAttribute<CliCommandAttribute>();
if (commandAttr == null)
return null;
// Validate method is static (required for CLI commands).
// Any static method may be registered regardless of accessibility
// (public, internal, or private) — invocation goes through MethodInfo.Invoke.
if (!method.IsStatic)
{
Debug.LogWarning($"Command method {method.DeclaringType?.Name}.{method.Name} must be static");
return null;
}
// Discover parameters
var parameters = DiscoverParameters(method).ToList();
return new CommandInfo(
commandAttr.Name,
commandAttr.Description,
commandAttr.MainThreadRequired,
method,
parameters,
commandAttr.RuntimeOnly,
commandAttr.Tags,
method.DeclaringType?.Assembly.GetName().Name
);
}
/// <summary>
/// Discover parameter information from method parameters.
/// </summary>
private static IEnumerable<CommandParameterInfo> DiscoverParameters(MethodInfo method)
{
foreach (var param in method.GetParameters())
{
var argAttr = param.GetCustomAttribute<CliArgAttribute>();
// Parameters without CliArg attribute get default metadata
var name = argAttr?.Name ?? param.Name;
var description = argAttr?.Description ?? $"Parameter: {param.Name}";
var required = argAttr?.Required ?? !param.HasDefaultValue;
var defaultValue = param.HasDefaultValue ? param.DefaultValue : argAttr?.DefaultValue;
yield return new CommandParameterInfo(name, description, required, param.ParameterType, defaultValue);
}
}
}
}