Tier 2 · Compilation and verification

Agent compile check

The agent proves its code compiles before it claims a change is done.

The agent proves its code compiles before it claims a change is done.

Never ask the user to compile, refresh, or check the Console for script errors. After any C# / asmdef / package / define change that can affect compile, you drive compile and read the result via tools. Handing “please compile in Unity and paste errors” is a rule violation unless the Editor/MCP is unreachable (see Exceptions).

When you must compile

Trigger a self-owned compile check after you:

  • Create, edit, move, or delete .cs / .asmdef / .asmref
  • Change Packages/manifest.json, packages-lock, or project scripting defines
  • Add/remove assembly references that other code depends on
  • Finish a multi-file slice and claim the work “done” / ready for play smoke

Skip a full cycle only for pure docs, comments, or non-code assets that cannot affect ScriptAssemblies.

How (Unity projects with MCP)

Use unity_* MCP tools only — never raw HTTP to the bridge. Multi-instance: unity_list_instances → unity_select_instance first.

  1. Refresh / request compile (pick what the session supports): - Prefer forcing asset DB refresh + script compile via unity_execute_code when available, e.g.: csharp UnityEditor.AssetDatabase.Refresh; UnityEditor.Compilation.CompilationPipeline.RequestScriptCompilation; - Unfocused Editor (HARD, UNFOCUSED-COMPILE-IS-A-REQUESTED-TICK): Auto Refresh off plus an unfocused window keeps serving the pre-edit project. isCompiling: false and a 0-error query are the last cycle until you request one. Do not steal focus. Keep the editor ticking, then Refresh, then recompile, then poll: unity command set_autotick --enable true # AssetDatabase.Refresh in its OWN eval/exec — a reload wipes a combined call unity command recompile unity command recompile_status # until completed; up_to_date BEFORE Refresh is not a compile Pipeline recompile with no preceding Refresh reports up_to_date and skips the import. Prove the new type loaded (Type.GetType / assembly name), not file mtime. MCP equivalent: Refresh in its own exec, then RequestScriptCompilation, then poll until the bridge goes unreachable (reload started) and returns. See MCP-FORCE-COMPILE-NEEDS-FOCUS in the Live play testing via Unity MCP page and AUTO-REFRESH-OFF below.
  2. Wait for idle — poll unity_editor_state until compilation is finished (do not assume zero-latency).
  3. Read errors from the pipeline, not the user — unity_get_compilation_errors with severity: "error" (use "all" only if diagnosing noise). Prefer this over unity_console_log for script failures (console can be cleared / flooded).
  4. Fix and re-run — stay in the loop until errors from your change set are gone, or you have a documented blocker (missing package, Latios parked, Editor not open).
  5. Report — in the agent reply, state compile outcome briefly: clean, N errors fixed, or blocked + why. Do not dump full logs unless needed.

Never ask the user to click Unity — take the foreground yourself (HARD)

"Please focus the Editor so it refreshes" is the same rule violation as "please compile". Unity only runs an asset refresh when its window is activated, so a background Editor sits on stale files indefinitely — but you can activate it yourself. Do not wait on a human click.

Windows blocks a naive focus call, and one of the failures lies to you:

  • SetForegroundWindow alone — no effect, focus does not move.
  • WScript.Shell.AppActivate($pid) — returns True while focus never moves. Never treat its return value as evidence.

What actually works — clear the foreground lock timeout, then attach to the current foreground thread's input queue so the OS treats the call as user-initiated:

# 0x2001 = SPI_SETFOREGROUNDLOCKTIMEOUT -> 0
[FG]::SystemParametersInfo(0x2001, 0, [IntPtr]::Zero, 3)
$fgThread = GetWindowThreadProcessId(GetForegroundWindow)   # thread that owns focus now
[FG]::AttachThreadInput($myThread, $fgThread, $true)
[FG]::ShowWindow($unity, 9); [FG]::BringWindowToTop($unity); [FG]::SetForegroundWindow($unity)
[FG]::AttachThreadInput($myThread, $fgThread, $false)

Verify by reading GetForegroundWindow back and comparing the PID — never by the return value of the focus call. The return value lies in BOTH directions: AppActivate says True while focus never moves, and an in-process SetForegroundWindow sequence reported false while the compile it wrapped ran anyway (the compile was requested by RequestScriptCompilation before the focus step, so the flag described the window, not the work — never return a focus bool from a "force compile" helper). Verified cause of the false: the same in-editor Focus called on its own returned true twice, so a focus grab issued right after RequestScriptCompilation lands while the editor is already tearing down for the reload — order it BEFORE the compile request, or focus from outside. An external PowerShell process (tools/focus-unity.ps1 shape: registry PID, else a Win32_Process scan filtered to the windowed Unity.exe — AssetImportWorkers share the -projectPath) took the foreground reliably and is the only path in the bootstrap case, where no bridge exists. (a tower-defense game.) Confirm the refresh actually started (Editor.log grows / Library/PackageCache hash changes), because focus succeeding does not prove the import ran.

Bootstrap case — the one the bridge cannot solve. When the MCP plugin is not yet imported, or its own assembly failed to compile, there is no unity_execute_code to call and no bridge port. Force-focus is then the ONLY way to make Unity import the plugin. Ordering:

write files -> force focus -> poll for the bridge port -> use unity_execute_code for every later cycle.

A requested compile drops the bridge — that is success, not failure. CompilationPipeline.RequestScriptCompilation triggers a domain reload, so the in-flight MCP call usually returns a transport error (Error polling queue: fetch failed). Expected. Poll until the port answers again, then read unity_editor_state + unity_get_compilation_errors. Do not retry the compile request on that error — you would loop reloading the domain.

Once the plugin is live, prefer the in-editor path and keep focus-stealing rare — it interrupts whatever the user is doing.

Edit source as bytes, not lines (Windows)

sed -i in Git-Bash rewrites the whole file in text mode: a CRLF file comes back LF-only, so a one-line fix lands as an every-line diff in Plastic/git and buries the real change. grep -c $'\r' does not catch it — the shell can eat the pattern and match every line in both files, reporting identical bogus counts.

Use a binary read/replace/write (open(p,'rb') -> bytes.replace -> open(p,'wb')) for source edits, and prove it with a byte census: size delta must equal exactly the characters you intended, with CRLF/LF/BOM counts unchanged. Quick gate before git add: file <path> must still say "CRLF line terminators"; a sed-edited C# source reads plain "ASCII text" (added).

Git plumbing from Python on Windows: BINARY stdin, then assert the tree changed (HARD)

subprocess.run(..., text=True) translates the stdin payload's \n to \r\n on Windows, so every path fed to git update-index --add --remove --stdin arrives CR-terminated, matches nothing on disk or in the index, and is silently ignored — exit 0, write-tree returns the parent's tree, commit-tree + update-ref happily create an EMPTY commit whose message claims a change that is not in it. Nothing errors anywhere; only reading the commit back exposes it. (a mobile strategy game: a 3,700-file IP-purge "landed" twice as empty shells before a git show --stat caught it.)

  • Feed git plumbing bytes: input=('\n'.join(paths) + '\n').encode with NO text=True on that call (or use -z + NUL separators).
  • Never update-ref before proving the tree moved: assert write-tree != rev-parse HEAD^{tree} AND git diff-tree -r count is in the expected order of magnitude. A surgical-commit script without these two asserts will eventually ship an empty commit with a confident message.
  • Same family as cm ci above and AudioImporter.threeD: trust a READ-BACK, never the write's exit code.

Plastic: cm ci with no paths silently skips "Changed" files (HARD)

Plastic separates Changed (modified on disk) from Checked-out and Added. cm ci with no path argument commits the Added + Checked-out set and leaves plain Changed files behind, while still printing a cheerful Created changeset cs:N. The per-file list it prints is the truth; the changeset line is not.

Measured (a tic-tac-toe game): a checkin reported cs:184 created, and two .cs files carrying the entire slice were still pending afterwards. Pushing that to another machine would have shipped the docs without the code.

  • Commit by explicit path when files are merely Changed: cm ci "<path>" "<path>" -c "..." (Plastic auto-checkouts them).
  • Always re-run cm status after a checkin and require it to be empty. Never report "committed" off the Created changeset line alone — diff the printed per-file list against what you intended.
  • Same shape as any partial-write trap: the tool succeeded, just not at what you asked.

Cost discipline (pairs with context-efficiency.md)

  • One refresh → wait → one error fetch; not thrashing.
  • Don’t re-fetch full console with stack traces by default.
  • Don’t ask the user to “confirm it compiles” after a clean unity_get_compilation_errors.

Non-Unity / no Editor

  • Prefer the project’s normal CLI (dotnet build, solution build, package test) when Unity is not in play.
  • If neither Unity MCP nor a CLI build is available, say so once and list the exact command the user would run — still try MCP/CLI first.

Exceptions (only)

  • Unity Editor not open / MCP bridge down / wrong instance and you cannot connect after unity_list_instances and force-focus (above) failed to bring it back. A merely unfocused Editor is NOT an exception — focus it yourself.
  • Compile blocked by a known external state the agent must not fix alone (e.g. L0-owned Latios parked, license, network package resolve) — still run the check, paste the blocker from tools, and stop.

Anti-patterns

  • “Compile in the Editor and tell me what errors you get.”
  • Shipping a code slice as done without a tool-backed compile read when Editor is available.
  • Ignoring unity_get_compilation_errors and relying only on “files look right.”
  • Asking the user to click/focus the Unity window — compile is not a focus job; keep it ticking and request Refresh+recompile. Report blocked only if the editor is unreachable.
  • Treating unity_editor_state isCompiling: false or recompile up_to_date as a compile you requested, when you never Refresh'd.
  • Treating AppActivate returning True, or a domain-reload transport error, as proof of anything.

  • PORT-CLOSURE-FIRST (HARD) — a bulk file port lands with its complete reference closure in ONE batch. Before writing ported files into a live Unity project: grep the sources for type refs, statics, and [UpdateAfter(typeof(...))] targets; diff against the destination; port or API-identical-stub every missing symbol in the same drop (netcode usings stripped, inline [GhostField] attrs stripped — they share the field's line). Iterating console errors instead costs ~90 s per pass with a broken project on the developer's screen. And never gate waits on ScriptAssemblies dll mtimes (compile can finish before the marker is taken → infinite loop that looks like a hung agent) — poll the compilation-pipeline error API instead.

EXECUTEMENUITEM-SILENTLY-NOOPS — verify with the CLI, not with a menu item

EditorApplication.ExecuteMenuItem(...) returns true while doing nothing on an unfocused editor, and for any item whose handler defers to an interactive importer. Observed twice in one session on 6000.5.0f1: Window → TextMeshPro → Import TMP Essential Resources returned true and imported no files, and a [MenuItem] self-test returned true and logged nothing. Its return value is worth exactly as much as AppActivate's — see AGENT-COMPILE-FORCE-FOCUS above.

For anything you intend to VERIFY, drive it headlessly instead. The CLI gives a real exit code, a log you can grep, and needs no focus at all:

Unity.exe -projectPath <proj> -batchmode -nographics -quit \
  -executeMethod Namespace.Class.StaticMethod -logFile <log>

Grep the log for the method's own output, not just the exit code — a batchmode run exits 0 whether your method logged 92 passes or was never reached. Confirm the invocation itself appears in the log.

The GUI editor holds the project lock, so close it first (EditorApplication.Exit(0) over the bridge — the in-flight MCP call will fail with a transport error, which is the process dying, not a failure) and relaunch after. Prefer this shape for any assertion you plan to report as evidence; keep the focused-GUI path for visual work, where you need frames flowing anyway.

EDITOR-MODAL-FROM-INSIDE-A-BUILD-DEADLOCKS — and it looks exactly like a crashed bridge

A vendor hook that opens EditorUtility.DisplayDialog from inside BuildPipeline.BuildPlayer deadlocks the editor: the main thread is blocked in the build, so the dialog it raised cannot pump its own input. Clicking the dialog does nothing — the thread that would process the click is the one waiting on it. Measured symptom set, all at once:

  • the MCP/automation bridge stops answering while its TCP port still listens (it runs on the editor update loop, which no longer ticks),
  • the editor log stops dead — 20 minutes of no output is the tell,
  • the Gradle java process sits idle at low CPU,
  • a "Building Player (busy for NN:NN)" progress window that never advances.

The bridge going quiet reads as "Unity crashed". It has not. Enumerate the process's visible windows before concluding anything — EnumWindows filtered by PID names the modal outright:

45980 | Building Player (busy for 16:51)...
45980 | Enable Android Auto-resolution?      <-- the deadlock

Fix the cause in settings so the prompt cannot appear, and run any build you intend to REPORT through -batchmode -executeMethod (EXECUTEMENUITEM-SILENTLY-NOOPS), where no dialog can block. Dismissing the modal is not a fix: a vendor that raises it once raises it every build, and dismissing without answering leaves the underlying setting unchanged.

Second known trigger, Unity's own (a mobile card game): "Can not sign the application" — an Android build with a custom keystore configured and no password in the editor session. The password is session state, not project state, so a fresh editor + a scripted build reliably hits it, and the log's last line is the Android SDK/NDK banner, which reads as "build in progress". Gate on PlayerSettings.Android.keystorePass / keyaliasPass being non-empty and abort if not. Do not flip useCustomKeystore off as a workaround — the build pipeline saves PlayerSettings mid-build, so the flip lands in ProjectSettings.asset before your finally restores it. Ask the developer to type the password into Publishing Settings; never handle it yourself.

Amendment (v286, EDITOR-MODAL-LOOKS-LIKE-A-HANG): the same shape outside a build is NOT a deadlock and must not be killed. one shipped project: after a Play stop the bridge timed out, the editor log froze 14 min, CPU sat at 0 and the registry heartbeat went stale - every symptom of the documented kill + lockfile + relaunch hang - but Process.Responding was True and EnumWindows by pid listed a native modal ("Save changes made in Play Mode", Cinemachine SaveDuringPlay, buttons Keep / Don't Keep). One click resumed the editor; the kill would have taken three lanes down. Before any relaunch recipe: enumerate the process's visible top-level windows and read Responding; a second window = a modal, answer it (play-mode changes: Don't Keep), never kill.

SYNTHESIZED-CLICKS-MISS-UNDER-DPI-SCALING — capture and click must share DPI awareness

SetCursorPos and mouse_event take physical pixels, but a DPI-unaware process has its coordinates rescaled by Windows, and Screen.PrimaryScreen.Bounds reports logical pixels there. On a 3440x1440 display at 125%, a screenshot came back 2752x1152 and every coordinate read off it was 1.25x short — the clicks landed in empty space, and "the dialog is still up" read as "the click was rejected" rather than "the click missed".

Call SetProcessDPIAware once, in the same process that both captures and clicks, then check the numbers agree: GetSystemMetrics(0/1) against the captured bitmap's size. If they differ, every coordinate is scaled and nothing you click is where you think. Verify a click by re-capturing and looking, never by whether the target window is still present — a window that re-raises itself is indistinguishable from one that ignored you.

  • BOOTSTRAP-ENTRY-APPLY-PLUS-RUN. Every bootstrap / setup entry point is two methods: Apply (a MenuItem, no exit) for the editor, and Run (Apply then EditorApplication.Exit) for -executeMethod. One method that exits kills the interactive editor; one that does not hangs batchmode. (a card game.)

Synthetic pointer input leaves device state behind (a tower-defense game)

  • A queued mouse press with a dropped release sticks the editor Mouse device on pressed, across play sessions. InputSystem.QueueStateEvent(Mouse.current, down) and an up that never processes (unfocused editor resets, the developer clicking meanwhile) leave leftButton.isPressed == true; every real press after that is "no change", so no UITK Clickable/uGUI Button completes and the developer reports "nothing is clickable" — while PointerDown still logs on every button.
  • Do not inject pointer state into an editor the developer is using. If a synthetic click is unavoidable, finish with InputSystem.ResetDevice(Mouse.current) and assert Mouse.current.leftButton.isPressed == false before the turn ends. Prove buttons with EventSystem.RaycastAll at the centre + panel.Pick + a clicked/ClickEvent log of the developer's OWN click instead.

  • DEVICE-SIMULATOR-SPLITS-SCREEN-PARAMS — a docked Device Simulator next to the Game view breaks every runtime click in the editor. It installs a "Device Simulator Touchscreen" device and its screen params leak into the play view: Screen.currentResolution read the simulated device, Screen.safeArea sat outside the Game view, and Screen.height flipped between two values on consecutive calls, so UI Toolkit scaled the panel from one height and the pointer from the other. Close the window (Resources.FindObjectsOfTypeAll(Type.GetType("UnityEditor.DeviceSimulation. SimulatorWindow, UnityEditor.DeviceSimulatorModule"))), or guard it closed from [InitializeOnLoad]; test aspect ratios with Game view presets. Also: a synthetic click cannot prove the UITK path while the editor app is unfocused — InputSystemUIInputModule drops events when EventSystem.isFocused is false — so the developer's real press is the only click evidence. (a tower-defense game, third "nothing is clickable" report.)

  • DOMAIN-RELOAD-HANGS-AFTER-BULK-ASSET-OPS — a domain reload right after a big asset batch can deadlock at Reloading Domain (busy for …) with zero CPU; nothing recovers it. Seen three times in one session (after 86 generated skin prefabs, after 71 deletions, and after a two-file edit) — every time an MCP bridge request was in flight when OnBeforeAssemblyReload stopped the bridge server, so treat the reload window as radio silence: no unity_* call or bridge ping for 60 s after Refresh / Play / Stop, then poll. Recipe: after ~5 minutes at zero CPU Stop-Process -Force, move Temp/UnityLockfile aside, relaunch Unity.exe -projectPath, wait for the bridge, unity_select_instance again, and re-pin EditorSceneManager.playModeStartScene (session state, lost with the process). Pre-warm entity-scene artifacts before Play (EntityScenesPaths.GetSubSceneArtifactHash(sceneGuid, config, true, ImportMode.Synchronous) via reflection for the client GUID and the default GUID) and batch asset writes so only one reload follows them. (a tower-defense game.) Zero CPU in the PARENT is not the signal - measure the CHILDREN before you kill anything (a space sandbox game). A Unity player build farms shader work to UnityShaderCompiler child processes, so Unity.exe sits at ~1 s of CPU per wall-minute while the title reads Compiling compute shader (busy for 13:18)... - identical on every surface to the deadlock above. Measured on a healthy build: parent 9799 -> 9800 s CPU in a minute (looks wedged), while the children moved 4415 -> 4544 s across 25 -> 29 processes in 45 s, about three cores busy. Applying the five-minute kill recipe there destroys a live 15-minute build. Before declaring a hang, sum CPU across the child processes (UnityShaderCompiler, Bee, asset import workers) over a 30-60 s window; a rising sum means working, a flat sum across parent AND children means wedged. The same care applies to the log: a single long operation buffers, so a stale Editor.log mtime is not evidence of death either.

AUTO-REFRESH-OFF-SERVES-YOU-THE-PRE-EDIT-PROJECT (HARD)

a space sandbox game. Unity's Preferences > Asset Pipeline > Auto Refresh can be off (EditorPrefs.GetInt("kAutoRefresh") returns 0). With it off, a .cs / .asmdef write under Assets/ is not imported - not on write, and not when the Editor regains focus. The AssetDatabase keeps serving the OLD project, and every signal an agent normally trusts agrees with it: EditorApplication.isCompiling is false, the compilation-errors query returns count: 0, and the console is clean. That reads exactly like "my edit compiled green". It is the pre-edit project answering.

Measured: five files git mvd into a new assembly plus a new .asmdef. A refresh scheduled with EditorApplication.delayCall += => AssetDatabase.Refresh did not take; AssetDatabase.LoadAssetAtPath still resolved the OLD paths and did not resolve the new .asmdef, three minutes later, while the error query said zero.

Do this. Call AssetDatabase.Refresh directly (a handful of files is not the mass reimport that BUILD-AND-TEST-RUNS-ARE-NOT-UPDATE-TICK-WORK in the Live play testing via Unity MCP page warns about), then prove the database moved before you read any compile result:

AssetDatabase.Refresh(ImportAssetOptions.Default);
// new path resolves AND old path is gone - either alone can pass on a stale database
AssetDatabase.LoadAssetAtPath<Object>(newPath) != null
AssetDatabase.LoadAssetAtPath<Object>(oldPath) == null

Then verify the code by TYPE LOAD, not by file mtime or a green error count: typeof(TheType).Assembly.GetName.Name, or grep the built DLL under Library/ScriptAssemblies/ for a symbol that exists only after your edit.

Second-order. With Auto Refresh off, SCRIPT-WRITE-ENDS-A-PEER-PLAY-SESSION (the Live play testing via Unity MCP page) cannot fire until somebody explicitly refreshes. That is a mitigation, not a licence: the next lane to refresh detonates every queued write at once, so keep the freeze.

Same family as UNCHANGED-SAVE-STILL-REIMPORTS (the editor tooling page) and the zero-error assembly drop in the compile assemblies page: a failure that presents as its own success. Check the setting once per session before you trust a compile - it is one EditorPrefs read.

EditorPrefs are per-USER, not per-project (HKCU\Software\Unity Technologies\Unity Editor 5.x). Another project's Auto Refresh setting is already yours. Measured: kAutoRefresh = 0 on a machine where a DIFFERENT project had turned it off, silently applying to a second project that never touched the setting - HotReloadWindow.* keys sitting beside it is how it was spotted. Read the pref before assuming your project set it. Same shared-per-user-store shape as PER-USER-EDITOR-LOG-IS-SHARED in the Live play testing via Unity MCP page.

Corrected v283 - those keys are a LIVE HOLDER, not a leftover. v277 shipped this as "keys present from a plugin not installed in that project"; a direct registry read of HKCU\Software\Unity Technologies\Unity Editor 5.x says otherwise:

kAutoRefresh_h2404942332                      REG_DWORD  0x0
kAutoRefreshMode_h2874646975                  REG_DWORD  0x2
HotReloadWindow.ShouldDoAutoRefreshFixup      REG_DWORD  0x0
HotReloadWindow.AllowDisableUnityAutoRefresh  REG_DWORD  0x1

The Hot Reload tool holds Auto Refresh off BY DESIGN. Consequence: kAutoRefresh = 1 "fixes" nothing - the tool fights it and the setting silently reverts, so an agent that repairs the pref and then trusts an automatic import is back to reading the pre-edit project. The explicit-Refresh path (ForceCompile, then prove the AssetDatabase moved, then verify by type load) is the PERMANENT shape here, not a stopgap. Read the keys, not just kAutoRefresh: a holder key beside it tells you whether the setting is yours to change at all. Measured one shipped project lane B.

CALL-THE-OFFLINE-IL-FROM-A-CONSOLE - verify pure-math runtime code with no Editor

An offline compile that writes real assemblies can be referenced by a throwaway dotnet console project that CALLS the shipped methods: solvers, rule structs, scoring, with no lease and no Editor.

  • Reference the lane's freshly compiled assemblies, Unity.Mathematics.dll, Unity.Collections.dll, and the editor's UnityEngine.CoreModule.dll plus UnityEngine.MathematicsModule.dll (float3 and friends are type-forwarded there; Unity.Mathematics.dll alone does not resolve them).
  • Never reference Library/ScriptAssemblies/* for the code under test: that is the Editor's last build and is stale whenever the Editor has not compiled your branch.
  • Symptom: a before/after A/B prints the same numbers twice. Cause: the rebuild kept the referenced DLL it had already copied, so the "after" run tested the old code. Fix: give each variant its own output folder with its own copy of the assembly, and print the assembly's hash in the output.
  • Symptom: SecurityException: ECall methods must be packaged into a system module from a Unity struct's ToString/ToFixedString (a NetCode tick). Cause: it reaches an engine internal call that exists only inside Unity. Fix: print the raw fields (a NetCode tick's index is SerializedData >> 1).
  • (HARNESS-RUNS-UNDER-PWSH) Symptom: a reflection harness over the offline IL (an AssemblyResolve scriptblock over the offline and ScriptAssemblies folders) died with StackOverflowException under powershell.exe 5.1. Cause: not isolated; the same script ran clean under pwsh 7. Fix: run harness scripts with pwsh, never powershell.exe.
  • (HARNESS-RESOLVES-ENGINE-MODULES) Symptom: "Could not load file or assembly UnityEngine.AudioModule" when a test touched a MonoBehaviour class, while a plain struct in the same DLL ran. Cause: loading the class resolves every engine module its members reference, and the console does not probe the editor's folders. Fix: an AssemblyResolve handler over the editor's Data/Managed and Data/Managed/UnityEngine folders plus the offline output folder; reference only the modules you call.

TIME-BOTH-SIDES-AT-ONE-OPTIMISATION-LEVEL - offline IL is debug; time the source compiled Release

The Editor compiles scripts in Debug code optimisation by default, and an offline compile that replays its response file carries no -optimize, so those assemblies are debug-level IL. A harness that times that IL against a copy of the old code compiled Release inside the harness is comparing two optimisation levels.

  • For timing, compile the source file itself into a Release harness (<Compile Include="..."> with <Optimize>true</Optimize>) and put both variants there. Keep the offline-IL reference for correctness only.
  • Drop the first round: tier-0 JIT ran both variants at 1.7 us per call in round 0, against 1.3 and 0.8 in the steady rounds.
  • One level makes the comparison fair, not the gap smaller: in the case measured the gap held (1.3 vs 0.8 us per call; inline copies of the new code in the harness ran 0.9). Report the steady numbers of both sides and the allocation per call.

GIT-SHOW-IN-POWERSHELL-EATS-THE-BOM - a captured blob is text, not bytes

An offline compile that filled a sparse worktree's missing sources with git show HEAD:<file> captured in PowerShell failed the Editor assembly with CS1056 '' (and CS1040) at column 1 of every copied file. PowerShell decodes a native command's stdout with the console (OEM) code page, so the UTF-8 BOM EF BB BF came back as three characters and was written out as text.

  • Write a blob as bytes: git show HEAD:<file> > out under cmd /c, git cat-file blob <sha> redirected by a byte-safe shell, or set [Console]::OutputEncoding = [Text.UTF8Encoding]::new($false) before capturing.
  • Until the script is fixed, a failure made only of CS1056/CS1040 at (1,1-4) on copied files is this defect, not the branch under test.

POLL-MATCHES-THE-KEY-AND-ITS-VALUE - a bare word in a JSON poll matches its own false

A compile poll read a finished compile as failed: grep failed matched the key in "failed":false of the bridge's recompile status.

  • Match the key with its value ("status":"completed", then "failed":true), never a bare word; the same holds for error, success and every other boolean key in a status poll.

DOTNET-PATH-IGNORES-SET-LOCATION - a relative path in a .NET call resolves against the process

PowerShell [IO.File]::ReadAllText('Assets/...') threw FileNotFound right after cd into a worktree: .NET resolves relative paths against the process working directory, which Set-Location/cd does not change.

  • Give .NET APIs absolute paths ((Resolve-Path x).Path, or Join-Path $PWD x), or edit with the file tool.

RENAME-COMPILES-THE-EDITOR-CALLERS - a runtime-only compile passes a rename that breaks tools

Renaming a replicated field passed every runtime assembly in an offline compile and failed only the Editor assembly (CS0117) on a preview tool that still set the old name. The code graph had no cross-file call edges, and a runtime compile never sees Editor or test callers.

  • Grep the old member across runtime, Editor and test folders before calling a rename done.
  • Compile the Editor and test assemblies too, not only the runtime ones.