diff --git a/.agents/skills/uloop-pause-point/SKILL.md b/.agents/skills/uloop-pause-point/SKILL.md index 40442008b..d55a8d648 100644 --- a/.agents/skills/uloop-pause-point/SKILL.md +++ b/.agents/skills/uloop-pause-point/SKILL.md @@ -73,7 +73,7 @@ While Unity is paused on a hit, `execute-dynamic-code` can read live captured re - `GetCapturedNames()` lists captured variable names from that snapshot. - `GetCapturedPausePointId()` returns the pause-point id for the held snapshot. -The holder clears when Unity resumes (not when you `Step` while still paused), when the matching pause point is cleared, when a new hit replaces the snapshot, or when PlayMode exits. After resume, `TryGetCapturedValue` returns `Found=false`. +The holder clears when Unity resumes (not when you `Step` while still paused), when the matching pause point is cleared, when a new hit replaces the snapshot, or when PlayMode exits. After resume, `TryGetCapturedValue` returns `Found=false`. Re-enabling the same pause point while still paused (for example to refresh its timeout during a step session) keeps the held references, because a re-enable does not resume Unity. ## Watch Expressions @@ -84,7 +84,7 @@ uloop enable-watch --id "speed" --expression "UloopPausePoint.TryGetCapturedValu uloop get-watch-values --id "speed" ``` -`enable-watch` compiles the C# expression once, evaluates it immediately for a baseline, and then evaluates it once per changed `Time.frameCount` while Unity is both playing and paused. Multiple watches run in registration order. `enable-watch` rejects a duplicate id instead of overwriting; clear with `clear-watch --id ` before re-registering a changed expression. `clear-watch --id ` removes one watch; `clear-watch --all` removes all watches. `get-watch-values` without `--id` returns every registered watch. +`enable-watch` compiles the C# expression once, evaluates it immediately for a baseline, and then evaluates it once per changed `Time.frameCount`, but only while Play Mode is running and the Editor is paused (each hit pause and each `Step`); nothing is recorded while the game runs unpaused. Multiple watches run in registration order. `enable-watch` rejects a duplicate id instead of overwriting; clear with `clear-watch --id ` before re-registering a changed expression. `clear-watch --id ` removes one watch; `clear-watch --all` removes all watches. `get-watch-values` without `--id` returns every registered watch. The expression may use `UloopPausePoint.TryGetCapturedValue("name")` to inspect the latest raw pause-point capture while paused. Each history entry includes the frame and either a stringified value or an explicit error type and message. A throwing expression is recorded as an error and does not stop the Editor update loop. `--max-history` accepts 1 through 100 and drops the oldest entries after the limit. diff --git a/.claude/skills/uloop-pause-point/SKILL.md b/.claude/skills/uloop-pause-point/SKILL.md index 40442008b..d55a8d648 100644 --- a/.claude/skills/uloop-pause-point/SKILL.md +++ b/.claude/skills/uloop-pause-point/SKILL.md @@ -73,7 +73,7 @@ While Unity is paused on a hit, `execute-dynamic-code` can read live captured re - `GetCapturedNames()` lists captured variable names from that snapshot. - `GetCapturedPausePointId()` returns the pause-point id for the held snapshot. -The holder clears when Unity resumes (not when you `Step` while still paused), when the matching pause point is cleared, when a new hit replaces the snapshot, or when PlayMode exits. After resume, `TryGetCapturedValue` returns `Found=false`. +The holder clears when Unity resumes (not when you `Step` while still paused), when the matching pause point is cleared, when a new hit replaces the snapshot, or when PlayMode exits. After resume, `TryGetCapturedValue` returns `Found=false`. Re-enabling the same pause point while still paused (for example to refresh its timeout during a step session) keeps the held references, because a re-enable does not resume Unity. ## Watch Expressions @@ -84,7 +84,7 @@ uloop enable-watch --id "speed" --expression "UloopPausePoint.TryGetCapturedValu uloop get-watch-values --id "speed" ``` -`enable-watch` compiles the C# expression once, evaluates it immediately for a baseline, and then evaluates it once per changed `Time.frameCount` while Unity is both playing and paused. Multiple watches run in registration order. `enable-watch` rejects a duplicate id instead of overwriting; clear with `clear-watch --id ` before re-registering a changed expression. `clear-watch --id ` removes one watch; `clear-watch --all` removes all watches. `get-watch-values` without `--id` returns every registered watch. +`enable-watch` compiles the C# expression once, evaluates it immediately for a baseline, and then evaluates it once per changed `Time.frameCount`, but only while Play Mode is running and the Editor is paused (each hit pause and each `Step`); nothing is recorded while the game runs unpaused. Multiple watches run in registration order. `enable-watch` rejects a duplicate id instead of overwriting; clear with `clear-watch --id ` before re-registering a changed expression. `clear-watch --id ` removes one watch; `clear-watch --all` removes all watches. `get-watch-values` without `--id` returns every registered watch. The expression may use `UloopPausePoint.TryGetCapturedValue("name")` to inspect the latest raw pause-point capture while paused. Each history entry includes the frame and either a stringified value or an explicit error type and message. A throwing expression is recorded as an error and does not stop the Editor update loop. `--max-history` accepts 1 through 100 and drops the oldest entries after the limit. diff --git a/Assets/Tests/Editor/PausePointTests.cs b/Assets/Tests/Editor/PausePointTests.cs index 77e188272..2e9c81854 100644 --- a/Assets/Tests/Editor/PausePointTests.cs +++ b/Assets/Tests/Editor/PausePointTests.cs @@ -578,6 +578,45 @@ public void TryGetCapturedValue_WhenUnrelatedPausePointIsCleared_KeepsLatestHitR Assert.That(UloopPausePoint.GetCapturedPausePointId(), Is.EqualTo("land")); } + [Test] + public void TryGetCapturedValue_WhenSamePausePointIsReenabledWhilePaused_KeepsLatestHitRawCapture() + { + // Verifies re-enabling the same id while still paused (e.g. to refresh its timeout + // during a step session) keeps the held raw capture instead of clearing it. + UloopPausePointRegistry.Enable("jump", 30); + UloopPausePointCapturedVariableFrame frame = new( + new[] { new UloopPausePointCapturedVariableEntry("speed", UloopCapturedVariableScope.Local, 1) }, + false); + UloopPausePointRegistry.HitWithCapturedFrame("jump", frame, Array.Empty(), false); + + UloopPausePointRegistry.Enable("jump", 30); + + (bool found, object value) = UloopPausePoint.TryGetCapturedValue("speed"); + Assert.That(found, Is.True); + Assert.That(value, Is.EqualTo(1)); + Assert.That(UloopPausePoint.GetCapturedPausePointId(), Is.EqualTo("jump")); + } + + [Test] + public void TryGetCapturedValue_WhenSamePausePointIsReenabledThenCleared_StillClearsRawCapture() + { + // Verifies Clear(id) after a same-id re-enable still drops the raw capture holder, + // even though the re-enable already reset the hit snapshot bookkeeping. + UloopPausePointRegistry.Enable("jump", 30); + UloopPausePointCapturedVariableFrame frame = new( + new[] { new UloopPausePointCapturedVariableEntry("speed", UloopCapturedVariableScope.Local, 1) }, + false); + UloopPausePointRegistry.HitWithCapturedFrame("jump", frame, Array.Empty(), false); + + UloopPausePointRegistry.Enable("jump", 30); + UloopPausePointRegistry.Clear("jump"); + + (bool found, object value) = UloopPausePoint.TryGetCapturedValue("speed"); + Assert.That(found, Is.False); + Assert.That(value, Is.Null); + Assert.That(UloopPausePoint.GetCapturedPausePointId(), Is.Empty); + } + [Test] public void Hit_WhenPausePointIsEnabled_ReportsEmptyCapturedVariables() { diff --git a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md index 40442008b..d55a8d648 100644 --- a/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md +++ b/Packages/src/Editor/CliOnlyTools~/PausePoint/Skill/SKILL.md @@ -73,7 +73,7 @@ While Unity is paused on a hit, `execute-dynamic-code` can read live captured re - `GetCapturedNames()` lists captured variable names from that snapshot. - `GetCapturedPausePointId()` returns the pause-point id for the held snapshot. -The holder clears when Unity resumes (not when you `Step` while still paused), when the matching pause point is cleared, when a new hit replaces the snapshot, or when PlayMode exits. After resume, `TryGetCapturedValue` returns `Found=false`. +The holder clears when Unity resumes (not when you `Step` while still paused), when the matching pause point is cleared, when a new hit replaces the snapshot, or when PlayMode exits. After resume, `TryGetCapturedValue` returns `Found=false`. Re-enabling the same pause point while still paused (for example to refresh its timeout during a step session) keeps the held references, because a re-enable does not resume Unity. ## Watch Expressions @@ -84,7 +84,7 @@ uloop enable-watch --id "speed" --expression "UloopPausePoint.TryGetCapturedValu uloop get-watch-values --id "speed" ``` -`enable-watch` compiles the C# expression once, evaluates it immediately for a baseline, and then evaluates it once per changed `Time.frameCount` while Unity is both playing and paused. Multiple watches run in registration order. `enable-watch` rejects a duplicate id instead of overwriting; clear with `clear-watch --id ` before re-registering a changed expression. `clear-watch --id ` removes one watch; `clear-watch --all` removes all watches. `get-watch-values` without `--id` returns every registered watch. +`enable-watch` compiles the C# expression once, evaluates it immediately for a baseline, and then evaluates it once per changed `Time.frameCount`, but only while Play Mode is running and the Editor is paused (each hit pause and each `Step`); nothing is recorded while the game runs unpaused. Multiple watches run in registration order. `enable-watch` rejects a duplicate id instead of overwriting; clear with `clear-watch --id ` before re-registering a changed expression. `clear-watch --id ` removes one watch; `clear-watch --all` removes all watches. `get-watch-values` without `--id` returns every registered watch. The expression may use `UloopPausePoint.TryGetCapturedValue("name")` to inspect the latest raw pause-point capture while paused. Each history entry includes the frame and either a stringified value or an explicit error type and message. A throwing expression is recorded as an error and does not stop the Editor update loop. `--max-history` accepts 1 through 100 and drops the oldest entries after the limit. diff --git a/Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs b/Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs index 595579eac..ab8491c44 100644 --- a/Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs +++ b/Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs @@ -53,7 +53,10 @@ public static UloopPausePointSnapshot Enable( int generation = ++_nextGeneration; UloopPausePointEntry entry = new(id, timeoutSeconds, mode, maxHistory, now, generation); Entries[id] = entry; - ClearLatestHitSnapshotIfMatches(id); + // Why not clear the raw capture holder here: a re-enable does not resume Unity, so the + // paused-window constraint (see UloopPausePointRawCaptureHolder's class comment) is not + // violated by keeping the previous hit's live references across a same-id re-enable. + ForgetHitSnapshotForId(id); return entry.ToSnapshot(now, _pauseController); } @@ -86,7 +89,7 @@ public static UloopPausePointSnapshot Clear(string id) _ => "Pause point cleared." }; entry.MarkCleared(UloopPausePointClearedReason.ExplicitClear, message); - ClearLatestHitSnapshotIfMatches(id); + ClearHitSnapshotAndRawCaptureForId(id); return entry.ToSnapshot(now, _pauseController); } @@ -244,21 +247,31 @@ public static void ClearLatestHitSnapshot() UloopPausePointRawCaptureHolder.Clear(); } - private static void ClearLatestHitSnapshotIfMatches(string id) + // Drops the id's own hit-history entry and, if it currently owns the latest hit, that + // pointer too - but never touches the raw capture holder. Enable() uses this so a same-id + // re-enable while paused only resets the entry's generation bookkeeping. + private static void ForgetHitSnapshotForId(string id) { _hitSnapshots.RemoveAll(hitSnapshot => hitSnapshot.Id == id); - if (_latestHitSnapshot == null) + if (_latestHitSnapshot != null && _latestHitSnapshot.Id == id) { - return; + _latestHitSnapshot = null; } + } - if (_latestHitSnapshot.Id != id) + // Same as ForgetHitSnapshotForId, plus clears the raw capture holder when the holder's + // captured snapshot belongs to the cleared id. Clear(id) uses this because an explicit + // clear is documented to drop captures. Ownership is checked against the holder itself + // (not _latestHitSnapshot) because Enable()'s ForgetHitSnapshotForId already nulls out + // _latestHitSnapshot on a same-id re-enable, which would otherwise make a later Clear(id) + // think it no longer owns a holder it actually still does. + private static void ClearHitSnapshotAndRawCaptureForId(string id) + { + ForgetHitSnapshotForId(id); + if (UloopPausePointRawCaptureHolder.GetCapturedPausePointId() == id) { - return; + UloopPausePointRawCaptureHolder.Clear(); } - - _latestHitSnapshot = null; - UloopPausePointRawCaptureHolder.Clear(); } public static void ConfigureForTests(IUloopPausePointPauseController pauseController, Func nowProvider)