---
title: "Platform build pipeline"
slug: /workflows/platform-build/
markdown: /workflows/platform-build.md
section: build-platform
tier: 3
summary: "Android/iOS/IL2CPP, artefacts, build modes and device testing."
sources: ['unity/platform-build.md']
canonical: https://unityworkflowlibrary.dev/workflows/platform-build/
read_first: /read-first.md
---

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

# Platform build pipeline

> Android/iOS/IL2CPP, artefacts, build modes and device testing.

Android/iOS/IL2CPP, scripting defines, Gradle and vendor SDK ports. Mostly failures that no editor check can catch because they only exist at build or launch time.

- **PRODUCT-VERSION-IS-THREE-SEGMENT — product version is `MAJOR.MINOR.PATCH` in repo-root `VERSION.txt`, and that string must equal `PlayerSettings.bundleVersion` / `Application.version`.** Start development at `0.0.1`; increment PATCH for every product change; PATCH 0–99 rolls into MINOR (`0.0.99` → `0.1.0`) and never persists as 100. Keep MAJOR at 0 throughout development. Advance only to `1.0.0` when the developer explicitly requests the production release; do not roll into MAJOR automatically. Android `bundleVersionCode` / platform `buildNumber` are monotonic and never go backward. Edit `bundleVersion` through the PlayerSettings API (`PROJECTSETTINGS-EDIT-VIA-API`) — a disk edit of `ProjectSettings.asset` while the editor is open reverts. The file is **`VERSION.txt`**, never bare `VERSION` (next bullet). Project spec lives at `docs/GAME-VERSION.md` when the title has one.
- **VERSION-BUMPS-AT-THE-MERGE — bump PATCH where changes serialize, in the merge into the integration branch, never per commit in parallel branches.** Per-commit bumps in parallel worktrees collide (two branches both claim `x.y.12`), so they get skipped, and the number stops meaning anything: one project found 627 product commits behind a version that never moved. Rule: every merge into the integration branch that touches product files (Assets, ProjectSettings, Packages) bumps PATCH in the merge commit, reading the number from the remote branch AFTER the merge; a rejected push re-merges and re-reads, so no two merges share a number. A repair counts the missed commits (`git rev-list --no-merges --count <last-bump>..origin/<branch> -- Assets ProjectSettings Packages`) and adds one per commit. Write each active build profile's `bundleVersion` override too: an active profile overrides the global PlayerSettings value.
- **SERVER-VERSION-LINE + BUILD-STAMP — a dedicated server keeps its own version line and every build names its commit.** `SERVER-VERSION.txt` starts at the client version it diverged from; server-only changes bump it, client-only changes bump `VERSION.txt`, shared contracts (RPCs, replicated components) bump both. The build step writes `BUILD.txt` beside the exe (client version, server version, `git rev-parse --short HEAD` plus `+dirty` when product files were uncommitted, UTC time); each client sends that stamp once on join and the server logs and lists any that differ. Two version numbers say THAT builds differ; the commit pair says HOW (`git log <a>..<b>`).
- **Android IL2CPP: never put a bare file named `VERSION` at the project root.** NDK libc++ does `#include <version>` (C++20 header). On Windows the FS is case-insensitive, so the project-root `VERSION` (game semver text) is picked up instead and every translation unit dies with `version(1,1): expected unqualified-id`. Use `VERSION.txt` / `GAME_VERSION` / a non-root path. (Seen in a mobile strategy game: Android build.)
  - **Generalize the check: ANY extensionless file at the compiler's include root whose lowercase
    name matches a C++ standard header is poison** — `version`, `ratio`, `string`, `array`, `list`,
    `map`, `set`, `vector`, `memory`, `new`, `limits`, `numeric`, `optional`, `atomic`, `chrono`,
    `format`, `span`, `bit`, `concepts`, `ranges`, `future`, `thread`, `mutex`, `regex`, `random`,
    `complex`, `bitset`, `functional`, `iterator`, `tuple`, `utility`, `variant`, `any`, `filesystem`.
    `LICENSE`, `README`, `CHANGELOG`, `Dockerfile` are all safe. Audit is one line —
    `ls -la | awk '$0 ~ /^-/ {print $NF}' | grep -v '\.'` — and it is worth running once per repo
    rather than waiting to recognise the one name that already burned you.
  - **DELETE-AND-IGNORE, never just add the replacement — a poison file left TRACKED IN GIT is a
    time bomb that re-arms on the next checkout.** This is the half that gets skipped: the
    fix correctly introduced `VERSION.txt`, but nobody ran `git rm` on the bare `VERSION`, so it sat
    committed and clean for 18 days and a routine checkout restored it — costing a **37-minute**
    Android build that died in `Postprocess built player` with 19 identical
    `version(1,1): error: expected unqualified-id` failures. Nothing had changed in the build config;
    the repo simply handed the file back. So the fix is three parts, and the third is the one that
    makes it stick: (1) create the replacement, (2) `git rm` the poison file, (3) add it to
    `.gitignore` (anchored — `/VERSION`, not `VERSION`, or you also ignore every nested one) with a
    comment naming the failure, and verify with `git check-ignore -v <file>`. Rule of thumb beyond
    this instance: **when the remedy for a landmine is "rename it", the rename is not done until
    version control can no longer produce the old name.** Same shape as the vendor-residue trap where
    the generated Gradle project keeps handing back a deleted vendor file.
  - **The error names the STDLIB header, not your file — read the include chain, not the last line.**
    Clang reports the chain (`ratio` → `<version>`) and only the final frame carries your repo path,
    so the failure reads as a broken NDK/toolchain rather than a stray file, and the `-ferror-limit`
    `fatal error: too many errors emitted` at the bottom buries it further. (a mobile strategy game.)

- **MOBILE-TEXTURE-AUDIT-RESOLUTION-NOT-FORMAT — when a mobile build is too big, measure before
  optimizing: compression is usually already correct and RESOLUTION is the lever.** The reflex is to
  hunt uncompressed textures; on a real project the importer defaults have almost always handled
  that. Measured (a mobile strategy game, 771 textures): compression was ETC2 across the board with
  only 7.7 MB uncompressed — and that 7.7 MB was *data* textures (splat, walkability mask, colormap,
  a LUT) that MUST stay raw. Meanwhile 115 textures over 1024px were **413 MB of a 495 MB** payload.
  Capping units/buildings at 1024 and VFX at 512 took the shipped total to 121.6 MB with formats
  byte-for-byte unchanged.
  Method notes that matter: (1) source file size is NOT shipped size — a 64 MB 4096² TGA ships as
  ~10 MB ETC2, so rank by `min(source, maxTextureSize)² × bits-per-pixel × (1.333 if mipmapped)`,
  not by bytes on disk; (2) **a cap above a texture's native size is a no-op** — 515 icons at 256²
  carrying a 2048 cap looked like an easy win and yielded 0.2 MB, so exclude already-small art from
  the projected saving instead of counting it; (3) exclude data textures, terrain, sky and font
  atlases by path BEFORE the pass, and say which were excluded; (4) hold SHARED atlases (a clothing
  atlas serving ~20 units) one tier higher than single-object textures — halving the one texture
  every unit samples is where softness actually shows; (5) always DRY RUN the classification and
  print per-category before/after, because an importer sweep's blast radius is the whole matched
  set (see the AssetPostprocessor blast-radius rule in the assets serialization page);
  (6) `SaveAndReimport` also migrates the `.meta`
  serialization (`serializedVersion`, `ignorePlatformSupport`, materialized platform blocks), so the
  diff is wider than the property you set — verify no FORMAT changed by re-reading the format
  distribution afterwards, rather than assuming only your field moved.

- **PLAYER-COOK-SWEEPS-UNUSED-ASMDEFS (HARD) — before every player cook, drop unused
  runtime asmdef `references` and leftover packages that no player script names.** Managed
  stripping (next bullet) cannot save you from a reference you still declare: the assembly is
  considered used and ships. Run `tools/asmdef-unused-refs.sh` from the project root, confirm
  each hit, remove the ref, then cook. Same slice as the cook, not a later cleanup. Detail:
  the compile assemblies page UNUSED-ASMDEF-REFERENCE-SHIPS-THE-LIBRARY. A keep-all
  shader-variant vote is the opposite of this rule — HDRP already keeps the lighting keywords
  the HDRP asset uses; blocking that strip compiles tens of thousands of unused Forward
  variants.

- **MANAGED-STRIPPING-MINIMAL-IS-THE-DEFAULT — Unity strips unreferenced ASSETS, not CODE. At the
  IL2CPP default (`Minimal`) every runtime assembly under `Assets/` and `Packages/` ships whole.**
  The developer's model ("Unity strips what scenes don't use") is true for textures and audio and false
  for code: Minimal trims only UnityEngine and the .NET class libraries, so every asset-store plugin,
  every unused Mirror transport and every demo assembly is compiled to C++ and linked. Measured (a mobile card game, a uGUI card game): 942 MB of generated C++, 68 MB of it BouncyCastle from an
  Encryption transport nothing referenced; arm64 `libil2cpp.so` 114.6 MB; App Store install 315 MB.
  Two PlayerSettings, no code, took it to 373 MB of C++ / 46.3 MB / AAB 216 -> 144 MB:
  `ManagedStrippingLevel.Medium` and `Il2CppCodeGeneration.OptimizeSize` ("Faster (smaller)
  builds" — generics were 46% of the C++). Rank what to expect by the generated-C++ size per
  assembly in `Library/Bee/artifacts/<Platform>/il2cppOutput/cpp` and the survivors in
  `.../ManagedStripped`; both are free to read after any build.
  **The price is a `link.xml`, and it is the whole contract:** the linker keeps only what static
  references reach, so anything resolved by NAME at runtime is listed there — your own assembly
  (JsonUtility DTOs, save snapshots, weaver output), Newtonsoft, DOTween, attribute-discovered
  effect libraries, native bridges with string callbacks. UGS packages self-preserve with
  `[Preserve]`; Unity's Newtonsoft package ships a link.xml that only covers `System.ComponentModel`
  converters. Deliberately leave dead plugins OFF the list so they strip, and still delete them —
  the linker keeps a stub of each. Never answer a `MissingMethodException` on device by going back
  to Minimal; add the line. It compiles, plays in the editor, and dies only in a release build.

- **NPOT-MULTIPLE-OF-4-SHIPS-RAW-RGBA32 — the exception to "compression is usually already
  correct": a sprite whose size after the max-size downscale is not a multiple of 4 cannot be ETC2
  (or crunched ETC2) and Unity silently ships it RGBA32.** The importer still reports the
  compressed format; only the built texture is raw, and `Texture2D.format` on the imported asset
  for the active target shows it (`1024x775 -> RGBA32`, next to a `512x512 -> ETC2_RGBA8`). Measured (a mobile card game, 477 textures): 169 shipped raw, 114 of 135 MB, including 80 the previous
  audit had "crunched" to no effect. ASTC has no such rule (verified: a 42x64 sprite compresses
  with an ASTC override), so iOS and ASTC-default Android are exempt and the bug hides on the
  platform you test. Fix shape: sprite atlases (pages are POT) or sources resized to multiples of
  4; a max-size cap alone just picks a new non-multiple. Audit by joining the build log's per-asset
  sizes to imported dimensions and flagging `size ~= w*h*4`.

- **BUILD-WINDOW-COMPRESSION-IS-A-BUILDOPTION — the Build Settings "Compression Method" is not
  project state; the window adds `BuildOptions.CompressWithLz4[HC]` per build.** A scripted
  `BuildPlayer` (or an MCP build tool) that passes `BuildOptions.None` ships loose uncompressed data
  — on Android `data.unity3d` vanishes and the Data folder went 84 -> 204 MB — so its size numbers are
  not comparable to the developer's builds. Read the window's choice with the internal
  `EditorUserBuildSettings.GetCompressionType(BuildTargetGroup)` and pass the matching flag.

- **IL2CPP-MASTER-IS-SHIP-ONLY — `Il2CppCompilerConfiguration.Master` left in PlayerSettings turns
  EVERY manual File ▸ Build into a ~30 min whole-program link, and nothing in the UI says so.**
  Master enables whole-program optimization: the link is not incremental, so a one-line change pays
  the same cost as a cold build, and the IL2CPP cache bucket is keyed on the configuration so it
  shares nothing with Release builds. The trap is that the setting is *correct* — a ship build wants
  Master — and `BuildPipeline.BuildPlayer` / the Build Settings window simply consume whatever
  PlayerSettings holds, with no warning, no progress hint distinguishing link from compile, and no
  per-build override in the UI. So the project is configured right and every iteration build is
  silently a ship build. The tell is a build whose Unity progress bar sits on the linker for tens of
  minutes while the editor is otherwise idle; check
  `PlayerSettings.GetIl2CppCompilerConfiguration(NamedBuildTarget…)` (YAML
  `il2cppCompilerConfiguration:` — `Debug=0, Release=1, Master=2`, and an ABSENT platform key means
  Release, so only the platforms actually listed are slow).
  **Fix shape — an iteration build path overrides the configuration per-build and restores it in a
  `finally`:** set Release, `BuildPlayer`, restore the project value in `finally` (not after the
  call) so a thrown `BuildFailedException` from a preprocess/postprocess guard cannot strand the
  project on the iteration configuration and silently downgrade the next ship build. Surface the
  cost next to the picker rather than in a doc nobody opens, and predict cache reuse BEFORE building
  — afterwards the fingerprint is already overwritten. Corollary: obfuscation / signing / symbol
  postprocessors usually gate on `!BuildOptions.Development`, so a manual non-dev build also pays
  those; run iteration builds as Development, or as Release-non-dev only when you need the real
  shipping surface. (a mobile strategy game: Standalone pinned to Master, a manual Windows build ran
  30+ min; the project's own Build & Ship tab already offered a Release iteration path.)

- **Android Active Input Handling must be Input System Package only — not Both.** Player setting `activeInputHandler: 2` (Both) logs *"unsupported on Android"* and breaks touch/perf. PC mouse/keyboard + mobile EnhancedTouch all live on the new Input System; use `InputSystemUIInputModule` (not StandaloneInputModule). (Seen in a mobile strategy game: Android build.)

- **MOBILE-TIER-FROM-HARDWARE — derive the graphics platform tier from the device at boot;
  never let it default to a desktop pref. And every UI panel host resolves its device scale
  through ONE shared helper.** Two paired mobile-ship traps: (1) a settings system whose
  platform-tier pref defaults to the desktop band boots phones on desktop defaults (long
  shadows, HQ post-FX, uncapped FPS → slideshow); force tier from `Application.isMobilePlatform`
  every boot and seed the mobile performance band ONCE so later player tuning survives.
  (2) When compact layouts key off `isMobilePlatform`, every PanelSettings host must re-reference
  through the same central resolve — one screen cloning a mobile reference locally while the
  rest use the raw desktop asset ships mixed UI scales (menu correct, HUD ~2.5× too small to
  tap). (Seen in a mobile strategy game: Android build: 15 fps + tiny in-game UI.)

- **STANDALONE-NATIVE-RESOLUTION — `defaultIsNativeResolution` is ignored when the player
  starts Windowed, and `Screen.currentResolution` is not the monitor.** If
  `PlayerSettings.fullScreenMode` is `Windowed`, Unity uses `defaultScreenWidth/Height`
  (often leftover 1024×768) and never reads native. Native-at-launch needs
  `FullScreenWindow`/`ExclusiveFullScreen` plus `defaultIsNativeResolution`. At runtime,
  "this monitor" is `Display.main.systemWidth/Height`. `Screen.currentResolution` in
  fullscreen is the *current swap size* — a 1080p pick on a 1440p display rewrites
  "native" if you seed from it. Persist width/height yourself; first-run seed from
  `Display.main`, then `Screen.SetResolution(w, h, mode)`. Edit those PlayerSettings
  fields via the API (`PROJECTSETTINGS-EDIT-VIA-API`, the editor tooling page).
  (Seen in a mobile strategy game: settings had display mode only, PlayerSettings was Windowed 1024×768 with native
  checked, and boot never called `SetResolution`.)


## Major editor-version upgrades

**BATCHMODE-CANNOT-UPGRADE-A-BROKEN-PROJECT (HARD).** `Unity.exe -batchmode -quit` will not finish
an in-place editor upgrade while any script fails to compile. It logs `Scripts have compiler errors`,
terminates with return code 1, and **does not stamp `ProjectVersion.txt`** — so the upgrade silently
did not happen. This is a chicken-and-egg: the upgrade is precisely what breaks vendor scripts.

Sequence that works:

1. Open the project **once in the GUI** with the target editor. It enters Safe Mode, but it stamps
   the version and migrates packages.
2. Fix compile errors from the shell.
3. Drive every subsequent compile headless:
   `Unity.exe -projectPath <p> -batchmode -quit -nographics -logFile <log>`.

A background shell wrapper's exit code is **not** Unity's. Read the log's last lines — `Shut down.`
after a clean run, versus `Scripts have compiler errors ... return code 1`.

**UNITY65-ENTITYID-DEPRECATIONS.** Unity 6.5 promotes a family of deprecations to `CS0619`
**errors**, and they land almost entirely in asset-store code rather than yours. The `InstanceID`
-> `EntityId` rename table, and the "prefer the overload that was never deprecated" fix, are
UNITY65-INSTANCEID-OBSOLETE-ERROR in the compile assemblies page; the two members of the
family that are NOT renames:

| Deprecated | Replacement |
| --- | --- |
| `TreeView` / `TreeViewState` / `TreeViewItem` | the `<int>` generics |
| `HierarchyProperty` | `HierarchyIterator` — a **redesign**, not a rename |


**The TreeView trap:** an asset that declares its own `TreeViewItem<T> : TreeViewItem` cannot take
the prescribed `<int>` fix — `TreeViewItem<int>` then binds to *the vendor's* generic and violates
its `where T : ...` constraint. Fixing it properly means renaming a vendor generic across every use.

For an **editor-only** tool the game never calls, park it instead of forking it: move the folder out
of `Assets/` **with its `.meta`** (GUIDs survive, restore is a move back), and record why in a README
next to it. Patching vendor code is debt that the next asset-store update silently reverts.

- **EDITORBUILDSETTINGS-MUST-BE-VERSIONED — an absent `ProjectSettings/EditorBuildSettings.asset`
  ships a BLANK player**, and on iOS may not even emit an Xcode project; the Build Settings window
  shows zero scenes after the first open on that checkout. Version the asset, and have the build
  script refuse unless the exact scene order is present. (a card game, Mac checkout.)
- **ANDROID-MIN-API-FOLLOWS-EDITOR — never copy an Android API floor from a project on an older
  editor.** 6000.5 raised the minimum to 26; `AndroidApiLevel24` is `CS0618` today and an error
  on the next release. (a card game, copied from a 6000.3 title's 25.)

## PRODUCT-RENAME-IS-A-SWEEP (HARD)

 `productName` and `companyName` are
keys, not labels. A rename, in global Player Settings or in any build profile override, lands in ONE
commit with all of:

- **Vendor state keyed on the name.** Carlos Wilkes' `CwShaderBundle` (Space Graphics Toolkit and
  siblings) stores `projectHash = Application.productName.GetHashCode`. On a mismatch its `AutoSwitch`,
  queued with `delayCall` after every script reload and so run when the Editor next gets focus, rewrites
  every generated `.shader` from the bundle and drops local shader patches. Measured: 28 bundles stale
  for seven weeks after a rename; one focus rewrote nine shaders and brought back 8 build errors. Set
  every bundle's hash, and add an EditMode test comparing each one to the current name.
- **Build profiles.** A profile that overrides `productName` must equal the global one; while it is
  active, `Application.productName` in the Editor is the profile's value.
- **Paths and prefs move.** `persistentDataPath` (`LocalLow/<company>/<product>`: saves, `Player.log`,
  tool inboxes) and the PlayerPrefs registry key (`HKCU\Software\<company>\<product>`) follow the
  name. Every setting resets unless migrated; say so in the release notes.
- **Grep for the old name** across docs, tools, recipes and agent memory.

## RELEASE-NOTES-DESCRIBE-THE-FIX (HARD)

- Before distributing a build, write release/fix notes for its exact version and build number. Each
  material fix states the previous symptom and the new user-visible behavior; add a concrete
  **What to Test** action and expected result.
- Use specific outcomes, not standalone "bug fixes", "improvements", or internal filenames. Include
  only changes present in that build; exclude plans and unfinished work, and do not claim
  verification that was not performed.
- Keep the project changelog and destination notes (TestFlight, store, or release page) consistent
  with that shipped scope. Respect the destination's length and locale requirements without
  silently dropping the description.
- After publishing, read back the notes attached to the exact remote version/build and locale.
  Verify their content, not just the binary upload result; report metadata as pending if it cannot
  yet be confirmed. A successful upload does not prove testers received the fix description.

## SERVER-BUILD-SHIPS-COMPILED-OUT-COMPONENTS

A dedicated server build logged about 80 `The referenced script (Unknown) ... is missing` lines, 6 `different
serialization layout` errors, 20+ `Trying to access a shader but no shaders were included ... Dedicated Server
Optimizations` warnings and an `ArgumentNullException ... shader` from `FontAsset.CreateFontAssetInstance`, although
Multiplayer Roles stripped the scene's UIDocuments. Role stripping removes only the engine component types listed per
role. Scene components from an assembly with `defineConstraints: ["!UNITY_SERVER"]` still ship as missing scripts, and
every client asset they reference (PanelSettings, fonts, VFX, item models) rides into the server build; a PanelSettings
that reaches the server then lays out text with a stripped shader.

- Strip them at build time: an Editor `IProcessSceneWithReport` that runs for the Server subtarget only and destroys
  every component whose assembly is compiled out under `UNITY_SERVER`, plus every component holding a managed
  reference to such a type.
- Never catch the font/shader exception or add shaders back to the server; the UI asset should not be there.
- Gate: a fresh server log carries zero missing-script and serialization-layout lines. Prefabs still reached from
  shared server data log on; trace their owner next.
- Amended: the scene processor never reaches ASSETS. After it, one server still logged 46 missing-script and 14
  layout lines per boot: ScriptableObject definitions the server loads (item, content references, building part, world
  screen) held client view prefabs as direct fields, so every prefab and its presentation scripts shipped in and loaded.
  Wrap each view-prefab field and its accessors in `#if UNITY_EDITOR || !UNITY_SERVER`. The Editor keeps the data across
  build-profile switches, client players keep it, and the server player neither serializes nor loads the reference. Then
  compile the runtime assemblies with the server player's defines (UNITY_SERVER, no UNITY_EDITOR): every server-compiled
  reader fails to compile, which is the reader list. Gate out the client-only systems and components it names, whole
  files where they only run in client worlds. An offline server compile must also drop assemblies whose constraint is
  `"!UNITY_SERVER || UNITY_EDITOR"`: true in the Editor, false in the player.

## HEADLESS-NULL-DEVICE-LOGS-ARE-BENIGN

- A `UNITY_SERVER` player (null graphics device) logs once per boot: Entities Graphics `No SRP present ... disabled`,
  `Warning: No Graphics Device found. Deformation systems will not run.` and `No texture data is available to
  upload.`. Engine systems created in the server's local world self-disable. Record them as benign in the log
  inventory and count them out of error budgets; do not suppress them or chase them as defects.

## ENTITIES-CONTENT-FOLLOWS-THE-ACTIVE-TARGET

- `BuildPipeline.BuildPlayer(BuildPlayerWithProfileOptions)` with an Android profile, started while the Editor's active
  build target was still StandaloneWindows64, reported Succeeded, but its Entities runtime content (the entity scene
  files behind the subscenes) was built for the active target (build target 19, standalone). On the device the log said
  "file created for another build target ... File's Build target is: 19", the subscene never loaded, and the game's
  readiness gate timed out on boot. Measured: one APK broke this way; the next build, made after the switch, booted on
  the same phone.
- Switch the active build target to the profile's platform first (`EditorUserBuildSettings.SwitchActiveBuildTarget`,
  and wait for the reimport), or build a second time after the switch. "Succeeded" and a valid APK prove nothing about
  baked content.
- Gate: on the first device boot, grep logcat for `another build target`, then start a real match on the device. See
  ACTIVE-BUILD-PROFILE-REDEFINES-EDITOR-COMPILE in the compile assemblies page for the compile side of the
  same active-target coupling.

## ONE-BUILD-MODE-PER-TEST-CYCLE



- **The player build cache lives in `Library/`, not in the output folder.** Bee, ShaderCache, Burst and the generated
  Gradle project are all under `Library/`, so building into the same folder saves nothing. Keep one fixed dev folder
  anyway (e.g. `Builds/<Platform>/Dev/`) so testers always know where to look. Corrected: the file name carries
  the version, `<Product>-<version>-dev.apk` (ARTIFACT-NAME-CARRIES-THE-VERSION below).
- **Release builds only on the developer's explicit request** (corrected;  A batch close, a version bump or a finished lane is not that request.
- **The real costs are the first-ever import for a platform and switching build modes.** Measured on one Android
  project: the first Android import took 1:05:55. Alternating Development and Release builds recompiled shader
  variants and IL2CPP from scratch: a dev build after a release build compiled its 2304-variant batches anew, about
  60 min. Pick one mode per test cycle and stay in it; see IL2CPP-MASTER-IS-SHIP-ONLY for the compiler configuration
  side.

## BUILD-FOLDER-SHIPS-DEBUG-ARTEFACTS (HARD)

 A client patch measured 22.8 MiB where the game change was 7.6 MiB: the rest was debug output.

- A Development build writes one PDB per assembly beside the player (measured 219 files, 92 MB, the Burst library's
  PDB alone 77 MB). EVERY build, Release included, writes `<product>_BurstDebugInformation_DoNotShip` (2.2 MB) beside
  the exe; IL2CPP adds `*_BackUpThisFolder_ButDontShipItWithYourGame`. Unity writes them into the output folder and
  nothing removes them, so zipping or uploading the folder ships them.
- After `BuildPipeline.BuildPlayer` succeeds, move them to a private symbol archive keyed by folder and version (crash
  symbolication reads them from there), then validate the folder against ONE rule list and leave the build unstamped on
  any finding. Run the same list from a menu/tool tab, a unit test and a CLI, and again at pack/upload time with the
  run-data rules (saves, logs, archives written by running the exe) that a rebuild of a used folder would trip.
- Moving a Development build's PDBs away removes file:line from its log stack traces; archive them anyway if dev builds
  leave the PC, and read crashes against the archive.
- Gate: a validator exit code on the exact folder being packed, not a remembered pattern list per tool.

## DEVELOPMENT-PLAYER-IS-NOT-SHAREABLE (HARD)

- A `BuildOptions.Development` player writes `boot.config` with `player-connection-mode=Listen` and
  `player-connection-ip=<the build PC's LAN address>`, ships `WinPixEventRuntime.dll` on Windows, and compiles in every
  `DEVELOPMENT_BUILD` block (dev command inboxes, scripted input, debug probes). Measured: dev builds were zipped and
  sent to testers with all of it.
- Builds that leave the build PC are Release. A validator flags `player-connection-*` in `boot.config` and the PIX
  runtime when the build claims Release flavour.

## EDITOR-TOOLING-PACKAGE-SHIPS-IN-EVERY-PLAYER

- An Editor tooling package whose runtime asmdef has no Editor-only platform lands in every player and server build.
  Measured: an experimental compile-pipeline package shipped Roslyn (`Microsoft.CodeAnalysis*`) plus its IL interpreter,
  9.2 MB per build, with a dormant `RuntimeInitializeOnLoadMethod` bootstrap and, in Development players, a
  PlayerConnection receiver. Sample assemblies (`*DocCodeExamples`, `*DemoScripts*`) ride along the same way.
- List every non-engine DLL in `<Player>_Data/Managed` after the first build. Remove tooling with an Editor
  `IFilterBuildAssemblies` (keep the Editor copy), then prove the player boots with no `RuntimeInitializeOnLoad`
  or missing-type error in the player log before the filter enters shared builds; grep project code for the
  assembly first, since an empty grep does not cover the package's own bootstrap.

## DEVICE-TEST-RESTORES-PLAYER-DATA

- Playing real matches on the developer's phone changes their saved progress. On a Development (debuggable) build, back up
  `adb shell run-as <pkg> cat shared_prefs/<pkg>.v2.playerprefs.xml` and `/sdcard/Android/data/<pkg>/files/*` before the
  session, push them back afterwards (`run-as <pkg> cp /data/local/tmp/<file> shared_prefs/<file>`), and `cmp` to prove
  they are byte-identical. Git Bash rewrites `/sdcard` paths unless `MSYS_NO_PATHCONV=1` is set. `run-as` works only on
  debuggable builds, so a release build needs the developer's consent or a separate test profile.

## DEV-BUILD-OVERRIDES-IL2CPP-MASTER

- Amends IL2CPP-MASTER-IS-SHIP-ONLY above. A Development build does not change `il2cppCompilerConfiguration`: with
  Master saved in PlayerSettings, every dev APK paid the whole-program link. The dev build script sets
  `Il2CppCompilerConfiguration.Release` for the build, restores the saved value in `finally`, and records
  `configRestored=` in its receipt.
- Measured dev APK times on one project: 1:05:55 (first platform import), 1:00:41 (first build after a target
  switch), 0:25:05 (warm `Library/Bee`, a few changed files). The first Release-configuration build is cold, because
  the cache is keyed by configuration; time the second one before claiming a gain (see ONE-BUILD-MODE-PER-TEST-CYCLE).

## EDITOR-CAPTURE-IS-THE-DESKTOP-TIER

- `Application.isMobilePlatform` is false in the Editor even with the Android build target active. So any tier chosen
  from it (see MOBILE-TIER-FROM-HARDWARE above) resolves to Desktop in every Editor capture.
- An Editor capture of a tiered effect or preset proves the Desktop tier only. Say so in the evidence class, and prove
  the mobile tier on a device, or through an explicit Editor preview override when the project has one.
- If mobile tiers need Editor review, add that override as one authored setting. Never add a second detection path.

## ARTIFACT-NAME-CARRIES-THE-VERSION (HARD)



- Every build artifact's file name carries the product version: `<Product>-<version>-dev.apk` for a development build,
  and `<Product>-<version>.apk` for a release build. The same applies to other platforms' artifacts.
- The build script derives the version from the project's version source of truth, the same file that sets the player
  version. Never type it by hand or copy it from the previous build. A name that disagrees with the running game's
  version is a failed build.
- The fixed dev folder stays, so testers know where to look (see ONE-BUILD-MODE-PER-TEST-CYCLE). A rebuild at the
  same version overwrites its file, and a version bump writes a new one.
- Gate: the build receipt prints the output path, and its version segment equals the version file and the installed
  game's reported version.
