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/.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..6b30679b --- /dev/null +++ b/Aspid.FastTools.Analyzers @@ -0,0 +1 @@ +Subproject commit 6b30679b15d9267f68ba6047bfefcb57727ada27 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..701ac9ec 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/Documentation/EN/README.md b/Aspid.FastTools/Packages/tech.aspid.fasttools/Documentation/EN/README.md index 5b241f97..92659961 100644 --- a/Aspid.FastTools/Packages/tech.aspid.fasttools/Documentation/EN/README.md +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Documentation/EN/README.md @@ -17,6 +17,7 @@ - **Features** - [ProfilerMarker](#profilermarker) - [Serializable Type System](#serializable-type-system) + - [SerializeReference Selector](#serializereference-selector) - [Enum System](#enum-system) - [ID System (Beta)](#id-system-beta) - [SerializedProperty Extensions](#serializedproperty-extensions) @@ -247,6 +248,29 @@ 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 `[TypeSelectorItem]` 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; + +// Re-home the type under a category and give it a tooltip and ordering hint: +[TypeSelectorItem("Combat/Damage Modifier", Tooltip = "Scales incoming damage", Order = 10)] +public sealed class DamageModifier { } + +// A plain name (no '/') just renames the leaf in place, keeping its namespace location: +[TypeSelectorItem("Damage Modifier")] +public sealed class DamageModifierAlt { } +``` + +| Member | Description | +|--------|-------------| +| `DisplayPath` | A `"Category/Name"` value re-homes the type under those category nodes; a plain value renames the leaf in place. `null`/empty keeps the default type name. | +| `Tooltip` | Tooltip shown when hovering the type's row. | +| `Order` | Ordering hint within the group — lower values appear higher; ties are broken alphabetically. Default `0`. | +| `Icon` | Editor icon shown left of the label — an `EditorGUIUtility.IconContent` name or a `Resources` texture path. | + +> Search still matches the real type name, so a re-homed or renamed entry stays findable by its original name. + --- ### Type Selector Window @@ -258,6 +282,7 @@ The Inspector shows a button that opens a searchable popup window with: - Keyboard navigation (Arrow keys, Enter, Escape) - Navigation history (back button) - Assembly disambiguation for types with identical names +- **Favorites** and **Recent** sections on the root page: a hover-revealed ★ toggle pins a type to Favorites, and the last 8 picked types are kept under Recent (both persisted per project, hidden while searching) ![aspid_fasttools_type_selector_window.png](../Images/aspid_fasttools_type_selector_window.png) @@ -273,7 +298,8 @@ namespace Aspid.FastTools.Types.Editors Type[] types = null, string currentAqn = "", TypeAllow allow = TypeAllow.None, - Action onSelected = null); + Action onSelected = null, + Func filter = null); } } ``` @@ -285,6 +311,7 @@ namespace Aspid.FastTools.Types.Editors | `currentAqn` | Assembly-qualified name of the currently selected type, used to pre-navigate to its location. Pass `null` or empty to start at the root. | | `allow` | Which special type kinds (abstract classes, interfaces) are included in addition to concrete classes. Default: `TypeAllow.None`. | | `onSelected` | Callback invoked with the assembly-qualified name of the selected type, or `null` if the user chose ``. | +| `filter` | Optional predicate applied to each candidate type after the base-type and `allow` checks. Return `false` to hide a type. Pass `null` to keep every match. | ### ComponentTypeSelector @@ -325,6 +352,69 @@ public sealed class TankEnemy : EnemyBase --- +## SerializeReference Selector + +A drop-in dropdown for `[SerializeReference]` fields. Add `[TypeSelector]` next to `[SerializeReference]` and the Inspector replaces the default managed-reference UI with the same searchable, hierarchical type picker used by `SerializableType` — letting you choose which concrete implementation of the field's declared type is instantiated. + +- Lists every concrete, non-`UnityEngine.Object` class assignable to the field's declared interface / base type. +- Passing base types narrows the candidates below the field's declared type — `[TypeSelector(typeof(IMelee))]` on an `IWeapon` field offers only `IMelee` implementations. +- Picking a type instantiates it; `` clears the reference. +- The assigned instance's serialized fields are drawn inline under a foldout. +- A stored type that no longer resolves (renamed or deleted) is surfaced as a missing-type warning instead of silently clearing. +- Open generic implementations (e.g. `Modifier`) are offered too: arguments are inferred from a closed-generic field, or picked in a follow-up window (validated against the field type) before instantiation. +- Switching the selected type preserves matching data — fields shared by the old and new implementation (by name and serialized shape) carry over instead of resetting to defaults. +- Right-click the header for a Copy / Paste context menu: it copies the managed-reference value and pastes it as an independent instance into any compatible field (paste is disabled when the clipboard type is not assignable to the target). +- A missing type can be repaired in place: the warning is a compact yellow notice whose underlined **Fix** word opens the type picker — choose the correct type and the reference is re-pointed while keeping its stored data; hover the notice for the full missing-type detail. Works for saved assets (ScriptableObjects and prefab assets) selected in the Project **and for objects open in Prefab Mode** — saved assets are rewritten in their YAML, while a Prefab Mode object is repaired on the live instance, recovering the data Unity still holds for the missing type. The repair also reaches nested references — through nested managed references and through plain `[Serializable]` containers (a struct/class field or a `List` of them) — so a missing type buried in a slot or list element is fixed inline too. +- The notice can also surface a **Smart Fix** suggestion — a second clickable segment next to **Fix** (e.g. `· → Pistol?`) that ranks the most likely replacement (a declared `[MovedFrom]` rename, the same class name in a different namespace/assembly, a casing-only rename, or a near-miss name backed by a matching field shape) and applies it in one click. The suggestion is only ever a type the picker would offer, and is never auto-applied — you always click. +- For missing references the Inspector cannot surface in the moment — components on child objects when the asset is not open in Prefab Mode, plus bulk repair and orphaned entries no field points at — the **Repair Missing References** window (`Tools → Aspid 🐍 → Repair Missing References FastTools`) scans the whole asset file and lists every one with its own **Fix** picker, no Prefab Mode required. A `Scan Project` button extends this project-wide: it sweeps every `.prefab` / `.asset` / `.unity` file under `Assets/`, groups the broken references by their stored type, and rewrites every entry across every affected file with a single `Fix all` (plus a Smart Fix quick-apply) per group — entries in currently open scenes are skipped during a bulk apply. +- The **Managed References** window (`Tools → Aspid 🐍 → Managed References FastTools`) maps an asset's whole managed-reference graph from the YAML: a per-component tree of field-pointer roots, nested children, shared references and orphaned payloads, with `MISSING` / `SHARED` badges, deterministic per-rid colours, and a constrained inline **Fix** for missing entries. It surfaces references at any nesting depth and the orphans the Inspector cannot navigate to. +- An aliased reference (two fields sharing one instance, e.g. after duplicating a list element) is flagged by the same compact notice, whose underlined **Make unique** word (also a right-click → **Make Unique Reference** action) splits it into an independent copy; the shared fields are tinted with a deterministic per-rid colour stripe and chip that matches the **Managed References** window. +- Duplicating a list element (Duplicate / Ctrl+D, or `+`-appending a copy of the last element) no longer aliases the reference in the first place — the copy silently becomes an independent instance in a single Undo step. Intentional cross-field sharing is left untouched and keeps the **Make unique** notice. +- Multi-object editing is supported: a mixed selection shows a mixed-type dropdown, and picking a type (or pasting) applies an independent instance to each selected object in one Undo group; per-asset notices are suppressed under a multi-object selection. +- Usage is validated at compile time by the Roslyn analyzer: `AFT0004` (error) flags a `[SerializeReference]` + `[TypeSelector]` field whose type derives from `UnityEngine.Object`, and `AFT0005` (warning) flags a constraint no visible concrete type can satisfy — the picker would be empty. +- Works on single fields, arrays, and `List`, in both IMGUI and UIToolkit inspectors. + +```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. + +--- + ## Enum System Provides serializable enum-to-value mappings configurable from the Inspector. 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 54ad4964..6040b97a 100644 --- a/Aspid.FastTools/Packages/tech.aspid.fasttools/Documentation/RU/README.md +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Documentation/RU/README.md @@ -17,6 +17,7 @@ - **Features** - [ProfilerMarker](#profilermarker) - [Serializable Type System](#serializable-type-system) + - [SerializeReference Selector](#serializereference-selector) - [Enum System](#enum-system) - [ID System (Beta)](#id-system-beta) - [SerializedProperty Extensions](#serializedproperty-extensions) @@ -247,6 +248,29 @@ public sealed class AbilitySelector : MonoBehaviour > Полный сэмпл — `Ability` / `AbilitySelector` / `EnemyBase` и их наследники — поставляется в сэмпле `Types` (Package Manager → Aspid.FastTools → Samples). +Пометьте тип-кандидат атрибутом `[TypeSelectorItem]`, чтобы настроить, как он показывается в селекторе — это editor-only атрибут (`[Conditional("UNITY_EDITOR")]`) в `Aspid.FastTools.Types`, не несущий стоимости в рантайме: + +```csharp +using Aspid.FastTools.Types; + +// Перенести тип под категорию и задать tooltip и подсказку порядка: +[TypeSelectorItem("Combat/Damage Modifier", Tooltip = "Scales incoming damage", Order = 10)] +public sealed class DamageModifier { } + +// Простое имя (без '/') лишь переименовывает лист на месте, сохраняя расположение по namespace: +[TypeSelectorItem("Damage Modifier")] +public sealed class DamageModifierAlt { } +``` + +| Член | Описание | +|------|----------| +| `DisplayPath` | Значение `"Category/Name"` переносит тип под эти узлы-категории; простое значение переименовывает лист на месте. `null`/пустое сохраняет имя типа по умолчанию. | +| `Tooltip` | Tooltip, показываемый при наведении на строку типа. | +| `Order` | Подсказка порядка внутри группы — меньшие значения выше; ничьи разрешаются по алфавиту. По умолчанию `0`. | +| `Icon` | Иконка редактора слева от лейбла — имя `EditorGUIUtility.IconContent` или путь к текстуре в `Resources`. | + +> Поиск по-прежнему сопоставляет реальное имя типа, поэтому перенесённая или переименованная запись остаётся находимой по исходному имени. + --- ### Type Selector Window @@ -258,6 +282,7 @@ public sealed class AbilitySelector : MonoBehaviour - Навигацию с клавиатуры (стрелки, Enter, Escape) - Историю навигации (кнопка «назад») - Разрешение неоднозначности для типов с одинаковыми именами из разных сборок +- Секции **Favorites** и **Recent** на корневой странице: появляющийся при наведении переключатель ★ закрепляет тип в Favorites, а последние 8 выбранных типов хранятся в Recent (оба сохраняются на проект и скрыты во время поиска) ![aspid_fasttools_type_selector_window.png](../Images/aspid_fasttools_type_selector_window.png) @@ -273,7 +298,8 @@ namespace Aspid.FastTools.Types.Editors Type[] types = null, string currentAqn = "", TypeAllow allow = TypeAllow.None, - Action onSelected = null); + Action onSelected = null, + Func filter = null); } } ``` @@ -285,6 +311,7 @@ namespace Aspid.FastTools.Types.Editors | `currentAqn` | Assembly-qualified имя текущего выбранного типа: окно сразу откроется на его уровне иерархии. Передайте `null` или пустую строку, чтобы стартовать с корня. | | `allow` | Какие специальные категории (абстрактные классы, интерфейсы) включаются в список в дополнение к конкретным классам. По умолчанию: `TypeAllow.None`. | | `onSelected` | Callback с assembly-qualified именем выбранного типа или `null`, если пользователь выбрал ``. | +| `filter` | Необязательный предикат, применяемый к каждому типу-кандидату после проверок базового типа и `allow`. Верните `false`, чтобы скрыть тип. Передайте `null`, чтобы оставить все совпадения. | ### ComponentTypeSelector @@ -325,6 +352,69 @@ public sealed class TankEnemy : EnemyBase --- +## SerializeReference Selector + +Готовый выпадающий список для полей с `[SerializeReference]`. Добавьте `[TypeSelector]` рядом с `[SerializeReference]`, и Inspector заменит стандартный UI managed-ссылки тем же иерархическим выбором типа с поиском, что используется в `SerializableType` — позволяя выбрать, какая конкретная реализация объявленного типа поля будет создана. + +- Показывает каждый конкретный, не наследующий `UnityEngine.Object` класс, совместимый с объявленным интерфейсом / базовым типом поля. +- Передача базовых типов сужает список ниже объявленного типа поля — `[TypeSelector(typeof(IMelee))]` на поле `IWeapon` предлагает только реализации `IMelee`. +- Выбор типа создаёт его экземпляр; `` очищает ссылку. +- Сериализуемые поля назначенного экземпляра рисуются вложенно под foldout. +- Сохранённый тип, который больше не разрешается (переименован или удалён), показывается как предупреждение о потерянном типе, а не очищается молча. +- Открытые generic-реализации (например, `Modifier`) тоже предлагаются: аргументы выводятся из закрытого generic-поля либо выбираются в дополнительном окне (с проверкой на присваиваемость полю) перед созданием экземпляра. +- При смене типа совпадающие данные сохраняются — поля, общие у старой и новой реализации (по имени и сериализуемой форме), переносятся, а не сбрасываются в значения по умолчанию. +- По правому клику на заголовке доступно контекстное меню Copy / Paste: оно копирует значение managed-ссылки и вставляет его как независимый экземпляр в любое совместимое поле (вставка недоступна, если тип из буфера нельзя присвоить полю). +- Потерянный тип можно починить на месте: предупреждение показано компактным жёлтым лейблом, подчёркнутое слово **Fix** открывает селектор типов — выбранный тип переназначается с сохранением данных, а при наведении на лейбл показывается полная информация о потерянном типе. Работает для сохранённых ассетов (ScriptableObject и префаб-ассетов), выделенных в Project, **и для объектов, открытых в Prefab Mode**: сохранённые ассеты переписываются в YAML, а объект в Prefab Mode чинится прямо на живом экземпляре — с восстановлением данных, которые Unity всё ещё хранит для потерянного типа. Починка работает на любой глубине — через вложенные managed-ссылки и через обычные `[Serializable]`-контейнеры (поле-struct/class или `List` из них), так что потерянный тип внутри слота или элемента списка чинится инлайном тоже. +- Лейбл также может показать подсказку **Smart Fix** — второй кликабельный сегмент рядом с **Fix** (например, `· → Pistol?`), который ранжирует наиболее вероятную замену (объявленное переименование `[MovedFrom]`, тот же класс в другом пространстве имён/сборке, переименование только по регистру или близкое имя, подкреплённое совпадением формы полей) и применяет её в один клик. Подсказка всегда лишь тип, который и так предложил бы селектор, и никогда не применяется автоматически — вы всегда кликаете сами. +- До потерянных ссылок, которые инспектор не показывает в моменте — компоненты дочерних объектов, когда ассет не открыт в Prefab Mode, плюс массовая починка и осиротевшие записи, на которые не указывает ни одно поле, — есть окно **Repair Missing References** (`Tools → Aspid 🐍 → Repair Missing References FastTools`): оно сканирует весь файл ассета и выводит каждую со своим **Fix**, без Prefab Mode. Кнопка `Scan Project` расширяет это на весь проект: она обходит каждый файл `.prefab` / `.asset` / `.unity` под `Assets/`, группирует сломанные ссылки по их сохранённому типу и переназначает каждую запись во всех затронутых файлах одним `Fix all` (плюс быстрое применение Smart Fix) на группу — записи в открытых в данный момент сценах при массовом применении пропускаются. +- Окно **Managed References** (`Tools → Aspid 🐍 → Managed References FastTools`) строит весь граф managed-ссылок ассета прямо из YAML: дерево по компонентам с корнями-указателями полей, вложенными потомками, общими ссылками и осиротевшими payload'ами, со значками `MISSING` / `SHARED`, детерминированными цветами по rid и ограниченной инлайн-кнопкой **Fix** для потерянных записей. Оно показывает ссылки на любой глубине и осиротевшие записи, к которым инспектор не может перейти. +- Общая ссылка (два поля делят один экземпляр, например после дублирования элемента списка) помечается тем же компактным лейблом; подчёркнутое слово **Make unique** (а также действие правой кнопкой → **Make Unique Reference**) расщепляет её в независимую копию; общие поля подсвечиваются детерминированной по rid цветной полосой и чипом, совпадающими с окном **Managed References**. +- Дублирование элемента списка (Duplicate / Ctrl+D или `+`-добавление копии последнего элемента) больше не создаёт алиас ссылки изначально — копия молча становится независимым экземпляром за один шаг Undo. Намеренное межполевое разделение не трогается и сохраняет лейбл **Make unique**. +- Поддерживается мультиобъектное редактирование: при смешанном выделении показывается смешанное состояние выпадающего списка, а выбор типа (или вставка) применяет независимый экземпляр к каждому выделенному объекту в одной группе Undo; пер-ассетные лейблы при мультивыделении подавляются. +- Использование проверяется на этапе компиляции анализатором Roslyn: `AFT0004` (ошибка) отмечает поле `[SerializeReference]` + `[TypeSelector]`, тип которого наследует `UnityEngine.Object`, а `AFT0005` (предупреждение) — ограничение, которому не удовлетворяет ни один видимый конкретный тип, то есть селектор оказался бы пустым. +- Работает с одиночными полями, массивами и `List`, в инспекторах IMGUI и UIToolkit. + +```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")]`) и не несёт стоимости в рантайме. + +--- + ## Enum System Предоставляет сериализуемые отображения enum → значение, настраиваемые через Inspector. 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..f81eedb1 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Prefabs/IMGUILoadout.prefab @@ -0,0 +1,64 @@ +%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: Aspid.FastTools.Samples.SerializeReferences::Aspid.FastTools.Samples.SerializeReferences.IMGUILoadout + _primaryWeapon: + rid: 2001 + _sidearms: [] + _onHitEffect: + rid: 2002 + references: + version: 2 + RefIds: + - rid: 2001 + type: {class: Pistol, ns: Aspid.FastTools.Samples.SerializeReferences, asm: Aspid.FastTools.Samples.SerializeReferences} + data: + _damage: 10 + _magazineSize: 12 + - rid: 2002 + 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..3ff1a95b --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Prefabs/Loadout.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: 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: Aspid.FastTools.Samples.SerializeReferences::Aspid.FastTools.Samples.SerializeReferences.Loadout + _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: 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..625467eb --- /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: Aspid.FastTools.Samples.SerializeReferences::Aspid.FastTools.Samples.SerializeReferences.Loadout + _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..92bde9e6 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Prefabs/LoadoutSharedRef.prefab @@ -0,0 +1,77 @@ +%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: LoadoutSharedRef + 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: Aspid.FastTools.Samples.SerializeReferences::Aspid.FastTools.Samples.SerializeReferences.Loadout + _primaryWeapon: + rid: 1001 + _sidearms: + - rid: 1002 + - rid: 1002 + _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: Pistol, 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 + - 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..468f737d --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Prefabs/LoadoutSharedRef.prefab.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 16d87b1e935f4ebbae70d89f7b57f84e +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..a580bffe --- /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: Aspid.FastTools.Samples.SerializeReferences::Aspid.FastTools.Samples.SerializeReferences.SlottedLoadout + _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/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/README.md b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/README.md new file mode 100644 index 00000000..68a39eeb --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/README.md @@ -0,0 +1,67 @@ +# SerializeReferences Sample + +A tiny loadout system that demonstrates `[TypeSelector]` — a searchable, hierarchical type dropdown for `[SerializeReference]` fields. You pick which concrete implementation of a polymorphic field is instantiated, directly in the Inspector. + +Look at: + +- `Scripts/Loadout.cs` — single (`IWeapon`), `List`, and abstract-base (`StatusEffect`) `[SerializeReference]` fields, each annotated with `[TypeSelector]`. +- `Scripts/Weapons/` — `IWeapon` interface and its implementations (`Pistol`, `Shotgun`, `Railgun`). `Railgun` nests another `[TypeSelector]` field, showing recursive polymorphic editing. +- `Scripts/Effects/` — abstract `StatusEffect` base with `BurnEffect` / `FreezeEffect`. The dropdown offers only the concrete subclasses; the abstract base is never listed. +- `Scripts/Modifiers/` — generic hierarchy: a non-abstract `Modifier` generic class (`IModifier`) with closed-generic subclasses `DamageModifier : Modifier`, `AmmoModifier : Modifier`, `NameModifier : Modifier`. An `IModifier` field offers all three subclasses **and** the open generic `Modifier` — picking `Modifier` opens a second window to choose the argument `T`. A `Modifier` field offers only the candidates assignable to it (`DamageModifier`, and `Modifier` with `T` inferred to `float`). +- `Scripts/WeaponPreset.cs` + `Presets/BrokenWeaponPreset.asset` — a `ScriptableObject` whose `_weapon` points at a type that no longer exists, used to demonstrate the missing-type repair flow (see *Maintenance features* below). + +The drawer ships both a UIToolkit and an IMGUI rendering path. The `IMGUILoadout` variant forces the IMGUI path so you can compare them or migrate IMGUI-only projects: + +- `Scripts/IMGUILoadout.cs` + `Scripts/Editor/IMGUILoadoutEditor.cs` — the same fields rendered via `OnInspectorGUI` (`SerializeReferenceIMGUIPropertyDrawer`). + +## How to run + +Two ready-made prefabs live in `Prefabs/` — double-click to open in Prefab Mode, or drag either into any scene: + +- **Loadout** (`Prefabs/Loadout.prefab`) — UIToolkit path. Pre-filled: `Primary Weapon = Railgun` (with a nested `BurnEffect` charge effect), `Sidearms = [Pistol, Shotgun]`, `On Hit Effect = FreezeEffect`. +- **IMGUILoadout** (`Prefabs/IMGUILoadout.prefab`) — IMGUI path. Pre-filled: `Primary Weapon = Pistol`, `On Hit Effect = BurnEffect`. + +Then experiment with the dropdowns: + +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 three concrete subclasses (`DamageModifier`, `AmmoModifier`, `NameModifier`) are offered alongside the open generic `Modifier`. Pick `Modifier` and a second window opens to choose the argument `T` (try `string`, then `float`) before the instance is created. Open `Float Modifier` — only candidates assignable to `Modifier` are offered (`DamageModifier`, and `Modifier` whose `T` is inferred to `float` without the extra window). +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** (UIToolkit) or **IMGUILoadout** (IMGUI) 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. + +## Maintenance features + +The drawer also helps recover from the two 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` + +Two assets ship pre-broken, pointing at classes that do not exist: + +- `Presets/BrokenWeaponPreset.asset` — a `ScriptableObject` whose `Weapon` references a missing `GhostWeapon`. +- `Prefabs/LoadoutMissingType.prefab` — a prefab whose `Sidearms → Element 0` references a missing `GhostPistol`. + +Select either **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. + +> 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 therefore needs a **saved asset file**: it works for ScriptableObjects and prefab assets selected in the Project, but not for objects edited in Prefab Mode or instances living in a scene (no backing asset to rewrite). +> +> When a missing reference is nested inside another value or sits on a child object the Inspector can't reach, use **`Tools → Aspid 🐍 → Repair Missing References FastTools`** instead: it scans the whole asset file and lists every missing reference (any depth, any child) with its own **Fix** picker. + +### Un-share an aliased reference — `LoadoutSharedRef.prefab` + +`Prefabs/LoadoutSharedRef.prefab` has both `Sidearms` elements backed by the **same** instance (a state you can also reach by duplicating an array element). + +1. Open it — both elements show a **shared reference** notice; editing one changes the other. +2. **Right-click** one element → **Make Unique Reference**. It gets its own copy of the data and the two fields become independent. 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..477f29e3 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/README_RU.md @@ -0,0 +1,67 @@ +# Пример SerializeReferences + +Маленькая система снаряжения, демонстрирующая `[TypeSelector]` — иерархический выпадающий список с поиском для полей `[SerializeReference]`. Вы прямо в Inspector выбираете, какая конкретная реализация полиморфного поля будет создана. + +Смотрите: + +- `Scripts/Loadout.cs` — одиночное поле (`IWeapon`), `List` и поле с абстрактным базовым типом (`StatusEffect`), каждое с `[SerializeReference]` и `[TypeSelector]`. +- `Scripts/Weapons/` — интерфейс `IWeapon` и его реализации (`Pistol`, `Shotgun`, `Railgun`). `Railgun` вкладывает ещё одно поле `[TypeSelector]` — показывает рекурсивное полиморфное редактирование. +- `Scripts/Effects/` — абстрактный базовый `StatusEffect` с `BurnEffect` / `FreezeEffect`. В списке предлагаются только конкретные подтипы; абстрактный базовый класс никогда не показывается. +- `Scripts/Modifiers/` — generic-иерархия: неабстрактный generic-класс `Modifier` (`IModifier`) с закрытыми подтипами `DamageModifier : Modifier`, `AmmoModifier : Modifier`, `NameModifier : Modifier`. Поле `IModifier` предлагает все три подтипа **и** сам открытый `Modifier` — при его выборе открывается второе окно для выбора аргумента `T`. Поле `Modifier` предлагает только присваиваемых кандидатов (`DamageModifier` и `Modifier` с выведенным `T = float`). +- `Scripts/WeaponPreset.cs` + `Presets/BrokenWeaponPreset.asset` — `ScriptableObject`, у которого поле `_weapon` указывает на несуществующий тип; используется для демонстрации починки потерянного типа (см. *Сервисные функции* ниже). + +Drawer поддерживает и UIToolkit, и IMGUI. Вариант `IMGUILoadout` принудительно использует IMGUI-путь — удобно для сравнения или миграции IMGUI-проектов: + +- `Scripts/IMGUILoadout.cs` + `Scripts/Editor/IMGUILoadoutEditor.cs` — те же поля, отрисованные через `OnInspectorGUI` (`SerializeReferenceIMGUIPropertyDrawer`). + +## Как запустить + +В `Prefabs/` лежат два готовых префаба — дважды кликните, чтобы открыть в Prefab Mode, или перетащите любой в сцену: + +- **Loadout** (`Prefabs/Loadout.prefab`) — путь UIToolkit. Предзаполнено: `Primary Weapon = Railgun` (с вложенным эффектом заряда `BurnEffect`), `Sidearms = [Pistol, Shotgun]`, `On Hit Effect = FreezeEffect`. +- **IMGUILoadout** (`Prefabs/IMGUILoadout.prefab`) — путь IMGUI. Предзаполнено: `Primary Weapon = Pistol`, `On Hit Effect = BurnEffect`. + +Дальше поэкспериментируйте с выпадающими списками: + +1. Кликните по любому дропдауну типа и выберите другую реализацию — экземпляр создастся, а его сериализуемые поля появятся вложенно под foldout. +2. Разверните `Railgun` и смените вложенный `Charge Effect` — увидите рекурсивное полиморфное редактирование. +3. Нажмите **+** на `Sidearms` и задайте каждому элементу свой тип оружия. +4. Откройте `On Hit Effect` — обратите внимание, что предлагаются только `BurnEffect` / `FreezeEffect` (абстрактный `StatusEffect` скрыт). +5. Откройте `Modifier` — рядом с тремя конкретными подтипами (`DamageModifier`, `AmmoModifier`, `NameModifier`) предлагается и открытый `Modifier`. Выберите `Modifier` — откроется второе окно для выбора аргумента `T` (попробуйте `string`, затем `float`), и только потом создастся экземпляр. Откройте `Float Modifier` — предлагаются только присваиваемые к `Modifier` кандидаты (`DamageModifier` и `Modifier` с выведенным `T = float`, без дополнительного окна). +6. ПКМ по заголовку компонента → **Log Loadout**, чтобы вывести настроенное снаряжение в Console. + +Хотите собрать с нуля? Добавьте пустой GameObject и прикрепите компонент **Loadout** (UIToolkit) или **IMGUILoadout** (IMGUI). + +Переключение поля обратно на `` очищает ссылку. Если сохранённый тип позже переименуют или удалят, в списке появится подпись `` и предупреждение, вместо тихой очистки. + +## Сервисные функции + +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`. +- `Prefabs/LoadoutMissingType.prefab` — префаб, `Sidearms → Element 0` ссылается на потерянный `GhostPistol`. + +Выделите любой **в окне Project**. У потерянного поля будет подпись ``, предупреждение **Missing type** и кнопка **Fix**: + +1. Нажмите **Fix** — откроется привычный селектор типов с поиском. Выберите `Pistol`. +2. Ссылка восстановится в `Pistol` с сохранёнными данными (префаб сохранит `_damage = 15`, `_magazineSize = 12`; ассет — `_damage = 25`, `_magazineSize = 8`). Выбор типа переписывает сохранённый тип в файле ассета, а не создаёт экземпляр заново — поэтому значения сохраняются. + +> Починка читает и переписывает файл ассета напрямую — Unity не отдаёт потерянный тип через свой serialization API (а на GameObject/префабах ещё и обнуляет его в живом объекте, UUM-129100), поэтому осиротевшие тип и данные восстанавливаются прямо из YAML. Значит нужен **сохранённый файл ассета**: работает для ScriptableObject и префаб-ассетов, выделенных в Project, но не для объектов в Prefab Mode или экземпляров в сцене (нет файла ассета для перезаписи). +> +> Если потерянная ссылка вложена в другое значение или лежит на дочернем объекте, до которого не добраться в инспекторе — используйте **`Tools → Aspid 🐍 → Repair Missing References FastTools`**: окно сканирует весь файл ассета и выводит все потерянные ссылки (любой глубины, на любом дочернем объекте), каждую со своим **Fix**. + +### Расцепление общей ссылки — `LoadoutSharedRef.prefab` + +В `Prefabs/LoadoutSharedRef.prefab` оба элемента `Sidearms` ссылаются на **один и тот же** экземпляр (это же состояние получается дублированием элемента массива). + +1. Откройте его — оба элемента показывают пометку **shared reference**; редактирование одного меняет другой. +2. **ПКМ** по элементу → **Make Unique Reference**. Он получит собственную копию данных, и поля станут независимыми. 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/Scripts.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts.meta new file mode 100644 index 00000000..d3d21526 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 0c6859af7d0f4560a27fc9398c16cac2 +timeCreated: 1780775167 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..29604cb6 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Editor.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 5ae54c703d8f4ad4a074367f0614d47c +timeCreated: 1780775167 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..672a61e1 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Editor/Aspid.FastTools.Samples.SerializeReferences.Editor.asmdef @@ -0,0 +1,18 @@ +{ + "name": "Aspid.FastTools.Samples.SerializeReferences.Editor", + "rootNamespace": "", + "references": [ + "Aspid.FastTools.Samples.SerializeReferences" + ], + "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..3a9b5ae3 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Editor/IMGUILoadoutEditor.cs @@ -0,0 +1,22 @@ +using UnityEditor; + +// 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. + [CustomEditor(typeof(IMGUILoadout))] + internal sealed class IMGUILoadoutEditor : Editor + { + public override void OnInspectorGUI() + { + serializedObject.Update(); + DrawPropertiesExcluding(serializedObject, "m_Script"); + serializedObject.ApplyModifiedProperties(); + } + } +} diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Editor/IMGUILoadoutEditor.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Editor/IMGUILoadoutEditor.cs.meta new file mode 100644 index 00000000..0d589592 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Editor/IMGUILoadoutEditor.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 49097153234d4b66b7eacaff0ba4d88d +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/Effects.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Effects.meta new file mode 100644 index 00000000..2efe7f26 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Effects.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 0f9e11c2bbf345a0a2895fc744afba61 +timeCreated: 1780775167 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/Samples~/SerializeReferences/Scripts/Effects/BurnEffect.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Effects/BurnEffect.cs.meta new file mode 100644 index 00000000..1c4aba5a --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Effects/BurnEffect.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7dd6bde21faf4019b74c28d087f07570 +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/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/Samples~/SerializeReferences/Scripts/Effects/FreezeEffect.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Effects/FreezeEffect.cs.meta new file mode 100644 index 00000000..f5bdb149 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Effects/FreezeEffect.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 95af46da00c94957a8642c716ab6aabb +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/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..891471f5 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Loadout.cs @@ -0,0 +1,69 @@ +using System.Collections.Generic; +using UnityEngine; +using Aspid.FastTools.Types; + +// ReSharper disable once CheckNamespace +namespace Aspid.FastTools.Samples.SerializeReferences +{ + // Demonstrates [TypeSelector] through the default (UIToolkit) Inspector. + // + // Add [TypeSelector] next to [SerializeReference] and the field renders as a + // searchable, hierarchical type dropdown: + // - single field → pick one IWeapon implementation + // - List / array → each element is its own polymorphic picker + // - abstract base type → only concrete subclasses are offered + // - generic hierarchy → concrete closed-generic subclasses are offered; the open generic + // Modifier is also offered and opens a second window to pick T; + // a closed-generic field type (Modifier) constrains candidates + // by assignability and infers T directly + // + // Picking a type instantiates it, clears the reference, and the assigned instance's + // serialized fields appear inline under the foldout. Nested [SerializeReference] fields + // (e.g. Railgun's charge effect) get their own dropdown recursively. + public sealed class Loadout : MonoBehaviour + { + // Interface-typed field: lists every IWeapon implementation (Pistol, Shotgun, Railgun). + [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; + + // Generic hierarchy. Non-generic IModifier field: the picker offers the concrete subclasses + // (DamageModifier, AmmoModifier, NameModifier) AND the open generic Modifier — choosing the + // latter opens a second window to pick T (e.g. string vs float). + [SerializeReference] [TypeSelector] + private IModifier _modifier; + + // Closed-generic field type: only types assignable to Modifier are offered — + // DamageModifier (Modifier) and Modifier (its T is inferred to float, no extra window). + // AmmoModifier (int) and NameModifier (string) are excluded. + [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..0fccbd20 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/SlottedLoadout.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; +using UnityEngine; +using Aspid.FastTools.Types; + +namespace Aspid.FastTools.Samples.SerializeReferences +{ + // Demonstrates [TypeSelector] on references that live INSIDE plain [Serializable] + // containers — a single container field and a List of them — instead of directly on the component. + // + // Everything works at this depth exactly as for a top-level field: the type-picker dropdown, the inline + // child properties of the chosen type, and the missing-type warning with its inline Fix. A renamed or + // removed weapon type nested in a slot is detected and re-pointed in place (keeping its data), so the + // asset-level Repair window is only needed for things the Inspector cannot reach at all. + public sealed class SlottedLoadout : MonoBehaviour + { + // 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. + [Serializable] + public sealed class WeaponSlot + { + public string label; + + [Min(0)] public int priority; + + // Polymorphic weapon nested one level inside the container — picker, inline fields and Fix all apply. + [SerializeReference] [TypeSelector] + private IWeapon _weapon; + } + + // 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..ca22612b --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/SlottedLoadout.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 251896effa22341e7981b29000d77094 \ No newline at end of file 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..fb42cd8b --- /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 "Edit Type" 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 an "Edit Type" 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/Weapons.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons.meta new file mode 100644 index 00000000..0e76adff --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: b9b0ae5ee78a4faa9a79767c9f22771b +timeCreated: 1780775167 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..fec54fdd --- /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. 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 : IWeapon + { + [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..440a757d --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/Railgun.cs @@ -0,0 +1,24 @@ +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. + [Serializable] + public sealed class Railgun : IWeapon + { + [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..c93cb855 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Samples~/SerializeReferences/Scripts/Weapons/Shotgun.cs @@ -0,0 +1,15 @@ +using System; +using UnityEngine; + +// ReSharper disable once CheckNamespace +namespace Aspid.FastTools.Samples.SerializeReferences +{ + [Serializable] + public sealed class Shotgun : IWeapon + { + [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/Tests.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests.meta new file mode 100644 index 00000000..181ef549 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 63a1ce046a89e405ba8f3843a964d66e +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor.meta new file mode 100644 index 00000000..420b4b99 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 2a7e1769cbf4c4d35a3fccad05961fab +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/Aspid.FastTools.Unity.Editor.Tests.asmdef b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/Aspid.FastTools.Unity.Editor.Tests.asmdef new file mode 100644 index 00000000..da56dfe8 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/Aspid.FastTools.Unity.Editor.Tests.asmdef @@ -0,0 +1,26 @@ +{ + "name": "Aspid.FastTools.Unity.Editor.Tests", + "rootNamespace": "", + "references": [ + "UnityEngine.TestRunner", + "UnityEditor.TestRunner", + "Aspid.FastTools.Unity.Editor", + "Aspid.FastTools.Unity", + "Aspid.FastTools" + ], + "includePlatforms": [ + "Editor" + ], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": true, + "precompiledReferences": [ + "nunit.framework.dll" + ], + "autoReferenced": false, + "defineConstraints": [ + "UNITY_INCLUDE_TESTS" + ], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/Aspid.FastTools.Unity.Editor.Tests.asmdef.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/Aspid.FastTools.Unity.Editor.Tests.asmdef.meta new file mode 100644 index 00000000..c790ca4a --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/Aspid.FastTools.Unity.Editor.Tests.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: abab51ccaa79848deaa6a47923cccb1b +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences.meta new file mode 100644 index 00000000..296172a2 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 987d027d395694761b193d7d515b44fa +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/Fixtures.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/Fixtures.meta new file mode 100644 index 00000000..bd96d215 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/Fixtures.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: eb9c7d06ca323440598385b67ca7c4ef +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/Fixtures/YamlFixtures.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/Fixtures/YamlFixtures.cs new file mode 100644 index 00000000..686d3502 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/Fixtures/YamlFixtures.cs @@ -0,0 +1,120 @@ +using System; +using System.IO; + +namespace Aspid.FastTools.SerializeReferences.Editors.Tests +{ + /// + /// Real-format Unity YAML fixtures and temp-file helpers for the tests. + /// The prefab text is copied verbatim from the Samples~/SerializeReferences demo so the parser is exercised + /// against Unity's exact indentation, references:/RefIds:/data: layout and field-pointer shapes + /// (single managed ref, list of managed refs, nested managed ref). The strings are written at column 0 inside the + /// verbatim literal — their leading whitespace IS the YAML indentation and must not be reflowed. + /// + internal static class YamlFixtures + { + // The MonoBehaviour document's local file id (its "--- !u!114 &" anchor). + public const long MonoBehaviourFileId = 6500000000000000003L; + + // RefIds present in the fixture, by stored type. + public const long RailgunRid = 1001; // _primaryWeapon (resolvable) + public const long GhostPistolRid = 1002; // _sidearms[0] (MISSING — class starts with "Ghost") + public const long ShotgunRid = 1003; // _sidearms[1] (resolvable) + public const long FreezeEffectRid = 1004; // _onHitEffect (resolvable) + public const long BurnEffectRid = 1005; // _primaryWeapon._chargeEffect (resolvable, nested in Railgun) + + // A MonoBehaviour with one missing managed reference (GhostPistol), a single ref field, a list of refs, and a + // nested ref (Railgun -> _chargeEffect -> BurnEffect). + public const string MissingTypePrefab = +@"%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!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: Aspid.FastTools.Samples.SerializeReferences::Aspid.FastTools.Samples.SerializeReferences.Loadout + _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 +"; + + /// Writes to a fresh temp file (never under Assets/) and returns its path. + public static string WriteTemp(string yaml) + { + var dir = Path.Combine(Path.GetTempPath(), "aspid-sr-tests"); + Directory.CreateDirectory(dir); + var path = Path.Combine(dir, Guid.NewGuid().ToString("N") + ".prefab"); + File.WriteAllText(path, yaml); + return path; + } + + /// Deletes a temp fixture file if present. + public static void Delete(string path) + { + try + { + if (!string.IsNullOrEmpty(path) && File.Exists(path)) File.Delete(path); + } + catch + { + // Best-effort cleanup; a leftover temp file is harmless. + } + } + } +} diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/Fixtures/YamlFixtures.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/Fixtures/YamlFixtures.cs.meta new file mode 100644 index 00000000..e3c34fdf --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/Fixtures/YamlFixtures.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4430a873bfd5648d583dca1b1c02a532 \ No newline at end of file diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceYamlEditorRewriteTests.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceYamlEditorRewriteTests.cs new file mode 100644 index 00000000..19d58cf4 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceYamlEditorRewriteTests.cs @@ -0,0 +1,131 @@ +using System.IO; +using System.Linq; +using NUnit.Framework; + +namespace Aspid.FastTools.SerializeReferences.Editors.Tests +{ + /// + /// Round-trip and confinement coverage for — the highest + /// risk write path. Asserts the rewrite re-points the correct rid and touches exactly one line, so the regression + /// guard locks in that no other YAML is disturbed. + /// + [TestFixture] + internal sealed class SerializeReferenceYamlEditorRewriteTests + { + private string _path; + + [SetUp] + public void SetUp() => _path = YamlFixtures.WriteTemp(YamlFixtures.MissingTypePrefab); + + [TearDown] + public void TearDown() => YamlFixtures.Delete(_path); + + [Test] + public void TryRewriteType_RepointsRid_AndChangesExactlyOneLine() + { + var before = File.ReadAllLines(_path); + var newType = new ManagedTypeName( + "Aspid.FastTools.Samples.SerializeReferences", + "Aspid.FastTools.Samples.SerializeReferences", + "Pistol"); + + var rewritten = SerializeReferenceYamlEditor.TryRewriteType( + _path, YamlFixtures.MonoBehaviourFileId, YamlFixtures.GhostPistolRid, newType); + Assert.IsTrue(rewritten, "Rewrite of a present rid must succeed."); + + var after = File.ReadAllLines(_path); + Assert.AreEqual(before.Length, after.Length, "A type rewrite must not add or remove lines."); + + var changed = Enumerable.Range(0, before.Length).Where(i => before[i] != after[i]).ToArray(); + Assert.AreEqual(1, changed.Length, "Exactly one line (the type mapping) should change."); + + var line = after[changed[0]]; + StringAssert.Contains("class: Pistol", line); + StringAssert.Contains("type:", line); + StringAssert.Contains("GhostPistol", before[changed[0]]); + } + + [Test] + public void TryRewriteType_RoundTrips_ReadBackReportsNewType() + { + var newType = new ManagedTypeName( + "Aspid.FastTools.Samples.SerializeReferences", + "Aspid.FastTools.Samples.SerializeReferences", + "Pistol"); + + Assert.IsTrue(SerializeReferenceYamlEditor.TryRewriteType( + _path, YamlFixtures.MonoBehaviourFileId, YamlFixtures.GhostPistolRid, newType)); + + // Reading back also exercises the probe cache being invalidated by the writer (TryRewriteType clears it). + Assert.IsTrue(SerializeReferenceYamlEditor.TryReadStoredType( + _path, YamlFixtures.MonoBehaviourFileId, "_sidearms.Array.data[0]", out var rid, out var type)); + Assert.AreEqual(YamlFixtures.GhostPistolRid, rid); + Assert.AreEqual("Pistol", type.Class); + } + + [Test] + public void TryRewriteType_UnknownRid_ReturnsFalse_AndLeavesFileUnchanged() + { + var before = File.ReadAllText(_path); + var newType = new ManagedTypeName("Asm", "Ns", "Pistol"); + + Assert.IsFalse(SerializeReferenceYamlEditor.TryRewriteType( + _path, YamlFixtures.MonoBehaviourFileId, 999999, newType)); + Assert.AreEqual(before, File.ReadAllText(_path), "A no-op rewrite must leave the file byte-identical."); + } + + [Test] + public void TryRemoveEntry_RemovesOnlyTheTargetEntry() + { + var beforeLines = File.ReadAllLines(_path).Length; + + Assert.IsTrue(SerializeReferenceYamlEditor.TryRemoveEntry( + _path, YamlFixtures.MonoBehaviourFileId, YamlFixtures.ShotgunRid)); + + var after = File.ReadAllText(_path); + + // The Shotgun entry (type line + its data) is gone... + StringAssert.DoesNotContain("Shotgun", after); + StringAssert.DoesNotContain("_pellets", after); + StringAssert.DoesNotContain("_spreadAngle", after); + + // ...every other RefIds entry is intact... + StringAssert.Contains("Railgun", after); + StringAssert.Contains("GhostPistol", after); + StringAssert.Contains("FreezeEffect", after); + StringAssert.Contains("BurnEffect", after); + + // ...and exactly the entry's five lines were removed. + Assert.AreEqual(beforeLines - 5, File.ReadAllLines(_path).Length); + } + + [Test] + public void TryRemoveEntry_UnknownRid_ReturnsFalse_AndLeavesFileUnchanged() + { + var before = File.ReadAllText(_path); + Assert.IsFalse(SerializeReferenceYamlEditor.TryRemoveEntry(_path, YamlFixtures.MonoBehaviourFileId, 424242)); + Assert.AreEqual(before, File.ReadAllText(_path), "A no-op remove must leave the file byte-identical."); + } + + [Test] + public void TryComputeRewrite_PreviewEqualsApplied() + { + var newType = new ManagedTypeName( + "Aspid.FastTools.Samples.SerializeReferences", + "Aspid.FastTools.Samples.SerializeReferences", + "Pistol"); + + Assert.IsTrue(SerializeReferenceYamlEditor.TryComputeRewrite( + _path, YamlFixtures.MonoBehaviourFileId, YamlFixtures.GhostPistolRid, newType, out var edit)); + StringAssert.Contains("GhostPistol", edit.OldLine); + StringAssert.Contains("class: Pistol", edit.NewLine); + + Assert.IsTrue(SerializeReferenceYamlEditor.TryRewriteType( + _path, YamlFixtures.MonoBehaviourFileId, YamlFixtures.GhostPistolRid, newType)); + + // The preview promised edit.NewLine at edit.LineNumber — the applied file must match exactly. + var after = File.ReadAllLines(_path); + Assert.AreEqual(edit.NewLine, after[edit.LineNumber], "Applied line must equal the previewed NewLine."); + } + } +} diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceYamlEditorRewriteTests.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceYamlEditorRewriteTests.cs.meta new file mode 100644 index 00000000..09c7d895 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceYamlEditorRewriteTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 1728f7079fa1248e6aabf3b6f206848d \ No newline at end of file diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceYamlEditorTests.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceYamlEditorTests.cs new file mode 100644 index 00000000..1ef44724 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceYamlEditorTests.cs @@ -0,0 +1,112 @@ +using System; +using NUnit.Framework; + +namespace Aspid.FastTools.SerializeReferences.Editors.Tests +{ + /// + /// Read-side coverage for the YAML engine: missing-type discovery, propertyPath -> rid resolution (top-level, + /// list-element and nested managed chains), stored-type reading, and field-name parsing. All tests drive the public + /// static methods on the internal against temp files — no asset import, + /// no SerializedObject — so they exercise the parser in isolation. + /// + [TestFixture] + internal sealed class SerializeReferenceYamlEditorTests + { + private string _path; + + // A stored type is "resolvable" unless its class name starts with "Ghost" — the fixture's deliberately-missing + // GhostPistol. This keeps the test independent of which types actually load in the test domain. + private static bool Resolves(ManagedTypeName type) => + !type.Class.StartsWith("Ghost", StringComparison.Ordinal); + + [SetUp] + public void SetUp() => _path = YamlFixtures.WriteTemp(YamlFixtures.MissingTypePrefab); + + [TearDown] + public void TearDown() => YamlFixtures.Delete(_path); + + [Test] + public void FindMissingReferences_ReportsOnlyUnresolvableEntry() + { + var missing = SerializeReferenceYamlEditor.FindMissingReferences(_path, Resolves); + + Assert.AreEqual(1, missing.Count, "Only GhostPistol should be reported as missing."); + Assert.AreEqual(YamlFixtures.GhostPistolRid, missing[0].Rid); + Assert.AreEqual(YamlFixtures.MonoBehaviourFileId, missing[0].FileId); + Assert.AreEqual("GhostPistol", missing[0].StoredType.Class); + } + + [Test] + public void FindMissingReferences_AllResolvable_ReturnsEmpty() + { + var missing = SerializeReferenceYamlEditor.FindMissingReferences(_path, _ => true); + Assert.AreEqual(0, missing.Count); + } + + [Test] + public void TryReadReferenceId_SingleField_ResolvesRid() + { + Assert.IsTrue(SerializeReferenceYamlEditor.TryReadReferenceId( + _path, YamlFixtures.MonoBehaviourFileId, "_primaryWeapon", out var rid)); + Assert.AreEqual(YamlFixtures.RailgunRid, rid); + } + + [Test] + public void TryReadReferenceId_ListElement_ResolvesRid() + { + Assert.IsTrue(SerializeReferenceYamlEditor.TryReadReferenceId( + _path, YamlFixtures.MonoBehaviourFileId, "_sidearms.Array.data[0]", out var first)); + Assert.AreEqual(YamlFixtures.GhostPistolRid, first); + + Assert.IsTrue(SerializeReferenceYamlEditor.TryReadReferenceId( + _path, YamlFixtures.MonoBehaviourFileId, "_sidearms.Array.data[1]", out var second)); + Assert.AreEqual(YamlFixtures.ShotgunRid, second); + } + + [Test] + public void TryReadReferenceId_NestedManagedChain_ResolvesRid() + { + Assert.IsTrue(SerializeReferenceYamlEditor.TryReadReferenceId( + _path, YamlFixtures.MonoBehaviourFileId, "_primaryWeapon._chargeEffect", out var rid)); + Assert.AreEqual(YamlFixtures.BurnEffectRid, rid); + } + + [Test] + public void TryReadReferenceId_UnknownPath_ReturnsFalse() + { + Assert.IsFalse(SerializeReferenceYamlEditor.TryReadReferenceId( + _path, YamlFixtures.MonoBehaviourFileId, "_doesNotExist", out _)); + } + + [Test] + public void TryReadStoredType_ReturnsRidAndType() + { + Assert.IsTrue(SerializeReferenceYamlEditor.TryReadStoredType( + _path, YamlFixtures.MonoBehaviourFileId, "_sidearms.Array.data[0]", out var rid, out var type)); + Assert.AreEqual(YamlFixtures.GhostPistolRid, rid); + Assert.AreEqual("GhostPistol", type.Class); + Assert.AreEqual("Aspid.FastTools.Samples.SerializeReferences", type.Namespace); + } + + [Test] + public void GetReferenceFieldNames_ReturnsTopLevelDataKeys() + { + var pistolFields = SerializeReferenceYamlEditor.GetReferenceFieldNames( + _path, YamlFixtures.MonoBehaviourFileId, YamlFixtures.GhostPistolRid); + CollectionAssert.AreEquivalent(new[] { "_damage", "_magazineSize" }, pistolFields); + + // Railgun's data has a nested managed ref (_chargeEffect -> rid); only the top-level keys are reported. + var railgunFields = SerializeReferenceYamlEditor.GetReferenceFieldNames( + _path, YamlFixtures.MonoBehaviourFileId, YamlFixtures.RailgunRid); + CollectionAssert.AreEquivalent(new[] { "_chargeTime", "_chargeEffect" }, railgunFields); + } + + [Test] + public void ParseTopLevelFieldNames_SkipsIndentedAndSequenceLines() + { + var names = SerializeReferenceYamlEditor.ParseTopLevelFieldNames( + "_damage: 15\n_magazineSize: 12\n _nested: 3\n- 7\n"); + CollectionAssert.AreEqual(new[] { "_damage", "_magazineSize" }, names); + } + } +} diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceYamlEditorTests.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceYamlEditorTests.cs.meta new file mode 100644 index 00000000..71af3c5a --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceYamlEditorTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 92ba11e56ef0e4d7699bad728d7e24e6 \ No newline at end of file diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceYamlProbeCacheTests.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceYamlProbeCacheTests.cs new file mode 100644 index 00000000..a9ed83bd --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceYamlProbeCacheTests.cs @@ -0,0 +1,86 @@ +using System.IO; +using NUnit.Framework; + +namespace Aspid.FastTools.SerializeReferences.Editors.Tests +{ + /// + /// Behavioural coverage for : it serves a cached copy while the file's + /// last-write-time is unchanged, re-reads when the timestamp moves, and drops everything on . + /// + [TestFixture] + internal sealed class SerializeReferenceYamlProbeCacheTests + { + private string _path; + + [SetUp] + public void SetUp() + { + SerializeReferenceYamlProbeCache.ClearCache(); + _path = YamlFixtures.WriteTemp("alpha\n"); + } + + [TearDown] + public void TearDown() + { + SerializeReferenceYamlProbeCache.ClearCache(); + YamlFixtures.Delete(_path); + } + + [Test] + public void ReadAllLines_ReturnsFileContent() + { + var lines = SerializeReferenceYamlProbeCache.ReadAllLines(_path); + Assert.AreEqual(1, lines.Length); + Assert.AreEqual("alpha", lines[0]); + } + + [Test] + public void ReadAllLines_SameTimestamp_ServesCachedContent() + { + // Pin an explicit write time on BOTH the warm read and the post-edit read. Capturing the OS-written stamp + // instead would be unreliable: Mono's SetLastWriteTimeUtc can store a lower-precision value than + // GetLastWriteTimeUtc returned, so the cached key would never match. Setting the same value twice is + // deterministic regardless of the platform's timestamp resolution. + var pinned = new System.DateTime(2020, 1, 1, 0, 0, 0, System.DateTimeKind.Utc); + + File.SetLastWriteTimeUtc(_path, pinned); + var first = SerializeReferenceYamlProbeCache.ReadAllLines(_path); + Assert.AreEqual("alpha", first[0]); + + // Change the content but restore the same pinned write time: the cache key is (path, write-time), so the + // stale copy is served by design until the timestamp moves or the cache is cleared. + File.WriteAllText(_path, "beta\n"); + File.SetLastWriteTimeUtc(_path, pinned); + + var cached = SerializeReferenceYamlProbeCache.ReadAllLines(_path); + Assert.AreEqual("alpha", cached[0], "Unchanged write-time must serve the cached copy."); + } + + [Test] + public void ReadAllLines_NewerTimestamp_ReReads() + { + SerializeReferenceYamlProbeCache.ReadAllLines(_path); // warm + + File.WriteAllText(_path, "beta\n"); + File.SetLastWriteTimeUtc(_path, File.GetLastWriteTimeUtc(_path).AddSeconds(5)); + + var reread = SerializeReferenceYamlProbeCache.ReadAllLines(_path); + Assert.AreEqual("beta", reread[0], "A newer write-time must bust the cache."); + } + + [Test] + public void ClearCache_ForcesReRead() + { + var stamp = File.GetLastWriteTimeUtc(_path); + SerializeReferenceYamlProbeCache.ReadAllLines(_path); // warm + + File.WriteAllText(_path, "beta\n"); + File.SetLastWriteTimeUtc(_path, stamp); // keep timestamp so only ClearCache can bust it + + SerializeReferenceYamlProbeCache.ClearCache(); + + var reread = SerializeReferenceYamlProbeCache.ReadAllLines(_path); + Assert.AreEqual("beta", reread[0], "ClearCache must force a fresh read."); + } + } +} diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceYamlProbeCacheTests.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceYamlProbeCacheTests.cs.meta new file mode 100644 index 00000000..d864a7df --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Tests/Editor/SerializeReferences/SerializeReferenceYamlProbeCacheTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: cab17ef1b22d64962adf79a1e370e2ce \ No newline at end of file diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Resources/UI/SerializeReferences.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Resources/UI/SerializeReferences.meta new file mode 100644 index 00000000..fe9608c5 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Resources/UI/SerializeReferences.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 31a60714a36b461385fa435ea880b724 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Resources/UI/SerializeReferences/Aspid-FastTools-ReferenceGraph.uss b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Resources/UI/SerializeReferences/Aspid-FastTools-ReferenceGraph.uss new file mode 100644 index 00000000..c45750a3 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Resources/UI/SerializeReferences/Aspid-FastTools-ReferenceGraph.uss @@ -0,0 +1,255 @@ +/* --- Inspect Asset view --- + Transparent content floating over the window's shared dotted canvas: a boxed asset-picker card (Aspid header with + a neutral divider, full-width picker, Rescan trailing it), then either a centred hero (no asset / no managed + references) or a scrolling per-document tree of indented node cards. Each node row shows the stored type short name + (amber when the type is missing), the dim rid and MISSING / SHARED badges; a missing row carries a trailing amber + Fix gradient button that expands an inline type picker. Orphaned rids — reachable from no root — collect in a + trailing warning-tinted group per document. */ +.aspid-fasttools-reference-graph__content { + flex-grow: 1; + padding: 12px; +} + +/* The AspidBox supplies the rounded darkness panel; the hairline border lifts it off the pure-black canvas. The + box's own stylesheet sets `:root { flex-grow: 1; }` on the element itself, which outweighs a single class from + this ancestor sheet — the compound selector restores enough specificity to pin the card to its content height. */ +.aspid-fasttools-reference-graph__card.aspid-fasttools-background { + flex-grow: 0; + margin-bottom: 10px; + border-width: 1px; + border-color: var(--aspid-colors-shade-darkness); +} + +.aspid-fasttools-reference-graph__card-header { + margin-bottom: 8px; +} + +.aspid-fasttools-reference-graph__field-row { + flex-direction: row; + align-items: center; +} + +.aspid-fasttools-reference-graph__asset { + flex-grow: 1; +} + +.aspid-fasttools-reference-graph__rescan { + min-width: 92px; + margin-left: 8px; + margin-bottom: 0; +} + +/* Centre the Rescan label (the gradient button left-aligns its label by default). */ +.aspid-fasttools-reference-graph__rescan .aspid-fasttools-gradient-button__label { + flex-grow: 0; + -unity-text-align: middle-center; +} + +/* Centred hero for the two empty states (no asset / no managed references): a large dimmed info icon, a headline + and a dimmed explanation, filling the space below the asset card. The --hidden modifier swaps it for the results + scroll (and vice versa), mirroring the Project Audit view's hero. */ +.aspid-fasttools-reference-graph__empty { + flex-grow: 1; + padding: 20px; + align-items: center; + justify-content: center; +} + +.aspid-fasttools-reference-graph__empty--hidden { + display: none; +} + +.aspid-fasttools-reference-graph__empty-icon { + width: 96px; + height: 96px; + opacity: 0.9; + margin-bottom: 14px; + background-size: contain; + background-repeat: no-repeat; + background-position: center; +} + +.aspid-fasttools-reference-graph__empty-icon--info { + background-image: var(--aspid-icons-status-info); +} + +.aspid-fasttools-reference-graph__empty-title { + margin-bottom: 4px; +} + +.aspid-fasttools-reference-graph__empty-message { + max-width: 420px; + color: var(--aspid-colors-text-dark); + white-space: normal; + -unity-text-align: middle-center; +} + +.aspid-fasttools-reference-graph__scroll { + flex-grow: 1; +} + +.aspid-fasttools-reference-graph__scroll--hidden { + display: none; +} + +/* --- Per-document group --- */ +.aspid-fasttools-reference-graph__document { + margin-bottom: 12px; +} + +.aspid-fasttools-reference-graph__document-header { + margin-bottom: 6px; +} + +/* --- Node cards --- */ +.aspid-fasttools-reference-graph__node.aspid-fasttools-background { + flex-grow: 0; + margin-bottom: 4px; + padding: 4px 8px; + border-width: 1px; + border-color: var(--aspid-colors-shade-darkness); +} + +/* A back-edge leaf (a cycle pointing at a rid already on the render path) reads dim and italic. */ +.aspid-fasttools-reference-graph__node--back-edge.aspid-fasttools-background { + border-color: var(--aspid-colors-shade-dark); +} + +.aspid-fasttools-reference-graph__node--back-edge .aspid-fasttools-reference-graph__node-label { + color: var(--aspid-colors-text-darkness); + -unity-font-style: italic; +} + +/* The single-row node body: root label, type name (grown so the rid and badges push right), rid, badges, Fix. */ +.aspid-fasttools-reference-graph__node-label { + flex-direction: row; + align-items: center; + flex-wrap: wrap; +} + +/* The field/element name a root reference sits under — a dim lead-in before the type name. */ +.aspid-fasttools-reference-graph__node-root-label { + margin-right: 6px; + color: var(--aspid-colors-text-dark); + -unity-font-style: italic; +} + +/* The stored type short name; flex-grown so trailing elements align to the right edge. */ +.aspid-fasttools-reference-graph__node-type { + flex-grow: 1; + flex-shrink: 1; + color: var(--aspid-colors-text-lightness); + -unity-font-style: bold; +} + +/* A missing type reads amber, matching the MISSING badge and the Fix button. */ +.aspid-fasttools-reference-graph__node-label--missing .aspid-fasttools-reference-graph__node-type { + color: var(--aspid-colors-status-warning-text-light); +} + +/* An orphan row's type dims toward the warning palette so it reads as a leftover, even when its type resolves. */ +.aspid-fasttools-reference-graph__node-label--orphan .aspid-fasttools-reference-graph__node-type { + color: var(--aspid-colors-status-warning-text-dark); + -unity-font-style: normal; +} + +.aspid-fasttools-reference-graph__node-rid { + margin-left: 8px; + color: var(--aspid-colors-text-dark); + -unity-font-style: normal; + font-size: 10px; +} + +/* --- Badges --- */ +.aspid-fasttools-reference-graph__node-badges { + flex-direction: row; + align-items: center; + margin-left: 8px; +} + +.aspid-fasttools-reference-graph__badge { + margin-left: 4px; + padding: 1px 5px; + border-radius: 3px; + font-size: 9px; + -unity-font-style: bold; + flex-direction: row; + align-items: center; +} + +.aspid-fasttools-reference-graph__badge--missing { + color: var(--aspid-colors-status-warning-text-lightness); + background-color: var(--aspid-colors-status-warning-dark); +} + +.aspid-fasttools-reference-graph__badge--shared { + color: var(--aspid-colors-status-info-text-lightness); + background-color: var(--aspid-colors-status-info-dark); +} + +/* The deterministic per-rid colour chip beside the SHARED badge (its background colour is set inline from code). */ +.aspid-fasttools-reference-graph__chip { + width: 8px; + height: 8px; + margin-left: 4px; + border-radius: 4px; +} + +/* --- Inline Fix gradient button (missing rows) --- */ +.aspid-fasttools-reference-graph__fix { + min-width: 66px; + margin-left: 8px; + margin-bottom: 0; + padding: 2px 6px; + --aspid-fasttools-colors-gradient_button-bg: var(--aspid-colors-status-warning-darkness); + --aspid-fasttools-colors-gradient_button-accent: var(--aspid-colors-status-warning-text-light); +} + +.aspid-fasttools-reference-graph__fix .aspid-fasttools-gradient-button__label { + flex-grow: 0; + font-size: 11px; + -unity-text-align: middle-center; +} + +/* --- Orphaned group --- */ +.aspid-fasttools-reference-graph__orphan-group.aspid-fasttools-background { + flex-grow: 0; + margin-top: 6px; + padding: 6px 8px; + border-width: 1px; + border-color: var(--aspid-colors-status-warning-shade-darkness); +} + +.aspid-fasttools-reference-graph__orphan-group-header { + margin-bottom: 4px; +} + +/* Orphan rows sit flush inside the group card rather than in their own node cards. */ +.aspid-fasttools-reference-graph__orphan-group .aspid-fasttools-reference-graph__node-label { + padding: 2px 0; +} + +/* --- Inline type picker (mirrors the Repair window) --- + The dropdown's selector view boxed in the window's dark style, expanded directly below the row being fixed. Fixed + height — the selector's ListView needs bounds to virtualize. The 5px padding matches the selector's own edge + spacing so its full-bleed header strip reaches the box border; overflow keeps that strip inside the rounded + corners. */ +.aspid-fasttools-reference-graph__picker.aspid-fasttools-background { + flex-grow: 0; + height: 300px; + padding: 5px; + overflow: hidden; + margin: 0 0 8px 0; + border-width: 1px; + border-color: var(--aspid-colors-status-warning-shade-darkness); +} + +/* Re-tint the selector list's default editor-blue hover/selection to the window's palette. */ +.aspid-fasttools-reference-graph__picker .unity-collection-view__item:hover { + background-color: var(--aspid-colors-bg-dark); +} + +.aspid-fasttools-reference-graph__picker .unity-collection-view__item--selected, +.aspid-fasttools-reference-graph__picker .unity-collection-view__item--selected:hover { + background-color: var(--aspid-colors-status-success-dark); +} diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Resources/UI/SerializeReferences/Aspid-FastTools-ReferenceGraph.uss.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Resources/UI/SerializeReferences/Aspid-FastTools-ReferenceGraph.uss.meta new file mode 100644 index 00000000..d379ef31 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Resources/UI/SerializeReferences/Aspid-FastTools-ReferenceGraph.uss.meta @@ -0,0 +1,12 @@ +fileFormatVersion: 2 +guid: 4d3587597888e494db81a6e16e77b303 +ScriptedImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 2 + userData: + assetBundleName: + assetBundleVariant: + script: {fileID: 12385, guid: 0000000000000000e000000000000000, type: 0} + disableValidation: 0 + unsupportedSelectorAction: 0 diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Resources/UI/SerializeReferences/Aspid-FastTools-SerializeReference-Window.uss b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Resources/UI/SerializeReferences/Aspid-FastTools-SerializeReference-Window.uss new file mode 100644 index 00000000..6a9dd54e --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Resources/UI/SerializeReferences/Aspid-FastTools-SerializeReference-Window.uss @@ -0,0 +1,76 @@ +/* --- Managed References window chrome --- + The shell hosting the two modes (Inspect Asset / Project Audit). One animated dotted canvas, owned by the window, + fills the whole window behind everything; the toolbar and the mode content are transparent and float over it, so + the dots read continuously from the tab strip down through the content. The active mode is marked by a bright, + bold label and a signature green underline (a child bar, not a border-bottom — see below). */ +.aspid-fasttools-serialize-reference-window { + flex-grow: 1; +} + +/* The shared dotted canvas: a black base over which the dots paint; it absolutely fills the window via its own + stylesheet, sitting behind the toolbar and the mode content. Its blob tint is driven from code per view state. */ +.aspid-fasttools-serialize-reference-window__background { + background-color: rgb(0, 0, 0); +} + +/* The tab strip is fully transparent so the dotted canvas shows through behind the tabs. A little top padding lifts + the tabs off the window's top edge instead of jamming them against it. */ +.aspid-fasttools-serialize-reference-window__toolbar { + flex-direction: row; + flex-shrink: 0; + padding-top: 6px; + background-color: rgba(0, 0, 0, 0); +} + +/* Each mode tab: a large, flat, full-flex button stripped of Unity's button chrome and of any fill, so it sits + directly on the canvas. The element-qualified selector outranks the built-in `.unity-button` rules. The small + horizontal margin opens a gap between the two tabs (and their underlines). The active underline rides a child bar + (see below), not a border-bottom, so it always repaints. */ +Button.aspid-fasttools-serialize-reference-window__toolbar-button { + flex-grow: 1; + margin: 0 4px; + padding: 15px 16px; + background-color: rgba(0, 0, 0, 0); + border-width: 0; + border-radius: 0; + color: var(--aspid-colors-text-dark); + font-size: 14px; + -unity-font-style: normal; + -unity-text-align: middle-center; +} + +/* Hover on an inactive tab: a faint light wash (kept translucent so the dots still read) and a brighter label. */ +Button.aspid-fasttools-serialize-reference-window__toolbar-button:hover { + background-color: rgba(255, 255, 255, 0.05); + color: var(--aspid-colors-text-light); +} + +/* Active tab: brightest label, bold weight. Placed after the base/hover rules so it wins at equal specificity. */ +Button.aspid-fasttools-serialize-reference-window__toolbar-button--active { + color: var(--aspid-colors-text-lightness); + -unity-font-style: bold; +} + +/* The active tab also lifts on hover (same faint wash as inactive tabs) for consistent feedback, but keeps its + brightest label rather than dimming to the inactive-hover colour. */ +Button.aspid-fasttools-serialize-reference-window__toolbar-button--active:hover { + background-color: rgba(255, 255, 255, 0.05); + color: var(--aspid-colors-text-lightness); +} + +/* Underline accent: a 2px child bar pinned to the tab's bottom edge. Toggling a child's background-color via the + parent's --active class repaints reliably, whereas flipping a border-bottom-color did not redraw until the next + relayout (the underline only appeared after a window resize). A grey baseline marks every (inactive) tab; the + active tab swaps it for the signature green. */ +.aspid-fasttools-serialize-reference-window__tab-underline { + position: absolute; + left: 0; + right: 0; + bottom: 0; + height: 2px; + background-color: var(--aspid-colors-shade-light); +} + +Button.aspid-fasttools-serialize-reference-window__toolbar-button--active .aspid-fasttools-serialize-reference-window__tab-underline { + background-color: var(--aspid-colors-status-success-text-light); +} diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Resources/UI/SerializeReferences/Aspid-FastTools-SerializeReference-Window.uss.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Resources/UI/SerializeReferences/Aspid-FastTools-SerializeReference-Window.uss.meta new file mode 100644 index 00000000..234c8084 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Resources/UI/SerializeReferences/Aspid-FastTools-SerializeReference-Window.uss.meta @@ -0,0 +1,12 @@ +fileFormatVersion: 2 +guid: 06abcfdfbf8f4f86b4cbbe289df31fe1 +ScriptedImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 2 + userData: + assetBundleName: + assetBundleVariant: + script: {fileID: 12385, guid: 0000000000000000e000000000000000, type: 0} + disableValidation: 0 + unsupportedSelectorAction: 0 diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Resources/UI/SerializeReferences/Aspid-FastTools-SerializeReference.uss b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Resources/UI/SerializeReferences/Aspid-FastTools-SerializeReference.uss new file mode 100644 index 00000000..f961bb08 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Resources/UI/SerializeReferences/Aspid-FastTools-SerializeReference.uss @@ -0,0 +1,445 @@ +:root { + margin: 0; +} + +/* Lay the foldout header out as a row and centre its items so the dropdown and the open-script + button line up vertically, mirroring the single-row SerializableType field. */ +.aspid-fasttools-serialize-reference .unity-foldout__toggle { + flex-direction: row; + align-items: center; +} + +/* Keep the expand arrow at its natural width on the far left of the header. */ +.aspid-fasttools-serialize-reference .unity-foldout__toggle > .unity-foldout__input { + flex-grow: 0; + flex-shrink: 0; +} + +/* The type dropdown fills the header row up to the open-script button. Its left margin is set + in code to offset the arrow so the dropdown begins at the inspector value column. */ +.aspid-fasttools-serialize-reference__dropdown { + flex-grow: 1; +} + +/* Cancel the EnumField caption's built-in -2px left margin so the text indents from the box + border like SerializableType's field instead of hugging the edge. */ +.aspid-fasttools-serialize-reference__dropdown .unity-enum-field__text { + margin-left: 0; +} + +/* Hide the expand arrow when the reference is empty — there is nothing to expand. */ +.aspid-fasttools-serialize-reference--empty .unity-foldout__toggle .unity-foldout__checkmark { + visibility: hidden; +} + +/* Open-script button mirrors the SerializableType drawer affordance. */ +.aspid-fasttools-serialize-reference Button { + padding: 0; + min-width: 18px; + max-width: 18px; + min-height: 18px; + max-height: 18px; + margin: 0 0 0 1px; +} + +.aspid-fasttools-serialize-reference Button > VisualElement { + width: 100%; + height: 100%; + background-image: resource("d_Folder Icon"); +} + +.aspid-fasttools-serialize-reference Button > VisualElement:enabled:hover { + background-image: resource("d_FolderOpened Icon"); +} + +/* --- Shared-reference rid colour stripe --- + A 3 px absolutely-positioned bar on the left edge of the field root, coloured per-rid so aliased fields + can be matched at a glance. Hidden (zero width) by default; the --active modifier reveals it when a shared + reference is detected. The colour is always set inline from code; only geometry lives here. */ +.aspid-fasttools-serialize-reference__rid-stripe { + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 0; +} + +.aspid-fasttools-serialize-reference__rid-stripe--active { + width: 3px; +} + +/* --- Compact inline notice (missing type / shared reference) --- + Replaces the bulky help-box + button: a small warning icon, a terse yellow message and an + underlined, clickable action word. The full explanation rides the element's tooltip on hover. */ +.aspid-fasttools-serialize-reference-notice { + flex-direction: row; + align-items: center; + flex-wrap: wrap; + margin: 2px 0 1px 0; +} + +.aspid-fasttools-serialize-reference-notice__icon { + width: 14px; + height: 14px; + min-width: 14px; + margin-right: 5px; + background-size: contain; + background-repeat: no-repeat; + background-position: center; + background-image: resource("d_console.warnicon"); +} + +/* Both states use the same yellow text per the design — warning, not info. */ +.aspid-fasttools-serialize-reference-notice__message { + color: var(--aspid-colors-status-warning-text-light); + -unity-font-style: normal; + white-space: normal; +} + +.aspid-fasttools-serialize-reference-notice__action { + margin-left: 4px; + color: var(--aspid-colors-status-warning-text-light); + -unity-font-style: bold; + border-bottom-width: 1px; + border-bottom-color: var(--aspid-colors-status-warning-text-light); + cursor: link; +} + +.aspid-fasttools-serialize-reference-notice__action:hover { + color: var(--aspid-colors-status-warning-text-lightness); + border-bottom-color: var(--aspid-colors-status-warning-text-lightness); +} + +/* Trailing Smart Fix suggestion ("· → Pistol?"): a second underlined clickable word after Fix, same warning + palette, sitting a touch further to the right so the two actions read as separate affordances. Hidden by + default; the --visible modifier reveals it when a suggestion is set. */ +.aspid-fasttools-serialize-reference-notice__suggestion { + margin-left: 6px; + color: var(--aspid-colors-status-warning-text-light); + -unity-font-style: bold; + border-bottom-width: 1px; + border-bottom-color: var(--aspid-colors-status-warning-text-light); + cursor: link; + display: none; +} + +.aspid-fasttools-serialize-reference-notice__suggestion--visible { + display: flex; +} + +.aspid-fasttools-serialize-reference-notice__suggestion:hover { + color: var(--aspid-colors-status-warning-text-lightness); + border-bottom-color: var(--aspid-colors-status-warning-text-lightness); +} + +/* Small round rid colour chip appended after the action word on the shared-reference notice. Its background + colour is set inline from code so the same rid always renders the same colour. Hidden by default; the + --visible modifier reveals it when a shared reference is detected. */ +.aspid-fasttools-serialize-reference-notice__rid-chip { + width: 8px; + height: 8px; + min-width: 8px; + margin-left: 5px; + border-radius: 4px; + display: none; +} + +.aspid-fasttools-serialize-reference-notice__rid-chip--visible { + display: flex; +} + +/* Info variant — a non-actionable, dim hint (the multi-object "different types" notice that stands in for the + suppressed per-instance child fields). Swaps the warning icon for the info icon and the yellow text for the + generic dim shade, so it reads as informational rather than something to fix. */ +.aspid-fasttools-serialize-reference-notice--info .aspid-fasttools-serialize-reference-notice__icon { + background-image: resource("d_console.infoicon"); +} + +.aspid-fasttools-serialize-reference-notice--info .aspid-fasttools-serialize-reference-notice__message { + color: var(--aspid-colors-text-dark); +} + +/* --- Project Audit view --- + Transparent content floating over the window's shared dotted canvas (tinted by status from code — green when + clean, amber when something needs fixing). A single Scan Project action sits in a slim translucent panel at the + top-left; the old boxed header card was dropped since the active tab already names the mode. Below sits either a + centred hero for the terminal states (success = nothing to repair, info = canceled) or warning-accented group + cards, one per broken stored type, each with a bulk Fix all over its entry rows. */ +.aspid-fasttools-repair-references__content { + flex-grow: 1; + padding: 12px; +} + +/* Full-width translucent header panel, stacked: the audit's title + one-line description, then a full-width Scan + Project button below. The semi-transparent dark fill lets the window's dotted canvas read through. Replaces both + the old solid header card and the bare floating button. */ +.aspid-fasttools-repair-references__panel { + flex-direction: column; + flex-shrink: 0; + margin-bottom: 12px; + padding: 12px; + border-radius: 8px; + border-width: 1px; + border-color: var(--aspid-colors-shade-darkness); + background-color: rgba(20, 20, 20, 0.55); +} + +.aspid-fasttools-repair-references__panel-title { + margin-bottom: 2px; +} + +/* The description carries the gap above the button (margin-bottom), so the button-to-description spacing matches the + button-to-panel-edge spacing (the panel's 12px bottom padding). Put on the plain Label here — not the button's + margin-top — because the gradient button's own :root margin would override an equal-specificity rule. */ +.aspid-fasttools-repair-references__panel-description { + color: var(--aspid-colors-text-dark); + white-space: normal; + margin-bottom: 12px; +} + +/* Scan Project / Rescan sweeps every text asset under Assets/; full-width below the description. The descendant + selector outranks the gradient button's own :root, so the panel zeros the button's margin (spacing lives on the + description / panel padding) while keeping its natural 0 10px padding, so the label sits indented inside the pill + rather than hugging its left edge. */ +.aspid-fasttools-repair-references__panel .aspid-fasttools-repair-references__scan-project { + margin: 0; +} + +/* Centred hero for the two terminal states: package icon in the status colour, headline, dimmed explanation. */ +.aspid-fasttools-repair-references__empty { + flex-grow: 1; + padding: 20px; + align-items: center; + justify-content: center; +} + +.aspid-fasttools-repair-references__empty--hidden { + display: none; +} + +.aspid-fasttools-repair-references__empty-icon { + width: 96px; + height: 96px; + opacity: 0.9; + margin-bottom: 14px; + background-size: contain; + background-repeat: no-repeat; + background-position: center; +} + +.aspid-fasttools-repair-references__empty-icon--info { + background-image: var(--aspid-icons-status-info); +} + +.aspid-fasttools-repair-references__empty-icon--success { + background-image: var(--aspid-icons-status-success); +} + +.aspid-fasttools-repair-references__empty-title { + margin-bottom: 4px; +} + +.aspid-fasttools-repair-references__empty-message { + max-width: 420px; + color: var(--aspid-colors-text-dark); + white-space: normal; + -unity-text-align: middle-center; +} + +.aspid-fasttools-repair-references__results { + flex-grow: 1; +} + +.aspid-fasttools-repair-references__results--hidden { + display: none; +} + +.aspid-fasttools-repair-references__results-header { + margin-bottom: 6px; +} + +.aspid-fasttools-repair-references__results-hint { + margin-bottom: 10px; + color: var(--aspid-colors-text-dark); + white-space: normal; +} + +/* Success summary reporting the count after a bulk Fix all; hidden until a bulk fix runs. */ +.aspid-fasttools-repair-references__summary { + margin-bottom: 10px; +} + +.aspid-fasttools-repair-references__summary--hidden { + display: none; +} + +/* The vertical scrollbar rides the scroll's right edge; a right padding insets it from the window edge so it does + not hug the border, and gives the group cards a small gap before it. */ +.aspid-fasttools-repair-references__scroll { + flex-grow: 1; + padding-right: 6px; +} + +/* Amber-tinted gradient rows — a broken reference reads as a warning — with a matching hover accent. */ +.aspid-fasttools-repair-references__entry { + --aspid-fasttools-colors-gradient_button-bg: var(--aspid-colors-status-warning-darkness); + --aspid-fasttools-colors-gradient_button-accent: var(--aspid-colors-status-warning-text-light); +} + +/* The dimmed rid rides between the type label and the trailing "Fix" cue; reset the row's bold weight. */ +.aspid-fasttools-repair-references__entry-rid { + margin-left: 10px; + color: var(--aspid-colors-text-dark); + -unity-font-style: normal; + -unity-text-align: middle-right; +} + +/* Inline type picker: the selector view expanded as an accordion full-width in the group card, below the header + row. A translucent fill (the cards'/panel's value) lets the window's dotted canvas read through it too, framed by + a neutral border and the card's 8px radius rather than the warning amber it used to carry. It composites over the + card's own translucent fill, so the open picker still sits a touch denser than the bare card — enough figure to + read as an active surface without going opaque. Fixed height — the selector's ListView needs bounds to virtualize. + The 5px padding matches the selector's own edge spacing so its full-bleed header strip reaches the box border; + overflow keeps that strip inside the rounded corners. */ +.aspid-fasttools-repair-references__picker.aspid-fasttools-background { + flex-grow: 0; + height: 300px; + padding: 5px; + overflow: hidden; + margin: 0 0 8px 0; + border-radius: 8px; + border-width: 1px; + border-color: var(--aspid-colors-shade-dark); + background-color: rgba(20, 20, 20, 0.55); +} + +/* Bleed the selector's header strip to the picker box edges (top + sides) so it reads as a filled header bar, not an + inset rectangle. -10px cancels both the picker box's 5px padding and the selector root's own 5px padding; the + picker's overflow:hidden + 8px radius round the header's top corners to match the box. */ +.aspid-fasttools-repair-references__picker .aspid-fasttools-type-selector__header { + margin: -10px -10px 5px; +} + +/* Stretch the search field to the full row width so it lines up with the header and the list rows below — Unity's + ToolbarSearchField otherwise keeps an intrinsic width that left the field visibly narrower than the items. */ +.aspid-fasttools-repair-references__picker .aspid-fasttools-type-selector ToolbarSearchField { + margin: 0 0 5px 0; + width: auto; + max-width: none; + align-self: stretch; +} + +/* Row hover and selection are owned by the base type-selector stylesheet now — a neutral surface lift plus a 2 px + green accent bar on the left edge — so this host no longer paints the rows. It only makes the list area read denser + than the translucent picker chrome: a near-opaque backing keeps the window's dotted canvas from bleeding through the + type names while the framing border, header and footer still composite over the dots. */ +.aspid-fasttools-repair-references__picker .aspid-fasttools-type-selector .unity-collection-view { + background-color: rgba(20, 20, 20, 0.5); +} + +/* Bleed the selector's footer hint to the picker box edges (bottom + sides), mirroring the header strip above. + -10px cancels both the picker box's 5px padding and the selector root's own 5px padding. */ +.aspid-fasttools-repair-references__picker .aspid-fasttools-type-selector__footer-hint { + margin: 5px -10px -10px; +} + +/* --- Project-mode group cards --- + Each broken stored type becomes a card that shares the Scan panel's surface: a translucent dark fill so the + window's dotted canvas reads through, a neutral border and the panel's 8px radius (overriding AspidBox's opaque + darkness fill and its 10px --rounded). The warning read comes from the amber type header and the amber-tinted + dotted canvas behind it, not a warm border — so the cards belong to the same family as the Scan panel above them. + Layout: a header row (type name + entry/file counts on the left, bulk Fix all + optional Smart Fix on the right) + over a list of ping-only entry rows. */ +.aspid-fasttools-repair-references__group.aspid-fasttools-background { + flex-grow: 0; + margin-bottom: 10px; + padding: 8px 10px; + border-radius: 8px; + border-width: 1px; + border-color: var(--aspid-colors-shade-darkness); + background-color: rgba(20, 20, 20, 0.55); +} + +.aspid-fasttools-repair-references__group-header-row { + flex-direction: row; + align-items: center; + flex-wrap: wrap; + margin-bottom: 6px; +} + +.aspid-fasttools-repair-references__group-header { + flex-shrink: 1; +} + +/* Dimmed "N entries · M files" count, riding between the type header and the trailing actions. */ +.aspid-fasttools-repair-references__group-count { + margin-left: 10px; + flex-grow: 1; + color: var(--aspid-colors-text-dark); + -unity-font-style: normal; +} + +.aspid-fasttools-repair-references__group-actions { + flex-direction: row; + align-items: center; +} + +/* Amber bulk-fix button, matching the single-asset entry rows' warning palette. */ +.aspid-fasttools-repair-references__group-fix-all { + min-width: 96px; + margin-bottom: 0; + --aspid-fasttools-colors-gradient_button-bg: var(--aspid-colors-status-warning-darkness); + --aspid-fasttools-colors-gradient_button-accent: var(--aspid-colors-status-warning-text-light); +} + +.aspid-fasttools-repair-references__group-fix-all .aspid-fasttools-gradient-button__label { + flex-grow: 0; + -unity-text-align: middle-center; +} + +/* Smart Fix quick-apply: a success-tinted suggestion sitting to the right of Fix all. */ +.aspid-fasttools-repair-references__group-suggest { + margin-left: 6px; + margin-bottom: 0; + --aspid-fasttools-colors-gradient_button-bg: var(--aspid-colors-status-success-darkness); + --aspid-fasttools-colors-gradient_button-accent: var(--aspid-colors-status-success-text-light); +} + +.aspid-fasttools-repair-references__group-suggest .aspid-fasttools-gradient-button__label { + flex-grow: 0; + -unity-text-align: middle-center; +} + +/* A single broken reference inside a group: asset path on the left, dim rid on the right; the whole row pings. */ +.aspid-fasttools-repair-references__group-entry { + flex-direction: row; + align-items: center; + padding: 2px 4px; + border-radius: 3px; + cursor: link; +} + +.aspid-fasttools-repair-references__group-entry:hover { + background-color: var(--aspid-colors-bg-dark); +} + +.aspid-fasttools-repair-references__group-entry-path { + flex-grow: 1; + color: var(--aspid-colors-text-light); + white-space: normal; + overflow: hidden; + text-overflow: ellipsis; +} + +.aspid-fasttools-repair-references__group-entry-rid { + margin-left: 10px; + color: var(--aspid-colors-text-dark); + -unity-text-align: middle-right; +} + +/* A compatible MonoScript dragged over the field highlights the header as a valid drop target. */ +.aspid-fasttools-serialize-reference--drop-target { + background-color: var(--aspid-colors-bg-light); + border-left-width: 2px; + border-left-color: var(--aspid-colors-status-info-light); +} diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Resources/UI/SerializeReferences/Aspid-FastTools-SerializeReference.uss.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Resources/UI/SerializeReferences/Aspid-FastTools-SerializeReference.uss.meta new file mode 100644 index 00000000..261dd60c --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Resources/UI/SerializeReferences/Aspid-FastTools-SerializeReference.uss.meta @@ -0,0 +1,12 @@ +fileFormatVersion: 2 +guid: 80c610f029234224a2ab1568059370a3 +ScriptedImporter: + internalIDToNameTable: [] + externalObjects: {} + serializedVersion: 2 + userData: + assetBundleName: + assetBundleVariant: + script: {fileID: 12385, guid: 0000000000000000e000000000000000, type: 0} + disableValidation: 0 + unsupportedSelectorAction: 0 diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Resources/UI/Types/Aspid-FastTools-TypeSelector.uss b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Resources/UI/Types/Aspid-FastTools-TypeSelector.uss new file mode 100644 index 00000000..2315e5c4 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Resources/UI/Types/Aspid-FastTools-TypeSelector.uss @@ -0,0 +1,208 @@ +.aspid-fasttools-type-selector { + padding: 5px; + flex-grow: 1; + flex-direction: column; +} + +/* ---------------------------------------------------- Header ------------------------------------------------------ */ +/* Breadcrumb bar: a single neutral strip carrying the clickable navigation trail. It bleeds to the box edges in the + embedding hosts (the host stylesheet cancels the surrounding padding); here it only owns its own fill and spacing. */ +.aspid-fasttools-type-selector__header { + min-height: 20px; + flex-direction: row; + align-items: center; + flex-wrap: nowrap; + overflow: hidden; + padding: 2px 8px; + margin: -5px -5px 5px; + background-color: var(--aspid-colors-bg-dark); +} + +/* A breadcrumb crumb. Ancestors are dim and shrink/ellipsis under pressure; the current crumb is bright and pinned. */ +.aspid-fasttools-type-selector__breadcrumb { + font-size: 12px; + -unity-font-style: bold; + color: var(--aspid-colors-text-dark); + flex-shrink: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* The current crumb is bright; it still ellipsises under pressure (a flattened single-child namespace chain can be + very long) — its full text rides the tooltip so nothing is lost. */ +.aspid-fasttools-type-selector__breadcrumb--current { + color: var(--aspid-colors-text-lightness); +} + +.aspid-fasttools-type-selector__breadcrumb--link { + cursor: link; +} + +.aspid-fasttools-type-selector__breadcrumb--link:hover { + color: var(--aspid-colors-status-info-text-light); +} + +.aspid-fasttools-type-selector__breadcrumb-separator { + margin: 0 4px; + flex-shrink: 0; + color: var(--aspid-colors-text-darkness); +} +/* ------------------------------------------------------------------------------------------------------------------ */ + +.aspid-fasttools-type-selector ToolbarSearchField { + width: auto; + margin-bottom: 5px; +} + +/* The list fills the space between the search field and the footer hint; the empty-state hint takes its place when the + list is empty (both flex-grow, only one is shown at a time). */ +.aspid-fasttools-type-selector .unity-list-view { + flex-grow: 1; +} + +/* Inline error (e.g. a generic-argument constraint violation). Visibility is toggled in code; only the + error palette and spacing live here. */ +.aspid-fasttools-type-selector__error { + margin: 2px 4px; + white-space: normal; + color: var(--aspid-colors-status-error-text-light); +} + +/* Centred stand-in shown when the list is empty (most often a search miss). */ +.aspid-fasttools-type-selector__empty-hint { + flex-grow: 1; + padding: 20px; + white-space: normal; + -unity-text-align: middle-center; + color: var(--aspid-colors-text-dark); +} + +/* Static keyboard affordance pinned to the bottom edge; bleeds to the box edges in embedding hosts. */ +.aspid-fasttools-type-selector__footer-hint { + margin: 5px -5px -5px; + padding: 3px 8px; + font-size: 10px; + color: var(--aspid-colors-text-darkness); + background-color: var(--aspid-colors-bg-dark); + border-top-width: 1px; + border-top-color: var(--aspid-colors-shade-darkness); +} + +/* ------------------------------------------------------ Item ------------------------------------------------------ */ +/* Every row reserves a 2 px transparent left rail so the selection accent bar can colour it in without shifting the + row's content (padding-left + rail = the old 5 px inset). Hover lifts the surface a step; the selected row lifts one + more and lights its rail in the signature green — a single restrained accent rather than a full gradient wash. */ +.aspid-fasttools-type-selector .unity-collection-view__item { + height: 22px; + align-items: center; + flex-direction: row; + padding-left: 3px; + padding-right: 5px; + border-left-width: 2px; + border-left-color: rgba(0, 0, 0, 0); +} + +.aspid-fasttools-type-selector .unity-collection-view__item:hover { + background-color: var(--aspid-colors-bg-light); +} + +.aspid-fasttools-type-selector .unity-collection-view__item--selected, +.aspid-fasttools-type-selector .unity-collection-view__item--selected:hover { + background-color: var(--aspid-colors-bg-lightness); + border-left-color: var(--aspid-colors-status-success-text-light); +} + +.aspid-fasttools-type-selector__item { + flex-grow: 1; + align-items: center; + flex-direction: row; +} + +.aspid-fasttools-type-selector__item-icon { + width: 14px; + height: 14px; + margin-right: 4px; + flex-shrink: 0; +} + +/* Text-glyph alternative to the image icon (the circle, the section chevron). It mirrors the image icon's box + exactly — the same 14x14 leading slot, the glyph middle-centered inside it — so a font glyph centres by its box like + the icon does, instead of by a taller text line-box that left it riding above the caption. A font glyph stays crisp + at any size, so the font is sized up a touch to read at the icons' weight while the fixed box keeps it aligned. */ +.aspid-fasttools-type-selector__item-glyph { + width: 14px; + height: 14px; + margin-right: 4px; + flex-shrink: 0; + font-size: 16px; + -unity-text-align: middle-center; + color: var(--aspid-colors-text-light); +} + +.aspid-fasttools-type-selector__item-title { + color: var(--aspid-colors-text-lightness); + flex-grow: 1; +} + +.aspid-fasttools-type-selector__item-arrow { + color: var(--aspid-colors-text-dark); +} + +/* is a plain leaf row like any type — same label colour, same icon rail — distinguished only by its minus + glyph (bound in code) and its label, so it sits with the list instead of reading as a different kind of element. */ +/* ------------------------------------------------------------------------------------------------------------------ */ + +/* ----------------------------------------------- Favorites & Recents ---------------------------------------------- */ +.aspid-fasttools-type-selector__favorite-toggle { + margin: 0; + padding: 0; + width: 16px; + height: 16px; + flex-shrink: 0; + border-width: 0; + font-size: 13px; + -unity-text-align: middle-center; + background-color: rgba(0, 0, 0, 0); + color: var(--aspid-colors-text-darkness); + opacity: 0; +} + +/* Reveal the star on row hover (or whenever the type is already favorited). */ +.aspid-fasttools-type-selector__item:hover .aspid-fasttools-type-selector__favorite-toggle { + opacity: 1; +} + +/* The gold star is the one place amber survives in the picker chrome — it is a favourite marker, not a warning. */ +.aspid-fasttools-type-selector__favorite-toggle--favorite-on { + opacity: 1; + color: var(--aspid-colors-status-warning-text-light); +} + +.aspid-fasttools-type-selector__favorite-toggle:hover { + color: var(--aspid-colors-status-warning-text-lightness); +} + +/* Section header (Favorites / Recents) reads as a normal row — same height, font size and background as the rest of + the list — set apart only by its leading collapse chevron, a bold weight and a slightly dimmer caption. No caps, no + shrunken font, no special fill, so the list stays visually uniform. */ +.aspid-fasttools-type-selector__section-title { + align-items: center; + flex-direction: row; + background-color: rgba(0, 0, 0, 0); + cursor: link; +} + +/* Same caption colour as the rest of the list — the section is set apart only by its chevron and bold weight, not a + different hue. (It is navigable/selectable too; the wrapper carries the green accent bar when it is the active row.) */ +.aspid-fasttools-type-selector__section-title .aspid-fasttools-type-selector__item-title { + color: var(--aspid-colors-text-lightness); + -unity-font-style: bold; +} + +/* Favorites/Recents item rows: a small indent so they read as nested under their header. The selection accent bar + (on the collection-view wrapper) is the only vertical line, so the rows carry no left rule of their own. */ +.aspid-fasttools-type-selector__item--in-section { + margin-left: 6px; +} +/* ------------------------------------------------------------------------------------------------------------------ */ diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Resources/UI/Types/Aspid-FastTools-TypeSelectorWindow.uss.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Resources/UI/Types/Aspid-FastTools-TypeSelector.uss.meta similarity index 100% rename from Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Resources/UI/Types/Aspid-FastTools-TypeSelectorWindow.uss.meta rename to Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Resources/UI/Types/Aspid-FastTools-TypeSelector.uss.meta diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Resources/UI/Types/Aspid-FastTools-TypeSelectorWindow.uss b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Resources/UI/Types/Aspid-FastTools-TypeSelectorWindow.uss deleted file mode 100644 index 6a812724..00000000 --- a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Resources/UI/Types/Aspid-FastTools-TypeSelectorWindow.uss +++ /dev/null @@ -1,65 +0,0 @@ -:root { - padding: 5px; - flex-grow: 1; - flex-direction: column; -} - -.aspid-fasttools-type-selector-header { - padding-left: 10; - height: 20px; - min-height: 20px; - align-items: center; - flex-direction: row; - margin: -5px -5px 5px; - background-color: var(--aspid-colors-bg-dark); -} - -/* -------------------------------------------------- Back Button --------------------------------------------------- */ -.aspid-fasttools-type-selector-header > Button { - margin: 0; - width: 20px; - height: 16px; - border-width: 0; - margin-right: 4px; - background-color: rgba(0, 0, 0, 0); - color: var(--aspid-colors-text-lightness); -} - -.aspid-fasttools-type-selector-header > Button:enabled:hover, -.aspid-fasttools-type-selector-header > Button:enabled:focus { - border-width: 1px; - color: var(--aspid-colors-status-info-text-lightness); - border-color: var(--aspid-colors-status-info-shade-lightness); -} -/* ------------------------------------------------------------------------------------------------------------------ */ - -/* ----------------------------------------------------- Title ------------------------------------------------------ */ -.aspid-fasttools-type-selector-header > Label { - flex-grow: 1; - -unity-font-style: bold; -} -/* ------------------------------------------------------------------------------------------------------------------ */ - -ToolbarSearchField { - width: auto; - margin-bottom: 5px; -} - -/* ------------------------------------------------------ Item ------------------------------------------------------ */ -.unity-collection-view__item { - height: 20px; - align-items: center; - padding-left: 5px; - padding-right: 5px; - flex-direction: row; -} - -.aspid-fasttools-type-selector-item-title { - color: var(--aspid-colors-text-lightness); - flex-grow: 1; -} - -.aspid-fasttools-type-selector-item-arrow { - color: var(--aspid-colors-text-light); -} -/* ------------------------------------------------------------------------------------------------------------------ */ diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences.meta new file mode 100644 index 00000000..e3cfd0d3 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: ee1385cfb3fb4d79b8831e0de0102dcf +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Build.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Build.meta new file mode 100644 index 00000000..7e34db09 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Build.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 02e137d88e99341b9811e5ca81d0705e +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Build/SerializeReferenceBuildGate.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Build/SerializeReferenceBuildGate.cs new file mode 100644 index 00000000..ae2af69f --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Build/SerializeReferenceBuildGate.cs @@ -0,0 +1,53 @@ +using System.Text; +using UnityEngine; +using UnityEditor.Build; +using UnityEditor.Build.Reporting; +using System.Collections.Generic; + +// ReSharper disable once CheckNamespace +namespace Aspid.FastTools.SerializeReferences.Editors +{ + /// + /// Fails or warns a player build when the project contains missing managed-reference types, governed by the + /// Project Settings gate severity. Runs the fast pure-YAML missing-type scan (required-field scanning is reserved + /// for the CI entry point, which opts in explicitly, to keep build start fast). + /// + internal sealed class SerializeReferenceBuildGate : IPreprocessBuildWithReport + { + public int callbackOrder => 0; + + public void OnPreprocessBuild(BuildReport report) + { + var severity = SerializeReferenceSettings.BuildSeverity; + if (severity == GateSeverity.Off) return; + + var violations = SerializeReferenceGateScanner.Scan(GateOptions.MissingOnly); + if (violations.Count == 0) return; + + var summary = BuildSummary(violations); + + if (severity == GateSeverity.Fail) + throw new BuildFailedException(summary); + + Debug.LogWarning(summary); + } + + internal static string BuildSummary(IReadOnlyList violations) + { + var files = new HashSet(); + var types = new HashSet(); + foreach (var violation in violations) + { + files.Add(violation.AssetPath); + types.Add(SerializeReferenceHelpers.StoredTypeKey(violation.StoredType)); + } + + var builder = new StringBuilder(); + builder.AppendLine($"[Aspid FastTools] {violations.Count} missing managed reference(s) across {files.Count} file(s), {types.Count} broken type(s):"); + foreach (var violation in violations) + builder.AppendLine($" {violation}"); + + return builder.ToString(); + } + } +} diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Build/SerializeReferenceBuildGate.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Build/SerializeReferenceBuildGate.cs.meta new file mode 100644 index 00000000..0527f8f1 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Build/SerializeReferenceBuildGate.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a87149109bf45425cb63507ba928d3b6 \ No newline at end of file diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Build/SerializeReferenceCiGate.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Build/SerializeReferenceCiGate.cs new file mode 100644 index 00000000..75735db4 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Build/SerializeReferenceCiGate.cs @@ -0,0 +1,102 @@ +using System; +using System.IO; +using System.Text; +using UnityEditor; +using UnityEngine; +using System.Collections.Generic; + +// ReSharper disable once CheckNamespace +namespace Aspid.FastTools.SerializeReferences.Editors +{ + /// + /// Headless CI entry point. Invoke with + /// Unity -batchmode -quit -projectPath . -executeMethod Aspid.FastTools.SerializeReferences.Editors.SerializeReferenceCiGate.RunCheck. + /// Scans the project, writes a report, logs each violation, and exits non-zero when violations exist so a pipeline + /// can fail the job. Flags: -srGateReport <path> (report file), -srGateRequired (also scan + /// unset-required fields), -srGateWarnOnly (always exit 0). + /// + internal static class SerializeReferenceCiGate + { + private const string DefaultReportPath = "SerializeReferenceGateReport.txt"; + + // ReSharper disable once UnusedMember.Global — invoked via -executeMethod. + public static void RunCheck() + { + if (!Application.isBatchMode) + { + Debug.LogWarning("[Aspid FastTools] SerializeReferenceCiGate.RunCheck is intended for -batchmode; ignoring."); + return; + } + + int exitCode; + try + { + var args = Environment.GetCommandLineArgs(); + var reportPath = GetArgValue(args, "-srGateReport") ?? DefaultReportPath; + var scanRequired = HasFlag(args, "-srGateRequired"); + var warnOnly = HasFlag(args, "-srGateWarnOnly"); + + var options = scanRequired ? GateOptions.Full : GateOptions.MissingOnly; + var violations = SerializeReferenceGateScanner.Scan(options); + + File.WriteAllText(reportPath, BuildReport(violations)); + foreach (var violation in violations) + Debug.LogError($"[Aspid FastTools] {violation}"); + + exitCode = ComputeExitCode(violations.Count, warnOnly); + Debug.Log($"[Aspid FastTools] Gate check complete: {violations.Count} violation(s), exit code {exitCode}. Report: {reportPath}"); + } + catch (Exception exception) + { + Debug.LogError($"[Aspid FastTools] Gate check failed: {exception}"); + exitCode = 2; // distinguish an internal failure from a clean violation result + } + + EditorApplication.Exit(exitCode); + } + + /// 0 when clean or warn-only; 1 when violations exist. Extracted so it is unit-testable without exiting. + internal static int ComputeExitCode(int violationCount, bool warnOnly) => + warnOnly || violationCount == 0 ? 0 : 1; + + internal static string BuildReport(IReadOnlyList violations) + { + var builder = new StringBuilder(); + builder.AppendLine($"# SerializeReference Gate Report"); + builder.AppendLine($"# Violations: {violations.Count}"); + builder.AppendLine(); + + foreach (var violation in violations) + { + // Machine-readable line: KINDassetPathfileIdridStoredTypefieldPath + builder.Append(violation.Kind).Append('\t') + .Append(violation.AssetPath).Append('\t') + .Append(violation.FileId).Append('\t') + .Append(violation.Rid).Append('\t') + .Append(violation.StoredType.Class ?? string.Empty).Append('\t') + .Append(violation.FieldPath ?? string.Empty) + .AppendLine(); + } + + return builder.ToString(); + } + + private static string GetArgValue(string[] args, string flag) + { + for (var i = 0; i < args.Length - 1; i++) + if (string.Equals(args[i], flag, StringComparison.OrdinalIgnoreCase)) + return args[i + 1]; + + return null; + } + + private static bool HasFlag(string[] args, string flag) + { + foreach (var arg in args) + if (string.Equals(arg, flag, StringComparison.OrdinalIgnoreCase)) + return true; + + return false; + } + } +} diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Build/SerializeReferenceCiGate.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Build/SerializeReferenceCiGate.cs.meta new file mode 100644 index 00000000..0ba98d4d --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Build/SerializeReferenceCiGate.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d81328d633e97411cab4a9872a63e7a6 \ No newline at end of file diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Build/SerializeReferenceGateScanner.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Build/SerializeReferenceGateScanner.cs new file mode 100644 index 00000000..a8f3ff46 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Build/SerializeReferenceGateScanner.cs @@ -0,0 +1,118 @@ +using System; +using System.Linq; +using UnityEditor; +using System.Collections.Generic; + +// ReSharper disable once CheckNamespace +namespace Aspid.FastTools.SerializeReferences.Editors +{ + /// What a gate violation is: a missing managed-reference type, or an unset required reference. + internal enum GateViolationKind + { + MissingType, + RequiredUnset, + } + + /// Which checks the gate runs. + internal readonly struct GateOptions + { + public readonly bool ScanMissingTypes; + public readonly bool ScanRequiredFields; + + public GateOptions(bool scanMissingTypes, bool scanRequiredFields) + { + ScanMissingTypes = scanMissingTypes; + ScanRequiredFields = scanRequiredFields; + } + + public static GateOptions MissingOnly => new(true, false); + public static GateOptions Full => new(true, true); + } + + /// One gate violation located during a project scan. + internal readonly struct GateViolation + { + public readonly string AssetPath; + public readonly long FileId; + public readonly long Rid; + public readonly ManagedTypeName StoredType; + public readonly GateViolationKind Kind; + public readonly string FieldPath; + + public GateViolation(string assetPath, long fileId, long rid, ManagedTypeName storedType, GateViolationKind kind, string fieldPath) + { + AssetPath = assetPath; + FileId = fileId; + Rid = rid; + StoredType = storedType; + Kind = kind; + FieldPath = fieldPath; + } + + public override string ToString() + { + var where = string.IsNullOrEmpty(FieldPath) ? $"rid {Rid}" : FieldPath; + var what = Kind == GateViolationKind.MissingType ? $"missing type {StoredType.Class}" : "required reference not set"; + return $"{AssetPath} : {where} -> {what}"; + } + } + + /// + /// Window-free, headless-safe project scanner for managed-reference gate violations, shared by the build gate and + /// the CI entry point. Missing-type detection reuses the pure-YAML + /// ; required-field detection loads each asset's + /// objects and checks per managed-reference property. + /// + internal static class SerializeReferenceGateScanner + { + /// + /// Scans every candidate asset under Assets/ and returns all gate violations for the enabled checks. + /// (fraction, label) may be null for a headless run. + /// + public static IReadOnlyList Scan(GateOptions options, Action onProgress = null) + { + var violations = new List(); + var paths = AssetDatabase.GetAllAssetPaths().Where(SerializeReferenceHelpers.IsScanCandidate).ToArray(); + + for (var i = 0; i < paths.Length; i++) + { + var path = paths[i]; + onProgress?.Invoke((float)i / Math.Max(1, paths.Length), path); + + if (options.ScanMissingTypes) + foreach (var entry in SerializeReferenceYamlEditor.FindMissingReferences(path, SerializeReferenceHelpers.StoredTypeResolves)) + violations.Add(new GateViolation(path, entry.FileId, entry.Rid, entry.StoredType, GateViolationKind.MissingType, string.Empty)); + + if (options.ScanRequiredFields) + CollectRequiredViolations(path, violations); + } + + return violations; + } + + private static void CollectRequiredViolations(string assetPath, List violations) + { + // Loading objects + walking SerializedObjects is heavier than the pure-YAML missing scan, so this check is + // opt-in (off by default for the fast build-time mode). + foreach (var asset in AssetDatabase.LoadAllAssetsAtPath(assetPath)) + { + if (asset == null) continue; + if (!AssetDatabase.TryGetGUIDAndLocalFileIdentifier(asset, out _, out long fileId)) continue; + + using var serializedObject = new SerializedObject(asset); + using var iterator = serializedObject.GetIterator(); + if (!iterator.Next(enterChildren: true)) continue; + + do + { + if (iterator.propertyType != SerializedPropertyType.ManagedReference) continue; + if (!SerializeReferenceRequiredGate.IsViolation(iterator)) continue; + + violations.Add(new GateViolation(assetPath, fileId, iterator.managedReferenceId, default, + GateViolationKind.RequiredUnset, iterator.propertyPath)); + } + while (iterator.Next(enterChildren: true)); + } + } + } +} diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Build/SerializeReferenceGateScanner.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Build/SerializeReferenceGateScanner.cs.meta new file mode 100644 index 00000000..bf2a7273 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Build/SerializeReferenceGateScanner.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9ffb2be9bcb684acb880963c648c0b4a \ No newline at end of file diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Diagnostics.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Diagnostics.meta new file mode 100644 index 00000000..d4d707e8 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Diagnostics.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 247e8000c48be4b0e9f94854b7072341 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Diagnostics/BreakageReport.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Diagnostics/BreakageReport.cs new file mode 100644 index 00000000..ef26ebcd --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Diagnostics/BreakageReport.cs @@ -0,0 +1,53 @@ +using System.Collections.Generic; + +// ReSharper disable once CheckNamespace +namespace Aspid.FastTools.SerializeReferences.Editors +{ + /// One managed reference that just became missing, plus its pre-computed best fix candidate (if any). + internal readonly struct BreakageEntry + { + public readonly string AssetPath; + public readonly long FileId; + public readonly long Rid; + public readonly ManagedTypeName StoredType; + + /// False for entries the per-asset repair flow cannot reach (currently scene-hosted references). + public readonly bool IsRepairable; + + /// The top Smart-Fix suggestion (e.g. a declared [MovedFrom] rename), pre-ranked at detection time. + public readonly SerializeReferenceRepairSuggestions.RepairCandidate? TopSuggestion; + + public BreakageEntry( + string assetPath, + long fileId, + long rid, + ManagedTypeName storedType, + bool isRepairable, + SerializeReferenceRepairSuggestions.RepairCandidate? topSuggestion) + { + AssetPath = assetPath; + FileId = fileId; + Rid = rid; + StoredType = storedType; + IsRepairable = isRepairable; + TopSuggestion = topSuggestion; + } + + public string TypeName => StoredType.Class; + } + + /// The set of managed references that became missing since the last scan, grouped count metadata included. + internal readonly struct BreakageReport + { + public readonly IReadOnlyList Entries; + public readonly int TypeCount; + + public BreakageReport(IReadOnlyList entries, int typeCount) + { + Entries = entries; + TypeCount = typeCount; + } + + public bool HasAny => Entries is { Count: > 0 }; + } +} diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Diagnostics/BreakageReport.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Diagnostics/BreakageReport.cs.meta new file mode 100644 index 00000000..4ef6d95c --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Diagnostics/BreakageReport.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 42555250e3642430e930434e66a65bf3 \ No newline at end of file diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Diagnostics/SerializeReferenceBreakageDetector.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Diagnostics/SerializeReferenceBreakageDetector.cs new file mode 100644 index 00000000..1750d5cf --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Diagnostics/SerializeReferenceBreakageDetector.cs @@ -0,0 +1,141 @@ +using System; +using UnityEditor; +using UnityEngine; +using System.Collections.Generic; + +// ReSharper disable once CheckNamespace +namespace Aspid.FastTools.SerializeReferences.Editors +{ + /// + /// Observational safety net: detects managed references that JUST became missing (a script renamed/deleted) by + /// diffing the current resolve state against a per-session baseline of what resolved before. Raises + /// with pre-ranked fix candidates — it never repairs anything itself. + /// + /// + /// The usage index is reset on every domain reload, so it cannot remember a prior state; the detector keeps its own + /// baseline of resolvable stored-type keys in . The baseline is established silently on the + /// first run of a session, so pre-existing breakages never alarm — only a key that WAS resolvable and is now missing + /// is reported. Driven by on relevant asset/script changes. + /// + internal static class SerializeReferenceBreakageDetector + { + /// Raised when one or more references became missing since the last scan. + public static event Action BreakageDetected; + + private const string EstablishedKey = "Aspid.FastTools.SerializeReferences.Breakage.Established"; + private const string BaselineKey = "Aspid.FastTools.SerializeReferences.Breakage.Baseline"; + private const char BaselineSeparator = '\n'; + + [InitializeOnLoadMethod] + private static void EstablishBaselineOnce() + { + EditorApplication.delayCall += () => + { + if (Application.isBatchMode) return; + if (SessionState.GetBool(EstablishedKey, false)) return; + + // First run of the session: record what currently resolves, report nothing (pre-existing breakages are + // not "new"). + RunDetection(report: false); + }; + } + + /// Re-scans and reports any newly-missing references. Called by the reimport hook on relevant changes. + public static void Scan() => RunDetection(report: true); + + private static void RunDetection(bool report) + { + if (Application.isBatchMode) return; + + var resolvable = new HashSet(StringComparer.Ordinal); + var unresolved = new List(); + + foreach (var usage in SerializeReferenceTypeUsageIndex.AllUsages()) + { + if (usage.Resolves) resolvable.Add(SerializeReferenceHelpers.StoredTypeKey(usage.StoredType)); + else unresolved.Add(usage); + } + + var established = SessionState.GetBool(EstablishedKey, false); + BreakageReport result = default; + + if (established && report) + { + var baseline = LoadBaseline(); + result = BuildReport(unresolved, baseline); + } + + // Always advance the baseline to the current resolvable set so a key that just broke (now unresolved) drops + // out and is never re-alarmed on the next scan. + SaveBaseline(resolvable); + SessionState.SetBool(EstablishedKey, true); + + if (result.HasAny) BreakageDetected?.Invoke(result); + } + + // Builds the report from the unresolved usages whose stored type was resolvable in the baseline (i.e. just broke). + private static BreakageReport BuildReport( + List unresolved, + HashSet baseline) + { + var entries = new List(); + var types = new HashSet(StringComparer.Ordinal); + + foreach (var usage in unresolved) + { + var key = SerializeReferenceHelpers.StoredTypeKey(usage.StoredType); + if (!baseline.Contains(key)) continue; // was already broken (or never resolved) — not new + + entries.Add(BuildEntry(usage)); + types.Add(key); + } + + return entries.Count == 0 ? default : new BreakageReport(entries, types.Count); + } + + // Resolves the asset path, decides repairability and pre-ranks the best fix (priming the shared suggestion cache + // so the Repair window shows Smart Fix without a delay). + private static BreakageEntry BuildEntry(SerializeReferenceTypeUsageIndex.Usage usage) + { + var path = AssetDatabase.GUIDToAssetPath(usage.Guid); + var repairable = !string.IsNullOrEmpty(path) && !path.EndsWith(".unity", StringComparison.OrdinalIgnoreCase); + + SerializeReferenceRepairSuggestions.RepairCandidate? top = null; + if (repairable) + { + try + { + var fieldNames = SerializeReferenceYamlEditor.GetReferenceFieldNames(path, usage.FileId, usage.Rid); + var constraints = SerializeReferenceHelpers.BuildConstraintMap(path); + constraints.TryGetValue((usage.FileId, usage.Rid), out var constraint); + + var ranked = SerializeReferenceRepairSuggestions.GetCached(path, usage.FileId, usage.Rid, + () => SerializeReferenceRepairSuggestions.Rank(usage.StoredType, fieldNames, constraint ?? typeof(object), 5)); + + if (ranked.Count > 0) top = ranked[0]; + } + catch (Exception) + { + // Suggestion priming is best-effort; a parse miss must not suppress the breakage notice itself. + } + } + + return new BreakageEntry(path, usage.FileId, usage.Rid, usage.StoredType, repairable, top); + } + + private static HashSet LoadBaseline() + { + var raw = SessionState.GetString(BaselineKey, string.Empty); + var set = new HashSet(StringComparer.Ordinal); + if (string.IsNullOrEmpty(raw)) return set; + + foreach (var key in raw.Split(BaselineSeparator)) + if (key.Length > 0) set.Add(key); + + return set; + } + + private static void SaveBaseline(HashSet resolvable) => + SessionState.SetString(BaselineKey, string.Join(BaselineSeparator.ToString(), resolvable)); + } +} diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Diagnostics/SerializeReferenceBreakageDetector.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Diagnostics/SerializeReferenceBreakageDetector.cs.meta new file mode 100644 index 00000000..de37d301 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Diagnostics/SerializeReferenceBreakageDetector.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 68f991886735b45c69c57d65c217a983 \ No newline at end of file diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Diagnostics/SerializeReferenceBreakageHook.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Diagnostics/SerializeReferenceBreakageHook.cs new file mode 100644 index 00000000..352b6bb2 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Diagnostics/SerializeReferenceBreakageHook.cs @@ -0,0 +1,40 @@ +using System; +using System.Linq; +using UnityEditor; +using UnityEngine; + +// ReSharper disable once CheckNamespace +namespace Aspid.FastTools.SerializeReferences.Editors +{ + /// + /// Drives when assets or scripts change: a renamed/deleted script + /// surfaces as a .cs in the deleted/moved lists, and a re-saved prefab/asset/scene as a candidate import. The + /// scan is debounced to one run per change burst via . + /// + internal sealed class SerializeReferenceBreakageHook : AssetPostprocessor + { + private static bool _scheduled; + + private static void OnPostprocessAllAssets(string[] imported, string[] deleted, string[] moved, string[] movedFrom) + { + if (Application.isBatchMode) return; + + var relevant = HasScript(deleted) || HasScript(moved) + || HasCandidate(imported) || HasCandidate(deleted) || HasCandidate(moved); + if (!relevant || _scheduled) return; + + _scheduled = true; + EditorApplication.delayCall += () => + { + _scheduled = false; + SerializeReferenceBreakageDetector.Scan(); + }; + } + + private static bool HasScript(string[] paths) => + paths.Any(path => path.EndsWith(".cs", StringComparison.OrdinalIgnoreCase)); + + private static bool HasCandidate(string[] paths) => + paths.Any(SerializeReferenceHelpers.IsScanCandidate); + } +} diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Diagnostics/SerializeReferenceBreakageHook.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Diagnostics/SerializeReferenceBreakageHook.cs.meta new file mode 100644 index 00000000..24d5a8fe --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Diagnostics/SerializeReferenceBreakageHook.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c1c12372d0204481eb8ced7672aec078 \ No newline at end of file diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Diagnostics/SerializeReferenceBreakageNotificationController.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Diagnostics/SerializeReferenceBreakageNotificationController.cs new file mode 100644 index 00000000..8c9294bc --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Diagnostics/SerializeReferenceBreakageNotificationController.cs @@ -0,0 +1,80 @@ +using System.Text; +using UnityEditor; +using UnityEngine; +using System.Collections.Generic; + +// ReSharper disable once CheckNamespace +namespace Aspid.FastTools.SerializeReferences.Editors +{ + /// + /// Surfaces a breakage report non-intrusively: a fade-out toast that + /// steals no focus, plus a single clickable warning in the console pointing at the Repair window. The same breakage + /// set is shown at most once per session (a content hash in ), so a recompile that + /// re-detects the identical set does not nag. + /// + internal static class SerializeReferenceBreakageNotificationController + { + private const string ShownPrefix = "Aspid.FastTools.SerializeReferences.Breakage.Shown."; + private const double FadeOutSeconds = 5.0; + + [InitializeOnLoadMethod] + private static void Hook() => SerializeReferenceBreakageDetector.BreakageDetected += OnBreakageDetected; + + private static void OnBreakageDetected(BreakageReport report) + { + if (!report.HasAny || Application.isBatchMode) return; + + var shownKey = ShownPrefix + ProjectId() + "." + ContentHash(report); + if (SessionState.GetBool(shownKey, false)) return; + SessionState.SetBool(shownKey, true); + + var count = report.Entries.Count; + var typeWord = report.TypeCount == 1 ? "type" : "types"; + var message = $"{count} managed reference{(count == 1 ? "" : "s")} became missing " + + $"({report.TypeCount} {typeWord}) — open Repair"; + + ShowToast(message); + Debug.LogWarning($"[Aspid FastTools] {message}. Open Tools/Aspid \U0001F40D/Repair Missing References FastTools."); + } + + /// Public deep-link the user can wire to a button/menu — opens Repair straight into project-scan mode. + public static void OpenRepair() => SerializeReferenceWindow.OpenProjectScan(); + + private static void ShowToast(string message) + { + var content = new GUIContent(message); + + var sceneView = SceneView.lastActiveSceneView; + if (sceneView != null) + { + sceneView.ShowNotification(content, FadeOutSeconds); + sceneView.Repaint(); + return; + } + + foreach (var window in Resources.FindObjectsOfTypeAll()) + { + if (window == null) continue; + window.ShowNotification(content, FadeOutSeconds); + window.Repaint(); + return; + } + // No editor window open (rare) — the console warning above is the fallback signal. + } + + // A stable identity for the breakage set: its sorted, distinct stored-type keys. Two events affecting the same + // types are the "same" set and are not re-toasted. + private static string ContentHash(BreakageReport report) + { + var keys = new SortedSet(System.StringComparer.Ordinal); + foreach (var entry in report.Entries) + keys.Add(SerializeReferenceHelpers.StoredTypeKey(entry.StoredType)); + + var builder = new StringBuilder(); + foreach (var key in keys) builder.Append(key).Append(';'); + return builder.ToString().GetHashCode().ToString("X8"); + } + + private static string ProjectId() => PlayerSettings.productGUID.ToString(); + } +} diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Diagnostics/SerializeReferenceBreakageNotificationController.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Diagnostics/SerializeReferenceBreakageNotificationController.cs.meta new file mode 100644 index 00000000..777fe06c --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Diagnostics/SerializeReferenceBreakageNotificationController.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 2618a9e72d22542ba92fa47f475e12d3 \ No newline at end of file diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Drawers.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Drawers.meta new file mode 100644 index 00000000..dac523a3 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Drawers.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 4765d7eeba4748d3acd9142e2d2dca03 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Drawers/SerializeReferenceDropHandler.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Drawers/SerializeReferenceDropHandler.cs new file mode 100644 index 00000000..e76758e7 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Drawers/SerializeReferenceDropHandler.cs @@ -0,0 +1,56 @@ +using System; +using UnityEditor; +using Aspid.FastTools.Editors; + +// ReSharper disable once CheckNamespace +namespace Aspid.FastTools.SerializeReferences.Editors +{ + /// + /// Shared logic for assigning a managed reference by dropping a onto a + /// [SerializeReference] field. Resolves the dropped script's class, checks it is an assignable + /// managed-reference type, and writes an instance (per-target under a multi-object selection so the drop never + /// aliases). Used by both the UIToolkit field and the IMGUI drawer. + /// + internal static class SerializeReferenceDropHandler + { + /// + /// Resolves the type of the first dragged when it is assignable to the field, honoring + /// the optional [TypeSelector] base-type narrowing. Returns false (and a null type) otherwise. + /// + public static bool TryResolveDroppedType(Type fieldType, Type[] baseTypes, out Type type) + { + type = null; + + foreach (var dragged in DragAndDrop.objectReferences) + { + if (dragged is not MonoScript script) continue; + + var candidate = script.GetClass(); + if (candidate is null) continue; + if (!SerializeReferenceHelpers.IsAssignableManagedReference(candidate)) continue; + if (fieldType != null && !fieldType.IsAssignableFrom(candidate)) continue; + if (!SerializeReferenceHelpers.BuildAssignableFilter(baseTypes)(candidate)) continue; + + type = candidate; + return true; + } + + return false; + } + + /// Assigns a fresh instance of to the field (per-target on a multi-selection). + public static void Assign(SerializedProperty property, Type type) + { + if (property is null || type is null) return; + + var persistent = property.Persistent(); + var previous = persistent.managedReferenceValue; + + if (SerializeReferenceHelpers.IsEditingMultipleObjects(persistent)) + SerializeReferenceHelpers.ApplyManagedReferencePerTarget(persistent, + target => SerializeReferenceHelpers.CreateInstancePreservingData(type, target)); + else + persistent.SetManagedReferenceAndApply(SerializeReferenceHelpers.CreateInstancePreservingData(type, previous)); + } + } +} diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Drawers/SerializeReferenceDropHandler.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Drawers/SerializeReferenceDropHandler.cs.meta new file mode 100644 index 00000000..219b5c82 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Drawers/SerializeReferenceDropHandler.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f2729f70a1e8c464f8bcf793a78d3820 \ No newline at end of file diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Drawers/SerializeReferenceIMGUIPropertyDrawer.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Drawers/SerializeReferenceIMGUIPropertyDrawer.cs new file mode 100644 index 00000000..e8ec4b41 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Drawers/SerializeReferenceIMGUIPropertyDrawer.cs @@ -0,0 +1,501 @@ +using System; +using UnityEditor; +using UnityEngine; +using Aspid.FastTools.Types; +using Aspid.FastTools.Editors; +using Aspid.FastTools.Types.Editors; + +// ReSharper disable once CheckNamespace +namespace Aspid.FastTools.SerializeReferences.Editors +{ + /// + /// IMGUI rendering for the [TypeSelector] drawer on a [SerializeReference] field: a + /// foldout-and-dropdown header row, an optional missing-type warning, and the nested properties of the + /// assigned instance. The optional base types narrow the candidate list below the declared field type. + /// + internal static class SerializeReferenceIMGUIPropertyDrawer + { + public static float GetHeight(SerializedProperty property) + { + var spacing = EditorGUIUtility.standardVerticalSpacing; + var height = EditorGUIUtility.singleLineHeight; + + // Mixed types across the selection: the per-instance child fields cannot be merged, so only the dropdown + // and a single-line "different types" hint are drawn — never the children or the per-asset notices. + if (SerializeReferenceHelpers.HasMixedTypes(property)) + return height + spacing + EditorGUIUtility.singleLineHeight; + + // Per-asset notices are suppressed under a multi-object selection (each reads/writes a single backing asset). + if (SerializeReferenceHelpers.NoticesApply(property)) + { + if (SerializeReferenceHelpers.IsMissingType(property)) + height += spacing + EditorGUIUtility.singleLineHeight; + + if (SerializeReferenceHelpers.HasSharedReference(property)) + height += spacing + EditorGUIUtility.singleLineHeight; + + if (SerializeReferenceRequiredGate.IsViolation(property)) + height += spacing + EditorGUIUtility.singleLineHeight; + } + + if (property.managedReferenceValue is not null && property.isExpanded) + height += GetChildrenHeight(property, spacing); + + return height; + } + + public static void Draw(Rect position, GUIContent label, SerializedProperty property, params Type[] baseTypes) + { + // Auto-de-alias a freshly duplicated list element (Ctrl+D / Duplicate / list +): when this element shares its + // rid with another element of the same array, the guard queues a swap to an independent clone on the next + // editor tick (one Undo step) — never mutating the SerializedObject mid-draw. Cheap on the unchanged path + // (size + rolling-hash gate), so it is safe to call from every IMGUI repaint. + SerializeReferenceDuplicateGuard.Observe(property); + + var spacing = EditorGUIUtility.standardVerticalSpacing; + var mixedTypes = SerializeReferenceHelpers.HasMixedTypes(property); + var currentType = SerializeReferenceHelpers.GetCurrentType(property); + var hasValue = currentType is not null && !mixedTypes; + var fieldType = SerializeReferenceHelpers.GetFieldType(property); + + var line = new Rect(position.x, position.y, position.width, EditorGUIUtility.singleLineHeight); + + var contextEvent = Event.current; + if (contextEvent.type == EventType.ContextClick && line.Contains(contextEvent.mousePosition)) + { + ShowContextMenu(property, fieldType); + contextEvent.Use(); + } + + // Dropping a MonoScript on the header row assigns an instance of its class (when assignable). + if ((contextEvent.type == EventType.DragUpdated || contextEvent.type == EventType.DragPerform) && + line.Contains(contextEvent.mousePosition)) + { + if (SerializeReferenceDropHandler.TryResolveDroppedType(fieldType, baseTypes, out var droppedType)) + { + DragAndDrop.visualMode = DragAndDropVisualMode.Link; + if (contextEvent.type == EventType.DragPerform) + { + DragAndDrop.AcceptDrag(); + SerializeReferenceDropHandler.Assign(property, droppedType); + contextEvent.Use(); + return; // re-layout on the next repaint with the new value + } + } + else + { + DragAndDrop.visualMode = DragAndDropVisualMode.Rejected; + } + } + + var labelRect = new Rect(line.x, line.y, EditorGUIUtility.labelWidth, line.height); + if (hasValue) property.isExpanded = EditorGUI.Foldout(labelRect, property.isExpanded, label, toggleOnLabelClick: true); + else EditorGUI.LabelField(labelRect, label); + + var dropdownRect = new Rect( + line.x + EditorGUIUtility.labelWidth + 2f, + line.y, + line.width - EditorGUIUtility.labelWidth - 2f, + line.height); + + var openRect = Rect.zero; + if (hasValue) + { + var openSize = line.height; + openRect = new Rect(dropdownRect.xMax - openSize, dropdownRect.y, openSize, openSize); + dropdownRect.width -= openSize + 1f; + } + + // Mixed types across the selection: show the standard "—" treatment on the dropdown and never the open-script + // button (there is no single type to open). Picking a type still rewrites every target. The "—" caption is + // what renders the dash here — DropdownButton has no mixed-value styling — but EditorGUI.showMixedValue is + // still set/restored to mirror the UIToolkit side's mixed flag and to propagate to any nested IMGUI control. + var caption = mixedTypes ? "—" : GetCaption(property, currentType); + var previousMixed = EditorGUI.showMixedValue; + EditorGUI.showMixedValue = mixedTypes; + if (EditorGUI.DropdownButton(dropdownRect, new GUIContent(caption, + mixedTypes ? "Mixed — the selected objects hold different types." : null), FocusType.Passive)) + // Under mixed types there is no single "current" type to pre-highlight — open the picker unselected. + ShowSelector(property, fieldType, baseTypes, mixedTypes ? null : currentType, dropdownRect); + EditorGUI.showMixedValue = previousMixed; + + if (hasValue) + TypeIMGUIPropertyDrawer.DrawOpenScriptButton(openRect, currentType); + + var y = line.yMax + spacing; + + // Mixed types: stand in for the per-instance child fields (which cannot be merged) with a single dim info + // line, and skip the per-asset notices entirely. + if (mixedTypes) + { + var hintRect = new Rect(position.x, y, position.width, EditorGUIUtility.singleLineHeight); + DrawInfoNotice( + hintRect, + "Different types selected", + "The selected objects hold different managed-reference types, so their fields cannot be shown " + + "together.\nPick a type from the dropdown to set it on all of them, or select a single object " + + "to edit its own fields."); + return; + } + + // Per-asset notices read/write a single backing asset, so they are suppressed under a multi-object selection. + var noticesApply = SerializeReferenceHelpers.NoticesApply(property); + + if (noticesApply && SerializeReferenceHelpers.IsMissingType(property)) + { + var noticeRect = new Rect(position.x, y, position.width, EditorGUIUtility.singleLineHeight); + var typeName = SerializeReferenceHelpers.GetMissingTypeDisplayName(property); + var canFix = SerializeReferenceHelpers.TryGetRepairLocation(property, out _, out _, out _); + + // The Smart Fix suggestion rides the same row as a second clickable word ("· → Pistol?"): the highest + // ranked existing type the renamed/moved reference most likely became. The ranking is cached per + // (asset, rid), so this stays cheap across IMGUI's per-frame repaints. The candidate is pre-declared so + // it stays definitely assigned even when the short-circuit skips the probe (canFix == false). + SerializeReferenceRepairSuggestions.RepairCandidate suggestion = default; + var hasSuggestion = canFix && + SerializeReferenceHelpers.TryGetRepairSuggestion(property, baseTypes, out suggestion); + + DrawNotice( + noticeRect, + "Missing type —", + canFix ? "Fix" : null, + canFix + ? $"Missing type: {typeName}.\nClick Fix to re-point this reference to an existing type, keeping its data." + : $"Missing type: {typeName}.\nOpen this asset from the Project window to repair it.", + canFix + ? () => + { + var screenPosition = GUIUtility.GUIToScreenPoint(new Vector2(noticeRect.x, noticeRect.yMax)); + var screenRect = new Rect(screenPosition.x, screenPosition.y, noticeRect.width, EditorGUIUtility.singleLineHeight); + SerializeReferenceHelpers.ShowFixTypeSelector(property.Persistent(), screenRect, null, baseTypes); + } + : null, + hasSuggestion ? SerializeReferenceHelpers.GetSuggestionLabel(suggestion) : null, + hasSuggestion ? SerializeReferenceHelpers.GetSuggestionDetail(suggestion) : null, + hasSuggestion + ? () => SerializeReferenceHelpers.TryFixMissingType(property.Persistent(), suggestion.Type) + : null); + + y += EditorGUIUtility.singleLineHeight + spacing; + } + + if (noticesApply && SerializeReferenceHelpers.HasSharedReference(property)) + { + var rid = property.managedReferenceId; + if (rid >= 0 && SerializeReferenceSettings.RidColorsEnabled) + { + // Draw a 3 px deterministic-colour stripe at the left edge of the header row so the aliased + // fields are visually identifiable by colour before the user reads the notice text. + var stripeRect = new Rect(position.x, line.y, 3f, line.height); + EditorGUI.DrawRect(stripeRect, SerializeReferenceRidColor.ForRid(rid)); + } + + var noticeRect = new Rect(position.x, y, position.width, EditorGUIUtility.singleLineHeight); + var persistent = property.Persistent(); + + DrawNotice( + noticeRect, + "Shared reference —", + "Make unique", + "This reference is shared with another field — editing one changes both.\n" + + "Click Make unique to give this field its own independent copy.", + () => SerializeReferenceHelpers.MakeReferenceUnique(persistent)); + + y += EditorGUIUtility.singleLineHeight + spacing; + } + + // A required-but-empty reference shows a non-actionable notice; the header dropdown above is the fix. + if (noticesApply && SerializeReferenceRequiredGate.IsViolation(property)) + { + var noticeRect = new Rect(position.x, y, position.width, EditorGUIUtility.singleLineHeight); + SerializeReferenceRequiredGate.TryGetRequired(property, out var required); + var message = string.IsNullOrEmpty(required?.Message) ? "Required reference is not set" : required.Message; + + DrawInfoNotice(noticeRect, message, "This [SerializeReference] field is marked required but has no value."); + y += EditorGUIUtility.singleLineHeight + spacing; + } + + if (!hasValue || !property.isExpanded) return; + + EditorGUI.indentLevel++; + DrawChildren(property, position.x, position.width, spacing, ref y); + EditorGUI.indentLevel--; + } + + private static void DrawChildren(SerializedProperty property, float x, float width, float spacing, ref float y) + { + var iterator = property.Copy(); + var end = property.GetEndProperty(); + var enterChildren = true; + + while (iterator.NextVisible(enterChildren) && !SerializedProperty.EqualContents(iterator, end)) + { + enterChildren = false; + + var height = EditorGUI.GetPropertyHeight(iterator, includeChildren: true); + EditorGUI.PropertyField(new Rect(x, y, width, height), iterator, includeChildren: true); + y += height + spacing; + } + } + + private static float GetChildrenHeight(SerializedProperty property, float spacing) + { + var height = 0f; + var iterator = property.Copy(); + var end = property.GetEndProperty(); + var enterChildren = true; + + while (iterator.NextVisible(enterChildren) && !SerializedProperty.EqualContents(iterator, end)) + { + enterChildren = false; + height += EditorGUI.GetPropertyHeight(iterator, includeChildren: true) + spacing; + } + + return height; + } + + private static void ShowSelector(SerializedProperty property, Type fieldType, Type[] baseTypes, Type currentType, Rect dropdownRect) + { + var persistent = property.Persistent(); + var screenPosition = GUIUtility.GUIToScreenPoint(new Vector2(dropdownRect.x, dropdownRect.y)); + var screenRect = new Rect(screenPosition.x, screenPosition.y, dropdownRect.width, dropdownRect.height); + + TypeSelectorWindow.Show( + screenRect: screenRect, + types: new[] { fieldType }, + currentAqn: currentType?.AssemblyQualifiedName ?? string.Empty, + allow: TypeAllow.None, + onSelected: assemblyQualifiedName => Apply(string.IsNullOrEmpty(assemblyQualifiedName) + ? null + : Type.GetType(assemblyQualifiedName, throwOnError: false)), + filter: SerializeReferenceHelpers.BuildAssignableFilter(baseTypes), + additionalTypes: GenericTypeResolver.GetAssignableGenericDefinitions(fieldType, baseTypes), + argumentFilter: SerializeReferenceHelpers.IsValidGenericArgument); + + return; + + void Apply(Type type) + { + // Multi-object: each target gets its OWN instance, created from that target's previous value, so the + // managed reference is never aliased across objects; clears all. One Undo step covers them all. + if (SerializeReferenceHelpers.IsEditingMultipleObjects(persistent)) + { + SerializeReferenceHelpers.ApplyManagedReferencePerTarget( + persistent, + previous => SerializeReferenceHelpers.CreateInstancePreservingData(type, previous)); + + // All targets now share the new type, so the live foldout drives expansion; set it on the + // persistent property (the per-target writes went through disposed SerializedObjects). + persistent.isExpanded = type is not null; + return; + } + + var single = persistent.managedReferenceValue; + persistent.SetManagedReferenceAndApply(SerializeReferenceHelpers.CreateInstancePreservingData(type, single)); + persistent.isExpanded = type is not null; + } + } + + private static void ShowContextMenu(SerializedProperty property, Type fieldType) + { + var persistent = property.Persistent(); + var menu = new GenericMenu(); + + // Copy reads the first target's value (Unity's convention for a multi-selection menu). Paste then applies an + // independent instance PER target, so the pasted reference is never aliased across objects. + menu.AddItem(new GUIContent("Copy Serialize Reference"), false, + () => SerializeReferenceClipboard.Copy(persistent.managedReferenceValue)); + + var pasteLabel = new GUIContent("Paste Serialize Reference"); + if (SerializeReferenceClipboard.CanPasteInto(fieldType)) + menu.AddItem(pasteLabel, false, () => Paste(persistent)); + else + menu.AddDisabledItem(pasteLabel); + + // Make-unique is a single-asset cross-reference operation; only offered (and only correct) for a single + // target — under a multi-object selection the shared-reference notice is already suppressed. + if (SerializeReferenceHelpers.NoticesApply(property) && + SerializeReferenceHelpers.HasSharedReference(property)) + menu.AddItem(new GUIContent("Make Unique Reference"), false, + () => SerializeReferenceHelpers.MakeReferenceUnique(persistent)); + + // Find every asset/field using the current type, via the sr: Quick Search provider. + var usagesType = SerializeReferenceHelpers.GetCurrentType(property); + if (usagesType != null) + menu.AddItem(new GUIContent($"Find Usages of {usagesType.Name}"), false, + () => SerializeReferenceUsageSearchProvider.OpenSearch(usagesType)); + + // Link this field to an existing instance of the same object (inverse of Make Unique), single-target only. + if (SerializeReferenceHelpers.NoticesApply(property)) + foreach (var candidate in SerializeReferenceLinker.CollectLinkCandidates(property)) + { + var path = candidate.Path; + menu.AddItem(new GUIContent($"Link to Existing/{candidate.Type.Name} ({path})"), false, + () => SerializeReferenceLinker.LinkTo(property, path)); + } + + // Generate a new subclass of the field's type and assign it once it compiles. + if (fieldType != null) + menu.AddItem(new GUIContent("Create New Script…"), false, () => + { + if (SerializeReferenceScriptCreator.TryCreateSubclassStub(fieldType, out _, out var fullTypeName)) + SerializeReferencePendingAssignment.Enqueue(property.serializedObject.targetObject, property.propertyPath, fullTypeName); + }); + + // Save the current instance as a durable named template, and paste any assignable saved template. + if (usagesType != null) + { + var value = persistent.managedReferenceValue; + menu.AddItem(new GUIContent("Save as Template…"), false, + () => SerializeReferenceNamePrompt.Show("Save Template", + SerializeReferenceTemplates.SuggestName(usagesType), name => SerializeReferenceTemplates.Save(name, value))); + } + + foreach (var template in SerializeReferenceTemplates.LoadResolved()) + { + if (fieldType != null && !fieldType.IsAssignableFrom(template.Type)) continue; + var name = template.Name; + menu.AddItem(new GUIContent($"Paste Template/{name}"), false, () => ApplyTemplate(property, name)); + } + + menu.ShowAsContext(); + + void Paste(SerializedProperty target) + { + if (SerializeReferenceHelpers.IsEditingMultipleObjects(target)) + { + SerializeReferenceHelpers.ApplyManagedReferencePerTarget( + target, + _ => SerializeReferenceClipboard.CreateInstance()); + + // All targets now share the pasted type, so the live foldout drives expansion; set it on the + // persistent property (the per-target writes went through disposed SerializedObjects). A null + // clipboard type is an empty-reference paste, which collapses — matching the single-object branch. + target.isExpanded = SerializeReferenceClipboard.Type is not null; + return; + } + + var value = SerializeReferenceClipboard.CreateInstance(); + target.SetManagedReferenceAndApply(value); + target.isExpanded = value is not null; + } + } + + // Applies a saved template to the property (an independent instance per target on a multi-object selection). + private static void ApplyTemplate(SerializedProperty property, string name) + { + var persistent = property.Persistent(); + + if (SerializeReferenceHelpers.IsEditingMultipleObjects(persistent)) + { + SerializeReferenceHelpers.ApplyManagedReferencePerTarget(persistent, _ => SerializeReferenceTemplates.CreateInstance(name)); + persistent.isExpanded = true; + return; + } + + var instance = SerializeReferenceTemplates.CreateInstance(name); + if (instance is null) return; + + persistent.SetManagedReferenceAndApply(instance); + persistent.isExpanded = true; + } + + + private static string GetCaption(SerializedProperty property, Type currentType) + { + if (currentType is not null) + return TypeSelectorHelpers.GetTypeSelectorTitle(currentType); + + var missingName = SerializeReferenceHelpers.IsMissingType(property) + ? SerializeReferenceHelpers.GetMissingTypeDisplayName(property) + : null; + + return TypeSelectorHelpers.GetTypeSelectorTitle(null, missingName); + } + + // Warning yellow mirrors the UIToolkit notice palette: + // --aspid-colors-status-warning-text-light / -lightness. + private static readonly Color NoticeColor = new(245f / 255f, 185f / 255f, 85f / 255f); + private static readonly Color NoticeColorHover = new(255f / 255f, 235f / 255f, 175f / 255f); + + // Dim grey for the non-actionable mixed-types info hint, mirroring the UIToolkit info notice's --aspid-colors-text-dark. + private static readonly Color InfoNoticeColor = new(150f / 255f, 150f / 255f, 150f / 255f); + + private static GUIStyle _messageStyle; + private static GUIStyle _actionStyle; + private static GUIStyle _infoMessageStyle; + + /// + /// Draws a compact single-row, non-actionable info hint: a small info icon and a terse dim message. Used for the + /// multi-object "different types" notice that stands in for the suppressed child fields, mirroring the UIToolkit + /// info variant. The full rides the hover tooltip. + /// + private static void DrawInfoNotice(Rect rect, string message, string detail) + { + _infoMessageStyle ??= new GUIStyle(EditorStyles.label) { wordWrap = false }; + _infoMessageStyle.normal.textColor = InfoNoticeColor; + + const float iconSize = 16f; + var iconRect = new Rect(rect.x, rect.y + (rect.height - iconSize) * 0.5f, iconSize, iconSize); + GUI.Label(iconRect, EditorGUIUtility.IconContent("console.infoicon")); + + var messageContent = new GUIContent(message, detail); + var messageRect = new Rect(iconRect.xMax + 4f, rect.y, rect.xMax - iconRect.xMax - 4f, rect.height); + GUI.Label(messageRect, messageContent, _infoMessageStyle); + } + + /// + /// Draws a compact single-row warning: a small warning icon, a terse yellow message, an optional underlined, + /// clickable action word and — for a missing-type notice with a Smart Fix candidate — an optional trailing + /// suggestion word ("· → Pistol?"). The full rides each segment's hover tooltip, + /// mirroring the UIToolkit . + /// + private static void DrawNotice(Rect rect, string message, string actionText, string detail, Action onClick, + string suggestionText = null, string suggestionDetail = null, Action onSuggestion = null) + { + _messageStyle ??= new GUIStyle(EditorStyles.label) { wordWrap = false }; + _actionStyle ??= new GUIStyle(EditorStyles.label) { fontStyle = FontStyle.Bold }; + _messageStyle.normal.textColor = NoticeColor; + + const float iconSize = 16f; + var iconRect = new Rect(rect.x, rect.y + (rect.height - iconSize) * 0.5f, iconSize, iconSize); + GUI.Label(iconRect, EditorGUIUtility.IconContent("console.warnicon")); + + var messageContent = new GUIContent(message, detail); + var messageWidth = _messageStyle.CalcSize(messageContent).x; + var messageRect = new Rect(iconRect.xMax + 4f, rect.y, messageWidth, rect.height); + GUI.Label(messageRect, messageContent, _messageStyle); + + if (string.IsNullOrEmpty(actionText) || onClick is null) return; + + var actionEnd = DrawLink(messageRect.xMax + 4f, rect, actionText, detail, onClick); + + if (!string.IsNullOrEmpty(suggestionText) && onSuggestion is not null) + DrawLink(actionEnd + 6f, rect, suggestionText, suggestionDetail, onSuggestion); + } + + // Draws one underlined, clickable, hover-tracking link word at x and returns its right edge, so the caller can + // lay the next segment out after it. Shared by the Fix action and the trailing Smart Fix suggestion. + private static float DrawLink(float x, Rect rect, string text, string detail, Action onClick) + { + var content = new GUIContent(text, detail); + var width = _actionStyle.CalcSize(content).x; + var linkRect = new Rect(x, rect.y, width, rect.height); + + var hover = linkRect.Contains(Event.current.mousePosition); + var color = hover ? NoticeColorHover : NoticeColor; + _actionStyle.normal.textColor = color; + _actionStyle.hover.textColor = color; + + EditorGUIUtility.AddCursorRect(linkRect, MouseCursor.Link); + var clicked = GUI.Button(linkRect, content, _actionStyle); + + // Underline the word — IMGUI styles have no text-decoration, so draw the rule manually. + var underline = new Rect(linkRect.x, linkRect.center.y + EditorGUIUtility.singleLineHeight * 0.35f, width, 1f); + EditorGUI.DrawRect(underline, color); + + if (clicked) onClick(); + return linkRect.xMax; + } + } +} diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Drawers/SerializeReferenceIMGUIPropertyDrawer.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Drawers/SerializeReferenceIMGUIPropertyDrawer.cs.meta new file mode 100644 index 00000000..0473bc6d --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Drawers/SerializeReferenceIMGUIPropertyDrawer.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0c73abd645ce47b8b2d711c39909eaa8 +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/Unity/Editor/Scripts/SerializeReferences/Drawers/SerializeReferenceListAddBehavior.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Drawers/SerializeReferenceListAddBehavior.cs new file mode 100644 index 00000000..73f95393 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Drawers/SerializeReferenceListAddBehavior.cs @@ -0,0 +1,89 @@ +using System; +using UnityEditor; +using UnityEngine; +using UnityEngine.UIElements; +using Aspid.FastTools.Types; +using Aspid.FastTools.Editors; +using Aspid.FastTools.Types.Editors; +using Object = UnityEngine.Object; + +// ReSharper disable once CheckNamespace +namespace Aspid.FastTools.SerializeReferences.Editors +{ + /// + /// Replaces the default "+" on a List<[SerializeReference]> / array (which duplicates the last element + /// and leaves it rid-aliased) with one that opens the type picker and appends a fresh typed instance — killing the + /// alias at the source. remains the fallback for the native add path + /// (Ctrl+D, paste, multi-object selections). + /// + internal static class SerializeReferenceListAddBehavior + { + /// + /// Installs the picker-backed add behavior on the ListView hosting , once. No-op + /// for non-list elements, multi-object selections (handled by the de-alias guard), or when an override is + /// already present. + /// + public static void TryInstall(VisualElement elementField, SerializedProperty elementProperty, Type elementType, Type[] baseTypes) + { + if (elementField is null || elementProperty is null) return; + + var serializedObject = elementProperty.serializedObject; + if (serializedObject is null || serializedObject.isEditingMultipleObjects) return; + + var path = elementProperty.propertyPath; + var arrayMarker = path.IndexOf(".Array.data[", StringComparison.Ordinal); + if (arrayMarker < 0) return; // not a list/array element + + var arrayPath = path[..arrayMarker]; + var target = serializedObject.targetObject; + if (target == null) return; + + var listView = elementField.GetFirstAncestorOfType(); + if (listView is null || listView.overridingAddButtonBehavior != null) return; + + listView.overridingAddButtonBehavior = (_, button) => + OpenAppendPicker(target, arrayPath, elementType, baseTypes, button); + } + + private static void OpenAppendPicker(Object target, string arrayPath, Type elementType, Type[] baseTypes, VisualElement anchor) + { + var window = EditorWindow.mouseOverWindow != null ? EditorWindow.mouseOverWindow : EditorWindow.focusedWindow; + if (window == null) return; + + var screenRect = new Rect( + window.position.x + anchor.worldBound.xMin, + window.position.y + anchor.worldBound.yMax, + Mathf.Max(anchor.worldBound.width, 240f), + anchor.worldBound.height); + + TypeSelectorWindow.Show( + screenRect: screenRect, + types: new[] { elementType }, + currentAqn: string.Empty, + allow: TypeAllow.None, + onSelected: aqn => Append(target, arrayPath, aqn), + filter: SerializeReferenceHelpers.BuildAssignableFilter(baseTypes), + additionalTypes: GenericTypeResolver.GetAssignableGenericDefinitions(elementType, baseTypes), + argumentFilter: SerializeReferenceHelpers.IsValidGenericArgument); + } + + private static void Append(Object target, string arrayPath, string assemblyQualifiedName) + { + var type = string.IsNullOrEmpty(assemblyQualifiedName) ? null : Type.GetType(assemblyQualifiedName, throwOnError: false); + if (type is null || target == null) return; + + // A fresh SerializedObject avoids any stale-binding hazard from a captured one; the bound inspector ListView + // refreshes from the changed target on its next update. + var serializedObject = new SerializedObject(target); + var array = serializedObject.FindProperty(arrayPath); + if (array is null || !array.isArray) return; + + var index = array.arraySize; + array.arraySize = index + 1; + serializedObject.ApplyModifiedProperties(); + serializedObject.Update(); + + array.GetArrayElementAtIndex(index).SetManagedReferenceAndApply(SerializeReferenceHelpers.CreateInstance(type)); + } + } +} diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Drawers/SerializeReferenceListAddBehavior.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Drawers/SerializeReferenceListAddBehavior.cs.meta new file mode 100644 index 00000000..630970fd --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Drawers/SerializeReferenceListAddBehavior.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 2d08bbd546010490a9d98022754332ed \ No newline at end of file diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Drawers/SerializeReferenceUIToolkitPropertyDrawer.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Drawers/SerializeReferenceUIToolkitPropertyDrawer.cs new file mode 100644 index 00000000..0d497a7a --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Drawers/SerializeReferenceUIToolkitPropertyDrawer.cs @@ -0,0 +1,16 @@ +using System; +using UnityEditor; +using UnityEngine.UIElements; + +// ReSharper disable once CheckNamespace +namespace Aspid.FastTools.SerializeReferences.Editors +{ + internal static class SerializeReferenceUIToolkitPropertyDrawer + { + public static VisualElement Draw(string label, SerializedProperty property, params Type[] baseTypes) + { + label = string.IsNullOrWhiteSpace(label) ? null : label; + return new SerializeReferenceField(label, property, baseTypes); + } + } +} diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Drawers/SerializeReferenceUIToolkitPropertyDrawer.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Drawers/SerializeReferenceUIToolkitPropertyDrawer.cs.meta new file mode 100644 index 00000000..2bfcbff4 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Drawers/SerializeReferenceUIToolkitPropertyDrawer.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7cbfa5b504864624a596b92a76a09ec1 +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/Unity/Editor/Scripts/SerializeReferences/Extensions.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Extensions.meta new file mode 100644 index 00000000..672e73b8 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Extensions.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 4b6da85b53a545ed8bb4d2d6bed835b7 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Extensions/SerializeReferenceClipboard.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Extensions/SerializeReferenceClipboard.cs new file mode 100644 index 00000000..1564d446 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Extensions/SerializeReferenceClipboard.cs @@ -0,0 +1,58 @@ +using System; +using UnityEngine; + +// ReSharper disable once CheckNamespace +namespace Aspid.FastTools.SerializeReferences.Editors +{ + /// + /// Editor-session clipboard backing the Copy/Paste context-menu entries of the + /// [TypeSelector] drawer on [SerializeReference] fields. Stores the copied managed-reference value as JSON plus its + /// concrete , so a paste reconstructs an independent instance (rather than aliasing the + /// source object) and survives across different fields, inspectors, and target objects within the session. + /// + internal static class SerializeReferenceClipboard + { + private static bool _hasContent; + private static string _json; + private static Type _type; + + /// The concrete type of the copied value, or when an empty reference was copied. + public static Type Type => _type; + + /// + /// Captures into the clipboard. Copying is meaningful — a + /// subsequent paste clears the target field. + /// + public static void Copy(object value) + { + _hasContent = true; + _type = value?.GetType(); + _json = value is null ? null : JsonUtility.ToJson(value); + } + + /// + /// Returns when the clipboard holds content that can be pasted into a field whose + /// declared managed-reference type is (an empty reference always pastes — + /// it clears the field). + /// + public static bool CanPasteInto(Type fieldType) + { + if (!_hasContent) return false; + if (_type is null) return true; + return fieldType is null || fieldType.IsAssignableFrom(_type); + } + + /// + /// Reconstructs a fresh instance from the clipboard contents for assignment to a managed reference, or + /// when an empty reference was copied. The result is independent of the copied object. + /// + public static object CreateInstance() + { + if (!_hasContent || _type is null) return null; + + return string.IsNullOrEmpty(_json) + ? SerializeReferenceHelpers.CreateInstance(_type) + : JsonUtility.FromJson(_json, _type); + } + } +} diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Extensions/SerializeReferenceClipboard.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Extensions/SerializeReferenceClipboard.cs.meta new file mode 100644 index 00000000..30baf539 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Extensions/SerializeReferenceClipboard.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9667c30ec60b8473bacad6570addcffe \ No newline at end of file diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Extensions/SerializeReferenceDuplicateGuard.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Extensions/SerializeReferenceDuplicateGuard.cs new file mode 100644 index 00000000..37e49739 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Extensions/SerializeReferenceDuplicateGuard.cs @@ -0,0 +1,397 @@ +using System; +using UnityEditor; +using System.Collections.Generic; +using Object = UnityEngine.Object; + +// ReSharper disable once CheckNamespace +namespace Aspid.FastTools.SerializeReferences.Editors +{ + /// + /// Auto-de-aliases freshly duplicated [SerializeReference] list elements. When the user duplicates an array + /// element (context-menu Duplicate, Ctrl+D) or adds one with the list + button, Unity copies the + /// source element's managed-reference rid, so two elements end up backed by a single instance and editing one + /// silently edits the other. This guard watches the live : per (target, array path) it + /// keeps a snapshot of index → rid and, when a new same-array alias appears between observations, it + /// replaces the later (appended / higher-index) element with an independent clone via the same + /// machinery the Make-unique flow uses, + /// registered as a single Undo step. + /// + /// + /// + /// Detection is purely live-state ( / value), so it works for + /// scene objects, Prefab Mode, and saved assets alike — no YAML is read. The fix is silent by product decision: no + /// notice, no dialog. Undo reverts to the aliased state; after an Undo/Redo the snapshots are resynced rather than + /// re-evaluated, so a restored alias is never re-fixed. + /// + /// + /// Intentional cross-field sharing is out of scope — that is handled by the existing shared-reference notice. + /// This guard only acts on same-array aliasing that appears while the inspector is alive; pre-existing aliases + /// present on the first observation of an array (inspector just opened, domain reload) are recorded, never fixed. + /// A fix is considered only when the array grew since the last observation and the duplicated rid's + /// occurrence count rose with it — the signature of an actual duplicate-element operation. Same-size observations + /// (a reorder) and shrunk ones (a removal) only resync the snapshot, so shuffling or deleting around a pre-existing + /// alias never de-aliases it. + /// + /// + internal static class SerializeReferenceDuplicateGuard + { + // A managed reference with no value reports RefIdNull (-1); a missing-type one reports RefIdUnknown (-2). Only + // ids >= 0 are real instances that can alias — the rest are excluded from the index → rid map. + private const long FirstValidReferenceId = 0; + + // Unity's array-element path marker (e.g. "_slots.Array.data[3]"). The text before it is the parent array path. + private const string ArrayElementMarker = ".Array.data["; + + // Caps the live cache so a session that opens many inspectors cannot grow it without bound. Each entry is a tiny + // index → rid map; the cap is generous but finite. On overflow the whole cache is dropped (cheap, and the next + // observation simply re-snapshots — which never auto-fixes, so dropping snapshots only ever loses a fix, the + // conservative direction). + private const int MaxTrackedArrays = 512; + + // Per (target instance id, parent array path) snapshot of the last observed index → rid layout. Static, so it is + // cleared automatically on domain reload; dead-target entries are pruned lazily on access and on Undo resync. + private static readonly Dictionary Snapshots = new(); + + // Arrays whose de-alias fix is queued for the next editor tick. Guards against re-detecting (the layout still + // shows the alias until the deferred fix runs) and re-scheduling on every intervening repaint. + private static readonly HashSet Pending = new(); + + private static bool _undoHooked; + + /// + /// Observes (an element of a [SerializeReference] array) and, when it + /// detects that the element is part of a freshly created same-array duplicate, schedules a fix that + /// replaces the later element with an independent clone (single Undo step) and returns . + /// The mutation runs on the next editor tick (), never inside the + /// caller's draw/binding pass, so the inspector's live property iteration is not disturbed mid-frame; the field's + /// own property tracking re-renders once the fix lands. Returns for the no-op fast paths + /// (not an array element, multi-object edit, first observation, pre-existing alias, or a fix already pending). + /// Cheap on the unchanged path: an array-size + rolling-hash compare gates the full map rebuild, so it is safe to + /// call from IMGUI's per-frame repaint. + /// + public static bool Observe(SerializedProperty elementProperty) + { + if (!SerializeReferenceSettings.AutoDeAliasEnabled) return false; + if (elementProperty is null) return false; + if (elementProperty.propertyType != SerializedPropertyType.ManagedReference) return false; + + // Multi-object editing is owned by the per-target apply path; the live SerializedObject here walks only the + // first target, so the guard cannot reason about the others — skip it entirely (conservative). + if (elementProperty.serializedObject.isEditingMultipleObjects) return false; + + if (!TryGetArrayPath(elementProperty.propertyPath, out var arrayPath)) return false; + + EnsureUndoHook(); + + var serializedObject = elementProperty.serializedObject; + var target = serializedObject.targetObject; + if (target == null) return false; + + var key = new ArrayKey(target.GetInstanceID(), arrayPath); + + // A fix for this array is already queued for the next tick; do not re-detect (the layout still shows the + // alias until the deferred fix runs) and do not re-schedule it. + if (Pending.Contains(key)) return false; + + var arrayProperty = serializedObject.FindProperty(arrayPath); + if (arrayProperty is null || !arrayProperty.isArray) return false; + + // Fast no-change gate: a size + order-sensitive rolling hash of the element rids. When it matches the stored + // signature nothing in the array's rid layout moved since the last observation, so the index → rid Dictionary + // rebuild (and the alias diffing) is skipped. The hash pass itself is O(size) cheap reads — it must touch each + // rid to detect a change — but allocates no per-observation collection, which is the IMGUI per-repaint path. + var size = arrayProperty.arraySize; + var signature = ComputeSignature(arrayProperty, size); + + if (Snapshots.TryGetValue(key, out var snapshot) && + snapshot.Size == size && snapshot.Signature == signature) + return false; + + var current = BuildMap(arrayProperty, size); + + // First time we see this array (fresh inspector, domain reload): record the layout but never auto-fix — + // pre-existing same-array aliases keep the existing shared notice instead. + if (snapshot is null) + { + Store(key, size, signature, current); + return false; + } + + // The only operation that creates a fresh alias is one that *grows* the array (Ctrl+D / Duplicate / list + + // all append an element). A same-size or shrunk observation is a reorder or a removal — both of which can + // shuffle a pre-existing alias into a new (index, rid) binding that the per-index diff would otherwise + // misread as fresh — so those just resync the baseline and never fix, honouring the "pre-existing aliases + // are recorded, never fixed" contract. + if (size > snapshot.Size && + TryFindFreshDuplicate(snapshot.Map, current, out var duplicateIndex)) + { + // Keep the existing baseline snapshot (do not advance it to the aliased layout): once the deferred fix + // lands, the next observation compares the de-aliased layout against that same baseline, so the fixed + // element reads as unique while any *further* rapid duplicate made during the pending window is still + // caught as fresh. The Pending guard suppresses re-detection only until the queued fix runs. + ScheduleFix(key, target, arrayPath, duplicateIndex); + return true; + } + + // No fresh duplicate (or a same-size / shrunk layout): just advance the snapshot to the observed layout. + Store(key, size, signature, current); + return false; + } + + // Queues the de-alias to the next editor tick. The mutation must not run inside the drawer's draw/binding pass: + // writing the SerializedObject mid-iteration can invalidate the inspector's active property walk. delayCall runs + // after the current GUI event, where a fresh SerializedObject can be applied safely. + private static void ScheduleFix(ArrayKey key, Object target, string arrayPath, int duplicateIndex) + { + Pending.Add(key); + EditorApplication.delayCall += () => + { + // Releasing Pending lets the next observation re-evaluate the now-de-aliased array against the unchanged + // baseline: the fixed element reads as unique (no re-fix) while a further duplicate made meanwhile is + // still caught. The fix re-verifies the alias on a fresh read, so a stale schedule is a safe no-op. + Pending.Remove(key); + MakeElementUnique(target, arrayPath, duplicateIndex); + }; + } + + // Replaces the element at duplicateIndex with an independent clone carrying the same data, on a fresh + // SerializedObject built from the target. SetManagedReferenceAndApply registers a single Undo step; a fresh + // instance gets a new managedReferenceId on assignment, breaking the alias. + private static void MakeElementUnique(Object target, string arrayPath, int duplicateIndex) + { + if (target == null) return; + + using var serializedObject = new SerializedObject(target); + var arrayProperty = serializedObject.FindProperty(arrayPath); + if (arrayProperty is null || !arrayProperty.isArray) return; + if (duplicateIndex < 0 || duplicateIndex >= arrayProperty.arraySize) return; + + var element = arrayProperty.GetArrayElementAtIndex(duplicateIndex); + if (element.propertyType != SerializedPropertyType.ManagedReference) return; + + var current = element.managedReferenceValue; + if (current is null) return; + + // Re-verify the alias still holds on this fresh read — the layout may have changed between the scheduling + // tick and now (another Undo, a manual edit) — so a stale schedule never clobbers an already-unique element. + if (!SharesReferenceWithEarlierElement(arrayProperty, duplicateIndex, element.managedReferenceId)) return; + + element.managedReferenceValue = + SerializeReferenceHelpers.CreateInstancePreservingData(current.GetType(), current); + serializedObject.ApplyModifiedProperties(); + } + + // True when an element at a lower index of the same array currently holds rid — i.e. the element at index is the + // later half of a still-live same-array alias pair. + private static bool SharesReferenceWithEarlierElement(SerializedProperty arrayProperty, int index, long rid) + { + if (rid < FirstValidReferenceId) return false; + + for (var i = 0; i < index; i++) + { + var other = arrayProperty.GetArrayElementAtIndex(i); + if (other.propertyType == SerializedPropertyType.ManagedReference && other.managedReferenceId == rid) + return true; + } + + return false; + } + + // A fresh duplicate is the LATER element of a pair that now shares an rid where (a) that exact (index, rid) + // binding was not present in the previous snapshot AND (b) the rid now occurs more times than it did in the + // snapshot. The occurrence-count gate is what separates a genuine new copy from a reorder: dragging an element + // of a pre-existing alias to a new index changes its (index, rid) binding but leaves the rid's total count + // unchanged, so it is not treated as fresh. Walking ascending, the first index satisfying both is the + // appended/duplicated copy to fix. + private static bool TryFindFreshDuplicate( + IReadOnlyDictionary previous, + IReadOnlyDictionary current, + out int duplicateIndex) + { + duplicateIndex = -1; + + // rid -> lowest current index holding it, so an aliased later element can be matched to an earlier owner. + var lowestIndexByRid = new Dictionary(); + foreach (var pair in current) + if (!lowestIndexByRid.TryGetValue(pair.Value, out var existing) || pair.Key < existing) + lowestIndexByRid[pair.Value] = pair.Key; + + // rid occurrence counts in each layout, to compare the multiset rather than per-index bindings. + var previousCount = CountByRid(previous); + var currentCount = CountByRid(current); + + var best = int.MaxValue; + foreach (var pair in current) + { + var index = pair.Key; + var rid = pair.Value; + + // Only the later element of an alias pair is a candidate (an earlier owner of the rid keeps its instance). + if (lowestIndexByRid[rid] >= index) continue; + + // Skip aliases that already existed: a binding present unchanged in the previous snapshot is intentional + // (or pre-existing) sharing, not a fresh duplicate. A new index, or a changed rid at this index, is fresh. + if (previous.TryGetValue(index, out var previousRid) && previousRid == rid) continue; + + // Require the rid to have multiplied since the snapshot. A reorder that merely moved a pre-existing + // alias into a new binding leaves the rid's count unchanged and so is not a fresh duplicate. + previousCount.TryGetValue(rid, out var before); + if (currentCount[rid] <= before) continue; + + if (index < best) best = index; + } + + if (best == int.MaxValue) return false; + duplicateIndex = best; + return true; + } + + // Occurrence count of each rid in an index -> rid map, for multiset comparison between snapshots. + private static Dictionary CountByRid(IReadOnlyDictionary map) + { + var counts = new Dictionary(map.Count); + foreach (var pair in map) + counts[pair.Value] = counts.TryGetValue(pair.Value, out var existing) ? existing + 1 : 1; + + return counts; + } + + // Builds the index → rid map for the array, keeping only elements that are managed references with a real + // instance id (>= 0). Null and missing-type elements carry no aliasable instance, so they are excluded. + private static Dictionary BuildMap(SerializedProperty arrayProperty, int size) + { + var map = new Dictionary(size); + for (var i = 0; i < size; i++) + { + var element = arrayProperty.GetArrayElementAtIndex(i); + if (element.propertyType != SerializedPropertyType.ManagedReference) continue; + + var rid = element.managedReferenceId; + if (rid >= FirstValidReferenceId) map[i] = rid; + } + + return map; + } + + // Cheap order-sensitive rolling hash of the element rids — enough to detect any change in the array's rid layout + // (a duplicate, a reorder, an add/remove) without rebuilding the index → rid map. A single SerializedProperty is + // walked across siblings (Next(enterChildren: false) skips each managed reference's own children and lands on the + // next element), so the no-change gate allocates one property per call rather than one per element. + private static int ComputeSignature(SerializedProperty arrayProperty, int size) + { + unchecked + { + var hash = 17; + if (size == 0) return hash; + + var element = arrayProperty.GetArrayElementAtIndex(0); + for (var i = 0; i < size; i++) + { + var rid = element.propertyType == SerializedPropertyType.ManagedReference + ? element.managedReferenceId + : long.MinValue; + hash = hash * 31 + rid.GetHashCode(); + + if (i + 1 < size && !element.Next(enterChildren: false)) break; + } + + return hash; + } + } + + private static void Store(ArrayKey key, int size, int signature, Dictionary map) + { + // Drop the whole cache on overflow rather than evicting one entry: simpler, and a re-snapshot never + // auto-fixes, so the only cost is losing a not-yet-observed fix — the conservative direction. Pending is + // cleared alongside so the two never desync (a queued fix re-verifies its alias before applying, so dropping + // a pending key only ever cancels a fix, never mis-applies one). + if (!Snapshots.ContainsKey(key) && Snapshots.Count >= MaxTrackedArrays) + { + Snapshots.Clear(); + Pending.Clear(); + } + + Snapshots[key] = new Snapshot(size, signature, map); + } + + // The parent array path of an element path: "_slots.Array.data[3]._weapon..." has no array marker at its own + // level only when it is not an array element; an element path always ends the marker with an index, so the text + // up to the marker is the array property's path. Nested arrays resolve to the innermost array (last marker), + // which is the array this element directly belongs to. + private static bool TryGetArrayPath(string elementPath, out string arrayPath) + { + arrayPath = null; + if (string.IsNullOrEmpty(elementPath)) return false; + + var marker = elementPath.LastIndexOf(ArrayElementMarker, StringComparison.Ordinal); + if (marker < 0) return false; + + // The element must be the array entry itself ("...Array.data[N]"), not a sub-field of one + // ("...Array.data[N]._weapon") — only the entry carries the element's own managed reference. Verify the + // marker is closed by an index and nothing follows the closing bracket. + var close = elementPath.IndexOf(']', marker + ArrayElementMarker.Length); + if (close < 0 || close != elementPath.Length - 1) return false; + + arrayPath = elementPath[..marker]; + return arrayPath.Length > 0; + } + + // Subscribed once. After an Undo/Redo the restored layout must be re-snapshotted as the new baseline so a + // reverted alias is not treated as a fresh duplicate and immediately re-fixed; the simplest correct resync is to + // drop every snapshot, so the next observation of each array re-records (and never auto-fixes) its layout. + private static void EnsureUndoHook() + { + if (_undoHooked) return; + _undoHooked = true; + Undo.undoRedoPerformed += OnUndoRedoPerformed; + } + + private static void OnUndoRedoPerformed() + { + // Drop both the baseline snapshots and any queued fixes: an Undo can revert an array to a state a fix was + // scheduled against (or restore an intentional alias), and re-snapshotting on next observation never + // auto-fixes — so clearing both guarantees a reverted alias is recorded, not re-fixed. + Snapshots.Clear(); + Pending.Clear(); + } + + private readonly struct ArrayKey : IEquatable + { + private readonly int _targetInstanceId; + private readonly string _arrayPath; + + public ArrayKey(int targetInstanceId, string arrayPath) + { + _targetInstanceId = targetInstanceId; + _arrayPath = arrayPath; + } + + public bool Equals(ArrayKey other) => + _targetInstanceId == other._targetInstanceId && _arrayPath == other._arrayPath; + + public override bool Equals(object obj) => obj is ArrayKey other && Equals(other); + + public override int GetHashCode() + { + unchecked + { + return (_targetInstanceId * 397) ^ (_arrayPath?.GetHashCode() ?? 0); + } + } + } + + private sealed class Snapshot + { + public Snapshot(int size, int signature, Dictionary map) + { + Size = size; + Signature = signature; + Map = map; + } + + public int Size { get; } + public int Signature { get; } + public Dictionary Map { get; } + } + } +} diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Extensions/SerializeReferenceDuplicateGuard.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Extensions/SerializeReferenceDuplicateGuard.cs.meta new file mode 100644 index 00000000..35225781 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Extensions/SerializeReferenceDuplicateGuard.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 81bff6bc3d1434c3484556b04ae401f9 \ No newline at end of file diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Extensions/SerializeReferenceGraphScanner.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Extensions/SerializeReferenceGraphScanner.cs new file mode 100644 index 00000000..1adf7281 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Extensions/SerializeReferenceGraphScanner.cs @@ -0,0 +1,436 @@ +using System; +using System.IO; +using UnityEditor; +using System.Collections.Generic; +using System.Text.RegularExpressions; +using Object = UnityEngine.Object; + +// ReSharper disable once CheckNamespace +namespace Aspid.FastTools.SerializeReferences.Editors +{ + /// + /// A single managed-reference node in a document's graph: its RefIds id, the stored type identity and + /// whether that type still resolves to a loadable . Built purely from the asset YAML, so it + /// surfaces references at any nesting depth — including the orphaned ones Unity drops from the live object. + /// + internal readonly struct ReferenceGraphNode + { + public readonly long Rid; + public readonly ManagedTypeName StoredType; + public readonly bool Resolves; + + public ReferenceGraphNode(long rid, ManagedTypeName storedType, bool resolves) + { + Rid = rid; + StoredType = storedType; + Resolves = resolves; + } + + /// Short type name (the class identifier without namespace/assembly), for the row label. + public string ShortName => + string.IsNullOrEmpty(StoredType.Class) ? $"rid {Rid}" : StoredType.Class; + + /// Full Namespace.Class, Assembly identity, for the row tooltip. + public string FullName + { + get + { + if (StoredType.IsEmpty) return string.Empty; + + var name = string.IsNullOrEmpty(StoredType.Namespace) + ? StoredType.Class + : $"{StoredType.Namespace}.{StoredType.Class}"; + + return string.IsNullOrEmpty(StoredType.Assembly) ? name : $"{name}, {StoredType.Assembly}"; + } + } + } + + /// + /// A field pointer from a document's body into its RefIds block — a root of the reference tree. The + /// is the nearest mapping key on or above the rid: line (best effort), e.g. the + /// field name that holds the reference. + /// + internal readonly struct ReferenceGraphRoot + { + public readonly long Rid; + public readonly string Label; + + public ReferenceGraphRoot(long rid, string label) + { + Rid = rid; + Label = label; + } + } + + /// + /// The managed-reference graph of one serialized object document: its fileId anchor, an optional + /// best-effort component/type name for the header, the RefIds nodes, the parent → child edges between + /// them, the field-pointer roots and the derived shared / orphaned sets. + /// + internal sealed class ReferenceGraphDocument + { + public long FileId; + public string TypeName; + + public readonly List Nodes = new(); + + // One entry per field pointer in the document body (the tree's entry points). The same rid may appear under + // two fields — both are kept, so the window renders each subtree and the shared set flags the alias. + public readonly List Roots = new(); + + // Parent rid → ordered, de-duplicated child rids of the nested graph (data-block pointers only; roots are + // tracked separately in Roots). + public readonly Dictionary> Edges = new(); + + // rids referenced by two or more parents in total (root pointers + nested edges) — aliased managed references. + public readonly HashSet Shared = new(); + + // rids reachable from no root — leftover payloads no field points at. + public readonly HashSet Orphans = new(); + + public ReferenceGraphNode? FindNode(long rid) + { + foreach (var node in Nodes) + if (node.Rid == rid) return node; + + return null; + } + + public IReadOnlyList ChildrenOf(long rid) => + Edges.TryGetValue(rid, out var children) ? children : Array.Empty(); + } + + /// + /// Builds, per asset path, a document-per-component managed-reference graph from the raw YAML — independent of + /// the live serialization API, so it sees nested, orphaned and missing references the Inspector cannot navigate + /// to. Parsing is local to this scanner: it reuses / + /// for type identity only, and does not depend on the + /// repair-flow helpers in . + /// + internal static class SerializeReferenceGraphScanner + { + // "--- !u!114 &11400000" — object document header carrying the local file id as its YAML anchor and the + // class id ("!u!114") used as a best-effort fallback label when the live type name is unavailable. + private static readonly Regex DocumentHeader = new(@"^--- !u!(?\d+) &(?\d+)", RegexOptions.Compiled); + private static readonly Regex ReferencesKey = new(@"^\s*references:\s*$", RegexOptions.Compiled); + private static readonly Regex RefIdsKey = new(@"^\s*RefIds:\s*$", RegexOptions.Compiled); + private static readonly Regex EntryRid = new(@"^(?\s*)-\s+rid:\s*(?-?\d+)\s*$", RegexOptions.Compiled); + private static readonly Regex TypeLine = new(@"^\s*type:\s*\{(?.*)\}\s*$", RegexOptions.Compiled); + private static readonly Regex DataKey = new(@"^\s*data:\s*$", RegexOptions.Compiled); + private static readonly Regex InlineType = new( + @"class:\s*(?:'(?(?:[^']|'')*)'|(?[^,}]*?))\s*,\s*ns:\s*(?[^,}]*?)\s*,\s*asm:\s*(?[^,}]*?)\s*$", + RegexOptions.Compiled); + + // A "rid:" pointer anywhere in a body/data line (inline "{rid: N}", "- rid: N", or a bare "rid: N" scalar). + // The leading non-word lookbehind keeps a field whose name ends in "rid" (e.g. "_hybrid: 5") from matching; + // a matched number is further validated against the known RefIds set before becoming an edge. + private static readonly Regex RidPointer = new(@"(?-?\d+)", RegexOptions.Compiled); + + // A mapping key on a body line ("_weapon:", "data:", a sequence item's "- _weapon:"), used to label a root. + private static readonly Regex MappingKey = new(@"^\s*(?:-\s+)?(?[A-Za-z_][\w\-]*)\s*:", RegexOptions.Compiled); + + /// + /// Scans every object document in the asset and returns the managed-reference graph of each one that has a + /// RefIds block. Documents without managed references are skipped. A read or parse failure yields an + /// empty list — the window simply shows its empty state. + /// + public static List Build(string assetPath) + { + var result = new List(); + + try + { + if (string.IsNullOrEmpty(assetPath) || !File.Exists(assetPath)) return result; + + var lines = File.ReadAllLines(assetPath); + var headers = CollectHeaders(lines); + var typeNames = ResolveTypeNames(assetPath); + + for (var h = 0; h < headers.Count; h++) + { + var (fileId, classId, start) = headers[h]; + var end = h + 1 < headers.Count ? headers[h + 1].start : lines.Length; + + var document = BuildDocument(lines, fileId, start, end); + if (document is null) continue; + + document.TypeName = typeNames.TryGetValue(fileId, out var name) && !string.IsNullOrEmpty(name) + ? name + : $"!u!{classId}"; + + result.Add(document); + } + } + catch (Exception) + { + // Best effort — a parse failure simply yields no graph to display. + } + + return result; + } + + // Parses one document's RefIds block into nodes, then walks the body (field pointers = roots) and each + // entry's data block (nested pointers = edges) to assemble edges, shared and orphan sets. Returns null when + // the document has no RefIds block (no managed references to graph). + private static ReferenceGraphDocument BuildDocument(string[] lines, long fileId, int start, int end) + { + var referencesStart = FindKey(lines, ReferencesKey, start, end); + var bodyEnd = referencesStart >= 0 ? referencesStart : end; + + var refIdsStart = FindKey(lines, RefIdsKey, start, end); + if (refIdsStart < 0) return null; + + var document = new ReferenceGraphDocument { FileId = fileId }; + CollectNodes(lines, refIdsStart, end, document); + if (document.Nodes.Count == 0) return null; + + var knownRids = new HashSet(); + foreach (var node in document.Nodes) knownRids.Add(node.Rid); + + CollectRoots(lines, start, bodyEnd, knownRids, document); + CollectEdges(lines, refIdsStart, end, knownRids, document); + ComputeSharedAndOrphans(document, knownRids); + + return document; + } + + // Each "- rid: N" entry in the RefIds block becomes a node; its type is read from the following "type:" line. + private static void CollectNodes(string[] lines, int refIdsStart, int end, ReferenceGraphDocument document) + { + for (var i = refIdsStart + 1; i < end; i++) + { + var ridMatch = EntryRid.Match(lines[i]); + if (!ridMatch.Success || !long.TryParse(ridMatch.Groups["id"].Value, out var rid)) continue; + + var type = default(ManagedTypeName); + for (var j = i + 1; j < end && j <= i + 4; j++) + { + var typeMatch = TypeLine.Match(lines[j]); + if (!typeMatch.Success) continue; + + // On a parse failure type stays default/empty (and the node renders as just "rid N"). + if (!TryParseInlineType(typeMatch.Groups["body"].Value, out type)) + type = default; + break; + } + + var resolves = !type.IsEmpty && SerializeReferenceHelpers.StoredTypeResolves(type); + document.Nodes.Add(new ReferenceGraphNode(rid, type, resolves)); + } + } + + // Field pointers in the document body (everything before the "references:" block) are the tree roots. The + // label is the nearest mapping key on or above the pointer line — the field/element that holds the reference. + // Each pointer is kept (no rid de-duplication) so two fields aliasing one reference both render and the + // alias is counted as shared. + private static void CollectRoots(string[] lines, int start, int bodyEnd, HashSet knownRids, ReferenceGraphDocument document) + { + for (var i = start + 1; i < bodyEnd; i++) + { + foreach (Match match in RidPointer.Matches(lines[i])) + { + if (!long.TryParse(match.Groups["id"].Value, out var rid)) continue; + if (!knownRids.Contains(rid)) continue; // a dangling pointer, not a graphed reference + + var label = NearestKey(lines, i, start); + document.Roots.Add(new ReferenceGraphRoot(rid, label)); + } + } + } + + // Within each RefIds entry's "data:" block, every "rid:" pointer is a parent → child edge. The entry's own + // "- rid:" header line is skipped so an entry is never recorded as its own child. + private static void CollectEdges(string[] lines, int refIdsStart, int end, HashSet knownRids, ReferenceGraphDocument document) + { + for (var i = refIdsStart + 1; i < end; i++) + { + var ridMatch = EntryRid.Match(lines[i]); + if (!ridMatch.Success || !long.TryParse(ridMatch.Groups["id"].Value, out var parent)) continue; + + var entryIndent = ridMatch.Groups["indent"].Length; + var entryEnd = FindEntryEnd(lines, i + 1, end, entryIndent); + + var dataStart = FindKey(lines, DataKey, i + 1, entryEnd); + if (dataStart < 0) continue; + + for (var j = dataStart + 1; j < entryEnd; j++) + { + foreach (Match match in RidPointer.Matches(lines[j])) + { + if (!long.TryParse(match.Groups["id"].Value, out var child)) continue; + if (child == parent || !knownRids.Contains(child)) continue; + + AddEdge(document, parent, child); + } + } + } + } + + // Shared = referenced by 2+ parents in total — every root pointer plus every nested edge counts once. + // Orphans = reachable from no root, found by a BFS from the roots and complementing against the known node + // set (cycle-safe via the visited set). + private static void ComputeSharedAndOrphans(ReferenceGraphDocument document, HashSet knownRids) + { + var parentCount = new Dictionary(); + + foreach (var root in document.Roots) + parentCount[root.Rid] = parentCount.GetValueOrDefault(root.Rid) + 1; + + foreach (var pair in document.Edges) + foreach (var child in pair.Value) + parentCount[child] = parentCount.GetValueOrDefault(child) + 1; + + foreach (var pair in parentCount) + if (pair.Value >= 2) document.Shared.Add(pair.Key); + + var reachable = new HashSet(); + var queue = new Queue(); + foreach (var root in document.Roots) + if (reachable.Add(root.Rid)) queue.Enqueue(root.Rid); + + while (queue.Count > 0) + { + var rid = queue.Dequeue(); + foreach (var child in document.ChildrenOf(rid)) + if (reachable.Add(child)) queue.Enqueue(child); + } + + foreach (var rid in knownRids) + if (!reachable.Contains(rid)) document.Orphans.Add(rid); + } + + private static void AddEdge(ReferenceGraphDocument document, long parent, long child) + { + if (!document.Edges.TryGetValue(parent, out var children)) + { + children = new List(); + document.Edges[parent] = children; + } + + if (!children.Contains(child)) children.Add(child); + } + + // Walks up from line i to the first line whose indent is shallower and carries a mapping key, returning that + // key — the field/element name the pointer sits under. Falls back to the key on the pointer line itself. + private static string NearestKey(string[] lines, int i, int start) + { + var pointerIndent = IndentOf(lines[i]); + + var onLine = MappingKey.Match(lines[i]); + if (onLine.Success && !IsStructuralKey(onLine.Groups["key"].Value)) + return onLine.Groups["key"].Value; + + for (var j = i - 1; j >= start; j--) + { + if (lines[j].Trim().Length == 0) continue; + if (IndentOf(lines[j]) >= pointerIndent) continue; + + var match = MappingKey.Match(lines[j]); + if (match.Success && !IsStructuralKey(match.Groups["key"].Value)) + return match.Groups["key"].Value; + + pointerIndent = IndentOf(lines[j]); + } + + return onLine.Success ? onLine.Groups["key"].Value : "reference"; + } + + // YAML scaffolding keys that never make a meaningful root label. + private static bool IsStructuralKey(string key) => + key is "rid" or "data" or "type" or "version" or "references" or "RefIds"; + + private static List<(long fileId, int classId, int start)> CollectHeaders(string[] lines) + { + var headers = new List<(long, int, int)>(); + for (var i = 0; i < lines.Length; i++) + { + var match = DocumentHeader.Match(lines[i]); + if (match.Success && + long.TryParse(match.Groups["id"].Value, out var fileId) && + int.TryParse(match.Groups["class"].Value, out var classId)) + { + headers.Add((fileId, classId, i)); + } + } + + return headers; + } + + // Maps each document's fileId to a display name from the live object (component / ScriptableObject type + // name). Best effort and cheap: a single LoadAllAssetsAtPath pass; objects Unity cannot load are simply + // omitted and fall back to the YAML class id. + private static Dictionary ResolveTypeNames(string assetPath) + { + var map = new Dictionary(); + + try + { + foreach (var obj in AssetDatabase.LoadAllAssetsAtPath(assetPath)) + { + if (obj == null) continue; + if (!AssetDatabase.TryGetGUIDAndLocalFileIdentifier(obj, out _, out var fileId)) continue; + + map[fileId] = DisplayNameOf(obj); + } + } + catch (Exception) + { + // Best effort — the YAML class id is the fallback header label. + } + + return map; + } + + private static string DisplayNameOf(Object obj) + { + var typeName = obj.GetType().Name; + return string.IsNullOrEmpty(obj.name) ? typeName : $"{typeName} · {obj.name}"; + } + + private static int FindKey(string[] lines, Regex key, int start, int end) + { + for (var i = start; i < end; i++) + if (key.IsMatch(lines[i])) return i; + + return -1; + } + + // A RefIds entry runs until the next list item at its own indent, or until the block dedents out of it. + private static int FindEntryEnd(string[] lines, int from, int end, int entryIndent) + { + for (var j = from; j < end; j++) + { + if (lines[j].Trim().Length == 0) continue; + + var indent = IndentOf(lines[j]); + if (indent < entryIndent) return j; + if (indent == entryIndent && lines[j].TrimStart().StartsWith("- ")) return j; + } + + return end; + } + + private static int IndentOf(string line) + { + var count = 0; + while (count < line.Length && line[count] == ' ') count++; + return count; + } + + // Parses the inline "class: X, ns: Y, asm: Z" body of a RefIds type mapping, honouring the single-quoted + // class names Unity writes for closed generics (e.g. 'Modifier`1[[…]]'). Mirrors the parser in + // SerializeReferenceYamlEditor but kept local so the scanner owns its parsing. + private static bool TryParseInlineType(string body, out ManagedTypeName type) + { + type = default; + + var match = InlineType.Match(body); + if (!match.Success) return false; + + var className = match.Groups["class"].Value.Replace("''", "'"); + type = new ManagedTypeName(match.Groups["asm"].Value, match.Groups["ns"].Value, className); + return !type.IsEmpty; + } + } +} diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Extensions/SerializeReferenceGraphScanner.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Extensions/SerializeReferenceGraphScanner.cs.meta new file mode 100644 index 00000000..a0fe6749 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Extensions/SerializeReferenceGraphScanner.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 78e457f89e9fc492d9dc7d8aa7ffc767 \ No newline at end of file diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Extensions/SerializeReferenceHelpers.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Extensions/SerializeReferenceHelpers.cs new file mode 100644 index 00000000..a9b33fc3 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Extensions/SerializeReferenceHelpers.cs @@ -0,0 +1,891 @@ +using System; +using System.Text; +using UnityEngine; +using UnityEditor; +using Aspid.FastTools.Types; +using Aspid.FastTools.Editors; +using System.Collections.Generic; +using Aspid.FastTools.Types.Editors; +using UnityEditor.SceneManagement; +using System.Runtime.Serialization; +using System.Text.RegularExpressions; +using Object = UnityEngine.Object; + +// ReSharper disable once CheckNamespace +namespace Aspid.FastTools.SerializeReferences.Editors +{ + /// + /// Shared helpers for the [TypeSelector] drawer on [SerializeReference] fields: resolving the + /// declared managed-reference field type, filtering candidate types, instantiating the selected type, and + /// parsing Unity's managed-reference type-name format. The open-generic argument flow itself lives in + /// the shared / + /// ; + /// is supplied to the selector as its argument filter. + /// + internal static class SerializeReferenceHelpers + { + /// + /// Resolves the declared element type of a managed-reference property — the base type that + /// constrains the candidate list. Uses , + /// which already reports the element type for array/list entries. + /// + public static Type GetFieldType(SerializedProperty property) => + GetTypeFromTypename(property.managedReferenceFieldTypename) ?? typeof(object); + + /// + /// Resolves the concrete type currently stored in the managed reference, or + /// when the reference is empty or its stored type can no longer be loaded. + /// + public static Type GetCurrentType(SerializedProperty property) => + property.managedReferenceValue?.GetType(); + + #region Project scan helpers + // File kinds that can carry SerializeReference managed-reference documents. Single-sourced here so the Repair + // window, the usage index, the breakage detector and the build/CI gate all scan the same candidate set. + internal static readonly string[] ScanExtensions = { ".prefab", ".asset", ".unity" }; + + /// + /// Returns when is a project asset (under Assets/) whose + /// extension can host managed references. Promoted from the Repair window so every project-wide scanner shares + /// one definition. + /// + internal static bool IsScanCandidate(string path) + { + if (string.IsNullOrEmpty(path) || !path.StartsWith("Assets/", StringComparison.Ordinal)) return false; + if (SerializeReferenceSettings.IsExcluded(path)) return false; + + foreach (var extension in ScanExtensions) + if (path.EndsWith(extension, StringComparison.OrdinalIgnoreCase)) + return true; + + return false; + } + + /// + /// Stable grouping key for a stored type identity (class + namespace + assembly). + /// carries no value equality, so the three fields are joined into a key string instead. + /// + internal static string StoredTypeKey(ManagedTypeName type) => + $"{type.Assembly}|{type.Namespace}|{type.Class}"; + #endregion + + #region Multi-object editing + /// + /// Returns when this property belongs to a editing more + /// than one target object at once (a multi-selection in the inspector). + /// + public static bool IsEditingMultipleObjects(SerializedProperty property) => + property.serializedObject.isEditingMultipleObjects; + + /// + /// Returns when the selected targets do not all hold the same managed-reference type — + /// either Unity reports , or the per-target + /// differs across + /// . Always for a single target, so the + /// single-object paths are untouched. Used to drive the dropdown's mixed-value state and to suppress merging + /// child field UIs of incompatible types. + /// + public static bool HasMixedTypes(SerializedProperty property) + { + if (!property.serializedObject.isEditingMultipleObjects) return false; + + // hasMultipleDifferentValues catches differing concrete instances; the explicit type-name comparison also + // catches the all-missing case, where every target reads back a null value but the stored (unloadable) + // type names still differ — hasMultipleDifferentValues does not always flag that on its own. + if (property.hasMultipleDifferentValues) return true; + + // A non-null, agreed value means all targets share the concrete type — skip the per-target probe (and its + // SerializedObject allocations). The per-target comparison only matters for the all-missing case (null value). + if (property.managedReferenceValue is not null) return false; + + var first = property.managedReferenceFullTypename; + var targets = property.serializedObject.targetObjects; + if (targets.Length < 2) return false; + + foreach (var target in targets) + { + if (target == null) continue; + + using var single = new SerializedObject(target); + var other = single.FindProperty(property.propertyPath); + if (other is null) continue; + if (other.managedReferenceFullTypename != first) return true; + } + + return false; + } + + /// + /// Applies a managed-reference change to every selected target independently, so each object receives its + /// own instance rather than the shared reference that a single multi-object + /// assignment would alias across all of them. For each + /// target, is invoked with that target's previous value (to support Keep-Data + /// through ) and must return a fresh, independent instance (or + /// to clear). The whole batch is collapsed into a single Undo step so one Ctrl+Z reverts + /// all targets together. The originating 's is then + /// refreshed so the live inspector re-reads the new state. + /// + public static void ApplyManagedReferencePerTarget(SerializedProperty property, Func factory) + { + var serializedObject = property.serializedObject; + var targets = serializedObject.targetObjects; + var propertyPath = property.propertyPath; + + Undo.IncrementCurrentGroup(); + var undoGroup = Undo.GetCurrentGroup(); + + foreach (var target in targets) + { + if (target == null) continue; + + using var single = new SerializedObject(target); + var singleProperty = single.FindProperty(propertyPath); + if (singleProperty is null) continue; + + var previous = singleProperty.managedReferenceValue; + var instance = factory(previous); + + // Each target gets its own instance: the factory must not return the same object for two targets, + // otherwise the managed reference would be aliased across objects again. + singleProperty.managedReferenceValue = instance; + singleProperty.isExpanded = instance is not null; + single.ApplyModifiedProperties(); + } + + Undo.CollapseUndoOperations(undoGroup); + + // Pull the per-target writes back into the inspector's live SerializedObject so the drawer re-reads the new + // state without waiting for the next external change notification. Update() re-reads from the targets and + // discards any unapplied edits the live SO still holds — applying it instead would write the live SO's stale + // (pre-change) managed reference back over the per-target writes. This drawer applies its own change through + // the per-target path immediately, so the live SO carries no competing pending edits to lose here. + serializedObject.Update(); + } + + /// + /// Whether the per-asset missing/shared notices may be shown for this property. They are file-level operations + /// keyed to a single backing asset (YAML rewrite, single-object cross-reference scan), so under a multi-object + /// selection they would either misreport (the probes read only the first target) or apply to a single target + /// while presenting as if they covered the selection. The conservative rule is therefore to surface a notice + /// only for a single target; with several targets selected the notices are suppressed and the mixed/same-type + /// hint takes their place. Returns for the single-target case (notices allowed). + /// + public static bool NoticesApply(SerializedProperty property) => + !property.serializedObject.isEditingMultipleObjects; + #endregion + + /// + /// Returns when this property holds a managed reference whose type can no longer be + /// loaded (renamed / moved / deleted). Unity does not expose a missing type through the per-property API + /// (the value reads back and + /// is empty) and even drops it from the live object on prefabs / GameObjects, so detection reads the stored + /// reference straight from the asset YAML: a null value whose recorded type cannot be resolved is missing. + /// + public static bool IsMissingType(SerializedProperty property) => + TryGetMissingType(property, out _, out _); + + // Core missing-type probe shared by the public helpers: reads the property's stored id and type from the + // asset YAML and reports it missing when the recorded type no longer resolves to a loadable Type. + private static bool TryGetMissingType(SerializedProperty property, out long referenceId, out ManagedTypeName storedType) + { + referenceId = 0; + storedType = default; + + if (property.propertyType != SerializedPropertyType.ManagedReference) return false; + if (property.managedReferenceValue is not null) return false; + if (!TryGetRepairLocation(property, out var assetPath, out var fileId, out _)) return false; + if (!SerializeReferenceYamlEditor.TryReadStoredType(assetPath, fileId, property.propertyPath, out referenceId, out storedType)) + return false; + + return !storedType.IsEmpty && !StoredTypeResolves(storedType); + } + + // True when the YAML-recorded type identity can be loaded — i.e. the reference is intact, not missing. + public static bool StoredTypeResolves(ManagedTypeName name) + { + if (string.IsNullOrEmpty(name.Class)) return false; + + var className = name.Class.Replace('/', '+'); + var fullName = string.IsNullOrEmpty(name.Namespace) ? className : $"{name.Namespace}.{className}"; + var assemblyQualified = string.IsNullOrEmpty(name.Assembly) ? fullName : $"{fullName}, {name.Assembly}"; + + return Type.GetType(assemblyQualified, throwOnError: false) is not null; + } + + /// + /// Predicate identifying types that can legally be assigned to a [SerializeReference] field: + /// concrete reference types that are neither , open generics, strings, nor delegates. + /// + public static bool IsAssignableManagedReference(Type type) => + type is { IsClass: true, IsAbstract: false, ContainsGenericParameters: false } && + type != typeof(string) && + !typeof(Object).IsAssignableFrom(type) && + !typeof(Delegate).IsAssignableFrom(type); + + /// + /// Builds the candidate predicate for the type picker: the structural + /// check, optionally narrowed so only types assignable to one of qualify. A null or + /// empty set — or one that only names (the unconstrained [TypeSelector] default) — + /// applies no extra narrowing, leaving every concrete type assignable to the field's declared type as a candidate. + /// + public static Func BuildAssignableFilter(Type[] baseTypes) + { + var narrowing = FilterNarrowingTypes(baseTypes); + if (narrowing is null) return IsAssignableManagedReference; + + return type => IsAssignableManagedReference(type) && + Array.Exists(narrowing, baseType => baseType.IsAssignableFrom(type)); + } + + // Drops nulls and the unconstrained `object` sentinel; returns null when nothing meaningful narrows the set, + // so the caller can fall back to the plain structural filter without allocating a predicate closure. + private static Type[] FilterNarrowingTypes(Type[] baseTypes) + { + if (baseTypes is null || baseTypes.Length == 0) return null; + + var count = 0; + foreach (var type in baseTypes) + if (type is not null && type != typeof(object)) count++; + + if (count == 0) return null; + + var result = new Type[count]; + var index = 0; + foreach (var type in baseTypes) + if (type is not null && type != typeof(object)) result[index++] = type; + + return result; + } + + /// + /// Creates an instance of for assignment to a managed reference. + /// Prefers a (public or non-public) parameterless constructor so field initializers run, and + /// falls back to an uninitialized instance for types that expose no parameterless constructor. + /// + public static object CreateInstance(Type type) + { + if (type is null) return null; + + try + { + return Activator.CreateInstance(type, nonPublic: true); + } + catch (MissingMethodException) + { + return FormatterServices.GetUninitializedObject(type); + } + } + + /// + /// Creates an instance of and carries over the data of + /// for every field the two types share by name and serialized shape. Mirrors Unity's own type-change + /// behaviour: the old value is serialized to JSON and overwritten onto the new instance, so matching fields + /// survive a type switch (e.g. a shared _radius) while the rest fall back to the new type's defaults. + /// A structural mismatch simply leaves the new instance untouched. + /// + public static object CreateInstancePreservingData(Type newType, object previous) + { + var instance = CreateInstance(newType); + if (instance is null || previous is null) return instance; + + try + { + var json = JsonUtility.ToJson(previous); + if (!string.IsNullOrEmpty(json) && json != "{}") + JsonUtility.FromJsonOverwrite(json, instance); + } + catch (Exception) + { + // Best effort: incompatible layouts just mean nothing is carried over. + } + + return instance; + } + + /// + /// Parses Unity's managed-reference type-name format ("AssemblyName Namespace.TypeName") + /// into a , or when it is empty or cannot be loaded. + /// + public static Type GetTypeFromTypename(string typename) + { + if (string.IsNullOrEmpty(typename)) return null; + + var separator = typename.IndexOf(' '); + if (separator < 0) return Type.GetType(typename, throwOnError: false); + + var assembly = typename[..separator]; + var fullName = typename[(separator + 1)..]; + return Type.GetType($"{fullName}, {assembly}", throwOnError: false); + } + + /// + /// Predicate identifying types usable as a generic argument of a serialized managed reference: + /// concrete, non-generic types Unity can serialize as a field value (primitives, , + /// enums, -derived references, or [Serializable] structs/classes). Passed to + /// as the argument filter. + /// + public static bool IsValidGenericArgument(Type type) + { + if (type is null) return false; + if (type.IsAbstract || type.IsInterface || type.ContainsGenericParameters) return false; + if (typeof(Delegate).IsAssignableFrom(type)) return false; + + return type.IsPrimitive || + type.IsEnum || + type == typeof(string) || + typeof(Object).IsAssignableFrom(type) || + (type.IsValueType && type.IsSerializable) || + (type.IsClass && type.IsSerializable); + } + + #region Missing-type repair + /// + /// Resolves the stored (now unloadable) type identity of this property's missing managed reference, read from + /// the asset YAML, for display in the caption / warning. Returns when the property + /// is not a recognised missing reference. + /// + public static ManagedTypeName GetMissingTypeName(SerializedProperty property) => + TryGetMissingType(property, out _, out var storedType) ? storedType : default; + + /// + /// Human-readable Namespace.Class of this property's missing type, for the dropdown caption and the + /// warning message, or an empty string when the property is not a recognised missing reference. + /// + public static string GetMissingTypeDisplayName(SerializedProperty property) + { + var name = GetMissingTypeName(property); + if (name.IsEmpty) return string.Empty; + return string.IsNullOrEmpty(name.Namespace) ? name.Class : $"{name.Namespace}.{name.Class}"; + } + + /// + /// Computes the best Smart Fix repair suggestion for this property's missing managed reference: the + /// highest-scoring existing type the renamed/moved reference most likely became, ranked by + /// . The suggestion is never applied automatically — the caller + /// surfaces it as a one-click action. The candidate pool is constrained to types the picker itself would offer + /// (assignable to the field's declared type, narrowed by ), so a suggestion can + /// never violate the field's constraint. Returns when the property is not a recognised + /// missing reference, the repair location is unavailable, or no candidate clears the confidence threshold. + /// + public static bool TryGetRepairSuggestion(SerializedProperty property, Type[] baseTypes, + out SerializeReferenceRepairSuggestions.RepairCandidate suggestion) + { + suggestion = default; + + if (!TryGetMissingType(property, out var referenceId, out var storedType)) return false; + if (!TryGetRepairLocation(property, out var assetPath, out var fileId, out var inMemory)) return false; + + var fieldType = GetFieldType(property); + // The same predicate the picker applies, so a surfaced suggestion is guaranteed to be a type the user could + // have picked manually — never one the [TypeSelector] base-type narrowing would have hidden. + var pickerFilter = BuildAssignableFilter(baseTypes); + + var ranked = SerializeReferenceRepairSuggestions.GetCached(assetPath, fileId, referenceId, + () => SerializeReferenceRepairSuggestions.Rank( + storedType, + GetMissingFieldNames(property, assetPath, fileId, referenceId, inMemory), + fieldType)); + + foreach (var candidate in ranked) + { + if (!pickerFilter(candidate.Type)) continue; + suggestion = candidate; + return true; + } + + return false; + } + + /// + /// The trailing notice label for a Smart Fix — the short type name with the + /// "· → Name?" affordance. Shared by the UIToolkit and IMGUI notices so the two never drift. + /// + public static string GetSuggestionLabel(SerializeReferenceRepairSuggestions.RepairCandidate suggestion) => + $"· → {TypeSelectorHelpers.GetTypeSelectorTitle(suggestion.Type)}?"; + + /// + /// The hover-tooltip detail for a Smart Fix — the full type identity and the + /// ranking reason. Shared by the UIToolkit and IMGUI notices so the two never drift. + /// + public static string GetSuggestionDetail(SerializeReferenceRepairSuggestions.RepairCandidate suggestion) => + $"Suggested: {suggestion.Type.FullName}, {suggestion.Type.Assembly.GetName().Name}.\n" + + $"Reason: {suggestion.Reason}.\nClick to re-point this reference to it, keeping its data."; + + // Top-level serialized field names of the missing reference's orphaned payload, for the field-shape heuristic. + // Saved assets read them straight from the rid's YAML data block; a Prefab Mode object has no committed block + // for the live copy, so the flat payload Unity still exposes for the missing reference is parsed instead. + private static List GetMissingFieldNames(SerializedProperty property, string assetPath, long fileId, long referenceId, bool inMemory) + { + if (!inMemory) + return SerializeReferenceYamlEditor.GetReferenceFieldNames(assetPath, fileId, referenceId); + + var target = property.serializedObject.targetObject; + foreach (var entry in SerializationUtility.GetManagedReferencesWithMissingTypes(target)) + if (entry.referenceId == referenceId) + return SerializeReferenceYamlEditor.ParseTopLevelFieldNames(entry.serializedData); + + return new List(); + } + + /// + /// Resolves the on-disk asset path and the target object's local file id (the YAML document anchor) backing + /// this property. Returns for scene objects and prefab instances, which have no + /// editable asset file — the YAML repair flow only applies to saved assets (ScriptableObjects, prefabs). + /// + public static bool TryGetAssetLocation(SerializedProperty property, out string assetPath, out long fileId) + { + fileId = 0; + var target = property.serializedObject.targetObject; + assetPath = AssetDatabase.GetAssetPath(target); + + if (string.IsNullOrEmpty(assetPath)) return false; + return AssetDatabase.TryGetGUIDAndLocalFileIdentifier(target, out _, out fileId); + } + + /// + /// Resolves the YAML document backing this property's stored managed reference and reports whether the repair + /// must be applied in memory. Saved assets (ScriptableObjects, prefab assets selected in the Project) resolve + /// directly and are repaired by rewriting the file. Objects open in Prefab Mode have no asset path of + /// their own — the path comes from the prefab stage and the document id is matched back to the asset on disk — + /// and must be repaired in memory ( = ), because the open + /// stage holds a separate copy that does not refresh on reimport and would overwrite a file rewrite on save. + /// + public static bool TryGetRepairLocation(SerializedProperty property, out string assetPath, out long fileId, out bool inMemory) + { + inMemory = false; + if (TryGetAssetLocation(property, out assetPath, out fileId)) return true; + + assetPath = null; + fileId = 0; + + var target = property.serializedObject.targetObject; + var go = target as GameObject ?? (target as Component)?.gameObject; + if (go is null) return false; + + var stage = PrefabStageUtility.GetPrefabStage(go); + if (stage is not null) + { + if (!TryMatchAssetFileId(stage, target, go, out fileId)) return false; + + assetPath = stage.assetPath; + inMemory = true; + return true; + } + + // A plain object in a saved scene: its scene file is the YAML document store and its scene-local file id is + // the document anchor. Repaired in memory (a loaded scene must not be rewritten on disk under it). + if (TryGetSceneLocation(target, go, out assetPath, out fileId)) + { + inMemory = true; + return true; + } + + return false; + } + + // Resolves a saved-scene object's (scene path, document file id). GlobalObjectId.targetObjectId is the scene's + // local file identifier, which matches the YAML "--- !u!114 &" anchor. Bails for unsaved/dirty scenes + // (the on-disk YAML would not match the live object) and for prefab-instance overrides (their data lives in the + // source prefab, not this scene — see jump-to-source-prefab). + private static bool TryGetSceneLocation(Object target, GameObject go, out string assetPath, out long fileId) + { + assetPath = null; + fileId = 0; + + var scene = go.scene; + if (!scene.IsValid() || string.IsNullOrEmpty(scene.path) || scene.isDirty) return false; + + var globalId = GlobalObjectId.GetGlobalObjectIdSlow(target); + if (globalId.identifierType != 2) return false; // 2 == scene object + if (globalId.targetPrefabId != 0) return false; // a prefab-instance override — defer to the source prefab + + assetPath = scene.path; + fileId = unchecked((long)globalId.targetObjectId); + return true; + } + + /// + /// Resolves the source prefab asset path for a nested prefab instance's , whose managed + /// reference data lives in that source prefab rather than the host. Returns for plain + /// scene objects and saved assets. + /// + public static bool TryGetSourcePrefabPath(Object target, out string sourcePath) + { + sourcePath = null; + if (target == null) return false; + + var go = target as GameObject ?? (target as Component)?.gameObject; + if (go is null || !PrefabUtility.IsPartOfPrefabInstance(go)) return false; + + sourcePath = PrefabUtility.GetPrefabAssetPathOfNearestInstanceRoot(go); + return !string.IsNullOrEmpty(sourcePath); + } + + // A Prefab Mode object is a copy in a preview scene and carries no file id of its own, so the matching + // persisted object is located in the asset by replaying its child path from the stage root, and the document + // id is read from the asset's component (or GameObject) there. + private static bool TryMatchAssetFileId(PrefabStage stage, Object target, GameObject stageGo, out long fileId) + { + fileId = 0; + + var indices = new List(); + var transform = stageGo.transform; + var root = stage.prefabContentsRoot.transform; + while (transform != root) + { + if (transform.parent is null) return false; // object is not under the stage root + indices.Insert(0, transform.GetSiblingIndex()); + transform = transform.parent; + } + + var assetRoot = AssetDatabase.LoadAssetAtPath(stage.assetPath); + if (assetRoot is null) return false; + + var assetTransform = assetRoot.transform; + foreach (var index in indices) + { + if (index < 0 || index >= assetTransform.childCount) return false; + assetTransform = assetTransform.GetChild(index); + } + + if (target is not Component component) + return AssetDatabase.TryGetGUIDAndLocalFileIdentifier(assetTransform.gameObject, out _, out fileId); + + // Disambiguate by component index in case the object carries several components of the same type. + var stageComponents = stageGo.GetComponents(component.GetType()); + var componentIndex = Array.IndexOf(stageComponents, component); + var assetComponents = assetTransform.GetComponents(component.GetType()); + if (componentIndex < 0 || componentIndex >= assetComponents.Length) return false; + + return AssetDatabase.TryGetGUIDAndLocalFileIdentifier(assetComponents[componentIndex], out _, out fileId); + } + + /// + /// Finds the RefIds id of the missing managed reference this property points at, read from the asset + /// YAML. Detection is strict and per-property: only a field whose own recorded type fails to resolve counts + /// as missing, so legitimately-empty fields are never flagged. + /// + public static bool TryGetMissingReferenceId(SerializedProperty property, out long referenceId) => + TryGetMissingType(property, out referenceId, out _); + + /// + /// Opens the same hierarchical type picker the dropdown uses, anchored at , to + /// choose the existing type a missing reference should resolve to. narrows the + /// candidates the same way the live dropdown does, so a repair cannot pick a type the attribute excludes. The + /// chosen type is written into the asset YAML (re-pointing the reference and keeping its stored data); + /// runs on success. + /// + public static void ShowFixTypeSelector(SerializedProperty property, Rect screenRect, Action onFixed, Type[] baseTypes = null) + { + var fieldType = GetFieldType(property); + + TypeSelectorWindow.Show( + screenRect: screenRect, + types: new[] { fieldType }, + currentAqn: string.Empty, + allow: TypeAllow.None, + onSelected: assemblyQualifiedName => + { + var type = string.IsNullOrEmpty(assemblyQualifiedName) + ? null + : Type.GetType(assemblyQualifiedName, throwOnError: false); + + if (type is not null && TryFixMissingType(property, type)) + onFixed?.Invoke(); + }, + filter: BuildAssignableFilter(baseTypes), + additionalTypes: GenericTypeResolver.GetAssignableGenericDefinitions(fieldType, baseTypes), + argumentFilter: IsValidGenericArgument); + } + + /// + /// Re-points this property's missing managed reference to , keeping its stored data. + /// Saved assets are repaired by rewriting the type in the YAML and reimporting; objects open in Prefab Mode are + /// repaired in memory (see ). Returns on success; the + /// caller refreshes the inspector. + /// + public static bool TryFixMissingType(SerializedProperty property, Type newType) + { + if (newType is null) return false; + if (!TryGetRepairLocation(property, out var assetPath, out var fileId, out var inMemory)) return false; + if (!TryGetMissingReferenceId(property, out var referenceId)) return false; + + bool repaired; + if (inMemory) + { + repaired = TryFixMissingTypeInMemory(property, newType, referenceId); + } + else + { + repaired = SerializeReferenceYamlEditor.TryRewriteType(assetPath, fileId, referenceId, ManagedTypeName.FromType(newType)); + // ForceUpdate reloads the asset and invalidates the live SerializedObject, so the property must not be + // touched afterwards — the inspector is rebuilt below from a fresh selection instead. + if (repaired) AssetDatabase.ImportAsset(assetPath, ImportAssetOptions.ForceUpdate); + } + + // A repair reimports the asset / rewrites the live object: the candidate set changes and the missing + // reference is gone, so any cached ranking and any cached YAML lines for it are now stale. + if (repaired) + { + SerializeReferenceRepairSuggestions.ClearCache(); + SerializeReferenceYamlProbeCache.ClearCache(); + } + + if (repaired) ScheduleInspectorRebuild(); + return repaired; + } + + // Forces the inspector to rebuild after a repair. Unity's object-level "contains missing SerializeReference + // types" banner is drawn from a flag cached when the editor is built and does not react to + // ClearManagedReferenceWithMissingType or an inspector reload — it only clears on a genuine reselection. + // A reimport (saved-asset path) likewise leaves the live SerializedObject stale. Deselecting and reselecting + // the current objects on the next ticks tears the editors down and recreates them from scratch — exactly what + // a manual reselect does — so the banner clears and the resolved field shows through. + private static void ScheduleInspectorRebuild() + { + var selection = Selection.objects; + if (selection is null || selection.Length == 0) return; + + EditorApplication.delayCall += () => + { + Selection.objects = Array.Empty(); + EditorApplication.delayCall += () => Selection.objects = selection; + }; + } + + // Prefab Mode objects cannot be repaired by rewriting the asset file: the open stage holds its own copy that + // does not refresh on reimport and overwrites the change on save. Instead the reference is reassigned on the + // live object — recovering the orphaned field data Unity still keeps for the missing type — and the now-unused + // missing-type entry is cleared so the object stops being flagged. + private static bool TryFixMissingTypeInMemory(SerializedProperty property, Type newType, long referenceId) + { + var target = property.serializedObject.targetObject; + var instance = CreateInstance(newType); + if (instance is null) return false; + + foreach (var entry in SerializationUtility.GetManagedReferencesWithMissingTypes(target)) + { + if (entry.referenceId != referenceId) continue; + RecoverManagedReferenceData(entry.serializedData, instance); + break; + } + + property.SetManagedReferenceAndApply(instance); + ClearMissingSubtree(target, referenceId); + EditorUtility.SetDirty(target); + property.serializedObject.Update(); + + // Mark the owning scene (the prefab stage's preview scene, or a regular scene) dirty so the in-memory + // repair is offered for save — a file rewrite that the open stage would otherwise discard is avoided. + var scene = (target as Component)?.gameObject.scene ?? (target as GameObject)?.scene ?? default; + if (scene.IsValid()) EditorSceneManager.MarkSceneDirty(scene); + + return true; + } + + // Clears the fixed missing-type entry and any missing-type entries it transitively referenced. The in-memory + // repair replaces the reference with a fresh instance, dropping the orphaned payload's nested references — so a + // missing child it carried (e.g. a missing effect nested inside a missing weapon) would otherwise linger as an + // unreachable orphan and keep Unity's object-level missing-types flag (and its banner) raised. + private static void ClearMissingSubtree(Object target, long rootReferenceId) + { + var dataByRid = new Dictionary(); + foreach (var entry in SerializationUtility.GetManagedReferencesWithMissingTypes(target)) + dataByRid[entry.referenceId] = entry.serializedData; + + var pending = new Stack(); + var visited = new HashSet(); + pending.Push(rootReferenceId); + + while (pending.Count > 0) + { + var rid = pending.Pop(); + if (!visited.Add(rid)) continue; + if (!dataByRid.TryGetValue(rid, out var data)) continue; // a resolvable reference, or already cleared + + foreach (Match match in Regex.Matches(data ?? string.Empty, @"rid:\s*(-?\d+)")) + if (long.TryParse(match.Groups[1].Value, out var child) && child != rid) + pending.Push(child); + + SerializationUtility.ClearManagedReferenceWithMissingType(target, rid); + } + } + + // Best-effort recovery of a missing reference's stored data onto the replacement instance. Unity surfaces the + // orphaned payload as the field block of YAML scalars (e.g. "_damage: 15"); the flat top-level scalars are + // mapped to JSON and overwritten onto the instance, so a renamed-type fix keeps its values. Nested mappings + // and sequences are skipped and left at the new type's defaults. + private static void RecoverManagedReferenceData(string serializedData, object instance) + { + if (string.IsNullOrEmpty(serializedData)) return; + + try + { + var json = new StringBuilder("{"); + var first = true; + + foreach (var raw in serializedData.Split('\n')) + { + var line = raw.TrimEnd('\r'); + // Only top-level scalars: skip blanks, indented (nested) lines and sequence items. + if (line.Length == 0 || char.IsWhiteSpace(line[0]) || line[0] == '-') continue; + + var separator = line.IndexOf(':'); + if (separator <= 0) continue; + + var key = line[..separator].Trim(); + var value = line[(separator + 1)..].Trim(); + + // Empty value = a mapping/array header (e.g. "_nested:"); complex flow values are not flat scalars. + if (key.Length == 0 || value.Length == 0 || value[0] is '{' or '[') continue; + + if (!first) json.Append(','); + first = false; + + json.Append('"').Append(key).Append("\":"); + json.Append(IsJsonNumber(value) ? value : Quote(UnquoteYaml(value))); + } + + json.Append('}'); + if (!first) JsonUtility.FromJsonOverwrite(json.ToString(), instance); + } + catch (Exception) + { + // Best effort: an unparseable payload simply leaves the new instance at its defaults. + } + } + + private static bool IsJsonNumber(string value) => Regex.IsMatch(value, @"^-?\d+(\.\d+)?$"); + + // Unity single-quotes YAML scalars that contain reserved characters, doubling embedded quotes. + private static string UnquoteYaml(string value) => + value.Length >= 2 && value[0] == '\'' && value[^1] == '\'' + ? value[1..^1].Replace("''", "'") + : value; + + private static string Quote(string value) => + $"\"{value.Replace("\\", "\\\\").Replace("\"", "\\\"")}\""; + #endregion + + #region Constraint map + /// + /// Maps every managed reference in to the declared field type that holds it, keyed + /// by the owning object document (its local file id) and the reference's RefIds id. A missing reference + /// reads back through the serialization API, but its field still reports the declared + /// element type via , and the orphaned rid survives + /// in the YAML — so the two together recover the constraint the picker should honour. References nested inside a + /// missing parent are unreachable here (the parent is null) and simply fall back to an unconstrained picker, as do + /// orphaned rids no field points at. + /// + /// + /// Shared by the asset-level Repair window (per-entry and project-wide group constraints) and the Managed + /// References graph window, so a single declared-type recovery backs every embedded picker. + /// + public static Dictionary<(long fileId, long rid), Type> BuildConstraintMap(string assetPath) + { + var map = new Dictionary<(long, long), Type>(); + if (string.IsNullOrEmpty(assetPath)) return map; + + // A managed-reference graph may be cyclic (the graph window renders back-edges), so descending into a rid + // already on this document's walk would loop forever. One HashSet per call, cleared per document (rids are + // only unique within a document), records visited rids; revisiting one advances without entering its + // already-walked subtree. + var visited = new HashSet(); + + foreach (var obj in AssetDatabase.LoadAllAssetsAtPath(assetPath)) + { + if (obj == null) continue; + if (!AssetDatabase.TryGetGUIDAndLocalFileIdentifier(obj, out _, out var fileId)) continue; + + visited.Clear(); + + using var serialized = new SerializedObject(obj); + var iterator = serialized.GetIterator(); + + var enterChildren = true; + while (iterator.Next(enterChildren)) + { + enterChildren = true; + + if (iterator.propertyType != SerializedPropertyType.ManagedReference) continue; + + long rid; + if (iterator.managedReferenceValue is not null) + rid = iterator.managedReferenceId; + else if (!SerializeReferenceYamlEditor.TryReadReferenceId(assetPath, fileId, iterator.propertyPath, out rid)) + continue; + + // A rid already walked is a back-edge in a cyclic graph; record the constraint but do not descend + // into its subtree again, or the iterator would never terminate. + if (rid >= 0 && !visited.Add(rid)) enterChildren = false; + + var fieldType = GetFieldType(iterator); + if (fieldType is null || fieldType == typeof(object)) continue; + + map[(fileId, rid)] = fieldType; + } + } + + return map; + } + #endregion + + #region Cross references + /// + /// Returns when another managed-reference property in the same object aliases this + /// one (shares its ) — which happens after duplicating an + /// array element or pasting, leaving two fields backed by a single instance so edits to one bleed into the other. + /// + public static bool HasSharedReference(SerializedProperty property) + { + if (property.managedReferenceValue is null) return false; + + var id = property.managedReferenceId; + var shared = false; + var path = property.propertyPath; + + TraverseManagedReferences(property.serializedObject, other => + { + if (other.propertyPath != path && other.managedReferenceId == id) + { + shared = true; + return true; + } + + return false; + }); + + return shared; + } + + /// + /// Breaks an aliased managed reference by replacing it with an independent clone that carries the same data + /// (a fresh instance gets a new on assignment), so the + /// two formerly shared fields no longer affect each other. + /// + public static void MakeReferenceUnique(SerializedProperty property) + { + var persistent = property.Persistent(); + var current = persistent.managedReferenceValue; + if (current is null) return; + + persistent.SetManagedReferenceAndApply(CreateInstancePreservingData(current.GetType(), current)); + } + + // Visits every managed-reference property in the object, descending into nested values; stops early when + // the visitor returns true. + private static void TraverseManagedReferences(SerializedObject serializedObject, Func visit) + { + using var iterator = serializedObject.GetIterator(); + if (!iterator.Next(enterChildren: true)) return; + + do + { + if (iterator.propertyType == SerializedPropertyType.ManagedReference && visit(iterator)) + return; + } + while (iterator.Next(enterChildren: true)); + } + #endregion + } +} diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Extensions/SerializeReferenceHelpers.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Extensions/SerializeReferenceHelpers.cs.meta new file mode 100644 index 00000000..a5d12ec0 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Extensions/SerializeReferenceHelpers.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 81c62c61701e4f6d88b678ddae8380ba +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/Unity/Editor/Scripts/SerializeReferences/Extensions/SerializeReferenceLinker.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Extensions/SerializeReferenceLinker.cs new file mode 100644 index 00000000..323091a3 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Extensions/SerializeReferenceLinker.cs @@ -0,0 +1,89 @@ +using System; +using UnityEditor; +using Aspid.FastTools.Editors; +using System.Collections.Generic; + +// ReSharper disable once CheckNamespace +namespace Aspid.FastTools.SerializeReferences.Editors +{ + /// + /// The inverse of Make Unique: deliberately shares one managed-reference instance across several fields of the same + /// object. Because there is no rid setter, sharing is achieved by assigning the SAME instance to both paths — + /// Unity then keeps them on a single (exactly the aliasing the + /// shared-reference notice detects), so the rid stripe and notice light up automatically. + /// + internal static class SerializeReferenceLinker + { + /// A sibling managed reference this field could be linked to. + internal readonly struct LinkCandidate + { + public readonly long Rid; + public readonly Type Type; + public readonly string Path; + + public LinkCandidate(long rid, Type type, string path) + { + Rid = rid; + Type = type; + Path = path; + } + } + + /// + /// Every other managed reference in the same object that is assignable to this field and is neither this property + /// nor one of its ancestors/descendants (which would form a self-cycle). + /// + public static List CollectLinkCandidates(SerializedProperty property) + { + var result = new List(); + if (property is null) return result; + + var fieldType = SerializeReferenceHelpers.GetFieldType(property); + var selfPath = property.propertyPath; + var seen = new HashSet(); + + using var iterator = property.serializedObject.GetIterator(); + if (!iterator.Next(enterChildren: true)) return result; + + do + { + if (iterator.propertyType != SerializedPropertyType.ManagedReference) continue; + + var path = iterator.propertyPath; + if (path == selfPath) continue; + if (IsDescendant(path, selfPath) || IsDescendant(selfPath, path)) continue; + + var value = iterator.managedReferenceValue; + if (value is null) continue; + + var type = value.GetType(); + if (fieldType != null && !fieldType.IsAssignableFrom(type)) continue; + + var rid = iterator.managedReferenceId; + if (!seen.Add(rid)) continue; // one representative per shared instance + + result.Add(new LinkCandidate(rid, type, path)); + } + while (iterator.Next(enterChildren: true)); + + return result; + } + + /// Points this field at the instance held by , sharing its rid. + public static bool LinkTo(SerializedProperty property, string sourcePath) + { + if (property is null || string.IsNullOrEmpty(sourcePath)) return false; + + var source = property.serializedObject.FindProperty(sourcePath); + var value = source?.managedReferenceValue; + if (value is null) return false; + + property.Persistent().SetManagedReferenceAndApply(value); + return true; + } + + // True when "candidate" lies inside the "ancestor" property's subtree (a nested field or array element). + private static bool IsDescendant(string candidate, string ancestor) => + candidate.StartsWith(ancestor + ".", StringComparison.Ordinal); + } +} diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Extensions/SerializeReferenceLinker.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Extensions/SerializeReferenceLinker.cs.meta new file mode 100644 index 00000000..55ecf8d5 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Extensions/SerializeReferenceLinker.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b37481d45c0f64bfda47f687d520ec47 \ No newline at end of file diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Extensions/SerializeReferenceRepairSuggestions.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Extensions/SerializeReferenceRepairSuggestions.cs new file mode 100644 index 00000000..163ce74c --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Extensions/SerializeReferenceRepairSuggestions.cs @@ -0,0 +1,341 @@ +using System; +using System.Linq; +using System.Reflection; +using UnityEditor; +using UnityEngine; +using System.Collections.Generic; + +// ReSharper disable once CheckNamespace +namespace Aspid.FastTools.SerializeReferences.Editors +{ + /// + /// Ranking engine behind the missing-type Smart Fix suggestion: given the stored (now unloadable) type + /// identity of a [SerializeReference], the field names recorded for it and the field's declared base + /// constraint, it computes ordered repair candidates and surfaces the best one as a one-click suggestion. The + /// candidate pool is the same set the type picker would offer (concrete managed-reference types assignable to the + /// constraint), so a surfaced suggestion can never be a type the picker itself would refuse. The suggestion is + /// never auto-applied — the user always clicks (an explicit product decision). + /// + internal static class SerializeReferenceRepairSuggestions + { + /// + /// A scored repair candidate for a missing managed reference: the existing the reference + /// could be re-pointed to, the heuristic (highest wins) and a short human . + /// + internal readonly struct RepairCandidate + { + public readonly Type Type; + public readonly float Score; + public readonly string Reason; + + public RepairCandidate(Type type, float score, string reason) + { + Type = type; + Score = score; + Reason = reason; + } + } + + // Only suggestions at or above this confidence are surfaced — below it the heuristics are too weak to offer. + public const float MinScore = 0.6f; + + // The field-shape overlap can add up to this much, lifting a marginal name match over the threshold and + // breaking ties between equally-named candidates. + private const float FieldShapeBonus = 0.2f; + + /// + /// Returns up to repair candidates for a missing managed reference, ordered by descending + /// score (ties broken by field-shape overlap). Only candidates scoring at least are + /// returned. The pool is every concrete managed-reference type assignable to + /// (the field's declared element type), filtered by the same eligibility rules the picker uses, so a returned + /// type always satisfies the field's constraint. + /// + /// The stored, now-unresolvable type identity read from the asset YAML. + /// Top-level serialized field names recorded for the missing reference, or empty. + /// The field's declared element type; typeof(object) for an unconstrained field. + /// Maximum number of candidates to return. + public static IReadOnlyList Rank( + ManagedTypeName stored, + IReadOnlyCollection storedFieldNames, + Type baseConstraint, + int max = 3) + { + if (stored.IsEmpty || max <= 0) return Array.Empty(); + + var constraint = baseConstraint ?? typeof(object); + var storedClass = NormalizeName(stored.Class); + if (string.IsNullOrEmpty(storedClass)) return Array.Empty(); + + var hasFieldNames = storedFieldNames is { Count: > 0 }; + var storedFields = hasFieldNames + ? new HashSet(storedFieldNames, StringComparer.Ordinal) + : null; + + var scored = new List(); + + foreach (var candidate in EnumerateCandidates(constraint)) + { + var baseScore = ScoreCandidate(stored, storedClass, candidate, out var reason); + if (baseScore <= 0f) continue; + + var bonus = hasFieldNames ? FieldShapeOverlap(storedFields, candidate) * FieldShapeBonus : 0f; + var score = baseScore + bonus; + if (score < MinScore) continue; + + scored.Add(new RepairCandidate(candidate, score, reason)); + } + + if (scored.Count == 0) return Array.Empty(); + + scored.Sort((a, b) => b.Score.CompareTo(a.Score)); + return scored.Count <= max ? scored : scored.GetRange(0, max); + } + + // The picker's candidate pool: types derived from the constraint (or every loaded type when unconstrained), + // narrowed to the same managed-reference eligibility the picker enforces and to types actually assignable to + // the constraint — so a suggestion can never be a type the field would refuse. + private static IEnumerable EnumerateCandidates(Type constraint) + { + var pool = constraint == typeof(object) + ? TypeCache.GetTypesDerivedFrom() + : TypeCache.GetTypesDerivedFrom(constraint); + + foreach (var type in pool) + { + if (!SerializeReferenceHelpers.IsAssignableManagedReference(type)) continue; + if (constraint != typeof(object) && !constraint.IsAssignableFrom(type)) continue; + yield return type; + } + } + + // Base score for a candidate against the stored identity, before the field-shape bonus. Returns 0 for no match. + private static float ScoreCandidate(ManagedTypeName stored, string storedClass, Type candidate, out string reason) + { + // A declared [MovedFrom] whose recorded old identity matches the stored one is an authoritative rename — top score. + if (MatchesMovedFrom(candidate, stored, storedClass)) + { + reason = "declared [MovedFrom]"; + return 1f; + } + + var candidateClass = NormalizeName(candidate.Name); + + // Same simple class name, different namespace and/or assembly — the class was moved/renamed-by-namespace. + if (string.Equals(candidateClass, storedClass, StringComparison.Ordinal)) + { + reason = "same type name"; + return 0.8f; + } + + // Same name ignoring case — a casing-only rename. + if (string.Equals(candidateClass, storedClass, StringComparison.OrdinalIgnoreCase)) + { + reason = "same name (case-insensitive)"; + return 0.6f; + } + + // A near-miss name — only surfaced once the field-shape bonus lifts it over the threshold. + if (LevenshteinAtMost(candidateClass, storedClass, 2)) + { + reason = "similar name"; + return 0.5f; + } + + reason = null; + return 0f; + } + + // True when the candidate carries a [MovedFrom] whose recorded old identity matches the stored type's class + // (and, when declared, namespace / assembly). The attribute's backing data is not public API, so every member + // is read reflectively and any failure is treated as "no match" rather than throwing. + private static bool MatchesMovedFrom(Type candidate, ManagedTypeName stored, string storedClass) + { + try + { + foreach (var attribute in candidate.GetCustomAttributes(inherit: false)) + { + var attributeType = attribute.GetType(); + if (attributeType.Name != "MovedFromAttribute") continue; + + var data = attributeType + .GetField("data", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) + ?.GetValue(attribute); + if (data is null) continue; + + var dataType = data.GetType(); + + // When a "*HasChanged" flag is false the old value equals the current type's value, so fall back to + // the candidate's own identity for that slot — exactly how Unity's updater resolves the old name. + var oldClass = NormalizeName(ReadMovedSlot(dataType, data, "className", "classHasChanged", candidate.Name)); + if (!string.Equals(oldClass, storedClass, StringComparison.Ordinal)) continue; + + if (!string.IsNullOrEmpty(stored.Namespace)) + { + var oldNamespace = ReadMovedSlot(dataType, data, "nameSpace", "nameSpaceHasChanged", candidate.Namespace); + if (!string.Equals(oldNamespace ?? string.Empty, stored.Namespace, StringComparison.Ordinal)) continue; + } + + if (!string.IsNullOrEmpty(stored.Assembly)) + { + var oldAssembly = ReadMovedSlot(dataType, data, "assembly", "assemblyHasChanged", candidate.Assembly.GetName().Name); + if (!string.Equals(oldAssembly ?? string.Empty, stored.Assembly, StringComparison.Ordinal)) continue; + } + + return true; + } + } + catch (Exception) + { + // The attribute data struct is not public API; any reflection failure simply means "no MovedFrom match". + } + + return false; + } + + // Reads a string slot of MovedFromAttributeData, returning the recorded old value when its companion + // "*HasChanged" flag is true and the current type's value otherwise (a slot that did not change). + private static string ReadMovedSlot(Type dataType, object data, string valueField, string changedField, string current) + { + var changed = dataType.GetField(changedField, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + var hasChanged = changed?.GetValue(data) is true; + if (!hasChanged) return current; + + var value = dataType.GetField(valueField, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + return value?.GetValue(data) as string; + } + + // Overlap ratio (0..1) between the stored field names and the candidate's serialized instance field names: + // the fraction of stored names that exist on the candidate. An empty candidate shape contributes nothing. + private static float FieldShapeOverlap(HashSet storedFields, Type candidate) + { + var candidateFields = GetSerializedFieldNames(candidate); + if (candidateFields.Count == 0 || storedFields.Count == 0) return 0f; + + var matched = storedFields.Count(candidateFields.Contains); + return (float)matched / storedFields.Count; + } + + // Names of the serialized instance fields Unity would persist for a type: public fields plus private fields + // marked [SerializeField], walking the base chain (each level only reports its own declared fields). + private static HashSet GetSerializedFieldNames(Type type) + { + var names = new HashSet(StringComparer.Ordinal); + + for (var current = type; current is not null && current != typeof(object); current = current.BaseType) + { + const BindingFlags flags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly; + foreach (var field in current.GetFields(flags)) + { + if (field.IsStatic || field.IsLiteral || field.IsInitOnly) continue; + if (field.IsNotSerialized) continue; + + var serialized = field.IsPublic || field.IsDefined(typeof(SerializeField), inherit: false); + if (serialized) names.Add(field.Name); + } + } + + return names; + } + + // Strips Unity's generic-arity/expansion decoration from a stored or live class name so both sides compare on + // the bare simple name: "Modifier`1[[System.Single, mscorlib]]" and "Modifier`1" both reduce to "Modifier". + private static string NormalizeName(string className) + { + if (string.IsNullOrEmpty(className)) return string.Empty; + + // Drop a bracketed generic expansion ("Foo`1[[...]]") and then the backtick-arity suffix ("Foo`1"). + var bracket = className.IndexOf('['); + if (bracket >= 0) className = className[..bracket]; + + var tick = className.IndexOf('`'); + if (tick >= 0) className = className[..tick]; + + // A nested type ("Outer/Inner" or "Outer+Inner") is identified by its innermost segment. + var slash = className.LastIndexOfAny(NestedSeparators); + if (slash >= 0) className = className[(slash + 1)..]; + + return className.Trim(); + } + + private static readonly char[] NestedSeparators = { '/', '+' }; + + // Bounded Levenshtein: returns true when the edit distance between a and b is at most maxDistance, bailing out + // early once a row's best possible distance exceeds the bound (so a long/short mismatch is rejected cheaply). + private static bool LevenshteinAtMost(string a, string b, int maxDistance) + { + if (a is null || b is null) return false; + if (Math.Abs(a.Length - b.Length) > maxDistance) return false; + if (a.Length == 0) return b.Length <= maxDistance; + if (b.Length == 0) return a.Length <= maxDistance; + + var previous = new int[b.Length + 1]; + var current = new int[b.Length + 1]; + for (var j = 0; j <= b.Length; j++) previous[j] = j; + + for (var i = 1; i <= a.Length; i++) + { + current[0] = i; + var rowBest = current[0]; + + for (var j = 1; j <= b.Length; j++) + { + var cost = a[i - 1] == b[j - 1] ? 0 : 1; + current[j] = Math.Min(Math.Min(previous[j] + 1, current[j - 1] + 1), previous[j - 1] + cost); + if (current[j] < rowBest) rowBest = current[j]; + } + + if (rowBest > maxDistance) return false; + (previous, current) = (current, previous); + } + + return previous[b.Length] <= maxDistance; + } + + #region Cached ranking + // IMGUI repaints every frame, so the ranking — which scans the whole TypeCache — is cached per + // (asset, document, rid) with a small FIFO cap. The document file id is part of the key because a rid is only + // unique within one host object: in Prefab Mode several components of the same prefab asset share an asset path + // and can reuse a rid, so a (path, rid) key alone would alias their distinct rankings. The cache is cleared + // whenever a repair lands (a reimport changes the candidate set and clears the missing reference), so a stale + // entry never outlives the asset state it was computed against. + private const int CacheCapacity = 64; + + private static readonly Dictionary<(string assetPath, long fileId, long rid), IReadOnlyList> Cache = new(); + private static readonly Queue<(string assetPath, long fileId, long rid)> CacheOrder = new(); + + /// + /// Cached keyed by , the host document's + /// and the reference's , so a per-frame IMGUI repaint never re-scans the type cache. The + /// factory runs only on a cache miss. + /// + public static IReadOnlyList GetCached( + string assetPath, + long fileId, + long rid, + Func> rank) + { + var key = (assetPath ?? string.Empty, fileId, rid); + if (Cache.TryGetValue(key, out var cached)) return cached; + + var result = rank() ?? Array.Empty(); + Cache[key] = result; + CacheOrder.Enqueue(key); + + while (CacheOrder.Count > CacheCapacity) + { + var evicted = CacheOrder.Dequeue(); + Cache.Remove(evicted); + } + + return result; + } + + /// Drops every cached ranking — called after a repair, since the candidate set has changed. + public static void ClearCache() + { + Cache.Clear(); + CacheOrder.Clear(); + } + #endregion + } +} diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Extensions/SerializeReferenceRepairSuggestions.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Extensions/SerializeReferenceRepairSuggestions.cs.meta new file mode 100644 index 00000000..4a035e6c --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Extensions/SerializeReferenceRepairSuggestions.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: ac74173acf69647aa91a832ea699691d \ No newline at end of file diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Extensions/SerializeReferenceRequiredGate.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Extensions/SerializeReferenceRequiredGate.cs new file mode 100644 index 00000000..333c812c --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Extensions/SerializeReferenceRequiredGate.cs @@ -0,0 +1,92 @@ +using System; +using UnityEditor; +using System.Reflection; +using Aspid.FastTools.Types; +using System.Collections.Generic; + +// ReSharper disable once CheckNamespace +namespace Aspid.FastTools.SerializeReferences.Editors +{ + /// + /// Shared logic for the [SerializeReferenceRequired] marker: detecting whether a property carries it (via the + /// field reflected from the property path) and whether it is currently violated (a genuinely empty reference). Used + /// by the inspector notice and by the build/CI gate's per-property check. + /// + internal static class SerializeReferenceRequiredGate + { + private const BindingFlags FieldFlags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; + + /// Resolves the [SerializeReferenceRequired] attribute on this property's declared field, if any. + public static bool TryGetRequired(SerializedProperty property, out SerializeReferenceRequiredAttribute required) + { + required = null; + if (property is null || property.propertyType != SerializedPropertyType.ManagedReference) return false; + + var field = ResolveFieldInfo(property); + required = field?.GetCustomAttribute(); + return required is not null; + } + + /// + /// True when the property is required and currently unset (its managed reference is empty). A missing-type + /// reference is NOT a required violation here — it has its own notice/gate. + /// + public static bool IsViolation(SerializedProperty property) + { + if (!TryGetRequired(property, out _)) return false; + if (SerializeReferenceHelpers.IsMissingType(property)) return false; // handled by the missing notice + return property.managedReferenceValue is null; + } + + // Walks the property path against the target object's type to find the declared field (which carries the + // attribute). For a list/array element the field is the collection itself, matching PropertyDrawer.fieldInfo. + private static FieldInfo ResolveFieldInfo(SerializedProperty property) + { + var type = property.serializedObject?.targetObject?.GetType(); + if (type is null) return null; + + // "_slots.Array.data[0]._weapon" -> "_slots[0]._weapon" + var path = property.propertyPath.Replace(".Array.data[", "["); + FieldInfo field = null; + + foreach (var rawSegment in path.Split('.')) + { + var segment = rawSegment; + var bracket = segment.IndexOf('['); + var isElement = bracket >= 0; + if (isElement) segment = segment[..bracket]; + + field = GetFieldIncludingBase(type, segment); + if (field is null) return null; + + type = isElement ? GetElementType(field.FieldType) : field.FieldType; + if (type is null) return null; + } + + return field; + } + + private static FieldInfo GetFieldIncludingBase(Type type, string name) + { + for (var current = type; current is not null; current = current.BaseType) + { + var field = current.GetField(name, FieldFlags); + if (field is not null) return field; + } + + return null; + } + + private static Type GetElementType(Type collectionType) + { + if (collectionType.IsArray) return collectionType.GetElementType(); + if (collectionType.IsGenericType) + { + var args = collectionType.GetGenericArguments(); + if (args.Length == 1) return args[0]; + } + + return collectionType; + } + } +} diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Extensions/SerializeReferenceRequiredGate.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Extensions/SerializeReferenceRequiredGate.cs.meta new file mode 100644 index 00000000..2381aaa3 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Extensions/SerializeReferenceRequiredGate.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: dd51acc39e25043489eb6b68bdaa16e4 \ No newline at end of file diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Extensions/SerializeReferenceRidColor.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Extensions/SerializeReferenceRidColor.cs new file mode 100644 index 00000000..ff2aedf8 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Extensions/SerializeReferenceRidColor.cs @@ -0,0 +1,29 @@ +using UnityEngine; + +// ReSharper disable once CheckNamespace +namespace Aspid.FastTools.SerializeReferences.Editors +{ + /// + /// Deterministic per-rid colour helper shared by the inspector field stripe/chip and the Managed + /// References window SHARED chip, so the same rid always renders the same colour across both surfaces. + /// + internal static class SerializeReferenceRidColor + { + // Golden-ratio hue rotation: a Knuth multiplicative hash spreads the integer rid across the full + // hue circle, and adding the golden-ratio conjugate fraction of its low byte pushes consecutive + // ids further apart. Saturation/value are fixed so the chip reads legibly on the dark inspector + // and the Managed References window's dark canvas alike. + private const float GoldenRatioConjugate = 0.618033988749895f; + + /// + /// Returns a deterministic, visually distinct colour for . The same rid + /// always maps to the same colour; nearby rids are separated by the golden-ratio hue rotation. + /// + public static Color ForRid(long rid) + { + var hash = unchecked((uint)(rid * 2654435761)); + var hue = (hash / (float)uint.MaxValue + GoldenRatioConjugate * (hash & 0xFF)) % 1f; + return Color.HSVToRGB(hue, 0.55f, 0.85f); + } + } +} diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Extensions/SerializeReferenceRidColor.cs.meta b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Extensions/SerializeReferenceRidColor.cs.meta new file mode 100644 index 00000000..59ddcb75 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Extensions/SerializeReferenceRidColor.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 170daab4096b048c8bfbcb364cdbca11 \ No newline at end of file diff --git a/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Extensions/SerializeReferenceTemplates.cs b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Extensions/SerializeReferenceTemplates.cs new file mode 100644 index 00000000..42d5a471 --- /dev/null +++ b/Aspid.FastTools/Packages/tech.aspid.fasttools/Unity/Editor/Scripts/SerializeReferences/Extensions/SerializeReferenceTemplates.cs @@ -0,0 +1,142 @@ +using System; +using UnityEditor; +using UnityEngine; +using System.Collections.Generic; + +// ReSharper disable once CheckNamespace +namespace Aspid.FastTools.SerializeReferences.Editors +{ + /// + /// Durable, project-scoped named templates for managed-reference instances: a stored type plus its JSON payload, + /// rehydrated into an independent instance on use. A persistent upgrade over the session-only + /// , modeled on TypeSelectorPreferences (EditorPrefs JSON, scoped by + /// ). Entries whose type no longer resolves are pruned on load. + /// + /// + /// Like the clipboard, the JSON round-trip is -based, so nested [SerializeReference] + /// children are not preserved — templates capture single-level instances. + /// + internal static class SerializeReferenceTemplates + { + private const string KeyPrefix = "Aspid.FastTools.SerializeReference.Templates."; + + [Serializable] + private sealed class Entry + { + public string name; + public string aqn; + public string json; + } + + [Serializable] + private sealed class Store + { + public List entries = new(); + } + + /// A resolved template: its display name and the concrete type it instantiates. + internal readonly struct Template + { + public readonly string Name; + public readonly Type Type; + + public Template(string name, Type type) + { + Name = name; + Type = type; + } + } + + private static string Key => KeyPrefix + PlayerSettings.productGUID; + + /// Saves under (replacing an existing one). + public static void Save(string name, object value) + { + if (string.IsNullOrWhiteSpace(name) || value is null) return; + + var type = value.GetType(); + var store = Load(); + store.entries.RemoveAll(entry => entry.name == name); + store.entries.Add(new Entry { name = name.Trim(), aqn = type.AssemblyQualifiedName, json = JsonUtility.ToJson(value) }); + Persist(store); + } + + /// Every template whose type still resolves, pruning the rest. In stored order. + public static List