diff --git a/Assets/Tests/Editor/PausePointEditedLineRemapTests.cs b/Assets/Tests/Editor/PausePointEditedLineRemapTests.cs
new file mode 100644
index 000000000..b5ce36bd8
--- /dev/null
+++ b/Assets/Tests/Editor/PausePointEditedLineRemapTests.cs
@@ -0,0 +1,360 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+
+using NUnit.Framework;
+
+using io.github.hatayama.UnityCliLoop.FirstPartyTools;
+using io.github.hatayama.UnityCliLoop.Runtime;
+using io.github.hatayama.UnityCliLoop.ToolContracts;
+
+namespace io.github.hatayama.UnityCliLoop.Tests.Editor
+{
+ ///
+ /// Verifies edited-line remap onto a named method's compiled span, including the UseCase route.
+ ///
+ [TestFixture]
+ public sealed class PausePointEditedLineRemapTests
+ {
+ private const string RemapFixtureFile =
+ "Assets/Tests/Editor/SourcePausePointResolver/Fixtures/EditedLineRemapFixture.cs";
+ private const string RoundForwardFixtureFile =
+ "Assets/Tests/Editor/SourcePausePointResolver/Fixtures/EditedLineRemapRoundForwardFixture.cs";
+ private const int UniqueTargetStatementLine = 10;
+ private const int UniqueOtherStatementLine = 16;
+ private const int DuplicateOtherStatementLine = 30;
+ private const int ZeroMatchOtherStatementLine = 36;
+ private const int CommentOtherCommentLine = 16;
+
+ private const string ExpectedUniqueTargetResolvedMethod =
+ "System.Int32 io.github.hatayama.UnityCliLoop.Tests.SourcePausePointResolverFixtures.EditedLineRemapFixture::UniqueTarget(System.Int32)";
+
+ private const string ExpectedRemapWarning =
+ "--line 16 did not resolve in method 'UniqueTarget' against the last compiled source; the edited line's text was found at line 10 inside that method's compiled span, so the marker was placed there. Verify ResolvedLocation, or run 'uloop compile' and re-enable to use edited-file line numbers.";
+
+ private const string ExpectedSuccessWarning =
+ "--line 16 did not resolve in method 'UniqueTarget' against the last compiled source; the edited line's text was found at line 10 inside that method's compiled span, so the marker was placed there. Verify ResolvedLocation, or run 'uloop compile' and re-enable to use edited-file line numbers. The target method body is very small and may be inlined by Mono's JIT into its callers; if HitCount stays 0 while the line demonstrably runs, move the pause point into the calling method.";
+
+ private const string ExpectedZeroMatchFailureMessage =
+ "No method named 'UniqueTarget' with a sequence point on or after line 36 was found. Nearby methods in the last compiled source: 'EditedLineRemapFixture.ZeroMatchOther' spans lines 35-38.";
+
+ private const string ExpectedDuplicateMatchFailureMessage =
+ "No method named 'DuplicateTarget' with a sequence point on or after line 30 was found. Nearby methods in the last compiled source: 'EditedLineRemapFixture.DuplicateOther' spans lines 29-32.";
+
+ private const string ExpectedRoundForwardFailureMessage =
+ "No method named 'CommentTarget' with a sequence point on or after line 16 was found. Nearby methods in the last compiled source: 'EditedLineRemapRoundForwardFixture.CommentOther' spans lines 15-18.";
+
+ private const string ExpectedNoSnapshotFailureMessage =
+ "No method named 'UniqueTarget' with a sequence point on or after line 16 was found. Nearby methods in the last compiled source: 'EditedLineRemapFixture.UniqueOther' spans lines 15-18.";
+
+ private Func _previousSnapshotLoader;
+
+ [SetUp]
+ public void SetUp()
+ {
+ UloopPausePointRegistry.ConfigureForTests(new FakePausePointPauseController(), () => DateTime.UtcNow);
+ _previousSnapshotLoader = HotReloadPausePointCoordination.GetVerifiedSnapshotSourceForFile;
+ HotReloadPausePointCoordination.GetVerifiedSnapshotSourceForFile = null;
+ }
+
+ [TearDown]
+ public void TearDown()
+ {
+ HotReloadPausePointCoordination.GetVerifiedSnapshotSourceForFile = _previousSnapshotLoader;
+ SourcePausePointPatcher.UnpatchAll();
+ UloopPausePointRegistry.ResetForTests();
+ }
+
+ ///
+ /// What: a single trimmed match inside the named method span remaps to that compiled line.
+ ///
+ [Test]
+ public void FindUniqueMatchingCompiledLine_WhenOneMatchInSpan_ReturnsThatLine()
+ {
+ IReadOnlyList compiledSourceLines = new[]
+ {
+ "void Target()",
+ " int uniqueRemapProbe = value + 1;",
+ " return uniqueRemapProbe;",
+ "}",
+ " int uniqueRemapProbe = value + 1;"
+ };
+ IReadOnlyList spans = new[]
+ {
+ new SourcePausePointCompiledMethodSpan(1, 4)
+ };
+
+ int remapped = PausePointEditedLineRemap.FindUniqueMatchingCompiledLineOrZero(
+ "Target",
+ " int uniqueRemapProbe = value + 1;",
+ compiledSourceLines,
+ spans);
+
+ Assert.That(remapped, Is.EqualTo(2));
+ }
+
+ ///
+ /// What: a match that exists only outside the named method span does not remap.
+ ///
+ [Test]
+ public void FindUniqueMatchingCompiledLine_WhenMatchIsOutsideSpan_ReturnsZero()
+ {
+ IReadOnlyList compiledSourceLines = new[]
+ {
+ "void Target()",
+ " return value;",
+ "}",
+ " int uniqueRemapProbe = value + 1;"
+ };
+ IReadOnlyList spans = new[]
+ {
+ new SourcePausePointCompiledMethodSpan(1, 3)
+ };
+
+ int remapped = PausePointEditedLineRemap.FindUniqueMatchingCompiledLineOrZero(
+ "Target",
+ "int uniqueRemapProbe = value + 1;",
+ compiledSourceLines,
+ spans);
+
+ Assert.That(remapped, Is.EqualTo(0));
+ }
+
+ ///
+ /// What: two matches inside the named method span do not remap.
+ ///
+ [Test]
+ public void FindUniqueMatchingCompiledLine_WhenMultipleMatchesInSpan_ReturnsZero()
+ {
+ IReadOnlyList compiledSourceLines = new[]
+ {
+ "void Target()",
+ " _ = 12345;",
+ " int skip = 0;",
+ " _ = 12345;",
+ "}"
+ };
+ IReadOnlyList spans = new[]
+ {
+ new SourcePausePointCompiledMethodSpan(1, 5)
+ };
+
+ int remapped = PausePointEditedLineRemap.FindUniqueMatchingCompiledLineOrZero(
+ "Target",
+ "_ = 12345;",
+ compiledSourceLines,
+ spans);
+
+ Assert.That(remapped, Is.EqualTo(0));
+ }
+
+ ///
+ /// What: overlapping spans that share one matching line count as two hits and do not remap.
+ ///
+ [Test]
+ public void FindUniqueMatchingCompiledLine_WhenOverlappingSpansShareTheMatch_ReturnsZero()
+ {
+ IReadOnlyList compiledSourceLines = new[]
+ {
+ "void Foo()",
+ " int sharedRemapProbe = 1;",
+ " return sharedRemapProbe;",
+ "}"
+ };
+ IReadOnlyList spans = new[]
+ {
+ new SourcePausePointCompiledMethodSpan(1, 4),
+ new SourcePausePointCompiledMethodSpan(2, 4)
+ };
+
+ int remapped = PausePointEditedLineRemap.FindUniqueMatchingCompiledLineOrZero(
+ "Foo",
+ "int sharedRemapProbe = 1;",
+ compiledSourceLines,
+ spans);
+
+ Assert.That(remapped, Is.EqualTo(0));
+ }
+
+ ///
+ /// What: remap is skipped when --method is omitted even if the span has one match.
+ ///
+ [Test]
+ public void FindUniqueMatchingCompiledLine_WhenMethodFilterIsEmpty_ReturnsZero()
+ {
+ IReadOnlyList compiledSourceLines = new[]
+ {
+ " int uniqueRemapProbe = value + 1;"
+ };
+ IReadOnlyList spans = new[]
+ {
+ new SourcePausePointCompiledMethodSpan(1, 1)
+ };
+
+ int remapped = PausePointEditedLineRemap.FindUniqueMatchingCompiledLineOrZero(
+ string.Empty,
+ "int uniqueRemapProbe = value + 1;",
+ compiledSourceLines,
+ spans);
+
+ Assert.That(remapped, Is.EqualTo(0));
+ }
+
+ ///
+ /// What: the remap warning is the planned fixed literal.
+ ///
+ [Test]
+ public void BuildEditedLineRemapWarning_UsesFixedLiteral()
+ {
+ string warning = PausePointEnableWarnings.BuildEditedLineRemapWarning(16, "UniqueTarget", 10);
+
+ Assert.That(warning, Is.EqualTo(ExpectedRemapWarning));
+ }
+
+ ///
+ /// What: UseCase resolve failure remaps onto the unique compiled span line and patches there.
+ ///
+ [Test]
+ public void Enable_WhenEditedLineMatchesOnceInNamedMethodSpan_RemapsAndPatches()
+ {
+ InstallSnapshotFromFile(RemapFixtureFile);
+
+ PausePointResponse response = new PausePointUseCase().Enable(new EnablePausePointSchema
+ {
+ File = RemapFixtureFile,
+ Line = UniqueOtherStatementLine,
+ Method = "UniqueTarget",
+ TimeoutSeconds = 30,
+ Mode = UloopPausePointCaptureMode.SingleShot
+ });
+
+ Assert.That(response.Success, Is.True, response.ErrorCode + " / " + response.Message);
+ Assert.That(response.ResolvedLine, Is.EqualTo(UniqueTargetStatementLine));
+ Assert.That(response.ResolvedLineText, Is.EqualTo("int uniqueRemapProbe = value + 1;"));
+ Assert.That(response.ResolvedMethod, Is.EqualTo(ExpectedUniqueTargetResolvedMethod));
+ Assert.That(
+ response.Id,
+ Is.EqualTo(RemapFixtureFile + ":" + UniqueOtherStatementLine));
+ Assert.That(
+ response.SnapshotTiming,
+ Is.EqualTo(SourcePausePointConstants.PreLineSnapshotTimingNote));
+ Assert.That(response.Warning, Is.EqualTo(ExpectedSuccessWarning));
+ }
+
+ ///
+ /// What: without a verified compiled snapshot the UseCase keeps the existing resolve failure.
+ ///
+ [Test]
+ public void Enable_WhenVerifiedSnapshotIsMissing_KeepsResolveFailure()
+ {
+ PausePointResponse response = new PausePointUseCase().Enable(new EnablePausePointSchema
+ {
+ File = RemapFixtureFile,
+ Line = UniqueOtherStatementLine,
+ Method = "UniqueTarget",
+ TimeoutSeconds = 30,
+ Mode = UloopPausePointCaptureMode.SingleShot
+ });
+
+ Assert.That(response.Success, Is.False);
+ Assert.That(response.ErrorCode, Is.EqualTo(SourcePausePointConstants.ErrorCodeResolveFailed));
+ Assert.That(response.Message, Is.EqualTo(ExpectedNoSnapshotFailureMessage));
+ Assert.That(response.ResolvedLine, Is.EqualTo(0));
+ Assert.That(response.ResolvedMethod, Is.EqualTo(string.Empty));
+ }
+
+ ///
+ /// What: zero matches inside the named method span leave the existing resolve failure unchanged.
+ ///
+ [Test]
+ public void Enable_WhenEditedLineDoesNotMatchNamedMethodSpan_KeepsResolveFailure()
+ {
+ InstallSnapshotFromFile(RemapFixtureFile);
+
+ PausePointResponse response = new PausePointUseCase().Enable(new EnablePausePointSchema
+ {
+ File = RemapFixtureFile,
+ Line = ZeroMatchOtherStatementLine,
+ Method = "UniqueTarget",
+ TimeoutSeconds = 30,
+ Mode = UloopPausePointCaptureMode.SingleShot
+ });
+
+ Assert.That(response.Success, Is.False);
+ Assert.That(response.ErrorCode, Is.EqualTo(SourcePausePointConstants.ErrorCodeResolveFailed));
+ Assert.That(response.Message, Is.EqualTo(ExpectedZeroMatchFailureMessage));
+ Assert.That(response.ResolvedLine, Is.EqualTo(0));
+ Assert.That(response.ResolvedMethod, Is.EqualTo(string.Empty));
+ }
+
+ ///
+ /// What: multiple matches inside the named method span leave the existing resolve failure unchanged.
+ ///
+ [Test]
+ public void Enable_WhenEditedLineMatchesTwiceInNamedMethodSpan_KeepsResolveFailure()
+ {
+ InstallSnapshotFromFile(RemapFixtureFile);
+
+ PausePointResponse response = new PausePointUseCase().Enable(new EnablePausePointSchema
+ {
+ File = RemapFixtureFile,
+ Line = DuplicateOtherStatementLine,
+ Method = "DuplicateTarget",
+ TimeoutSeconds = 30,
+ Mode = UloopPausePointCaptureMode.SingleShot
+ });
+
+ Assert.That(response.Success, Is.False);
+ Assert.That(response.ErrorCode, Is.EqualTo(SourcePausePointConstants.ErrorCodeResolveFailed));
+ Assert.That(response.Message, Is.EqualTo(ExpectedDuplicateMatchFailureMessage));
+ }
+
+ ///
+ /// What: a unique span match that only rounds forward on re-resolve keeps the original failure.
+ ///
+ [Test]
+ public void Enable_WhenRemappedLineRoundsForward_KeepsResolveFailure()
+ {
+ InstallSnapshotFromFile(RoundForwardFixtureFile);
+
+ PausePointResponse response = new PausePointUseCase().Enable(new EnablePausePointSchema
+ {
+ File = RoundForwardFixtureFile,
+ Line = CommentOtherCommentLine,
+ Method = "CommentTarget",
+ TimeoutSeconds = 30,
+ Mode = UloopPausePointCaptureMode.SingleShot
+ });
+
+ Assert.That(response.Success, Is.False);
+ Assert.That(response.ErrorCode, Is.EqualTo(SourcePausePointConstants.ErrorCodeResolveFailed));
+ Assert.That(response.Message, Is.EqualTo(ExpectedRoundForwardFailureMessage));
+ }
+
+ private static void InstallSnapshotFromFile(string projectRelativeFile)
+ {
+ string absoluteFilePath = Path.Combine(
+ UnityCliLoopPathResolver.GetProjectRoot(),
+ projectRelativeFile);
+ string snapshotSource = File.ReadAllText(absoluteFilePath);
+ HotReloadPausePointCoordination.GetVerifiedSnapshotSourceForFile = _ => snapshotSource;
+ }
+
+ private sealed class FakePausePointPauseController : IUloopPausePointPauseController
+ {
+ public int PauseCount { get; private set; }
+ public bool IsPlaying => true;
+ public bool IsPaused => PauseCount > 0;
+
+ public void Pause()
+ {
+ PauseCount++;
+ }
+
+ public void Resume()
+ {
+ PauseCount = 0;
+ }
+ }
+ }
+}
diff --git a/Assets/Tests/Editor/PausePointEditedLineRemapTests.cs.meta b/Assets/Tests/Editor/PausePointEditedLineRemapTests.cs.meta
new file mode 100644
index 000000000..074193e1e
--- /dev/null
+++ b/Assets/Tests/Editor/PausePointEditedLineRemapTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: bbbb27937cd284c2cb47b56b5e6f8bc1
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Tests/Editor/SourcePausePointResolver/Fixtures/EditedLineRemapFixture.cs b/Assets/Tests/Editor/SourcePausePointResolver/Fixtures/EditedLineRemapFixture.cs
new file mode 100644
index 000000000..4da66bf7d
--- /dev/null
+++ b/Assets/Tests/Editor/SourcePausePointResolver/Fixtures/EditedLineRemapFixture.cs
@@ -0,0 +1,40 @@
+// FROZEN FIXTURE: content and line numbers are asserted by PausePointEditedLineRemapTests
+// and SourcePausePointResolverTests.
+// Do not reformat or edit this file; add a new fixture file instead.
+namespace io.github.hatayama.UnityCliLoop.Tests.SourcePausePointResolverFixtures
+{
+ internal sealed class EditedLineRemapFixture
+ {
+ public int UniqueTarget(int value)
+ {
+ int uniqueRemapProbe = value + 1;
+ return uniqueRemapProbe;
+ }
+
+ public int UniqueOther(int value)
+ {
+ int uniqueRemapProbe = value + 1;
+ return uniqueRemapProbe;
+ }
+
+ public int DuplicateTarget(int value)
+ {
+ _ = 12345;
+ int skip = 0;
+ _ = 12345;
+ return skip;
+ }
+
+ public int DuplicateOther(int value)
+ {
+ _ = 12345;
+ return value;
+ }
+
+ public int ZeroMatchOther(int value)
+ {
+ int zeroMatchOnlyHere = value;
+ return zeroMatchOnlyHere;
+ }
+ }
+}
diff --git a/Assets/Tests/Editor/SourcePausePointResolver/Fixtures/EditedLineRemapFixture.cs.meta b/Assets/Tests/Editor/SourcePausePointResolver/Fixtures/EditedLineRemapFixture.cs.meta
new file mode 100644
index 000000000..6cbfa3a80
--- /dev/null
+++ b/Assets/Tests/Editor/SourcePausePointResolver/Fixtures/EditedLineRemapFixture.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 6da72c4c915344fefa04892102fa0d81
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Tests/Editor/SourcePausePointResolver/Fixtures/EditedLineRemapRoundForwardFixture.cs b/Assets/Tests/Editor/SourcePausePointResolver/Fixtures/EditedLineRemapRoundForwardFixture.cs
new file mode 100644
index 000000000..37cc7ac21
--- /dev/null
+++ b/Assets/Tests/Editor/SourcePausePointResolver/Fixtures/EditedLineRemapRoundForwardFixture.cs
@@ -0,0 +1,20 @@
+// FROZEN FIXTURE: content and line numbers are asserted by PausePointEditedLineRemapTests.
+// Do not reformat or edit this file; add a new fixture file instead.
+namespace io.github.hatayama.UnityCliLoop.Tests.SourcePausePointResolverFixtures
+{
+ internal sealed class EditedLineRemapRoundForwardFixture
+ {
+ public int CommentTarget(int value)
+ {
+ // uniqueRemapCommentProbe
+ int x = value;
+ return x;
+ }
+
+ public int CommentOther(int value)
+ {
+ // uniqueRemapCommentProbe
+ return value;
+ }
+ }
+}
diff --git a/Assets/Tests/Editor/SourcePausePointResolver/Fixtures/EditedLineRemapRoundForwardFixture.cs.meta b/Assets/Tests/Editor/SourcePausePointResolver/Fixtures/EditedLineRemapRoundForwardFixture.cs.meta
new file mode 100644
index 000000000..b2c2c4ebe
--- /dev/null
+++ b/Assets/Tests/Editor/SourcePausePointResolver/Fixtures/EditedLineRemapRoundForwardFixture.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 19ff076ff2d1d4b81aa60ff977c675d7
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/Tests/Editor/SourcePausePointResolver/SourcePausePointResolverTests.cs b/Assets/Tests/Editor/SourcePausePointResolver/SourcePausePointResolverTests.cs
index 39b833f42..8d45e6176 100644
--- a/Assets/Tests/Editor/SourcePausePointResolver/SourcePausePointResolverTests.cs
+++ b/Assets/Tests/Editor/SourcePausePointResolver/SourcePausePointResolverTests.cs
@@ -316,6 +316,38 @@ public void Resolve_WhenMethodFilterHasNoSequencePointOnOrAfterLine_FailsWithNam
Is.EqualTo(otherMethod.Resolution.CompiledMethodEndLine));
}
+ ///
+ /// What: FindCompiledMethodSpans returns the named method's compiled span, not a neighbor.
+ ///
+ [Test]
+ public void FindCompiledMethodSpans_WhenMethodFilterMatches_ReturnsThatMethodSpan()
+ {
+ string file = FixturesDirectory + "CompiledMethodSpanFixture.cs";
+ SourcePausePointResolveResult expected = SourcePausePointResolver.Resolve(file, 9, "Target");
+ Assert.That(expected.Success, Is.True, expected.ErrorMessage);
+
+ IReadOnlyList spans =
+ SourcePausePointResolver.FindCompiledMethodSpans(file, "Target");
+
+ Assert.That(spans.Count, Is.EqualTo(1));
+ Assert.That(spans[0].StartLine, Is.EqualTo(expected.Resolution.CompiledMethodStartLine));
+ Assert.That(spans[0].EndLine, Is.EqualTo(expected.Resolution.CompiledMethodEndLine));
+ }
+
+ ///
+ /// What: FindCompiledMethodSpans with no --method returns no spans.
+ ///
+ [Test]
+ public void FindCompiledMethodSpans_WhenMethodFilterIsEmpty_ReturnsNoSpans()
+ {
+ string file = FixturesDirectory + "CompiledMethodSpanFixture.cs";
+
+ IReadOnlyList spans =
+ SourcePausePointResolver.FindCompiledMethodSpans(file, string.Empty);
+
+ Assert.That(spans, Is.Empty);
+ }
+
///
/// What: an empty method filter accepts every compiled method name.
///
diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointCompiledSourceReader.cs b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointCompiledSourceReader.cs
new file mode 100644
index 000000000..fd85254bf
--- /dev/null
+++ b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointCompiledSourceReader.cs
@@ -0,0 +1,18 @@
+using io.github.hatayama.UnityCliLoop.ToolContracts;
+
+namespace io.github.hatayama.UnityCliLoop.FirstPartyTools
+{
+ ///
+ /// Loads last-compiled snapshot source text for pause-point line comparison and remap.
+ ///
+ internal static class PausePointCompiledSourceReader
+ {
+ internal static string LoadSnapshotOrEmpty(string requestedFile)
+ {
+ string normalizedFile = SourcePausePointPathNormalizer.ToForwardSlashes(requestedFile);
+ string snapshotSource =
+ HotReloadPausePointCoordination.GetVerifiedSnapshotSourceForFile?.Invoke(normalizedFile);
+ return snapshotSource ?? string.Empty;
+ }
+ }
+}
diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointCompiledSourceReader.cs.meta b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointCompiledSourceReader.cs.meta
new file mode 100644
index 000000000..52717ab2b
--- /dev/null
+++ b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointCompiledSourceReader.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: e024249aebfd84757b173bd0c5c4e96a
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointEditedLineRemap.cs b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointEditedLineRemap.cs
new file mode 100644
index 000000000..ed732eba6
--- /dev/null
+++ b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointEditedLineRemap.cs
@@ -0,0 +1,151 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+
+namespace io.github.hatayama.UnityCliLoop.FirstPartyTools
+{
+ ///
+ /// Remaps a failed --method/--line resolve onto the unique matching line inside that
+ /// method's compiled span, or leaves the original failure unchanged.
+ ///
+ internal static class PausePointEditedLineRemap
+ {
+ internal static (SourcePausePointResolveResult resolveResult, string remapWarning)
+ ResolveWithEditedLineRemap(string file, int line, string method)
+ {
+ SourcePausePointResolveResult resolveResult = SourcePausePointResolver.Resolve(file, line, method);
+ if (resolveResult.Success)
+ {
+ return (resolveResult, string.Empty);
+ }
+
+ return TryRemapAfterResolveFailure(resolveResult, file, line, method);
+ }
+
+ internal static (SourcePausePointResolveResult resolveResult, string remapWarning)
+ TryRemapAfterResolveFailure(
+ SourcePausePointResolveResult failedResult,
+ string file,
+ int line,
+ string method)
+ {
+ Debug.Assert(failedResult != null, "failedResult must not be null.");
+ Debug.Assert(!failedResult.Success, "TryRemapAfterResolveFailure requires a failed resolve.");
+
+ if (string.IsNullOrEmpty(method) || line <= 0 || string.IsNullOrEmpty(file))
+ {
+ return (failedResult, string.Empty);
+ }
+
+ // Why snapshot-only: the on-disk file can already include uncompiled edits, so
+ // scanning it against the last PDB span can unique-match a later statement onto
+ // an old sequence-point line and still pass the exact-line pin.
+ string compiledSnapshotSource = PausePointCompiledSourceReader.LoadSnapshotOrEmpty(file);
+ if (string.IsNullOrEmpty(compiledSnapshotSource))
+ {
+ return (failedResult, string.Empty);
+ }
+
+ IReadOnlyList spans =
+ SourcePausePointResolver.FindCompiledMethodSpans(file, method);
+ if (spans.Count == 0)
+ {
+ return (failedResult, string.Empty);
+ }
+
+ (bool readOk, string editedLineText) =
+ PausePointCompiledLineComparisonWarnings.ReadEditedLineText(file, line);
+ if (!readOk)
+ {
+ return (failedResult, string.Empty);
+ }
+
+ string[] compiledSourceLines =
+ SourcePausePointSourceLineReader.SplitSourceLines(compiledSnapshotSource);
+ int remappedLine = FindUniqueMatchingCompiledLineOrZero(
+ method,
+ editedLineText,
+ compiledSourceLines,
+ spans);
+ if (remappedLine <= 0)
+ {
+ return (failedResult, string.Empty);
+ }
+
+ SourcePausePointResolveResult retry = SourcePausePointResolver.Resolve(file, remappedLine, method);
+ // Why exact line: Resolve rounds a comment or continuation forward, and the
+ // remap warning claims the marker was placed at remappedLine.
+ if (!retry.Success || retry.Resolution.ResolvedLine != remappedLine)
+ {
+ return (failedResult, string.Empty);
+ }
+
+ return (
+ retry,
+ PausePointEnableWarnings.BuildEditedLineRemapWarning(line, method, remappedLine));
+ }
+
+ // Why every span line: file-wide candidate search stops at three hits and cannot prove
+ // uniqueness; a match outside the named method's compiled span must not count.
+ internal static int FindUniqueMatchingCompiledLineOrZero(
+ string methodFilter,
+ string editedLineText,
+ IReadOnlyList compiledSourceLines,
+ IReadOnlyList spans)
+ {
+ if (string.IsNullOrEmpty(methodFilter)
+ || string.IsNullOrEmpty(editedLineText)
+ || compiledSourceLines == null
+ || spans == null)
+ {
+ return 0;
+ }
+
+ string editedTrimmed = editedLineText.Trim();
+ if (editedTrimmed.Length == 0)
+ {
+ return 0;
+ }
+
+ int matchingLine = 0;
+ int matchCount = 0;
+ for (int spanIndex = 0; spanIndex < spans.Count; spanIndex++)
+ {
+ SourcePausePointCompiledMethodSpan span = spans[spanIndex];
+ if (span == null)
+ {
+ continue;
+ }
+
+ for (int compiledLine = span.StartLine; compiledLine <= span.EndLine; compiledLine++)
+ {
+ if (compiledLine > compiledSourceLines.Count)
+ {
+ continue;
+ }
+
+ string compiledText = compiledSourceLines[compiledLine - 1];
+ if (compiledText == null)
+ {
+ continue;
+ }
+
+ if (!string.Equals(compiledText.Trim(), editedTrimmed, StringComparison.Ordinal))
+ {
+ continue;
+ }
+
+ matchCount++;
+ matchingLine = compiledLine;
+ }
+ }
+
+ if (matchCount != 1)
+ {
+ return 0;
+ }
+
+ return matchingLine;
+ }
+ }
+}
diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointEditedLineRemap.cs.meta b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointEditedLineRemap.cs.meta
new file mode 100644
index 000000000..eee20d31a
--- /dev/null
+++ b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointEditedLineRemap.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: bbfa30397a5384a678d63a25ca7d2f75
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointEnableWarnings.cs b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointEnableWarnings.cs
index 4afd9ed0a..a28e4485a 100644
--- a/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointEnableWarnings.cs
+++ b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointEnableWarnings.cs
@@ -476,6 +476,21 @@ internal static string BuildCompiledLineMapResolveFailureWarningOrEmpty(
SourcePausePointPathNormalizer.ToForwardSlashes(file));
}
+ internal static string BuildEditedLineRemapWarning(
+ int originalLine,
+ string methodName,
+ int remappedLine)
+ {
+ Debug.Assert(originalLine > 0, "originalLine must be a positive 1-based line number.");
+ Debug.Assert(!string.IsNullOrEmpty(methodName), "methodName must not be empty.");
+ Debug.Assert(remappedLine > 0, "remappedLine must be a positive 1-based line number.");
+ return string.Format(
+ SourcePausePointConstants.EditedLineRemapWarningFormat,
+ originalLine,
+ methodName,
+ remappedLine);
+ }
+
internal static string CreateEnableWarning()
{
if (EditorApplication.isPlaying)
diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointUseCase.cs b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointUseCase.cs
index 10f39b3dc..916b9ec84 100644
--- a/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointUseCase.cs
+++ b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointUseCase.cs
@@ -268,8 +268,9 @@ private static PausePointResponse EnableBySourceLocation(EnablePausePointSchema
// map; only the warning text differs.
}
- SourcePausePointResolveResult resolveResult = SourcePausePointResolver.Resolve(
- parameters.File, parameters.Line, parameters.Method);
+ (SourcePausePointResolveResult resolveResult, string editedLineRemapWarning) =
+ PausePointEditedLineRemap.ResolveWithEditedLineRemap(
+ parameters.File, parameters.Line, parameters.Method);
if (!resolveResult.Success)
{
bool hasActiveHotReloadPatches = shimLookup != null;
@@ -286,7 +287,7 @@ private static PausePointResponse EnableBySourceLocation(EnablePausePointSchema
// path, so those reads would change the historical no-patch failure for no gain.
if (hasActiveHotReloadPatches)
{
- string compiledSnapshotSource = LoadCompiledSnapshotSourceOrEmpty(parameters.File);
+ string compiledSnapshotSource = PausePointCompiledSourceReader.LoadSnapshotOrEmpty(parameters.File);
compiledSourceLinesOrNull = string.IsNullOrEmpty(compiledSnapshotSource)
? null
: SourcePausePointSourceLineReader.SplitSourceLines(compiledSnapshotSource);
@@ -349,7 +350,8 @@ private static PausePointResponse EnableBySourceLocation(EnablePausePointSchema
hasActiveHotReloadPatches: shimLookup != null,
compiledMethodStartLine: resolveResult.Resolution.CompiledMethodStartLine,
compiledMethodEndLine: resolveResult.Resolution.CompiledMethodEndLine,
- patchedMethodPdbUnavailableWarning: patchedMethodPdbUnavailableWarning);
+ patchedMethodPdbUnavailableWarning: patchedMethodPdbUnavailableWarning,
+ editedLineRemapWarning: editedLineRemapWarning);
}
private static PausePointResponse FinishEnableBySourceLocation(
@@ -365,7 +367,8 @@ private static PausePointResponse FinishEnableBySourceLocation(
int editedMethodEndLine = 0,
int compiledMethodStartLine = 0,
int compiledMethodEndLine = 0,
- string patchedMethodPdbUnavailableWarning = "")
+ string patchedMethodPdbUnavailableWarning = "",
+ string editedLineRemapWarning = "")
{
string rearmWarning = PausePointEnableWarnings.BuildRearmDiscardWarningOrEmpty(
UloopPausePointRegistry.GetStatus(id));
@@ -384,7 +387,7 @@ private static PausePointResponse FinishEnableBySourceLocation(
bool compareCompiledLineDrift = hasActiveHotReloadPatches && !retargetedToHotReloadPatch;
string compiledSnapshotSource = compareCompiledLineDrift
- ? LoadCompiledSnapshotSourceOrEmpty(parameters.File)
+ ? PausePointCompiledSourceReader.LoadSnapshotOrEmpty(parameters.File)
: string.Empty;
// Why snapshot over disk: the editor file may already include unpatched-line drift, so
// reading disk at the compiled ResolvedLine shows the wrong statement (FB9 empty/mismatch).
@@ -402,7 +405,9 @@ private static PausePointResponse FinishEnableBySourceLocation(
response.ResolvedLineText = resolvedLineText;
response.ResolvedMethod = resolvedMethod;
response.SnapshotTiming = SourcePausePointConstants.PreLineSnapshotTimingNote;
- string enableWarning = PausePointEnableWarnings.CreateEnableWarning();
+ string enableWarning = PausePointEnableWarnings.MergeWarnings(
+ PausePointEnableWarnings.CreateEnableWarning(),
+ editedLineRemapWarning);
enableWarning = PausePointEnableWarnings.MergeWarnings(
enableWarning,
PausePointEnableWarnings.BuildRetargetedToHotReloadPatchWarningOrEmpty(
@@ -531,14 +536,6 @@ private static void LogPhysicsDispatchDiagnostics(string operation, string id, T
});
}
- private static string LoadCompiledSnapshotSourceOrEmpty(string requestedFile)
- {
- string normalizedFile = SourcePausePointPathNormalizer.ToForwardSlashes(requestedFile);
- string snapshotSource =
- HotReloadPausePointCoordination.GetVerifiedSnapshotSourceForFile?.Invoke(normalizedFile);
- return snapshotSource ?? string.Empty;
- }
-
// The derived id must use the originally requested file/line (not the resolved/rounded
// line) so repeated calls at the same requested location stay idempotent.
private static string BuildSourcePausePointId(string file, int line)
diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointCompiledMethodSpan.cs b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointCompiledMethodSpan.cs
new file mode 100644
index 000000000..dc6d76718
--- /dev/null
+++ b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointCompiledMethodSpan.cs
@@ -0,0 +1,21 @@
+using System.Diagnostics;
+
+namespace io.github.hatayama.UnityCliLoop.FirstPartyTools
+{
+ ///
+ /// Inclusive compiled-source line range of one method that matches a --method filter.
+ ///
+ internal sealed class SourcePausePointCompiledMethodSpan
+ {
+ public int StartLine { get; }
+ public int EndLine { get; }
+
+ public SourcePausePointCompiledMethodSpan(int startLine, int endLine)
+ {
+ Debug.Assert(startLine > 0, "startLine must be a positive 1-based line number.");
+ Debug.Assert(endLine >= startLine, "endLine must be on or after startLine.");
+ StartLine = startLine;
+ EndLine = endLine;
+ }
+ }
+}
diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointCompiledMethodSpan.cs.meta b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointCompiledMethodSpan.cs.meta
new file mode 100644
index 000000000..2a10e9879
--- /dev/null
+++ b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointCompiledMethodSpan.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: d13319e661d0d44dcbbcb59e8189e342
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointConstants.cs b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointConstants.cs
index 45c7321c0..c9202e950 100644
--- a/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointConstants.cs
+++ b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointConstants.cs
@@ -390,5 +390,9 @@ internal static class SourcePausePointConstants
// Format: resolved line, resolved method display name.
public const string ClosingBraceResolvedLineWarningFormat =
"--line resolved to the method's closing brace at line {0}. Every return path through {1} reaches this line, including early returns, so captured variables can reflect a different path than the one you meant. To observe one specific path, target a statement line inside that path.";
+
+ // Format: original --line, --method name, remapped compiled line.
+ public const string EditedLineRemapWarningFormat =
+ "--line {0} did not resolve in method '{1}' against the last compiled source; the edited line's text was found at line {2} inside that method's compiled span, so the marker was placed there. Verify ResolvedLocation, or run 'uloop compile' and re-enable to use edited-file line numbers.";
}
}
diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointResolver.cs b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointResolver.cs
index c7ced427a..fd1994d01 100644
--- a/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointResolver.cs
+++ b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointResolver.cs
@@ -294,6 +294,50 @@ private static (int startLine, int endLine) CollectCompiledMethodSpan(
return (startLine, endLine);
}
+ // Why a dedicated API: FindNearbyCompiledMethods does not take methodFilter and cannot
+ // promise the named method's compiled span.
+ internal static IReadOnlyList FindCompiledMethodSpans(
+ string projectRelativeFilePath,
+ string methodFilter)
+ {
+ Debug.Assert(!string.IsNullOrEmpty(projectRelativeFilePath), "projectRelativeFilePath must not be null or empty.");
+ if (string.IsNullOrEmpty(methodFilter))
+ {
+ return Array.Empty();
+ }
+
+ return WithCompiledModuleOrDefault(
+ projectRelativeFilePath,
+ (module, normalizedInputPath) =>
+ CollectCompiledMethodSpans(module, normalizedInputPath, methodFilter),
+ Array.Empty());
+ }
+
+ private static IReadOnlyList CollectCompiledMethodSpans(
+ ModuleDefinition module,
+ string normalizedInputPath,
+ string methodFilter)
+ {
+ List spans = new List();
+ foreach (MethodDefinition method in EnumerateMethodsInModule(module))
+ {
+ if (!method.HasBody || !CompiledMethodMatchesFilter(methodFilter, method))
+ {
+ continue;
+ }
+
+ (int startLine, int endLine) = CollectCompiledMethodSpan(method, normalizedInputPath);
+ if (startLine <= 0 || endLine <= 0)
+ {
+ continue;
+ }
+
+ spans.Add(new SourcePausePointCompiledMethodSpan(startLine, endLine));
+ }
+
+ return spans;
+ }
+
// Why a file:line entry: the resolver test assembly cannot take a Cecil
// ModuleDefinition dependency, but TakeAtMostTwo only runs on this walk.
internal static IReadOnlyList FindNearbyCompiledMethodsInFile(
@@ -303,11 +347,25 @@ internal static IReadOnlyList FindNearbyCo
Debug.Assert(!string.IsNullOrEmpty(projectRelativeFilePath), "projectRelativeFilePath must not be null or empty.");
Debug.Assert(line > 0, "line must be a positive 1-based line number.");
+ return WithCompiledModuleOrDefault(
+ projectRelativeFilePath,
+ (module, normalizedInputPath) =>
+ FindNearbyCompiledMethods(module, normalizedInputPath, line),
+ Array.Empty());
+ }
+
+ private static TResult WithCompiledModuleOrDefault(
+ string projectRelativeFilePath,
+ Func read,
+ TResult fallback)
+ {
+ Debug.Assert(read != null, "read must not be null.");
+
string normalizedInputPath = SourcePausePointPathNormalizer.ToForwardSlashes(projectRelativeFilePath);
string rawAssemblyName = CompilationPipeline.GetAssemblyNameFromScriptPath(normalizedInputPath);
if (string.IsNullOrEmpty(rawAssemblyName))
{
- return Array.Empty();
+ return fallback;
}
string assemblyName = Path.GetFileNameWithoutExtension(rawAssemblyName);
@@ -322,7 +380,7 @@ internal static IReadOnlyList FindNearbyCo
assemblyName + SourcePausePointConstants.DebugSymbolsExtension);
if (!File.Exists(dllPath) || !File.Exists(pdbPath))
{
- return Array.Empty();
+ return fallback;
}
using FileStream dllStream = File.Open(dllPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
@@ -336,7 +394,7 @@ internal static IReadOnlyList FindNearbyCo
};
using AssemblyDefinition assemblyDefinition =
AssemblyDefinition.ReadAssembly(dllStream, readerParameters);
- return FindNearbyCompiledMethods(assemblyDefinition.MainModule, normalizedInputPath, line);
+ return read(assemblyDefinition.MainModule, normalizedInputPath);
}
// Why a separate walk from FindClosestSequencePointOnOrAfterLine: that search only