diff --git a/.agents/skills/uloop-pause-point/SKILL.md b/.agents/skills/uloop-pause-point/SKILL.md index 494d1c0785..d9f50d4164 100644 --- a/.agents/skills/uloop-pause-point/SKILL.md +++ b/.agents/skills/uloop-pause-point/SKILL.md @@ -52,6 +52,7 @@ Enable a pause point so Unity pauses when that code path is reached, either by a | `--mode` | enum | `single-shot` | Capture mode: single-shot pauses once, continuous pauses on every hit, trace records hits without pausing | | `--max-history` | integer | `20` | Maximum number of captured hit frames to retain (1-100) | | `--max-preview-elements` | integer | `10` | Maximum number of elements to include in a captured collection's preview (1-1000). The value set at enable time also caps the previews in every later pause-point-status response for that marker; status has no flag to change it. | +| `--max-caller-frames` | integer | `2` | Maximum number of caller stack frames to record on each hit (0-8). 0 disables capture (`CallerFrames` stays an empty array). The value set at enable time also caps every later pause-point-status response for that marker; status has no flag to change it. | | `--method` | string | - | Optional method simple name or `Type.Method`. When set, `--line` resolves only inside matching methods | ### clear-pause-point @@ -135,7 +136,7 @@ Every hit response embeds `CapturedVariables`: the method's in-scope locals, its - Collection values (arrays, `List`, dictionaries, plain objects) render as a JSON preview capped at 10 elements by default. When the elements you need sit past that cap (a 10x20 grid, a long list), re-enable with `--max-preview-elements ` (1–1000). The value set at enable time also caps the previews in every later `pause-point-status` response for that marker — status has no flag to change it. - While Unity is still paused, `UloopPausePoint.TryGetCapturedValue("name")` (and `"this"`) returns live captured references for `execute-dynamic-code`; the return is a `(bool Found, object Value)` tuple, and the holder clears on resume. (file:line marker hits only — id-only markers store no capture) These are **live objects in their frame-completed state, not snapshots** — use them only to dig further into objects that are still alive, never to reconstruct what a value was at the paused line. -`CallerFrames`: up to two caller stack frames showing how execution reached the marker, nearest caller first — top-level for the latest hit in `pause-point-status` / `await-pause-point` responses, and on every `CapturedVariableHistory` frame in all hit-carrying responses (`enable-pause-point` / `clear-pause-point` payloads have no top-level capture, so their frames appear in the history only). Always present (empty array when no managed callers were captured — for example when the marker's method is called directly by the engine). Each frame has `Method`; `File` (project-relative, forward slashes) and `Line` are omitted when debug symbols are unavailable. A caller running as a hot-reload-patched body (a Harmony dynamic method) is reported as a method-only frame under its original `Type.Method` name; `File` and `Line` are omitted because a dynamic method carries no debug symbols. A source path outside `Assets/`, `Packages/`, or `Library/PackageCache/` degrades to a method-only frame. Frame-selection rules: [references/captured-variables.md](references/captured-variables.md). +`CallerFrames`: caller stack frames showing how execution reached the marker, nearest caller first, capped by `--max-caller-frames` (default 2, range 0–8; 0 records none and leaves an empty array) — top-level for the latest hit in `pause-point-status` / `await-pause-point` responses, and on every `CapturedVariableHistory` frame in all hit-carrying responses (`enable-pause-point` / `clear-pause-point` payloads have no top-level capture, so their frames appear in the history only). Always present (empty array when no managed callers were captured — for example when the marker's method is called directly by the engine, or when `--max-caller-frames 0`). Each frame has `Method`; `File` (project-relative, forward slashes) and `Line` are omitted when debug symbols are unavailable. A caller running as a hot-reload-patched body (a Harmony dynamic method) is reported as a method-only frame under its original `Type.Method` name; `File` and `Line` are omitted because a dynamic method carries no debug symbols. A source path outside `Assets/`, `Packages/`, or `Library/PackageCache/` degrades to a method-only frame. Frame-selection rules: [references/captured-variables.md](references/captured-variables.md). For snapshot timing, preview/truncation caps, Unity-object `Value` semantics, capture-time vs live evidence, `Warning`/`MatchingLogs`, marker freshness, caller frames, and the raw capture API, read [references/captured-variables.md](references/captured-variables.md). diff --git a/.agents/skills/uloop-pause-point/references/captured-variables.md b/.agents/skills/uloop-pause-point/references/captured-variables.md index 95437d7259..9131d5d226 100644 --- a/.agents/skills/uloop-pause-point/references/captured-variables.md +++ b/.agents/skills/uloop-pause-point/references/captured-variables.md @@ -95,7 +95,7 @@ Use `Generation`, `EnabledAtUtc`, and the hit sequence fields from the hit or st ## Caller frames -Each hit records up to two managed caller frames (`CallerFrames`, nearest caller first). `pause-point-status` and `await-pause-point` responses carry them top-level for the latest hit and on every history frame; `enable-pause-point` / `clear-pause-point` responses carry them on history frames only, because those payloads have no top-level capture. The field is always present — an empty array when no managed callers were captured. Selection rules: +Each hit records up to `--max-caller-frames` managed caller frames (`CallerFrames`, nearest caller first; default 2, range 0–8). 0 disables capture and leaves an empty array. The value is fixed at enable time and also caps every later `pause-point-status` response for that marker; status has no flag to change it. `pause-point-status` and `await-pause-point` responses carry them top-level for the latest hit and on every history frame; `enable-pause-point` / `clear-pause-point` responses carry them on history frames only, because those payloads have no top-level capture. The field is always present — an empty array when no managed callers were captured. Selection rules: - Runtime machinery (`System.*`, `Microsoft.*`, `Mono.*`), patching infrastructure (`HarmonyLib.*`, `MonoMod.*`), and uloop's own frames are skipped — except a Harmony patch body, which is a real application caller and is kept as described below. Unity engine and editor frames are kept because an entry point such as `UnityEditor.EditorApplication.update` is itself diagnostic. - Async callers are reported by their logical method name (compiler state-machine frames are demangled to `Namespace.Type.Method`). diff --git a/.claude/skills/uloop-pause-point/SKILL.md b/.claude/skills/uloop-pause-point/SKILL.md index 494d1c0785..d9f50d4164 100644 --- a/.claude/skills/uloop-pause-point/SKILL.md +++ b/.claude/skills/uloop-pause-point/SKILL.md @@ -52,6 +52,7 @@ Enable a pause point so Unity pauses when that code path is reached, either by a | `--mode` | enum | `single-shot` | Capture mode: single-shot pauses once, continuous pauses on every hit, trace records hits without pausing | | `--max-history` | integer | `20` | Maximum number of captured hit frames to retain (1-100) | | `--max-preview-elements` | integer | `10` | Maximum number of elements to include in a captured collection's preview (1-1000). The value set at enable time also caps the previews in every later pause-point-status response for that marker; status has no flag to change it. | +| `--max-caller-frames` | integer | `2` | Maximum number of caller stack frames to record on each hit (0-8). 0 disables capture (`CallerFrames` stays an empty array). The value set at enable time also caps every later pause-point-status response for that marker; status has no flag to change it. | | `--method` | string | - | Optional method simple name or `Type.Method`. When set, `--line` resolves only inside matching methods | ### clear-pause-point @@ -135,7 +136,7 @@ Every hit response embeds `CapturedVariables`: the method's in-scope locals, its - Collection values (arrays, `List`, dictionaries, plain objects) render as a JSON preview capped at 10 elements by default. When the elements you need sit past that cap (a 10x20 grid, a long list), re-enable with `--max-preview-elements ` (1–1000). The value set at enable time also caps the previews in every later `pause-point-status` response for that marker — status has no flag to change it. - While Unity is still paused, `UloopPausePoint.TryGetCapturedValue("name")` (and `"this"`) returns live captured references for `execute-dynamic-code`; the return is a `(bool Found, object Value)` tuple, and the holder clears on resume. (file:line marker hits only — id-only markers store no capture) These are **live objects in their frame-completed state, not snapshots** — use them only to dig further into objects that are still alive, never to reconstruct what a value was at the paused line. -`CallerFrames`: up to two caller stack frames showing how execution reached the marker, nearest caller first — top-level for the latest hit in `pause-point-status` / `await-pause-point` responses, and on every `CapturedVariableHistory` frame in all hit-carrying responses (`enable-pause-point` / `clear-pause-point` payloads have no top-level capture, so their frames appear in the history only). Always present (empty array when no managed callers were captured — for example when the marker's method is called directly by the engine). Each frame has `Method`; `File` (project-relative, forward slashes) and `Line` are omitted when debug symbols are unavailable. A caller running as a hot-reload-patched body (a Harmony dynamic method) is reported as a method-only frame under its original `Type.Method` name; `File` and `Line` are omitted because a dynamic method carries no debug symbols. A source path outside `Assets/`, `Packages/`, or `Library/PackageCache/` degrades to a method-only frame. Frame-selection rules: [references/captured-variables.md](references/captured-variables.md). +`CallerFrames`: caller stack frames showing how execution reached the marker, nearest caller first, capped by `--max-caller-frames` (default 2, range 0–8; 0 records none and leaves an empty array) — top-level for the latest hit in `pause-point-status` / `await-pause-point` responses, and on every `CapturedVariableHistory` frame in all hit-carrying responses (`enable-pause-point` / `clear-pause-point` payloads have no top-level capture, so their frames appear in the history only). Always present (empty array when no managed callers were captured — for example when the marker's method is called directly by the engine, or when `--max-caller-frames 0`). Each frame has `Method`; `File` (project-relative, forward slashes) and `Line` are omitted when debug symbols are unavailable. A caller running as a hot-reload-patched body (a Harmony dynamic method) is reported as a method-only frame under its original `Type.Method` name; `File` and `Line` are omitted because a dynamic method carries no debug symbols. A source path outside `Assets/`, `Packages/`, or `Library/PackageCache/` degrades to a method-only frame. Frame-selection rules: [references/captured-variables.md](references/captured-variables.md). For snapshot timing, preview/truncation caps, Unity-object `Value` semantics, capture-time vs live evidence, `Warning`/`MatchingLogs`, marker freshness, caller frames, and the raw capture API, read [references/captured-variables.md](references/captured-variables.md). diff --git a/.claude/skills/uloop-pause-point/references/captured-variables.md b/.claude/skills/uloop-pause-point/references/captured-variables.md index 95437d7259..9131d5d226 100644 --- a/.claude/skills/uloop-pause-point/references/captured-variables.md +++ b/.claude/skills/uloop-pause-point/references/captured-variables.md @@ -95,7 +95,7 @@ Use `Generation`, `EnabledAtUtc`, and the hit sequence fields from the hit or st ## Caller frames -Each hit records up to two managed caller frames (`CallerFrames`, nearest caller first). `pause-point-status` and `await-pause-point` responses carry them top-level for the latest hit and on every history frame; `enable-pause-point` / `clear-pause-point` responses carry them on history frames only, because those payloads have no top-level capture. The field is always present — an empty array when no managed callers were captured. Selection rules: +Each hit records up to `--max-caller-frames` managed caller frames (`CallerFrames`, nearest caller first; default 2, range 0–8). 0 disables capture and leaves an empty array. The value is fixed at enable time and also caps every later `pause-point-status` response for that marker; status has no flag to change it. `pause-point-status` and `await-pause-point` responses carry them top-level for the latest hit and on every history frame; `enable-pause-point` / `clear-pause-point` responses carry them on history frames only, because those payloads have no top-level capture. The field is always present — an empty array when no managed callers were captured. Selection rules: - Runtime machinery (`System.*`, `Microsoft.*`, `Mono.*`), patching infrastructure (`HarmonyLib.*`, `MonoMod.*`), and uloop's own frames are skipped — except a Harmony patch body, which is a real application caller and is kept as described below. Unity engine and editor frames are kept because an entry point such as `UnityEditor.EditorApplication.update` is itself diagnostic. - Async callers are reported by their logical method name (compiler state-machine frames are demangled to `Namespace.Type.Method`). diff --git a/Assets/Tests/Editor/PausePointCallerFrameSelectorTests.cs b/Assets/Tests/Editor/PausePointCallerFrameSelectorTests.cs index 45d44fef6e..f238e3ae70 100644 --- a/Assets/Tests/Editor/PausePointCallerFrameSelectorTests.cs +++ b/Assets/Tests/Editor/PausePointCallerFrameSelectorTests.cs @@ -37,7 +37,7 @@ public void Select_WhenMarkerIsFirstFrame_ExcludesMarkerFromResult() CreateRawFrame(UserType, UserMethod, UserFile, UserLine), }; - List selected = SourcePausePointCallerFrameSelector.Select(rawFrames); + List selected = SourcePausePointCallerFrameSelector.Select(rawFrames, SourcePausePointConstants.MaxCallerFrames); Assert.That(selected, Has.Count.EqualTo(1)); Assert.That(selected[0].Method, Is.EqualTo(UserType + "." + UserMethod)); @@ -63,7 +63,7 @@ public void Select_WhenFrameTypeStartsWithSkippedPrefix_OmitsThatFrame(string sk CreateRawFrame(skippedTypeFullName, "Run", "Packages/Infrastructure/Run.cs", 7), }; - List selected = SourcePausePointCallerFrameSelector.Select(rawFrames); + List selected = SourcePausePointCallerFrameSelector.Select(rawFrames, SourcePausePointConstants.MaxCallerFrames); Assert.That(selected, Is.Empty); } @@ -84,7 +84,7 @@ public void Select_WhenFrameIsUnityEditorEditorApplication_KeepsTheFrame() 120), }; - List selected = SourcePausePointCallerFrameSelector.Select(rawFrames); + List selected = SourcePausePointCallerFrameSelector.Select(rawFrames, SourcePausePointConstants.MaxCallerFrames); Assert.That(selected, Has.Count.EqualTo(1)); Assert.That(selected[0].Method, Is.EqualTo("UnityEditor.EditorApplication.update")); @@ -105,7 +105,7 @@ public void Select_WhenTypeFullNameIsNull_KeepsRawMethodNameWithoutFileOrLine() CreateRawFrame(null, "DMD", null, 99), }; - List selected = SourcePausePointCallerFrameSelector.Select(rawFrames); + List selected = SourcePausePointCallerFrameSelector.Select(rawFrames, SourcePausePointConstants.MaxCallerFrames); Assert.That(selected, Has.Count.EqualTo(1)); Assert.That(selected[0].Method, Is.EqualTo("DMD")); @@ -134,7 +134,7 @@ public void Select_WhenCallerIsHarmonyPatchedBody_ReportsOriginalMethodNameWitho 0), }; - List selected = SourcePausePointCallerFrameSelector.Select(rawFrames); + List selected = SourcePausePointCallerFrameSelector.Select(rawFrames, SourcePausePointConstants.MaxCallerFrames); Assert.That(selected, Has.Count.EqualTo(1)); Assert.That(selected[0].Method, Is.EqualTo("Game.Input.HandleJump")); @@ -162,7 +162,7 @@ public void Select_WhenHarmonyPatchSuffixIsMultiDigit_ReportsOriginalMethodName( 0), }; - List selected = SourcePausePointCallerFrameSelector.Select(rawFrames); + List selected = SourcePausePointCallerFrameSelector.Select(rawFrames, SourcePausePointConstants.MaxCallerFrames); Assert.That(selected, Has.Count.EqualTo(1)); Assert.That(selected[0].Method, Is.EqualTo("Game.Input.HandleJump")); @@ -187,7 +187,7 @@ public void Select_WhenDmdNameHasNoPatchSuffix_OmitsThatFrame() 0), }; - List selected = SourcePausePointCallerFrameSelector.Select(rawFrames); + List selected = SourcePausePointCallerFrameSelector.Select(rawFrames, SourcePausePointConstants.MaxCallerFrames); Assert.That(selected, Is.Empty); } @@ -208,7 +208,7 @@ public void Select_WhenDmdNameHasNonDigitPatchTail_OmitsThatFrame() 0), }; - List selected = SourcePausePointCallerFrameSelector.Select(rawFrames); + List selected = SourcePausePointCallerFrameSelector.Select(rawFrames, SourcePausePointConstants.MaxCallerFrames); Assert.That(selected, Is.Empty); } @@ -230,7 +230,7 @@ public void Select_WhenPatchedCallerIsUloopInternal_OmitsThatFrame() 0), }; - List selected = SourcePausePointCallerFrameSelector.Select(rawFrames); + List selected = SourcePausePointCallerFrameSelector.Select(rawFrames, SourcePausePointConstants.MaxCallerFrames); Assert.That(selected, Is.Empty); } @@ -261,7 +261,7 @@ public void Select_WhenMarkerAndCallersAreAllHarmonyPatched_ReportsBothCallers() 0), }; - List selected = SourcePausePointCallerFrameSelector.Select(rawFrames); + List selected = SourcePausePointCallerFrameSelector.Select(rawFrames, SourcePausePointConstants.MaxCallerFrames); Assert.That(selected, Has.Count.EqualTo(2)); Assert.That(selected[0].Method, Is.EqualTo("CallerFrameProbe.ShallowCaller")); @@ -289,7 +289,7 @@ public void Select_WhenPatchedCallerIsAsyncStateMachine_ReportsLogicalMethodName 0), }; - List selected = SourcePausePointCallerFrameSelector.Select(rawFrames); + List selected = SourcePausePointCallerFrameSelector.Select(rawFrames, SourcePausePointConstants.MaxCallerFrames); Assert.That(selected, Has.Count.EqualTo(1)); Assert.That(selected[0].Method, Is.EqualTo("Game.Enemy.PatrolAsync")); @@ -311,13 +311,87 @@ public void Select_WhenMoreThanTwoUserFramesExist_ReturnsAtMostTwo() CreateRawFrame("Game.C", "M3", "Assets/Scripts/C.cs", 3), }; - List selected = SourcePausePointCallerFrameSelector.Select(rawFrames); + List selected = SourcePausePointCallerFrameSelector.Select(rawFrames, SourcePausePointConstants.MaxCallerFrames); Assert.That(selected, Has.Count.EqualTo(2)); Assert.That(selected[0].Method, Is.EqualTo("Game.A.M1")); Assert.That(selected[1].Method, Is.EqualTo("Game.B.M2")); } + /// + /// What: a per-marker cap of 1 keeps only the nearest caller. + /// + [Test] + public void Select_WhenMaxCallerFramesIsOne_ReturnsNearestCallerOnly() + { + SourcePausePointRawStackFrame[] rawFrames = + { + CreateRawFrame(MarkerType, MarkerMethod, MarkerFile, MarkerLine), + CreateRawFrame("Game.A", "M1", "Assets/Scripts/A.cs", 1), + CreateRawFrame("Game.B", "M2", "Assets/Scripts/B.cs", 2), + CreateRawFrame("Game.C", "M3", "Assets/Scripts/C.cs", 3), + }; + + List selected = SourcePausePointCallerFrameSelector.Select(rawFrames, 1); + + Assert.That(selected, Has.Count.EqualTo(1)); + Assert.That(selected[0].Method, Is.EqualTo("Game.A.M1")); + } + + /// + /// What: a per-marker cap of 4 returns four callers when more remain above the marker. + /// + [Test] + public void Select_WhenMaxCallerFramesIsFour_ReturnsFourCallers() + { + SourcePausePointRawStackFrame[] rawFrames = + { + CreateRawFrame(MarkerType, MarkerMethod, MarkerFile, MarkerLine), + CreateRawFrame("Game.A", "M1", "Assets/Scripts/A.cs", 1), + CreateRawFrame("Game.B", "M2", "Assets/Scripts/B.cs", 2), + CreateRawFrame("Game.C", "M3", "Assets/Scripts/C.cs", 3), + CreateRawFrame("Game.D", "M4", "Assets/Scripts/D.cs", 4), + CreateRawFrame("Game.E", "M5", "Assets/Scripts/E.cs", 5), + }; + + List selected = SourcePausePointCallerFrameSelector.Select(rawFrames, 4); + + Assert.That(selected, Has.Count.EqualTo(4)); + Assert.That(selected[0].Method, Is.EqualTo("Game.A.M1")); + Assert.That(selected[1].Method, Is.EqualTo("Game.B.M2")); + Assert.That(selected[2].Method, Is.EqualTo("Game.C.M3")); + Assert.That(selected[3].Method, Is.EqualTo("Game.D.M4")); + } + + /// + /// What: a per-marker cap of 0 disables capture and always returns an empty array. + /// + [Test] + public void Select_WhenMaxCallerFramesIsZero_ReturnsEmptyArray() + { + SourcePausePointRawStackFrame[] rawFrames = + { + CreateRawFrame(MarkerType, MarkerMethod, MarkerFile, MarkerLine), + CreateRawFrame(UserType, UserMethod, UserFile, UserLine), + }; + + List selected = SourcePausePointCallerFrameSelector.Select(rawFrames, 0); + + Assert.That(selected, Is.Empty); + } + + /// + /// What: CaptureCallerFrames with cap 0 returns an empty array. + /// + [Test] + public void CaptureCallerFrames_WhenCapIsZero_ReturnsEmptyArray() + { + List selected = + SourcePausePointCallerFrameCapture.CaptureCallerFrames(0); + + Assert.That(selected, Is.Empty); + } + /// /// What: compiler-generated async state-machine MoveNext frames demangle to the /// logical Type.Method name. @@ -513,7 +587,7 @@ public void Select_WhenFileNameIsNull_ReportsLineZeroRegardlessOfRawLine() CreateRawFrame(UserType, UserMethod, null, 42), }; - List selected = SourcePausePointCallerFrameSelector.Select(rawFrames); + List selected = SourcePausePointCallerFrameSelector.Select(rawFrames, SourcePausePointConstants.MaxCallerFrames); Assert.That(selected, Has.Count.EqualTo(1)); Assert.That(selected[0].File, Is.Null); @@ -528,7 +602,7 @@ public void Select_WhenRawFramesAreEmpty_ReturnsEmptyList() { SourcePausePointRawStackFrame[] rawFrames = { }; - List selected = SourcePausePointCallerFrameSelector.Select(rawFrames); + List selected = SourcePausePointCallerFrameSelector.Select(rawFrames, SourcePausePointConstants.MaxCallerFrames); Assert.That(selected, Is.Empty); } @@ -544,7 +618,7 @@ public void Select_WhenOnlyMarkerFrameIsPresent_ReturnsEmptyList() CreateRawFrame(MarkerType, MarkerMethod, MarkerFile, MarkerLine), }; - List selected = SourcePausePointCallerFrameSelector.Select(rawFrames); + List selected = SourcePausePointCallerFrameSelector.Select(rawFrames, SourcePausePointConstants.MaxCallerFrames); Assert.That(selected, Is.Empty); } @@ -562,7 +636,7 @@ public void Select_WhenCallerIsAsyncStateMachine_ReportsLogicalMethodName() CreateRawFrame("Game.Enemy+d__7", "MoveNext", "Assets/Scripts/Enemy.cs", 55), }; - List selected = SourcePausePointCallerFrameSelector.Select(rawFrames); + List selected = SourcePausePointCallerFrameSelector.Select(rawFrames, SourcePausePointConstants.MaxCallerFrames); Assert.That(selected, Has.Count.EqualTo(1)); Assert.That(selected[0].Method, Is.EqualTo("Game.Enemy.Chase")); @@ -584,7 +658,7 @@ public void Select_WhenFileHasDotSlashAndBackslashes_NormalizesThroughSelect() CreateRawFrame(UserType, UserMethod, ".\\Assets\\Scripts\\Input.cs", 10), }; - List selected = SourcePausePointCallerFrameSelector.Select(rawFrames); + List selected = SourcePausePointCallerFrameSelector.Select(rawFrames, SourcePausePointConstants.MaxCallerFrames); Assert.That(selected, Has.Count.EqualTo(1)); Assert.That(selected[0].File, Is.EqualTo("Assets/Scripts/Input.cs")); @@ -608,7 +682,7 @@ public void Select_WhenFileIsAbsoluteAssetsPath_NormalizesThroughSelect() 10), }; - List selected = SourcePausePointCallerFrameSelector.Select(rawFrames); + List selected = SourcePausePointCallerFrameSelector.Select(rawFrames, SourcePausePointConstants.MaxCallerFrames); Assert.That(selected, Has.Count.EqualTo(1)); Assert.That(selected[0].File, Is.EqualTo("Assets/Scripts/Input.cs")); @@ -628,7 +702,7 @@ public void Select_WhenFileIsRootedWithoutProjectSegment_DegradesToMethodOnlyFra CreateRawFrame(UserType, UserMethod, "/Users//External/Src/Foo.cs", 10), }; - List selected = SourcePausePointCallerFrameSelector.Select(rawFrames); + List selected = SourcePausePointCallerFrameSelector.Select(rawFrames, SourcePausePointConstants.MaxCallerFrames); Assert.That(selected, Has.Count.EqualTo(1)); Assert.That(selected[0].File, Is.Null); @@ -680,6 +754,6 @@ internal static class StackHost public static List Level1() { return Level2(); } [MethodImpl(MethodImplOptions.NoInlining)] - public static List Level2() { return SourcePausePointCallerFrameCapture.CaptureCallerFrames(); } + public static List Level2() { return SourcePausePointCallerFrameCapture.CaptureCallerFrames(SourcePausePointConstants.MaxCallerFrames); } } } diff --git a/Assets/Tests/Editor/PausePointExpiredRecommendedNextActionTests.cs b/Assets/Tests/Editor/PausePointExpiredRecommendedNextActionTests.cs index 76b0c28608..0ecda792d2 100644 --- a/Assets/Tests/Editor/PausePointExpiredRecommendedNextActionTests.cs +++ b/Assets/Tests/Editor/PausePointExpiredRecommendedNextActionTests.cs @@ -73,6 +73,7 @@ private static UloopPausePointSnapshot CreateExpiredSnapshot(string recommendedN UloopPausePointCaptureMode.SingleShot, 20, 15, + 2, Array.Empty(), 0, true, diff --git a/Assets/Tests/Editor/PausePointStatusResponseContractTests.cs b/Assets/Tests/Editor/PausePointStatusResponseContractTests.cs index f28327a827..9c43423aa6 100644 --- a/Assets/Tests/Editor/PausePointStatusResponseContractTests.cs +++ b/Assets/Tests/Editor/PausePointStatusResponseContractTests.cs @@ -37,6 +37,7 @@ public void PausePointStatusResponse_WhenSerialized_MatchesSharedContractFieldSh Mode = "continuous", MaxHistory = 20, MaxPreviewElements = 15, + MaxCallerFrames = 4, CapturedVariableHistory = new List { new() diff --git a/Assets/Tests/Editor/PausePointTests.cs b/Assets/Tests/Editor/PausePointTests.cs index 0bb0fb9f0b..0c5551d6bd 100644 --- a/Assets/Tests/Editor/PausePointTests.cs +++ b/Assets/Tests/Editor/PausePointTests.cs @@ -1555,6 +1555,28 @@ public async Task Enable_WhenFileAndLineResolveToRealMethod_PatchesAndCapturesVa Is.EquivalentTo(new[] { "left", "right", "sum", "this", "Tag" })); } + /// + /// What: max-caller-frames on a file:line enable reaches the response, covering the + /// source-location Enable path that the id-only tests do not exercise. + /// + [Test] + public async Task Enable_WhenFileAndLineAndMaxCallerFramesAreProvided_MapsParameter() + { + EnablePausePointTool tool = new(); + JObject parameters = new() + { + ["file"] = FixtureFilePath, + ["line"] = FixtureLine, + ["timeoutSeconds"] = 30, + ["maxCallerFrames"] = 5 + }; + + PausePointResponse response = (PausePointResponse)await tool.ExecuteAsync(parameters, CancellationToken.None); + + Assert.That(response.Success, Is.True); + Assert.That(response.MaxCallerFrames, Is.EqualTo(5)); + } + /// /// What: an enable failure response carries the live editor state instead of a /// zero-filled default (IsPlaying/CapturedAt must flow from the registry's pause controller). diff --git a/Assets/Tests/Editor/PausePointToolModeTests.cs b/Assets/Tests/Editor/PausePointToolModeTests.cs index 0b68c1d90a..91bbbb0b15 100644 --- a/Assets/Tests/Editor/PausePointToolModeTests.cs +++ b/Assets/Tests/Editor/PausePointToolModeTests.cs @@ -237,6 +237,105 @@ public void GetMaxPreviewElements_WhenMarkerIsUnknown_ReturnsRegistryDefault() Is.EqualTo(UloopPausePointRegistry.DefaultMaxPreviewElements)); } + /// + /// What: max-caller-frames 0, 1, and 8 reach the enable response. + /// + [TestCase(0)] + [TestCase(1)] + [TestCase(8)] + public async Task Enable_WhenMaxCallerFramesIsInRange_MapsParameter(int maxCallerFrames) + { + EnablePausePointTool tool = new(); + JObject parameters = new() + { + ["id"] = "jump", + ["timeoutSeconds"] = 30, + ["maxCallerFrames"] = maxCallerFrames + }; + + PausePointResponse response = (PausePointResponse)await tool.ExecuteAsync(parameters, CancellationToken.None); + + Assert.That(response.Success, Is.True); + Assert.That(response.MaxCallerFrames, Is.EqualTo(maxCallerFrames)); + } + + /// + /// What: max-caller-frames defaults to 2 when omitted. + /// + [Test] + public async Task Enable_WhenMaxCallerFramesIsOmitted_DefaultsToTwo() + { + EnablePausePointTool tool = new(); + JObject parameters = new() + { + ["id"] = "jump", + ["timeoutSeconds"] = 30 + }; + + PausePointResponse response = (PausePointResponse)await tool.ExecuteAsync(parameters, CancellationToken.None); + + Assert.That(response.Success, Is.True); + Assert.That(response.MaxCallerFrames, Is.EqualTo(UloopPausePointRegistry.DefaultMaxCallerFrames)); + } + + /// + /// What: max-caller-frames 9 is rejected as out of range. + /// + [Test] + public async Task Enable_WhenMaxCallerFramesIsNine_ReturnsValidationFailure() + { + EnablePausePointTool tool = new(); + JObject parameters = new() + { + ["id"] = "jump", + ["maxCallerFrames"] = 9 + }; + + PausePointResponse response = (PausePointResponse)await tool.ExecuteAsync(parameters, CancellationToken.None); + + Assert.That(response.Success, Is.False); + Assert.That(response.Message, Is.EqualTo("MaxCallerFrames must be between 0 and 8.")); + } + + /// + /// What: GetMaxCallerFrames returns the per-marker cap stored at Enable. + /// + [Test] + public void GetMaxCallerFrames_WhenMarkerIsEnabled_ReturnsConfiguredValue() + { + UloopPausePointRegistry.Enable( + "jump", 30, UloopPausePointCaptureMode.SingleShot, 20, 10, 4); + + Assert.That(UloopPausePointRegistry.GetMaxCallerFrames("jump"), Is.EqualTo(4)); + } + + /// + /// What: GetMaxCallerFrames falls back to the default for an unknown id. + /// + [Test] + public void GetMaxCallerFrames_WhenMarkerIsUnknown_ReturnsRegistryDefault() + { + Assert.That( + UloopPausePointRegistry.GetMaxCallerFrames("unknown"), + Is.EqualTo(UloopPausePointRegistry.DefaultMaxCallerFrames)); + } + + /// + /// What: a hit with max-caller-frames 0 still carries an empty CallerFrames array. + /// + [Test] + public void Hit_WhenMaxCallerFramesIsZero_ReturnsEmptyCallerFramesArray() + { + UloopPausePointRegistry.Enable( + "jump", 30, UloopPausePointCaptureMode.SingleShot, 20, 10, 0); + + SourcePausePointCapture.Capture( + "jump", null, Array.Empty(), Array.Empty()); + + UloopPausePointSnapshot snapshot = UloopPausePointRegistry.GetStatus("jump"); + Assert.That(snapshot.CallerFrames, Is.Empty); + } + /// /// Verifies the CLI-only status bridge exposes mode and captured history fields. /// diff --git a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md index 494d1c0785..d9f50d4164 100644 --- a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md +++ b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md @@ -52,6 +52,7 @@ Enable a pause point so Unity pauses when that code path is reached, either by a | `--mode` | enum | `single-shot` | Capture mode: single-shot pauses once, continuous pauses on every hit, trace records hits without pausing | | `--max-history` | integer | `20` | Maximum number of captured hit frames to retain (1-100) | | `--max-preview-elements` | integer | `10` | Maximum number of elements to include in a captured collection's preview (1-1000). The value set at enable time also caps the previews in every later pause-point-status response for that marker; status has no flag to change it. | +| `--max-caller-frames` | integer | `2` | Maximum number of caller stack frames to record on each hit (0-8). 0 disables capture (`CallerFrames` stays an empty array). The value set at enable time also caps every later pause-point-status response for that marker; status has no flag to change it. | | `--method` | string | - | Optional method simple name or `Type.Method`. When set, `--line` resolves only inside matching methods | ### clear-pause-point @@ -135,7 +136,7 @@ Every hit response embeds `CapturedVariables`: the method's in-scope locals, its - Collection values (arrays, `List`, dictionaries, plain objects) render as a JSON preview capped at 10 elements by default. When the elements you need sit past that cap (a 10x20 grid, a long list), re-enable with `--max-preview-elements ` (1–1000). The value set at enable time also caps the previews in every later `pause-point-status` response for that marker — status has no flag to change it. - While Unity is still paused, `UloopPausePoint.TryGetCapturedValue("name")` (and `"this"`) returns live captured references for `execute-dynamic-code`; the return is a `(bool Found, object Value)` tuple, and the holder clears on resume. (file:line marker hits only — id-only markers store no capture) These are **live objects in their frame-completed state, not snapshots** — use them only to dig further into objects that are still alive, never to reconstruct what a value was at the paused line. -`CallerFrames`: up to two caller stack frames showing how execution reached the marker, nearest caller first — top-level for the latest hit in `pause-point-status` / `await-pause-point` responses, and on every `CapturedVariableHistory` frame in all hit-carrying responses (`enable-pause-point` / `clear-pause-point` payloads have no top-level capture, so their frames appear in the history only). Always present (empty array when no managed callers were captured — for example when the marker's method is called directly by the engine). Each frame has `Method`; `File` (project-relative, forward slashes) and `Line` are omitted when debug symbols are unavailable. A caller running as a hot-reload-patched body (a Harmony dynamic method) is reported as a method-only frame under its original `Type.Method` name; `File` and `Line` are omitted because a dynamic method carries no debug symbols. A source path outside `Assets/`, `Packages/`, or `Library/PackageCache/` degrades to a method-only frame. Frame-selection rules: [references/captured-variables.md](references/captured-variables.md). +`CallerFrames`: caller stack frames showing how execution reached the marker, nearest caller first, capped by `--max-caller-frames` (default 2, range 0–8; 0 records none and leaves an empty array) — top-level for the latest hit in `pause-point-status` / `await-pause-point` responses, and on every `CapturedVariableHistory` frame in all hit-carrying responses (`enable-pause-point` / `clear-pause-point` payloads have no top-level capture, so their frames appear in the history only). Always present (empty array when no managed callers were captured — for example when the marker's method is called directly by the engine, or when `--max-caller-frames 0`). Each frame has `Method`; `File` (project-relative, forward slashes) and `Line` are omitted when debug symbols are unavailable. A caller running as a hot-reload-patched body (a Harmony dynamic method) is reported as a method-only frame under its original `Type.Method` name; `File` and `Line` are omitted because a dynamic method carries no debug symbols. A source path outside `Assets/`, `Packages/`, or `Library/PackageCache/` degrades to a method-only frame. Frame-selection rules: [references/captured-variables.md](references/captured-variables.md). For snapshot timing, preview/truncation caps, Unity-object `Value` semantics, capture-time vs live evidence, `Warning`/`MatchingLogs`, marker freshness, caller frames, and the raw capture API, read [references/captured-variables.md](references/captured-variables.md). diff --git a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/references/captured-variables.md b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/references/captured-variables.md index 95437d7259..9131d5d226 100644 --- a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/references/captured-variables.md +++ b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/references/captured-variables.md @@ -95,7 +95,7 @@ Use `Generation`, `EnabledAtUtc`, and the hit sequence fields from the hit or st ## Caller frames -Each hit records up to two managed caller frames (`CallerFrames`, nearest caller first). `pause-point-status` and `await-pause-point` responses carry them top-level for the latest hit and on every history frame; `enable-pause-point` / `clear-pause-point` responses carry them on history frames only, because those payloads have no top-level capture. The field is always present — an empty array when no managed callers were captured. Selection rules: +Each hit records up to `--max-caller-frames` managed caller frames (`CallerFrames`, nearest caller first; default 2, range 0–8). 0 disables capture and leaves an empty array. The value is fixed at enable time and also caps every later `pause-point-status` response for that marker; status has no flag to change it. `pause-point-status` and `await-pause-point` responses carry them top-level for the latest hit and on every history frame; `enable-pause-point` / `clear-pause-point` responses carry them on history frames only, because those payloads have no top-level capture. The field is always present — an empty array when no managed callers were captured. Selection rules: - Runtime machinery (`System.*`, `Microsoft.*`, `Mono.*`), patching infrastructure (`HarmonyLib.*`, `MonoMod.*`), and uloop's own frames are skipped — except a Harmony patch body, which is a real application caller and is kept as described below. Unity engine and editor frames are kept because an entry point such as `UnityEditor.EditorApplication.update` is itself diagnostic. - Async callers are reported by their logical method name (compiler state-machine frames are demangled to `Namespace.Type.Method`). diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointTools.cs b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointTools.cs index 2032b58c85..1ddbf99755 100644 --- a/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointTools.cs +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointTools.cs @@ -31,6 +31,8 @@ public class EnablePausePointSchema : UnityCliLoopToolSchema public int MaxPreviewElements { get; set; } = UloopPausePointRegistry.DefaultMaxPreviewElements; + public int MaxCallerFrames { get; set; } = UloopPausePointRegistry.DefaultMaxCallerFrames; + public string Method { get; set; } = string.Empty; } @@ -62,6 +64,7 @@ public class PausePointResponse : UnityCliLoopToolResponse public string Mode { get; set; } = string.Empty; public int MaxHistory { get; set; } public int MaxPreviewElements { get; set; } + public int MaxCallerFrames { get; set; } public IReadOnlyList CapturedVariableHistory { get; set; } = Array.Empty(); public int HistoryDroppedCount { get; set; } @@ -106,6 +109,7 @@ internal static PausePointResponse FromSnapshot(UloopPausePointSnapshot snapshot Mode = snapshot.Mode, MaxHistory = snapshot.MaxHistory, MaxPreviewElements = snapshot.MaxPreviewElements, + MaxCallerFrames = snapshot.MaxCallerFrames, CapturedVariableHistory = snapshot.CapturedVariableHistory .Select(PausePointCapturedHistoryFrame.FromSnapshot) .ToList(), diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointUseCase.cs b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointUseCase.cs index 8aa358d556..236759afa6 100644 --- a/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointUseCase.cs +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointUseCase.cs @@ -86,7 +86,8 @@ public PausePointResponse Enable(EnablePausePointSchema parameters) parameters.TimeoutSeconds, parameters.Mode, parameters.MaxHistory, - parameters.MaxPreviewElements); + parameters.MaxPreviewElements, + parameters.MaxCallerFrames); PausePointResponse response = PausePointResponse.FromSnapshot(snapshot); response.Warning = PausePointEnableWarnings.CreateEnableWarning(); LogEnable(response.Id, resolvedMethod: string.Empty, fileLine: string.Empty, response.Mode, response.Warning); @@ -341,7 +342,8 @@ private static PausePointResponse FinishEnableBySourceLocation( parameters.TimeoutSeconds, parameters.Mode, parameters.MaxHistory, - parameters.MaxPreviewElements); + parameters.MaxPreviewElements, + parameters.MaxCallerFrames); if (retargetedToHotReloadPatch) { UloopPausePointRegistry.SetRetargetedToHotReloadPatch(id, true); @@ -544,6 +546,12 @@ private static string ValidateCaptureSettings(EnablePausePointSchema parameters) return $"MaxPreviewElements must be between 1 and {UloopPausePointRegistry.MaxPreviewElementsLimit}."; } + if (parameters.MaxCallerFrames < 0 || + parameters.MaxCallerFrames > UloopPausePointRegistry.MaxCallerFramesLimit) + { + return $"MaxCallerFrames must be between 0 and {UloopPausePointRegistry.MaxCallerFramesLimit}."; + } + return null; } diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointCallerFrameCapture.cs b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointCallerFrameCapture.cs index 0d40590248..54c694fa75 100644 --- a/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointCallerFrameCapture.cs +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointCallerFrameCapture.cs @@ -16,8 +16,20 @@ internal static class SourcePausePointCallerFrameCapture // fNeedFileInfo:true is effectively free on Mono (measured at parity with false, // ~0.12 ms per capture) and yields file:line for script assemblies compiled with // Debug code optimization — the same prerequisite pause points already require. - public static List CaptureCallerFrames() + public static List CaptureCallerFrames(int maxCallerFrames) { + Debug.Assert(maxCallerFrames >= 0, "maxCallerFrames must not be negative"); + Debug.Assert( + maxCallerFrames <= UloopPausePointRegistry.MaxCallerFramesLimit, + "maxCallerFrames must not exceed the caller-frame limit"); + + if (maxCallerFrames == 0) + { + // Why skip the walk: 0 is the high-frequency trace escape hatch, so the cost of + // examining 24 frames would defeat the option. + return new List(); + } + StackTrace stackTrace = new StackTrace(fNeedFileInfo: true); int frameCount = Math.Min( stackTrace.FrameCount, SourcePausePointConstants.MaxCallerStackFramesToExamine); @@ -52,7 +64,7 @@ public static List CaptureCallerFrames() frame.GetFileLineNumber())); } - return SourcePausePointCallerFrameSelector.Select(rawFrames); + return SourcePausePointCallerFrameSelector.Select(rawFrames, maxCallerFrames); } } } diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointCallerFrameSelector.cs b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointCallerFrameSelector.cs index 8959a5bada..19d57d07c5 100644 --- a/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointCallerFrameSelector.cs +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointCallerFrameSelector.cs @@ -33,15 +33,25 @@ internal static class SourcePausePointCallerFrameSelector // positionally instead of by identity because a hot-reload-patched marker method can // appear as a Harmony dynamic method whose display name is not predictable. public static List Select( - IReadOnlyList rawFrames) + IReadOnlyList rawFrames, + int maxCallerFrames) { Debug.Assert(rawFrames != null, "rawFrames must not be null"); + Debug.Assert(maxCallerFrames >= 0, "maxCallerFrames must not be negative"); + Debug.Assert( + maxCallerFrames <= UloopPausePointRegistry.MaxCallerFramesLimit, + "maxCallerFrames must not exceed the caller-frame limit"); List selected = - new List(SourcePausePointConstants.MaxCallerFrames); + new List(maxCallerFrames); + if (maxCallerFrames == 0) + { + return selected; + } + for (int i = 1; i < rawFrames.Count; i++) { - if (selected.Count == SourcePausePointConstants.MaxCallerFrames) + if (selected.Count == maxCallerFrames) { break; } diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointCapture.cs b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointCapture.cs index 77ad11a116..e439206ead 100644 --- a/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointCapture.cs +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointCapture.cs @@ -30,8 +30,9 @@ public static void Capture( CaptureFrame(instance, parameterNamesAndValues, localNamesAndValues, maxPreviewElements); // The stack must be walked on the hitting thread; a deferred main-thread hit would see // the scheduler's stack instead of the caller chain that reached the marker. + int maxCallerFrames = UloopPausePointRegistry.GetMaxCallerFrames(id); List callerFrames = - SourcePausePointCallerFrameCapture.CaptureCallerFrames(); + SourcePausePointCallerFrameCapture.CaptureCallerFrames(maxCallerFrames); if (MainThreadSwitcher.IsMainThread) { diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointConstants.cs b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointConstants.cs index 607e04a15b..960bb5d5e7 100644 --- a/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointConstants.cs +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointConstants.cs @@ -42,8 +42,9 @@ internal static class SourcePausePointConstants public const int MaxCollectionPreviewValueLength = 1024; public const int MaxCollectionPreviewDepth = 2; - // Nearest caller plus one more; the CLI has no option to raise this. - public const int MaxCallerFrames = 2; + // Default nearest caller plus one more. enable-pause-point --max-caller-frames can raise + // or disable this per marker (0 skips capture; the examine walk stays capped at 24). + public const int MaxCallerFrames = UloopPausePointRegistry.DefaultMaxCallerFrames; // Walk this many raw stack frames so skipped infrastructure still leaves room for two callers. public const int MaxCallerStackFramesToExamine = 24; diff --git a/Packages/src/Editor/Infrastructure/Api/PausePointStatusBridgeCommand.cs b/Packages/src/Editor/Infrastructure/Api/PausePointStatusBridgeCommand.cs index 7e41aafcd4..85466c3b3f 100644 --- a/Packages/src/Editor/Infrastructure/Api/PausePointStatusBridgeCommand.cs +++ b/Packages/src/Editor/Infrastructure/Api/PausePointStatusBridgeCommand.cs @@ -133,6 +133,7 @@ public class PausePointStatusResponse : UnityCliLoopToolResponse public string Mode { get; set; } = string.Empty; public int MaxHistory { get; set; } public int MaxPreviewElements { get; set; } + public int MaxCallerFrames { get; set; } public IReadOnlyList CapturedVariableHistory { get; set; } = Array.Empty(); public int HistoryDroppedCount { get; set; } @@ -192,6 +193,7 @@ internal static PausePointStatusResponse FromSnapshot(UloopPausePointSnapshot sn Mode = snapshot.Mode, MaxHistory = snapshot.MaxHistory, MaxPreviewElements = snapshot.MaxPreviewElements, + MaxCallerFrames = snapshot.MaxCallerFrames, CapturedVariableHistory = snapshot.CapturedVariableHistory .Select(PausePointStatusCapturedHistoryFrame.FromSnapshot) .ToList(), diff --git a/Packages/src/Runtime/PausePoints/UloopPausePointEntry.cs b/Packages/src/Runtime/PausePoints/UloopPausePointEntry.cs index 9f12c76f7a..9336cda07e 100644 --- a/Packages/src/Runtime/PausePoints/UloopPausePointEntry.cs +++ b/Packages/src/Runtime/PausePoints/UloopPausePointEntry.cs @@ -16,6 +16,7 @@ public UloopPausePointEntry( string mode, int maxHistory, int maxPreviewElements, + int maxCallerFrames, DateTime enabledAtUtc, int generation) { @@ -24,6 +25,7 @@ public UloopPausePointEntry( Mode = mode; MaxHistory = maxHistory; MaxPreviewElements = maxPreviewElements; + MaxCallerFrames = maxCallerFrames; EnabledAtUtc = enabledAtUtc; ExpiresAtUtc = enabledAtUtc.AddSeconds(timeoutSeconds); Generation = generation; @@ -41,6 +43,7 @@ public UloopPausePointEntry( public string Mode { get; } public int MaxHistory { get; } public int MaxPreviewElements { get; } + public int MaxCallerFrames { get; } public DateTime EnabledAtUtc { get; } public DateTime ExpiresAtUtc { get; private set; } public int Generation { get; } @@ -259,6 +262,7 @@ public UloopPausePointSnapshot ToSnapshot(DateTime nowUtc, IUloopPausePointPause Mode, MaxHistory, MaxPreviewElements, + MaxCallerFrames, new List(_capturedVariableHistory), HistoryDroppedCount, expired, diff --git a/Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs b/Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs index 81369b21f2..0628bb5349 100644 --- a/Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs +++ b/Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs @@ -20,6 +20,8 @@ internal static class UloopPausePointRegistry public const int MaxHistoryLimit = 100; public const int DefaultMaxPreviewElements = 10; public const int MaxPreviewElementsLimit = 1000; + public const int DefaultMaxCallerFrames = 2; + public const int MaxCallerFramesLimit = 8; private static readonly ConcurrentDictionary Entries = new(); private static IUloopPausePointPauseController _pauseController = new UnityEditorPausePointPauseController(); @@ -76,7 +78,8 @@ public static UloopPausePointSnapshot Enable( int timeoutSeconds, string mode = UloopPausePointCaptureMode.SingleShot, int maxHistory = DefaultMaxHistory, - int maxPreviewElements = DefaultMaxPreviewElements) + int maxPreviewElements = DefaultMaxPreviewElements, + int maxCallerFrames = DefaultMaxCallerFrames) { Debug.Assert(!string.IsNullOrWhiteSpace(id), "id must not be null or empty"); Debug.Assert(timeoutSeconds > 0, "timeoutSeconds must be greater than zero"); @@ -87,10 +90,15 @@ public static UloopPausePointSnapshot Enable( Debug.Assert( maxPreviewElements <= MaxPreviewElementsLimit, "maxPreviewElements must not exceed the preview element limit"); + Debug.Assert(maxCallerFrames >= 0, "maxCallerFrames must not be negative"); + Debug.Assert( + maxCallerFrames <= MaxCallerFramesLimit, + "maxCallerFrames must not exceed the caller-frame limit"); DateTime now = NowUtc(); int generation = ++_nextGeneration; - UloopPausePointEntry entry = new(id, timeoutSeconds, mode, maxHistory, maxPreviewElements, now, generation); + UloopPausePointEntry entry = new( + id, timeoutSeconds, mode, maxHistory, maxPreviewElements, maxCallerFrames, now, generation); Entries[id] = entry; // Why not clear the raw capture holder here: a re-enable does not resume Unity, so the // paused-window constraint (see UloopPausePointRawCaptureHolder's class comment) is not @@ -327,6 +335,16 @@ public static int GetMaxPreviewElements(string id) : DefaultMaxPreviewElements; } + // Called from the Harmony Capture entry point so the per-marker caller-frame cap set at + // Enable time can size this hit. Falls back to the default when the id is unexpectedly + // missing (e.g. a race with Clear) rather than asserting, since Capture must never throw. + public static int GetMaxCallerFrames(string id) + { + return Entries.TryGetValue(id, out UloopPausePointEntry entry) + ? entry.MaxCallerFrames + : DefaultMaxCallerFrames; + } + /// /// Counts entries still armed (IsEnabled), i.e. markers whose Harmony patch is currently /// installed and would be lost on the next domain reload. diff --git a/Packages/src/Runtime/PausePoints/UloopPausePointSnapshot.cs b/Packages/src/Runtime/PausePoints/UloopPausePointSnapshot.cs index 0d386bdd98..88eb8bf4b2 100644 --- a/Packages/src/Runtime/PausePoints/UloopPausePointSnapshot.cs +++ b/Packages/src/Runtime/PausePoints/UloopPausePointSnapshot.cs @@ -21,6 +21,7 @@ public UloopPausePointSnapshot( string mode, int maxHistory, int maxPreviewElements, + int maxCallerFrames, IReadOnlyList capturedVariableHistory, int historyDroppedCount, bool expired, @@ -60,6 +61,7 @@ public UloopPausePointSnapshot( Mode = mode ?? UloopPausePointCaptureMode.SingleShot; MaxHistory = maxHistory; MaxPreviewElements = maxPreviewElements; + MaxCallerFrames = maxCallerFrames; CapturedVariableHistory = capturedVariableHistory ?? Array.Empty(); HistoryDroppedCount = historyDroppedCount; Expired = expired; @@ -98,6 +100,7 @@ public UloopPausePointSnapshot( public string Mode { get; } public int MaxHistory { get; } public int MaxPreviewElements { get; } + public int MaxCallerFrames { get; } public IReadOnlyList CapturedVariableHistory { get; } public int HistoryDroppedCount { get; } public bool Expired { get; } @@ -140,6 +143,7 @@ public static UloopPausePointSnapshot NotEnabled(string id, IUloopPausePointPaus UloopPausePointCaptureMode.SingleShot, 0, 0, + 0, Array.Empty(), 0, false, diff --git a/cli/common/tools/default-tools.json b/cli/common/tools/default-tools.json index 4eec68a254..aa56a871c4 100644 --- a/cli/common/tools/default-tools.json +++ b/cli/common/tools/default-tools.json @@ -394,6 +394,11 @@ "description": "Maximum number of elements to include in a captured collection's preview (1-1000). The value set at enable time also caps the previews in every later pause-point-status response for that marker; status has no flag to change it.", "default": 10 }, + "MaxCallerFrames": { + "type": "integer", + "description": "Maximum number of caller stack frames to record on each hit (0-8). 0 disables capture (CallerFrames stays an empty array). The value set at enable time also caps every later pause-point-status response for that marker; status has no flag to change it.", + "default": 2 + }, "Method": { "type": "string", "description": "Optional method simple name or Type.Method. When set, --line resolves only inside matching methods" diff --git a/cli/common/tools/description_fallback_test.go b/cli/common/tools/description_fallback_test.go index 4ad53be91f..d49afc36e2 100644 --- a/cli/common/tools/description_fallback_test.go +++ b/cli/common/tools/description_fallback_test.go @@ -206,3 +206,17 @@ func TestEmbeddedEnablePausePointDocumentsMaxPreviewElementsCarryOver(t *testing t.Errorf("--max-preview-elements description does not mention pause-point-status: %q", description) } } + +// Verifies enable-pause-point documents that --max-caller-frames also shapes later +// pause-point-status responses, which is not discoverable from the option name. +func TestEmbeddedEnablePausePointDocumentsMaxCallerFramesCarryOver(t *testing.T) { + tool, ok := Find(LoadDefault(), "enable-pause-point") + if !ok { + t.Fatal("embedded catalog has no enable-pause-point tool") + } + + description := tool.EffectiveInputSchema().Properties["MaxCallerFrames"].Description + if !strings.Contains(description, "pause-point-status") { + t.Errorf("--max-caller-frames description does not mention pause-point-status: %q", description) + } +} diff --git a/cli/dispatcher/shared-inputs-stamp.json b/cli/dispatcher/shared-inputs-stamp.json index f7b391eef7..713a42f1ab 100644 --- a/cli/dispatcher/shared-inputs-stamp.json +++ b/cli/dispatcher/shared-inputs-stamp.json @@ -1,4 +1,4 @@ { "schemaVersion": 1, - "sharedInputsHash": "78e467873f1001b3fd3479b6140c1af0c0ac31f1" + "sharedInputsHash": "f588a3abc7f8b366afcd631ef91d94572a86dc7b" } diff --git a/cli/project-runner/internal/projectrunner/pause_point_types.go b/cli/project-runner/internal/projectrunner/pause_point_types.go index c37440f226..c80ac9b06a 100644 --- a/cli/project-runner/internal/projectrunner/pause_point_types.go +++ b/cli/project-runner/internal/projectrunner/pause_point_types.go @@ -12,6 +12,7 @@ type pausePointStatusResponse struct { Mode string `json:"Mode"` MaxHistory int `json:"MaxHistory"` MaxPreviewElements int `json:"MaxPreviewElements"` + MaxCallerFrames int `json:"MaxCallerFrames"` CapturedVariableHistory []pausePointCapturedHistoryFrame `json:"CapturedVariableHistory"` HistoryDroppedCount int `json:"HistoryDroppedCount"` Expired bool `json:"Expired"` diff --git a/cli/project-runner/internal/projectrunner/pause_point_unknown_option.go b/cli/project-runner/internal/projectrunner/pause_point_unknown_option.go index 1ee7fc3c9a..cd7056e8f3 100644 --- a/cli/project-runner/internal/projectrunner/pause_point_unknown_option.go +++ b/cli/project-runner/internal/projectrunner/pause_point_unknown_option.go @@ -22,13 +22,14 @@ var pausePointFlagOwnerSearchOrder = []string{ } // pausePointCarriedOverEnableFlagNames are the enable-pause-point flags whose values Unity reports -// back on every later status response (as Mode, MaxHistory, MaxPreviewElements and TimeoutSeconds). +// back on every later status response (as Mode, MaxHistory, MaxPreviewElements, MaxCallerFrames and TimeoutSeconds). // Passing one of these to a query command is not just misplaced, it is unnecessary — which is the // part a caller cannot infer from "wrong command" alone. var pausePointCarriedOverEnableFlagNames = []string{ "mode", "max-history", "max-preview-elements", + "max-caller-frames", PausePointTimeoutFlagName, } diff --git a/cli/project-runner/internal/projectrunner/pause_point_unknown_option_test.go b/cli/project-runner/internal/projectrunner/pause_point_unknown_option_test.go index 21289b5a5a..838195303b 100644 --- a/cli/project-runner/internal/projectrunner/pause_point_unknown_option_test.go +++ b/cli/project-runner/internal/projectrunner/pause_point_unknown_option_test.go @@ -112,6 +112,7 @@ func TestPausePointCarriedOverEnableFlagsAreVisibleInTheStatusResponse(t *testin Mode: "continuous", MaxHistory: 20, MaxPreviewElements: 5, + MaxCallerFrames: 4, TimeoutSeconds: 30, }) if err != nil { @@ -122,6 +123,7 @@ func TestPausePointCarriedOverEnableFlagsAreVisibleInTheStatusResponse(t *testin "mode": "Mode", "max-history": "MaxHistory", "max-preview-elements": "MaxPreviewElements", + "max-caller-frames": "MaxCallerFrames", "timeout-seconds": "TimeoutSeconds", } if len(carriedOverFields) != len(pausePointCarriedOverEnableFlagNames) { diff --git a/cli/project-runner/shared-inputs-stamp.json b/cli/project-runner/shared-inputs-stamp.json index 00b34e6ece..9a8239c23d 100644 --- a/cli/project-runner/shared-inputs-stamp.json +++ b/cli/project-runner/shared-inputs-stamp.json @@ -1,4 +1,4 @@ { "schemaVersion": 1, - "sharedInputsHash": "4c6f0378239751e07ae529066059d0bf53c23359" + "sharedInputsHash": "fbe86d610cddff04a7e7ae09837319a7e4a0ccff" } diff --git a/tests/contracts/pause_point_status_response_contract.json b/tests/contracts/pause_point_status_response_contract.json index a99c17b672..97034f0052 100644 --- a/tests/contracts/pause_point_status_response_contract.json +++ b/tests/contracts/pause_point_status_response_contract.json @@ -9,6 +9,7 @@ "Mode": "continuous", "MaxHistory": 20, "MaxPreviewElements": 15, + "MaxCallerFrames": 4, "CapturedVariableHistory": [ { "HitSequence": 1,