Topic 3 · Unity programming
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, notType: 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 aUI_Animatortween: an agent reportedType=2 (MoveAnchored), the real enum wasFadeAlpha=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 aVector2in the sameEndValuefield, so the YAML alone could not disambiguate — and(500, 72.78)reads perfectly well as a bottom-left position. Rules: (1) open theenumdeclaration and count from 0, or compare by name viaSerializedProperty.enumNames[p.enumValueIndex]— neverenumValueIndex == <literal>; (2) a subagent that reportsN (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
.assetfiles indent with two spaces. A sweep that runs.replace(/\s{2,}/g, " ")(or any collapse of runs of spaces) on the full line turnsDisplayName:and- Label:into one-space YAML and silently breaks list nesting. Folded descriptions also wrap provenance across lines —(SC 4.3pon one line andName.)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-serializedconst/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*AtPathbreaks 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.formatand the importer'stextureCompressionboth lie about the platform you are not currently on. Auditing mobile texture memory from the editor,Texture2D.formatreflects only the active build target, andTextureImporterPlatformSettings.textureCompression: Compressedrecords intent, not the format that actually results. The authoritative call isTextureImporter.GetAutomaticFormat("iPhone" | "Android")(reflection: it is public but undocumented), plusGetPlatformTextureSettings(platform).overriddento see whether an override exists at all. Two concrete traps this closes: (1) crunch compression requires both dimensions to be multiples of 4, soETC2_RGBA8Crunchedsilently 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 confirmsASTC_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. MeasureProfiler.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
.unityYAML does NOT contain prefab-instance contents — every grep-based scene census undercounts by exactly the components that live in prefabs. APrefabInstanceblock stores only the prefab GUID plus a property-override list, sogrep -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 walkGetComponentsInChildren<T>(true)from each root — that expands prefabs and reports realisActiveAndEnabledstate; 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-seatPass-AnimationAnimators appeared only via the prefab.) -
A scene saved while a script class is unresolvable embeds a scene-local
MonoScriptstub (!u!115) pinned to the OLD class name — the missing-script breakage then SURVIVES fixing the code, becausem_Scriptpoints at the stub, not the script's GUID. Mid-rename (namespace/class move) any scene save rewrites affected components fromm_Script: {fileID: 11500000, guid: X, type: 3}tom_Script: {fileID: <local number>}plus an embeddedMonoScriptblock carrying the deadm_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 aguid:is a dead stub ref. Fix in place: repointm_Scriptto{fileID: 11500000, guid: <script .cs.meta guid>, type: 3}and delete the orphaned!u!115blocks; all wired refs revive untouched. Stalem_EditorClassIdentifierstrings 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-checkAudioImporter.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 isDecompressOnLoad, 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) wantsloadType = AudioClipLoadType.Streaming+AudioCompressionFormat.Vorbisat quality ~0.5 +loadInBackground = true; keepDecompressOnLoadfor short SFX only. (2) The obvious "normalize the settings" helper compares current vs desired and reimports when they differ — butAudioImporter.threeDis a Unity-4-era field that still EXISTS and still READS the meta's3D: 1, while assigning it is silently ignored (spatialization moved toAudioSource.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 afterSaveAndReimport— the field that did not change is the no-op. Same class astextureCompressionreporting intent rather than result (see the per-BUILD-TARGET rule above): trust a read-back, not the write. Pair the one-shot pass with anAssetPostprocessor(OnPreprocessAudio, gated onassetImporter.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/.materialImportModeC# property writes can silently NOT persist; set them throughSerializedObject+WriteImportSettingsIfDirty, or your material remap resolves to an empty slot. Symptom:AddRemap(new SourceAssetIdentifier(typeof(Material), "<name>"), mat)+SaveAndReimportreports a perfectly correct external-object map (m_ExternalObjectsshowsMaterial_0 -> M_Foo), the model file genuinely contains that material (binary FBX stores it asMaterial_0\x00\x01Material),m_Materialslists the same name — and the imported renderer'ssharedMaterialis still NULL, i.e. an untextured white model with no error anywhere. Cause: the meta hadmaterialLocation: 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 onmaterialLocation: 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.metaon disk, never the importer object — same "trust a read-back, not the write" class asAudioImporter.threeDandtextureCompression. NotematerialImportMode.ToStringprintingLegacyImportfor the value you set asImportStandardis 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/orUITK/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
.metafiles 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 statusrendered 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
MonoScriptby FILE NAME, so an SO sharing a.cswith another type serializes asm_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.CreateAssetserializes 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→AddObjectToAssetper sub-object → assign arrays →SetDirty→SaveAssets. -
A Unity object reference does not survive
EditorUtility.UnloadUnusedAssetsImmediateinside 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-memoryScriptableObjectand only appends it to the profile's list; it never callsAddObjectToAsset, so it renders fine until the next domain reload and then resolves null. Follow every such call withAddObjectToAsset+hideFlags+SaveAssets, then verify BOTHEditorUtility.IsPersistent(o)and the object's literal presence in the.assettext —LoadAssetAtPathreads the in-memory AssetDatabase, which is exactly how the bug hides. -
Every null-guarded
AssetDatabase.LoadAssetAtPathmustDebug.LogWarningthe 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 everyvar(--*)unresolved and it looks like a broken layout, with no error, and a future folder move re-breaks it just as quietly. (AssetDatabasedoes resolvePackages/...paths — use package-relative paths in reusable tooling.) -
AssetDatabasedoes not exist in a player build. Let editor tooling discover assets withFindAssets(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 neverFindAssets(...)[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 onvalue > 0silently disables itself everywhere while the code and the initializer both look correct. Give such gates an explicit<= 0 → fallbackbranch. (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
LogErrornaming 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.assetis 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.tileSizeis 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_0min/max FIRST. u/v beyond [0,1] is UDIM tiling and no texture-side fix helps; the importer'snPOTScale/maxTextureSizedefaults also crush non-power-of-two atlases. (a survival game)
SERIALIZED-WRITE-NEEDS-THE-MATCHING-TYPED-ACCESSOR
SerializedPropertytyped setters are silent no-ops when the accessor does not match the property'spropertyType. Writingvector2Valueto aVector2Intproperty throws nothing, logs nothing, and leaves the field at the engine default;ApplyModifiedPropertiesreturns as if the write landed.intValueinto a float,floatValueinto an int andobjectReferenceValueinto a non-object behave the same way.- Measured: a UI panel's
m_ReferenceResolution(aVector2Int) written throughvector2Valuekept 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
SerializedObjectafterApplyModifiedPropertiesWithoutUndoand 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. FindPropertyreturning 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.assetYAML:{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.jsonre-download into the gitignored Library cache when the Editor opens the clone. Classic Asset Store.unitypackagedumps copied intoAssets/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.jsonstays, 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
latestfor 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.xon Windows,~/Library/Unity/Asset Store-5.xon macOS,~/.local/share/unity3d/Asset Store-5.xon Linux, unless moved in Preferences > Package Manager or byASSETSTORE_CACHE_PATH), in a vendor subfolder. - Context: a registry package (for example Input System or Cinemachine) needs no session: its line in
Packages/manifest.jsonis 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 }. Settingmesh.boundsafterwards 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 runsOptimizein the real Editor, generate ONE smallest-LOD mesh through the real API and readGetSubMesh(0).vertexCountback - 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
.unitypackageoutside Unity: gunzip first, then stream the tar (tarfileon the gzip stream failed on the package's gzip extra-header fields). Eachpathnameentry's FIRST line is the asset path; one package appended a separate00line. 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.spmQuickSave blocks (TransformSwapYZ,TextureAtlasSize,LodAll)..spmis 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
.metacarries aplatformSettingsrow for each platform, and a row withoverridden: 0is inactive; the Default row applies. Measured: an audit quotedmaxTextureSize: 2048from each Android row and claimed about 90 MB of icons on Android, while the Default row said 256. - Read
overriddenbefore quoting any platform row. A size or format claim read from.metatext isstatic-assetevidence; confirm it withTextureImporter.GetPlatformTextureSettings(platform)or the imported texture's size.
ONVALIDATE-MASKS-A-MIGRATION-SAVE
- A migration wrote new field values through
SerializedObject.ApplyModifiedPropertiesand saved withAssetDatabase.SaveAssetIfDirty, but the file never changed.OnValidatehad 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 afterResources.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.PropertyToIDduring 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.