Topic 4 · Verify & test
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
- Package — UPM git (prefer
#mainunless the project owns Rimjob probes):json "com.anklebreaker.unity-mcp": "https://github.com/AnkleBreaker-Studio/unity-mcp-plugin.git#main"Or embed/copy underPackages/com.anklebreaker.unity-mcpand"file:com.anklebreaker.unity-mcp". - 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"] } } } - Editor must be open with the bridge running; multi-instance → pick with
unity_list_instances/unity_select_instancebefore 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.timeScalecovers 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 Pipelinerecompile_status) yourself until clean, or report a tool-backed blocker. Unfocused + Auto Refresh off is the pre-edit project until you Refresh then recompile. Withcom.unity.pipelineinstalled:set_autotick --enable true, Refresh in its own eval,unity command recompile, pollrecompile_status.up_to_datebefore Refresh is not a compile. Full procedure: the agent compile page. console/log: leaveincludeStackTracefalse unless diagnosing a specific stack (stacks are ~800 tokens each). Preferfilterregex + smallcount; response may includetruncated/matchedTotal— re-query with highercountwhen needed.- Play-mode structural / write ops on
dots/*reject unlessforce: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 - Headerrepeat under every panel of a menu scene, so aFindObjectsByTypescan 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 walkingforeach (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 setContainer - Store/Image - Dimto 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 /GetRootGameObjectscrawls 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_errorscan reportcount: 0, isCompiling: falsewhile the console holds realerror CSentries (script-updater / Bee edge after an asmdef or namespace change). After any refresh that should have compiled, cross-checkunity_console_logfiltered onerror CSbefore 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'tunity_scene_openover 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_listanswer while the queue is blocked andunity_editor_dialog_pressanswers the dialog — see UNITY-DRAWN-MODAL-IS-NOT-A-WIN32-DIALOG below for the kind a dialog scan misses. - Two
#32770modals come from routine work, not edits . A relaunch after a crash asks to copyTemp/__BackupscenesintoAssets/_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 sendBM_CLICKto 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
UnityContainerWndClasswindows, the main window and every docked/floating window are disabled (IsWindowEnabledfalse) 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; neverSendMessage, 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 buttonclose. scene/openduring Play must useEditorSceneManager.LoadSceneInPlayMode(orSceneManager.LoadScenefor a Build Settings scene).EditorSceneManager.OpenScenethrows "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.jsoncarries a_fingerprintfieldEditor/csc.rspexists 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_instancesbefore 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, aPlayerSettingswrite — 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-listreturns rawghostTypeindices — map names viaGhostCollection/ghost-prefab-info, not by guessing.- No per-ghost predicted-vs-authoritative delta probe yet — use
prediction-state+entity-infoon both worlds when diagnosing jank. - Plain
IJob/IJobParallelForBurst time shows inglobalBurstJobsMsbut often not in per-systemburstJobsMs(only EntitiesIJobChunk/IJobEntityattribute cleanly). - MCP
execute_codethat+=ontoEditorApplication.updateoften 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.SaveSceneserialises 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.asseta 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 areHideFlags.DontSaveor 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_instancesreturns 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:
tasklist | grep Unity.exe— is the process alive?<project>/Temp/UnityLockfile— is this project the one open?%LOCALAPPDATA%/UnityMCP/instances.json— is there an entry whoseprojectPathmatches, with a freshlastSeen? (the plugin heartbeats every 30 s; treat > 90 s as stale)- 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-crashesskips the crash dialog. Do not pass-nographicson an HDRP/URP player cook that has to compile shaders. -ignoreCompilerErrorsis not a Safe Mode skip. It only lets-executeMethodrun 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.jsonedit, runUnityEditor.PackageManager.Client.Resolveover the bridge, then wait for the recompile. Confirm withPackages/packages-lock.jsongaining the entry and the package's.dlls appearing inLibrary/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.RequestScriptCompilationon an UNFOCUSED editor only QUEUES the compile — nothing runs until something pumps the update loop, andisCompiling=falsecannot distinguish "finished" from "never started". Include a window-focus step (e.g. anEditorFocus.Focushelper) in the SAMEunity_execute_codecall, 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.Refreshin its OWN exec ran a full import + domain reload + compile on an unfocused editor, no focus and noRequestScriptCompilation. 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.pipelineset_autotick --enable trueis how an unfocused editor keeps pumping. Bareunity command recompileafter disk writes reportsup_to_dateand compiles nothing untilAssetDatabase.Refreshhas run in its own call. Pollrecompile_statusuntilcompleted; 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=falsethat 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 ofInteractionModewas 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 toLibrary/ScriptAssemblies/<name>.dll. HTML-unescape every path —Examples & Extrasis stored escaped and fails CS2001 otherwise. - Run
dotnet "<sdk>/Roslyn/bincore/csc.dll" @file.rspwith-nologo -noconfig -target:libraryand a-nowarnlist for Unity's usual noise (0169, 0649, 0414, 0108, 0618, 0162, 0219, 0168, 1998, 4014, 0067, 0109). Counterrorlines; ignore the rest. - Compile
Assembly-CSharp-Editor.csprojtoo when a Control Panel tab or any editor script changed — the editor assembly references the runtime one fromScriptAssemblies, 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>.csprojis 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 buildof the root csproj proves nothing about an assembly MSBuild never reached. a tower-defense game:dotnet build Assembly-CSharp.csprojwalked 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, seedingTemp/bin/Debug(the generated OutputPath) fresh fromLibrary/ScriptAssemblies/*.dlleach 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) orMSB3107(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-testthat 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 blanketsedcorrupted 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 wipesTemp/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:
.csprojfiles are GENERATED BY UNITY from the asmdef, so a file created since the editor last regenerated them is simply absent from<Compile Include=...>.dotnet buildthen 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 bygrep -c Admin a space sandbox game.Server.csprojreturning 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 — injectCS0103into 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.frameCountstays 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". SetPlayerSettings.runInBackground = trueand verifyrunInBackground: 1reachedProjectSettings.asset(it does not always persist onSaveAssets), take focus AFTER entering Play (the domain reload drops it), and trust no runtime read untilframeCountdiffers between two calls.EditorApplication.QueuePlayerLoopUpdateonly QUEUES — a loop of it inside oneexecute_codeadvances 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
resolvedStyleread in the call that changeddisplayis the pre-layout value: confident, precise, wrong numbers that send you fixing things that were fine. For hover, drive a realPointerMoveEventand confirmpseudoStatesreportsHoverbefore reading a colour; a reflection-pokedpseudoStates(the HOVER-RESET-SPECIFICITY audit in the HTML/CSS mock 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_testssits atblockedReason: editor_unfocusedwith total 0, so it does not even look started. Take focus, or pumpQueuePlayerLoopUpdate+RepaintAllViewsover 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, taker = conj(a)·b, angle =2·atan2(|r.xyz|, |r.w|), and count identical logged strings as zero. (.) - CONSECUTIVE-FRAME-CAPTURE-DROPS-BLACK-FRAMES —
ScreenCapture.CaptureScreenshotfired 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.CaptureScreenshotfrom anEditorApplication.updatedriver 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 -
AudioRendererrecords afterAudioListener.volumeand 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" writesAudioListener.volume = 0on focus loss, and the agent's Editor is never focused. While filming, clearEditorUtility.audioMasterMuteand holdAudioListener.volume = 1every 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.targetFrameRatedid not slow an unfocused Editor's Play loop, so a low-frame-rate bug never reproduced there. Measured: target 12 withvSyncCount0 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'sEditorApplication.updatedriver (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.Executedoes NOT pump the editor loop. It registers the run and returns; the runner then drives it across later editor updates. Called straight from a bridgeexecthe reply came back immediately, the editor stayed responsive throughout, and a 148-test EditMode suite finished normally. It is not theBuildPlayershape. 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.delayCallis not a dependable scheduler for an agent. In an UNFOCUSED editor it can be starved indefinitely whileEditorApplication.updatekeeps running. Probe: adelayCallwriting a marker file did not fire within 15 s; anupdatehandler fired instantly. Earlier in the same session adelayCall-scheduledAssetDatabase.Refreshsilently 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> --detachandunity 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 buildandunity testspawn 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 testadds--shard n/m,--retries,--rerun-failed;unity buildadds--profile,--timeoutand a provenance manifest.unity close <project>exits WITHOUT saving. Save scenes and the project first; never--forcewithout 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'sdocs/agents/UNITY-CLI.mdand cite them when claiming speed. unity skill install claude --localcopies the CLI's agent skill and the package'sunity-pipelineskill 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 isProjectSettings/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
AllDeviceInputAlwaysGoesToGameViewfor the duration and restore it after. The settings object usually has no asset path (AssetDatabase.GetAssetPathreturns 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.isFocusedin 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.Updatein 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(defaultResetAndDisableNonBackgroundDevices) resets devices on focus loss. Flipping it toIgnoreFocusdid 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
QueueStateEventposition never sticks. UseMouse.WarpCursorPosition. Read back a game-side flag (your pointer-over-UI state), neverMouse.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.Updateall 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,groupNamesor a fulltestNamesentry, 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 progresstotalexceeding 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
TestRunnerApifrom a project-owned entry point instead: a static method buildingnew Filter { testMode = ..., groupNames = new[] { regex } }, withICallbackswriting 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_testsnarrows only by exact full name, and drops test output .--filter_typetakestestName,assemblyorcategory;testNamematches whole names only, so aA|Bregex 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.WriteLineoutput 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;
isPlayingreads 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, notactiveScene(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.Singleand 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 byworldBoundrather 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
.cspath and rununity command eval_file <file.cs> <timeoutMs> --project-path <project>. The body keeps the same method-body form aseval. - 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.updatehook 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.Step1-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); setsceneView.camera.transformdirectly 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.metafiles that ride into version control, and a game capture that renders on the next frame misses a paused frame. For evidence, render a temporaryDontSavecamera into a RenderTexture and write the PNG outsideAssets/(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 freshTime.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_codebody is a method body and cannot declare types. Keep probe state in closures andAppDomain.CurrentDomain.SetData/GetData, and drive per-frame work from onePlayerLoopSystementry whosetypeis a unique marker type, so it can be found and removed cleanly (or a self-removingEditorApplication.updatehandler). - Common compile snags:
Objectis ambiguous betweenUnityEngine.ObjectandSystem.Object(writeUnityEngine.Object.FindFirstObjectByType<T>); UITKQueryfrom a snippet needsUQueryExtensions.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
#32770bullet 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_CLICKon "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.updatedelegate installed by anexecute_codecall, and kept inAppDomaindata 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 buildpre-check leaves idle MSBuild worker nodes (/nodeReuse:trueis 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=falseto the pre-check build so nothing outlives it.dotnet build-server shutdownrecovers an affected machine at once (it freed 1.3 GB). - Gate: before a device or player build, count the
dotnet.exeprocesses runningMSBuild.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 byAssetDatabase.LoadAssetAtPath(Editor code only). Then prove the reloaded object's own state (for example the array length), plus an unchanged file hash andEditorUtility.IsDirty(obj) == false. - Better still, run the probe on a
ScriptableObject.Instantiatecopy 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.updatesequencer 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 toAppDomain.CurrentDomaindata, 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:
ReadScreenPixelreturns 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. Scalingwindow.positionbyEditorGUIUtility.pixelsPerPointon a scaled display shifted the origin, and the capture came out offset and half black. Passwindow.positionand 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.