diff --git a/.claude/hooks/rebuild-analyzers-on-change.sh b/.claude/hooks/rebuild-analyzers-on-change.sh
new file mode 100755
index 00000000..8bb7c81c
--- /dev/null
+++ b/.claude/hooks/rebuild-analyzers-on-change.sh
@@ -0,0 +1,31 @@
+#!/usr/bin/env bash
+# PostToolUse hook: rebuild the Roslyn analyzer after edits inside the analyzer
+# project (a git submodule), then redeploy the DLL into the Unity package.
+# The submodule has no Directory.Build.targets on purpose (it stays independent
+# of this repo's layout), so the copy step lives here.
+#
+# Path-scoped on purpose:
+# - Triggers ONLY for *.cs under Aspid.FastTools.Analyzers/Aspid.FastTools.Analyzers/Aspid.FastTools.Analyzers/
+# - Skips the Tests and Sample projects.
+#
+# Build success -> exit 0 (silent).
+# Path mismatch -> exit 0 (silent).
+# Build failure -> exit 2 with stderr piped through, so the assistant sees it.
+
+set -uo pipefail
+
+file_path=$(jq -r '.tool_input.file_path // empty' 2>/dev/null)
+
+case "$file_path" in
+ */Aspid.FastTools.Analyzers/Aspid.FastTools.Analyzers/Aspid.FastTools.Analyzers/*.cs) ;;
+ *) exit 0 ;;
+esac
+
+cd "$CLAUDE_PROJECT_DIR" || exit 0
+
+dotnet build \
+ Aspid.FastTools.Analyzers/Aspid.FastTools.Analyzers/Aspid.FastTools.Analyzers/Aspid.FastTools.Analyzers.csproj \
+ -c Release --nologo -v quiet 1>&2 || exit 2
+
+cp Aspid.FastTools.Analyzers/Aspid.FastTools.Analyzers/Aspid.FastTools.Analyzers/bin/Release/netstandard2.0/Aspid.FastTools.Analyzers.dll \
+ Aspid.FastTools/Packages/tech.aspid.fasttools/Aspid.FastTools.Analyzers.dll 1>&2 || exit 2
diff --git a/.claude/settings.json b/.claude/settings.json
index 548c507c..44bb7e48 100644
--- a/.claude/settings.json
+++ b/.claude/settings.json
@@ -18,6 +18,10 @@
{
"type": "command",
"command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/rebuild-generators-on-change.sh\""
+ },
+ {
+ "type": "command",
+ "command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/rebuild-analyzers-on-change.sh\""
}
]
}
diff --git a/.claude/skills/build-analyzer/SKILL.md b/.claude/skills/build-analyzer/SKILL.md
new file mode 100644
index 00000000..fe2a34e9
--- /dev/null
+++ b/.claude/skills/build-analyzer/SKILL.md
@@ -0,0 +1,17 @@
+---
+name: build-analyzer
+description: Build the Roslyn analyzer submodule and deploy the resulting DLL into the Unity package
+user-invocable: true
+---
+
+Build the Aspid.FastTools analyzer (git submodule) and deploy to Unity:
+
+1. If `Aspid.FastTools.Analyzers/` is empty, run `git submodule update --init` first
+2. Run `dotnet build Aspid.FastTools.Analyzers/Aspid.FastTools.Analyzers/Aspid.FastTools.Analyzers/Aspid.FastTools.Analyzers.csproj -c Release` from the repository root
+3. Run `dotnet test Aspid.FastTools.Analyzers/Aspid.FastTools.Analyzers.sln -c Release` and stop if any test fails
+4. Copy `Aspid.FastTools.Analyzers/Aspid.FastTools.Analyzers/Aspid.FastTools.Analyzers/bin/Release/netstandard2.0/Aspid.FastTools.Analyzers.dll` to `Aspid.FastTools/Packages/tech.aspid.fasttools/Aspid.FastTools.Analyzers.dll`
+5. Report the result: build/test output, any errors, and confirm the DLL was copied successfully
+
+Note: diagnostic IDs use the `AFT*` prefix. After bumping the submodule commit, remember the gitlink change in the superproject (`git add Aspid.FastTools.Analyzers`).
+
+Arguments: $ARGUMENTS (optional: pass `Debug` to build in Debug configuration instead of Release)
diff --git a/.gitignore b/.gitignore
index 798138e6..42429634 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,6 +2,7 @@
.worktrees/
.claude/worktrees/
.zed/
+.DS_Store
# UPM convention: `Samples~` is hidden from Unity importer but must be tracked.
# Override global `*~` ignore.
diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 00000000..aaf13703
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,3 @@
+[submodule "Aspid.FastTools.Analyzers"]
+ path = Aspid.FastTools.Analyzers
+ url = https://github.com/VPDPersonal/Aspid.FastTools.Analyzers.git
diff --git a/Aspid.FastTools.Analyzers b/Aspid.FastTools.Analyzers
new file mode 160000
index 00000000..569c4fdc
--- /dev/null
+++ b/Aspid.FastTools.Analyzers
@@ -0,0 +1 @@
+Subproject commit 569c4fdc53535c41ff6d1185f52d68ebb0a883e3
diff --git a/Aspid.FastTools.Generators/Aspid.FastTools.Generators.Tests/Aspid.FastTools.Generators.Tests.csproj b/Aspid.FastTools.Generators/Aspid.FastTools.Generators.Tests/Aspid.FastTools.Generators.Tests.csproj
index a09224de..40073400 100644
--- a/Aspid.FastTools.Generators/Aspid.FastTools.Generators.Tests/Aspid.FastTools.Generators.Tests.csproj
+++ b/Aspid.FastTools.Generators/Aspid.FastTools.Generators.Tests/Aspid.FastTools.Generators.Tests.csproj
@@ -12,6 +12,8 @@
+
+
diff --git a/Aspid.FastTools.Generators/Aspid.FastTools.Generators/Aspid.FastTools.Generators.csproj b/Aspid.FastTools.Generators/Aspid.FastTools.Generators/Aspid.FastTools.Generators.csproj
index b3d7d6c4..564f2bba 100644
--- a/Aspid.FastTools.Generators/Aspid.FastTools.Generators/Aspid.FastTools.Generators.csproj
+++ b/Aspid.FastTools.Generators/Aspid.FastTools.Generators/Aspid.FastTools.Generators.csproj
@@ -4,6 +4,7 @@
netstandard2.0enablelatest
+ truetrueAspid.FastTools
@@ -22,8 +23,10 @@
runtime; build; native; contentfiles; analyzers; buildtransitive
-
-
+
+
diff --git a/Aspid.FastTools.Generators/Aspid.FastTools.Generators/ILRepack.targets b/Aspid.FastTools.Generators/Aspid.FastTools.Generators/ILRepack.targets
new file mode 100644
index 00000000..8bdc86ec
--- /dev/null
+++ b/Aspid.FastTools.Generators/Aspid.FastTools.Generators/ILRepack.targets
@@ -0,0 +1,32 @@
+
+
+
+
+
+ $(IntermediateOutputPath)unity-merge/
+ $(UnityAnalyzerMergeDir)$(TargetFileName)
+
+
+
+ <_MergeDependency Include="@(ReferencePath)"
+ Condition="'%(Filename)' == 'Aspid.Generators.Helper' Or '%(Filename)' == 'Aspid.Generators.Helper.Unity'" />
+ <_MergeLibraryDir Include="@(ReferencePath->'%(RootDir)%(Directory)'->Distinct())" />
+
+
+
+
+
+
+
+
+
+
diff --git a/Aspid.FastTools.Generators/CLAUDE.md b/Aspid.FastTools.Generators/CLAUDE.md
index 34d8b639..9c3f14c6 100644
--- a/Aspid.FastTools.Generators/CLAUDE.md
+++ b/Aspid.FastTools.Generators/CLAUDE.md
@@ -12,18 +12,18 @@ dotnet build -c Release
dotnet test
```
-`Directory.Build.targets` copies the compiled DLL to `../Aspid.FastTools/Packages/tech.aspid.fasttools/Aspid.FastTools.Generators.dll` on build.
+Deploy pipeline: `Aspid.FastTools.Generators/ILRepack.targets` merges `Aspid.Generators.Helper*` into a single-file copy of the DLL under `obj/` (Unity references exactly one analyzer DLL; `bin/` stays multi-assembly for tests), then `Directory.Build.targets` copies the merged DLL to `../Aspid.FastTools/Packages/tech.aspid.fasttools/Aspid.FastTools.Generators.dll`.
A repo-level PostToolUse hook (`.claude/hooks/rebuild-generators-on-change.sh`) also runs `dotnet build` automatically after any `Edit`/`Write` to `*.cs` under `Aspid.FastTools.Generators/Aspid.FastTools.Generators/`. The hook intentionally **does not** trigger for tests, the Sample project, or Unity-side edits — keep that scope if you modify it.
## Solution Structure
```
-Aspid.FastTools.Generators/ ← generator implementation
+Aspid.FastTools.Generators/ ← generator implementation (+ ILRepack.targets deploy merge)
Aspid.FastTools.Generators.Tests/ ← unit tests + GeneratorTestHost helper
Aspid.FastTools.Generators.Sample/ ← manual smoke-test project
Aspid.FastTools.Generators.sln
-Directory.Build.targets
+Directory.Build.targets ← copies the merged DLL into the Unity package
```
## Target Framework
@@ -37,7 +37,9 @@ Directory.Build.targets
| `Microsoft.CodeAnalysis.CSharp` 4.3.0 | Roslyn semantic model and syntax |
| `Aspid.Generators.Helper` | `CodeWriter` utility for emitting source |
| `Aspid.Generators.Helper.Unity` | Unity-specific analysis helpers |
-| `SourceGenerator.Foundations` 2.0.13 | Incremental generator infrastructure |
+| `ILRepack.Lib.MSBuild.Task` | Build-only: merges the Helper DLLs into the deployed analyzer DLL |
+
+**Never add `SourceGenerator.Foundations` (SGF).** Its MSBuild injector plants a module initializer that writes `Console.WriteLine` per embedded assembly on every generator load. Inside Unity's long-lived VBCSCompiler server nobody drains stdout, so the pipe buffer eventually fills and the `write()` blocks forever — script compilation hangs indefinitely (observed: 15 min – 2 h). It also embeds ~17 MB of unrelated assemblies (own copies of `Microsoft.CodeAnalysis*`, `envdte`, …) into the DLL. The same rule generalizes: no `Console` output anywhere in generator/analyzer code paths.
## Generator Implementation Pattern
diff --git a/Aspid.FastTools.Generators/Directory.Build.targets b/Aspid.FastTools.Generators/Directory.Build.targets
index 79d227d7..e11505b0 100644
--- a/Aspid.FastTools.Generators/Directory.Build.targets
+++ b/Aspid.FastTools.Generators/Directory.Build.targets
@@ -1,11 +1,12 @@
-
+
+
<_UnityDestination>$(MSBuildThisFileDirectory)../Aspid.FastTools/Packages/tech.aspid.fasttools/
-
+
diff --git a/Aspid.FastTools/.gitignore b/Aspid.FastTools/.gitignore
index 23373c51..eccac06c 100644
--- a/Aspid.FastTools/.gitignore
+++ b/Aspid.FastTools/.gitignore
@@ -78,6 +78,16 @@ crashlytics-build.properties
/[Aa]ssets/[Tt]utorialInfo.meta
/[Aa]ssets/[Tt]utorialInfo/*
+# Package samples imported into the dev project via Package Manager.
+# Source of truth is the package's `Samples~/`; the imported copy is local-only.
+/[Aa]ssets/[Ss]amples/
+/[Aa]ssets/[Ss]amples.meta
+
+# Auto-generated tool settings created by optional Editor packages
+# (Project Auditor, Timeline). Default values only, dev-project-local.
+/[Pp]roject[Ss]ettings/ProjectAuditorSettings.asset
+/[Pp]roject[Ss]ettings/TimelineSettings.asset
+
# OS generated
.DS_Store
._*
diff --git a/Aspid.FastTools/Packages/manifest.json b/Aspid.FastTools/Packages/manifest.json
index ca2e03c3..80f6a977 100755
--- a/Aspid.FastTools/Packages/manifest.json
+++ b/Aspid.FastTools/Packages/manifest.json
@@ -48,5 +48,8 @@
"com.unity.modules.vr": "1.0.0",
"com.unity.modules.wind": "1.0.0",
"com.unity.modules.xr": "1.0.0"
- }
+ },
+ "testables": [
+ "tech.aspid.fasttools"
+ ]
}
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Aspid.FastTools.Analyzers.dll b/Aspid.FastTools/Packages/tech.aspid.fasttools/Aspid.FastTools.Analyzers.dll
new file mode 100644
index 00000000..ed01cdda
Binary files /dev/null and b/Aspid.FastTools/Packages/tech.aspid.fasttools/Aspid.FastTools.Analyzers.dll differ
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Aspid.FastTools.Analyzers.dll.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Aspid.FastTools.Analyzers.dll.meta
new file mode 100644
index 00000000..582e1749
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Aspid.FastTools.Analyzers.dll.meta
@@ -0,0 +1,67 @@
+fileFormatVersion: 2
+guid: b8706429bd1d471380db4bce9eff80fe
+labels:
+- RoslynAnalyzer
+PluginImporter:
+ externalObjects: {}
+ serializedVersion: 3
+ iconMap: {}
+ executionOrder: {}
+ defineConstraints: []
+ isPreloaded: 0
+ isOverridable: 0
+ isExplicitlyReferenced: 0
+ validateReferences: 1
+ platformData:
+ Android:
+ enabled: 0
+ settings:
+ AndroidLibraryDependee: UnityLibrary
+ AndroidSharedLibraryType: Executable
+ CPU: ARMv7
+ Any:
+ enabled: 0
+ settings:
+ Exclude Android: 1
+ Exclude Editor: 1
+ Exclude Linux64: 1
+ Exclude OSXUniversal: 1
+ Exclude Win: 1
+ Exclude Win64: 1
+ Exclude iOS: 1
+ Editor:
+ enabled: 0
+ settings:
+ CPU: AnyCPU
+ DefaultValueInitialized: true
+ OS: AnyOS
+ Linux64:
+ enabled: 0
+ settings:
+ CPU: None
+ OSXUniversal:
+ enabled: 0
+ settings:
+ CPU: None
+ Win:
+ enabled: 0
+ settings:
+ CPU: None
+ Win64:
+ enabled: 0
+ settings:
+ CPU: None
+ WindowsStoreApps:
+ enabled: 0
+ settings:
+ CPU: AnyCPU
+ iOS:
+ enabled: 0
+ settings:
+ AddToEmbeddedBinaries: false
+ CPU: AnyCPU
+ CompileFlags:
+ FrameworkDependencies:
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Aspid.FastTools.Generators.dll b/Aspid.FastTools/Packages/tech.aspid.fasttools/Aspid.FastTools.Generators.dll
index 54cbb84b..d0ecd0d6 100644
Binary files a/Aspid.FastTools/Packages/tech.aspid.fasttools/Aspid.FastTools.Generators.dll and b/Aspid.FastTools/Packages/tech.aspid.fasttools/Aspid.FastTools.Generators.dll differ
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Documentation/EN/README.md b/Aspid.FastTools/Packages/tech.aspid.fasttools/Documentation/EN/README.md
index f7533b69..5975589c 100644
--- a/Aspid.FastTools/Packages/tech.aspid.fasttools/Documentation/EN/README.md
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Documentation/EN/README.md
@@ -1,6 +1,6 @@
-**Aspid.FastTools** is a set of tools designed to minimize routine code writing in Unity. It combines Roslyn-powered source generators with a curated collection of runtime and editor utilities — including per-call-site `ProfilerMarker` registration, a serializable `System.Type`, an `EnumValues` dictionary, a stable `int ↔ string` ID registry, fluent UI Toolkit extensions and IMGUI layout scopes.
+**Aspid.FastTools** is a set of tools designed to minimize routine code writing in Unity: `SerializeReference` tooling (an inspector type picker plus a reference explorer window), Roslyn-powered source generators, and a collection of runtime and editor utilities — from a serializable `System.Type` to fluent UI Toolkit extensions.
### \[[Unity Asset Store](https://assetstore.unity.com/packages/slug/365584)\] \[[Donate](#donate)\]
@@ -17,18 +17,19 @@
- **Features**
- [ProfilerMarker](#profilermarker)
- [Serializable Type System](#serializable-type-system)
+ - [SerializeReference Selector](#serializereference-selector)
- [Enum System](#enum-system)
- [ID System (Beta)](#id-system-beta)
+ - [VisualElement Extensions](#visualelement-extensions)
- [SerializedProperty Extensions](#serializedproperty-extensions)
- [IMGUI Layout Scopes](#imgui-layout-scopes)
- - [VisualElement Extensions](#visualelement-extensions)
- [Editor Helper Extensions](#editor-helper-extensions)
---
## Integration
-Install Aspid.FastTools via UPM (Unity Package Manager) — add the package using its Git URL. The release workflow publishes two branches containing only the package contents at their root, so no `?path=` query is needed.
+Install Aspid.FastTools via UPM: in the Package Manager click **+ → Install package from git URL…** and paste one of the URLs below.
### Stable
@@ -75,6 +76,9 @@ Add the marketplace and install the plugin:
```sh
/plugin marketplace add VPDPersonal/Aspid.Claude.Plugins
+```
+
+```sh
/plugin install aspid-fasttools@aspid-claude-plugins
```
@@ -211,7 +215,8 @@ public sealed class TypeSelectorAttribute : PropertyAttribute
public TypeSelectorAttribute(string assemblyQualifiedName)
public TypeSelectorAttribute(params string[] assemblyQualifiedNames)
- public TypeAllow Allow { get; set; } // default: TypeAllow.None
+ public TypeAllow Allow { get; set; } // default: TypeAllow.None
+ public bool Required { get; set; } // default: false
}
[Flags]
@@ -227,6 +232,7 @@ public enum TypeAllow
| Property | Description |
|----------|-------------|
| `Allow` | Which special type categories (abstract classes, interfaces) the picker includes in addition to plain concrete classes. Default: `TypeAllow.None` |
+| `Required` | Flags an unset field: a `[SerializeReference]` managed reference left `null`, or a `string` field left empty, shows an inline "required" warning in the Inspector and counts as a violation for the build/CI gate. Default: `false` |
```csharp
using UnityEngine;
@@ -247,6 +253,27 @@ public sealed class AbilitySelector : MonoBehaviour
> The complete sample — `Ability` / `AbilitySelector` / `EnemyBase` and their subclasses — ships in the `Types` sample (Package Manager → Aspid.FastTools → Samples).
+Decorate a candidate type with `[TypeSelectorDisplay]` to tune how it appears in the picker — an editor-only attribute (`[Conditional("UNITY_EDITOR")]`) in `Aspid.FastTools.Types` that carries no runtime cost:
+
+```csharp
+using Aspid.FastTools.Types;
+
+// Rename the type in the picker, place it under an explicit group, give it a tooltip and an icon:
+[TypeSelectorDisplay(
+ Name = "Damage ×",
+ Group = "Combat/Modifiers",
+ Tooltip = "Scales incoming damage",
+ Icon = "d_ScriptableObject Icon")]
+public sealed class DamageModifier { }
+```
+
+| Member | Description |
+|--------|-------------|
+| `Name` | Display name shown instead of the type's short name — in the picker rows and in the closed dropdown's caption. Search still matches the real type name too, and the hover tooltip keeps revealing the full `Namespace.Class, Assembly` identity. `null` or whitespace means no override. |
+| `Group` | Explicit picker path with `/` separating levels (e.g. `"Combat/Melee"`). **Replaces** the type's namespace placement — the type appears only under this path, and path segments are shared between types. `null` or whitespace keeps the namespace placement. |
+| `Tooltip` | Tooltip shown when hovering the type's row. `null` means no tooltip override. |
+| `Icon` | Editor icon shown left of the label — an `EditorGUIUtility.IconContent` name, a project-relative asset path with extension (loaded via `AssetDatabase`), or a `Resources` texture path without extension. `null` means no icon. |
+
---
### Type Selector Window
@@ -255,9 +282,14 @@ The Inspector shows a button that opens a searchable popup window with:
- Hierarchical namespace organization
- Text search with filtering
-- Keyboard navigation (Arrow keys, Enter, Escape)
-- Navigation history (back button)
+- Keyboard navigation (Arrow keys, Enter, Escape; Space toggles a favorite)
+- Breadcrumb trail with back navigation (Left arrow or a click on a crumb)
- Assembly disambiguation for types with identical names
+- **Favorites** (★ on hover) and **Recent** (last picks) sections on the root page — stored locally per project (`EditorPrefs`, never committed), hidden while searching
+- A `` option pinned at the top and a ✓ mark on the current value — its row is pre-selected on open
+- Type counters on namespace/group rows and section headers
+- Generic type support — picking an open generic walks through its type parameters and emits the constructed type
+- Favorites/Recent tuning (on/off, Recent capacity) in the Settings tab of the SerializeReference window

@@ -319,10 +351,117 @@ public sealed class TankEnemy : EnemyBase
}
```
+

---
+## SerializeReference Selector
+
+A drop-in type-picker dropdown for `[SerializeReference]` fields. Add `[TypeSelector]` next to `[SerializeReference]` and the Inspector replaces the default managed-reference UI with the same searchable, hierarchical picker used by `SerializableType`. You choose which concrete implementation of the field's type is instantiated, right in the Inspector; `` clears the reference.
+
+```csharp
+using System;
+using UnityEngine;
+using System.Collections.Generic;
+using Aspid.FastTools.Types;
+
+public interface IWeapon
+{
+ void Fire();
+}
+
+[Serializable]
+public sealed class Pistol : IWeapon
+{
+ [SerializeField] [Min(0)] private int _damage = 10;
+
+ public void Fire() => Debug.Log($"Pistol: {_damage} dmg");
+}
+
+[Serializable]
+public sealed class Railgun : IWeapon
+{
+ [SerializeField] [Min(0)] private float _chargeTime = 1.5f;
+
+ public void Fire() => Debug.Log($"Railgun charged for {_chargeTime}s");
+}
+
+public sealed class Loadout : MonoBehaviour
+{
+ [SerializeReference] [TypeSelector]
+ private IWeapon _primary;
+
+ [SerializeReference] [TypeSelector]
+ private List _sidearms;
+}
+```
+
+The attribute is editor-only (`[Conditional("UNITY_EDITOR")]`) and carries no runtime cost. It works on single fields, arrays, and `List`, in both IMGUI and UIToolkit inspectors.
+
+### Capabilities
+
+| Capability | What it does |
+|---|---|
+| **Pick an implementation** | The list shows the concrete, non-`UnityEngine.Object` classes assignable to the field's type. `[TypeSelector(typeof(IMelee))]` narrows it to `IMelee` implementations. |
+| **Inline inspector** | The selected instance's serialized fields are drawn under a foldout. |
+| **Open generics** | `Modifier` and the like: arguments are inferred from a closed-generic field, or picked on a second page inside the picker. |
+| **Data preserved** | Switching type carries over fields shared by name and serialized shape instead of resetting them to defaults. |
+| **Copy / Paste** | Right-click the header to copy the value and paste it as an independent instance into any compatible field. |
+| **Multi-object editing** | A mixed selection shows a mixed dropdown; picking a type or pasting applies an independent instance to each object in one Undo group. |
+| **Compile-time checks** | Roslyn analyzer: `AFT0004` (error) — the type derives from `UnityEngine.Object`; `AFT0005` (warning) — the picker would be empty. |
+
+### Repairing broken references
+
+| Case | Fix |
+|---|---|
+| **Missing type** (renamed or deleted) | A yellow notice instead of a silent clear. The underlined **Fix** opens the picker and re-points the type while keeping its data — at any depth, in saved assets and live in Prefab Mode. |
+| **Smart Fix** | Next to **Fix**, suggests the most likely replacement (`[MovedFrom]`, a different namespace/assembly, casing, a near-miss name) and applies it in one click — never automatically. |
+| **Shared reference** (two fields share one instance) | Flagged with a notice; **Make unique** splits it into an independent copy. Duplicating a list element (Ctrl+D, `+`) no longer aliases the reference. |
+
+
+
+Bulk repair lives in two dedicated tabs:
+
+| Tab | Purpose |
+|---|---|
+| **Asset References** (`Tools → Aspid 🐍 → FastTools → Asset References`) | Maps an asset's whole managed-reference graph from its YAML — a per-component tree with field paths, shared and orphaned references, `MISSING` / `SHARED` badges, and an inline type dropdown on every card. Surfaces the missing references the Inspector cannot show. |
+| **Project References** (`Tools → Aspid 🐍 → FastTools → Project References`) | `Scan Project` sweeps every `.prefab` / `.asset` / `.unity` under `Assets/`, groups broken references by stored type, and rewrites a whole group with a single `Fix all` (plus Smart Fix). A group whose stored type matches a declared `[MovedFrom]` rename reads as a pending migration instead of a breakage — one **Migrate all** click bakes the rename into the files, after which the attribute can be removed from code. |
+
+### Project settings & the build/CI gate
+
+**`Project Settings → Aspid FastTools → SerializeReference`** exposes:
+
+| Setting | Scope | What it does |
+|---|---|---|
+| **Breakage detection** | per-user | The proactive toast + console warning when references newly become missing after a recompile / import. |
+| **Auto de-alias duplicated list elements** | committed | A duplicated list element gets its own instance instead of sharing the original's reference id. |
+| **Build / CI gate** | committed | `Off` / `Warn` / `Fail`: at player-build time, log or abort on missing (and, for CI, unset-required) managed references. |
+| **Excluded scan folders** | committed | Paths skipped by every project scan. |
+
+- Committed values live in `ProjectSettings/SerializeReferenceSharedSettings.asset` — commit it so teammates and CI behave identically; breakage detection stays per-machine (`EditorPrefs`).
+- Rid colours are not a setting — a shared reference is always colour-coded by id, so matching colours reveal shared instances at a glance.
+
+The same options are mirrored in the window's **Settings** tab (`Tools → Aspid 🐍 → FastTools → Settings`) and at **`Preferences → Aspid FastTools`**, alongside the picker's per-user preferences:
+
+- **Favorites** — section on/off toggle.
+- **Recent items** — capacity slider (0–20; 0 hides the section and pauses recording without wiping history).
+- **Saved lists** — clears the stored Favorites / Recent.
+- **Appearance** — editor-theme override `StyleSheet` with **Create template…**.
+- **Welcome** — auto-show toggle.
+
+Every row carries a scope stripe (green — committed, blue — per-user); a pinned footer offers **Reset to defaults** per scope (saved Favorites / Recent lists survive a reset). All surfaces stay in live sync.
+
+For headless CI, `SerializeReferenceCiGate.RunCheck` (invoked via `-batchmode -executeMethod`) writes a report and honours the committed gate severity:
+
+- `Off` skips the check, `Warn` logs but exits 0, `Fail` exits non-zero when violations exist.
+- `-srGateRequired` also flags unset `[TypeSelector(Required = true)]` fields across prefabs, ScriptableObjects and scenes (top-level fields, pure-YAML pass).
+- `-srGateWarnOnly` / `-srGateFail` override the committed severity per run.
+
+> The full sample — `Loadout` / `IWeapon` / `Modifier` and the missing-reference repair scenarios — ships in the `SerializeReferences` sample (Package Manager → Aspid.FastTools → Samples). A step-by-step walkthrough lives in that sample's `TUTORIAL.md`.
+
+---
+
## Enum System
Provides serializable enum-to-value mappings configurable from the Inspector.
@@ -480,69 +619,6 @@ The registry derives from `ScriptableObject` directly and exposes a generic coun
---
-## SerializedProperty Extensions
-
-Chainable extensions on `SerializedProperty` for synchronizing the owning `SerializedObject`, writing typed values, and reflecting on the underlying field.
-
-```csharp
-property
- .Update()
- .SetVector3(Vector3.up)
- .SetBool(true)
- .ApplyModifiedProperties();
-```
-
-The package covers:
-
-- **Update / Apply** — `Update`, `UpdateIfRequiredOrScript`, `ApplyModifiedProperties`.
-- **Typed setters** — `SetValue` (generic dispatch) and `SetXxx` for `int`/`uint`/`long`/`ulong`/`float`/`double`/`bool`/`string`/`Color`/`Gradient`/`Hash128`/`Rect`/`RectInt`/`Bounds`/`BoundsInt`/`Vector2..4` (and `Vector2/3Int`)/`Quaternion`/`AnimationCurve`/`EntityId` (Unity 6.2+). Each comes with a paired `SetXxxAndApply` variant.
-- **Enum setters** — `SetEnumFlag` and `SetEnumIndex` (each + `AndApply`).
-- **Arrays** — `SetArraySize`, `AddArraySize`, `RemoveArraySize` (each + `AndApply`).
-- **References** — `SetManagedReference`, `SetObjectReference`, `SetExposedReference`, and `SetBoxed` (Unity 6+).
-- **Reflection helpers** — `GetPropertyType`, `GetMemberInfo`, `GetClassInstance` for resolving the C# member and runtime instance behind a property.
-
-> Full method-by-method reference: [SerializedPropertyExtensions.md](SerializedPropertyExtensions.md)
-
----
-
-## IMGUI Layout Scopes
-
-Three `ref struct` scopes — `VerticalScope`, `HorizontalScope`, `ScrollViewScope` — wrap `EditorGUILayout.Begin*` / `End*`. Each exposes a `Rect` property and calls the matching `End*` method on `Dispose`:
-
-```csharp
-using (VerticalScope.Begin())
-{
- EditorGUILayout.LabelField("Item 1");
- EditorGUILayout.LabelField("Item 2");
-}
-
-using (HorizontalScope.Begin())
-{
- EditorGUILayout.LabelField("Left");
- EditorGUILayout.LabelField("Right");
-}
-
-var scrollPos = Vector2.zero;
-using (ScrollViewScope.Begin(ref scrollPos))
-{
- EditorGUILayout.LabelField("Scrollable content");
-}
-```
-
-Capture the group rect with the `out`-overload when needed:
-
-```csharp
-using (VerticalScope.Begin(out var rect, GUI.skin.box))
-{
- EditorGUI.DrawRect(rect, new Color(0, 0, 0, 0.1f));
- EditorGUILayout.LabelField("Boxed content");
-}
-```
-
-All `Begin` overloads match the corresponding `EditorGUILayout.Begin*` signatures (optional `GUIStyle`, `GUILayoutOption[]`, scroll view options, etc.).
-
----
-
## VisualElement Extensions
Fluent extension methods for building UIToolkit trees in code. All methods return `T` (the element itself) for chaining.
@@ -610,6 +686,69 @@ internal sealed class AbilityConfigEditor : Editor
---
+## SerializedProperty Extensions
+
+Chainable extensions on `SerializedProperty` for synchronizing the owning `SerializedObject`, writing typed values, and reflecting on the underlying field.
+
+```csharp
+property
+ .Update()
+ .SetVector3(Vector3.up)
+ .SetBool(true)
+ .ApplyModifiedProperties();
+```
+
+The package covers:
+
+- **Update / Apply** — `Update`, `UpdateIfRequiredOrScript`, `ApplyModifiedProperties`.
+- **Typed setters** — `SetValue` (generic dispatch) and `SetXxx` for `int`/`uint`/`long`/`ulong`/`float`/`double`/`bool`/`string`/`Color`/`Gradient`/`Hash128`/`Rect`/`RectInt`/`Bounds`/`BoundsInt`/`Vector2..4` (and `Vector2/3Int`)/`Quaternion`/`AnimationCurve`/`EntityId` (Unity 6.2+). Each comes with a paired `SetXxxAndApply` variant.
+- **Enum setters** — `SetEnumFlag` and `SetEnumIndex` (each + `AndApply`).
+- **Arrays** — `SetArraySize`, `AddArraySize`, `RemoveArraySize` (each + `AndApply`).
+- **References** — `SetManagedReference`, `SetObjectReference`, `SetExposedReference`, and `SetBoxed` (Unity 6+).
+- **Reflection helpers** — `GetPropertyType`, `GetMemberInfo`, `GetClassInstance` for resolving the C# member and runtime instance behind a property.
+
+> Full method-by-method reference: [SerializedPropertyExtensions.md](SerializedPropertyExtensions.md)
+
+---
+
+## IMGUI Layout Scopes
+
+Three `ref struct` scopes — `VerticalScope`, `HorizontalScope`, `ScrollViewScope` — wrap `EditorGUILayout.Begin*` / `End*`. Each exposes a `Rect` property and calls the matching `End*` method on `Dispose`:
+
+```csharp
+using (VerticalScope.Begin())
+{
+ EditorGUILayout.LabelField("Item 1");
+ EditorGUILayout.LabelField("Item 2");
+}
+
+using (HorizontalScope.Begin())
+{
+ EditorGUILayout.LabelField("Left");
+ EditorGUILayout.LabelField("Right");
+}
+
+var scrollPos = Vector2.zero;
+using (ScrollViewScope.Begin(ref scrollPos))
+{
+ EditorGUILayout.LabelField("Scrollable content");
+}
+```
+
+Capture the group rect with the `out`-overload when needed:
+
+```csharp
+using (VerticalScope.Begin(out var rect, GUI.skin.box))
+{
+ EditorGUI.DrawRect(rect, new Color(0, 0, 0, 0.1f));
+ EditorGUILayout.LabelField("Boxed content");
+}
+```
+
+All `Begin` overloads match the corresponding `EditorGUILayout.Begin*` signatures (optional `GUIStyle`, `GUILayoutOption[]`, scroll view options, etc.).
+
+---
+
## Editor Helper Extensions
Utility methods for getting display names of Unity objects in custom editors.
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Documentation/Images/aspid_fasttools_serializable_type.gif b/Aspid.FastTools/Packages/tech.aspid.fasttools/Documentation/Images/aspid_fasttools_serializable_type.gif
index 30de9b89..e0a46576 100644
Binary files a/Aspid.FastTools/Packages/tech.aspid.fasttools/Documentation/Images/aspid_fasttools_serializable_type.gif and b/Aspid.FastTools/Packages/tech.aspid.fasttools/Documentation/Images/aspid_fasttools_serializable_type.gif differ
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Documentation/Images/aspid_fasttools_type_selector_window.png b/Aspid.FastTools/Packages/tech.aspid.fasttools/Documentation/Images/aspid_fasttools_type_selector_window.png
index afb53020..918ef3fb 100644
Binary files a/Aspid.FastTools/Packages/tech.aspid.fasttools/Documentation/Images/aspid_fasttools_type_selector_window.png and b/Aspid.FastTools/Packages/tech.aspid.fasttools/Documentation/Images/aspid_fasttools_type_selector_window.png differ
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Documentation/RU/README.md b/Aspid.FastTools/Packages/tech.aspid.fasttools/Documentation/RU/README.md
index 28f9c34a..2c0fc456 100644
--- a/Aspid.FastTools/Packages/tech.aspid.fasttools/Documentation/RU/README.md
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Documentation/RU/README.md
@@ -1,6 +1,6 @@
-**Aspid.FastTools** — набор инструментов, предназначенных для минимизации рутинного написания кода в Unity. Пакет объединяет генераторы кода на базе Roslyn и подборку runtime- и editor-утилит: регистрация `ProfilerMarker` для каждого места вызова, сериализуемый `System.Type`, словарь `EnumValues`, стабильный реестр `int ↔ string` ID, fluent-расширения UI Toolkit и IMGUI-скоупы для разметки.
+**Aspid.FastTools** — набор инструментов, предназначенных для минимизации рутинного написания кода в Unity: инструменты для `SerializeReference` (выбор типа в инспекторе и окно-обозреватель ссылок), генераторы кода на базе Roslyn и подборка runtime- и editor-утилит — от сериализуемого `System.Type` до fluent-расширений UI Toolkit.
### \[[Unity Asset Store](https://assetstore.unity.com/packages/slug/365584)\] \[[Donate](#donate)\]
@@ -17,18 +17,19 @@
- **Features**
- [ProfilerMarker](#profilermarker)
- [Serializable Type System](#serializable-type-system)
+ - [SerializeReference Selector](#serializereference-selector)
- [Enum System](#enum-system)
- [ID System (Beta)](#id-system-beta)
+ - [VisualElement Extensions](#visualelement-extensions)
- [SerializedProperty Extensions](#serializedproperty-extensions)
- [IMGUI Layout Scopes](#imgui-layout-scopes)
- - [VisualElement Extensions](#visualelement-extensions)
- [Editor Helper Extensions](#editor-helper-extensions)
---
## Integration
-Установите Aspid.FastTools через UPM (Unity Package Manager) — добавьте пакет по его Git URL. Релизный workflow публикует две ветки, в корне которых лежит само содержимое пакета, поэтому параметр `?path=` указывать не нужно.
+Установите Aspid.FastTools через UPM: в Package Manager нажмите **+ → Install package from git URL…** и вставьте один из URL ниже.
### Stable
@@ -75,6 +76,9 @@ https://github.com/VPDPersonal/Aspid.FastTools.git#upm-preview/1.0.0-rc.2
```sh
/plugin marketplace add VPDPersonal/Aspid.Claude.Plugins
+```
+
+```sh
/plugin install aspid-fasttools@aspid-claude-plugins
```
@@ -211,7 +215,8 @@ public sealed class TypeSelectorAttribute : PropertyAttribute
public TypeSelectorAttribute(string assemblyQualifiedName)
public TypeSelectorAttribute(params string[] assemblyQualifiedNames)
- public TypeAllow Allow { get; set; } // по умолчанию: TypeAllow.None
+ public TypeAllow Allow { get; set; } // по умолчанию: TypeAllow.None
+ public bool Required { get; set; } // по умолчанию: false
}
[Flags]
@@ -227,6 +232,7 @@ public enum TypeAllow
| Свойство | Описание |
|----------|----------|
| `Allow` | Какие специальные категории типов (абстрактные классы, интерфейсы) включаются в список выбора в дополнение к обычным конкретным классам. По умолчанию: `TypeAllow.None` |
+| `Required` | Помечает незаполненное поле: managed reference `[SerializeReference]`, оставшийся `null`, или пустое `string`-поле показывает предупреждение «required» в инспекторе и считается нарушением для build/CI-гейта. По умолчанию: `false` |
```csharp
using UnityEngine;
@@ -247,6 +253,27 @@ public sealed class AbilitySelector : MonoBehaviour
> Полный сэмпл — `Ability` / `AbilitySelector` / `EnemyBase` и их наследники — поставляется в сэмпле `Types` (Package Manager → Aspid.FastTools → Samples).
+Пометьте тип-кандидат атрибутом `[TypeSelectorDisplay]`, чтобы настроить, как он показывается в селекторе — это editor-only атрибут (`[Conditional("UNITY_EDITOR")]`) в `Aspid.FastTools.Types`, не несущий стоимости в рантайме:
+
+```csharp
+using Aspid.FastTools.Types;
+
+// Переименовать тип в пикере, положить его в явную группу, задать tooltip и иконку:
+[TypeSelectorDisplay(
+ Name = "Damage ×",
+ Group = "Combat/Modifiers",
+ Tooltip = "Scales incoming damage",
+ Icon = "d_ScriptableObject Icon")]
+public sealed class DamageModifier { }
+```
+
+| Член | Описание |
+|------|----------|
+| `Name` | Отображаемое имя вместо короткого имени типа — в строках пикера и в подписи закрытого дропдауна. Поиск по-прежнему находит тип и по настоящему имени, а tooltip при наведении показывает полную идентичность `Namespace.Class, Assembly`. `null` или пробелы — без переопределения. |
+| `Group` | Явный путь в пикере, уровни разделяются `/` (например `"Combat/Melee"`). **Заменяет** размещение по namespace — тип показывается только под этим путём, сегменты пути общие для разных типов. `null` или пробелы — размещение по namespace. |
+| `Tooltip` | Tooltip, показываемый при наведении на строку типа. `null` — без переопределения tooltip. |
+| `Icon` | Иконка редактора слева от лейбла — имя `EditorGUIUtility.IconContent`, путь к ассету в проекте с расширением (загружается через `AssetDatabase`) или путь к текстуре в `Resources` без расширения. `null` — без иконки. |
+
---
### Type Selector Window
@@ -255,9 +282,14 @@ public sealed class AbilitySelector : MonoBehaviour
- Иерархическую организацию по пространствам имён
- Текстовый поиск с фильтрацией
-- Навигацию с клавиатуры (стрелки, Enter, Escape)
-- Историю навигации (кнопка «назад»)
+- Навигацию с клавиатуры (стрелки, Enter, Escape; Space — в избранное)
+- Хлебные крошки и возврат назад (стрелка ← или клик по крошке)
- Разрешение неоднозначности для типов с одинаковыми именами из разных сборок
+- Секции **Favorites** (★ при наведении) и **Recent** (последние выборы) на корневой странице — хранятся локально для каждого проекта (`EditorPrefs`, не попадают в репозиторий), скрыты во время поиска
+- Пункт `` вверху списка и галочку ✓ у текущего значения — его строка выбирается при открытии
+- Счётчики типов у групп и заголовков секций
+- Поддержку generic-типов — выбор открытого generic ведёт через выбор его аргументов и возвращает сконструированный тип
+- Настройку Favorites/Recent (вкл/выкл, ёмкость Recent) во вкладке Settings окна SerializeReference

@@ -319,10 +351,117 @@ public sealed class TankEnemy : EnemyBase
}
```
+

---
+## SerializeReference Selector
+
+Готовый выпадающий список выбора типа для полей `[SerializeReference]`. Добавьте `[TypeSelector]` рядом с `[SerializeReference]` — Inspector заменит стандартный UI managed-ссылки тем же иерархическим селектором с поиском, что и `SerializableType`. Вы прямо в инспекторе выбираете, какая конкретная реализация типа поля будет создана; `` очищает ссылку.
+
+```csharp
+using System;
+using UnityEngine;
+using System.Collections.Generic;
+using Aspid.FastTools.Types;
+
+public interface IWeapon
+{
+ void Fire();
+}
+
+[Serializable]
+public sealed class Pistol : IWeapon
+{
+ [SerializeField] [Min(0)] private int _damage = 10;
+
+ public void Fire() => Debug.Log($"Pistol: {_damage} dmg");
+}
+
+[Serializable]
+public sealed class Railgun : IWeapon
+{
+ [SerializeField] [Min(0)] private float _chargeTime = 1.5f;
+
+ public void Fire() => Debug.Log($"Railgun charged for {_chargeTime}s");
+}
+
+public sealed class Loadout : MonoBehaviour
+{
+ [SerializeReference] [TypeSelector]
+ private IWeapon _primary;
+
+ [SerializeReference] [TypeSelector]
+ private List _sidearms;
+}
+```
+
+Атрибут существует только в редакторе (`[Conditional("UNITY_EDITOR")]`) и не несёт стоимости в рантайме. Работает с одиночными полями, массивами и `List`, в инспекторах IMGUI и UIToolkit.
+
+### Возможности
+
+| Возможность | Что делает |
+|---|---|
+| **Выбор реализации** | В списке — конкретные не-`UnityEngine.Object` классы, совместимые с типом поля. `[TypeSelector(typeof(IMelee))]` сужает его до реализаций `IMelee`. |
+| **Вложенный inspector** | Сериализуемые поля выбранного экземпляра рисуются под foldout. |
+| **Open generics** | `Modifier` и подобные: аргументы выводятся из закрытого generic-поля либо выбираются на второй странице селектора. |
+| **Сохранение данных** | При смене типа поля, совпадающие по имени и сериализуемой форме, переносятся, а не сбрасываются в значения по умолчанию. |
+| **Copy / Paste** | Правый клик по заголовку копирует значение и вставляет его независимым экземпляром в любое совместимое поле. |
+| **Мультивыделение** | Смешанное выделение показывает смешанное состояние dropdown; выбор или вставка применяется к каждому объекту в одной группе Undo. |
+| **Проверка компилятором** | Анализатор Roslyn: `AFT0004` (ошибка) — тип наследует `UnityEngine.Object`; `AFT0005` (предупреждение) — селектор оказался бы пустым. |
+
+### Починка сломанных ссылок
+
+| Случай | Решение |
+|---|---|
+| **Потерянный тип** (переименован или удалён) | Жёлтое предупреждение вместо молчаливой очистки. Подчёркнутое **Fix** открывает селектор и переназначает тип с сохранением данных — на любой глубине, в сохранённых ассетах и прямо в Prefab Mode. |
+| **Smart Fix** | Рядом с **Fix** предлагает наиболее вероятную замену (`[MovedFrom]`, другой namespace/сборка, регистр, близкое имя) и применяет в один клик — никогда не автоматически. |
+| **Общая ссылка** (два поля делят экземпляр) | Помечается лейблом; **Make unique** расщепляет её в независимую копию. Дублирование элемента списка (Ctrl+D, `+`) больше не создаёт алиас. |
+
+
+
+Массовая починка вынесена в отдельные вкладки:
+
+| Вкладка | Назначение |
+|---|---|
+| **Asset References** (`Tools → Aspid 🐍 → FastTools → Asset References`) | Строит весь граф managed-ссылок ассета прямо из YAML — дерево по компонентам с путями полей, общими и осиротевшими ссылками, значками `MISSING` / `SHARED` и инлайн-выбором типа на каждой карточке. Достаёт потерянные ссылки, которые инспектор не показывает. |
+| **Project References** (`Tools → Aspid 🐍 → FastTools → Project References`) | `Scan Project` обходит каждый `.prefab` / `.asset` / `.unity` под `Assets/`, группирует сломанные ссылки по сохранённому типу и чинит всю группу одним `Fix all` (плюс Smart Fix). Группа, чей сохранённый тип совпадает с объявленным переименованием `[MovedFrom]`, читается как ожидающая миграция, а не поломка — один клик **Migrate all** запекает переименование в файлы, после чего атрибут можно удалить из кода. |
+
+### Настройки проекта и build/CI gate
+
+**`Project Settings → Aspid FastTools → SerializeReference`** содержит:
+
+| Настройка | Scope | Что делает |
+|---|---|---|
+| **Breakage detection** | per-user | Проактивный тост + предупреждение в Console, когда ссылки заново становятся потерянными после рекомпиляции / импорта. |
+| **Auto de-alias duplicated list elements** | коммитимая | Дублированный элемент списка получает собственный экземпляр вместо совместного использования id оригинала. |
+| **Build / CI gate** | коммитимая | `Off` / `Warn` / `Fail`: при сборке плеера логировать или прерывать сборку на потерянных (а для CI — и на незаданных обязательных) managed-ссылках. |
+| **Excluded scan folders** | коммитимая | Пути, пропускаемые при всех проектных сканах. |
+
+- Коммитимые значения хранятся в `ProjectSettings/SerializeReferenceSharedSettings.asset` — закоммитьте его, чтобы команда и CI вели себя одинаково; breakage detection остаётся per-machine (`EditorPrefs`).
+- Rid colours — не настройка: общая ссылка всегда раскрашивается по id — совпадающий цвет и показывает, какие поля делят один экземпляр.
+
+Те же опции продублированы во вкладке **Settings** окна (`Tools → Aspid 🐍 → FastTools → Settings`) и на странице **`Preferences → Aspid FastTools`**, рядом с индивидуальными настройками пикера:
+
+- **Favorites** — переключатель секции.
+- **Recent items** — слайдер ёмкости (0–20; 0 скрывает секцию и приостанавливает запись, не стирая историю).
+- **Saved lists** — очищает сохранённые Favorites / Recent.
+- **Appearance** — override-`StyleSheet` темы редактора с действием **Create template…**.
+- **Welcome** — переключатель автопоказа.
+
+Каждая строка помечена полоской scope (зелёная — коммитимые, синяя — индивидуальные); закреплённый футер предлагает **Reset to defaults** отдельно для каждого scope (сохранённые списки Favorites / Recent сброс переживают). Все поверхности зеркалят друг друга живьём.
+
+Для headless-CI метод `SerializeReferenceCiGate.RunCheck` (через `-batchmode -executeMethod`) пишет отчёт и учитывает коммитимую строгость гейта:
+
+- `Off` пропускает проверку, `Warn` логирует, но завершается с кодом 0, `Fail` завершается с ненулевым кодом при нарушениях.
+- `-srGateRequired` дополнительно проверяет незаданные поля `[TypeSelector(Required = true)]` в префабах, ScriptableObject и сценах (required-поля верхнего уровня, чистый YAML-проход).
+- Per-run флаги `-srGateWarnOnly` / `-srGateFail` переопределяют коммитимую строгость.
+
+> Полный сэмпл — `Loadout` / `IWeapon` / `Modifier` и сценарии починки потерянных ссылок — поставляется в сэмпле `SerializeReferences` (Package Manager → Aspid.FastTools → Samples). Пошаговый разбор — в `TUTORIAL.md` этого сэмпла.
+
+---
+
## Enum System
Предоставляет сериализуемые отображения enum → значение, настраиваемые через Inspector.
@@ -480,69 +619,6 @@ public sealed class UniqueIdAttribute : PropertyAttribute { }
---
-## SerializedProperty Extensions
-
-Цепочные расширения над `SerializedProperty` для синхронизации владеющего `SerializedObject`, записи типизированных значений и рефлексии над полем-источником.
-
-```csharp
-property
- .Update()
- .SetVector3(Vector3.up)
- .SetBool(true)
- .ApplyModifiedProperties();
-```
-
-Пакет покрывает:
-
-- **Update / Apply** — `Update`, `UpdateIfRequiredOrScript`, `ApplyModifiedProperties`.
-- **Типизированные сеттеры** — `SetValue` (обобщённый диспетчер) и `SetXxx` для `int`/`uint`/`long`/`ulong`/`float`/`double`/`bool`/`string`/`Color`/`Gradient`/`Hash128`/`Rect`/`RectInt`/`Bounds`/`BoundsInt`/`Vector2..4` (и `Vector2/3Int`)/`Quaternion`/`AnimationCurve`/`EntityId` (Unity 6.2+). К каждому идёт парный вариант `SetXxxAndApply`.
-- **Enum-сеттеры** — `SetEnumFlag` и `SetEnumIndex` (каждый + `AndApply`).
-- **Массивы** — `SetArraySize`, `AddArraySize`, `RemoveArraySize` (каждый + `AndApply`).
-- **Ссылки** — `SetManagedReference`, `SetObjectReference`, `SetExposedReference`, а также `SetBoxed` (Unity 6+).
-- **Рефлексионные хелперы** — `GetPropertyType`, `GetMemberInfo`, `GetClassInstance` для разрешения C#-члена и runtime-экземпляра, стоящих за property.
-
-> Полный справочник по методам: [SerializedPropertyExtensions.md](SerializedPropertyExtensions.md)
-
----
-
-## IMGUI Layout Scopes
-
-Три `ref struct`-области — `VerticalScope`, `HorizontalScope`, `ScrollViewScope` — оборачивают `EditorGUILayout.Begin*` / `End*`. Каждая предоставляет свойство `Rect` и вызывает соответствующий метод `End*` в `Dispose`:
-
-```csharp
-using (VerticalScope.Begin())
-{
- EditorGUILayout.LabelField("Item 1");
- EditorGUILayout.LabelField("Item 2");
-}
-
-using (HorizontalScope.Begin())
-{
- EditorGUILayout.LabelField("Left");
- EditorGUILayout.LabelField("Right");
-}
-
-var scrollPos = Vector2.zero;
-using (ScrollViewScope.Begin(ref scrollPos))
-{
- EditorGUILayout.LabelField("Scrollable content");
-}
-```
-
-Получить rect области через перегрузку с `out`-параметром:
-
-```csharp
-using (VerticalScope.Begin(out var rect, GUI.skin.box))
-{
- EditorGUI.DrawRect(rect, new Color(0, 0, 0, 0.1f));
- EditorGUILayout.LabelField("Boxed content");
-}
-```
-
-Все перегрузки `Begin` соответствуют сигнатурам `EditorGUILayout.Begin*` (опциональные `GUIStyle`, `GUILayoutOption[]`, параметры scroll view и т.д.).
-
----
-
## VisualElement Extensions
Fluent-методы расширения для построения UIToolkit-деревьев в коде. Все методы возвращают `T` (сам элемент) для цепочки вызовов.
@@ -610,6 +686,69 @@ internal sealed class AbilityConfigEditor : Editor
---
+## SerializedProperty Extensions
+
+Цепочные расширения над `SerializedProperty` для синхронизации владеющего `SerializedObject`, записи типизированных значений и рефлексии над полем-источником.
+
+```csharp
+property
+ .Update()
+ .SetVector3(Vector3.up)
+ .SetBool(true)
+ .ApplyModifiedProperties();
+```
+
+Пакет покрывает:
+
+- **Update / Apply** — `Update`, `UpdateIfRequiredOrScript`, `ApplyModifiedProperties`.
+- **Типизированные сеттеры** — `SetValue` (обобщённый диспетчер) и `SetXxx` для `int`/`uint`/`long`/`ulong`/`float`/`double`/`bool`/`string`/`Color`/`Gradient`/`Hash128`/`Rect`/`RectInt`/`Bounds`/`BoundsInt`/`Vector2..4` (и `Vector2/3Int`)/`Quaternion`/`AnimationCurve`/`EntityId` (Unity 6.2+). К каждому идёт парный вариант `SetXxxAndApply`.
+- **Enum-сеттеры** — `SetEnumFlag` и `SetEnumIndex` (каждый + `AndApply`).
+- **Массивы** — `SetArraySize`, `AddArraySize`, `RemoveArraySize` (каждый + `AndApply`).
+- **Ссылки** — `SetManagedReference`, `SetObjectReference`, `SetExposedReference`, а также `SetBoxed` (Unity 6+).
+- **Рефлексионные хелперы** — `GetPropertyType`, `GetMemberInfo`, `GetClassInstance` для разрешения C#-члена и runtime-экземпляра, стоящих за property.
+
+> Полный справочник по методам: [SerializedPropertyExtensions.md](SerializedPropertyExtensions.md)
+
+---
+
+## IMGUI Layout Scopes
+
+Три `ref struct`-области — `VerticalScope`, `HorizontalScope`, `ScrollViewScope` — оборачивают `EditorGUILayout.Begin*` / `End*`. Каждая предоставляет свойство `Rect` и вызывает соответствующий метод `End*` в `Dispose`:
+
+```csharp
+using (VerticalScope.Begin())
+{
+ EditorGUILayout.LabelField("Item 1");
+ EditorGUILayout.LabelField("Item 2");
+}
+
+using (HorizontalScope.Begin())
+{
+ EditorGUILayout.LabelField("Left");
+ EditorGUILayout.LabelField("Right");
+}
+
+var scrollPos = Vector2.zero;
+using (ScrollViewScope.Begin(ref scrollPos))
+{
+ EditorGUILayout.LabelField("Scrollable content");
+}
+```
+
+Получить rect области через перегрузку с `out`-параметром:
+
+```csharp
+using (VerticalScope.Begin(out var rect, GUI.skin.box))
+{
+ EditorGUI.DrawRect(rect, new Color(0, 0, 0, 0.1f));
+ EditorGUILayout.LabelField("Boxed content");
+}
+```
+
+Все перегрузки `Begin` соответствуют сигнатурам `EditorGUILayout.Begin*` (опциональные `GUIStyle`, `GUILayoutOption[]`, параметры scroll view и т.д.).
+
+---
+
## Editor Helper Extensions
Утилитарные методы для получения отображаемых имён объектов Unity в пользовательских редакторах.
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences.meta
new file mode 100644
index 00000000..9d6b8820
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences.meta
@@ -0,0 +1,3 @@
+fileFormatVersion: 2
+guid: 5dca98ae8db24794bbb8b57f9074d562
+timeCreated: 1780775167
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Prefabs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Prefabs.meta
new file mode 100644
index 00000000..edaf3f9d
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Prefabs.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: cc14437c0afa46b8843c58f14cdbb07c
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Prefabs/IMGUILoadout.prefab b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Prefabs/IMGUILoadout.prefab
new file mode 100644
index 00000000..c7f9e68c
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Prefabs/IMGUILoadout.prefab
@@ -0,0 +1,87 @@
+%YAML 1.1
+%TAG !u! tag:unity3d.com,2011:
+--- !u!1 &6600000000000000001
+GameObject:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ serializedVersion: 6
+ m_Component:
+ - component: {fileID: 6600000000000000002}
+ - component: {fileID: 6600000000000000003}
+ m_Layer: 0
+ m_Name: IMGUILoadout
+ m_TagString: Untagged
+ m_Icon: {fileID: 0}
+ m_NavMeshLayer: 0
+ m_StaticEditorFlags: 0
+ m_IsActive: 1
+--- !u!4 &6600000000000000002
+Transform:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 6600000000000000001}
+ serializedVersion: 2
+ m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
+ m_LocalPosition: {x: 0, y: 0, z: 0}
+ m_LocalScale: {x: 1, y: 1, z: 1}
+ m_ConstrainProportionsScale: 0
+ m_Children: []
+ m_Father: {fileID: 0}
+ m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
+--- !u!114 &6600000000000000003
+MonoBehaviour:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 6600000000000000001}
+ m_Enabled: 1
+ m_EditorHideFlags: 0
+ m_Script: {fileID: 11500000, guid: e6cd8f6abf02422a92c5f183b53afa29, type: 3}
+ m_Name:
+ m_EditorClassIdentifier:
+ _primaryWeapon:
+ rid: 1001
+ _sidearms:
+ - rid: 1002
+ - rid: 1003
+ _onHitEffect:
+ rid: 1004
+ _modifier:
+ rid: -2
+ _floatModifier:
+ rid: -2
+ _modifiers: []
+ references:
+ version: 2
+ RefIds:
+ - rid: 1001
+ type: {class: Railgun, ns: Aspid.FastTools.Samples.SerializeReferences, asm: Aspid.FastTools.Samples.SerializeReferences}
+ data:
+ _chargeTime: 2
+ _chargeEffect:
+ rid: 1005
+ - rid: 1002
+ type: {class: Pistol, ns: Aspid.FastTools.Samples.SerializeReferences, asm: Aspid.FastTools.Samples.SerializeReferences}
+ data:
+ _damage: 15
+ _magazineSize: 12
+ - rid: 1003
+ type: {class: Shotgun, ns: Aspid.FastTools.Samples.SerializeReferences, asm: Aspid.FastTools.Samples.SerializeReferences}
+ data:
+ _pellets: 8
+ _spreadAngle: 25
+ - rid: 1004
+ type: {class: FreezeEffect, ns: Aspid.FastTools.Samples.SerializeReferences, asm: Aspid.FastTools.Samples.SerializeReferences}
+ data:
+ _duration: 2.5
+ _slowPercent: 40
+ - rid: 1005
+ type: {class: BurnEffect, ns: Aspid.FastTools.Samples.SerializeReferences, asm: Aspid.FastTools.Samples.SerializeReferences}
+ data:
+ _duration: 3
+ _damagePerSecond: 5
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Prefabs/IMGUILoadout.prefab.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Prefabs/IMGUILoadout.prefab.meta
new file mode 100644
index 00000000..c71e4a92
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Prefabs/IMGUILoadout.prefab.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: 78c0fa006a244ee78b5a2dfd9c6618a6
+PrefabImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Prefabs/Loadout.prefab b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Prefabs/Loadout.prefab
new file mode 100644
index 00000000..8b09b585
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Prefabs/Loadout.prefab
@@ -0,0 +1,87 @@
+%YAML 1.1
+%TAG !u! tag:unity3d.com,2011:
+--- !u!1 &6500000000000000001
+GameObject:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ serializedVersion: 6
+ m_Component:
+ - component: {fileID: 6500000000000000002}
+ - component: {fileID: 6500000000000000003}
+ m_Layer: 0
+ m_Name: Loadout
+ m_TagString: Untagged
+ m_Icon: {fileID: 0}
+ m_NavMeshLayer: 0
+ m_StaticEditorFlags: 0
+ m_IsActive: 1
+--- !u!4 &6500000000000000002
+Transform:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 6500000000000000001}
+ serializedVersion: 2
+ m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
+ m_LocalPosition: {x: 0, y: 0, z: 0}
+ m_LocalScale: {x: 1, y: 1, z: 1}
+ m_ConstrainProportionsScale: 0
+ m_Children: []
+ m_Father: {fileID: 0}
+ m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
+--- !u!114 &6500000000000000003
+MonoBehaviour:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 6500000000000000001}
+ m_Enabled: 1
+ m_EditorHideFlags: 0
+ m_Script: {fileID: 11500000, guid: 884d53b5154744d3af6948b1eef02505, type: 3}
+ m_Name:
+ m_EditorClassIdentifier:
+ _primaryWeapon:
+ rid: 1001
+ _sidearms:
+ - rid: 1002
+ - rid: 1003
+ _onHitEffect:
+ rid: 1004
+ _modifier:
+ rid: -2
+ _floatModifier:
+ rid: -2
+ _modifiers: []
+ references:
+ version: 2
+ RefIds:
+ - rid: 1001
+ type: {class: Railgun, ns: Aspid.FastTools.Samples.SerializeReferences, asm: Aspid.FastTools.Samples.SerializeReferences}
+ data:
+ _chargeTime: 2
+ _chargeEffect:
+ rid: 1005
+ - rid: 1002
+ type: {class: Pistol, ns: Aspid.FastTools.Samples.SerializeReferences, asm: Aspid.FastTools.Samples.SerializeReferences}
+ data:
+ _damage: 15
+ _magazineSize: 12
+ - rid: 1003
+ type: {class: Shotgun, ns: Aspid.FastTools.Samples.SerializeReferences, asm: Aspid.FastTools.Samples.SerializeReferences}
+ data:
+ _pellets: 8
+ _spreadAngle: 25
+ - rid: 1004
+ type: {class: FreezeEffect, ns: Aspid.FastTools.Samples.SerializeReferences, asm: Aspid.FastTools.Samples.SerializeReferences}
+ data:
+ _duration: 2.5
+ _slowPercent: 40
+ - rid: 1005
+ type: {class: BurnEffect, ns: Aspid.FastTools.Samples.SerializeReferences, asm: Aspid.FastTools.Samples.SerializeReferences}
+ data:
+ _duration: 3
+ _damagePerSecond: 5
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Prefabs/Loadout.prefab.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Prefabs/Loadout.prefab.meta
new file mode 100644
index 00000000..7b7dd168
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Prefabs/Loadout.prefab.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: 90d26bbfdf2e48fba5c42d214308e9fd
+PrefabImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Prefabs/LoadoutMissingType.prefab b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Prefabs/LoadoutMissingType.prefab
new file mode 100644
index 00000000..574508b8
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Prefabs/LoadoutMissingType.prefab
@@ -0,0 +1,82 @@
+%YAML 1.1
+%TAG !u! tag:unity3d.com,2011:
+--- !u!1 &6500000000000000001
+GameObject:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ serializedVersion: 6
+ m_Component:
+ - component: {fileID: 6500000000000000002}
+ - component: {fileID: 6500000000000000003}
+ m_Layer: 0
+ m_Name: LoadoutMissingType
+ m_TagString: Untagged
+ m_Icon: {fileID: 0}
+ m_NavMeshLayer: 0
+ m_StaticEditorFlags: 0
+ m_IsActive: 1
+--- !u!4 &6500000000000000002
+Transform:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 6500000000000000001}
+ serializedVersion: 2
+ m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
+ m_LocalPosition: {x: 0, y: 0, z: 0}
+ m_LocalScale: {x: 1, y: 1, z: 1}
+ m_ConstrainProportionsScale: 0
+ m_Children: []
+ m_Father: {fileID: 0}
+ m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
+--- !u!114 &6500000000000000003
+MonoBehaviour:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 6500000000000000001}
+ m_Enabled: 1
+ m_EditorHideFlags: 0
+ m_Script: {fileID: 11500000, guid: 884d53b5154744d3af6948b1eef02505, type: 3}
+ m_Name:
+ m_EditorClassIdentifier:
+ _primaryWeapon:
+ rid: 1001
+ _sidearms:
+ - rid: 1002
+ - rid: 1003
+ _onHitEffect:
+ rid: 1004
+ references:
+ version: 2
+ RefIds:
+ - rid: 1001
+ type: {class: Railgun, ns: Aspid.FastTools.Samples.SerializeReferences, asm: Aspid.FastTools.Samples.SerializeReferences}
+ data:
+ _chargeTime: 2
+ _chargeEffect:
+ rid: 1005
+ - rid: 1002
+ type: {class: GhostPistol, ns: Aspid.FastTools.Samples.SerializeReferences, asm: Aspid.FastTools.Samples.SerializeReferences}
+ data:
+ _damage: 15
+ _magazineSize: 12
+ - rid: 1003
+ type: {class: Shotgun, ns: Aspid.FastTools.Samples.SerializeReferences, asm: Aspid.FastTools.Samples.SerializeReferences}
+ data:
+ _pellets: 8
+ _spreadAngle: 25
+ - rid: 1004
+ type: {class: FreezeEffect, ns: Aspid.FastTools.Samples.SerializeReferences, asm: Aspid.FastTools.Samples.SerializeReferences}
+ data:
+ _duration: 2.5
+ _slowPercent: 40
+ - rid: 1005
+ type: {class: BurnEffect, ns: Aspid.FastTools.Samples.SerializeReferences, asm: Aspid.FastTools.Samples.SerializeReferences}
+ data:
+ _duration: 3
+ _damagePerSecond: 5
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Prefabs/LoadoutMissingType.prefab.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Prefabs/LoadoutMissingType.prefab.meta
new file mode 100644
index 00000000..8ee70796
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Prefabs/LoadoutMissingType.prefab.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: 2d0dd2d64d8644d082fcf7019c422955
+PrefabImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Prefabs/LoadoutSharedRef.prefab b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Prefabs/LoadoutSharedRef.prefab
new file mode 100644
index 00000000..c1e2147c
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Prefabs/LoadoutSharedRef.prefab
@@ -0,0 +1,72 @@
+%YAML 1.1
+%TAG !u! tag:unity3d.com,2011:
+--- !u!1 &6500000000000000101
+GameObject:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ serializedVersion: 6
+ m_Component:
+ - component: {fileID: 6500000000000000102}
+ - component: {fileID: 6500000000000000103}
+ m_Layer: 0
+ m_Name: LoadoutSharedRef
+ m_TagString: Untagged
+ m_Icon: {fileID: 0}
+ m_NavMeshLayer: 0
+ m_StaticEditorFlags: 0
+ m_IsActive: 1
+--- !u!4 &6500000000000000102
+Transform:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 6500000000000000101}
+ serializedVersion: 2
+ m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
+ m_LocalPosition: {x: 0, y: 0, z: 0}
+ m_LocalScale: {x: 1, y: 1, z: 1}
+ m_ConstrainProportionsScale: 0
+ m_Children: []
+ m_Father: {fileID: 0}
+ m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
+--- !u!114 &6500000000000000103
+MonoBehaviour:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 6500000000000000101}
+ m_Enabled: 1
+ m_EditorHideFlags: 0
+ m_Script: {fileID: 11500000, guid: 884d53b5154744d3af6948b1eef02505, type: 3}
+ m_Name:
+ m_EditorClassIdentifier:
+ _primaryWeapon:
+ rid: 1001
+ _sidearms:
+ - rid: 1002
+ - rid: 1002
+ _onHitEffect:
+ rid: 1005
+ references:
+ version: 2
+ RefIds:
+ - rid: 1001
+ type: {class: Railgun, ns: Aspid.FastTools.Samples.SerializeReferences, asm: Aspid.FastTools.Samples.SerializeReferences}
+ data:
+ _chargeTime: 2
+ _chargeEffect:
+ rid: 1005
+ - rid: 1002
+ type: {class: Pistol, ns: Aspid.FastTools.Samples.SerializeReferences, asm: Aspid.FastTools.Samples.SerializeReferences}
+ data:
+ _damage: 15
+ _magazineSize: 12
+ - rid: 1005
+ type: {class: BurnEffect, ns: Aspid.FastTools.Samples.SerializeReferences, asm: Aspid.FastTools.Samples.SerializeReferences}
+ data:
+ _duration: 3
+ _damagePerSecond: 5
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Prefabs/LoadoutSharedRef.prefab.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Prefabs/LoadoutSharedRef.prefab.meta
new file mode 100644
index 00000000..6c2eda37
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Prefabs/LoadoutSharedRef.prefab.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: 497032feb2ea4062b652b78771e6fe02
+PrefabImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Prefabs/NestedLoadout.prefab b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Prefabs/NestedLoadout.prefab
new file mode 100644
index 00000000..b2b8b856
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Prefabs/NestedLoadout.prefab
@@ -0,0 +1,203 @@
+%YAML 1.1
+%TAG !u! tag:unity3d.com,2011:
+--- !u!1 &7100000000000000001
+GameObject:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ serializedVersion: 6
+ m_Component:
+ - component: {fileID: 7100000000000000002}
+ - component: {fileID: 7100000000000000003}
+ m_Layer: 0
+ m_Name: NestedLoadout
+ m_TagString: Untagged
+ m_Icon: {fileID: 0}
+ m_NavMeshLayer: 0
+ m_StaticEditorFlags: 0
+ m_IsActive: 1
+--- !u!4 &7100000000000000002
+Transform:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 7100000000000000001}
+ serializedVersion: 2
+ m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
+ m_LocalPosition: {x: 0, y: 0, z: 0}
+ m_LocalScale: {x: 1, y: 1, z: 1}
+ m_ConstrainProportionsScale: 0
+ m_Children:
+ - {fileID: 7100000000000000012}
+ m_Father: {fileID: 0}
+ m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
+--- !u!114 &7100000000000000003
+MonoBehaviour:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 7100000000000000001}
+ m_Enabled: 1
+ m_EditorHideFlags: 0
+ m_Script: {fileID: 11500000, guid: 884d53b5154744d3af6948b1eef02505, type: 3}
+ m_Name:
+ m_EditorClassIdentifier:
+ _primaryWeapon:
+ rid: 1001
+ _sidearms:
+ - rid: 1003
+ - rid: -2
+ _onHitEffect:
+ rid: 1004
+ references:
+ version: 2
+ RefIds:
+ - rid: -2
+ type: {class: , ns: , asm: }
+ - rid: 1001
+ type: {class: Railgun, ns: Aspid.FastTools.Samples.SerializeReferences, asm: Aspid.FastTools.Samples.SerializeReferences}
+ data:
+ _chargeTime: 1.5
+ _chargeEffect:
+ rid: 1002
+ - rid: 1002
+ type: {class: BurnEffect, ns: Aspid.FastTools.Samples.SerializeReferences, asm: Aspid.FastTools.Samples.SerializeReferences}
+ data:
+ _duration: 3
+ _damagePerSecond: 5
+ - rid: 1003
+ type: {class: GhostPistol, ns: Aspid.FastTools.Samples.SerializeReferences, asm: Aspid.FastTools.Samples.SerializeReferences}
+ data:
+ _damage: 15
+ _magazineSize: 12
+ - rid: 1004
+ type: {class: FreezeEffect, ns: Aspid.FastTools.Samples.SerializeReferences, asm: Aspid.FastTools.Samples.SerializeReferences}
+ data:
+ _duration: 2.5
+ _slowPercent: 40
+--- !u!1 &7100000000000000011
+GameObject:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ serializedVersion: 6
+ m_Component:
+ - component: {fileID: 7100000000000000012}
+ - component: {fileID: 7100000000000000013}
+ m_Layer: 0
+ m_Name: WeaponSlot
+ m_TagString: Untagged
+ m_Icon: {fileID: 0}
+ m_NavMeshLayer: 0
+ m_StaticEditorFlags: 0
+ m_IsActive: 1
+--- !u!4 &7100000000000000012
+Transform:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 7100000000000000011}
+ serializedVersion: 2
+ m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
+ m_LocalPosition: {x: 0, y: 0, z: 0}
+ m_LocalScale: {x: 1, y: 1, z: 1}
+ m_ConstrainProportionsScale: 0
+ m_Children:
+ - {fileID: 7100000000000000022}
+ m_Father: {fileID: 7100000000000000002}
+ m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
+--- !u!114 &7100000000000000013
+MonoBehaviour:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 7100000000000000011}
+ m_Enabled: 1
+ m_EditorHideFlags: 0
+ m_Script: {fileID: 11500000, guid: 884d53b5154744d3af6948b1eef02505, type: 3}
+ m_Name:
+ m_EditorClassIdentifier:
+ _primaryWeapon:
+ rid: 2001
+ _sidearms:
+ - rid: 2002
+ references:
+ version: 2
+ RefIds:
+ - rid: 2001
+ type: {class: GhostBlade, ns: Aspid.FastTools.Samples.SerializeReferences, asm: Aspid.FastTools.Samples.SerializeReferences}
+ data:
+ _damage: 25
+ _reach: 1.8
+ - rid: 2002
+ type: {class: Pistol, ns: Aspid.FastTools.Samples.SerializeReferences, asm: Aspid.FastTools.Samples.SerializeReferences}
+ data:
+ _damage: 10
+ _magazineSize: 8
+--- !u!1 &7100000000000000021
+GameObject:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ serializedVersion: 6
+ m_Component:
+ - component: {fileID: 7100000000000000022}
+ - component: {fileID: 7100000000000000023}
+ m_Layer: 0
+ m_Name: BackupSlot
+ m_TagString: Untagged
+ m_Icon: {fileID: 0}
+ m_NavMeshLayer: 0
+ m_StaticEditorFlags: 0
+ m_IsActive: 1
+--- !u!4 &7100000000000000022
+Transform:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 7100000000000000021}
+ serializedVersion: 2
+ m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
+ m_LocalPosition: {x: 0, y: 0, z: 0}
+ m_LocalScale: {x: 1, y: 1, z: 1}
+ m_ConstrainProportionsScale: 0
+ m_Children: []
+ m_Father: {fileID: 7100000000000000012}
+ m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
+--- !u!114 &7100000000000000023
+MonoBehaviour:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 7100000000000000021}
+ m_Enabled: 1
+ m_EditorHideFlags: 0
+ m_Script: {fileID: 11500000, guid: 884d53b5154744d3af6948b1eef02505, type: 3}
+ m_Name:
+ m_EditorClassIdentifier:
+ _primaryWeapon:
+ rid: 3002
+ _onHitEffect:
+ rid: 3001
+ references:
+ version: 2
+ RefIds:
+ - rid: 3001
+ type: {class: GhostAura, ns: Aspid.FastTools.Samples.SerializeReferences, asm: Aspid.FastTools.Samples.SerializeReferences}
+ data:
+ _duration: 4
+ _radius: 3
+ - rid: 3002
+ type: {class: Shotgun, ns: Aspid.FastTools.Samples.SerializeReferences, asm: Aspid.FastTools.Samples.SerializeReferences}
+ data:
+ _pellets: 6
+ _spreadAngle: 30
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Prefabs/NestedLoadout.prefab.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Prefabs/NestedLoadout.prefab.meta
new file mode 100644
index 00000000..84c1ca83
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Prefabs/NestedLoadout.prefab.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: 0e1b2a3402c742c69e160b1b1476e348
+PrefabImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Prefabs/SlottedLoadout.prefab b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Prefabs/SlottedLoadout.prefab
new file mode 100644
index 00000000..2d8b389e
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Prefabs/SlottedLoadout.prefab
@@ -0,0 +1,84 @@
+%YAML 1.1
+%TAG !u! tag:unity3d.com,2011:
+--- !u!1 &6166294228952064148
+GameObject:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ serializedVersion: 6
+ m_Component:
+ - component: {fileID: 8734551161022389016}
+ - component: {fileID: 1474488742748373929}
+ m_Layer: 0
+ m_Name: SlottedLoadout
+ m_TagString: Untagged
+ m_Icon: {fileID: 0}
+ m_NavMeshLayer: 0
+ m_StaticEditorFlags: 0
+ m_IsActive: 1
+--- !u!4 &8734551161022389016
+Transform:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 6166294228952064148}
+ serializedVersion: 2
+ m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
+ m_LocalPosition: {x: 0, y: 0, z: 0}
+ m_LocalScale: {x: 1, y: 1, z: 1}
+ m_ConstrainProportionsScale: 0
+ m_Children: []
+ m_Father: {fileID: 0}
+ m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
+--- !u!114 &1474488742748373929
+MonoBehaviour:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 6166294228952064148}
+ m_Enabled: 1
+ m_EditorHideFlags: 0
+ m_Script: {fileID: 11500000, guid: 251896effa22341e7981b29000d77094, type: 3}
+ m_Name:
+ m_EditorClassIdentifier:
+ _primarySlot:
+ label: Primary
+ priority: 0
+ _weapon:
+ rid: 2699798180063346814
+ _slots:
+ - label: Backup
+ priority: 1
+ _weapon:
+ rid: 2699798180063346816
+ - label: Heavy
+ priority: 2
+ _weapon:
+ rid: 2699798180063346817
+ references:
+ version: 2
+ RefIds:
+ - rid: 2699798180063346814
+ type: {class: Railgun, ns: Aspid.FastTools.Samples.SerializeReferences, asm: Aspid.FastTools.Samples.SerializeReferences}
+ data:
+ _chargeTime: 1.5
+ _chargeEffect:
+ rid: 2699798180063346815
+ - rid: 2699798180063346815
+ type: {class: BurnEffect, ns: Aspid.FastTools.Samples.SerializeReferences, asm: Aspid.FastTools.Samples.SerializeReferences}
+ data:
+ _duration: 3
+ _damagePerSecond: 5
+ - rid: 2699798180063346816
+ type: {class: Pistol, ns: Aspid.FastTools.Samples.SerializeReferences, asm: Aspid.FastTools.Samples.SerializeReferences}
+ data:
+ _damage: 10
+ _magazineSize: 12
+ - rid: 2699798180063346817
+ type: {class: Shotgun, ns: Aspid.FastTools.Samples.SerializeReferences, asm: Aspid.FastTools.Samples.SerializeReferences}
+ data:
+ _pellets: 8
+ _spreadAngle: 25
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Prefabs/SlottedLoadout.prefab.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Prefabs/SlottedLoadout.prefab.meta
new file mode 100644
index 00000000..4fe1f8a5
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Prefabs/SlottedLoadout.prefab.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: 9c2056f6e14fb4adba50391b71ae9757
+PrefabImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Presets.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Presets.meta
new file mode 100644
index 00000000..393093ec
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Presets.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 8e15c8d83f394218957f70a4d3126ac7
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Presets/BrokenArsenalPreset.asset b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Presets/BrokenArsenalPreset.asset
new file mode 100644
index 00000000..dce91638
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Presets/BrokenArsenalPreset.asset
@@ -0,0 +1,43 @@
+%YAML 1.1
+%TAG !u! tag:unity3d.com,2011:
+--- !u!114 &11400000
+MonoBehaviour:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 0}
+ m_Enabled: 1
+ m_EditorHideFlags: 0
+ m_Script: {fileID: 11500000, guid: b7874533c7294db1b8aa77e7d4102c9f, type: 3}
+ m_Name: BrokenArsenalPreset
+ m_EditorClassIdentifier:
+ _weapon:
+ rid: 8000
+ _alternates:
+ - rid: 8001
+ - rid: 8002
+ - rid: 8003
+ references:
+ version: 2
+ RefIds:
+ - rid: 8000
+ type: {class: GhostWeapon, ns: Aspid.FastTools.Samples.SerializeReferences, asm: Aspid.FastTools.Samples.SerializeReferences}
+ data:
+ _damage: 40
+ _magazineSize: 6
+ - rid: 8001
+ type: {class: GhostWeapon, ns: Aspid.FastTools.Samples.SerializeReferences, asm: Aspid.FastTools.Samples.SerializeReferences}
+ data:
+ _damage: 18
+ _magazineSize: 30
+ - rid: 8002
+ type: {class: Pistol, ns: Aspid.FastTools.Samples.SerializeReferences, asm: Aspid.FastTools.Samples.SerializeReferences}
+ data:
+ _damage: 12
+ _magazineSize: 10
+ - rid: 8003
+ type: {class: GhostWeapon, ns: Aspid.FastTools.Samples.SerializeReferences, asm: Aspid.FastTools.Samples.SerializeReferences}
+ data:
+ _damage: 55
+ _magazineSize: 4
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Presets/BrokenArsenalPreset.asset.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Presets/BrokenArsenalPreset.asset.meta
new file mode 100644
index 00000000..4d2f0dda
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Presets/BrokenArsenalPreset.asset.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: c8d4e7a91b3f4e62a9d0517c3e6b2f48
+NativeFormatImporter:
+ externalObjects: {}
+ mainObjectFileID: 11400000
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Presets/BrokenWeaponPreset.asset b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Presets/BrokenWeaponPreset.asset
new file mode 100644
index 00000000..da1a4c28
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Presets/BrokenWeaponPreset.asset
@@ -0,0 +1,25 @@
+%YAML 1.1
+%TAG !u! tag:unity3d.com,2011:
+--- !u!114 &11400000
+MonoBehaviour:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 0}
+ m_Enabled: 1
+ m_EditorHideFlags: 0
+ m_Script: {fileID: 11500000, guid: b7874533c7294db1b8aa77e7d4102c9f, type: 3}
+ m_Name: BrokenWeaponPreset
+ m_EditorClassIdentifier:
+ _weapon:
+ rid: 7000
+ _alternates: []
+ references:
+ version: 2
+ RefIds:
+ - rid: 7000
+ type: {class: GhostWeapon, ns: Aspid.FastTools.Samples.SerializeReferences, asm: Aspid.FastTools.Samples.SerializeReferences}
+ data:
+ _damage: 25
+ _magazineSize: 8
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Presets/BrokenWeaponPreset.asset.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Presets/BrokenWeaponPreset.asset.meta
new file mode 100644
index 00000000..fd2843ff
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Presets/BrokenWeaponPreset.asset.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 6a4cbc7edeb6449ca04211e456655406
+NativeFormatImporter:
+ externalObjects: {}
+ mainObjectFileID: 11400000
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Presets/MovedWeaponPreset.asset b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Presets/MovedWeaponPreset.asset
new file mode 100644
index 00000000..a8419d3f
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Presets/MovedWeaponPreset.asset
@@ -0,0 +1,25 @@
+%YAML 1.1
+%TAG !u! tag:unity3d.com,2011:
+--- !u!114 &11400000
+MonoBehaviour:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 0}
+ m_Enabled: 1
+ m_EditorHideFlags: 0
+ m_Script: {fileID: 11500000, guid: b7874533c7294db1b8aa77e7d4102c9f, type: 3}
+ m_Name: MovedWeaponPreset
+ m_EditorClassIdentifier:
+ _weapon:
+ rid: 7200
+ _alternates: []
+ references:
+ version: 2
+ RefIds:
+ - rid: 7200
+ type: {class: Pistol, ns: Aspid.FastTools.Samples.SerializeReferences.Legacy, asm: Aspid.FastTools.Samples.SerializeReferences}
+ data:
+ _damage: 21
+ _magazineSize: 6
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Presets/MovedWeaponPreset.asset.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Presets/MovedWeaponPreset.asset.meta
new file mode 100644
index 00000000..0bebc674
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Presets/MovedWeaponPreset.asset.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 5eee53194e7347148e541067d421712f
+NativeFormatImporter:
+ externalObjects: {}
+ mainObjectFileID: 11400000
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Presets/RenamedWeaponPreset.asset b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Presets/RenamedWeaponPreset.asset
new file mode 100644
index 00000000..e79d915e
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Presets/RenamedWeaponPreset.asset
@@ -0,0 +1,25 @@
+%YAML 1.1
+%TAG !u! tag:unity3d.com,2011:
+--- !u!114 &11400000
+MonoBehaviour:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 0}
+ m_Enabled: 1
+ m_EditorHideFlags: 0
+ m_Script: {fileID: 11500000, guid: b7874533c7294db1b8aa77e7d4102c9f, type: 3}
+ m_Name: RenamedWeaponPreset
+ m_EditorClassIdentifier:
+ _weapon:
+ rid: 7300
+ _alternates: []
+ references:
+ version: 2
+ RefIds:
+ - rid: 7300
+ type: {class: CrossbowLauncher, ns: Aspid.FastTools.Samples.SerializeReferences, asm: Aspid.FastTools.Samples.SerializeReferences}
+ data:
+ _damage: 17
+ _boltCount: 5
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Presets/RenamedWeaponPreset.asset.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Presets/RenamedWeaponPreset.asset.meta
new file mode 100644
index 00000000..0842a705
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Presets/RenamedWeaponPreset.asset.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: aa278a63a2cd44d1844f31ec4281db98
+NativeFormatImporter:
+ externalObjects: {}
+ mainObjectFileID: 11400000
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/README.md b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/README.md
new file mode 100644
index 00000000..042611cf
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/README.md
@@ -0,0 +1,116 @@
+# SerializeReferences Sample
+
+A tiny loadout system that demonstrates `[TypeSelector]` — a searchable, hierarchical type dropdown for `[SerializeReference]` fields. Put both attributes on one field and the Inspector lets you pick which concrete implementation is instantiated; `` clears the reference, and the instance's serialized fields appear inline under the foldout.
+
+```csharp
+[SerializeReference] [TypeSelector]
+private IWeapon _weapon;
+```
+
+> **New here? Start with [TUTORIAL.md](TUTORIAL.md)** ([RU](TUTORIAL_RU.md)) — a guided, step-by-step tour (Lessons 1–8) built around `Scripts/Tutorial/TypeSelectorTutorial.cs` and `Scenes/TypeSelectorTutorial.unity`. This page is the feature reference; the tutorial is the walkthrough.
+
+
+
+
+*The same searchable picker window, shown here on another candidate list — your fields open it filtered to their own type hierarchy.*
+
+## How to run
+
+Ready-made demo prefabs live in `Prefabs/` — double-click one to open it in Prefab Mode, or drag it into any scene. Start with **`Loadout.prefab`**, pre-filled with `Primary Weapon = Railgun` (carrying a nested `BurnEffect` charge effect), `Sidearms = [Pistol, Shotgun]`, `On Hit Effect = FreezeEffect`. Then experiment:
+
+1. Click any type dropdown and pick another implementation — the instance is created and its serialized fields appear inline under the foldout.
+2. Expand `Railgun` and change its nested `Charge Effect` to see recursive polymorphic editing.
+3. Press **+** on `Sidearms` and give each element its own weapon type.
+4. Open `On Hit Effect` — note only `BurnEffect` / `FreezeEffect` are offered (the abstract `StatusEffect` is hidden).
+5. Open `Modifier` — the picker offers the concrete subclasses **and** the open generic `Modifier`; picking `Modifier` opens a second page inside the same picker to choose `T`. Open `Float Modifier` — only candidates assignable to `Modifier` are offered, with `T` inferred (no extra page). The full walkthrough is [TUTORIAL.md, Lesson 6](TUTORIAL.md#lesson-6--generic-hierarchies).
+6. Right-click the component header → **Log Loadout** to print the configured loadout to the Console.
+
+Prefer building from scratch? Add an empty GameObject and attach the **Loadout** component.
+
+Switching a field back to `` clears the reference. If a stored type is later renamed or deleted, the dropdown shows a `` caption and a warning instead of silently clearing.
+
+### The demo prefabs
+
+| Prefab | Shows |
+|---|---|
+| `Loadout.prefab` | Every field flavour: single, list, abstract base, generics, nesting |
+| `SlottedLoadout.prefab` | References inside plain `[Serializable]` containers (Lesson 7) |
+| `LoadoutMissingType.prefab` | The missing-type warning and its inline **Fix** |
+| `NestedLoadout.prefab` | A three-level hierarchy for the **Asset References** graph |
+| `LoadoutSharedRef.prefab` | Shared-reference pairs, colour coding, **Make Unique Reference** |
+| `IMGUILoadout.prefab` | The same data as `Loadout.prefab`, forced through the IMGUI renderer (see *The IMGUI path* below) |
+
+## What's in the code
+
+- `Scripts/Loadout.cs` — single (`IWeapon`), `List`, abstract-base (`StatusEffect`) and generic (`IModifier` / `Modifier`) `[SerializeReference]` fields, each annotated with `[TypeSelector]`.
+- `Scripts/Weapons/` — `IWeapon` interface with its `IMelee` / `IRanged` branches and implementations (`Sword`, `Pistol`, `Shotgun`, `Railgun`, `Crossbow`). `Railgun` nests another `[TypeSelector]` field; `Crossbow` carries a `[MovedFrom]` used by the migration demo.
+- `Scripts/Effects/` — abstract `StatusEffect` base with `BurnEffect` / `FreezeEffect`. The dropdown offers only the concrete subclasses.
+- `Scripts/Modifiers/` — the generic hierarchy: open generic `Modifier` plus closed subclasses over `float` / `int` / `string` ([TUTORIAL.md, Lesson 6](TUTORIAL.md#lesson-6--generic-hierarchies)).
+- `Scripts/WeaponSlot.cs` — the plain `[Serializable]` container used by `SlottedLoadout` and the tutorial's Lesson 7.
+- `Scripts/WeaponPreset.cs` + `Presets/` — `ScriptableObject`s whose stored type identities are deliberately broken or stale, used by the repair flows below.
+
+## Maintenance features
+
+The drawer also helps recover from the ways a managed reference goes wrong in practice.
+
+### Copy / Paste & keep-data
+
+- **Right-click** any selector header → **Copy Serialize Reference** / **Paste Serialize Reference**. Paste rebuilds an *independent* instance in the target field and is greyed out when the copied type does not fit the field.
+- **Switching the type** keeps the fields the old and new implementation share. Set `Sidearms[0]` to `Pistol`, give it a damage value, then switch it to `Shotgun` and back — the `Pistol` value is still there.
+
+### Repair a missing type — `BrokenWeaponPreset.asset` & `LoadoutMissingType.prefab`
+
+Five assets ship storing type identities that no longer resolve directly:
+
+- `Presets/BrokenWeaponPreset.asset` — a `ScriptableObject` whose `Weapon` references a missing `GhostWeapon`.
+- `Presets/BrokenArsenalPreset.asset` — a second `ScriptableObject` that also references the missing `GhostWeapon`, three times over (`Weapon` plus two of its `Alternates`), so it shares a broken type with `BrokenWeaponPreset.asset`.
+- `Prefabs/LoadoutMissingType.prefab` — a prefab whose `Sidearms → Element 0` references a missing `GhostPistol`.
+- `Presets/MovedWeaponPreset.asset` — a `ScriptableObject` whose `Weapon` stores `Pistol` under an old `…Samples.SerializeReferences.Legacy` namespace, as if the class had been moved without `[MovedFrom]` — this one demonstrates the one-click **Smart Fix** below.
+- `Presets/RenamedWeaponPreset.asset` — a `ScriptableObject` whose `Weapon` stores the old `CrossbowLauncher` class name; the class now ships as `Crossbow` carrying a declared `[MovedFrom]`, so the Inspector shows a healthy weapon and only the file is stale — this one demonstrates the **Migrate all** flow in Project References.
+
+Select any of the first four **in the Project window**. The missing field shows a `` caption, a **Missing type** warning, and a **Fix** button:
+
+1. Click **Fix** — the usual searchable type picker opens. Choose `Pistol`.
+2. The reference is restored to a `Pistol` with its preserved data (the prefab keeps `_damage = 15`, `_magazineSize = 12`; the asset keeps `_damage = 25`, `_magazineSize = 8`). Picking the type rewrites the stored type in the asset file rather than recreating the instance, so the values survive.
+
+When the broken identity has a plausible successor, the warning also carries a one-click **Smart Fix** suggestion —
+open `MovedWeaponPreset.asset`: its notice ends with **`→ Pistol?`** (hover for the full identity and the ranking
+reason). Click it to re-point the reference without opening the picker, keeping `_damage = 21`, `_magazineSize = 6`.
+
+- Ranking, highest first: a declared `[MovedFrom]` match, a same-named type in another namespace/assembly, a casing-only rename, a near-miss name backed by the orphaned data's field shape.
+- Never applied automatically — you always click.
+- A move that ships `[MovedFrom]` from the start never breaks (Unity migrates it on load); Smart Fix catches the moves that forgot it. The `GhostWeapon`/`GhostPistol` assets have no plausible successor, so they show no suggestion — that contrast is intentional.
+
+> The repair reads and rewrites the asset file directly — Unity does not expose a missing type through its serialization API (and on GameObjects/prefabs even drops it from the live object, UUM-129100), so the orphaned type and data are recovered straight from the YAML. It works for ScriptableObjects and prefab assets selected in the Project (rewritten in their YAML), for objects open in **Prefab Mode** (repaired on the live instance), and for objects in a **clean saved scene** (located via `GlobalObjectId`) — but not for an **unsaved/dirty scene** or a **prefab-instance override**, which have no committed asset document to map the reference to.
+>
+> When a missing reference is nested inside another value or sits on a child object the Inspector can't reach, use **`Tools → Aspid 🐍 → FastTools → Asset References`** instead: it scans the whole asset file and lists every missing reference (any depth, any child) with its own **Fix** picker.
+>
+> Its **Project References** tab sweeps every asset under `Assets/` and groups the broken references by their stored type — so `BrokenWeaponPreset.asset` and `BrokenArsenalPreset.asset` collapse into a single **GhostWeapon** group (`4 entries · 2 files`). One **Fix all** picks a single replacement and re-points every entry across both files at once. And `RenamedWeaponPreset.asset` surfaces there as a calm, info-tinted **pending migration** instead of a warning: its stored `CrossbowLauncher` matches the `[MovedFrom]` declared on `Crossbow`, so the card offers an authoritative **Migrate all (1) → Crossbow** that bakes the rename into the file — after which the attribute could be deleted from code.
+
+### Map a nested graph — `NestedLoadout.prefab`
+
+`Prefabs/NestedLoadout.prefab` is a three-level hierarchy — `NestedLoadout → WeaponSlot → BackupSlot` — with a `Loadout` on **every** object, so each child carries a broken reference the Inspector can't reach from outside Prefab Mode:
+
+- **NestedLoadout** (root) — `Primary Weapon = Railgun` (with a nested `BurnEffect` charge effect), `Sidearms = [GhostPistol (missing), (empty slot)]`, `On Hit Effect = FreezeEffect`.
+- **WeaponSlot** (child) — `Primary Weapon = GhostBlade` (missing), `Sidearms[0] = Pistol`.
+- **BackupSlot** (grandchild) — `On Hit Effect = GhostAura` (missing), `Primary Weapon = Shotgun`.
+
+Select it **in the Project window** and open the **Asset References** tab — **`Tools → Aspid 🐍 → FastTools → Asset References`**. The graph maps all three components at once (one document per object). Every reference is an inline dropdown: pick a type to assign / re-point it, or `` to clear it; the missing `GhostPistol` / `GhostBlade` / `GhostAura` cards carry the amber **Fix Missing** action. Nesting is read from the field path (`_primaryWeapon._chargeEffect`), not from indentation, so the flat card stack stays scannable.
+
+### Un-share aliased references & tell groups apart by colour — `LoadoutSharedRef.prefab`
+
+`Prefabs/LoadoutSharedRef.prefab` carries **two independent** shared-reference pairs on one object (each pair is a state you can also reach by duplicating an array element), so the rid-colour stripe/notice actually earns its keep:
+
+- `Sidearms[0]` and `Sidearms[1]` both back the same `Pistol` — one colour.
+- `Primary Weapon → Charge Effect` and `On Hit Effect` both back the same `BurnEffect` — a different colour, even though one is nested three levels deep and the other is a top-level field.
+
+1. Open it — each pair shows a **shared reference** notice and editing one member changes its partner. Matching stripe/notice colour means matching instance regardless of where the field sits in the hierarchy, so the two pairs read as two distinct colours.
+2. **Right-click** a member → **Make Unique Reference**. It gets its own copy of the data and the two fields become independent — its notice clears, and so does its former partner's, since nothing is shared any more.
+
+## The IMGUI path
+
+The drawer ships both a UIToolkit and an IMGUI rendering path, at full feature parity. **`Prefabs/IMGUILoadout.prefab`** carries the same data as `Loadout.prefab` but forces the IMGUI path, so you can compare the two renderers side by side — or copy the pattern into an IMGUI-only project.
+
+The trick is the companion editor: `IMGUILoadoutEditor` overrides `OnInspectorGUI` **without** `CreateInspectorGUI` — that alone routes every nested `[TypeSelector]` field through `SerializeReferenceIMGUIPropertyDrawer` instead of the UIToolkit `CreatePropertyGUI`. The one IMGUI-specific caveat: Unity applies the drawer per list *element*, so a `[SerializeReference]` list's **+** button is drawn with `SerializeReferenceIMGUIList.Draw(listProperty, label, elementType)` to keep the picker-backed, de-aliased add. See `Scripts/IMGUILoadout.cs` + `Scripts/Editor/IMGUILoadoutEditor.cs`.
+
+All maintenance windows (**Asset References**, **Project References**) are renderer-agnostic and work identically for both paths.
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/README.md.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/README.md.meta
new file mode 100644
index 00000000..e0f714a1
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/README.md.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: 28ef597d95ad4acda660bc86c164648b
+TextScriptImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/README_RU.md b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/README_RU.md
new file mode 100644
index 00000000..81c283a7
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/README_RU.md
@@ -0,0 +1,117 @@
+# Пример SerializeReferences
+
+Маленькая система снаряжения, демонстрирующая `[TypeSelector]` — иерархический выпадающий список с поиском для полей `[SerializeReference]`. Поставьте оба атрибута на одно поле — и прямо в Inspector выбираете, какая конкретная реализация будет создана; `` очищает ссылку, а сериализуемые поля экземпляра появляются вложенно под foldout.
+
+```csharp
+[SerializeReference] [TypeSelector]
+private IWeapon _weapon;
+```
+
+> **Впервые здесь? Начните с [TUTORIAL_RU.md](TUTORIAL_RU.md)** ([EN](TUTORIAL.md)) — пошаговый разбор (уроки 1–8) вокруг `Scripts/Tutorial/TypeSelectorTutorial.cs` и `Scenes/TypeSelectorTutorial.unity`. Эта страница — справочник по фичам, а туториал — пошаговое прохождение.
+
+
+
+
+*То же окно-селектор с поиском, здесь показано на другом списке кандидатов — ваши поля открывают его отфильтрованным под собственную иерархию типов.*
+
+## Как запустить
+
+В `Prefabs/` лежат готовые демо-префабы — дважды кликните, чтобы открыть в Prefab Mode, или перетащите любой в сцену. Начните с **`Loadout.prefab`** — он предзаполнен: `Primary Weapon = Railgun` (с вложенным эффектом заряда `BurnEffect`), `Sidearms = [Pistol, Shotgun]`, `On Hit Effect = FreezeEffect`. Дальше поэкспериментируйте:
+
+1. Кликните по любому дропдауну типа и выберите другую реализацию — экземпляр создастся, а его сериализуемые поля появятся вложенно под foldout.
+2. Разверните `Railgun` и смените вложенный `Charge Effect` — увидите рекурсивное полиморфное редактирование.
+3. Нажмите **+** на `Sidearms` и задайте каждому элементу свой тип оружия.
+4. Откройте `On Hit Effect` — обратите внимание, что предлагаются только `BurnEffect` / `FreezeEffect` (абстрактный `StatusEffect` скрыт).
+5. Откройте `Modifier` — селектор предлагает конкретные подтипы **и** открытый `Modifier`; при выборе `Modifier` внутри того же окна открывается вторая страница для выбора `T`. Откройте `Float Modifier` — предлагаются только присваиваемые к `Modifier` кандидаты, `T` выводится сам (без дополнительной страницы). Полный разбор — [TUTORIAL_RU.md, урок 6](TUTORIAL_RU.md#урок-6--generic-иерархии).
+6. ПКМ по заголовку компонента → **Log Loadout**, чтобы вывести настроенное снаряжение в Console.
+
+Хотите собрать с нуля? Добавьте пустой GameObject и прикрепите компонент **Loadout**.
+
+Переключение поля обратно на `` очищает ссылку. Если сохранённый тип позже переименуют или удалят, в списке появится подпись `` и предупреждение, вместо тихой очистки.
+
+### Демо-префабы
+
+| Префаб | Показывает |
+|---|---|
+| `Loadout.prefab` | Все виды полей: одиночное, список, абстрактная база, generics, вложенность |
+| `SlottedLoadout.prefab` | Ссылки внутри обычных `[Serializable]`-контейнеров (урок 7) |
+| `LoadoutMissingType.prefab` | Предупреждение о потерянном типе и инлайн-**Fix** |
+| `NestedLoadout.prefab` | Трёхуровневая иерархия для графа **Asset References** |
+| `LoadoutSharedRef.prefab` | Пары общих ссылок, цветовая кодировка, **Make Unique Reference** |
+| `IMGUILoadout.prefab` | Те же данные, что у `Loadout.prefab`, но принудительно через IMGUI-рендерер (см. *Путь IMGUI* ниже) |
+
+## Что в коде
+
+- `Scripts/Loadout.cs` — одиночное поле (`IWeapon`), `List`, поле с абстрактным базовым типом (`StatusEffect`) и generic-поля (`IModifier` / `Modifier`), каждое с `[SerializeReference]` и `[TypeSelector]`.
+- `Scripts/Weapons/` — интерфейс `IWeapon` с ветками `IMelee` / `IRanged` и реализациями (`Sword`, `Pistol`, `Shotgun`, `Railgun`, `Crossbow`). `Railgun` вкладывает ещё одно поле `[TypeSelector]`; `Crossbow` несёт `[MovedFrom]`, используемый демо миграции.
+- `Scripts/Effects/` — абстрактный базовый `StatusEffect` с `BurnEffect` / `FreezeEffect`. В списке предлагаются только конкретные подтипы.
+- `Scripts/Modifiers/` — generic-иерархия: открытый `Modifier` и закрытые подтипы над `float` / `int` / `string` ([TUTORIAL_RU.md, урок 6](TUTORIAL_RU.md#урок-6--generic-иерархии)).
+- `Scripts/WeaponSlot.cs` — обычный `[Serializable]`-контейнер, общий для `SlottedLoadout` и урока 7 туториала.
+- `Scripts/WeaponPreset.cs` + `Presets/` — `ScriptableObject`-ы с намеренно сломанными или устаревшими идентичностями типов, используемые потоками починки ниже.
+
+## Сервисные функции
+
+Drawer также помогает восстановиться после типичных поломок managed-ссылки.
+
+### Copy / Paste и сохранение данных
+
+- **ПКМ** по заголовку любого селектора → **Copy Serialize Reference** / **Paste Serialize Reference**. Вставка создаёт *независимый* экземпляр в целевом поле и неактивна, если скопированный тип не подходит полю.
+- **Смена типа** сохраняет поля, общие у старой и новой реализации. Поставьте `Sidearms[0] = Pistol`, задайте урон, переключите на `Shotgun` и обратно — значение `Pistol` сохранится.
+
+### Починка потерянного типа — `BrokenWeaponPreset.asset` и `LoadoutMissingType.prefab`
+
+Пять ассетов поставляются с сохранёнными идентичностями типов, которые больше не резолвятся напрямую:
+
+- `Presets/BrokenWeaponPreset.asset` — `ScriptableObject`, поле `Weapon` ссылается на потерянный `GhostWeapon`.
+- `Presets/BrokenArsenalPreset.asset` — второй `ScriptableObject`, который тоже ссылается на потерянный `GhostWeapon`, причём трижды (`Weapon` плюс два элемента `Alternates`), поэтому делит сломанный тип с `BrokenWeaponPreset.asset`.
+- `Prefabs/LoadoutMissingType.prefab` — префаб, `Sidearms → Element 0` ссылается на потерянный `GhostPistol`.
+- `Presets/MovedWeaponPreset.asset` — `ScriptableObject`, у которого `Weapon` хранит `Pistol` под старым namespace `…Samples.SerializeReferences.Legacy` — как будто класс перенесли без `[MovedFrom]`; именно он демонстрирует одно-кликовый **Smart Fix** ниже.
+- `Presets/RenamedWeaponPreset.asset` — `ScriptableObject`, у которого `Weapon` хранит старое имя класса `CrossbowLauncher`; сам класс теперь называется `Crossbow` и несёт объявленный `[MovedFrom]`, поэтому инспектор показывает здоровое оружие и устарел только файл — именно он демонстрирует поток **Migrate all** в Project References.
+
+Выделите любой из первых четырёх **в окне Project**. У потерянного поля будет подпись ``, предупреждение **Missing type** и кнопка **Fix**:
+
+1. Нажмите **Fix** — откроется привычный селектор типов с поиском. Выберите `Pistol`.
+2. Ссылка восстановится в `Pistol` с сохранёнными данными (префаб сохранит `_damage = 15`, `_magazineSize = 12`; ассет — `_damage = 25`, `_magazineSize = 8`). Выбор типа переписывает сохранённый тип в файле ассета, а не создаёт экземпляр заново — поэтому значения сохраняются.
+
+Когда у сломанной идентичности есть правдоподобный преемник, предупреждение дополняется одно-кликовой подсказкой
+**Smart Fix** — откройте `MovedWeaponPreset.asset`: его плашка заканчивается на **`→ Pistol?`** (наведите курсор,
+чтобы увидеть полную идентичность и причину ранжирования). Клик перенаправляет ссылку без открытия селектора,
+сохраняя `_damage = 21`, `_magazineSize = 6`.
+
+- Ранжирование, от высшего балла: объявленное совпадение `[MovedFrom]`, одноимённый тип в другом namespace/сборке, переименование только по регистру, похожее имя, подкреплённое формой полей осиротевших данных.
+- Никогда не применяется автоматически — клик всегда за вами.
+- Перенос, сразу снабжённый `[MovedFrom]`, вообще не ломается (Unity мигрирует ссылку при загрузке); Smart Fix ловит переносы, где про атрибут забыли. У `GhostWeapon`/`GhostPistol`-ассетов преемника нет, поэтому подсказки у них не появляется — этот контраст намеренный.
+
+> Починка читает и переписывает файл ассета напрямую — Unity не отдаёт потерянный тип через свой serialization API (а на GameObject/префабах ещё и обнуляет его в живом объекте, UUM-129100), поэтому осиротевшие тип и данные восстанавливаются прямо из YAML. Работает для ScriptableObject и префаб-ассетов, выделенных в Project (переписывается их YAML), для объектов в **Prefab Mode** (чинится на живом экземпляре) и для объектов в **сохранённой чистой сцене** (находятся через `GlobalObjectId`) — но не для **несохранённой/грязной сцены** или **переопределения на экземпляре префаба**, у которых нет зафиксированного файла ассета для маппинга ссылки.
+>
+> Если потерянная ссылка вложена в другое значение или лежит на дочернем объекте, до которого не добраться в инспекторе — используйте **`Tools → Aspid 🐍 → FastTools → Asset References`**: вкладка сканирует весь файл ассета и выводит все потерянные ссылки (любой глубины, на любом дочернем объекте), каждую со своим **Fix**.
+>
+> Вкладка **Project References** проходит по всем ассетам в `Assets/` и группирует потерянные ссылки по сохранённому типу — поэтому `BrokenWeaponPreset.asset` и `BrokenArsenalPreset.asset` сворачиваются в одну группу **GhostWeapon** (`4 entries · 2 files`). Одна кнопка **Fix all** выбирает единственную замену и перенаправляет все вхождения сразу в обоих файлах. А `RenamedWeaponPreset.asset` всплывает там как спокойная info-подсвеченная **ожидающая миграция**, а не предупреждение: его сохранённый `CrossbowLauncher` совпадает с `[MovedFrom]`, объявленным на `Crossbow`, поэтому карточка предлагает авторитетный **Migrate all (1) → Crossbow**, запекающий переименование в файл — после чего атрибут можно удалить из кода.
+
+### Карта вложенного графа — `NestedLoadout.prefab`
+
+`Prefabs/NestedLoadout.prefab` — трёхуровневая иерархия `NestedLoadout → WeaponSlot → BackupSlot`, на **каждом** объекте свой `Loadout`, так что на каждом дочернем объекте есть потерянная ссылка, до которой не добраться в инспекторе вне Prefab Mode:
+
+- **NestedLoadout** (корень) — `Primary Weapon = Railgun` (с вложенным эффектом заряда `BurnEffect`), `Sidearms = [GhostPistol (потерян), (пустой слот)]`, `On Hit Effect = FreezeEffect`.
+- **WeaponSlot** (дочерний) — `Primary Weapon = GhostBlade` (потерян), `Sidearms[0] = Pistol`.
+- **BackupSlot** (внук) — `On Hit Effect = GhostAura` (потерян), `Primary Weapon = Shotgun`.
+
+Выделите его **в окне Project** и откройте вкладку **Asset References** — **`Tools → Aspid 🐍 → FastTools → Asset References`**. Граф строит сразу все три компонента (по документу на объект). Каждая ссылка — инлайн-дропдаун: выберите тип, чтобы присвоить / перенаправить её, или ``, чтобы очистить; у потерянных `GhostPistol` / `GhostBlade` / `GhostAura` карточек — янтарное действие **Fix Missing**. Вложенность читается по пути поля (`_primaryWeapon._chargeEffect`), а не по отступу, поэтому плоский список карточек остаётся читаемым.
+
+### Расцепление общих ссылок и различение групп по цвету — `LoadoutSharedRef.prefab`
+
+В `Prefabs/LoadoutSharedRef.prefab` на одном объекте — **две независимые** пары общих ссылок (каждую пару можно также получить дублированием элемента массива), поэтому цветовая полоска/нотис по rid здесь действительно полезна:
+
+- `Sidearms[0]` и `Sidearms[1]` ссылаются на один и тот же `Pistol` — один цвет.
+- `Primary Weapon → Charge Effect` и `On Hit Effect` ссылаются на один и тот же `BurnEffect` — другой цвет, хотя одно поле лежит на три уровня вложенности глубже, а другое — на верхнем уровне.
+
+1. Откройте его — каждая пара показывает пометку **shared reference**, и редактирование одного элемента меняет его партнёра. Совпадающий цвет полоски/нотиса означает один и тот же экземпляр, независимо от места поля в иерархии, поэтому две пары читаются как два разных цвета.
+2. **ПКМ** по элементу → **Make Unique Reference**. Он получит собственную копию данных, и поля станут независимыми — его пометка исчезнет, как и у бывшего партнёра, ведь ничего больше не общее.
+
+## Путь IMGUI
+
+Drawer поддерживает и UIToolkit-, и IMGUI-рендеринг на полном паритете функций. **`Prefabs/IMGUILoadout.prefab`** несёт те же данные, что и `Loadout.prefab`, но принудительно идёт по IMGUI-пути — так можно сравнить оба рендерера бок о бок или скопировать паттерн в IMGUI-проект.
+
+Секрет — в companion-editor: `IMGUILoadoutEditor` переопределяет `OnInspectorGUI` **без** `CreateInspectorGUI` — этого достаточно, чтобы каждое вложенное поле `[TypeSelector]` пошло через `SerializeReferenceIMGUIPropertyDrawer`, а не через UIToolkit-путь `CreatePropertyGUI`. Единственная IMGUI-особенность: Unity применяет drawer к каждому *элементу* списка, поэтому кнопка **+** `[SerializeReference]`-списка рисуется через `SerializeReferenceIMGUIList.Draw(listProperty, label, elementType)` — это сохраняет добавление через селектор без алиасинга. Образец — `Scripts/IMGUILoadout.cs` + `Scripts/Editor/IMGUILoadoutEditor.cs`.
+
+Все сервисные окна (**Asset References**, **Project References**) не зависят от рендерера и работают одинаково для обоих путей.
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/README_RU.md.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/README_RU.md.meta
new file mode 100644
index 00000000..64992c02
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/README_RU.md.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: 2ed2b6607ff347f6ae540ace02dbc14c
+TextScriptImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scenes.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scenes.meta
new file mode 100644
index 00000000..132f1395
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scenes.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 075cca7b11084f27bbf9242a1f54c774
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scenes/TypeSelectorTutorial.unity b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scenes/TypeSelectorTutorial.unity
new file mode 100644
index 00000000..0ec8ea98
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scenes/TypeSelectorTutorial.unity
@@ -0,0 +1,442 @@
+%YAML 1.1
+%TAG !u! tag:unity3d.com,2011:
+--- !u!29 &1
+OcclusionCullingSettings:
+ m_ObjectHideFlags: 0
+ serializedVersion: 2
+ m_OcclusionBakeSettings:
+ smallestOccluder: 5
+ smallestHole: 0.25
+ backfaceThreshold: 100
+ m_SceneGUID: 00000000000000000000000000000000
+ m_OcclusionCullingData: {fileID: 0}
+--- !u!104 &2
+RenderSettings:
+ m_ObjectHideFlags: 0
+ serializedVersion: 10
+ m_Fog: 0
+ m_FogColor: {r: 0.5, g: 0.5, b: 0.5, a: 1}
+ m_FogMode: 3
+ m_FogDensity: 0.01
+ m_LinearFogStart: 0
+ m_LinearFogEnd: 300
+ m_AmbientSkyColor: {r: 0.212, g: 0.227, b: 0.259, a: 1}
+ m_AmbientEquatorColor: {r: 0.114, g: 0.125, b: 0.133, a: 1}
+ m_AmbientGroundColor: {r: 0.047, g: 0.043, b: 0.035, a: 1}
+ m_AmbientIntensity: 1
+ m_AmbientMode: 0
+ m_SubtractiveShadowColor: {r: 0.42, g: 0.478, b: 0.627, a: 1}
+ m_SkyboxMaterial: {fileID: 10304, guid: 0000000000000000f000000000000000, type: 0}
+ m_HaloStrength: 0.5
+ m_FlareStrength: 1
+ m_FlareFadeSpeed: 3
+ m_HaloTexture: {fileID: 0}
+ m_SpotCookie: {fileID: 10001, guid: 0000000000000000e000000000000000, type: 0}
+ m_DefaultReflectionMode: 0
+ m_DefaultReflectionResolution: 128
+ m_ReflectionBounces: 1
+ m_ReflectionIntensity: 1
+ m_CustomReflection: {fileID: 0}
+ m_Sun: {fileID: 0}
+ m_UseRadianceAmbientProbe: 0
+--- !u!157 &3
+LightmapSettings:
+ m_ObjectHideFlags: 0
+ serializedVersion: 13
+ m_BakeOnSceneLoad: 0
+ m_GISettings:
+ serializedVersion: 2
+ m_BounceScale: 1
+ m_IndirectOutputScale: 1
+ m_AlbedoBoost: 1
+ m_EnvironmentLightingMode: 0
+ m_EnableBakedLightmaps: 1
+ m_EnableRealtimeLightmaps: 0
+ m_LightmapEditorSettings:
+ serializedVersion: 12
+ m_Resolution: 2
+ m_BakeResolution: 40
+ m_AtlasSize: 1024
+ m_AO: 0
+ m_AOMaxDistance: 1
+ m_CompAOExponent: 1
+ m_CompAOExponentDirect: 0
+ m_ExtractAmbientOcclusion: 0
+ m_Padding: 2
+ m_LightmapParameters: {fileID: 0}
+ m_LightmapsBakeMode: 1
+ m_TextureCompression: 1
+ m_ReflectionCompression: 2
+ m_MixedBakeMode: 2
+ m_BakeBackend: 1
+ m_PVRSampling: 1
+ m_PVRDirectSampleCount: 32
+ m_PVRSampleCount: 512
+ m_PVRBounces: 2
+ m_PVREnvironmentSampleCount: 256
+ m_PVREnvironmentReferencePointCount: 2048
+ m_PVRFilteringMode: 1
+ m_PVRDenoiserTypeDirect: 1
+ m_PVRDenoiserTypeIndirect: 1
+ m_PVRDenoiserTypeAO: 1
+ m_PVRFilterTypeDirect: 0
+ m_PVRFilterTypeIndirect: 0
+ m_PVRFilterTypeAO: 0
+ m_PVREnvironmentMIS: 1
+ m_PVRCulling: 1
+ m_PVRFilteringGaussRadiusDirect: 1
+ m_PVRFilteringGaussRadiusIndirect: 1
+ m_PVRFilteringGaussRadiusAO: 1
+ m_PVRFilteringAtrousPositionSigmaDirect: 0.5
+ m_PVRFilteringAtrousPositionSigmaIndirect: 2
+ m_PVRFilteringAtrousPositionSigmaAO: 1
+ m_ExportTrainingData: 0
+ m_TrainingDataDestination: TrainingData
+ m_LightProbeSampleCountMultiplier: 4
+ m_LightingDataAsset: {fileID: 0}
+ m_LightingSettings: {fileID: 0}
+--- !u!196 &4
+NavMeshSettings:
+ serializedVersion: 2
+ m_ObjectHideFlags: 0
+ m_BuildSettings:
+ serializedVersion: 3
+ agentTypeID: 0
+ agentRadius: 0.5
+ agentHeight: 2
+ agentSlope: 45
+ agentClimb: 0.4
+ ledgeDropHeight: 0
+ maxJumpAcrossDistance: 0
+ minRegionArea: 2
+ manualCellSize: 0
+ cellSize: 0.16666667
+ manualTileSize: 0
+ tileSize: 256
+ buildHeightMesh: 0
+ maxJobWorkers: 0
+ preserveTilesOutsideBounds: 0
+ debug:
+ m_Flags: 0
+ m_NavMeshData: {fileID: 0}
+--- !u!1 &100001
+GameObject:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ serializedVersion: 6
+ m_Component:
+ - component: {fileID: 100003}
+ - component: {fileID: 100002}
+ m_Layer: 0
+ m_Name: Directional Light
+ m_TagString: Untagged
+ m_Icon: {fileID: 0}
+ m_NavMeshLayer: 0
+ m_StaticEditorFlags: 0
+ m_IsActive: 1
+--- !u!108 &100002
+Light:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 100001}
+ m_Enabled: 1
+ serializedVersion: 13
+ m_Type: 1
+ m_Color: {r: 1, g: 0.95686275, b: 0.8392157, a: 1}
+ m_Intensity: 1
+ m_Range: 10
+ m_SpotAngle: 30
+ m_InnerSpotAngle: 21.80208
+ m_CookieSize2D: {x: 10, y: 10}
+ m_Shadows:
+ m_Type: 2
+ m_Resolution: -1
+ m_CustomResolution: -1
+ m_Strength: 1
+ m_Bias: 0.05
+ m_NormalBias: 0.4
+ m_NearPlane: 0.2
+ m_CullingMatrixOverride:
+ e00: 1
+ e01: 0
+ e02: 0
+ e03: 0
+ e10: 0
+ e11: 1
+ e12: 0
+ e13: 0
+ e20: 0
+ e21: 0
+ e22: 1
+ e23: 0
+ e30: 0
+ e31: 0
+ e32: 0
+ e33: 1
+ m_UseCullingMatrixOverride: 0
+ m_Cookie: {fileID: 0}
+ m_DrawHalo: 0
+ m_Flare: {fileID: 0}
+ m_RenderMode: 0
+ m_CullingMask:
+ serializedVersion: 2
+ m_Bits: 4294967295
+ m_RenderingLayerMask: 1
+ m_Lightmapping: 4
+ m_LightShadowCasterMode: 0
+ m_AreaSize: {x: 1, y: 1}
+ m_BounceIntensity: 1
+ m_ColorTemperature: 6570
+ m_UseColorTemperature: 0
+ m_BoundingSphereOverride: {x: 0, y: 0, z: 0, w: 0}
+ m_UseBoundingSphereOverride: 0
+ m_UseViewFrustumForShadowCasterCull: 1
+ m_ForceVisible: 0
+ m_ShapeRadius: 0
+ m_ShadowAngle: 0
+ m_LightUnit: 1
+ m_LuxAtDistance: 1
+ m_EnableSpotReflector: 1
+--- !u!4 &100003
+Transform:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 100001}
+ serializedVersion: 2
+ m_LocalRotation: {x: 0.40821788, y: -0.23456968, z: 0.10938163, w: 0.8754261}
+ m_LocalPosition: {x: 0, y: 3, z: 0}
+ m_LocalScale: {x: 1, y: 1, z: 1}
+ m_ConstrainProportionsScale: 0
+ m_Children: []
+ m_Father: {fileID: 0}
+ m_LocalEulerAnglesHint: {x: 50, y: -30, z: 0}
+--- !u!1 &200001
+GameObject:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ serializedVersion: 6
+ m_Component:
+ - component: {fileID: 200004}
+ - component: {fileID: 200003}
+ - component: {fileID: 200002}
+ m_Layer: 0
+ m_Name: Main Camera
+ m_TagString: MainCamera
+ m_Icon: {fileID: 0}
+ m_NavMeshLayer: 0
+ m_StaticEditorFlags: 0
+ m_IsActive: 1
+--- !u!81 &200002
+AudioListener:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 200001}
+ m_Enabled: 1
+--- !u!20 &200003
+Camera:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 200001}
+ m_Enabled: 1
+ serializedVersion: 2
+ m_ClearFlags: 1
+ m_BackGroundColor: {r: 0.19215687, g: 0.3019608, b: 0.4745098, a: 0}
+ m_projectionMatrixMode: 1
+ m_GateFitMode: 2
+ m_FOVAxisMode: 0
+ m_Iso: 200
+ m_ShutterSpeed: 0.005
+ m_Aperture: 16
+ m_FocusDistance: 10
+ m_FocalLength: 50
+ m_BladeCount: 5
+ m_Curvature: {x: 2, y: 11}
+ m_BarrelClipping: 0.25
+ m_Anamorphism: 0
+ m_SensorSize: {x: 36, y: 24}
+ m_LensShift: {x: 0, y: 0}
+ m_NormalizedViewPortRect:
+ serializedVersion: 2
+ x: 0
+ y: 0
+ width: 1
+ height: 1
+ near clip plane: 0.3
+ far clip plane: 1000
+ field of view: 60
+ orthographic: 0
+ orthographic size: 5
+ m_Depth: -1
+ m_CullingMask:
+ serializedVersion: 2
+ m_Bits: 4294967295
+ m_RenderingPath: -1
+ m_TargetTexture: {fileID: 0}
+ m_TargetDisplay: 0
+ m_TargetEye: 3
+ m_HDR: 1
+ m_AllowMSAA: 1
+ m_AllowDynamicResolution: 0
+ m_ForceIntoRT: 0
+ m_OcclusionCulling: 1
+ m_StereoConvergence: 10
+ m_StereoSeparation: 0.022
+--- !u!4 &200004
+Transform:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 200001}
+ serializedVersion: 2
+ m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
+ m_LocalPosition: {x: 0, y: 1, z: -10}
+ m_LocalScale: {x: 1, y: 1, z: 1}
+ m_ConstrainProportionsScale: 0
+ m_Children: []
+ m_Father: {fileID: 0}
+ m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
+--- !u!1 &300001
+GameObject:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ serializedVersion: 6
+ m_Component:
+ - component: {fileID: 300002}
+ - component: {fileID: 300003}
+ m_Layer: 0
+ m_Name: TypeSelector Tutorial
+ m_TagString: Untagged
+ m_Icon: {fileID: 0}
+ m_NavMeshLayer: 0
+ m_StaticEditorFlags: 0
+ m_IsActive: 1
+--- !u!4 &300002
+Transform:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 300001}
+ serializedVersion: 2
+ m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
+ m_LocalPosition: {x: 0, y: 0, z: 0}
+ m_LocalScale: {x: 1, y: 1, z: 1}
+ m_ConstrainProportionsScale: 0
+ m_Children: []
+ m_Father: {fileID: 0}
+ m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
+--- !u!114 &300003
+MonoBehaviour:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 300001}
+ m_Enabled: 1
+ m_EditorHideFlags: 0
+ m_Script: {fileID: 11500000, guid: 5c5ff252d28c48738739916254d8480f, type: 3}
+ m_Name:
+ m_EditorClassIdentifier:
+ _step1Single:
+ rid: 1001
+ _step2List:
+ - rid: 1002
+ - rid: 1003
+ _step3Abstract:
+ rid: 1004
+ _step4Ranged:
+ rid: 1005
+ _step4Melee:
+ rid: 1006
+ _step4MeleeOrRanged:
+ rid: 1007
+ _step5Nested:
+ rid: 1008
+ _step6Open:
+ rid: -2
+ _step6Closed:
+ rid: -2
+ _step7Slot:
+ label: Primary
+ priority: 0
+ _weapon:
+ rid: 1010
+ _step7Slots: []
+ _step8Required:
+ rid: -2
+ references:
+ version: 2
+ RefIds:
+ - rid: 1001
+ type: {class: Pistol, ns: Aspid.FastTools.Samples.SerializeReferences, asm: Aspid.FastTools.Samples.SerializeReferences}
+ data:
+ _damage: 15
+ _magazineSize: 12
+ - rid: 1002
+ type: {class: Sword, ns: Aspid.FastTools.Samples.SerializeReferences, asm: Aspid.FastTools.Samples.SerializeReferences}
+ data:
+ _damage: 30
+ _reach: 1.8
+ - rid: 1003
+ type: {class: Shotgun, ns: Aspid.FastTools.Samples.SerializeReferences, asm: Aspid.FastTools.Samples.SerializeReferences}
+ data:
+ _pellets: 8
+ _spreadAngle: 25
+ - rid: 1004
+ type: {class: BurnEffect, ns: Aspid.FastTools.Samples.SerializeReferences, asm: Aspid.FastTools.Samples.SerializeReferences}
+ data:
+ _duration: 3
+ _damagePerSecond: 5
+ - rid: 1005
+ type: {class: Shotgun, ns: Aspid.FastTools.Samples.SerializeReferences, asm: Aspid.FastTools.Samples.SerializeReferences}
+ data:
+ _pellets: 6
+ _spreadAngle: 30
+ - rid: 1006
+ type: {class: Sword, ns: Aspid.FastTools.Samples.SerializeReferences, asm: Aspid.FastTools.Samples.SerializeReferences}
+ data:
+ _damage: 25
+ _reach: 1.5
+ - rid: 1007
+ type: {class: Pistol, ns: Aspid.FastTools.Samples.SerializeReferences, asm: Aspid.FastTools.Samples.SerializeReferences}
+ data:
+ _damage: 10
+ _magazineSize: 8
+ - rid: 1008
+ type: {class: Railgun, ns: Aspid.FastTools.Samples.SerializeReferences, asm: Aspid.FastTools.Samples.SerializeReferences}
+ data:
+ _chargeTime: 2
+ _chargeEffect:
+ rid: 1009
+ - rid: 1009
+ type: {class: FreezeEffect, ns: Aspid.FastTools.Samples.SerializeReferences, asm: Aspid.FastTools.Samples.SerializeReferences}
+ data:
+ _duration: 2.5
+ _slowPercent: 40
+ - rid: 1010
+ type: {class: Pistol, ns: Aspid.FastTools.Samples.SerializeReferences, asm: Aspid.FastTools.Samples.SerializeReferences}
+ data:
+ _damage: 12
+ _magazineSize: 10
+--- !u!1660057539 &9223372036854775807
+SceneRoots:
+ m_ObjectHideFlags: 0
+ m_Roots:
+ - {fileID: 200004}
+ - {fileID: 100003}
+ - {fileID: 300002}
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scenes/TypeSelectorTutorial.unity.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scenes/TypeSelectorTutorial.unity.meta
new file mode 100644
index 00000000..f58969d1
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scenes/TypeSelectorTutorial.unity.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: 2ed68d5dff9146498c5c12697d811d0a
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts.meta
new file mode 100644
index 00000000..f8990489
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 0c6859af7d0f4560a27fc9398c16cac2
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Aspid.FastTools.Samples.SerializeReferences.asmdef b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Aspid.FastTools.Samples.SerializeReferences.asmdef
new file mode 100644
index 00000000..6aeeefc0
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Aspid.FastTools.Samples.SerializeReferences.asmdef
@@ -0,0 +1,16 @@
+{
+ "name": "Aspid.FastTools.Samples.SerializeReferences",
+ "rootNamespace": "",
+ "references": [
+ "Aspid.FastTools.Unity"
+ ],
+ "includePlatforms": [],
+ "excludePlatforms": [],
+ "allowUnsafeCode": false,
+ "overrideReferences": false,
+ "precompiledReferences": [],
+ "autoReferenced": true,
+ "defineConstraints": [],
+ "versionDefines": [],
+ "noEngineReferences": false
+}
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Aspid.FastTools.Samples.SerializeReferences.asmdef.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Aspid.FastTools.Samples.SerializeReferences.asmdef.meta
new file mode 100644
index 00000000..2202ab8f
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Aspid.FastTools.Samples.SerializeReferences.asmdef.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: 9fad5519270642308b608484e669eeaf
+AssemblyDefinitionImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Editor.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Editor.meta
new file mode 100644
index 00000000..7ac9b27c
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Editor.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 5ae54c703d8f4ad4a074367f0614d47c
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Editor/Aspid.FastTools.Samples.SerializeReferences.Editor.asmdef b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Editor/Aspid.FastTools.Samples.SerializeReferences.Editor.asmdef
new file mode 100644
index 00000000..31ebbc9b
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Editor/Aspid.FastTools.Samples.SerializeReferences.Editor.asmdef
@@ -0,0 +1,19 @@
+{
+ "name": "Aspid.FastTools.Samples.SerializeReferences.Editor",
+ "rootNamespace": "",
+ "references": [
+ "Aspid.FastTools.Samples.SerializeReferences",
+ "Aspid.FastTools.Unity.Editor"
+ ],
+ "includePlatforms": [
+ "Editor"
+ ],
+ "excludePlatforms": [],
+ "allowUnsafeCode": false,
+ "overrideReferences": false,
+ "precompiledReferences": [],
+ "autoReferenced": true,
+ "defineConstraints": [],
+ "versionDefines": [],
+ "noEngineReferences": false
+}
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Editor/Aspid.FastTools.Samples.SerializeReferences.Editor.asmdef.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Editor/Aspid.FastTools.Samples.SerializeReferences.Editor.asmdef.meta
new file mode 100644
index 00000000..721adc5e
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Editor/Aspid.FastTools.Samples.SerializeReferences.Editor.asmdef.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: 97608434520c4d078f28ab868e7f3e06
+AssemblyDefinitionImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Editor/IMGUILoadoutEditor.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Editor/IMGUILoadoutEditor.cs
new file mode 100644
index 00000000..b3627e37
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Editor/IMGUILoadoutEditor.cs
@@ -0,0 +1,53 @@
+using UnityEditor;
+using UnityEngine;
+using Aspid.FastTools.SerializeReferences.Editors;
+
+// ReSharper disable once CheckNamespace
+namespace Aspid.FastTools.Samples.SerializeReferences.Editors
+{
+ // Forces IMGUI rendering for the IMGUILoadout inspector.
+ //
+ // Unity picks IMGUI vs UIToolkit at the Editor level: when CreateInspectorGUI is NOT
+ // overridden but OnInspectorGUI is, the whole inspector — including every nested
+ // PropertyDrawer — falls back to IMGUI. That routes [TypeSelector] fields
+ // through SerializeReferenceIMGUIPropertyDrawer.OnGUI instead of CreatePropertyGUI.
+ //
+ // The single-reference fields (_primaryWeapon, _onHitEffect, _modifier, _floatModifier)
+ // render straight through PropertyField. The two [SerializeReference] LISTS are drawn via
+ // SerializeReferenceIMGUIList instead: Unity applies a [TypeSelector] drawer per element in
+ // IMGUI, so it can never reach the list's "+" — the helper restores the picker-backed,
+ // de-aliased add the UIToolkit ListView gets automatically.
+ [CustomEditor(typeof(IMGUILoadout))]
+ internal sealed class IMGUILoadoutEditor : Editor
+ {
+ public override void OnInspectorGUI()
+ {
+ serializedObject.Update();
+
+ var iterator = serializedObject.GetIterator();
+ iterator.NextVisible(enterChildren: true); // skip m_Script
+
+ while (iterator.NextVisible(enterChildren: false))
+ {
+ switch (iterator.name)
+ {
+ case "_sidearms":
+ SerializeReferenceIMGUIList.Draw(iterator.Copy(),
+ new GUIContent(iterator.displayName), typeof(IWeapon));
+ break;
+
+ case "_modifiers":
+ SerializeReferenceIMGUIList.Draw(iterator.Copy(),
+ new GUIContent(iterator.displayName), typeof(IModifier));
+ break;
+
+ default:
+ EditorGUILayout.PropertyField(iterator, includeChildren: true);
+ break;
+ }
+ }
+
+ serializedObject.ApplyModifiedProperties();
+ }
+ }
+}
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/VisualElements/Internal/Styles/Theme/AspidThemeSettingsProvider.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Editor/IMGUILoadoutEditor.cs.meta
similarity index 86%
rename from Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/VisualElements/Internal/Styles/Theme/AspidThemeSettingsProvider.cs.meta
rename to Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Editor/IMGUILoadoutEditor.cs.meta
index e8f28c1e..0d589592 100644
--- a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/VisualElements/Internal/Styles/Theme/AspidThemeSettingsProvider.cs.meta
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Editor/IMGUILoadoutEditor.cs.meta
@@ -1,5 +1,5 @@
fileFormatVersion: 2
-guid: 13105acb34d494c0dbc255827c74607b
+guid: 49097153234d4b66b7eacaff0ba4d88d
MonoImporter:
externalObjects: {}
serializedVersion: 2
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Effects.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Effects.meta
new file mode 100644
index 00000000..18c6a76c
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Effects.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 0f9e11c2bbf345a0a2895fc744afba61
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Effects/BurnEffect.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Effects/BurnEffect.cs
new file mode 100644
index 00000000..24dc603f
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Effects/BurnEffect.cs
@@ -0,0 +1,14 @@
+using System;
+using UnityEngine;
+
+// ReSharper disable once CheckNamespace
+namespace Aspid.FastTools.Samples.SerializeReferences
+{
+ [Serializable]
+ public sealed class BurnEffect : StatusEffect
+ {
+ [SerializeField] [Min(0f)] private float _damagePerSecond = 5f;
+
+ public override string Describe() => $"Burn — {_damagePerSecond} dps for {Duration}s";
+ }
+}
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Welcome/WelcomeWindow.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Effects/BurnEffect.cs.meta
similarity index 86%
rename from Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Welcome/WelcomeWindow.cs.meta
rename to Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Effects/BurnEffect.cs.meta
index 97f0c06a..1c4aba5a 100644
--- a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/Welcome/WelcomeWindow.cs.meta
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Effects/BurnEffect.cs.meta
@@ -1,5 +1,5 @@
fileFormatVersion: 2
-guid: 623f45f16b1a4e1ba50feb1123e63176
+guid: 7dd6bde21faf4019b74c28d087f07570
MonoImporter:
externalObjects: {}
serializedVersion: 2
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Effects/FreezeEffect.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Effects/FreezeEffect.cs
new file mode 100644
index 00000000..c5e91e6f
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Effects/FreezeEffect.cs
@@ -0,0 +1,14 @@
+using System;
+using UnityEngine;
+
+// ReSharper disable once CheckNamespace
+namespace Aspid.FastTools.Samples.SerializeReferences
+{
+ [Serializable]
+ public sealed class FreezeEffect : StatusEffect
+ {
+ [SerializeField] [Range(0f, 100f)] private float _slowPercent = 40f;
+
+ public override string Describe() => $"Freeze — {_slowPercent}% slow for {Duration}s";
+ }
+}
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/AssemblyInfo.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Effects/FreezeEffect.cs.meta
similarity index 86%
rename from Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/AssemblyInfo.cs.meta
rename to Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Effects/FreezeEffect.cs.meta
index e6fb6c30..f5bdb149 100644
--- a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/AssemblyInfo.cs.meta
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Effects/FreezeEffect.cs.meta
@@ -1,5 +1,5 @@
fileFormatVersion: 2
-guid: 5c5edebc830a4b6e87b10075287af86d
+guid: 95af46da00c94957a8642c716ab6aabb
MonoImporter:
externalObjects: {}
serializedVersion: 2
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Effects/StatusEffect.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Effects/StatusEffect.cs
new file mode 100644
index 00000000..7f033c7a
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Effects/StatusEffect.cs
@@ -0,0 +1,21 @@
+using System;
+using UnityEngine;
+
+// ReSharper disable once CheckNamespace
+namespace Aspid.FastTools.Samples.SerializeReferences
+{
+ // Abstract base for a second polymorphic hierarchy.
+ //
+ // When a field is declared as StatusEffect, [TypeSelector] offers only the
+ // concrete subclasses (BurnEffect, FreezeEffect) — the abstract base itself is never listed,
+ // because it cannot be instantiated.
+ [Serializable]
+ public abstract class StatusEffect
+ {
+ [SerializeField] [Min(0f)] private float _duration = 3f;
+
+ protected float Duration => _duration;
+
+ public abstract string Describe();
+ }
+}
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Effects/StatusEffect.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Effects/StatusEffect.cs.meta
new file mode 100644
index 00000000..2574018b
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Effects/StatusEffect.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: aea8b9d6094b41bf80cab9036bf88bbe
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/IMGUILoadout.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/IMGUILoadout.cs
new file mode 100644
index 00000000..40344a43
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/IMGUILoadout.cs
@@ -0,0 +1,34 @@
+using System.Collections.Generic;
+using UnityEngine;
+using Aspid.FastTools.Types;
+
+// ReSharper disable once CheckNamespace
+namespace Aspid.FastTools.Samples.SerializeReferences
+{
+ // Same fields as Loadout, but the companion editor (IMGUILoadoutEditor) overrides
+ // OnInspectorGUI without CreateInspectorGUI, forcing the entire inspector — and every
+ // nested [TypeSelector] field — through the IMGUI path
+ // (SerializeReferenceIMGUIPropertyDrawer) instead of the UIToolkit one.
+ //
+ // Use this to verify both rendering paths stay visually and behaviourally aligned.
+ public sealed class IMGUILoadout : MonoBehaviour
+ {
+ [SerializeReference] [TypeSelector]
+ private IWeapon _primaryWeapon;
+
+ [SerializeReference] [TypeSelector]
+ private List _sidearms = new();
+
+ [SerializeReference] [TypeSelector]
+ private StatusEffect _onHitEffect;
+
+ [SerializeReference] [TypeSelector]
+ private IModifier _modifier;
+
+ [SerializeReference] [TypeSelector]
+ private Modifier _floatModifier;
+
+ [SerializeReference] [TypeSelector]
+ private List _modifiers = new();
+ }
+}
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/IMGUILoadout.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/IMGUILoadout.cs.meta
new file mode 100644
index 00000000..ba18fad5
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/IMGUILoadout.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: e6cd8f6abf02422a92c5f183b53afa29
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Loadout.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Loadout.cs
new file mode 100644
index 00000000..b856506e
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Loadout.cs
@@ -0,0 +1,54 @@
+using System.Collections.Generic;
+using UnityEngine;
+using Aspid.FastTools.Types;
+
+// ReSharper disable once CheckNamespace
+namespace Aspid.FastTools.Samples.SerializeReferences
+{
+ // The demo component behind the Prefabs/ scenarios: every flavour of [SerializeReference] +
+ // [TypeSelector] field (single, list, abstract base, generics) on one MonoBehaviour, rendered
+ // through the default UIToolkit Inspector. The guided walkthrough of each flavour lives in
+ // TUTORIAL.md and Scripts/Tutorial/TypeSelectorTutorial.cs.
+ public sealed class Loadout : MonoBehaviour
+ {
+ // Interface-typed field: lists every IWeapon implementation (Sword, Pistol, Shotgun, Railgun, Crossbow).
+ [SerializeReference] [TypeSelector]
+ private IWeapon _primaryWeapon;
+
+ // Each list element is its own independent picker.
+ [SerializeReference] [TypeSelector]
+ private List _sidearms = new();
+
+ // Abstract-base field: the picker offers BurnEffect / FreezeEffect, never StatusEffect.
+ [SerializeReference] [TypeSelector]
+ private StatusEffect _onHitEffect;
+
+ // Open-generic entry point: offers the closed subclasses AND Modifier itself (see Modifiers/).
+ [SerializeReference] [TypeSelector]
+ private IModifier _modifier;
+
+ // Closed-generic field type: candidates are constrained by assignability to Modifier.
+ [SerializeReference] [TypeSelector]
+ private Modifier _floatModifier;
+
+ // Polymorphic list mixing different closed-generic subclasses.
+ [SerializeReference] [TypeSelector]
+ private List _modifiers = new();
+
+ [ContextMenu("Log Loadout")]
+ private void LogLoadout()
+ {
+ Debug.Log($"Primary: {_primaryWeapon?.Describe() ?? "none"}");
+
+ for (var i = 0; i < _sidearms.Count; i++)
+ Debug.Log($"Sidearm {i}: {_sidearms[i]?.Describe() ?? "none"}");
+
+ Debug.Log($"On-hit effect: {_onHitEffect?.Describe() ?? "none"}");
+ Debug.Log($"Modifier: {_modifier?.Describe() ?? "none"}");
+ Debug.Log($"Float modifier: {_floatModifier?.Describe() ?? "none"}");
+
+ for (var i = 0; i < _modifiers.Count; i++)
+ Debug.Log($"Modifier {i}: {_modifiers[i]?.Describe() ?? "none"}");
+ }
+ }
+}
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Loadout.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Loadout.cs.meta
new file mode 100644
index 00000000..00265ea2
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Loadout.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 884d53b5154744d3af6948b1eef02505
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Modifiers.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Modifiers.meta
new file mode 100644
index 00000000..18cfcbda
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Modifiers.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 9100055084444cdf8e2f37ff3b613c02
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Modifiers/AmmoModifier.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Modifiers/AmmoModifier.cs
new file mode 100644
index 00000000..1f33dc51
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Modifiers/AmmoModifier.cs
@@ -0,0 +1,14 @@
+using System;
+
+// ReSharper disable once CheckNamespace
+namespace Aspid.FastTools.Samples.SerializeReferences
+{
+ // Concrete subclass closing Modifier over int.
+ // Offered for an IModifier field, but NOT for a Modifier field —
+ // it is Modifier, which is not assignable to Modifier.
+ [Serializable]
+ public sealed class AmmoModifier : Modifier
+ {
+ public override string Describe() => $"+{Value} ammo";
+ }
+}
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Modifiers/AmmoModifier.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Modifiers/AmmoModifier.cs.meta
new file mode 100644
index 00000000..63c3ed5e
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Modifiers/AmmoModifier.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 350225ab4907402aa855efd9c953246f
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Modifiers/DamageModifier.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Modifiers/DamageModifier.cs
new file mode 100644
index 00000000..9d31af86
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Modifiers/DamageModifier.cs
@@ -0,0 +1,13 @@
+using System;
+
+// ReSharper disable once CheckNamespace
+namespace Aspid.FastTools.Samples.SerializeReferences
+{
+ // Concrete subclass closing Modifier over float.
+ // Offered wherever the field type is IModifier or Modifier.
+ [Serializable]
+ public sealed class DamageModifier : Modifier
+ {
+ public override string Describe() => $"Damage ×{Value}";
+ }
+}
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Modifiers/DamageModifier.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Modifiers/DamageModifier.cs.meta
new file mode 100644
index 00000000..68f58708
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Modifiers/DamageModifier.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: b5d6a98f98bf40aa8f7e296f383e265b
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Modifiers/IModifier.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Modifiers/IModifier.cs
new file mode 100644
index 00000000..ef9c0909
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Modifiers/IModifier.cs
@@ -0,0 +1,15 @@
+// ReSharper disable once CheckNamespace
+namespace Aspid.FastTools.Samples.SerializeReferences
+{
+ // Non-generic entry point for the generic [SerializeReference] sample.
+ //
+ // A field typed as IModifier lets [TypeSelector] offer both:
+ // - every concrete subclass that closes Modifier over a real type argument
+ // (DamageModifier : Modifier, AmmoModifier : Modifier, NameModifier : Modifier), and
+ // - the open generic Modifier itself — picking it opens a second window to choose the argument T,
+ // then instantiates Modifier / Modifier / etc.
+ public interface IModifier
+ {
+ string Describe();
+ }
+}
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Modifiers/IModifier.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Modifiers/IModifier.cs.meta
new file mode 100644
index 00000000..04f0fbe3
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Modifiers/IModifier.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 3864ea1917414755add52a58178a20e5
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Modifiers/Modifier.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Modifiers/Modifier.cs
new file mode 100644
index 00000000..c10a5e4d
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Modifiers/Modifier.cs
@@ -0,0 +1,26 @@
+using System;
+using UnityEngine;
+
+// ReSharper disable once CheckNamespace
+namespace Aspid.FastTools.Samples.SerializeReferences
+{
+ // Non-abstract generic base for the [TypeSelector] generic test.
+ //
+ // Because it is a concrete open generic, [TypeSelector] lists it as "Modifier".
+ // - On a non-generic IModifier field, picking it opens a second window to choose the argument T
+ // (e.g. string in one case, float in another), then instantiates Modifier / Modifier.
+ // - On a closed-generic field such as Modifier, the argument is inferred from the field, so it
+ // is created directly as Modifier without the extra window.
+ //
+ // The typed _value field verifies that Unity's generic serialization handles a bare type-parameter
+ // field (for float/int/string) and renders it inline under the dropdown.
+ [Serializable]
+ public class Modifier : IModifier
+ {
+ [SerializeField] private T _value;
+
+ protected T Value => _value;
+
+ public virtual string Describe() => $"Modifier<{typeof(T).Name}> = {_value}";
+ }
+}
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Modifiers/Modifier.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Modifiers/Modifier.cs.meta
new file mode 100644
index 00000000..d8e7bd3c
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Modifiers/Modifier.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: dcde013178d3400892d1f76d1b8a1cb2
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Modifiers/NameModifier.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Modifiers/NameModifier.cs
new file mode 100644
index 00000000..2c618c88
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Modifiers/NameModifier.cs
@@ -0,0 +1,13 @@
+using System;
+
+// ReSharper disable once CheckNamespace
+namespace Aspid.FastTools.Samples.SerializeReferences
+{
+ // Concrete subclass closing Modifier over string.
+ // Offered for an IModifier field; excluded from a Modifier field.
+ [Serializable]
+ public sealed class NameModifier : Modifier
+ {
+ public override string Describe() => $"Renamed to \"{Value}\"";
+ }
+}
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Modifiers/NameModifier.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Modifiers/NameModifier.cs.meta
new file mode 100644
index 00000000..32be9d14
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Modifiers/NameModifier.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 1557d1630f2245b289641510ed2e6021
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/SlottedLoadout.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/SlottedLoadout.cs
new file mode 100644
index 00000000..1fcf1835
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/SlottedLoadout.cs
@@ -0,0 +1,18 @@
+using System.Collections.Generic;
+using UnityEngine;
+
+namespace Aspid.FastTools.Samples.SerializeReferences
+{
+ // Demonstrates [TypeSelector] on references that live INSIDE plain [Serializable]
+ // containers (the shared WeaponSlot.cs) — a single container field and a List of them —
+ // instead of directly on the component. See TUTORIAL.md, Lesson 7: everything (picker,
+ // inline child fields, missing-type Fix) works at this depth exactly as for a top-level field.
+ public sealed class SlottedLoadout : MonoBehaviour
+ {
+ // A reference nested inside a single container field (path "_primarySlot._weapon").
+ [SerializeField] private WeaponSlot _primarySlot = new();
+
+ // References nested inside each element of a List of containers (path "_slots.Array.data[i]._weapon").
+ [SerializeField] private List _slots = new();
+ }
+}
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/SlottedLoadout.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/SlottedLoadout.cs.meta
new file mode 100644
index 00000000..8862fc1a
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/SlottedLoadout.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 251896effa22341e7981b29000d77094
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Tutorial.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Tutorial.meta
new file mode 100644
index 00000000..4ebb835a
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Tutorial.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 09e31347a89c49b1b3fd66d884de46b6
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Tutorial/TypeSelectorTutorial.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Tutorial/TypeSelectorTutorial.cs
new file mode 100644
index 00000000..2f3bdac0
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Tutorial/TypeSelectorTutorial.cs
@@ -0,0 +1,130 @@
+using System.Collections.Generic;
+using UnityEngine;
+using Aspid.FastTools.Types;
+
+// ReSharper disable once CheckNamespace
+namespace Aspid.FastTools.Samples.SerializeReferences
+{
+ // ─────────────────────────────────────────────────────────────────────────────────────────────
+ // [TypeSelector] for [SerializeReference] — a guided, step-by-step tour.
+ //
+ // Read this component top to bottom: each [Header("STEP N …")] is one self-contained lesson,
+ // ordered from the simplest picker to generics, narrowing and required validation. Open the
+ // bundled Scenes/TypeSelectorTutorial.unity (a few steps are pre-filled) and follow along, or
+ // drop this component on an empty GameObject and start from a clean slate.
+ //
+ // The companion TUTORIAL.md / TUTORIAL_RU.md walk through the same steps in prose and add the
+ // power-user gestures (copy/paste, templates, drag-drop, repair) and window-level tools that
+ // do not show up as plain fields.
+ //
+ // The rule for every step: put [SerializeReference] AND [TypeSelector] on the same field. The
+ // first makes Unity store a polymorphic instance; the second renders the searchable type picker.
+ // ─────────────────────────────────────────────────────────────────────────────────────────────
+ public sealed class TypeSelectorTutorial : MonoBehaviour
+ {
+ // STEP 1 — Your first picker.
+ // A single interface-typed field. Click the dropdown to pick any IWeapon implementation
+ // (Sword, Pistol, Shotgun, Railgun, Crossbow). Type to search; ↑↓ navigate; Space stars a favourite;
+ // the first row, , clears the field. The chosen instance's own fields appear inline.
+ [Header("STEP 1 — Single polymorphic reference")]
+ [SerializeReference] [TypeSelector]
+ [Tooltip("Click the dropdown → pick an IWeapon. Type to search, Space favourites, clears.")]
+ private IWeapon _step1Single;
+
+ // STEP 2 — Lists and arrays.
+ // Every element gets its own independent picker. The list's "+" is replaced: instead of
+ // duplicating the last element it opens the picker so you choose the new element's type.
+ [Header("STEP 2 — List / array of references")]
+ [SerializeReference] [TypeSelector]
+ [Tooltip("Press + — the picker opens (it does not duplicate). Each element picks its own type.")]
+ private List _step2List = new();
+
+ // STEP 3 — Abstract bases and interfaces.
+ // StatusEffect is abstract, so the picker offers only its concrete subclasses
+ // (BurnEffect, FreezeEffect). You can never pick the abstract base — it cannot be instantiated.
+ [Header("STEP 3 — Abstract base → only concrete subclasses")]
+ [SerializeReference] [TypeSelector]
+ [Tooltip("Only BurnEffect / FreezeEffect are listed; the abstract StatusEffect is hidden.")]
+ private StatusEffect _step3Abstract;
+
+ // STEP 4 — Narrowing the candidate list.
+ // All three fields are declared IWeapon, yet the picker shows different sets: the base type(s)
+ // passed to [TypeSelector(...)] act as an extra filter BELOW the declared type. You can narrow
+ // to one branch, or pass several base types to union them.
+ [Header("STEP 4 — Narrow candidates with [TypeSelector(typeof(...))]")]
+ [SerializeReference] [TypeSelector(typeof(IRanged))]
+ [Tooltip("typeof(IRanged) → only Pistol, Shotgun, Railgun, Crossbow.")]
+ private IWeapon _step4Ranged;
+
+ [SerializeReference] [TypeSelector(typeof(IMelee))]
+ [Tooltip("typeof(IMelee) → only Sword.")]
+ private IWeapon _step4Melee;
+
+ [SerializeReference] [TypeSelector(typeof(IMelee), typeof(IRanged))]
+ [Tooltip("Multiple base types are OR-ed → the whole IWeapon hierarchy again.")]
+ private IWeapon _step4MeleeOrRanged;
+
+ // STEP 5 — Nested references (recursion).
+ // Pick Railgun here, then expand it: its Charge Effect is itself a [SerializeReference]
+ // [TypeSelector] field, so it gets its own picker. The drawer nests to any depth.
+ [Header("STEP 5 — Nested reference inside the payload")]
+ [SerializeReference] [TypeSelector]
+ [Tooltip("Pick Railgun, expand it — its Charge Effect is a picker too (recursive).")]
+ private IWeapon _step5Nested;
+
+ // STEP 6 — Generics.
+ // Modifier is a concrete OPEN generic. On a non-generic IModifier field the picker offers the
+ // closed subclasses (DamageModifier/AmmoModifier/NameModifier) AND "Modifier" itself — picking
+ // the latter opens a second page to choose T before the instance is built.
+ [Header("STEP 6 — Generic hierarchy (open + closed)")]
+ [SerializeReference] [TypeSelector]
+ [Tooltip("Offers the 3 subclasses AND open Modifier; picking Modifier asks for T (try string, then float).")]
+ private IModifier _step6Open;
+
+ // A closed-generic field type fixes T, so the picker constrains candidates by assignability and
+ // builds Modifier directly — no second page. AmmoModifier (int) / NameModifier (string)
+ // are excluded; DamageModifier (float) and Modifier remain.
+ [SerializeReference] [TypeSelector]
+ [Tooltip("T is fixed to float: only DamageModifier and Modifier are offered, no extra page.")]
+ private Modifier _step6Closed;
+
+ // STEP 7 — References nested in plain [Serializable] containers.
+ // The picker, inline child fields and every repair gesture work at any depth — not just when the
+ // field sits directly on the component. Here a polymorphic weapon lives inside a container
+ // (the shared WeaponSlot.cs), and inside each element of a list of containers.
+ [Header("STEP 7 — References inside [Serializable] containers")]
+ [SerializeField]
+ [Tooltip("The weapon lives one level deep, inside this container — still a full picker.")]
+ private WeaponSlot _step7Slot = new();
+
+ [SerializeField]
+ [Tooltip("Each list element is a container whose weapon is its own picker.")]
+ private List _step7Slots = new();
+
+ // STEP 8 — Required references.
+ // TypeSelector's Required flag marks an unset reference with an inline warning (and feeds the
+ // build/CI gate). Leave it empty to see the warning; pick any type to clear it.
+ [Header("STEP 8 — Required reference validation")]
+ [SerializeReference] [TypeSelector(Required = true)]
+ [Tooltip("Empty → 'Required reference is not set' notice. Pick any IWeapon to satisfy it.")]
+ private IWeapon _step8Required;
+
+ // Right-click the component header → "Log Tutorial State" to print every configured step.
+ [ContextMenu("Log Tutorial State")]
+ private void LogTutorialState()
+ {
+ Debug.Log($"STEP 1 single: {Describe(_step1Single)}");
+
+ for (var i = 0; i < _step2List.Count; i++)
+ Debug.Log($"STEP 2 list[{i}]: {Describe(_step2List[i])}");
+
+ Debug.Log($"STEP 3 abstract: {(_step3Abstract is null ? "none" : _step3Abstract.Describe())}");
+ Debug.Log($"STEP 4 ranged: {Describe(_step4Ranged)} | melee: {Describe(_step4Melee)} | either: {Describe(_step4MeleeOrRanged)}");
+ Debug.Log($"STEP 5 nested: {Describe(_step5Nested)}");
+ Debug.Log($"STEP 6 open: {(_step6Open is null ? "none" : _step6Open.Describe())} | closed: {(_step6Closed is null ? "none" : _step6Closed.Describe())}");
+ Debug.Log($"STEP 8 required: {Describe(_step8Required)}");
+ }
+
+ private static string Describe(IWeapon weapon) => weapon?.Describe() ?? "none";
+ }
+}
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Tutorial/TypeSelectorTutorial.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Tutorial/TypeSelectorTutorial.cs.meta
new file mode 100644
index 00000000..b6770353
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Tutorial/TypeSelectorTutorial.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 5c5ff252d28c48738739916254d8480f
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/WeaponPreset.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/WeaponPreset.cs
new file mode 100644
index 00000000..969c4e2a
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/WeaponPreset.cs
@@ -0,0 +1,27 @@
+using System.Collections.Generic;
+using UnityEngine;
+using Aspid.FastTools.Types;
+
+// ReSharper disable once CheckNamespace
+namespace Aspid.FastTools.Samples.SerializeReferences
+{
+ // ScriptableObject host for [TypeSelector], used to demonstrate the missing-type repair flow.
+ //
+ // Why a ScriptableObject and not the Loadout MonoBehaviour? Unity preserves a managed reference whose type
+ // went missing (renamed / moved / deleted) only on ScriptableObject assets — on GameObjects and prefabs the
+ // reference is silently dropped to null on load (Unity bug UUM-129100). The "Fix" action that rewrites
+ // the stored type therefore only has something to repair on assets like this one.
+ //
+ // See the bundled BrokenWeaponPreset.asset: its _weapon points at a type that does not exist (GhostWeapon),
+ // so the Inspector shows a "Missing type" warning with a "Fix" button — set the class back to "Pistol"
+ // to recover the reference and its data.
+ [CreateAssetMenu(menuName = "Aspid/FastTools Samples/Weapon Preset", fileName = "WeaponPreset")]
+ public sealed class WeaponPreset : ScriptableObject
+ {
+ [SerializeReference] [TypeSelector]
+ private IWeapon _weapon;
+
+ [SerializeReference] [TypeSelector]
+ private List _alternates = new();
+ }
+}
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/WeaponPreset.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/WeaponPreset.cs.meta
new file mode 100644
index 00000000..cdacb9eb
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/WeaponPreset.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: b7874533c7294db1b8aa77e7d4102c9f
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/WeaponSlot.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/WeaponSlot.cs
new file mode 100644
index 00000000..5c1b6f1b
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/WeaponSlot.cs
@@ -0,0 +1,21 @@
+using System;
+using UnityEngine;
+using Aspid.FastTools.Types;
+
+// ReSharper disable once CheckNamespace
+namespace Aspid.FastTools.Samples.SerializeReferences
+{
+ // A plain [Serializable] container — NOT a managed reference itself — pairing a polymorphic
+ // weapon with some metadata. The [SerializeReference] weapon inside it is still a full
+ // hierarchical picker (TUTORIAL.md, Lesson 7). Shared by TypeSelectorTutorial and SlottedLoadout.
+ [Serializable]
+ public sealed class WeaponSlot
+ {
+ public string label = "Slot";
+
+ [Min(0)] public int priority;
+
+ [SerializeReference] [TypeSelector]
+ private IWeapon _weapon;
+ }
+}
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/WeaponSlot.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/WeaponSlot.cs.meta
new file mode 100644
index 00000000..24606f3e
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/WeaponSlot.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 8fd4eb93eab448f6afee150ba0df31a5
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons.meta
new file mode 100644
index 00000000..baff8dd5
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: b9b0ae5ee78a4faa9a79767c9f22771b
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/Crossbow.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/Crossbow.cs
new file mode 100644
index 00000000..f3cf6584
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/Crossbow.cs
@@ -0,0 +1,22 @@
+using System;
+using UnityEngine;
+using UnityEngine.Scripting.APIUpdating;
+
+// ReSharper disable once CheckNamespace
+namespace Aspid.FastTools.Samples.SerializeReferences
+{
+ // Renamed from "CrossbowLauncher" — the declared [MovedFrom] is what turns Presets/RenamedWeaponPreset.asset
+ // (whose YAML still stores the old class name) into a pending migration rather than a breakage: Unity migrates
+ // the loaded object in memory, the Inspector shows a healthy Crossbow, and Project References offers an
+ // authoritative "Migrate all" that — after a confirm dialog with a YAML diff preview — bakes the rename into
+ // the file. Once no file stores the old name, this attribute can be deleted.
+ [Serializable]
+ [MovedFrom(false, null, null, "CrossbowLauncher")]
+ public sealed class Crossbow : IRanged
+ {
+ [SerializeField] [Min(0)] private int _damage = 14;
+ [SerializeField] [Min(0)] private int _boltCount = 8;
+
+ public string Describe() => $"Crossbow — {_damage} dmg, {_boltCount} bolts";
+ }
+}
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/Crossbow.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/Crossbow.cs.meta
new file mode 100644
index 00000000..994d0fc6
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/Crossbow.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: fddd370bf45b4362b0de436ec2fc0c1b
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/IMelee.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/IMelee.cs
new file mode 100644
index 00000000..48960d23
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/IMelee.cs
@@ -0,0 +1,10 @@
+// ReSharper disable once CheckNamespace
+namespace Aspid.FastTools.Samples.SerializeReferences
+{
+ // Marker sub-interface used by the tutorial's "narrowing" step.
+ //
+ // A field declared as IWeapon normally lists every IWeapon implementation. Annotating it with
+ // [TypeSelector(typeof(IMelee))] narrows that same field to the melee branch only (Sword) — the
+ // attribute's base type is applied as an extra filter BELOW the declared field type.
+ public interface IMelee : IWeapon { }
+}
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/IMelee.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/IMelee.cs.meta
new file mode 100644
index 00000000..7502913f
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/IMelee.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 5ae1c814cbe44fb28cc39dd64004e7b0
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/IRanged.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/IRanged.cs
new file mode 100644
index 00000000..7064a609
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/IRanged.cs
@@ -0,0 +1,10 @@
+// ReSharper disable once CheckNamespace
+namespace Aspid.FastTools.Samples.SerializeReferences
+{
+ // Marker sub-interface used by the tutorial's "narrowing" step.
+ //
+ // Pistol, Shotgun, Railgun and Crossbow implement it. [TypeSelector(typeof(IRanged))] on an IWeapon
+ // field offers only those four; [TypeSelector(typeof(IMelee), typeof(IRanged))] offers both branches
+ // (multiple base types are OR-ed), and a bare [TypeSelector] offers every IWeapon.
+ public interface IRanged : IWeapon { }
+}
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/IRanged.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/IRanged.cs.meta
new file mode 100644
index 00000000..45c0500f
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/IRanged.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 74915b06fb274b28a1704a6d835b4766
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/IWeapon.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/IWeapon.cs
new file mode 100644
index 00000000..7322fc92
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/IWeapon.cs
@@ -0,0 +1,12 @@
+// ReSharper disable once CheckNamespace
+namespace Aspid.FastTools.Samples.SerializeReferences
+{
+ // Base interface for the polymorphic [SerializeReference] sample.
+ //
+ // [TypeSelector] lists every concrete, non-UnityEngine.Object class
+ // assignable to the field's declared type — here, every IWeapon implementation.
+ public interface IWeapon
+ {
+ string Describe();
+ }
+}
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/IWeapon.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/IWeapon.cs.meta
new file mode 100644
index 00000000..b7e5c0aa
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/IWeapon.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 4559c2a45a7844d28bee4f6935696eea
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/Pistol.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/Pistol.cs
new file mode 100644
index 00000000..0811fcee
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/Pistol.cs
@@ -0,0 +1,17 @@
+using System;
+using UnityEngine;
+
+// ReSharper disable once CheckNamespace
+namespace Aspid.FastTools.Samples.SerializeReferences
+{
+ // Concrete IWeapon (ranged branch). Its serialized fields are drawn inline under the dropdown's
+ // foldout once it is assigned. [Serializable] is conventional for managed-reference payloads.
+ [Serializable]
+ public sealed class Pistol : IRanged
+ {
+ [SerializeField] [Min(0)] private int _damage = 10;
+ [SerializeField] [Min(0)] private int _magazineSize = 12;
+
+ public string Describe() => $"Pistol — {_damage} dmg, {_magazineSize}-round mag";
+ }
+}
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/Pistol.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/Pistol.cs.meta
new file mode 100644
index 00000000..cce63b4b
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/Pistol.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 9acccfdd901f4e38b86499e3577cf2b4
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/Railgun.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/Railgun.cs
new file mode 100644
index 00000000..b45c8928
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/Railgun.cs
@@ -0,0 +1,25 @@
+using System;
+using UnityEngine;
+using Aspid.FastTools.Types;
+
+// ReSharper disable once CheckNamespace
+namespace Aspid.FastTools.Samples.SerializeReferences
+{
+ // Demonstrates a nested [SerializeReference] inside a managed-reference payload:
+ // the charge effect is itself polymorphic and gets its own inline dropdown.
+ // Ranged branch of the IWeapon hierarchy (see IRanged).
+ [Serializable]
+ public sealed class Railgun : IRanged
+ {
+ [SerializeField] [Min(0f)] private float _chargeTime = 1.5f;
+
+ [SerializeReference] [TypeSelector]
+ private StatusEffect _chargeEffect;
+
+ public string Describe()
+ {
+ var effect = _chargeEffect is null ? "none" : _chargeEffect.Describe();
+ return $"Railgun — {_chargeTime}s charge, effect: {effect}";
+ }
+ }
+}
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/Railgun.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/Railgun.cs.meta
new file mode 100644
index 00000000..c81685d5
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/Railgun.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: bf44f2297b1c4276a53ffe31f331254e
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/Shotgun.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/Shotgun.cs
new file mode 100644
index 00000000..313781f9
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/Shotgun.cs
@@ -0,0 +1,16 @@
+using System;
+using UnityEngine;
+
+// ReSharper disable once CheckNamespace
+namespace Aspid.FastTools.Samples.SerializeReferences
+{
+ // Ranged branch of the IWeapon hierarchy (see IRanged).
+ [Serializable]
+ public sealed class Shotgun : IRanged
+ {
+ [SerializeField] [Min(1)] private int _pellets = 8;
+ [SerializeField] [Range(0f, 90f)] private float _spreadAngle = 25f;
+
+ public string Describe() => $"Shotgun — {_pellets} pellets, {_spreadAngle}° spread";
+ }
+}
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/Shotgun.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/Shotgun.cs.meta
new file mode 100644
index 00000000..97569737
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/Shotgun.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 6b6a4b841bbf4765a143eaa74ca1d6a1
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/Sword.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/Sword.cs
new file mode 100644
index 00000000..d286b84e
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/Sword.cs
@@ -0,0 +1,17 @@
+using System;
+using UnityEngine;
+
+// ReSharper disable once CheckNamespace
+namespace Aspid.FastTools.Samples.SerializeReferences
+{
+ // The melee branch of the IWeapon hierarchy, added for the tutorial's narrowing step.
+ // It is the only IMelee, so [TypeSelector(typeof(IMelee))] resolves to exactly this type.
+ [Serializable]
+ public sealed class Sword : IMelee
+ {
+ [SerializeField] [Min(0)] private int _damage = 30;
+ [SerializeField] [Range(0f, 5f)] private float _reach = 1.8f;
+
+ public string Describe() => $"Sword — {_damage} dmg, {_reach}m reach";
+ }
+}
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/Sword.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/Sword.cs.meta
new file mode 100644
index 00000000..5f0b51bf
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/Sword.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: ef56508b96df4d8aaabf9a53c4c15a73
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/TUTORIAL.md b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/TUTORIAL.md
new file mode 100644
index 00000000..c5910da5
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/TUTORIAL.md
@@ -0,0 +1,320 @@
+# TypeSelector for SerializeReference — Step-by-Step Tutorial
+
+A guided, hands-on tour of everything `[TypeSelector]` does for `[SerializeReference]` fields. Work through the
+lessons in order: each one builds on the previous and maps to one section of the `TypeSelectorTutorial` component.
+
+**The one rule:** put `[SerializeReference]` **and** `[TypeSelector]` on the same field. The first tells Unity to
+store a polymorphic instance; the second renders the searchable type picker.
+
+```csharp
+[SerializeReference] [TypeSelector]
+private IWeapon _weapon;
+```
+
+## Open the tutorial
+
+1. Import this sample from **Package Manager → Aspid.FastTools → Samples → SerializeReferences → Import**.
+2. Open **`Scenes/TypeSelectorTutorial.unity`** and select the **TypeSelector Tutorial** GameObject.
+3. **Checkpoint:** the Inspector shows eight numbered sections, **STEP 1 → STEP 8**, with STEP 1 reading
+ *"Single polymorphic reference"*. If you see plain foldouts without type dropdowns, the sample assembly has
+ not finished compiling — wait for Unity to recompile. A few steps come pre-filled so you can see working
+ examples immediately; the rest are empty for you to try.
+
+Prefer a clean slate? Add an empty GameObject and attach the **TypeSelectorTutorial** component.
+
+Every lesson below works in both the default **UIToolkit** inspector and the **IMGUI** inspector — the package
+ships a drawer for each path and they are at feature parity. (`Prefabs/IMGUILoadout.prefab` forces the IMGUI path
+if you want to compare — see *The IMGUI path* in [README.md](README.md).)
+
+---
+
+## Lesson 1 — Your first picker
+
+**Field:** `IWeapon _step1Single` · `[SerializeReference] [TypeSelector]`
+
+Click the dropdown in the field header. A searchable, hierarchical window opens listing every concrete `IWeapon`
+implementation: `Sword`, `Pistol`, `Shotgun`, `Railgun`, `Crossbow`.
+
+
+
+
+*The picker window (shown here on another candidate list — yours opens filtered to `IWeapon`).*
+
+- **Pick a type** → Unity instantiates it and its own serialized fields appear inline under the foldout.
+- **``** (first row) → clears the reference back to `null`.
+- **Type to search** — just start typing; or click the magnifier. `↑ ↓` navigate, `Enter` selects, `Esc` closes.
+- **Favourites & Recent** — hover a row and click the star, or press `Space`, to pin a type to the **Favorites**
+ group. Types you pick are remembered under **Recent**. Both groups are collapsible.
+
+> The picker only ever lists types you can actually instantiate — concrete, non-abstract classes that are not
+> `UnityEngine.Object`. That filtering is automatic; you never see an invalid choice.
+
+---
+
+## Lesson 2 — Lists and arrays
+
+**Field:** `List _step2List` · `[SerializeReference] [TypeSelector]`
+
+A `List` (or array) of a `[SerializeReference]` type turns every element into its own independent picker.
+
+1. Press **+**. Instead of duplicating the last element, the **picker opens** so you choose the new element's type.
+2. Add a few elements and give each a different weapon — they are fully independent instances.
+3. Reorder or remove elements as usual.
+
+This is the first behaviour the attribute *changes* versus stock Unity: the native "+" would clone the previous
+element (and alias its data); here "+" always appends a fresh, typed instance.
+
+> **IMGUI note.** The UIToolkit field manages the list "+" automatically. In an IMGUI inspector Unity applies the
+> `[TypeSelector]` drawer to each *element*, so it cannot reach the list's "+" — draw such lists with
+> `SerializeReferenceIMGUIList.Draw(listProperty, label, elementType)` to get the same picker-backed "+" (see
+> `Editor/IMGUILoadoutEditor.cs`). The per-element dropdowns need no special handling.
+
+---
+
+## Lesson 3 — Abstract bases and interfaces
+
+**Field:** `StatusEffect _step3Abstract` · `[SerializeReference] [TypeSelector]`
+
+`StatusEffect` is an **abstract** class — you cannot `new` it.
+
+**Try it:**
+1. Open the picker.
+2. Note that only the concrete subclasses `BurnEffect` and `FreezeEffect` are offered.
+
+**Notice:**
+- The abstract base itself is never listed — there would be nothing to instantiate.
+- An interface-typed field behaves identically (Lesson 1's `IWeapon` is an interface).
+- Declare the field as the broadest type that makes sense; the picker shows only the concrete leaves you can create.
+
+---
+
+## Lesson 4 — Narrowing the candidate list
+
+**Fields:** three `IWeapon` fields with different attribute arguments.
+
+```csharp
+[SerializeReference] [TypeSelector(typeof(IRanged))] private IWeapon _step4Ranged;
+[SerializeReference] [TypeSelector(typeof(IMelee))] private IWeapon _step4Melee;
+[SerializeReference] [TypeSelector(typeof(IMelee), typeof(IRanged))] private IWeapon _step4MeleeOrRanged;
+```
+
+All three fields are declared `IWeapon`, yet the picker shows different sets. The base type(s) you pass to
+`[TypeSelector(...)]` act as an **extra filter, applied below the declared field type**:
+
+| Attribute | Offered types |
+|---|---|
+| `[TypeSelector]` (Lesson 1) | `Sword`, `Pistol`, `Shotgun`, `Railgun`, `Crossbow` (all `IWeapon`) |
+| `[TypeSelector(typeof(IRanged))]` | `Pistol`, `Shotgun`, `Railgun`, `Crossbow` |
+| `[TypeSelector(typeof(IMelee))]` | `Sword` |
+| `[TypeSelector(typeof(IMelee), typeof(IRanged))]` | all five (multiple base types are OR-ed) |
+
+Use this to keep a field's *type* broad (so your code stays generic) while constraining what designers can pick.
+
+> The base types narrow **below** the declared field type — they can never widen it. `[TypeSelector(typeof(object))]`
+> on an `IWeapon` field still only shows `IWeapon` implementations.
+
+> **Note — `TypeAllow` does not apply here.** The `Allow = TypeAllow.Abstract / Interface` option on the attribute
+> only affects `[TypeSelector]` on a **`string`** field (where you are naming a type, not instantiating it). For a
+> `[SerializeReference]` field it is ignored — you can never pick an abstract class or interface, because there would
+> be nothing to instantiate.
+
+---
+
+## Lesson 5 — Nested references (recursion)
+
+**Field:** `IWeapon _step5Nested` · `[SerializeReference] [TypeSelector]`
+
+**Try it:**
+1. Pick **`Railgun`** and expand its foldout.
+2. Find its `Charge Effect` field — itself a `[SerializeReference] [TypeSelector] StatusEffect`.
+3. Open *that* picker and assign a `BurnEffect` or `FreezeEffect`.
+
+**Notice:**
+- A managed reference can contain another managed reference, to any depth.
+- Every nested level gets its own picker, inline child fields, notices and context-menu actions — the drawer is fully recursive.
+
+---
+
+## Lesson 6 — Generic hierarchies
+
+**Fields:** `IModifier _step6Open` and `Modifier _step6Closed`.
+
+`Modifier` is a **concrete open generic** (`IModifier`), with closed subclasses `DamageModifier : Modifier`,
+`AmmoModifier : Modifier`, `NameModifier : Modifier`.
+
+**On the `IModifier` field** (`_step6Open`), the picker offers:
+
+- the three concrete closed subclasses, **and**
+- the open generic **`Modifier`** itself.
+
+Pick `Modifier` and a **second page** opens to choose the argument `T`. Try `string`, then `float` — only after `T`
+is resolved is the closed type (`Modifier` / `Modifier`) constructed and assigned.
+
+**On the `Modifier` field** (`_step6Closed`), `T` is already fixed by the field type, so:
+
+- candidates are constrained by assignability — only `DamageModifier` (a `Modifier`) and `Modifier`
+ itself are offered; `AmmoModifier` (int) and `NameModifier` (string) are excluded, and
+- picking `Modifier<…>` builds `Modifier` **directly**, with no second page.
+
+---
+
+## Lesson 7 — References inside `[Serializable]` containers
+
+**Fields:** `WeaponSlot _step7Slot` and `List _step7Slots`.
+
+`WeaponSlot` (`Scripts/WeaponSlot.cs`) is a plain `[Serializable]` class (not a managed reference itself) holding a
+`label`, a `priority`, and a `[SerializeReference] [TypeSelector] IWeapon`.
+
+**Try it:**
+1. Expand `_step7Slot` and pick a weapon for its inner field.
+2. Add elements to `_step7Slots` — each element is a container whose weapon is its own picker.
+
+**Notice:**
+- A managed reference does **not** have to sit directly on the component.
+- Everything you learned still applies at this depth: the picker, inline child fields, the missing-type warning and its inline **Fix**.
+- It works on the component, one level inside a container, or inside each element of a `List`.
+
+---
+
+## Lesson 8 — Required references
+
+**Field:** `IWeapon _step8Required` · `[SerializeReference] [TypeSelector(Required = true)]`
+
+Set `Required = true` on `[TypeSelector]` to mark a reference as mandatory.
+
+```csharp
+[SerializeReference, TypeSelector(Required = true)]
+private IWeapon _weapon;
+```
+
+**Try it:**
+1. Leave the field empty — an inline **"Required reference is not set"** warning appears.
+2. Pick any `IWeapon` — the notice clears.
+
+**Notice:**
+- The same `Required = true` works on a `[TypeSelector] string` type field — there "unset" means an empty type name.
+- A present-but-missing managed-reference type is never a *required* violation — it keeps its own missing-type notice.
+- Required references also feed the **build / CI gate** (see *Project settings* at the end of this page).
+
+---
+
+## Power-user gestures (right-click any field above)
+
+These are not separate fields — they are gestures available on every `[TypeSelector]` field via its right-click
+context menu, header, or drag-and-drop. Try them on the fields from Lessons 1–8.
+
+| Gesture | What it does |
+|---|---|
+| **Switch the type** | Keeps the fields the old and new type share. Set a `Pistol`'s damage, switch to `Shotgun` and back — the value survives (data is carried over by name). |
+| **Copy / Paste Serialize Reference** | Right-click the header. Paste rebuilds an **independent** instance; it is greyed-out when the copied type does not fit the field. |
+| **Make Unique Reference** | When two fields share one instance (a "shared reference" notice appears — e.g. after duplicating a list element), this gives the field its own copy so edits stop bleeding across. |
+| **Link to Existing ▸ …** | The inverse: deliberately point this field at a sibling field's instance, sharing one object across both. |
+| **Drag a `MonoScript` onto the field** | Drag a `.cs` script from the Project window onto the header; if its class fits (honouring the narrowing from Lesson 4) a fresh instance is assigned. |
+| **Create New Script…** | Generates a `[Serializable]` class deriving from the field's base type, then auto-assigns a new instance after the recompile. |
+| **Save as Template… / Paste Template ▸ …** | Save the current value as a named, project-wide template and re-apply it to any compatible field later. |
+| **Find Usages of …** | Opens the Search window (`sr:`) listing every place that type is used as a managed reference. |
+
+---
+
+## Maintenance: repairing broken references
+
+When a managed-reference type is renamed, moved or deleted, its stored data is orphaned. The tutorial ships sibling
+assets **pre-broken** on purpose so you can practise the recovery flow:
+
+- `Presets/BrokenWeaponPreset.asset`, `Presets/BrokenArsenalPreset.asset` — `ScriptableObject`s referencing a missing `GhostWeapon`.
+- `Prefabs/LoadoutMissingType.prefab` — a prefab whose `Sidearms → Element 0` references a missing `GhostPistol`.
+- `Presets/MovedWeaponPreset.asset` — a `ScriptableObject` whose `Weapon` still stores `Pistol` under an old `…Samples.SerializeReferences.Legacy` namespace, as if the class had been moved without a `[MovedFrom]` attribute. The type itself exists — only the stored identity is stale.
+- `Presets/RenamedWeaponPreset.asset` — still stores the old `CrossbowLauncher` class name; the class now ships as `Crossbow` with a declared `[MovedFrom]`. Not broken, just stale on disk — demonstrates the **Migrate all** flow below.
+
+### Inline repair (one field)
+
+1. Select a broken asset **in the Project window**.
+2. The missing field shows a `` caption, a **Missing type** warning and a **Fix** button (often with a one-click **Smart Fix** suggestion of the likely new type).
+3. Click **Fix**, pick the replacement (e.g. `Pistol`) — the reference is rewritten **keeping its data** (the picker rewrites the stored type in the asset file rather than recreating the instance).
+
+
+
+### Smart Fix (one click, no picker)
+
+The `GhostWeapon` assets above have no plausible successor, so their notice only offers the manual **Fix**. Open
+`Presets/MovedWeaponPreset.asset` instead — its warning ends with a clickable **`→ Pistol?`** suggestion:
+
+1. Hover the suggestion — the tooltip shows the full suggested identity and the ranking reason (`same type name`).
+2. Click it — the reference is re-pointed at the moved `Pistol` in one step, keeping `_damage = 21`, `_magazineSize = 6`.
+
+Ranking, highest first (the same candidate pool the picker would offer):
+
+1. a declared `[MovedFrom]` match
+2. a same-named type in another namespace/assembly
+3. a casing-only rename
+4. a near-miss name backed by the orphaned data's field shape
+
+The suggestion is **never applied automatically** — you always click.
+
+> A rename/move that ships `[MovedFrom]` from the start never breaks at all — Unity migrates the reference on load.
+> Smart Fix is the safety net for the moves that forgot it. The files themselves still store the old name until each
+> asset is re-saved, though — the migration flow below bakes the rename in.
+
+### Migrate a `[MovedFrom]` rename (Project References)
+
+`Presets/RenamedWeaponPreset.asset` stores its weapon under the old class name `CrossbowLauncher`; the class now ships
+as `Crossbow` with `[MovedFrom(false, null, null, "CrossbowLauncher")]`. The Inspector shows a healthy `Crossbow` —
+Unity migrates the reference **in memory** on load — but the file on disk still stores the old name: stale for version
+control, YAML scans and any asset that never gets re-saved.
+
+1. Open **`Tools → Aspid 🐍 → FastTools → Project References`** and **Scan Project**.
+2. The `CrossbowLauncher` group renders as a calm, info-tinted **pending migration** — not a warning: an authoritative
+ `[MovedFrom]` match is not a guess, so in place of a Smart Fix suggestion the card carries a
+ **`Migrate all (1) → Crossbow`** button.
+3. Click it and confirm (the dialog previews the exact YAML lines that will change). The file now stores `Crossbow`,
+ keeping `_damage = 17`, `_boltCount = 5`.
+
+Once no file in the project stores the old name, the `[MovedFrom]` attribute can be deleted from the code — migrating
+is what makes that cleanup safe.
+
+> Repair reads and rewrites the asset YAML directly, because Unity does not expose a missing type through its
+> serialization API (and on GameObjects/prefabs even drops it from the live object — UUM-129100). It therefore needs a
+> **saved asset file**: it works on ScriptableObjects and prefab assets selected in the Project, and on objects in
+> Prefab Mode / a clean saved scene, but not on a dirty scene or a prefab-instance override.
+
+### Whole-asset & project-wide repair
+
+Open **`Tools → Aspid 🐍 → FastTools`**:
+
+| Tab | What it does |
+|---|---|
+| **Asset References** | Maps a saved asset's entire `[SerializeReference]` graph and repairs any missing node inline — any depth, any child object the Inspector can't otherwise reach. |
+| **Project References** | Sweeps every asset under `Assets/` and groups broken references **by stored type** — `BrokenWeaponPreset.asset` and `BrokenArsenalPreset.asset` collapse into one **GhostWeapon** group (`4 entries · 2 files`). **Fix all** re-points every entry across both files at once. A group whose stored type matches a declared `[MovedFrom]` shows **Migrate all** instead — see the migration section above. |
+
+### Guard rails (no window needed)
+
+- **Delete guard** — deleting a `.cs` whose class is still used as a managed reference pops a confirm dialog listing the affected assets before it lets the delete through.
+- **Breakage toast** — when references newly become missing after a recompile, a dismissable toast and one console warning deep-link straight to the repair window. Switch it off via **Breakage detection** in Project settings (below) if you find it intrusive.
+
+---
+
+## Project settings
+
+**`Project Settings → Aspid FastTools → SerializeReference`** exposes:
+
+- **Breakage detection** — the proactive toast; per-machine.
+- **Auto de-alias duplicated list elements**, **Build / CI gate** (`Off` / `Warn` / `Fail`), **Excluded scan folders** — saved to a **committed** `ProjectSettings/SerializeReferenceSharedSettings.asset` so teammates and CI behave identically.
+- The same options, plus the picker's per-user preferences (Favorites, Recent capacity, appearance), also live in the window's **Settings** tab and at **`Preferences → Aspid FastTools`**.
+
+The full reference — every setting, scope rules and the headless-CI entry point
+(`SerializeReferenceCiGate.RunCheck` and its `-srGate*` flags) — lives in the package documentation:
+**Documentation → SerializeReference Selector → Project settings & the build/CI gate**.
+
+---
+
+## Where to look in code
+
+| File | Shows |
+|---|---|
+| `Scripts/Tutorial/TypeSelectorTutorial.cs` | All eight lessons as numbered fields |
+| `Scripts/Weapons/` | `IWeapon` + `IMelee`/`IRanged` branches and `Sword`/`Pistol`/`Shotgun`/`Railgun`/`Crossbow` |
+| `Scripts/Effects/` | abstract `StatusEffect` + `BurnEffect`/`FreezeEffect` |
+| `Scripts/Modifiers/` | the `Modifier` generic hierarchy |
+| `Scripts/WeaponSlot.cs` + `Scripts/SlottedLoadout.cs` | references nested in `[Serializable]` containers (Lesson 7, standalone) |
+| `Scripts/WeaponPreset.cs` + `Presets/Broken*.asset` / `Presets/MovedWeaponPreset.asset` / `Presets/RenamedWeaponPreset.asset` | the missing-type repair flow — manual **Fix**, the one-click **Smart Fix** and the `[MovedFrom]` **Migrate all** |
+| `Scripts/IMGUILoadout.cs` + `Scripts/Editor/IMGUILoadoutEditor.cs` | the same fields forced through the IMGUI path (see *The IMGUI path* in [README.md](README.md)) |
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/TUTORIAL.md.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/TUTORIAL.md.meta
new file mode 100644
index 00000000..d3ede7c6
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/TUTORIAL.md.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: cc5aeec9b48e4ea49f6a98938aefc54d
+TextScriptImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/TUTORIAL_RU.md b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/TUTORIAL_RU.md
new file mode 100644
index 00000000..3fe629dd
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/TUTORIAL_RU.md
@@ -0,0 +1,310 @@
+# TypeSelector для SerializeReference — пошаговый туториал
+
+Практический разбор всего, что `[TypeSelector]` даёт полям `[SerializeReference]`. Проходите уроки по порядку: каждый опирается на предыдущий и соответствует одной секции компонента `TypeSelectorTutorial`.
+
+**Единственное правило:** ставьте `[SerializeReference]` **и** `[TypeSelector]` на одно и то же поле. Первое говорит Unity хранить полиморфный экземпляр, второе рисует выпадающий селектор типов с поиском.
+
+```csharp
+[TypeSelector]
+[SerializeReference] private IWeapon _weapon;
+```
+
+## Как открыть туториал
+
+1. Импортируйте пример: **Package Manager → Aspid.FastTools → Samples → SerializeReferences → Import**.
+2. Откройте **`Scenes/TypeSelectorTutorial.unity`** и выделите объект **TypeSelector Tutorial**.
+3. **Контрольная точка:** Inspector показывает восемь пронумерованных секций **STEP 1 → STEP 8**, где STEP 1 —
+ *«Single polymorphic reference»*. Если видны обычные foldout'ы без дропдаунов типов — сборка примера ещё
+ компилируется, дождитесь рекомпиляции Unity. Несколько шагов уже предзаполнены, чтобы сразу видеть рабочие
+ примеры; остальные пустые — для самостоятельной практики.
+
+Хотите с чистого листа? Добавьте пустой GameObject и прикрепите компонент **TypeSelectorTutorial**.
+
+Каждый урок работает и в инспекторе **UIToolkit**, и в **IMGUI** — пакет поставляет drawer для каждого пути, и они на паритете по функциям. (`Prefabs/IMGUILoadout.prefab` принудительно использует путь IMGUI, если хотите сравнить — см. *Путь IMGUI* в [README_RU.md](README_RU.md).)
+
+---
+
+## Урок 1 — Ваш первый селектор
+
+**Поле:** `[TypeSelector] [SerializeReference] IWeapon _step1Single`
+
+Кликните по выпадающему списку в заголовке поля. Откроется иерархическое окно с поиском, в котором перечислены все конкретные реализации `IWeapon`: `Sword`, `Pistol`, `Shotgun`, `Railgun`, `Crossbow`.
+
+
+
+
+*Окно-селектор (здесь показано на другом списке кандидатов — ваше откроется отфильтрованным под `IWeapon`).*
+
+- **Выберите тип** → Unity создаёт экземпляр, а его сериализуемые поля появляются вложенно под foldout.
+- **``** (первая строка) → очищает ссылку обратно в `null`.
+- **Поиск по вводу** — просто начните печатать; или нажмите на лупу. `↑ ↓` — навигация, `Enter` — выбор, `Esc` —
+ закрыть.
+- **Избранное и недавние** — наведитесь на строку и нажмите на звезду либо клавишу `Space`, чтобы закрепить тип в
+ группе **Favorites**. Выбранные типы запоминаются в **Recent**. Обе группы сворачиваются.
+
+> В списке всегда только те типы, которые реально можно создать — конкретные, неабстрактные классы, не наследники `UnityEngine.Object`. Эта фильтрация автоматическая; невалидного варианта вы никогда не увидите.
+
+---
+
+## Урок 2 — Списки и массивы
+
+**Поле:** `[TypeSelector] [SerializeReference] List _step2List`
+
+`List` (или массив) поля `[SerializeReference]` превращает каждый элемент в собственный независимый селектор.
+
+1. Нажмите **+**. Вместо дублирования последнего элемента **откроется селектор**, чтобы выбрать тип нового элемента.
+2. Добавьте несколько элементов и задайте каждому своё оружие — это полностью независимые экземпляры.
+3. Меняйте порядок и удаляйте элементы как обычно.
+
+Это первое поведение, которое аттрибут *меняет* относительно стандартного Unity: родная «+» клонировала бы предыдущий элемент (и делила бы его данные), здесь «+» всегда добавляет новый типизированный экземпляр.
+
+> **Про IMGUI.** В UIToolkit-поле кнопка «+» списка обрабатывается автоматически. В IMGUI-инспекторе Unity применяет drawer `[TypeSelector]` к каждому *элементу*, поэтому до «+» списка он «не дотягивается» — рисуйте такие списки через `SerializeReferenceIMGUIList.Draw(listProperty, label, elementType)`, чтобы получить ту же «+» с селектором (см. `Editor/IMGUILoadoutEditor.cs`). Поэлементным выпадающим селекторам ничего дополнительно не нужно.
+
+---
+
+## Урок 3 — Абстрактные базы и интерфейсы
+
+**Поле:** `[TypeSelector] [SerializeReference] StatusEffect _step3Abstract`
+
+`StatusEffect` — **абстрактный** класс, создать его через `new` нельзя.
+
+**Попробуйте:**
+1. Откройте селектор.
+2. Убедитесь, что предлагаются только конкретные подтипы `BurnEffect` и `FreezeEffect`.
+
+**Обратите внимание:**
+- Сам абстрактный базовый класс никогда не показывается — инстанцировать было бы нечего.
+- Поле с типом-интерфейсом ведёт себя так же (`IWeapon` из урока 1 — интерфейс).
+- Объявляйте поле максимально широким осмысленным типом; селектор покажет только конкретные «листья».
+
+---
+
+## Урок 4 — Сужение списка кандидатов
+
+**Поля:** три поля `IWeapon` с разными аргументами аттрибута.
+
+```csharp
+[TypeSelector(typeof(IRanged))]
+[SerializeReference] private IWeapon _step4Ranged;
+
+[TypeSelector(typeof(IMelee))]
+[SerializeReference] private IWeapon _step4Melee;
+
+[TypeSelector(typeof(IMelee), typeof(IRanged))]
+[SerializeReference] private IWeapon _step4MeleeOrRanged;
+```
+
+Все три поля объявлены как `IWeapon`, но селектор показывает разные наборы. Базовые типы, переданные в `[TypeSelector(...)]`, работают как **дополнительный фильтр, применяемый ниже объявленного типа поля**:
+
+| Аттрибут | Предлагаемые типы |
+|---|---|
+| `[TypeSelector]` (урок 1) | `Sword`, `Pistol`, `Shotgun`, `Railgun`, `Crossbow` (все `IWeapon`) |
+| `[TypeSelector(typeof(IRanged))]` | `Pistol`, `Shotgun`, `Railgun`, `Crossbow` |
+| `[TypeSelector(typeof(IMelee))]` | `Sword` |
+| `[TypeSelector(typeof(IMelee), typeof(IRanged))]` | все пять (несколько базовых типов объединяются по ИЛИ) |
+
+Так можно держать *тип* поля широким (чтобы код оставался обобщённым) и при этом ограничивать выбор для дизайнеров.
+
+> Базовые типы сужают **ниже** объявленного типа поля и никогда не расширяют его. `[TypeSelector(typeof(object))]` на поле `IWeapon` всё равно покажет только реализации `IWeapon`.
+
+> **Важно — `TypeAllow` здесь не применяется.** Опция `Allow = TypeAllow.Abstract / Interface` аттрибута влияет только
+> на `[TypeSelector]` для поля **`string`** (где вы именуете тип, а не создаёте его). Для поля `[SerializeReference]` она игнорируется — абстрактный класс или интерфейс выбрать нельзя, потому что нечего было бы инстанцировать.
+
+---
+
+## Урок 5 — Вложенные ссылки (рекурсия)
+
+**Поле:** `[TypeSelector] [SerializeReference] IWeapon _step5Nested`
+
+**Попробуйте:**
+1. Выберите **`Railgun`** и разверните его foldout.
+2. Найдите его поле `Charge Effect` — это снова `[TypeSelector] [SerializeReference] StatusEffect`.
+3. Откройте *этот* селектор и присвойте `BurnEffect` или `FreezeEffect`.
+
+**Обратите внимание:**
+- Managed-ссылка может содержать другую managed-ссылку на любой глубине.
+- У каждого вложенного уровня свой селектор, вложенные дочерние поля, пометки и пункты контекстного меню — drawer полностью рекурсивен.
+
+---
+
+## Урок 6 — Generic-иерархии
+
+**Поля:** `IModifier _step6Open` и `Modifier _step6Closed`.
+
+`Modifier` — **конкретный открытый generic** (`IModifier`) с закрытыми подтипами `DamageModifier : Modifier`,
+`AmmoModifier : Modifier`, `NameModifier : Modifier`.
+
+**На поле `IModifier`** (`_step6Open`) селектор предлагает:
+
+- три конкретных закрытых подтипа **и**
+- сам открытый generic **`Modifier`**.
+
+Выберите `Modifier` — откроется **вторая страница** для выбора аргумента `T`. Попробуйте `string`, затем `float` — закрытый тип (`Modifier` / `Modifier`) создаётся и присваивается только после того, как `T` определён.
+
+**На поле `Modifier`** (`_step6Closed`) `T` уже зафиксирован типом поля, поэтому:
+
+- кандидаты ограничены присваиваемостью — предлагаются только `DamageModifier` (это `Modifier`) и сам `Modifier`; `AmmoModifier` (int) и `NameModifier` (string) исключены, а
+- выбор `Modifier<…>` создаёт `Modifier` **сразу**, без второй страницы.
+
+---
+
+## Урок 7 — Ссылки внутри `[Serializable]`- контейнеров
+
+**Поля:** `WeaponSlot _step7Slot` и `List _step7Slots`.
+
+`WeaponSlot` (`Scripts/WeaponSlot.cs`) — обычный `[Serializable]`-класс (сам не managed-ссылка), содержащий `label`, `priority` и `[TypeSelector] [SerializeReference] IWeapon`.
+
+**Попробуйте:**
+1. Разверните `_step7Slot` и выберите оружие для его внутреннего поля.
+2. Добавьте элементы в `_step7Slots` — каждый элемент это контейнер, и у его оружия свой селектор.
+
+**Обратите внимание:**
+- Managed-ссылка **не обязана** лежать прямо на компоненте.
+- Всё, что вы изучили, работает и на этой глубине: селектор, вложенные дочерние поля, предупреждение о потерянном типе и встроенная кнопка **Fix**.
+- Работает и на компоненте, и на уровень глубже внутри контейнера, и внутри каждого элемента `List`.
+
+---
+
+## Урок 8 — Обязательные ссылки
+
+**Поле:** `IWeapon _step8Required` · `[SerializeReference] [TypeSelector(Required = true)]`
+
+Установите `Required = true` у `[TypeSelector]`, чтобы пометить ссылку как обязательную.
+
+```csharp
+[SerializeReference, TypeSelector(Required = true)]
+private IWeapon _weapon;
+```
+
+**Попробуйте:**
+1. Оставьте поле пустым — появится встроенное предупреждение **«Required reference is not set»**.
+2. Выберите любой `IWeapon` — пометка исчезнет.
+
+**Обратите внимание:**
+- Тот же `Required = true` работает и на `[TypeSelector] string` поле — там «не задано» означает пустое имя типа.
+- Present-but-missing тип managed-ссылки никогда не считается нарушением *required* — у него собственная пометка о пропавшем типе.
+- Обязательные ссылки также учитываются **build / CI gate** (см. *Настройки проекта* в конце страницы).
+
+---
+
+## Приёмы для продвинутых (ПКМ по любому полю выше)
+
+Это не отдельные поля, а жесты, доступные на каждом поле `[TypeSelector]` через контекстное меню (ПКМ), заголовок или
+drag-and-drop. Попробуйте их на полях из уроков 1–8.
+
+| Жест | Что делает |
+|---|---|
+| **Смена типа** | Сохраняет поля, общие у старого и нового типа. Задайте `Pistol` урон, переключите на `Shotgun` и обратно — значение сохранится (данные переносятся по имени). |
+| **Copy / Paste Serialize Reference** | ПКМ по заголовку. Вставка создаёт **независимый** экземпляр; неактивна, если скопированный тип не подходит полю. |
+| **Make Unique Reference** | Когда два поля делят один экземпляр (появляется пометка «shared reference» — например, после дублирования элемента списка), даёт полю собственную копию, и правки перестают «протекать». |
+| **Link to Existing ▸ …** | Обратное: намеренно указать поле на экземпляр соседнего поля, разделив один объект на два. |
+| **Перетащить `MonoScript` на поле** | Перетащите `.cs`-скрипт из Project на заголовок; если его класс подходит (с учётом сужения из урока 4) — присвоится новый экземпляр. |
+| **Create New Script…** | Генерирует `[Serializable]`-класс, наследующий базовый тип поля, и после рекомпиляции автоматически присваивает новый экземпляр. |
+| **Save as Template… / Paste Template ▸ …** | Сохранить текущее значение как именованный проектный шаблон и применить его к любому подходящему полю позже. |
+| **Find Usages of …** | Открывает окно Search (`sr:`) со всеми местами, где тип используется как managed-ссылка. |
+
+---
+
+## Обслуживание: починка сломанных ссылок
+
+Когда тип managed-ссылки переименован, перемещён или удалён, сохранённые данные осиротевают. Туториал специально
+поставляет соседние ассеты **заранее сломанными**, чтобы потренироваться в восстановлении:
+
+- `Presets/BrokenWeaponPreset.asset`, `Presets/BrokenArsenalPreset.asset` — `ScriptableObject`-ы, ссылающиеся на потерянный `GhostWeapon`.
+- `Prefabs/LoadoutMissingType.prefab` — префаб, у которого `Sidearms → Element 0` ссылается на потерянный `GhostPistol`.
+- `Presets/MovedWeaponPreset.asset` — `ScriptableObject`, у которого `Weapon` всё ещё хранит `Pistol` под старым namespace `…Samples.SerializeReferences.Legacy` — как будто класс перенесли без атрибута `[MovedFrom]`. Сам тип существует — устарела только сохранённая идентичность.
+- `Presets/RenamedWeaponPreset.asset` — всё ещё хранит старое имя класса `CrossbowLauncher`; сам класс теперь называется `Crossbow` и несёт объявленный `[MovedFrom]`. Не сломан, просто устарел на диске — демонстрирует поток **Migrate all** ниже.
+
+### Починка одного поля в инспекторе
+
+1. Выделите сломанный ассет **в окне Project**.
+2. У потерянного поля будет подпись ``, предупреждение **Missing type** и кнопка **Fix** (часто с подсказкой **Smart Fix** в один клик — вероятным новым типом).
+3. Нажмите **Fix**, выберите замену (например, `Pistol`) — ссылка перепишется **с сохранением данных** (селектор переписывает сохранённый тип в файле ассета, а не создаёт экземпляр заново).
+
+
+
+### Smart Fix (один клик, без селектора)
+
+У `GhostWeapon`-ассетов выше нет правдоподобного преемника, поэтому их плашка предлагает только ручной **Fix**.
+Откройте вместо них `Presets/MovedWeaponPreset.asset` — его предупреждение заканчивается кликабельной подсказкой
+**`→ Pistol?`**:
+
+1. Наведите курсор на подсказку — тултип покажет полную предлагаемую идентичность и причину ранжирования (`same type name`).
+2. Кликните — ссылка в один шаг перенаправится на переехавший `Pistol` с сохранением данных (`_damage = 21`, `_magazineSize = 6`).
+
+Ранжирование, от высшего балла (пул кандидатов — тот же, что у селектора):
+
+1. объявленное совпадение `[MovedFrom]`
+2. одноимённый тип в другом namespace/сборке
+3. переименование только по регистру
+4. похожее имя, подкреплённое формой полей осиротевших данных
+
+Подсказка **никогда не применяется автоматически** — клик всегда за вами.
+
+> Переименование/перенос, сразу снабжённый `[MovedFrom]`, вообще не ломается — Unity мигрирует ссылку при загрузке.
+> Smart Fix — страховка для переносов, где про атрибут забыли. Но сами файлы хранят старое имя, пока каждый ассет не
+> пересохранят — поток миграции ниже запекает переименование в файлы.
+
+### Миграция переименования с `[MovedFrom]` (Project References)
+
+`Presets/RenamedWeaponPreset.asset` хранит оружие под старым именем класса `CrossbowLauncher`; сам класс теперь
+называется `Crossbow` и несёт `[MovedFrom(false, null, null, "CrossbowLauncher")]`. Инспектор показывает здоровый
+`Crossbow` — Unity мигрирует ссылку **в памяти** при загрузке, — но файл на диске всё ещё хранит старое имя:
+«протухшее» для контроля версий, YAML-сканов и любого ассета, который никогда не пересохраняют.
+
+1. Откройте **`Tools → Aspid 🐍 → FastTools → Project References`** и нажмите **Scan Project**.
+2. Группа `CrossbowLauncher` рендерится как спокойная info-подсвеченная **ожидающая миграция**, а не предупреждение:
+ авторитетное совпадение по `[MovedFrom]` — не догадка, поэтому вместо подсказки Smart Fix карточка несёт кнопку
+ **`Migrate all (1) → Crossbow`**.
+3. Кликните и подтвердите (диалог показывает точные YAML-строки, которые изменятся). Файл теперь хранит `Crossbow`,
+ данные сохранены (`_damage = 17`, `_boltCount = 5`).
+
+Когда ни один файл в проекте не хранит старое имя, атрибут `[MovedFrom]` можно удалить из кода — миграция и делает
+эту зачистку безопасной.
+
+> Починка читает и переписывает YAML ассета напрямую, потому что Unity не отдаёт потерянный тип через свой serialization
+> API (а на GameObject/префабах ещё и обнуляет его в живом объекте — UUM-129100). Поэтому нужен **сохранённый файл
+> ассета**: работает для ScriptableObject и префаб-ассетов, выделенных в Project, и для объектов в Prefab Mode / чистой
+> сохранённой сцене, но не для «грязной» сцены или override экземпляра префаба.
+
+### Починка всего ассета и всего проекта
+
+Откройте **`Tools → Aspid 🐍 → FastTools`**:
+
+| Вкладка | Что делает |
+|---|---|
+| **Asset References** | Строит граф всех `[SerializeReference]` сохранённого ассета и чинит любой потерянный узел встроенно — любой глубины, на любом дочернем объекте, до которого инспектор иначе не дотянется. |
+| **Project References** | Проходит по всем ассетам в `Assets/` и группирует сломанные ссылки **по сохранённому типу** — `BrokenWeaponPreset.asset` и `BrokenArsenalPreset.asset` сворачиваются в одну группу **GhostWeapon** (`4 entries · 2 files`). **Fix all** перенаправляет все вхождения сразу в обоих файлах. Группа, чей сохранённый тип совпадает с объявленным `[MovedFrom]`, вместо этого показывает **Migrate all** — см. секцию миграции выше. |
+
+### Защитные механизмы (без открытия окон)
+
+- **Delete guard** — удаление `.cs`, чей класс ещё используется как managed-ссылка, вызывает диалог подтверждения со списком затронутых ассетов, прежде чем пропустить удаление.
+- **Breakage toast** — когда ссылки заново становятся потерянными после рекомпиляции, всплывающее уведомление и одно предупреждение в Console ведут прямо в окно починки. Отключается через **Breakage detection** в настройках проекта (ниже), если мешает.
+
+---
+
+## Настройки проекта
+
+**`Project Settings → Aspid FastTools → SerializeReference`** содержит:
+
+- **Breakage detection** — проактивный тост; per-machine.
+- **Auto de-alias duplicated list elements**, **Build / CI gate** (`Off` / `Warn` / `Fail`), **Excluded scan folders** — сохраняются в **коммитимый** `ProjectSettings/SerializeReferenceSharedSettings.asset`, чтобы команда и CI вели себя одинаково.
+- Те же опции, плюс индивидуальные настройки пикера (Favorites, ёмкость Recent, оформление), доступны во вкладке **Settings** окна и на странице **`Preferences → Aspid FastTools`**.
+
+Полный справочник — каждая настройка, правила scope и headless-CI-входная точка
+(`SerializeReferenceCiGate.RunCheck` с флагами `-srGate*`) — в документации пакета:
+**Documentation → SerializeReference Selector → Настройки проекта и build/CI gate**.
+
+---
+
+## Где смотреть в коде
+
+| Файл | Что показывает |
+|---|---|
+| `Scripts/Tutorial/TypeSelectorTutorial.cs` | Все восемь уроков как пронумерованные поля |
+| `Scripts/Weapons/` | `IWeapon` + ветки `IMelee`/`IRanged` и `Sword`/`Pistol`/`Shotgun`/`Railgun`/`Crossbow` |
+| `Scripts/Effects/` | абстрактный `StatusEffect` + `BurnEffect`/`FreezeEffect` |
+| `Scripts/Modifiers/` | generic-иерархия `Modifier` |
+| `Scripts/WeaponSlot.cs` + `Scripts/SlottedLoadout.cs` | ссылки, вложенные в `[Serializable]`-контейнеры (урок 7, отдельно) |
+| `Scripts/WeaponPreset.cs` + `Presets/Broken*.asset` / `Presets/MovedWeaponPreset.asset` / `Presets/RenamedWeaponPreset.asset` | сценарий починки потерянного типа — ручной **Fix**, одно-кликовый **Smart Fix** и `[MovedFrom]`-**Migrate all** |
+| `Scripts/IMGUILoadout.cs` + `Scripts/Editor/IMGUILoadoutEditor.cs` | те же поля, принудительно через путь IMGUI (см. *Путь IMGUI* в [README_RU.md](README_RU.md)) |
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/TUTORIAL_RU.md.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/TUTORIAL_RU.md.meta
new file mode 100644
index 00000000..1f7d8ab4
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/TUTORIAL_RU.md.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: 6267c23143c5450b87f253de3a180630
+TextScriptImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests.meta
index affaf382..181ef549 100644
--- a/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests.meta
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests.meta
@@ -1,5 +1,5 @@
fileFormatVersion: 2
-guid: dacaf7afcfc54fc2ad76fb2533213aa4
+guid: 63a1ce046a89e405ba8f3843a964d66e
folderAsset: yes
DefaultImporter:
externalObjects: {}
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor.meta
index e517099d..420b4b99 100644
--- a/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor.meta
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor.meta
@@ -1,5 +1,5 @@
fileFormatVersion: 2
-guid: f2a422d094104109a79a6e91ff366e4b
+guid: 2a7e1769cbf4c4d35a3fccad05961fab
folderAsset: yes
DefaultImporter:
externalObjects: {}
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/Aspid.FastTools.Unity.Editor.SerializeReferences.Tests.asmdef b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/Aspid.FastTools.Unity.Editor.SerializeReferences.Tests.asmdef
index 2cf4ab89..4ebf0f84 100644
--- a/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/Aspid.FastTools.Unity.Editor.SerializeReferences.Tests.asmdef
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/Aspid.FastTools.Unity.Editor.SerializeReferences.Tests.asmdef
@@ -4,7 +4,10 @@
"references": [
"UnityEngine.TestRunner",
"UnityEditor.TestRunner",
- "Aspid.FastTools.Unity.Editor.SerializeReferences"
+ "Aspid.FastTools",
+ "Aspid.FastTools.Unity",
+ "Aspid.FastTools.Unity.Editor",
+ "Aspid.FastTools.Unity.Editor.SerializeReferences.Yaml"
],
"includePlatforms": [
"Editor"
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceAutoDropdownTests.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceAutoDropdownTests.cs
new file mode 100644
index 00000000..9dbef0cb
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceAutoDropdownTests.cs
@@ -0,0 +1,275 @@
+using System;
+using NUnit.Framework;
+using UnityEditor;
+using UnityEngine;
+using UnityEngine.UIElements;
+using UnityEditor.UIElements;
+using Object = UnityEngine.Object;
+
+namespace Aspid.FastTools.SerializeReferences.Editors.Tests
+{
+ ///
+ /// Behavioural coverage for the attribute-free dropdown opt-in ("Dropdown without [TypeSelector]"):
+ ///
+ /// the decision matrix — only a bare managed reference (or a
+ /// bare list of them) is substituted, only under the opt-in, and an attributed field always keeps its drawer —
+ /// at the top level and on the nested fields of an assigned instance alike;
+ /// the fallback inspector contract — it is the editor Unity selects for a plain component, it hands the
+ /// component back to the default inspector (null) whenever it has nothing to substitute, and when it does build,
+ /// exactly the bare reference fields come out as dropdown fields;
+ /// the public facade's shape validation;
+ /// the setting's persistence contract — Changed fires, and the per-user reset restores the default (off).
+ ///
+ ///
+ [TestFixture]
+ internal sealed class SerializeReferenceAutoDropdownTests
+ {
+ private bool _dropdownWithoutAttribute;
+ private bool _breakageDetection;
+
+ [SetUp]
+ public void SetUp()
+ {
+ // Snapshot every per-user setting the tests (and ResetUserToDefaults) touch, restoring on teardown.
+ _dropdownWithoutAttribute = SerializeReferenceSettings.DropdownWithoutAttributeEnabled;
+ _breakageDetection = SerializeReferenceSettings.BreakageDetectionEnabled;
+ }
+
+ [TearDown]
+ public void TearDown()
+ {
+ SerializeReferenceSettings.DropdownWithoutAttributeEnabled = _dropdownWithoutAttribute;
+ SerializeReferenceSettings.BreakageDetectionEnabled = _breakageDetection;
+ }
+
+ // -----------------------------------------------------------------------------------------------------
+ // A — the ShouldDraw decision matrix
+ // -----------------------------------------------------------------------------------------------------
+
+ [Test]
+ public void ShouldDraw_BareReference_FollowsTheOptIn()
+ {
+ WithTarget((_, serialized) =>
+ {
+ SerializeReferenceSettings.DropdownWithoutAttributeEnabled = false;
+ Assert.IsFalse(SerializeReferenceAutoDropdown.ShouldDraw(serialized.FindProperty("plain")),
+ "With the opt-in off, nothing is ever substituted.");
+
+ SerializeReferenceSettings.DropdownWithoutAttributeEnabled = true;
+ Assert.IsTrue(SerializeReferenceAutoDropdown.ShouldDraw(serialized.FindProperty("plain")),
+ "With the opt-in on, a bare managed reference gets the dropdown.");
+ });
+ }
+
+ [Test]
+ public void ShouldDraw_AttributedReference_False_TheAttributeAlwaysWins()
+ {
+ WithTarget((_, serialized) =>
+ {
+ SerializeReferenceSettings.DropdownWithoutAttributeEnabled = true;
+ Assert.IsFalse(SerializeReferenceAutoDropdown.ShouldDraw(serialized.FindProperty("marked")),
+ "A [TypeSelector] field keeps its drawer (and its base-type narrowing) — never substituted.");
+ });
+ }
+
+ [Test]
+ public void ShouldDraw_BareList_True_AndRecognisedAsManagedReferenceArray()
+ {
+ WithTarget((_, serialized) =>
+ {
+ SerializeReferenceSettings.DropdownWithoutAttributeEnabled = true;
+ var list = serialized.FindProperty("list");
+
+ Assert.IsTrue(SerializeReferenceAutoDropdown.IsManagedReferenceArray(list),
+ "A [SerializeReference] List must be recognised as a managed-reference array.");
+ Assert.IsTrue(SerializeReferenceAutoDropdown.ShouldDraw(list),
+ "A bare managed-reference list gets the dropdown list.");
+ });
+ }
+
+ [Test]
+ public void ShouldDraw_PlainValueField_False()
+ {
+ WithTarget((_, serialized) =>
+ {
+ SerializeReferenceSettings.DropdownWithoutAttributeEnabled = true;
+ Assert.IsFalse(SerializeReferenceAutoDropdown.ShouldDraw(serialized.FindProperty("number")),
+ "A plain value field is never substituted.");
+ });
+ }
+
+ [Test]
+ public void ShouldDraw_NestedChildren_FollowTheirOwnAttribute()
+ {
+ WithTarget((_, serialized) =>
+ {
+ // The nested fields live on the CONCRETE assigned type (TestWeaponHolder), not the declared
+ // interface — exactly what the runtime-instance attribute walk must cross.
+ serialized.FindProperty("plain").managedReferenceValue = new TestWeaponHolder();
+ serialized.ApplyModifiedProperties();
+ serialized.Update();
+
+ SerializeReferenceSettings.DropdownWithoutAttributeEnabled = true;
+
+ Assert.IsTrue(SerializeReferenceAutoDropdown.ShouldDraw(serialized.FindProperty("plain.inner")),
+ "A bare nested reference on the assigned instance gets the dropdown.");
+ Assert.IsFalse(SerializeReferenceAutoDropdown.ShouldDraw(serialized.FindProperty("plain.innerMarked")),
+ "An attributed nested reference keeps its drawer.");
+ });
+ }
+
+ // -----------------------------------------------------------------------------------------------------
+ // B — element-type resolution and the factory dispatch
+ // -----------------------------------------------------------------------------------------------------
+
+ [Test]
+ public void GetElementType_BareList_ReturnsTheDeclaredElementType()
+ {
+ WithTarget((_, serialized) =>
+ Assert.AreEqual(typeof(ITestWeapon),
+ SerializeReferenceAutoDropdown.GetElementType(serialized.FindProperty("list")),
+ "The add-picker constraint must be the list's declared element type, even while the list is empty."));
+ }
+
+ [Test]
+ public void CreateField_DispatchesOnTheShape()
+ {
+ WithTarget((_, serialized) =>
+ {
+ Assert.IsInstanceOf(
+ SerializeReferenceAutoDropdown.CreateField(serialized.FindProperty("plain")),
+ "A single managed reference builds the dropdown field.");
+ Assert.IsInstanceOf(
+ SerializeReferenceAutoDropdown.CreateField(serialized.FindProperty("list")),
+ "A managed-reference list builds the list field.");
+ });
+ }
+
+ // -----------------------------------------------------------------------------------------------------
+ // C — the fallback inspector contract
+ // -----------------------------------------------------------------------------------------------------
+
+ [Test]
+ public void FallbackInspector_IsTheSelectedEditor_ForAPlainScriptableObject()
+ {
+ WithTarget((target, _) => WithEditor(target, editor =>
+ Assert.IsInstanceOf(editor,
+ "With no custom editor declared, Unity must select the package's fallback inspector.")));
+ }
+
+ [Test]
+ public void FallbackInspector_ReturnsNull_WhenTheOptInIsOff()
+ {
+ WithTarget((target, _) => WithEditor(target, editor =>
+ {
+ SerializeReferenceSettings.DropdownWithoutAttributeEnabled = false;
+ Assert.IsNull(editor.CreateInspectorGUI(),
+ "With the opt-in off the fallback inspector must hand the component to Unity's default inspector.");
+ }));
+ }
+
+ [Test]
+ public void FallbackInspector_ReturnsNull_WithoutEligibleFields()
+ {
+ var target = ScriptableObject.CreateInstance();
+ try
+ {
+ WithEditor(target, editor =>
+ {
+ SerializeReferenceSettings.DropdownWithoutAttributeEnabled = true;
+ Assert.IsNull(editor.CreateInspectorGUI(),
+ "A component with no bare managed reference must be left to Unity's default inspector.");
+ });
+ }
+ finally { Object.DestroyImmediate(target); }
+ }
+
+ [Test]
+ public void FallbackInspector_SubstitutesExactlyTheBareReferences()
+ {
+ WithTarget((target, _) => WithEditor(target, editor =>
+ {
+ SerializeReferenceSettings.DropdownWithoutAttributeEnabled = true;
+
+ var root = editor.CreateInspectorGUI();
+ Assert.IsNotNull(root, "An eligible component must get the substituted inspector.");
+
+ Assert.AreEqual(1, root.Query().ToList().Count,
+ "Exactly the one bare reference ('plain') becomes a dropdown field — 'marked' keeps its drawer.");
+ Assert.AreEqual(1, root.Query().ToList().Count,
+ "Exactly the one bare list ('list') becomes a dropdown list.");
+
+ // m_Script, 'marked' and 'number' all stay plain PropertyFields (the drawer resolution stays Unity's).
+ Assert.AreEqual(3, root.Query().ToList().Count,
+ "Every non-substituted property must remain a plain PropertyField.");
+ }));
+ }
+
+ // -----------------------------------------------------------------------------------------------------
+ // D — the public custom-editor facade validates the property shape
+ // -----------------------------------------------------------------------------------------------------
+
+ [Test]
+ public void EditorGUIFacade_BuildsForTheRightShapes_AndThrowsForTheWrongOnes()
+ {
+ WithTarget((_, serialized) =>
+ {
+ Assert.IsInstanceOf(
+ SerializeReferenceEditorGUI.CreateField(serialized.FindProperty("plain")));
+ Assert.IsInstanceOf(
+ SerializeReferenceEditorGUI.CreateList(serialized.FindProperty("list")));
+
+ Assert.Throws(
+ () => SerializeReferenceEditorGUI.CreateField(serialized.FindProperty("number")),
+ "CreateField must reject a non-managed-reference property.");
+ Assert.Throws(
+ () => SerializeReferenceEditorGUI.CreateList(serialized.FindProperty("plain")),
+ "CreateList must reject a non-list property.");
+ });
+ }
+
+ // -----------------------------------------------------------------------------------------------------
+ // E — the setting's persistence contract
+ // -----------------------------------------------------------------------------------------------------
+
+ [Test]
+ public void SettingToggle_RaisesChanged()
+ {
+ var fired = 0;
+ void Handler() => fired++;
+ SerializeReferenceSettings.Changed += Handler;
+ try
+ {
+ SerializeReferenceSettings.DropdownWithoutAttributeEnabled =
+ !SerializeReferenceSettings.DropdownWithoutAttributeEnabled;
+ Assert.GreaterOrEqual(fired, 1, "The setter must raise Changed for repaint and live-sync.");
+ }
+ finally { SerializeReferenceSettings.Changed -= Handler; }
+ }
+
+ [Test]
+ public void ResetUserToDefaults_TurnsTheOptInOff()
+ {
+ SerializeReferenceSettings.DropdownWithoutAttributeEnabled = true;
+ SerializeReferenceSettings.ResetUserToDefaults();
+ Assert.IsFalse(SerializeReferenceSettings.DropdownWithoutAttributeEnabled,
+ "The attribute-free dropdown is a deliberate opt-in — its default (and reset value) is off.");
+ }
+
+ // -----------------------------------------------------------------------------------------------------
+
+ private static void WithTarget(Action assert)
+ {
+ var target = ScriptableObject.CreateInstance();
+ try { assert(target, new SerializedObject(target)); }
+ finally { Object.DestroyImmediate(target); }
+ }
+
+ private static void WithEditor(Object target, Action assert)
+ {
+ var editor = Editor.CreateEditor(target);
+ try { assert(editor); }
+ finally { Object.DestroyImmediate(editor); }
+ }
+ }
+}
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceAutoDropdownTests.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceAutoDropdownTests.cs.meta
new file mode 100644
index 00000000..757052d7
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceAutoDropdownTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 84c86a8318cde4252a88c0be0ae2d713
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceCiGateTests.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceCiGateTests.cs
new file mode 100644
index 00000000..2c1eafcc
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceCiGateTests.cs
@@ -0,0 +1,55 @@
+using NUnit.Framework;
+using Aspid.FastTools.SerializeReferences.Editors;
+
+namespace Aspid.FastTools.SerializeReferences.Editors.Tests
+{
+ ///
+ /// Pure decision-logic coverage for the headless CI gate: that the committed gate severity decides the exit code
+ /// and that the CLI flags override it as documented (ASP-21). No
+ /// is invoked — only the extracted and
+ /// helpers are exercised.
+ ///
+ [TestFixture]
+ internal sealed class SerializeReferenceCiGateTests
+ {
+ // No violations: never fail, whatever the severity.
+ [TestCase(GateSeverity.Off)]
+ [TestCase(GateSeverity.Warn)]
+ [TestCase(GateSeverity.Fail)]
+ public void ComputeExitCode_NoViolations_IsZero(GateSeverity severity) =>
+ Assert.AreEqual(0, SerializeReferenceCiGate.ComputeExitCode(0, severity));
+
+ // Violations present: only Fail turns into a non-zero exit code.
+ [TestCase(GateSeverity.Off, 0)]
+ [TestCase(GateSeverity.Warn, 0)]
+ [TestCase(GateSeverity.Fail, 1)]
+ public void ComputeExitCode_WithViolations_MatchesSeverity(GateSeverity severity, int expected) =>
+ Assert.AreEqual(expected, SerializeReferenceCiGate.ComputeExitCode(3, severity));
+
+ // No flags: the committed Project Settings value passes straight through.
+ [TestCase(GateSeverity.Off)]
+ [TestCase(GateSeverity.Warn)]
+ [TestCase(GateSeverity.Fail)]
+ public void ResolveSeverity_NoFlags_UsesCommittedValue(GateSeverity committed) =>
+ Assert.AreEqual(committed, SerializeReferenceCiGate.ResolveSeverity(committed, warnOnly: false, failOverride: false));
+
+ // -srGateWarnOnly forces Warn regardless of the committed value.
+ [TestCase(GateSeverity.Off)]
+ [TestCase(GateSeverity.Warn)]
+ [TestCase(GateSeverity.Fail)]
+ public void ResolveSeverity_WarnOnly_ForcesWarn(GateSeverity committed) =>
+ Assert.AreEqual(GateSeverity.Warn, SerializeReferenceCiGate.ResolveSeverity(committed, warnOnly: true, failOverride: false));
+
+ // -srGateFail forces Fail regardless of the committed value.
+ [TestCase(GateSeverity.Off)]
+ [TestCase(GateSeverity.Warn)]
+ [TestCase(GateSeverity.Fail)]
+ public void ResolveSeverity_FailOverride_ForcesFail(GateSeverity committed) =>
+ Assert.AreEqual(GateSeverity.Fail, SerializeReferenceCiGate.ResolveSeverity(committed, warnOnly: false, failOverride: true));
+
+ // Both flags together: warn-only wins (the safe choice — never fail unexpectedly).
+ [Test]
+ public void ResolveSeverity_BothFlags_WarnOnlyWins() =>
+ Assert.AreEqual(GateSeverity.Warn, SerializeReferenceCiGate.ResolveSeverity(GateSeverity.Fail, warnOnly: true, failOverride: true));
+ }
+}
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceCiGateTests.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceCiGateTests.cs.meta
new file mode 100644
index 00000000..533a470e
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceCiGateTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 1acbc9f7a4534e4a995a76aac814268c
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceCloneTests.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceCloneTests.cs
new file mode 100644
index 00000000..e4258be8
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceCloneTests.cs
@@ -0,0 +1,119 @@
+using System;
+using UnityEngine;
+using NUnit.Framework;
+using System.Collections.Generic;
+
+namespace Aspid.FastTools.SerializeReferences.Editors.Tests
+{
+ ///
+ /// Coverage for the two data-preservation copiers behind type switches and Make-unique:
+ ///
+ /// carries nested
+ /// [SerializeReference] children ACROSS a type switch by reference — JsonUtility alone drops them,
+ /// which silently reset every nested reference before this coverage existed;
+ /// deep-copies for Make-unique /
+ /// de-alias: children become independent, internal aliasing topology survives, cycles terminate.
+ ///
+ ///
+ [TestFixture]
+ internal sealed class SerializeReferenceCloneTests
+ {
+ private interface IPart { }
+
+ [Serializable]
+ private sealed class Gem : IPart
+ {
+ public int power;
+ }
+
+ [Serializable]
+ private sealed class Weapon : IPart
+ {
+ public int damage;
+ [SerializeReference] public IPart gem;
+ [SerializeReference] public List mods = new();
+ [SerializeReference] public IPart[] attachments = Array.Empty();
+ }
+
+ [Serializable]
+ private sealed class OtherWeapon : IPart
+ {
+ public int damage;
+ [SerializeReference] public IPart gem;
+ }
+
+ [Serializable]
+ private sealed class Link : IPart
+ {
+ [SerializeReference] public IPart next;
+ }
+
+ [Test]
+ public void CreateInstancePreservingData_TypeSwitch_CarriesNestedReferencesByIdentity()
+ {
+ var previous = new Weapon { damage = 7, gem = new Gem { power = 3 } };
+
+ var switched = (OtherWeapon)SerializeReferenceHelpers.CreateInstancePreservingData(
+ typeof(OtherWeapon), previous);
+
+ Assert.AreEqual(7, switched.damage, "Plain shared fields must keep riding the JSON round-trip.");
+ Assert.AreSame(previous.gem, switched.gem,
+ "A nested [SerializeReference] shared by name must carry over as the same instance — the old " +
+ "parent is discarded, so reuse is what preserves the child (JsonUtility alone drops it).");
+ }
+
+ [Test]
+ public void CloneManagedReferenceGraph_MakesNestedChildrenIndependent()
+ {
+ var source = new Weapon
+ {
+ damage = 5,
+ gem = new Gem { power = 9 },
+ mods = new List { new Gem { power = 1 } },
+ attachments = new IPart[] { new Gem { power = 2 } },
+ };
+
+ var clone = (Weapon)SerializeReferenceHelpers.CloneManagedReferenceGraph(source);
+
+ Assert.AreEqual(5, clone.damage);
+ Assert.AreNotSame(source.gem, clone.gem, "Make-unique promises an independent nested instance.");
+ Assert.AreEqual(9, ((Gem)clone.gem).power, "The independent copy must keep the child's data.");
+
+ Assert.AreNotSame(source.mods, clone.mods, "A list of references must be rebuilt, never shared.");
+ Assert.AreNotSame(source.mods[0], clone.mods[0]);
+ Assert.AreEqual(1, ((Gem)clone.mods[0]).power);
+
+ Assert.AreNotSame(source.attachments, clone.attachments, "An array of references must be rebuilt too.");
+ Assert.AreNotSame(source.attachments[0], clone.attachments[0]);
+ Assert.AreEqual(2, ((Gem)clone.attachments[0]).power);
+ }
+
+ [Test]
+ public void CloneManagedReferenceGraph_PreservesInternalAliasing()
+ {
+ var shared = new Gem { power = 4 };
+ var source = new Weapon { gem = shared, mods = new List { shared } };
+
+ var clone = (Weapon)SerializeReferenceHelpers.CloneManagedReferenceGraph(source);
+
+ Assert.AreNotSame(shared, clone.gem, "The shared child itself must still be copied.");
+ Assert.AreSame(clone.gem, clone.mods[0],
+ "Two fields aliasing one nested instance must alias one copy — internal topology is data.");
+ }
+
+ [Test]
+ public void CloneManagedReferenceGraph_TerminatesOnCycles()
+ {
+ var first = new Link();
+ var second = new Link { next = first };
+ first.next = second;
+
+ var clone = (Link)SerializeReferenceHelpers.CloneManagedReferenceGraph(first);
+
+ Assert.AreNotSame(first, clone);
+ Assert.AreNotSame(second, clone.next);
+ Assert.AreSame(clone, ((Link)clone.next).next,
+ "A cyclic graph must clone into its own cycle instead of recursing forever.");
+ }
+ }
+}
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceCloneTests.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceCloneTests.cs.meta
new file mode 100644
index 00000000..265da409
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceCloneTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 02703da7cda743d1b5ae3a5ec62391df
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceFieldNoticeLayoutTests.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceFieldNoticeLayoutTests.cs
new file mode 100644
index 00000000..a982af95
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceFieldNoticeLayoutTests.cs
@@ -0,0 +1,117 @@
+using System.Collections.Generic;
+using NUnit.Framework;
+using UnityEditor;
+using UnityEngine;
+using UnityEngine.UIElements;
+
+namespace Aspid.FastTools.SerializeReferences.Editors.Tests
+{
+ ///
+ /// Guards the cross-drawer notice-position contract (ASP-24): the UIToolkit
+ /// must render the shared-reference notice BELOW the assigned instance's child fields, exactly like the IMGUI
+ /// drawer, which draws that notice after the children. A shared reference is the only notice that coexists with
+ /// children (missing / required / mixed render no value, so no children), so it sits at the very bottom of the
+ /// field. The notice host is a sibling placed after the foldout content, so it never inherits the child indent.
+ ///
+ ///
+ /// Reuses LinkerTestObject / TestSword from : linking two
+ /// managed-reference fields onto one rid surfaces the shared-reference notice on a field that also has a value (so it
+ /// renders child fields), which is the only notice that co-exists with children — the case the bug was about.
+ ///
+ [TestFixture]
+ internal sealed class SerializeReferenceFieldNoticeLayoutTests
+ {
+ [Test]
+ public void SharedNotice_RendersBelowChildFields()
+ {
+ var obj = ScriptableObject.CreateInstance();
+ try
+ {
+ var serialized = new SerializedObject(obj);
+ serialized.FindProperty("a").managedReferenceValue = new TestSword { damage = 7 };
+ serialized.ApplyModifiedProperties();
+
+ // Put both fields on one shared rid so field 'a' shows the shared-reference notice while still holding a
+ // value (and therefore rendering the TestSword.damage child field).
+ Assert.IsTrue(SerializeReferenceLinker.LinkTo(serialized.FindProperty("b"), "a"));
+ serialized.Update();
+
+ var property = serialized.FindProperty("a");
+ property.isExpanded = true;
+
+ var field = new SerializeReferenceField("A", property);
+
+ var notice = FindFirst(field);
+ Assert.IsNotNull(notice, "A shared reference must surface a notice in the UIToolkit field.");
+
+ var content = FindFirstWithClass(field, Foldout.contentUssClassName);
+ Assert.IsNotNull(content, "The foldout content container (which hosts the child fields) must exist.");
+
+ // The notice must NOT live inside the content container — it is a sibling that follows it, so it never
+ // inherits the child indent and always renders below the children.
+ Assert.IsFalse(IsAncestorOf(content, notice),
+ "The notice must not be nested inside the foldout content (the children container).");
+
+ Assert.Greater(
+ PreOrderIndex(field, notice),
+ PreOrderIndex(field, content),
+ "The notice must render below (after) the foldout content/children, matching the IMGUI drawer.");
+ }
+ finally
+ {
+ Object.DestroyImmediate(obj);
+ }
+ }
+
+ // Pre-order (document-order) position of an element in the real visual tree, so "renders below" reduces to a
+ // larger index. Walks the hierarchy (not the contentContainer view) so the toggle, the notices host and the
+ // content container are all visited as siblings of the foldout.
+ private static int PreOrderIndex(VisualElement root, VisualElement target)
+ {
+ var index = 0;
+ foreach (var element in DescendantsAndSelf(root))
+ {
+ if (element == target) return index;
+ index++;
+ }
+
+ return -1;
+ }
+
+ private static T FindFirst(VisualElement root) where T : VisualElement
+ {
+ foreach (var element in DescendantsAndSelf(root))
+ if (element is T match)
+ return match;
+
+ return null;
+ }
+
+ private static VisualElement FindFirstWithClass(VisualElement root, string className)
+ {
+ foreach (var element in DescendantsAndSelf(root))
+ if (element.ClassListContains(className))
+ return element;
+
+ return null;
+ }
+
+ private static bool IsAncestorOf(VisualElement ancestor, VisualElement node)
+ {
+ for (var current = node.hierarchy.parent; current is not null; current = current.hierarchy.parent)
+ if (current == ancestor)
+ return true;
+
+ return false;
+ }
+
+ private static IEnumerable DescendantsAndSelf(VisualElement root)
+ {
+ yield return root;
+
+ for (var i = 0; i < root.hierarchy.childCount; i++)
+ foreach (var descendant in DescendantsAndSelf(root.hierarchy[i]))
+ yield return descendant;
+ }
+ }
+}
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceFieldNoticeLayoutTests.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceFieldNoticeLayoutTests.cs.meta
new file mode 100644
index 00000000..f7c921d0
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceFieldNoticeLayoutTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 81d5faee01a145f1b06783e54fda3f98
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceGateScannerTests.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceGateScannerTests.cs
new file mode 100644
index 00000000..5cd8f57a
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceGateScannerTests.cs
@@ -0,0 +1,36 @@
+using NUnit.Framework;
+
+namespace Aspid.FastTools.SerializeReferences.Editors.Tests
+{
+ ///
+ /// Coverage for — the gate's pre-filter that keeps
+ /// properly declared renames from ever warning or failing a build / CI run.
+ ///
+ [TestFixture]
+ internal sealed class SerializeReferenceGateScannerTests
+ {
+ // A [MovedFrom]-claimed stale name is a pending migration, not a violation: Unity migrates the reference in
+ // memory at load, so the gate must accept it — a properly declared rename can never warn or fail a build /
+ // CI run. A scene path exercises the trust-the-claim branch (constraints are unrecoverable for scenes).
+ // (RenamedRanged is the shared [MovedFrom(..., "OldRenamedRanged")] fixture.)
+ [Test]
+ public void IsPendingMigration_MovedFromClaimedName_IsNotAViolation()
+ {
+ var stored = new ManagedTypeName(
+ typeof(RenamedRanged).Assembly.GetName().Name, typeof(RenamedRanged).Namespace, "OldRenamedRanged");
+ var entry = new MissingReferenceEntry(fileId: 1, rid: 100, stored);
+
+ Assert.IsTrue(SerializeReferenceGateScanner.IsPendingMigration("Assets/Fake.unity", entry));
+ }
+
+ [Test]
+ public void IsPendingMigration_UnknownName_StaysAViolation()
+ {
+ var stored = new ManagedTypeName(
+ typeof(RenamedRanged).Assembly.GetName().Name, typeof(RenamedRanged).Namespace, "GhostNeverExisted");
+ var entry = new MissingReferenceEntry(fileId: 1, rid: 100, stored);
+
+ Assert.IsFalse(SerializeReferenceGateScanner.IsPendingMigration("Assets/Fake.unity", entry));
+ }
+ }
+}
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceGateScannerTests.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceGateScannerTests.cs.meta
new file mode 100644
index 00000000..5454e327
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceGateScannerTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: bf9e611e982b4f678530cf7f7b3af003
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceGraphScannerTests.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceGraphScannerTests.cs
new file mode 100644
index 00000000..15173724
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceGraphScannerTests.cs
@@ -0,0 +1,153 @@
+using System.Linq;
+using NUnit.Framework;
+
+namespace Aspid.FastTools.SerializeReferences.Editors.Tests
+{
+ ///
+ /// Structural coverage for : the field-pointer roots, the nested
+ /// parent → child edges (and the relative field path each carries), and the unassigned (null-sentinel) slots the
+ /// scanner now surfaces as empty roots / edges. Drives the public Build against temp YAML files, so it
+ /// exercises the parser in isolation — no asset import, no SerializedObject. Assertions stay off the per-node
+ /// Resolves flag (which depends on whether the Samples assembly's types load in the test domain) and only
+ /// check graph structure, which is purely a function of the YAML.
+ ///
+ [TestFixture]
+ internal sealed class SerializeReferenceGraphScannerTests
+ {
+ private string _missingPath;
+ private string _emptyPath;
+ private string _allUnassignedPath;
+
+ // Every test only reads the fixtures, so the temp files are written once per fixture, not per test.
+ [OneTimeSetUp]
+ public void OneTimeSetUp()
+ {
+ _missingPath = YamlFixtures.WriteTemp(YamlFixtures.MissingTypePrefab);
+ _emptyPath = YamlFixtures.WriteTemp(YamlFixtures.EmptyFieldsPrefab);
+ _allUnassignedPath = YamlFixtures.WriteTemp(YamlFixtures.AllUnassignedAsset);
+ }
+
+ [OneTimeTearDown]
+ public void OneTimeTearDown()
+ {
+ YamlFixtures.Delete(_missingPath);
+ YamlFixtures.Delete(_emptyPath);
+ YamlFixtures.Delete(_allUnassignedPath);
+ }
+
+ // The MonoBehaviour document is the one carrying the RefIds graph; the GameObject document has none and is
+ // dropped by the scanner, so Build returns exactly the MonoBehaviour graph.
+ private static ReferenceGraphDocument SingleDocument(string path)
+ {
+ var documents = SerializeReferenceGraphScanner.Build(path);
+ Assert.AreEqual(1, documents.Count, "Only the MonoBehaviour document carries managed references.");
+ return documents[0];
+ }
+
+ // --- Issue 1: nested references carry their full field path -------------------------------------------------
+
+ [Test]
+ public void Build_NestedReference_EdgeCarriesRelativeFieldPath()
+ {
+ var document = SingleDocument(_missingPath);
+
+ // Railgun (_primaryWeapon) owns BurnEffect through its _chargeEffect field. The edge records the path
+ // relative to Railgun's data block — "_chargeEffect" — which the view joins onto "_primaryWeapon" to show
+ // the full "_primaryWeapon._chargeEffect" path on the nested card.
+ var children = document.ChildrenOf(YamlFixtures.RailgunRid);
+ Assert.AreEqual(1, children.Count);
+ Assert.AreEqual(YamlFixtures.BurnEffectRid, children[0].Rid);
+ Assert.AreEqual("_chargeEffect", children[0].Label);
+ Assert.IsFalse(children[0].IsEmpty);
+ }
+
+ [Test]
+ public void Build_Roots_CarryIndexedFieldPaths()
+ {
+ var document = SingleDocument(_missingPath);
+
+ Assert.AreEqual("_primaryWeapon", LabelOfRoot(document, YamlFixtures.RailgunRid));
+ Assert.AreEqual("_sidearms[0]", LabelOfRoot(document, YamlFixtures.GhostPistolRid));
+ Assert.AreEqual("_sidearms[1]", LabelOfRoot(document, YamlFixtures.ShotgunRid));
+ Assert.AreEqual("_onHitEffect", LabelOfRoot(document, YamlFixtures.FreezeEffectRid));
+ }
+
+ [Test]
+ public void Build_AllAssigned_HasNoEmptySlots()
+ {
+ var document = SingleDocument(_missingPath);
+
+ Assert.IsFalse(document.Roots.Any(root => root.IsEmpty), "Every field in the fixture is assigned.");
+ Assert.IsFalse(document.Edges.Values.SelectMany(edges => edges).Any(edge => edge.IsEmpty));
+ }
+
+ // --- Issue 2: unassigned (null-sentinel) slots surface ------------------------------------------------------
+
+ [Test]
+ public void Build_ClearedTopLevelField_SurfacesAsEmptyRoot()
+ {
+ var document = SingleDocument(_emptyPath);
+
+ var emptyRoots = document.Roots.Where(root => root.IsEmpty).Select(root => root.Label).ToList();
+
+ // The cleared single field and the null list element both surface, each at its own field path.
+ CollectionAssert.AreEquivalent(new[] { "_onHitEffect", "_sidearms[1]" }, emptyRoots);
+ Assert.IsTrue(document.Roots.All(root => !root.IsEmpty || root.Rid < 0),
+ "An empty root must carry a null sentinel rid.");
+ }
+
+ [Test]
+ public void Build_ClearedNestedField_SurfacesAsEmptyEdge()
+ {
+ var document = SingleDocument(_emptyPath);
+
+ // Railgun's _chargeEffect was cleared: the edge is kept (so the empty slot renders nested) but points at the
+ // null sentinel, so the view draws an "" leaf and never recurses.
+ var children = document.ChildrenOf(YamlFixtures.EmptyRailgunRid);
+ Assert.AreEqual(1, children.Count);
+ Assert.IsTrue(children[0].IsEmpty);
+ Assert.AreEqual("_chargeEffect", children[0].Label);
+ }
+
+ [Test]
+ public void Build_NullSentinels_ExcludedFromSharedAndOrphans()
+ {
+ var document = SingleDocument(_emptyPath);
+
+ // Two empty roots and one empty edge all point at the same -2 sentinel — it must never be flagged shared,
+ // and the assigned references are all reachable, so there are no orphans either.
+ Assert.IsEmpty(document.Shared, "The shared -2 null sentinel must not count as an aliased reference.");
+ Assert.IsEmpty(document.Orphans, "Every assigned reference is reachable from a root.");
+ }
+
+ // --- An asset whose every managed-ref field is unassigned must still surface (regression) ------------------
+
+ [Test]
+ public void Build_AllFieldsUnassigned_KeepsDocumentWithEmptyRoot()
+ {
+ // The whole RefIds block is just Unity's null sentinel, so the document carries zero real nodes — yet its
+ // single unassigned field is a slot worth showing, so the scanner must keep the document (with one empty
+ // root) rather than dropping it as "no managed references". Regression: a Nodes.Count == 0 early-return
+ // used to drop these assets entirely so the Inspect Asset view showed its empty state.
+ var documents = SerializeReferenceGraphScanner.Build(_allUnassignedPath);
+ Assert.AreEqual(1, documents.Count, "An all-unassigned asset still has a slot to surface.");
+
+ var document = documents[0];
+ Assert.IsEmpty(document.Nodes, "The RefIds block holds only the null sentinel — no real nodes.");
+
+ var emptyRoots = document.Roots.Where(root => root.IsEmpty).Select(root => root.Label).ToList();
+ CollectionAssert.AreEquivalent(new[] { "_weapon" }, emptyRoots);
+
+ Assert.IsEmpty(document.Shared, "The shared -2 sentinel must not count as an aliased reference.");
+ Assert.IsEmpty(document.Orphans, "There are no real nodes, so nothing can be orphaned.");
+ }
+
+ private static string LabelOfRoot(ReferenceGraphDocument document, long rid)
+ {
+ foreach (var root in document.Roots)
+ if (root.Rid == rid) return root.Label;
+
+ return null;
+ }
+ }
+}
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceGraphScannerTests.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceGraphScannerTests.cs.meta
new file mode 100644
index 00000000..47b094ca
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceGraphScannerTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 25370c1a526a4cc1bd184146b0e2be20
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceInspectorTests.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceInspectorTests.cs
new file mode 100644
index 00000000..b95535fb
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceInspectorTests.cs
@@ -0,0 +1,78 @@
+using NUnit.Framework;
+using UnityEditor;
+using UnityEngine;
+
+namespace Aspid.FastTools.SerializeReferences.Editors.Tests
+{
+ ///
+ /// EditMode integration coverage that needs a live : the rid-sharing contract of
+ /// (a single managedReferenceId across two fields) and the
+ /// violation logic for both the managed-reference and string shapes.
+ ///
+ [TestFixture]
+ internal sealed class SerializeReferenceInspectorTests
+ {
+ [Test]
+ public void LinkTo_SharesTheSameRid()
+ {
+ var obj = ScriptableObject.CreateInstance();
+ try
+ {
+ var serialized = new SerializedObject(obj);
+ serialized.FindProperty("a").managedReferenceValue = new TestSword { damage = 7 };
+ serialized.ApplyModifiedProperties();
+
+ Assert.IsTrue(SerializeReferenceLinker.LinkTo(serialized.FindProperty("b"), "a"));
+ serialized.Update();
+
+ var ridA = serialized.FindProperty("a").managedReferenceId;
+ var ridB = serialized.FindProperty("b").managedReferenceId;
+
+ Assert.AreNotEqual(-2L, ridA, "Field 'a' should hold a real managed reference.");
+ Assert.AreEqual(ridA, ridB, "Link to Existing must put both fields on a single shared managedReferenceId.");
+ }
+ finally { UnityEngine.Object.DestroyImmediate(obj); }
+ }
+
+ [Test]
+ public void IsViolation_RequiredManagedRef_TrueWhenEmpty_FalseWhenSet()
+ {
+ var obj = ScriptableObject.CreateInstance();
+ try
+ {
+ var serialized = new SerializedObject(obj);
+ Assert.IsTrue(SerializeReferenceRequiredGate.IsViolation(serialized.FindProperty("requiredRef")),
+ "An empty required managed reference is a violation.");
+
+ var prop = serialized.FindProperty("requiredRef");
+ prop.managedReferenceValue = new TestSword();
+ serialized.ApplyModifiedProperties();
+ serialized.Update();
+
+ Assert.IsFalse(SerializeReferenceRequiredGate.IsViolation(serialized.FindProperty("requiredRef")),
+ "A set required managed reference is not a violation.");
+ }
+ finally { UnityEngine.Object.DestroyImmediate(obj); }
+ }
+
+ [Test]
+ public void IsViolation_RequiredString_TrueWhenEmpty_FalseWhenSet()
+ {
+ var obj = ScriptableObject.CreateInstance();
+ try
+ {
+ var serialized = new SerializedObject(obj);
+ Assert.IsTrue(SerializeReferenceRequiredGate.IsViolation(serialized.FindProperty("requiredString")),
+ "An empty required string type field is a violation.");
+
+ serialized.FindProperty("requiredString").stringValue = "Some.Namespace.SomeType, Some.Assembly";
+ serialized.ApplyModifiedProperties();
+ serialized.Update();
+
+ Assert.IsFalse(SerializeReferenceRequiredGate.IsViolation(serialized.FindProperty("requiredString")),
+ "A populated required string type field is not a violation.");
+ }
+ finally { UnityEngine.Object.DestroyImmediate(obj); }
+ }
+ }
+}
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceInspectorTests.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceInspectorTests.cs.meta
new file mode 100644
index 00000000..b3e88f2b
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceInspectorTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 242d62dc34f04451b43546fe0cc949ec
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceManagedTypeNameTests.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceManagedTypeNameTests.cs
new file mode 100644
index 00000000..0e9f6dfa
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceManagedTypeNameTests.cs
@@ -0,0 +1,85 @@
+using System.Collections.Generic;
+using NUnit.Framework;
+
+namespace Aspid.FastTools.SerializeReferences.Editors.Tests
+{
+ // Top-level (namespace-scoped) types so the nested-identity test sees exactly "NestingOuter/NestingInner" rather than
+ // the test-fixture class prepended to the chain.
+ internal sealed class NestingOuter
+ {
+ internal sealed class NestingInner { }
+ }
+
+ // Top-level generic types so the open/closed identity test keys on "Modifier`1"/"Pair`2" rather than a nested name.
+ internal sealed class GenericModifier { }
+
+ internal sealed class GenericPair { }
+
+ ///
+ /// Coverage for — the YAML type-identity builder used by every repair write. Pins the
+ /// closed-generic Name`N[[arg, asm]] shape and the single-quote escaping Unity requires for class identities
+ /// containing reserved characters, the write-side half of the generic round-trip (risk register #1).
+ ///
+ [TestFixture]
+ internal sealed class SerializeReferenceManagedTypeNameTests
+ {
+ [Test]
+ public void FromType_ClosedGeneric_BuildsBacktickName_AndQuotesYaml()
+ {
+ var name = ManagedTypeName.FromType(typeof(List));
+
+ StringAssert.Contains("List`1[[", name.Class, "A closed generic must use Unity's Name`N[[arg]] class shape.");
+ StringAssert.Contains("System.Single", name.Class);
+ StringAssert.Contains("{class: '", name.ToYamlType(),
+ "A class identity with reserved chars ([ ] , { }) must be single-quoted in the inline mapping.");
+ }
+
+ [Test]
+ public void ToYamlType_NonGeneric_IsNotQuoted()
+ {
+ var name = new ManagedTypeName("Asm", "Ns", "Pistol");
+ Assert.AreEqual("{class: Pistol, ns: Ns, asm: Asm}", name.ToYamlType());
+ }
+
+ [Test]
+ public void FromType_Null_IsEmpty()
+ {
+ Assert.IsTrue(ManagedTypeName.FromType(null).IsEmpty);
+ }
+
+ [Test]
+ public void FromType_NestedType_JoinsDeclaringChainWithSlash()
+ {
+ // Unity stores nested managed-reference types as "Outer/Inner"; reflection's Type.Name is only the leaf, so
+ // FromType must rebuild the declaring-type prefix or a repair to a nested type writes an unresolvable class.
+ var name = ManagedTypeName.FromType(typeof(NestingOuter.NestingInner));
+ Assert.AreEqual("NestingOuter/NestingInner", name.Class);
+ }
+
+ [Test]
+ public void OpenTypeKey_OpenAndClosedGeneric_CollapseToSameKey()
+ {
+ // The delete guard reads a generic script's OPEN definition (Modifier`1[[T]]), while YAML stores each CLOSED
+ // instantiation (Modifier`1[[System.Single, …]]) — they must reduce to the same open-generic key, or deleting
+ // a generic [SerializeReference] type's script never warns (the closed StoredTypeKey would never match).
+ var open = SerializeReferenceHelpers.OpenTypeKey(ManagedTypeName.FromType(typeof(GenericModifier<>)));
+ var closedFloat = SerializeReferenceHelpers.OpenTypeKey(ManagedTypeName.FromType(typeof(GenericModifier)));
+ var closedInt = SerializeReferenceHelpers.OpenTypeKey(ManagedTypeName.FromType(typeof(GenericModifier)));
+
+ Assert.AreEqual(open, closedFloat, "The open definition and a closed instantiation must share one open-generic key.");
+ Assert.AreEqual(open, closedInt, "Every closed instantiation of the same definition must share one open-generic key.");
+ StringAssert.Contains("GenericModifier`1", open, "The open key keeps the backtick arity and drops the [[…]] argument expansion.");
+ StringAssert.DoesNotContain("[", open, "The open key must drop the bracketed closed-argument expansion.");
+ }
+
+ [Test]
+ public void OpenTypeKey_DifferentArity_DoNotCollapse()
+ {
+ // The backtick arity is retained, so a one-arg and a two-arg definition never share a key.
+ var arityOne = SerializeReferenceHelpers.OpenTypeKey(ManagedTypeName.FromType(typeof(GenericModifier<>)));
+ var arityTwo = SerializeReferenceHelpers.OpenTypeKey(ManagedTypeName.FromType(typeof(GenericPair<,>)));
+
+ Assert.AreNotEqual(arityOne, arityTwo);
+ }
+ }
+}
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceManagedTypeNameTests.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceManagedTypeNameTests.cs.meta
new file mode 100644
index 00000000..9a9949aa
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceManagedTypeNameTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 206158cc3d944bd68cbc912cf8c89ec1
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceMovedFromResolverTests.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceMovedFromResolverTests.cs
new file mode 100644
index 00000000..5edad75a
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceMovedFromResolverTests.cs
@@ -0,0 +1,98 @@
+using System;
+using NUnit.Framework;
+using UnityEngine;
+using UnityEngine.Scripting.APIUpdating;
+
+namespace Aspid.FastTools.SerializeReferences.Editors.Tests
+{
+ // A namespace move recorded by [MovedFrom]: the stored identity keeps the class name, only the namespace differs.
+ [Serializable]
+ [MovedFrom(true, sourceNamespace: "Aspid.FastTools.SerializeReferences.Editors.Tests.Legacy")]
+ internal sealed class MovedNamespacePistol
+ {
+ [SerializeField] private int _damage;
+ }
+
+ // Two types claiming the same recorded old class name: neither claim is authoritative.
+ [Serializable]
+ [MovedFrom(false, null, null, "AmbiguousOldSword")]
+ internal sealed class AmbiguousNewSwordA { }
+
+ [Serializable]
+ [MovedFrom(false, null, null, "AmbiguousOldSword")]
+ internal sealed class AmbiguousNewSwordB { }
+
+ ///
+ /// Coverage for — the authoritative-rename resolver behind the
+ /// migration classification (breakage report entries and the Project References bulk Migrate all):
+ /// a recorded namespace move and a recorded class rename each resolve to their single declaring type, while an
+ /// ambiguous claim, an unknown identity or a mismatched assembly resolve to nothing.
+ ///
+ [TestFixture]
+ internal sealed class SerializeReferenceMovedFromResolverTests
+ {
+ private static string Assembly => typeof(MovedNamespacePistol).Assembly.GetName().Name;
+
+ private static string Namespace => typeof(MovedNamespacePistol).Namespace;
+
+ [Test]
+ public void TryResolve_RecordedNamespaceMove_FindsTheSingleTarget()
+ {
+ var stored = new ManagedTypeName(Assembly, Namespace + ".Legacy", nameof(MovedNamespacePistol));
+
+ Assert.IsTrue(SerializeReferenceMovedFromResolver.TryResolve(stored, out var target),
+ "A stored identity matching a recorded [MovedFrom] namespace move must resolve.");
+ Assert.AreEqual(typeof(MovedNamespacePistol), target);
+ }
+
+ [Test]
+ public void TryResolve_RecordedClassRename_FindsTheSingleTarget()
+ {
+ // Reuses the RenamedRanged fixture declared for the ranking tests: [MovedFrom(..., "OldRenamedRanged")].
+ var stored = new ManagedTypeName(Assembly, Namespace, "OldRenamedRanged");
+
+ Assert.IsTrue(SerializeReferenceMovedFromResolver.TryResolve(stored, out var target),
+ "A stored identity matching a recorded [MovedFrom] class rename must resolve.");
+ Assert.AreEqual(typeof(RenamedRanged), target);
+ }
+
+ [Test]
+ public void TryResolve_AmbiguousClaims_RefusesToPick()
+ {
+ var stored = new ManagedTypeName(Assembly, Namespace, "AmbiguousOldSword");
+
+ Assert.IsFalse(SerializeReferenceMovedFromResolver.TryResolve(stored, out var target),
+ "Two types claiming the same old identity make the rename non-authoritative.");
+ Assert.IsNull(target);
+ }
+
+ [Test]
+ public void TryResolve_UnknownIdentity_ReturnsFalse()
+ {
+ var stored = new ManagedTypeName(Assembly, Namespace, "NoSuchOldTypeAnywhere");
+
+ Assert.IsFalse(SerializeReferenceMovedFromResolver.TryResolve(stored, out _));
+ }
+
+ [Test]
+ public void TryResolve_ClosedGenericIdentity_NeverMigrates()
+ {
+ // TypeCache yields definitions and the eligibility filter excludes generic parameters, so the only
+ // possible claimant for a stored closed-generic identity is an arity-stripped name collision — a guess
+ // the resolver must leave to the scored Smart Fix path.
+ var stored = new ManagedTypeName(Assembly, Namespace, "OldRenamedRanged`1[[System.Single, mscorlib]]");
+
+ Assert.IsFalse(SerializeReferenceMovedFromResolver.TryResolve(stored, out _));
+ }
+
+ [Test]
+ public void TryResolve_AssemblyMismatch_ReturnsFalse()
+ {
+ // The fixture's [MovedFrom] records no assembly move, so the old assembly is the declaring one — a stored
+ // identity from a different assembly must not match.
+ var stored = new ManagedTypeName("Some.Other.Assembly", Namespace, "OldRenamedRanged");
+
+ Assert.IsFalse(SerializeReferenceMovedFromResolver.TryResolve(stored, out _));
+ }
+ }
+}
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceMovedFromResolverTests.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceMovedFromResolverTests.cs.meta
new file mode 100644
index 00000000..31743f3e
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceMovedFromResolverTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: e68b248b3d4742bb8dcad1f5b2b3e120
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferencePendingAssignmentTests.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferencePendingAssignmentTests.cs
new file mode 100644
index 00000000..fcdc5fa4
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferencePendingAssignmentTests.cs
@@ -0,0 +1,242 @@
+using System.Collections.Generic;
+using NUnit.Framework;
+using UnityEditor;
+using UnityEngine;
+using Entry = Aspid.FastTools.SerializeReferences.Editors.SerializeReferencePendingAssignment.Entry;
+using Store = Aspid.FastTools.SerializeReferences.Editors.SerializeReferencePendingAssignment;
+
+namespace Aspid.FastTools.SerializeReferences.Editors.Tests
+{
+ ///
+ /// Coverage for the persistence contract of — the deferred
+ /// "Create new script" assignment that must survive every domain reload until the new type compiles. The bug being
+ /// pinned here is the old up-front erase that dropped any entry unresolvable in the first post-reload pass; these
+ /// tests fix the wire format (so re-persisted entries round-trip), the legacy back-compat decode, malformed-line
+ /// rejection, the supersede-on-re-pick merge, and the cross-reload give-up boundary.
+ ///
+ [TestFixture]
+ internal sealed class SerializeReferencePendingAssignmentTests
+ {
+ private const string GlobalId = "GlobalObjectId_V1-2-0000000000000000f000000000000000-12345678901234567-0";
+ private const string Path = "_weapons.Array.data[3]._primary";
+ private const string TypeName = "Game.Weapons.Pistol";
+
+ [Test]
+ public void Entry_RoundTrips_AllFieldsThroughEncodeDecode()
+ {
+ var entry = new Entry(GlobalId, Path, TypeName, attempts: 7);
+
+ Assert.IsTrue(Entry.TryDecode(entry.Encode(), out var decoded));
+ Assert.AreEqual(entry, decoded, "Encode/TryDecode must be a lossless round-trip including the attempt count.");
+ }
+
+ [Test]
+ public void Entry_GenericAqnTypeName_SurvivesRoundTrip()
+ {
+ // An assembly-qualified closed-generic name carries '[', ']', ',' and '.' — but never '|' or '\n', so the
+ // pipe/newline split must not corrupt it.
+ const string generic = "Game.Inventory.Slot`1[[Game.Weapons.Pistol, Game.Runtime]]";
+ var entry = new Entry(GlobalId, Path, generic, attempts: 0);
+
+ Assert.IsTrue(Entry.TryDecode(entry.Encode(), out var decoded));
+ Assert.AreEqual(generic, decoded.FullTypeName);
+ }
+
+ [Test]
+ public void TryDecode_LegacyThreeFieldLine_DecodesWithZeroAttempts()
+ {
+ // Entries written before retry tracking have no attempt field; they must decode (attempts = 0), not be dropped.
+ var legacy = $"{GlobalId}|{Path}|{TypeName}";
+
+ Assert.IsTrue(Entry.TryDecode(legacy, out var decoded));
+ Assert.AreEqual(0, decoded.Attempts);
+ Assert.AreEqual(TypeName, decoded.FullTypeName);
+ }
+
+ [Test]
+ public void TryDecode_NonPositiveOrGarbageAttempts_ClampToZero()
+ {
+ Assert.IsTrue(Entry.TryDecode($"{GlobalId}|{Path}|{TypeName}|-5", out var negative));
+ Assert.AreEqual(0, negative.Attempts);
+
+ Assert.IsTrue(Entry.TryDecode($"{GlobalId}|{Path}|{TypeName}|oops", out var garbage));
+ Assert.AreEqual(0, garbage.Attempts);
+ }
+
+ [Test]
+ public void TryDecode_MalformedLines_AreRejected()
+ {
+ Assert.IsFalse(Entry.TryDecode(null, out _), "null line");
+ Assert.IsFalse(Entry.TryDecode("", out _), "empty line");
+ Assert.IsFalse(Entry.TryDecode($"{GlobalId}|{Path}", out _), "fewer than three fields");
+ Assert.IsFalse(Entry.TryDecode($"|{Path}|{TypeName}", out _), "empty global id");
+ Assert.IsFalse(Entry.TryDecode($"{GlobalId}||{TypeName}", out _), "empty property path");
+ Assert.IsFalse(Entry.TryDecode($"{GlobalId}|{Path}|", out _), "empty type name");
+ }
+
+ [Test]
+ public void Decode_SkipsMalformedLines_KeepsValidOnes()
+ {
+ var raw = string.Join("\n",
+ $"{GlobalId}|{Path}|{TypeName}|1",
+ "garbage-without-separators",
+ $"{GlobalId}|{Path}|Game.Weapons.Rifle|0");
+
+ var entries = Store.Decode(raw);
+
+ Assert.AreEqual(2, entries.Count, "The malformed middle line must be skipped, not abort the whole decode.");
+ Assert.AreEqual(TypeName, entries[0].FullTypeName);
+ Assert.AreEqual("Game.Weapons.Rifle", entries[1].FullTypeName);
+ }
+
+ [Test]
+ public void Decode_EmptyOrNull_ReturnsEmptyList()
+ {
+ Assert.IsEmpty(Store.Decode(null));
+ Assert.IsEmpty(Store.Decode(string.Empty));
+ }
+
+ [Test]
+ public void EncodeDecode_MultipleEntries_PreserveOrderAndContent()
+ {
+ var original = new List
+ {
+ new(GlobalId, "_a", "Game.A", 0),
+ new(GlobalId, "_b", "Game.B", 4),
+ new(GlobalId, "_c", "Game.C", 31),
+ };
+
+ var roundTripped = Store.Decode(Store.Encode(original));
+
+ CollectionAssert.AreEqual(original, roundTripped, "A multi-entry queue must survive encode/decode in order.");
+ }
+
+ [Test]
+ public void WithIncrementedAttempt_BumpsCount_PreservesIdentity()
+ {
+ var entry = new Entry(GlobalId, Path, TypeName, attempts: 2);
+ var next = entry.WithIncrementedAttempt();
+
+ Assert.AreEqual(3, next.Attempts);
+ Assert.IsTrue(entry.SameTarget(next), "Incrementing the attempt must not change the entry's target.");
+ Assert.AreEqual(TypeName, next.FullTypeName);
+ }
+
+ [Test]
+ public void Merge_RePickSameField_SupersedesPreviousEntry()
+ {
+ var queue = new List { new(GlobalId, Path, "Game.OldPick", 5) };
+
+ Store.Merge(queue, new Entry(GlobalId, Path, "Game.NewPick", 0));
+
+ Assert.AreEqual(1, queue.Count, "Re-picking the same field must replace, not append, the pending assignment.");
+ Assert.AreEqual("Game.NewPick", queue[0].FullTypeName);
+ Assert.AreEqual(0, queue[0].Attempts, "The superseding pick starts its own attempt budget.");
+ }
+
+ [Test]
+ public void Merge_DifferentField_AppendsAndKeepsExisting()
+ {
+ var queue = new List { new(GlobalId, "_a", "Game.A", 1) };
+
+ Store.Merge(queue, new Entry(GlobalId, "_b", "Game.B", 0));
+
+ Assert.AreEqual(2, queue.Count);
+ Assert.AreEqual("Game.A", queue[0].FullTypeName, "An unrelated pending field must be untouched.");
+ Assert.AreEqual("Game.B", queue[1].FullTypeName);
+ }
+
+ [Test]
+ public void GiveUpBoundary_LastIncrementBeforeCapSurvives_NextOneIsAbandoned()
+ {
+ var oneShort = new Entry(GlobalId, Path, TypeName, Store.MaxResolveAttempts - 2).WithIncrementedAttempt();
+ Assert.Less(oneShort.Attempts, Store.MaxResolveAttempts,
+ "An entry one short of the cap must remain pending after the next reload.");
+
+ Assert.GreaterOrEqual(oneShort.WithIncrementedAttempt().Attempts, Store.MaxResolveAttempts,
+ "Reaching the attempt cap is the give-up boundary the resolver drops (with a warning) on.");
+ }
+ }
+
+ // A persistable target for the resolve-pass tests: saved as an asset so its GlobalObjectId round-trips back to the
+ // live object (an in-memory ScriptableObject's id does not). No fields are needed — an unresolved type short-circuits
+ // before the property is ever read.
+ internal sealed class PendingAssignmentProbeObject : ScriptableObject { }
+
+ ///
+ /// Integration coverage that drives against a real
+ /// . This is the assertion that actually pins ASP-29: an entry that cannot be applied this
+ /// pass must be re-persisted, not erased. It also pins the budget split — a not-yet-loaded target must not
+ /// spend the cross-reload give-up budget, while a loaded target whose type is unresolved spends exactly one attempt.
+ ///
+ [TestFixture]
+ internal sealed class SerializeReferencePendingAssignmentResolveTests
+ {
+ // A well-formed GlobalObjectId that parses but resolves to no live object (its owner is "not loaded").
+ private const string UnloadedGlobalId =
+ "GlobalObjectId_V1-2-0000000000000000f000000000000000-12345678901234567-0";
+ private const string UnresolvableType = "Aspid.FastTools.Tests.NoSuchPendingType";
+ private const string ProbeAssetPath = "Assets/__AspidPendingAssignmentProbe__.asset";
+
+ [SetUp]
+ public void SetUp() => SessionState.EraseString(Store.Key);
+
+ [TearDown]
+ public void TearDown() => SessionState.EraseString(Store.Key);
+
+ [Test]
+ public void ResolvePass_UnapplicableEntry_IsRePersisted_NotErased()
+ {
+ // The ASP-29 regression guard: the old code erased the key up front and dropped any entry it could not
+ // resolve in the first pass. A re-introduction of that bug must fail HERE, not slip past the wire-model tests.
+ Seed(new Entry(UnloadedGlobalId, "_field", UnresolvableType, attempts: 0));
+
+ var stillPending = Store.ResolvePass(countAttempt: true);
+
+ Assert.IsTrue(stillPending, "An entry that cannot be applied yet must keep the queue pending.");
+ var survivors = Store.Decode(SessionState.GetString(Store.Key, string.Empty));
+ Assert.AreEqual(1, survivors.Count, "The unapplied entry must be re-persisted, not erased (the ASP-29 bug).");
+ Assert.AreEqual(UnresolvableType, survivors[0].FullTypeName);
+ }
+
+ [Test]
+ public void ResolvePass_TargetNotLoaded_DoesNotSpendTheGiveUpBudget()
+ {
+ // A closed scene/asset can never be fixed by a domain reload, so its attempt counter must stay put — otherwise
+ // unrelated reloads burn the budget and silently drop a still-valid assignment.
+ Seed(new Entry(UnloadedGlobalId, "_field", UnresolvableType, attempts: 5));
+
+ Store.ResolvePass(countAttempt: true);
+
+ var survivors = Store.Decode(SessionState.GetString(Store.Key, string.Empty));
+ Assert.AreEqual(1, survivors.Count);
+ Assert.AreEqual(5, survivors[0].Attempts, "A not-yet-loaded target must not increment the give-up budget.");
+ }
+
+ [Test]
+ public void ResolvePass_LoadedTargetUnresolvedType_SpendsExactlyOneAttempt()
+ {
+ var probe = ScriptableObject.CreateInstance();
+ try
+ {
+ AssetDatabase.CreateAsset(probe, ProbeAssetPath);
+ var globalId = GlobalObjectId.GetGlobalObjectIdSlow(probe).ToString();
+ Seed(new Entry(globalId, "_field", UnresolvableType, attempts: 0));
+
+ Store.ResolvePass(countAttempt: true);
+
+ var survivors = Store.Decode(SessionState.GetString(Store.Key, string.Empty));
+ Assert.AreEqual(1, survivors.Count, "A resolvable target with an unresolved type stays pending.");
+ Assert.AreEqual(1, survivors[0].Attempts,
+ "A loaded target whose type has not compiled yet spends exactly one attempt per reload pass.");
+ }
+ finally
+ {
+ AssetDatabase.DeleteAsset(ProbeAssetPath);
+ }
+ }
+
+ private static void Seed(Entry entry) =>
+ SessionState.SetString(Store.Key, Store.Encode(new List { entry }));
+ }
+}
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferencePendingAssignmentTests.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferencePendingAssignmentTests.cs.meta
new file mode 100644
index 00000000..e595155d
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferencePendingAssignmentTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 918145ce11349475e8e26598f39fd9c2
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceRepairSuggestionTests.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceRepairSuggestionTests.cs
new file mode 100644
index 00000000..3219efae
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceRepairSuggestionTests.cs
@@ -0,0 +1,62 @@
+using System;
+using NUnit.Framework;
+
+namespace Aspid.FastTools.SerializeReferences.Editors.Tests
+{
+ ///
+ /// Coverage for — the ranking engine behind the
+ /// missing-type Smart Fix suggestion. Pins the three outcomes the bundled sample assets rely on: a
+ /// same-named type in another namespace surfaces with the full field-shape bonus (MovedWeaponPreset.asset), a
+ /// declared [MovedFrom] match takes the authoritative top score, and a Ghost-style identity with no
+ /// plausible successor surfaces nothing (BrokenWeaponPreset.asset intentionally shows no suggestion).
+ ///
+ [TestFixture]
+ internal sealed class SerializeReferenceRepairSuggestionTests
+ {
+ private static string Assembly => typeof(RelocatedRanged).Assembly.GetName().Name;
+
+ private static string Namespace => typeof(RelocatedRanged).Namespace;
+
+ [Test]
+ public void Rank_SameNameInOtherNamespace_SurfacesWithFieldShapeBonus()
+ {
+ var stored = new ManagedTypeName(Assembly, Namespace + ".Legacy", nameof(RelocatedRanged));
+
+ var ranked = SerializeReferenceRepairSuggestions.Rank(
+ stored, new[] { "_damage", "_magazineSize" }, typeof(IRepairRankTarget));
+
+ Assert.IsNotEmpty(ranked, "A same-named type in another namespace must surface as a Smart Fix candidate.");
+ Assert.AreEqual(typeof(RelocatedRanged), ranked[0].Type);
+ Assert.AreEqual("same type name", ranked[0].Reason);
+ Assert.AreEqual(1f, ranked[0].Score, 1e-4f,
+ "Full field-shape overlap must add the whole bonus on top of the same-name base score.");
+ }
+
+ [Test]
+ public void Rank_DeclaredMovedFrom_TakesTopScore()
+ {
+ var stored = new ManagedTypeName(Assembly, Namespace, "OldRenamedRanged");
+
+ var ranked = SerializeReferenceRepairSuggestions.Rank(
+ stored, Array.Empty(), typeof(IRepairRankTarget));
+
+ Assert.IsNotEmpty(ranked, "A candidate whose [MovedFrom] records the stored identity must surface.");
+ Assert.AreEqual(typeof(RenamedRanged), ranked[0].Type);
+ Assert.AreEqual("declared [MovedFrom]", ranked[0].Reason);
+ Assert.GreaterOrEqual(ranked[0].Score, 1f, "A declared rename is authoritative — the top base score.");
+ }
+
+ [Test]
+ public void Rank_NoPlausibleSuccessor_SurfacesNothing()
+ {
+ // Levenshtein distance from every candidate is far above the near-miss bound, and no [MovedFrom] records
+ // the identity — matching field names alone must never conjure a suggestion (bonus without base score).
+ var stored = new ManagedTypeName(Assembly, Namespace, "GhostRanged");
+
+ var ranked = SerializeReferenceRepairSuggestions.Rank(
+ stored, new[] { "_damage", "_magazineSize" }, typeof(IRepairRankTarget));
+
+ Assert.IsEmpty(ranked);
+ }
+ }
+}
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceRepairSuggestionTests.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceRepairSuggestionTests.cs.meta
new file mode 100644
index 00000000..0e9589c9
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceRepairSuggestionTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 11397fe40c884b7f92675099756e3674
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceRequiredYamlTests.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceRequiredYamlTests.cs
new file mode 100644
index 00000000..14d6fca7
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceRequiredYamlTests.cs
@@ -0,0 +1,127 @@
+using System;
+using System.Linq;
+using NUnit.Framework;
+using UnityEngine;
+using Aspid.FastTools.Types;
+using System.Collections.Generic;
+
+namespace Aspid.FastTools.SerializeReferences.Editors.Tests
+{
+ // A type mixing required, optional and plain fields, to prove GetRequiredFields returns only the required ones and
+ // classifies each by kind (string vs [SerializeReference] managed reference).
+ internal sealed class MixedRequiredObject : ScriptableObject
+ {
+ [SerializeReference, TypeSelector(Required = true)] public ITestWeapon requiredRef;
+ [TypeSelector(Required = true)] public string requiredString;
+ [SerializeReference, TypeSelector] public ITestWeapon optionalRef;
+ [TypeSelector] public string optionalString;
+ public int plain;
+ }
+
+ ///
+ /// Coverage for the scene-safe required-field gate:
+ /// (reflection over a type's required fields) and
+ /// (the pure-YAML scan that reads scene MonoBehaviours straight from the file). The YAML tests drive the public
+ /// method against temp .unity-shaped fixtures with an injected script→fields resolver, so the parser and the
+ /// violation logic are exercised in isolation — no asset import, no , no AssetDatabase.
+ ///
+ [TestFixture]
+ internal sealed class SerializeReferenceRequiredYamlTests
+ {
+ // Maps the fixtures' first script guid to RequiredTestObject's required fields; every other guid is unknown.
+ private static IReadOnlyList Resolve(string guid) =>
+ guid == YamlFixtures.RequiredSceneScriptGuid
+ ? SerializeReferenceRequiredGate.GetRequiredFields(typeof(RequiredTestObject))
+ : Array.Empty();
+
+ private string _path;
+
+ [TearDown]
+ public void TearDown() => YamlFixtures.Delete(_path);
+
+ [Test]
+ public void GetRequiredFields_ReturnsOnlyRequiredFields_ClassifiedByKind()
+ {
+ var byName = SerializeReferenceRequiredGate.GetRequiredFields(typeof(MixedRequiredObject))
+ .ToDictionary(field => field.FieldName, field => field.IsString);
+
+ Assert.AreEqual(2, byName.Count, "Only the two Required fields should be returned.");
+ Assert.IsTrue(byName.ContainsKey("requiredRef"), "The required managed reference should be included.");
+ Assert.IsTrue(byName.ContainsKey("requiredString"), "The required string field should be included.");
+ Assert.IsFalse(byName["requiredRef"], "A [SerializeReference] field is a managed reference, not a string.");
+ Assert.IsTrue(byName["requiredString"], "A string type field is classified as a string.");
+ }
+
+ [Test]
+ public void GetRequiredFields_NoRequiredFields_ReturnsEmpty()
+ {
+ // LinkerTestObject has [SerializeReference] fields but none mark Required.
+ Assert.AreEqual(0, SerializeReferenceRequiredGate.GetRequiredFields(typeof(LinkerTestObject)).Count);
+ }
+
+ [Test]
+ public void GetRequiredFields_NullType_ReturnsEmpty()
+ {
+ Assert.AreEqual(0, SerializeReferenceRequiredGate.GetRequiredFields(null).Count);
+ }
+
+ [Test]
+ public void FindUnsetRequiredFields_BothUnset_ReportsBoth()
+ {
+ _path = YamlFixtures.WriteTemp(YamlFixtures.RequiredSceneUnset);
+
+ var violations = SerializeReferenceYamlEditor.FindUnsetRequiredFields(_path, Resolve);
+
+ Assert.AreEqual(2, violations.Count, "An unset required reference and string should both be reported.");
+ Assert.IsTrue(violations.All(v => v.FileId == YamlFixtures.RequiredSceneMonoFileId),
+ "Both violations belong to the single MonoBehaviour document.");
+
+ var managed = violations.Single(v => v.FieldName == "requiredRef");
+ Assert.AreEqual(-2L, managed.Rid, "An unset managed reference reads the null id (-2).");
+ Assert.IsTrue(violations.Any(v => v.FieldName == "requiredString"), "The empty string field is a violation.");
+ }
+
+ [Test]
+ public void FindUnsetRequiredFields_BothSet_ReportsNone()
+ {
+ _path = YamlFixtures.WriteTemp(YamlFixtures.RequiredSceneSet);
+
+ var violations = SerializeReferenceYamlEditor.FindUnsetRequiredFields(_path, Resolve);
+
+ Assert.AreEqual(0, violations.Count, "A set managed reference and a populated string are not violations.");
+ }
+
+ [Test]
+ public void FindUnsetRequiredFields_AbsentKeys_ReportsNone()
+ {
+ // Both required keys are missing from the document (object saved before the fields were added / stripped doc).
+ // An absent key needs a reserialize, not a build failure, so it must not be reported as a violation.
+ _path = YamlFixtures.WriteTemp(YamlFixtures.RequiredSceneAbsentKeys);
+
+ var violations = SerializeReferenceYamlEditor.FindUnsetRequiredFields(_path, Resolve);
+
+ Assert.AreEqual(0, violations.Count, "An absent required key is not a violation — it needs a reserialize.");
+ }
+
+ [Test]
+ public void FindUnsetRequiredFields_MixedAndUnknownScript_ReportsOnlyKnownUnset()
+ {
+ _path = YamlFixtures.WriteTemp(YamlFixtures.RequiredSceneMixedUnknownScript);
+
+ var violations = SerializeReferenceYamlEditor.FindUnsetRequiredFields(_path, Resolve);
+
+ Assert.AreEqual(1, violations.Count,
+ "Only the empty string on the known script is reported; the unknown-script document is skipped.");
+ Assert.AreEqual("requiredString", violations[0].FieldName);
+ Assert.AreEqual(YamlFixtures.RequiredSceneMonoFileId, violations[0].FileId,
+ "The violation must be attributed to the first MonoBehaviour, not the unknown-script one.");
+ }
+
+ [Test]
+ public void FindUnsetRequiredFields_NullResolver_ReturnsEmpty()
+ {
+ _path = YamlFixtures.WriteTemp(YamlFixtures.RequiredSceneUnset);
+ Assert.AreEqual(0, SerializeReferenceYamlEditor.FindUnsetRequiredFields(_path, null).Count);
+ }
+ }
+}
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceRequiredYamlTests.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceRequiredYamlTests.cs.meta
new file mode 100644
index 00000000..d44722d6
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceRequiredYamlTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 2371606da73a46b29b80172e1ed694f5
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {fileID: 2800000, guid: 2d380e9402a9f4c5b8eb637f9f40c400, type: 3}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceSettingsTests.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceSettingsTests.cs
new file mode 100644
index 00000000..2d806b34
--- /dev/null
+++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceSettingsTests.cs
@@ -0,0 +1,229 @@
+using System;
+using System.Linq;
+using NUnit.Framework;
+using UnityEngine.UIElements;
+using Aspid.FastTools.Editors;
+using Aspid.FastTools.UIElements.Editors.Internal;
+
+namespace Aspid.FastTools.SerializeReferences.Editors.Tests
+{
+ ///
+ /// Behavioural coverage for the SerializeReference settings propagation contract (ASP-22):
+ ///
+ /// the excluded-folder set raises the dedicated
+ /// signal only when it genuinely changes — so the usage index drops its warm copy on a real change but an unrelated
+ /// setting never triggers a costly index rebuild;
+ /// the shared controls built by mirror the store live, so the
+ /// in-window Settings tab and the Project Settings page stay in sync when either edits a value.
+ ///
+ ///
+ [TestFixture]
+ internal sealed class SerializeReferenceSettingsTests
+ {
+ private bool _autoDeAlias;
+ private bool _breakageDetection;
+ private bool _dropdownWithoutAttribute;
+ private string[] _excludedFolders;
+ private GateSeverity _buildSeverity;
+
+ [SetUp]
+ public void SetUp()
+ {
+ // Snapshot the project's real settings so the assertions below can mutate them freely and restore on teardown.
+ _autoDeAlias = SerializeReferenceSettings.AutoDeAliasEnabled;
+ _breakageDetection = SerializeReferenceSettings.BreakageDetectionEnabled;
+ _dropdownWithoutAttribute = SerializeReferenceSettings.DropdownWithoutAttributeEnabled;
+ _excludedFolders = SerializeReferenceSettings.ExcludedFolders;
+ _buildSeverity = SerializeReferenceSettings.BuildSeverity;
+ }
+
+ [TearDown]
+ public void TearDown()
+ {
+ SerializeReferenceSettings.AutoDeAliasEnabled = _autoDeAlias;
+ SerializeReferenceSettings.BreakageDetectionEnabled = _breakageDetection;
+ SerializeReferenceSettings.DropdownWithoutAttributeEnabled = _dropdownWithoutAttribute;
+ SerializeReferenceSettings.ExcludedFolders = _excludedFolders;
+ SerializeReferenceSettings.BuildSeverity = _buildSeverity;
+ }
+
+ // -----------------------------------------------------------------------------------------------------
+ // A — excluded folders drive the dedicated index-invalidation signal (and nothing else does)
+ // -----------------------------------------------------------------------------------------------------
+
+ // Counts how many times ExcludedFoldersChanged fires while `mutate` runs, leaving the static event clean.
+ private static int ExcludedFoldersChangedCount(Action mutate)
+ {
+ var fired = 0;
+ void Handler() => fired++;
+ SerializeReferenceSettings.ExcludedFoldersChanged += Handler;
+ try { mutate(); }
+ finally { SerializeReferenceSettings.ExcludedFoldersChanged -= Handler; }
+ return fired;
+ }
+
+ [Test]
+ public void ExcludedFolders_NewValue_RaisesExcludedFoldersChanged()
+ {
+ SerializeReferenceSettings.ExcludedFolders = Array.Empty();
+
+ var fired = ExcludedFoldersChangedCount(() =>
+ SerializeReferenceSettings.ExcludedFolders = new[] { "Assets/Third Party/" });
+
+ Assert.AreEqual(1, fired, "A genuinely new excluded-folder set must raise ExcludedFoldersChanged exactly once.");
+ }
+
+ [Test]
+ public void ExcludedFolders_SameValue_DoesNotRaiseExcludedFoldersChanged()
+ {
+ SerializeReferenceSettings.ExcludedFolders = new[] { "Assets/Plugins/" };
+
+ // Same paths, fresh array instance: the set did not move, so the warm index must not be dropped.
+ var fired = ExcludedFoldersChangedCount(() =>
+ SerializeReferenceSettings.ExcludedFolders = new[] { "Assets/Plugins/" });
+
+ Assert.AreEqual(0, fired, "Re-assigning an identical set must not raise ExcludedFoldersChanged (no needless index rebuild).");
+ }
+
+ [Test]
+ public void UnrelatedSetting_DoesNotRaiseExcludedFoldersChanged()
+ {
+ var fired = ExcludedFoldersChangedCount(() =>
+ {
+ SerializeReferenceSettings.BreakageDetectionEnabled = !SerializeReferenceSettings.BreakageDetectionEnabled;
+ SerializeReferenceSettings.AutoDeAliasEnabled = !SerializeReferenceSettings.AutoDeAliasEnabled;
+ SerializeReferenceSettings.BuildSeverity = GateSeverity.Fail;
+ });
+
+ Assert.AreEqual(0, fired, "Toggling an unrelated setting must never raise ExcludedFoldersChanged (the index stays warm).");
+ }
+
+ [Test]
+ public void AnySetting_RaisesGeneralChanged()
+ {
+ var fired = 0;
+ void Handler() => fired++;
+ SerializeReferenceSettings.Changed += Handler;
+ try
+ {
+ SerializeReferenceSettings.BreakageDetectionEnabled = !SerializeReferenceSettings.BreakageDetectionEnabled;
+ Assert.GreaterOrEqual(fired, 1, "Every setter must still raise the general Changed for repaint and live-sync.");
+ }
+ finally { SerializeReferenceSettings.Changed -= Handler; }
+ }
+
+ // -----------------------------------------------------------------------------------------------------
+ // B — the per-scope resets restore exactly their own defaults
+ // -----------------------------------------------------------------------------------------------------
+
+ [Test]
+ public void ResetSharedToDefaults_RestoresCommittedDefaults_AndLeavesUserSettingsAlone()
+ {
+ SerializeReferenceSettings.AutoDeAliasEnabled = false;
+ SerializeReferenceSettings.BuildSeverity = GateSeverity.Fail;
+ SerializeReferenceSettings.ExcludedFolders = new[] { "Assets/Third Party/" };
+ SerializeReferenceSettings.BreakageDetectionEnabled = false;
+
+ SerializeReferenceSettings.ResetSharedToDefaults();
+
+ Assert.IsTrue(SerializeReferenceSettings.AutoDeAliasEnabled, "The shared reset must restore auto de-alias to on.");
+ Assert.AreEqual(GateSeverity.Warn, SerializeReferenceSettings.BuildSeverity, "The shared reset must restore the gate to Warn.");
+ Assert.IsEmpty(SerializeReferenceSettings.ExcludedFolders, "The shared reset must drop every excluded folder.");
+ Assert.IsFalse(SerializeReferenceSettings.BreakageDetectionEnabled,
+ "The shared reset must not touch the per-user breakage-detection setting.");
+ }
+
+ [Test]
+ public void ResetUserToDefaults_RestoresBreakageDetection_AndLeavesSharedSettingsAlone()
+ {
+ SerializeReferenceSettings.BreakageDetectionEnabled = false;
+ SerializeReferenceSettings.BuildSeverity = GateSeverity.Fail;
+
+ SerializeReferenceSettings.ResetUserToDefaults();
+
+ Assert.IsTrue(SerializeReferenceSettings.BreakageDetectionEnabled, "The per-user reset must restore breakage detection to on.");
+ Assert.AreEqual(GateSeverity.Fail, SerializeReferenceSettings.BuildSeverity,
+ "The per-user reset must not touch the shared gate severity.");
+ }
+
+ // -----------------------------------------------------------------------------------------------------
+ // C — the shared controls mirror the store live (the two settings surfaces stay in sync)
+ // -----------------------------------------------------------------------------------------------------
+
+ [Test]
+ public void BuildControls_LiveSyncsControlsFromSettings()
+ {
+ SerializeReferenceSettings.AutoDeAliasEnabled = true;
+ SerializeReferenceSettings.BreakageDetectionEnabled = true;
+ SerializeReferenceSettings.DropdownWithoutAttributeEnabled = false;
+ SerializeReferenceSettings.BuildSeverity = GateSeverity.Warn;
+ SerializeReferenceSettings.ExcludedFolders = Array.Empty();
+
+ var container = new VisualElement();
+ SerializeReferenceSettingsUI.BuildControls(container);
+
+ // The three boolean settings render as iOS-style AspidSwitch fields (BaseField), not plain Toggles.
+ // Looked up by label, so reordering the rows never silently swaps the assertions.
+ var switches = container.Query().ToList();
+ Assert.AreEqual(3, switches.Count,
+ "BuildControls must emit the breakage-detection, attribute-free-dropdown and auto-de-alias switches.");
+ var breakageDetection = switches.Single(s => s.label == "Breakage detection");
+ var dropdownWithoutAttribute = switches.Single(s => s.label.StartsWith("Dropdown without"));
+ var autoDeAlias = switches.Single(s => s.label.StartsWith("Auto de-alias"));
+ var severity = container.Q();
+ var folders = container.Q();
+ Assert.IsNotNull(severity, "BuildControls must emit the build-gate EnumField.");
+ Assert.IsNotNull(folders, "BuildControls must emit the excluded-folders field.");
+
+ // Mutating the shared store (as the other surface would) must reach these controls without a manual refresh:
+ // the switches and the gate re-read their value off SerializeReferenceSettings.Changed, and the folders list
+ // rebuilds off the dedicated ExcludedFoldersChanged signal.
+ SerializeReferenceSettings.AutoDeAliasEnabled = false;
+ SerializeReferenceSettings.BreakageDetectionEnabled = false;
+ SerializeReferenceSettings.DropdownWithoutAttributeEnabled = true;
+ SerializeReferenceSettings.BuildSeverity = GateSeverity.Fail;
+ SerializeReferenceSettings.ExcludedFolders = new[] { "Assets/Plugins/", "Assets/Generated/" };
+
+ Assert.IsFalse(autoDeAlias.value, "The auto-de-alias switch must mirror Settings live.");
+ Assert.IsFalse(breakageDetection.value, "The breakage-detection switch must mirror Settings live.");
+ Assert.IsTrue(dropdownWithoutAttribute.value, "The attribute-free-dropdown switch must mirror Settings live.");
+ Assert.AreEqual(GateSeverity.Fail, (GateSeverity)severity.value, "The build-gate field must mirror Settings live.");
+
+ // The list-based folders field renders one path Label per excluded folder; both new paths must appear live.
+ var listedPaths = folders.Query