---
title: "Assets & serialization"
slug: /workflows/assets-serialization/
markdown: /workflows/assets-serialization.md
section: architecture-csharp
tier: 2
summary: "Importers, YAML and serialized fields: keep data stable across refactors."
sources: ['unity/assets-serialization.md']
canonical: https://unityworkflowlibrary.dev/workflows/assets-serialization/
read_first: /read-first.md
---

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

# Assets & serialization

> Importers, YAML and serialized fields: keep data stable across refactors.

Everything between an asset on disk and the object Unity hands you: importers that lie about what they wrote, YAML that is not what it looks like, and writing assets from code without severing references.

- **A serialized enum is an INT in YAML — never map it to a name by inference (HARD).**
  Scene/prefab YAML stores `Type: 2`, not `Type: SizeDelta`. Reading the int and *guessing* which
  member it names is a coin flip that silently corrupts authored data, because sibling members
  usually share a payload type and every wrong reading still looks plausible. Seen in practice: on a
  `UI_Animator` tween: an agent reported `Type=2 (MoveAnchored)`, the real enum was
  `FadeAlpha=0, MoveAnchored=1, SizeDelta=2`, and writing an anchoredPosition into what was
  actually the clip's **sizeDelta** target resized the element to those coordinates
  (`(503,445)` became a 503x445 rect) and destroyed its entrance animation. Both members store a
  `Vector2` in the same `EndValue` field, so the YAML alone could not disambiguate — and
  `(500, 72.78)` reads perfectly well as a bottom-left position.
  Rules: (1) open the `enum` declaration and count from 0, or compare by name via
  `SerializedProperty.enumNames[p.enumValueIndex]` — never `enumValueIndex == <literal>`;
  (2) a subagent that reports `N (SomeName)` from raw YAML has *inferred* that name — re-derive it
  before writing; (3) after writing serialized data, read back the **effect** (rect size, position,
  colour), not just the field you set.

- **Rewrite Unity YAML *values*, never the whole line (HARD).**
  ScriptableObject `.asset` files indent with two spaces. A sweep that runs
  `.replace(/\s{2,}/g, " ")` (or any collapse of runs of spaces) on the full line turns
  `  DisplayName:` and `    - Label:` into one-space YAML and silently breaks list nesting.
  Folded descriptions also wrap provenance across lines — `(SC 4.3p` on one line and `Name.)`
  on the next — so a same-line strip misses them. Rewrite only the value after the first
  `: `, restore indent from HEAD when healing a bad sweep, and match provenance tags
  across newlines.

- **Kitchen-sink ScriptableObject split: FormerlySerializedAs + forwarding properties (HARD).**
  Do not rename or delete serialized fields on the first compile of a split. Unity remaps YAML
  by **field name**; a `public CardInfo[] CardInfos` → property of the same name drops the
  array on save unless a `[SerializeField, FormerlySerializedAs("CardInfos")]` legacy copy
  still exists. Keep the original type as a composition root, add child-SO refs, expose the
  old names as properties that read the child when assigned and the legacy field otherwise.
  Copy into children via an editor migrator **after** that first load (properties still hit
  legacy). Consumers keep compiling; a half-migrated asset still Plays. Drain legacy arrays
  only after Play-verify. (a mobile card game: NetworkManagerSettings → Deck / Presentation /
  AI / Timing.)

- **SerializeField defaults do not migrate.** Changing a C# field initializer / default on a
  `[SerializeField]` does **not** update values already baked into prefabs, scenes, or ScriptableObjects.
  The asset keeps the old serialized value forever. "I set the default to false" that still runs true
  at Play is this trap. For hard toggles prefer non-serialized `const`/`static readonly`, or edit the
  asset/prefab explicitly; when "reverting" a serialized field, **verify the runtime value** (inspector
  or MCP), not the source default. (Seen in a mobile strategy game: feel/camera toggles; still burns agents weekly.)

- **Generator/tooling code REGENERATES an asset IN PLACE — never Delete+Create, which mints a new
  GUID and silently severs every reference to it.** Any editor generator that "refreshes" an asset
  (AnimatorController, ScriptableObject, mesh, material) by `AssetDatabase.DeleteAsset` +
  `Create*AtPath` breaks all prefabs/scenes/SOs pointing at the old GUID — they null out with no
  error and the breakage surfaces later as "the thing silently stopped working". Rebuild in place:
  load the existing asset, `DestroyImmediate(sub, true)` its stale sub-assets (state machines,
  blend trees, embedded children), reset its top-level arrays, and rebuild content into the SAME
  object — GUID stable, every reference survives. (Proven on generated unit AnimatorControllers:
  Delete+Create would have severed all 21 skin prefab Animator refs.)

- **A texture's shipped format is per-BUILD-TARGET — `tex.format` and the importer's
  `textureCompression` both lie about the platform you are not currently on.** Auditing mobile
  texture memory from the editor, `Texture2D.format` reflects only the **active** build target, and
  `TextureImporterPlatformSettings.textureCompression: Compressed` records *intent*, not the format
  that actually results. The authoritative call is
  `TextureImporter.GetAutomaticFormat("iPhone" | "Android")` (reflection: it is public but
  undocumented), plus `GetPlatformTextureSettings(platform).overridden` to see whether an override
  exists at all. Two concrete traps this closes: (1) **crunch** compression requires both dimensions
  to be multiples of 4, so `ETC2_RGBA8Crunched` silently falls back to raw **RGBA32** on any NPOT-ish
  texture — a 1024×775 sprite lands at 3 MB while a 1920×1080 next to it compresses fine, and the
  importer still reads "Compressed"; (2) **ASTC has no such constraint** and has been the automatic
  iOS format since ~2021 (Unity 6 confirms `ASTC_6x6`), so the old "PVRTC needs square power-of-two"
  reflex is obsolete — do not recommend an iOS ASTC override as a fix when iOS already resolves to
  ASTC. Measure `Profiler.GetRuntimeMemorySizeLong(tex)` only after confirming which target you are
  reading. (Seen in practice: auditing a mobile card game iPad framerate: 109 gameplay textures measured as
  139.7 MB RGBA32 on the Android target and were reported as an iOS memory bug — iOS ships them
  ASTC_6x6 at ~1/8 that, and the real fallback was Android crunch-on-NPOT.)

- **Scene `.unity` YAML does NOT contain prefab-instance contents — every grep-based scene census
  undercounts by exactly the components that live in prefabs.** A `PrefabInstance` block stores only
  the prefab GUID plus a property-override list, so `grep -c '^--- !u!95 &'` (Animator),
  `!u!223` (Canvas), component-GUID frequency counts, etc. see nothing inside instanced prefabs —
  and objects instantiated at runtime are invisible too. Symptom: a perf/structure audit confidently
  reports "N Animators, zero ParticleSystems" and misses the always-active animators that are
  actually costing the frame. Recipe: open the scene (`EditorSceneManager.OpenScene(path,
  OpenSceneMode.Additive)` in edit mode, `CloseScene(sc, true)` after) and walk
  `GetComponentsInChildren<T>(true)` from each root — that expands prefabs and reports real
  `isActiveAndEnabled` state; separately account for runtime-instantiated objects from the spawning
  code. Cross-check both numbers before ranking anything. (Seen in practice: on a mobile card game: YAML grep said
  42 Canvas / 267 CanvasRenderer / 197 raycast targets; the loaded scene had 53 Canvas / 38 active,
  559 Graphics / 173 active — and five per-seat `Pass-Animation` Animators appeared only via the
  prefab.)

- **A scene saved while a script class is unresolvable embeds a scene-local `MonoScript` stub
  (`!u!115`) pinned to the OLD class name — the missing-script breakage then SURVIVES fixing the
  code, because `m_Script` points at the stub, not the script's GUID.** Mid-rename (namespace/class
  move) any scene save rewrites affected components from
  `m_Script: {fileID: 11500000, guid: X, type: 3}` to `m_Script: {fileID: <local number>}` plus an
  embedded `MonoScript` block carrying the dead `m_ClassName`/`m_Namespace` — so after the rename
  lands and compiles clean, those components STAY missing (Inspector shows empty GameObjects,
  runtime logs "referenced script … is missing"), and repair actions that re-create components can
  mask which scenes are still broken. The serialized DATA under the component is intact — nothing
  was lost. Audit: `grep -n 'm_Script: {fileID: [0-9-]*}$' Assets/**/*.{unity,prefab,asset}` —
  every hit without a `guid:` is a dead stub ref. Fix in place: repoint `m_Script` to
  `{fileID: 11500000, guid: <script .cs.meta guid>, type: 3}` and delete the orphaned `!u!115`
  blocks; all wired refs revive untouched. Stale `m_EditorClassIdentifier` strings with a VALID
  guid are cosmetic (Unity rewrites on next save) — do not chase them. (Seen in a mobile strategy game: 6 bridge components across 4 flow scenes lost their scripts in the
  a survival RTS→a mobile strategy game rename; map + race catalog "didn't exist" at match load while every
  asset ref was still perfectly wired.)

- **Long-form music MUST import as `Streaming`, and never dirty-check `AudioImporter.threeD` — it
  is a legacy no-op whose write does not persist.** Two paired traps when adding a music library.
  (1) The audio importer default is `DecompressOnLoad`, which expands a track to raw PCM in RAM
  (~35 MB for 5 minutes stereo) and ships the source WAVs in the build — a 38-track album set
  lands at 1.6 GB and is simply unshippable on mobile. Music (and any clip over ~30 s) wants
  `loadType = AudioClipLoadType.Streaming` + `AudioCompressionFormat.Vorbis` at quality ~0.5 +
  `loadInBackground = true`; keep `DecompressOnLoad` for short SFX only. (2) The obvious
  "normalize the settings" helper compares current vs desired and reimports when they differ —
  but `AudioImporter.threeD` is a Unity-4-era field that still EXISTS and still READS the meta's
  `3D: 1`, while assigning it is silently ignored (spatialization moved to
  `AudioSource.spatialBlend`). Including it in the comparison makes every clip read dirty forever:
  the idempotent "reimport only what changed" pass re-encodes the ENTIRE library on every run and
  any "N clips still need fixing" counter never converges. Diagnose by reading the flags back
  after `SaveAndReimport` — the field that did not change is the no-op. Same class as
  `textureCompression` reporting intent rather than result (see the per-BUILD-TARGET rule above):
  trust a read-back, not the write. Pair the one-shot pass with an `AssetPostprocessor`
  (`OnPreprocessAudio`, gated on `assetImporter.importSettingsMissing`) so future drops import
  correctly on first touch without stomping designer overrides. (Seen in a mobile strategy game: adding a 4-album, 38-track soundtrack.)

- **MODELIMPORTER-MATERIAL-SETTERS-SERIALIZED — `ModelImporter.materialLocation` /
  `.materialImportMode` C# property writes can silently NOT persist; set them through
  `SerializedObject` + `WriteImportSettingsIfDirty`, or your material remap resolves to an empty
  slot.** Symptom: `AddRemap(new SourceAssetIdentifier(typeof(Material), "<name>"), mat)` +
  `SaveAndReimport` reports a perfectly correct external-object map (`m_ExternalObjects` shows
  `Material_0 -> M_Foo`), the model file genuinely contains that material (binary FBX stores it
  as `Material_0\x00\x01Material`), `m_Materials` lists the same name — and the imported
  renderer's `sharedMaterial` is still **NULL**, i.e. an untextured white model with no error
  anywhere. Cause: the meta had `materialLocation: 0` (InPrefab) the whole time; the property
  setter reads back the value you assigned in memory, so the usual "set it, print it, it's
  right" check passes while the reimport uses the old serialized value. Remaps only apply on
  `materialLocation: 1` (External). Fix: `var so = new SerializedObject(importer);
  so.FindProperty("m_MaterialLocation").intValue = 1;
  so.FindProperty("m_MaterialImportMode").intValue = 1; so.ApplyModifiedPropertiesWithoutUndo;
  AssetDatabase.WriteImportSettingsIfDirty(path);` then reimport. Diagnose by reading the
  `.meta` on disk, never the importer object — same "trust a read-back, not the write" class as
  `AudioImporter.threeD` and `textureCompression`. Note `materialImportMode.ToString` printing
  `LegacyImport` for the value you set as `ImportStandard` is a red herring (aliased enum value),
  not the fault. (Seen in a mobile strategy game: swapping an AI-generated model in place.)

## An AssetPostprocessor's path IS its blast radius (HARD)

`OnPreprocessTexture` / `OnPreprocessModel` / etc. apply to **everything under the path you test for**,
with no confirmation, no diff, and no undo prompt. Get the path one folder too wide and you have
silently rewritten import settings across somebody else's assets.

Measured (a tic-tac-toe game): an importer written to force UI Toolkit's atlas-friendly settings was
scoped to `Assets/4 - Art/UI/` - which was the project's ENTIRE existing UI art folder, not the new
subfolder it was written for. The next `AssetDatabase.Refresh` rewrote the `.meta` of **143 textures
the live uGUI menu uses**, forcing Sprite / no-mips / **Uncompressed**. Uncompressed alone multiplies
texture memory on a phone, and none of it was requested.

- **Scope to the narrowest folder that is entirely yours** - a `Generated/` or `UITK/` subfolder,
  not the category root.

- **Check source-control status immediately after the first refresh that runs it.** This failure is
  invisible in the editor and in the console; the only tell is a wall of modified `.meta` files in
  pending changes.
- Reverting is per-file. Watch for filenames that break shell tooling - the one straggler in that
  clean-up had **em-dashes** in its name that `cm status` rendered as `-`, so every path-based revert
  reported "not found" until the real bytes were read off disk.

## Writing assets from code — order, identity, and the null that isn't logged

- **One ScriptableObject TYPE per file, named after the class.** Unity resolves `MonoScript` by FILE
  NAME, so an SO sharing a `.cs` with another type serializes as `m_Script: {fileID: 0}` and loads
  back **null forever** — invisible at write time (right size, every field correct in the YAML, the
  in-memory object keeps working) until the next domain reload. Verify any new asset type by forcing a
  round trip (`UnloadUnusedAssetsImmediate` → `LoadAssetAtPath`) in the SAME pass that writes it,
  never by trusting the bake log. (a survival game baked 347 assets twice before finding this.)

- **`AssetDatabase.CreateAsset` serializes the main object IMMEDIATELY** — creating an asset whose
  array fields already point at in-memory objects that are not yet sub-assets writes those references
  BROKEN. Order: `CreateAsset` → `AddObjectToAsset` per sub-object → assign arrays → `SetDirty` →
  `SaveAssets`.

- **A Unity object reference does not survive `EditorUtility.UnloadUnusedAssetsImmediate` inside a
  bulk loop** — a freshly created asset nothing else references yet is collected mid-loop ("has been
  destroyed but you are still trying to access it"). Read what you need into plain C# data before the
  loop.

- **A container's `Add<T>` is not persistence.** `VolumeProfile.Add<T>` creates a floating
  in-memory `ScriptableObject` and only appends it to the profile's list; it never calls
  `AddObjectToAsset`, so it renders fine until the next domain reload and then resolves null. Follow
  every such call with `AddObjectToAsset` + `hideFlags` + `SaveAssets`, then verify BOTH
  `EditorUtility.IsPersistent(o)` and the object's literal presence in the `.asset` text —
  `LoadAssetAtPath` reads the in-memory AssetDatabase, which is exactly how the bug hides.

- **Every null-guarded `AssetDatabase.LoadAssetAtPath` must `Debug.LogWarning` the path.** A wrong or
  moved path is otherwise indistinguishable from "the asset loaded and did nothing" — a StyleSheet
  that fails to load opens the window with every `var(--*)` unresolved and it looks like a broken
  layout, with no error, and a future folder move re-breaks it just as quietly. (`AssetDatabase` does
  resolve `Packages/...` paths — use package-relative paths in reusable tooling.)

- **`AssetDatabase` does not exist in a player build.** Let editor tooling discover assets with
  `FindAssets` (a scan never goes stale, so a new asset appears immediately) and build a serialized
  catalog SO ONLY as the runtime index — rebuilt by an explicit button, with the tool showing a banner
  when scan-count ≠ catalog-count. And never `FindAssets(...)[0]`: it is silently wrong the moment a
  second matching asset exists (it is how a project ends up bound to a package-cache PanelSettings).

- **A texture used as DATA needs every import default turned off, and each one fails silently.** Turn
  **Alpha Is Transparency** OFF (it dilates RGB into low-alpha texels, and a data alpha channel is 0
  across most of the image), forbid **block compression** (it quantises channels and DXT1 has no alpha
  at all), and enable **Read/Write** for CPU sampling. Remember a blank data mask is all four channels
  ZERO — the opposite of a normal image. Unity has rewritten most of the image before the first sample,
  with no warning.

## Silent-config family

- **A newly ADDED `[SerializeField]` reads back as 0/default on every prefab and scene object that
  already existed** — not as its C# initializer. So a feature gated on `value > 0` silently disables
  itself everywhere while the code and the initializer both look correct. Give such gates an explicit
  `<= 0 → fallback` branch. (This is the sibling of "SerializeField defaults do not migrate" above:
  that one is about CHANGING an initializer, this one about ADDING the field.)

- **A validator that REFUSES an authored asset must fail LOUD — the fallback is a different config,
  not "the last good values".** Refusal must `LogError` naming the fallback ACTUALLY in force, the live
  values must be visible in a readiness/diagnostic surface, and any invariant coupling two authored
  fields must be raised in the same commit that changes either one. Note a player build usually has no
  editor seed path at all, so the C# fallback const is what ships — changing only the `.asset` is a
  config fork that first appears at build time. (a survival game: four play sessions silently ran a different
  movement backend and different gravity, reported as two unrelated-looking bugs.)

- **Every readiness gate ships with its timeout in the SAME commit.** A gate with no escape hatch turns
  a slow boot into an infinite screen — strictly worse than the bug it was added for. Expire, enter
  anyway, and log ONCE naming the failed condition AND its last value. Two companions: a signal that is
  never WRITTEN in a given build (an editor-only producer) must count as SATISFIED, not pending; and
  never key a "first time" test off a flag that is also cleared as "retry next tick".

- **TERRAIN-API-UNITS-AND-POLARITY — `SetHoles(true)` means terrain PRESENT (UE's mask is the
  inverse), `TerrainLayer.tileSize` is METRES, alphamaps are cell-centred and heightmaps
  vertex-centred (+0.5).** Wrong is plausible-looking: holes stay sealed and the render looks
  perfect, tiling is wrong-scaled, splats sit half a cell off. Assert the hole count against its
  expected fraction. (a survival game)
- **UDIM-UV-RANGE-BEFORE-TEXTURE — a vendor mesh whose texture "wraps wrong": dump `TEXCOORD_0`
  min/max FIRST.** u/v beyond [0,1] is UDIM tiling and no texture-side fix helps; the importer's
  `nPOTScale` / `maxTextureSize` defaults also crush non-power-of-two atlases. (a survival game)

## SERIALIZED-WRITE-NEEDS-THE-MATCHING-TYPED-ACCESSOR

- **`SerializedProperty` typed setters are silent no-ops when the accessor does not match the
  property's `propertyType`.** Writing `vector2Value` to a `Vector2Int` property throws nothing,
  logs nothing, and leaves the field at the engine default; `ApplyModifiedProperties` returns as
  if the write landed. `intValue` into a float, `floatValue` into an int and `objectReferenceValue`
  into a non-object behave the same way.
- Measured: a UI panel's `m_ReferenceResolution` (a `Vector2Int`) written through `vector2Value`
  kept the engine's default for the life of the project. Every authored layout number then rendered
  at 37% of its intended size, which reads as "the design is too small", not as a failed write.
- **The write is not the check — the read-back is.** A generated-asset builder must re-open a fresh
  `SerializedObject` after `ApplyModifiedPropertiesWithoutUndo` and report every field it wrote
  against what it wrote. The one field in that builder with no read-back line was the one that was
  wrong, for weeks.
- `FindProperty` returning non-null proves the path exists, not that your accessor fits it. When a
  field's type is not obvious from the inspector, read the serialized `.asset` YAML: `{x: 1200,
  y: 800}` on an integer-typed field looks identical to a float pair.

## DELETING-A-SERIALIZED-FIELD-RESETS-EVERY-TUNED-ASSET

Consolidating several global serialized fields into one new structured field — per-state rows, per
variant, per tier — is a **data migration**, not a refactor. Do it as a refactor and every existing
asset silently reverts to the compiled default, with a clean diff and a clean compile.

The mechanism: Unity constructs the object first (so field initializers run), then applies the YAML.
A key that is **absent** from the YAML keeps the constructed value, so the new field takes its
compiled default on every asset that predates it, while the deleted fields' keys sit orphaned until
the next reserialize drops them. Nothing errors. Nothing logs.

What makes it expensive is *which* value you land on: a compiled default is almost always the
original guess, and the tuned asset is the accumulated work. Measured on one authored asset — hip
pose `(.19,-.16,.18)` against a default of `(.03,-.10,.12)`, two clearance distances the designer had
deliberately zeroed against defaults of `.25`/`.08`, two more maxed against defaults of `.2`/`.15`.
Seeding from `Default` would have thrown all six away, and the only reason it surfaced was a test
that computed expected geometry from the asset's own values and missed by exactly default-minus-tuned.

Rules:
- **Seed the new structure from the old keys, per asset.** Read the legacy values (out of the YAML,
  or from the fields kept briefly as private deprecated storage) and write the equivalent rows.
  `Default` is for assets that do not exist yet. Say so in its doc comment.
- **Apply each old scalar where the old CODE applied it**, not where it was declared. A global scaled
  by a variant factor at one call site becomes that variant's row value already multiplied; a term a
  branch excluded for one variant becomes zero in that row.
- **A consolidation that merges two terms with different curves cannot be bit-exact.** When the old
  code summed two overlapping contributions with different ranges, one row reproduces neither. Pick
  the dominant one, seed it, and name that single value as the feel-check — do not claim parity.
- **Verify by loading the migrated asset and printing the seeded values**, not by compiling. The
  compile is guaranteed to pass: the type is valid, only the data is gone.
- A field whose meaning did not change can stay as a **read-only projection** of the new structure,
  which keeps a large read-site count untouched; delete outright only the ones whose meaning changed,
  so no caller can read one variant's value while in another.

**Rewriting the asset file: build the whole payload before you open it for writing.** `open(path,
"wb")` (and every truncating open) empties the file immediately, so any exception between the open
and the write leaves a **zero-byte asset** — a formatting error in the last line of the generator is
enough. Back the file up first, assemble the complete bytes, sanity-check the length against the
original, and only then write. Measured: one `TypeError` while joining the output lines truncated a
live authored asset to 0 bytes; recovery was the backup taken one command earlier.

- **MANIFEST-PACKAGES-REDOWNLOAD-STORE-DUMPS-DO-NOT (HARD).**
  Packages listed in `Packages/manifest.json` re-download into the gitignored Library cache when
  the Editor opens the clone. Classic Asset Store `.unitypackage` dumps copied into `Assets/` do
  not: Unity's own support page says purchases are not fetched automatically. Gitignore those
  folders, record each store product id, and on Editor load request My Assets download for missing
  ids then import from your machine's Asset Store cache. A clone still runs from tracked baked
  copies; the dump is source, not the game.
- **UNITY-6-CORE-PACKAGES-COME-WITH-THE-EDITOR (HARD) — do not look up or pick versions for core packages.**
  - Rule: on Unity 6, core packages such as URP, HDRP, uGUI (with TextMeshPro inside it) and the Test Framework are distributed with each Editor version, and you cannot pick a different version through the Package Manager window or API. Their line in `Packages/manifest.json` stays, with the version the Editor sets; take that Editor-bundled version and do not query packages.unity.com for it. The core list differs between Editor versions, so check the Core packages page of the Unity manual for the version in use.
  - Why: the public registry's `latest` for these packages is misleading (URP and HDRP show 10.x there), and since Unity 6 the Package Manager uses the built-in version when a dependency asks for a different one, so a hand-picked number does nothing or confuses the next reader.
  - Check: core package versions in the manifest match what the Package Manager window shows for the installed Editor.
- **ASSET-STORE-SESSION-IS-LIVE-BEFORE-PROMISING (HARD, locked).** Source: learned firsthand on a real project; the rule is generic. Status: locked.
  - Rule: before you promise, plan or install any Asset Store package (a purchased or free package from **My Assets**, as opposed to a registry package), check that the user's Unity account session is actually live. If it is not, stop and ask the user to sign back in to Unity Hub or the Editor; never attempt the install and let it fail.
  - Why: My Assets and Asset Store downloads need a signed-in Unity account, and a stale login does not announce itself. The install then looks broken, or a rule that depends on the package looks wrong.
  - Check: the Hub or Editor shows the account signed in; the Package Manager's **My Assets** list loads and contains the expected package; or the package is already in the asset package cache (`%APPDATA%\Unity\Asset Store-5.x` on Windows, `~/Library/Unity/Asset Store-5.x` on macOS, `~/.local/share/unity3d/Asset Store-5.x` on Linux, unless moved in Preferences > Package Manager or by `ASSETSTORE_CACHE_PATH`), in a vendor subfolder.
  - Context: a registry package (for example Input System or Cinemachine) needs no session: its line in `Packages/manifest.json` is enough, and the Editor downloads it. Which packages a project uses follows the setup answers (for example UI Toolkit needs no package at all). If you use store packages, sign your agent's Unity session in before setup.

## EMPTY-SEQUENCE-IS-INLINE-OR-THE-REST-OF-THE-OBJECT-IS-DROPPED (HARD)

Unity serialises an empty sequence INLINE, on the same line as its key, and that is the only
form its YAML reader accepts. A generator that writes the key and then a bare sequence marker on
the NEXT line makes the reader stop dead at that point and SILENTLY DROP every remaining field on
the object - no error, no warning, no import failure. The asset loads, and the fields declared
after the empty one come back as defaults.

- **It presents as a data or bake failure on correct data.** Measured: eight authored entries
  deserialised with zero LODs, zero height and empty bounds because the fields holding them were
  declared after an empty list. The objects had been generated correctly; only the read was
  truncated. Boot then threw a validation error naming the bake, and roughly 30 minutes went into
  the wrong layer before anyone read the file.
- **Only the empty case breaks, which is why it hides.** Entries whose collection had items wrote
  an ordinary block sequence and parsed fine, so the failure looked specific to a subset of the
  data rather than structural.
- **Guard the ASSETS, not the writer - the writer is often not in the repo.** The instinct is to
  patch the generator, and it can protect nothing: the damaged files are frequently produced by an
  ad-hoc script from someone-s session that was never committed, while the committed authoring path
  goes through the engine-s own serialiser and cannot emit the broken form. Add a test that WALKS
  the authored asset tree and fails on a field line followed by a bare sequence or a bare map
  marker. That catches the class whatever script or field produced it, which a round-trip test on
  one type does not. If a committed writer does exist, fix it too - but the asset sweep is the
  guard that holds.
- **Assert a field declared AFTER the empty collection, never the collection itself.** Whichever
  form the check takes, the empty collection reads back as empty either way - that assertion passes
  while the truncation is live. Only a field declared after it shows the loss.

## SUBMESH-VERTEX-RANGE-BEFORE-OPTIMIZE (HARD)

**`MeshUpdateFlags.DontRecalculateBounds` on `SetSubMesh` also skips the sub-mesh VERTEX RANGE,
and `Mesh.Optimize` on a zero-vertex sub-mesh is a native crash, not an exception.** Measured:
a generator uploaded 2562 vertices and 15360 indices through `SetVertexBufferData` /
`SetIndexBufferData`, called `SetSubMesh(0, new SubMeshDescriptor(0, indexCount),
DontRecalculateBounds)`, set `mesh.bounds` by hand and then `Optimize`. The Editor died in
`OptimizeVertexCache::OptimizeVertexCacheHits<unsigned short,3>` on a job thread - no managed
frame, no Console line, only a crash folder. The descriptor's `firstVertex` / `vertexCount` are
what the flag left at zero; the optimizer trusted them and walked real indices over an empty range.

- **Fill the descriptor yourself when you suppress the recalculation:** `new SubMeshDescriptor(0,
  indexCount) { firstVertex = 0, vertexCount = vertexCount, bounds = bounds }`. Setting
  `mesh.bounds` afterwards does not touch the sub-mesh range.
- **A mesh-generation harness that stubs the engine proves nothing about `Optimize`, index
  format or vertex layout.** 84 harness meshes passed closed / outward / seated checks offline;
  the first native bake crashed. Before a bake that runs `Optimize` in the real Editor,
  generate ONE smallest-LOD mesh through the real API and read `GetSubMesh(0).vertexCount`
  back - a repeat crash then costs one mesh, not the session.
- **A native crash during an Editor exec looks like a bridge timeout.** Every later call times
  out too; read the process start time and the crash folder before assuming the Editor is busy.
- **UNITYPACKAGE-EXTRACT .** Extracting a `.unitypackage` outside Unity: gunzip first, then stream the
  tar (`tarfile` on the gzip stream failed on the package's gzip extra-header fields). Each `pathname` entry's
  FIRST line is the asset path; one package appended a separate `00` line. Reject absolute and `..` paths
  before writing.
- **SPEEDTREE-CLI-EXPORT-OPTIONS .** The SpeedTree Modeler CLI (`-export_game <out>.st9 -export_options
  <ini>`) ignores dialog-format preset files (`[General]` keys) in either argument order, so scale, atlas size
  and SwapYZ stay native and trees import Z-up, lying on their side. It honours an ini in the `[Options]` key
  format found in `.spm` QuickSave blocks (`TransformSwapYZ`, `TextureAtlasSize`, `LodAll`). `.spm` is gzipped
  XML, so parameters can be scripted before export.

## META-PLATFORM-ROW-COUNTS-ONLY-WHEN-OVERRIDDEN

- Amends the per-build-target texture bullet above. A texture `.meta` carries a `platformSettings` row for each
  platform, and a row with `overridden: 0` is inactive; the Default row applies. Measured: an audit quoted
  `maxTextureSize: 2048` from each Android row and claimed about 90 MB of icons on Android, while the Default row said
  256.
- Read `overridden` before quoting any platform row. A size or format claim read from `.meta` text is `static-asset`
  evidence; confirm it with `TextureImporter.GetPlatformTextureSettings(platform)` or the imported texture's size.

## ONVALIDATE-MASKS-A-MIGRATION-SAVE

- A migration wrote new field values through `SerializedObject.ApplyModifiedProperties` and saved with
  `AssetDatabase.SaveAssetIfDirty`, but the file never changed. `OnValidate` had already filled the same values in
  memory, so the apply found no difference, the object was never marked dirty, and the save skipped it.
- In a migration, call `EditorUtility.SetDirty(obj)` explicitly before saving. Then prove the save by reading the value
  back from the file (the YAML text, or a fresh load after `Resources.UnloadAsset`), not from the in-memory object.

## SHADER-PROPERTY-IDS-ARE-PROCESS-LOCAL

- Persist shader/VFX property and initial-event **names**, then convert with `Shader.PropertyToID` during explicit
  initialization or pool warmup. Never serialize or transmit the returned integer as portable identity: it is stable
  only within one process, so an Editor/player restart can invalidate saved IDs.
  [Unity API contract](https://docs.unity3d.com/6000.0/Documentation/ScriptReference/Shader.PropertyToID.html).
