diff --git a/.agents/skills/uloop-execute-dynamic-code/SKILL.md b/.agents/skills/uloop-execute-dynamic-code/SKILL.md index dd28613ea..d5d4edc3d 100644 --- a/.agents/skills/uloop-execute-dynamic-code/SKILL.md +++ b/.agents/skills/uloop-execute-dynamic-code/SKILL.md @@ -11,7 +11,7 @@ Run focused C# snippets in the active Unity Editor with `uloop execute-dynamic-c For basic selected GameObject discovery or property inspection, use `find-game-objects --search-mode Selected` before this tool. Use this tool after the built-in inspection tools are not enough or when you need to modify Unity state. -This tool can inspect reachable Unity state, such as GameObjects, components, public properties, static values, and method results. It cannot directly read local variables or intermediate calculations inside an already-running method. When those values matter, enable a source pause point on that line instead (`uloop enable-pause-point --file --line `, see the `uloop-pause-point` skill): the hit response's `CapturedVariables` already contains the locals, parameters, and instance fields at that line, with no code edit or recompile. Do not try to reconstruct those values with execute-dynamic-code. +This tool can inspect reachable Unity state, such as GameObjects, components, public properties, static values, and method results. It cannot directly read local variables or intermediate calculations inside an already-running method. When those values matter, enable a source pause point on that line instead (`uloop enable-pause-point --file --line `, see the `uloop-pause-point` skill): the hit response's `CapturedVariables` already contains the locals, parameters, and instance fields at that line, with no code edit or recompile. `CapturedVariables` is a pre-line snapshot; this tool during the pause sees the interrupted method's post-interrupt state instead. While Unity stays paused on the hit, use `UloopPausePoint.TryGetCapturedValue(name)` (plus `GetCapturedNames()` / `GetCapturedPausePointId()`) for live captured references such as collections. Do not try to reconstruct pre-line locals with execute-dynamic-code alone. ## Parameters diff --git a/.agents/skills/uloop-pause-point/SKILL.md b/.agents/skills/uloop-pause-point/SKILL.md index dba1bc4e9..adc303b84 100644 --- a/.agents/skills/uloop-pause-point/SKILL.md +++ b/.agents/skills/uloop-pause-point/SKILL.md @@ -15,6 +15,8 @@ Use this small loop for one representative frame you care about. No source edit uloop enable-pause-point --file Assets/Scripts/Enemy.cs --line 42 --timeout-seconds 30 ``` +`--timeout-seconds` on enable starts the marker lifetime clock at enable time, not when you later run `await-pause-point`. + The response returns the derived marker `Id` (`Assets/Scripts/Enemy.cs:42`), the `ResolvedLine` that was actually patched, and the `ResolvedMethod`. When the requested line has no executable statement, the pause point rounds forward to the next executable line — check `ResolvedLine` when precision matters. Use the returned `Id` for every follow-up command. 2. Trigger the action with a `simulate-*` command. @@ -33,6 +35,7 @@ uloop await-pause-point --id "Assets/Scripts/Enemy.cs:42" --timeout-seconds 30 Every hit response embeds `CapturedVariables`: the method's in-scope locals, its parameters, and the `this` instance fields, captured at the exact moment execution reached the patched line. Values are point-in-time strings, not live references, so they stay valid as evidence even after Unity resumes. - The snapshot is taken **before** the resolved line executes, exactly like an IDE breakpoint on that line. To inspect a value after an assignment, place the pause point on the following line. +- `execute-dynamic-code` during the pause sees the interrupted method's **post-interrupt** state, not this pre-line snapshot. Use `CapturedVariables` for pre-line evidence; use the raw capture API below when you need live references while paused. - `Scope` is `Local`, `Parameter`, or `InstanceField`. - `UnityEngine.Object` values additionally carry `UnityObjectKind` (`SceneObject`, `PrefabAsset`, `Asset`, `RuntimeInstance`, or `Destroyed`), `UnityObjectPath`, and `UnityObjectInstanceId`. Use these as handles for the next dig: a `SceneObject` path feeds `get-hierarchy`/`find-game-objects`, an asset path locates the asset, and the InstanceID works with `execute-dynamic-code`. - `CapturedVariablesTruncated=true` means at least one value was clipped to the length cap or the variable-count cap stopped enumeration; clipped values are still present up to the cap. @@ -43,6 +46,26 @@ Read `EvidenceSummary` first when it is present. It groups `EditorState`, pause Use `Generation`, `EnabledAtUtc`, and the hit sequence fields from the hit or status response to tell a fresh marker from stale evidence with the same id. `RemainingMilliseconds` and `Expired` are returned directly so you do not need to infer marker lifetime from elapsed time. +## Raw Capture While Paused + +While Unity is paused on a hit, `execute-dynamic-code` can read live captured references through `UloopPausePoint`: + +- `TryGetCapturedValue(string name)` returns `(bool Found, object Value)` for the latest hit only. When multiple captured variables share the same name, the last one wins. +- `GetCapturedNames()` lists captured variable names from that snapshot. +- `GetCapturedPausePointId()` returns the pause-point id for the held snapshot. + +The holder clears when Unity resumes (not when you `Step` while still paused), when the matching pause point is cleared, when a new hit replaces the snapshot, or when PlayMode exits. After resume, `TryGetCapturedValue` returns `Found=false`. + +## Marker Types + +- `uloop enable-pause-point --file --line` patches the already-compiled method at a source line. No code edit or recompile is required. +- `UloopPausePoint.Pause(id)` is a hand-written marker call for code paths that file:line patching cannot reach. Pair it with `uloop enable-pause-point --id ` (no `--file`/`--line`). +- For ordinary file:line debugging you do not need `UloopPausePoint.Pause` in source. Prefer CLI enable when the target line can be patched. + +## Hit Preconditions + +A pause point hits only when control flow reaches the patched line (or the `Pause(id)` call). `simulate-keyboard` returning `PressEdgeObserved=true` means the input edge was observed, not that your target game logic has reached the pause line yet. + ## When To Use - Use this as the standard frame proof for state-changing PlayMode/E2E simulated input, physics, or UI transitions. diff --git a/.claude/skills/uloop-execute-dynamic-code/SKILL.md b/.claude/skills/uloop-execute-dynamic-code/SKILL.md index dd28613ea..d5d4edc3d 100644 --- a/.claude/skills/uloop-execute-dynamic-code/SKILL.md +++ b/.claude/skills/uloop-execute-dynamic-code/SKILL.md @@ -11,7 +11,7 @@ Run focused C# snippets in the active Unity Editor with `uloop execute-dynamic-c For basic selected GameObject discovery or property inspection, use `find-game-objects --search-mode Selected` before this tool. Use this tool after the built-in inspection tools are not enough or when you need to modify Unity state. -This tool can inspect reachable Unity state, such as GameObjects, components, public properties, static values, and method results. It cannot directly read local variables or intermediate calculations inside an already-running method. When those values matter, enable a source pause point on that line instead (`uloop enable-pause-point --file --line `, see the `uloop-pause-point` skill): the hit response's `CapturedVariables` already contains the locals, parameters, and instance fields at that line, with no code edit or recompile. Do not try to reconstruct those values with execute-dynamic-code. +This tool can inspect reachable Unity state, such as GameObjects, components, public properties, static values, and method results. It cannot directly read local variables or intermediate calculations inside an already-running method. When those values matter, enable a source pause point on that line instead (`uloop enable-pause-point --file --line `, see the `uloop-pause-point` skill): the hit response's `CapturedVariables` already contains the locals, parameters, and instance fields at that line, with no code edit or recompile. `CapturedVariables` is a pre-line snapshot; this tool during the pause sees the interrupted method's post-interrupt state instead. While Unity stays paused on the hit, use `UloopPausePoint.TryGetCapturedValue(name)` (plus `GetCapturedNames()` / `GetCapturedPausePointId()`) for live captured references such as collections. Do not try to reconstruct pre-line locals with execute-dynamic-code alone. ## Parameters diff --git a/.claude/skills/uloop-pause-point/SKILL.md b/.claude/skills/uloop-pause-point/SKILL.md index dba1bc4e9..adc303b84 100644 --- a/.claude/skills/uloop-pause-point/SKILL.md +++ b/.claude/skills/uloop-pause-point/SKILL.md @@ -15,6 +15,8 @@ Use this small loop for one representative frame you care about. No source edit uloop enable-pause-point --file Assets/Scripts/Enemy.cs --line 42 --timeout-seconds 30 ``` +`--timeout-seconds` on enable starts the marker lifetime clock at enable time, not when you later run `await-pause-point`. + The response returns the derived marker `Id` (`Assets/Scripts/Enemy.cs:42`), the `ResolvedLine` that was actually patched, and the `ResolvedMethod`. When the requested line has no executable statement, the pause point rounds forward to the next executable line — check `ResolvedLine` when precision matters. Use the returned `Id` for every follow-up command. 2. Trigger the action with a `simulate-*` command. @@ -33,6 +35,7 @@ uloop await-pause-point --id "Assets/Scripts/Enemy.cs:42" --timeout-seconds 30 Every hit response embeds `CapturedVariables`: the method's in-scope locals, its parameters, and the `this` instance fields, captured at the exact moment execution reached the patched line. Values are point-in-time strings, not live references, so they stay valid as evidence even after Unity resumes. - The snapshot is taken **before** the resolved line executes, exactly like an IDE breakpoint on that line. To inspect a value after an assignment, place the pause point on the following line. +- `execute-dynamic-code` during the pause sees the interrupted method's **post-interrupt** state, not this pre-line snapshot. Use `CapturedVariables` for pre-line evidence; use the raw capture API below when you need live references while paused. - `Scope` is `Local`, `Parameter`, or `InstanceField`. - `UnityEngine.Object` values additionally carry `UnityObjectKind` (`SceneObject`, `PrefabAsset`, `Asset`, `RuntimeInstance`, or `Destroyed`), `UnityObjectPath`, and `UnityObjectInstanceId`. Use these as handles for the next dig: a `SceneObject` path feeds `get-hierarchy`/`find-game-objects`, an asset path locates the asset, and the InstanceID works with `execute-dynamic-code`. - `CapturedVariablesTruncated=true` means at least one value was clipped to the length cap or the variable-count cap stopped enumeration; clipped values are still present up to the cap. @@ -43,6 +46,26 @@ Read `EvidenceSummary` first when it is present. It groups `EditorState`, pause Use `Generation`, `EnabledAtUtc`, and the hit sequence fields from the hit or status response to tell a fresh marker from stale evidence with the same id. `RemainingMilliseconds` and `Expired` are returned directly so you do not need to infer marker lifetime from elapsed time. +## Raw Capture While Paused + +While Unity is paused on a hit, `execute-dynamic-code` can read live captured references through `UloopPausePoint`: + +- `TryGetCapturedValue(string name)` returns `(bool Found, object Value)` for the latest hit only. When multiple captured variables share the same name, the last one wins. +- `GetCapturedNames()` lists captured variable names from that snapshot. +- `GetCapturedPausePointId()` returns the pause-point id for the held snapshot. + +The holder clears when Unity resumes (not when you `Step` while still paused), when the matching pause point is cleared, when a new hit replaces the snapshot, or when PlayMode exits. After resume, `TryGetCapturedValue` returns `Found=false`. + +## Marker Types + +- `uloop enable-pause-point --file --line` patches the already-compiled method at a source line. No code edit or recompile is required. +- `UloopPausePoint.Pause(id)` is a hand-written marker call for code paths that file:line patching cannot reach. Pair it with `uloop enable-pause-point --id ` (no `--file`/`--line`). +- For ordinary file:line debugging you do not need `UloopPausePoint.Pause` in source. Prefer CLI enable when the target line can be patched. + +## Hit Preconditions + +A pause point hits only when control flow reaches the patched line (or the `Pause(id)` call). `simulate-keyboard` returning `PressEdgeObserved=true` means the input edge was observed, not that your target game logic has reached the pause line yet. + ## When To Use - Use this as the standard frame proof for state-changing PlayMode/E2E simulated input, physics, or UI transitions. diff --git a/Assets/Tests/Editor/CollectionPreviewPausePointFixture.cs b/Assets/Tests/Editor/CollectionPreviewPausePointFixture.cs new file mode 100644 index 000000000..c31555e2a --- /dev/null +++ b/Assets/Tests/Editor/CollectionPreviewPausePointFixture.cs @@ -0,0 +1,14 @@ +// Fixture for collection JSON preview smoke verification. Line numbers are referenced by manual E2E checks. +namespace io.github.hatayama.UnityCliLoop.Tests.PausePointToolsFixtures +{ + using System.Collections.Generic; + + public sealed class CollectionPreviewPausePointFixture + { + public List BuildScores() + { + List scores = new() { 10, 20, 30 }; + return scores; + } + } +} diff --git a/Assets/Tests/Editor/CollectionPreviewPausePointFixture.cs.meta b/Assets/Tests/Editor/CollectionPreviewPausePointFixture.cs.meta new file mode 100644 index 000000000..7f2bb6343 --- /dev/null +++ b/Assets/Tests/Editor/CollectionPreviewPausePointFixture.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e36b3cb441daf4ca288096a49697d943 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/PausePointStatusResponseContractTests.cs b/Assets/Tests/Editor/PausePointStatusResponseContractTests.cs index 2360a072a..3e56fb8de 100644 --- a/Assets/Tests/Editor/PausePointStatusResponseContractTests.cs +++ b/Assets/Tests/Editor/PausePointStatusResponseContractTests.cs @@ -61,7 +61,10 @@ public void PausePointStatusResponse_WhenSerialized_MatchesSharedContractFieldSh UnityObjectInstanceId = -1234 } }, - CapturedVariablesTruncated = true + CapturedVariablesTruncated = true, + ClearedReason = "", + StatusBeforeClear = "", + LateHitDiscardedAfterClear = false }; string json = JsonConvert.SerializeObject( response, diff --git a/Assets/Tests/Editor/PausePointTests.cs b/Assets/Tests/Editor/PausePointTests.cs index e16d4192f..77e188272 100644 --- a/Assets/Tests/Editor/PausePointTests.cs +++ b/Assets/Tests/Editor/PausePointTests.cs @@ -7,6 +7,8 @@ using Newtonsoft.Json.Linq; using NUnit.Framework; using UnityEditor; +using UnityEngine; +using UnityEngine.TestTools; using io.github.hatayama.UnityCliLoop.FirstPartyTools; using io.github.hatayama.UnityCliLoop.Infrastructure; @@ -137,6 +139,66 @@ public void Enable_WhenSamePausePointWasHit_RemovesItFromHitSnapshots() Assert.That(UloopPausePointRegistry.GetHitSnapshots(), Is.Empty); } + [Test] + public void Clear_WhenAlreadyClearedByRunTests_PreservesOriginalReason() + { + // Verifies a later explicit clear does not erase run-tests auto-clear evidence. + UloopPausePointRegistry.Enable("jump", 30); + UloopPausePointRegistry.ClearAll(UloopPausePointClearedReason.RunTestsAutoClear); + + UloopPausePointRegistry.Clear("jump"); + UloopPausePointSnapshot snapshot = UloopPausePointRegistry.GetStatus("jump"); + + Assert.That(snapshot.Status, Is.EqualTo(UloopPausePointStatus.Cleared)); + Assert.That(snapshot.ClearedReason, Is.EqualTo(UloopPausePointClearedReason.RunTestsAutoClear)); + Assert.That(snapshot.StatusBeforeClear, Is.EqualTo(UloopPausePointStatus.Enabled)); + } + + [Test] + public void ClearAll_WhenEnabled_SetsRunTestsAutoClearReason() + { + // Verifies run-tests-style ClearAll is visible on status after wiping an enabled marker. + UloopPausePointRegistry.Enable("jump", 30); + + UloopPausePointRegistry.ClearAll(UloopPausePointClearedReason.RunTestsAutoClear); + UloopPausePointSnapshot snapshot = UloopPausePointRegistry.GetStatus("jump"); + + Assert.That(snapshot.Status, Is.EqualTo(UloopPausePointStatus.Cleared)); + Assert.That(snapshot.ClearedReason, Is.EqualTo(UloopPausePointClearedReason.RunTestsAutoClear)); + Assert.That(snapshot.StatusBeforeClear, Is.EqualTo(UloopPausePointStatus.Enabled)); + } + + [Test] + public void ClearAll_WhenExpired_ReportsAfterExpiredReason() + { + // Verifies ClearAll after timeout keeps AfterExpired instead of erasing the timeout clue. + UloopPausePointRegistry.Enable("jump", 1); + _nowUtc = _nowUtc.AddSeconds(2); + + UloopPausePointRegistry.ClearAll(UloopPausePointClearedReason.ClearAll); + UloopPausePointSnapshot snapshot = UloopPausePointRegistry.GetStatus("jump"); + + Assert.That(snapshot.Status, Is.EqualTo(UloopPausePointStatus.Cleared)); + Assert.That(snapshot.ClearedReason, Is.EqualTo(UloopPausePointClearedReason.AfterExpired)); + Assert.That(snapshot.StatusBeforeClear, Is.EqualTo(UloopPausePointStatus.Expired)); + } + + [Test] + public void Hit_WhenAlreadyCleared_LogsLateHitAndSetsDiscardFlag() + { + // Verifies a delayed hit after Clear is observable instead of a silent no-op. + UloopPausePointRegistry.Enable("jump", 30); + UloopPausePointRegistry.Clear("jump"); + + LogAssert.Expect(LogType.Warning, new System.Text.RegularExpressions.Regex("hit after it was cleared")); + UloopPausePointSnapshot snapshot = UloopPausePointRegistry.Hit("jump"); + + Assert.That(snapshot.Status, Is.EqualTo(UloopPausePointStatus.Cleared)); + Assert.That(snapshot.LateHitDiscardedAfterClear, Is.True); + Assert.That(snapshot.ClearedReason, Is.EqualTo(UloopPausePointClearedReason.ExplicitClear)); + Assert.That(_pauseController.PauseCount, Is.EqualTo(0)); + } + [Test] public void GetStatus_WhenTimeoutPasses_ExpiresAndDisarms() { @@ -422,6 +484,100 @@ public void HitWithCapturedVariables_WhenPausePointIsEnabled_StoresCapturedVaria Assert.That(_pauseController.PauseCount, Is.EqualTo(1)); } + [Test] + public void TryGetCapturedValue_WhenLatestHitStoredRawFrame_ReturnsLiveReferences() + { + // Verifies raw capture exposes live objects for the latest hit only. + UloopPausePointRegistry.Enable("jump", 30); + List scores = new() { 10, 20, 30 }; + UloopPausePointCapturedVariableFrame frame = new( + new[] + { + new UloopPausePointCapturedVariableEntry("scores", UloopCapturedVariableScope.Local, scores), + new UloopPausePointCapturedVariableEntry("empty", UloopCapturedVariableScope.Local, null) + }, + false); + UloopCapturedVariable[] capturedVariables = + { + new("scores", UloopCapturedVariableScope.Local, "System.Collections.Generic.List`1[System.Int32]", "[10,20,30]", string.Empty, string.Empty, 0) + }; + + UloopPausePointRegistry.HitWithCapturedFrame("jump", frame, capturedVariables, false); + + (bool foundScores, object scoresValue) = UloopPausePoint.TryGetCapturedValue("scores"); + (bool foundNull, object nullValue) = UloopPausePoint.TryGetCapturedValue("empty"); + (bool foundMissing, object missingValue) = UloopPausePoint.TryGetCapturedValue("missing"); + + Assert.That(foundScores, Is.True); + Assert.That(scoresValue, Is.SameAs(scores)); + Assert.That(foundNull, Is.True); + Assert.That(nullValue, Is.Null); + Assert.That(foundMissing, Is.False); + Assert.That(missingValue, Is.Null); + Assert.That(UloopPausePoint.GetCapturedPausePointId(), Is.EqualTo("jump")); + Assert.That(UloopPausePoint.GetCapturedNames(), Is.EqualTo(new[] { "scores", "empty" })); + } + + [Test] + public void TryGetCapturedValue_WhenRegistryClearsLatestHit_ReturnsNotFound() + { + // Verifies clear and reset paths drop raw references instead of leaving stale handles. + UloopPausePointRegistry.Enable("jump", 30); + UloopPausePointCapturedVariableFrame frame = new( + new[] { new UloopPausePointCapturedVariableEntry("speed", UloopCapturedVariableScope.Local, 5) }, + false); + UloopPausePointRegistry.HitWithCapturedFrame( + "jump", frame, Array.Empty(), false); + + UloopPausePointRegistry.Clear("jump"); + + (bool found, object value) = UloopPausePoint.TryGetCapturedValue("speed"); + Assert.That(found, Is.False); + Assert.That(value, Is.Null); + Assert.That(UloopPausePoint.GetCapturedPausePointId(), Is.Empty); + } + + [Test] + public void TryGetCapturedValue_WhenNewHitReplacesPrevious_ExposesLatestSnapshotOnly() + { + // Verifies only the latest hit snapshot is held, matching _latestHitSnapshot semantics. + UloopPausePointRegistry.Enable("jump", 30); + UloopPausePointRegistry.Enable("land", 30); + UloopPausePointCapturedVariableFrame jumpFrame = new( + new[] { new UloopPausePointCapturedVariableEntry("speed", UloopCapturedVariableScope.Local, 1) }, + false); + UloopPausePointCapturedVariableFrame landFrame = new( + new[] { new UloopPausePointCapturedVariableEntry("speed", UloopCapturedVariableScope.Local, 2) }, + false); + + UloopPausePointRegistry.HitWithCapturedFrame("jump", jumpFrame, Array.Empty(), false); + UloopPausePointRegistry.HitWithCapturedFrame("land", landFrame, Array.Empty(), false); + + (bool found, object value) = UloopPausePoint.TryGetCapturedValue("speed"); + Assert.That(found, Is.True); + Assert.That(value, Is.EqualTo(2)); + Assert.That(UloopPausePoint.GetCapturedPausePointId(), Is.EqualTo("land")); + } + + [Test] + public void TryGetCapturedValue_WhenUnrelatedPausePointIsCleared_KeepsLatestHitRawCapture() + { + // Verifies Clear(id) only drops raw refs when id matches the latest hit snapshot. + UloopPausePointRegistry.Enable("jump", 30); + UloopPausePointRegistry.Enable("land", 30); + UloopPausePointCapturedVariableFrame landFrame = new( + new[] { new UloopPausePointCapturedVariableEntry("speed", UloopCapturedVariableScope.Local, 7) }, + false); + UloopPausePointRegistry.HitWithCapturedFrame("land", landFrame, Array.Empty(), false); + + UloopPausePointRegistry.Clear("jump"); + + (bool found, object value) = UloopPausePoint.TryGetCapturedValue("speed"); + Assert.That(found, Is.True); + Assert.That(value, Is.EqualTo(7)); + Assert.That(UloopPausePoint.GetCapturedPausePointId(), Is.EqualTo("land")); + } + [Test] public void Hit_WhenPausePointIsEnabled_ReportsEmptyCapturedVariables() { diff --git a/Assets/Tests/Editor/SourcePausePointCapture/SourcePausePointVariableFormatterTests.cs b/Assets/Tests/Editor/SourcePausePointCapture/SourcePausePointVariableFormatterTests.cs index 951f2ce06..fe223a4c7 100644 --- a/Assets/Tests/Editor/SourcePausePointCapture/SourcePausePointVariableFormatterTests.cs +++ b/Assets/Tests/Editor/SourcePausePointCapture/SourcePausePointVariableFormatterTests.cs @@ -327,6 +327,142 @@ public void Format_WithDestroyedUnityObjectValue_ClassifiesAsDestroyed() Assert.That(variable.Value, Is.EqualTo("(destroyed)")); } + [Test] + public void Format_WithListOfIntegers_SerializesCollectionAsJsonArray() + { + // Verifies List values preview as JSON instead of the default type-name ToString. + object[] locals = { "scores", new List { 1, 2, 3 } }; + + (List variables, bool truncated) = SourcePausePointVariableFormatter.Format( + null, Array.Empty(), locals); + + Assert.That(variables.Single().Value, Is.EqualTo("[1,2,3]")); + Assert.That(truncated, Is.False); + } + + [Test] + public void Format_WithStringDictionary_SerializesCollectionAsJsonObject() + { + // Verifies dictionary values preview as JSON objects with string keys. + object[] locals = + { + "labels", + new Dictionary { { "hp", "100" }, { "mp", "50" } } + }; + + (List variables, bool truncated) = SourcePausePointVariableFormatter.Format( + null, Array.Empty(), locals); + + Assert.That(variables.Single().Value, Is.EqualTo("{\"hp\":\"100\",\"mp\":\"50\"}")); + Assert.That(truncated, Is.False); + } + + [Test] + public void Format_WhenCollectionElementCountExceedsPreviewCap_TruncatesElementsAndSetsFlag() + { + // Verifies only the first preview-cap elements are serialized and truncation is reported. + List values = Enumerable.Range(0, SourcePausePointConstants.MaxCollectionPreviewElementCount + 5).ToList(); + object[] locals = { "scores", values }; + + (List variables, bool truncated) = SourcePausePointVariableFormatter.Format( + null, Array.Empty(), locals); + + string value = variables.Single().Value; + Assert.That(value, Does.StartWith("[")); + Assert.That(value, Does.EndWith("]")); + Assert.That(value.Split(',').Length, Is.EqualTo(SourcePausePointConstants.MaxCollectionPreviewElementCount)); + Assert.That(truncated, Is.True); + } + + [Test] + public void Format_WithCompositeCollectionElements_FallsBackElementsToToString() + { + // Verifies composite element types stringify instead of expanding nested object graphs. + object[] locals = { "items", new List { new() { PublicField = 7 } } }; + + (List variables, bool truncated) = SourcePausePointVariableFormatter.Format( + null, Array.Empty(), locals); + + Assert.That(variables.Single().Value, Does.Contain(nameof(InstanceFieldFixture))); + Assert.That(truncated, Is.False); + } + + [Test] + public void Format_WithCircularCollectionReference_DoesNotThrow() + { + // Verifies cyclic graphs degrade safely instead of crashing collection preview. + List outer = new(); + List inner = new(); + outer.Add(inner); + inner.Add(outer); + object[] locals = { "graph", outer }; + + (List variables, bool truncated) = SourcePausePointVariableFormatter.Format( + null, Array.Empty(), locals); + + Assert.That(variables.Single().Value, Does.Contain("(circular)")); + Assert.That(truncated, Is.False); + } + + [Test] + public void Format_WithStringValue_KeepsPlainStringPreview() + { + // Verifies string is excluded from IEnumerable JSON preview and keeps the raw value. + object[] locals = { "label", "ready" }; + + (List variables, bool truncated) = SourcePausePointVariableFormatter.Format( + null, Array.Empty(), locals); + + Assert.That(variables.Single().Value, Is.EqualTo("ready")); + Assert.That(truncated, Is.False); + } + + [Test] + public void Format_WhenCollectionPreviewExceedsMaxLength_TruncatesValueAndSetsTruncatedFlag() + { + // Verifies expanded collection JSON uses the larger preview cap before clipping. + List values = Enumerable.Range(0, SourcePausePointConstants.MaxCollectionPreviewElementCount) + .Select(_ => new string('x', 120)) + .ToList(); + object[] locals = { "chunks", values }; + + (List variables, bool truncated) = SourcePausePointVariableFormatter.Format( + null, Array.Empty(), locals); + + Assert.That(variables.Single().Value.Length, Is.EqualTo(SourcePausePointConstants.MaxCollectionPreviewValueLength)); + Assert.That(truncated, Is.True); + } + + [Test] + public void Format_WithDeferredLinqQuery_FallsBackToToStringInsteadOfJson() + { + // Verifies deferred IEnumerable/LINQ is not executed for JSON preview. + IEnumerable query = Enumerable.Range(1, 3).Select(static value => value); + object[] locals = { "query", query }; + + (List variables, bool truncated) = SourcePausePointVariableFormatter.Format( + null, Array.Empty(), locals); + + Assert.That(variables.Single().Value, Is.EqualTo(query.ToString())); + Assert.That(variables.Single().Value, Does.Not.StartWith("[")); + Assert.That(truncated, Is.False); + } + + [Test] + public void Format_WhenMaterializedCollectionEnumerationThrows_FallsBackToToString() + { + // Verifies enumeration exceptions during preview do not escape into user game code. + LogAssert.Expect(LogType.Exception, "InvalidOperationException: enum boom"); + ThrowingOnEnumerateCollection collection = new(); + object[] locals = { "broken", collection }; + + (List variables, bool truncated) = SourcePausePointVariableFormatter.Format( + null, Array.Empty(), locals); + + Assert.That(variables.Single().Value, Is.EqualTo(collection.ToString())); + Assert.That(truncated, Is.False); + } + [UnityTest] public IEnumerator Format_WhenCalledOffMainThread_DegradesUnityObjectValueWithoutEngineApiAccess() { @@ -399,5 +535,21 @@ public async Task RunAsync(int seed) return localValue; } } + + private sealed class ThrowingOnEnumerateCollection : ICollection + { + public int Count => 3; + public bool IsSynchronized => false; + public object SyncRoot => this; + + public void CopyTo(Array array, int index) + { + } + + public IEnumerator GetEnumerator() + { + throw new InvalidOperationException("enum boom"); + } + } } } diff --git a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md index dba1bc4e9..adc303b84 100644 --- a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md +++ b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md @@ -15,6 +15,8 @@ Use this small loop for one representative frame you care about. No source edit uloop enable-pause-point --file Assets/Scripts/Enemy.cs --line 42 --timeout-seconds 30 ``` +`--timeout-seconds` on enable starts the marker lifetime clock at enable time, not when you later run `await-pause-point`. + The response returns the derived marker `Id` (`Assets/Scripts/Enemy.cs:42`), the `ResolvedLine` that was actually patched, and the `ResolvedMethod`. When the requested line has no executable statement, the pause point rounds forward to the next executable line — check `ResolvedLine` when precision matters. Use the returned `Id` for every follow-up command. 2. Trigger the action with a `simulate-*` command. @@ -33,6 +35,7 @@ uloop await-pause-point --id "Assets/Scripts/Enemy.cs:42" --timeout-seconds 30 Every hit response embeds `CapturedVariables`: the method's in-scope locals, its parameters, and the `this` instance fields, captured at the exact moment execution reached the patched line. Values are point-in-time strings, not live references, so they stay valid as evidence even after Unity resumes. - The snapshot is taken **before** the resolved line executes, exactly like an IDE breakpoint on that line. To inspect a value after an assignment, place the pause point on the following line. +- `execute-dynamic-code` during the pause sees the interrupted method's **post-interrupt** state, not this pre-line snapshot. Use `CapturedVariables` for pre-line evidence; use the raw capture API below when you need live references while paused. - `Scope` is `Local`, `Parameter`, or `InstanceField`. - `UnityEngine.Object` values additionally carry `UnityObjectKind` (`SceneObject`, `PrefabAsset`, `Asset`, `RuntimeInstance`, or `Destroyed`), `UnityObjectPath`, and `UnityObjectInstanceId`. Use these as handles for the next dig: a `SceneObject` path feeds `get-hierarchy`/`find-game-objects`, an asset path locates the asset, and the InstanceID works with `execute-dynamic-code`. - `CapturedVariablesTruncated=true` means at least one value was clipped to the length cap or the variable-count cap stopped enumeration; clipped values are still present up to the cap. @@ -43,6 +46,26 @@ Read `EvidenceSummary` first when it is present. It groups `EditorState`, pause Use `Generation`, `EnabledAtUtc`, and the hit sequence fields from the hit or status response to tell a fresh marker from stale evidence with the same id. `RemainingMilliseconds` and `Expired` are returned directly so you do not need to infer marker lifetime from elapsed time. +## Raw Capture While Paused + +While Unity is paused on a hit, `execute-dynamic-code` can read live captured references through `UloopPausePoint`: + +- `TryGetCapturedValue(string name)` returns `(bool Found, object Value)` for the latest hit only. When multiple captured variables share the same name, the last one wins. +- `GetCapturedNames()` lists captured variable names from that snapshot. +- `GetCapturedPausePointId()` returns the pause-point id for the held snapshot. + +The holder clears when Unity resumes (not when you `Step` while still paused), when the matching pause point is cleared, when a new hit replaces the snapshot, or when PlayMode exits. After resume, `TryGetCapturedValue` returns `Found=false`. + +## Marker Types + +- `uloop enable-pause-point --file --line` patches the already-compiled method at a source line. No code edit or recompile is required. +- `UloopPausePoint.Pause(id)` is a hand-written marker call for code paths that file:line patching cannot reach. Pair it with `uloop enable-pause-point --id ` (no `--file`/`--line`). +- For ordinary file:line debugging you do not need `UloopPausePoint.Pause` in source. Prefer CLI enable when the target line can be patched. + +## Hit Preconditions + +A pause point hits only when control flow reaches the patched line (or the `Pause(id)` call). `simulate-keyboard` returning `PressEdgeObserved=true` means the input edge was observed, not that your target game logic has reached the pause line yet. + ## When To Use - Use this as the standard frame proof for state-changing PlayMode/E2E simulated input, physics, or UI transitions. diff --git a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Skill/SKILL.md index dd28613ea..d5d4edc3d 100644 --- a/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Skill/SKILL.md +++ b/Packages/src/Editor/FirstPartyTools/ExecuteDynamicCode/Skill/SKILL.md @@ -11,7 +11,7 @@ Run focused C# snippets in the active Unity Editor with `uloop execute-dynamic-c For basic selected GameObject discovery or property inspection, use `find-game-objects --search-mode Selected` before this tool. Use this tool after the built-in inspection tools are not enough or when you need to modify Unity state. -This tool can inspect reachable Unity state, such as GameObjects, components, public properties, static values, and method results. It cannot directly read local variables or intermediate calculations inside an already-running method. When those values matter, enable a source pause point on that line instead (`uloop enable-pause-point --file --line `, see the `uloop-pause-point` skill): the hit response's `CapturedVariables` already contains the locals, parameters, and instance fields at that line, with no code edit or recompile. Do not try to reconstruct those values with execute-dynamic-code. +This tool can inspect reachable Unity state, such as GameObjects, components, public properties, static values, and method results. It cannot directly read local variables or intermediate calculations inside an already-running method. When those values matter, enable a source pause point on that line instead (`uloop enable-pause-point --file --line `, see the `uloop-pause-point` skill): the hit response's `CapturedVariables` already contains the locals, parameters, and instance fields at that line, with no code edit or recompile. `CapturedVariables` is a pre-line snapshot; this tool during the pause sees the interrupted method's post-interrupt state instead. While Unity stays paused on the hit, use `UloopPausePoint.TryGetCapturedValue(name)` (plus `GetCapturedNames()` / `GetCapturedPausePointId()`) for live captured references such as collections. Do not try to reconstruct pre-line locals with execute-dynamic-code alone. ## Parameters diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointTools.cs b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointTools.cs index cf6c04c07..5c5b28112 100644 --- a/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointTools.cs +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointTools.cs @@ -65,6 +65,9 @@ public class PausePointResponse : UnityCliLoopToolResponse public string Message { get; set; } = string.Empty; public string RecommendedNextAction { get; set; } = string.Empty; public string Warning { get; set; } = string.Empty; + public string ClearedReason { get; set; } = string.Empty; + public string StatusBeforeClear { get; set; } = string.Empty; + public bool LateHitDiscardedAfterClear { get; set; } internal static PausePointResponse FromSnapshot(UloopPausePointSnapshot snapshot) { @@ -92,7 +95,10 @@ internal static PausePointResponse FromSnapshot(UloopPausePointSnapshot snapshot FirstHitSequence = snapshot.FirstHitSequence, LastHitSequence = snapshot.LastHitSequence, Message = snapshot.Message, - RecommendedNextAction = snapshot.RecommendedNextAction + RecommendedNextAction = snapshot.RecommendedNextAction, + ClearedReason = snapshot.ClearedReason, + StatusBeforeClear = snapshot.StatusBeforeClear, + LateHitDiscardedAfterClear = snapshot.LateHitDiscardedAfterClear }; } diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointCapture.cs b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointCapture.cs index 6bb61efeb..f1f0016a8 100644 --- a/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointCapture.cs +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointCapture.cs @@ -25,12 +25,12 @@ public static void Capture( return; } - (List variables, bool truncated) = SourcePausePointVariableFormatter.Format( - instance, parameterNamesAndValues, localNamesAndValues); + (UloopPausePointCapturedVariableFrame frame, List variables, bool truncated) = + CaptureFrame(instance, parameterNamesAndValues, localNamesAndValues); if (MainThreadSwitcher.IsMainThread) { - UloopPausePointRegistry.HitWithCapturedVariables(id, variables, truncated); + UloopPausePointRegistry.HitWithCapturedFrame(id, frame, variables, truncated); return; } @@ -38,7 +38,17 @@ public static void Capture( // touched from the main thread, so an off-thread hit is recorded on the next // main-thread tick instead of inline. HitCore re-checks IsEnabled at that point, so a // marker that already got disarmed by a faster hit safely no-ops there. - MainThreadSwitcher.AddContinuation(() => UloopPausePointRegistry.HitWithCapturedVariables(id, variables, truncated)); + MainThreadSwitcher.AddContinuation( + () => UloopPausePointRegistry.HitWithCapturedFrame(id, frame, variables, truncated)); + } + + internal static (UloopPausePointCapturedVariableFrame Frame, List Variables, bool Truncated) + CaptureFrame(object instance, object[] parameterNamesAndValues, object[] localNamesAndValues) + { + UloopPausePointCapturedVariableFrame frame = SourcePausePointVariableCollector.Collect( + instance, parameterNamesAndValues, localNamesAndValues); + (List variables, bool truncated) = SourcePausePointVariableFormatter.FormatFrame(frame); + return (frame, variables, truncated); } } } diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointCollectionPreviewSerializer.cs b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointCollectionPreviewSerializer.cs new file mode 100644 index 000000000..f4e600b8c --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointCollectionPreviewSerializer.cs @@ -0,0 +1,243 @@ +using System; +using System.Collections; +using System.Collections.Generic; + +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Newtonsoft.Json.Serialization; + +using UnityEngine; + +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Builds a shallow JSON preview for captured materialized collections so pause-point status can + /// show collection contents instead of the default type-name ToString. + /// + internal static class SourcePausePointCollectionPreviewSerializer + { + private const string OffMainThreadValue = "(captured off main thread)"; + private const string DestroyedValue = "(destroyed)"; + + private static readonly JsonSerializer PrimitiveSerializer = JsonSerializer.Create( + new JsonSerializerSettings + { + ReferenceLoopHandling = ReferenceLoopHandling.Ignore, + Error = HandleSerializationError + }); + + public static bool TrySerialize(object rawValue, ref bool truncated, out string preview) + { + preview = string.Empty; + if (rawValue == null) + { + return false; + } + + if (rawValue is string || rawValue is byte[]) + { + return false; + } + + // Why: deferred IEnumerable/LINQ must not execute user code during preview; only + // materialized ICollection/IDictionary snapshots are safe to walk. + if (rawValue is not ICollection && rawValue is not IDictionary) + { + return false; + } + + try + { + HashSet visited = new(ReferenceEqualityComparer.Instance); + JToken token = BuildToken( + rawValue, SourcePausePointConstants.MaxCollectionPreviewDepth, visited, ref truncated); + preview = token.ToString(Formatting.None); + return true; + } + catch (Exception exception) + { + Debug.LogException(exception); + return false; + } + } + + private static void HandleSerializationError(object sender, ErrorEventArgs args) + { + Debug.LogException(args.ErrorContext.Error); + args.ErrorContext.Handled = true; + } + + private static JToken BuildToken( + object value, int remainingDepth, HashSet visited, ref bool truncated) + { + if (value == null) + { + return JValue.CreateNull(); + } + + if (value is UnityEngine.Object unityObject) + { + return new JValue(FormatUnityObjectElement(unityObject)); + } + + if (value is string stringValue) + { + return JValue.FromObject(stringValue, PrimitiveSerializer); + } + + if (IsJsonPrimitive(value)) + { + return JToken.FromObject(value, PrimitiveSerializer); + } + + if (value is IEnumerable enumerable) + { + if (!visited.Add(value)) + { + return new JValue("(circular)"); + } + + if (remainingDepth <= 0) + { + return new JValue(SafeToString(value)); + } + + if (value is IDictionary dictionary) + { + return BuildDictionaryToken(dictionary, remainingDepth, visited, ref truncated); + } + + if (value is ICollection) + { + return BuildArrayToken(enumerable, remainingDepth, visited, ref truncated); + } + + return new JValue(SafeToString(value)); + } + + return new JValue(SafeToString(value)); + } + + private static JArray BuildArrayToken( + IEnumerable enumerable, int remainingDepth, HashSet visited, ref bool truncated) + { + JArray array = new(); + int elementCount = 0; + foreach (object element in enumerable) + { + if (elementCount >= SourcePausePointConstants.MaxCollectionPreviewElementCount) + { + truncated = true; + break; + } + + array.Add(BuildToken(element, remainingDepth - 1, visited, ref truncated)); + elementCount++; + } + + return array; + } + + private static JObject BuildDictionaryToken( + IDictionary dictionary, int remainingDepth, HashSet visited, ref bool truncated) + { + JObject jsonObject = new(); + int elementCount = 0; + foreach (DictionaryEntry entry in dictionary) + { + if (elementCount >= SourcePausePointConstants.MaxCollectionPreviewElementCount) + { + truncated = true; + break; + } + + string key = FormatDictionaryKey(entry.Key); + jsonObject[key] = BuildToken(entry.Value, remainingDepth - 1, visited, ref truncated); + elementCount++; + } + + return jsonObject; + } + + private static string FormatDictionaryKey(object key) + { + if (key == null) + { + return "null"; + } + + if (key is UnityEngine.Object unityObject) + { + return FormatUnityObjectElement(unityObject); + } + + return SafeToString(key); + } + + private static bool IsJsonPrimitive(object value) + { + return value is bool + or byte or sbyte + or short or ushort + or int or uint + or long or ulong + or float or double + or decimal + or char + or Enum; + } + + private static string FormatUnityObjectElement(UnityEngine.Object unityObject) + { + if (!MainThreadSwitcher.IsMainThread) + { + return OffMainThreadValue; + } + + if (unityObject == null) + { + return DestroyedValue; + } + + return unityObject.name; + } + + // Sanctioned try-catch in the capture path: user ToString() overrides are untrusted code + // we must not let crash a pause-point hit. + private static string SafeToString(object value) + { + try + { + return value.ToString(); + } + catch (Exception exception) + { + Debug.LogException(exception); + return $"(toString threw {exception.GetType().Name})"; + } + } + + private sealed class ReferenceEqualityComparer : IEqualityComparer + { + public static ReferenceEqualityComparer Instance { get; } = new(); + + public new bool Equals(object x, object y) + { + return ReferenceEquals(x, y); + } + + public int GetHashCode(object obj) + { + return RuntimeHelpersGetHashCode(obj); + } + + // Why: object.GetHashCode is overridden by many collection types; reference identity is required. + private static int RuntimeHelpersGetHashCode(object obj) + { + return System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(obj); + } + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointCollectionPreviewSerializer.cs.meta b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointCollectionPreviewSerializer.cs.meta new file mode 100644 index 000000000..52d7128b4 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointCollectionPreviewSerializer.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8f4c2d1a9b7e4f6c8d3e5a1b2c4d6e8f +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 5da5a80e8..34893b076 100644 --- a/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointConstants.cs +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointConstants.cs @@ -14,6 +14,9 @@ internal static class SourcePausePointConstants // pause-point evidence to stay skimmable, mirroring the truncation-by-cap pattern MatchingLogs uses. public const int MaxCapturedVariableCount = 50; public const int MaxCapturedVariableValueLength = 256; + public const int MaxCollectionPreviewElementCount = 10; + public const int MaxCollectionPreviewValueLength = 1024; + public const int MaxCollectionPreviewDepth = 2; public const string HarmonyId = "io.github.hatayama.uloop.source-pause-point"; public const string BurstCompileAttributeFullName = "Unity.Burst.BurstCompileAttribute"; diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointVariableCollector.cs b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointVariableCollector.cs new file mode 100644 index 000000000..8df66c959 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointVariableCollector.cs @@ -0,0 +1,160 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Text.RegularExpressions; + +using UnityEngine; + +using io.github.hatayama.UnityCliLoop.Runtime; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Builds the shared demangled capture frame consumed by the formatter and raw-ref holder. + /// + internal static class SourcePausePointVariableCollector + { + private static readonly Regex HoistedLocalFieldNamePattern = new(@"^<([^>]+)>5__\d+$", RegexOptions.Compiled); + private const string StateMachineOuterThisFieldName = "<>4__this"; + + public static UloopPausePointCapturedVariableFrame Collect( + object instance, object[] parameterNamesAndValues, object[] localNamesAndValues) + { + Debug.Assert(parameterNamesAndValues != null, "parameterNamesAndValues must not be null"); + Debug.Assert(localNamesAndValues != null, "localNamesAndValues must not be null"); + Debug.Assert(parameterNamesAndValues.Length % 2 == 0, "parameterNamesAndValues must contain name/value pairs"); + Debug.Assert(localNamesAndValues.Length % 2 == 0, "localNamesAndValues must contain name/value pairs"); + + List entries = new(); + bool truncated = false; + HashSet capturedNames = new(); + + bool countCapReached = AppendPairs( + entries, capturedNames, ref truncated, localNamesAndValues, UloopCapturedVariableScope.Local); + if (!countCapReached) + { + countCapReached = AppendPairs( + entries, capturedNames, ref truncated, parameterNamesAndValues, UloopCapturedVariableScope.Parameter); + } + + if (instance != null && !countCapReached) + { + CollectInstanceFieldVariables(instance, entries, capturedNames, ref truncated); + } + + return new UloopPausePointCapturedVariableFrame(entries, truncated); + } + + private static bool AppendPairs( + List entries, HashSet capturedNames, ref bool truncated, + object[] namesAndValues, string scope) + { + for (int i = 0; i < namesAndValues.Length; i += 2) + { + string name = (string)namesAndValues[i]; + object value = namesAndValues[i + 1]; + if (!TryAppendEntry(entries, capturedNames, ref truncated, name, scope, value)) + { + return true; + } + } + + return false; + } + + private static void CollectInstanceFieldVariables( + object instance, List entries, HashSet capturedNames, + ref bool truncated) + { + (object outerThis, bool countCapReached) = CollectDirectFieldVariables( + instance, entries, capturedNames, ref truncated, followOuterThis: true); + if (countCapReached || outerThis == null) + { + return; + } + + CollectDirectFieldVariables(outerThis, entries, capturedNames, ref truncated, followOuterThis: false); + } + + private static (object OuterThis, bool CountCapReached) CollectDirectFieldVariables( + object source, List entries, HashSet capturedNames, + ref bool truncated, bool followOuterThis) + { + object outerThis = null; + bool isCompilerGeneratedStateMachine = Attribute.IsDefined(source.GetType(), typeof(CompilerGeneratedAttribute)); + string plainFieldScope = isCompilerGeneratedStateMachine + ? UloopCapturedVariableScope.Parameter + : UloopCapturedVariableScope.InstanceField; + + foreach (FieldInfo field in EnumerateInstanceFields(source.GetType())) + { + if (followOuterThis && field.Name == StateMachineOuterThisFieldName) + { + outerThis = field.GetValue(source); + continue; + } + + Match hoistedLocalMatch = HoistedLocalFieldNamePattern.Match(field.Name); + if (hoistedLocalMatch.Success) + { + if (!TryAppendEntry( + entries, capturedNames, ref truncated, hoistedLocalMatch.Groups[1].Value, + UloopCapturedVariableScope.Local, field.GetValue(source))) + { + return (outerThis, true); + } + + continue; + } + + if (field.Name.StartsWith("<", StringComparison.Ordinal)) + { + continue; + } + + if (!TryAppendEntry(entries, capturedNames, ref truncated, field.Name, plainFieldScope, field.GetValue(source))) + { + return (outerThis, true); + } + } + + return (outerThis, false); + } + + private static IEnumerable EnumerateInstanceFields(Type type) + { + const BindingFlags flags = + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly; + + for (Type current = type; + current != null && current != typeof(UnityEngine.Object) && current != typeof(object); + current = current.BaseType) + { + foreach (FieldInfo field in current.GetFields(flags)) + { + yield return field; + } + } + } + + private static bool TryAppendEntry( + List entries, HashSet capturedNames, ref bool truncated, + string name, string scope, object rawValue) + { + if (!capturedNames.Add(name)) + { + return true; + } + + if (entries.Count >= SourcePausePointConstants.MaxCapturedVariableCount) + { + truncated = true; + return false; + } + + entries.Add(new UloopPausePointCapturedVariableEntry(name, scope, rawValue)); + return true; + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointVariableCollector.cs.meta b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointVariableCollector.cs.meta new file mode 100644 index 000000000..09bb32752 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointVariableCollector.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: de60b915f89f645178073973356c1502 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointVariableFormatter.cs b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointVariableFormatter.cs index 12733f701..be9ec6976 100644 --- a/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointVariableFormatter.cs +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointVariableFormatter.cs @@ -12,171 +12,34 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools { /// - /// Turns the raw name/value pairs a pause point captured into the DTOs a CLI response can - /// serialize: demangling compiler-hoisted local/`this` fields, classifying UnityEngine.Object - /// references, and capping both value length and variable count. + /// Turns the shared capture frame into DTOs a CLI response can serialize. /// internal static class SourcePausePointVariableFormatter { - // Roslyn hoists a local that crosses an await/yield into a state machine field named - // "5__N"; this demangles it back to the source-level local name. - private static readonly Regex HoistedLocalFieldNamePattern = new(@"^<([^>]+)>5__\d+$", RegexOptions.Compiled); - private const string StateMachineOuterThisFieldName = "<>4__this"; private const string OffMainThreadValue = "(captured off main thread)"; private const string DestroyedValue = "(destroyed)"; public static (List Variables, bool Truncated) Format( object instance, object[] parameterNamesAndValues, object[] localNamesAndValues) { - Debug.Assert(parameterNamesAndValues != null, "parameterNamesAndValues must not be null"); - Debug.Assert(localNamesAndValues != null, "localNamesAndValues must not be null"); - Debug.Assert(parameterNamesAndValues.Length % 2 == 0, "parameterNamesAndValues must contain name/value pairs"); - Debug.Assert(localNamesAndValues.Length % 2 == 0, "localNamesAndValues must contain name/value pairs"); - - List results = new(); - // Reports whether ANY value was clipped or the count cap was hit; a single over-long - // value must not stop enumeration of the remaining locals/parameters/instance fields. - bool truncated = false; - // An async state machine's own fields include hoisted copies of the original method's - // parameters under their plain source name (only true locals get the "5__N" - // treatment); tracking already-captured names keeps those from being double-reported - // once as Parameter (from the array below) and again as InstanceField (from the walk). - HashSet capturedNames = new(); - - bool countCapReached = AppendPairs( - results, capturedNames, ref truncated, localNamesAndValues, UloopCapturedVariableScope.Local); - if (!countCapReached) - { - countCapReached = AppendPairs( - results, capturedNames, ref truncated, parameterNamesAndValues, UloopCapturedVariableScope.Parameter); - } - - if (instance != null && !countCapReached) - { - CollectInstanceFieldVariables(instance, results, capturedNames, ref truncated); - } - - return (results, truncated); - } - - // Returns true once the count cap is hit, so the caller can stop enumerating further - // arrays/fields; a per-value length truncation alone must not signal this. - private static bool AppendPairs( - List results, HashSet capturedNames, ref bool truncated, - object[] namesAndValues, string scope) - { - for (int i = 0; i < namesAndValues.Length; i += 2) - { - string name = (string)namesAndValues[i]; - object value = namesAndValues[i + 1]; - if (!TryAppendVariable(results, capturedNames, ref truncated, name, scope, value)) - { - return true; - } - } - - return false; - } - - // Async/iterator state machines hoist the original `this` into a `<>4__this` field; this - // follows it exactly one level deep to also capture the real instance's fields, without - // recursing into any further state-machine hop. - private static void CollectInstanceFieldVariables( - object instance, List results, HashSet capturedNames, ref bool truncated) - { - (object outerThis, bool countCapReached) = CollectDirectFieldVariables( - instance, results, capturedNames, ref truncated, followOuterThis: true); - if (countCapReached || outerThis == null) - { - return; - } - - CollectDirectFieldVariables(outerThis, results, capturedNames, ref truncated, followOuterThis: false); - } - - private static (object OuterThis, bool CountCapReached) CollectDirectFieldVariables( - object source, List results, HashSet capturedNames, ref bool truncated, - bool followOuterThis) - { - object outerThis = null; - // A compiler-generated state machine hoists the original method's parameters as - // plain-named fields (only true locals get the "5__N" treatment), so those - // fields are the method's Parameter scope, not this type's own InstanceField scope. - bool isCompilerGeneratedStateMachine = Attribute.IsDefined(source.GetType(), typeof(CompilerGeneratedAttribute)); - string plainFieldScope = isCompilerGeneratedStateMachine - ? UloopCapturedVariableScope.Parameter - : UloopCapturedVariableScope.InstanceField; - - foreach (FieldInfo field in EnumerateInstanceFields(source.GetType())) - { - if (followOuterThis && field.Name == StateMachineOuterThisFieldName) - { - outerThis = field.GetValue(source); - continue; - } - - Match hoistedLocalMatch = HoistedLocalFieldNamePattern.Match(field.Name); - if (hoistedLocalMatch.Success) - { - if (!TryAppendVariable( - results, capturedNames, ref truncated, hoistedLocalMatch.Groups[1].Value, - UloopCapturedVariableScope.Local, field.GetValue(source))) - { - return (outerThis, true); - } - - continue; - } - - if (field.Name.StartsWith("<", StringComparison.Ordinal)) - { - // Other compiler-generated plumbing (state machine "<>1__state", "<>t__builder", - // auto-property backing fields, etc.) carries no source-level meaning to capture. - continue; - } - - if (!TryAppendVariable(results, capturedNames, ref truncated, field.Name, plainFieldScope, field.GetValue(source))) - { - return (outerThis, true); - } - } - - return (outerThis, false); - } - - private static IEnumerable EnumerateInstanceFields(Type type) - { - const BindingFlags flags = - BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly; - - for (Type current = type; - current != null && current != typeof(UnityEngine.Object) && current != typeof(object); - current = current.BaseType) - { - foreach (FieldInfo field in current.GetFields(flags)) - { - yield return field; - } - } + UloopPausePointCapturedVariableFrame frame = SourcePausePointVariableCollector.Collect( + instance, parameterNamesAndValues, localNamesAndValues); + return FormatFrame(frame); } - private static bool TryAppendVariable( - List results, HashSet capturedNames, ref bool truncated, string name, - string scope, object rawValue) + public static (List Variables, bool Truncated) FormatFrame( + UloopPausePointCapturedVariableFrame frame) { - if (!capturedNames.Add(name)) - { - return true; - } + Debug.Assert(frame != null, "frame must not be null"); - if (results.Count >= SourcePausePointConstants.MaxCapturedVariableCount) + List results = new(); + bool truncated = frame.Truncated; + foreach (UloopPausePointCapturedVariableEntry entry in frame.Entries) { - truncated = true; - return false; + results.Add(FormatVariable(entry.Name, entry.Scope, entry.Value, ref truncated)); } - results.Add(FormatVariable(name, scope, rawValue, ref truncated)); - return true; + return (results, truncated); } private static UloopCapturedVariable FormatVariable(string name, string scope, object rawValue, ref bool truncated) @@ -192,7 +55,17 @@ private static UloopCapturedVariable FormatVariable(string name, string scope, o return FormatUnityObjectVariable(name, scope, typeName, unityObjectCandidate); } - string value = ApplyValueLengthCap(SafeToString(rawValue), ref truncated); + if (SourcePausePointCollectionPreviewSerializer.TrySerialize(rawValue, ref truncated, out string collectionPreview)) + { + string cappedPreview = ApplyValueLengthCap( + collectionPreview, + SourcePausePointConstants.MaxCollectionPreviewValueLength, + ref truncated); + return new UloopCapturedVariable(name, scope, typeName, cappedPreview, string.Empty, string.Empty, 0); + } + + string value = ApplyValueLengthCap( + SafeToString(rawValue), SourcePausePointConstants.MaxCapturedVariableValueLength, ref truncated); return new UloopCapturedVariable(name, scope, typeName, value, string.Empty, string.Empty, 0); } @@ -201,14 +74,11 @@ private static UloopCapturedVariable FormatUnityObjectVariable( { if (!MainThreadSwitcher.IsMainThread) { - // Transform/AssetDatabase/InstanceID access all require the main thread; degrade - // to a plain type-tagged placeholder rather than risk touching engine state here. return new UloopCapturedVariable(name, scope, typeName, OffMainThreadValue, string.Empty, string.Empty, 0); } if (unityObjectCandidate == null) { - // Fake-null: the managed wrapper is a live reference, so GetInstanceID() is still safe. return new UloopCapturedVariable( name, scope, typeName, DestroyedValue, UloopCapturedVariableUnityObjectKind.Destroyed, string.Empty, unityObjectCandidate.GetInstanceID()); @@ -221,19 +91,20 @@ private static UloopCapturedVariable FormatUnityObjectVariable( classification.Kind, classification.Path, classification.InstanceId); } - private static string ApplyValueLengthCap(string value, ref bool truncated) + private static string ApplyValueLengthCap(string value, int maxLength, ref bool truncated) { - if (value.Length <= SourcePausePointConstants.MaxCapturedVariableValueLength) + if (value.Length <= maxLength) { return value; } truncated = true; - return value.Substring(0, SourcePausePointConstants.MaxCapturedVariableValueLength); + return value.Substring(0, maxLength); } - // The single sanctioned try-catch in this codebase's capture path: user ToString() - // overrides are untrusted code we must not let crash a pause-point hit. + // Sanctioned try-catch sites in the capture path: user ToString() overrides (below) and + // materialized-collection enumeration in SourcePausePointCollectionPreviewSerializer. + // Both must not let untrusted user code crash a pause-point hit. private static string SafeToString(object value) { try diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/UnityCLILoop.FirstPartyTools.PausePoint.Editor.asmdef b/Packages/src/Editor/FirstPartyTools/PausePoint/UnityCLILoop.FirstPartyTools.PausePoint.Editor.asmdef index d5d9b635b..7586068b4 100644 --- a/Packages/src/Editor/FirstPartyTools/PausePoint/UnityCLILoop.FirstPartyTools.PausePoint.Editor.asmdef +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/UnityCLILoop.FirstPartyTools.PausePoint.Editor.asmdef @@ -13,7 +13,8 @@ "overrideReferences": true, "precompiledReferences": [ "UnityCliLoop.0Harmony.dll", - "Mono.Cecil.dll" + "Mono.Cecil.dll", + "Newtonsoft.Json.dll" ], "autoReferenced": false, "defineConstraints": [], diff --git a/Packages/src/Editor/FirstPartyTools/RunTests/RunTestsUseCase.cs b/Packages/src/Editor/FirstPartyTools/RunTests/RunTestsUseCase.cs index 07287ef1b..197213947 100644 --- a/Packages/src/Editor/FirstPartyTools/RunTests/RunTestsUseCase.cs +++ b/Packages/src/Editor/FirstPartyTools/RunTests/RunTestsUseCase.cs @@ -164,7 +164,7 @@ private static RunTestsResponse CreateFailureResponse(string message) private static string[] ClearActivePausePointsDefault() { - UloopPausePointClearAllResult result = UloopPausePointRegistry.ClearAll(); + UloopPausePointClearAllResult result = UloopPausePointRegistry.ClearAll(UloopPausePointClearedReason.RunTestsAutoClear); if (result.ClearedCount == 0) { return null; diff --git a/Packages/src/Editor/Infrastructure/Api/PausePointStatusBridgeCommand.cs b/Packages/src/Editor/Infrastructure/Api/PausePointStatusBridgeCommand.cs index c8e12fd86..2c8171533 100644 --- a/Packages/src/Editor/Infrastructure/Api/PausePointStatusBridgeCommand.cs +++ b/Packages/src/Editor/Infrastructure/Api/PausePointStatusBridgeCommand.cs @@ -71,6 +71,9 @@ public class PausePointStatusResponse : UnityCliLoopToolResponse public IReadOnlyList CapturedVariables { get; set; } = Array.Empty(); public bool CapturedVariablesTruncated { get; set; } + public string ClearedReason { get; set; } = string.Empty; + public string StatusBeforeClear { get; set; } = string.Empty; + public bool LateHitDiscardedAfterClear { get; set; } internal static PausePointStatusResponse FromSnapshot(UloopPausePointSnapshot snapshot) { @@ -102,7 +105,10 @@ internal static PausePointStatusResponse FromSnapshot(UloopPausePointSnapshot sn CapturedVariables = snapshot.CapturedVariables .Select(PausePointStatusCapturedVariable.FromCapturedVariable) .ToList(), - CapturedVariablesTruncated = snapshot.CapturedVariablesTruncated + CapturedVariablesTruncated = snapshot.CapturedVariablesTruncated, + ClearedReason = snapshot.ClearedReason, + StatusBeforeClear = snapshot.StatusBeforeClear, + LateHitDiscardedAfterClear = snapshot.LateHitDiscardedAfterClear }; } } diff --git a/Packages/src/Runtime/PausePoints/UloopPausePoint.cs b/Packages/src/Runtime/PausePoints/UloopPausePoint.cs index d7572528e..8f91c7312 100644 --- a/Packages/src/Runtime/PausePoints/UloopPausePoint.cs +++ b/Packages/src/Runtime/PausePoints/UloopPausePoint.cs @@ -1,3 +1,5 @@ +using System; +using System.Collections.Generic; using System.Diagnostics; namespace io.github.hatayama.UnityCliLoop.Runtime @@ -15,6 +17,43 @@ public static void Pause(string id) { #if UNITY_EDITOR UloopPausePointRegistry.Hit(id); +#endif + } + + /// + /// Tries to read a raw captured variable from the latest pause-point hit while Unity is paused. + /// When multiple captured variables share the same name, the last one wins. + /// + public static (bool Found, object Value) TryGetCapturedValue(string name) + { +#if UNITY_EDITOR + return UloopPausePointRawCaptureHolder.TryGetCapturedValue(name); +#else + return (false, null); +#endif + } + + /// + /// Returns captured variable names from the latest pause-point hit, or empty when none is held. + /// + public static IReadOnlyList GetCapturedNames() + { +#if UNITY_EDITOR + return UloopPausePointRawCaptureHolder.GetCapturedNames(); +#else + return Array.Empty(); +#endif + } + + /// + /// Returns the pause-point id for the latest raw capture snapshot, or empty when none is held. + /// + public static string GetCapturedPausePointId() + { +#if UNITY_EDITOR + return UloopPausePointRawCaptureHolder.GetCapturedPausePointId(); +#else + return string.Empty; #endif } } diff --git a/Packages/src/Runtime/PausePoints/UloopPausePointCapturedVariableEntry.cs b/Packages/src/Runtime/PausePoints/UloopPausePointCapturedVariableEntry.cs new file mode 100644 index 000000000..203c89e3e --- /dev/null +++ b/Packages/src/Runtime/PausePoints/UloopPausePointCapturedVariableEntry.cs @@ -0,0 +1,21 @@ +#if UNITY_EDITOR +namespace io.github.hatayama.UnityCliLoop.Runtime +{ + /// + /// One demangled captured variable before string formatting for CLI responses. + /// + internal sealed class UloopPausePointCapturedVariableEntry + { + public UloopPausePointCapturedVariableEntry(string name, string scope, object value) + { + Name = name; + Scope = scope; + Value = value; + } + + public string Name { get; } + public string Scope { get; } + public object Value { get; } + } +} +#endif diff --git a/Packages/src/Runtime/PausePoints/UloopPausePointCapturedVariableEntry.cs.meta b/Packages/src/Runtime/PausePoints/UloopPausePointCapturedVariableEntry.cs.meta new file mode 100644 index 000000000..e6bd0ccb0 --- /dev/null +++ b/Packages/src/Runtime/PausePoints/UloopPausePointCapturedVariableEntry.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e6bfe22de3d5d41d19f48e65e950f635 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Runtime/PausePoints/UloopPausePointCapturedVariableFrame.cs b/Packages/src/Runtime/PausePoints/UloopPausePointCapturedVariableFrame.cs new file mode 100644 index 000000000..559d9d80c --- /dev/null +++ b/Packages/src/Runtime/PausePoints/UloopPausePointCapturedVariableFrame.cs @@ -0,0 +1,22 @@ +#if UNITY_EDITOR +using System.Collections.Generic; + +namespace io.github.hatayama.UnityCliLoop.Runtime +{ + /// + /// Shared demangled capture output consumed by both the string formatter and raw-ref holder. + /// + internal sealed class UloopPausePointCapturedVariableFrame + { + public UloopPausePointCapturedVariableFrame( + IReadOnlyList entries, bool truncated) + { + Entries = entries; + Truncated = truncated; + } + + public IReadOnlyList Entries { get; } + public bool Truncated { get; } + } +} +#endif diff --git a/Packages/src/Runtime/PausePoints/UloopPausePointCapturedVariableFrame.cs.meta b/Packages/src/Runtime/PausePoints/UloopPausePointCapturedVariableFrame.cs.meta new file mode 100644 index 000000000..0710f45e8 --- /dev/null +++ b/Packages/src/Runtime/PausePoints/UloopPausePointCapturedVariableFrame.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c20cc7494cadf416e894f63f8b55da0d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Runtime/PausePoints/UloopPausePointClearedReason.cs b/Packages/src/Runtime/PausePoints/UloopPausePointClearedReason.cs new file mode 100644 index 000000000..63154eae6 --- /dev/null +++ b/Packages/src/Runtime/PausePoints/UloopPausePointClearedReason.cs @@ -0,0 +1,17 @@ +#if UNITY_EDITOR +namespace io.github.hatayama.UnityCliLoop.Runtime +{ + /// + /// Machine-readable reasons a pause point transitioned to Cleared. + /// Why: Cleared alone hid whether run-tests, explicit clear, or an expired marker was wiped. + /// + internal static class UloopPausePointClearedReason + { + public const string ExplicitClear = "ExplicitClear"; + public const string ClearAll = "ClearAll"; + public const string RunTestsAutoClear = "RunTestsAutoClear"; + public const string AfterExpired = "AfterExpired"; + public const string AlreadyHit = "AlreadyHit"; + } +} +#endif diff --git a/Packages/src/Runtime/PausePoints/UloopPausePointClearedReason.cs.meta b/Packages/src/Runtime/PausePoints/UloopPausePointClearedReason.cs.meta new file mode 100644 index 000000000..dbb7389ef --- /dev/null +++ b/Packages/src/Runtime/PausePoints/UloopPausePointClearedReason.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a78733ac41b8341d593561758fb06481 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Runtime/PausePoints/UloopPausePointEntry.cs b/Packages/src/Runtime/PausePoints/UloopPausePointEntry.cs index bee8a9b96..7c5f6e90d 100644 --- a/Packages/src/Runtime/PausePoints/UloopPausePointEntry.cs +++ b/Packages/src/Runtime/PausePoints/UloopPausePointEntry.cs @@ -40,6 +40,9 @@ public UloopPausePointEntry(string id, int timeoutSeconds, DateTime enabledAtUtc public string Message { get; private set; } public IReadOnlyList CapturedVariables { get; private set; } public bool CapturedVariablesTruncated { get; private set; } + public string ClearedReason { get; private set; } = string.Empty; + public string StatusBeforeClear { get; private set; } = string.Empty; + public bool LateHitDiscardedAfterClear { get; private set; } public void ExpireIfNeeded(DateTime nowUtc) { @@ -58,13 +61,44 @@ public void ExpireIfNeeded(DateTime nowUtc) Message = "Pause point expired before it was hit."; } - public void MarkCleared(string message = "Pause point cleared.") + public void MarkCleared(string clearedReason, string message = "Pause point cleared.") { + // Why: a second clear must not erase the first reason (e.g. RunTestsAutoClear). + if (Status == UloopPausePointStatus.Cleared) + { + IsEnabled = false; + Message = message; + return; + } + + // Why: keep the pre-clear status so agents can still see Expired/Hit after Cleared overwrites Status. + StatusBeforeClear = Status; + if (Status == UloopPausePointStatus.Expired) + { + ClearedReason = UloopPausePointClearedReason.AfterExpired; + } + else if (Status == UloopPausePointStatus.Hit && + clearedReason == UloopPausePointClearedReason.ExplicitClear) + { + ClearedReason = UloopPausePointClearedReason.AlreadyHit; + } + else + { + ClearedReason = string.IsNullOrEmpty(clearedReason) + ? UloopPausePointClearedReason.ExplicitClear + : clearedReason; + } + IsEnabled = false; Status = UloopPausePointStatus.Cleared; Message = message; } + public void MarkLateHitDiscardedAfterClear() + { + LateHitDiscardedAfterClear = true; + } + public void RecordHit(DateTime nowUtc, bool isPlaying, bool isPaused, int hitSequence) { RecordHitWithCapturedVariables( @@ -140,7 +174,10 @@ public UloopPausePointSnapshot ToSnapshot(DateTime nowUtc, IUloopPausePointPause Message, recommendedNextAction, CapturedVariables, - CapturedVariablesTruncated); + CapturedVariablesTruncated, + ClearedReason, + StatusBeforeClear, + LateHitDiscardedAfterClear); } private long CalculateRemainingMilliseconds(DateTime nowUtc) diff --git a/Packages/src/Runtime/PausePoints/UloopPausePointRawCaptureHolder.cs b/Packages/src/Runtime/PausePoints/UloopPausePointRawCaptureHolder.cs new file mode 100644 index 000000000..d413691c6 --- /dev/null +++ b/Packages/src/Runtime/PausePoints/UloopPausePointRawCaptureHolder.cs @@ -0,0 +1,81 @@ +#if UNITY_EDITOR +using System.Collections.Generic; +using System.Threading; + +namespace io.github.hatayama.UnityCliLoop.Runtime +{ + /// + /// Retains raw captured variable references for the latest pause-point hit only. + /// Why: execute-dynamic-code can inspect live objects while Unity stays paused; references + /// must not outlive that paused window. + /// + internal static class UloopPausePointRawCaptureHolder + { + private static UloopPausePointRawCaptureSnapshot _latest; + + internal static void Store(UloopPausePointCapturedVariableFrame frame, string pausePointId) + { + Dictionary valuesByName = new(); + foreach (UloopPausePointCapturedVariableEntry entry in frame.Entries) + { + valuesByName[entry.Name] = entry.Value; + } + + UloopPausePointRawCaptureSnapshot snapshot = new(pausePointId, valuesByName); + Interlocked.Exchange(ref _latest, snapshot); + } + + internal static void Clear() + { + Interlocked.Exchange(ref _latest, null); + } + + internal static (bool Found, object Value) TryGetCapturedValue(string name) + { + UloopPausePointRawCaptureSnapshot snapshot = Volatile.Read(ref _latest); + if (snapshot == null) + { + return (false, null); + } + + if (!snapshot.ValuesByName.TryGetValue(name, out object value)) + { + return (false, null); + } + + return (true, value); + } + + internal static IReadOnlyList GetCapturedNames() + { + UloopPausePointRawCaptureSnapshot snapshot = Volatile.Read(ref _latest); + if (snapshot == null) + { + return System.Array.Empty(); + } + + return snapshot.Names; + } + + internal static string GetCapturedPausePointId() + { + UloopPausePointRawCaptureSnapshot snapshot = Volatile.Read(ref _latest); + return snapshot?.PausePointId ?? string.Empty; + } + + private sealed class UloopPausePointRawCaptureSnapshot + { + public UloopPausePointRawCaptureSnapshot(string pausePointId, Dictionary valuesByName) + { + PausePointId = pausePointId; + ValuesByName = valuesByName; + Names = new List(valuesByName.Keys); + } + + public string PausePointId { get; } + public IReadOnlyDictionary ValuesByName { get; } + public IReadOnlyList Names { get; } + } + } +} +#endif diff --git a/Packages/src/Runtime/PausePoints/UloopPausePointRawCaptureHolder.cs.meta b/Packages/src/Runtime/PausePoints/UloopPausePointRawCaptureHolder.cs.meta new file mode 100644 index 000000000..0a15c6053 --- /dev/null +++ b/Packages/src/Runtime/PausePoints/UloopPausePointRawCaptureHolder.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5c5e64f9c5619468e860b28361156a61 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Runtime/PausePoints/UloopPausePointRawCaptureLifecycle.cs b/Packages/src/Runtime/PausePoints/UloopPausePointRawCaptureLifecycle.cs new file mode 100644 index 000000000..eaed9432d --- /dev/null +++ b/Packages/src/Runtime/PausePoints/UloopPausePointRawCaptureLifecycle.cs @@ -0,0 +1,48 @@ +#if UNITY_EDITOR +using UnityEditor; + +namespace io.github.hatayama.UnityCliLoop.Runtime +{ + /// + /// Clears raw capture references when Unity leaves the paused-hit window. + /// + [InitializeOnLoad] + internal static class UloopPausePointRawCaptureLifecycle + { + static UloopPausePointRawCaptureLifecycle() + { + EditorApplication.pauseStateChanged += OnPauseStateChanged; + EditorApplication.playModeStateChanged += OnPlayModeStateChanged; + } + + private static void OnPauseStateChanged(PauseState pauseState) + { + if (pauseState != PauseState.Unpaused) + { + return; + } + + // Why: Step can transiently unpause then re-pause; delayCall re-checks the settled state. + EditorApplication.delayCall += ClearRawCaptureIfStillUnpaused; + } + + private static void ClearRawCaptureIfStillUnpaused() + { + if (EditorApplication.isPaused) + { + return; + } + + UloopPausePointRawCaptureHolder.Clear(); + } + + private static void OnPlayModeStateChanged(PlayModeStateChange change) + { + if (change == PlayModeStateChange.ExitingPlayMode || change == PlayModeStateChange.EnteredEditMode) + { + UloopPausePointRawCaptureHolder.Clear(); + } + } + } +} +#endif diff --git a/Packages/src/Runtime/PausePoints/UloopPausePointRawCaptureLifecycle.cs.meta b/Packages/src/Runtime/PausePoints/UloopPausePointRawCaptureLifecycle.cs.meta new file mode 100644 index 000000000..0e7cca742 --- /dev/null +++ b/Packages/src/Runtime/PausePoints/UloopPausePointRawCaptureLifecycle.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e91b0adefacc84e5d96264e965dbd602 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs b/Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs index 015371a04..826f9ae70 100644 --- a/Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs +++ b/Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs @@ -70,12 +70,13 @@ public static UloopPausePointSnapshot Clear(string id) UloopPausePointStatus.Cleared => "Pause point was already cleared.", _ => "Pause point cleared." }; - entry.MarkCleared(message); + entry.MarkCleared(UloopPausePointClearedReason.ExplicitClear, message); ClearLatestHitSnapshotIfMatches(id); return entry.ToSnapshot(now, _pauseController); } - public static UloopPausePointClearAllResult ClearAll() + public static UloopPausePointClearAllResult ClearAll( + string clearedReason = UloopPausePointClearedReason.ClearAll) { OnClearedAll?.Invoke(); @@ -88,10 +89,13 @@ public static UloopPausePointClearAllResult ClearAll() continue; } + // Resolve expiry first so ClearAll after timeout keeps AfterExpired visibility. + entry.ExpireIfNeeded(now); clearedIds.Add(entry.Id); - entry.MarkCleared(); + entry.MarkCleared(clearedReason); } ClearLatestHitSnapshot(); + UloopPausePointRawCaptureHolder.Clear(); UloopPausePointEditorStateSnapshot editorState = UloopPausePointEditorStateSnapshot.FromController( _pauseController, @@ -116,7 +120,7 @@ public static UloopPausePointSnapshot GetStatus(string id) public static UloopPausePointSnapshot Hit(string id) { - return HitCore(id, Array.Empty(), false); + return HitCore(id, null, Array.Empty(), false); } public static UloopPausePointSnapshot HitWithCapturedVariables( @@ -124,7 +128,19 @@ public static UloopPausePointSnapshot HitWithCapturedVariables( { Debug.Assert(capturedVariables != null, "capturedVariables must not be null"); - return HitCore(id, capturedVariables, capturedVariablesTruncated); + return HitCore(id, null, capturedVariables, capturedVariablesTruncated); + } + + public static UloopPausePointSnapshot HitWithCapturedFrame( + string id, + UloopPausePointCapturedVariableFrame capturedFrame, + IReadOnlyList capturedVariables, + bool capturedVariablesTruncated) + { + Debug.Assert(capturedFrame != null, "capturedFrame must not be null"); + Debug.Assert(capturedVariables != null, "capturedVariables must not be null"); + + return HitCore(id, capturedFrame, capturedVariables, capturedVariablesTruncated); } // Returns after a single dictionary lookup when the id is not armed. Harmony-injected @@ -140,7 +156,10 @@ public static bool IsArmed(string id) } private static UloopPausePointSnapshot HitCore( - string id, IReadOnlyList capturedVariables, bool capturedVariablesTruncated) + string id, + UloopPausePointCapturedVariableFrame capturedFrame, + IReadOnlyList capturedVariables, + bool capturedVariablesTruncated) { if (string.IsNullOrWhiteSpace(id)) { @@ -158,6 +177,16 @@ private static UloopPausePointSnapshot HitCore( entry.ExpireIfNeeded(now); if (!entry.IsEnabled) { + // Why: delayed main-thread hits can lose the race to Clear/ClearAll; surface that race. + if (entry.Status == UloopPausePointStatus.Cleared) + { + entry.MarkLateHitDiscardedAfterClear(); + Debug.LogWarning( + $"Pause point '{id}' was hit after it was cleared " + + $"(ClearedReason={entry.ClearedReason}, StatusBeforeClear={entry.StatusBeforeClear}). " + + "The late hit was discarded."); + } + return entry.ToSnapshot(now, _pauseController); } @@ -170,6 +199,11 @@ private static UloopPausePointSnapshot HitCore( _latestHitSnapshot = snapshot; _hitSnapshots.RemoveAll(hitSnapshot => hitSnapshot.Id == id); _hitSnapshots.Add(snapshot); + if (capturedFrame != null) + { + UloopPausePointRawCaptureHolder.Store(capturedFrame, id); + } + return snapshot; } @@ -187,6 +221,7 @@ public static void ClearLatestHitSnapshot() { _latestHitSnapshot = null; _hitSnapshots.Clear(); + UloopPausePointRawCaptureHolder.Clear(); } private static void ClearLatestHitSnapshotIfMatches(string id) @@ -203,6 +238,7 @@ private static void ClearLatestHitSnapshotIfMatches(string id) } _latestHitSnapshot = null; + UloopPausePointRawCaptureHolder.Clear(); } public static void ConfigureForTests(IUloopPausePointPauseController pauseController, Func nowProvider) @@ -223,6 +259,7 @@ public static void ResetForTests() _hitSnapshots.Clear(); _pauseController = new UnityEditorPausePointPauseController(); _nowProvider = () => DateTime.UtcNow; + UloopPausePointRawCaptureHolder.Clear(); } private static DateTime NowUtc() diff --git a/Packages/src/Runtime/PausePoints/UloopPausePointSnapshot.cs b/Packages/src/Runtime/PausePoints/UloopPausePointSnapshot.cs index 3e54c987a..61b986d22 100644 --- a/Packages/src/Runtime/PausePoints/UloopPausePointSnapshot.cs +++ b/Packages/src/Runtime/PausePoints/UloopPausePointSnapshot.cs @@ -31,7 +31,10 @@ public UloopPausePointSnapshot( string message, string recommendedNextAction, IReadOnlyList capturedVariables, - bool capturedVariablesTruncated) + bool capturedVariablesTruncated, + string clearedReason, + string statusBeforeClear, + bool lateHitDiscardedAfterClear) { Debug.Assert(editorState != null, "editorState must not be null"); @@ -55,6 +58,9 @@ public UloopPausePointSnapshot( RecommendedNextAction = recommendedNextAction ?? string.Empty; CapturedVariables = capturedVariables ?? Array.Empty(); CapturedVariablesTruncated = capturedVariablesTruncated; + ClearedReason = clearedReason ?? string.Empty; + StatusBeforeClear = statusBeforeClear ?? string.Empty; + LateHitDiscardedAfterClear = lateHitDiscardedAfterClear; } public string Id { get; } @@ -77,6 +83,9 @@ public UloopPausePointSnapshot( public string RecommendedNextAction { get; } public IReadOnlyList CapturedVariables { get; } public bool CapturedVariablesTruncated { get; } + public string ClearedReason { get; } + public string StatusBeforeClear { get; } + public bool LateHitDiscardedAfterClear { get; } public static UloopPausePointSnapshot NotEnabled(string id, IUloopPausePointPauseController pauseController) { @@ -104,6 +113,9 @@ public static UloopPausePointSnapshot NotEnabled(string id, IUloopPausePointPaus "Pause point is not enabled.", string.Empty, Array.Empty(), + false, + string.Empty, + string.Empty, false); } } diff --git a/cli/project-runner/internal/projectrunner/pause_point_wait.go b/cli/project-runner/internal/projectrunner/pause_point_wait.go index 840c6ecb5..5b2303d06 100644 --- a/cli/project-runner/internal/projectrunner/pause_point_wait.go +++ b/cli/project-runner/internal/projectrunner/pause_point_wait.go @@ -66,6 +66,9 @@ type pausePointStatusResponse struct { RecommendedNextAction string `json:"RecommendedNextAction"` CapturedVariables []pausePointCapturedVariable `json:"CapturedVariables"` CapturedVariablesTruncated bool `json:"CapturedVariablesTruncated"` + ClearedReason string `json:"ClearedReason"` + StatusBeforeClear string `json:"StatusBeforeClear"` + LateHitDiscardedAfterClear bool `json:"LateHitDiscardedAfterClear"` } type pausePointEditorState struct { diff --git a/tests/contracts/pause_point_status_response_contract.json b/tests/contracts/pause_point_status_response_contract.json index dbc414581..2ff369267 100644 --- a/tests/contracts/pause_point_status_response_contract.json +++ b/tests/contracts/pause_point_status_response_contract.json @@ -32,5 +32,8 @@ "UnityObjectInstanceId": -1234 } ], - "CapturedVariablesTruncated": true + "CapturedVariablesTruncated": true, + "ClearedReason": "", + "StatusBeforeClear": "", + "LateHitDiscardedAfterClear": false }