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(); 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(); } /// /// 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. /// 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; } /// Resolve a named field or property, walking base types for inherited PUBLIC /// members. Public-only is a deliberate security boundary (see ) — /// the base walk exists because Type.GetField/GetProperty with declared-only semantics /// would miss inherited members, not to reach private state. 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; } /// Coerce a JSON operand (JToken or boxed primitive/string) to the member's runtime type. 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(); 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); } /// /// 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. /// 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; } } } }