Unity Workflow LibraryUWLAbout

Topic 4 · Verify & test

Compilation & assembly membership

Why code will not compile, or was never compiled: asmdefs, defines, build profiles.

"Why won't my C# compile — or why was it never compiled?" Assembly membership, obsolete-as-error APIs, package shims, per-platform defines. A different question from "why won't my APK build" (the platform build page).

  • Unity 6000.5 + com.unity.mathematics CS0729: builtin [email protected] can ship an empty Forwarders.cs shim; TypeForwardedTo + module types in the same assembly → CS0729 on float3, math, float3x4, … and ScriptAssemblies stall. Local recovery: clear the body of Library/PackageCache/com.unity.mathematics@*/Unity.Mathematics/Forwarders.cs (module still provides types). Re-apply after Library wipe / package reimport. Prefer not embedding a fake empty Packages/com.unity.mathematics (can hide module types entirely). (Seen in a mobile strategy game: on Entities 6.5 / Unity 6000.5.0f1.)

  • PHANTOM-SCRIPT-NOT-IN-ASSEMBLY — a .cs file can sit on disk with a valid .meta and a resolvable GUID while Unity's compilation pipeline never adds it to ANY assembly, and the only symptom is CS0103 "the name X does not exist" reported from OTHER files. The file itself produces no error, no warning, and no missing-script marker — it is simply absent from the compile, so every type it declares is unresolvable to its neighbours even though the Inspector shows a normal MonoScript and AssetDatabase.LoadAssetAtPath<MonoScript> returns non-null. Diagnosis is one call, and it is definitive: CompilationPipeline.GetAssemblies(AssembliesType.Editor) → find the owning assembly → check whether sourceFiles contains the path. If the file is missing there, stop debugging the code: the code is fine and was never compiled. What does NOT fix it: AssetDatabase.Refresh (even ForceSynchronousImport | ForceUpdate), ImportAsset on the file, RequestScriptCompilation, or even RequestScriptCompilation(RequestScriptCompilationOptions.CleanBuildCache) — all of these succeed and change nothing, which is what makes the trap eat time. The fix is to give the file a new asset identity: delete the .cs AND its .meta, AssetDatabase.Refresh, then recreate the file at a DIFFERENT path. It then imports normally and sourceFiles count goes up by one. Root cause is a corrupted per-path AssetDatabase entry, usually from writing the file externally while the editor was mid-import/mid-compile — which is exactly what an agent does when it writes a script and immediately requests a compile, and is far likelier when a second session is editing the same project concurrently. Prefer verifying sourceFiles membership (not just "the .meta exists") before concluding a new script's compile error is about its contents. (Seen in a mobile strategy game: adding a fog-of-war visibility system: four compile cycles and a full clean build blamed on namespaces and asmdef refs; the file had simply never entered Game.DOTS's 169-file source list.) Amended v246 (a tower-defense game, second occurrence). Four corrections: (a) The orphan's OWN assembly stays GREEN. The error surfaces only where a type is named in code; a <see cref="..."/> doc comment is not a reference. a tower-defense game.Sim compiled clean at 122 of its 123 files because the orphan's only same-assembly mention was in a doc comment — the break appeared solely in the consuming Assembly-CSharp-Editor. So "assembly X reported 0 errors" never proves X's file list is complete, and the error's assembly is not the broken file's assembly. (b) A cross-assembly reference DETECTS the orphan; it does not enroll it. Adding a consumer cannot "keep a file compiled" — it only converts silence into CS0246/CS0103. Never write that claim into a comment as a safeguard; it is not one. (c) Sweep for orphans project-wide in one call instead of testing the file you suspect: build a HashSet of the union of every assembly's sourceFiles, then set-subtract it from AssetDatabase.FindAssets("t:MonoScript", <script root>). The remainder is every file compiled by no assembly. It is one line of output and it is exhaustive. (d) An editor restart also clears it, and preserves the GUID. Choose by whether the GUID is already serialized: grep it in *.unity/*.prefab/*.asset first. Never serialized (always true of a MonoBehaviour that has never compiled — no instance of it can exist) → the path change is free. Already serialized → restart instead, or the move silently detaches every component. When moving, the class name must stay the file name, so change the FOLDER, not the filename. ImportAsset(ForceUpdate) + RequestScriptCompilation were re-measured here and again changed nothing (10238 → 10238 total sourceFiles); the "does NOT fix it" list above is confirmed, not folklore. Grep this glossary BEFORE diagnosing a missing type — the second occurrence cost a domain reload that the rule already predicted would be wasted.

  • FIRST-PARTY-CODE-LIVES-UNDER-PACKAGES-TOO — a search scoped to Assets/ returns false negatives in any project with file: / junctioned packages. a tower-defense game: a lane grepped Assets/ for the interfaces its new Control Panel tab implements, got zero hits, and was one step from reporting "the tab cannot compile" — the types live in Packages/com.game.foundation/ (junctioned, manifest.json file:). Before reporting a negative about a symbol: search Assets/ AND Packages/ (and Library/PackageCache/ for registry packages), or ask the editor (CompilationPipeline.GetAssemblies, a type load). Cross-check a negative against a file that already uses the symbol and compiles. A zero-hit grep is a scoping claim, not a finding. (Lane B worker, a tower-defense game.)

  • ORPHAN-SWEEP-MUST-FILTER-DEFINE-CONSTRAINED-ASSEMBLIES: a project-wide "which .cs is in no assembly" sweep (CompilationPipeline.GetAssemblies sourceFiles vs every .cs) always lists the files of an asmdef whose defineConstraints are unsatisfied — those assemblies are switched off by design and look identical to real phantoms by count. one shipped project: 20 "orphans" were Latios.FlowFieldNavigation behind LATIOS_ADDON_FLOWFIELD, and the lane then dismissed its own true phantom as the same noise. Filter out asmdefs with unsatisfied constraints (or scope to your script root) so a non-zero result always means something. A sweep that cries wolf every run trains you to discard the true result. (Lane B.)
  • SCRIPTING-DEFINES-ARE-PER-PLATFORM — a define set on Standalone/Android is NOT set on iOS, and the failure is silent by construction. PlayerSettings.SetScriptingDefineSymbols is per NamedBuildTarget, so a define added during desktop development simply does not exist on a mobile target unless someone added it there too. Nothing warns: the code inside #if MY_DEFINE is preprocessed away, the build compiles clean, and the feature is absent at runtime. Framework mode-switches are the dangerous case — a define that selects a transform/backend mode (Latios LATIOS_TRANSFORMS_UNITY, gating 89 framework files) makes the missing platform build a different framework configuration, not merely a disabled feature. Audit by printing the define string for every NamedBuildTarget side by side and diffing them; treat any platform-unique define as a defect until justified. Cheap guard: an IPreprocessBuildWithReport that compares the active target's defines against a required set and throws. (Seen in a mobile strategy game: iOS carried only dUI_MANAGER while every other platform carried four defines including the one gating the whole animation bootstrap — an iOS build would have shipped every unit in bind pose with zero errors.)

Unity 6.5 turned the InstanceID family into compile ERRORS (not warnings)

6000.5 promoted the InstanceID -> EntityId migration from deprecation warning to [Obsolete(..., error: true)], so these are CS0619 hard errors, not CS0618 warnings:

Erroring API Replacement
Object.GetInstanceID Object.GetEntityId
EditorUtility.InstanceIDToObject(int) EditorUtility.EntityIdToObject(EntityId)
AssetDatabase.GetAssetPath(int) AssetDatabase.GetAssetPath(Object) (or the EntityId overload)
AssetPreview.IsLoadingAssetPreview(int) ...IsLoadingAssetPreview(EntityId)
SerializedProperty.objectReferenceInstanceIDValue ...objectReferenceEntityIdValue

This breaks third-party Asset Store code you did not write (and editor tooling — see the Live play testing via Unity MCP page). Upgrading an existing project to 6.5 will surface errors in plugins that have shipped unchanged for years.

Prefer the overload that was never deprecated over the new one. For the AssetDatabase case, GetAssetPath(Object) has always existed and is not obsolete — it fixes the error without pinning the file to 6.5+, which matters in vendor code you may re-import later. Reach for GetEntityId only where no such overload exists.

A failed assembly hides every error downstream of it. Assemblies that reference a broken one are never compiled, so their errors are simply absent from the console — a two-error console can be concealing a hundred. After fixing the first wave, always recompile and re-read before claiming the project is clean, and sweep the whole tree for the offending API rather than fixing only what the console listed.

DEDICATED-SERVER-IS-A-DEFINE-NOT-A-PLATFORM - you cannot excludePlatforms your way off the server

There is no "Server" entry in an asmdef's includePlatforms / excludePlatforms. Dedicated Server is a build SUBTARGET of Standalone, not a platform, so the only platform token that would match it is WindowsStandalone64 / LinuxStandalone64 - excluding that takes the normal client build with it. Measured vocabulary actually in use across ~170 package asmdef platform entries in a Unity 6 project: Editor (164), iOS (3), macOSStandalone (2), WindowsStandalone64 (2), WebGL (2), LinuxStandalone64 (2), WindowsStandalone32 (1). No Server, anywhere. Verify with grep -rl '"Server"' Library/PackageCache --include=*.asmdef before believing any plan built on one.

The code lever is "defineConstraints": ["!UNITY_SERVER"] on the asmdef: UNITY_SERVER is defined for the Server subtarget, so the assembly is simply not compiled into that build.

Two things that look like they solve this and do not: - Multiplayer Roles content selection (com.unity.dedicated-server) strips render / UI / audio COMPONENTS out of scenes and prefabs. It never removes code. It is complementary to the asmdef fence, never a substitute, and ContentSelectionSettings is internal to the package - script it through the public EditorMultiplayerRolesManager.SetMultiplayerRoleForBuildProfile, and read the strip flags off ProjectSettings/Packages/com.unity.dedicated-server/ContentSelectionSettings.asset. [RequireComponent] makes a component permanently unstrippable (a space sandbox game). Unity refuses to remove a component another component declares a dependency on, so m_StripUIComponents: 1 silently fails on every object whose behaviour declares [RequireComponent(typeof(UIDocument))] - measured, 8 presenters declaring it, 4 "Can't remove UIDocument (Script) because X (Script) depends on it" errors, and 2 UIDocuments left alive and ENABLED in a server-role play session while cameras stripped to 0. Strip the whole GameObject by role instead of pulling a component out from under a dependent. And note what the strip FLAG is worth: reading m_StripUIComponents: 1 back out of the settings asset tells you the setting is on, never that anything was stripped - only counting live objects in a running server distinguishes "render=True ui=True" from "cameras 0, UIDocuments 2". A validator that reads its own configuration is not a measurement. - Managed stripping removes unreferenced IL, which is not the same as a guaranteed exclusion and cannot be asserted in a validator.

The real precondition nobody costs in: a folder with NO asmdef compiles into Assembly-CSharp, which takes no define constraint and is built for every platform and subtarget. So any folder still in Assembly-CSharp that touches a vendor or render-pipeline type pins that dependency onto the server no matter what you do to the vendor asmdefs - and worse, the moment you DO constrain the vendor assembly, Assembly-CSharp stops compiling on the server. A server-clean build therefore starts by giving every runtime folder an asmdef, and that ordering is the whole schedule: fence the leaves last, not first. Find the real reference surface with BOTH grep -rln "using <Vendor>" AND grep -rn "<Root>\.<Namespace>\." - a fully-qualified reference has no using line and is invisible to the first grep alone. (a space sandbox game, developer decision D-026 "headless server runs only its own code": the plan was written as an excludePlatforms change; no such token exists. Three of 35 Flow files carried the whole HDRP/vendor dependency, and a fully-qualified MyGame.Presentation.NavigationService call in a fourth was missed by a using-only grep and would have failed the dedicated build with an error pointing nowhere near its cause.)

Amendment (a space sandbox game, v243). A PREDEFINED assembly cannot reference a define-constrained one. The moment two runtime assemblies took !UNITY_SERVER, Assembly-CSharp-Editor could no longer see them and the whole editor tree collapsed into 30 errors, every one cascading from a single CS0234: 'Presentation' does not exist in the namespace 'a space sandbox game'. Giving the editor folder its own asmdef with explicit references fixed all 30 in one pass. So "give every folder an asmdef" includes the Editor folder, and it is a PREREQUISITE of the fence, not tidy-up afterwards. Do not reflexively stamp the same constraint on the editor assembly to make the error go away: that makes the tooling VANISH under a server profile instead of failing loudly, and it takes your measurement tools down at exactly the moment you want to measure a server. Unconstrained-and-loud beats constrained-and-silent.

ACTIVE-BUILD-PROFILE-REDEFINES-EDITOR-COMPILE - a server profile silently deletes your editor tooling

An ACTIVE Build Profile re-applies its subtarget on every domain reload. Select a Dedicated Server profile once and UNITY_SERVER is defined for editor compilation from then on, so every assembly carrying defineConstraints: ["!UNITY_SERVER"] is dropped from the compile - and setting EditorUserBuildSettings.standaloneBuildSubtarget back does NOT hold, because the active profile re-imposes it on the next reload. Clear or switch the PROFILE, not the subtarget.

What makes this dangerous is the shape of the failure: the compile reports ZERO ERRORS, because the dropped code is not compiled rather than compiled wrong. Measured in one real occurrence: 41 editor source files silently not built, the whole Control Panel down from 21 tabs to 2, and unity_get_compilation_errors returning count: 0 throughout. An agent polling the error count reports green and moves on.

  • Verify by CompilationPipeline.GetAssemblies or a type/tab count, never by the error count. An assembly that is absent produces no errors. This is the same family as MUTEX-DIR-NEEDS-OWNER-AND-EXIT-TRAP : a failure that presents as its own success.
  • BUILD-UNDER-ITS-OWN-ACTIVE-PROFILE : never switch profile inside the build call. SetActiveBuildProfile changes the defines, but the domain reloads only after the call returns. The build then writes the player's assets with Editor types compiled under the OLD profile's defines. It reports Succeeded, and the player dies at launch: "sharedassets1.assets is corrupted! [Position out of bounds!]". Measured: a client built while the server profile was active (its define list lacked two vendor defines the client profile carries) crashed on 2/2 launches. The rebuild, with the client profile activated and reloaded first, launched clean. So a build REFUSES unless its profile is already active. The developer's button switches the profile and asks for a second press after the reload.
  • The loaded domain also lies for a while after an asmdef change: TypeCache and AppDomain.GetAssemblies can still report the OLD layout while the pipeline already has the new one. Trust the pipeline, and re-check after a real reload. (a space sandbox game: a cancelled dedicated-server build left Windows Server as the active profile; the next reload deleted Presentation, UI and the editor assembly from the compile with 0 errors reported.)

EDITOR-PLATFORM-ASSEMBLY-HAS-NO-PLAYMODE-TESTS

  • A test .asmdef with "includePlatforms": ["Editor"] can only host EditMode tests. The Test Runner will not load it for a PlayMode run, so -runTests -testPlatform PlayMode reports total="0" and exits 0. A green pipeline and a folder named Tests/PlayMode then both assert play-mode coverage that does not exist.
  • Measured: 62 green "UI" tests, every one of them EditMode, none of which had ever run the scene. The first four real play-mode tests (load the shipping scene, let frames run, assert) immediately found a shipping defect the whole EditMode suite could not see.
  • total="0" is a failure, not a pass. Assert a minimum test count per platform in the runner, or the suite silently evaporates when an assembly's platform list changes.
  • A real play-mode assembly ("includePlatforms": []) cannot reference UnityEditor, so it cannot use AssetDatabase. That constraint is the point: it forces the test to load the shipping scene and inspect what actually booted, instead of building its own subject out of editor-only handles.

UNUSED-ASMDEF-REFERENCE-SHIPS-THE-LIBRARY (HARD)

  • An unused using line does not pull a library into the player. The C# compiler emits no assembly reference for it. What ships is: (1) every references entry on a runtime .asmdef, (2) every autoReferenced assembly, (3) every package in Packages/manifest.json.
  • An asmdef references row is a hard dependency. Unity includes that assembly in the player even when no type from it is named. A leftover Unity.Entities.Hybrid or a Foundation assembly on a world-gen asmdef that never mentions those types still cooks into the client.
  • A leftover render-pipeline package is the same defect at the next layer up. It is not inert: vendor megashaders keep the foreign SubShader alive and PrepareStageVariantsForSinglePlatform enumerates it (SHADER-VARIANT-PREP-RUNS-BEFORE-STRIPPING).
  • Sweep before every player cook. For each runtime asmdef (includePlatforms empty or Standalone), grep that assembly's scripts for the reference name and its namespace. Zero hits → drop the reference in the same slice, then cook. tools/asmdef-unused-refs.sh reports candidates; confirm with a type grep before deleting (Burst can appear as [BurstCompile] without using Unity.Burst).
  • Do not "keep it for later". A sentinel-only runtime assembly belongs on "includePlatforms": ["Editor"], not in the player.

OFFLINE-COMPILE-ONLY-COVERS-LISTED-SOURCES (HARD)

An out-of-editor compile that reuses the build system-s PINNED response file compiles exactly the sources that response lists, and nothing else. A file you just created is not in it. So a NEW file - a new type, and most dangerously a new TEST - passes the compile by never being compiled at all, and the run reports clean.

  • It fails in both directions. A new file that the response omits but existing code REFERENCES produces phantom -type or namespace not found- errors on a tree that is actually healthy; the same omission on a file nothing references yet reports a false PASS. Measured: a guard test compiled clean on its first run without existing, and separately, two missing-type errors were briefly read as a broken integration branch when the type was present and merely unlisted.
  • Grep the GENERATED response file for each changed file-s FULL PATH before quoting a pass. It beats watching the source count, which only says a file moved, and it names the assembly that picked the file up. The full path, never the basename: the pinned response is generated against the shared checkout, so a branch file overrides it only if the tool-s resolver found it, and a basename match is satisfied by the WRONG TREE-s copy - you then compile the integration branch-s version of a file you are certain you just edited. A count moving does not catch that either.
  • Have the response include a whole folder or glob for the kinds of file that get added often (tests especially), rather than naming files or matching one lane-s pattern.
  • A lane-s glob list silently defines what -pass- means, and nothing signals the boundary. When lanes each compile their own folder set, one lane adding a file outside a peer-s globs gives that peer phantom errors with no hint of the cause. The per-file path check is what closes it; neither lane is told.
  • Never report an integration branch broken on a pinned-response compile alone. Check whether the missing symbol exists in the tree and is simply absent from the response, before telling anyone the branch is red.
  • An assembly the tool does not rebuild is linked from the Editor's last build, and that is stale too (amended). A lane compiling only its own assemblies linked a prebuilt dll older than a peer's merged members, and the peer's source failed against it; it was reported as the peer's defect on a healthy tree. Rebuild every assembly that the compiled sources reference, and keep ONE list of files merged into the integration branch that the pinned response lacks, compiled by every lane and pruned once the Editor recompiles, instead of per-lane copies.

OFFLINE-PLAYER-PASS-OVERWRITES-THE-EDITOR-OUTPUT

An offline compile with a player (or server) define set wrote to the same output folder as the Editor-define pass. A recipe compiled against that folder next reported editor-only members (#if UNITY_EDITOR statics) as missing, on a tree that was healthy. Run the Editor pass LAST, or give each define set its own output folder, before compiling or running anything that uses editor-only members.

PURE-LOGIC-RUNS-WITHOUT-THE-EDITOR (0.3.99)

An EditMode test needs the Editor, and the Editor is a single seat: when another lane holds it, "compiles clean" is the only evidence left and the assertions in a new test file have never once been executed. Anything that does not touch engine objects can be run anyway, in about five minutes, against the assembly the offline compile already produced.

Point csc at the built DLL plus the packages it needs and the Editor's own .NET runtime, and run the result as a console program. The pieces:

  • reference <Editor>/Data/NetCoreRuntime/shared/Microsoft.NETCore.App/<ver>/ for System.Private.CoreLib, System.Runtime, System.Console and netstandard - without the last one, a struct from a package fails with "the type 'ValueType' is defined in an assembly that is not referenced";
  • a package type may be type-forwarded: Unity.Mathematics.float3 resolves through UnityEngine.MathematicsModule.dll, so reference both it and the package assembly;
  • the compiled package DLL lives under Library/ScriptAssemblies/, never the .ref.dll the response file cites - a reference assembly has no method bodies;
  • ship a <name>.runtimeconfig.json naming the framework, or the runtime refuses to start the program.

Worth the five minutes for more than a green tick: printing the function across its real input range catches a modelling error that passing tests cannot. A gather-aim cone measured a target by the corner of its mesh bounds, which passed every assertion while handing a flat, wide object half again the slack its silhouette deserved; the table of resulting angles at realistic distances is what exposed it. Do not claim the engine-side behaviour from this - it is the arithmetic, not the game.

Getting it to LOAD is the whole cost, and the failures arrive one at a time. The compile reference set is not the run set: a type that merely IMPLEMENTS an ECS interface drags the whole entities assembly and the engine core module in at runtime, even when the code under test touches neither and only does arithmetic. Each missing assembly surfaces only when the line that needs it executes, so a probe that checks numbers first and a string last prints eleven passes and then dies on the twelfth. Copy assemblies next to the program and re-run until it completes - the passes printed before a load failure are real results, not a broken run.

  • One dependency is not where the others are. Package assemblies come from Library/ScriptAssemblies/, but a package may ship a PREBUILT support assembly inside its own package folder that never appears there. Fixed-string EQUALITY routes through one such assembly, so a probe that only compares numbers never needs it and a probe that compares a name fails at exactly that line. When a load failure names an assembly missing from ScriptAssemblies, search the package cache for the DLL before concluding the technique does not apply.
  • Add the engine core module as soon as anything package-typed is involved; it is the first failure in practice, not an edge case.