diff --git a/.agents/skills/uloop-pause-point/SKILL.md b/.agents/skills/uloop-pause-point/SKILL.md index d55a8d648..433843c0b 100644 --- a/.agents/skills/uloop-pause-point/SKILL.md +++ b/.agents/skills/uloop-pause-point/SKILL.md @@ -55,7 +55,8 @@ Every hit response embeds `CapturedVariables`: the method's in-scope locals, its - The snapshot is taken **before** the resolved line executes, exactly like an IDE breakpoint on that line. To inspect a value after an assignment, place the pause point on the following line. - `execute-dynamic-code` during the pause sees the interrupted method's **post-interrupt** state, not this pre-line snapshot. Use `CapturedVariables` for pre-line evidence; use the raw capture API below when you need live references while paused. -- `Scope` is `Local`, `Parameter`, or `InstanceField`. +- `Scope` is `Local`, `Parameter`, `InstanceField`, or `This`. +- The snapshot also includes a synthetic `this` entry (Scope `This`) for the paused instance itself, so you can tell which instance or GameObject was hit via its `UnityObjectPath` and `UnityObjectInstanceId`. For an async or coroutine method it resolves to the original outer instance, not the compiler-generated state machine, and static methods emit no `this` entry. While Unity is still paused, `UloopPausePoint.TryGetCapturedValue("this")` returns the live instance reference (for example so a watch expression can read `transform.position`). - `UnityEngine.Object` values additionally carry `UnityObjectKind` (`SceneObject`, `PrefabAsset`, `Asset`, `RuntimeInstance`, or `Destroyed`), `UnityObjectPath`, and `UnityObjectInstanceId`. Use these as handles for the next dig: a `SceneObject` path feeds `get-hierarchy`/`find-game-objects`, an asset path locates the asset, and the InstanceID works with `execute-dynamic-code`. - `CapturedVariablesTruncated=true` means at least one value was clipped to the length cap or the variable-count cap stopped enumeration; clipped values are still present up to the cap. - async and coroutine methods work: hoisted locals and the original `this` fields appear under their normal names. diff --git a/.claude/skills/uloop-pause-point/SKILL.md b/.claude/skills/uloop-pause-point/SKILL.md index d55a8d648..433843c0b 100644 --- a/.claude/skills/uloop-pause-point/SKILL.md +++ b/.claude/skills/uloop-pause-point/SKILL.md @@ -55,7 +55,8 @@ Every hit response embeds `CapturedVariables`: the method's in-scope locals, its - The snapshot is taken **before** the resolved line executes, exactly like an IDE breakpoint on that line. To inspect a value after an assignment, place the pause point on the following line. - `execute-dynamic-code` during the pause sees the interrupted method's **post-interrupt** state, not this pre-line snapshot. Use `CapturedVariables` for pre-line evidence; use the raw capture API below when you need live references while paused. -- `Scope` is `Local`, `Parameter`, or `InstanceField`. +- `Scope` is `Local`, `Parameter`, `InstanceField`, or `This`. +- The snapshot also includes a synthetic `this` entry (Scope `This`) for the paused instance itself, so you can tell which instance or GameObject was hit via its `UnityObjectPath` and `UnityObjectInstanceId`. For an async or coroutine method it resolves to the original outer instance, not the compiler-generated state machine, and static methods emit no `this` entry. While Unity is still paused, `UloopPausePoint.TryGetCapturedValue("this")` returns the live instance reference (for example so a watch expression can read `transform.position`). - `UnityEngine.Object` values additionally carry `UnityObjectKind` (`SceneObject`, `PrefabAsset`, `Asset`, `RuntimeInstance`, or `Destroyed`), `UnityObjectPath`, and `UnityObjectInstanceId`. Use these as handles for the next dig: a `SceneObject` path feeds `get-hierarchy`/`find-game-objects`, an asset path locates the asset, and the InstanceID works with `execute-dynamic-code`. - `CapturedVariablesTruncated=true` means at least one value was clipped to the length cap or the variable-count cap stopped enumeration; clipped values are still present up to the cap. - async and coroutine methods work: hoisted locals and the original `this` fields appear under their normal names. diff --git a/Assets/Tests/Editor/PausePointTests.cs b/Assets/Tests/Editor/PausePointTests.cs index 2e9c81854..a5370f42b 100644 --- a/Assets/Tests/Editor/PausePointTests.cs +++ b/Assets/Tests/Editor/PausePointTests.cs @@ -706,7 +706,8 @@ public async Task Enable_WhenFileAndLineResolveToRealMethod_PatchesAndCapturesVa { // Verifies the File/Line path resolves a real fixture method, patches it via Harmony // through the full public tool surface, and a subsequent call to the patched method - // hits the registry with its locals, parameters, and instance field captured. + // hits the registry with its locals, parameters, the synthetic "this" entry, and + // instance field captured. PausePointResponse response = await EnablePausePointByFileLineAsync(FixtureFilePath, FixtureLine); Assert.That(response.Success, Is.True); @@ -722,7 +723,7 @@ public async Task Enable_WhenFileAndLineResolveToRealMethod_PatchesAndCapturesVa Assert.That(snapshot.IsHit, Is.True); Assert.That( snapshot.CapturedVariables.Select(v => v.Name), - Is.EquivalentTo(new[] { "left", "right", "sum", "Tag" })); + Is.EquivalentTo(new[] { "left", "right", "sum", "this", "Tag" })); } [Test] diff --git a/Assets/Tests/Editor/SourcePausePointCapture/SourcePausePointCaptureTests.cs b/Assets/Tests/Editor/SourcePausePointCapture/SourcePausePointCaptureTests.cs index 06329f899..1072d4155 100644 --- a/Assets/Tests/Editor/SourcePausePointCapture/SourcePausePointCaptureTests.cs +++ b/Assets/Tests/Editor/SourcePausePointCapture/SourcePausePointCaptureTests.cs @@ -1,6 +1,8 @@ using System; using System.Collections; +using System.Collections.Generic; using System.Linq; +using System.Reflection; using System.Threading.Tasks; using NUnit.Framework; @@ -120,6 +122,136 @@ public IEnumerator Capture_WhenCalledOffMainThread_RecordsHitOnNextMainThreadTic Assert.That(_pauseController.PauseCount, Is.EqualTo(1)); } + [Test] + public void Collect_WithNormalInstance_AddsThisEntryReferencingTheInstance() + { + // Verifies a non-state-machine instance yields a synthetic "this" entry (Scope=This) + // whose raw value is the paused instance itself. + NormalInstanceFixture instance = new(); + + UloopPausePointCapturedVariableFrame frame = SourcePausePointVariableCollector.Collect( + instance, Array.Empty(), Array.Empty()); + + UloopPausePointCapturedVariableEntry thisEntry = frame.Entries.Single(entry => entry.Name == "this"); + Assert.That(thisEntry.Scope, Is.EqualTo(UloopCapturedVariableScope.This)); + Assert.That(thisEntry.Value, Is.SameAs(instance)); + } + + [Test] + public void Collect_OrdersThisAfterLocalsAndParametersButBeforeInstanceFields() + { + // Verifies the "this" entry lands after locals/parameters and before instance fields, + // so the count cap keeps prioritizing locals and parameters. + NormalInstanceFixture instance = new() { Health = 7 }; + object[] locals = { "speed", 5 }; + object[] parameters = { "damage", 3 }; + + UloopPausePointCapturedVariableFrame frame = SourcePausePointVariableCollector.Collect( + instance, parameters, locals); + + List names = frame.Entries.Select(entry => entry.Name).ToList(); + int thisIndex = names.IndexOf("this"); + Assert.That(names.IndexOf("speed"), Is.LessThan(thisIndex)); + Assert.That(names.IndexOf("damage"), Is.LessThan(thisIndex)); + Assert.That(thisIndex, Is.LessThan(names.IndexOf("Health"))); + } + + [Test] + public void Collect_WithStateMachineInstance_ResolvesThisToOuterInstanceNotStateMachine() + { + // Verifies an async/coroutine state machine emits the hoisted outer instance as "this" + // and never surfaces the compiler-generated state machine object itself as "this". + AsyncStateMachineFixture outer = new(); + (object stateMachine, Type stateMachineType) = CreateStateMachine(); + FieldInfo outerThisField = stateMachineType.GetField( + "<>4__this", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); + Assert.That(outerThisField, Is.Not.Null, "compiler must hoist <>4__this for this fixture"); + outerThisField.SetValue(stateMachine, outer); + + UloopPausePointCapturedVariableFrame frame = SourcePausePointVariableCollector.Collect( + stateMachine, Array.Empty(), Array.Empty()); + + UloopPausePointCapturedVariableEntry thisEntry = frame.Entries.Single(entry => entry.Name == "this"); + Assert.That(thisEntry.Scope, Is.EqualTo(UloopCapturedVariableScope.This)); + Assert.That(thisEntry.Value, Is.SameAs(outer)); + Assert.That(frame.Entries.Any(entry => ReferenceEquals(entry.Value, stateMachine)), Is.False); + } + + [Test] + public void Collect_WithStateMachineInstanceMissingOuterThis_AddsNoThisEntry() + { + // Verifies a state machine with a null hoisted outer instance emits no "this" entry, + // rather than falling back to the state machine object itself. + (object stateMachine, _) = CreateStateMachine(); + + UloopPausePointCapturedVariableFrame frame = SourcePausePointVariableCollector.Collect( + stateMachine, Array.Empty(), Array.Empty()); + + Assert.That(frame.Entries.Any(entry => entry.Name == "this"), Is.False); + } + + [Test] + public void Collect_WithNullInstance_AddsNoThisEntry() + { + // Verifies a static method (null instance) produces no "this" entry. + object[] locals = { "speed", 5 }; + + UloopPausePointCapturedVariableFrame frame = SourcePausePointVariableCollector.Collect( + null, Array.Empty(), locals); + + Assert.That(frame.Entries.Any(entry => entry.Name == "this"), Is.False); + } + + [Test] + public void Collect_WhenCountCapReachedBeforeThis_OmitsThisAndReportsTruncated() + { + // Verifies that when locals already fill the count cap, the "this" entry is dropped and + // truncation is reported per the existing TryAppendEntry contract. + int localCount = SourcePausePointConstants.MaxCapturedVariableCount; + object[] locals = new object[localCount * 2]; + for (int i = 0; i < localCount; i++) + { + locals[i * 2] = $"local{i}"; + locals[i * 2 + 1] = i; + } + + NormalInstanceFixture instance = new(); + + UloopPausePointCapturedVariableFrame frame = SourcePausePointVariableCollector.Collect( + instance, Array.Empty(), locals); + + Assert.That(frame.Entries.Count, Is.EqualTo(SourcePausePointConstants.MaxCapturedVariableCount)); + Assert.That(frame.Entries.Any(entry => entry.Name == "this"), Is.False); + Assert.That(frame.Truncated, Is.True); + } + + private static (object StateMachine, Type StateMachineType) CreateStateMachine() + { + Type stateMachineType = typeof(AsyncStateMachineFixture) + .GetNestedTypes(BindingFlags.NonPublic) + .Single(type => type.Name.StartsWith("d__", StringComparison.Ordinal)); + object stateMachine = Activator.CreateInstance(stateMachineType); + return (stateMachine, stateMachineType); + } + + private sealed class NormalInstanceFixture + { + public int Health; + } + + private sealed class AsyncStateMachineFixture + { + public int OuterField; + + public async Task RunAsync(int seed) + { + int localValue = seed * 2; + await Task.Yield(); + OuterField += localValue; + return localValue; + } + } + private sealed class FakePausePointPauseController : IUloopPausePointPauseController { public int PauseCount { get; private set; } diff --git a/Assets/Tests/Editor/SourcePausePointCapture/SourcePausePointVariableFormatterTests.cs b/Assets/Tests/Editor/SourcePausePointCapture/SourcePausePointVariableFormatterTests.cs index fe223a4c7..5140eb1d0 100644 --- a/Assets/Tests/Editor/SourcePausePointCapture/SourcePausePointVariableFormatterTests.cs +++ b/Assets/Tests/Editor/SourcePausePointCapture/SourcePausePointVariableFormatterTests.cs @@ -106,7 +106,8 @@ public void Format_WhenValueExceedsMaxLength_TruncatesValueAndSetsTruncatedFlag( public void Format_WhenOneValueExceedsMaxLength_StillCapturesSubsequentVariables() { // Regression test: an over-long value must only clip itself, never abort capture of - // the parameters/instance fields that come after it in the same call. + // the parameters, the synthetic "this" entry, and instance fields that come after it + // in the same call. string longValue = new string('a', SourcePausePointConstants.MaxCapturedVariableValueLength + 10); object[] locals = { "longText", longValue }; object[] parameters = { "hp", 42 }; @@ -115,7 +116,7 @@ public void Format_WhenOneValueExceedsMaxLength_StillCapturesSubsequentVariables (List variables, bool truncated) = SourcePausePointVariableFormatter.Format( instance, parameters, locals); - Assert.That(variables.Select(v => v.Name), Is.EqualTo(new[] { "longText", "hp", "PublicField" })); + Assert.That(variables.Select(v => v.Name), Is.EqualTo(new[] { "longText", "hp", "this", "PublicField" })); Assert.That(variables.Single(v => v.Name == "hp").Value, Is.EqualTo("42")); Assert.That(variables.Single(v => v.Name == "PublicField").Value, Is.EqualTo("5")); Assert.That(truncated, Is.True); @@ -143,8 +144,9 @@ public void Format_WhenVariableCountExceedsMax_StopsAtCapAndSetsTruncatedFlag() [Test] public void Format_WithInstanceFields_CapturesThemAfterLocalsAndParameters() { - // Verifies instance fields (Scope=InstanceField) are appended after locals/parameters, - // and compiler-generated backing fields ("k__BackingField") are skipped. + // Verifies the synthetic "this" entry (Scope=This) lands after locals/parameters and + // before instance fields (Scope=InstanceField), and compiler-generated backing fields + // ("k__BackingField") are skipped. InstanceFieldFixture instance = new() { PublicField = 9 }; instance.Prop = "hello"; object[] locals = { "local", 1 }; @@ -153,9 +155,10 @@ public void Format_WithInstanceFields_CapturesThemAfterLocalsAndParameters() (List variables, _) = SourcePausePointVariableFormatter.Format( instance, parameters, locals); - Assert.That(variables.Select(v => v.Name), Is.EqualTo(new[] { "local", "param", "PublicField" })); - Assert.That(variables[2].Scope, Is.EqualTo(UloopCapturedVariableScope.InstanceField)); - Assert.That(variables[2].Value, Is.EqualTo("9")); + Assert.That(variables.Select(v => v.Name), Is.EqualTo(new[] { "local", "param", "this", "PublicField" })); + Assert.That(variables[2].Scope, Is.EqualTo(UloopCapturedVariableScope.This)); + Assert.That(variables[3].Scope, Is.EqualTo(UloopCapturedVariableScope.InstanceField)); + Assert.That(variables[3].Value, Is.EqualTo("9")); Assert.That(variables.Any(v => v.Name.Contains("Prop")), Is.False); } diff --git a/Assets/Tests/Editor/SourcePausePointPatcher/SourcePausePointPatcherTests.cs b/Assets/Tests/Editor/SourcePausePointPatcher/SourcePausePointPatcherTests.cs index 299c99a74..c7e59c316 100644 --- a/Assets/Tests/Editor/SourcePausePointPatcher/SourcePausePointPatcherTests.cs +++ b/Assets/Tests/Editor/SourcePausePointPatcher/SourcePausePointPatcherTests.cs @@ -40,7 +40,8 @@ public void TearDown() public void Patch_InstanceMethod_CapturesLocalsParametersAndInstanceFieldOnHit() { // Verifies the base case: an instance method's parameters, its as-yet-unassigned local, - // and its declaring instance's own field are all captured at the resolved statement. + // the synthetic "this" entry, and its declaring instance's own field are all captured + // at the resolved statement. const string id = "patcher-normal-method"; SourcePausePointResolveResult resolveResult = SourcePausePointResolver.Resolve( FixturesDirectory + "PatcherNormalMethodFixture.cs", 11); @@ -56,7 +57,7 @@ public void Patch_InstanceMethod_CapturesLocalsParametersAndInstanceFieldOnHit() Assert.That(sum, Is.EqualTo(5)); UloopPausePointSnapshot snapshot = UloopPausePointRegistry.GetStatus(id); Assert.That(snapshot.IsHit, Is.True); - Assert.That(snapshot.CapturedVariables.Select(v => v.Name), Is.EquivalentTo(new[] { "left", "right", "sum", "Tag" })); + Assert.That(snapshot.CapturedVariables.Select(v => v.Name), Is.EquivalentTo(new[] { "left", "right", "sum", "this", "Tag" })); Assert.That(snapshot.CapturedVariables.First(v => v.Name == "left").Value, Is.EqualTo("2")); Assert.That(snapshot.CapturedVariables.First(v => v.Name == "right").Value, Is.EqualTo("3")); Assert.That(snapshot.CapturedVariables.First(v => v.Name == "sum").Value, Is.EqualTo("0")); @@ -90,7 +91,8 @@ public void Patch_StaticMethod_PassesNullInstanceAndUsesUnshiftedArgumentIndices public void Patch_StructInstanceMethod_BoxesValueTypeThisAndCapturesInstanceField() { // Verifies the ldobj+box path used when `this` is a value type: ldarg.0 yields a managed - // pointer for a struct instance method, which must be dereferenced and boxed before Capture. + // pointer for a struct instance method, which must be dereferenced and boxed before + // Capture, surfacing both the synthetic "this" entry and the boxed instance's field. const string id = "patcher-struct-instance-method"; SourcePausePointResolveResult resolveResult = SourcePausePointResolver.Resolve( FixturesDirectory + "PatcherStructInstanceMethodFixture.cs", 11); @@ -106,7 +108,7 @@ public void Patch_StructInstanceMethod_BoxesValueTypeThisAndCapturesInstanceFiel Assert.That(doubled, Is.EqualTo(14)); UloopPausePointSnapshot snapshot = UloopPausePointRegistry.GetStatus(id); Assert.That(snapshot.IsHit, Is.True); - Assert.That(snapshot.CapturedVariables.Select(v => v.Name), Is.EquivalentTo(new[] { "doubled", "Value" })); + Assert.That(snapshot.CapturedVariables.Select(v => v.Name), Is.EquivalentTo(new[] { "doubled", "this", "Value" })); Assert.That(snapshot.CapturedVariables.First(v => v.Name == "Value").Value, Is.EqualTo("7")); } diff --git a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md index d55a8d648..433843c0b 100644 --- a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md +++ b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md @@ -55,7 +55,8 @@ Every hit response embeds `CapturedVariables`: the method's in-scope locals, its - The snapshot is taken **before** the resolved line executes, exactly like an IDE breakpoint on that line. To inspect a value after an assignment, place the pause point on the following line. - `execute-dynamic-code` during the pause sees the interrupted method's **post-interrupt** state, not this pre-line snapshot. Use `CapturedVariables` for pre-line evidence; use the raw capture API below when you need live references while paused. -- `Scope` is `Local`, `Parameter`, or `InstanceField`. +- `Scope` is `Local`, `Parameter`, `InstanceField`, or `This`. +- The snapshot also includes a synthetic `this` entry (Scope `This`) for the paused instance itself, so you can tell which instance or GameObject was hit via its `UnityObjectPath` and `UnityObjectInstanceId`. For an async or coroutine method it resolves to the original outer instance, not the compiler-generated state machine, and static methods emit no `this` entry. While Unity is still paused, `UloopPausePoint.TryGetCapturedValue("this")` returns the live instance reference (for example so a watch expression can read `transform.position`). - `UnityEngine.Object` values additionally carry `UnityObjectKind` (`SceneObject`, `PrefabAsset`, `Asset`, `RuntimeInstance`, or `Destroyed`), `UnityObjectPath`, and `UnityObjectInstanceId`. Use these as handles for the next dig: a `SceneObject` path feeds `get-hierarchy`/`find-game-objects`, an asset path locates the asset, and the InstanceID works with `execute-dynamic-code`. - `CapturedVariablesTruncated=true` means at least one value was clipped to the length cap or the variable-count cap stopped enumeration; clipped values are still present up to the cap. - async and coroutine methods work: hoisted locals and the original `this` fields appear under their normal names. diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointVariableCollector.cs b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointVariableCollector.cs index 8df66c959..74688369d 100644 --- a/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointVariableCollector.cs +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointVariableCollector.cs @@ -18,6 +18,10 @@ internal static class SourcePausePointVariableCollector private static readonly Regex HoistedLocalFieldNamePattern = new(@"^<([^>]+)>5__\d+$", RegexOptions.Compiled); private const string StateMachineOuterThisFieldName = "<>4__this"; + // The synthetic entry name for the paused instance itself. C# identifiers cannot be named + // "this", so this never collides with a captured local, parameter, or field. + private const string ThisEntryName = "this"; + public static UloopPausePointCapturedVariableFrame Collect( object instance, object[] parameterNamesAndValues, object[] localNamesAndValues) { @@ -67,6 +71,20 @@ private static void CollectInstanceFieldVariables( object instance, List entries, HashSet capturedNames, ref bool truncated) { + bool isCompilerGeneratedStateMachine = + Attribute.IsDefined(instance.GetType(), typeof(CompilerGeneratedAttribute)); + + // Normal method: the paused instance itself is `this`, emitted before its fields so the + // count cap keeps prioritizing locals and parameters over instance state. + if (!isCompilerGeneratedStateMachine) + { + if (!TryAppendEntry( + entries, capturedNames, ref truncated, ThisEntryName, UloopCapturedVariableScope.This, instance)) + { + return; + } + } + (object outerThis, bool countCapReached) = CollectDirectFieldVariables( instance, entries, capturedNames, ref truncated, followOuterThis: true); if (countCapReached || outerThis == null) @@ -74,6 +92,14 @@ private static void CollectInstanceFieldVariables( return; } + // Async/coroutine state machine: the real `this` is the hoisted outer instance, never the + // compiler-generated state machine object. Emit it before the outer instance's fields. + if (!TryAppendEntry( + entries, capturedNames, ref truncated, ThisEntryName, UloopCapturedVariableScope.This, outerThis)) + { + return; + } + CollectDirectFieldVariables(outerThis, entries, capturedNames, ref truncated, followOuterThis: false); } diff --git a/Packages/src/Runtime/PausePoints/UloopCapturedVariableScope.cs b/Packages/src/Runtime/PausePoints/UloopCapturedVariableScope.cs index 85f0b9bb0..ed0045b20 100644 --- a/Packages/src/Runtime/PausePoints/UloopCapturedVariableScope.cs +++ b/Packages/src/Runtime/PausePoints/UloopCapturedVariableScope.cs @@ -9,6 +9,7 @@ internal static class UloopCapturedVariableScope public const string Local = "Local"; public const string Parameter = "Parameter"; public const string InstanceField = "InstanceField"; + public const string This = "This"; } } #endif