---
title: "Live play testing via Unity MCP"
slug: /workflows/live-play-testing-unity-mcp/
markdown: /workflows/live-play-testing-unity-mcp.md
section: verify-and-test
tier: 2
summary: "Drive the running Editor over MCP: ordered test recipes, crash recovery, offline compile."
sources: ['unity-mcp.md']
canonical: https://unityworkflowlibrary.dev/workflows/live-play-testing-unity-mcp/
read_first: /read-first.md
---

> Read first, on every prompt: [non-destructive rules](/read-first.md).

# Live play testing via Unity MCP

> Drive the running Editor over MCP: ordered test recipes, crash recovery, offline compile.

Canonical local clones (your machine):

- **Plugin (Editor bridge):** a local clone next to your projects, from your fork's git URL.
  Branches: `main` = DOTS/NetCode fork of upstream; `rimjob3d` = `main` + Rimjob-only probes.
- **Server (MCP process):** a local clone next to the plugin, from your fork's git URL.
  Pair with the fork server — upstream silently drops plugin-added dynamic routes under queue mode.

Upstream (non-DOTS) is AnkleBreaker-Studio; do **not** reinstall upstream in DOTS/NetCode projects
or you lose `dots/*` + `netcode/*`.

## Wire a project

1. **Package** — UPM git (prefer `#main` unless the project owns Rimjob probes):
   ```json
   "com.anklebreaker.unity-mcp": "https://github.com/AnkleBreaker-Studio/unity-mcp-plugin.git#main"
   ```
   Or embed/copy under `Packages/com.anklebreaker.unity-mcp` and `"file:com.anklebreaker.unity-mcp"`.
2. **MCP client** — project `.mcp.json` (or host equivalent) pointing at the fork server:
   ```json
   {
     "mcpServers": {
       "unity": {
         "command": "node",
         "args": ["<abs path to unity-mcp-server>/src/index.js"]
       }
     }
   }
   ```
3. Editor must be open with the bridge running; multi-instance → pick with `unity_list_instances` /
   `unity_select_instance` before other tools.

## Agent hard rules

### LIVE-PLAY-TESTS-USE-ORDERED-RECIPES

every agent uses the same fast, reusable Editor
testing flow. Tool routing is shared knowledge, not a host-specific trick.

- Read the project's live-Editor skill and tool-routing recipe. Inspect the
  installed tools/skills before copying another host's commands; a plugin may
  contain instructions only. Use the supported MCP queue, not direct HTTP.
- Acquire the Editor lease, identify the project, query Play/compiling state,
  and inspect compiler plus fresh Console. After code changes, explicitly
  refresh/compile and prove the new code loaded. Test the integrated checkout.
- Start the smallest real player-facing check as soon as it is runnable.
  Source investigation and a broken load fixture must not postpone all Play
  testing. Use the product's entry event and assert admission/spawn/readiness;
  do not invoke a lower service to bypass a gate.
- **Teleport to the test spot, never walk there**  Place the player just above each target through the game's own teleport or admin move, let
  it settle, then check. A scripted walk snags on terrain and props and burns the shared Editor
  window. Walk only when movement is the thing under test (footsteps, stairs, a flight route).
- **TEST-ON-AN-AUTHORED-FIXTURE-AREA**  A placement recipe walked off the
  dev compound onto sloped terrain beside earlier test bases, blocked 10 m out, and found only stairs and
  doorways left by other runs. Every feature that needs a place to act (build, deploy, drive, spawn)
  gets a level, clear, authored area in the EXISTING development ground: extend it, never a second
  scene or a wild spot. The game's own rules must accept that area the way they accept the real
  surface (a flag or collision layer the production check reads), so the test exercises the shipped
  path. Teleport onto it; the recipe first proves the area itself (level, clear, nothing poking
  through), and a new condition to test (slope, water edge) is a new authored fixture, not a hunt.
- Save a reusable method-body recipe outside imported code. Submit it once:
  one frame-gated EditorApplication.update handler or owned coroutine sequences
  actions, waits for observable game effects, and enforces phase/total limits.
  Avoid a separate tool round-trip for every input, frame or measurement.
- Collect state, existing profiler/traffic counters, fresh Console and selected
  HUD screenshots in the same run. Use scheduled ScreenCapture.CaptureScreenshot
  for the HUD; inspect the resulting images. Do not add a synchronous manual
  Camera.Render while stressing the Editor unless that path is itself the test.
- **CAPTURE-SPANS-THE-CYCLE-IN-GAME-TIME** . A frame capture under a slowed
  `Time.timeScale` covers frames x real step x time scale of game time. A swing capture of 16 frames at
  0.1 scale ended at about 0.56 s of game time, before the 0.8 s impact it existed to show, and still
  read as a complete run. Size the capture from the cycle (frames x step x scale >= the full cycle) and
  log the game time of the first and last frame, so a short capture shows itself.
- Poll a small progress file while doing independent work. Flush bounded samples
  during execution so a freeze cannot erase the prefix. Record UTC, candidate,
  actual entity/connection counts, units, observer cost, scope and unavailable
  counters. Snapshot sizes or enabled callbacks alone are not bandwidth gains;
  compare controlled workloads and preserve correctness/freshness checks.
- Give every recipe cancellation and cleanup: release input, unregister update
  and log callbacks, dispose profiler readers/private worlds, restore settings,
  and remove only entities the recipe owns. Verify cleanup and overload recovery.
  A native stall cannot be rescued by a callback timeout; preserve evidence and
  diagnose it before repeating. Never turn a timed-out check into a pass.
- Stop Play through the explicit tool and confirm state when the test owns the
  session; release the lease. Retain a small result/manifest with large captures
  kept outside Git. Bound sample rate, duration, file growth and retention; read
  profiler recording state so diagnostics do not silently keep writing.

Why: ordered gameplay and simultaneous observations remove serial tool latency
without replacing real behavior with source inspection or assumed success.

- **Always use the MCP `unity_*` tools.** Never call the Unity HTTP bridge
  (`http://127.0.0.1:7890/...` etc.) directly — bypasses multi-agent queue, identity, and safety.
- **Don't invent routes.** Flat tools are tier-1; everything else goes through
  `unity_list_advanced_tools` / `unity_advanced_tool({ tool: "unity_<cat>_<verb>", ... })`.
  Dynamic categories (`dots/*`, `netcode/*`, project overlays) register at runtime — if missing,
  package/branch is wrong or Entities/NetCode not installed.
- **Verify editor state via tools, don't ask the user.** Compilation, play mode, selection,
  console, scene hierarchy, package list — all MCP-readable.
- **Agent owns compile — never wait for the user to compile manually.** After C# / asmdef /
  package / define edits, *request* a compile, wait, and read `unity_get_compilation_errors`
  (or Pipeline `recompile_status`) yourself until clean, or report a tool-backed blocker.
  Unfocused + Auto Refresh off is the pre-edit project until you Refresh then recompile.
  With `com.unity.pipeline` installed: `set_autotick --enable true`, Refresh in its own eval,
  `unity command recompile`, poll `recompile_status`. `up_to_date` before Refresh is not a
  compile. Full procedure: the agent compile page.
- **`console/log`:** leave `includeStackTrace` false unless diagnosing a specific stack (stacks are
  ~800 tokens each). Prefer `filter` regex + small `count`; response may include `truncated` /
  `matchedTotal` — re-query with higher `count` when needed.
- **Play-mode structural / write ops** on `dots/*` reject unless `force:true`. Prefer read probes
  in Play; force only when the task explicitly needs a live mutation.
- **SCENE-WRITE-BY-PATH-NEVER-BY-NAME — never write through a bare-name lookup.** `Image - Dim`,
  `Header`, `Body`, `Content`, `Grid`, `Row - Header` repeat under every panel of a menu scene, so a
  `FindObjectsByType` scan that returns the first name match READS correctly for a dozen probes and
  then writes the wrong panel. Resolve a write target by full path from a known root, or by walking
  `foreach (Transform c in parent)` from the panel you own. After any scripted scene edit, diff the
  saved scene against the base revision per document — a wrong-target write is silent, looks right
  in the panel you were building, and is invisible in a screenshot. (a mobile card game: previewing
  the profile dim set `Container - Store/Image - Dim` to 0 and the Store shipped with no dim.)

## Prefer Layer-3 probes over spelunking

When Entities / NetCode are present, **one probe beats a hierarchy crawl**:

| Need | Route (via advanced tool) |
|---|---|
| Worlds / entity counts | `dots/world-list` |
| Find entities by components | `dots/entity-query` |
| One entity dump | `dots/entity-info` |
| Singleton | `dots/singleton-get` / `singleton-set` |
| System cost / enable | `dots/system-list` / `system-toggle` |
| Buffers / archetypes / chunks | `dots/buffer-read`, `archetype-list`, `chunk-info` |
| Connections + tick | `netcode/connection-state` |
| Live ghosts | `netcode/ghost-list` |
| Prefab ghost authoring | `netcode/ghost-prefab-info` |
| RTT / acks | `netcode/snapshot-stats` |
| Client NetworkTime / prediction | `netcode/prediction-state` |

`dots/*` gates on `ENTITIES_INSTALLED`; `netcode/*` on `NETCODE_INSTALLED` (asmdef versionDefines).
Non-DOTS projects still get full GO/scene/asset MCP — only the ECS routes are absent.

## Scene-tree map (HARD — stop re-crawling scenes)

See also: the scene flow page - the app & scene flow hub (bootstrap -> load -> transitions/additive -> runtime lifecycle -> teardown).

- **Every project keeps `docs/agents/SCENE-MAP.md`: one generated tree per flow/product scene
  (GameObject hierarchy + component type names + which key serialized refs are wired), and the
  agent UPDATES it in the same slice as any scene edit.** Repeatedly opening scenes / spelunking
  YAML / `GetRootGameObjects` crawls just to learn what exists burns editor round-trips and makes
  the developer watch the editor jump between scenes. Rebuild via a small editor snippet (walk roots,
  dump names + component types per scene), never by hand; regenerate in the SAME slice as any
  AddComponent / setup-action / scene-save work. A scene edited without a map update is a rule
  violation — same class as a stale graphify map.

## Compile-check + modal caveats (hard-won)

- **`unity_get_compilation_errors` can report `count: 0, isCompiling: false` while the console
  holds real `error CS` entries** (script-updater / Bee edge after an asmdef or namespace change).
  After any refresh that should have compiled, cross-check `unity_console_log` filtered on
  `error CS` before claiming clean — a stale "0 errors" read let a missing asmdef ref reach Play
  with stale assemblies (bootstrap silently didn't run).
- **A native modal dialog (scene-save prompt, import popup) blocks the editor main thread and the
  ENTIRE MCP queue** — every unity_* call times out while `Responding=true`. Avoid triggering
  save prompts: don't `unity_scene_open` over a dirty scene you didn't mean to edit. If stuck,
  enumerate the process's visible windows (the dialog has its own title) and dismiss via UI
  automation — prefer Cancel / Don't Save for dirt you never intended to keep. On a plugin with
  listener-thread diagnostics, `unity_editor_health` / `unity_editor_dialog_list` answer while the
  queue is blocked and `unity_editor_dialog_press` answers the dialog — see
  UNITY-DRAWN-MODAL-IS-NOT-A-WIN32-DIALOG below for the kind a dialog scan misses.
- **Two `#32770` modals come from routine work, not edits .** A relaunch after a crash asks to copy
  `Temp/__Backupscenes` into `Assets/_Recovery` (copy the backup out of the project, answer No); a git pull or
  fast-forward that rewrites an open scene raises "modified externally" at the next refresh (answer Reload).
  Their buttons expose no invoke pattern, so send `BM_CLICK` to the button's own handle. Measured: 25 min of a
  lease lost at ~0 CPU with every pipeline and MCP call timing out.

### UNITY-DRAWN-MODAL-IS-NOT-A-WIN32-DIALOG

Unity's own modal windows (window class `UnityContainerWndClass`) stall the main thread and every
queued call exactly like `EditorUtility.DisplayDialog`, but a scan for Win32 dialogs (`#32770`)
returns nothing. Measured: the unsigned-package notice raised after a package re-resolve held the
main thread 8 minutes with work queued; `Responding=true`, dialog scan empty, log silent.

- **Tell:** among the editor PID's visible `UnityContainerWndClass` windows, the main window and
  every docked/floating window are **disabled** (`IsWindowEnabled` false) and exactly one owned
  window (`GetWindow(GW_OWNER)` non-zero) stays enabled — that one is the modal; its text is the title.
- **Buttons are IMGUI**, invisible to `FindWindowEx`/`BM_CLICK`. `PostMessage(WM_CLOSE)` is its
  Close/Cancel; never `SendMessage`, which waits on the thread that is stuck. Read the window first
  (PrintWindow capture) and only close notices and questions whose close means "no".
- The plugin's dialog scan reports these as `kind: "unity-modal"` with the single button `close`.
- **`scene/open` during Play must use `EditorSceneManager.LoadSceneInPlayMode`** (or
  `SceneManager.LoadScene` for a Build Settings scene). `EditorSceneManager.OpenScene` throws
  *"This cannot be used during play mode"* and the MCP queue logs it as a red console error.
  Return `{ error }` — do not throw — if the asset path is missing.

## The plugin copy must match the Editor's Unity version (HARD)

When seeding the MCP plugin into a new project, **copy it from a project on the SAME Unity
version** — not from whichever project you know best. The plugin is Editor C#, so it is subject to
the same API deprecations as any other code, and the version string does not tell you which
variant you have.

Real failure: `com.anklebreaker.unity-mcp` **2.28.0** was copied from a Unity
**6000.3** project into a Unity **6000.5** project. It produced **144 `CS0619` errors** — the
whole `InstanceID` family is obsolete-as-*error* on 6.5 — so the bridge never started and the
project stayed invisible to `unity_list_instances`. A second local project already on 6000.5 had a
migrated copy of the *same version number* that compiled clean.

**Both copies said `"version": "2.28.0"`.** The tells that a copy has been migrated:

- `package.json` carries a `_fingerprint` field
- `Editor/csc.rsp` exists containing `-nowarn:0619`
- the asmdef has `"nowarn": ["0619"]`
- `grep -rn "\.GetInstanceID\|InstanceIDToObject(\|objectReferenceInstanceIDValue" Editor/` returns 0

Check the last one before trusting any copy; it is the only tell that cannot be stale.

**Chicken-and-egg:** a plugin that does not compile cannot report that it does not compile. If a
freshly seeded project never appears in `unity_list_instances`, do not assume the editor is closed
— read `<project>/Logs/Editor.log` directly for `error CS` and check the plugin's own assembly
(`AnkleBreaker.UnityMCP.Editor.dll`) among the failures.

## Instance ports are NOT stable — re-resolve, never cache (HARD)

`unity_list_instances` ports are assigned by availability (7890 upward, with "port affinity" trying
to reclaim a previous one). Two editors that were 7890/7891 an hour ago can be **swapped** after
either one restarts, so a port you resolved earlier IN THE SAME SESSION can now address a different
project.

Measured: mid-task, a tic-tac-toe game went 7890 -> 7891 and a mobile strategy game took 7890. A cached
`port: 7890` then reached the wrong editor.

- **Re-run `unity_list_instances` before a new phase of work**, and after any gap, restart, or
  domain reload — not just once at session start.
- **Put a project assertion inside the payload**, so a misroute fails loudly instead of returning
  plausible data about the wrong project: return `Application.dataPath` (or the project path)
  alongside whatever you asked for, and check it before believing the result.
- Type names save you by accident, not by design: a project-specific namespace
  (`MyGame.Editor.…`) fails to compile in the other editor, which is what exposed the swap here.
  A call using only Unity APIs — `unity_editor_state`, a scene query, a `PlayerSettings` **write** —
  would have succeeded silently against the wrong project.

The danger is asymmetric: reads give you wrong answers, writes land in the wrong repo. Assert
before you write.

## Known fork limits (don't thrash)

- `netcode/ghost-list` returns raw `ghostType` indices — map names via `GhostCollection` /
  `ghost-prefab-info`, not by guessing.
- No per-ghost predicted-vs-authoritative delta probe yet — use `prediction-state` +
  `entity-info` on both worlds when diagnosing jank.
- Plain `IJob` / `IJobParallelFor` Burst time shows in `globalBurstJobsMs` but often **not** in
  per-system `burstJobsMs` (only Entities `IJobChunk`/`IJobEntity` attribute cleanly).
- MCP `execute_code` that `+=` onto `EditorApplication.update` often never fires afterward — for
  hitch analysis use ProfilerDriver / hierarchy frame data, not deferred snippet callbacks.
- Each MCP call can stall the editor main thread (~200–500ms compile for snippets) — treat
  editor "Server Tick Batching" warnings during MCP sessions as observer effect, not sim proof.

## ONE-AGENT-ONE-EDITOR — a second lane kills the editor two different ways (HARD)

One agent holds one editor.

- **GPU pool:** a second concurrent lane (or heavy imports alongside QA captures) exhausts the SHARED
  GPU pool — system RAM lent to the GPU, capped near half of physical (~33 of 64 GB) — while VRAM sits
  idle (4 of 16 GB). The D3D12 device is then removed and the editor dies, so the memory graph you
  check looks fine.
- **Compile domain:** worse and quieter — a second lane's mid-edit syntax error in a shared asmdef
  PINS the old assemblies, so your own freshly written types "do not exist" at execute time even
  though your files are clean. You then debug a correct file that was never compiled.

If two lanes are genuinely needed, the second must be `unity_*`-free.

**TWO-SESSIONS-ONE-EDITOR-PROTOCOL — when the developer runs two agent sessions on one editor anyway.**
Both sessions share the bridge port AND the workspace, so the failure modes are a whole-file `Write`
rejected as "modified since read" (or silently clobbering the peer's edit), a peer's mid-edit
syntax error surfacing in YOUR compile, a Refresh/Play/Stop from one side reloading the domain under
the other's in-flight bridge call (DOMAIN-RELOAD-HANGS-AFTER-BULK-ASSET-OPS), and scene YAML that
never merges. Protocol: when the host exposes session inventory, inspect it for a peer on the same cwd; message
it the exact files and symbols you own; before every Refresh / Play / Stop announce it and ask for
~90 s of bridge silence, and honour the peer's; re-read a shared file before every targeted `Edit`
and never `Write` it whole; reload a scene the peer saved before you save over it; a peer's script
reimport can leave a scene dirty in memory — compare the in-memory hierarchy to disk before deciding
the flag is real. **The bridge has exactly one owner at every moment, and only the explicit handback message ("bridge is free") transfers it** — an offer inside a status message ("I will probe once the bridge is free") is not a claim, and a handback does not cancel a claim the peer already made on the previous handback: two sessions both believed they held it and one sent Play onto the other's running probe. (a tower-defense game, two Claude sessions on port 7891.)
- **SCRIPT-WRITE-ENDS-A-PEER-PLAY-SESSION (HARD, a tower-defense game):** a `.cs`/`.asmdef` write under
  `Assets/` while another lane is in Play mode does not merely risk their run — the editor recompiles on its
  next refresh and a script reload EXITS Play mode, so the run is gone and every unbanked measurement with it.
  The head lost a lane two play passes this way (a camera fix written at 23:24 during a live session). Rule:
  when any lane announces a play pass, every other lane and the head are under a SCRIPT WRITE FREEZE until
  that lane sends "PLAY PASS DONE"; draft in scratch outside `Assets/`, drop on the all-clear. Docs, plans and
  messages are free. Announce file writes with the mtime you READ, never an estimate.

- **ASSET-WRITE-NEEDS-A-NAMED-OWNER (HARD, a tower-defense game):** the ASSET half of
  SCRIPT-WRITE-ENDS-A-PEER-PLAY-SESSION above. With several agents in one project a shared asset has
  no lock and no merge, and two mechanisms lose work silently. **Saving is whole-file:**
  `EditorSceneManager.SaveScene` serialises the ENTIRE scene, so a lane saving its own one-line change
  also persists every other lane's unsaved state - scratch preview roots included - into the developer's
  diff, under the saving lane's name, and neither lane sees it happen. **Read-modify-write drops
  neighbours:** rewriting a `.asset` a peer edited seconds ago silently discards their addition
  (measured: two lanes wrote one ScriptableObject ten minutes apart; a rule-delete took a third lane's
  just-added node with it; the lane that survived did so only because its read and write were a single
  command - luck, not protocol).
  Rules: **one named owner per shared asset at a time** (the open scene belongs to the current bridge
  owner), **only an explicit handback transfers it**, **ask the developer before touching a file you do not
  own**, and **scratch scene objects are `HideFlags.DontSave` or deleted before any save**. Before a save
  you did not solely author, enumerate the roots and REFUSE if a foreign one would persist - print what
  you found and which lane owns it rather than attributing the dirt to whoever you last spoke to.

## The crash log is NOT in the Crashes folder

`%LOCALAPPDATA%\Temp\Unity\Editor\Crashes\Crash_*\Editor.log` is a startup **stub** that only
redirects. The real stack is project-relative: **`<project>/Logs/Editor-prev.log`** for the session
that crashed (`<project>/Logs/Editor.log` for the current one). Never conclude "no crash info" from
the Crashes folder and re-run the thing that killed the editor.

## PER-USER-EDITOR-LOG-IS-SHARED - `%LOCALAPPDATA%` holds ONE editor's log, not yours

`%LOCALAPPDATA%\Unity\Editor\Editor.log` is one file per **USER**, not per project. Every editor
started without `-logFile` targets it, so when two projects are open at once the file holds exactly
one of them and the other's output is **absent entirely** - not interleaved, not truncated, absent.
Grepping it for your own lines then returns `0`, which is the same shape as a clean run: a structural
false all-clear, and any 'errors since the last reload' count is some other editor's.

Measured (a tower-defense game): a second editor from the same 6000.5.0f1 install was running
`E:\UnityProjects\a space sandbox game`. The per-user log held `a space sandbox game` 1416 times and `a tower-defense game` **zero**,
while `<project>/Logs/Editor.log` (2.6 MB) held ours - `a tower-defense game` 270, `a space sandbox game` 0. Three
`[SimMatch]` lines plainly visible in the bridge Console appeared nowhere in the per-user log.

**Read `<project>/Logs/Editor.log`** (per project, cannot be stolen) or the Console over the bridge.
If you must touch the per-user log, prove ownership first: `grep -c` a string only your project can
emit, or read the `COMMAND LINE ARGUMENTS` block at its top - it records the **winner's**
`-projectpath` and is the cheapest tell. Concurrent editors are normal in a multi-lane agent setup,
so treat the per-user path as unowned by default.

## STALE-INPUT-LOOKS-LIKE-SUCCESS - a diagnostic that cannot say "unknown" will say "fine"

Four diagnostics failed this way in ONE session (a tower-defense game/05). Each read a stale,
partial or mismeasured input and returned a **confident** answer instead of an honest "I cannot
tell". Two invented a problem, two invented an all-clear:

| Signal read | What it actually proves | The lie it told |
| --- | --- | --- |
| `dotnet build` over a Unity csproj | the csproj's file list *as of the last Unity refresh* | `CS0103` on a real, enrolled type - a phantom that was not there |
| `Logs/Editor.log` grep for `error CS` | that an error was printed *at some point ever* | errors fixed hours earlier, reported as current |
| `Library/ScriptAssemblies/*.dll` mtime + "nothing newer since" | that a compile **ended** | an 8-minute domain reload read as "settled" |
| `grep -c $'
'` | nothing - the shell eats the pattern and it matches every line | "all lines CRLF" for a pure-LF file |

**The shape:** absence of a signal was treated as evidence of completion, and a build artefact was
treated as evidence of readiness. They are not. **Filesystem output tells you a step ENDED; only the
owning process tells you it is READY** - and a long quiet period is exactly what work in progress
looks like from outside.

Rules that follow:
- **Name what the signal measures before you infer from it.** If the state you care about and the
  state you can observe differ, you have an inference, not a measurement - say so in the report.
- **Prefer a signal that only the state you care about can produce.** Editor readiness: the window
  title / registry heartbeat, never a DLL mtime. A type's existence: `CompilationPipeline`
  `sourceFiles` enrolment + a `GetClass` type load, never a log scrape or an offline build's error
  text (`GREEN-COMPILE-NEEDS-TYPE-LOAD`, `PHANTOM-SCRIPT-NOT-IN-ASSEMBLY`).
- **Date every append-only source.** `Logs/Editor.log` is never time-filtered; scope a scrape to the
  text after the last `Begin MonoManager ReloadAssembly` (written only when a compile SUCCEEDED, so
  it is a hard date line) and say how many older lines you suppressed.
- **A printed warning is not a safeguard.** `unity-status.sh` carried a "these may be historical"
  caution and a lane still nearly filed the bug: readers take the output first and the caveat second.
  Make the tool withhold the wrong answer instead of annotating it.

Sibling of `PROVE-A-GATE-CAN-FAIL` (a gate that checks nothing exits 0): that one is about a gate
that did not run, this one about a diagnostic that ran on the wrong input. Both end in a false green.

## A green compile check is necessary, NOT sufficient — confirm by TYPE-LOAD

Extends the `unity_get_compilation_errors` bullet above. After a green read, prove the new code is
actually LOADED by resolving a **newly added member** at runtime. And suspect your own probe: a probe
aimed at the wrong assembly reports the SAME misleading green as a stale DLL — read
`<project>/Logs/Editor.log` when the two disagree. "0 errors" against a stale or wrong assembly is not
a compile-verified done claim.



## Seeding the plugin (MCP-PLUGIN-SEED-FROM-MATCHING-EDITOR)

Copy the plugin folder from **a project already running the target editor version**, not from the
standalone source repo — the source lags the editor. `package.json`'s `unity` field is a *floor*,
not proof of compatibility: a plugin declaring `2021.3` still throws ~144 `CS0619` on 6.5.

Two properties make a stale copy expensive to diagnose:

- **A plugin that will not compile cannot report that it did not compile.** The bridge simply never
  starts, and `unity_list_instances` returns an empty list — indistinguishable from "editor not running".
- **Safe Mode runs no editor scripts.** So MCP is unavailable at exactly the moment you need it to
  drive the fix. Compile-green is a *precondition* for MCP, never a thing MCP can help you reach.

When the bridge is silent, check the editor log for the plugin's own compile errors before assuming
a port, firewall, or discovery problem.


## An empty instance list is ambiguous (MCP-EMPTY-LIST-IS-AMBIGUOUS)

`unity_list_instances` returning `[]` does **not** mean "editor not running". It collapses four
different states with four different fixes:

| State | Tell | Fix |
| --- | --- | --- |
| Editor not running | no `Unity.exe` process | launch it |
| Still importing | process alive, no registry entry, no compile errors | wait |
| **Safe Mode** | process alive, `error CS...` in the editor log | do **not** relaunch the GUI. Safe Mode shows a modal the agent cannot dismiss, and it runs no editor scripts so MCP never starts. Fix the errors on disk, then recover with `-batchmode` (below) or wait for the developer to click through |
| Crashed editor | process gone or ping dead, crash dialog / recovery prompt | do **not** relaunch the GUI editor. See CRASHED-EDITOR-GUI-IS-UNRECOVERABLE-FOR-AGENTS |

Resolve it with facts, never by narrating a guess. Four cheap checks decide it:

1. `tasklist | grep Unity.exe` — is the process alive?
2. `<project>/Temp/UnityLockfile` — is *this* project the one open?
3. `%LOCALAPPDATA%/UnityMCP/instances.json` — is there an entry whose `projectPath` matches, with a
   fresh `lastSeen`? (the plugin heartbeats every **30 s**; treat > 90 s as stale)
4. Query the exposed Unity MCP instance or health tool for the matching project; do not bypass the MCP queue with raw HTTP.

The bridge chooses instance details dynamically; do not treat a fixed port or stale wrapper as a current fact.

## CRASHED-EDITOR-GUI-IS-UNRECOVERABLE-FOR-AGENTS (HARD)

after a crash, a bad compile, or a Safe Mode prompt, **do not
relaunch the interactive Editor and sit on MCP ping.** Unity's GUI always asks — crash
recovery, Safe Mode, script compile failed — and the agent cannot click those dialogs. A
dead ping then looks like "still importing" for fifteen minutes while a modal owns the
process.

- **Do not wait on the bridge after a crash.** MCP needs `[InitializeOnLoad]`; Safe Mode and
  a hung recovery dialog never run it.
- **Do not launch the GUI Editor to recover a cook.** Use a detached cook:
  `-batchmode -quit -silent-crashes -executeMethod <BuildMenu> -logFile <path>`
  (BUILD-AND-TEST-RUNS-ARE-NOT-UPDATE-TICK-WORK). `-silent-crashes` skips the crash dialog.
  Do **not** pass `-nographics` on an HDRP/URP player cook that has to compile shaders.
- **`-ignoreCompilerErrors` is not a Safe Mode skip.** It only lets `-executeMethod` run with
  compile errors. It does not dismiss the GUI prompt.
- **Interactive Editor after a crash is a developer click.** Tell the developer the process died and
  that a modal is waiting if Hub already relaunched it. Leave that instance alone.
- A Hub-relaunched GUI (`-useHub -hubIPC`) sitting at the splash with no MCP port **is**
  that prompt. Killing it without the developer asking discards their recovery choice.

**Do not read editor-log liveness from a `Shut down.` line** — `Logs/Editor.log` is appended across
sessions, so that string is almost always from a *previous* run. The lockfile and the process list
are the liveness signals.

**Re-call before concluding.** The bridge registers late in editor startup; a list taken seconds
earlier is simply out of date. One retry costs nothing and resolves most "discovery is broken"
reports — no MCP-server restart required.

Worth scripting per project — a tower-defense game ships `tools/unity-status.sh`, which prints all four
facts and exits 0/1/2/3 by state.

## MCP-LIVENESS-IS-PER-PROJECT-PID — a machine-wide Unity.exe count proves nothing (HARD)

Decide whether THIS project's editor is alive from the PID the MCP registry records for its
`projectPath` (`tasklist //FI "PID eq <pid>"`), never from `tasklist | grep -c Unity.exe` — with two
other editors open that count is never zero, so a crashed editor reads as "running, bridge not
registered" forever. A `Temp/UnityLockfile` with no live owner PID is a STALE lock from a crash, not
proof the project is open. `Editor.log` `error CS` lines are append-only history: cross-check the
symbol against source (`grep -rn <symbol> Assets/…`) before believing a compile error from the log.
Extends MCP-EMPTY-LIST-IS-AMBIGUOUS. (a tower-defense game: two sessions queued ~2 h for an
editor that had died, and a stale log line nearly became a false bug report against a peer's file;
`tools/unity-status.sh` now checks the project PID and labels stale locks and historical log lines.)

## UPM-RESOLVE-NOT-REFRESH — manifest edits need Client.Resolve, not AssetDatabase.Refresh

`AssetDatabase.Refresh` imports *assets*; it does **not** make UPM re-read an edited
`Packages/manifest.json`. Driving an unfocused editor over the bridge, a newly added package
(registry or `file:`) therefore never resolves, and the failure is deceptive: **the package's own
assemblies simply never appear in `Library/ScriptAssemblies` and produce zero compile errors**,
while every dependent assembly fails `CS0246`/`CS0234` on its namespaces. It looks like a broken
asmdef reference; it is a package that was never compiled.

- After any `manifest.json` edit, run `UnityEditor.PackageManager.Client.Resolve` over the
  bridge, then wait for the recompile. Confirm with `Packages/packages-lock.json` gaining the entry
  and the package's `.dll`s appearing in `Library/ScriptAssemblies`.
- The deciding check when dependents fail CS0246 on a package namespace: `ls
  Library/ScriptAssemblies | grep <package>` — absent assemblies + no errors from the package =
  unresolved manifest, not a reference problem.
- Focus-free compile of *code* changes stays `AssetDatabase.Refresh` +
  `CompilationPipeline.RequestScriptCompilation` — the resolve step is only for package-set
  changes. (Seen in a tower-defense game: , seeding Latios — 216 phantom CS0246s.)

## Force-compile is three things in ONE call

- `AssetDatabase.Refresh` + `CompilationPipeline.RequestScriptCompilation` on an UNFOCUSED
  editor only QUEUES the compile — nothing runs until something pumps the update loop, and
  `isCompiling=false` cannot distinguish "finished" from "never started". Include a window-focus
  step (e.g. an `EditorFocus.Focus` helper) in the SAME `unity_execute_code` call, then poll
  errors. (a card game, developer caught the stall live.)

- **AMENDED: an editor that keeps ticking unfocused needs NO focus step, and stealing focus
  from the developer is banned.** An explicit `AssetDatabase.Refresh` in its OWN exec ran a full
  import + domain reload + compile on an unfocused editor, no focus and no
  `RequestScriptCompilation`. What is focus-gated is the AUTOMATIC refresh (`kAutoRefreshMode=2`
  = refresh on window focus) — that is the setting people generalise into "a manual refresh needs
  focus too", and it does not.
  **AMENDED : keep it ticking, then Refresh, then recompile.** `com.unity.pipeline`
  `set_autotick --enable true` is how an unfocused editor keeps pumping. Bare
  `unity command recompile` after disk writes reports `up_to_date` and compiles nothing until
  `AssetDatabase.Refresh` has run in its own call. Poll `recompile_status` until `completed`;
  prove a post-edit type loaded. Compile is still not a focus job.
  - **The finished-vs-never-started ambiguity is decidable without focus.** The bridge going
    UNREACHABLE ("Editor may be reloading or not running") on the next call is positive proof the
    reload actually started; poll until it answers again, then read the error list. An
    `isCompiling=false` that was never preceded by an unreachable window is the suspicious one.
  - Two calls, never one: Refresh in its own exec, then poll — a reload wipes anything you
    scheduled alongside it (see the refresh/update-schedule rule).
  - Measured under `InteractionMode=2` (Monitor Refresh Rate), `kAutoRefreshMode=2`,
    `kAutoRefresh=0`. **The causal role of `InteractionMode` was NOT isolated** — flipping it would
    have meant editing the developer's editor — so treat those as the configuration it was measured on,
    not as a proven mechanism. EditorPrefs are per-USER, so they do not travel with a project.
  - Focus-stealing remains correct for the things that genuinely do not tick unfocused: Play Mode
    (MCP-PLAYMODE-FOCUS-AFTER-PLAY) and `run_tests` (MCP-TEST-RUNNER-BLOCKS-UNFOCUSED). Compile is
    no longer one of them.

  (a space sandbox game, editor lane, A1: nine files written, Refresh in its own exec, window never
  focused; bridge went unreachable, came back, `unity_get_compilation_errors` = 0 and a reflection
  probe found the new test methods in the built assembly — 14 in `GameplayInputGateTests`, up from
  6. The developer had been focusing the editor by hand until this was measured.)

## OFFLINE-CSC-FROM-CSPROJ — compile Unity assemblies without the editor when the bridge is busy

A build, a domain reload or the developer's own session takes the bridge away for minutes at a time,
and "I will check compile when the editor is back" is how a broken script reaches the developer's next
build. The generated `Assembly-CSharp*.csproj` files carry everything Roslyn needs: `<HintPath>`
references, `<Compile Include>` lists, `<DefineConstants>` and `<LangVersion>`. Feed them to the
SDK's own compiler and you get a real answer in seconds, editor untouched.

- Parse the csproj: HintPaths -> `-r:`, Compile Includes -> sources, DefineConstants ->
  `-define:`, LangVersion -> `-langversion:`; resolve `<ProjectReference>` entries to
  `Library/ScriptAssemblies/<name>.dll`. **HTML-unescape every path** — `Examples &amp; Extras`
  is stored escaped and fails CS2001 otherwise.
- Run `dotnet "<sdk>/Roslyn/bincore/csc.dll" @file.rsp` with `-nologo -noconfig -target:library`
  and a `-nowarn` list for Unity's usual noise (0169, 0649, 0414, 0108, 0618, 0162, 0219, 0168,
  1998, 4014, 0067, 0109). Count `error` lines; ignore the rest.
- Compile `Assembly-CSharp-Editor.csproj` too when a Control Panel tab or any editor script
  changed — the editor assembly references the runtime one from `ScriptAssemblies`, which is why
  the project-reference resolution matters.
- It is a COMPILE check, not a Unity check: serialization, `[SerializeField]` wiring and
  scene/prefab references still need the editor. And a green offline compile does not make the
  editor recompile — force it with refresh + request + focus once the bridge is back
  (MCP-FORCE-COMPILE-NEEDS-FOCUS).

(a mobile card game: eight scripts changed while the developer built a player; 171 + 46 files compiled
clean offline, the editor confirmed 0 errors afterwards.)

- **OFFLINE-BUILD-FILTER-OWN-TREE — `dotnet build <generated>.csproj` is the cheap form, but filter the
  output to YOUR tree.** The vendor/IDE csprojs (Rider, VisualStudio, ShaderGraph, Cinemachine,
  Psyshock, the MCP plugin) cannot resolve NUnit / render-pipeline refs outside Unity and throw
  ~120 errors that are pure noise; read raw and you either drown or learn to ignore errors. Wrap it
  in a project script that prints "errors in our tree" and "vendor noise" separately, exits 1 only
  on ours, and says in its header that green is a pre-check, not a Unity compile (no Burst, no
  source generators, no asmdef defines) — "you own the compile" is still owed. Reads over
  the bridge (`unity_editor_state`, `unity_get_compilation_errors`, `unity_console_log`) are also
  free during a peer's reload hold; only reload-triggering calls need the developer. But do NOT run the
  build while a reload is in flight: it competes for CPU and disk with the domain reload — measured
  19.7 s → 54 s for the same reload. Safe while the bridge is held idle, not during the reload itself.
  (a tower-defense game, auth/services session: `tools/offline-compile.sh`, ~60 s, zero editor
  involvement; contention measured by the head session.)

- **OFFLINE-BUILD-MUST-COMPILE-THE-ASSEMBLY-YOU-CHANGED — a green `dotnet build` of the root csproj proves
  nothing about an assembly MSBuild never reached.** a tower-defense game: `dotnet build Assembly-CSharp.csproj`
  walked its ProjectReference chain into a vendor test project (Latios Psyshock) that failed on NUnit, MSBuild
  stopped there, `MyGame.Sim` (the whole game sim) was never compiled, and the script filtered the vendor
  failure out as "noise" and printed green — proven by appending a deliberate syntax error to a Sim file and
  watching the verdict stay green. Working shape: build EACH of your own csprojs on its own with
  `-p:BuildProjectReferences=false`, seeding `Temp/bin/Debug` (the generated OutputPath) fresh from
  `Library/ScriptAssemblies/*.dll` each run and moving it aside after (a stale seed compiles new code against an
  old API = the next false green); **a build that never compiled your code must never be allowed to say
  "no errors in our code"** — `CS0006` (reference DLL missing) or `MSB3107` (broken ProjectReference) stops
  MSBuild before the compiler runs, so those are a hard UNVERIFIED / exit 2, never "vendor noise" (lane A
  worker); and ship a `--self-test` that injects an error via a throwaway csproj copy and expects it back
  — rewriting ONLY the closing `</Project>` tag, because `<Project>{GUID}</Project>` reference metadata
  lines contain the same text and a blanket `sed` corrupted eleven references. The proof and the
  verification must be the same inputs: an error caught in run 1 says nothing about a clean run 2 made
  against a different seed or a tree a peer was writing.
  Do not run it while the editor is starting — Unity wipes `Temp/` and the seed vanishes under the copy.
  With parallel lanes, a lone syntax error in a file you do not own can be a half-written save (mtime
  within seconds of the build) — re-run once before reporting it to the peer.
  Companion: PROVE-A-GATE-CAN-FAIL. (Found by the a tower-defense game lane B worker.)

- **A NEW FILE IS NOT IN THE CSPROJ, SO THE BUILD COMPILES ZERO OF IT AND STILL SAYS 0 ERRORS.** The
  sibling of the rule above, and it bites the moment you ADD files rather than edit them: `.csproj`
  files are GENERATED BY UNITY from the asmdef, so a file created since the editor last regenerated
  them is simply absent from `<Compile Include=...>`. `dotnet build` then compiles none of your new
  code and reports success — a zero-input false green with no error, no warning and no file list.
  a space sandbox game: six new files across three assemblies (`Runtime/Shared/Admin`,
  `Runtime/Server/Admin`, `Runtime/Client/Admin`); Unity had regenerated only the Shared csproj, so
  Server and Client each reported **0 Error(s)** while compiling **none** of their new files.
  Caught by `grep -c Admin a space sandbox game.Server.csproj` returning **0**.
  Working shape: after adding any file, **assert it appears in the csproj before believing a green**
  (`grep -o 'YourFile\.cs' *.csproj`), and prove the gate on THAT file — inject `CS0103` into the new
  file specifically, not into an old one that was already in the list. The same shape catches a
  zero-input green anywhere: rule-check handed a directory instead of files reports "clean" over zero
  inputs, and a test filter that matches nothing reports all-passing. **A tool that was given nothing
  to do reports success identically to a tool that did the work.** Whenever a gate can receive an
  empty input set, make it say how many inputs it had, and read that number.

## Play-mode probes over the bridge — the editor does no work while your call holds the main thread

- **MCP-PLAYMODE-FOCUS-AFTER-PLAY — an unfocused editor does not tick Play Mode, and entering Play
  DROPS focus.** `Time.frameCount` stays at 1, coroutines never advance, the next scene never loads,
  and every Game View capture is the same frame — which reads exactly like "the game is broken".
  Set `PlayerSettings.runInBackground = true` and verify `runInBackground: 1` reached
  `ProjectSettings.asset` (it does not always persist on `SaveAssets`), take focus AFTER entering
  Play (the domain reload drops it), and trust no runtime read until `frameCount` differs between
  two calls. `EditorApplication.QueuePlayerLoopUpdate` only QUEUES — a loop of it inside one
  `execute_code` advances nothing, because the call itself holds the thread. (a card game.)
- **MCP-READ-IN-THE-NEXT-CALL — mutate in one call, READ in the next.** A layout pass only runs
  between bridge calls, so `resolvedStyle` read in the call that changed `display` is the
  pre-layout value: confident, precise, wrong numbers that send you fixing things that were fine.
  For hover, drive a real `PointerMoveEvent` and confirm `pseudoStates` reports `Hover` before
  reading a colour; a reflection-poked `pseudoStates` (the HOVER-RESET-SPECIFICITY audit in
  the [HTML/CSS mock to UI Toolkit](/workflows/html-css-to-ui-toolkit/) page) is a last resort that must be read back the same way. (a card game.)
- **MCP-TEST-RUNNER-BLOCKS-UNFOCUSED — `run_tests` sits at `blockedReason: editor_unfocused` with
  total 0**, so it does not even look started. Take focus, or pump `QueuePlayerLoopUpdate` +
  `RepaintAllViews` over the bridge (this worked for the EDIT-mode test loop; it does not run Play
  Mode — see the first bullet), then re-poll. (a card game.)
- **PROBE-ANGLE-FROM-NORMALIZED-RELATIVE-ROTATION — never measure a logged rotation step as
  `2*acos(dot(a, b))`.** A float32 unit quaternion has |q|² just under 1, so the raw dot of two
  bit-identical rotations gives 0.04–0.07°. On a 60° / 1200 px view that is a phantom 1 px camera
  shake, and it nearly became the root cause of a horizon-flicker bug. Normalize both, take
  `r = conj(a)·b`, angle = `2·atan2(|r.xyz|, |r.w|)`, and count identical logged strings as zero.
  (.)
- **CONSECUTIVE-FRAME-CAPTURE-DROPS-BLACK-FRAMES — `ScreenCapture.CaptureScreenshot` fired every frame
  from a render callback writes some all-black PNGs** (1 of 8, 2 of 30, 3 of 60 measured, HDRP Editor
  Play). The HUD is black too: that is the tell it is the capture, not the render. Drop frames with
  mean luminance < 1 before diffing. The HUD FPS counter reads ~2 FPS while the per-frame PNG encode
  runs; that is the probe, not the game. (.)
- **UNFOCUSED-EDITOR-CAPTURE-IS-BLACK - `ScreenCapture.CaptureScreenshot` from an `EditorApplication.update`
  driver in an unfocused Editor wrote identical all-black PNGs** (two noon captures, 16 KB each, HDRP Editor
  Play), because the Game View was not presenting frames to the capture. The bridge's Game View screenshot of
  the same session rendered. Take world-only captures through the bridge while the Editor is unfocused, and
  compare size or hash of successive captures before reading any of them. (.)
- **RECORDED-AUDIO-IS-THE-LISTENER-MIX - `AudioRenderer` records after `AudioListener.volume` and the Game
  view's Mute Audio, so an agent film of an unfocused Editor is silent** (peak 0.000 over 76 s, twice). A game
  setting "mute when unfocused" writes `AudioListener.volume = 0` on focus loss, and the agent's Editor is never
  focused. While filming, clear `EditorUtility.audioMasterMute` and hold `AudioListener.volume = 1` every captured
  frame, then restore both; check the WAV peak before building anything on it (0.260 after the fix). (.)

- **LOW-FPS-REPRO-PADS-THE-FRAME - `Application.targetFrameRate` did not slow an unfocused Editor's Play loop, so a
  low-frame-rate bug never reproduced there.** Measured: target 12 with `vSyncCount` 0 left frames at 28 ms mean
  (36 fps) over 95 frames. To reproduce a slow client, e.g. a prediction race where one snapshot spans several
  ticks, sleep once per frame in the probe's `EditorApplication.update` driver (`Thread.Sleep(1000 / fps - 30)`)
  and read the achieved frame time from the samples before trusting the run: 90 ms mean over 30 frames (11.3 fps).
  The pad ends with the driver. (.)

## SEARCH-INDEX-IMPORT-STALLS-PLAY - a Search index rebuild seizes the main thread for minutes

Unity's Search index rebuild runs as an ASSET IMPORT (`UnityEditor.Search` ScriptedImporter over the
whole project) and can hold the Editor main thread for minutes, including in the middle of Play mode.
Every MCP bridge call then times out at 30 s and the editor reads as hung. Before concluding it died:
check the process window title for `Importing assets (busy for mm:ss)`
(`Get-Process Unity | select MainWindowTitle`) and the importer names in `Logs/AssetImportWorker*.log`,
then wait it out. Gameplay code with `Time.realtimeSinceStartup` deadlines expires across the stall, so
any timing measured over it is invalid - rerun the measurement. (a space sandbox game: ~4 min stall
mid-drop.)

## Long editor work is not update-tick work (BUILD-AND-TEST-RUNS-ARE-NOT-UPDATE-TICK-WORK)

**HARD, a space sandbox game — this crashed the editor and lost a 22-minute build.**

An MCP `exec` body does not run "in the editor" generically: the bridge routes it through
`MCPBridgeServer.OnEditorUpdate` -> `MCPRequestQueue.ProcessNextRequests` -> `ExecuteOnMainThread`,
i.e. inside an `EditorApplication.update` callback. `BuildPipeline.BuildPlayer` pumps its own editor
loop, so calling it from there **re-enters the update tick it was called from**. Measured: it ran 22
minutes of HDRP shader work and then took a native fault, killing the process. Managed stack, top
down: `BuildPlayerInternal_Injected` <- `BuildPlayer` <- the project's build menu <-
`MCPDynamicCode:Execute` <- `MCPEditorCommands:ExecuteCode` <- `RouteRequest` <-
`ExecuteOnMainThread` <- `ProcessNextRequests` <- `OnEditorUpdate` <- `Internal_CallUpdateFunctions`.

Same shape, same rule: a mass reimport, a large bake. Anything that pumps the editor loop or blocks
it for minutes. Schedule it and let the bridge call RETURN, then poll a results file or a completion
flag from later calls.

**CORRECTION (a space sandbox game, measured).** This rule originally listed `TestRunnerApi.Execute`
in that group and prescribed `EditorApplication.delayCall` as the way to schedule. Both halves were
wrong, and both were inference dressed as measurement. What was actually measured afterwards:

- **`TestRunnerApi.Execute` does NOT pump the editor loop.** It registers the run and returns; the
  runner then drives it across later editor updates. Called straight from a bridge `exec` the reply
  came back immediately, the editor stayed responsive throughout, and a 148-test EditMode suite
  finished normally. It is not the `BuildPlayer` shape. What IS true is that it is callback-driven,
  so **no caller can get the result inline** — start it, then read a file the callbacks write.
  Otherwise "started the tests" reads exactly like "the tests passed".
- **`EditorApplication.delayCall` is not a dependable scheduler for an agent.** In an UNFOCUSED
  editor it can be starved indefinitely while `EditorApplication.update` keeps running. Probe: a
  `delayCall` writing a marker file did not fire within 15 s; an `update` handler fired instantly.
  Earlier in the same session a `delayCall`-scheduled `AssetDatabase.Refresh` silently never ran
  and the agent read a stale "0 errors" as a green compile.

Schedule on the tick you can PROVE is alive. The bridge itself rides `EditorApplication.update`, so
if a bridge call is answering, `update` is running — use it, with a self-unsubscribing handler, and
the bridge call still returns before the work starts, which is all the re-entrancy rule ever needed:

    EditorApplication.CallbackFunction h = null;
    h =  => { EditorApplication.update -= h; StartTheLongThing; };
    EditorApplication.update += h;

**A refresh and a schedule cannot share one call.** A domain reload clears every `EditorApplication.update`
and `delayCall` subscription, so an `exec` body that calls `AssetDatabase.Refresh` *and* registers a
handler registers it onto a domain that is about to be thrown away — the handler is wiped and the work
silently never runs, with no error anywhere. Measured (a space sandbox game): a refresh + test-run kick in
one call produced no result file at all; the identical kick in a second call, after the reload settled,
ran immediately. Split it: refresh in one call, confirm the reload finished (`isCompiling == false`, and
the AssetDatabase resolves the new state), then schedule in the next. The same applies to anything else
you register from an `exec` that also touches assets.

**The bridge already tells you.** `{"error":"Timed out after 30s waiting for main thread"}` is not an
inconvenience to poll around; it is the bridge saying this call does not belong on this tick. Treat
it as a stop signal and reschedule the work, not as "it detached, watch the filesystem" — that
misreading is what turned a rejected call into a dead editor.

**A crash does not run `finally`.** Any build path that captures and restores state (active Build
Profile and standalone subtarget — see ACTIVE-BUILD-PROFILE-REDEFINES-EDITOR-COMPILE in
the compile assemblies page) leaves that state UNRESTORED when the process dies. After any
editor crash during a build, re-read profile, subtarget, assembly count and a tab count before
trusting the next compile — the failure mode there reports zero errors.

## The Unity CLI and the Pipeline package are the detached path (UNITY-CLI-PIPELINE-IS-THE-DETACHED-PATH)

**HARD.**

Unity's official `unity` CLI (`unity --version`, 1.0.0-beta.8 at time of writing; `unity self-update`)
reaches a *running* editor only through the **Pipeline package** (`com.unity.pipeline`, installed with
`unity pipeline install --project-path <project>`). Without it `unity status` returns
`STATUS_NO_INSTANCES` and `unity command` / `unity job` have no server to talk to. Every new Unity
project installs it in the first session, before the first long editor job.

- **Long jobs go through `unity command <cmd> --detach` and `unity job status|wait|cancel <id>`.**
  That is the crash-safe shape for builds, test runs, mass reimports and bakes: the editor runs the
  command outside any bridge tick and the caller polls the job. Never a blocking MCP exec.
- **`unity build` and `unity test` spawn a second, batch-mode editor.** They cannot open a project
  the interactive editor already holds; use them only when the editor is closed (CI, a cold
  machine). `unity test` adds `--shard n/m`, `--retries`, `--rerun-failed`; `unity build` adds
  `--profile`, `--timeout` and a provenance manifest.
- **`unity close <project>` exits WITHOUT saving.** Save scenes and the project first; never
  `--force` without telling the developer. `unity open <project>` relaunches on the pinned version.
- **The hot loop stays on the MCP bridge.** A bridge call is ~100 ms; a CLI call is ~200 ms of
  process start before it does anything. State, compile errors, console, play/stop, scene: MCP.
  Detached jobs, open/close, editors/modules, `doctor`, `env`, `license`: CLI. Record the measured
  numbers in the project's `docs/agents/UNITY-CLI.md` and cite them when claiming speed.
- `unity skill install claude --local` copies the CLI's agent skill and the package's
  `unity-pipeline` skill under the project's `.claude/skills/` (never user-global: the pipeline
  skill versions with the project's package).
- Never `unity install`, editor version switches or Pipeline version bumps on a product without the
  developer; the pinned version is `ProjectSettings/ProjectVersion.txt`.

## INJECTED-INPUT-RESPECTS-GAME-VIEW-FOCUS - a queued event the game never sees, that reads back as pressed

`InputSystem.settings.editorInputBehaviorInPlayMode` defaults to
`PointersAndKeyboardsRespectGameViewFocus`. With the editor unfocused - the normal state when you
drive it over the bridge - a `QueueStateEvent` for a keyboard or pointer never reaches the dynamic
update the game reads. `wasPressedThisFrame` never fires, the game does nothing, and the run looks
like a product bug in the code under test.

The trap is the read-back. From an editor exec `Keyboard.current.escapeKey.isPressed` returns
**true** - that is a different state buffer from the one the player loop consumed. Asking the device
whether the key is down is not asking what the game saw, so the probe *confirms* an input that was
never delivered. Assert on the game-side effect (`Cursor.lockState`, your own state), never on the
device.

- Set `AllDeviceInputAlwaysGoesToGameView` for the duration and restore it after. The settings
  object usually has **no asset path** (`AssetDatabase.GetAssetPath` returns empty), so this cannot
  dirty the project - check rather than assume, and never edit the pref if it resolves to an asset.
- Focus drifts back between bridge calls. Re-assert it, or read `EditorApplication.isFocused` in the
  same exec as every injection - a pass that worked once fails silently after the next call.
- The first press after entering a new state yields no edge when the key is still held in the
  dynamic buffer. Queue an explicit release, then the press.
- Do NOT call `InputSystem.Update` in the same exec as the queue: it runs an editor update and the
  edge is consumed where the game cannot see it. Queue, return, read in a LATER exec.
- `backgroundBehavior` (default `ResetAndDisableNonBackgroundDevices`) resets devices on focus loss.
  Flipping it to `IgnoreFocus` did NOT rescue an already-desynced session - restart Play instead.
- A press and its release must not land in the same frame or the edge is lost. Queueing the press and
  the release across ONE exec boundary is still too tight: the same key failed twice that way and
  worked immediately once the release moved a full round trip later. Press, read, THEN release.
- The hardware pointer overwrites an injected position every frame, so a `QueueStateEvent` position
  never sticks. Use `Mouse.WarpCursorPosition`. Read back a game-side flag (your pointer-over-UI
  state), never `Mouse.current.position` - the warp lands and the device read stays stale, the same
  split as the key read-back above. That stale value latches per frame, so a probe between two warps
  reports the PREVIOUS point; only the value sampled on the frame the game acted on is evidence.
- Injection can die mid-session and stay dead. Keys that had worked minutes earlier stopped reaching
  the game with the setting still applied, the Game View focused, devices added and enabled, and the
  sim demonstrably ticking. Re-applying the settings, re-focusing, queueing repeatedly and an explicit
  `InputSystem.Update` all failed. Budget for it: land the load-bearing cases FIRST, and report an
  unfinished case as environment rather than spending the window trying to revive the queue.

## BRIDGE-TEST-FILTER-MAY-BE-IGNORED - a filtered run that sweeps the whole suite (HARD)

- **A bridge or MCP test-run tool can accept `filter`, `groupNames` or a full `testNames` entry, report
  success, and run the ENTIRE suite anyway.** Measured: one fully-qualified test name still executed 1912
  EditMode tests in 310 s, twice, on a lease three other agents were queued for; and the job's own
  results call with details requested returned an empty test array, so the sweep could not even say
  whether the named test passed. The tell is the progress `total` exceeding what was asked for - read it
  on the first poll and abandon the run rather than paying for the sweep.
- **Drive the editor's own `TestRunnerApi` from a project-owned entry point instead**: a static method
  building `new Filter { testMode = ..., groupNames = new[] { regex } }`, with `ICallbacks` writing a
  result file the agent reads. One class then runs in about a second instead of five minutes, and the
  file carries the failure list the bridge withheld. Check whether the project already has one before
  writing it; a repo at this size usually does.
- **The Pipeline CLI `run_tests` narrows only by exact full name, and drops test output .**
  `--filter_type` takes `testName`, `assembly` or `category`; `testName` matches whole names only, so a
  `A|B` regex ran 0 tests and still reported success, while a fixture's full name (`Namespace.Fixture`)
  ran exactly its 15 tests in 2.3 s. The result rows carry status, duration and message only:
  `TestContext.WriteLine` output never reaches the caller, and the project's file-writing callbacks
  record failures only. A test that exists to report a number (a timing, a measured envelope) must put
  it where the runner keeps it (the assert message, or a file the test writes), or the number is read
  by re-running the same code from an editor exec.
- **A green offline compile is not a test run.** The same commit compiled all six assemblies clean while
  31 of those 1912 tests failed, four of them from a missing ECS component on the test entity. Compiling
  proves the code binds, nothing more.

- **ANY-IMPORT-ENDS-PLAY  - silently.** An asset import in the shared checkout during Play ends
  the session with no Console line; `isPlaying` reads false and only the bootstrap scene remains, which
  looks like a crash. A long network host load (5-12 min measured) was lost to a peer's refresh. Hold the
  lease for the whole run, write no assets between start and measurement, and poll the loaded scene list
  plus the main camera, not `activeScene` (additive scenes leave it on the menu scene).
- **EDITOR-START-EMPTIES-TEMP .** Launching the Editor clears `<project>/Temp`, deleting a running
  dedicated server's scratch save and logs. Keep scratch saves, server data and evidence outside the
  project folder.
- **BROAD-UI-TEST-SWEEP-CRASHED-THE-EDITOR (cause not isolated).** Twice in one day a broad test
  name regex that pulled in many UI Toolkit tests crashed the Editor natively inside the text-shaping job
  system (HarfBuzz). Run named test classes, never a wide pattern, while a peer's session lives in the same
  Editor; record the crash stack when it recurs.
- **ONE-LIGHTING-SCENE-PER-CAPTURE .** Gallery captures showed two shadow-casting suns: the gallery scene
  loaded additively beside the menu, so both scenes' lights rendered. Load capture scenes with
  `LoadSceneMode.Single` and confirm one scene and one shadow-casting light on a later frame before capturing.
- **EDITOR-WINDOW-CAPTURE-NEEDS-PRINTWINDOW .** Reading screen pixels at an EditorWindow's rect captures
  whatever covers it; on a shared desktop that was the developer's running game clients, private content that had
  to be deleted. Use a PrintWindow-based window capture, which renders the window even when occluded, save
  outside `Assets`, and check layout by `worldBound` rather than pixels.

## LONG-EVAL-GOES-THROUGH-A-FILE  - a recipe passed as an argument dies at the command-line cap

`unity command eval (Get-Content -Raw recipe)` ran a 6.5 KB recipe and failed a 46 KB one with "The filename or
extension is too long": the code travels as one process argument, and Windows caps a command line at 32,767 characters.

- A recipe over a few KB goes through a file: copy it to a `.cs` path and run `unity command eval_file <file.cs>
  <timeoutMs> --project-path <project>`. The body keeps the same method-body form as `eval`.
- The same cap applies to any CLI that takes code or JSON as an argument; pass a file or stdin.

## CAPTURE-A-PULSE-WITH-A-PAUSE-HOOK

- A bridge round trip takes seconds, so a 0.12 s flash or a 0.55 s vignette has decayed before a screenshot lands.
  Arm a Play-only `EditorApplication.update` hook that pauses Play (`EditorApplication.isPaused = true`), or pins the
  effect's age, on the first frame the presented value crosses the target; then capture.
- The hook removes itself on success, on Play exit and at a real-time deadline. A tick-count deadline expires in
  seconds, because editor updates run at hundreds per second. Schedule re-arms by `Time.frameCount`: paused wall time
  counts, so a real-time offset fires again the moment Play resumes.
- A screenshot that reads the Game view writes nothing while Play is paused; a capture that renders the camera (a
  scratch camera into a RenderTexture, or the bridge's camera-render capture) works. Check the game phase in the same
  call that raises a synthetic event: a presentation bus that is inactive outside the playing phase swallows it.
- Refined: for an effect made of short-lived pieces (chained bolts, line renderers), arm the pause as a one-shot
  handler on the event bus's own event (for example the second hop's impact), then `EditorApplication.Step` 1-2
  frames so the renderers spawn, and capture while paused. A paused Scene view does not repaint, so its camera keeps
  a stale pose (the world origin); set `sceneView.camera.transform` directly before a Scene-view capture.

## MCP-SCREENSHOTS-WRITE-INTO-ASSETS

- Bridge screenshot tools can save their PNGs under `Assets/Screenshots`, where they import as assets with `.meta`
  files that ride into version control, and a game capture that renders on the next frame misses a paused frame.
  For evidence, render a temporary `DontSave` camera into a RenderTexture and write the PNG outside `Assets/` (the
  lane's capture folder); move stray tool output out of the repository before any check-in.

## PLAY-TRANSITION-GOES-ALONE

See also: the scene flow page - the app & scene flow hub (bootstrap -> load -> transitions/additive -> runtime lifecycle -> teardown).

- Never send a Play or Stop in the same parallel batch as other bridge calls. The domain reload evicts queued
  tickets: the others return "Ticket not found" or time out at 120 s, and you cannot tell whether they ran. Send the
  transition alone, read the editor state (`isPlaying`, a fresh `Time.time`), then send the next call. After a lost
  ticket, verify the effect on disk or with a read; never blindly retry a write.
- **A timed-out long exec is still running** (amends BUILD-AND-TEST-RUNS-ARE-NOT-UPDATE-TICK-WORK above). "Queue
  polling timed out after 120000ms" on a long bake ends the ticket, not the work. Issue one long job per call, never
  re-issue it, wait, then read the result state (counts, a receipt file) with a cheap call. A player build started
  over the bridge writes its own receipt file (result, error and warning counts, duration, output path, settings it
  restored): a first Android build with a full reimport ran 66 minutes, far past any call timeout.
- Refined: a call sent alone right after Stop also queues behind the domain reload. The Editor shows Not
  Responding, the bridge lists 0 instances and the call "times out" after 120 s, yet the ticket still runs about 3
  minutes later. Keep 60 s of bridge silence after Stop. Before resending, check the call's side effects on disk (a
  file it writes, an asset's modified time) and the bridge's queue info; a resend runs the work twice.
- Refined: after a timed-out multi-step write (a long bake returned "Queue polling timed out after 120000ms"
  while the Editor kept working), read the state first: assets on disk, scene object counts, whether the scene is
  dirty. Then run only the steps that did not happen. Lease scripts write their report file after each stage, not
  only at the end, so the report itself shows how far the timed-out call got.

## EXEC-SNIPPET-LIMITS

- An `execute_code` body is a method body and cannot declare types. Keep probe state in closures and
  `AppDomain.CurrentDomain.SetData` / `GetData`, and drive per-frame work from one `PlayerLoopSystem` entry whose
  `type` is a unique marker type, so it can be found and removed cleanly (or a self-removing
  `EditorApplication.update` handler).
- Common compile snags: `Object` is ambiguous between `UnityEngine.Object` and `System.Object` (write
  `UnityEngine.Object.FindFirstObjectByType<T>`); UITK `Query` from a snippet needs `UQueryExtensions.Query<T>(root)`.
  `System.Func<>` lambdas and string concatenation work.
- Long bodies run from a file (LONG-EVAL-GOES-THROUGH-A-FILE): over the bridge, call the plugin's own execute entry
  with `File.ReadAllText(path)` as the code rather than pasting an 18 KB body.

## ROUTINE-MODALS-THAT-STALL-THE-BRIDGE

- Amends the `#32770` bullet in "Compile-check + modal caveats" above with two more modals from routine work:
  - "Scene(s) Have Been Modified" is a Win32 dialog whose buttons are child windows (`EnumChildWindows`); `BM_CLICK` on
    "Don't Save" clears it without killing the Editor, when the dirt is not yours to keep.
  - A VFX Graph compile progress window right after leaving Play times out bridge calls. It is not a hang: poll the
    window list in the background until it clears, and do not re-ping the bridge meanwhile.

## PROBE-HOOK-OUTLIVES-ITS-CALL

- Amends EXEC-SNIPPET-LIMITS above. An `EditorApplication.update` delegate installed by an `execute_code` call, and kept
  in `AppDomain` data so a later call can find it, outlives that call and keeps running for the whole Play session,
  sampling and writing into every later lease step.
- Remove it explicitly in the call that ends the probe, and make the delegate remove itself when
  `!EditorApplication.isPlaying` (plus a real-time deadline). Before a lease is released, check that no probe
  delegate is still registered.

## OFFLINE-BUILD-LEAVES-MSBUILD-NODES

- Amends OFFLINE-CSC-FROM-CSPROJ above. Every `dotnet build` pre-check leaves idle MSBuild worker nodes
  (`/nodeReuse:true` is the default) and the VBCSCompiler server alive. With several agents running the offline compile
  all day, one PC measured 15 idle nodes at about 130 MB each (about 2 GB), which pushed a 32 GB machine down to
  1.2 GB free during an IL2CPP player build.
- Pass `-nodeReuse:false -p:UseSharedCompilation=false` to the pre-check build so nothing outlives it.
  `dotnet build-server shutdown` recovers an affected machine at once (it freed 1.3 GB).
- Gate: before a device or player build, count the `dotnet.exe` processes running `MSBuild.dll`; the count should be 0.

## TRANSIENT-ASSET-RESTORE-UNLOADS-AND-PROVES

- An agent changed a loaded ScriptableObject in memory for a probe: an array field set by reflection, never saved. It
  then "restored" the asset with `AssetDatabase.ImportAsset(path, ImportAssetOptions.ForceUpdate)`. Because the file was
  unchanged, the reimport kept the loaded instance, and the edit survived: 2 entries instead of 1. The disk hash was
  unchanged and the object was not dirty, so file-level checks passed.
- Restore with `Resources.UnloadAsset(obj)` followed by `AssetDatabase.LoadAssetAtPath` (Editor code only). Then prove
  the reloaded object's own state (for example the array length), plus an unchanged file hash and
  `EditorUtility.IsDirty(obj) == false`.
- Better still, run the probe on a `ScriptableObject.Instantiate` copy so the loaded asset is never touched.

## VARIANT-SWEEP-IS-ONE-SEQUENCER-PER-PLAY

- Specializes the "one frame-gated EditorApplication.update handler" recipe above for sweeps. Driving many variants
  (attacks, loadouts, presets) by hand took 100+ bridge calls, one each per restart, wait, read and capture, and ran
  past the lease.
- Submit one `EditorApplication.update` sequencer per Play session. For each variant it sets the transient input,
  clears the log, restarts the run, observes for N seconds, then writes a PNG. The PNG renders the gameplay camera into
  a RenderTexture a fixed number of frames after the variant's first observable event. It then appends one summary
  line to `AppDomain.CurrentDomain` data, which a single later call reads back.
- The sequencer unhooks itself on Play exit and at a real-time deadline. Transient inputs are memory-only and restored
  when it finishes.

## READSCREENPIXEL-TAKES-POINTS

- Amends EDITOR-WINDOW-CAPTURE-NEEDS-PRINTWINDOW above, which stays the first choice: `ReadScreenPixel` returns
  whatever is on screen at the rect, including other windows covering it. Use it only as a fallback, for example when
  the bridge's editor-window screenshot tool is missing ("Unknown API endpoint"), and only with the window focused and
  nothing covering it.
- `InternalEditorUtility.ReadScreenPixel(position, width, height)` takes logical points. Scaling `window.position` by
  `EditorGUIUtility.pixelsPerPoint` on a scaled display shifted the origin, and the capture came out offset and half
  black. Pass `window.position` and its size unscaled; the result has 1 sample per point, so it is lower resolution
  than the physical screen on a scaled display.

## CLI-STATUS-USES-REPORTED-PROJECT-PATH

Unity CLI status filtering can differ from command targeting. On Windows in CLI 1.0.0-beta.12,
a three-second `status --until-ready --project-path` wait with forward slashes returned zero
instances; the same ready Editor with its inventory-reported backslash path returned immediately.
Use the exact `project` spelling reported by unfiltered `unity status`, or a native normalized
absolute path. Give readiness waits an explicit finite `--timeout` in seconds.

A false-empty path match is not evidence of a dead bridge or a frozen player loop. Independently
read the actual frame/Play state and fresh Console before recovery; verify Play stopped and every
owned watchdog exited before releasing a workload lease. Preserve diagnostic evidence.
