Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .agents/skills/uloop-pause-point/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ Every hit response embeds `CapturedVariables`: the method's in-scope locals, its
- Collection values (arrays, `List<T>`, 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 <n>` (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).

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 1 addition & 1 deletion .claude/skills/uloop-pause-point/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ Every hit response embeds `CapturedVariables`: the method's in-scope locals, its
- Collection values (arrays, `List<T>`, 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 <n>` (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).

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
34 changes: 34 additions & 0 deletions Assets/Tests/Editor/PausePointCallerFrameSelectorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/// <summary>
/// What: rawFrames[0] is the marker's own frame and is skipped by position, so it
/// never appears in the selected callers.
Expand All @@ -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);
}

/// <summary>
Expand Down Expand Up @@ -111,6 +119,7 @@ public void Select_WhenTypeFullNameIsNull_KeepsRawMethodNameWithoutFileOrLine()
Assert.That(selected[0].Method, Is.EqualTo("DMD<Foo>"));
Assert.That(selected[0].File, Is.Null);
Assert.That(selected[0].Line, Is.EqualTo(0));
Assert.That(selected[0].Note, Is.EqualTo(WantMissingDebugSymbolsNote));
}

/// <summary>
Expand Down Expand Up @@ -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));
}

/// <summary>
Expand Down Expand Up @@ -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));
}

/// <summary>
Expand Down Expand Up @@ -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));
}

/// <summary>
Expand Down Expand Up @@ -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));
}

/// <summary>
/// What: an empty FileName is the missing-debug-symbols path, not "outside the project",
/// so checking IsNullOrEmpty before NormalizeFilePath is required.
/// </summary>
[Test]
public void Select_WhenFileNameIsEmpty_ReportsMissingDebugSymbolsNote()
{
SourcePausePointRawStackFrame[] rawFrames =
{
CreateRawFrame(MarkerType, MarkerMethod, MarkerFile, MarkerLine),
CreateRawFrame(UserType, UserMethod, string.Empty, 42),
};

List<UloopPausePointCallerFrame> 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));
}

/// <summary>
Expand Down
83 changes: 83 additions & 0 deletions Assets/Tests/Editor/PausePointStatusCallerFrameTests.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Verifies pause-point caller-frame DTOs copy Note from the runtime frame.
/// </summary>
[TestFixture]
public sealed class PausePointStatusCallerFrameTests
{
private const string WantDynamicMethodNote =
"dynamic method (patched by hot reload or pause-point instrumentation); no debug symbols";

/// <summary>
/// What: FromCallerFrame copies Note so a selector-set note is not dropped at the bridge.
/// </summary>
[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));
}

/// <summary>
/// What: PausePointCallerFrame.FromSnapshot copies Note so enable/clear history frames
/// do not drop a selector-set note.
/// </summary>
[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));
}

/// <summary>
/// What: a null Note is omitted from JSON so File-bearing fixture frames keep their shape.
/// </summary>
[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"));
}
}
}
Loading
Loading