diff --git a/.agents/skills/uloop-pause-point/SKILL.md b/.agents/skills/uloop-pause-point/SKILL.md index d966d042e8..494d1c0785 100644 --- a/.agents/skills/uloop-pause-point/SKILL.md +++ b/.agents/skills/uloop-pause-point/SKILL.md @@ -98,6 +98,7 @@ Choose the capture mode when enabling a pause point: - `continuous` pauses Unity on every hit and remains armed. - `trace` remains armed and records each hit without pausing Unity. - In every mode, `CapturedVariables` holds the latest hit and `CapturedVariableHistory` holds only strictly older frames, so with a single hit the history is empty (for `single-shot` it always is). When the latest-hit frame is excluded, `CapturedVariableHistoryNote` explains that the latest hit's variables are in `CapturedVariables`. +- Prefer tracing a line that executes conditionally: a line that runs every frame fills the capped history within a fraction of a second and drops everything recorded before it. - In trace mode, Status "Hit" does not mean Play Mode paused; the response carries a StatusNote saying the marker fired while the game kept running. - An Expired response carries a RecommendedNextAction: re-enable the pause point with a longer --timeout-seconds (default 30) and trigger the code path again; clearing the expired marker first is not required. - For an already-hit `continuous` or `trace` marker, `await-pause-point` waits for a **new** hit after the wait starts (`LastHitSequence` advancing). It does not return the stale hit that is already present. Read the current hit with `pause-point-status` instead. If await times out while waiting for that new hit, the error stays `PAUSE_POINT_WAIT_TIMEOUT` and `Details.Hint` tells you to pass `--resume-play` (or resume Play Mode) so another hit can occur. A freshly enabled marker (including `enable-pause-point --await`) has no prior hit, so the first hit satisfies the wait as before. @@ -134,7 +135,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 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`: 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). 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 2c348f60b6..95437d7259 100644 --- a/.agents/skills/uloop-pause-point/references/captured-variables.md +++ b/.agents/skills/uloop-pause-point/references/captured-variables.md @@ -97,9 +97,9 @@ Use `Generation`, `EnabledAtUtc`, and the hit sequence fields from the hit or st 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: -- Runtime machinery (`System.*`, `Microsoft.*`, `Mono.*`), patching infrastructure (`HarmonyLib.*`, `MonoMod.*`), and uloop's own frames are skipped. Unity engine and editor frames are kept because an entry point such as `UnityEditor.EditorApplication.update` is itself diagnostic. +- 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 whose declaring type cannot be resolved — for example a hot-reload-patched body executing as a Harmony dynamic method — is reported by its raw method name instead. 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`. 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. - 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 such as the Unity synchronization context (or an empty array); the awaiting method itself never appears. 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 d966d042e8..494d1c0785 100644 --- a/.claude/skills/uloop-pause-point/SKILL.md +++ b/.claude/skills/uloop-pause-point/SKILL.md @@ -98,6 +98,7 @@ Choose the capture mode when enabling a pause point: - `continuous` pauses Unity on every hit and remains armed. - `trace` remains armed and records each hit without pausing Unity. - In every mode, `CapturedVariables` holds the latest hit and `CapturedVariableHistory` holds only strictly older frames, so with a single hit the history is empty (for `single-shot` it always is). When the latest-hit frame is excluded, `CapturedVariableHistoryNote` explains that the latest hit's variables are in `CapturedVariables`. +- Prefer tracing a line that executes conditionally: a line that runs every frame fills the capped history within a fraction of a second and drops everything recorded before it. - In trace mode, Status "Hit" does not mean Play Mode paused; the response carries a StatusNote saying the marker fired while the game kept running. - An Expired response carries a RecommendedNextAction: re-enable the pause point with a longer --timeout-seconds (default 30) and trigger the code path again; clearing the expired marker first is not required. - For an already-hit `continuous` or `trace` marker, `await-pause-point` waits for a **new** hit after the wait starts (`LastHitSequence` advancing). It does not return the stale hit that is already present. Read the current hit with `pause-point-status` instead. If await times out while waiting for that new hit, the error stays `PAUSE_POINT_WAIT_TIMEOUT` and `Details.Hint` tells you to pass `--resume-play` (or resume Play Mode) so another hit can occur. A freshly enabled marker (including `enable-pause-point --await`) has no prior hit, so the first hit satisfies the wait as before. @@ -134,7 +135,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 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`: 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). 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 2c348f60b6..95437d7259 100644 --- a/.claude/skills/uloop-pause-point/references/captured-variables.md +++ b/.claude/skills/uloop-pause-point/references/captured-variables.md @@ -97,9 +97,9 @@ Use `Generation`, `EnabledAtUtc`, and the hit sequence fields from the hit or st 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: -- Runtime machinery (`System.*`, `Microsoft.*`, `Mono.*`), patching infrastructure (`HarmonyLib.*`, `MonoMod.*`), and uloop's own frames are skipped. Unity engine and editor frames are kept because an entry point such as `UnityEditor.EditorApplication.update` is itself diagnostic. +- 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 whose declaring type cannot be resolved — for example a hot-reload-patched body executing as a Harmony dynamic method — is reported by its raw method name instead. 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`. 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. - 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 such as the Unity synchronization context (or an empty array); the awaiting method itself never appears. 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 2bb52d97e6..45d44fef6e 100644 --- a/Assets/Tests/Editor/PausePointCallerFrameSelectorTests.cs +++ b/Assets/Tests/Editor/PausePointCallerFrameSelectorTests.cs @@ -113,6 +113,190 @@ public void Select_WhenTypeFullNameIsNull_KeepsRawMethodNameWithoutFileOrLine() Assert.That(selected[0].Line, Is.EqualTo(0)); } + /// + /// What: a Harmony-patched caller (DMD declaring type and _PatchN name) is reported + /// as a method-only frame under its original Type.Method name. + /// + [Test] + public void Select_WhenCallerIsHarmonyPatchedBody_ReportsOriginalMethodNameWithoutFileOrLine() + { + SourcePausePointRawStackFrame[] rawFrames = + { + CreateRawFrame( + SourcePausePointConstants.HarmonyDynamicMethodDeclaringType, + "Game.Player.Jump_Patch1", + null, + 0), + CreateRawFrame( + SourcePausePointConstants.HarmonyDynamicMethodDeclaringType, + "Game.Input.HandleJump_Patch1", + null, + 0), + }; + + List selected = SourcePausePointCallerFrameSelector.Select(rawFrames); + + Assert.That(selected, Has.Count.EqualTo(1)); + 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)); + } + + /// + /// What: a multi-digit Harmony patch suffix still resolves to the original method name. + /// + [Test] + public void Select_WhenHarmonyPatchSuffixIsMultiDigit_ReportsOriginalMethodName() + { + SourcePausePointRawStackFrame[] rawFrames = + { + CreateRawFrame( + SourcePausePointConstants.HarmonyDynamicMethodDeclaringType, + "Game.Player.Jump_Patch1", + null, + 0), + CreateRawFrame( + SourcePausePointConstants.HarmonyDynamicMethodDeclaringType, + "Game.Input.HandleJump_Patch12", + null, + 0), + }; + + List selected = SourcePausePointCallerFrameSelector.Select(rawFrames); + + Assert.That(selected, Has.Count.EqualTo(1)); + 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)); + } + + /// + /// What: a DMD frame whose name has no Harmony _Patch digits suffix stays skipped as + /// genuine MonoMod infrastructure. + /// + [Test] + public void Select_WhenDmdNameHasNoPatchSuffix_OmitsThatFrame() + { + SourcePausePointRawStackFrame[] rawFrames = + { + CreateRawFrame(MarkerType, MarkerMethod, MarkerFile, MarkerLine), + CreateRawFrame( + SourcePausePointConstants.HarmonyDynamicMethodDeclaringType, + "MonoMod.Utils.DynamicMethodDefinition.Generate", + null, + 0), + }; + + List selected = SourcePausePointCallerFrameSelector.Select(rawFrames); + + Assert.That(selected, Is.Empty); + } + + /// + /// What: a DMD frame with a non-digit tail after _Patch stays skipped. + /// + [Test] + public void Select_WhenDmdNameHasNonDigitPatchTail_OmitsThatFrame() + { + SourcePausePointRawStackFrame[] rawFrames = + { + CreateRawFrame(MarkerType, MarkerMethod, MarkerFile, MarkerLine), + CreateRawFrame( + SourcePausePointConstants.HarmonyDynamicMethodDeclaringType, + "Foo.Bar_PatchX", + null, + 0), + }; + + List selected = SourcePausePointCallerFrameSelector.Select(rawFrames); + + Assert.That(selected, Is.Empty); + } + + /// + /// What: a Harmony-patched uloop-internal caller stays skipped after logical-name + /// resolution, matching the compiled-counterpart skip policy. + /// + [Test] + public void Select_WhenPatchedCallerIsUloopInternal_OmitsThatFrame() + { + SourcePausePointRawStackFrame[] rawFrames = + { + CreateRawFrame(MarkerType, MarkerMethod, MarkerFile, MarkerLine), + CreateRawFrame( + SourcePausePointConstants.HarmonyDynamicMethodDeclaringType, + "io.github.hatayama.UnityCliLoop.Foo.Bar_Patch1", + null, + 0), + }; + + List selected = SourcePausePointCallerFrameSelector.Select(rawFrames); + + Assert.That(selected, Is.Empty); + } + + /// + /// What: Dump 3 shape (marker DMD + patched caller DMD + patched Update DMD) yields + /// both callers as method-only frames instead of an empty payload. + /// + [Test] + public void Select_WhenMarkerAndCallersAreAllHarmonyPatched_ReportsBothCallers() + { + SourcePausePointRawStackFrame[] rawFrames = + { + CreateRawFrame( + SourcePausePointConstants.HarmonyDynamicMethodDeclaringType, + "CallerFrameProbe.ShallowMarker_Patch3", + null, + 0), + CreateRawFrame( + SourcePausePointConstants.HarmonyDynamicMethodDeclaringType, + "CallerFrameProbe.ShallowCaller_Patch3", + null, + 0), + CreateRawFrame( + SourcePausePointConstants.HarmonyDynamicMethodDeclaringType, + "CallerFrameProbe.Update_Patch1", + null, + 0), + }; + + List selected = SourcePausePointCallerFrameSelector.Select(rawFrames); + + Assert.That(selected, Has.Count.EqualTo(2)); + Assert.That(selected[0].Method, Is.EqualTo("CallerFrameProbe.ShallowCaller")); + Assert.That(selected[0].File, Is.Null); + Assert.That(selected[0].Line, Is.EqualTo(0)); + Assert.That(selected[1].Method, Is.EqualTo("CallerFrameProbe.Update")); + Assert.That(selected[1].File, Is.Null); + Assert.That(selected[1].Line, Is.EqualTo(0)); + } + + /// + /// What: a Harmony-patched async caller (state-machine MoveNext_PatchN) is reported + /// by its logical method name, matching compiled async frames. + /// + [Test] + public void Select_WhenPatchedCallerIsAsyncStateMachine_ReportsLogicalMethodName() + { + SourcePausePointRawStackFrame[] rawFrames = + { + CreateRawFrame(MarkerType, MarkerMethod, MarkerFile, MarkerLine), + CreateRawFrame( + SourcePausePointConstants.HarmonyDynamicMethodDeclaringType, + "Game.Enemy+d__4.MoveNext_Patch2", + null, + 0), + }; + + List selected = SourcePausePointCallerFrameSelector.Select(rawFrames); + + Assert.That(selected, Has.Count.EqualTo(1)); + Assert.That(selected[0].Method, Is.EqualTo("Game.Enemy.PatrolAsync")); + Assert.That(selected[0].File, Is.Null); + Assert.That(selected[0].Line, Is.EqualTo(0)); + } + /// /// What: selection stops at two user frames even when more callers remain. /// diff --git a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md index d966d042e8..494d1c0785 100644 --- a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md +++ b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md @@ -98,6 +98,7 @@ Choose the capture mode when enabling a pause point: - `continuous` pauses Unity on every hit and remains armed. - `trace` remains armed and records each hit without pausing Unity. - In every mode, `CapturedVariables` holds the latest hit and `CapturedVariableHistory` holds only strictly older frames, so with a single hit the history is empty (for `single-shot` it always is). When the latest-hit frame is excluded, `CapturedVariableHistoryNote` explains that the latest hit's variables are in `CapturedVariables`. +- Prefer tracing a line that executes conditionally: a line that runs every frame fills the capped history within a fraction of a second and drops everything recorded before it. - In trace mode, Status "Hit" does not mean Play Mode paused; the response carries a StatusNote saying the marker fired while the game kept running. - An Expired response carries a RecommendedNextAction: re-enable the pause point with a longer --timeout-seconds (default 30) and trigger the code path again; clearing the expired marker first is not required. - For an already-hit `continuous` or `trace` marker, `await-pause-point` waits for a **new** hit after the wait starts (`LastHitSequence` advancing). It does not return the stale hit that is already present. Read the current hit with `pause-point-status` instead. If await times out while waiting for that new hit, the error stays `PAUSE_POINT_WAIT_TIMEOUT` and `Details.Hint` tells you to pass `--resume-play` (or resume Play Mode) so another hit can occur. A freshly enabled marker (including `enable-pause-point --await`) has no prior hit, so the first hit satisfies the wait as before. @@ -134,7 +135,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 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`: 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). 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 2c348f60b6..95437d7259 100644 --- a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/references/captured-variables.md +++ b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/references/captured-variables.md @@ -97,9 +97,9 @@ Use `Generation`, `EnabledAtUtc`, and the hit sequence fields from the hit or st 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: -- Runtime machinery (`System.*`, `Microsoft.*`, `Mono.*`), patching infrastructure (`HarmonyLib.*`, `MonoMod.*`), and uloop's own frames are skipped. Unity engine and editor frames are kept because an entry point such as `UnityEditor.EditorApplication.update` is itself diagnostic. +- 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 whose declaring type cannot be resolved — for example a hot-reload-patched body executing as a Harmony dynamic method — is reported by its raw method name instead. 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`. 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. - 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 such as the Unity synchronization context (or an empty array); the awaiting method itself never appears. 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/SourcePausePointCallerFrameSelector.cs b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointCallerFrameSelector.cs index f552294cea..8959a5bada 100644 --- a/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointCallerFrameSelector.cs +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointCallerFrameSelector.cs @@ -16,7 +16,9 @@ internal static class SourcePausePointCallerFrameSelector // Frames whose declaring type starts with one of these carry no diagnostic value for // "how execution reached the marker": runtime async machinery, patching infrastructure, // and uloop's own plumbing. Unity engine/editor frames are deliberately kept because an - // entry point such as EditorApplication.update is itself diagnostic. + // entry point such as EditorApplication.update is itself diagnostic. MonoMod.* is still + // skipped as infrastructure, except Harmony patch bodies which Select re-includes via + // TryResolvePatchedCallerDisplay because they are the real application callers. private static readonly string[] SkippedTypePrefixes = { "System.", @@ -45,6 +47,15 @@ public static List Select( } SourcePausePointRawStackFrame frame = rawFrames[i]; + string patchedCallerDisplay = TryResolvePatchedCallerDisplay(frame); + 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)); + continue; + } + if (frame.TypeFullName != null && IsSkippedInfrastructureType(frame.TypeFullName)) { continue; @@ -60,6 +71,78 @@ public static List Select( return selected; } + // A hot-reload-patched or pause-point-instrumented body executes as a Harmony dynamic + // method ("MonoMod.Utils.DynamicMethodDefinition" declaring type, "_Patch{N}" name + // suffix). Such a frame is a real application caller, so it must survive the + // infrastructure prefix skip and be reported under its original "{Type}.{Method}" name. + internal static string TryResolvePatchedCallerDisplay(SourcePausePointRawStackFrame frame) + { + if (frame.TypeFullName != SourcePausePointConstants.HarmonyDynamicMethodDeclaringType) + { + return null; + } + + string logicalName = StripHarmonyPatchSuffix(frame.MethodName); + if (logicalName == null) + { + return null; + } + + // The logical name starts with the original declaring type, so the infrastructure + // prefix policy still applies: a patched uloop-internal or BCL method must stay + // hidden exactly like its compiled counterpart would be. + if (IsSkippedInfrastructureType(logicalName)) + { + return null; + } + + // A patched async body surfaces as its state machine ("Ns.Type+d__N.MoveNext"); + // route the logical name through the same demangling as compiled frames so the payload + // keeps the documented "logical method name" contract. For ordinary names the round + // trip is the identity. + int lastDot = logicalName.LastIndexOf('.'); + if (lastDot <= 0 || lastDot == logicalName.Length - 1) + { + return logicalName; + } + + return FormatMethodDisplay(logicalName.Substring(0, lastDot), logicalName.Substring(lastDot + 1)); + } + + // "Ns.Type.Method_Patch3" -> "Ns.Type.Method"; null when the name does not end with + // the Harmony patch suffix (then the frame is genuine MonoMod infrastructure, not a + // patch body, and must stay skipped). + private static string StripHarmonyPatchSuffix(string methodName) + { + if (string.IsNullOrEmpty(methodName)) + { + return null; + } + + int suffixIndex = methodName.LastIndexOf( + SourcePausePointConstants.HarmonyPatchNameSuffix, StringComparison.Ordinal); + if (suffixIndex <= 0) + { + return null; + } + + int digitsStart = suffixIndex + SourcePausePointConstants.HarmonyPatchNameSuffix.Length; + if (digitsStart >= methodName.Length) + { + return null; + } + + for (int i = digitsStart; i < methodName.Length; i++) + { + if (!char.IsDigit(methodName[i])) + { + return null; + } + } + + return methodName.Substring(0, suffixIndex); + } + internal static bool IsSkippedInfrastructureType(string typeFullName) { Debug.Assert(typeFullName != null, "typeFullName must not be null"); diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointConstants.cs b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointConstants.cs index a5568d0728..607e04a15b 100644 --- a/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointConstants.cs +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointConstants.cs @@ -47,6 +47,13 @@ internal static class SourcePausePointConstants // Walk this many raw stack frames so skipped infrastructure still leaves room for two callers. public const int MaxCallerStackFramesToExamine = 24; + // Frame identity of a Harmony patch body on Mono: MonoMod assigns this declaring type + // to every DynamicMethodDefinition-generated method, and Harmony names the patch + // "{OriginalType}.{OriginalMethod}_Patch{N}". Both are needed to tell a real patched + // application caller apart from genuine MonoMod infrastructure frames. + public const string HarmonyDynamicMethodDeclaringType = "MonoMod.Utils.DynamicMethodDefinition"; + public const string HarmonyPatchNameSuffix = "_Patch"; + public const string HarmonyId = "io.github.hatayama.uloop.source-pause-point"; public const string BurstCompileAttributeFullName = "Unity.Burst.BurstCompileAttribute";