---
title: "Editor tooling"
slug: /workflows/editor-tooling/
markdown: /workflows/editor-tooling.md
section: architecture-csharp
tier: 2
summary: "Inspectors, generators and authoring surfaces that never leak into a build."
sources: ['unity/editor-tooling.md']
canonical: https://unityworkflowlibrary.dev/workflows/editor-tooling/
read_first: /read-first.md
---

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

# Editor tooling

> Inspectors, generators and authoring surfaces that never leak into a build.

Code that only ever runs in the Editor: Control Panel tabs, generators, capture rigs, preview stages, and the ways editor-only work leaks into a build or the user's scene.

- **AUTHORING-SURFACE-IS-PART-OF-THE-SYSTEM (HARD).** *Every* feature system ships the surface a
  human operates it from, in the SAME slice — not presentation systems only. Generalises three
  narrower rules that each already demanded a panel over their own domain: `DC-AUTHOR` below
  (presentation), `AUTHORING-SURFACE-SAME-SLICE` and `SELECTION-PANEL-CATALOG`
  (the content production page). Those describe HOW; this says WHO IT APPLIES TO: all of them.
  A feature is done when the developer can inspect and change it **without an agent and without editing
  C#** — tunable numbers get rows bound to the authoring asset, a catalog gets add/duplicate/delete,
  runtime state gets an inspect view (counts, overlay, gizmo toggle), a destructive operation gets a
  confirmed one-shot action, server state gets a read view. Contract, assembly split and anti-patterns:
  `examples/designer-console.md`. Declared up front on the blueprint's AUTHORING line
  (the system blueprint page Shape C), because retrofitting a tool means first rediscovering what the
  system's inputs were. "I'll add the tooling after" means never, and the interval is exactly the
  period in which only an agent can operate the feature.

- **NO-BLACK-BOX-INSPECTION.** The half everyone skips: a surface that only EDITS is half a tool.
  If the answer to "what does the server currently think" or "how many of these exist right now" is
  "add a log line and re-run", the feature is unobservable and the tab is unfinished. Counts, current
  values, validation status and a scene overlay are the cheap 80%. Every validator the tab calls is a
  plain static callable from tests and automation — never logic that exists only inside the window.

- **TUNING-LAYER-DECIDES-HOT-RELOAD (HARD).** Hot-reload is a property of the LAYER a number lives in,
  not of the feature, and a tool that hides the difference is worse than no tool. Three layers, and the
  tab states which one it is editing:
  1. **`const` a Burst job reads** — changing it is code + recompile. It gets a READ-ONLY row with a
     jump to source. **Never convert a `const` to a serialized field to make it "editable"**: a field
     over a const is a lie that changes nothing until recompile, and it can silently de-Burst the job.
  2. **ScriptableObject / baked authoring data** — session-frozen when a bootstrap merges once and the
     table is built in `OnCreate`. The tab declares `AvailableInPlayMode => false` **and guards its
     mutating handlers with `isPlaying`** — the descriptor only dims the rail button, so a tab that is
     already active when Play starts stays interactive and can re-merge a registry the running worlds
     are reading.
  3. **Live-tuned values** — backed by a monotonic `Version` that a watcher system polls to re-apply.
     Live only if a watcher actually exists: a `Version` nobody polls is dead code wearing a feature's
     name, and a setter with no caller is a seam that was never connected.
  **Ship the index.** One place in the console lists every tuning surface and which layer it is in, and
  it is wrong the moment a class is added and not listed. Observed in a shipped building system: of
  four numbers a designer would reach for, cost is an SO (restart), decay is an SO (restart), claim
  radius is `PlayerPrefs` (live) and snap tolerance is a `const` (recompile) — three layers, and the
  in-tool index misses the const the designer is most likely to want.

- **APPLY-NAMES-WHAT-IT-REMOVES (HARD).** A tool action that writes a template, preset or profile OVER
  values a human authored must list, per target, every loss of detail it would cause (fewer layers or
  entries, lower strength/density/resolution, a swapped texture, a feature turned off) and refuse
  unless the caller explicitly accepts - the UI asks in a confirmation dialog, an agent must pass the
  accept flag by name. Found: a "weather type" preset replaced a sky's three authored cloud
  layers (erosion 15, density 15000) with one layer (erosion 2, density 8000); the only check was
  "scene equals profile" drift, which passed, and the regression shipped through a merge. **A
  consistency check is not a quality check** - drift 0 proves the copy matches, not that the copy is
  any good; a look change is not done until the before/after is compared against the previously
  authored values. Presets change only the fields they name and keep detail fields (erosion, layer
  structure) the author tuned. Same trap in the widget kit: **a slider whose range excludes the stored
  value displays a clamped number and rewrites the data on the first drag** (erosion 15 showed as 5,
  a scatter strength 285 as 100) - widen the range to include the value when the row is built.

- **Retire the window the tab replaced, in the same commit.** "Keep the old `EditorWindow` for A/B
  until parity is blessed" reliably becomes two live writers over one asset with no way to tell which
  is authoritative (observed: a 1076-line IMGUI window shipped for weeks beside the tab that replaced
  it, both writing the same economy config asset). A/B parity is a
  reason to branch, not a reason to ship two owners. This is the resolution of the apparent tension
  below: the console IS the "one window or suite" that rule demands, and a surviving standalone window
  is the loose second path, not a second suite.

- **Designer Console / Control Panel authoring for presentation systems (HARD).** When a host ships
  a designer-facing presentation system (sky, lighting profile, water look, backdrop, post stack,
  etc.), land a proper **Control Panel tab** — do not leave it as inspector-only or a one-shot menu.
  Required shape: (1) **ScriptableObject source of truth**; (2) **named subsections** (Status ·
  Profile/Asset · Layers/Features · domain knobs · Lighting/Atmosphere when relevant · Actions);
  (3) **Live Apply** toggle + explicit **Apply To Scene** / **Save Asset**; (4) **presets** for
  baseline looks; (5) optional **Capture From Scene**. Pattern proven on world-map and sky/arena-map
  tabs in two separate projects. New editor tooling = `IDesignerConsoleTab` + `GameUi`, not a
  standalone `EditorWindow`. Vendor mega-packs (e.g. full Space Graphics Toolkit) stay in the
  project that owns the license seat — copy only the textures/shaders the host actually needs under
  project-owned paths + a provenance README.

- **Editor code never reaches a runtime build.** One of: an `Editor/` folder, an Editor-only asmdef,
  or `#if UNITY_EDITOR`.

- **Editor tooling is an `EditorWindow` (or one suite), or a tagged temp — never a loose pile of
  `[MenuItem]` commands.** Keep-it tooling belongs in a window opened by a SINGLE menu entry, not
  scattered as N one-off action commands that clutter the `Tools/` dropdown and read identical to
  throwaways. Anything genuinely TEMPORARY (a one-shot diagnostic, a run-once migration/repair, a
  probe) MUST (1) sit under a quarantine submenu — `Tools/TEMP/…` with a high `priority` so it sinks
  to the bottom of the menu — and (2) carry a greppable removal marker on the line above the
  attribute: `// TEMP-MENU remove-after: <condition or date>`. Cleanup is then one grep
  (`grep -rn "TEMP-MENU" Assets --include=*.cs`) and every hit says when it should die. A menu item
  that is neither window-worthy nor tagged-temp is cruft by construction — promote it into the window
  or delete it. Never add per-variant commands (`Create Foo` / `Create Bar`) when one parameterized
  entry, or the window itself, covers them.

- **Editor-scrubbing an FBX animation (contact frames, pose measurement) needs an Animator with a
  VALID avatar — and animation-pack FBXs usually carry a BROKEN one.** Anim packs import humanoid
  clips as muscle curves (`RootT.x` etc., path `''`) with `avatarSetup = CopyFromOther`, so: (1) the
  anim FBX's own Avatar sub-asset is INVALID (`avatar.isValid == false`) — sampling on the anim FBX's
  rig silently poses NOTHING (zero movement, no error); (2) curve-based analyzers looking for hand/bone
  transform curves find none and return garbage fallbacks — muscle clips have no per-bone curves.
  Working recipe: instantiate the MODEL FBX (its avatar is real), ensure `Animator` + that avatar, then
  plain `clip.SampleAnimation(instance, t)` retargets the humanoid clip fine in-editor — scrub N samples,
  track `animator.GetBoneTransform(HumanBodyBones.RightHand).position`, contact ≈ peak downward/forward
  hand velocity. `AnimationMode`/PlayableGraph give the same result once the avatar is valid — the avatar
  was the blocker, not the API. Generic rigs (rider+horse packs) bind by PATH instead — clips only pose a
  hierarchy matching the authored rig, and a single-mesh variant model usually doesn't match (silent
  zero again); measure those on the exact customizable rig or eyeball them. (Proven on a shipped project: measured melee contact norms machine-side after two days of "clips can't be sampled".)

- **EDITOR PREVIEW/STAGE OBJECTS ARE INVISIBLE TO SCENE-FILE SEARCHES AND OUTLIVE THE STATIC
  THAT OWNS THEM — recover the root BY NAME in the dispose path or a recompile orphans it
  forever.** `HideFlags.DontSave` is the right flag for a designer-tool preview stage (it must
  not be saved into the user's scene), but it creates two traps that compound. (1) The object
  is NOT in the `.unity` YAML, so every grep-the-scene / prefab-GUID census reports the scene
  as clean while a live looping VFX sits in the game view — you must enumerate the loaded
  scene (`GetRootGameObjects`, `GameObject.Find`) to see it. (2) The GameObject is a NATIVE
  object: it survives domain reload, while the `static GameObject _stageRoot` that owns it does
  not. So the standard pair — `EnsureStageRoot` doing `GameObject.Find(Name) ?? new
  GameObject(Name)` and `ClearAll` doing `if (_stageRoot != null) DestroyImmediate(...)` —
  is asymmetric: after ANY script compile the static is null, Clear silently no-ops, and the
  stage is orphaned with no owner and no way to reach it from the tool. Every dispose path
  must do the SAME by-name recovery the ensure path does before destroying. Give staged
  content a single named root so one recovery+destroy cleans all of it, and prefer a stage
  that parks its effects OFF the world origin — a preview left at (0,0,0) reads to the developer
  as a gameplay bug ("why is there a meteor storm in the middle of the map"). (Seen in a mobile strategy game: an `__AbilityPresentationStage` from the Ability Presentation tab
  looped the full Meteors AOE storm at map center across sessions.)

- **A PREVIEW THAT BORROWS SCENE-VIEW STATE MUST PERSIST THE SNAPSHOT OUTSIDE THE STATIC THAT
  HOLDS IT, RESTORE IT BEFORE THE TEARDOWN THAT CAN THROW, AND PUT IT BACK BEFORE BORROWING
  AGAIN.** A preview stage that flies the Scene view has to override its camera settings - clip
  planes above all, because a planet-scale stage needs a near plane thousands of times the default.
  Restoring those from `static SavedCamera` is the same asymmetry as an orphaned stage root, but
  worse: an orphaned GameObject at least sits in the hierarchy, while a stranded near plane leaves
  NOTHING to find. Every later scene renders black and the tool that did it is closed. Three
  compounding traps, all of which must be closed:
  (1) **Any path that skips the close keeps the borrowed values forever** - an editor killed or
  crashed while the preview is open takes the only copy of the authored settings with it, and
  `beforeAssemblyReload` does not fire. Write the snapshot to `EditorPrefs` when it is taken
  (`JsonUtility.ToJson` on a `[Serializable]` struct round-trips `ulong` culling masks fine),
  recover it from `[InitializeOnLoad]` via `delayCall` (there is no Scene view yet at static-ctor
  time), and delete the key only after a successful apply.
  (2) **Restoring at the END of a dispose puts the restore inside the blast radius of the dispose.**
  A throw destroying the copies skips it, and the `finally` that nulls the fields then drops the
  only copy. Give the borrowed state back FIRST, before any destroy.
  (3) **Snapshotting a view that is still holding a previous borrow saves the leak as authored**,
  so every close from then on faithfully restores the broken value and the leak becomes permanent.
  Restore any pending snapshot before taking a new one.
  Diagnose it from the numbers, not the symptom: read `SceneView.camera.nearClipPlane` and compare
  against the tool's own formula (a near plane that is exactly `radius * 0.0005` names its owner),
  and `cameraSettings.dynamicClip == false` is the fingerprint of a tool that turned it off, since
  nothing else does. Same rule for `sceneLighting`, `overrideSceneCullingMask` and
  `sceneViewState` - every borrowed editor setting, not just the clip planes.

- **UBA-EDITOR-TRIGGER — a Unity Build Automation build can be queued from the Editor over
  REST; the two things that silently break it are the ORG identifier and the assumption that
  the cloud sees your working tree.** Build Automation (ex-Cloud Build) is not commit-trigger-only:
  `POST https://build-api.cloud.unity3d.com/api/v1/orgs/{orgid}/projects/{projectid}/buildtargets/{buildtargetid}/builds`
  with body `{"clean":false,"delay":0}` queues a build on demand (`_all` as buildtargetid queues
  every target); siblings are `GET …/buildtargets`, `GET …/builds/{n}`, `DELETE …/builds/{n}`.
  Statuses: `created, queued, sentToBuilder, started, restarted, success, failure, canceled`.
  Three traps, all of which produce a confident-looking 404/empty result rather than an error
  that names the cause:
  (1) **`{orgid}` is the org SLUG, and `CloudProjectSettings.organizationKey` is NOT it** —
  `organizationKey` returns the numeric *genesis* org id while the
  REST path wants `CloudProjectSettings.organizationId` (the organization's text slug). Reaching for `organizationKey` because it is the newer-sounding member 404s
  every call. Dump both by reflection once rather than guessing which member is current for the
  editor version in hand.
  (2) **Auth is Unity's non-standard Basic**: `Authorization: Basic <RAW API KEY>` — *not*
  base64("user:pass"). Base64-encoding it, the reflex for Basic auth, yields 401.
  **And a UGS service account is NOT a substitute — measured.** Build Automation is
  the one UGS surface still on its own legacy per-user auth: a service account that is provably
  valid (`POST services.api.unity.com/auth/v1/token-exchange` -> 201) is rejected 401 by
  `build-api.cloud.unity3d.com` as BOTH `Basic base64(key:secret)` AND `Bearer <token>`; eight
  guessed `services.api.unity.com` build routes return 404 with a UGS gateway error body (proving
  the gateway authenticated the token and simply has no such route); and the `ugs` CLI v1.9.0 has
  no build module at all (access, cloud-code, cloud-save, economy, leaderboards, matchmaker,
  game-server-hosting, remote-config... no build). So "I made a service account for CI" does not
  get you builds — grab the per-user Build Automation API key. The 404-vs-401 split is the useful
  diagnostic: 401 means the host rejected the credential, 404-with-requestId means the credential
  was accepted and the PATH is wrong.
  (3) **The cloud builds the target's configured branch at its latest PUSHED commit**, never the
  local working tree — so an editor "Build" button must surface unpushed/uncommitted state before
  it queues, or a green build silently ships a stale commit. Cheap probe: `git rev-list --count
  @{u}..HEAD` plus `git status --porcelain -uno` (the `-uno` matters — a full status walks
  untracked files and stalls the editor for seconds on an asset-heavy project).
  Delivery to a store is a separate concern from the build: it happens in the target's
  **post-build script** (dashboard → target → Advanced Settings), invoked as
  `script "$BUILD_TEMP_DIR" "$BUILD_OUTPUT_DIR" "$TARGET_NAME"` with `WORKSPACE`/`TARGET_NAME`/
  `UNITY_PLAYER_PATH` in env, and store credentials as target environment variables. Make that
  script FAIL LOUD when no artifact is found — the soft form (`exit 0` when no `.ipa`/`.aab`
  exists) marks a build green having shipped nothing, which is how broken signing survives for
  weeks. The API key is a machine-local secret: `EditorPrefs`, never an asset or ProjectSettings
  field, or it rides a commit. (a mobile strategy game, porting Cross-The-Line's TestFlight
  pipeline into an editor Control Panel tab.)

- **PROJECTSETTINGS-EDIT-VIA-API — never write `ProjectSettings/ProjectSettings.asset` on disk
  while the Unity editor is open; the editor holds it in memory and silently reverts you.** The
  editor loads ProjectSettings at startup and writes its in-memory copy back on any save (asset
  save, play-mode entry, quit). An external edit — `sed`, a script, a text editor — is invisible
  to it, so the value is correct on disk, correct in `git diff`, correct in the commit, and then
  quietly overwritten the next time the editor flushes. The tell is a read-back mismatch:
  `PlayerSettings.bundleVersion` reporting an older value than the file you just committed.
  Use the API instead (`PlayerSettings.bundleVersion`, `SetScriptingDefineSymbols`,
  `SetApplicationIdentifier`, …) followed by `AssetDatabase.SaveAssets`, and verify by reading
  the property back, not by reading the file. Same hazard class as the `.meta`/YAML rules: the
  editor, not the filesystem, is the authority for anything it has loaded. (Seen in a mobile strategy game: three `sed` version bumps landed in commits while the editor still held the older
  bundleVersion, one editor save away from reverting all of them.)

## Building a scene from an editor script

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

- **SCENE-SWITCH-UNLOADS-ASSETS-LOADED-BEFORE-IT (HARD): a single-mode scene switch UNLOADS assets
  loaded before it, and the reference you then write is silently dropped.** `EditorSceneManager.NewScene(..., NewSceneMode.Single)` and
  `OpenScene(..., OpenSceneMode.Single)` unload assets nothing in the *new* scene references yet. An
  asset handle taken before that call survives as a **fake-null** managed wrapper, and assigning a
  destroyed object to `SerializedProperty.objectReferenceValue` **clears the field without
  throwing**. So the write reports success, the tool exits 0, and the field reads back empty.
  What makes it expensive to diagnose is the split result on one `SerializedObject`: every reference
  to an object *inside* the new scene writes and reads back fine, and only the persistent-asset
  references drop — which reads like a serialization bug in the asset type, not a lifetime bug in
  the handle. (Seen in practice: a generated shipping scene wired 14 of 16 references; the two
  `ScriptableObject`/`PanelSettings` fields read back MISSING across four rebuild attempts, including
  one that split asset creation and scene building into two separate batch-mode editor launches to
  rule out import timing. The fix was three lines moved.)
  **Fix: load every asset AFTER the scene switch**, inside the same function, and never hold an asset
  handle across `NewScene`/`OpenScene`. Then **verify by read-back**: build a fresh
  `SerializedObject` over the saved scene's component and assert each field is non-null, reporting
  one line per field. A generator that only writes has no way to notice this class of failure, and
  neither does a compile or an exit code.
- **Two phases, two editor launches, when it runs in batch mode.** A batch-mode editor does not
  finish importing a newly created asset within one `-executeMethod` call, so `LoadAssetAtPath`
  returns null for an asset the same call just created — even after `SaveAssets` + `Refresh`. Split
  the tool into a create-assets entry point and a build-scene entry point and invoke them as two
  launches; keep a menu item that calls both for the live editor, where the import does settle.

## Editor capture rigs and additive scene swaps

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

- **Reuse ONE camera and ONE RenderTexture; never render QA through the SceneView.** A camera + RT
  created per shot leaks — SRP allocates per-camera history/RTHandle resources that `DestroyImmediate`
  does not promptly release (a survival game: 8 captures → 12.5 GB → D3D12 device removal while VRAM was fine).
  Cache one camera + one RT, null `cam.targetTexture` BEFORE `rt.Release`, and release only on
  `beforeAssemblyReload`/`quitting` — a per-shot `ReleaseResources` defeats the entire fix. SceneView
  screenshots smear through the SceneView's own TAA history (reproduced on an EMPTY scene): render
  through an explicit camera with `antialiasing = None`. And a capture that looks wrong is usually the
  render pipeline (a project-default Depth of Field), not the content.
- **UITK-OVERLAY-INVISIBLE-TO-CAMERA-CAPTURE — an Overlay UI Toolkit panel renders ABOVE the camera,
  so any camera-based capture (`game_capture`, an RT from the camera) returns the scene with no UI.**
  A `PanelSettings.targetTexture` capture is sound only when real frames elapse between binding
  and reading — split it into `Begin` / `End` across bridge calls — and every capture must
  report its own non-blankness (distinct sampled colour count): flat clear colour and pure white
  both look like "the UI did not build" rather than "the capture is wrong". (a card game.)
- **GAME-VIEW-SIZE-INDEX-IS-TWO-SPACES — a capture that switches the Game View to a temporary fixed size**
  **must convert `GameViewSizeGroup.IndexOf` before selecting or removing.** `IndexOf` returns a built-in
  entry's index but a CUSTOM entry's slot inside the custom list, while `SizeSelectionCallback`,
  `selectedSizeIndex` and `RemoveCustomSize` take the index across built-ins then customs. Measured: 7
  built-ins, new custom size, `IndexOf` 17 vs real 24 - the capture selected another saved size, failed
  its own "still selected" check, left the view on the wrong size and leaked its entry. Map with
  `GetBuiltinCount` + an identity check on `GetGameViewSize(i)`, and sweep leftover temporary entries by label.
- **EDIT-MODE-RENDERED-FRAME-COUNT-IS-FROZEN — `Time.renderedFrameCount` stays at 1 without Play Mode,**
  so a capture that waits for "one more rendered frame" times out every time in Edit Mode even though the
  view already rendered at the new size. In Edit Mode repaint the view synchronously (internal
  `EditorWindow.RepaintImmediately`) and read; keep the frame-count wait for Play Mode, where per-frame
  `Graphics.Draw*` submissions only reach cameras rendered inside the player loop.

- **SCENE-HOSTED-SYNC-VERIFIES-HOST — a sync that writes into "the active scene" verifies the host
  by path, or is find-only.** The creature catalog sync ran while a quarantined, gitignored fixture
  was the active scene: the catalog landed there, the gameplay scenes never got one, and "sync
  reported success" told nobody. Do not copy a find-or-create host helper across domains without
  checking how the catalog is DELIVERED (DOTS: a baker-delivered catalog only bakes inside a SubScene; see ECS core mechanics.)
  (a survival game)

- **EDITOR-WINDOW-AND-INSPECTOR-TRAPS.** A mixed-content inspector (props + buttons + EditorPref
  toggles + sub-editors) triggers regen off the `ApplyModifiedProperties` return, never
  `Begin/EndChangeCheck`; a window that is not shown gets NO editor tick, so its
  `OnInspectorUpdate → Repaint` never fires; a domain assembly never references the console shell
  assembly (deep-links go through the contract); `EditorApplication.delayCall -=/+=` needs a
  stable method reference, a lambda is a new delegate every call; and nothing under scene-GUI
  hits `AssetDatabase` per frame — cache with a TTL and cull by distance.

- **UNCHANGED-SAVE-STILL-REIMPORTS (HARD).** An `InitializeOnLoad` +
  `delayCall` "ensure/sync" pass that writes unconditionally re-saves its targets on EVERY domain
  reload, and a byte-identical `EditorSceneManager.SaveScene` / `AssetDatabase.SaveAssets` still
  bumps the file mtime and re-imports the asset. Measured: six scenes re-saved every reload,
  all three collision scenes and all three surface scenes, `git status` clean against the
  checkpoint the whole time — so the churn is INVISIBLE to a dirty-file check. The detection
  heuristic is the transferable part: **git clean + a fresh mtime means a writer that does not
  compare.** `ls -la` the targets after a reload and read the mtimes; never conclude "nothing was
  written" from `git status`. (DOTS: the SubScene consequence is in ECS core mechanics, SUBSCENE-REIMPORT-UNBAKES-THE-SECTION.) Fix: every `Ensure*` returns whether it actually changed
  something, `MarkSceneDirty`/`SaveScene` happen only when true, the guard covers every field the
  block writes (a guard that omits one is not idempotent), and a real write LOGS which file and
  why so the next occurrence names itself. Watch for the no-op `EditorUtility.SetDirty(x)` with
  no preceding mutation — one of those forces a save on every pass by itself.

- **IDEMPOTENT-WRITER-REDERIVES-DERIVED-STATE (HARD, one shipped project).** The exact dual of
  `UNCHANGED-SAVE-STILL-REIMPORTS` above, and the more expensive half: a generator whose reuse path
  is `LoadAssetAtPath(path); if (existing != null) return existing;` **freezes every derived
  property to whatever resolved on the asset's FIRST bake, forever.** Re-running the generator looks
  like it worked — same asset, same GUID, no error, no warning — and changes nothing. The tell is
  that the *authoring loop is dead*: the developer retunes a source asset, re-bakes, and sees the old
  output, so it reads as "the tool ignored me" rather than as a caching bug.
  Two instances measured in ONE bake path, which is what makes this a family and not an anecdote.
  (1) *Material*: the reuse branch called `configure(existing)` but never `create`, and only
  `create` resolves the shader — so six materials stayed pinned to an HD-only Shader Graph
  (`passCount 1`, draws nothing under URP) across every re-bake, while the fallback that would have
  been visible never ran. (2) *Mesh*: the mesh WAS regenerated from the current source asset and
  then discarded by the `return existing`, and its cache key was `{asset.name}_{seed}` where the
  seed hashed placement inputs only — **no source parameter entered the key**, so no edit could ever
  invalidate it. Proof shape that settles it in one step, and the one to reach for: bake, retune,
  re-bake, compare — then move only the generated asset aside and re-bake the *identical* source. Same
  asset name with different content proves the cache, not the tuning (measured: 480 → 696 verts,
  bounds `(0.368,0.268,0.381)` → `(0.610,0.159,0.625)`).
  **Fix: split derived from authored.** Re-derive everything the ENVIRONMENT owns (shader, mesh
  contents, instancing flags, import settings) and keep everything the OWNER typed. Never
  `DeleteAsset` + `CreateAsset` to refresh — that mints a new GUID and breaks every reference:
  mutate the existing asset in place (`existing.shader = fresh.shader`; `existing.Clear` then copy
  vertices/normals/tangents/colors/UVs/submesh triangles/bounds), `SetDirty`, return it. Destroy any
  throwaway probe object you created to read the derived value from, or the bake leaks one per
  asset per run. And a generator must **flush what it derived** — an in-memory re-derive that never
  reaches `AssetDatabase.SaveAssets` is invisible to every check that greps the file on disk, so
  it presents as the freeze it was supposed to fix.
  Amendment: two more instances in the same bake path the same night. (a) A `HideFlags.DontSave`
  texture assigned to a material that IS saved serialises as `fileID: 0` - persist the texture to an asset
  first. (b) A property setter that early-outs on `null` (`if (tex == null) return`) can never CLEAR a slot,
  so a reconfigure that should drop a map leaves the old one bound (a sculpted canopy sampling a leaf atlas
  rendered black). Re-derive means clear the derived slots, THEN configure.
  Amendment : the same GUID loss in the shape where "mutate in place" is not offered - a
  snapshot step that refreshes a whole PREFAB by `DeleteAsset(path)` then `CopyAsset(source, path)`.
  The copy is correct content at the correct path, so every check that opens the asset passes and
  nothing reports the break: the new file takes a NEW guid, and every scene and asset that referenced
  the old one silently holds a dangling id. It surfaces a run later as a null the boot merely warns
  about ("no X to warm"), pointing at the wiring rather than at the rebuild. **Copy through a staging
  asset and replace the FILE, leaving the meta alone**: `CopyAsset(source, staging)`, `File.Copy(
  full(staging), full(target), overwrite: true)`, `DeleteAsset(staging)`, `ImportAsset(target,
  ForceUpdate)`. The meta is untouched so the guid holds; internal fileIDs come from the source and
  stay stable across rebuilds, so a reference to an object INSIDE the asset survives too. Proof is
  one line: compare `AssetPathToGUID(path, AssetPathToGUIDOptions.OnlyExistingAssets)` before and after the refresh
  (option added; see PATH-TO-GUID-SEES-DELETED-ASSETS).

## READOUT-FIELD-WITH-NO-WRITER-IS-A-WORKING-ZERO

- **A diagnostics field that has a reader and no writer displays `0` forever and is indistinguishable
  from a healthy idle system.** It is the mirror of a dead authoring knob (DEAD-AUTHORING-FIELD-
  MEASURE-INPUT in the content production page): that one accepts input nothing consumes,
  this one reports output nothing produces. Both look finished.
- Found by auditing a live metrics struct field by field for `writers=N, readers=N`: nine fields had
  writers, one had zero and had been printed on a panel since it was written. The developer's report was
  "it's as if we're not keeping track of everything" — a person reading a panel cannot tell a
  measured zero from an unmeasured one, so they distrust the whole panel.
- Do the audit when you ADD the struct, not later: for each field, name the one system that writes it.
  A field whose writer you cannot name is not a field yet.
- Label the field with what it actually counts, and rename it when the two drift apart. "Brain
  ticks/s" and "decisions scored/s" are different numbers, and shipping the first name over the
  second one's value costs someone an afternoon.
- A readout that cannot be measured yet says so in the text ("not measured in this world",
  "measuring; no sample yet") rather than printing a zero — the distinction is the whole value of the
  panel.

## AN-INSPECTION-SURFACE-IS-A-LIST-NOT-A-PAGE

An inspection tab is a ranked list of selectable rows plus a detail pane for the selected row.
It is never a set of collapsed foldouts holding preformatted text.

Measured: a diagnostics page put every real number behind a `Foldout` (default closed) wrapping one
`<pre>` label per subsystem. The developer reported that a subsystem's traffic "never changed" while the
live figure had been on screen the whole time, inside two shut foldouts. A collapsed foldout reads
as "nothing here", and a wall of monospace is unreadable even once opened.

What the shape requires:

- **Rank by the number the reader is hunting** - bytes, cost, count - biggest first, with a share bar.
- **Keep idle rows listed.** A row measuring zero is tracked and quiet. Dropping it from the list
  reads as "not tracked", which is the opposite answer, and is the specific defect that produced the
  complaint above.
- **Every quantity is its own labelled row**, never a line inside a text block, or it cannot be
  sorted, selected, coloured when it crosses a threshold, or clicked through to its source.
- **Name a section for what the reader is looking for**, not for the system that produced it. The
  figure above was filed under the subsystem that emitted it, which is not where anyone looks.
- **Account for the remainder.** When the per-item rows cannot sum to the measured total, show the
  difference as its own honest row rather than letting the numbers quietly not add up.
- **Say what is not measured, next to what is.** An absent number otherwise reads as a zero. A short
  bulleted list under the figures beats a paragraph nobody reaches.

A quantity the tool reports as bytes must BE bytes. A reserved-buffer size shown next to measured
throughput will be read as throughput and optimised against - label the units and the stage.

## USAGE-LISTS-SPLIT-HELD-ACCESS-FROM-OCCASIONAL-WRITES

A tool that lists "who writes this" is answering a cost question, and two kinds of write have costs
that differ by orders of magnitude. Declared write access - a read-write query reference, an
unqualified lookup or type handle - is taken every update the system runs. A one-shot write at
spawn or on an event costs once. Listing them together sends the reader to the wrong file.

Classify read-only spellings BEFORE their read-write siblings: the read-only form is the narrower
spelling of the same call, because an unqualified lookup or handle defaults to write access.

Exclude test code from such a list and say so. Tests write components and never reach the wire or
the frame, and a test that quotes a call in a string literal otherwise appears as a live caller.

**TEMPLATE-EDIT-ASSERTS-ONE-MATCH .** A script that builds an asset by editing a package template (a VFX
graph, shader or config) at anchored text drifts silently when a package update moves or duplicates the
anchor. Assert that every anchored edit matched exactly once and fail the build otherwise.

**HDRP-PREVIEW-ICON-CAPTURE .** Icon captures through `PreviewRenderUtility` under HDRP (see
HDRP-PREVIEW-CAMERA-IS-SANITIZED):

- Preview buffers discard alpha: render lit RGB on black, then the same meshes HDRP/Unlit white for coverage,
  with post-processing off (Preview camera type, no volume layers) so bloom cannot spread the silhouette.
- The first renders of a fresh preview scene come before the sky lights the ambient probe and are solid black:
  render 4 warm-up frames, keep the last, and scan output for all-black.
- Separately, a model-preview tool got a flat white silhouette from every static preview after the first in
  one utility; if that appears, use one fresh utility per image.
- Preview size is in GUI points: divide the pixel size by `EditorGUIUtility.pixelsPerPoint` before
  `BeginPreview`, or HiDPI crops the icon.
- Preview lights need `HDAdditionalLightData` and an update after the physical intensity is set.
- World-fixed studio lights light the back of anything seen from a new side; key and fill ride with the camera.

**PENDING-SETTINGS-LIVE-IN-THE-EDITOR-OBJECT .** A value just picked in Player Settings lives in the open
settings editor's pending `SerializedObject` until applied; saved settings and Project window focus do not show
it. Read the pending object before inferring the user's choice.

## DONTSAVE-IS-INVISIBLE-TO-FINDOBJECTSBYTYPE

- `Object.FindObjectsByType` skips objects flagged `DontSave` / `HideAndDontSave`, so a cleanup pass that searches for
  your scratch previews finds none and leaves them in the scene. Clean up with `Resources.FindObjectsOfTypeAll<T>`
  filtered by `gameObject.scene.IsValid` (scene objects, not assets), or keep a list of what you created.
- `FindFirstObjectByType<T>` and the default `FindObjectsByType` skip inactive GameObjects, so a readback reports
  "none" for a disabled Volume or host. Read back with
  `FindObjectsByType<T>(FindObjectsInactive.Include, FindObjectsSortMode.None)`.

## DEEP-COPY-A-SERIALIZED-ENTRY

- To duplicate a `[Serializable]` list entry (a rule, a row) in an Editor tool while keeping its `UnityEngine.Object`
  references, use `EditorJsonUtility.FromJsonOverwrite(EditorJsonUtility.ToJson(src), copy)`, then assert the two JSON
  strings are equal. `JsonUtility` drops object references; hand-written field copies miss fields added later.

## BAKE-FAILS-HALF-WRITTEN

- A generator that throws midway leaves some outputs rewritten and others not. Measured: a scatter bake threw on a
  null authored texture in a species asset, and the failed run had already cleared the textures on a shared bark
  material. Validate every authored input before the first write. After any failed bake, fix the input, re-run, and
  diff the generated assets against version control before trusting them (here the re-bake restored them
  byte-identical). See IDEMPOTENT-WRITER-REDERIVES-DERIVED-STATE above.

## BAKE-KEYED-BY-STABLE-ID

- A procedural bake keyed on a rule's list position re-rolls when the list is re-ordered or split: random streams,
  generated file names and the order rules claim shared occupancy all followed the index. Store a stable per-rule key
  that seeds the stream, prefixes the generated files and sets a stable-sorted bake order. Migrate existing rules to
  key = their old index (output byte-identical), give a new rule the next free key, and have the bake preflight
  refuse missing or duplicate keys. Gate: a placement hash identical before and after the migration, plus an
  in-memory test that swaps two rules and gets the same placements.
- Measured: a vegetation bake migrated to per-rule keys produced the same placement hash before the migration,
  after it, and with two rules swapped (55 batches, 1,946 matrices).

## GUARD-RETRIPS-ABOVE-ITS-TRIP-LEVEL

- A Play guard that pauses at a cap (units, memory, frame time) and re-arms only once the value drops back below the cap
  is defeated by unpausing: the value never drops, so the guard stays disarmed and the count climbs unchecked.
- Give it hysteresis in both directions: after a trip, trip again at +10 % over the last trip level, and fully re-arm
  only below 90 % of the cap. This is the enforcement side of AGENT-PLAY-PROBES-ARE-BOUNDED.
- Test the guard by unpausing past it once, not only by reaching the cap the first time.

## WRITTEN-FILE-IMPORTS-INSIDE-THE-EDIT-BATCH

- A baker wrapped in `AssetDatabase.StartAssetEditing` wrote its outputs with `File.WriteAllBytes`, and none of them
  were imported: 0 `.meta` files after `StopAssetEditing`. A raw file write does not queue an import, and
  `StopAssetEditing` imports only what was queued inside the batch.
- Inside the batch, call `AssetDatabase.ImportAsset(path, ImportAssetOptions.ForceUpdate)` for each written file. Gate
  the bake on every output having its `.meta` and loading through `AssetDatabase.LoadAssetAtPath`.

## NEVER-CLEAR-THE-EDITOR-ROOT-STYLESHEETS

- An editor window's UI Toolkit content pane painted empty on every tab. Its `TwoPaneSplitView` stacked the panes in a
  column, and the second pane had 0 size.
- Cause: the window's rebuild called `rootVisualElement.styleSheets.Clear`. In Unity 6.5 the window root also
  carries Unity's default editor theme sheet (`DefaultCommonDark_inter.uss` or its light twin). Clearing it strips the
  built-in controls of their default USS, and a split view without its layout rules collapses.
- Fix: keep a list of the sheets your code added, and on rebuild remove only those, never `Clear` a root you did not
  create. Gate: after a rebuild, the root's sheet count equals Unity's initial count plus yours, and a split view's
  panes have non-zero size.

## COPIED-AUTHORING-VALUE-IS-VALIDATED-AGAINST-ITS-SOURCE

A gun's grip was retuned by 1.7 cm on its source asset and the hand never moved. The authoring pass pushed the new
value to the gun's own animation profiles but not to the per-character profile sets that hold their own copy, and
the live hand reads the copy. A scan found 28 of 216 copies off their source, by 1.7 to 14 cm.

- A value an authoring tool copies into other assets (baked for a job, a layer or a per-character set) has one
  writer that updates every copy. A tool that edits the source calls that writer; it never writes a subset.
- A validator compares every copy with its source and fails on any difference beyond float noise (0.5 mm here).
  Run it in tests and in the authoring tool, so a partial push shows up as a count, not as a feel.

## RECREATE-THE-BEFORE-STATE-FROM-A-SNAPSHOT

- When a before capture or measurement must be redone after the change has landed, recreate the old state exactly
  instead of approximating it or reusing a stale image.
- Snapshot each affected settings asset with `EditorJsonUtility.ToJson`. Write the old values, re-bake, and gate on a
  content hash of the old output, which must equal the recorded before hash. Take the before capture. Then restore
  with `EditorJsonUtility.FromJsonOverwrite(snapshot, asset)`, check that the JSON is identical to the snapshot,
  re-bake, and gate on the recorded after hash.
- Measured: a map's old strokes re-baked to the exact before placement hash, then back to the after hash with an
  identical walkability receipt. Keep the snapshot outside `Assets/`, and never save the asset while it holds the old
  values unless the restore gate follows in the same lease.

## VIOLATION-GATE-NEEDS-A-PRESENCE-FLOOR

- A gate set made only of violation counts ("band misses = 0", "path blocks = 0") passes a rule that places nothing.
  Measured: a vegetation grove rule placed 0 instances and every gate still read PASS.
- Give every gated rule a minimum placed count next to its violation gates: at least greater than 0, or the expected
  share or count from the spec (see SPEC-STATES-COVERAGE in the rendering page). The report prints the placed
  count beside each zero.
- The same holds for any "0 errors" gate over generated output: a generator that produced nothing has no errors.

## PATH-TO-GUID-SEES-DELETED-ASSETS

- `AssetDatabase.AssetPathToGUID(path)` can still return a GUID for a path whose asset was deleted, so an existence
  check built on it treats the deleted path as occupied. Measured: after a cleanup deleted "orphan" bake outputs, the
  baker refused to recreate them ("an asset of another type already exists").
- Pass `AssetPathToGUIDOptions.OnlyExistingAssets` on every existence check, or load the asset
  (`AssetDatabase.LoadMainAssetAtPath(path) != null`) instead.
- Deleting generated outputs as orphans caused this; see ORPHAN-IS-UNREFERENCED-AND-UNPRODUCED .

## ORPHAN-IS-UNREFERENCED-AND-UNPRODUCED

- Before moving generated files aside as orphans, prove both conditions. Nothing references them, and the current
  generator does not write them: none appears in the latest bake's manifest or carries the latest bake's timestamp.
  "Zero references" alone is not enough. Measured: 4 zero-reference variant meshes deleted as orphans were live bake
  outputs, and they had to be regenerated. Their absence then also tripped PATH-TO-GUID-SEES-DELETED-ASSETS in the
  baker.
- Re-run both tests right before the move, not at the time the list was drawn up, since a bake may have run in
  between. List the moved paths and their backup folder in the handoff.

## ORPHAN-SCAN-READS-EVERY-REFERENCE-FORM

- The unreferenced half of ORPHAN-IS-UNREFERENCED-AND-UNPRODUCED needs a scan that sees every way assets reference
  each other. A scan that reads only `guid:` fields marks live files as dead.
- A reference is any 32-hex token that resolves to a known GUID: `guid:` fields, GUIDs stored as plain string fields
  in library or catalog indexes (for example `SourceGuid:`), asmdef `GUID:` references and importer remaps in metas.
- USS and UXML reference by relative path with no GUID (`url("../Art/UI/Card.png")`, `@import url("Menu.uss")`).
  Resolve those paths too.
- C# folder literals and `FindAssets("t:X")` are not references. They mark generator inputs and outputs and
  type-found authoring assets, which go on the keep list.
- A script with no external mention and no `m_Script` inbound is not yet dead. First clear it against types found by
  attribute or reflection: ECS systems and bakers, editor tabs, `[InitializeOnLoad]`, `[CustomEditor]`, toolbar
  overlays, and probe classes run through an Editor bridge.
- A whole vendor pack is removable only when 0 files outside it reference it, 0 remaining scripts use its namespaces,
  and 0 asmdefs reference its assemblies.
