From 7fbd79587278a30ed4c27a317d390506017acc4b Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Sun, 12 Jul 2026 18:01:33 +0900 Subject: [PATCH 1/3] feat: support continuous and trace pause point capture (#1726) --- .../Editor/PausePointCaptureModeTests.cs | 185 ++++++++++++++++++ .../Editor/PausePointCaptureModeTests.cs.meta | 11 ++ .../PausePoints/UloopPausePointCaptureMode.cs | 14 ++ .../UloopPausePointCaptureMode.cs.meta | 11 ++ .../UloopPausePointCapturedHistoryFrame.cs | 32 +++ ...loopPausePointCapturedHistoryFrame.cs.meta | 11 ++ .../PausePoints/UloopPausePointEntry.cs | 52 ++++- .../PausePoints/UloopPausePointRegistry.cs | 41 +++- .../PausePoints/UloopPausePointSnapshot.cs | 16 ++ 9 files changed, 356 insertions(+), 17 deletions(-) create mode 100644 Assets/Tests/Editor/PausePointCaptureModeTests.cs create mode 100644 Assets/Tests/Editor/PausePointCaptureModeTests.cs.meta create mode 100644 Packages/src/Runtime/PausePoints/UloopPausePointCaptureMode.cs create mode 100644 Packages/src/Runtime/PausePoints/UloopPausePointCaptureMode.cs.meta create mode 100644 Packages/src/Runtime/PausePoints/UloopPausePointCapturedHistoryFrame.cs create mode 100644 Packages/src/Runtime/PausePoints/UloopPausePointCapturedHistoryFrame.cs.meta diff --git a/Assets/Tests/Editor/PausePointCaptureModeTests.cs b/Assets/Tests/Editor/PausePointCaptureModeTests.cs new file mode 100644 index 000000000..37e310207 --- /dev/null +++ b/Assets/Tests/Editor/PausePointCaptureModeTests.cs @@ -0,0 +1,185 @@ +using System; +using System.Linq; + +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.Runtime; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Verifies pause point capture modes and bounded hit history behavior. + /// + [TestFixture] + public sealed class PausePointCaptureModeTests + { + private DateTime _nowUtc; + private FakePausePointPauseController _pauseController; + + [SetUp] + public void SetUp() + { + _nowUtc = new DateTime(2026, 6, 3, 0, 0, 0, DateTimeKind.Utc); + _pauseController = new FakePausePointPauseController(); + UloopPausePointRegistry.ConfigureForTests(_pauseController, () => _nowUtc); + } + + [TearDown] + public void TearDown() + { + UloopPausePointRegistry.ResetForTests(); + } + + /// + /// Verifies continuous capture keeps the marker armed while exposing ordered history and the latest variables. + /// + [Test] + public void Continuous_WhenHitMultipleTimes_PreservesHistoryAndLatestVariables() + { + UloopCapturedVariable[] firstVariables = { CreateVariable("speed", "1") }; + UloopCapturedVariable[] secondVariables = { CreateVariable("speed", "2") }; + UloopPausePointRegistry.Enable("jump", 30, UloopPausePointCaptureMode.Continuous, 20); + + UloopPausePointRegistry.HitWithCapturedVariables("jump", firstVariables, false); + UloopPausePointRegistry.HitWithCapturedVariables("jump", secondVariables, false); + + UloopPausePointSnapshot snapshot = UloopPausePointRegistry.GetStatus("jump"); + + Assert.That(snapshot.IsEnabled, Is.True); + Assert.That(snapshot.HitCount, Is.EqualTo(2)); + Assert.That(snapshot.CapturedVariables.Single().Value, Is.EqualTo("2")); + Assert.That(snapshot.CapturedVariableHistory.Select(frame => frame.HitSequence), Is.EqualTo(new[] { 1, 2 })); + Assert.That(snapshot.CapturedVariableHistory.Last().CapturedVariables.Single().Value, Is.EqualTo("2")); + Assert.That(_pauseController.PauseCount, Is.EqualTo(2)); + } + + /// + /// Verifies trace capture records a hit without requesting an Editor pause. + /// + [Test] + public void Trace_WhenHit_DoesNotPauseAndKeepsMarkerArmed() + { + UloopPausePointRegistry.Enable("trace", 30, UloopPausePointCaptureMode.Trace, 20); + + UloopPausePointSnapshot snapshot = UloopPausePointRegistry.Hit("trace"); + + Assert.That(snapshot.IsEnabled, Is.True); + Assert.That(snapshot.HitCount, Is.EqualTo(1)); + Assert.That(snapshot.Mode, Is.EqualTo(UloopPausePointCaptureMode.Trace)); + Assert.That(snapshot.CapturedVariableHistory, Has.Count.EqualTo(1)); + Assert.That(_pauseController.PauseCount, Is.EqualTo(0)); + } + + /// + /// Verifies the bounded history drops the oldest frame and reports the dropped count. + /// + [Test] + public void History_WhenMaxHistoryIsExceeded_DropsOldestFrames() + { + UloopPausePointRegistry.Enable("jump", 30, UloopPausePointCaptureMode.Trace, 2); + + UloopPausePointRegistry.Hit("jump"); + UloopPausePointRegistry.Hit("jump"); + UloopPausePointRegistry.Hit("jump"); + + UloopPausePointSnapshot snapshot = UloopPausePointRegistry.GetStatus("jump"); + + Assert.That(snapshot.CapturedVariableHistory.Select(frame => frame.HitSequence), Is.EqualTo(new[] { 2, 3 })); + Assert.That(snapshot.HistoryDroppedCount, Is.EqualTo(1)); + } + + /// + /// Verifies the default single-shot mode retains current disarm behavior and records its hit. + /// + [Test] + public void SingleShot_WhenHit_DisarmsAndKeepsOneHistoryFrame() + { + UloopPausePointRegistry.Enable("jump", 30); + + UloopPausePointRegistry.Hit("jump"); + UloopPausePointRegistry.Hit("jump"); + + UloopPausePointSnapshot snapshot = UloopPausePointRegistry.GetStatus("jump"); + + Assert.That(snapshot.IsEnabled, Is.False); + Assert.That(snapshot.HitCount, Is.EqualTo(1)); + Assert.That(snapshot.CapturedVariableHistory, Has.Count.EqualTo(1)); + Assert.That(_pauseController.PauseCount, Is.EqualTo(1)); + } + + /// + /// Verifies clearing an armed continuous marker disarms it without erasing captured history. + /// + [Test] + public void Clear_WhenContinuousMarkerHasHits_DisarmsAndPreservesHistory() + { + UloopPausePointRegistry.Enable("jump", 30, UloopPausePointCaptureMode.Continuous, 20); + UloopPausePointRegistry.Hit("jump"); + + UloopPausePointSnapshot snapshot = UloopPausePointRegistry.Clear("jump"); + + Assert.That(snapshot.IsEnabled, Is.False); + Assert.That(snapshot.CapturedVariableHistory, Has.Count.EqualTo(1)); + Assert.That(snapshot.ClearedReason, Is.EqualTo(UloopPausePointClearedReason.ExplicitClear)); + Assert.That(snapshot.Message, Is.EqualTo("Pause point cleared after 1 hit(s); capture history is preserved.")); + } + + /// + /// Verifies expiry after a continuous hit reports a capture-window message and retains history. + /// + [Test] + public void Expire_WhenContinuousMarkerHasHits_DisarmsAndPreservesHistory() + { + UloopPausePointRegistry.Enable("jump", 1, UloopPausePointCaptureMode.Continuous, 20); + UloopPausePointRegistry.Hit("jump"); + _nowUtc = _nowUtc.AddSeconds(2); + + UloopPausePointSnapshot snapshot = UloopPausePointRegistry.GetStatus("jump"); + + Assert.That(snapshot.Status, Is.EqualTo(UloopPausePointStatus.Expired)); + Assert.That(snapshot.CapturedVariableHistory, Has.Count.EqualTo(1)); + Assert.That(snapshot.Message, Is.EqualTo("Pause point capture window expired after 1 hit(s); capture history is preserved.")); + } + + /// + /// Verifies re-enabling a marker starts a fresh generation with empty history. + /// + [Test] + public void Enable_WhenMarkerIsReenabled_ResetsHistory() + { + UloopPausePointRegistry.Enable("jump", 30, UloopPausePointCaptureMode.Trace, 2); + UloopPausePointRegistry.Hit("jump"); + + UloopPausePointSnapshot snapshot = UloopPausePointRegistry.Enable( + "jump", 30, UloopPausePointCaptureMode.Trace, 2); + + Assert.That(snapshot.HitCount, Is.EqualTo(0)); + Assert.That(snapshot.CapturedVariableHistory, Is.Empty); + Assert.That(snapshot.HistoryDroppedCount, Is.EqualTo(0)); + } + + private static UloopCapturedVariable CreateVariable(string name, string value) + { + return new UloopCapturedVariable( + name, + UloopCapturedVariableScope.Local, + "System.String", + value, + string.Empty, + string.Empty, + 0); + } + + private sealed class FakePausePointPauseController : IUloopPausePointPauseController + { + public int PauseCount { get; private set; } + public bool IsPlaying => true; + public bool IsPaused => PauseCount > 0; + + public void Pause() + { + PauseCount++; + } + } + } +} diff --git a/Assets/Tests/Editor/PausePointCaptureModeTests.cs.meta b/Assets/Tests/Editor/PausePointCaptureModeTests.cs.meta new file mode 100644 index 000000000..02f102676 --- /dev/null +++ b/Assets/Tests/Editor/PausePointCaptureModeTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: cf4f284db0df5429a8d287aa1833ae45 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Runtime/PausePoints/UloopPausePointCaptureMode.cs b/Packages/src/Runtime/PausePoints/UloopPausePointCaptureMode.cs new file mode 100644 index 000000000..700891968 --- /dev/null +++ b/Packages/src/Runtime/PausePoints/UloopPausePointCaptureMode.cs @@ -0,0 +1,14 @@ +#if UNITY_EDITOR +namespace io.github.hatayama.UnityCliLoop.Runtime +{ + /// + /// Defines how a pause point behaves after it captures a hit. + /// + internal static class UloopPausePointCaptureMode + { + public const string SingleShot = "single-shot"; + public const string Continuous = "continuous"; + public const string Trace = "trace"; + } +} +#endif diff --git a/Packages/src/Runtime/PausePoints/UloopPausePointCaptureMode.cs.meta b/Packages/src/Runtime/PausePoints/UloopPausePointCaptureMode.cs.meta new file mode 100644 index 000000000..ffb2758ad --- /dev/null +++ b/Packages/src/Runtime/PausePoints/UloopPausePointCaptureMode.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2057563b6f48b41ada9ba6d245b357b3 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Runtime/PausePoints/UloopPausePointCapturedHistoryFrame.cs b/Packages/src/Runtime/PausePoints/UloopPausePointCapturedHistoryFrame.cs new file mode 100644 index 000000000..dd0b2a206 --- /dev/null +++ b/Packages/src/Runtime/PausePoints/UloopPausePointCapturedHistoryFrame.cs @@ -0,0 +1,32 @@ +#if UNITY_EDITOR +using System.Collections.Generic; + +namespace io.github.hatayama.UnityCliLoop.Runtime +{ + /// + /// Stores the formatted evidence captured by one pause point hit. + /// + internal sealed class UloopPausePointCapturedHistoryFrame + { + public UloopPausePointCapturedHistoryFrame( + int hitSequence, + int frameCount, + string hitAtUtc, + IReadOnlyList capturedVariables, + bool truncated) + { + HitSequence = hitSequence; + FrameCount = frameCount; + HitAtUtc = hitAtUtc; + CapturedVariables = capturedVariables; + Truncated = truncated; + } + + public int HitSequence { get; } + public int FrameCount { get; } + public string HitAtUtc { get; } + public IReadOnlyList CapturedVariables { get; } + public bool Truncated { get; } + } +} +#endif diff --git a/Packages/src/Runtime/PausePoints/UloopPausePointCapturedHistoryFrame.cs.meta b/Packages/src/Runtime/PausePoints/UloopPausePointCapturedHistoryFrame.cs.meta new file mode 100644 index 000000000..c4252b0b7 --- /dev/null +++ b/Packages/src/Runtime/PausePoints/UloopPausePointCapturedHistoryFrame.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 709b465a62e244755a4c8ee3a2208b35 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Runtime/PausePoints/UloopPausePointEntry.cs b/Packages/src/Runtime/PausePoints/UloopPausePointEntry.cs index 7c5f6e90d..a153cfaec 100644 --- a/Packages/src/Runtime/PausePoints/UloopPausePointEntry.cs +++ b/Packages/src/Runtime/PausePoints/UloopPausePointEntry.cs @@ -10,10 +10,18 @@ namespace io.github.hatayama.UnityCliLoop.Runtime /// internal sealed class UloopPausePointEntry { - public UloopPausePointEntry(string id, int timeoutSeconds, DateTime enabledAtUtc, int generation) + public UloopPausePointEntry( + string id, + int timeoutSeconds, + string mode, + int maxHistory, + DateTime enabledAtUtc, + int generation) { Id = id; TimeoutSeconds = timeoutSeconds; + Mode = mode; + MaxHistory = maxHistory; EnabledAtUtc = enabledAtUtc; ExpiresAtUtc = enabledAtUtc.AddSeconds(timeoutSeconds); Generation = generation; @@ -21,10 +29,13 @@ public UloopPausePointEntry(string id, int timeoutSeconds, DateTime enabledAtUtc IsEnabled = true; Message = "Pause point enabled."; CapturedVariables = Array.Empty(); + _capturedVariableHistory = new Queue(maxHistory); } public string Id { get; } public int TimeoutSeconds { get; } + public string Mode { get; } + public int MaxHistory { get; } public DateTime EnabledAtUtc { get; } public DateTime ExpiresAtUtc { get; } public int Generation { get; } @@ -40,6 +51,7 @@ public UloopPausePointEntry(string id, int timeoutSeconds, DateTime enabledAtUtc public string Message { get; private set; } public IReadOnlyList CapturedVariables { get; private set; } public bool CapturedVariablesTruncated { get; private set; } + public int HistoryDroppedCount { get; private set; } public string ClearedReason { get; private set; } = string.Empty; public string StatusBeforeClear { get; private set; } = string.Empty; public bool LateHitDiscardedAfterClear { get; private set; } @@ -58,7 +70,9 @@ public void ExpireIfNeeded(DateTime nowUtc) IsEnabled = false; Status = UloopPausePointStatus.Expired; - Message = "Pause point expired before it was hit."; + Message = HitCount == 0 + ? "Pause point expired before it was hit." + : $"Pause point capture window expired after {HitCount} hit(s); capture history is preserved."; } public void MarkCleared(string clearedReason, string message = "Pause point cleared.") @@ -78,6 +92,7 @@ public void MarkCleared(string clearedReason, string message = "Pause point clea ClearedReason = UloopPausePointClearedReason.AfterExpired; } else if (Status == UloopPausePointStatus.Hit && + !IsEnabled && clearedReason == UloopPausePointClearedReason.ExplicitClear) { ClearedReason = UloopPausePointClearedReason.AlreadyHit; @@ -99,17 +114,12 @@ public void MarkLateHitDiscardedAfterClear() LateHitDiscardedAfterClear = true; } - public void RecordHit(DateTime nowUtc, bool isPlaying, bool isPaused, int hitSequence) - { - RecordHitWithCapturedVariables( - nowUtc, isPlaying, isPaused, hitSequence, Array.Empty(), false); - } - public void RecordHitWithCapturedVariables( DateTime nowUtc, bool isPlaying, bool isPaused, int hitSequence, + int frameCount, IReadOnlyList capturedVariables, bool capturedVariablesTruncated) { @@ -127,11 +137,27 @@ public void RecordHitWithCapturedVariables( LastHitSequence = hitSequence; IsPlayingAtHit = isPlaying; IsPausedAtHit = isPaused; - IsEnabled = false; + IsEnabled = Mode != UloopPausePointCaptureMode.SingleShot; Status = UloopPausePointStatus.Hit; - Message = "Pause point hit; Unity pause was requested."; + Message = Mode == UloopPausePointCaptureMode.Trace + ? "Pause point hit; recorded to history without pausing (trace mode)." + : "Pause point hit; Unity pause was requested."; CapturedVariables = capturedVariables; CapturedVariablesTruncated = capturedVariablesTruncated; + + if (_capturedVariableHistory.Count == MaxHistory) + { + _capturedVariableHistory.Dequeue(); + HistoryDroppedCount++; + } + + _capturedVariableHistory.Enqueue( + new UloopPausePointCapturedHistoryFrame( + hitSequence, + frameCount, + FormatUtc(nowUtc), + capturedVariables, + capturedVariablesTruncated)); } public UloopPausePointSnapshot ToSnapshot(DateTime nowUtc, IUloopPausePointPauseController pauseController) @@ -161,6 +187,10 @@ public UloopPausePointSnapshot ToSnapshot(DateTime nowUtc, IUloopPausePointPause isHit, HitCount, TimeoutSeconds, + Mode, + MaxHistory, + new List(_capturedVariableHistory), + HistoryDroppedCount, expired, FormatUtc(EnabledAtUtc), elapsedMilliseconds, @@ -201,6 +231,8 @@ private static string FormatUtc(DateTime value) DateTime utcValue = value.Kind == DateTimeKind.Utc ? value : value.ToUniversalTime(); return utcValue.ToString("O"); } + + private readonly Queue _capturedVariableHistory; } } #endif diff --git a/Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs b/Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs index 826f9ae70..595579eac 100644 --- a/Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs +++ b/Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs @@ -15,6 +15,8 @@ namespace io.github.hatayama.UnityCliLoop.Runtime internal static class UloopPausePointRegistry { public const int DefaultTimeoutSeconds = 30; + public const int DefaultMaxHistory = 20; + public const int MaxHistoryLimit = 100; private static readonly ConcurrentDictionary Entries = new(); private static IUloopPausePointPauseController _pauseController = new UnityEditorPausePointPauseController(); @@ -35,14 +37,21 @@ internal static class UloopPausePointRegistry public static Action OnCleared { get; set; } public static Action OnClearedAll { get; set; } - public static UloopPausePointSnapshot Enable(string id, int timeoutSeconds) + public static UloopPausePointSnapshot Enable( + string id, + int timeoutSeconds, + string mode = UloopPausePointCaptureMode.SingleShot, + int maxHistory = DefaultMaxHistory) { Debug.Assert(!string.IsNullOrWhiteSpace(id), "id must not be null or empty"); Debug.Assert(timeoutSeconds > 0, "timeoutSeconds must be greater than zero"); + Debug.Assert(IsSupportedMode(mode), "mode must be a supported pause point capture mode"); + Debug.Assert(maxHistory > 0, "maxHistory must be greater than zero"); + Debug.Assert(maxHistory <= MaxHistoryLimit, "maxHistory must not exceed the history limit"); DateTime now = NowUtc(); int generation = ++_nextGeneration; - UloopPausePointEntry entry = new(id, timeoutSeconds, now, generation); + UloopPausePointEntry entry = new(id, timeoutSeconds, mode, maxHistory, now, generation); Entries[id] = entry; ClearLatestHitSnapshotIfMatches(id); return entry.ToSnapshot(now, _pauseController); @@ -63,10 +72,16 @@ public static UloopPausePointSnapshot Clear(string id) UloopPausePointEntry entry = Entries[id]; // Resolve expiry first so a clear after the timeout reports "expired", not a normal clear. entry.ExpireIfNeeded(now); - string message = entry.Status switch + string message = !entry.IsEnabled && entry.Status == UloopPausePointStatus.Hit + ? "Pause point was already hit (auto-disarmed); nothing to clear." + : entry.Status switch { - UloopPausePointStatus.Hit => "Pause point was already hit (auto-disarmed); nothing to clear.", - UloopPausePointStatus.Expired => "Pause point had already expired before being hit; nothing to clear.", + UloopPausePointStatus.Hit => + $"Pause point cleared after {entry.HitCount} hit(s); capture history is preserved.", + UloopPausePointStatus.Expired when entry.HitCount > 0 => + "Pause point capture window had already expired; nothing to clear.", + UloopPausePointStatus.Expired => + "Pause point had already expired before being hit; nothing to clear.", UloopPausePointStatus.Cleared => "Pause point was already cleared.", _ => "Pause point cleared." }; @@ -190,11 +205,16 @@ private static UloopPausePointSnapshot HitCore( return entry.ToSnapshot(now, _pauseController); } - _pauseController.Pause(); + if (entry.Mode != UloopPausePointCaptureMode.Trace) + { + _pauseController.Pause(); + } + int hitSequence = ++_nextHitSequence; + int frameCount = Time.frameCount; entry.RecordHitWithCapturedVariables( now, _pauseController.IsPlaying, _pauseController.IsPaused, hitSequence, - capturedVariables, capturedVariablesTruncated); + frameCount, capturedVariables, capturedVariablesTruncated); UloopPausePointSnapshot snapshot = entry.ToSnapshot(now, _pauseController); _latestHitSnapshot = snapshot; _hitSnapshots.RemoveAll(hitSnapshot => hitSnapshot.Id == id); @@ -267,6 +287,13 @@ private static DateTime NowUtc() DateTime now = _nowProvider(); return now.Kind == DateTimeKind.Utc ? now : now.ToUniversalTime(); } + + private static bool IsSupportedMode(string mode) + { + return mode == UloopPausePointCaptureMode.SingleShot || + mode == UloopPausePointCaptureMode.Continuous || + mode == UloopPausePointCaptureMode.Trace; + } } } #endif diff --git a/Packages/src/Runtime/PausePoints/UloopPausePointSnapshot.cs b/Packages/src/Runtime/PausePoints/UloopPausePointSnapshot.cs index 61b986d22..fb9914863 100644 --- a/Packages/src/Runtime/PausePoints/UloopPausePointSnapshot.cs +++ b/Packages/src/Runtime/PausePoints/UloopPausePointSnapshot.cs @@ -18,6 +18,10 @@ public UloopPausePointSnapshot( bool isHit, int hitCount, int timeoutSeconds, + string mode, + int maxHistory, + IReadOnlyList capturedVariableHistory, + int historyDroppedCount, bool expired, string enabledAtUtc, long elapsedMilliseconds, @@ -44,6 +48,10 @@ public UloopPausePointSnapshot( IsHit = isHit; HitCount = hitCount; TimeoutSeconds = timeoutSeconds; + Mode = mode ?? UloopPausePointCaptureMode.SingleShot; + MaxHistory = maxHistory; + CapturedVariableHistory = capturedVariableHistory ?? Array.Empty(); + HistoryDroppedCount = historyDroppedCount; Expired = expired; EnabledAtUtc = enabledAtUtc ?? string.Empty; ElapsedSinceEnabledMilliseconds = elapsedMilliseconds; @@ -69,6 +77,10 @@ public UloopPausePointSnapshot( public bool IsHit { get; } public int HitCount { get; } public int TimeoutSeconds { get; } + public string Mode { get; } + public int MaxHistory { get; } + public IReadOnlyList CapturedVariableHistory { get; } + public int HistoryDroppedCount { get; } public bool Expired { get; } public string EnabledAtUtc { get; } public long ElapsedSinceEnabledMilliseconds { get; } @@ -98,6 +110,10 @@ public static UloopPausePointSnapshot NotEnabled(string id, IUloopPausePointPaus false, 0, 0, + UloopPausePointCaptureMode.SingleShot, + 0, + Array.Empty(), + 0, false, string.Empty, 0, From f8deb234456662708c3666928a0794b0fcb70f87 Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Sun, 12 Jul 2026 18:15:26 +0900 Subject: [PATCH 2/3] feat: expose pause point modes and capture history in tools (#1727) --- .../PausePointStatusResponseContractTests.cs | 14 ++ .../Tests/Editor/PausePointToolModeTests.cs | 147 ++++++++++++++++++ .../Editor/PausePointToolModeTests.cs.meta | 11 ++ .../SourcePausePointCaptureTests.cs | 20 +++ .../PausePoint/PausePointTools.cs | 122 ++++++++++++++- .../Api/PausePointStatusBridgeCommand.cs | 44 ++++++ .../projectrunner/pause_point_types.go | 59 +++++++ .../projectrunner/pause_point_wait.go | 45 ------ .../pause_point_status_response_contract.json | 12 ++ 9 files changed, 427 insertions(+), 47 deletions(-) create mode 100644 Assets/Tests/Editor/PausePointToolModeTests.cs create mode 100644 Assets/Tests/Editor/PausePointToolModeTests.cs.meta create mode 100644 cli/project-runner/internal/projectrunner/pause_point_types.go diff --git a/Assets/Tests/Editor/PausePointStatusResponseContractTests.cs b/Assets/Tests/Editor/PausePointStatusResponseContractTests.cs index 3e56fb8de..d65130ff6 100644 --- a/Assets/Tests/Editor/PausePointStatusResponseContractTests.cs +++ b/Assets/Tests/Editor/PausePointStatusResponseContractTests.cs @@ -31,6 +31,20 @@ public void PausePointStatusResponse_WhenSerialized_MatchesSharedContractFieldSh IsHit = true, HitCount = 1, TimeoutSeconds = 30, + Mode = "continuous", + MaxHistory = 20, + CapturedVariableHistory = new List + { + new() + { + HitSequence = 1, + FrameCount = 42, + HitAtUtc = "2026-06-03T00:00:01.0000000Z", + CapturedVariables = new List(), + Truncated = false + } + }, + HistoryDroppedCount = 0, Expired = false, EnabledAtUtc = "2026-06-03T00:00:00.0000000Z", ElapsedSinceEnabledMilliseconds = 1200, diff --git a/Assets/Tests/Editor/PausePointToolModeTests.cs b/Assets/Tests/Editor/PausePointToolModeTests.cs new file mode 100644 index 000000000..a02984b61 --- /dev/null +++ b/Assets/Tests/Editor/PausePointToolModeTests.cs @@ -0,0 +1,147 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +using Newtonsoft.Json.Linq; +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; +using io.github.hatayama.UnityCliLoop.Infrastructure; +using io.github.hatayama.UnityCliLoop.Runtime; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Verifies pause point tool mode parameters and response history mapping. + /// + [TestFixture] + public sealed class PausePointToolModeTests + { + private FakePausePointPauseController _pauseController; + + [SetUp] + public void SetUp() + { + _pauseController = new FakePausePointPauseController(); + UloopPausePointRegistry.ConfigureForTests(_pauseController, () => DateTime.UtcNow); + } + + [TearDown] + public void TearDown() + { + UloopPausePointRegistry.ResetForTests(); + } + + /// + /// Verifies mode and max-history JSON parameters reach the enable response. + /// + [Test] + public async Task Enable_WhenModeAndMaxHistoryAreProvided_MapsParameters() + { + EnablePausePointTool tool = new(); + JObject parameters = new() + { + ["id"] = "jump", + ["timeoutSeconds"] = 30, + ["mode"] = UloopPausePointCaptureMode.Continuous, + ["maxHistory"] = 2 + }; + + PausePointResponse response = (PausePointResponse)await tool.ExecuteAsync(parameters, CancellationToken.None); + + Assert.That(response.Success, Is.True); + Assert.That(response.Mode, Is.EqualTo(UloopPausePointCaptureMode.Continuous)); + Assert.That(response.MaxHistory, Is.EqualTo(2)); + } + + /// + /// Verifies an unknown mode returns a user-facing validation failure with supported values. + /// + [Test] + public async Task Enable_WhenModeIsUnknown_ReturnsSupportedModeValidationFailure() + { + EnablePausePointTool tool = new(); + JObject parameters = new() + { + ["id"] = "jump", + ["mode"] = "unknown", + ["maxHistory"] = 20 + }; + + PausePointResponse response = (PausePointResponse)await tool.ExecuteAsync(parameters, CancellationToken.None); + + Assert.That(response.Success, Is.False); + Assert.That(response.Message, Is.EqualTo("Mode must be one of: single-shot, continuous, trace.")); + } + + /// + /// Verifies an out-of-range max-history value returns a user-facing validation failure. + /// + [Test] + public async Task Enable_WhenMaxHistoryIsOutOfRange_ReturnsValidationFailure() + { + EnablePausePointTool tool = new(); + JObject parameters = new() + { + ["id"] = "jump", + ["mode"] = UloopPausePointCaptureMode.SingleShot, + ["maxHistory"] = UloopPausePointRegistry.MaxHistoryLimit + 1 + }; + + PausePointResponse response = (PausePointResponse)await tool.ExecuteAsync(parameters, CancellationToken.None); + + Assert.That(response.Success, Is.False); + Assert.That(response.Message, Is.EqualTo("MaxHistory must be between 1 and 100.")); + } + + /// + /// Verifies a non-positive max-history value returns a validation failure. + /// + [Test] + public async Task Enable_WhenMaxHistoryIsZero_ReturnsValidationFailure() + { + EnablePausePointTool tool = new(); + JObject parameters = new() + { + ["id"] = "jump", + ["mode"] = UloopPausePointCaptureMode.SingleShot, + ["maxHistory"] = 0 + }; + + PausePointResponse response = (PausePointResponse)await tool.ExecuteAsync(parameters, CancellationToken.None); + + Assert.That(response.Success, Is.False); + Assert.That(response.Message, Is.EqualTo("MaxHistory must be between 1 and 100.")); + } + + /// + /// Verifies the CLI-only status bridge exposes mode and captured history fields. + /// + [Test] + public void StatusBridge_WhenContinuousMarkerHasHit_ReturnsHistoryFields() + { + UloopPausePointRegistry.Enable("jump", 30, UloopPausePointCaptureMode.Continuous, 20); + UloopPausePointRegistry.Hit("jump"); + + PausePointStatusResponse response = PausePointStatusBridgeCommand.Execute( + new JObject { ["id"] = "jump" }); + + Assert.That(response.Mode, Is.EqualTo(UloopPausePointCaptureMode.Continuous)); + Assert.That(response.MaxHistory, Is.EqualTo(20)); + Assert.That(response.CapturedVariableHistory, Has.Count.EqualTo(1)); + Assert.That(response.HistoryDroppedCount, Is.EqualTo(0)); + } + + private sealed class FakePausePointPauseController : IUloopPausePointPauseController + { + public int PauseCount { get; private set; } + public bool IsPlaying => true; + public bool IsPaused => PauseCount > 0; + + public void Pause() + { + PauseCount++; + } + } + } +} diff --git a/Assets/Tests/Editor/PausePointToolModeTests.cs.meta b/Assets/Tests/Editor/PausePointToolModeTests.cs.meta new file mode 100644 index 000000000..fcd2817bf --- /dev/null +++ b/Assets/Tests/Editor/PausePointToolModeTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e934cb81e3d6542e788dd37faaa6026b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/SourcePausePointCapture/SourcePausePointCaptureTests.cs b/Assets/Tests/Editor/SourcePausePointCapture/SourcePausePointCaptureTests.cs index 403d2528f..06329f899 100644 --- a/Assets/Tests/Editor/SourcePausePointCapture/SourcePausePointCaptureTests.cs +++ b/Assets/Tests/Editor/SourcePausePointCapture/SourcePausePointCaptureTests.cs @@ -75,6 +75,26 @@ public void Capture_WhenPausePointWasAlreadyHit_IgnoresSecondCall() Assert.That(_pauseController.PauseCount, Is.EqualTo(1)); } + /// + /// Verifies the source capture path records every hit for an armed continuous marker. + /// + [Test] + public void Capture_WhenContinuousPausePointIsEnabled_RecordsEveryFormattedHit() + { + UloopPausePointRegistry.Enable("jump", 30, UloopPausePointCaptureMode.Continuous, 20); + + SourcePausePointCapture.Capture("jump", null, Array.Empty(), new object[] { "speed", 1 }); + SourcePausePointCapture.Capture("jump", null, Array.Empty(), new object[] { "speed", 2 }); + + UloopPausePointSnapshot snapshot = UloopPausePointRegistry.GetStatus("jump"); + + Assert.That(snapshot.IsEnabled, Is.True); + Assert.That(snapshot.HitCount, Is.EqualTo(2)); + Assert.That(snapshot.CapturedVariableHistory, Has.Count.EqualTo(2)); + Assert.That(snapshot.CapturedVariables.Single(variable => variable.Name == "speed").Value, Is.EqualTo("2")); + Assert.That(_pauseController.PauseCount, Is.EqualTo(2)); + } + [UnityTest] public IEnumerator Capture_WhenCalledOffMainThread_RecordsHitOnNextMainThreadTick() { diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointTools.cs b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointTools.cs index 5c5b28112..a422660c2 100644 --- a/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointTools.cs +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/PausePointTools.cs @@ -1,4 +1,6 @@ using System; +using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; using UnityEditor; @@ -23,6 +25,10 @@ public class EnablePausePointSchema : UnityCliLoopToolSchema public int Line { get; set; } public int TimeoutSeconds { get; set; } = UloopPausePointRegistry.DefaultTimeoutSeconds; + + public string Mode { get; set; } = UloopPausePointCaptureMode.SingleShot; + + public int MaxHistory { get; set; } = UloopPausePointRegistry.DefaultMaxHistory; } /// @@ -51,6 +57,11 @@ public class PausePointResponse : UnityCliLoopToolResponse public bool IsHit { get; set; } public int HitCount { get; set; } public int TimeoutSeconds { get; set; } + public string Mode { get; set; } = string.Empty; + public int MaxHistory { get; set; } + public IReadOnlyList CapturedVariableHistory { get; set; } = + Array.Empty(); + public int HistoryDroppedCount { get; set; } public bool Expired { get; set; } public string EnabledAtUtc { get; set; } = string.Empty; public long ElapsedSinceEnabledMilliseconds { get; set; } @@ -84,6 +95,12 @@ internal static PausePointResponse FromSnapshot(UloopPausePointSnapshot snapshot IsHit = snapshot.IsHit, HitCount = snapshot.HitCount, TimeoutSeconds = snapshot.TimeoutSeconds, + Mode = snapshot.Mode, + MaxHistory = snapshot.MaxHistory, + CapturedVariableHistory = snapshot.CapturedVariableHistory + .Select(PausePointCapturedHistoryFrame.FromSnapshot) + .ToList(), + HistoryDroppedCount = snapshot.HistoryDroppedCount, Expired = snapshot.Expired, EnabledAtUtc = snapshot.EnabledAtUtc, ElapsedSinceEnabledMilliseconds = snapshot.ElapsedSinceEnabledMilliseconds, @@ -121,6 +138,72 @@ internal static PausePointResponse FromClearAll(UloopPausePointClearAllResult re } } + /// + /// One formatted capture frame included in the enable-pause-point response history. + /// + public class PausePointCapturedHistoryFrame + { + public int HitSequence { get; set; } + public int FrameCount { get; set; } + public string HitAtUtc { get; set; } = string.Empty; + public IReadOnlyList CapturedVariables { get; set; } = + Array.Empty(); + public bool Truncated { get; set; } + + internal static PausePointCapturedHistoryFrame FromSnapshot( + UloopPausePointCapturedHistoryFrame snapshot) + { + if (snapshot == null) + { + throw new ArgumentNullException(nameof(snapshot)); + } + + return new PausePointCapturedHistoryFrame + { + HitSequence = snapshot.HitSequence, + FrameCount = snapshot.FrameCount, + HitAtUtc = snapshot.HitAtUtc, + CapturedVariables = snapshot.CapturedVariables + .Select(PausePointCapturedVariable.FromSnapshot) + .ToList(), + Truncated = snapshot.Truncated + }; + } + } + + /// + /// One formatted variable included in a pause point capture history frame. + /// + public class PausePointCapturedVariable + { + public string Name { get; set; } = string.Empty; + public string Scope { get; set; } = string.Empty; + public string TypeName { get; set; } = string.Empty; + public string Value { get; set; } = string.Empty; + public string UnityObjectKind { get; set; } = string.Empty; + public string UnityObjectPath { get; set; } = string.Empty; + public int UnityObjectInstanceId { get; set; } + + internal static PausePointCapturedVariable FromSnapshot(UloopCapturedVariable snapshot) + { + if (snapshot == null) + { + throw new ArgumentNullException(nameof(snapshot)); + } + + return new PausePointCapturedVariable + { + Name = snapshot.Name, + Scope = snapshot.Scope, + TypeName = snapshot.TypeName, + Value = snapshot.Value, + UnityObjectKind = snapshot.UnityObjectKind, + UnityObjectPath = snapshot.UnityObjectPath, + UnityObjectInstanceId = snapshot.UnityObjectInstanceId + }; + } + } + /// /// Unity Editor play state attached to pause point tool evidence. /// @@ -187,6 +270,12 @@ internal sealed class PausePointUseCase { public PausePointResponse Enable(EnablePausePointSchema parameters) { + string captureSettingsError = ValidateCaptureSettings(parameters); + if (captureSettingsError != null) + { + return CreateValidationFailure(captureSettingsError); + } + string modeError = ValidateEnableMode(parameters); if (modeError != null) { @@ -203,7 +292,11 @@ public PausePointResponse Enable(EnablePausePointSchema parameters) return EnableBySourceLocation(parameters); } - UloopPausePointSnapshot snapshot = UloopPausePointRegistry.Enable(parameters.Id, parameters.TimeoutSeconds); + UloopPausePointSnapshot snapshot = UloopPausePointRegistry.Enable( + parameters.Id, + parameters.TimeoutSeconds, + parameters.Mode, + parameters.MaxHistory); PausePointResponse response = PausePointResponse.FromSnapshot(snapshot); response.Warning = CreateEnableWarning(); return response; @@ -257,7 +350,11 @@ private static PausePointResponse EnableBySourceLocation(EnablePausePointSchema }; } - UloopPausePointSnapshot snapshot = UloopPausePointRegistry.Enable(id, parameters.TimeoutSeconds); + UloopPausePointSnapshot snapshot = UloopPausePointRegistry.Enable( + id, + parameters.TimeoutSeconds, + parameters.Mode, + parameters.MaxHistory); PausePointResponse response = PausePointResponse.FromSnapshot(snapshot); response.ResolvedLine = resolveResult.Resolution.ResolvedLine; response.ResolvedMethod = resolveResult.Resolution.MethodDisplayName; @@ -313,6 +410,27 @@ private static string ValidateEnableMode(EnablePausePointSchema parameters) return null; } + private static string ValidateCaptureSettings(EnablePausePointSchema parameters) + { + string[] supportedModes = + { + UloopPausePointCaptureMode.SingleShot, + UloopPausePointCaptureMode.Continuous, + UloopPausePointCaptureMode.Trace + }; + if (!supportedModes.Contains(parameters.Mode)) + { + return $"Mode must be one of: {string.Join(", ", supportedModes)}."; + } + + if (parameters.MaxHistory <= 0 || parameters.MaxHistory > UloopPausePointRegistry.MaxHistoryLimit) + { + return $"MaxHistory must be between 1 and {UloopPausePointRegistry.MaxHistoryLimit}."; + } + + return null; + } + // Returns an error message when id fails validation, or null when it is valid. private static string ValidateId(string id) { diff --git a/Packages/src/Editor/Infrastructure/Api/PausePointStatusBridgeCommand.cs b/Packages/src/Editor/Infrastructure/Api/PausePointStatusBridgeCommand.cs index 2c8171533..6d5d00e7f 100644 --- a/Packages/src/Editor/Infrastructure/Api/PausePointStatusBridgeCommand.cs +++ b/Packages/src/Editor/Infrastructure/Api/PausePointStatusBridgeCommand.cs @@ -56,6 +56,11 @@ public class PausePointStatusResponse : UnityCliLoopToolResponse public bool IsHit { get; set; } public int HitCount { get; set; } public int TimeoutSeconds { get; set; } + public string Mode { get; set; } = string.Empty; + public int MaxHistory { get; set; } + public IReadOnlyList CapturedVariableHistory { get; set; } = + Array.Empty(); + public int HistoryDroppedCount { get; set; } public bool Expired { get; set; } public string EnabledAtUtc { get; set; } = string.Empty; public long ElapsedSinceEnabledMilliseconds { get; set; } @@ -90,6 +95,12 @@ internal static PausePointStatusResponse FromSnapshot(UloopPausePointSnapshot sn IsHit = snapshot.IsHit, HitCount = snapshot.HitCount, TimeoutSeconds = snapshot.TimeoutSeconds, + Mode = snapshot.Mode, + MaxHistory = snapshot.MaxHistory, + CapturedVariableHistory = snapshot.CapturedVariableHistory + .Select(PausePointStatusCapturedHistoryFrame.FromSnapshot) + .ToList(), + HistoryDroppedCount = snapshot.HistoryDroppedCount, Expired = snapshot.Expired, EnabledAtUtc = snapshot.EnabledAtUtc, ElapsedSinceEnabledMilliseconds = snapshot.ElapsedSinceEnabledMilliseconds, @@ -113,6 +124,39 @@ internal static PausePointStatusResponse FromSnapshot(UloopPausePointSnapshot sn } } + /// + /// One formatted capture frame included in the CLI-only pause point status response history. + /// + public class PausePointStatusCapturedHistoryFrame + { + public int HitSequence { get; set; } + public int FrameCount { get; set; } + public string HitAtUtc { get; set; } = string.Empty; + public IReadOnlyList CapturedVariables { get; set; } = + Array.Empty(); + public bool Truncated { get; set; } + + internal static PausePointStatusCapturedHistoryFrame FromSnapshot( + UloopPausePointCapturedHistoryFrame snapshot) + { + if (snapshot == null) + { + throw new ArgumentNullException(nameof(snapshot)); + } + + return new PausePointStatusCapturedHistoryFrame + { + HitSequence = snapshot.HitSequence, + FrameCount = snapshot.FrameCount, + HitAtUtc = snapshot.HitAtUtc, + CapturedVariables = snapshot.CapturedVariables + .Select(PausePointStatusCapturedVariable.FromCapturedVariable) + .ToList(), + Truncated = snapshot.Truncated + }; + } + } + /// /// Unity Editor play state attached to pause point status evidence. /// diff --git a/cli/project-runner/internal/projectrunner/pause_point_types.go b/cli/project-runner/internal/projectrunner/pause_point_types.go new file mode 100644 index 000000000..a5618c338 --- /dev/null +++ b/cli/project-runner/internal/projectrunner/pause_point_types.go @@ -0,0 +1,59 @@ +package projectrunner + +type pausePointStatusResponse struct { + Id string `json:"Id"` + Status string `json:"Status"` + IsEnabled bool `json:"IsEnabled"` + IsHit bool `json:"IsHit"` + HitCount int `json:"HitCount"` + TimeoutSeconds int `json:"TimeoutSeconds"` + Mode string `json:"Mode"` + MaxHistory int `json:"MaxHistory"` + CapturedVariableHistory []pausePointCapturedHistoryFrame `json:"CapturedVariableHistory"` + HistoryDroppedCount int `json:"HistoryDroppedCount"` + Expired bool `json:"Expired"` + EnabledAtUtc string `json:"EnabledAtUtc"` + ElapsedSinceEnabledMilliseconds int64 `json:"ElapsedSinceEnabledMilliseconds"` + RemainingMilliseconds int64 `json:"RemainingMilliseconds"` + Generation int `json:"Generation"` + EditorState pausePointEditorState `json:"EditorState"` + FirstHitAtUtc string `json:"FirstHitAtUtc"` + LastHitAtUtc string `json:"LastHitAtUtc"` + FirstHitSequence int `json:"FirstHitSequence"` + LastHitSequence int `json:"LastHitSequence"` + Message string `json:"Message"` + RecommendedNextAction string `json:"RecommendedNextAction"` + CapturedVariables []pausePointCapturedVariable `json:"CapturedVariables"` + CapturedVariablesTruncated bool `json:"CapturedVariablesTruncated"` + ClearedReason string `json:"ClearedReason"` + StatusBeforeClear string `json:"StatusBeforeClear"` + LateHitDiscardedAfterClear bool `json:"LateHitDiscardedAfterClear"` +} + +type pausePointEditorState struct { + IsPlaying bool `json:"IsPlaying"` + IsPaused bool `json:"IsPaused"` + CapturedAt string `json:"CapturedAt"` +} + +// pausePointCapturedHistoryFrame mirrors the Unity-side history DTO field-for-field. +type pausePointCapturedHistoryFrame struct { + HitSequence int `json:"HitSequence"` + FrameCount int `json:"FrameCount"` + HitAtUtc string `json:"HitAtUtc"` + CapturedVariables []pausePointCapturedVariable `json:"CapturedVariables"` + Truncated bool `json:"Truncated"` +} + +// pausePointCapturedVariable mirrors the flat Unity-side +// PausePointStatusCapturedVariable/UloopCapturedVariable DTO field-for-field: one variable +// captured at a source pause point (a local, a parameter, or a `this` instance field). +type pausePointCapturedVariable struct { + Name string `json:"Name"` + Scope string `json:"Scope"` + TypeName string `json:"TypeName"` + Value string `json:"Value"` + UnityObjectKind string `json:"UnityObjectKind"` + UnityObjectPath string `json:"UnityObjectPath"` + UnityObjectInstanceId int `json:"UnityObjectInstanceId"` +} diff --git a/cli/project-runner/internal/projectrunner/pause_point_wait.go b/cli/project-runner/internal/projectrunner/pause_point_wait.go index 5b2303d06..f7448f6e9 100644 --- a/cli/project-runner/internal/projectrunner/pause_point_wait.go +++ b/cli/project-runner/internal/projectrunner/pause_point_wait.go @@ -45,51 +45,6 @@ type pausePointStatusOptions struct { id string } -type pausePointStatusResponse struct { - Id string `json:"Id"` - Status string `json:"Status"` - IsEnabled bool `json:"IsEnabled"` - IsHit bool `json:"IsHit"` - HitCount int `json:"HitCount"` - TimeoutSeconds int `json:"TimeoutSeconds"` - Expired bool `json:"Expired"` - EnabledAtUtc string `json:"EnabledAtUtc"` - ElapsedSinceEnabledMilliseconds int64 `json:"ElapsedSinceEnabledMilliseconds"` - RemainingMilliseconds int64 `json:"RemainingMilliseconds"` - Generation int `json:"Generation"` - EditorState pausePointEditorState `json:"EditorState"` - FirstHitAtUtc string `json:"FirstHitAtUtc"` - LastHitAtUtc string `json:"LastHitAtUtc"` - FirstHitSequence int `json:"FirstHitSequence"` - LastHitSequence int `json:"LastHitSequence"` - Message string `json:"Message"` - RecommendedNextAction string `json:"RecommendedNextAction"` - CapturedVariables []pausePointCapturedVariable `json:"CapturedVariables"` - CapturedVariablesTruncated bool `json:"CapturedVariablesTruncated"` - ClearedReason string `json:"ClearedReason"` - StatusBeforeClear string `json:"StatusBeforeClear"` - LateHitDiscardedAfterClear bool `json:"LateHitDiscardedAfterClear"` -} - -type pausePointEditorState struct { - IsPlaying bool `json:"IsPlaying"` - IsPaused bool `json:"IsPaused"` - CapturedAt string `json:"CapturedAt"` -} - -// pausePointCapturedVariable mirrors the flat Unity-side -// PausePointStatusCapturedVariable/UloopCapturedVariable DTO field-for-field: one variable -// captured at a source pause point (a local, a parameter, or a `this` instance field). -type pausePointCapturedVariable struct { - Name string `json:"Name"` - Scope string `json:"Scope"` - TypeName string `json:"TypeName"` - Value string `json:"Value"` - UnityObjectKind string `json:"UnityObjectKind"` - UnityObjectPath string `json:"UnityObjectPath"` - UnityObjectInstanceId int `json:"UnityObjectInstanceId"` -} - type pausePointWaitState string const ( diff --git a/tests/contracts/pause_point_status_response_contract.json b/tests/contracts/pause_point_status_response_contract.json index 2ff369267..6668b4647 100644 --- a/tests/contracts/pause_point_status_response_contract.json +++ b/tests/contracts/pause_point_status_response_contract.json @@ -5,6 +5,18 @@ "IsHit": true, "HitCount": 1, "TimeoutSeconds": 30, + "Mode": "continuous", + "MaxHistory": 20, + "CapturedVariableHistory": [ + { + "HitSequence": 1, + "FrameCount": 42, + "HitAtUtc": "2026-06-03T00:00:01.0000000Z", + "CapturedVariables": [], + "Truncated": false + } + ], + "HistoryDroppedCount": 0, "Expired": false, "EnabledAtUtc": "2026-06-03T00:00:00.0000000Z", "ElapsedSinceEnabledMilliseconds": 1200, From 7b29d36fe693210e08073768de37eb79bae4be7f Mon Sep 17 00:00:00 2001 From: Masamichi Hatayama Date: Sun, 12 Jul 2026 18:32:46 +0900 Subject: [PATCH 3/3] feat: document pause point capture workflows (#1728) --- .agents/skills/uloop-pause-point/SKILL.md | 19 +++++++++++++++++++ .claude/skills/uloop-pause-point/SKILL.md | 19 +++++++++++++++++++ .../CliOnlyTools~/PausePoint/Skill/SKILL.md | 19 +++++++++++++++++++ cli/common/tools/default-tools.json | 15 +++++++++++++++ .../projectrunner/pause_point_wait_test.go | 17 ++++++++++++----- 5 files changed, 84 insertions(+), 5 deletions(-) diff --git a/.agents/skills/uloop-pause-point/SKILL.md b/.agents/skills/uloop-pause-point/SKILL.md index adc303b84..71bd2aa23 100644 --- a/.agents/skills/uloop-pause-point/SKILL.md +++ b/.agents/skills/uloop-pause-point/SKILL.md @@ -30,6 +30,25 @@ uloop await-pause-point --id "Assets/Scripts/Enemy.cs:42" --timeout-seconds 30 5. While Unity is still paused, capture any additional evidence with `uloop execute-dynamic-code`, `uloop get-hierarchy`, `uloop find-game-objects`, and one screenshot. 6. Clear the marker with `uloop clear-pause-point --id "Assets/Scripts/Enemy.cs:42"` or stop PlayMode before moving on. Use `uloop clear-pause-point --all` to clear every active marker at once, for example when resetting between E2E scenarios. Clearing also removes the underlying code patch, so the method runs untouched afterwards. +## Capture Modes and History + +Choose the capture mode when enabling a pause point: + +- `single-shot` is the default. The first hit pauses Unity and disarms the marker. +- `continuous` pauses Unity on every hit and remains armed. Each hit adds a frame to `CapturedVariableHistory`, while `CapturedVariables` remains the latest-hit compatibility view. +- `trace` remains armed and records each hit without pausing Unity. + +`--max-history` defaults to 20 and accepts values from 1 through 100. When the limit is exceeded, the oldest frames are dropped and `HistoryDroppedCount` reports how many were removed. `pause-point-status` returns the current `Mode`, `MaxHistory`, history frames, and dropped count. + +To inspect value changes one Editor Step at a time, enable a `continuous` pause point on a line inside `Update` or `FixedUpdate`, trigger the first hit, then run: + +```bash +uloop control-play-mode --action Step +uloop pause-point-status --id "Assets/Scripts/Enemy.cs:42" +``` + +Repeat the Step/status pair to inspect the history tail. A new frame is captured only when the patched line executes during that frame; event handlers such as `OnCollisionEnter` update only when the event occurs again. Use a longer `--timeout-seconds` for a Step session because the enable-time timeout does not extend after hits. + ## Reading CapturedVariables Every hit response embeds `CapturedVariables`: the method's in-scope locals, its parameters, and the `this` instance fields, captured at the exact moment execution reached the patched line. Values are point-in-time strings, not live references, so they stay valid as evidence even after Unity resumes. diff --git a/.claude/skills/uloop-pause-point/SKILL.md b/.claude/skills/uloop-pause-point/SKILL.md index adc303b84..71bd2aa23 100644 --- a/.claude/skills/uloop-pause-point/SKILL.md +++ b/.claude/skills/uloop-pause-point/SKILL.md @@ -30,6 +30,25 @@ uloop await-pause-point --id "Assets/Scripts/Enemy.cs:42" --timeout-seconds 30 5. While Unity is still paused, capture any additional evidence with `uloop execute-dynamic-code`, `uloop get-hierarchy`, `uloop find-game-objects`, and one screenshot. 6. Clear the marker with `uloop clear-pause-point --id "Assets/Scripts/Enemy.cs:42"` or stop PlayMode before moving on. Use `uloop clear-pause-point --all` to clear every active marker at once, for example when resetting between E2E scenarios. Clearing also removes the underlying code patch, so the method runs untouched afterwards. +## Capture Modes and History + +Choose the capture mode when enabling a pause point: + +- `single-shot` is the default. The first hit pauses Unity and disarms the marker. +- `continuous` pauses Unity on every hit and remains armed. Each hit adds a frame to `CapturedVariableHistory`, while `CapturedVariables` remains the latest-hit compatibility view. +- `trace` remains armed and records each hit without pausing Unity. + +`--max-history` defaults to 20 and accepts values from 1 through 100. When the limit is exceeded, the oldest frames are dropped and `HistoryDroppedCount` reports how many were removed. `pause-point-status` returns the current `Mode`, `MaxHistory`, history frames, and dropped count. + +To inspect value changes one Editor Step at a time, enable a `continuous` pause point on a line inside `Update` or `FixedUpdate`, trigger the first hit, then run: + +```bash +uloop control-play-mode --action Step +uloop pause-point-status --id "Assets/Scripts/Enemy.cs:42" +``` + +Repeat the Step/status pair to inspect the history tail. A new frame is captured only when the patched line executes during that frame; event handlers such as `OnCollisionEnter` update only when the event occurs again. Use a longer `--timeout-seconds` for a Step session because the enable-time timeout does not extend after hits. + ## Reading CapturedVariables Every hit response embeds `CapturedVariables`: the method's in-scope locals, its parameters, and the `this` instance fields, captured at the exact moment execution reached the patched line. Values are point-in-time strings, not live references, so they stay valid as evidence even after Unity resumes. diff --git a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md index adc303b84..71bd2aa23 100644 --- a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md +++ b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md @@ -30,6 +30,25 @@ uloop await-pause-point --id "Assets/Scripts/Enemy.cs:42" --timeout-seconds 30 5. While Unity is still paused, capture any additional evidence with `uloop execute-dynamic-code`, `uloop get-hierarchy`, `uloop find-game-objects`, and one screenshot. 6. Clear the marker with `uloop clear-pause-point --id "Assets/Scripts/Enemy.cs:42"` or stop PlayMode before moving on. Use `uloop clear-pause-point --all` to clear every active marker at once, for example when resetting between E2E scenarios. Clearing also removes the underlying code patch, so the method runs untouched afterwards. +## Capture Modes and History + +Choose the capture mode when enabling a pause point: + +- `single-shot` is the default. The first hit pauses Unity and disarms the marker. +- `continuous` pauses Unity on every hit and remains armed. Each hit adds a frame to `CapturedVariableHistory`, while `CapturedVariables` remains the latest-hit compatibility view. +- `trace` remains armed and records each hit without pausing Unity. + +`--max-history` defaults to 20 and accepts values from 1 through 100. When the limit is exceeded, the oldest frames are dropped and `HistoryDroppedCount` reports how many were removed. `pause-point-status` returns the current `Mode`, `MaxHistory`, history frames, and dropped count. + +To inspect value changes one Editor Step at a time, enable a `continuous` pause point on a line inside `Update` or `FixedUpdate`, trigger the first hit, then run: + +```bash +uloop control-play-mode --action Step +uloop pause-point-status --id "Assets/Scripts/Enemy.cs:42" +``` + +Repeat the Step/status pair to inspect the history tail. A new frame is captured only when the patched line executes during that frame; event handlers such as `OnCollisionEnter` update only when the event occurs again. Use a longer `--timeout-seconds` for a Step session because the enable-time timeout does not extend after hits. + ## Reading CapturedVariables Every hit response embeds `CapturedVariables`: the method's in-scope locals, its parameters, and the `this` instance fields, captured at the exact moment execution reached the patched line. Values are point-in-time strings, not live references, so they stay valid as evidence even after Unity resumes. diff --git a/cli/common/tools/default-tools.json b/cli/common/tools/default-tools.json index 878f28d27..2ff709a17 100644 --- a/cli/common/tools/default-tools.json +++ b/cli/common/tools/default-tools.json @@ -360,6 +360,21 @@ "type": "integer", "description": "Seconds before the enable request expires and stops pausing late hits", "default": 30 + }, + "Mode": { + "type": "string", + "description": "Capture mode: single-shot pauses once, continuous pauses on every hit, trace records hits without pausing", + "enum": [ + "single-shot", + "continuous", + "trace" + ], + "default": "single-shot" + }, + "MaxHistory": { + "type": "integer", + "description": "Maximum number of captured hit frames to retain (1-100)", + "default": 20 } } } diff --git a/cli/project-runner/internal/projectrunner/pause_point_wait_test.go b/cli/project-runner/internal/projectrunner/pause_point_wait_test.go index 030352174..f3fd6d13e 100644 --- a/cli/project-runner/internal/projectrunner/pause_point_wait_test.go +++ b/cli/project-runner/internal/projectrunner/pause_point_wait_test.go @@ -30,11 +30,15 @@ func TestWaitForPausePointReturnsHitAfterEnabledStatus(t *testing.T) { responses := []pausePointStatusResponse{ {Id: "jump", Status: pausePointStatusEnabled, IsEnabled: true}, { - Id: "jump", - Status: pausePointStatusHit, - IsHit: true, - HitCount: 1, - EditorState: pausePointEditorState{IsPlaying: true, IsPaused: true, CapturedAt: "PausePointHit"}, + Id: "jump", + Status: pausePointStatusHit, + IsHit: true, + HitCount: 1, + Mode: "continuous", + MaxHistory: 20, + CapturedVariableHistory: []pausePointCapturedHistoryFrame{{HitSequence: 1, FrameCount: 42, HitAtUtc: "2026-06-03T00:00:01.0000000Z"}}, + HistoryDroppedCount: 0, + EditorState: pausePointEditorState{IsPlaying: true, IsPaused: true, CapturedAt: "PausePointHit"}, }, } requestCount := 0 @@ -65,6 +69,9 @@ func TestWaitForPausePointReturnsHitAfterEnabledStatus(t *testing.T) { if response.Status != pausePointStatusHit || response.HitCount != 1 { t.Fatalf("response mismatch: %#v", response) } + if response.Mode != "continuous" || response.MaxHistory != 20 || len(response.CapturedVariableHistory) != 1 { + t.Fatalf("capture history mismatch: %#v", response) + } if requestCount != 2 { t.Fatalf("request count mismatch: %d", requestCount) }