Topic 3 · Unity programming
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-AUTHORbelow (presentation),AUTHORING-SURFACE-SAME-SLICEandSELECTION-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.
consta Burst job reads — changing it is code + recompile. It gets a READ-ONLY row with a jump to source. Never convert aconstto 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 inOnCreate. The tab declaresAvailableInPlayMode => falseand guards its mutating handlers withisPlaying— 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 monotonicVersionthat a watcher system polls to re-apply. Live only if a watcher actually exists: aVersionnobody 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 isPlayerPrefs(live) and snap tolerance is aconst(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
EditorWindowfor 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 standaloneEditorWindow. 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 theTools/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 highpriorityso 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.xetc., path'') withavatarSetup = 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), ensureAnimator+ that avatar, then plainclip.SampleAnimation(instance, t)retargets the humanoid clip fine in-editor — scrub N samples, trackanimator.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.DontSaveis 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.unityYAML, 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 thestatic GameObject _stageRootthat owns it does not. So the standard pair —EnsureStageRootdoingGameObject.Find(Name) ?? new GameObject(Name)andClearAlldoingif (_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__AbilityPresentationStagefrom 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 SavedCamerais 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, andbeforeAssemblyReloaddoes not fire. Write the snapshot toEditorPrefswhen it is taken (JsonUtility.ToJsonon a[Serializable]struct round-tripsulongculling masks fine), recover it from[InitializeOnLoad]viadelayCall(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 thefinallythat 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: readSceneView.camera.nearClipPlaneand compare against the tool's own formula (a near plane that is exactlyradius * 0.0005names its owner), andcameraSettings.dynamicClip == falseis the fingerprint of a tool that turned it off, since nothing else does. Same rule forsceneLighting,overrideSceneCullingMaskandsceneViewState- 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}/buildswith body{"clean":false,"delay":0}queues a build on demand (_allas buildtargetid queues every target); siblings areGET …/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, andCloudProjectSettings.organizationKeyis NOT it —organizationKeyreturns the numeric genesis org id while the REST path wantsCloudProjectSettings.organizationId(the organization's text slug). Reaching fororganizationKeybecause 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 bybuild-api.cloud.unity3d.comas BOTHBasic base64(key:secret)ANDBearer <token>; eight guessedservices.api.unity.combuild routes return 404 with a UGS gateway error body (proving the gateway authenticated the token and simply has no such route); and theugsCLI 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}..HEADplusgit status --porcelain -uno(the-unomatters — 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 asscript "$BUILD_TEMP_DIR" "$BUILD_OUTPUT_DIR" "$TARGET_NAME"withWORKSPACE/TARGET_NAME/UNITY_PLAYER_PATHin env, and store credentials as target environment variables. Make that script FAIL LOUD when no artifact is found — the soft form (exit 0when no.ipa/.aabexists) 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.asseton 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 ingit diff, correct in the commit, and then quietly overwritten the next time the editor flushes. The tell is a read-back mismatch:PlayerSettings.bundleVersionreporting an older value than the file you just committed. Use the API instead (PlayerSettings.bundleVersion,SetScriptingDefineSymbols,SetApplicationIdentifier, …) followed byAssetDatabase.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: threesedversion 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)andOpenScene(..., 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 toSerializedProperty.objectReferenceValueclears 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 oneSerializedObject: 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 twoScriptableObject/PanelSettingsfields 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 acrossNewScene/OpenScene. Then verify by read-back: build a freshSerializedObjectover 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
-executeMethodcall, soLoadAssetAtPathreturns null for an asset the same call just created — even afterSaveAssets+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
DestroyImmediatedoes not promptly release (a survival game: 8 captures → 12.5 GB → D3D12 device removal while VRAM was fine). Cache one camera + one RT, nullcam.targetTextureBEFORErt.Release, and release only onbeforeAssemblyReload/quitting— a per-shotReleaseResourcesdefeats the entire fix. SceneView screenshots smear through the SceneView's own TAA history (reproduced on an EMPTY scene): render through an explicit camera withantialiasing = 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. APanelSettings.targetTexturecapture is sound only when real frames elapse between binding and reading — split it intoBegin/Endacross 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.IndexOfbefore selecting or removing.IndexOfreturns a built-in entry's index but a CUSTOM entry's slot inside the custom list, whileSizeSelectionCallback,selectedSizeIndexandRemoveCustomSizetake the index across built-ins then customs. Measured: 7 built-ins, new custom size,IndexOf17 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 withGetBuiltinCount+ an identity check onGetGameViewSize(i), and sweep leftover temporary entries by label. -
EDIT-MODE-RENDERED-FRAME-COUNT-IS-FROZEN —
Time.renderedFrameCountstays 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 (internalEditorWindow.RepaintImmediately) and read; keep the frame-count wait for Play Mode, where per-frameGraphics.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
ApplyModifiedPropertiesreturn, neverBegin/EndChangeCheck; a window that is not shown gets NO editor tick, so itsOnInspectorUpdate → Repaintnever 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 hitsAssetDatabaseper 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-identicalEditorSceneManager.SaveScene/AssetDatabase.SaveAssetsstill 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 statusclean 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 -lathe targets after a reload and read the mtimes; never conclude "nothing was written" fromgit status. (DOTS: the SubScene consequence is in ECS core mechanics, SUBSCENE-REIMPORT-UNBAKES-THE-SECTION.) Fix: everyEnsure*returns whether it actually changed something,MarkSceneDirty/SaveScenehappen 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-opEditorUtility.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-REIMPORTSabove, and the more expensive half: a generator whose reuse path isLoadAssetAtPath(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 calledconfigure(existing)but nevercreate, and onlycreateresolves 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 thereturn 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. NeverDeleteAsset+CreateAssetto refresh — that mints a new GUID and breaks every reference: mutate the existing asset in place (existing.shader = fresh.shader;existing.Clearthen 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 reachesAssetDatabase.SaveAssetsis 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) AHideFlags.DontSavetexture assigned to a material that IS saved serialises asfileID: 0- persist the texture to an asset first. (b) A property setter that early-outs onnull(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 byDeleteAsset(path)thenCopyAsset(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: compareAssetPathToGUID(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
0forever 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.pixelsPerPointbeforeBeginPreview, or HiDPI crops the icon. - Preview lights need
HDAdditionalLightDataand 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.FindObjectsByTypeskips objects flaggedDontSave/HideAndDontSave, so a cleanup pass that searches for your scratch previews finds none and leaves them in the scene. Clean up withResources.FindObjectsOfTypeAll<T>filtered bygameObject.scene.IsValid(scene objects, not assets), or keep a list of what you created.FindFirstObjectByType<T>and the defaultFindObjectsByTypeskip inactive GameObjects, so a readback reports "none" for a disabled Volume or host. Read back withFindObjectsByType<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 itsUnityEngine.Objectreferences, useEditorJsonUtility.FromJsonOverwrite(EditorJsonUtility.ToJson(src), copy), then assert the two JSON strings are equal.JsonUtilitydrops 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.StartAssetEditingwrote its outputs withFile.WriteAllBytes, and none of them were imported: 0.metafiles afterStopAssetEditing. A raw file write does not queue an import, andStopAssetEditingimports 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.metaand loading throughAssetDatabase.LoadAssetAtPath.
NEVER-CLEAR-THE-EDITOR-ROOT-STYLESHEETS
- An editor window's UI Toolkit content pane painted empty on every tab. Its
TwoPaneSplitViewstacked 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.ussor 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
Cleara 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 withEditorJsonUtility.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.OnlyExistingAssetson 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 exampleSourceGuid:), asmdefGUID: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_Scriptinbound 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.