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
{
///
/// 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 failed result with this message, never as a spurious timedOut
/// (unless tolerate_missing is set, in which case resolution failures are retried as
/// condition-not-met until the wait's own budget expires) — UNLESS is
/// set, for a failure no amount of retrying can ever resolve (a malformed member path, a
/// findType naming a non- 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".
///
internal sealed class WaitEvaluationException : Exception
{
internal bool IsPermanent { get; }
public WaitEvaluationException(string message, bool isPermanent = false) : base(message)
{
IsPermanent = isPermanent;
}
}
///
/// Cached resolution state for one wait's condition. The expensive part of member resolution —
/// '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 tolerate_missing waits on
/// not-yet-loadable paths). A findType instance binds once and re-scans
/// only when the bound instance is destroyed; an explicit target 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 on the main thread.
///
internal sealed class WaitConditionPlan
{
internal readonly WaitConditionInput Condition;
internal readonly string[] Segments;
/// Resolved type of a static member path's leading prefix (cached after the first successful scan).
internal Type StaticType;
/// Number of leading segments consumed by .
internal int StaticSplit;
/// Resolved findType (cached after the first successful scan).
internal Type FindTypeResolved;
/// The live instance a findType wait is bound to; re-resolved only when it
/// has been destroyed (never silently re-bound while alive).
internal Object BoundInstance;
/// The ambiguity note captured when was bound,
/// re-surfaced on every poll for the wait result.
internal string BoundNote;
/// 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.
internal string TypeNote;
///
/// Signature of the loaded-assembly SET observed by the last FAILED type-resolution scan
/// (combines identity hashes, not just Count — 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. run_script/eval loading one).
/// Without this, a tolerate_missing 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.
///
internal long? FailedScanAssemblySignature;
/// Non-fatal note from the latest resolution (e.g. an ambiguous findType match), surfaced on the wait result.
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;
}
///
/// Reads and compares a member value for the wait_for 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):
/// - target (an ) resolves via
/// ; the member path then walks instance members from it.
/// - findType 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 (Ns.Type.StaticMember.Nested).
///
/// Only readable fields and properties are resolved — write-only members and methods are rejected,
/// so the surface is read-only by construction.
///
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;
/// Convenience overload for one-shot reads (tests): builds a throwaway plan.
internal static object ReadMember(WaitConditionInput condition, out Type memberType)
=> ReadMember(new WaitConditionPlan(condition), out memberType);
///
/// 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
/// so repeated polls never repeat the appdomain scan; only the live
/// instance (target / findType) is re-resolved per call. Throws
/// on any resolution failure.
///
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;
}
///
/// Evaluate an operator against a freshly read value. For
/// changed, compares against (the first observed value)
/// and never needs an operand. All other operators coerce the JSON operand to
/// first. Sets (and returns false) when
/// the operator or operand cannot be applied.
///
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;
}
}
///
/// 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 so it runs at most once per wait, not per poll.
///
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 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())).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;
}
///
/// 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.
///
private static bool ShouldRescan(WaitConditionPlan plan)
{
var signature = AssemblySetSignature(PipelineUtils.GetLoadedAssemblies());
if (signature == plan.FailedScanAssemblySignature)
return false;
plan.FailedScanAssemblySignature = signature;
return true;
}
///
/// Identity-based signature of a loaded-assembly set: combines each assembly's own
/// (identity) hash code, not just the set's Count. 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.
///
private static long AssemblySetSignature(IReadOnlyList 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();
}
catch
{
return Array.Empty();
}
}
///
/// Pick the live instance a findType wait binds to. Persistent objects
/// (prefab/asset files on disk — ) and hidden
/// editor internals (hideFlags != None) 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 on the wait result.
///
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