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,786 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Unity.Pipeline;
|
||||
using Unity.Pipeline.Editor.Authoring;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using Object = UnityEngine.Object;
|
||||
|
||||
namespace Unity.Pipeline.Editor.Commands
|
||||
{
|
||||
/// <summary>
|
||||
/// Thrown when a wait condition cannot be resolved or evaluated (bad member path, unresolved
|
||||
/// target, unreadable member, un-coercible operand). Distinct from a timeout: the wait reports
|
||||
/// these as a <c>failed</c> result with this message, never as a spurious <c>timedOut</c>
|
||||
/// (unless <c>tolerate_missing</c> is set, in which case resolution failures are retried as
|
||||
/// condition-not-met until the wait's own budget expires) — UNLESS <see cref="IsPermanent"/> is
|
||||
/// set, for a failure no amount of retrying can ever resolve (a malformed member path, a
|
||||
/// findType naming a non-<see cref="UnityEngine.Object"/> type, a member that does not exist on
|
||||
/// the resolved type): those fail fast even under tolerate_missing, whose contract is "does not
|
||||
/// exist YET", not "will never exist".
|
||||
/// </summary>
|
||||
internal sealed class WaitEvaluationException : Exception
|
||||
{
|
||||
internal bool IsPermanent { get; }
|
||||
|
||||
public WaitEvaluationException(string message, bool isPermanent = false) : base(message)
|
||||
{
|
||||
IsPermanent = isPermanent;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cached resolution state for one wait's condition. The expensive part of member resolution —
|
||||
/// <see cref="WaitConditionEvaluator.ResolveType"/>'s whole-appdomain type scan — is cached here
|
||||
/// instead of running once per poll: a successful scan never repeats, and a failed one is only
|
||||
/// retried after the loaded-assembly set changes (relevant to <c>tolerate_missing</c> waits on
|
||||
/// not-yet-loadable paths). A <c>findType</c> instance binds once and re-scans
|
||||
/// only when the bound instance is destroyed; an explicit <c>target</c> handle is re-resolved
|
||||
/// every poll, because it can legitimately change mid-wait. Member lookups are
|
||||
/// memoized per encountered runtime type, so steady-state polls perform dictionary hits, not
|
||||
/// reflection walks.
|
||||
///
|
||||
/// Created when the wait is registered (pure reflection state — safe off the main thread);
|
||||
/// afterwards it is only touched by <see cref="WaitJob.PollOnce"/> on the main thread.
|
||||
/// </summary>
|
||||
internal sealed class WaitConditionPlan
|
||||
{
|
||||
internal readonly WaitConditionInput Condition;
|
||||
internal readonly string[] Segments;
|
||||
|
||||
/// <summary>Resolved type of a static member path's leading prefix (cached after the first successful scan).</summary>
|
||||
internal Type StaticType;
|
||||
|
||||
/// <summary>Number of leading segments consumed by <see cref="StaticType"/>.</summary>
|
||||
internal int StaticSplit;
|
||||
|
||||
/// <summary>Resolved <c>findType</c> (cached after the first successful scan).</summary>
|
||||
internal Type FindTypeResolved;
|
||||
|
||||
/// <summary>The live instance a <c>findType</c> wait is bound to; re-resolved only when it
|
||||
/// has been destroyed (never silently re-bound while alive).</summary>
|
||||
internal Object BoundInstance;
|
||||
|
||||
/// <summary>The ambiguity note captured when <see cref="BoundInstance"/> was bound,
|
||||
/// re-surfaced on every poll for the wait result.</summary>
|
||||
internal string BoundNote;
|
||||
|
||||
/// <summary>Ambiguity note from type resolution (a bare type name matching several loaded
|
||||
/// types), captured once when the scan resolves and re-surfaced on every poll.</summary>
|
||||
internal string TypeNote;
|
||||
|
||||
/// <summary>
|
||||
/// Signature of the loaded-assembly SET observed by the last FAILED type-resolution scan
|
||||
/// (combines identity hashes, not just <c>Count</c> — an unload-and-load-of-a-different-
|
||||
/// assembly can leave the count unchanged while still making a previously-unresolvable name
|
||||
/// resolvable). A failed scan is only re-attempted once this changes: within a wait's
|
||||
/// lifetime (waits don't survive domain reloads) the only way an unresolvable name becomes
|
||||
/// resolvable is a newly loaded assembly (e.g. <c>run_script</c>/<c>eval</c> loading one).
|
||||
/// Without this, a <c>tolerate_missing</c> wait on a never-resolving path would repeat the
|
||||
/// whole-appdomain scan every poll. Null means "never scanned" — distinct from any signature
|
||||
/// value, including a coincidental 0 — so the very first call always rescans.
|
||||
/// </summary>
|
||||
internal long? FailedScanAssemblySignature;
|
||||
|
||||
/// <summary>Non-fatal note from the latest resolution (e.g. an ambiguous findType match), surfaced on the wait result.</summary>
|
||||
internal string ResolutionNote;
|
||||
|
||||
private readonly Dictionary<(Type Lookup, int Segment, bool IsStatic), MemberInfo> m_Members =
|
||||
new Dictionary<(Type, int, bool), MemberInfo>();
|
||||
|
||||
internal WaitConditionPlan(WaitConditionInput condition)
|
||||
{
|
||||
Condition = condition;
|
||||
Segments = (condition?.Member ?? string.Empty).Trim().Split('.');
|
||||
}
|
||||
|
||||
internal bool TryGetCachedMember(Type lookup, int segment, bool isStatic, out MemberInfo member)
|
||||
=> m_Members.TryGetValue((lookup, segment, isStatic), out member);
|
||||
|
||||
internal void CacheMember(Type lookup, int segment, bool isStatic, MemberInfo member)
|
||||
=> m_Members[(lookup, segment, isStatic)] = member;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads and compares a member value for the <c>wait_for</c> command (AUTHAPI-25).
|
||||
///
|
||||
/// Member resolution mirrors the rules AUTHAPI-16's invoke/query surface will use (that shared
|
||||
/// resolver does not exist yet, so this is a self-contained implementation the two can later
|
||||
/// converge on — see PR notes):
|
||||
/// - <c>target</c> (an <see cref="Unity.Pipeline.Models.ObjectRef"/>) resolves via
|
||||
/// <see cref="ObjectResolver"/>; the member path then walks instance members from it.
|
||||
/// - <c>findType</c> resolves a UnityEngine.Object type and walks from a live (non-persistent,
|
||||
/// unhidden) instance — prefab assets and hidden editor internals are filtered out, scene
|
||||
/// instances are preferred, and an ambiguous match binds deterministically to the lowest
|
||||
/// instance id with a note on the result.
|
||||
/// - Otherwise the member path is a fully-qualified static path (<c>Ns.Type.StaticMember.Nested</c>).
|
||||
///
|
||||
/// Only readable fields and properties are resolved — write-only members and methods are rejected,
|
||||
/// so the surface is read-only by construction.
|
||||
/// </summary>
|
||||
internal static class WaitConditionEvaluator
|
||||
{
|
||||
// PUBLIC members only, deliberately: wait conditions observe the same surface any C#
|
||||
// consumer of the API sees. Reading private/internal state by name would let the `wait`
|
||||
// tag expose internals below eval's capability gate (a host can disable eval but allow
|
||||
// wait) — anyone who genuinely needs private state has eval/run_script on this server.
|
||||
private const BindingFlags MemberFlags = BindingFlags.Public;
|
||||
|
||||
/// <summary>Convenience overload for one-shot reads (tests): builds a throwaway plan.</summary>
|
||||
internal static object ReadMember(WaitConditionInput condition, out Type memberType)
|
||||
=> ReadMember(new WaitConditionPlan(condition), out memberType);
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the plan's member path and return the current value plus its declared type (used
|
||||
/// to coerce the comparison operand). Type resolution results are cached on
|
||||
/// <paramref name="plan"/> so repeated polls never repeat the appdomain scan; only the live
|
||||
/// instance (target / findType) is re-resolved per call. Throws
|
||||
/// <see cref="WaitEvaluationException"/> on any resolution failure.
|
||||
/// </summary>
|
||||
internal static object ReadMember(WaitConditionPlan plan, out Type memberType)
|
||||
{
|
||||
memberType = typeof(object);
|
||||
if (plan == null || plan.Condition == null)
|
||||
throw new WaitEvaluationException("condition is required.", isPermanent: true);
|
||||
|
||||
plan.ResolutionNote = null;
|
||||
var condition = plan.Condition;
|
||||
var path = (condition.Member ?? string.Empty).Trim();
|
||||
if (path.Length == 0)
|
||||
throw new WaitEvaluationException("condition.member is required.", isPermanent: true);
|
||||
|
||||
var segments = plan.Segments;
|
||||
|
||||
object current;
|
||||
Type staticType = null;
|
||||
int start;
|
||||
var isFindTypeInstance = false;
|
||||
|
||||
if (condition.Target != null && !condition.Target.IsEmpty)
|
||||
{
|
||||
// A malformed globalId is a property of the input STRING itself — no amount of
|
||||
// retrying ever makes it parseable, unlike every other TryResolve failure below
|
||||
// (empty/unknown/not-yet-loaded), which can legitimately resolve once the
|
||||
// referenced object appears — the same "wait for a spawned object" story `target`
|
||||
// shares with `findType`. Checked locally rather than widening ObjectResolver's
|
||||
// public TryResolve (20+ call sites) with a permanence flag it has no other use for.
|
||||
if (!string.IsNullOrEmpty(condition.Target.GlobalId) &&
|
||||
!GlobalObjectId.TryParse(condition.Target.GlobalId, out _))
|
||||
throw new WaitEvaluationException(
|
||||
$"Invalid condition.target.globalId '{condition.Target.GlobalId}'.", isPermanent: true);
|
||||
|
||||
// Re-resolved every poll on purpose: the referenced object can appear/change mid-wait.
|
||||
if (!ObjectResolver.TryResolve(condition.Target, out var obj, out var err))
|
||||
throw new WaitEvaluationException($"Could not resolve condition.target: {err}");
|
||||
current = obj;
|
||||
start = 0;
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(condition.FindType))
|
||||
{
|
||||
// The type scan is cached (failures too, until a new assembly loads); the instance
|
||||
// lookup stays per-poll (it can legitimately change).
|
||||
var type = plan.FindTypeResolved;
|
||||
if (type == null && ShouldRescan(plan))
|
||||
{
|
||||
type = plan.FindTypeResolved = ResolveType(condition.FindType, out var typeNote);
|
||||
if (type != null)
|
||||
plan.TypeNote = typeNote;
|
||||
}
|
||||
if (type == null)
|
||||
throw new WaitEvaluationException($"Unknown findType '{condition.FindType}'.");
|
||||
if (plan.BoundInstance is Object boundInstance && boundInstance)
|
||||
{
|
||||
// Bound once, reused until destroyed: re-scanning every loaded object on every
|
||||
// poll (up to ~60/s at the minimum interval) scales with scene size for no
|
||||
// benefit — the bind is deterministic anyway. Destruction of the bound
|
||||
// instance triggers a re-scan, so the spawn-wait and replaced-after-destroy
|
||||
// stories keep working.
|
||||
current = boundInstance;
|
||||
}
|
||||
else
|
||||
{
|
||||
current = FindInstance(type, out var note)
|
||||
?? throw new WaitEvaluationException($"No live instance of type '{type.FullName}' (persistent assets and hidden objects are excluded).");
|
||||
plan.BoundInstance = (Object)current;
|
||||
plan.BoundNote = note;
|
||||
}
|
||||
plan.ResolutionNote = plan.BoundNote;
|
||||
start = 0;
|
||||
isFindTypeInstance = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Static path: find the longest leading dotted prefix that resolves to a type,
|
||||
// leaving at least one trailing member segment. Resolved once, then cached.
|
||||
if (segments.Length < 2)
|
||||
// A property of the input STRING, not of anything that could load later —
|
||||
// permanent regardless of tolerate_missing.
|
||||
throw new WaitEvaluationException(
|
||||
$"Static member path '{path}' must be 'Type.Member'; supply 'target' or 'findType' for instance members.",
|
||||
isPermanent: true);
|
||||
|
||||
if (plan.StaticType == null && ShouldRescan(plan))
|
||||
{
|
||||
for (var k = segments.Length - 1; k >= 1; k--)
|
||||
{
|
||||
var prefix = string.Join(".", segments, 0, k);
|
||||
var t = ResolveType(prefix, out var typeNote);
|
||||
if (t != null)
|
||||
{
|
||||
plan.StaticType = t;
|
||||
plan.StaticSplit = k;
|
||||
plan.TypeNote = typeNote;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (plan.StaticType == null)
|
||||
throw new WaitEvaluationException(
|
||||
$"Could not resolve a type from static path '{path}'. Supply 'target' or 'findType' for instance members.");
|
||||
|
||||
staticType = plan.StaticType;
|
||||
current = null;
|
||||
start = plan.StaticSplit;
|
||||
}
|
||||
|
||||
object value = current;
|
||||
var firstStatic = current == null;
|
||||
|
||||
for (var i = start; i < segments.Length; i++)
|
||||
{
|
||||
var segment = segments[i];
|
||||
var isStatic = value == null && firstStatic;
|
||||
|
||||
if (value == null && !firstStatic)
|
||||
throw new WaitEvaluationException($"Member path '{path}' hit null before segment '{segment}'.");
|
||||
|
||||
// An object-typed `== null` misses UnityEngine.Object's fake-null: a destroyed
|
||||
// object would pass and the next getter would throw a raw
|
||||
// MissingReferenceException instead of a structured evaluation failure.
|
||||
if (value is Object destroyedCheck && !destroyedCheck)
|
||||
throw new WaitEvaluationException(
|
||||
$"Member path '{path}' hit a destroyed Unity object before segment '{segment}'.");
|
||||
|
||||
var lookup = value?.GetType() ?? staticType;
|
||||
if (lookup == null)
|
||||
throw new WaitEvaluationException($"Could not determine a type to resolve segment '{segment}'.", isPermanent: true);
|
||||
|
||||
// TryReadMember's failures (no such readable member, write-only, indexer) are
|
||||
// ordinarily properties of the resolved TYPE's shape, which cannot change without
|
||||
// a domain reload — and a wait does not survive one. Permanent: never worth
|
||||
// retrying. EXCEPT the very first segment off a findType-bound instance: which
|
||||
// concrete subtype got bound is a deterministic-but-arbitrary tie-break among
|
||||
// whichever live candidates exist right now (FindInstance's lowest-instance-id
|
||||
// pick), not a fixed property of the wait's target the way `target`/static paths
|
||||
// are — a sibling instance of a different subtype (already alive, or not yet
|
||||
// spawned) might have this member even though the one we happened to bind doesn't.
|
||||
// Failing fast here would defeat tolerate_missing's "wait for the right kind of
|
||||
// spawned object" story; retrying until timeout is the honest answer instead.
|
||||
var ambiguousCandidate = isFindTypeInstance && i == start;
|
||||
if (!TryReadMember(plan, i, lookup, segment, isStatic, value, out var next, out var nextType, out var readError))
|
||||
throw new WaitEvaluationException(readError, isPermanent: !ambiguousCandidate);
|
||||
|
||||
value = next;
|
||||
memberType = nextType;
|
||||
firstStatic = false;
|
||||
}
|
||||
|
||||
if (plan.TypeNote != null)
|
||||
plan.ResolutionNote = plan.ResolutionNote == null
|
||||
? plan.TypeNote
|
||||
: plan.TypeNote + " " + plan.ResolutionNote;
|
||||
|
||||
// A destroyed Unity object as the TERMINAL value is observed as null — Unity's own
|
||||
// == semantics — so a condition can express "wait until destroyed" as
|
||||
// { op: "equals", value: null }. (Traversal THROUGH a destroyed object still throws
|
||||
// the structured error above; only the leaf value normalizes.)
|
||||
if (value is Object terminalUnityObject && !terminalUnityObject)
|
||||
value = null;
|
||||
|
||||
if (memberType == typeof(object) && value != null)
|
||||
memberType = value.GetType();
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evaluate an operator against a freshly read <paramref name="current"/> value. For
|
||||
/// <c>changed</c>, compares against <paramref name="initialValue"/> (the first observed value)
|
||||
/// and never needs an operand. All other operators coerce the JSON operand to
|
||||
/// <paramref name="memberType"/> first. Sets <paramref name="error"/> (and returns false) when
|
||||
/// the operator or operand cannot be applied.
|
||||
/// </summary>
|
||||
internal static bool Evaluate(string op, object current, object operandRaw, Type memberType,
|
||||
object initialValue, bool haveInitial, out string error)
|
||||
{
|
||||
error = null;
|
||||
// Mirrors WaitForCommands.ValidateCondition's normalization exactly: an empty string is
|
||||
// as much "not specified" as a null, and the two must agree, or a condition that passed
|
||||
// validation as a legal equals-wait (op:"") would fail every poll on "Unknown op ''".
|
||||
op = string.IsNullOrEmpty(op) ? "equals" : op.Trim();
|
||||
|
||||
if (op == "changed")
|
||||
return haveInitial && !ValuesEqual(current, initialValue);
|
||||
|
||||
var operandIsNull = operandRaw == null
|
||||
|| (operandRaw is JToken nullToken && nullToken.Type == JTokenType.Null);
|
||||
if (operandIsNull && (op == "equals" || op == "notEquals"))
|
||||
{
|
||||
// A null operand is meaningful for (in)equality — including "wait until
|
||||
// destroyed", since ReadMember observes a destroyed leaf as null. Bypass coercion
|
||||
// (there is no type to coerce null to) and compare null-ness directly,
|
||||
// fake-null-aware for direct evaluator calls.
|
||||
var isNull = current == null || (current is Object unityCurrent && !unityCurrent);
|
||||
return op == "equals" ? isNull : !isNull;
|
||||
}
|
||||
|
||||
if (!TryCoerceOperand(operandRaw, memberType, out var operand, out error))
|
||||
return false;
|
||||
|
||||
switch (op)
|
||||
{
|
||||
case "equals":
|
||||
return ValuesEqual(current, operand);
|
||||
case "notEquals":
|
||||
return !ValuesEqual(current, operand);
|
||||
case "greaterThan":
|
||||
case "lessThan":
|
||||
if (!TryCompareNumeric(current, operand, out var comparison, out error))
|
||||
return false;
|
||||
return op == "greaterThan" ? comparison > 0 : comparison < 0;
|
||||
case "contains":
|
||||
if (!(current is string s))
|
||||
{
|
||||
error = "op 'contains' requires a string member.";
|
||||
return false;
|
||||
}
|
||||
var sub = operand as string ?? operand?.ToString() ?? string.Empty;
|
||||
return s.IndexOf(sub, StringComparison.Ordinal) >= 0;
|
||||
default:
|
||||
error = $"Unknown op '{op}'.";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve a type by assembly-qualified name, full name, or simple name across loaded
|
||||
/// assemblies. This is the expensive whole-appdomain scan — callers cache the result on the
|
||||
/// wait's <see cref="WaitConditionPlan"/> so it runs at most once per wait, not per poll.
|
||||
/// </summary>
|
||||
internal static Type ResolveType(string name) => ResolveType(name, out _);
|
||||
|
||||
internal static Type ResolveType(string name, out string ambiguityNote)
|
||||
{
|
||||
ambiguityNote = null;
|
||||
if (string.IsNullOrEmpty(name))
|
||||
return null;
|
||||
|
||||
var direct = Type.GetType(name, throwOnError: false);
|
||||
if (direct != null)
|
||||
return direct;
|
||||
|
||||
foreach (var asm in PipelineUtils.GetLoadedAssemblies())
|
||||
{
|
||||
var t = asm.GetType(name, throwOnError: false);
|
||||
if (t != null)
|
||||
return t;
|
||||
}
|
||||
|
||||
// A dotted name can never equal a simple Type.Name (which never contains '.') — skip
|
||||
// the whole-appdomain scan below for it. This matters: ReadMember's static-path prefix
|
||||
// loop calls ResolveType once per FAILING dotted prefix (e.g. "Ns.Type.Nested", "Ns.Type"
|
||||
// for a 4-segment path), and without this guard every one of those burns a full scan that
|
||||
// can never succeed.
|
||||
if (name.IndexOf('.') >= 0)
|
||||
return null;
|
||||
|
||||
// Simple-name fallback: a bare name can match several loaded types (e.g. "Debug" in
|
||||
// UnityEngine and System.Diagnostics). Bind DETERMINISTICALLY (lowest full name
|
||||
// ordinally) instead of assembly-enumeration order, and surface the ambiguity as a
|
||||
// note so the caller learns to fully qualify.
|
||||
Type chosen = null;
|
||||
List<string> matches = null;
|
||||
foreach (var asm in PipelineUtils.GetLoadedAssemblies())
|
||||
{
|
||||
foreach (var candidate in SafeGetTypes(asm))
|
||||
{
|
||||
// No FullName check here: the asm.GetType(name) pass above already covered
|
||||
// every exact-full-name match, so by this point only simple names can hit.
|
||||
if (candidate.Name != name)
|
||||
continue;
|
||||
if (chosen == null || string.CompareOrdinal(candidate.FullName, chosen.FullName) < 0)
|
||||
chosen = candidate;
|
||||
(matches ?? (matches = new List<string>())).Add(candidate.FullName);
|
||||
}
|
||||
}
|
||||
|
||||
if (matches != null && matches.Count > 1)
|
||||
{
|
||||
matches.Sort(StringComparer.Ordinal);
|
||||
ambiguityNote = $"Type name '{name}' matched {matches.Count} loaded types " +
|
||||
$"({string.Join(", ", matches.GetRange(0, Math.Min(matches.Count, 4)))}" +
|
||||
(matches.Count > 4 ? ", ..." : "") +
|
||||
$"); bound to '{chosen.FullName}'. Use the fully-qualified name to disambiguate.";
|
||||
}
|
||||
|
||||
return chosen;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gate for retrying a previously failed type-resolution scan: true only when the set of
|
||||
/// loaded assemblies has changed since the last attempt (recording a signature of the
|
||||
/// current set, so the next failure waits for the next actual change). If the scan succeeds
|
||||
/// the positive cache on the plan short-circuits all later polls and the recorded signature
|
||||
/// is never consulted again.
|
||||
/// </summary>
|
||||
private static bool ShouldRescan(WaitConditionPlan plan)
|
||||
{
|
||||
var signature = AssemblySetSignature(PipelineUtils.GetLoadedAssemblies());
|
||||
if (signature == plan.FailedScanAssemblySignature)
|
||||
return false;
|
||||
plan.FailedScanAssemblySignature = signature;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Identity-based signature of a loaded-assembly set: combines each assembly's own
|
||||
/// (identity) hash code, not just the set's <c>Count</c>. An unload-and-load-of-a-different-
|
||||
/// assembly (e.g. a hot-reloaded script assembly) can leave the count unchanged while
|
||||
/// swapping every member of the set — a count-only gate would then never re-attempt a scan
|
||||
/// that just became resolvable.
|
||||
/// </summary>
|
||||
private static long AssemblySetSignature(IReadOnlyList<Assembly> assemblies)
|
||||
{
|
||||
unchecked
|
||||
{
|
||||
long hash = 17;
|
||||
for (var i = 0; i < assemblies.Count; i++)
|
||||
hash = hash * 31 + assemblies[i].GetHashCode();
|
||||
return hash;
|
||||
}
|
||||
}
|
||||
|
||||
private static Type[] SafeGetTypes(Assembly assembly)
|
||||
{
|
||||
try
|
||||
{
|
||||
return assembly.GetTypes();
|
||||
}
|
||||
catch (ReflectionTypeLoadException ex)
|
||||
{
|
||||
return ex.Types?.Where(t => t != null).ToArray() ?? Array.Empty<Type>();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return Array.Empty<Type>();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pick the live instance a <c>findType</c> wait binds to. Persistent objects
|
||||
/// (prefab/asset files on disk — <see cref="EditorUtility.IsPersistent"/>) and hidden
|
||||
/// editor internals (<c>hideFlags != None</c>) are excluded so the wait observes the live
|
||||
/// instance, never the asset. Scene instances are preferred over loose runtime objects;
|
||||
/// if several candidates still remain, the lowest instance id wins deterministically and
|
||||
/// the ambiguity is reported via <paramref name="note"/> on the wait result.
|
||||
/// </summary>
|
||||
private static Object FindInstance(Type type, out string note)
|
||||
{
|
||||
note = null;
|
||||
if (!typeof(Object).IsAssignableFrom(type))
|
||||
// A property of the resolved TYPE, not of whether an instance exists yet —
|
||||
// permanent regardless of tolerate_missing.
|
||||
throw new WaitEvaluationException(
|
||||
$"findType '{type.FullName}' is not a UnityEngine.Object type; use a static member path or a 'target' handle.",
|
||||
isPermanent: true);
|
||||
|
||||
var all = Resources.FindObjectsOfTypeAll(type);
|
||||
if (all == null || all.Length == 0)
|
||||
return null;
|
||||
|
||||
var candidates = new List<Object>();
|
||||
foreach (var o in all)
|
||||
{
|
||||
if (o == null)
|
||||
continue;
|
||||
if (EditorUtility.IsPersistent(o))
|
||||
continue; // a prefab or other asset on disk, not the live instance
|
||||
if (o.hideFlags != HideFlags.None)
|
||||
continue; // hidden editor internals / preview objects
|
||||
candidates.Add(o);
|
||||
}
|
||||
|
||||
if (candidates.Count == 0)
|
||||
return null;
|
||||
if (candidates.Count == 1)
|
||||
return candidates[0];
|
||||
|
||||
var sceneInstances = candidates.Where(IsSceneInstance).ToList();
|
||||
var pool = sceneInstances.Count > 0 ? sceneInstances : candidates;
|
||||
// Version-stable ids: GetInstanceID() is obsolete-as-error from 6000.4.
|
||||
var chosen = pool.OrderBy(o => PipelineUtils.GetObjectId(o).RawValue).First();
|
||||
if (pool.Count > 1)
|
||||
note = $"findType '{type.FullName}' matched {pool.Count} live instances; bound to instanceId {PipelineUtils.GetObjectId(chosen).RawValue} (lowest). Use 'target' to select one explicitly.";
|
||||
return chosen;
|
||||
}
|
||||
|
||||
private static bool IsSceneInstance(Object o)
|
||||
{
|
||||
var go = o as GameObject ?? (o as Component)?.gameObject;
|
||||
return go != null && go.scene.IsValid();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read a single named field or property. The member lookup is memoized on the plan per
|
||||
/// encountered runtime type, so only the first poll pays for reflection resolution.
|
||||
/// Rejects write-only properties, indexers, and anything that is not a readable field/property.
|
||||
/// </summary>
|
||||
private static bool TryReadMember(WaitConditionPlan plan, int segmentIndex, Type lookup, string name,
|
||||
bool isStatic, object instance, out object value, out Type type, out string error)
|
||||
{
|
||||
value = null;
|
||||
type = null;
|
||||
error = null;
|
||||
|
||||
if (!plan.TryGetCachedMember(lookup, segmentIndex, isStatic, out var member))
|
||||
{
|
||||
if (!TryResolveMember(lookup, name, isStatic, out member, out error))
|
||||
return false;
|
||||
plan.CacheMember(lookup, segmentIndex, isStatic, member);
|
||||
}
|
||||
|
||||
if (member is PropertyInfo property)
|
||||
{
|
||||
value = property.GetValue(isStatic ? null : instance);
|
||||
type = property.PropertyType;
|
||||
return true;
|
||||
}
|
||||
|
||||
var field = (FieldInfo)member;
|
||||
value = field.GetValue(isStatic ? null : instance);
|
||||
type = field.FieldType;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Resolve a named field or property, walking base types for inherited PUBLIC
|
||||
/// members. Public-only is a deliberate security boundary (see <see cref="MemberFlags"/>) —
|
||||
/// the base walk exists because Type.GetField/GetProperty with declared-only semantics
|
||||
/// would miss inherited members, not to reach private state.</summary>
|
||||
private static bool TryResolveMember(Type lookup, string name, bool isStatic,
|
||||
out MemberInfo member, out string error)
|
||||
{
|
||||
member = null;
|
||||
error = null;
|
||||
|
||||
var flags = MemberFlags | (isStatic ? BindingFlags.Static : BindingFlags.Instance) | BindingFlags.DeclaredOnly;
|
||||
|
||||
for (var t = lookup; t != null; t = t.BaseType)
|
||||
{
|
||||
var property = t.GetProperty(name, flags);
|
||||
if (property != null)
|
||||
{
|
||||
if (!property.CanRead)
|
||||
{
|
||||
error = $"Member '{name}' on '{lookup.FullName}' is write-only and cannot be observed.";
|
||||
return false;
|
||||
}
|
||||
if (property.GetIndexParameters().Length > 0)
|
||||
{
|
||||
error = $"Member '{name}' on '{lookup.FullName}' is an indexer and cannot be observed.";
|
||||
return false;
|
||||
}
|
||||
|
||||
member = property;
|
||||
return true;
|
||||
}
|
||||
|
||||
var field = t.GetField(name, flags);
|
||||
if (field != null)
|
||||
{
|
||||
member = field;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
error = $"No readable field or property '{name}' on type '{lookup.FullName}' " +
|
||||
$"({(isStatic ? "static" : "instance")} members only; methods and write-only members are rejected).";
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Coerce a JSON operand (JToken or boxed primitive/string) to the member's runtime type.</summary>
|
||||
private static bool TryCoerceOperand(object raw, Type targetType, out object coerced, out string error)
|
||||
{
|
||||
coerced = null;
|
||||
error = null;
|
||||
|
||||
if (raw == null)
|
||||
{
|
||||
error = "condition.value is required for this operator.";
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var underlying = Nullable.GetUnderlyingType(targetType) ?? targetType;
|
||||
|
||||
if (underlying.IsEnum)
|
||||
{
|
||||
var text = RawToString(raw);
|
||||
if (!string.IsNullOrEmpty(text) && !LooksNumeric(text))
|
||||
{
|
||||
coerced = Enum.Parse(underlying, text, ignoreCase: true);
|
||||
return true;
|
||||
}
|
||||
var numeric = Convert.ToInt64(RawToScalar(raw));
|
||||
coerced = Enum.ToObject(underlying, numeric);
|
||||
return true;
|
||||
}
|
||||
|
||||
var token = raw as JToken ?? JToken.FromObject(raw);
|
||||
coerced = token.ToObject(underlying);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
error = $"Could not coerce value '{RawToString(raw)}' to {targetType.Name}: {ex.Message}";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string RawToString(object raw)
|
||||
{
|
||||
if (raw is JToken token)
|
||||
return token.Type == JTokenType.Null ? null : token.ToObject<string>();
|
||||
return raw?.ToString();
|
||||
}
|
||||
|
||||
private static object RawToScalar(object raw)
|
||||
{
|
||||
if (raw is JValue value)
|
||||
return value.Value;
|
||||
return raw;
|
||||
}
|
||||
|
||||
private static bool LooksNumeric(string text)
|
||||
{
|
||||
return long.TryParse(text, out _) || double.TryParse(text, out _);
|
||||
}
|
||||
|
||||
private static bool ValuesEqual(object a, object b)
|
||||
{
|
||||
if (a == null && b == null)
|
||||
return true;
|
||||
if (a == null || b == null)
|
||||
return false;
|
||||
|
||||
if (IsNumeric(a) && IsNumeric(b))
|
||||
{
|
||||
// A float member must be compared at float precision: widening float to double
|
||||
// manufactures noise digits (0.9f -> 0.90000003576...) that never equal the double
|
||||
// operand parsed from JSON, so 'equals' on a float would never match.
|
||||
if (a is float || b is float)
|
||||
return Convert.ToSingle(a).Equals(Convert.ToSingle(b));
|
||||
if (a is double || b is double)
|
||||
return Convert.ToDouble(a).Equals(Convert.ToDouble(b));
|
||||
// Both integral/decimal: compare exactly via decimal — a double round-trip
|
||||
// collapses distinct values above 2^53 (9007199254740993 == 9007199254740992.0d).
|
||||
return Convert.ToDecimal(a).Equals(Convert.ToDecimal(b));
|
||||
}
|
||||
|
||||
if (a.Equals(b))
|
||||
return true;
|
||||
|
||||
// Enum-vs-name / cross-type tolerance (e.g. an enum compared to its string name).
|
||||
return string.Equals(a.ToString(), b.ToString(), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ordering comparison that stays exact for integral/decimal operands (decimal compare —
|
||||
/// double collapses distinct values above 2^53) and uses double only when a floating-point
|
||||
/// side or an enum is genuinely involved.
|
||||
/// </summary>
|
||||
private static bool TryCompareNumeric(object a, object b, out int comparison, out string error)
|
||||
{
|
||||
comparison = 0;
|
||||
if (IsExactNumeric(a) && IsExactNumeric(b))
|
||||
{
|
||||
try
|
||||
{
|
||||
comparison = Convert.ToDecimal(a).CompareTo(Convert.ToDecimal(b));
|
||||
error = null;
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
error = $"Values '{a}' and '{b}' are not comparable: {ex.Message}";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!TryToDouble(a, out var da, out error))
|
||||
return false;
|
||||
if (!TryToDouble(b, out var db, out error))
|
||||
return false;
|
||||
comparison = da.CompareTo(db);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsExactNumeric(object value) =>
|
||||
value is sbyte || value is byte || value is short || value is ushort ||
|
||||
value is int || value is uint || value is long || value is ulong || value is decimal;
|
||||
|
||||
private static bool TryToDouble(object value, out double result, out string error)
|
||||
{
|
||||
result = 0;
|
||||
error = null;
|
||||
|
||||
if (value == null)
|
||||
{
|
||||
error = "Cannot compare a null value numerically.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (IsNumeric(value) || value is Enum)
|
||||
{
|
||||
try
|
||||
{
|
||||
result = Convert.ToDouble(value);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
error = $"Value '{value}' is not numeric: {ex.Message}";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
error = $"greaterThan/lessThan require a numeric member; got '{value.GetType().Name}'.";
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsNumeric(object value)
|
||||
{
|
||||
switch (value)
|
||||
{
|
||||
case sbyte _:
|
||||
case byte _:
|
||||
case short _:
|
||||
case ushort _:
|
||||
case int _:
|
||||
case uint _:
|
||||
case long _:
|
||||
case ulong _:
|
||||
case float _:
|
||||
case double _:
|
||||
case decimal _:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user