Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .agents/skills/uloop-pause-point/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion .claude/skills/uloop-pause-point/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 3 additions & 2 deletions Assets/Tests/Editor/PausePointTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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]
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<object>(), Array.Empty<object>());

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<string> 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<object>(), Array.Empty<object>());

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<object>(), Array.Empty<object>());

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<object>(), 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<object>(), 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("<RunAsync>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<int> 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; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand All @@ -115,7 +116,7 @@ public void Format_WhenOneValueExceedsMaxLength_StillCapturesSubsequentVariables
(List<UloopCapturedVariable> 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);
Expand Down Expand Up @@ -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 ("<Prop>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
// ("<Prop>k__BackingField") are skipped.
InstanceFieldFixture instance = new() { PublicField = 9 };
instance.Prop = "hello";
object[] locals = { "local", 1 };
Expand All @@ -153,9 +155,10 @@ public void Format_WithInstanceFields_CapturesThemAfterLocalsAndParameters()
(List<UloopCapturedVariable> 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);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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"));
Expand Down Expand Up @@ -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);
Expand All @@ -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"));
}

Expand Down
3 changes: 2 additions & 1 deletion Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading