Initial commit: AIGame Unity client with URP, HybridCLR, YooAsset and tooling.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,422 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
|
||||
#if UNITY_EDITOR || (UNITY_STANDALONE && DEBUG)
|
||||
using UnityPipeline.Microsoft.CodeAnalysis;
|
||||
using UnityPipeline.Microsoft.CodeAnalysis.CSharp;
|
||||
using UnityPipeline.Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||
#endif
|
||||
|
||||
namespace Unity.Pipeline.Telemetry
|
||||
{
|
||||
/// <summary>
|
||||
/// Structural classification of an eval body. Matches the epic's usage buckets: the majority of
|
||||
/// eval calls are one-liners (a single method invoke / value read / flag set), which we tag as
|
||||
/// <see cref="SingleExpression"/>; anything with more than one statement (polling loops, bulk
|
||||
/// setup) is <see cref="Statements"/>.
|
||||
/// </summary>
|
||||
static class EvalClassification
|
||||
{
|
||||
public const string SingleExpression = "single-expression";
|
||||
public const string Statements = "statements";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of analysing one eval body: its structural <see cref="Classification"/> and the ranked
|
||||
/// API <see cref="Fingerprints"/> (top-level member-access paths) extracted from the syntax tree.
|
||||
/// </summary>
|
||||
struct EvalUsageAnalysis
|
||||
{
|
||||
public string Classification;
|
||||
public IReadOnlyList<string> Fingerprints;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts a privacy-preserving "fingerprint" of an eval body from its C# syntax tree, without
|
||||
/// storing the raw source (AUTHAPI-29). A fingerprint is the top-level member-access path of each
|
||||
/// access site — e.g. <c>AssetDatabase.Refresh</c>, <c>PlayerSettings.SetScriptingBackend</c>,
|
||||
/// <c>Object.FindFirstObjectByType<T></c>, <c>MatchManager.Instance.State</c> — which is
|
||||
/// enough to rank which APIs agents actually reach for (steering the eval-displacement backlog,
|
||||
/// AUTHAPI-24).
|
||||
///
|
||||
/// PRIVACY CONTRACT — strict whitelist rendering. The only source text that can ever appear in a
|
||||
/// fingerprint is an identifier, type name, or member name taken from the syntax tree. Literals,
|
||||
/// interpolated strings, and every expression form not on the whitelist are replaced by the
|
||||
/// neutral <see cref="Placeholder"/> (<c>"secret".Length</c> records as <c><expr>.Length</c>);
|
||||
/// object creations render as <c>new TypeName</c> (constructor arguments never appear); casts
|
||||
/// unwrap to the inner expression (<c>((Foo)o).Bar</c> records as <c>o.Bar</c>); generic type
|
||||
/// arguments are normalised to arity placeholders (<c><T></c>). A path that would be nothing
|
||||
/// but the placeholder carries no API information and is dropped entirely.
|
||||
///
|
||||
/// Conditional-access chains (<c>a?.B.C()</c>) are fingerprinted with <c>?.</c> normalised to
|
||||
/// <c>.</c>, so they rank together with their unconditional form.
|
||||
///
|
||||
/// Known blind spots — the ranking UNDERCOUNTS these forms (deliberate, to keep the walker
|
||||
/// simple and re-entrancy-free); consumers of the report should not read absence as proof of
|
||||
/// non-use:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>Arguments nested inside a chain's receiver call are not walked: in
|
||||
/// <c>Configure(Settings.Load()).Apply()</c> only <c>Configure().Apply</c> is recorded — the
|
||||
/// callee chain is not re-entered, so <c>Settings.Load</c> is missed.</description></item>
|
||||
/// <item><description>A bare object creation with no trailing member access (<c>new Foo(...);</c> as a whole
|
||||
/// statement) produces no fingerprint.</description></item>
|
||||
/// <item><description>Bodies nested deeper than the walker's depth guard are abandoned mid-walk.</description></item>
|
||||
/// </list>
|
||||
///
|
||||
/// Threading: pure Roslyn + BCL — no Unity API calls — so <see cref="Analyze"/> is safe to run
|
||||
/// off the main thread. The telemetry writer relies on this and runs it on a background task
|
||||
/// from a snapshot captured at eval completion.
|
||||
///
|
||||
/// This is a SEPARATE parse from the one <c>EvalCodeCompiler</c> does to actually compile and
|
||||
/// run the eval, by necessity rather than oversight: that parse is of a generated wrapper
|
||||
/// source (the raw body embedded inside a <c>class PipelineEval_<id> { ... Execute() {
|
||||
/// ... } }</c>) under <c>SourceCodeKind.Regular</c> with the project's <c>#if</c> defines, not
|
||||
/// of the raw body alone as a top-level <c>SourceCodeKind.Script</c> compilation unit the way
|
||||
/// <see cref="Analyze"/> expects it. Sharing that tree would mean threading it out of
|
||||
/// <c>RoslynCompilationService</c> (on both the success AND failure path — failures still get
|
||||
/// recorded) and locating the wrapped <c>Execute()</c> body inside it, coupling this class to
|
||||
/// the wrapper's exact shape. Not worth it today: the extra parse already runs off the eval
|
||||
/// response path (see Threading above), so it costs background CPU, not eval latency.
|
||||
/// </summary>
|
||||
static class EvalUsageFingerprinter
|
||||
{
|
||||
/// <summary>
|
||||
/// Upper bound on fingerprints kept per record, so a pathological body can't bloat the log.
|
||||
/// Truncation keeps the FIRST N access sites in tree (source) order, not the most frequent —
|
||||
/// acceptable because bodies with more than 32 access sites are rare outliers.
|
||||
/// </summary>
|
||||
public const int MaxFingerprints = 32;
|
||||
|
||||
/// <summary>
|
||||
/// Neutral stand-in for any expression not on the rendering whitelist (literals, interpolated
|
||||
/// strings, arithmetic, lambdas, …). Guarantees no raw source text leaks into a fingerprint.
|
||||
/// </summary>
|
||||
public const string Placeholder = "<expr>";
|
||||
|
||||
#if UNITY_EDITOR || (UNITY_STANDALONE && DEBUG)
|
||||
|
||||
private static readonly CSharpParseOptions m_ScriptOptions =
|
||||
new CSharpParseOptions(kind: SourceCodeKind.Script);
|
||||
|
||||
/// <summary>
|
||||
/// Analyse an eval body: classify it and extract its API fingerprints. Never throws — a body
|
||||
/// that fails to parse yields an empty fingerprint list and a best-effort classification.
|
||||
/// </summary>
|
||||
public static EvalUsageAnalysis Analyze(string code)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(code))
|
||||
return new EvalUsageAnalysis { Classification = EvalClassification.Statements, Fingerprints = new List<string>() };
|
||||
|
||||
CompilationUnitSyntax root;
|
||||
try
|
||||
{
|
||||
root = CSharpSyntaxTree.ParseText(code, m_ScriptOptions).GetCompilationUnitRoot();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new EvalUsageAnalysis { Classification = EvalClassification.Statements, Fingerprints = new List<string>() };
|
||||
}
|
||||
|
||||
return new EvalUsageAnalysis
|
||||
{
|
||||
Classification = Classify(root),
|
||||
Fingerprints = ExtractFingerprints(root)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Convenience overload used by tests: fingerprints only.</summary>
|
||||
public static IReadOnlyList<string> ExtractFingerprints(string code) => Analyze(code).Fingerprints;
|
||||
|
||||
/// <summary>Convenience overload used by tests: classification only.</summary>
|
||||
public static string Classify(string code) => Analyze(code).Classification;
|
||||
|
||||
/// <summary>
|
||||
/// One-liner iff the body is exactly one statement that is an expression or a value-returning
|
||||
/// return. A lone loop/if/block (e.g. a polling loop) is a statement body, not a one-liner.
|
||||
/// </summary>
|
||||
private static string Classify(CompilationUnitSyntax root)
|
||||
{
|
||||
var statements = root.Members
|
||||
.OfType<GlobalStatementSyntax>()
|
||||
.Select(m => m.Statement)
|
||||
.ToList();
|
||||
|
||||
if (statements.Count == 1)
|
||||
{
|
||||
var only = statements[0];
|
||||
if (only is ExpressionStatementSyntax ||
|
||||
(only is ReturnStatementSyntax ret && ret.Expression != null))
|
||||
{
|
||||
return EvalClassification.SingleExpression;
|
||||
}
|
||||
}
|
||||
|
||||
return EvalClassification.Statements;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ExtractFingerprints(CompilationUnitSyntax root)
|
||||
{
|
||||
var walker = new FingerprintWalker();
|
||||
walker.Visit(root);
|
||||
// Take() keeps the first MaxFingerprints access sites in tree (source) order — see the
|
||||
// MaxFingerprints doc for why that truncation policy is acceptable.
|
||||
return walker.Fingerprints.Count > MaxFingerprints
|
||||
? walker.Fingerprints.Take(MaxFingerprints).ToList()
|
||||
: walker.Fingerprints;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Walks the syntax tree collecting one fingerprint per top-level access site: an invocation's
|
||||
/// callee (<c>A.B.C(...)</c> → <c>A.B.C</c>) or a member-access chain that is not itself the
|
||||
/// head of an enclosing call/access (<c>A.Instance.State</c>). Nested access sites inside call
|
||||
/// arguments are still collected (they are their own sites); the callee chain is not re-counted.
|
||||
/// A depth counter abandons subtrees nested past <see cref="MaxDepth"/> so a pathological body
|
||||
/// (machine-generated nesting) can't blow the stack — fingerprinting is best-effort.
|
||||
/// </summary>
|
||||
private sealed class FingerprintWalker : CSharpSyntaxWalker
|
||||
{
|
||||
/// <summary>Maximum syntax depth walked; deeper subtrees are abandoned.</summary>
|
||||
private const int MaxDepth = 64;
|
||||
|
||||
public readonly List<string> Fingerprints = new List<string>();
|
||||
private int m_Depth;
|
||||
|
||||
public override void Visit(SyntaxNode node)
|
||||
{
|
||||
if (m_Depth >= MaxDepth)
|
||||
return; // Abandon this subtree — best-effort telemetry, never a liability.
|
||||
|
||||
m_Depth++;
|
||||
try
|
||||
{
|
||||
base.Visit(node);
|
||||
}
|
||||
finally
|
||||
{
|
||||
m_Depth--;
|
||||
}
|
||||
}
|
||||
|
||||
public override void VisitInvocationExpression(InvocationExpressionSyntax node)
|
||||
{
|
||||
AddChainHeadFingerprint(node, node.Expression, () => RenderPath(node.Expression));
|
||||
|
||||
// Deliberately do NOT recurse into the callee chain (already captured above), but do
|
||||
// walk the arguments so nested calls/reads passed as arguments are captured too.
|
||||
VisitArgumentsIfPresent(node.ArgumentList);
|
||||
}
|
||||
|
||||
public override void VisitMemberAccessExpression(MemberAccessExpressionSyntax node)
|
||||
{
|
||||
if (IsChainHead(node))
|
||||
Add(RenderPath(node));
|
||||
|
||||
// Continue into the expression side so nested access sites are still discovered;
|
||||
// inner chain members are guarded by IsChainHead and won't be double-counted.
|
||||
base.VisitMemberAccessExpression(node);
|
||||
}
|
||||
|
||||
public override void VisitElementAccessExpression(ElementAccessExpressionSyntax node)
|
||||
{
|
||||
// A bare element-access chain head (cache[key]) previously produced NO fingerprint
|
||||
// at all — no member access for the walker to see — while the conditional form
|
||||
// (cache?[key]) records "cache[]": the two must rank together, so bare element
|
||||
// access on a member/name receiver is fingerprinted the same way.
|
||||
AddChainHeadFingerprint(node, node.Expression, () => RenderPath(node));
|
||||
|
||||
// Walk the bracket arguments so nested reads/calls inside the indexer are captured.
|
||||
VisitArgumentsIfPresent(node.ArgumentList);
|
||||
}
|
||||
|
||||
public override void VisitConditionalAccessExpression(ConditionalAccessExpressionSyntax node)
|
||||
{
|
||||
// The whole ?. chain is ONE fingerprint (RenderPath normalizes '?.' to '.'):
|
||||
// descending into its parts would record the receiver and the bound members as
|
||||
// separate (and misleading) entries.
|
||||
if (IsChainHead(node))
|
||||
Add(RenderPath(node));
|
||||
|
||||
// Parity with VisitInvocationExpression/VisitElementAccessExpression: only the head
|
||||
// access's OWN argument list is walked, scoped to WhenNotNull (not the receiver
|
||||
// before '?.') — the receiver's nested-call arguments are the same documented
|
||||
// blind spot the unconditional forms accept, not something '?.' should uncover.
|
||||
// Only the outermost argument lists are visited — the walker recurses within.
|
||||
// Covers both parenthesized (ArgumentListSyntax) and bracketed
|
||||
// (BracketedArgumentListSyntax) forms, so an indexer inside a conditional chain
|
||||
// (a?.b[SomeCall()]) is captured too — a bracketed list was previously invisible
|
||||
// to this sweep, silently dropping SomeCall.
|
||||
if (node.WhenNotNull != null)
|
||||
{
|
||||
foreach (var argumentList in node.WhenNotNull
|
||||
.DescendantNodes(n => !(n is ArgumentListSyntax) && !(n is BracketedArgumentListSyntax))
|
||||
.Where(n => n is ArgumentListSyntax || n is BracketedArgumentListSyntax))
|
||||
Visit(argumentList);
|
||||
}
|
||||
}
|
||||
|
||||
// Shared shape between VisitInvocationExpression and VisitElementAccessExpression: add
|
||||
// the rendered path only for a chain head whose receiver is renderable, computing the
|
||||
// path lazily so non-chain-head nodes (the overwhelming majority visited) skip RenderPath
|
||||
// entirely instead of just discarding its result.
|
||||
private void AddChainHeadFingerprint(SyntaxNode node, ExpressionSyntax receiver, System.Func<string> render)
|
||||
{
|
||||
if (IsChainHead(node) && IsMemberOrName(receiver))
|
||||
Add(render());
|
||||
}
|
||||
|
||||
private void VisitArgumentsIfPresent(BaseArgumentListSyntax argumentList)
|
||||
{
|
||||
if (argumentList != null)
|
||||
Visit(argumentList);
|
||||
}
|
||||
|
||||
private void Add(string path)
|
||||
{
|
||||
// A path that is nothing but the neutral placeholder carries no API information —
|
||||
// drop the fingerprint rather than pollute the ranking with "<expr>" noise.
|
||||
if (string.IsNullOrEmpty(path) || path == Placeholder)
|
||||
return;
|
||||
Fingerprints.Add(path);
|
||||
}
|
||||
|
||||
// A node is the head of its access/call chain when its parent is not another link of the
|
||||
// same chain (member access, invocation, or element access).
|
||||
private static bool IsChainHead(SyntaxNode node)
|
||||
{
|
||||
var parent = node.Parent;
|
||||
// See through parentheses: in (a?.b).c the conditional is part of the enclosing
|
||||
// chain, not a head of its own.
|
||||
while (parent is ParenthesizedExpressionSyntax paren)
|
||||
parent = paren.Parent;
|
||||
return !(parent is MemberAccessExpressionSyntax)
|
||||
&& !(parent is InvocationExpressionSyntax)
|
||||
&& !(parent is ElementAccessExpressionSyntax);
|
||||
}
|
||||
|
||||
// Also accepts a call or parenthesized expression as the receiver: RenderPath already
|
||||
// knows how to render both (GetHandler()() -> "GetHandler()", (A.B)() -> "A.B"), so
|
||||
// gating them out here was dropping the whole access site instead of rendering it —
|
||||
// not even the placeholder, since Add() was never reached.
|
||||
private static bool IsMemberOrName(ExpressionSyntax expr) =>
|
||||
expr is MemberAccessExpressionSyntax || expr is IdentifierNameSyntax || expr is GenericNameSyntax
|
||||
|| expr is InvocationExpressionSyntax || expr is ParenthesizedExpressionSyntax;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Render an expression to its fingerprint path: dotted identifiers, with generic method type
|
||||
/// arguments normalised to arity placeholders (<c><T></c>, <c><T1, T2></c>) so
|
||||
/// <c>FindFirstObjectByType<Foo></c> and <c><Bar></c> rank as one API.
|
||||
///
|
||||
/// STRICT WHITELIST (the privacy contract of the class doc): only the expression forms listed
|
||||
/// below render source text, and each renders identifiers/type/member names exclusively.
|
||||
/// Everything else — literals, interpolated strings, arithmetic, lambdas, conditionals, … —
|
||||
/// falls through to the neutral <see cref="Placeholder"/> so no raw source can ever leak into
|
||||
/// a fingerprint. Casts unwrap to the inner expression; object creations render as
|
||||
/// <c>new TypeName</c>. Rendering recursion is bounded by <paramref name="depth"/> as a second
|
||||
/// layer of defence against pathological nesting (chains hang below their head, so they are
|
||||
/// not covered by the walker's descent guard).
|
||||
/// </summary>
|
||||
private static string RenderPath(ExpressionSyntax expr) => RenderPath(expr, 0);
|
||||
|
||||
private const int MaxRenderDepth = 64;
|
||||
|
||||
private static string RenderPath(ExpressionSyntax expr, int depth)
|
||||
{
|
||||
if (depth >= MaxRenderDepth)
|
||||
return Placeholder;
|
||||
depth++;
|
||||
|
||||
switch (expr)
|
||||
{
|
||||
case IdentifierNameSyntax id:
|
||||
return id.Identifier.Text;
|
||||
case GenericNameSyntax gn:
|
||||
return gn.Identifier.Text + RenderArity(gn.TypeArgumentList.Arguments.Count);
|
||||
case QualifiedNameSyntax qn: // Namespace-qualified type in type position, e.g. new UnityEngine.GameObject
|
||||
return RenderPath(qn.Left, depth) + "." + RenderSimpleName(qn.Right);
|
||||
case AliasQualifiedNameSyntax aq: // global::UnityEngine → keep the name, drop the alias
|
||||
return RenderSimpleName(aq.Name);
|
||||
case MemberAccessExpressionSyntax ma:
|
||||
return RenderPath(ma.Expression, depth) + "." + RenderSimpleName(ma.Name);
|
||||
case InvocationExpressionSyntax inv:
|
||||
return RenderPath(inv.Expression, depth) + "()";
|
||||
case ElementAccessExpressionSyntax ea:
|
||||
return RenderPath(ea.Expression, depth) + "[]";
|
||||
case ParenthesizedExpressionSyntax pe:
|
||||
return RenderPath(pe.Expression, depth);
|
||||
case CastExpressionSyntax ce: // ((Foo)o).Bar → o.Bar: unwrap to the casted expression
|
||||
return RenderPath(ce.Expression, depth);
|
||||
case ObjectCreationExpressionSyntax oc: // new GameObject("x").AddComponent<T> → new GameObject.AddComponent<T>
|
||||
return "new " + RenderPath(oc.Type, depth);
|
||||
case PredefinedTypeSyntax pt:
|
||||
return pt.Keyword.Text;
|
||||
case ThisExpressionSyntax _:
|
||||
return "this";
|
||||
case BaseExpressionSyntax _:
|
||||
return "base";
|
||||
case ConditionalAccessExpressionSyntax cond:
|
||||
// a?.B.C renders as a.B.C: '?.' is null-propagation noise for ranking
|
||||
// purposes. Rendering only the bound member (the old fall-through via the bare
|
||||
// MemberBinding) silently dropped the receiver, producing a colliding entry
|
||||
// ("State.Value" for MatchManager.Instance?.State.Value). When the chain ends
|
||||
// in a call, fingerprint the CALLEE (a?.B() -> a.B) so it ranks together with
|
||||
// its unconditional form — the walker uses the same callee convention for
|
||||
// plain invocation heads.
|
||||
var conditionalTail = cond.WhenNotNull is InvocationExpressionSyntax topCall
|
||||
? topCall.Expression
|
||||
: cond.WhenNotNull;
|
||||
var tailRendered = RenderPath(conditionalTail, depth);
|
||||
// An element-binding tail (cache?[key] -> "[]") concatenates WITHOUT a dot so
|
||||
// the fingerprint matches the unconditional cache[key] form ("cache[]").
|
||||
return tailRendered.Length > 0 && tailRendered[0] == '['
|
||||
? RenderPath(cond.Expression, depth) + tailRendered
|
||||
: RenderPath(cond.Expression, depth) + "." + tailRendered;
|
||||
case MemberBindingExpressionSyntax mb:
|
||||
return RenderSimpleName(mb.Name);
|
||||
case ElementBindingExpressionSyntax _: // the ?[...] binding of a conditional chain
|
||||
return "[]";
|
||||
default:
|
||||
// NOT a raw ToString(): anything off the whitelist (string/char/numeric literals,
|
||||
// interpolated strings, lambdas, arithmetic, …) is reduced to the neutral
|
||||
// placeholder so source text can never leak into the telemetry log (AUTHAPI-29).
|
||||
return Placeholder;
|
||||
}
|
||||
}
|
||||
|
||||
private static string RenderSimpleName(SimpleNameSyntax name)
|
||||
{
|
||||
if (name is GenericNameSyntax gn)
|
||||
return gn.Identifier.Text + RenderArity(gn.TypeArgumentList.Arguments.Count);
|
||||
return name.Identifier.Text;
|
||||
}
|
||||
|
||||
private static string RenderArity(int count)
|
||||
{
|
||||
if (count <= 0)
|
||||
return string.Empty;
|
||||
if (count == 1)
|
||||
return "<T>";
|
||||
|
||||
var sb = new StringBuilder("<");
|
||||
for (var i = 1; i <= count; i++)
|
||||
{
|
||||
if (i > 1)
|
||||
sb.Append(", ");
|
||||
sb.Append("T").Append(i);
|
||||
}
|
||||
sb.Append(">");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
#else
|
||||
/// <summary>Runtime compilation (and therefore eval) is unavailable on this platform.</summary>
|
||||
public static EvalUsageAnalysis Analyze(string code) =>
|
||||
new EvalUsageAnalysis { Classification = EvalClassification.Statements, Fingerprints = new List<string>() };
|
||||
|
||||
public static IReadOnlyList<string> ExtractFingerprints(string code) => new List<string>();
|
||||
|
||||
public static string Classify(string code) => EvalClassification.Statements;
|
||||
#endif
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user