diff --git a/.agents/skills/uloop-pause-point/SKILL.md b/.agents/skills/uloop-pause-point/SKILL.md index 7de52f2e86..5c64352dff 100644 --- a/.agents/skills/uloop-pause-point/SKILL.md +++ b/.agents/skills/uloop-pause-point/SKILL.md @@ -136,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`: 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). +`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. When they are omitted, `Note` distinguishes a hot-reload **or pause-point instrumentation** dynamic method, a frame with no debug symbols, and a source path outside the Unity project. 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 e8bfa9518d..e6887c23f8 100644 --- a/.agents/skills/uloop-pause-point/references/captured-variables.md +++ b/.agents/skills/uloop-pause-point/references/captured-variables.md @@ -99,7 +99,7 @@ Each hit records up to `--max-caller-frames` managed caller frames (`CallerFrame - 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`). -- Debug symbols (the Debug code-optimization prerequisite pause points already have) control only `File` and `Line`: a frame without symbols keeps its formatted `Method` and omits `File`/`Line`. 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 so the payload never carries a machine path. +- Debug symbols (the Debug code-optimization prerequisite pause points already have) control only `File` and `Line`: a frame without symbols keeps its formatted `Method` and omits `File`/`Line`. When those fields are omitted, `Note` names the reason: a caller running as a hot-reload-patched **or pause-point-instrumented** Harmony dynamic method (`"dynamic method (patched by hot reload or pause-point instrumentation); no debug symbols"`); a frame whose assembly has no debug symbols (`"no source file information; the frame's assembly has no debug symbols"`); or a source path outside `Assets/`, `Packages/`, or `Library/PackageCache/` (`"source file is outside the Unity project"`), so the payload never carries a machine path. Do not treat a missing `File` as "outside the project" by default — that label applies only when the raw path was present and failed project-root normalization. - The frames are the synchronous call chain at the moment the marker line ran. A marker that resumes after an `await` does not see its original awaiting caller — only dispatch machinery remains, so expect a method-only engine frame (or an empty array); the awaiting method itself never appears. After a synchronization-context resume that frame is typically `UnityEngine.UnitySynchronizationContext`; after `await Awaitable.NextFrameAsync` it is typically an Awaitable continuation such as `` UnityEngine.Awaitable+AwaitableAsyncMethodBuilder+StateMachineBox`1.DoMoveNext `` or `UnityEngine.Awaitable.RunOrScheduleContinuation`. The engine-direct case (an `Update` marker) is a plain empty array. Capturing the frames costs on the order of 0.1 ms per hit, which also bounds the extra trace-mode overhead per recorded hit. diff --git a/.claude/skills/uloop-pause-point/SKILL.md b/.claude/skills/uloop-pause-point/SKILL.md index 7de52f2e86..5c64352dff 100644 --- a/.claude/skills/uloop-pause-point/SKILL.md +++ b/.claude/skills/uloop-pause-point/SKILL.md @@ -136,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`: 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). +`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. When they are omitted, `Note` distinguishes a hot-reload **or pause-point instrumentation** dynamic method, a frame with no debug symbols, and a source path outside the Unity project. 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 e8bfa9518d..e6887c23f8 100644 --- a/.claude/skills/uloop-pause-point/references/captured-variables.md +++ b/.claude/skills/uloop-pause-point/references/captured-variables.md @@ -99,7 +99,7 @@ Each hit records up to `--max-caller-frames` managed caller frames (`CallerFrame - 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`). -- Debug symbols (the Debug code-optimization prerequisite pause points already have) control only `File` and `Line`: a frame without symbols keeps its formatted `Method` and omits `File`/`Line`. 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 so the payload never carries a machine path. +- Debug symbols (the Debug code-optimization prerequisite pause points already have) control only `File` and `Line`: a frame without symbols keeps its formatted `Method` and omits `File`/`Line`. When those fields are omitted, `Note` names the reason: a caller running as a hot-reload-patched **or pause-point-instrumented** Harmony dynamic method (`"dynamic method (patched by hot reload or pause-point instrumentation); no debug symbols"`); a frame whose assembly has no debug symbols (`"no source file information; the frame's assembly has no debug symbols"`); or a source path outside `Assets/`, `Packages/`, or `Library/PackageCache/` (`"source file is outside the Unity project"`), so the payload never carries a machine path. Do not treat a missing `File` as "outside the project" by default — that label applies only when the raw path was present and failed project-root normalization. - The frames are the synchronous call chain at the moment the marker line ran. A marker that resumes after an `await` does not see its original awaiting caller — only dispatch machinery remains, so expect a method-only engine frame (or an empty array); the awaiting method itself never appears. After a synchronization-context resume that frame is typically `UnityEngine.UnitySynchronizationContext`; after `await Awaitable.NextFrameAsync` it is typically an Awaitable continuation such as `` UnityEngine.Awaitable+AwaitableAsyncMethodBuilder+StateMachineBox`1.DoMoveNext `` or `UnityEngine.Awaitable.RunOrScheduleContinuation`. The engine-direct case (an `Update` marker) is a plain empty array. Capturing the frames costs on the order of 0.1 ms per hit, which also bounds the extra trace-mode overhead per recorded hit. diff --git a/Assets/Tests/Editor/PausePointCallerFrameSelectorTests.cs b/Assets/Tests/Editor/PausePointCallerFrameSelectorTests.cs index f238e3ae70..53ced722e9 100644 --- a/Assets/Tests/Editor/PausePointCallerFrameSelectorTests.cs +++ b/Assets/Tests/Editor/PausePointCallerFrameSelectorTests.cs @@ -24,6 +24,13 @@ public sealed class PausePointCallerFrameSelectorTests private const string UserFile = "Assets/Scripts/Input.cs"; private const int UserLine = 10; + private const string WantDynamicMethodNote = + "dynamic method (patched by hot reload or pause-point instrumentation); no debug symbols"; + private const string WantMissingDebugSymbolsNote = + "no source file information; the frame's assembly has no debug symbols"; + private const string WantOutsideProjectNote = + "source file is outside the Unity project"; + /// /// What: rawFrames[0] is the marker's own frame and is skipped by position, so it /// never appears in the selected callers. @@ -43,6 +50,7 @@ public void Select_WhenMarkerIsFirstFrame_ExcludesMarkerFromResult() Assert.That(selected[0].Method, Is.EqualTo(UserType + "." + UserMethod)); Assert.That(selected[0].File, Is.EqualTo(UserFile)); Assert.That(selected[0].Line, Is.EqualTo(UserLine)); + Assert.That(selected[0].Note, Is.Null); } /// @@ -111,6 +119,7 @@ public void Select_WhenTypeFullNameIsNull_KeepsRawMethodNameWithoutFileOrLine() Assert.That(selected[0].Method, Is.EqualTo("DMD")); Assert.That(selected[0].File, Is.Null); Assert.That(selected[0].Line, Is.EqualTo(0)); + Assert.That(selected[0].Note, Is.EqualTo(WantMissingDebugSymbolsNote)); } /// @@ -140,6 +149,7 @@ public void Select_WhenCallerIsHarmonyPatchedBody_ReportsOriginalMethodNameWitho Assert.That(selected[0].Method, Is.EqualTo("Game.Input.HandleJump")); Assert.That(selected[0].File, Is.Null); Assert.That(selected[0].Line, Is.EqualTo(0)); + Assert.That(selected[0].Note, Is.EqualTo(WantDynamicMethodNote)); } /// @@ -168,6 +178,7 @@ public void Select_WhenHarmonyPatchSuffixIsMultiDigit_ReportsOriginalMethodName( Assert.That(selected[0].Method, Is.EqualTo("Game.Input.HandleJump")); Assert.That(selected[0].File, Is.Null); Assert.That(selected[0].Line, Is.EqualTo(0)); + Assert.That(selected[0].Note, Is.EqualTo(WantDynamicMethodNote)); } /// @@ -592,6 +603,7 @@ public void Select_WhenFileNameIsNull_ReportsLineZeroRegardlessOfRawLine() Assert.That(selected, Has.Count.EqualTo(1)); Assert.That(selected[0].File, Is.Null); Assert.That(selected[0].Line, Is.EqualTo(0)); + Assert.That(selected[0].Note, Is.EqualTo(WantMissingDebugSymbolsNote)); } /// @@ -707,6 +719,28 @@ public void Select_WhenFileIsRootedWithoutProjectSegment_DegradesToMethodOnlyFra Assert.That(selected, Has.Count.EqualTo(1)); Assert.That(selected[0].File, Is.Null); Assert.That(selected[0].Line, Is.EqualTo(0)); + Assert.That(selected[0].Note, Is.EqualTo(WantOutsideProjectNote)); + } + + /// + /// What: an empty FileName is the missing-debug-symbols path, not "outside the project", + /// so checking IsNullOrEmpty before NormalizeFilePath is required. + /// + [Test] + public void Select_WhenFileNameIsEmpty_ReportsMissingDebugSymbolsNote() + { + SourcePausePointRawStackFrame[] rawFrames = + { + CreateRawFrame(MarkerType, MarkerMethod, MarkerFile, MarkerLine), + CreateRawFrame(UserType, UserMethod, string.Empty, 42), + }; + + List selected = SourcePausePointCallerFrameSelector.Select(rawFrames, SourcePausePointConstants.MaxCallerFrames); + + Assert.That(selected, Has.Count.EqualTo(1)); + Assert.That(selected[0].File, Is.Null); + Assert.That(selected[0].Line, Is.EqualTo(0)); + Assert.That(selected[0].Note, Is.EqualTo(WantMissingDebugSymbolsNote)); } /// diff --git a/Assets/Tests/Editor/PausePointStatusCallerFrameTests.cs b/Assets/Tests/Editor/PausePointStatusCallerFrameTests.cs new file mode 100644 index 0000000000..a77fe9c4ab --- /dev/null +++ b/Assets/Tests/Editor/PausePointStatusCallerFrameTests.cs @@ -0,0 +1,83 @@ +using Newtonsoft.Json; +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; +using io.github.hatayama.UnityCliLoop.Infrastructure; +using io.github.hatayama.UnityCliLoop.Runtime; +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Verifies pause-point caller-frame DTOs copy Note from the runtime frame. + /// + [TestFixture] + public sealed class PausePointStatusCallerFrameTests + { + private const string WantDynamicMethodNote = + "dynamic method (patched by hot reload or pause-point instrumentation); no debug symbols"; + + /// + /// What: FromCallerFrame copies Note so a selector-set note is not dropped at the bridge. + /// + [Test] + public void FromCallerFrame_WhenNoteIsSet_CopiesNoteOntoTheStatusDto() + { + UloopPausePointCallerFrame source = new( + "Game.Input.HandleJump", + null, + 0, + WantDynamicMethodNote); + + PausePointStatusCallerFrame dto = PausePointStatusCallerFrame.FromCallerFrame(source); + + Assert.That(dto.Method, Is.EqualTo("Game.Input.HandleJump")); + Assert.That(dto.File, Is.Null); + Assert.That(dto.Line, Is.EqualTo(0)); + Assert.That(dto.Note, Is.EqualTo(WantDynamicMethodNote)); + } + + /// + /// What: PausePointCallerFrame.FromSnapshot copies Note so enable/clear history frames + /// do not drop a selector-set note. + /// + [Test] + public void PausePointCallerFrameFromSnapshot_WhenNoteIsSet_CopiesNoteOntoTheHistoryDto() + { + UloopPausePointCallerFrame source = new( + "Game.Input.HandleJump", + null, + 0, + WantDynamicMethodNote); + + PausePointCallerFrame dto = PausePointCallerFrame.FromSnapshot(source); + + Assert.That(dto.Method, Is.EqualTo("Game.Input.HandleJump")); + Assert.That(dto.File, Is.Null); + Assert.That(dto.Line, Is.EqualTo(0)); + Assert.That(dto.Note, Is.EqualTo(WantDynamicMethodNote)); + } + + /// + /// What: a null Note is omitted from JSON so File-bearing fixture frames keep their shape. + /// + [Test] + public void PausePointStatusCallerFrame_WhenNoteIsNull_OmitsNoteFromJson() + { + PausePointStatusCallerFrame frame = new() + { + Method = "Game.AI.Tick", + File = "Assets/Scripts/AI.cs", + Line = 44, + Note = null + }; + + string json = JsonConvert.SerializeObject( + frame, + Formatting.None, + UnityCliLoopJsonResponseSerializerSettings.Settings); + + Assert.That(json, Does.Not.Contain("Note")); + } + } +} diff --git a/Assets/Tests/Editor/PausePointStatusCallerFrameTests.cs.meta b/Assets/Tests/Editor/PausePointStatusCallerFrameTests.cs.meta new file mode 100644 index 0000000000..d6d29eb24d --- /dev/null +++ b/Assets/Tests/Editor/PausePointStatusCallerFrameTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b66c823678ffe4a8aba14a2091e4a4d1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/PausePointTests.cs b/Assets/Tests/Editor/PausePointTests.cs index 0c5551d6bd..8f524612ba 100644 --- a/Assets/Tests/Editor/PausePointTests.cs +++ b/Assets/Tests/Editor/PausePointTests.cs @@ -1161,8 +1161,8 @@ public void HitWithCapturedFrame_WhenCallerFramesAreProvided_StoresThemOnLatestS UloopPausePointCapturedVariableFrame frame = CreateEmptyCapturedFrame(); UloopPausePointCallerFrame[] callerFrames = { - new("Game.Input.HandleJump", "Assets/Scripts/Input.cs", 10), - new("Game.Player.Update", "Assets/Scripts/Player.cs", 20), + new("Game.Input.HandleJump", "Assets/Scripts/Input.cs", 10, null), + new("Game.Player.Update", "Assets/Scripts/Player.cs", 20, null), }; UloopPausePointSnapshot snapshot = UloopPausePointRegistry.HitWithCapturedFrame( @@ -1181,12 +1181,12 @@ public void HitWithCapturedFrame_WhenMultipleHitsAreRecorded_StoresCallerFramesO UloopPausePointCapturedVariableFrame frame = CreateEmptyCapturedFrame(); UloopPausePointCallerFrame[] firstCallerFrames = { - new("Game.Input.HandleJump", "Assets/Scripts/Input.cs", 10), + new("Game.Input.HandleJump", "Assets/Scripts/Input.cs", 10, null), }; UloopPausePointCallerFrame[] secondCallerFrames = { - new("Game.AI.Tick", "Assets/Scripts/AI.cs", 44), - new("Game.World.Update", "Assets/Scripts/World.cs", 8), + new("Game.AI.Tick", "Assets/Scripts/AI.cs", 44, null), + new("Game.World.Update", "Assets/Scripts/World.cs", 8, null), }; UloopPausePointRegistry.HitWithCapturedFrame( @@ -1242,7 +1242,7 @@ public void StatusFromSnapshot_WhenCallerFramesArePresent_MapsTopLevelAndHistory UloopPausePointRegistry.Enable("jump", 30); UloopPausePointCallerFrame[] callerFrames = { - new("Game.Input.HandleJump", "Assets/Scripts/Input.cs", 10), + new("Game.Input.HandleJump", "Assets/Scripts/Input.cs", 10, null), }; UloopPausePointRegistry.HitWithCapturedFrame( "jump", @@ -1275,7 +1275,7 @@ public void FromSnapshot_WhenCallerFramesArePresent_MapsHistoryFramesOnly() UloopPausePointRegistry.Enable("jump", 30); UloopPausePointCallerFrame[] callerFrames = { - new("Game.Input.HandleJump", "Assets/Scripts/Input.cs", 10), + new("Game.Input.HandleJump", "Assets/Scripts/Input.cs", 10, null), }; UloopPausePointRegistry.HitWithCapturedFrame( "jump", diff --git a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md index 7de52f2e86..5c64352dff 100644 --- a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md +++ b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md @@ -136,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`: 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). +`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. When they are omitted, `Note` distinguishes a hot-reload **or pause-point instrumentation** dynamic method, a frame with no debug symbols, and a source path outside the Unity project. 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 e8bfa9518d..e6887c23f8 100644 --- a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/references/captured-variables.md +++ b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/references/captured-variables.md @@ -99,7 +99,7 @@ Each hit records up to `--max-caller-frames` managed caller frames (`CallerFrame - 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`). -- Debug symbols (the Debug code-optimization prerequisite pause points already have) control only `File` and `Line`: a frame without symbols keeps its formatted `Method` and omits `File`/`Line`. 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 so the payload never carries a machine path. +- Debug symbols (the Debug code-optimization prerequisite pause points already have) control only `File` and `Line`: a frame without symbols keeps its formatted `Method` and omits `File`/`Line`. When those fields are omitted, `Note` names the reason: a caller running as a hot-reload-patched **or pause-point-instrumented** Harmony dynamic method (`"dynamic method (patched by hot reload or pause-point instrumentation); no debug symbols"`); a frame whose assembly has no debug symbols (`"no source file information; the frame's assembly has no debug symbols"`); or a source path outside `Assets/`, `Packages/`, or `Library/PackageCache/` (`"source file is outside the Unity project"`), so the payload never carries a machine path. Do not treat a missing `File` as "outside the project" by default — that label applies only when the raw path was present and failed project-root normalization. - The frames are the synchronous call chain at the moment the marker line ran. A marker that resumes after an `await` does not see its original awaiting caller — only dispatch machinery remains, so expect a method-only engine frame (or an empty array); the awaiting method itself never appears. After a synchronization-context resume that frame is typically `UnityEngine.UnitySynchronizationContext`; after `await Awaitable.NextFrameAsync` it is typically an Awaitable continuation such as `` UnityEngine.Awaitable+AwaitableAsyncMethodBuilder+StateMachineBox`1.DoMoveNext `` or `UnityEngine.Awaitable.RunOrScheduleContinuation`. The engine-direct case (an `Update` marker) is a plain empty array. Capturing the frames costs on the order of 0.1 ms per hit, which also bounds the extra trace-mode overhead per recorded hit. diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointTools.cs b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointTools.cs index 1ddbf99755..f83f730a54 100644 --- a/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointTools.cs +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointTools.cs @@ -259,6 +259,10 @@ public class PausePointCallerFrame [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)] public int Line { get; set; } + // Null when File is present. Distinguishes why File/Line were omitted. + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + public string Note { get; set; } + internal static PausePointCallerFrame FromSnapshot(UloopPausePointCallerFrame snapshot) { if (snapshot == null) @@ -270,7 +274,8 @@ internal static PausePointCallerFrame FromSnapshot(UloopPausePointCallerFrame sn { Method = snapshot.Method, File = snapshot.File, - Line = snapshot.Line + Line = snapshot.Line, + Note = snapshot.Note }; } } diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointCallerFrameSelector.cs b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointCallerFrameSelector.cs index 19d57d07c5..8e1138fb7e 100644 --- a/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointCallerFrameSelector.cs +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointCallerFrameSelector.cs @@ -61,8 +61,13 @@ public static List Select( if (patchedCallerDisplay != null) { // A dynamic method carries no debug symbols, so the frame is method-only by - // construction (File null, Line 0). - selected.Add(new UloopPausePointCallerFrame(patchedCallerDisplay, null, 0)); + // construction (File null, Line 0). Note names the cause: hot-reload patch + // or pause-point instrumentation, not a missing FileName in general. + selected.Add(new UloopPausePointCallerFrame( + patchedCallerDisplay, + null, + 0, + SourcePausePointConstants.CallerFrameDynamicMethodNote)); continue; } @@ -71,11 +76,32 @@ public static List Select( continue; } + string methodDisplay = FormatMethodDisplay(frame.TypeFullName, frame.MethodName); + // Why inspect FileName before NormalizeFilePath: that helper also returns null + // for an empty FileName, and labeling every null as "outside the project" would + // mis-describe frames that simply have no debug symbols. + if (string.IsNullOrEmpty(frame.FileName)) + { + selected.Add(new UloopPausePointCallerFrame( + methodDisplay, + null, + 0, + SourcePausePointConstants.CallerFrameMissingDebugSymbolsNote)); + continue; + } + string file = NormalizeFilePath(frame.FileName); - selected.Add(new UloopPausePointCallerFrame( - FormatMethodDisplay(frame.TypeFullName, frame.MethodName), - file, - file == null ? 0 : frame.Line)); + if (file == null) + { + selected.Add(new UloopPausePointCallerFrame( + methodDisplay, + null, + 0, + SourcePausePointConstants.CallerFrameOutsideProjectNote)); + continue; + } + + selected.Add(new UloopPausePointCallerFrame(methodDisplay, file, frame.Line, null)); } return selected; diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointConstants.cs b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointConstants.cs index 3c34d73a8a..1cd8e24dee 100644 --- a/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointConstants.cs +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointConstants.cs @@ -46,6 +46,16 @@ internal static class SourcePausePointConstants // dimension varies fastest), and the preview Elements array is that same flattening. public const string MultidimensionalArrayElementOrder = "row-major (last dimension fastest)"; + // Why three distinct notes: File/Line omission has three causes that look identical on + // the wire without Note. NormalizeFilePath returning null covers both missing FileName + // and a non-project path, so that null must not be labeled "outside the project". + public const string CallerFrameDynamicMethodNote = + "dynamic method (patched by hot reload or pause-point instrumentation); no debug symbols"; + public const string CallerFrameMissingDebugSymbolsNote = + "no source file information; the frame's assembly has no debug symbols"; + public const string CallerFrameOutsideProjectNote = + "source file is outside the Unity project"; + // 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; diff --git a/Packages/src/Editor/Infrastructure/Api/PausePointStatusBridgeCommand.cs b/Packages/src/Editor/Infrastructure/Api/PausePointStatusBridgeCommand.cs index 85466c3b3f..92f78fedf7 100644 --- a/Packages/src/Editor/Infrastructure/Api/PausePointStatusBridgeCommand.cs +++ b/Packages/src/Editor/Infrastructure/Api/PausePointStatusBridgeCommand.cs @@ -366,6 +366,10 @@ public class PausePointStatusCallerFrame [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)] public int Line { get; set; } + // Null when File is present. Distinguishes why File/Line were omitted. + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + public string Note { get; set; } + internal static PausePointStatusCallerFrame FromCallerFrame(UloopPausePointCallerFrame callerFrame) { if (callerFrame == null) @@ -377,7 +381,8 @@ internal static PausePointStatusCallerFrame FromCallerFrame(UloopPausePointCalle { Method = callerFrame.Method, File = callerFrame.File, - Line = callerFrame.Line + Line = callerFrame.Line, + Note = callerFrame.Note }; } } diff --git a/Packages/src/Runtime/PausePoints/UloopPausePointCallerFrame.cs b/Packages/src/Runtime/PausePoints/UloopPausePointCallerFrame.cs index e6be031630..3995f49e74 100644 --- a/Packages/src/Runtime/PausePoints/UloopPausePointCallerFrame.cs +++ b/Packages/src/Runtime/PausePoints/UloopPausePointCallerFrame.cs @@ -8,7 +8,7 @@ namespace io.github.hatayama.UnityCliLoop.Runtime /// internal sealed class UloopPausePointCallerFrame { - public UloopPausePointCallerFrame(string method, string file, int line) + public UloopPausePointCallerFrame(string method, string file, int line, string note) { Debug.Assert(!string.IsNullOrEmpty(method), "method must not be null or empty"); // A frame without debug symbols must not report a stale line number. @@ -17,6 +17,7 @@ public UloopPausePointCallerFrame(string method, string file, int line) Method = method; File = file; Line = line; + Note = note; } public string Method { get; } @@ -26,6 +27,10 @@ public UloopPausePointCallerFrame(string method, string file, int line) public string File { get; } public int Line { get; } + + // Null when File is present. Set when File/Line are omitted, distinguishing a dynamic + // method, missing debug symbols, and a source path outside the Unity project. + public string Note { get; } } } #endif diff --git a/cli/project-runner/internal/projectrunner/pause_point_caller_frame_note_test.go b/cli/project-runner/internal/projectrunner/pause_point_caller_frame_note_test.go new file mode 100644 index 0000000000..f860a7db3d --- /dev/null +++ b/cli/project-runner/internal/projectrunner/pause_point_caller_frame_note_test.go @@ -0,0 +1,54 @@ +package projectrunner + +import ( + "encoding/json" + "strings" + "testing" +) + +const wantCallerFrameDynamicMethodNote = "dynamic method (patched by hot reload or pause-point instrumentation); no debug symbols" + +// Verifies a set caller-frame Note survives json.Marshal under that exact key. +func TestPausePointCallerFrameIncludesNote(t *testing.T) { + marshaled, err := json.Marshal(pausePointCallerFrame{ + Method: "Game.Input.HandleJump", + Note: wantCallerFrameDynamicMethodNote, + }) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + + var decoded map[string]json.RawMessage + if err := json.Unmarshal(marshaled, &decoded); err != nil { + t.Fatalf("unmarshal envelope failed: %v", err) + } + + rawNote, ok := decoded["Note"] + if !ok { + t.Fatalf("Note missing from JSON: %s", marshaled) + } + + var note string + if err := json.Unmarshal(rawNote, ¬e); err != nil { + t.Fatalf("unmarshal Note failed: %v", err) + } + if note != wantCallerFrameDynamicMethodNote { + t.Fatalf("Note mismatch: got %#v, want %#v", note, wantCallerFrameDynamicMethodNote) + } +} + +// Verifies an empty Note is omitted so File-bearing frames keep the shared contract shape. +func TestPausePointCallerFrameOmitsEmptyNote(t *testing.T) { + marshaled, err := json.Marshal(pausePointCallerFrame{ + Method: "Game.AI.Tick", + File: "Assets/Scripts/AI.cs", + Line: 44, + }) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + + if strings.Contains(string(marshaled), "Note") { + t.Fatalf("empty Note must be omitted from JSON: %s", marshaled) + } +} diff --git a/cli/project-runner/internal/projectrunner/pause_point_types.go b/cli/project-runner/internal/projectrunner/pause_point_types.go index 4da8bb3a54..fb1656c9a0 100644 --- a/cli/project-runner/internal/projectrunner/pause_point_types.go +++ b/cli/project-runner/internal/projectrunner/pause_point_types.go @@ -139,11 +139,13 @@ type pausePointCapturedHistoryFrame struct { } // pausePointCallerFrame mirrors the Unity-side caller-frame DTO: Method is always -// present; File and Line are omitted when debug symbols are unavailable. +// present; File and Line are omitted when debug symbols are unavailable. Note is +// omitted when File is present. type pausePointCallerFrame struct { Method string `json:"Method"` File string `json:"File,omitempty"` Line int `json:"Line,omitempty"` + Note string `json:"Note,omitempty"` } // pausePointCapturedVariable mirrors the flat Unity-side