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
101 changes: 28 additions & 73 deletions .agents/skills/uloop-pause-point/SKILL.md

Large diffs are not rendered by default.

26 changes: 24 additions & 2 deletions .agents/skills/uloop-pause-point/references/captured-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ Read this before interpreting unexpected, missing, or truncated captured values,

- The snapshot is taken **before** the resolved line executes, exactly like an IDE breakpoint on that line. To inspect a value after an assignment, place the pause point on the following line.
- The pause itself only takes effect at the next frame boundary: the frame that hit the pause point still runs to completion first, so any event that fires later in that same frame (a chained collision, a cascading destroy) has already happened by the time Unity actually stops. Trust `CapturedVariables` (the pre-line snapshot) as evidence for what was true up to the patched line; do not assume the paused state still matches it for events later in the same frame.
- Rigidbody values read inside a physics callback (`OnCollision*`/`OnTrigger*`) can be mid-solver intermediates — `velocity` may capture as `(0.0, 0.0)` at the callback even though the body visibly moves. `CapturedVariables` faithfully records that intermediate value; a later `execute-dynamic-code` read returning something different means the physics solver has since finished the step, not that the capture was wrong.
- `execute-dynamic-code` during the pause sees the interrupted method's **post-interrupt** state, not this pre-line snapshot. Use `CapturedVariables` for pre-line evidence; use the raw capture API below when you need live references while paused. If you suspect a captured value is stale or wrong, cross-check it against the live scene object with `execute-dynamic-code` (for example reading `transform.position` off the instance found via `UnityObjectPath`) rather than trusting either source alone. `execute-dynamic-code` responses also carry `EditorPaused` and `ActivePausePointId` — these fields appear only while the Editor is paused, so a call made while a pause point still has Unity paused is unambiguous instead of looking like a stale or buggy result.

## Scopes and the `this` Entry
Expand All @@ -19,7 +20,7 @@ Read this before interpreting unexpected, missing, or truncated captured values,
## Value Rendering, Previews, and Caps

- Nested previews stop at `MaxCollectionPreviewDepth` (2 levels) below each captured variable: past that, an object or collection renders as type-name-only text instead of expanding — a type name where you expected contents means you hit this cap, not a bug. The budget is counted per captured variable, so reaching a value through `this` costs one extra level compared to reading it as a direct local: `this.CurrentPiece.Origin` bottoms out as a type name, while a `dropped` local holding the same piece expands to `{Kind, RotationState, Origin: {X, Y}}`. When the value you need sits too deep, pick a pause point line where it is a direct local or parameter — as its own top-level entry it starts with a fresh full budget. Primitive leaves (numbers, strings, booleans, and any type that overrides `ToString()`) always render regardless of depth; only nested objects and collections get cut off.
- A value's `Value` string is not always its plain `ToString()`. A materialized collection (`List<T>`, arrays, dictionaries, ...) previews as a shallow JSON array/object instead of the default type-name text. A custom struct/class whose declared type does not override `ToString()` previews the same way — a shallow JSON object of its fields — so you do not need to add a temporary `ToString()` override just to see its contents. A type that does override `ToString()` keeps using that result unchanged. Either kind of preview is capped by depth, element count, and length like any other captured value; the element-count cap (default 10) and the preview's character budget both scale with `enable-pause-point --max-preview-elements`.
- A value's `Value` string is not always its plain `ToString()`. A materialized collection (`List<T>`, arrays, dictionaries, ...) previews as a shallow JSON array/object instead of the default type-name text. A custom struct/class whose declared type does not override `ToString()` previews the same way — a shallow JSON object of its fields — so you do not need to add a temporary `ToString()` override just to see its contents. A type that does override `ToString()` keeps using that result unchanged. Either kind of preview is capped by depth, element count, and length like any other captured value; the element-count cap (default 10) and the preview's character budget both scale with `enable-pause-point --max-preview-elements` (1–1000). Raising it scales the character budget proportionally, so each element keeps the same ~100-character share it has at the default — plenty for numeric or boolean cells, but individually long elements can still be clipped by the scaled budget. The enable response echoes the effective `MaxPreviewElements`.
- A multidimensional array (`int[,]`, `int[,,]`, ...) previews as `{"Shape":"Int32[2,3]","TotalElements":6,"Elements":[...]}` instead of a bare JSON array, since `Elements` alone would flatten every rank in row-major order with no way to tell it apart from an empty or 1D collection; a `T[]` or jagged `T[][]` array is unaffected and still previews as a plain JSON array.
- `CapturedVariablesTruncated=true` means at least one value was clipped to the length cap or the variable-count cap stopped enumeration; clipped values are still present up to the cap.

Expand All @@ -37,6 +38,17 @@ Pull only what you need instead of paying for it all up front:
- `--captured-variables names` on `await-pause-point`/`pause-point-status` drops `Value` from every captured variable (including every history frame) and keeps `Name`/`Scope`/`TypeName`. Use it first on a field-heavy class, then fetch specific values afterward.
- `uloop pause-point-status --id <id>` returns the full response again, including every `Value`, whenever you need it — call it plain (no `--captured-variables`) for the complete picture after a lightweight `names` scan.

## Step Sessions

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.

## Choosing the Right Evidence Source

Three different sources answer three different questions about a captured variable; pick by what you actually need:
Expand All @@ -57,9 +69,19 @@ 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.

Deconstruct the tuple before use:

```csharp
(bool found, object value) = UloopPausePoint.TryGetCapturedValue("this");
if (!found) { return "capture missing"; }
return value;
```

The references are live objects in their frame-completed state: anything the hit's method changed — or destroyed — after the patched line is already applied, so a captured object can read as destroyed/null here even though `CapturedVariables` shows its pre-line field values intact.

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.

For a self-progressing game (a board that advances on a timer, an opponent that keeps moving), arranging a specific scenario through real input alone is a race you will usually lose: each `simulate-*` call is a separate CLI round trip, and the gap between two calls is often longer than the game's own tick. Instead, while paused on a hit, use `TryGetCapturedValue("this")` to get the live instance and call its production methods directly to build up the exact state you need, then send real simulated input for only the one action you are verifying. The setup becomes deterministic while the observed action still exercises the real input path.
For a self-progressing game, arranging a scenario through real input alone is a race (each `simulate-*` call is a separate CLI round trip, often longer than the game's own tick). Instead, while paused on a hit, use `TryGetCapturedValue("this")` to get the live instance and call its production methods to build the exact state, then resume and send real simulated input for only the one action you are verifying — arm the next marker with `--resume-play`, or run `control-play-mode --action Play` first, since `simulate-*` input requires an unpaused PlayMode. The setup stays deterministic while the observed action still exercises the real input path.

## Warnings and Marker Freshness

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Fast-Progressing Games

Read this when the game advances on its own (timers, gravity, spawners, a ball that keeps bouncing, pieces that keep falling) and CLI round-trips are slower than the game's own tick.

## Freeze → Build → Resume in One Call

Any state you arrange while PlayMode runs live can be consumed by the game before your next command arrives. Freeze first, build while paused, then resume and fire the input in one call:

```bash
# 1) Freeze the whole player loop before arranging anything
uloop control-play-mode --action Pause

# 2) While paused, build the exact scenario (production methods preferred; see below)
uloop execute-dynamic-code --code '...'

# 3) One call: confirm the marker armed, resume PlayMode, fire the input, await the hit
uloop enable-pause-point --file Assets/Scripts/Enemy.cs --line 42 --timeout-seconds 60 \
--await --resume-play --trigger "simulate-keyboard --action Press --key Space"
```

## --resume-play Semantics

`--resume-play` runs after the marker's arming is confirmed and before `--trigger` is dispatched: it resumes PlayMode only when PlayMode is actually paused, and reports what it did in `ResumePlayResult` (`WasPaused` / `Resumed` / `Error`). If the resume fails, the trigger is not dispatched and `TriggerResult.Error` says so. When the game reaches the line on its own after resuming (gravity, physics), omit `--trigger` and keep `--resume-play`.

## Do Not Use Time.timeScale = 0

Projects that read unscaled time keep advancing regardless, and the value silently persists into the next PlayMode session. Editor pause and `Step` freeze the entire player loop independent of `Time.timeScale`.

## The Residual Race

After the resume, the game runs freely for the single in-process round-trip until the trigger input lands. When even that is longer than the game's natural tick interval (for example a piece that auto-falls every 0.8 seconds), remove the race instead of trying to outrun it: temporarily overwrite the tick-interval field with `execute-dynamic-code`, run the verification, then restore the original value and confirm the restore with a re-read.

## Injecting State While Paused

Writing fields or transforms directly while the Editor is paused can silently fail to stick: `transform.position` and `Rigidbody2D.position` do not synchronize until the next simulation step, and any production `Update()` that recomputes the value will overwrite the injection on the next frame. Prefer arranging state through the game's own methods. After a direct write, verify in two stages: re-read immediately while still paused to confirm the write landed, then advance one frame with `control-play-mode --action Step` and re-read again to confirm it survives the frame — a post-Step revert means an `Update()` recompute or a deferred physics sync overwrote it, which the immediate read alone cannot reveal.
36 changes: 36 additions & 0 deletions .agents/skills/uloop-pause-point/references/troubleshooting.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Pause Point Troubleshooting

Read this when a wait times out, `HitCount` stays `0`, or `enable-pause-point` fails.

## Timeout Diagnosis

A `PAUSE_POINT_EXPIRED` error carries the same `Error.Details.Hint` as a timeout plus a shell-neutral `Error.Details.RecommendedNextAction`. Inspect `Error.Details.Status`, `HitCount`, `Generation`, `EnabledAtUtc`, `EditorState`, `ElapsedSinceEnabledMilliseconds`, and `RemainingMilliseconds` to distinguish input not being consumed, stale evidence from an older marker generation, runtime conditions not being met, an id mismatch, or Unity already being paused. `ElapsedSinceEnabledMilliseconds` is measured from `enable-pause-point`, not from `await-pause-point`.

The `--timeout-seconds` countdown freezes only while a pause-point hit holds the Editor paused; the elapsed pause duration is credited back onto the marker's expiry on resume, so inspecting a paused hit for as long as you need does not erode the remaining timeout budget. A manual pause without a hit does not stop the countdown.

## Locating Where Control Flow Stops

To locate where control flow stops before an unhit line, bisect with a second pause point on the method's entry (its first executable line). If the entry point hits while the target line stays at `HitCount=0`, an early return or a branch between the two lines is filtering execution — inspect the guard values in the entry hit's `CapturedVariables` instead of retrying the original line.

## JIT Inlining

Mono can inline very small target methods into callers, and the pause point then never fires even though the line runs. If a line demonstrably runs but the pause point stays unhit and nothing else explains it, move the pause point into the calling method.

## Physics Message Methods and One-Hop Helpers

Physical Unity message methods (`OnCollisionEnter2D`, `OnTriggerEnter2D`, and similar callbacks) can silently miss: a GameObject that already existed at enable time may keep calling the pre-patch code, so `HitCount` stays `0` even though the method body runs. The condition is environment-dependent, and the response `Warning` flags such lines at enable time. The same applies one hop out — a helper called from a physics message method in the same compiled assembly; deeper call chains or callers in other assemblies are not detected by the warning but can fail the same way.

Recovery order:

1. Confirm the body actually ran after arming, via evidence from fresh contact — a stale pre-arm counter or log proves nothing.
2. `clear-pause-point` the marker, `enable-pause-point` it again, and wait for the next fresh contact.
3. Recreate the GameObject after enabling.
4. Embed `UloopPausePoint.Pause("<id>")` in the method body and use an id-only marker.

## Pre-Bound Delegates

A method already bound into a delegate or event before `enable-pause-point` may not fire through that delegate: the pre-bound invocation path can bypass the patch. Workarounds: enable the pause point before the delegate is created, recreate the subscribing GameObject, or re-bind the delegate (e.g. via `execute-dynamic-code`) after enabling.

## Enable Failures

If enable fails with a "No sequence point found" error even for clearly executable lines, that script's assembly lacks debug sequence points and no line in the file can be patched. Move the pause point to a script in an assembly that carries them, such as a script under `Assets/`.
Loading