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,604 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text;
using System.Threading;
using Newtonsoft.Json;
using Unity.Pipeline.Commands;
using UnityEditor;
using UnityEngine;
#if UNITY_6000_5_OR_NEWER
using Unity.Scripting.LifecycleManagement;
#endif
namespace Unity.Pipeline.Editor.Commands.ProjectAudit
{
/// <summary>
/// Exposes Unity's Project Auditor static-analysis scan as <c>audit</c> / <c>audit_status</c>:
/// start a scan, poll it, read a CSV of the reported issues.
///
/// Project Auditor is reached entirely by reflection (as <see cref="FocusEditorCommand"/> reaches
/// its editor internals): this package must compile and run in editors without Project Auditor, so
/// it takes no asmdef reference and no package dependency. One reflection path covers both Project
/// Auditor deployments — the built-in editor module and the com.unity.project-auditor package —
/// because they share the type name and public surface. They do not share an assembly name, so the
/// lookup scans loaded assemblies rather than naming one; see <see cref="FindLoadedType"/>.
///
/// <c>AuditAsync</c> is only partly asynchronous: it blocks its caller through assembly
/// compilation and the first analysis phase, then finishes on its own background thread and fires
/// <c>OnCompleted</c> there (or synchronously, when Code analysis is out of scope). So
/// <c>audit</c> only enqueues — an <see cref="EditorApplication.update"/> hook runs the scan on a
/// later tick — and <c>audit_status</c> answers off the main thread while compilation holds it.
///
/// A resolvable Project Auditor is not necessarily a usable one: as a built-in editor module its
/// rules ship in the separate com.unity.project-auditor-rules package, and without them it
/// registers no modules and produces an empty report indistinguishable from a clean project.
/// Hence the registered-modules check, reported as <c>unavailable</c>.
/// </summary>
[InitializeOnLoad]
#if UNITY_6000_5_OR_NEWER
[NoAutoStaticsCleanup]
#endif
static class ProjectAuditCommands
{
const string StatusFile = "Temp/pipeline_audit_status.json";
const string CsvDirectory = "Temp/pipeline-audit";
const string RulesPackageName = "com.unity.project-auditor-rules";
const string AuditorTypeName = "Unity.ProjectAuditor.Editor.ProjectAuditor";
static readonly object s_Lock = new object();
// Enqueued by audit(); consumed by the OnUpdate pump on the next main-thread tick.
static bool s_Pending;
// True from when the pump starts a scan until OnDone (completion) or a start failure.
static bool s_Scanning;
// Category array for AnalysisParams.Categories (element type varies by deployment), or null = all.
static object s_PendingCategories;
static string s_ScanId;
static string s_CsvPath;
// Last status JSON successfully written; lets audit_status answer while a write is in flight.
static volatile string s_LastStatusJson;
static ProjectAuditCommands()
{
// A scan cannot survive a domain reload (its background thread and our static state are
// gone), so flip any dangling "scanning" status to "interrupted" before watching again.
ReconcileAfterReload();
EditorApplication.update += OnUpdate;
}
[CliCommand("audit", "Run a Project Auditor static-analysis scan. Returns immediately; poll audit_status until status is 'completed', then read the CSV.", MainThreadRequired = false, Tags = new[] { "observability/audit" })]
public static object Audit(
[CliArg("categories", "Comma-separated issue categories to scan (e.g. Code,ProjectSetting,Texture). Default: all categories.")] string categories = "",
[CliArg("output", "CSV output path (absolute or relative to the project root). Defaults to Temp/pipeline-audit/<scanId>.csv.")] string output = "")
{
if (!TryResolve(out var pa, out var error))
return new { status = "unavailable", message = error };
var requested = ParseCategoryNames(categories);
var validNames = Enum.GetNames(pa.IssueCategoryType);
var validationError = ValidateCategories(requested, validNames);
if (validationError != null)
return new { status = "error", message = validationError };
lock (s_Lock)
{
if (s_Pending || s_Scanning)
return new { status = "busy", message = "An audit is already queued or running. Poll audit_status." };
s_ScanId = Guid.NewGuid().ToString("N").Substring(0, 8);
s_CsvPath = ResolveCsvPath(output, s_ScanId);
s_PendingCategories = BuildCategoriesArray(pa, requested);
s_Pending = true;
// Written inside the lock: the pump can take it on the very next frame and write a
// terminal status synchronously (unavailable, or completed when the scan runs inline).
// Writing after the release would clobber that, wedging audit_status on "scanning".
WriteStatus(new AuditStatus { Status = "scanning", ScanId = s_ScanId, CsvPath = s_CsvPath });
}
return new { status = "scanning", scanId = s_ScanId, csvPath = s_CsvPath };
}
[CliCommand("audit_status", "Get the status of the last audit: idle | scanning | completed | failed | interrupted | unavailable.", MainThreadRequired = false, Tags = new[] { "observability/audit" })]
public static string AuditStatus()
{
if (!TryResolve(out _, out var error))
return JsonConvert.SerializeObject(new AuditStatus { Status = "unavailable", Message = error });
if (!File.Exists(StatusFile))
return "{\"status\":\"idle\"}";
// WriteStatus truncates then rewrites, and its writer is usually another thread (the pump,
// or Project Auditor's background thread) while this read serves an HTTP worker. A read
// landing mid-write either throws (sharing violation) or returns truncated JSON, so retry,
// then fall back to the last status we wrote - the file will match it once the write lands.
for (var attempt = 0; attempt < 3; attempt++)
{
if (attempt > 0)
Thread.Sleep(20);
try
{
var json = File.ReadAllText(StatusFile);
// WriteStatus writes serialized JSON with nothing after it, so a closing '}' means
// this read caught a whole object rather than a half-written one.
if (json.Length > 1 && json[json.Length - 1] == '}')
return json;
}
catch (IOException)
{
// Mid-write collision; retry.
}
}
return s_LastStatusJson ?? File.ReadAllText(StatusFile);
}
/// <summary>
/// Main-thread pump: picks up a queued request and runs the audit. Building AnalysisParams and
/// calling AuditAsync must happen on the main thread; AuditAsync then blocks here through
/// compilation and returns once the remaining analysis is handed to its background thread.
/// </summary>
static void OnUpdate()
{
object categories;
lock (s_Lock)
{
if (!s_Pending || s_Scanning)
return;
s_Pending = false;
s_Scanning = true;
categories = s_PendingCategories;
}
try
{
if (!RunAudit(categories))
{
// RunAudit wrote the terminal status and OnDone will not run: release the guard here.
lock (s_Lock) { s_Scanning = false; }
}
}
catch (Exception ex)
{
// Only up-front and synchronous failures reach here; once AuditAsync has handed off to
// its background thread, OnDone owns both the outcome and s_Scanning.
WriteStatus(new AuditStatus { Status = "failed", ScanId = s_ScanId, Error = ex.Message });
lock (s_Lock) { s_Scanning = false; }
}
}
/// <summary>
/// Starts the scan. Returns false when no scan was started, in which case the terminal status
/// has already been written and no completion callback will fire.
/// </summary>
static bool RunAudit(object categories)
{
if (!TryResolve(out var pa, out var error))
throw new InvalidOperationException(error);
// Constructing ProjectAuditor is what initializes its modules, so this is the earliest
// point at which the "has usable rules" precondition can be checked.
var auditor = Activator.CreateInstance(pa.ProjectAuditorType);
if (HasNoModules(pa, auditor))
{
WriteStatus(new AuditStatus
{
Status = "unavailable",
ScanId = s_ScanId,
Message = "Project Auditor registered no analysis modules, so it cannot analyze this " +
$"project. Install the Project Auditor Rules package ({RulesPackageName})."
});
return false;
}
var analysisParams = Activator.CreateInstance(pa.AnalysisParamsType, new object[] { true });
if (categories != null)
pa.CategoriesField.SetValue(analysisParams, categories);
// Wire OnCompleted, which is an Action<Report>. OnDone takes 'object'; relaxed delegate
// binding permits it because Report derives from object (reference-type contravariance).
var actionType = typeof(Action<>).MakeGenericType(pa.ReportType);
var onDone = Delegate.CreateDelegate(actionType, typeof(ProjectAuditCommands)
.GetMethod(nameof(OnDone), BindingFlags.NonPublic | BindingFlags.Static));
pa.OnCompletedField.SetValue(analysisParams, onDone);
// AuditAsync(AnalysisParams, IProgress); a null progress means no progress and no cancel hook.
pa.AuditAsyncMethod.Invoke(auditor, new[] { analysisParams, null });
return true;
}
/// <summary>No registered modules means the rules package is missing: a scan would complete
/// instantly with an empty report. False when the reflected accessor is absent.</summary>
static bool HasNoModules(ResolvedProjectAuditor pa, object auditor)
{
if (pa.GetModulesMethod == null)
return false;
return pa.GetModulesMethod.Invoke(auditor, null) is ICollection modules && modules.Count == 0;
}
/// <summary>
/// Completion callback bound to AnalysisParams.OnCompleted. Fires on Project Auditor's
/// background analysis thread (or synchronously inside AuditAsync when Code analysis is not in
/// scope). Only does thread-safe work: reads the report and writes the CSV + status files.
/// </summary>
static void OnDone(object report)
{
try
{
if (!TryResolve(out var pa, out var error))
throw new InvalidOperationException(error);
var rows = CollectRows(pa, report);
WriteCsv(s_CsvPath, rows);
WriteStatus(new AuditStatus
{
Status = "completed",
ScanId = s_ScanId,
CsvPath = s_CsvPath,
IssueCount = rows.Count
});
}
catch (Exception ex)
{
WriteStatus(new AuditStatus { Status = "failed", ScanId = s_ScanId, Error = ex.Message });
}
finally
{
lock (s_Lock) { s_Scanning = false; }
}
}
/// <summary>
/// Enumerate the report's issues (ReportItems whose descriptor is valid — i.e. diagnostics, not
/// raw-inventory insights) and project each to a CSV row. Reflection only; safe off-thread.
/// </summary>
static List<string[]> CollectRows(ResolvedProjectAuditor pa, object report)
{
var rows = new List<string[]>();
var all = pa.GetAllIssuesMethod.Invoke(report, null) as IEnumerable;
if (all == null)
return rows;
foreach (var item in all)
{
if (item == null || !(bool)pa.IsIssueMethod.Invoke(item, null))
continue;
var descriptor = pa.GetDescriptorMethod.Invoke(pa.IdProperty.GetValue(item), null);
var line = (int)pa.LineProperty.GetValue(item);
rows.Add(new[]
{
pa.CategoryProperty.GetValue(item)?.ToString() ?? string.Empty,
pa.SeverityProperty.GetValue(item)?.ToString() ?? string.Empty,
pa.AreasField.GetValue(descriptor)?.ToString() ?? string.Empty,
pa.DescriptionProperty.GetValue(item) as string ?? string.Empty,
pa.RelativePathProperty.GetValue(item) as string ?? string.Empty,
line > 0 ? line.ToString() : string.Empty,
pa.IdField.GetValue(descriptor) as string ?? string.Empty,
pa.RecommendationField.GetValue(descriptor) as string ?? string.Empty,
});
}
return rows;
}
static readonly string[] k_CsvHeader =
{
"Category", "Severity", "Areas", "Description", "RelativePath", "Line", "DescriptorId", "Recommendation"
};
static void WriteCsv(string path, List<string[]> rows)
{
var dir = Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(dir))
Directory.CreateDirectory(dir);
var sb = new StringBuilder();
AppendCsvRow(sb, k_CsvHeader);
foreach (var row in rows)
AppendCsvRow(sb, row);
File.WriteAllText(path, sb.ToString());
}
static void AppendCsvRow(StringBuilder sb, string[] fields)
{
for (var i = 0; i < fields.Length; i++)
{
if (i > 0)
sb.Append(',');
sb.Append(EscapeCsv(fields[i]));
}
sb.Append("\r\n");
}
/// <summary>RFC 4180 escaping: quote a field containing a comma, quote or newline; double any embedded quotes.</summary>
internal static string EscapeCsv(string field)
{
field = field ?? string.Empty;
if (field.IndexOfAny(new[] { ',', '"', '\n', '\r' }) < 0)
return field;
return "\"" + field.Replace("\"", "\"\"") + "\"";
}
/// <summary>
/// Returns null when every requested category name is valid, otherwise an error message listing
/// the unknown names and the valid values. Pure (valid names injected) so it needs no Project
/// Auditor types to test.
/// </summary>
internal static string ValidateCategories(IReadOnlyList<string> requested, IReadOnlyCollection<string> validNames)
{
if (requested == null || requested.Count == 0)
return null;
var unknown = requested
.Where(r => !validNames.Any(v => string.Equals(v, r, StringComparison.OrdinalIgnoreCase)))
.ToList();
if (unknown.Count == 0)
return null;
return $"Unknown categor{(unknown.Count == 1 ? "y" : "ies")}: {string.Join(", ", unknown)}. " +
$"Valid categories: {string.Join(", ", validNames)}.";
}
static List<string> ParseCategoryNames(string categories)
{
if (string.IsNullOrWhiteSpace(categories))
return new List<string>();
return categories.Split(',')
.Select(c => c.Trim())
.Where(c => c.Length > 0)
.ToList();
}
/// <summary>Build the array for AnalysisParams.Categories, or null when no filter was requested (= all).</summary>
static object BuildCategoriesArray(ResolvedProjectAuditor pa, IReadOnlyList<string> requested)
{
if (requested.Count == 0)
return null;
return BuildCategoryArray(pa.CategoryElementType, pa.CategoryWrapperCtor, pa.IssueCategoryType, requested);
}
/// <summary>
/// Build the typed category array. The element type is a deployment detail — the standalone
/// package stores the bare enum, the built-in module a serialization wrapper — so it and its
/// wrapping constructor are passed in, which also makes this testable without Project Auditor.
/// </summary>
internal static Array BuildCategoryArray(Type elementType, ConstructorInfo wrapperCtor, Type enumType,
IReadOnlyList<string> requested)
{
var array = Array.CreateInstance(elementType, requested.Count);
for (var i = 0; i < requested.Count; i++)
{
var category = Enum.Parse(enumType, requested[i], ignoreCase: true);
array.SetValue(wrapperCtor == null ? category : wrapperCtor.Invoke(new[] { category }), i);
}
return array;
}
static string ResolveCsvPath(string output, string scanId)
{
var projectRoot = Path.GetDirectoryName(Application.dataPath);
if (!string.IsNullOrWhiteSpace(output))
return Path.IsPathRooted(output) ? output : Path.GetFullPath(Path.Combine(projectRoot, output));
return Path.Combine(projectRoot, CsvDirectory, scanId + ".csv");
}
static void ReconcileAfterReload()
{
if (!File.Exists(StatusFile))
return;
AuditStatus prior;
try
{
prior = JsonConvert.DeserializeObject<AuditStatus>(File.ReadAllText(StatusFile));
}
catch
{
return;
}
if (prior != null && prior.Status == "scanning")
WriteStatus(new AuditStatus { Status = "interrupted", ScanId = prior.ScanId });
}
static void WriteStatus(AuditStatus status)
{
var json = JsonConvert.SerializeObject(status);
try
{
File.WriteAllText(StatusFile, json);
// Cached only after a successful write, so the audit_status fallback can never report a
// status the file does not hold.
s_LastStatusJson = json;
}
catch (Exception ex)
{
Debug.LogError($"[ProjectAudit] Failed to write status file: {ex.Message}");
}
}
#region reflection resolution
static readonly object s_ResolveLock = new object();
static ResolvedProjectAuditor s_Resolved;
/// <summary>
/// Resolve the Project Auditor types/members once (thread-safe: audit_status runs off the main
/// thread). Returns false with a clear message when Project Auditor is not installed or an
/// expected member is missing (e.g. an incompatible version).
/// </summary>
static bool TryResolve(out ResolvedProjectAuditor pa, out string error)
{
lock (s_ResolveLock)
{
if (s_Resolved != null)
{
pa = s_Resolved;
error = null;
return true;
}
pa = null;
error = null;
var auditorType = FindLoadedType(AuditorTypeName);
if (auditorType == null)
{
error = $"Project Auditor is not installed in this Editor ({AuditorTypeName} not found).";
return false;
}
var asm = auditorType.Assembly;
try
{
var resolved = new ResolvedProjectAuditor(auditorType, asm);
s_Resolved = resolved;
pa = resolved;
return true;
}
catch (Exception ex)
{
error = $"Project Auditor is present but its API could not be resolved (version mismatch?): {ex.Message}";
return false;
}
}
}
/// <summary>
/// Find a type by full name in any loaded assembly. The assembly name cannot be used to narrow
/// the search: the same type ships in <c>Unity.ProjectAuditor.Editor</c> as a package but in
/// <c>UnityEditor.ProjectAuditorModule</c> as a built-in editor module (measured on 6000.7), so
/// an assembly-qualified <see cref="Type.GetType(string)"/> finds only the package deployment.
/// </summary>
static Type FindLoadedType(string fullName)
{
foreach (var asm in PipelineUtils.GetLoadedAssemblies())
{
var type = asm.GetType(fullName, throwOnError: false);
if (type != null)
return type;
}
return null;
}
#endregion
}
/// <summary>
/// Cached Project Auditor reflection handles. The constructor throws if any expected member is
/// missing, so <see cref="ProjectAuditCommands"/> can report a clean "unavailable" rather than
/// failing mid-scan. All members come from the one assembly that declares ProjectAuditor.
/// </summary>
sealed class ResolvedProjectAuditor
{
public readonly Type ProjectAuditorType;
public readonly Type AnalysisParamsType;
public readonly Type ReportType;
public readonly Type IssueCategoryType;
public readonly MethodInfo AuditAsyncMethod;
public readonly FieldInfo CategoriesField;
// Element type of AnalysisParams.Categories: the bare IssueCategory in the standalone Project
// Auditor package, a SerializableEnum<IssueCategory> wrapper in the built-in editor module.
public readonly Type CategoryElementType;
// Constructor wrapping an IssueCategory into that element type; null when it is the bare enum.
public readonly ConstructorInfo CategoryWrapperCtor;
// Optional (internal API): when absent, the registered-modules precondition check is skipped.
public readonly MethodInfo GetModulesMethod;
public readonly FieldInfo OnCompletedField;
public readonly MethodInfo GetAllIssuesMethod;
public readonly MethodInfo IsIssueMethod;
public readonly PropertyInfo CategoryProperty;
public readonly PropertyInfo SeverityProperty;
public readonly PropertyInfo DescriptionProperty;
public readonly PropertyInfo RelativePathProperty;
public readonly PropertyInfo LineProperty;
public readonly PropertyInfo IdProperty;
public readonly MethodInfo GetDescriptorMethod;
public readonly FieldInfo IdField;
public readonly FieldInfo AreasField;
public readonly FieldInfo RecommendationField;
public ResolvedProjectAuditor(Type auditorType, Assembly asm)
{
ProjectAuditorType = auditorType;
AnalysisParamsType = Require(asm, "Unity.ProjectAuditor.Editor.AnalysisParams");
ReportType = Require(asm, "Unity.ProjectAuditor.Editor.Report");
IssueCategoryType = Require(asm, "Unity.ProjectAuditor.Editor.IssueCategory");
var reportItemType = Require(asm, "Unity.ProjectAuditor.Editor.ReportItem");
var descriptorType = Require(asm, "Unity.ProjectAuditor.Editor.Descriptor");
AuditAsyncMethod = Require(ProjectAuditorType.GetMethods()
.FirstOrDefault(m => m.Name == "AuditAsync" && m.GetParameters().Length == 2), "ProjectAuditor.AuditAsync");
CategoriesField = Require(AnalysisParamsType.GetField("Categories"), "AnalysisParams.Categories");
CategoryElementType = Require(CategoriesField.FieldType.GetElementType(), "AnalysisParams.Categories element type");
CategoryWrapperCtor = CategoryElementType == IssueCategoryType
? null
: Require(CategoryElementType.GetConstructor(new[] { IssueCategoryType }),
$"{CategoryElementType.Name}(IssueCategory) constructor");
GetModulesMethod = ProjectAuditorType.GetMethod("GetModules",
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, null, Type.EmptyTypes, null);
OnCompletedField = Require(AnalysisParamsType.GetField("OnCompleted"), "AnalysisParams.OnCompleted");
GetAllIssuesMethod = Require(ReportType.GetMethod("GetAllIssues", Type.EmptyTypes), "Report.GetAllIssues");
IsIssueMethod = Require(reportItemType.GetMethod("IsIssue", Type.EmptyTypes), "ReportItem.IsIssue");
CategoryProperty = Require(reportItemType.GetProperty("Category"), "ReportItem.Category");
SeverityProperty = Require(reportItemType.GetProperty("Severity"), "ReportItem.Severity");
DescriptionProperty = Require(reportItemType.GetProperty("Description"), "ReportItem.Description");
RelativePathProperty = Require(reportItemType.GetProperty("RelativePath"), "ReportItem.RelativePath");
LineProperty = Require(reportItemType.GetProperty("Line"), "ReportItem.Line");
IdProperty = Require(reportItemType.GetProperty("Id"), "ReportItem.Id");
GetDescriptorMethod = Require(IdProperty.PropertyType.GetMethod("GetDescriptor", Type.EmptyTypes), "DescriptorId.GetDescriptor");
IdField = Require(descriptorType.GetField("Id"), "Descriptor.Id");
AreasField = Require(descriptorType.GetField("Areas"), "Descriptor.Areas");
RecommendationField = Require(descriptorType.GetField("Recommendation"), "Descriptor.Recommendation");
}
static Type Require(Assembly asm, string fullName)
{
return Require(asm.GetType(fullName, throwOnError: false), fullName);
}
static T Require<T>(T member, string name) where T : class
{
if (member == null)
throw new MissingMemberException($"Project Auditor member not found: {name}");
return member;
}
}
/// <summary>Status/result payload for the <c>audit</c> / <c>audit_status</c> commands.</summary>
[Serializable]
class AuditStatus
{
[JsonProperty("status")]
public string Status { get; set; }
[JsonProperty("scanId", NullValueHandling = NullValueHandling.Ignore)]
public string ScanId { get; set; }
[JsonProperty("csvPath", NullValueHandling = NullValueHandling.Ignore)]
public string CsvPath { get; set; }
[JsonProperty("issueCount", NullValueHandling = NullValueHandling.Ignore)]
public int? IssueCount { get; set; }
[JsonProperty("error", NullValueHandling = NullValueHandling.Ignore)]
public string Error { get; set; }
[JsonProperty("message", NullValueHandling = NullValueHandling.Ignore)]
public string Message { get; set; }
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 16350a6f8b1204eeea6649d939abc4b8
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: