diff --git a/Assets/Tests/Editor/PausePointStatusResponseContractTests.cs b/Assets/Tests/Editor/PausePointStatusResponseContractTests.cs new file mode 100644 index 0000000000..2360a072a3 --- /dev/null +++ b/Assets/Tests/Editor/PausePointStatusResponseContractTests.cs @@ -0,0 +1,125 @@ +using System.Collections.Generic; +using System.IO; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.Infrastructure; +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Test fixture that verifies the Unity pause-point-status response (including captured + /// variables) stays aligned with the shared Go CLI contract. + /// + public sealed class PausePointStatusResponseContractTests + { + private const string SharedContractPath = "tests/contracts/pause_point_status_response_contract.json"; + + [Test] + public void PausePointStatusResponse_WhenSerialized_MatchesSharedContractFieldShape() + { + // Verifies C# does not add, remove, or rename fields (including CapturedVariables) without + // updating the shared CLI contract. + JObject expected = ReadSharedContractFieldShape(); + PausePointStatusResponse response = new() + { + Id = "Assets/Scripts/Enemy.cs:42", + Status = "Hit", + IsEnabled = true, + IsHit = true, + HitCount = 1, + TimeoutSeconds = 30, + Expired = false, + EnabledAtUtc = "2026-06-03T00:00:00.0000000Z", + ElapsedSinceEnabledMilliseconds = 1200, + RemainingMilliseconds = 28800, + Generation = 3, + EditorState = new PausePointStatusEditorState + { + IsPlaying = true, + IsPaused = true, + CapturedAt = "PausePointHit" + }, + FirstHitAtUtc = "2026-06-03T00:00:01.0000000Z", + LastHitAtUtc = "2026-06-03T00:00:01.0000000Z", + FirstHitSequence = 1, + LastHitSequence = 1, + Message = "Pause point hit.", + RecommendedNextAction = "Clear this marker, then re-enable it with the same Id and TimeoutSeconds values.", + CapturedVariables = new List + { + new() + { + Name = "target", + Scope = "InstanceField", + TypeName = "UnityEngine.GameObject", + Value = "Enemy", + UnityObjectKind = "SceneObject", + UnityObjectPath = "MainScene:/Root/Enemy", + UnityObjectInstanceId = -1234 + } + }, + CapturedVariablesTruncated = true + }; + string json = JsonConvert.SerializeObject( + response, + Formatting.None, + UnityCliLoopJsonResponseSerializerSettings.Settings); + JObject actual = NormalizeFieldShape(JObject.Parse(json)); + + Assert.That(JToken.DeepEquals(actual, expected), Is.True, $"Expected {expected} but got {actual}"); + } + + private static JObject ReadSharedContractFieldShape() + { + string projectRoot = UnityCliLoopPathResolver.GetProjectRoot(); + string json = File.ReadAllText(Path.Combine(projectRoot, SharedContractPath)); + return NormalizeFieldShape(JObject.Parse(json)); + } + + private static JObject NormalizeFieldShape(JObject value) + { + JObject shape = new(); + foreach (JProperty property in value.Properties()) + { + shape[property.Name] = NormalizeTokenFieldShape(property.Value); + } + return shape; + } + + private static JToken NormalizeTokenFieldShape(JToken value) + { + if (value is JObject objectValue) + { + return NormalizeFieldShape(objectValue); + } + + if (value is JArray arrayValue) + { + JArray arrayShape = new(); + if (arrayValue.Count > 0) + { + arrayShape.Add(NormalizeTokenFieldShape(arrayValue[0])); + } + return arrayShape; + } + + return new JValue(NormalizeScalarType(value.Type)); + } + + private static string NormalizeScalarType(JTokenType type) + { + return type switch + { + JTokenType.Boolean => "boolean", + JTokenType.Float => "number", + JTokenType.Integer => "number", + JTokenType.Null => "null", + JTokenType.String => "string", + _ => "unknown" + }; + } + } +} diff --git a/Assets/Tests/Editor/PausePointStatusResponseContractTests.cs.meta b/Assets/Tests/Editor/PausePointStatusResponseContractTests.cs.meta new file mode 100644 index 0000000000..861688b845 --- /dev/null +++ b/Assets/Tests/Editor/PausePointStatusResponseContractTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7810f9394bc7741588a05981aec61339 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/PausePointTests.cs b/Assets/Tests/Editor/PausePointTests.cs index 6338740356..ec7e03cd74 100644 --- a/Assets/Tests/Editor/PausePointTests.cs +++ b/Assets/Tests/Editor/PausePointTests.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; using System.Threading; using System.Threading.Tasks; using Newtonsoft.Json.Linq; @@ -10,6 +11,7 @@ using io.github.hatayama.UnityCliLoop.FirstPartyTools; using io.github.hatayama.UnityCliLoop.Infrastructure; using io.github.hatayama.UnityCliLoop.Runtime; +using io.github.hatayama.UnityCliLoop.Tests.PausePointToolsFixtures; namespace io.github.hatayama.UnityCliLoop.Tests.Editor { @@ -39,6 +41,10 @@ public void TearDown() { EditorSettings.enterPlayModeOptionsEnabled = _originalEnterPlayModeOptionsEnabled; EditorSettings.enterPlayModeOptions = _originalEnterPlayModeOptions; + // Tests that enable pause points by File/Line leave a Harmony transpiler attached to + // the fixture method; clear it so later tests re-patch cleanly instead of hitting the + // Patcher's "already patched" no-op path against a previous test's ledger entry. + SourcePausePointPatcher.UnpatchAll(); UloopPausePointRegistry.ResetForTests(); } @@ -299,6 +305,26 @@ public void PausePointStatusBridge_WhenMarkerExpired_ReturnsRecoveryAction() Is.EqualTo("Clear this marker, then re-enable it with the same Id and TimeoutSeconds values.")); } + [Test] + public void PausePointStatusBridge_WhenPausePointHitWithCapturedVariables_ReturnsCapturedVariables() + { + // Verifies the CLI status bridge surfaces captured variables and the truncated flag + // from the registry snapshot, not just the marker/hit bookkeeping fields. + UloopPausePointRegistry.Enable("jump", 30); + UloopCapturedVariable[] capturedVariables = + { + new("speed", UloopCapturedVariableScope.Local, "System.Int32", "5", string.Empty, string.Empty, 0) + }; + UloopPausePointRegistry.HitWithCapturedVariables("jump", capturedVariables, true); + JObject parameters = new() { ["id"] = "jump" }; + + PausePointStatusResponse response = PausePointStatusBridgeCommand.Execute(parameters); + + Assert.That(response.CapturedVariablesTruncated, Is.True); + Assert.That(response.CapturedVariables.Select(v => v.Name), Is.EquivalentTo(new[] { "speed" })); + Assert.That(response.CapturedVariables[0].Value, Is.EqualTo("5")); + } + [Test] public void Enable_WhenSamePausePointWasHit_ClearsLatestHitSnapshot() { @@ -474,6 +500,199 @@ public async Task Enable_WhenPlayModeInactiveAndDomainReloadDisabled_ReturnsNoWa Assert.That(response.Warning, Is.Empty); } + // NOTE: Enabling by File/Line is rejected in Debug-only when + // CompilationPipeline.codeOptimization == CodeOptimization.Release. There is no seam to + // fake that Editor-global static property in an EditMode test, and flipping it for real + // would trigger a recompilation mid-test (forbidden by this repo's Unity Freeze Prevention + // guardrails). This branch is verified manually/E2E instead (see PR 6). + + [Test] + public async Task Enable_WhenFileAndLineResolveToRealMethod_PatchesAndCapturesVariablesOnHit() + { + // 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. + PausePointResponse response = await EnablePausePointByFileLineAsync(FixtureFilePath, FixtureLine); + + Assert.That(response.Success, Is.True); + Assert.That(response.Id, Is.EqualTo($"{FixtureFilePath}:{FixtureLine}")); + Assert.That(response.ResolvedLine, Is.EqualTo(FixtureLine)); + Assert.That(response.ResolvedMethod, Does.Contain("Add")); + + EnableBySourceLocationFixture fixture = new(); + int sum = fixture.Add(2, 3); + + Assert.That(sum, Is.EqualTo(5)); + UloopPausePointSnapshot snapshot = UloopPausePointRegistry.GetStatus(response.Id); + Assert.That(snapshot.IsHit, Is.True); + Assert.That( + snapshot.CapturedVariables.Select(v => v.Name), + Is.EquivalentTo(new[] { "left", "right", "sum", "Tag" })); + } + + [Test] + public async Task Enable_WhenIdAndFileBothProvided_ReturnsValidationFailureResponse() + { + // Verifies Id and File/Line are mutually exclusive. + EnablePausePointTool tool = new(); + JObject parameters = new() + { + ["id"] = "jump", + ["file"] = FixtureFilePath, + ["line"] = FixtureLine, + ["timeoutSeconds"] = 30 + }; + + PausePointResponse response = (PausePointResponse)await tool.ExecuteAsync(parameters, CancellationToken.None); + + Assert.That(response.Success, Is.False); + Assert.That(response.Message, Is.EqualTo("Specify either Id or File and Line, not both.")); + } + + [Test] + public async Task Enable_WhenFileProvidedWithoutLine_ReturnsValidationFailureResponse() + { + // Verifies File requires Line to be provided together. + EnablePausePointTool tool = new(); + JObject parameters = new() + { + ["file"] = FixtureFilePath, + ["timeoutSeconds"] = 30 + }; + + PausePointResponse response = (PausePointResponse)await tool.ExecuteAsync(parameters, CancellationToken.None); + + Assert.That(response.Success, Is.False); + Assert.That(response.Message, Is.EqualTo("File and Line must both be provided together.")); + } + + [Test] + public async Task Enable_WhenLineProvidedWithoutFile_ReturnsValidationFailureResponse() + { + // Verifies Line requires File to be provided together. + EnablePausePointTool tool = new(); + JObject parameters = new() + { + ["line"] = FixtureLine, + ["timeoutSeconds"] = 30 + }; + + PausePointResponse response = (PausePointResponse)await tool.ExecuteAsync(parameters, CancellationToken.None); + + Assert.That(response.Success, Is.False); + Assert.That(response.Message, Is.EqualTo("File and Line must both be provided together.")); + } + + [Test] + public async Task Enable_WhenLineHasNoSequencePoint_ReturnsResolverErrorAsValidationFailure() + { + // Verifies a line with no sequence point on or after it (deliberately far past the + // fixture file's end) surfaces the Resolver's error message as a Success=false + // response instead of throwing. + EnablePausePointTool tool = new(); + JObject parameters = new() + { + ["file"] = FixtureFilePath, + ["line"] = 9999, + ["timeoutSeconds"] = 30 + }; + + PausePointResponse response = (PausePointResponse)await tool.ExecuteAsync(parameters, CancellationToken.None); + + Assert.That(response.Success, Is.False); + Assert.That(response.Message, Does.Contain("No sequence point found on or after line")); + } + + [Test] + public async Task Clear_WhenSpecificIdCleared_CallsPatcherUnpatchSoTheIdCanBeFreshlyRePatched() + { + // Verifies PausePointUseCase.Clear actually calls SourcePausePointPatcher.Unpatch (not + // just the registry): after clearing, re-Patch-ing the same id with a deliberately + // stale Mvid must reach the Patcher's stale-assembly gate again, which only runs when + // the id is no longer in the Patcher's ledger (an "already patched" id short-circuits + // before that gate ever runs, per SourcePausePointPatcherTests coverage from PR 4). + SourcePausePointResolveResult resolveResult = SourcePausePointResolver.Resolve(FixtureFilePath, FixtureLine); + Assert.That(resolveResult.Success, Is.True); + string id = $"{FixtureFilePath}:{FixtureLine}"; + + UloopPausePointRegistry.Enable(id, 30); + Assert.That(SourcePausePointPatcher.Patch(id, resolveResult.Resolution).Success, Is.True); + + ClearPausePointTool clearTool = new(); + JObject clearParameters = new() { ["id"] = id, ["all"] = false }; + await clearTool.ExecuteAsync(clearParameters, CancellationToken.None); + + SourcePausePointPatchResult rePatchResult = SourcePausePointPatcher.Patch(id, WithStaleMvid(resolveResult.Resolution)); + + Assert.That(rePatchResult.Success, Is.False); + Assert.That(rePatchResult.FailureReason, Is.EqualTo(SourcePausePointPatchFailureReason.StaleAssembly)); + } + + [Test] + public async Task ClearAll_WhenSourcePausePointsExist_CallsPatcherUnpatchAllSoIdsCanBeFreshlyRePatched() + { + // Verifies PausePointUseCase.Clear(All) calls SourcePausePointPatcher.UnpatchAll, + // using the same stale-Mvid gate signal as the --id case above to prove the ledger + // entry was actually removed rather than only clearing the registry. + SourcePausePointResolveResult resolveResult = SourcePausePointResolver.Resolve(FixtureFilePath, FixtureLine); + Assert.That(resolveResult.Success, Is.True); + string id = $"{FixtureFilePath}:{FixtureLine}"; + + UloopPausePointRegistry.Enable(id, 30); + Assert.That(SourcePausePointPatcher.Patch(id, resolveResult.Resolution).Success, Is.True); + + ClearPausePointTool clearTool = new(); + JObject clearParameters = new() { ["all"] = true }; + await clearTool.ExecuteAsync(clearParameters, CancellationToken.None); + + SourcePausePointPatchResult rePatchResult = SourcePausePointPatcher.Patch(id, WithStaleMvid(resolveResult.Resolution)); + + Assert.That(rePatchResult.Success, Is.False); + Assert.That(rePatchResult.FailureReason, Is.EqualTo(SourcePausePointPatchFailureReason.StaleAssembly)); + } + + [Test] + public void PausePointStatusBridgeCommand_Clear_CallsPatcherUnpatchSoTheIdCanBeFreshlyRePatched() + { + // Verifies the CLI bridge's Clear (the path Go's wait-for-pause-point timeout + // auto-clear and clear-pause-point-status hit) also calls + // SourcePausePointPatcher.Unpatch, using the same stale-Mvid gate signal as the tool + // tests above to prove the ledger entry was actually removed. + SourcePausePointResolveResult resolveResult = SourcePausePointResolver.Resolve(FixtureFilePath, FixtureLine); + Assert.That(resolveResult.Success, Is.True); + string id = $"{FixtureFilePath}:{FixtureLine}"; + + UloopPausePointRegistry.Enable(id, 30); + Assert.That(SourcePausePointPatcher.Patch(id, resolveResult.Resolution).Success, Is.True); + + JObject bridgeParameters = new() { ["Id"] = id }; + PausePointStatusBridgeCommand.Clear(bridgeParameters); + + SourcePausePointPatchResult rePatchResult = SourcePausePointPatcher.Patch(id, WithStaleMvid(resolveResult.Resolution)); + + Assert.That(rePatchResult.Success, Is.False); + Assert.That(rePatchResult.FailureReason, Is.EqualTo(SourcePausePointPatchFailureReason.StaleAssembly)); + } + + private const string FixtureFilePath = "Assets/Tests/Editor/PausePointToolsFixture.cs"; + private const int FixtureLine = 12; + + private static SourcePausePointResolution WithStaleMvid(SourcePausePointResolution resolution) + { + return new SourcePausePointResolution( + resolution.AssemblyName, + Guid.NewGuid().ToString(), + resolution.MetadataToken, + resolution.MethodDisplayName, + resolution.IsStatic, + resolution.IsDeclaringTypeValueType, + resolution.InstructionIndex, + resolution.IlOffset, + resolution.ResolvedLine, + resolution.Locals, + resolution.Parameters); + } + private static async Task EnablePausePointAsync(string id) { EnablePausePointTool tool = new(); @@ -487,6 +706,20 @@ private static async Task EnablePausePointAsync(string id) return response; } + private static async Task EnablePausePointByFileLineAsync(string file, int line) + { + EnablePausePointTool tool = new(); + JObject parameters = new() + { + ["file"] = file, + ["line"] = line, + ["timeoutSeconds"] = 30 + }; + + PausePointResponse response = (PausePointResponse)await tool.ExecuteAsync(parameters, CancellationToken.None); + return response; + } + /// /// Test double that records pause requests without mutating Unity Editor state. /// diff --git a/Assets/Tests/Editor/PausePointToolsFixture.cs b/Assets/Tests/Editor/PausePointToolsFixture.cs new file mode 100644 index 0000000000..f5ff18eef6 --- /dev/null +++ b/Assets/Tests/Editor/PausePointToolsFixture.cs @@ -0,0 +1,15 @@ +// FROZEN FIXTURE: content and line numbers are asserted by PausePointTests. +// Do not reformat or edit this file; add a new fixture file instead. +namespace io.github.hatayama.UnityCliLoop.Tests.PausePointToolsFixtures +{ + internal sealed class EnableBySourceLocationFixture + { + public string Tag = "source-fixture-instance"; + + public int Add(int left, int right) + { + int sum = left + right; + return sum; + } + } +} diff --git a/Assets/Tests/Editor/PausePointToolsFixture.cs.meta b/Assets/Tests/Editor/PausePointToolsFixture.cs.meta new file mode 100644 index 0000000000..a3fbb1d16a --- /dev/null +++ b/Assets/Tests/Editor/PausePointToolsFixture.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 41dd106203c1f42f0a3fc84e8dc90c55 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/AssemblyInfo.cs b/Packages/src/Editor/FirstPartyTools/PausePoint/AssemblyInfo.cs index 7c0a946fb0..99cb7a0a06 100644 --- a/Packages/src/Editor/FirstPartyTools/PausePoint/AssemblyInfo.cs +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/AssemblyInfo.cs @@ -3,3 +3,6 @@ [assembly: InternalsVisibleTo("UnityCLILoop.Tests.Editor.SourcePausePointResolver")] [assembly: InternalsVisibleTo("UnityCLILoop.Tests.Editor.SourcePausePointCapture")] [assembly: InternalsVisibleTo("UnityCLILoop.Tests.Editor.SourcePausePointPatcher")] +// PausePointTests (Tests.Editor) exercises the Resolver/Patcher pipeline end-to-end and needs +// SourcePausePointPatcher visibility to prove Unpatch actually detaches the ledger entry. +[assembly: InternalsVisibleTo("UnityCLILoop.Tests.Editor")] diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointTools.cs b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointTools.cs index 0cb8a719a5..cf6c04c070 100644 --- a/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointTools.cs +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointTools.cs @@ -2,6 +2,7 @@ using System.Threading; using System.Threading.Tasks; using UnityEditor; +using UnityEditor.Compilation; using io.github.hatayama.UnityCliLoop.Runtime; using io.github.hatayama.UnityCliLoop.ToolContracts; @@ -9,12 +10,18 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools { /// - /// Parameters for enabling one named pause point marker. + /// Parameters for enabling one pause point, either by a hand-written marker id or by + /// resolving a source file:line to a patch location. Exactly one of "Id" or "File"+"Line" + /// must be provided. /// public class EnablePausePointSchema : UnityCliLoopToolSchema { public string Id { get; set; } = string.Empty; + public string File { get; set; } = string.Empty; + + public int Line { get; set; } + public int TimeoutSeconds { get; set; } = UloopPausePointRegistry.DefaultTimeoutSeconds; } @@ -37,6 +44,8 @@ public class PausePointResponse : UnityCliLoopToolResponse // Only explicit validation failures set this to false. public bool Success { get; set; } = true; public string Id { get; set; } = string.Empty; + public int ResolvedLine { get; set; } + public string ResolvedMethod { get; set; } = string.Empty; public string Status { get; set; } = string.Empty; public bool IsEnabled { get; set; } public bool IsHit { get; set; } @@ -172,10 +181,10 @@ internal sealed class PausePointUseCase { public PausePointResponse Enable(EnablePausePointSchema parameters) { - string idError = ValidateId(parameters.Id); - if (idError != null) + string modeError = ValidateEnableMode(parameters); + if (modeError != null) { - return CreateValidationFailure(idError); + return CreateValidationFailure(modeError); } if (parameters.TimeoutSeconds <= 0) @@ -183,6 +192,11 @@ public PausePointResponse Enable(EnablePausePointSchema parameters) return CreateValidationFailure("TimeoutSeconds must be greater than zero."); } + if (!string.IsNullOrWhiteSpace(parameters.File)) + { + return EnableBySourceLocation(parameters); + } + UloopPausePointSnapshot snapshot = UloopPausePointRegistry.Enable(parameters.Id, parameters.TimeoutSeconds); PausePointResponse response = PausePointResponse.FromSnapshot(snapshot); response.Warning = CreateEnableWarning(); @@ -193,6 +207,9 @@ public PausePointResponse Clear(ClearPausePointSchema parameters) { if (parameters.All) { + // Registry.ClearAll unpatches any source pause points via the hook + // SourcePausePointPatcher wires into it; this use case never references the + // Patcher directly. UloopPausePointClearAllResult clearAllResult = UloopPausePointRegistry.ClearAll(); return PausePointResponse.FromClearAll(clearAllResult); } @@ -207,6 +224,89 @@ public PausePointResponse Clear(ClearPausePointSchema parameters) return PausePointResponse.FromSnapshot(snapshot); } + // Resolves File:Line to a patch location via the Resolver, patches it via Harmony, then + // arms the same registry state machine the Id path uses, keyed by the derived source id. + private static PausePointResponse EnableBySourceLocation(EnablePausePointSchema parameters) + { + if (CompilationPipeline.codeOptimization == CodeOptimization.Release) + { + return CreateValidationFailure(SourcePausePointConstants.ReleaseCodeOptimizationRejectionMessage); + } + + SourcePausePointResolveResult resolveResult = SourcePausePointResolver.Resolve(parameters.File, parameters.Line); + if (!resolveResult.Success) + { + return CreateValidationFailure(resolveResult.ErrorMessage); + } + + string id = BuildSourcePausePointId(parameters.File, parameters.Line); + SourcePausePointPatchResult patchResult = SourcePausePointPatcher.Patch(id, resolveResult.Resolution); + if (!patchResult.Success) + { + return new PausePointResponse + { + Success = false, + Message = patchResult.ErrorMessage, + RecommendedNextAction = patchResult.Hint + }; + } + + UloopPausePointSnapshot snapshot = UloopPausePointRegistry.Enable(id, parameters.TimeoutSeconds); + PausePointResponse response = PausePointResponse.FromSnapshot(snapshot); + response.ResolvedLine = resolveResult.Resolution.ResolvedLine; + response.ResolvedMethod = resolveResult.Resolution.MethodDisplayName; + response.Warning = MergeWarnings(CreateEnableWarning(), patchResult.Warning); + return response; + } + + // 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) + { + return SourcePausePointPathNormalizer.ToForwardSlashes(file) + ":" + line; + } + + private static string MergeWarnings(string first, string second) + { + if (string.IsNullOrEmpty(first)) + { + return second; + } + + if (string.IsNullOrEmpty(second)) + { + return first; + } + + return first + " " + second; + } + + // Returns an error message when the Id/File/Line combination fails validation, or null + // when exactly one of "Id" or "File"+"Line" is provided. + private static string ValidateEnableMode(EnablePausePointSchema parameters) + { + bool hasId = !string.IsNullOrWhiteSpace(parameters.Id); + bool hasFile = !string.IsNullOrWhiteSpace(parameters.File); + bool hasLine = parameters.Line > 0; + + if (hasId && (hasFile || hasLine)) + { + return "Specify either Id or File and Line, not both."; + } + + if (!hasId && !hasFile && !hasLine) + { + return "Id must not be null or empty."; + } + + if (!hasId && hasFile != hasLine) + { + return "File and Line must both be provided together."; + } + + return null; + } + // Returns an error message when id fails validation, or null when it is valid. private static string ValidateId(string id) { diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointConstants.cs b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointConstants.cs index 3ba7f752f3..5da5a80e8e 100644 --- a/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointConstants.cs +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointConstants.cs @@ -43,5 +43,13 @@ internal static class SourcePausePointConstants public const string RefStructInstanceNotCapturedWarning = "The declaring type is a ref struct; this-instance fields are not captured " + "(locals and parameters are still captured normally)."; + + // Release code optimization strips most sequence points and hoists/elides locals, so the + // Resolver's PDB-driven lookup cannot reliably find a patch location; rejecting up front + // avoids patching the wrong instruction instead of failing later in a confusing way. + public const string ReleaseCodeOptimizationRejectionMessage = + "Enabling a pause point by file and line requires Debug code optimization. The project " + + "is currently set to Release; switch the Editor's Code Optimization mode to Debug " + + "(the bug icon in the main toolbar) and recompile, then retry."; } } diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointPatcher.cs b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointPatcher.cs index 6676f52bc7..ecb07e0847 100644 --- a/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointPatcher.cs +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointPatcher.cs @@ -32,6 +32,17 @@ internal static class SourcePausePointPatcher private static readonly Dictionary> InjectionsByMethod = new(); private static readonly Dictionary MethodById = new(); + // The registry lives in a Runtime assembly this Editor-only tool assembly may depend on, + // but not the reverse (patching is an outer/implementation concern the registry's inner + // layer must not know about). Wiring these hooks here - rather than having every Clear + // caller reference this class directly - lets the Infrastructure CLI bridge call + // UloopPausePointRegistry.Clear/ClearAll without ever referencing this assembly. + static SourcePausePointPatcher() + { + UloopPausePointRegistry.OnCleared = Unpatch; + UloopPausePointRegistry.OnClearedAll = UnpatchAll; + } + public static SourcePausePointPatchResult Patch(string id, SourcePausePointResolution resolution) { Debug.Assert(!string.IsNullOrEmpty(id), "id must not be null or empty."); diff --git a/Packages/src/Editor/Infrastructure/Api/PausePointStatusBridgeCommand.cs b/Packages/src/Editor/Infrastructure/Api/PausePointStatusBridgeCommand.cs index c5e3bba70f..c8e12fd86c 100644 --- a/Packages/src/Editor/Infrastructure/Api/PausePointStatusBridgeCommand.cs +++ b/Packages/src/Editor/Infrastructure/Api/PausePointStatusBridgeCommand.cs @@ -1,4 +1,6 @@ using System; +using System.Collections.Generic; +using System.Linq; using Newtonsoft.Json.Linq; using io.github.hatayama.UnityCliLoop.Runtime; @@ -23,6 +25,10 @@ public static PausePointStatusResponse Execute(JToken paramsToken) public static PausePointStatusResponse Clear(JToken paramsToken) { string id = ReadId(paramsToken); + // Registry.Clear unpatches any source pause point via the hook + // SourcePausePointPatcher wires into it, so this bridge - which must not reference + // that Editor-only tool assembly directly - never leaves a Harmony injection attached + // after the marker itself reports Cleared. UloopPausePointSnapshot snapshot = UloopPausePointRegistry.Clear(id); return PausePointStatusResponse.FromSnapshot(snapshot); } @@ -62,6 +68,9 @@ public class PausePointStatusResponse : UnityCliLoopToolResponse public int LastHitSequence { get; set; } public string Message { get; set; } = string.Empty; public string RecommendedNextAction { get; set; } = string.Empty; + public IReadOnlyList CapturedVariables { get; set; } = + Array.Empty(); + public bool CapturedVariablesTruncated { get; set; } internal static PausePointStatusResponse FromSnapshot(UloopPausePointSnapshot snapshot) { @@ -89,7 +98,11 @@ internal static PausePointStatusResponse FromSnapshot(UloopPausePointSnapshot sn FirstHitSequence = snapshot.FirstHitSequence, LastHitSequence = snapshot.LastHitSequence, Message = snapshot.Message, - RecommendedNextAction = snapshot.RecommendedNextAction + RecommendedNextAction = snapshot.RecommendedNextAction, + CapturedVariables = snapshot.CapturedVariables + .Select(PausePointStatusCapturedVariable.FromCapturedVariable) + .ToList(), + CapturedVariablesTruncated = snapshot.CapturedVariablesTruncated }; } } @@ -118,4 +131,38 @@ internal static PausePointStatusEditorState FromSnapshot(UloopPausePointEditorSt }; } } + + /// + /// One variable captured at a source pause point, mirroring Runtime.UloopCapturedVariable's + /// fields for the CLI polling bridge response. + /// + public class PausePointStatusCapturedVariable + { + public string Name { get; set; } = string.Empty; + public string Scope { get; set; } = string.Empty; + public string TypeName { get; set; } = string.Empty; + public string Value { get; set; } = string.Empty; + public string UnityObjectKind { get; set; } = string.Empty; + public string UnityObjectPath { get; set; } = string.Empty; + public int UnityObjectInstanceId { get; set; } + + internal static PausePointStatusCapturedVariable FromCapturedVariable(UloopCapturedVariable capturedVariable) + { + if (capturedVariable == null) + { + throw new ArgumentNullException(nameof(capturedVariable)); + } + + return new PausePointStatusCapturedVariable + { + Name = capturedVariable.Name, + Scope = capturedVariable.Scope, + TypeName = capturedVariable.TypeName, + Value = capturedVariable.Value, + UnityObjectKind = capturedVariable.UnityObjectKind, + UnityObjectPath = capturedVariable.UnityObjectPath, + UnityObjectInstanceId = capturedVariable.UnityObjectInstanceId + }; + } + } } diff --git a/Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs b/Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs index ee29dc492b..68cf004502 100644 --- a/Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs +++ b/Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs @@ -26,6 +26,15 @@ internal static class UloopPausePointRegistry // not just the latest hit, to report every marker that interrupted them. private static readonly List _hitSnapshots = new(); + // Source pause points are patched into IL by an Editor-only tool assembly this Runtime + // assembly must not reference directly (patching is an outer/implementation concern; this + // registry is the inner layer). That tool assembly wires its own Unpatch/UnpatchAll into + // these hooks the first time it patches a method, so every Clear/ClearAll caller - + // including the Infrastructure CLI bridge, which also must not reference the tool + // assembly - removes the underlying Harmony patch without knowing it exists. + public static Action OnCleared { get; set; } + public static Action OnClearedAll { get; set; } + public static UloopPausePointSnapshot Enable(string id, int timeoutSeconds) { Debug.Assert(!string.IsNullOrWhiteSpace(id), "id must not be null or empty"); @@ -43,6 +52,8 @@ public static UloopPausePointSnapshot Clear(string id) { Debug.Assert(!string.IsNullOrWhiteSpace(id), "id must not be null or empty"); + OnCleared?.Invoke(id); + DateTime now = NowUtc(); if (!Entries.ContainsKey(id)) { @@ -66,6 +77,8 @@ public static UloopPausePointSnapshot Clear(string id) public static UloopPausePointClearAllResult ClearAll() { + OnClearedAll?.Invoke(); + DateTime now = NowUtc(); int clearedCount = 0; foreach (UloopPausePointEntry entry in Entries.Values) diff --git a/cli/common/tools/default-tools.json b/cli/common/tools/default-tools.json index e434429cca..878f28d279 100644 --- a/cli/common/tools/default-tools.json +++ b/cli/common/tools/default-tools.json @@ -340,13 +340,21 @@ }, { "name": "enable-pause-point", - "description": "Enable a named UloopPausePoint.Pause marker so Unity pauses when that code path is reached", + "description": "Enable a pause point so Unity pauses when that code path is reached, either by a named UloopPausePoint.Pause marker (Id) or by resolving a source file and line (File+Line)", "inputSchema": { "type": "object", "properties": { "Id": { "type": "string", - "description": "Named pause point id passed to UloopPausePoint.Pause" + "description": "Named pause point id passed to UloopPausePoint.Pause. Mutually exclusive with File/Line" + }, + "File": { + "type": "string", + "description": "Project-relative source file path to patch a pause point into. Requires Line; mutually exclusive with Id" + }, + "Line": { + "type": "integer", + "description": "1-based source line to resolve within File. Requires File; mutually exclusive with Id" }, "TimeoutSeconds": { "type": "integer", diff --git a/cli/project-runner/internal/projectrunner/pause_point_status_response_contract_test.go b/cli/project-runner/internal/projectrunner/pause_point_status_response_contract_test.go new file mode 100644 index 0000000000..4ec501f43d --- /dev/null +++ b/cli/project-runner/internal/projectrunner/pause_point_status_response_contract_test.go @@ -0,0 +1,44 @@ +package projectrunner + +import ( + "encoding/json" + "os" + "reflect" + "testing" +) + +const pausePointStatusResponseContractPath = "tests/contracts/pause_point_status_response_contract.json" + +// Verifies the Go pausePointStatusResponse DTO preserves every field in the shared Unity +// PausePointStatusResponse contract, including the CapturedVariables/CapturedVariablesTruncated +// fields introduced for source file:line pause points. +func TestPausePointStatusResponseMatchesSharedContract(t *testing.T) { + fixture := readPausePointStatusResponseContract(t) + + var response pausePointStatusResponse + if err := json.Unmarshal(fixture, &response); err != nil { + t.Fatalf("failed to unmarshal shared pause point status response contract: %v", err) + } + + roundTripped, err := json.Marshal(response) + if err != nil { + t.Fatalf("failed to marshal Go pause point status response: %v", err) + } + + expected := readJSONFieldShape(t, fixture) + actual := readJSONFieldShape(t, roundTripped) + if !reflect.DeepEqual(actual, expected) { + t.Fatalf("pause point status response field shape drifted\nexpected: %#v\nactual: %#v", expected, actual) + } +} + +func readPausePointStatusResponseContract(t *testing.T) []byte { + t.Helper() + + path := findRepoRelativeFile(t, pausePointStatusResponseContractPath) + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("failed to read shared pause point status response contract: %v", err) + } + return data +} diff --git a/cli/project-runner/internal/projectrunner/pause_point_wait.go b/cli/project-runner/internal/projectrunner/pause_point_wait.go index 975756a6ec..5f6956b3cb 100644 --- a/cli/project-runner/internal/projectrunner/pause_point_wait.go +++ b/cli/project-runner/internal/projectrunner/pause_point_wait.go @@ -46,24 +46,26 @@ type pausePointStatusOptions struct { } type pausePointStatusResponse struct { - Id string `json:"Id"` - Status string `json:"Status"` - IsEnabled bool `json:"IsEnabled"` - IsHit bool `json:"IsHit"` - HitCount int `json:"HitCount"` - TimeoutSeconds int `json:"TimeoutSeconds"` - Expired bool `json:"Expired"` - EnabledAtUtc string `json:"EnabledAtUtc"` - ElapsedSinceEnabledMilliseconds int64 `json:"ElapsedSinceEnabledMilliseconds"` - RemainingMilliseconds int64 `json:"RemainingMilliseconds"` - Generation int `json:"Generation"` - EditorState pausePointEditorState `json:"EditorState"` - FirstHitAtUtc string `json:"FirstHitAtUtc"` - LastHitAtUtc string `json:"LastHitAtUtc"` - FirstHitSequence int `json:"FirstHitSequence"` - LastHitSequence int `json:"LastHitSequence"` - Message string `json:"Message"` - RecommendedNextAction string `json:"RecommendedNextAction"` + Id string `json:"Id"` + Status string `json:"Status"` + IsEnabled bool `json:"IsEnabled"` + IsHit bool `json:"IsHit"` + HitCount int `json:"HitCount"` + TimeoutSeconds int `json:"TimeoutSeconds"` + Expired bool `json:"Expired"` + EnabledAtUtc string `json:"EnabledAtUtc"` + ElapsedSinceEnabledMilliseconds int64 `json:"ElapsedSinceEnabledMilliseconds"` + RemainingMilliseconds int64 `json:"RemainingMilliseconds"` + Generation int `json:"Generation"` + EditorState pausePointEditorState `json:"EditorState"` + FirstHitAtUtc string `json:"FirstHitAtUtc"` + LastHitAtUtc string `json:"LastHitAtUtc"` + FirstHitSequence int `json:"FirstHitSequence"` + LastHitSequence int `json:"LastHitSequence"` + Message string `json:"Message"` + RecommendedNextAction string `json:"RecommendedNextAction"` + CapturedVariables []pausePointCapturedVariable `json:"CapturedVariables"` + CapturedVariablesTruncated bool `json:"CapturedVariablesTruncated"` } type pausePointEditorState struct { @@ -72,6 +74,19 @@ type pausePointEditorState struct { CapturedAt string `json:"CapturedAt"` } +// pausePointCapturedVariable mirrors the flat Unity-side +// PausePointStatusCapturedVariable/UloopCapturedVariable DTO field-for-field: one variable +// captured at a source pause point (a local, a parameter, or a `this` instance field). +type pausePointCapturedVariable struct { + Name string `json:"Name"` + Scope string `json:"Scope"` + TypeName string `json:"TypeName"` + Value string `json:"Value"` + UnityObjectKind string `json:"UnityObjectKind"` + UnityObjectPath string `json:"UnityObjectPath"` + UnityObjectInstanceId int `json:"UnityObjectInstanceId"` +} + type pausePointWaitState string const ( diff --git a/cli/project-runner/internal/projectrunner/pause_point_wait_test.go b/cli/project-runner/internal/projectrunner/pause_point_wait_test.go index a82c6cb07b..17410ae7e3 100644 --- a/cli/project-runner/internal/projectrunner/pause_point_wait_test.go +++ b/cli/project-runner/internal/projectrunner/pause_point_wait_test.go @@ -6,6 +6,7 @@ import ( "encoding/json" "os" "path/filepath" + "reflect" "strings" "testing" "time" @@ -923,6 +924,115 @@ func TestRunPausePointStatusReturnsCurrentStatus(t *testing.T) { } } +// Verifies pause-point-status passes captured variables and the truncated flag through to stdout. +func TestRunPausePointStatusReturnsCapturedVariables(t *testing.T) { + originalQuery := queryPausePointStatus + defer func() { + queryPausePointStatus = originalQuery + }() + + queryPausePointStatus = func( + ctx context.Context, + connection unityipc.Connection, + id string, + ) (pausePointStatusResponse, error) { + return pausePointStatusResponse{ + Id: id, + Status: pausePointStatusHit, + IsEnabled: true, + IsHit: true, + CapturedVariables: []pausePointCapturedVariable{ + { + Name: "speed", + Scope: "Local", + TypeName: "System.Int32", + Value: "5", + }, + { + Name: "enemy", + Scope: "InstanceField", + TypeName: "UnityEngine.GameObject", + Value: "Enemy", + UnityObjectKind: "SceneObject", + UnityObjectPath: "MainScene:/Root/Enemy", + UnityObjectInstanceId: -1234, + }, + }, + CapturedVariablesTruncated: true, + }, nil + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := runPausePointStatusCommand( + context.Background(), + unityipc.Connection{ProjectRoot: "/tmp/MyProject"}, + []string{"--id", "jump"}, + &stdout, + &stderr) + + if code != 0 { + t.Fatalf("expected success, got %d with stderr %s", code, stderr.String()) + } + var response pausePointStatusResponse + if err := json.Unmarshal(stdout.Bytes(), &response); err != nil { + t.Fatalf("stdout is not valid JSON: %v\n%s", err, stdout.String()) + } + if !response.CapturedVariablesTruncated { + t.Fatalf("expected CapturedVariablesTruncated to be true: %#v", response) + } + if len(response.CapturedVariables) != 2 { + t.Fatalf("expected 2 captured variables, got %#v", response.CapturedVariables) + } + if response.CapturedVariables[0].Name != "speed" || response.CapturedVariables[0].Value != "5" { + t.Fatalf("first captured variable mismatch: %#v", response.CapturedVariables[0]) + } + second := response.CapturedVariables[1] + if second.Name != "enemy" || second.UnityObjectKind != "SceneObject" || + second.UnityObjectPath != "MainScene:/Root/Enemy" || second.UnityObjectInstanceId != -1234 { + t.Fatalf("second captured variable mismatch: %#v", second) + } +} + +// Verifies pausePointCapturedVariable round-trips through JSON without losing or reordering fields. +func TestPausePointStatusResponseCapturedVariablesJSONRoundTrip(t *testing.T) { + original := pausePointStatusResponse{ + Id: "jump", + Status: pausePointStatusHit, + CapturedVariables: []pausePointCapturedVariable{ + { + Name: "speed", + Scope: "Local", + TypeName: "System.Int32", + Value: "5", + UnityObjectKind: "SceneObject", + UnityObjectPath: "MainScene:/Root/Enemy", + UnityObjectInstanceId: -1234, + }, + }, + CapturedVariablesTruncated: true, + } + + marshaled, err := json.Marshal(original) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + + var roundTripped pausePointStatusResponse + if err := json.Unmarshal(marshaled, &roundTripped); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + + if !reflect.DeepEqual(original.CapturedVariables, roundTripped.CapturedVariables) { + t.Fatalf("captured variables mismatch after round trip: got %#v, want %#v", + roundTripped.CapturedVariables, original.CapturedVariables) + } + if original.CapturedVariablesTruncated != roundTripped.CapturedVariablesTruncated { + t.Fatalf("capturedVariablesTruncated mismatch after round trip: got %v, want %v", + roundTripped.CapturedVariablesTruncated, original.CapturedVariablesTruncated) + } +} + // Verifies pause-point-status derives Expired from Status when older Unity packages omit the bool field. func TestRunPausePointStatusDerivesExpiredFromStatus(t *testing.T) { originalQuery := queryPausePointStatus diff --git a/tests/contracts/pause_point_status_response_contract.json b/tests/contracts/pause_point_status_response_contract.json new file mode 100644 index 0000000000..dbc4145810 --- /dev/null +++ b/tests/contracts/pause_point_status_response_contract.json @@ -0,0 +1,36 @@ +{ + "Id": "Assets/Scripts/Enemy.cs:42", + "Status": "Hit", + "IsEnabled": true, + "IsHit": true, + "HitCount": 1, + "TimeoutSeconds": 30, + "Expired": false, + "EnabledAtUtc": "2026-06-03T00:00:00.0000000Z", + "ElapsedSinceEnabledMilliseconds": 1200, + "RemainingMilliseconds": 28800, + "Generation": 3, + "EditorState": { + "IsPlaying": true, + "IsPaused": true, + "CapturedAt": "PausePointHit" + }, + "FirstHitAtUtc": "2026-06-03T00:00:01.0000000Z", + "LastHitAtUtc": "2026-06-03T00:00:01.0000000Z", + "FirstHitSequence": 1, + "LastHitSequence": 1, + "Message": "Pause point hit.", + "RecommendedNextAction": "Clear this marker, then re-enable it with the same Id and TimeoutSeconds values.", + "CapturedVariables": [ + { + "Name": "target", + "Scope": "InstanceField", + "TypeName": "UnityEngine.GameObject", + "Value": "Enemy", + "UnityObjectKind": "SceneObject", + "UnityObjectPath": "MainScene:/Root/Enemy", + "UnityObjectInstanceId": -1234 + } + ], + "CapturedVariablesTruncated": true +}