diff --git a/Assets/Tests/Editor/SourcePausePointPatcher.meta b/Assets/Tests/Editor/SourcePausePointPatcher.meta
new file mode 100644
index 0000000000..576a891518
--- /dev/null
+++ b/Assets/Tests/Editor/SourcePausePointPatcher.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: d00ee049258040938dd8016d55ebf134
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Tests/Editor/SourcePausePointPatcher/Fixtures.meta b/Assets/Tests/Editor/SourcePausePointPatcher/Fixtures.meta
new file mode 100644
index 0000000000..caae881bd2
--- /dev/null
+++ b/Assets/Tests/Editor/SourcePausePointPatcher/Fixtures.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 991529f89d0c4383b309968d96439bbb
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Tests/Editor/SourcePausePointPatcher/Fixtures/PatcherLoopMethodFixture.cs b/Assets/Tests/Editor/SourcePausePointPatcher/Fixtures/PatcherLoopMethodFixture.cs
new file mode 100644
index 0000000000..b32f32d2cd
--- /dev/null
+++ b/Assets/Tests/Editor/SourcePausePointPatcher/Fixtures/PatcherLoopMethodFixture.cs
@@ -0,0 +1,17 @@
+// FROZEN FIXTURE: content and line numbers are asserted by SourcePausePointPatcherTests.
+// Do not reformat or edit this file; add a new fixture file instead.
+namespace io.github.hatayama.UnityCliLoop.Tests.SourcePausePointPatcherFixtures
+{
+ internal static class PatcherLoopMethodFixture
+ {
+ public static int SumUpTo(int count)
+ {
+ int total = 0;
+ for (int i = 0; i < count; i++)
+ {
+ total += i;
+ }
+ return total;
+ }
+ }
+}
diff --git a/Assets/Tests/Editor/SourcePausePointPatcher/Fixtures/PatcherLoopMethodFixture.cs.meta b/Assets/Tests/Editor/SourcePausePointPatcher/Fixtures/PatcherLoopMethodFixture.cs.meta
new file mode 100644
index 0000000000..7fd4437167
--- /dev/null
+++ b/Assets/Tests/Editor/SourcePausePointPatcher/Fixtures/PatcherLoopMethodFixture.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 73af9b49824e4930aa6307b67be70308
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Tests/Editor/SourcePausePointPatcher/Fixtures/PatcherNormalMethodFixture.cs b/Assets/Tests/Editor/SourcePausePointPatcher/Fixtures/PatcherNormalMethodFixture.cs
new file mode 100644
index 0000000000..db33e7699b
--- /dev/null
+++ b/Assets/Tests/Editor/SourcePausePointPatcher/Fixtures/PatcherNormalMethodFixture.cs
@@ -0,0 +1,15 @@
+// FROZEN FIXTURE: content and line numbers are asserted by SourcePausePointPatcherTests.
+// Do not reformat or edit this file; add a new fixture file instead.
+namespace io.github.hatayama.UnityCliLoop.Tests.SourcePausePointPatcherFixtures
+{
+ internal sealed class PatcherNormalMethodFixture
+ {
+ public string Tag = "fixture-instance";
+
+ public int Add(int left, int right)
+ {
+ int sum = left + right;
+ return sum;
+ }
+ }
+}
diff --git a/Assets/Tests/Editor/SourcePausePointPatcher/Fixtures/PatcherNormalMethodFixture.cs.meta b/Assets/Tests/Editor/SourcePausePointPatcher/Fixtures/PatcherNormalMethodFixture.cs.meta
new file mode 100644
index 0000000000..6c45a6e850
--- /dev/null
+++ b/Assets/Tests/Editor/SourcePausePointPatcher/Fixtures/PatcherNormalMethodFixture.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 54454a144d994ed6ad1a90e5c7f34862
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Tests/Editor/SourcePausePointPatcher/Fixtures/PatcherRefStructInstanceMethodFixture.cs b/Assets/Tests/Editor/SourcePausePointPatcher/Fixtures/PatcherRefStructInstanceMethodFixture.cs
new file mode 100644
index 0000000000..36550aadf1
--- /dev/null
+++ b/Assets/Tests/Editor/SourcePausePointPatcher/Fixtures/PatcherRefStructInstanceMethodFixture.cs
@@ -0,0 +1,15 @@
+// FROZEN FIXTURE: content and line numbers are asserted by SourcePausePointPatcherTests.
+// Do not reformat or edit this file; add a new fixture file instead.
+namespace io.github.hatayama.UnityCliLoop.Tests.SourcePausePointPatcherFixtures
+{
+ internal ref struct PatcherRefStructInstanceMethodFixture
+ {
+ public int Value;
+
+ public int Double()
+ {
+ int doubled = Value * 2;
+ return doubled;
+ }
+ }
+}
diff --git a/Assets/Tests/Editor/SourcePausePointPatcher/Fixtures/PatcherRefStructInstanceMethodFixture.cs.meta b/Assets/Tests/Editor/SourcePausePointPatcher/Fixtures/PatcherRefStructInstanceMethodFixture.cs.meta
new file mode 100644
index 0000000000..5daa501d67
--- /dev/null
+++ b/Assets/Tests/Editor/SourcePausePointPatcher/Fixtures/PatcherRefStructInstanceMethodFixture.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 8655a205d5ab240068f65ec1c31cbe2b
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Tests/Editor/SourcePausePointPatcher/Fixtures/PatcherStaticMethodFixture.cs b/Assets/Tests/Editor/SourcePausePointPatcher/Fixtures/PatcherStaticMethodFixture.cs
new file mode 100644
index 0000000000..a2aaca65a4
--- /dev/null
+++ b/Assets/Tests/Editor/SourcePausePointPatcher/Fixtures/PatcherStaticMethodFixture.cs
@@ -0,0 +1,13 @@
+// FROZEN FIXTURE: content and line numbers are asserted by SourcePausePointPatcherTests.
+// Do not reformat or edit this file; add a new fixture file instead.
+namespace io.github.hatayama.UnityCliLoop.Tests.SourcePausePointPatcherFixtures
+{
+ internal static class PatcherStaticMethodFixture
+ {
+ public static int Add(int left, int right)
+ {
+ int sum = left + right;
+ return sum;
+ }
+ }
+}
diff --git a/Assets/Tests/Editor/SourcePausePointPatcher/Fixtures/PatcherStaticMethodFixture.cs.meta b/Assets/Tests/Editor/SourcePausePointPatcher/Fixtures/PatcherStaticMethodFixture.cs.meta
new file mode 100644
index 0000000000..52c6c607b8
--- /dev/null
+++ b/Assets/Tests/Editor/SourcePausePointPatcher/Fixtures/PatcherStaticMethodFixture.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 1c696cf18d9f49278a82e5bf3feb0438
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Tests/Editor/SourcePausePointPatcher/Fixtures/PatcherStructInstanceMethodFixture.cs b/Assets/Tests/Editor/SourcePausePointPatcher/Fixtures/PatcherStructInstanceMethodFixture.cs
new file mode 100644
index 0000000000..1116dc2923
--- /dev/null
+++ b/Assets/Tests/Editor/SourcePausePointPatcher/Fixtures/PatcherStructInstanceMethodFixture.cs
@@ -0,0 +1,15 @@
+// FROZEN FIXTURE: content and line numbers are asserted by SourcePausePointPatcherTests.
+// Do not reformat or edit this file; add a new fixture file instead.
+namespace io.github.hatayama.UnityCliLoop.Tests.SourcePausePointPatcherFixtures
+{
+ internal struct PatcherStructInstanceMethodFixture
+ {
+ public int Value;
+
+ public int Double()
+ {
+ int doubled = Value * 2;
+ return doubled;
+ }
+ }
+}
diff --git a/Assets/Tests/Editor/SourcePausePointPatcher/Fixtures/PatcherStructInstanceMethodFixture.cs.meta b/Assets/Tests/Editor/SourcePausePointPatcher/Fixtures/PatcherStructInstanceMethodFixture.cs.meta
new file mode 100644
index 0000000000..b7312d9460
--- /dev/null
+++ b/Assets/Tests/Editor/SourcePausePointPatcher/Fixtures/PatcherStructInstanceMethodFixture.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: d64b676bfa954416a7bf07586036073e
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Tests/Editor/SourcePausePointPatcher/Fixtures/PatcherTryFinallyMethodFixture.cs b/Assets/Tests/Editor/SourcePausePointPatcher/Fixtures/PatcherTryFinallyMethodFixture.cs
new file mode 100644
index 0000000000..4ad48798b5
--- /dev/null
+++ b/Assets/Tests/Editor/SourcePausePointPatcher/Fixtures/PatcherTryFinallyMethodFixture.cs
@@ -0,0 +1,24 @@
+// FROZEN FIXTURE: content and line numbers are asserted by SourcePausePointPatcherTests.
+// Do not reformat or edit this file; add a new fixture file instead.
+using System;
+
+namespace io.github.hatayama.UnityCliLoop.Tests.SourcePausePointPatcherFixtures
+{
+ internal static class PatcherTryFinallyMethodFixture
+ {
+ public static int Divide(int numerator, int denominator)
+ {
+ int result;
+ try
+ {
+ result = numerator / denominator;
+ }
+ finally
+ {
+ GC.KeepAlive(denominator);
+ }
+
+ return result;
+ }
+ }
+}
diff --git a/Assets/Tests/Editor/SourcePausePointPatcher/Fixtures/PatcherTryFinallyMethodFixture.cs.meta b/Assets/Tests/Editor/SourcePausePointPatcher/Fixtures/PatcherTryFinallyMethodFixture.cs.meta
new file mode 100644
index 0000000000..71c852840e
--- /dev/null
+++ b/Assets/Tests/Editor/SourcePausePointPatcher/Fixtures/PatcherTryFinallyMethodFixture.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 61863cc5216547cf820dd2690907a888
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Tests/Editor/SourcePausePointPatcher/SourcePausePointPatcherTests.cs b/Assets/Tests/Editor/SourcePausePointPatcher/SourcePausePointPatcherTests.cs
new file mode 100644
index 0000000000..299c99a745
--- /dev/null
+++ b/Assets/Tests/Editor/SourcePausePointPatcher/SourcePausePointPatcherTests.cs
@@ -0,0 +1,489 @@
+using System;
+using System.Linq;
+using System.Reflection;
+
+using NUnit.Framework;
+
+using io.github.hatayama.UnityCliLoop.FirstPartyTools;
+using io.github.hatayama.UnityCliLoop.Runtime;
+using io.github.hatayama.UnityCliLoop.Tests.SourcePausePointPatcherFixtures;
+
+namespace io.github.hatayama.UnityCliLoop.Tests.Editor
+{
+ ///
+ /// Verifies the Resolver -> Patcher -> Capture -> Registry pipeline end-to-end against real
+ /// compiled fixture methods, proving the resolved instruction index and IL argument/local
+ /// indexing line up correctly across instance, static, value-type, loop, and try/finally shapes.
+ ///
+ [TestFixture]
+ public sealed class SourcePausePointPatcherTests
+ {
+ private const string FixturesDirectory = "Assets/Tests/Editor/SourcePausePointPatcher/Fixtures/";
+
+ private FakePausePointPauseController _pauseController;
+
+ [SetUp]
+ public void SetUp()
+ {
+ _pauseController = new FakePausePointPauseController();
+ UloopPausePointRegistry.ConfigureForTests(_pauseController, () => DateTime.UtcNow);
+ }
+
+ [TearDown]
+ public void TearDown()
+ {
+ SourcePausePointPatcher.UnpatchAll();
+ UloopPausePointRegistry.ResetForTests();
+ }
+
+ [Test]
+ 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.
+ const string id = "patcher-normal-method";
+ SourcePausePointResolveResult resolveResult = SourcePausePointResolver.Resolve(
+ FixturesDirectory + "PatcherNormalMethodFixture.cs", 11);
+ Assert.That(resolveResult.Success, Is.True);
+
+ UloopPausePointRegistry.Enable(id, 30);
+ SourcePausePointPatchResult patchResult = SourcePausePointPatcher.Patch(id, resolveResult.Resolution);
+ Assert.That(patchResult.Success, Is.True);
+
+ PatcherNormalMethodFixture fixture = new();
+ int sum = fixture.Add(2, 3);
+
+ 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.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"));
+ Assert.That(snapshot.CapturedVariables.First(v => v.Name == "Tag").Value, Is.EqualTo("fixture-instance"));
+ }
+
+ [Test]
+ public void Patch_StaticMethod_PassesNullInstanceAndUsesUnshiftedArgumentIndices()
+ {
+ // Verifies static-method IL argument indexing has no `this`-slot offset, unlike the instance case above.
+ const string id = "patcher-static-method";
+ SourcePausePointResolveResult resolveResult = SourcePausePointResolver.Resolve(
+ FixturesDirectory + "PatcherStaticMethodFixture.cs", 9);
+ Assert.That(resolveResult.Success, Is.True);
+
+ UloopPausePointRegistry.Enable(id, 30);
+ SourcePausePointPatchResult patchResult = SourcePausePointPatcher.Patch(id, resolveResult.Resolution);
+ Assert.That(patchResult.Success, Is.True);
+
+ int sum = PatcherStaticMethodFixture.Add(10, 20);
+
+ Assert.That(sum, Is.EqualTo(30));
+ 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" }));
+ Assert.That(snapshot.CapturedVariables.First(v => v.Name == "left").Value, Is.EqualTo("10"));
+ Assert.That(snapshot.CapturedVariables.First(v => v.Name == "right").Value, Is.EqualTo("20"));
+ }
+
+ [Test]
+ 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.
+ const string id = "patcher-struct-instance-method";
+ SourcePausePointResolveResult resolveResult = SourcePausePointResolver.Resolve(
+ FixturesDirectory + "PatcherStructInstanceMethodFixture.cs", 11);
+ Assert.That(resolveResult.Success, Is.True);
+
+ UloopPausePointRegistry.Enable(id, 30);
+ SourcePausePointPatchResult patchResult = SourcePausePointPatcher.Patch(id, resolveResult.Resolution);
+ Assert.That(patchResult.Success, Is.True);
+
+ PatcherStructInstanceMethodFixture fixture = new() { Value = 7 };
+ int doubled = fixture.Double();
+
+ 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.First(v => v.Name == "Value").Value, Is.EqualTo("7"));
+ }
+
+ [Test]
+ public void Patch_RefStructInstanceMethod_DegradesToNullInstanceAndCapturesLocalsWithWarning()
+ {
+ // Verifies the byref-like (ref struct) declaring-type degradation: boxing a ref
+ // struct's `this` is illegal IL, so the injected instance load must fall back to a
+ // null instance instead. Locals are still captured normally, the instance field
+ // ("Value") is absent since there is no boxed instance to read it from, and Patch
+ // reports a non-empty Warning explaining the degradation.
+ const string id = "patcher-ref-struct-instance-method";
+ SourcePausePointResolveResult resolveResult = SourcePausePointResolver.Resolve(
+ FixturesDirectory + "PatcherRefStructInstanceMethodFixture.cs", 11);
+ Assert.That(resolveResult.Success, Is.True);
+
+ UloopPausePointRegistry.Enable(id, 30);
+ SourcePausePointPatchResult patchResult = SourcePausePointPatcher.Patch(id, resolveResult.Resolution);
+ Assert.That(patchResult.Success, Is.True);
+ Assert.That(patchResult.Warning, Is.Not.Empty);
+
+ PatcherRefStructInstanceMethodFixture fixture = new() { Value = 5 };
+ int doubled = fixture.Double();
+
+ Assert.That(doubled, Is.EqualTo(10));
+ UloopPausePointSnapshot snapshot = UloopPausePointRegistry.GetStatus(id);
+ Assert.That(snapshot.IsHit, Is.True);
+ Assert.That(snapshot.CapturedVariables.Select(v => v.Name), Is.EquivalentTo(new[] { "doubled" }));
+ }
+
+ [Test]
+ public void Patch_LoopMethod_PreservesBackEdgeBranchTargetAndCapturesFirstIterationState()
+ {
+ // Verifies CodeInstruction.labels are moved to the injected sequence's first instruction
+ // when the insertion point is a loop's back-edge branch target, so the loop still runs
+ // correctly; the pause point auto-disarms after its first hit, so only i=0/total=0 is seen.
+ const string id = "patcher-loop-method";
+ SourcePausePointResolveResult resolveResult = SourcePausePointResolver.Resolve(
+ FixturesDirectory + "PatcherLoopMethodFixture.cs", 12);
+ Assert.That(resolveResult.Success, Is.True);
+
+ UloopPausePointRegistry.Enable(id, 30);
+ SourcePausePointPatchResult patchResult = SourcePausePointPatcher.Patch(id, resolveResult.Resolution);
+ Assert.That(patchResult.Success, Is.True);
+
+ int total = PatcherLoopMethodFixture.SumUpTo(4);
+
+ Assert.That(total, Is.EqualTo(6));
+ UloopPausePointSnapshot snapshot = UloopPausePointRegistry.GetStatus(id);
+ Assert.That(snapshot.IsHit, Is.True);
+ Assert.That(snapshot.HitCount, Is.EqualTo(1));
+ Assert.That(snapshot.CapturedVariables.Select(v => v.Name), Is.EquivalentTo(new[] { "i", "total", "count" }));
+ Assert.That(snapshot.CapturedVariables.First(v => v.Name == "i").Value, Is.EqualTo("0"));
+ Assert.That(snapshot.CapturedVariables.First(v => v.Name == "total").Value, Is.EqualTo("0"));
+ Assert.That(snapshot.CapturedVariables.First(v => v.Name == "count").Value, Is.EqualTo("4"));
+ }
+
+ [Test]
+ public void Patch_TryFinallyMethod_PreservesExceptionRegionBoundaryAndExecutesNormally()
+ {
+ // Verifies CodeInstruction.blocks are moved to the injected sequence's first instruction
+ // when the insertion point is the first instruction inside a try block, so the exception
+ // region still starts in the right place and the patched method still executes correctly.
+ const string id = "patcher-try-finally-method";
+ SourcePausePointResolveResult resolveResult = SourcePausePointResolver.Resolve(
+ FixturesDirectory + "PatcherTryFinallyMethodFixture.cs", 14);
+ Assert.That(resolveResult.Success, Is.True);
+
+ UloopPausePointRegistry.Enable(id, 30);
+ SourcePausePointPatchResult patchResult = SourcePausePointPatcher.Patch(id, resolveResult.Resolution);
+ Assert.That(patchResult.Success, Is.True);
+
+ int result = PatcherTryFinallyMethodFixture.Divide(10, 2);
+
+ Assert.That(result, 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[] { "numerator", "denominator", "result" }));
+ Assert.That(snapshot.CapturedVariables.First(v => v.Name == "numerator").Value, Is.EqualTo("10"));
+ Assert.That(snapshot.CapturedVariables.First(v => v.Name == "denominator").Value, Is.EqualTo("2"));
+ }
+
+ [Test]
+ public void Patch_NeverArmedId_SkipsCaptureArrayBuildAndExecutesNormally()
+ {
+ // Verifies the not-armed guard: when Patch is called without ever Enable-ing the id,
+ // the injected IsArmed check short-circuits straight to the original instruction
+ // (skipping the parameter/local array build and the Capture call entirely) and the
+ // method's own result is unaffected.
+ const string id = "patcher-never-armed";
+ SourcePausePointResolveResult resolveResult = SourcePausePointResolver.Resolve(
+ FixturesDirectory + "PatcherStaticMethodFixture.cs", 9);
+ Assert.That(resolveResult.Success, Is.True);
+
+ SourcePausePointPatchResult patchResult = SourcePausePointPatcher.Patch(id, resolveResult.Resolution);
+ Assert.That(patchResult.Success, Is.True);
+
+ int sum = PatcherStaticMethodFixture.Add(4, 5);
+
+ Assert.That(sum, Is.EqualTo(9));
+ Assert.That(UloopPausePointRegistry.GetStatus(id).IsHit, Is.False);
+ }
+
+ [Test]
+ public void Patch_TryFinallyMethod_AtExceptionRegionEndBoundary_ExecutesNormally()
+ {
+ // Verifies the displaced instruction case at the opposite exception-region boundary
+ // from the existing try/finally test above: line 21 is the first instruction after
+ // the whole try/finally construct, so it carries an end-of-region block marker
+ // rather than a begin marker, and must move to the injected sequence the same way.
+ const string id = "patcher-try-finally-end-boundary";
+ SourcePausePointResolveResult resolveResult = SourcePausePointResolver.Resolve(
+ FixturesDirectory + "PatcherTryFinallyMethodFixture.cs", 21);
+ Assert.That(resolveResult.Success, Is.True);
+
+ UloopPausePointRegistry.Enable(id, 30);
+ SourcePausePointPatchResult patchResult = SourcePausePointPatcher.Patch(id, resolveResult.Resolution);
+ Assert.That(patchResult.Success, Is.True);
+
+ int result = PatcherTryFinallyMethodFixture.Divide(10, 2);
+
+ Assert.That(result, 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[] { "numerator", "denominator", "result" }));
+ Assert.That(snapshot.CapturedVariables.First(v => v.Name == "result").Value, Is.EqualTo("5"));
+ }
+
+ [Test]
+ public void Patch_TwoPausePointsInSameMethod_BothHitIndependentlyWithCorrectState()
+ {
+ // Verifies multiple injections into the same method insert correctly regardless of
+ // instruction order, each capturing the local's value at its own point in execution.
+ const string idBeforeAssignment = "patcher-multi-before-assignment";
+ const string idBeforeReturn = "patcher-multi-before-return";
+
+ SourcePausePointResolveResult beforeAssignment = SourcePausePointResolver.Resolve(
+ FixturesDirectory + "PatcherNormalMethodFixture.cs", 11);
+ SourcePausePointResolveResult beforeReturn = SourcePausePointResolver.Resolve(
+ FixturesDirectory + "PatcherNormalMethodFixture.cs", 12);
+ Assert.That(beforeAssignment.Success, Is.True);
+ Assert.That(beforeReturn.Success, Is.True);
+
+ UloopPausePointRegistry.Enable(idBeforeAssignment, 30);
+ UloopPausePointRegistry.Enable(idBeforeReturn, 30);
+ Assert.That(SourcePausePointPatcher.Patch(idBeforeAssignment, beforeAssignment.Resolution).Success, Is.True);
+ Assert.That(SourcePausePointPatcher.Patch(idBeforeReturn, beforeReturn.Resolution).Success, Is.True);
+
+ PatcherNormalMethodFixture fixture = new();
+ int sum = fixture.Add(2, 3);
+
+ Assert.That(sum, Is.EqualTo(5));
+ UloopPausePointSnapshot beforeAssignmentSnapshot = UloopPausePointRegistry.GetStatus(idBeforeAssignment);
+ UloopPausePointSnapshot beforeReturnSnapshot = UloopPausePointRegistry.GetStatus(idBeforeReturn);
+ Assert.That(beforeAssignmentSnapshot.IsHit, Is.True);
+ Assert.That(beforeAssignmentSnapshot.CapturedVariables.First(v => v.Name == "sum").Value, Is.EqualTo("0"));
+ Assert.That(beforeReturnSnapshot.IsHit, Is.True);
+ Assert.That(beforeReturnSnapshot.CapturedVariables.First(v => v.Name == "sum").Value, Is.EqualTo("5"));
+ }
+
+ [Test]
+ public void Patch_SameIdPatchedTwice_IsIdempotentAndStillHits()
+ {
+ // Verifies re-patching the same already-patched id is a no-op per Patch's documented
+ // contract, and the already-injected call site still fires correctly.
+ const string id = "patcher-idempotent";
+ SourcePausePointResolveResult resolveResult = SourcePausePointResolver.Resolve(
+ FixturesDirectory + "PatcherStaticMethodFixture.cs", 9);
+ Assert.That(resolveResult.Success, Is.True);
+
+ UloopPausePointRegistry.Enable(id, 30);
+ Assert.That(SourcePausePointPatcher.Patch(id, resolveResult.Resolution).Success, Is.True);
+ Assert.That(SourcePausePointPatcher.Patch(id, resolveResult.Resolution).Success, Is.True);
+
+ int sum = PatcherStaticMethodFixture.Add(1, 1);
+
+ Assert.That(sum, Is.EqualTo(2));
+ Assert.That(UloopPausePointRegistry.GetStatus(id).IsHit, Is.True);
+ }
+
+ [Test]
+ public void Unpatch_ThenRepatch_RestoresOriginalBehaviorThenCapturesAgain()
+ {
+ // Verifies Unpatch removes the injected call site (no more capture/hit) and a
+ // subsequent Patch with the same id re-injects it correctly.
+ const string id = "patcher-unpatch-repatch";
+ SourcePausePointResolveResult resolveResult = SourcePausePointResolver.Resolve(
+ FixturesDirectory + "PatcherStaticMethodFixture.cs", 9);
+ Assert.That(resolveResult.Success, Is.True);
+
+ UloopPausePointRegistry.Enable(id, 30);
+ Assert.That(SourcePausePointPatcher.Patch(id, resolveResult.Resolution).Success, Is.True);
+ SourcePausePointPatcher.Unpatch(id);
+
+ int sumWhileUnpatched = PatcherStaticMethodFixture.Add(4, 5);
+ Assert.That(sumWhileUnpatched, Is.EqualTo(9));
+ Assert.That(UloopPausePointRegistry.GetStatus(id).IsHit, Is.False);
+
+ UloopPausePointRegistry.Enable(id, 30);
+ Assert.That(SourcePausePointPatcher.Patch(id, resolveResult.Resolution).Success, Is.True);
+ int sumAfterRepatch = PatcherStaticMethodFixture.Add(6, 7);
+
+ Assert.That(sumAfterRepatch, Is.EqualTo(13));
+ Assert.That(UloopPausePointRegistry.GetStatus(id).IsHit, Is.True);
+ }
+
+ [Test]
+ public void Patch_AbstractMethod_ReturnsUnpatchableAbstractFailure()
+ {
+ // Verifies an abstract method (no method body to patch) is rejected before ever calling Harmony.Patch.
+ MethodBase method = typeof(AbstractMethodFixture).GetMethod(nameof(AbstractMethodFixture.DoWork));
+ SourcePausePointPatchResult result = SourcePausePointPatcher.Patch(
+ "patcher-abstract-method", BuildSyntheticResolution(method));
+
+ Assert.That(result.Success, Is.False);
+ Assert.That(result.FailureReason, Is.EqualTo(SourcePausePointPatchFailureReason.UnpatchableAbstract));
+ Assert.That(result.Hint, Is.Not.Empty);
+ }
+
+ [Test]
+ public void Patch_ExternMethod_ReturnsUnpatchableExternFailure()
+ {
+ // Verifies a method with no IL body (an internal call, the same shape a DllImport extern
+ // method has) is rejected.
+ MethodBase method = typeof(object).GetMethod("MemberwiseClone", BindingFlags.NonPublic | BindingFlags.Instance);
+ SourcePausePointPatchResult result = SourcePausePointPatcher.Patch(
+ "patcher-extern-method", BuildSyntheticResolution(method));
+
+ Assert.That(result.Success, Is.False);
+ Assert.That(result.FailureReason, Is.EqualTo(SourcePausePointPatchFailureReason.UnpatchableExtern));
+ }
+
+ [Test]
+ public void Patch_OpenGenericMethod_ReturnsUnpatchableOpenGenericFailure()
+ {
+ // Verifies a method with an unbound generic type parameter of its own is rejected.
+ MethodBase method = typeof(GenericMethodFixture).GetMethod(nameof(GenericMethodFixture.DoWork));
+ SourcePausePointPatchResult result = SourcePausePointPatcher.Patch(
+ "patcher-open-generic-method", BuildSyntheticResolution(method));
+
+ Assert.That(result.Success, Is.False);
+ Assert.That(result.FailureReason, Is.EqualTo(SourcePausePointPatchFailureReason.UnpatchableOpenGeneric));
+ }
+
+ [Test]
+ public void Patch_NonGenericMethodInsideOpenGenericType_ReturnsUnpatchableOpenGenericFailure()
+ {
+ // Verifies a plain (non-generic) method declared inside an open generic type is also
+ // rejected, since its declaring type's unbound T makes it just as unsafe to patch.
+ MethodBase method = typeof(GenericTypeFixture<>).GetMethod(nameof(GenericTypeFixture