From a308357196913a19361b7422a1e3a76e404ead9e Mon Sep 17 00:00:00 2001 From: hatayama Date: Mon, 13 Jul 2026 13:12:47 +0900 Subject: [PATCH 1/3] Add synthetic "this" entry to pause-point captured variables Instance fields were captured but the paused instance itself was not, so a hit could not identify which instance or GameObject it landed on (name, hierarchy path, InstanceID), and scripts without any UnityEngine.Object field gave watch expressions no path to reach the instance via UloopPausePoint.TryGetCapturedValue. - Emit a "this" entry (new Scope "This") after locals/parameters and before instance fields, so the count cap keeps prioritizing locals/params; the existing formatter then attaches UnityObjectKind/Path/InstanceId for UnityEngine.Object instances - For async/coroutine state machines, resolve "this" to the hoisted outer instance via <>4__this and never surface the compiler-generated state machine; static methods emit no entry --- .../SourcePausePointCaptureTests.cs | 132 ++++++++++++++++++ .../SourcePausePointVariableCollector.cs | 26 ++++ .../PausePoints/UloopCapturedVariableScope.cs | 1 + 3 files changed, 159 insertions(+) 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/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 From 4a23aa965d12c39cdfd89fac8b397671ce347feb Mon Sep 17 00:00:00 2001 From: hatayama Date: Mon, 13 Jul 2026 13:12:55 +0900 Subject: [PATCH 2/3] Document the "this" captured-variable entry in the pause-point skill Explain the new This scope, how the this entry identifies the hit instance via UnityObjectPath/UnityObjectInstanceId, and how TryGetCapturedValue("this") exposes the live reference while paused. Skill copies under .agents/ and .claude/ are regenerated from the source skill by the installer, not hand-edited. --- .agents/skills/uloop-pause-point/SKILL.md | 3 ++- .claude/skills/uloop-pause-point/SKILL.md | 3 ++- Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) 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/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. From 0b62649fed98a75ec5a2c324baea4c545b069033 Mon Sep 17 00:00:00 2001 From: hatayama Date: Mon, 13 Jul 2026 13:23:18 +0900 Subject: [PATCH 3/3] Update pause-point test expectations for the "this" entry Five pre-existing tests pinned exact captured-variable name lists from before the synthetic "this" entry existed. Update the expected sequences (and their explanatory comments) to include "this" at its spec'd position between parameters and instance fields; assertions stay exact rather than being loosened. The byref-like struct path is unchanged: the patcher passes a null instance there, so no "this" entry is expected. --- Assets/Tests/Editor/PausePointTests.cs | 5 +++-- .../SourcePausePointVariableFormatterTests.cs | 17 ++++++++++------- .../SourcePausePointPatcherTests.cs | 10 ++++++---- 3 files changed, 19 insertions(+), 13 deletions(-) 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/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")); }