diff --git a/Editor/PrefabPatchPlayModeCleanup.cs b/Editor/PrefabPatchPlayModeCleanup.cs
new file mode 100644
index 0000000..e112247
--- /dev/null
+++ b/Editor/PrefabPatchPlayModeCleanup.cs
@@ -0,0 +1,20 @@
+using PatchManager.PrefabPatching;
+using UnityEditor;
+
+namespace PatchManager.Editor;
+
+[InitializeOnLoad]
+internal static class PrefabPatchPlayModeCleanup
+{
+ static PrefabPatchPlayModeCleanup()
+ {
+ EditorApplication.playModeStateChanged -= OnPlayModeStateChanged;
+ EditorApplication.playModeStateChanged += OnPlayModeStateChanged;
+ }
+
+ private static void OnPlayModeStateChanged(PlayModeStateChange state)
+ {
+ if (state == PlayModeStateChange.ExitingPlayMode)
+ PrefabPatchRuntime.ReleaseSessionResources();
+ }
+}
diff --git a/Editor/PrefabPatchPlayModeCleanup.cs.meta b/Editor/PrefabPatchPlayModeCleanup.cs.meta
new file mode 100644
index 0000000..f1a1b47
--- /dev/null
+++ b/Editor/PrefabPatchPlayModeCleanup.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: eb817b87703b6924b85e5d1609007969
\ No newline at end of file
diff --git a/README.md b/README.md
index 55e4a19..5d69084 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,4 @@
# Patch Manager
A mod for generic patching needs similar to KSP 1's Module Manager.
-Documentation: https://ksp2community.github.io/PatchManagerDocs/
+Documentation: https://modding.ksp2redux.org
diff --git a/Runtime/CSharpPatching/PrefabPatchingCSharpPatching.cs b/Runtime/CSharpPatching/PrefabPatchingCSharpPatching.cs
new file mode 100644
index 0000000..9729e28
--- /dev/null
+++ b/Runtime/CSharpPatching/PrefabPatchingCSharpPatching.cs
@@ -0,0 +1,24 @@
+using PatchManager.PrefabPatching;
+
+namespace PatchManager.CSharpPatching
+{
+ ///
+ /// Mod-scoped C# frontend for declarative prefab patches.
+ ///
+ public static class PrefabPatchingCSharpPatching
+ {
+ ///
+ /// Creates a prefab patch owned by the calling mod's swinfo identity.
+ ///
+ ///
+ /// Creates a prefab patch targeting a stock Addressables key.
+ /// Canonical bundle and CAB metadata are not part of imperative
+ /// authoring.
+ ///
+ public static PrefabPatchBuilder PatchPrefab(
+ this PmScope scope,
+ string name,
+ string address
+ ) => new(scope.ModId, name, address);
+ }
+}
diff --git a/Runtime/CSharpPatching/PrefabPatchingCSharpPatching.cs.meta b/Runtime/CSharpPatching/PrefabPatchingCSharpPatching.cs.meta
new file mode 100644
index 0000000..18b9209
--- /dev/null
+++ b/Runtime/CSharpPatching/PrefabPatchingCSharpPatching.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: a9063ecf1f9c8734abf1ed5334198e30
\ No newline at end of file
diff --git a/Runtime/Core/Assets/Locators.cs b/Runtime/Core/Assets/Locators.cs
index 2d808ec..280ed2e 100644
--- a/Runtime/Core/Assets/Locators.cs
+++ b/Runtime/Core/Assets/Locators.cs
@@ -38,15 +38,33 @@ public static void Register(IResourceLocator locator)
/// List of locations of the found assets.
/// True if any assets were found, false otherwise.
public static bool LocateAll(object label, out List locations)
+ {
+ return LocateAll(label, typeof(TextAsset), out locations);
+ }
+
+ ///
+ /// Locate assets by key and requested type across every Patch Manager
+ /// asset-domain locator.
+ ///
+ public static bool LocateAll(
+ object key,
+ System.Type type,
+ out List locations
+ )
{
locations = new List();
foreach (var locator in ResourceLocators)
{
- locator.Locate(label, typeof(TextAsset), out var foundLocations);
- locations.AddRange(foundLocations);
+ if (
+ locator.Locate(key, type, out var foundLocations)
+ && foundLocations != null
+ )
+ {
+ locations.AddRange(foundLocations);
+ }
}
return locations.Count > 0;
}
}
-}
\ No newline at end of file
+}
diff --git a/Runtime/Core/Cache/CacheManager.cs b/Runtime/Core/Cache/CacheManager.cs
index 58276da..3da8878 100644
--- a/Runtime/Core/Cache/CacheManager.cs
+++ b/Runtime/Core/Cache/CacheManager.cs
@@ -3,6 +3,7 @@
using System.IO;
using System.Reflection;
using PatchManager.Core.Cache.Json;
+using PatchManager.PrefabPatching;
using PatchManager.Shared;
namespace PatchManager.Core.Cache
@@ -200,7 +201,7 @@ public static void SaveInventory()
public static void SaveSummary(Summary universeSummary)
{
- File.WriteAllText("./pm_summary.log", universeSummary.Dump());
+ PatchManagerSummaryLog.UpdateCoreSummary(universeSummary.Dump());
}
}
}
diff --git a/Runtime/Core/CoreModule.cs b/Runtime/Core/CoreModule.cs
index d0aea6f..827222b 100644
--- a/Runtime/Core/CoreModule.cs
+++ b/Runtime/Core/CoreModule.cs
@@ -1,15 +1,18 @@
using System;
using System.Collections.Generic;
+using System.Linq;
using JetBrains.Annotations;
using KSP.Game;
using KSP.Game.Flow;
using PatchManager.Core.Assets;
using PatchManager.Core.Cache;
using PatchManager.LuaPatching;
+using PatchManager.PrefabPatching;
using PatchManager.Shared;
using PatchManager.Shared.Modules;
using ReduxLib.Configuration;
using ReduxLib.Configuration.Attributes;
+using SpaceWarp2.API.Mods;
using UnityEngine;
using UnityEngine.AddressableAssets;
using UnityEngine.UIElements;
@@ -81,6 +84,12 @@ private void DecideCacheValidity(Action resolve, Action reject)
tail.Add(new GenericFlowAction("Patch Manager: Saving Patch Summary", SavePatchSummary));
}
+ tail.Add(
+ new GenericFlowAction(
+ "Patch Manager: Resolving Prefab Patch Plans",
+ ResolvePrefabPatchPlans
+ )
+ );
tail.Add(new GenericFlowAction("Patch Manager: Registering Resource Locator", RegisterResourceLocator));
// Splice the tail in right after this step. Insert back-to-front so each Insert at the same index
@@ -97,9 +106,39 @@ private void DecideCacheValidity(Action resolve, Action reject)
private static void CloseRegistration(Action resolve, Action reject)
{
PatchingManager.Universe.RegistrationOpen = false;
+ PrefabPatchRuntime.CloseRegistration();
resolve();
}
+ private static void ResolvePrefabPatchPlans(
+ Action resolve,
+ Action reject
+ )
+ {
+ var manifestSources =
+ PluginList.AllEnabledAndActivePlugins
+ .Where(descriptor =>
+ !string.IsNullOrWhiteSpace(
+ descriptor.AddressablePrefabPatchLabel
+ )
+ )
+ .Select(descriptor =>
+ new PrefabPatchManifestSource
+ {
+ OwnerModId = descriptor.Guid,
+ AddressablesLabel =
+ descriptor.AddressablePrefabPatchLabel
+ }
+ )
+ .ToArray();
+ PrefabPatchRuntime.DiscoverAndResolve(
+ PatchingManager.Universe.AllMods,
+ manifestSources,
+ resolve,
+ reject
+ );
+ }
+
private void SavePatchSummary(Action resolve, Action reject)
{
PatchingManager.Universe.Summary.RecognizedModIds = PatchingManager.Universe.AllMods;
@@ -146,6 +185,7 @@ private void RegisterResourceLocator(Action resolve, Action reject)
}
Locators.Register(new ArchiveResourceLocator());
+ Locators.Register(PrefabPatchRuntime.RegisterResourceProvider());
GameManager.Instance.Game.UI.UitkLoadingCurtain.Data.PatchManagerDefinitionsModifiedCount =
CacheManager.Inventory.DefinitionCount;
GameManager.Instance.Game.UI.UitkLoadingCurtain.Data.PatchManagerNewAssetCount =
@@ -189,6 +229,15 @@ public override VisualElement GetDetails()
text.text += $"\n- {label}";
}
+ var prefabMetrics = PrefabPatchRuntime.CurrentMetrics;
+ text.text +=
+ $"\nPrefab plans: {prefabMetrics.ResolvedPlanCount}"
+ + $" ({prefabMetrics.CacheHitCount} cache hit(s), "
+ + $"{prefabMetrics.CacheMissCount} miss(es))";
+ text.text +=
+ $"\nRetained prefab handles: "
+ + $"{prefabMetrics.RetainedAddressablesHandles}";
+
text.visible = true;
text.style.display = DisplayStyle.Flex;
foldout.Add(text);
diff --git a/Runtime/LuaPatching/Builtin/PatchManagerCore.cs b/Runtime/LuaPatching/Builtin/PatchManagerCore.cs
index 0dec80b..d6b0903 100644
--- a/Runtime/LuaPatching/Builtin/PatchManagerCore.cs
+++ b/Runtime/LuaPatching/Builtin/PatchManagerCore.cs
@@ -85,6 +85,44 @@ public PatchDefinition Patch(ScriptExecutionContext context, string converter, s
return newPatch;
}
+ ///
+ /// Begins a declarative prefab patch for one stock Addressables key.
+ ///
+ public PrefabPatchLuaBuilder Prefab(
+ ScriptExecutionContext context,
+ string name,
+ DynValue target
+ )
+ {
+ if (!_universe.RegistrationOpen)
+ {
+ throw new ScriptRuntimeException(
+ $"PM:Prefab('{name}') can only be called during patch "
+ + "registration."
+ );
+ }
+
+ var modId = context.CurrentGlobalEnv
+ .Get("ModId")
+ .CastToString();
+ if (target.Type != DataType.String)
+ {
+ throw new ScriptRuntimeException(
+ "PM:Prefab expects the stock prefab's Addressables key."
+ );
+ }
+ var identity =
+ global::PatchManager.PrefabPatching.PrefabPatchPrefabIdentity
+ .FromAddress(target.String);
+ return new PrefabPatchLuaBuilder(
+ new global::PatchManager.PrefabPatching.PrefabPatchBuilder(
+ modId,
+ name,
+ identity
+ )
+ );
+ }
+
///
/// Queues a brand-new asset for creation under the given label and address.
///
diff --git a/Runtime/LuaPatching/Builtin/PrefabPatchLuaBuilder.cs b/Runtime/LuaPatching/Builtin/PrefabPatchLuaBuilder.cs
new file mode 100644
index 0000000..8556c1b
--- /dev/null
+++ b/Runtime/LuaPatching/Builtin/PrefabPatchLuaBuilder.cs
@@ -0,0 +1,384 @@
+using System;
+using System.Collections;
+using System.Linq;
+using System.Reflection;
+using MoonSharp.Interpreter;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+using PatchManager.PrefabPatching;
+
+namespace PatchManager.LuaPatching.Builtin;
+
+///
+/// Lua frontend for the public declarative prefab-patch schema. Lua tables are
+/// converted directly into the same model used by C# and visual authoring.
+///
+[MoonSharpUserData]
+public sealed class PrefabPatchLuaBuilder
+{
+ private readonly PrefabPatchBuilder _builder;
+
+ internal PrefabPatchLuaBuilder(PrefabPatchBuilder builder)
+ {
+ _builder = builder;
+ }
+
+ public PrefabPatchLuaBuilder Early()
+ {
+ _builder.Early();
+ return this;
+ }
+
+ public PrefabPatchLuaBuilder Late()
+ {
+ _builder.Late();
+ return this;
+ }
+
+ public PrefabPatchLuaBuilder First()
+ {
+ _builder.First();
+ return this;
+ }
+
+ public PrefabPatchLuaBuilder Last()
+ {
+ _builder.Last();
+ return this;
+ }
+
+ public PrefabPatchLuaBuilder Needs(params string[] ids)
+ {
+ _builder.NeedsMod(ids);
+ return this;
+ }
+
+ public PrefabPatchLuaBuilder Conflicts(params string[] ids)
+ {
+ _builder.ConflictsMod(ids);
+ return this;
+ }
+
+ public PrefabPatchLuaBuilder NeedsPatch(params string[] ids)
+ {
+ _builder.NeedsPatch(ids);
+ return this;
+ }
+
+ public PrefabPatchLuaBuilder ConflictsPatch(params string[] ids)
+ {
+ _builder.ConflictsPatch(ids);
+ return this;
+ }
+
+ public PrefabPatchLuaBuilder BeforePatch(params string[] ids)
+ {
+ _builder.BeforePatch(ids);
+ return this;
+ }
+
+ public PrefabPatchLuaBuilder AfterPatch(params string[] ids)
+ {
+ _builder.AfterPatch(ids);
+ return this;
+ }
+
+ public PrefabPatchLuaBuilder Before(params string[] modIds)
+ {
+ _builder.BeforeMod(modIds);
+ return this;
+ }
+
+ public PrefabPatchLuaBuilder After(params string[] modIds)
+ {
+ _builder.AfterMod(modIds);
+ return this;
+ }
+
+ public PrefabPatchLuaBuilder Configuration(params string[] inputs)
+ {
+ _builder.Configuration(inputs);
+ return this;
+ }
+
+ public PrefabPatchLuaBuilder Set(
+ string operationId,
+ DynValue target,
+ string propertyPath,
+ DynValue value
+ )
+ {
+ _builder.SetValue(
+ operationId,
+ Model(target, "operation target"),
+ propertyPath,
+ Value(value)
+ );
+ return this;
+ }
+
+ public PrefabPatchLuaBuilder SetComponent(
+ string operationId,
+ string hierarchyPath,
+ string componentType,
+ string propertyPath,
+ DynValue value,
+ int componentOrdinal = 0
+ )
+ {
+ _builder.SetValue(
+ operationId,
+ PrefabPatchBuilder.ComponentAt(
+ hierarchyPath,
+ componentType,
+ componentOrdinal
+ ),
+ propertyPath,
+ Value(value)
+ );
+ return this;
+ }
+
+ public PrefabPatchObjectTarget GameObject(string hierarchyPath) =>
+ PrefabPatchBuilder.GameObjectAt(hierarchyPath);
+
+ public PrefabPatchObjectTarget Component(
+ string hierarchyPath,
+ string componentType,
+ int componentOrdinal = 0
+ ) =>
+ PrefabPatchBuilder.ComponentAt(
+ hierarchyPath,
+ componentType,
+ componentOrdinal
+ );
+
+ public PrefabPatchLuaBuilder Reference(
+ string operationId,
+ DynValue target,
+ string propertyPath,
+ DynValue reference
+ )
+ {
+ _builder.SetObjectReference(
+ operationId,
+ Model(target, "operation target"),
+ propertyPath,
+ Model(
+ reference,
+ "object reference"
+ )
+ );
+ return this;
+ }
+
+ public PrefabPatchLuaBuilder Active(
+ string operationId,
+ DynValue target,
+ bool active
+ )
+ {
+ _builder.SetActive(
+ operationId,
+ Model(target, "operation target"),
+ active
+ );
+ return this;
+ }
+
+ public PrefabPatchLuaBuilder Suppress(
+ string operationId,
+ DynValue target
+ )
+ {
+ _builder.SuppressObject(
+ operationId,
+ Model(target, "operation target")
+ );
+ return this;
+ }
+
+ public PrefabPatchLuaBuilder AddObject(
+ string operationId,
+ DynValue parent,
+ DynValue fragment
+ )
+ {
+ _builder.AddObject(
+ operationId,
+ parent.IsNil()
+ ? null
+ : Model(
+ parent,
+ "parent target"
+ ),
+ Model(
+ fragment,
+ "object fragment"
+ )
+ );
+ return this;
+ }
+
+ public PrefabPatchLuaBuilder AddComponent(
+ string operationId,
+ DynValue target,
+ DynValue component
+ )
+ {
+ _builder.AddComponent(
+ operationId,
+ Model(target, "operation target"),
+ Model(
+ component,
+ "component fragment"
+ )
+ );
+ return this;
+ }
+
+ public PrefabPatchLuaBuilder RemoveComponent(
+ string operationId,
+ DynValue target
+ )
+ {
+ _builder.RemoveComponent(
+ operationId,
+ Model(target, "component target")
+ );
+ return this;
+ }
+
+ public JsonUserData Build() =>
+ new(JToken.Parse(PrefabPatchJson.Serialize(_builder.Build())));
+
+ public void Register()
+ {
+ _builder.Register();
+ }
+
+ private static PrefabPatchValue Value(DynValue value)
+ {
+ switch (value.Type)
+ {
+ case DataType.Boolean:
+ return PrefabPatchValue.FromBoolean(value.Boolean);
+ case DataType.Number:
+ return PrefabPatchValue.FromFloat(value.Number);
+ case DataType.String:
+ return PrefabPatchValue.FromString(value.String);
+ case DataType.Table:
+ case DataType.UserData:
+ return Model(
+ value,
+ "typed prefab-patch value"
+ );
+ default:
+ throw new ScriptRuntimeException(
+ $"Prefab patch values cannot be '{value.Type}'."
+ );
+ }
+ }
+
+ internal static T Model(DynValue value, string description)
+ {
+ try
+ {
+ if (
+ value.Type == DataType.UserData
+ && value.UserData?.Object is T model
+ )
+ {
+ return model;
+ }
+ var token = JsonUserData.GetJTokenForDynValue(value);
+ token = NormalizeEmptyTables(token, typeof(T));
+ var serializer = JsonSerializer.Create(
+ PrefabPatchJson.Settings
+ );
+ var result = token.ToObject(serializer);
+ if (result == null)
+ {
+ throw new ScriptRuntimeException(
+ $"Expected {description}, got nil."
+ );
+ }
+ return result;
+ }
+ catch (ScriptRuntimeException)
+ {
+ throw;
+ }
+ catch (Exception exception)
+ {
+ throw new ScriptRuntimeException(
+ $"Invalid {description}: {exception.Message}"
+ );
+ }
+ }
+
+ private static JToken NormalizeEmptyTables(
+ JToken token,
+ Type expectedType
+ )
+ {
+ if (
+ token is JObject emptyObject
+ && !emptyObject.Properties().Any()
+ && IsCollection(expectedType)
+ )
+ return new JArray();
+ if (token is JArray array)
+ {
+ var itemType = CollectionItemType(expectedType);
+ if (itemType != null)
+ {
+ for (var index = 0; index < array.Count; index++)
+ array[index] = NormalizeEmptyTables(
+ array[index],
+ itemType
+ );
+ }
+ return array;
+ }
+ if (token is not JObject value)
+ return token;
+
+ const BindingFlags flags =
+ BindingFlags.Instance | BindingFlags.Public;
+ foreach (var field in expectedType.GetFields(flags))
+ {
+ var camelName =
+ char.ToLowerInvariant(field.Name[0])
+ + field.Name.Substring(1);
+ var property = value.Property(
+ camelName,
+ StringComparison.OrdinalIgnoreCase
+ )
+ ?? value.Property(
+ field.Name,
+ StringComparison.OrdinalIgnoreCase
+ );
+ if (property != null)
+ property.Value = NormalizeEmptyTables(
+ property.Value,
+ field.FieldType
+ );
+ }
+ return value;
+ }
+
+ private static bool IsCollection(Type type) =>
+ type.IsArray
+ || (
+ type != typeof(string)
+ && typeof(IEnumerable).IsAssignableFrom(type)
+ );
+
+ private static Type CollectionItemType(Type type) =>
+ type.IsArray
+ ? type.GetElementType()
+ : type.IsGenericType
+ ? type.GetGenericArguments()[0]
+ : null;
+}
diff --git a/Runtime/LuaPatching/Builtin/PrefabPatchLuaBuilder.cs.meta b/Runtime/LuaPatching/Builtin/PrefabPatchLuaBuilder.cs.meta
new file mode 100644
index 0000000..87c7ded
--- /dev/null
+++ b/Runtime/LuaPatching/Builtin/PrefabPatchLuaBuilder.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: b0a738bdf2e64d789dbfb74b4c881f1d
diff --git a/Runtime/LuaPatching/PatchDefinition.cs b/Runtime/LuaPatching/PatchDefinition.cs
index c282d74..d2d5a0f 100644
--- a/Runtime/LuaPatching/PatchDefinition.cs
+++ b/Runtime/LuaPatching/PatchDefinition.cs
@@ -5,6 +5,7 @@
using MoonSharp.Interpreter;
using Newtonsoft.Json.Linq;
using PatchManager.LuaPatching.Utility;
+using PatchManager.PrefabPatching;
using PatchManager.Shared;
namespace PatchManager.LuaPatching;
@@ -13,7 +14,7 @@ namespace PatchManager.LuaPatching;
/// A registered patch operation: which converter, which addressables target, what callback to run, and at which stage.
///
[MoonSharpUserData]
-public class PatchDefinition
+public class PatchDefinition : IPatchRelationships
{
///
/// The pass a patch runs in.
@@ -514,6 +515,15 @@ public PatchDefinition Late()
///
[MoonSharpHidden] public int Order;
+ IEnumerable IPatchRelationships.NeedsMods => NeedsMods;
+ IEnumerable IPatchRelationships.ConflictsMods => ConflictsMods;
+ IEnumerable IPatchRelationships.NeedsPatches => NeedsPatches;
+ IEnumerable IPatchRelationships.ConflictsPatches => ConflictsPatches;
+ IEnumerable IPatchRelationships.BeforePatches => BeforePatches;
+ IEnumerable IPatchRelationships.AfterPatches => AfterPatches;
+ IEnumerable IPatchRelationships.BeforeMods => BeforeMods;
+ IEnumerable IPatchRelationships.AfterMods => AfterMods;
+
///
/// Applies the patch to , recording the outcome in .
///
diff --git a/Runtime/PrefabPatching.meta b/Runtime/PrefabPatching.meta
new file mode 100644
index 0000000..6550ab1
--- /dev/null
+++ b/Runtime/PrefabPatching.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 840fdb67dba5bef47b5ed07fb118481e
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Runtime/PrefabPatching/PatchManager.PrefabPatching.asmdef b/Runtime/PrefabPatching/PatchManager.PrefabPatching.asmdef
new file mode 100644
index 0000000..3510d93
--- /dev/null
+++ b/Runtime/PrefabPatching/PatchManager.PrefabPatching.asmdef
@@ -0,0 +1,17 @@
+{
+ "name": "PatchManager.PrefabPatching",
+ "rootNamespace": "PatchManager.PrefabPatching",
+ "references": [
+ "Unity.Addressables",
+ "Unity.ResourceManager"
+ ],
+ "includePlatforms": [],
+ "excludePlatforms": [],
+ "allowUnsafeCode": false,
+ "overrideReferences": false,
+ "precompiledReferences": [],
+ "autoReferenced": true,
+ "defineConstraints": [],
+ "versionDefines": [],
+ "noEngineReferences": false
+}
diff --git a/Runtime/PrefabPatching/PatchManager.PrefabPatching.asmdef.meta b/Runtime/PrefabPatching/PatchManager.PrefabPatching.asmdef.meta
new file mode 100644
index 0000000..c8cee06
--- /dev/null
+++ b/Runtime/PrefabPatching/PatchManager.PrefabPatching.asmdef.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: 8689a1c3eae3ae34a84c40b6173daf4f
+AssemblyDefinitionImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Runtime/PrefabPatching/PrefabPatchBuilder.cs b/Runtime/PrefabPatching/PrefabPatchBuilder.cs
new file mode 100644
index 0000000..6b30152
--- /dev/null
+++ b/Runtime/PrefabPatching/PrefabPatchBuilder.cs
@@ -0,0 +1,465 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace PatchManager.PrefabPatching;
+
+///
+/// Fluent C# frontend that generates the same declarative public manifest used
+/// by visual prefab-variant compilation.
+///
+///
+/// Calls only mutate an in-memory . Call
+/// to inspect or serialize it, or
+/// during mod initialization to add it to the current runtime registration
+/// window. Dependency and ordering methods share the
+/// model used by ordinary JSON patches.
+///
+public sealed class PrefabPatchBuilder
+{
+ private readonly string _modId;
+ private readonly PrefabPatchManifest _manifest;
+ private readonly HashSet _needsMods = new(StringComparer.Ordinal);
+ private readonly HashSet _conflictsMods = new(
+ StringComparer.Ordinal
+ );
+ private readonly HashSet _needsPatches = new(StringComparer.Ordinal);
+ private readonly HashSet _conflictsPatches = new(
+ StringComparer.Ordinal
+ );
+ private readonly HashSet _beforePatches = new(
+ StringComparer.Ordinal
+ );
+ private readonly HashSet _afterPatches = new(StringComparer.Ordinal);
+ private readonly HashSet _beforeMods = new(StringComparer.Ordinal);
+ private readonly HashSet _afterMods = new(StringComparer.Ordinal);
+
+ /// Creates a builder for a prefab identified by a complete target descriptor.
+ /// The owning SpaceWarp mod ID.
+ /// A mod-local patch name without a colon.
+ /// The stock prefab identity to patch.
+ public PrefabPatchBuilder(
+ string modId,
+ string patchName,
+ PrefabPatchPrefabIdentity target
+ )
+ {
+ if (string.IsNullOrWhiteSpace(modId))
+ throw new ArgumentException("Mod ID is required.", nameof(modId));
+ if (string.IsNullOrWhiteSpace(patchName))
+ throw new ArgumentException(
+ "Patch name is required.",
+ nameof(patchName)
+ );
+ if (patchName.IndexOf(':') >= 0)
+ throw new ArgumentException(
+ "Patch name must be local to the mod and cannot contain ':'.",
+ nameof(patchName)
+ );
+ _modId = modId.Trim();
+ _manifest = new PrefabPatchManifest
+ {
+ PatchName = patchName.Trim(),
+ TargetPrefab =
+ target ?? throw new ArgumentNullException(nameof(target))
+ };
+ }
+
+ /// Creates a builder for a stock prefab Addressables key.
+ /// The owning SpaceWarp mod ID.
+ /// A mod-local patch name without a colon.
+ /// The stock prefab Addressables key.
+ public PrefabPatchBuilder(
+ string modId,
+ string patchName,
+ string address
+ ) : this(
+ modId,
+ patchName,
+ PrefabPatchPrefabIdentity.FromAddress(address)
+ ) { }
+
+ /// Places the patch in the Early pass.
+ public PrefabPatchBuilder Early()
+ {
+ _manifest.Pass = PrefabPatchPass.Early;
+ return this;
+ }
+
+ /// Places the patch in the Late pass.
+ public PrefabPatchBuilder Late()
+ {
+ _manifest.Pass = PrefabPatchPass.Late;
+ return this;
+ }
+
+ /// Places the patch in the First bucket of its pass.
+ public PrefabPatchBuilder First()
+ {
+ _manifest.Ordering = PrefabPatchOrdering.First;
+ return this;
+ }
+
+ /// Places the patch in the Last bucket of its pass.
+ public PrefabPatchBuilder Last()
+ {
+ _manifest.Ordering = PrefabPatchOrdering.Last;
+ return this;
+ }
+
+ /// Requires the supplied mod IDs to be active.
+ public PrefabPatchBuilder NeedsMod(params string[] ids) =>
+ Add(_needsMods, ids);
+
+ /// Disables the patch when any supplied mod ID is active.
+ public PrefabPatchBuilder ConflictsMod(params string[] ids) =>
+ Add(_conflictsMods, ids);
+
+ /// Requires the supplied patch IDs to be enabled.
+ public PrefabPatchBuilder NeedsPatch(params string[] ids) =>
+ Add(_needsPatches, Normalize(ids));
+
+ /// Disables the patch when any supplied patch ID is enabled.
+ public PrefabPatchBuilder ConflictsPatch(params string[] ids) =>
+ Add(_conflictsPatches, Normalize(ids));
+
+ /// Orders this patch before the supplied patch IDs in the same bucket.
+ public PrefabPatchBuilder BeforePatch(params string[] ids) =>
+ Add(_beforePatches, Normalize(ids));
+
+ /// Orders this patch after the supplied patch IDs in the same bucket.
+ public PrefabPatchBuilder AfterPatch(params string[] ids) =>
+ Add(_afterPatches, Normalize(ids));
+
+ /// Orders this patch before patches owned by the supplied mods.
+ public PrefabPatchBuilder BeforeMod(params string[] ids) =>
+ Add(_beforeMods, ids);
+
+ /// Orders this patch after patches owned by the supplied mods.
+ public PrefabPatchBuilder AfterMod(params string[] ids) =>
+ Add(_afterMods, ids);
+
+ /// Adds a normalized operation and binds it to this patch.
+ /// The operation to append in call order.
+ public PrefabPatchBuilder AddOperation(PrefabPatchOperation operation)
+ {
+ if (operation == null)
+ throw new ArgumentNullException(nameof(operation));
+ operation.PatchId = PrefabPatchOwnership.Qualify(
+ _modId,
+ _manifest.PatchName
+ );
+ _manifest.Operations.Add(operation);
+ return this;
+ }
+
+ /// Writes a serialized value on the target object or component.
+ public PrefabPatchBuilder SetValue(
+ string operationId,
+ PrefabPatchObjectTarget target,
+ string propertyPath,
+ PrefabPatchValue value
+ ) =>
+ AddOperation(
+ new PrefabPatchOperation
+ {
+ OperationId = operationId,
+ Kind = PrefabPatchOperationKind.SetValue,
+ Target = target,
+ PropertyPath = propertyPath,
+ Value = value
+ }
+ );
+
+ /// Changes a target GameObject's active state.
+ public PrefabPatchBuilder SetActive(
+ string operationId,
+ PrefabPatchObjectTarget target,
+ bool active
+ ) =>
+ AddOperation(
+ new PrefabPatchOperation
+ {
+ OperationId = operationId,
+ Kind = PrefabPatchOperationKind.SetActive,
+ Target = target,
+ Value = PrefabPatchValue.FromBoolean(active)
+ }
+ );
+
+ /// Writes an Addressable or target-local Unity object reference.
+ public PrefabPatchBuilder SetObjectReference(
+ string operationId,
+ PrefabPatchObjectTarget target,
+ string propertyPath,
+ PrefabPatchObjectReference reference
+ ) =>
+ AddOperation(
+ new PrefabPatchOperation
+ {
+ OperationId = operationId,
+ Kind = PrefabPatchOperationKind.SetObjectReference,
+ Target = target,
+ PropertyPath = propertyPath,
+ ObjectReference = reference
+ }
+ );
+
+ /// Suppresses a target GameObject in the effective prefab.
+ public PrefabPatchBuilder SuppressObject(
+ string operationId,
+ PrefabPatchObjectTarget target
+ ) =>
+ AddOperation(
+ new PrefabPatchOperation
+ {
+ OperationId = operationId,
+ Kind = PrefabPatchOperationKind.SuppressObject,
+ Target = target
+ }
+ );
+
+ /// Adds an inline patch-owned hierarchy beneath a target parent.
+ public PrefabPatchBuilder AddObject(
+ string operationId,
+ PrefabPatchObjectTarget parent,
+ PrefabPatchObjectFragment fragment
+ ) =>
+ AddOperation(
+ new PrefabPatchOperation
+ {
+ OperationId = operationId,
+ Kind = PrefabPatchOperationKind.AddObject,
+ Target = parent,
+ AddedObject = fragment
+ }
+ );
+
+ /// Adds a serialized component fragment to a target GameObject.
+ public PrefabPatchBuilder AddComponent(
+ string operationId,
+ PrefabPatchObjectTarget target,
+ PrefabPatchComponentFragment component
+ ) =>
+ AddOperation(
+ new PrefabPatchOperation
+ {
+ OperationId = operationId,
+ Kind = PrefabPatchOperationKind.AddComponent,
+ Target = target,
+ AddedComponent = component
+ }
+ );
+
+ /// Removes the targeted component from the effective prefab.
+ public PrefabPatchBuilder RemoveComponent(
+ string operationId,
+ PrefabPatchObjectTarget target
+ ) =>
+ AddOperation(
+ new PrefabPatchOperation
+ {
+ OperationId = operationId,
+ Kind = PrefabPatchOperationKind.RemoveComponent,
+ Target = target
+ }
+ );
+
+ /// Adds configuration values that participate in cache invalidation.
+ public PrefabPatchBuilder Configuration(params string[] inputs)
+ {
+ _manifest.ConfigurationInputs = Sorted(
+ (_manifest.ConfigurationInputs ?? Array.Empty())
+ .Concat(inputs ?? Array.Empty())
+ .Where(value => !string.IsNullOrWhiteSpace(value))
+ .Distinct(StringComparer.Ordinal)
+ );
+ return this;
+ }
+
+ /// Targets an object introduced by this or a required patch.
+ public static PrefabPatchObjectTarget PatchObject(
+ string ownerPatchId,
+ string objectId
+ ) =>
+ new()
+ {
+ Kind = PrefabPatchTargetKind.PatchOwned,
+ OwnerPatchId = ownerPatchId,
+ ObjectId = objectId,
+ RuntimeLocator = new PrefabPatchRuntimeLocator
+ {
+ TargetKind = PrefabPatchRuntimeTargetKind.GameObject,
+ SiblingIndices = Array.Empty()
+ }
+ };
+
+ /// Targets a component introduced by this or a required patch.
+ public static PrefabPatchObjectTarget PatchComponent(
+ string ownerPatchId,
+ string componentId
+ ) =>
+ new()
+ {
+ Kind = PrefabPatchTargetKind.PatchComponent,
+ OwnerPatchId = ownerPatchId,
+ ComponentId = componentId,
+ RuntimeLocator = new PrefabPatchRuntimeLocator
+ {
+ TargetKind = PrefabPatchRuntimeTargetKind.Component,
+ SiblingIndices = Array.Empty()
+ }
+ };
+
+ /// Targets a stock GameObject by hierarchy path.
+ public static PrefabPatchObjectTarget GameObjectAt(
+ string hierarchyPath
+ ) =>
+ StockTarget(
+ hierarchyPath,
+ PrefabPatchRuntimeTargetKind.GameObject,
+ typeof(UnityEngine.GameObject).AssemblyQualifiedName,
+ 0
+ );
+
+ /// Targets a typed stock component by hierarchy path and ordinal.
+ public static PrefabPatchObjectTarget ComponentAt(
+ string hierarchyPath,
+ int componentOrdinal = 0
+ ) where TComponent : UnityEngine.Component =>
+ ComponentAt(
+ hierarchyPath,
+ typeof(TComponent).AssemblyQualifiedName,
+ componentOrdinal
+ );
+
+ /// Targets a stock component by assembly-qualified type and ordinal.
+ public static PrefabPatchObjectTarget ComponentAt(
+ string hierarchyPath,
+ string componentType,
+ int componentOrdinal = 0
+ )
+ {
+ if (string.IsNullOrWhiteSpace(componentType))
+ throw new ArgumentException(
+ "Component type is required.",
+ nameof(componentType)
+ );
+ return StockTarget(
+ hierarchyPath,
+ PrefabPatchRuntimeTargetKind.Component,
+ componentType,
+ componentOrdinal
+ );
+ }
+
+ /// Creates a reference to an Addressable asset.
+ public static PrefabPatchObjectReference Addressable(
+ string address,
+ Type expectedType = null
+ ) =>
+ PrefabPatchObjectReference.FromAddress(
+ address,
+ expectedType?.AssemblyQualifiedName
+ );
+
+ /// Creates a reference to another object in the composed prefab.
+ public static PrefabPatchObjectReference TargetReference(
+ PrefabPatchObjectTarget target,
+ Type expectedType = null
+ ) =>
+ PrefabPatchObjectReference.FromTarget(
+ target,
+ expectedType?.AssemblyQualifiedName
+ );
+
+ /// Normalizes relationships, capabilities, ownership, and the manifest hash.
+ public PrefabPatchManifest Build()
+ {
+ _manifest.NeedsMods = Sorted(_needsMods);
+ _manifest.ConflictsMods = Sorted(_conflictsMods);
+ _manifest.NeedsPatches = Sorted(_needsPatches);
+ _manifest.ConflictsPatches = Sorted(_conflictsPatches);
+ _manifest.BeforePatches = Sorted(_beforePatches);
+ _manifest.AfterPatches = Sorted(_afterPatches);
+ _manifest.BeforeMods = Sorted(_beforeMods);
+ _manifest.AfterMods = Sorted(_afterMods);
+ _manifest.DeclaredCapabilities = _manifest.Operations
+ .Select(value => value.Kind.ToString())
+ .Distinct(StringComparer.Ordinal)
+ .OrderBy(value => value, StringComparer.Ordinal)
+ .ToArray();
+ _manifest.ManifestHash = PrefabPatchJson.CalculateManifestHash(_manifest);
+ return PrefabPatchOwnership.Bind(_manifest, _modId);
+ }
+
+ /// Builds and registers the manifest for the current play session.
+ public PrefabPatchManifest Register()
+ {
+ var manifest = Build();
+ PrefabPatchRuntime.Register(manifest);
+ return manifest;
+ }
+
+ private PrefabPatchBuilder Add(
+ ISet destination,
+ IEnumerable ids
+ )
+ {
+ foreach (
+ var id in (ids ?? Array.Empty()).Where(
+ value => !string.IsNullOrWhiteSpace(value)
+ )
+ )
+ {
+ destination.Add(id);
+ }
+
+ return this;
+ }
+
+ private static IEnumerable Normalize(IEnumerable ids) =>
+ ids ?? Array.Empty();
+
+ private static string[] Sorted(IEnumerable values) =>
+ values.OrderBy(value => value, StringComparer.Ordinal).ToArray();
+
+ private static PrefabPatchObjectTarget StockTarget(
+ string hierarchyPath,
+ PrefabPatchRuntimeTargetKind targetKind,
+ string objectType,
+ int componentOrdinal
+ )
+ {
+ if (hierarchyPath == null)
+ throw new ArgumentNullException(nameof(hierarchyPath));
+ if (componentOrdinal < 0)
+ throw new ArgumentOutOfRangeException(
+ nameof(componentOrdinal)
+ );
+ var normalizedPath = string.Join(
+ "/",
+ hierarchyPath.Split(
+ new[] { '/' },
+ StringSplitOptions.RemoveEmptyEntries
+ )
+ );
+ return new PrefabPatchObjectTarget
+ {
+ Kind = PrefabPatchTargetKind.Stock,
+ ObjectType = objectType,
+ RuntimeLocator = new PrefabPatchRuntimeLocator
+ {
+ HierarchyPath = normalizedPath,
+ TargetKind = targetKind,
+ ComponentType =
+ targetKind == PrefabPatchRuntimeTargetKind.Component
+ ? objectType
+ : null,
+ ComponentOrdinal = componentOrdinal,
+ DisplayPath = normalizedPath,
+ SiblingIndices = Array.Empty()
+ }
+ };
+ }
+}
diff --git a/Runtime/PrefabPatching/PrefabPatchBuilder.cs.meta b/Runtime/PrefabPatching/PrefabPatchBuilder.cs.meta
new file mode 100644
index 0000000..899b53c
--- /dev/null
+++ b/Runtime/PrefabPatching/PrefabPatchBuilder.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: c2c844e10010e0643a7287766743f9b4
\ No newline at end of file
diff --git a/Runtime/PrefabPatching/PrefabPatchComposer.cs b/Runtime/PrefabPatching/PrefabPatchComposer.cs
new file mode 100644
index 0000000..f85d651
--- /dev/null
+++ b/Runtime/PrefabPatching/PrefabPatchComposer.cs
@@ -0,0 +1,1447 @@
+using System;
+using System.Collections.Generic;
+using System.Collections;
+using System.Diagnostics;
+using System.Globalization;
+using System.Linq;
+using System.Reflection;
+using Newtonsoft.Json;
+using UnityEngine;
+using Object = UnityEngine.Object;
+
+namespace PatchManager.PrefabPatching;
+
+///
+/// Applies a validated resolved plan once to a loaded external prefab asset.
+/// The caller owns and retains the Addressables handles.
+///
+///
+/// Composition runs in three phases: create every patch-owned object and
+/// component under an inactive safety root, apply serialized values in resolved
+/// order, then restore deferred Unity object references. This prevents Unity
+/// lifecycle callbacks from observing a partially hydrated hierarchy. The
+/// supplied prefab is mutated in place only after structural compatibility has
+/// been verified.
+///
+public static class PrefabPatchComposer
+{
+ private sealed class PendingReference
+ {
+ public Object Target;
+ public string PropertyPath;
+ public PrefabPatchObjectReference Reference;
+ public string OperationId;
+ }
+
+ private sealed class AnimationCurvePayload
+ {
+ public Keyframe[] Keys;
+ public int PreWrapMode;
+ public int PostWrapMode;
+ }
+
+ private sealed class GradientPayload
+ {
+ public GradientColorKey[] ColorKeys;
+ public GradientAlphaKey[] AlphaKeys;
+ public int Mode;
+ }
+
+ /// Describes the outcome and objects created by one composition.
+ public sealed class Result
+ {
+ /// Whether every operation completed successfully.
+ public bool Success;
+ /// The failure message when is false.
+ public string Failure;
+ /// Total synchronous composition time.
+ public long ElapsedMilliseconds;
+ /// The number of operations applied before completion or failure.
+ public int AppliedOperationCount;
+ /// Patch-owned GameObjects keyed by namespaced patch and object ID.
+ public Dictionary PatchOwnedObjects = new(
+ StringComparer.Ordinal
+ );
+ /// Patch-owned components keyed by namespaced patch and component ID.
+ public Dictionary PatchOwnedComponents = new(
+ StringComparer.Ordinal
+ );
+ }
+
+ /// Applies a validated resolved plan to a loaded prefab immediately.
+ /// The mutable prefab template.
+ /// A valid plan produced by .
+ /// Preloaded Addressable objects keyed by address.
+ /// A result containing diagnostics and introduced-object maps.
+ public static Result ApplySynchronously(
+ GameObject prefab,
+ PrefabPatchResolvedPlan plan,
+ IReadOnlyDictionary references
+ )
+ {
+ var stopwatch = Stopwatch.StartNew();
+ var result = new Result();
+ Transform originalParent = null;
+ var originalSiblingIndex = 0;
+ GameObject safetyRoot = null;
+ try
+ {
+ if (prefab == null)
+ throw new ArgumentNullException(nameof(prefab));
+ if (plan == null || !plan.IsValid)
+ throw new InvalidOperationException(
+ "The prefab patch plan is missing or invalid."
+ );
+ var actualFingerprint = PrefabPatchStructure.Calculate(prefab);
+ if (
+ !string.IsNullOrWhiteSpace(
+ plan.TargetPrefab.StructuralFingerprint
+ )
+ && !string.Equals(
+ actualFingerprint,
+ plan.TargetPrefab.StructuralFingerprint,
+ StringComparison.Ordinal
+ )
+ )
+ {
+ throw new InvalidOperationException(
+ $"Stock prefab '{plan.TargetPrefab.Address}' structural "
+ + $"fingerprint changed. Expected "
+ + $"'{plan.TargetPrefab.StructuralFingerprint}', got "
+ + $"'{actualFingerprint}'. Actual structure: "
+ + $"'{PrefabPatchStructure.Describe(prefab)}'. "
+ + "Recompile or repair the patch."
+ );
+ }
+
+ if (prefab.activeInHierarchy)
+ {
+ originalParent = prefab.transform.parent;
+ originalSiblingIndex = prefab.transform.GetSiblingIndex();
+ safetyRoot = new GameObject(
+ "PatchManager Composition Root"
+ );
+ safetyRoot.hideFlags = HideFlags.HideAndDontSave;
+ safetyRoot.SetActive(false);
+ prefab.transform.SetParent(safetyRoot.transform, false);
+ }
+ var unusedDeferredDestroy = false;
+ var pendingReferences = new List();
+ foreach (var operation in plan.Operations)
+ {
+ ApplyOne(
+ prefab,
+ operation,
+ references,
+ result.PatchOwnedObjects,
+ result.PatchOwnedComponents,
+ pendingReferences,
+ ref unusedDeferredDestroy,
+ true
+ );
+ result.AppliedOperationCount++;
+ }
+
+ foreach (var pending in pendingReferences)
+ {
+ var reference = ResolveReference(
+ prefab,
+ pending.Reference,
+ references,
+ result.PatchOwnedObjects,
+ result.PatchOwnedComponents
+ );
+ if (reference == null && pending.Reference != null)
+ {
+ throw new InvalidOperationException(
+ $"Operation '{pending.OperationId}' could not resolve "
+ + $"object reference "
+ + $"'{DescribeReference(pending.Reference)}'."
+ );
+ }
+
+ SetRawValue(
+ pending.Target,
+ pending.PropertyPath,
+ reference
+ );
+ }
+
+ result.Success = true;
+ }
+ catch (Exception exception)
+ {
+ result.Failure = exception.ToString();
+ }
+ finally
+ {
+ if (safetyRoot != null)
+ {
+ if (prefab != null)
+ {
+ prefab.transform.SetParent(originalParent, false);
+ if (originalParent != null)
+ prefab.transform.SetSiblingIndex(originalSiblingIndex);
+ }
+ Object.DestroyImmediate(safetyRoot);
+ }
+ stopwatch.Stop();
+ result.ElapsedMilliseconds = stopwatch.ElapsedMilliseconds;
+ }
+
+ return result;
+ }
+
+ private static void ApplyOne(
+ GameObject root,
+ PrefabPatchOperation operation,
+ IReadOnlyDictionary references,
+ IDictionary patchOwned,
+ IDictionary patchComponents,
+ ICollection pendingReferences,
+ ref bool deferredDestroy,
+ bool immediateDestroy
+ )
+ {
+ if (operation.Kind == PrefabPatchOperationKind.AddObject)
+ {
+ var parentObject = operation.Target == null
+ ? root
+ : AsGameObject(
+ Resolve(
+ root,
+ operation.Target,
+ patchOwned,
+ patchComponents
+ )
+ );
+ if (parentObject == null)
+ throw MissingTarget(operation);
+ CreateFragment(
+ operation.PatchId,
+ operation.AddedObject,
+ parentObject.transform,
+ references,
+ patchOwned,
+ patchComponents,
+ pendingReferences,
+ operation.OperationId
+ );
+ return;
+ }
+
+ var target = Resolve(
+ root,
+ operation.Target,
+ patchOwned,
+ patchComponents
+ );
+ if (target == null)
+ throw MissingTarget(operation);
+ switch (operation.Kind)
+ {
+ case PrefabPatchOperationKind.SetValue:
+ SetValue(target, operation.PropertyPath, operation.Value);
+ return;
+ case PrefabPatchOperationKind.SetObjectReference:
+ if (operation.ObjectReference == null)
+ throw new InvalidOperationException(
+ $"Operation '{operation.OperationId}' has no object "
+ + "reference payload."
+ );
+ pendingReferences.Add(
+ new PendingReference
+ {
+ Target = target,
+ PropertyPath = operation.PropertyPath,
+ Reference = operation.ObjectReference,
+ OperationId = operation.OperationId
+ }
+ );
+ return;
+ case PrefabPatchOperationKind.SetActive:
+ AsGameObject(target)?.SetActive(
+ operation.Value?.Boolean ?? false
+ );
+ return;
+ case PrefabPatchOperationKind.SuppressObject:
+ AsGameObject(target)?.SetActive(false);
+ return;
+ case PrefabPatchOperationKind.AddComponent:
+ AddComponent(
+ AsGameObject(target),
+ operation.AddedComponent,
+ references,
+ patchOwned,
+ patchComponents,
+ pendingReferences,
+ operation.PatchId,
+ operation.OperationId
+ );
+ return;
+ case PrefabPatchOperationKind.RemoveComponent:
+ if (target is not Component component)
+ {
+ throw new InvalidOperationException(
+ $"RemoveComponent operation '{operation.OperationId}' "
+ + "did not resolve to a Component."
+ );
+ }
+
+ if (immediateDestroy)
+ {
+ // Runtime composition operates on a session-owned clone,
+ // never directly on the read-only AssetBundle asset.
+ Object.DestroyImmediate(component);
+ if (component != null)
+ {
+ throw new InvalidOperationException(
+ $"RemoveComponent operation "
+ + $"'{operation.OperationId}' did not destroy "
+ + $"'{operation.Target.ObjectType}'."
+ );
+ }
+ }
+ else
+ {
+ Object.Destroy(component);
+ deferredDestroy = true;
+ }
+ return;
+ default:
+ throw new NotSupportedException(
+ $"Prefab operation kind '{operation.Kind}' is not supported "
+ + $"by composer {PrefabPatchSchema.ComposerVersion}."
+ );
+ }
+ }
+
+ private static Object Resolve(
+ GameObject root,
+ PrefabPatchObjectTarget target,
+ IDictionary patchOwned,
+ IDictionary patchComponents
+ )
+ {
+ if (target.Kind == PrefabPatchTargetKind.PatchComponent)
+ {
+ var componentKey =
+ $"{target.OwnerPatchId}:{target.ComponentId}";
+ return patchComponents.TryGetValue(
+ componentKey,
+ out var patchComponent
+ )
+ ? patchComponent
+ : null;
+ }
+
+ Transform transform;
+ if (target.Kind == PrefabPatchTargetKind.Stock)
+ {
+ transform = !string.IsNullOrWhiteSpace(
+ target.RuntimeLocator?.HierarchyPath
+ )
+ ? PrefabPatchStructure.ResolveHierarchyPath(
+ root.transform,
+ target.RuntimeLocator.HierarchyPath
+ )
+ : PrefabPatchStructure.Resolve(
+ root.transform,
+ target.RuntimeLocator?.SiblingIndices
+ );
+ }
+ else
+ {
+ var key = $"{target.OwnerPatchId}:{target.ObjectId}";
+ if (!patchOwned.TryGetValue(key, out var owned) || owned == null)
+ return null;
+ transform = owned.transform;
+ }
+
+ if (transform == null)
+ return null;
+ if (
+ target.RuntimeLocator == null
+ || target.RuntimeLocator.TargetKind
+ == PrefabPatchRuntimeTargetKind.GameObject
+ )
+ {
+ return transform.gameObject;
+ }
+
+ var type = ResolveType(target.RuntimeLocator.ComponentType);
+ if (type == null || !typeof(Component).IsAssignableFrom(type))
+ return null;
+ var components = transform.gameObject.GetComponents(type);
+ var ordinal = target.RuntimeLocator.ComponentOrdinal;
+ return ordinal >= 0 && ordinal < components.Length
+ ? components[ordinal]
+ : null;
+ }
+
+ private static GameObject CreateFragment(
+ string patchId,
+ PrefabPatchObjectFragment fragment,
+ Transform parent,
+ IReadOnlyDictionary references,
+ IDictionary patchOwned,
+ IDictionary patchComponents,
+ ICollection pendingReferences,
+ string operationId
+ )
+ {
+ if (fragment == null || string.IsNullOrWhiteSpace(fragment.ObjectId))
+ throw new InvalidOperationException(
+ $"Patch '{patchId}' contains an invalid added-object fragment."
+ );
+ var key = $"{patchId}:{fragment.ObjectId}";
+ if (patchOwned.ContainsKey(key))
+ throw new InvalidOperationException(
+ $"Patch-owned object '{key}' already exists."
+ );
+
+ var transformType = ResolveType(fragment.TransformType);
+ var gameObject =
+ transformType != null
+ && typeof(RectTransform).IsAssignableFrom(transformType)
+ ? new GameObject(
+ fragment.Name ?? fragment.ObjectId,
+ typeof(RectTransform)
+ )
+ : new GameObject(fragment.Name ?? fragment.ObjectId);
+ gameObject.transform.SetParent(parent, false);
+ gameObject.layer = fragment.Layer;
+ if (!string.IsNullOrWhiteSpace(fragment.Tag))
+ {
+ try
+ {
+ gameObject.tag = fragment.Tag;
+ }
+ catch (UnityException exception)
+ {
+ throw new InvalidOperationException(
+ $"Patch-owned object '{key}' uses unknown tag "
+ + $"'{fragment.Tag}'.",
+ exception
+ );
+ }
+ }
+ gameObject.isStatic = fragment.IsStatic;
+ gameObject.transform.localPosition = ToVector3(
+ fragment.LocalPosition,
+ Vector3.zero
+ );
+ gameObject.transform.localRotation = ToQuaternion(
+ fragment.LocalRotation,
+ Quaternion.identity
+ );
+ gameObject.transform.localScale = ToVector3(
+ fragment.LocalScale,
+ Vector3.one
+ );
+ if (gameObject.transform is RectTransform rectTransform)
+ {
+ rectTransform.anchorMin = ToVector2(
+ fragment.AnchorMin,
+ rectTransform.anchorMin
+ );
+ rectTransform.anchorMax = ToVector2(
+ fragment.AnchorMax,
+ rectTransform.anchorMax
+ );
+ rectTransform.anchoredPosition = ToVector2(
+ fragment.AnchoredPosition,
+ rectTransform.anchoredPosition
+ );
+ rectTransform.sizeDelta = ToVector2(
+ fragment.SizeDelta,
+ rectTransform.sizeDelta
+ );
+ rectTransform.pivot = ToVector2(
+ fragment.Pivot,
+ rectTransform.pivot
+ );
+ }
+ gameObject.SetActive(fragment.Active);
+ gameObject.AddComponent().Id = fragment.ObjectId;
+ patchOwned.Add(key, gameObject);
+ foreach (var component in fragment.Components)
+ AddComponent(
+ gameObject,
+ component,
+ references,
+ patchOwned,
+ patchComponents,
+ pendingReferences,
+ patchId,
+ operationId
+ );
+ foreach (var child in fragment.Children)
+ CreateFragment(
+ patchId,
+ child,
+ gameObject.transform,
+ references,
+ patchOwned,
+ patchComponents,
+ pendingReferences,
+ operationId
+ );
+ return gameObject;
+ }
+
+ private static Component AddComponent(
+ GameObject target,
+ PrefabPatchComponentFragment fragment,
+ IReadOnlyDictionary references,
+ IDictionary patchOwned,
+ IDictionary patchComponents,
+ ICollection pendingReferences,
+ string patchId,
+ string operationId
+ )
+ {
+ if (target == null || fragment == null)
+ throw new InvalidOperationException(
+ "An added component has no target or payload."
+ );
+ if (string.IsNullOrWhiteSpace(fragment.ComponentType))
+ throw new InvalidOperationException(
+ "An added component has no assembly-qualified component type."
+ );
+ var type = ResolveType(fragment.ComponentType);
+ if (type == null || !typeof(Component).IsAssignableFrom(type))
+ {
+ throw new InvalidOperationException(
+ $"Added component type '{fragment.ComponentType}' could "
+ + "not be resolved as a Unity Component."
+ );
+ }
+ if (typeof(Transform).IsAssignableFrom(type))
+ {
+ throw new InvalidOperationException(
+ $"Transform type '{fragment.ComponentType}' must be "
+ + "declared by the object fragment, not added as a "
+ + "component."
+ );
+ }
+
+ var component = target.AddComponent(type);
+ foreach (var value in fragment.Values ?? new())
+ {
+ if (
+ value == null
+ || string.IsNullOrWhiteSpace(value.PropertyPath)
+ )
+ continue;
+ SetValue(component, value.PropertyPath, value.Value);
+ }
+ QueueComponentReferences(
+ component,
+ fragment,
+ pendingReferences,
+ operationId
+ );
+ RegisterPatchComponent(
+ patchId,
+ fragment,
+ component,
+ patchComponents
+ );
+ return component;
+ }
+
+ private static void QueueComponentReferences(
+ Component component,
+ PrefabPatchComponentFragment fragment,
+ ICollection pendingReferences,
+ string operationId
+ )
+ {
+ foreach (var reference in fragment.References ?? new())
+ {
+ if (
+ reference == null
+ || string.IsNullOrWhiteSpace(reference.PropertyPath)
+ )
+ continue;
+ pendingReferences.Add(
+ new PendingReference
+ {
+ Target = component,
+ PropertyPath = reference.PropertyPath,
+ Reference = reference.Reference,
+ OperationId = operationId
+ }
+ );
+ }
+ }
+
+ private static void RegisterPatchComponent(
+ string patchId,
+ PrefabPatchComponentFragment fragment,
+ Component component,
+ IDictionary patchComponents
+ )
+ {
+ if (string.IsNullOrWhiteSpace(fragment.ComponentId))
+ return;
+ var key = $"{patchId}:{fragment.ComponentId}";
+ if (patchComponents.ContainsKey(key))
+ throw new InvalidOperationException(
+ $"Patch-owned component '{key}' already exists."
+ );
+ patchComponents.Add(key, component);
+ }
+
+ private static Object ResolveReference(
+ GameObject root,
+ PrefabPatchObjectReference reference,
+ IReadOnlyDictionary references,
+ IDictionary patchOwned,
+ IDictionary patchComponents
+ )
+ {
+ if (reference == null)
+ return null;
+ if (
+ reference.Kind == PrefabPatchObjectReferenceKind.Target
+ || reference.Target != null
+ )
+ {
+ return Resolve(
+ root,
+ reference.Target,
+ patchOwned,
+ patchComponents
+ );
+ }
+
+ return !string.IsNullOrWhiteSpace(reference.Address)
+ && references.TryGetValue(reference.Address, out var value)
+ ? value
+ : null;
+ }
+
+ private static string DescribeReference(
+ PrefabPatchObjectReference reference
+ ) =>
+ reference == null
+ ? ""
+ : reference.Kind == PrefabPatchObjectReferenceKind.Target
+ || reference.Target != null
+ ? reference.Target?.CanonicalKey ?? ""
+ : reference.Address ?? "";
+
+ private static void SetValue(
+ Object target,
+ string propertyPath,
+ PrefabPatchValue value
+ )
+ {
+ if (value == null)
+ throw new InvalidOperationException(
+ $"Property '{propertyPath}' has no typed value."
+ );
+ if (value.Kind == PrefabPatchValueKind.ArraySize)
+ {
+ SetCollectionSize(
+ target,
+ propertyPath,
+ checked((int)value.Integer)
+ );
+ return;
+ }
+ SetRawValue(target, propertyPath, ConvertValue(value));
+ }
+
+ private static void SetRawValue(
+ Object target,
+ string propertyPath,
+ object value
+ )
+ {
+ if (target is Transform transform)
+ {
+ if (TrySetTransform(transform, propertyPath, value))
+ return;
+ }
+
+ if (
+ target is GameObject gameObject
+ && (
+ propertyPath == "m_IsActive"
+ || propertyPath == "activeSelf"
+ )
+ )
+ {
+ gameObject.SetActive(Convert.ToBoolean(value, CultureInfo.InvariantCulture));
+ return;
+ }
+
+ SetMemberPath(target, propertyPath, value);
+ }
+
+ private static bool TrySetTransform(
+ Transform transform,
+ string propertyPath,
+ object value
+ )
+ {
+ var segments = propertyPath.Split('.');
+ if (segments.Length != 2)
+ return false;
+ var component = Convert.ToSingle(value, CultureInfo.InvariantCulture);
+ if (
+ transform is RectTransform rectTransform
+ && TrySetRectTransform(
+ rectTransform,
+ segments[0],
+ segments[1],
+ component
+ )
+ )
+ {
+ return true;
+ }
+
+ switch (segments[0])
+ {
+ case "m_LocalPosition":
+ case "localPosition":
+ {
+ var vector = transform.localPosition;
+ SetVectorComponent(ref vector, segments[1], component);
+ transform.localPosition = vector;
+ return true;
+ }
+ case "m_LocalScale":
+ case "localScale":
+ {
+ var vector = transform.localScale;
+ SetVectorComponent(ref vector, segments[1], component);
+ transform.localScale = vector;
+ return true;
+ }
+ case "m_LocalRotation":
+ case "localRotation":
+ {
+ var quaternion = transform.localRotation;
+ SetQuaternionComponent(
+ ref quaternion,
+ segments[1],
+ component
+ );
+ transform.localRotation = quaternion;
+ return true;
+ }
+ default:
+ return false;
+ }
+ }
+
+ private static bool TrySetRectTransform(
+ RectTransform transform,
+ string property,
+ string componentName,
+ float componentValue
+ )
+ {
+ switch (property)
+ {
+ case "m_AnchoredPosition":
+ case "anchoredPosition":
+ {
+ var value = transform.anchoredPosition;
+ SetVector2Component(
+ ref value,
+ componentName,
+ componentValue
+ );
+ transform.anchoredPosition = value;
+ return true;
+ }
+ case "m_SizeDelta":
+ case "sizeDelta":
+ {
+ var value = transform.sizeDelta;
+ SetVector2Component(
+ ref value,
+ componentName,
+ componentValue
+ );
+ transform.sizeDelta = value;
+ return true;
+ }
+ case "m_AnchorMin":
+ case "anchorMin":
+ {
+ var value = transform.anchorMin;
+ SetVector2Component(
+ ref value,
+ componentName,
+ componentValue
+ );
+ transform.anchorMin = value;
+ return true;
+ }
+ case "m_AnchorMax":
+ case "anchorMax":
+ {
+ var value = transform.anchorMax;
+ SetVector2Component(
+ ref value,
+ componentName,
+ componentValue
+ );
+ transform.anchorMax = value;
+ return true;
+ }
+ case "m_Pivot":
+ case "pivot":
+ {
+ var value = transform.pivot;
+ SetVector2Component(
+ ref value,
+ componentName,
+ componentValue
+ );
+ transform.pivot = value;
+ return true;
+ }
+ default:
+ return false;
+ }
+ }
+
+ private static void SetVector2Component(
+ ref Vector2 vector,
+ string component,
+ float value
+ )
+ {
+ switch (component)
+ {
+ case "x":
+ vector.x = value;
+ return;
+ case "y":
+ vector.y = value;
+ return;
+ default:
+ throw new InvalidOperationException(
+ $"Unknown Vector2 component '{component}'."
+ );
+ }
+ }
+
+ private static void SetMemberPath(
+ object root,
+ string path,
+ object value
+ )
+ {
+ var segments = ParsePath(path);
+ SetMemberRecursive(root, segments, 0, value);
+ }
+
+ private static void SetCollectionSize(
+ object root,
+ string propertyPath,
+ int size
+ )
+ {
+ const string suffix = ".Array.size";
+ if (
+ string.IsNullOrWhiteSpace(propertyPath)
+ || !propertyPath.EndsWith(suffix, StringComparison.Ordinal)
+ )
+ {
+ throw new InvalidOperationException(
+ $"Collection-size path '{propertyPath}' does not end in "
+ + $"'{suffix}'."
+ );
+ }
+ if (size < 0)
+ throw new ArgumentOutOfRangeException(nameof(size));
+ var collectionPath = propertyPath.Substring(
+ 0,
+ propertyPath.Length - suffix.Length
+ );
+ ResizeCollectionRecursive(
+ root,
+ ParsePath(collectionPath),
+ 0,
+ size
+ );
+ }
+
+ private static object ResizeCollectionRecursive(
+ object current,
+ IReadOnlyList segments,
+ int index,
+ int size
+ )
+ {
+ if (current == null)
+ throw new InvalidOperationException(
+ "Cannot resize a collection through a null serialized value."
+ );
+ var segment = segments[index];
+ var member = FindMember(current.GetType(), segment.Name)
+ ?? throw new MissingMemberException(
+ current.GetType().FullName,
+ segment.Name
+ );
+ var memberType = GetMemberType(member);
+ var memberValue = GetMemberValue(member, current);
+ if (segment.HasIndex)
+ {
+ if (memberValue is not IList list)
+ throw new InvalidOperationException(
+ $"Member '{segment.Name}' is not an indexed collection."
+ );
+ var element = list[segment.Index];
+ var updated = ResizeCollectionRecursive(
+ element,
+ segments,
+ index + 1,
+ size
+ );
+ var elementType = GetCollectionElementType(memberType);
+ if (elementType.IsValueType)
+ list[segment.Index] = updated;
+ return current;
+ }
+
+ if (index < segments.Count - 1)
+ {
+ var updated = ResizeCollectionRecursive(
+ memberValue,
+ segments,
+ index + 1,
+ size
+ );
+ if (memberType.IsValueType)
+ SetMemberValue(member, current, updated);
+ return current;
+ }
+
+ if (memberType.IsArray)
+ {
+ var elementType =
+ memberType.GetElementType() ?? typeof(object);
+ var previous = memberValue as Array;
+ var replacement = Array.CreateInstance(elementType, size);
+ if (previous != null)
+ {
+ Array.Copy(
+ previous,
+ replacement,
+ Math.Min(previous.Length, size)
+ );
+ }
+ for (
+ var itemIndex = previous?.Length ?? 0;
+ itemIndex < size;
+ itemIndex++
+ )
+ {
+ replacement.SetValue(
+ CreateCollectionElement(elementType),
+ itemIndex
+ );
+ }
+ SetMemberValue(member, current, replacement);
+ return current;
+ }
+
+ if (memberValue is not IList mutableList)
+ {
+ if (
+ memberType.IsInterface
+ || memberType.IsAbstract
+ )
+ {
+ throw new InvalidOperationException(
+ $"Collection member '{segment.Name}' of type "
+ + $"'{memberType.FullName}' is null and cannot be "
+ + "constructed."
+ );
+ }
+ mutableList = (IList)Activator.CreateInstance(memberType);
+ SetMemberValue(member, current, mutableList);
+ }
+ var itemType = GetCollectionElementType(memberType);
+ while (mutableList.Count > size)
+ mutableList.RemoveAt(mutableList.Count - 1);
+ while (mutableList.Count < size)
+ mutableList.Add(CreateCollectionElement(itemType));
+ return current;
+ }
+
+ private static object CreateCollectionElement(Type itemType)
+ {
+ if (
+ itemType == typeof(string)
+ || itemType.IsAbstract
+ || itemType.IsInterface
+ )
+ return null;
+ try
+ {
+ return Activator.CreateInstance(itemType);
+ }
+ catch (MissingMethodException)
+ {
+ return System.Runtime.Serialization.FormatterServices
+ .GetUninitializedObject(itemType);
+ }
+ }
+
+ private readonly struct MemberPathSegment
+ {
+ public readonly string Name;
+ public readonly int Index;
+ public readonly bool HasIndex;
+
+ public MemberPathSegment(string name, int index, bool hasIndex)
+ {
+ Name = name;
+ Index = index;
+ HasIndex = hasIndex;
+ }
+
+ public override string ToString() =>
+ HasIndex ? $"{Name}[{Index}]" : Name;
+ }
+
+ private static IReadOnlyList ParsePath(string path)
+ {
+ if (string.IsNullOrWhiteSpace(path))
+ throw new ArgumentException(
+ "A serialized property path is required.",
+ nameof(path)
+ );
+ var normalized = path.Replace(".Array.data[", "[");
+ var result = new List();
+ foreach (var raw in normalized.Split('.'))
+ {
+ var bracket = raw.LastIndexOf('[');
+ if (
+ bracket > 0
+ && raw.EndsWith("]", StringComparison.Ordinal)
+ && int.TryParse(
+ raw.Substring(bracket + 1, raw.Length - bracket - 2),
+ NumberStyles.Integer,
+ CultureInfo.InvariantCulture,
+ out var index
+ )
+ )
+ {
+ result.Add(
+ new MemberPathSegment(
+ raw.Substring(0, bracket),
+ index,
+ true
+ )
+ );
+ }
+ else
+ {
+ result.Add(new MemberPathSegment(raw, 0, false));
+ }
+ }
+
+ return result;
+ }
+
+ private static object SetMemberRecursive(
+ object current,
+ IReadOnlyList segments,
+ int index,
+ object value
+ )
+ {
+ if (current == null)
+ throw new InvalidOperationException(
+ $"Cannot traverse null while setting "
+ + $"'{string.Join(".", segments)}'."
+ );
+ var segment = segments[index];
+ var member = FindMember(current.GetType(), segment.Name);
+ if (member == null)
+ throw new MissingMemberException(
+ current.GetType().FullName,
+ segment.Name
+ );
+ var memberType = GetMemberType(member);
+ var memberValue = GetMemberValue(member, current);
+ if (segment.HasIndex)
+ {
+ if (memberValue is not IList list)
+ {
+ throw new InvalidOperationException(
+ $"Member '{segment.Name}' on "
+ + $"'{current.GetType().FullName}' is not an indexed "
+ + "serialized collection."
+ );
+ }
+ if (segment.Index < 0 || segment.Index >= list.Count)
+ {
+ throw new IndexOutOfRangeException(
+ $"Serialized collection '{segment.Name}' has "
+ + $"{list.Count} item(s), but index {segment.Index} "
+ + "was requested."
+ );
+ }
+
+ var elementType = GetCollectionElementType(memberType);
+ if (index == segments.Count - 1)
+ {
+ list[segment.Index] = ConvertForType(value, elementType);
+ return current;
+ }
+
+ var element = list[segment.Index];
+ var updatedElement = SetMemberRecursive(
+ element,
+ segments,
+ index + 1,
+ value
+ );
+ if (elementType.IsValueType)
+ list[segment.Index] = updatedElement;
+ return current;
+ }
+
+ if (index == segments.Count - 1)
+ {
+ var converted = ConvertForType(value, memberType);
+ SetMemberValue(member, current, converted);
+ return current;
+ }
+
+ var updatedChild = SetMemberRecursive(
+ memberValue,
+ segments,
+ index + 1,
+ value
+ );
+ if (memberType.IsValueType)
+ SetMemberValue(member, current, updatedChild);
+ return current;
+ }
+
+ private static Type GetCollectionElementType(Type collectionType)
+ {
+ if (collectionType.IsArray)
+ return collectionType.GetElementType() ?? typeof(object);
+ if (collectionType.IsGenericType)
+ return collectionType.GetGenericArguments()[0];
+ return typeof(object);
+ }
+
+ private static MemberInfo FindMember(Type type, string name)
+ {
+ const BindingFlags flags =
+ BindingFlags.Instance
+ | BindingFlags.Public
+ | BindingFlags.NonPublic;
+ for (var current = type; current != null; current = current.BaseType)
+ {
+ var field = current.GetField(name, flags);
+ if (field != null)
+ return field;
+ var property = current.GetProperty(name, flags);
+ if (property != null && property.CanRead && property.CanWrite)
+ return property;
+ }
+
+ if (
+ name.StartsWith("m_", StringComparison.Ordinal)
+ && name.Length > 2
+ )
+ {
+ var serializedName =
+ char.ToLowerInvariant(name[2]) + name.Substring(3);
+ var property = type.GetProperty(serializedName, flags);
+ if (property != null && property.CanRead && property.CanWrite)
+ return property;
+
+ var alias = name switch
+ {
+ "m_Mesh" => "sharedMesh",
+ "m_Material" => "sharedMaterial",
+ "m_Materials" => "sharedMaterials",
+ _ => null
+ };
+ if (alias != null)
+ {
+ property = type.GetProperty(alias, flags);
+ if (
+ property != null
+ && property.CanRead
+ && property.CanWrite
+ )
+ return property;
+ }
+ }
+
+ return null;
+ }
+
+ private static Type GetMemberType(MemberInfo member) =>
+ member is FieldInfo field
+ ? field.FieldType
+ : ((PropertyInfo)member).PropertyType;
+
+ private static object GetMemberValue(MemberInfo member, object target) =>
+ member is FieldInfo field
+ ? field.GetValue(target)
+ : ((PropertyInfo)member).GetValue(target);
+
+ private static void SetMemberValue(
+ MemberInfo member,
+ object target,
+ object value
+ )
+ {
+ if (member is FieldInfo field)
+ field.SetValue(target, value);
+ else
+ ((PropertyInfo)member).SetValue(target, value);
+ }
+
+ private static object ConvertForType(object value, Type targetType)
+ {
+ if (value is PrefabPatchValue patchValue)
+ {
+ if (
+ patchValue.Kind
+ == PrefabPatchValueKind.ManagedReference
+ )
+ {
+ var concreteType = ResolveType(
+ patchValue.SerializedType
+ );
+ if (
+ concreteType == null
+ || !targetType.IsAssignableFrom(concreteType)
+ )
+ {
+ throw new InvalidOperationException(
+ $"Managed-reference type "
+ + $"'{patchValue.SerializedType}' is not "
+ + $"assignable to '{targetType.FullName}'."
+ );
+ }
+ return CreateCollectionElement(concreteType);
+ }
+ if (patchValue.Kind != PrefabPatchValueKind.Json)
+ value = ConvertValue(patchValue);
+ else
+ return DeserializeJsonValue(patchValue, targetType);
+ }
+ if (value == null || targetType.IsInstanceOfType(value))
+ return value;
+ if (targetType.IsEnum)
+ return Enum.ToObject(targetType, value);
+ return Convert.ChangeType(
+ value,
+ targetType,
+ CultureInfo.InvariantCulture
+ );
+ }
+
+ private static object DeserializeJsonValue(
+ PrefabPatchValue value,
+ Type targetType
+ )
+ {
+ if (targetType == typeof(AnimationCurve))
+ {
+ var payload = JsonConvert.DeserializeObject<
+ AnimationCurvePayload
+ >(value.String, PrefabPatchJson.Settings);
+ var curve = new AnimationCurve(
+ payload?.Keys ?? Array.Empty()
+ )
+ {
+ preWrapMode =
+ (WrapMode)(payload?.PreWrapMode ?? (int)WrapMode.Default),
+ postWrapMode =
+ (WrapMode)(payload?.PostWrapMode ?? (int)WrapMode.Default)
+ };
+ return curve;
+ }
+ if (targetType == typeof(Gradient))
+ {
+ var payload = JsonConvert.DeserializeObject(
+ value.String,
+ PrefabPatchJson.Settings
+ );
+ var gradient = new Gradient
+ {
+ mode = (GradientMode)(
+ payload?.Mode ?? (int)GradientMode.Blend
+ )
+ };
+ gradient.SetKeys(
+ payload?.ColorKeys ?? Array.Empty(),
+ payload?.AlphaKeys ?? Array.Empty()
+ );
+ return gradient;
+ }
+ if (targetType == typeof(Hash128))
+ return Hash128.Parse(
+ JsonConvert.DeserializeObject(value.String)
+ );
+ return JsonConvert.DeserializeObject(
+ value.String ?? "null",
+ targetType,
+ PrefabPatchJson.Settings
+ );
+ }
+
+ private static object ConvertValue(PrefabPatchValue value) =>
+ value.Kind switch
+ {
+ PrefabPatchValueKind.Boolean => value.Boolean,
+ PrefabPatchValueKind.Integer => value.Integer,
+ PrefabPatchValueKind.Float => value.Float,
+ PrefabPatchValueKind.String => value.String,
+ PrefabPatchValueKind.Vector2 =>
+ new Vector2((float)value.X, (float)value.Y),
+ PrefabPatchValueKind.Vector3 =>
+ new Vector3((float)value.X, (float)value.Y, (float)value.Z),
+ PrefabPatchValueKind.Vector4 =>
+ new Vector4(
+ (float)value.X,
+ (float)value.Y,
+ (float)value.Z,
+ (float)value.W
+ ),
+ PrefabPatchValueKind.Quaternion =>
+ new Quaternion(
+ (float)value.X,
+ (float)value.Y,
+ (float)value.Z,
+ (float)value.W
+ ),
+ PrefabPatchValueKind.Color =>
+ new Color(
+ (float)value.X,
+ (float)value.Y,
+ (float)value.Z,
+ (float)value.W
+ ),
+ PrefabPatchValueKind.Json => value,
+ _ => throw new NotSupportedException(
+ $"Typed value kind '{value.Kind}' is unsupported."
+ )
+ };
+
+ private static Vector3 ToVector3(
+ PrefabPatchValue value,
+ Vector3 fallback
+ ) =>
+ value == null
+ ? fallback
+ : new Vector3((float)value.X, (float)value.Y, (float)value.Z);
+
+ private static Vector2 ToVector2(
+ PrefabPatchValue value,
+ Vector2 fallback
+ ) =>
+ value == null
+ ? fallback
+ : new Vector2((float)value.X, (float)value.Y);
+
+ private static Quaternion ToQuaternion(
+ PrefabPatchValue value,
+ Quaternion fallback
+ ) =>
+ value == null
+ ? fallback
+ : new Quaternion(
+ (float)value.X,
+ (float)value.Y,
+ (float)value.Z,
+ (float)value.W
+ );
+
+ private static void SetVectorComponent(
+ ref Vector3 value,
+ string component,
+ float replacement
+ )
+ {
+ switch (component)
+ {
+ case "x":
+ value.x = replacement;
+ break;
+ case "y":
+ value.y = replacement;
+ break;
+ case "z":
+ value.z = replacement;
+ break;
+ default:
+ throw new ArgumentOutOfRangeException(nameof(component));
+ }
+ }
+
+ private static void SetQuaternionComponent(
+ ref Quaternion value,
+ string component,
+ float replacement
+ )
+ {
+ switch (component)
+ {
+ case "x":
+ value.x = replacement;
+ break;
+ case "y":
+ value.y = replacement;
+ break;
+ case "z":
+ value.z = replacement;
+ break;
+ case "w":
+ value.w = replacement;
+ break;
+ default:
+ throw new ArgumentOutOfRangeException(nameof(component));
+ }
+ }
+
+ private static GameObject AsGameObject(Object target) =>
+ target switch
+ {
+ GameObject gameObject => gameObject,
+ Component component => component.gameObject,
+ _ => null
+ };
+
+ private static Type ResolveType(string name)
+ {
+ if (string.IsNullOrWhiteSpace(name))
+ return null;
+ var type = Type.GetType(name, false);
+ if (type != null)
+ return type;
+ return AppDomain.CurrentDomain
+ .GetAssemblies()
+ .Select(assembly => assembly.GetType(name, false))
+ .FirstOrDefault(value => value != null);
+ }
+
+ private static Exception MissingTarget(PrefabPatchOperation operation) =>
+ new InvalidOperationException(
+ $"Operation '{operation.OperationId}' from '{operation.PatchId}' "
+ + $"could not resolve target '{operation.Target?.CanonicalKey}'."
+ );
+}
diff --git a/Runtime/PrefabPatching/PrefabPatchComposer.cs.meta b/Runtime/PrefabPatching/PrefabPatchComposer.cs.meta
new file mode 100644
index 0000000..4f95033
--- /dev/null
+++ b/Runtime/PrefabPatching/PrefabPatchComposer.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 233910d5b0c5741478f5b3d1e07ea96f
\ No newline at end of file
diff --git a/Runtime/PrefabPatching/PrefabPatchFragmentBuilder.cs b/Runtime/PrefabPatching/PrefabPatchFragmentBuilder.cs
new file mode 100644
index 0000000..5048898
--- /dev/null
+++ b/Runtime/PrefabPatching/PrefabPatchFragmentBuilder.cs
@@ -0,0 +1,297 @@
+using System;
+using UnityEngine;
+
+namespace PatchManager.PrefabPatching;
+
+///
+/// Fluent, type-agnostic C# frontend for one serialized component fragment.
+/// It produces the same public model as visual and Lua authoring.
+///
+public sealed class PrefabPatchComponentBuilder
+{
+ private readonly PrefabPatchComponentFragment _fragment;
+
+ ///
+ /// Creates a builder for one patch-owned component.
+ ///
+ /// Stable ID used by later patch operations.
+ /// Concrete Unity component type to create.
+ public PrefabPatchComponentBuilder(string componentId, Type componentType)
+ {
+ if (string.IsNullOrWhiteSpace(componentId))
+ throw new ArgumentException(
+ "A stable component ID is required.",
+ nameof(componentId)
+ );
+ if (
+ componentType == null
+ || componentType.IsAbstract
+ || !typeof(Component).IsAssignableFrom(componentType)
+ || typeof(Transform).IsAssignableFrom(componentType)
+ )
+ {
+ throw new ArgumentException(
+ "The component type must be a concrete, non-Transform Unity "
+ + "Component.",
+ nameof(componentType)
+ );
+ }
+
+ _fragment = new PrefabPatchComponentFragment
+ {
+ ComponentId = componentId,
+ ComponentType = componentType.AssemblyQualifiedName
+ };
+ }
+
+ ///
+ /// Creates a component builder for .
+ ///
+ /// Stable ID used by later patch operations.
+ /// Concrete Unity component type to create.
+ /// A builder for the requested component type.
+ public static PrefabPatchComponentBuilder For(string componentId)
+ where T : Component =>
+ new(componentId, typeof(T));
+
+ ///
+ /// Adds a serialized value assignment to the component fragment.
+ ///
+ /// Unity serialized-property path.
+ /// Value to assign.
+ /// This builder.
+ public PrefabPatchComponentBuilder Value(
+ string propertyPath,
+ PrefabPatchValue value
+ )
+ {
+ _fragment.Values.Add(
+ new PrefabPatchSerializedValue
+ {
+ PropertyPath = propertyPath,
+ Value = value
+ }
+ );
+ return this;
+ }
+
+ ///
+ /// Adds a serialized object-reference assignment to the component fragment.
+ ///
+ /// Unity serialized-property path.
+ /// Reference to assign.
+ /// This builder.
+ public PrefabPatchComponentBuilder Reference(
+ string propertyPath,
+ PrefabPatchObjectReference reference
+ )
+ {
+ _fragment.References.Add(
+ new PrefabPatchSerializedReference
+ {
+ PropertyPath = propertyPath,
+ Reference = reference
+ }
+ );
+ return this;
+ }
+
+ ///
+ /// Returns the component fragment represented by this builder.
+ ///
+ /// The mutable component fragment.
+ public PrefabPatchComponentFragment Build() => _fragment;
+}
+
+///
+/// Fluent C# frontend for an inline patch-owned hierarchy.
+///
+public sealed class PrefabPatchObjectBuilder
+{
+ private readonly PrefabPatchObjectFragment _fragment;
+
+ ///
+ /// Creates a builder for one patch-owned GameObject.
+ ///
+ /// Stable ID used by later patch operations.
+ /// Optional GameObject name; defaults to the object ID.
+ public PrefabPatchObjectBuilder(string objectId, string name = null)
+ {
+ if (string.IsNullOrWhiteSpace(objectId))
+ throw new ArgumentException(
+ "A stable object ID is required.",
+ nameof(objectId)
+ );
+ _fragment = new PrefabPatchObjectFragment
+ {
+ ObjectId = objectId,
+ Name = name ?? objectId,
+ TransformType = typeof(Transform).AssemblyQualifiedName,
+ LocalPosition = Vector3Value(Vector3.zero),
+ LocalRotation = QuaternionValue(Quaternion.identity),
+ LocalScale = Vector3Value(Vector3.one)
+ };
+ }
+
+ ///
+ /// Uses a for the object.
+ ///
+ /// This builder.
+ public PrefabPatchObjectBuilder RectTransform()
+ {
+ _fragment.TransformType = typeof(RectTransform).AssemblyQualifiedName;
+ return this;
+ }
+
+ ///
+ /// Sets whether the object is active by default.
+ ///
+ /// Default active state.
+ /// This builder.
+ public PrefabPatchObjectBuilder Active(bool value)
+ {
+ _fragment.Active = value;
+ return this;
+ }
+
+ ///
+ /// Sets the GameObject layer.
+ ///
+ /// Unity layer index.
+ /// This builder.
+ public PrefabPatchObjectBuilder Layer(int value)
+ {
+ _fragment.Layer = value;
+ return this;
+ }
+
+ ///
+ /// Sets the GameObject tag.
+ ///
+ /// Unity tag name.
+ /// This builder.
+ public PrefabPatchObjectBuilder Tag(string value)
+ {
+ _fragment.Tag = value;
+ return this;
+ }
+
+ ///
+ /// Sets the GameObject static flag.
+ ///
+ /// Whether the object is static.
+ /// This builder.
+ public PrefabPatchObjectBuilder Static(bool value = true)
+ {
+ _fragment.IsStatic = value;
+ return this;
+ }
+
+ ///
+ /// Sets the local transform values for the object.
+ ///
+ /// Local position.
+ /// Local rotation.
+ /// Local scale.
+ /// This builder.
+ public PrefabPatchObjectBuilder Transform(
+ Vector3 localPosition,
+ Quaternion localRotation,
+ Vector3 localScale
+ )
+ {
+ _fragment.LocalPosition = Vector3Value(localPosition);
+ _fragment.LocalRotation = QuaternionValue(localRotation);
+ _fragment.LocalScale = Vector3Value(localScale);
+ return this;
+ }
+
+ ///
+ /// Configures the object as a UI rectangle.
+ ///
+ /// Minimum normalized anchor.
+ /// Maximum normalized anchor.
+ /// Position relative to the anchors.
+ /// Size relative to the anchors.
+ /// Normalized pivot.
+ /// This builder.
+ public PrefabPatchObjectBuilder Rect(
+ Vector2 anchorMin,
+ Vector2 anchorMax,
+ Vector2 anchoredPosition,
+ Vector2 sizeDelta,
+ Vector2 pivot
+ )
+ {
+ RectTransform();
+ _fragment.AnchorMin = Vector2Value(anchorMin);
+ _fragment.AnchorMax = Vector2Value(anchorMax);
+ _fragment.AnchoredPosition = Vector2Value(anchoredPosition);
+ _fragment.SizeDelta = Vector2Value(sizeDelta);
+ _fragment.Pivot = Vector2Value(pivot);
+ return this;
+ }
+
+ ///
+ /// Adds a component fragment to the object.
+ ///
+ /// Component fragment to add.
+ /// This builder.
+ public PrefabPatchObjectBuilder Component(
+ PrefabPatchComponentFragment component
+ )
+ {
+ _fragment.Components.Add(
+ component ?? throw new ArgumentNullException(nameof(component))
+ );
+ return this;
+ }
+
+ ///
+ /// Adds a child object fragment.
+ ///
+ /// Child fragment to add.
+ /// This builder.
+ public PrefabPatchObjectBuilder Child(
+ PrefabPatchObjectFragment child
+ )
+ {
+ _fragment.Children.Add(
+ child ?? throw new ArgumentNullException(nameof(child))
+ );
+ return this;
+ }
+
+ ///
+ /// Returns the object fragment represented by this builder.
+ ///
+ /// The mutable object fragment.
+ public PrefabPatchObjectFragment Build() => _fragment;
+
+ private static PrefabPatchValue Vector2Value(Vector2 value) =>
+ new()
+ {
+ Kind = PrefabPatchValueKind.Vector2,
+ X = value.x,
+ Y = value.y
+ };
+
+ private static PrefabPatchValue Vector3Value(Vector3 value) =>
+ new()
+ {
+ Kind = PrefabPatchValueKind.Vector3,
+ X = value.x,
+ Y = value.y,
+ Z = value.z
+ };
+
+ private static PrefabPatchValue QuaternionValue(Quaternion value) =>
+ new()
+ {
+ Kind = PrefabPatchValueKind.Quaternion,
+ X = value.x,
+ Y = value.y,
+ Z = value.z,
+ W = value.w
+ };
+}
diff --git a/Runtime/PrefabPatching/PrefabPatchFragmentBuilder.cs.meta b/Runtime/PrefabPatching/PrefabPatchFragmentBuilder.cs.meta
new file mode 100644
index 0000000..de96d65
--- /dev/null
+++ b/Runtime/PrefabPatching/PrefabPatchFragmentBuilder.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 65c3fc4c688b4aa192448f378182005c
diff --git a/Runtime/PrefabPatching/PrefabPatchJson.cs b/Runtime/PrefabPatching/PrefabPatchJson.cs
new file mode 100644
index 0000000..9317b94
--- /dev/null
+++ b/Runtime/PrefabPatching/PrefabPatchJson.cs
@@ -0,0 +1,78 @@
+using System;
+using System.Security.Cryptography;
+using System.Text;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Converters;
+using Newtonsoft.Json.Serialization;
+
+namespace PatchManager.PrefabPatching;
+
+///
+/// Canonical JSON and SHA-256 helpers shared by the editor compiler, resolver,
+/// cache, and fluent frontend.
+///
+public static class PrefabPatchJson
+{
+ ///
+ /// Canonical serializer settings used for manifests, plans, and hashes.
+ ///
+ public static readonly JsonSerializerSettings Settings = new()
+ {
+ ContractResolver = new DefaultContractResolver
+ {
+ NamingStrategy = new CamelCaseNamingStrategy()
+ },
+ Formatting = Formatting.Indented,
+ NullValueHandling = NullValueHandling.Ignore,
+ DefaultValueHandling = DefaultValueHandling.Include,
+ Converters = { new StringEnumConverter() }
+ };
+
+ ///
+ /// Serializes a value using the canonical prefab-patch JSON format.
+ ///
+ /// Value to serialize.
+ /// Formatted canonical JSON.
+ public static string Serialize(object value) =>
+ JsonConvert.SerializeObject(value, Settings);
+
+ ///
+ /// Deserializes canonical prefab-patch JSON.
+ ///
+ /// JSON to deserialize.
+ /// Expected result type.
+ /// The deserialized value.
+ public static T Deserialize(string json) =>
+ JsonConvert.DeserializeObject(json, Settings);
+
+ ///
+ /// Calculates the lowercase SHA-256 digest of a UTF-8 string.
+ ///
+ /// Value to hash; null is treated as an empty string.
+ /// A lowercase hexadecimal digest.
+ public static string Sha256(string value)
+ {
+ using var algorithm = SHA256.Create();
+ var bytes = algorithm.ComputeHash(Encoding.UTF8.GetBytes(value ?? ""));
+ return BitConverter.ToString(bytes).Replace("-", "").ToLowerInvariant();
+ }
+
+ ///
+ /// Calculates a manifest content hash without including its current hash field.
+ ///
+ /// Manifest to hash.
+ /// The canonical manifest hash.
+ public static string CalculateManifestHash(PrefabPatchManifest manifest)
+ {
+ var previous = manifest.ManifestHash;
+ manifest.ManifestHash = null;
+ try
+ {
+ return Sha256(Serialize(manifest));
+ }
+ finally
+ {
+ manifest.ManifestHash = previous;
+ }
+ }
+}
diff --git a/Runtime/PrefabPatching/PrefabPatchJson.cs.meta b/Runtime/PrefabPatching/PrefabPatchJson.cs.meta
new file mode 100644
index 0000000..36fb55a
--- /dev/null
+++ b/Runtime/PrefabPatching/PrefabPatchJson.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: c1eac2732f7895b49ae9365e98d46fc9
\ No newline at end of file
diff --git a/Runtime/PrefabPatching/PrefabPatchModel.cs b/Runtime/PrefabPatching/PrefabPatchModel.cs
new file mode 100644
index 0000000..2790212
--- /dev/null
+++ b/Runtime/PrefabPatching/PrefabPatchModel.cs
@@ -0,0 +1,637 @@
+using System;
+using System.Collections.Generic;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Converters;
+
+namespace PatchManager.PrefabPatching;
+
+///
+/// Versioned public schema for declarative prefab patches.
+///
+public static class PrefabPatchSchema
+{
+ /// The current serialized manifest schema version.
+ public const int Version = 1;
+ /// The current runtime composition algorithm version.
+ public const int ComposerVersion = 1;
+ /// The suffix used for per-mod prefab manifest labels.
+ public const string AddressablesLabelSuffix = "_prefab_patches";
+}
+
+///
+/// Common dependency and relative-ordering metadata shared by JSON patches
+/// and declarative prefab patches.
+///
+///
+/// The two patch domains retain their own serialized models, but consumers
+/// that inspect eligibility or ordering can use this interface instead of
+/// duplicating a second metadata abstraction.
+///
+public interface IPatchRelationships
+{
+ /// Gets the mod IDs required by the patch.
+ IEnumerable NeedsMods { get; }
+
+ /// Gets the mod IDs that disable the patch when present.
+ IEnumerable ConflictsMods { get; }
+
+ /// Gets the patch IDs required by the patch.
+ IEnumerable NeedsPatches { get; }
+
+ /// Gets the patch IDs that disable the patch when present.
+ IEnumerable ConflictsPatches { get; }
+
+ /// Gets the patch IDs that this patch must precede.
+ IEnumerable BeforePatches { get; }
+
+ /// Gets the patch IDs that this patch must follow.
+ IEnumerable AfterPatches { get; }
+
+ /// Gets the mod IDs whose patches this patch must precede.
+ IEnumerable BeforeMods { get; }
+
+ /// Gets the mod IDs whose patches this patch must follow.
+ IEnumerable AfterMods { get; }
+}
+
+///
+/// One active mod's Addressables discovery boundary for declarative prefab
+/// patches. The mod descriptor supplies ownership; the manifest asset address
+/// is deliberately not part of patch identity.
+///
+public sealed class PrefabPatchManifestSource
+{
+ /// The SpaceWarp mod ID that owns manifests discovered from this source.
+ public string OwnerModId;
+ /// The Addressables label that exposes the owning mod's manifests.
+ public string AddressablesLabel;
+}
+
+/// Defines the broad execution pass for a prefab patch.
+[JsonConverter(typeof(StringEnumConverter))]
+public enum PrefabPatchPass
+{
+ /// Runs before the normal patch pass.
+ Early,
+ /// Runs in the normal patch pass.
+ Default,
+ /// Runs after the normal patch pass.
+ Late
+}
+
+/// Defines the ordering bucket within a patch pass.
+[JsonConverter(typeof(StringEnumConverter))]
+public enum PrefabPatchOrdering
+{
+ /// Runs before Default and Last patches in the pass.
+ First,
+ /// Runs after First and before Last patches in the pass.
+ Default,
+ /// Runs after First and Default patches in the pass.
+ Last
+}
+
+/// Identifies the source domain of an operation target.
+[JsonConverter(typeof(StringEnumConverter))]
+public enum PrefabPatchTargetKind
+{
+ /// An object or component inherited from the stock prefab.
+ Stock,
+ /// A GameObject introduced by a patch operation.
+ PatchOwned,
+ /// A component introduced by a patch operation.
+ PatchComponent
+}
+
+/// Identifies the Unity object kind returned by a runtime locator.
+[JsonConverter(typeof(StringEnumConverter))]
+public enum PrefabPatchRuntimeTargetKind
+{
+ /// The locator resolves a GameObject.
+ GameObject,
+ /// The locator resolves a Component.
+ Component
+}
+
+/// Lists the normalized mutations supported by the composer.
+[JsonConverter(typeof(StringEnumConverter))]
+public enum PrefabPatchOperationKind
+{
+ /// Writes a serialized scalar or structured value.
+ SetValue,
+ /// Writes a Unity object reference.
+ SetObjectReference,
+ /// Changes a GameObject's active state.
+ SetActive,
+ /// Adds a patch-owned GameObject hierarchy.
+ AddObject,
+ /// Disables an inherited or patch-owned GameObject.
+ SuppressObject,
+ /// Adds a patch-owned component.
+ AddComponent,
+ /// Removes an inherited or patch-owned component.
+ RemoveComponent
+}
+
+/// Identifies the payload stored in a .
+[JsonConverter(typeof(StringEnumConverter))]
+public enum PrefabPatchValueKind
+{
+ /// A Boolean value.
+ Boolean,
+ /// A signed integer value.
+ Integer,
+ /// A floating-point value.
+ Float,
+ /// A string value.
+ String,
+ /// A two-component vector.
+ Vector2,
+ /// A three-component vector.
+ Vector3,
+ /// A four-component vector.
+ Vector4,
+ /// A quaternion.
+ Quaternion,
+ /// An RGBA color.
+ Color,
+ /// A serialized array or list size.
+ ArraySize,
+ /// A managed-reference payload with an explicit CLR type.
+ ManagedReference,
+ /// An arbitrary JSON payload interpreted by the target property.
+ Json
+}
+
+/// Identifies how a Unity object reference is resolved.
+[JsonConverter(typeof(StringEnumConverter))]
+public enum PrefabPatchObjectReferenceKind
+{
+ /// Resolve the object from an Addressables key.
+ Addressable,
+ /// Resolve the object from the effective prefab hierarchy.
+ Target
+}
+
+///
+/// Runtime identity and optional compatibility data for one stock Addressable
+/// prefab.
+///
+[Serializable]
+public sealed class PrefabPatchPrefabIdentity
+{
+ /// The stock prefab Addressables key.
+ public string Address;
+ /// The expected assembly-qualified asset type.
+ public string AssetType;
+ /// An optional authoring-time hierarchy fingerprint.
+ public string StructuralFingerprint;
+
+ /// Gets the stable cache and grouping identity.
+ [JsonIgnore]
+ public string CanonicalKey => $"address:{Address}";
+
+ /// Creates an identity for a GameObject Addressables key.
+ /// The non-empty stock prefab key.
+ /// A prefab identity without a structural fingerprint.
+ public static PrefabPatchPrefabIdentity FromAddress(string address)
+ {
+ if (string.IsNullOrWhiteSpace(address))
+ throw new ArgumentException(
+ "Addressables key is required.",
+ nameof(address)
+ );
+ return new PrefabPatchPrefabIdentity
+ {
+ Address = address.Trim(),
+ AssetType = typeof(UnityEngine.GameObject).AssemblyQualifiedName
+ };
+ }
+}
+
+///
+/// Runtime traversal data. Visual compilation uses sibling indices backed by
+/// canonical source identity; key-first C#/Lua authoring uses a hierarchy path.
+///
+[Serializable]
+public sealed class PrefabPatchRuntimeLocator
+{
+ /// Sibling indices from the prefab root to the target.
+ public int[] SiblingIndices = Array.Empty();
+ /// An optional slash-delimited hierarchy path for imperative patches.
+ public string HierarchyPath;
+ /// The Unity object kind produced by the locator.
+ public PrefabPatchRuntimeTargetKind TargetKind;
+ /// The assembly-qualified component type when targeting a component.
+ public string ComponentType;
+ /// The zero-based component occurrence on the located GameObject.
+ public int ComponentOrdinal;
+ /// A diagnostic-only human-readable authoring path.
+ public string DisplayPath;
+}
+
+///
+/// An inherited source object or an object introduced by a named patch.
+///
+[Serializable]
+public sealed class PrefabPatchObjectTarget
+{
+ /// The source domain of the target.
+ public PrefabPatchTargetKind Kind;
+ /// The expected assembly-qualified Unity object type.
+ public string ObjectType;
+ /// The namespaced patch ID that introduced a patch-owned target.
+ public string OwnerPatchId;
+ /// The stable local ID of a patch-owned GameObject.
+ public string ObjectId;
+ /// The stable local ID of a patch-owned component.
+ public string ComponentId;
+ /// The traversal information for stock targets.
+ public PrefabPatchRuntimeLocator RuntimeLocator;
+
+ /// Gets the deterministic conflict and lookup key for the target.
+ [JsonIgnore]
+ public string CanonicalKey
+ {
+ get
+ {
+ if (Kind == PrefabPatchTargetKind.PatchComponent)
+ return $"patch-component:{OwnerPatchId}:{ComponentId}";
+ if (Kind == PrefabPatchTargetKind.PatchOwned)
+ return $"patch:{OwnerPatchId}:{ObjectId}";
+
+ var path = !string.IsNullOrWhiteSpace(
+ RuntimeLocator?.HierarchyPath
+ )
+ ? RuntimeLocator.HierarchyPath
+ : RuntimeLocator?.DisplayPath;
+ if (!string.IsNullOrWhiteSpace(path))
+ return $"path:{path}:{RuntimeLocator.TargetKind}:"
+ + $"{RuntimeLocator.ComponentType}:"
+ + $"{RuntimeLocator.ComponentOrdinal}";
+
+ var indices = RuntimeLocator?.SiblingIndices == null
+ ? ""
+ : string.Join(",", RuntimeLocator.SiblingIndices);
+ return $"indices:{indices}:{RuntimeLocator?.TargetKind}:"
+ + $"{RuntimeLocator?.ComponentType}:"
+ + $"{RuntimeLocator?.ComponentOrdinal}";
+ }
+ }
+}
+
+///
+/// JSON-safe typed value. Numeric vectors use X/Y/Z/W in their normal Unity
+/// component order.
+///
+[Serializable]
+public sealed class PrefabPatchValue
+{
+ /// The active payload representation.
+ public PrefabPatchValueKind Kind;
+ /// The Boolean payload.
+ public bool Boolean;
+ /// The integer payload.
+ public long Integer;
+ /// The floating-point payload.
+ public double Float;
+ /// The string or raw JSON payload.
+ public string String;
+ /// The assembly-qualified type for managed-reference payloads.
+ public string SerializedType;
+ /// The first vector, quaternion, or color component.
+ public double X;
+ /// The second vector, quaternion, or color component.
+ public double Y;
+ /// The third vector, quaternion, or color component.
+ public double Z;
+ /// The fourth vector, quaternion, or color component.
+ public double W;
+
+ /// Creates a Boolean payload.
+ public static PrefabPatchValue FromBoolean(bool value) =>
+ new() { Kind = PrefabPatchValueKind.Boolean, Boolean = value };
+
+ /// Creates an integer payload.
+ public static PrefabPatchValue FromInteger(long value) =>
+ new() { Kind = PrefabPatchValueKind.Integer, Integer = value };
+
+ /// Creates a floating-point payload.
+ public static PrefabPatchValue FromFloat(double value) =>
+ new() { Kind = PrefabPatchValueKind.Float, Float = value };
+
+ /// Creates a string payload.
+ public static PrefabPatchValue FromString(string value) =>
+ new() { Kind = PrefabPatchValueKind.String, String = value };
+}
+
+///
+/// Addressable or target-local Unity object reference used by
+/// SetObjectReference or a component fragment.
+///
+[Serializable]
+public sealed class PrefabPatchObjectReference
+{
+ /// The resolution strategy.
+ public PrefabPatchObjectReferenceKind Kind;
+ /// The Addressables key for an external reference.
+ public string Address;
+ /// The optional assembly-qualified type expected after resolution.
+ public string ExpectedType;
+ /// The effective-prefab target for a local reference.
+ public PrefabPatchObjectTarget Target;
+
+ /// Creates an Addressables-backed reference.
+ public static PrefabPatchObjectReference FromAddress(
+ string address,
+ string expectedType = null
+ ) =>
+ new()
+ {
+ Kind = PrefabPatchObjectReferenceKind.Addressable,
+ Address = address,
+ ExpectedType = expectedType
+ };
+
+ /// Creates a reference to an object in the effective prefab.
+ public static PrefabPatchObjectReference FromTarget(
+ PrefabPatchObjectTarget target,
+ string expectedType = null
+ ) =>
+ new()
+ {
+ Kind = PrefabPatchObjectReferenceKind.Target,
+ Target = target,
+ ExpectedType = expectedType
+ };
+}
+
+///
+/// One serialized field/property value captured from an arbitrary component.
+/// Visual, C#, and Lua authoring all emit this property-stream representation.
+///
+[Serializable]
+public sealed class PrefabPatchSerializedValue
+{
+ /// The Unity SerializedProperty path.
+ public string PropertyPath;
+ /// The value written at the property path.
+ public PrefabPatchValue Value;
+}
+
+///
+/// One Unity object reference removed from a serialized component payload and
+/// restored after every patch-owned object and component has been created.
+///
+[Serializable]
+public sealed class PrefabPatchSerializedReference
+{
+ /// The Unity SerializedProperty path.
+ public string PropertyPath;
+ /// The reference restored after object creation.
+ public PrefabPatchObjectReference Reference;
+}
+
+///
+/// Serialized payload used for added patch-owned objects and AddComponent
+/// operations.
+///
+[Serializable]
+public sealed class PrefabPatchComponentFragment
+{
+ /// The stable patch-local component ID.
+ public string ComponentId;
+ /// The assembly-qualified concrete component type.
+ public string ComponentType;
+ /// The serialized non-reference values.
+ public List Values = new();
+ /// The deferred Unity object references.
+ public List References = new();
+}
+
+///
+/// An inline, patch-owned hierarchy fragment. Every object has an explicit ID,
+/// allowing later required patches to target it without hierarchy-name lookup.
+///
+[Serializable]
+public sealed class PrefabPatchObjectFragment
+{
+ /// The stable patch-local GameObject ID.
+ public string ObjectId;
+ /// The created GameObject name.
+ public string Name;
+ /// The assembly-qualified Transform or RectTransform type.
+ public string TransformType;
+ /// Whether the object is active after composition.
+ public bool Active = true;
+ /// The Unity layer assigned to the object.
+ public int Layer;
+ /// The Unity tag assigned to the object.
+ public string Tag = "Untagged";
+ /// Whether the object uses Unity's static flag.
+ public bool IsStatic;
+ /// The local-position payload.
+ public PrefabPatchValue LocalPosition;
+ /// The local-rotation payload.
+ public PrefabPatchValue LocalRotation;
+ /// The local-scale payload.
+ public PrefabPatchValue LocalScale;
+ /// The RectTransform anchor-min payload.
+ public PrefabPatchValue AnchorMin;
+ /// The RectTransform anchor-max payload.
+ public PrefabPatchValue AnchorMax;
+ /// The RectTransform anchored-position payload.
+ public PrefabPatchValue AnchoredPosition;
+ /// The RectTransform size-delta payload.
+ public PrefabPatchValue SizeDelta;
+ /// The RectTransform pivot payload.
+ public PrefabPatchValue Pivot;
+ /// Components created on this object.
+ public List Components = new();
+ /// Child objects created beneath this object.
+ public List Children = new();
+}
+
+///
+/// One normalized declarative operation.
+///
+[Serializable]
+public sealed class PrefabPatchOperation
+{
+ /// The stable patch-local operation ID.
+ public string OperationId;
+ /// The owning namespaced patch ID, assigned during resolution.
+ [JsonIgnore]
+ public string PatchId;
+ /// The mutation performed by the operation.
+ public PrefabPatchOperationKind Kind;
+ /// The object or component receiving the operation.
+ public PrefabPatchObjectTarget Target;
+ /// The Unity SerializedProperty path for value/reference writes.
+ public string PropertyPath;
+ /// The payload for value and active-state writes.
+ public PrefabPatchValue Value;
+ /// The payload for object-reference writes.
+ public PrefabPatchObjectReference ObjectReference;
+ /// The hierarchy created by an AddObject operation.
+ public PrefabPatchObjectFragment AddedObject;
+ /// The component created by an AddComponent operation.
+ public PrefabPatchComponentFragment AddedComponent;
+ /// An optional authoring-time value fingerprint used for stale-data checks.
+ public string ExpectedOriginalFingerprint;
+ /// The authoring asset path used in diagnostics.
+ public string AuthoringAssetPath;
+ /// The authoring property path used in diagnostics.
+ public string AuthoringPropertyPath;
+
+ /// Gets the deterministic key used to detect competing writes.
+ [JsonIgnore]
+ public string ConflictKey
+ {
+ get
+ {
+ var target = Target?.CanonicalKey ?? "";
+ return Kind switch
+ {
+ PrefabPatchOperationKind.SetValue =>
+ $"{target}:value:{PropertyPath}",
+ PrefabPatchOperationKind.SetObjectReference =>
+ $"{target}:object:{PropertyPath}",
+ PrefabPatchOperationKind.SetActive =>
+ $"{target}:active",
+ PrefabPatchOperationKind.SuppressObject =>
+ $"{target}:suppressed",
+ PrefabPatchOperationKind.RemoveComponent =>
+ $"{target}:removed",
+ PrefabPatchOperationKind.AddObject =>
+ $"patch:{PatchId}:{AddedObject?.ObjectId}:introduced",
+ PrefabPatchOperationKind.AddComponent =>
+ $"{target}:component:{AddedComponent?.ComponentId}",
+ _ => $"{target}:{Kind}:{OperationId}"
+ };
+ }
+ }
+}
+
+///
+/// One independently distributable prefab patch manifest.
+///
+[Serializable]
+public sealed class PrefabPatchManifest : IPatchRelationships
+{
+ /// The serialized schema version.
+ public int SchemaVersion = PrefabPatchSchema.Version;
+ /// The required composer version.
+ public int ComposerVersion = PrefabPatchSchema.ComposerVersion;
+ /// The mod-local patch name stored in distributable JSON.
+ public string PatchName;
+ /// The runtime namespaced patch ID assigned from ownership.
+ [JsonIgnore]
+ public string PatchId;
+ /// The runtime owning mod ID assigned during discovery.
+ [JsonIgnore]
+ public string ModId;
+ /// The stock prefab targeted by this manifest.
+ public PrefabPatchPrefabIdentity TargetPrefab;
+ /// The broad execution pass.
+ public PrefabPatchPass Pass = PrefabPatchPass.Default;
+ /// The ordering bucket within the pass.
+ public PrefabPatchOrdering Ordering = PrefabPatchOrdering.Default;
+ /// Required active mod IDs.
+ public string[] NeedsMods = Array.Empty();
+ /// Conflicting active mod IDs.
+ public string[] ConflictsMods = Array.Empty();
+ /// Required namespaced patch IDs.
+ public string[] NeedsPatches = Array.Empty();
+ /// Conflicting namespaced patch IDs.
+ public string[] ConflictsPatches = Array.Empty();
+ /// Patch IDs that this patch must precede in its bucket.
+ public string[] BeforePatches = Array.Empty();
+ /// Patch IDs that this patch must follow in its bucket.
+ public string[] AfterPatches = Array.Empty();
+ /// Mod IDs whose patches this patch must precede.
+ public string[] BeforeMods = Array.Empty();
+ /// Mod IDs whose patches this patch must follow.
+ public string[] AfterMods = Array.Empty();
+ /// Configuration values included in the plan cache key.
+ public string[] ConfigurationInputs = Array.Empty();
+ /// Operation capabilities declared for diagnostics and compatibility.
+ public string[] DeclaredCapabilities = Array.Empty();
+ /// Operations applied in their serialized order.
+ public List Operations = new();
+ /// The normalized content hash, excluding runtime ownership.
+ public string ManifestHash;
+
+ IEnumerable IPatchRelationships.NeedsMods => NeedsMods;
+ IEnumerable IPatchRelationships.ConflictsMods => ConflictsMods;
+ IEnumerable IPatchRelationships.NeedsPatches => NeedsPatches;
+ IEnumerable IPatchRelationships.ConflictsPatches => ConflictsPatches;
+ IEnumerable IPatchRelationships.BeforePatches => BeforePatches;
+ IEnumerable IPatchRelationships.AfterPatches => AfterPatches;
+ IEnumerable IPatchRelationships.BeforeMods => BeforeMods;
+ IEnumerable IPatchRelationships.AfterMods => AfterMods;
+}
+
+/// Defines the impact level of a resolver or composer diagnostic.
+[JsonConverter(typeof(StringEnumConverter))]
+public enum PrefabPatchDiagnosticSeverity
+{
+ /// Informational behavior that does not invalidate a plan.
+ Info,
+ /// A recoverable conflict resolved by deterministic ordering.
+ Warning,
+ /// An error that invalidates the resolved plan.
+ Error
+}
+
+///
+/// Machine-readable ordering, compatibility, conflict, cache, or composition
+/// diagnostic.
+///
+[Serializable]
+public sealed class PrefabPatchDiagnostic
+{
+ /// The diagnostic impact level.
+ public PrefabPatchDiagnosticSeverity Severity;
+ /// The stable machine-readable diagnostic code.
+ public string Code;
+ /// The affected stock prefab address.
+ public string TargetAddress;
+ /// The affected patch ID, when applicable.
+ public string PatchId;
+ /// The affected operation ID, when applicable.
+ public string OperationId;
+ /// The human-readable explanation.
+ public string Message;
+}
+
+///
+/// Cacheable result of discovery, dependency resolution, ordering, conflict
+/// analysis, and normalized operation validation for one stock prefab.
+///
+[Serializable]
+public sealed class PrefabPatchResolvedPlan
+{
+ /// The manifest schema version used to build the plan.
+ public int SchemaVersion = PrefabPatchSchema.Version;
+ /// The composer version required by the plan.
+ public int ComposerVersion = PrefabPatchSchema.ComposerVersion;
+ /// The stable cache filename identity.
+ public string CacheKey;
+ /// The hash of all source manifests and environment inputs.
+ public string SourceFingerprint;
+ /// The hash of enabled, ordered plan inputs.
+ public string InputHash;
+ /// The common stock prefab target.
+ public PrefabPatchPrefabIdentity TargetPrefab;
+ /// The final deterministic patch order.
+ public string[] OrderedPatchIds = Array.Empty();
+ /// The validated flattened operation stream.
+ public List Operations = new();
+ /// Diagnostics produced while resolving the plan.
+ public List Diagnostics = new();
+ /// Whether the plan can be composed.
+ public bool IsValid;
+ /// The UTC creation time represented as .
+ public long ResolvedUtcTicks;
+}
diff --git a/Runtime/PrefabPatching/PrefabPatchModel.cs.meta b/Runtime/PrefabPatching/PrefabPatchModel.cs.meta
new file mode 100644
index 0000000..c748b08
--- /dev/null
+++ b/Runtime/PrefabPatching/PrefabPatchModel.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: a7e84cd69b3f4824cb0855b8a36088cc
\ No newline at end of file
diff --git a/Runtime/PrefabPatching/PrefabPatchObjectId.cs b/Runtime/PrefabPatching/PrefabPatchObjectId.cs
new file mode 100644
index 0000000..62df26b
--- /dev/null
+++ b/Runtime/PrefabPatching/PrefabPatchObjectId.cs
@@ -0,0 +1,23 @@
+using UnityEngine;
+
+namespace PatchManager.PrefabPatching
+{
+ ///
+ /// Explicit stable ID for a GameObject introduced by a visual prefab patch.
+ /// Later patches address it as owning patch ID plus this patch-local ID.
+ ///
+ [DisallowMultipleComponent]
+ public sealed class PrefabPatchObjectId : MonoBehaviour
+ {
+ [SerializeField] private string _id;
+
+ ///
+ /// Gets or sets the patch-local stable object ID.
+ ///
+ public string Id
+ {
+ get => _id;
+ set => _id = value;
+ }
+ }
+}
diff --git a/Runtime/PrefabPatching/PrefabPatchObjectId.cs.meta b/Runtime/PrefabPatching/PrefabPatchObjectId.cs.meta
new file mode 100644
index 0000000..3fcc5e8
--- /dev/null
+++ b/Runtime/PrefabPatching/PrefabPatchObjectId.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 93da1f45b46a3f243ae12acca9ebff3a
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Runtime/PrefabPatching/PrefabPatchOwnership.cs b/Runtime/PrefabPatching/PrefabPatchOwnership.cs
new file mode 100644
index 0000000..49083d6
--- /dev/null
+++ b/Runtime/PrefabPatching/PrefabPatchOwnership.cs
@@ -0,0 +1,168 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace PatchManager.PrefabPatching;
+
+///
+/// Applies the containing mod's runtime identity to an ownership-free prefab
+/// patch manifest. Serialized manifests deliberately do not duplicate swinfo
+/// metadata.
+///
+public static class PrefabPatchOwnership
+{
+ ///
+ /// Binds a locally named manifest and its patch-owned references to a mod ID.
+ ///
+ /// Ownership-free authored manifest.
+ /// Runtime ID of the containing mod.
+ /// The same manifest after ownership and hashes are applied.
+ public static PrefabPatchManifest Bind(
+ PrefabPatchManifest manifest,
+ string modId
+ )
+ {
+ if (manifest == null)
+ throw new ArgumentNullException(nameof(manifest));
+ if (string.IsNullOrWhiteSpace(modId))
+ throw new ArgumentException(
+ "The containing mod ID is required.",
+ nameof(modId)
+ );
+ if (string.IsNullOrWhiteSpace(manifest.PatchName))
+ throw new InvalidOperationException(
+ "Prefab patch manifests must define a local patchName."
+ );
+ if (manifest.PatchName.IndexOf(':') >= 0)
+ throw new InvalidOperationException(
+ $"Prefab patch name '{manifest.PatchName}' must be local to "
+ + "its containing mod and cannot contain ':'."
+ );
+
+ modId = modId.Trim();
+ if (!string.IsNullOrWhiteSpace(manifest.ModId))
+ {
+ if (!string.Equals(manifest.ModId, modId, StringComparison.Ordinal))
+ {
+ throw new InvalidOperationException(
+ $"Prefab patch '{manifest.PatchName}' is already bound to "
+ + $"'{manifest.ModId}', not '{modId}'."
+ );
+ }
+ }
+
+ var authoredHash = PrefabPatchJson.CalculateManifestHash(manifest);
+ if (
+ !string.IsNullOrWhiteSpace(manifest.ManifestHash)
+ && !string.Equals(
+ authoredHash,
+ manifest.ManifestHash,
+ StringComparison.OrdinalIgnoreCase
+ )
+ )
+ {
+ throw new InvalidOperationException(
+ $"Prefab patch '{manifest.PatchName}' manifest hash does not "
+ + "match its authored content."
+ );
+ }
+
+ manifest.ModId = modId;
+ manifest.PatchId = Qualify(modId, manifest.PatchName);
+ manifest.NeedsPatches = QualifyAll(modId, manifest.NeedsPatches);
+ manifest.ConflictsPatches = QualifyAll(
+ modId,
+ manifest.ConflictsPatches
+ );
+ manifest.BeforePatches = QualifyAll(modId, manifest.BeforePatches);
+ manifest.AfterPatches = QualifyAll(modId, manifest.AfterPatches);
+
+ foreach (var operation in manifest.Operations ?? new())
+ {
+ if (operation == null)
+ continue;
+ operation.PatchId = manifest.PatchId;
+ BindTarget(operation.Target, modId);
+ BindReference(operation.ObjectReference, modId);
+ BindFragment(operation.AddedObject, modId);
+ BindComponent(operation.AddedComponent, modId);
+ }
+
+ manifest.ManifestHash = PrefabPatchJson.CalculateManifestHash(manifest);
+ return manifest;
+ }
+
+ ///
+ /// Qualifies a local patch name with its owning mod ID.
+ ///
+ /// Owning mod ID.
+ /// Local name or already-qualified patch ID.
+ /// A qualified patch ID, or the original blank value.
+ public static string Qualify(string modId, string patchNameOrId)
+ {
+ if (string.IsNullOrWhiteSpace(patchNameOrId))
+ return patchNameOrId;
+ var value = patchNameOrId.Trim();
+ return value.IndexOf(':') >= 0 ? value : modId + ":" + value;
+ }
+
+ private static string[] QualifyAll(
+ string modId,
+ IEnumerable values
+ ) =>
+ (values ?? Array.Empty())
+ .Where(value => !string.IsNullOrWhiteSpace(value))
+ .Select(value => Qualify(modId, value))
+ .Distinct(StringComparer.Ordinal)
+ .OrderBy(value => value, StringComparer.Ordinal)
+ .ToArray();
+
+ private static void BindFragment(
+ PrefabPatchObjectFragment fragment,
+ string modId
+ )
+ {
+ if (fragment == null)
+ return;
+ foreach (var component in fragment.Components ?? new())
+ BindComponent(component, modId);
+ foreach (var child in fragment.Children ?? new())
+ BindFragment(child, modId);
+ }
+
+ private static void BindComponent(
+ PrefabPatchComponentFragment component,
+ string modId
+ )
+ {
+ if (component == null)
+ return;
+ foreach (var reference in component.References ?? new())
+ BindReference(reference?.Reference, modId);
+ }
+
+ private static void BindReference(
+ PrefabPatchObjectReference reference,
+ string modId
+ )
+ {
+ if (reference?.Kind == PrefabPatchObjectReferenceKind.Target)
+ BindTarget(reference.Target, modId);
+ }
+
+ private static void BindTarget(
+ PrefabPatchObjectTarget target,
+ string modId
+ )
+ {
+ if (
+ target == null
+ || target.Kind == PrefabPatchTargetKind.Stock
+ || string.IsNullOrWhiteSpace(target.OwnerPatchId)
+ )
+ {
+ return;
+ }
+ target.OwnerPatchId = Qualify(modId, target.OwnerPatchId);
+ }
+}
diff --git a/Runtime/PrefabPatching/PrefabPatchOwnership.cs.meta b/Runtime/PrefabPatching/PrefabPatchOwnership.cs.meta
new file mode 100644
index 0000000..0f068bf
--- /dev/null
+++ b/Runtime/PrefabPatching/PrefabPatchOwnership.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: a3047e1f9c26b604d867329b03c2f316
\ No newline at end of file
diff --git a/Runtime/PrefabPatching/PrefabPatchPlanCache.cs b/Runtime/PrefabPatching/PrefabPatchPlanCache.cs
new file mode 100644
index 0000000..a32ed10
--- /dev/null
+++ b/Runtime/PrefabPatching/PrefabPatchPlanCache.cs
@@ -0,0 +1,278 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+using System.Linq;
+
+namespace PatchManager.PrefabPatching;
+
+///
+/// Atomic, recoverable, per-prefab resolved-plan cache. A cache hit avoids
+/// dependency ordering, target validation, and conflict analysis.
+///
+public sealed class PrefabPatchPlanCache
+{
+ ///
+ /// Describes the resolved plan and how it was obtained.
+ ///
+ public sealed class Result
+ {
+ /// The resolved prefab patch plan.
+ public PrefabPatchResolvedPlan Plan;
+ /// Whether the plan came from a valid cache entry.
+ public bool CacheHit;
+ /// Whether a backup cache entry was restored.
+ public bool RecoveredBackup;
+ /// Total cache lookup or resolve time in milliseconds.
+ public long ElapsedMilliseconds;
+ /// Cache file path, when a target produced one.
+ public string Path;
+ }
+
+ private readonly string _directory;
+
+ ///
+ /// Creates a cache rooted at the specified directory.
+ ///
+ /// Directory for per-prefab plan files.
+ public PrefabPatchPlanCache(string directory)
+ {
+ _directory = Path.GetFullPath(directory);
+ }
+
+ ///
+ /// Loads a compatible cached plan or resolves and atomically stores a new one.
+ ///
+ /// Manifests targeting one prefab.
+ /// IDs of mods active in the current session.
+ /// Unity version used by the running game.
+ /// Current runtime platform identifier.
+ /// The resolved plan and cache outcome.
+ public Result LoadOrResolve(
+ IEnumerable source,
+ ISet activeModIds,
+ string unityVersion,
+ string targetPlatform
+ )
+ {
+ var stopwatch = Stopwatch.StartNew();
+ var manifests = source
+ .Where(value => value != null)
+ .OrderBy(value => value.PatchId, StringComparer.Ordinal)
+ .ToList();
+ if (manifests.Count == 0)
+ {
+ var empty = PrefabPatchResolver.Resolve(
+ manifests,
+ activeModIds,
+ unityVersion,
+ targetPlatform
+ );
+ stopwatch.Stop();
+ return new Result
+ {
+ Plan = empty,
+ ElapsedMilliseconds = stopwatch.ElapsedMilliseconds
+ };
+ }
+
+ Directory.CreateDirectory(_directory);
+ var sourceFingerprint = CalculateSourceFingerprint(
+ manifests,
+ activeModIds,
+ unityVersion,
+ targetPlatform
+ );
+ var path = GetPath(manifests[0].TargetPrefab);
+ if (TryRead(path, sourceFingerprint, out var cached))
+ {
+ stopwatch.Stop();
+ return new Result
+ {
+ Plan = cached,
+ CacheHit = true,
+ ElapsedMilliseconds = stopwatch.ElapsedMilliseconds,
+ Path = path
+ };
+ }
+
+ var backupPath = path + ".bak";
+ if (TryRead(backupPath, sourceFingerprint, out cached))
+ {
+ AtomicWrite(path, PrefabPatchJson.Serialize(cached));
+ stopwatch.Stop();
+ return new Result
+ {
+ Plan = cached,
+ CacheHit = true,
+ RecoveredBackup = true,
+ ElapsedMilliseconds = stopwatch.ElapsedMilliseconds,
+ Path = path
+ };
+ }
+
+ var plan = PrefabPatchResolver.Resolve(
+ manifests,
+ activeModIds,
+ unityVersion,
+ targetPlatform
+ );
+ plan.SourceFingerprint = sourceFingerprint;
+ if (plan.IsValid)
+ AtomicWrite(path, PrefabPatchJson.Serialize(plan));
+ stopwatch.Stop();
+ return new Result
+ {
+ Plan = plan,
+ CacheHit = false,
+ ElapsedMilliseconds = stopwatch.ElapsedMilliseconds,
+ Path = path
+ };
+ }
+
+ ///
+ /// Gets the cache file path for a target prefab identity.
+ ///
+ /// Target prefab identity.
+ /// The absolute plan-cache path.
+ public string GetPath(PrefabPatchPrefabIdentity target)
+ {
+ var identity = target?.Address ?? target?.CanonicalKey ?? "invalid";
+ return Path.Combine(
+ _directory,
+ PrefabPatchJson.Sha256(identity) + ".plan.json"
+ );
+ }
+
+ ///
+ /// Calculates the fingerprint used to invalidate resolved plans.
+ ///
+ /// Manifests targeting one prefab.
+ /// IDs of active mods.
+ /// Current Unity version.
+ /// Current runtime platform.
+ /// A canonical source fingerprint.
+ public static string CalculateSourceFingerprint(
+ IEnumerable manifests,
+ ISet activeModIds,
+ string unityVersion,
+ string targetPlatform
+ )
+ {
+ var ordered = manifests
+ .Where(value => value != null)
+ .OrderBy(value => value.PatchId, StringComparer.Ordinal)
+ .ToList();
+ foreach (var manifest in ordered)
+ {
+ foreach (var operation in manifest.Operations)
+ operation.PatchId = manifest.PatchId;
+ manifest.ManifestHash = PrefabPatchJson.CalculateManifestHash(manifest);
+ }
+ var value = new
+ {
+ UnityVersion = unityVersion,
+ TargetPlatform = targetPlatform,
+ SchemaVersion = PrefabPatchSchema.Version,
+ ComposerVersion = PrefabPatchSchema.ComposerVersion,
+ Target = ordered.FirstOrDefault()?.TargetPrefab,
+ ActiveMods = activeModIds.OrderBy(
+ id => id,
+ StringComparer.Ordinal
+ ),
+ Manifests = ordered.Select(
+ manifest => new
+ {
+ manifest.PatchId,
+ manifest.ManifestHash,
+ manifest.ConfigurationInputs
+ }
+ )
+ };
+ return PrefabPatchJson.Sha256(PrefabPatchJson.Serialize(value));
+ }
+
+ private static bool TryRead(
+ string path,
+ string sourceFingerprint,
+ out PrefabPatchResolvedPlan plan
+ )
+ {
+ plan = null;
+ if (!File.Exists(path))
+ return false;
+ try
+ {
+ plan = PrefabPatchJson.Deserialize(
+ File.ReadAllText(path)
+ );
+ return plan != null
+ && plan.SchemaVersion == PrefabPatchSchema.Version
+ && plan.ComposerVersion == PrefabPatchSchema.ComposerVersion
+ && plan.IsValid
+ && string.Equals(
+ plan.SourceFingerprint,
+ sourceFingerprint,
+ StringComparison.Ordinal
+ );
+ }
+ catch
+ {
+ plan = null;
+ return false;
+ }
+ }
+
+ private static void AtomicWrite(string path, string contents)
+ {
+ var directory = Path.GetDirectoryName(path);
+ if (string.IsNullOrWhiteSpace(directory))
+ throw new InvalidOperationException(
+ $"Cache path '{path}' has no directory."
+ );
+ Directory.CreateDirectory(directory);
+ var tempPath = path + "." + Guid.NewGuid().ToString("N") + ".tmp";
+ var backupPath = path + ".bak";
+ try
+ {
+ var bytes = System.Text.Encoding.UTF8.GetBytes(contents);
+ using (
+ var stream = new FileStream(
+ tempPath,
+ FileMode.CreateNew,
+ FileAccess.Write,
+ FileShare.None,
+ 4096,
+ FileOptions.WriteThrough
+ )
+ )
+ {
+ stream.Write(bytes, 0, bytes.Length);
+ stream.Flush(true);
+ }
+
+ if (File.Exists(path))
+ {
+ try
+ {
+ File.Replace(tempPath, path, backupPath, true);
+ }
+ catch (PlatformNotSupportedException)
+ {
+ File.Copy(path, backupPath, true);
+ File.Delete(path);
+ File.Move(tempPath, path);
+ }
+ }
+ else
+ {
+ File.Move(tempPath, path);
+ }
+ }
+ finally
+ {
+ if (File.Exists(tempPath))
+ File.Delete(tempPath);
+ }
+ }
+}
diff --git a/Runtime/PrefabPatching/PrefabPatchPlanCache.cs.meta b/Runtime/PrefabPatching/PrefabPatchPlanCache.cs.meta
new file mode 100644
index 0000000..c2d9448
--- /dev/null
+++ b/Runtime/PrefabPatching/PrefabPatchPlanCache.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 91789b61dfb65b240a5be7121003c473
\ No newline at end of file
diff --git a/Runtime/PrefabPatching/PrefabPatchResolver.cs b/Runtime/PrefabPatching/PrefabPatchResolver.cs
new file mode 100644
index 0000000..ba047f0
--- /dev/null
+++ b/Runtime/PrefabPatching/PrefabPatchResolver.cs
@@ -0,0 +1,1106 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace PatchManager.PrefabPatching;
+
+///
+/// Deterministic dependency, ordering, target, and conflict resolver for the
+/// prefab asset domain. It does not execute Unity mutations.
+///
+///
+/// Resolution validates ownership and target compatibility, removes patches
+/// whose mod or patch constraints are not satisfied, topologically sorts each
+/// pass and ordering bucket, validates patch-owned target lifetimes, and
+/// flattens operations into one cacheable plan. All tie-breaking uses ordinal
+/// patch IDs so identical inputs produce identical output.
+///
+public static class PrefabPatchResolver
+{
+ /// Resolves manifests for one stock prefab into an executable plan.
+ /// The owned manifests targeting the prefab.
+ /// The active SpaceWarp mod IDs.
+ /// The Unity version included in the cache input.
+ /// The runtime platform included in the cache input.
+ /// A plan containing ordered operations and all diagnostics.
+ public static PrefabPatchResolvedPlan Resolve(
+ IEnumerable source,
+ ISet activeModIds,
+ string unityVersion,
+ string targetPlatform
+ )
+ {
+ var manifests = source
+ .Where(manifest => manifest != null)
+ .OrderBy(manifest => manifest.PatchId, StringComparer.Ordinal)
+ .ToList();
+ var plan = new PrefabPatchResolvedPlan
+ {
+ ResolvedUtcTicks = DateTime.UtcNow.Ticks
+ };
+ if (manifests.Count == 0)
+ {
+ Add(
+ plan,
+ PrefabPatchDiagnosticSeverity.Error,
+ "PM-PREFAB-NO-PATCHES",
+ null,
+ null,
+ "No prefab patch manifests were supplied."
+ );
+ return plan;
+ }
+
+ plan.TargetPrefab = manifests
+ .Select(manifest => manifest.TargetPrefab)
+ .Where(target => target != null)
+ .OrderByDescending(
+ target =>
+ !string.IsNullOrWhiteSpace(
+ target.StructuralFingerprint
+ )
+ )
+ .FirstOrDefault();
+ var address = plan.TargetPrefab?.Address;
+ var fatal = false;
+ var byId = new Dictionary(
+ StringComparer.Ordinal
+ );
+ foreach (var manifest in manifests)
+ {
+ if (!ValidateManifest(manifest, plan))
+ {
+ fatal = true;
+ continue;
+ }
+
+ if (!TargetsMatch(manifest.TargetPrefab, plan.TargetPrefab))
+ {
+ Add(
+ plan,
+ PrefabPatchDiagnosticSeverity.Error,
+ "PM-PREFAB-MIXED-TARGET",
+ manifest.PatchId,
+ null,
+ string.Equals(
+ manifest.TargetPrefab.Address,
+ plan.TargetPrefab.Address,
+ StringComparison.Ordinal
+ )
+ ? $"Patch '{manifest.PatchId}' was compiled against "
+ + "a different structural version of "
+ + $"'{plan.TargetPrefab.Address}'."
+ : $"Patch '{manifest.PatchId}' targets "
+ + $"'{manifest.TargetPrefab.Address}', not "
+ + $"'{plan.TargetPrefab.Address}'."
+ );
+ fatal = true;
+ continue;
+ }
+
+ if (!byId.TryAdd(manifest.PatchId, manifest))
+ {
+ Add(
+ plan,
+ PrefabPatchDiagnosticSeverity.Error,
+ "PM-PREFAB-DUPLICATE-PATCH-ID",
+ manifest.PatchId,
+ null,
+ $"Patch ID '{manifest.PatchId}' is registered more than once."
+ );
+ fatal = true;
+ }
+ }
+
+ var enabled = new HashSet(byId.Keys, StringComparer.Ordinal);
+ FilterModConstraints(byId, enabled, activeModIds, plan);
+ FilterPatchConstraints(byId, enabled, plan);
+
+ var ordered = Order(byId, enabled, plan, ref fatal);
+ plan.OrderedPatchIds = ordered
+ .Select(manifest => manifest.PatchId)
+ .ToArray();
+
+ ValidateAndFlattenOperations(ordered, plan, ref fatal);
+ plan.InputHash = BuildInputHash(
+ ordered,
+ plan.TargetPrefab,
+ plan.Diagnostics,
+ unityVersion,
+ targetPlatform
+ );
+ plan.CacheKey = PrefabPatchJson.Sha256(
+ $"{address}|{plan.TargetPrefab?.CanonicalKey}|{plan.InputHash}"
+ );
+ plan.IsValid = !fatal;
+ return plan;
+ }
+
+ private static bool ValidateManifest(
+ PrefabPatchManifest manifest,
+ PrefabPatchResolvedPlan plan
+ )
+ {
+ if (
+ manifest.SchemaVersion != PrefabPatchSchema.Version
+ || manifest.ComposerVersion != PrefabPatchSchema.ComposerVersion
+ )
+ {
+ Add(
+ plan,
+ PrefabPatchDiagnosticSeverity.Error,
+ "PM-PREFAB-SCHEMA",
+ manifest.PatchId,
+ null,
+ $"Patch '{manifest.PatchId}' uses schema "
+ + $"{manifest.SchemaVersion}/composer "
+ + $"{manifest.ComposerVersion}; runtime requires "
+ + $"{PrefabPatchSchema.Version}/"
+ + $"{PrefabPatchSchema.ComposerVersion}."
+ );
+ return false;
+ }
+
+ if (
+ string.IsNullOrWhiteSpace(manifest.PatchId)
+ || manifest.PatchId.IndexOf(':') <= 0
+ || string.IsNullOrWhiteSpace(manifest.PatchName)
+ )
+ {
+ Add(
+ plan,
+ PrefabPatchDiagnosticSeverity.Error,
+ "PM-PREFAB-PATCH-ID",
+ manifest.PatchId,
+ null,
+ "Prefab patch ownership has not been bound from its "
+ + "containing mod."
+ );
+ return false;
+ }
+
+ if (
+ string.IsNullOrWhiteSpace(manifest.ModId)
+ || !manifest.PatchId.StartsWith(
+ manifest.ModId + ":",
+ StringComparison.Ordinal
+ )
+ )
+ {
+ Add(
+ plan,
+ PrefabPatchDiagnosticSeverity.Error,
+ "PM-PREFAB-MOD-NAMESPACE",
+ manifest.PatchId,
+ null,
+ $"Patch '{manifest.PatchId}' is not namespaced to owning mod "
+ + $"'{manifest.ModId}'."
+ );
+ return false;
+ }
+
+ if (
+ manifest.TargetPrefab == null
+ || string.IsNullOrWhiteSpace(manifest.TargetPrefab.Address)
+ )
+ {
+ Add(
+ plan,
+ PrefabPatchDiagnosticSeverity.Error,
+ "PM-PREFAB-TARGET-IDENTITY",
+ manifest.PatchId,
+ null,
+ $"Patch '{manifest.PatchId}' has no target Addressables key."
+ );
+ return false;
+ }
+
+ var calculatedHash = PrefabPatchJson.CalculateManifestHash(manifest);
+ if (
+ !string.IsNullOrWhiteSpace(manifest.ManifestHash)
+ && !string.Equals(
+ calculatedHash,
+ manifest.ManifestHash,
+ StringComparison.OrdinalIgnoreCase
+ )
+ )
+ {
+ Add(
+ plan,
+ PrefabPatchDiagnosticSeverity.Error,
+ "PM-PREFAB-MANIFEST-HASH",
+ manifest.PatchId,
+ null,
+ $"Patch '{manifest.PatchId}' manifest hash does not match its "
+ + "normalized content."
+ );
+ return false;
+ }
+
+ manifest.ManifestHash = calculatedHash;
+ return true;
+ }
+
+ private static bool TargetsMatch(
+ PrefabPatchPrefabIdentity left,
+ PrefabPatchPrefabIdentity right
+ )
+ {
+ if (
+ left == null
+ || right == null
+ || !string.Equals(
+ left.Address,
+ right.Address,
+ StringComparison.Ordinal
+ )
+ )
+ return false;
+ return string.IsNullOrWhiteSpace(left.StructuralFingerprint)
+ || string.IsNullOrWhiteSpace(right.StructuralFingerprint)
+ || string.Equals(
+ left.StructuralFingerprint,
+ right.StructuralFingerprint,
+ StringComparison.Ordinal
+ );
+ }
+
+ private static void FilterModConstraints(
+ IReadOnlyDictionary byId,
+ ISet enabled,
+ ISet activeModIds,
+ PrefabPatchResolvedPlan plan
+ )
+ {
+ foreach (var manifest in byId.Values.OrderBy(
+ value => value.PatchId,
+ StringComparer.Ordinal
+ ))
+ {
+ var missing = Safe(manifest.NeedsMods)
+ .Where(id => !activeModIds.Contains(id))
+ .OrderBy(id => id, StringComparer.Ordinal)
+ .ToArray();
+ if (missing.Length > 0)
+ {
+ enabled.Remove(manifest.PatchId);
+ Add(
+ plan,
+ PrefabPatchDiagnosticSeverity.Error,
+ "PM-PREFAB-MISSING-MOD",
+ manifest.PatchId,
+ null,
+ $"Disabled '{manifest.PatchId}': missing required mod(s) "
+ + string.Join(", ", missing) + "."
+ );
+ continue;
+ }
+
+ var conflicts = Safe(manifest.ConflictsMods)
+ .Where(activeModIds.Contains)
+ .OrderBy(id => id, StringComparer.Ordinal)
+ .ToArray();
+ if (conflicts.Length > 0)
+ {
+ enabled.Remove(manifest.PatchId);
+ Add(
+ plan,
+ PrefabPatchDiagnosticSeverity.Error,
+ "PM-PREFAB-CONFLICTING-MOD",
+ manifest.PatchId,
+ null,
+ $"Disabled '{manifest.PatchId}': conflicting mod(s) "
+ + string.Join(", ", conflicts) + " are active."
+ );
+ }
+ }
+ }
+
+ private static void FilterPatchConstraints(
+ IReadOnlyDictionary byId,
+ ISet enabled,
+ PrefabPatchResolvedPlan plan
+ )
+ {
+ bool changed;
+ do
+ {
+ changed = false;
+ foreach (var patchId in enabled.OrderBy(
+ id => id,
+ StringComparer.Ordinal
+ ).ToArray())
+ {
+ var manifest = byId[patchId];
+ var missing = Safe(manifest.NeedsPatches)
+ .Where(id => !enabled.Contains(id))
+ .OrderBy(id => id, StringComparer.Ordinal)
+ .ToArray();
+ if (missing.Length > 0)
+ {
+ enabled.Remove(patchId);
+ changed = true;
+ Add(
+ plan,
+ PrefabPatchDiagnosticSeverity.Error,
+ "PM-PREFAB-MISSING-PATCH",
+ patchId,
+ null,
+ $"Disabled '{patchId}': missing required patch(es) "
+ + string.Join(", ", missing) + "."
+ );
+ continue;
+ }
+
+ var conflicts = Safe(manifest.ConflictsPatches)
+ .Where(enabled.Contains)
+ .OrderBy(id => id, StringComparer.Ordinal)
+ .ToArray();
+ if (conflicts.Length > 0)
+ {
+ enabled.Remove(patchId);
+ changed = true;
+ Add(
+ plan,
+ PrefabPatchDiagnosticSeverity.Error,
+ "PM-PREFAB-CONFLICTING-PATCH",
+ patchId,
+ null,
+ $"Disabled '{patchId}': conflicting patch(es) "
+ + string.Join(", ", conflicts) + " are active."
+ );
+ }
+ }
+ } while (changed);
+ }
+
+ private static List Order(
+ IReadOnlyDictionary byId,
+ ISet enabled,
+ PrefabPatchResolvedPlan plan,
+ ref bool fatal
+ )
+ {
+ var result = new List();
+ foreach (PrefabPatchPass pass in Enum.GetValues(typeof(PrefabPatchPass)))
+ {
+ foreach (
+ PrefabPatchOrdering bucket in Enum.GetValues(
+ typeof(PrefabPatchOrdering)
+ )
+ )
+ {
+ result.AddRange(
+ OrderBucket(byId, enabled, pass, bucket, plan, ref fatal)
+ );
+ }
+ }
+
+ return result;
+ }
+
+ private static IEnumerable OrderBucket(
+ IReadOnlyDictionary byId,
+ ISet enabled,
+ PrefabPatchPass pass,
+ PrefabPatchOrdering bucket,
+ PrefabPatchResolvedPlan plan,
+ ref bool fatal
+ )
+ {
+ var members = enabled
+ .Select(id => byId[id])
+ .Where(manifest => manifest.Pass == pass && manifest.Ordering == bucket)
+ .ToDictionary(manifest => manifest.PatchId, StringComparer.Ordinal);
+ var incoming = CreateEdgeMap(members.Keys);
+ var outgoing = CreateEdgeMap(members.Keys);
+
+ foreach (var manifest in members.Values)
+ {
+ AddDependencyEdges(
+ manifest,
+ byId,
+ enabled,
+ members,
+ incoming,
+ outgoing,
+ plan,
+ ref fatal
+ );
+ AddOrderingEdges(manifest, members, incoming, outgoing, plan);
+ }
+
+ return TopologicalSort(members, incoming, outgoing, plan, ref fatal);
+ }
+
+ private static Dictionary> CreateEdgeMap(
+ IEnumerable patchIds
+ ) =>
+ patchIds.ToDictionary(
+ id => id,
+ _ => new HashSet(StringComparer.Ordinal),
+ StringComparer.Ordinal
+ );
+
+ private static void AddDependencyEdges(
+ PrefabPatchManifest manifest,
+ IReadOnlyDictionary byId,
+ ISet enabled,
+ IReadOnlyDictionary members,
+ IDictionary> incoming,
+ IDictionary> outgoing,
+ PrefabPatchResolvedPlan plan,
+ ref bool fatal
+ )
+ {
+ foreach (var dependency in Safe(manifest.NeedsPatches))
+ {
+ if (!enabled.Contains(dependency))
+ continue;
+ if (Rank(byId[dependency]) > Rank(manifest))
+ {
+ Add(
+ plan,
+ PrefabPatchDiagnosticSeverity.Error,
+ "PM-PREFAB-DEPENDENCY-ORDER",
+ manifest.PatchId,
+ null,
+ $"Patch '{manifest.PatchId}' requires later patch "
+ + $"'{dependency}'."
+ );
+ fatal = true;
+ }
+ else if (members.ContainsKey(dependency))
+ {
+ AddEdge(dependency, manifest.PatchId, incoming, outgoing);
+ }
+ }
+ }
+
+ private static void AddOrderingEdges(
+ PrefabPatchManifest manifest,
+ IReadOnlyDictionary members,
+ IDictionary> incoming,
+ IDictionary> outgoing,
+ PrefabPatchResolvedPlan plan
+ )
+ {
+ AddSameBucketEdges(
+ manifest,
+ manifest.AfterPatches,
+ true,
+ members,
+ incoming,
+ outgoing,
+ plan
+ );
+ AddSameBucketEdges(
+ manifest,
+ manifest.BeforePatches,
+ false,
+ members,
+ incoming,
+ outgoing,
+ plan
+ );
+ AddModEdges(
+ manifest,
+ manifest.AfterMods,
+ true,
+ members,
+ incoming,
+ outgoing
+ );
+ AddModEdges(
+ manifest,
+ manifest.BeforeMods,
+ false,
+ members,
+ incoming,
+ outgoing
+ );
+ }
+
+ private static IEnumerable TopologicalSort(
+ IReadOnlyDictionary members,
+ IDictionary> incoming,
+ IReadOnlyDictionary> outgoing,
+ PrefabPatchResolvedPlan plan,
+ ref bool fatal
+ )
+ {
+ var result = new List();
+ var ready = new SortedSet(
+ incoming.Where(pair => pair.Value.Count == 0).Select(pair => pair.Key),
+ StringComparer.Ordinal
+ );
+ var emitted = new HashSet(StringComparer.Ordinal);
+ while (ready.Count > 0)
+ {
+ var id = ready.Min;
+ ready.Remove(id);
+ emitted.Add(id);
+ result.Add(members[id]);
+ foreach (var next in outgoing[id].OrderBy(value => value, StringComparer.Ordinal))
+ {
+ incoming[next].Remove(id);
+ if (incoming[next].Count == 0)
+ ready.Add(next);
+ }
+ }
+
+ var cyclic = members.Keys
+ .Where(id => !emitted.Contains(id))
+ .OrderBy(id => id, StringComparer.Ordinal)
+ .ToArray();
+ if (cyclic.Length > 0)
+ {
+ Add(
+ plan,
+ PrefabPatchDiagnosticSeverity.Error,
+ "PM-PREFAB-ORDER-CYCLE",
+ null,
+ null,
+ "Ordering cycle among prefab patches: "
+ + string.Join(", ", cyclic) + "."
+ );
+ fatal = true;
+ }
+
+ return result;
+ }
+
+ private static void ValidateAndFlattenOperations(
+ IReadOnlyList ordered,
+ PrefabPatchResolvedPlan plan,
+ ref bool fatal
+ )
+ {
+ var state = new OperationValidationState(ordered, plan);
+ foreach (var manifest in ordered)
+ {
+ foreach (
+ var operation in manifest.Operations
+ .Where(value => value != null)
+ )
+ {
+ ValidateOperation(manifest, operation, state);
+ }
+ }
+
+ fatal |= state.Fatal;
+ }
+
+ private sealed class OperationValidationState
+ {
+ public readonly IReadOnlyDictionary PatchOrder;
+ public readonly Dictionary IntroducedObjects = new(
+ StringComparer.Ordinal
+ );
+ public readonly Dictionary IntroducedComponents = new(
+ StringComparer.Ordinal
+ );
+ public readonly Dictionary Writes = new(
+ StringComparer.Ordinal
+ );
+ public readonly PrefabPatchResolvedPlan Plan;
+ public bool Fatal;
+
+ public OperationValidationState(
+ IReadOnlyList ordered,
+ PrefabPatchResolvedPlan plan
+ )
+ {
+ PatchOrder = ordered
+ .Select((manifest, index) => (manifest.PatchId, index))
+ .ToDictionary(pair => pair.PatchId, pair => pair.index);
+ Plan = plan;
+ }
+ }
+
+ private static void ValidateOperation(
+ PrefabPatchManifest manifest,
+ PrefabPatchOperation operation,
+ OperationValidationState state
+ )
+ {
+ operation.PatchId = manifest.PatchId;
+ if (!ValidateOperationHeader(manifest, operation, state))
+ return;
+ if (!ValidatePatchOwnedTarget(manifest, operation, state))
+ return;
+ if (!RegisterIntroducedContent(manifest, operation, state))
+ return;
+
+ RecordWriteConflict(manifest, operation, state);
+ state.Plan.Operations.Add(operation);
+ }
+
+ private static bool ValidateOperationHeader(
+ PrefabPatchManifest manifest,
+ PrefabPatchOperation operation,
+ OperationValidationState state
+ )
+ {
+ if (string.IsNullOrWhiteSpace(operation.OperationId))
+ {
+ Add(
+ state.Plan,
+ PrefabPatchDiagnosticSeverity.Error,
+ "PM-PREFAB-OPERATION-ID",
+ manifest.PatchId,
+ null,
+ $"Patch '{manifest.PatchId}' contains an operation without an ID."
+ );
+ state.Fatal = true;
+ return false;
+ }
+
+ if (
+ operation.Kind != PrefabPatchOperationKind.AddObject
+ && operation.Target == null
+ )
+ {
+ Add(
+ state.Plan,
+ PrefabPatchDiagnosticSeverity.Error,
+ "PM-PREFAB-MISSING-TARGET",
+ manifest.PatchId,
+ operation.OperationId,
+ $"Operation '{operation.OperationId}' has no target."
+ );
+ state.Fatal = true;
+ return false;
+ }
+
+ return true;
+ }
+
+ private static bool ValidatePatchOwnedTarget(
+ PrefabPatchManifest manifest,
+ PrefabPatchOperation operation,
+ OperationValidationState state
+ )
+ {
+ var target = operation.Target;
+ if (
+ target?.Kind != PrefabPatchTargetKind.PatchOwned
+ && target?.Kind != PrefabPatchTargetKind.PatchComponent
+ )
+ {
+ return true;
+ }
+
+ var owner = target.OwnerPatchId;
+ var isComponent = target.Kind == PrefabPatchTargetKind.PatchComponent;
+ var ownedId = isComponent ? target.ComponentId : target.ObjectId;
+ var targetKey = $"{owner}:{ownedId}";
+ var ownerIsCurrentPatch = string.Equals(
+ owner,
+ manifest.PatchId,
+ StringComparison.Ordinal
+ );
+ var needs = new HashSet(
+ Safe(manifest.NeedsPatches),
+ StringComparer.Ordinal
+ );
+ if (!ownerIsCurrentPatch && !needs.Contains(owner))
+ {
+ Add(
+ state.Plan,
+ PrefabPatchDiagnosticSeverity.Error,
+ "PM-PREFAB-OWNER-NOT-REQUIRED",
+ manifest.PatchId,
+ operation.OperationId,
+ $"Operation '{operation.OperationId}' targets '{targetKey}' but "
+ + $"does not require owning patch '{owner}'."
+ );
+ state.Fatal = true;
+ return false;
+ }
+
+ var introduced = isComponent
+ ? state.IntroducedComponents.ContainsKey(targetKey)
+ : state.IntroducedObjects.ContainsKey(targetKey);
+ var ownerRunsEarlier = ownerIsCurrentPatch
+ || (
+ state.PatchOrder.TryGetValue(owner, out var ownerOrder)
+ && ownerOrder < state.PatchOrder[manifest.PatchId]
+ );
+ if (introduced && ownerRunsEarlier)
+ return true;
+
+ Add(
+ state.Plan,
+ PrefabPatchDiagnosticSeverity.Error,
+ "PM-PREFAB-PATCH-OWNED-TARGET",
+ manifest.PatchId,
+ operation.OperationId,
+ "Patch-owned "
+ + (isComponent ? "component" : "object")
+ + $" target '{targetKey}' is not introduced by an earlier "
+ + "required operation."
+ );
+ state.Fatal = true;
+ return false;
+ }
+
+ private static bool RegisterIntroducedContent(
+ PrefabPatchManifest manifest,
+ PrefabPatchOperation operation,
+ OperationValidationState state
+ )
+ {
+ if (operation.Kind == PrefabPatchOperationKind.AddObject)
+ {
+ var valid = RegisterFragment(
+ operation.AddedObject,
+ manifest.PatchId,
+ operation.OperationId,
+ state.IntroducedObjects,
+ state.IntroducedComponents,
+ state.Plan
+ );
+ state.Fatal |= !valid;
+ return true;
+ }
+
+ if (operation.Kind != PrefabPatchOperationKind.AddComponent)
+ return true;
+
+ var componentId = operation.AddedComponent?.ComponentId;
+ var componentKey = $"{manifest.PatchId}:{componentId}";
+ if (
+ string.IsNullOrWhiteSpace(componentId)
+ || string.IsNullOrWhiteSpace(operation.AddedComponent?.ComponentType)
+ )
+ {
+ Add(
+ state.Plan,
+ PrefabPatchDiagnosticSeverity.Error,
+ "PM-PREFAB-ADDED-COMPONENT-ID",
+ manifest.PatchId,
+ operation.OperationId,
+ $"Added component operation '{operation.OperationId}' needs a "
+ + "stable component ID and assembly-qualified type."
+ );
+ state.Fatal = true;
+ return false;
+ }
+ if (state.IntroducedComponents.TryAdd(componentKey, operation.OperationId))
+ return true;
+
+ Add(
+ state.Plan,
+ PrefabPatchDiagnosticSeverity.Error,
+ "PM-PREFAB-DUPLICATE-COMPONENT-ID",
+ manifest.PatchId,
+ operation.OperationId,
+ $"Patch-owned component '{componentKey}' is introduced more than once."
+ );
+ state.Fatal = true;
+ return false;
+ }
+
+ private static void RecordWriteConflict(
+ PrefabPatchManifest manifest,
+ PrefabPatchOperation operation,
+ OperationValidationState state
+ )
+ {
+ if (!IsWrite(operation.Kind))
+ return;
+
+ var conflictKey = operation.ConflictKey;
+ if (state.Writes.TryGetValue(conflictKey, out var previous))
+ {
+ var identical = string.Equals(
+ OperationPayload(previous),
+ OperationPayload(operation),
+ StringComparison.Ordinal
+ );
+ Add(
+ state.Plan,
+ identical
+ ? PrefabPatchDiagnosticSeverity.Info
+ : PrefabPatchDiagnosticSeverity.Warning,
+ identical ? "PM-PREFAB-IDENTICAL-WRITE" : "PM-PREFAB-SOFT-CONFLICT",
+ manifest.PatchId,
+ operation.OperationId,
+ identical
+ ? $"'{manifest.PatchId}' repeats the identical write from "
+ + $"'{previous.PatchId}' to '{conflictKey}'."
+ : $"Different writes target '{conflictKey}'. Deterministic "
+ + $"order selects '{manifest.PatchId}' over "
+ + $"'{previous.PatchId}'."
+ );
+ }
+
+ state.Writes[conflictKey] = operation;
+ }
+
+ private static string BuildInputHash(
+ IReadOnlyList ordered,
+ PrefabPatchPrefabIdentity target,
+ IReadOnlyList diagnostics,
+ string unityVersion,
+ string targetPlatform
+ )
+ {
+ var value = new
+ {
+ UnityVersion = unityVersion,
+ TargetPlatform = targetPlatform,
+ SchemaVersion = PrefabPatchSchema.Version,
+ ComposerVersion = PrefabPatchSchema.ComposerVersion,
+ Target = target,
+ OrderedPatches = ordered.Select(
+ manifest => new
+ {
+ manifest.PatchId,
+ manifest.ManifestHash,
+ manifest.ConfigurationInputs
+ }
+ ),
+ Resolution = diagnostics.Select(
+ diagnostic => new
+ {
+ diagnostic.Severity,
+ diagnostic.Code,
+ diagnostic.PatchId,
+ diagnostic.OperationId,
+ diagnostic.Message
+ }
+ )
+ };
+ return PrefabPatchJson.Sha256(PrefabPatchJson.Serialize(value));
+ }
+
+ private static bool RegisterFragment(
+ PrefabPatchObjectFragment fragment,
+ string patchId,
+ string operationId,
+ IDictionary objects,
+ IDictionary components,
+ PrefabPatchResolvedPlan plan
+ )
+ {
+ if (fragment == null || string.IsNullOrWhiteSpace(fragment.ObjectId))
+ {
+ Add(
+ plan,
+ PrefabPatchDiagnosticSeverity.Error,
+ "PM-PREFAB-ADDED-OBJECT-ID",
+ patchId,
+ operationId,
+ $"Added object operation '{operationId}' contains an object "
+ + "without a stable patch-local ID."
+ );
+ return false;
+ }
+
+ var valid = true;
+ var objectKey = $"{patchId}:{fragment.ObjectId}";
+ if (!objects.TryAdd(objectKey, operationId))
+ {
+ Add(
+ plan,
+ PrefabPatchDiagnosticSeverity.Error,
+ "PM-PREFAB-DUPLICATE-OBJECT-ID",
+ patchId,
+ operationId,
+ $"Patch-owned object '{objectKey}' is introduced more than "
+ + "once."
+ );
+ valid = false;
+ }
+
+ foreach (
+ var component in fragment.Components
+ ?? new List()
+ )
+ {
+ var componentId = component?.ComponentId;
+ var componentKey = $"{patchId}:{componentId}";
+ if (
+ component == null
+ || string.IsNullOrWhiteSpace(componentId)
+ || string.IsNullOrWhiteSpace(component.ComponentType)
+ )
+ {
+ Add(
+ plan,
+ PrefabPatchDiagnosticSeverity.Error,
+ "PM-PREFAB-ADDED-COMPONENT-ID",
+ patchId,
+ operationId,
+ $"Patch-owned object '{objectKey}' contains a component "
+ + "without a stable ID or assembly-qualified type."
+ );
+ valid = false;
+ continue;
+ }
+ if (!components.TryAdd(componentKey, operationId))
+ {
+ Add(
+ plan,
+ PrefabPatchDiagnosticSeverity.Error,
+ "PM-PREFAB-DUPLICATE-COMPONENT-ID",
+ patchId,
+ operationId,
+ $"Patch-owned component '{componentKey}' is introduced "
+ + "more than once."
+ );
+ valid = false;
+ }
+ }
+
+ foreach (
+ var child in fragment.Children
+ ?? new List()
+ )
+ {
+ if (
+ !RegisterFragment(
+ child,
+ patchId,
+ operationId,
+ objects,
+ components,
+ plan
+ )
+ )
+ valid = false;
+ }
+
+ return valid;
+ }
+
+ private static void AddSameBucketEdges(
+ PrefabPatchManifest manifest,
+ IEnumerable ids,
+ bool after,
+ IReadOnlyDictionary members,
+ IDictionary> incoming,
+ IDictionary> outgoing,
+ PrefabPatchResolvedPlan plan
+ )
+ {
+ foreach (var id in Safe(ids))
+ {
+ if (!members.ContainsKey(id))
+ {
+ Add(
+ plan,
+ PrefabPatchDiagnosticSeverity.Info,
+ "PM-PREFAB-CROSS-BUCKET-ORDER-IGNORED",
+ manifest.PatchId,
+ null,
+ $"Ordering relation between '{manifest.PatchId}' and "
+ + $"'{id}' is absent or crosses a pass/bucket and was "
+ + "ignored."
+ );
+ continue;
+ }
+
+ AddEdge(
+ after ? id : manifest.PatchId,
+ after ? manifest.PatchId : id,
+ incoming,
+ outgoing
+ );
+ }
+ }
+
+ private static void AddModEdges(
+ PrefabPatchManifest manifest,
+ IEnumerable modIds,
+ bool after,
+ IReadOnlyDictionary members,
+ IDictionary> incoming,
+ IDictionary> outgoing
+ )
+ {
+ foreach (var modId in Safe(modIds))
+ {
+ foreach (
+ var other in members.Values.Where(
+ value =>
+ string.Equals(
+ value.ModId,
+ modId,
+ StringComparison.Ordinal
+ )
+ && value.PatchId != manifest.PatchId
+ )
+ )
+ {
+ AddEdge(
+ after ? other.PatchId : manifest.PatchId,
+ after ? manifest.PatchId : other.PatchId,
+ incoming,
+ outgoing
+ );
+ }
+ }
+ }
+
+ private static void AddEdge(
+ string before,
+ string after,
+ IDictionary> incoming,
+ IDictionary> outgoing
+ )
+ {
+ if (before == after)
+ return;
+ if (outgoing[before].Add(after))
+ incoming[after].Add(before);
+ }
+
+ private static int Rank(PrefabPatchManifest manifest) =>
+ ((int)manifest.Pass * 3) + (int)manifest.Ordering;
+
+ private static bool IsWrite(PrefabPatchOperationKind kind) =>
+ kind == PrefabPatchOperationKind.SetValue
+ || kind == PrefabPatchOperationKind.SetObjectReference
+ || kind == PrefabPatchOperationKind.SetActive
+ || kind == PrefabPatchOperationKind.SuppressObject
+ || kind == PrefabPatchOperationKind.RemoveComponent;
+
+ private static string OperationPayload(PrefabPatchOperation operation) =>
+ PrefabPatchJson.Serialize(
+ new
+ {
+ operation.Kind,
+ operation.PropertyPath,
+ operation.Value,
+ operation.ObjectReference
+ }
+ );
+
+ private static IEnumerable Safe(IEnumerable values) =>
+ values ?? Array.Empty();
+
+ private static void Add(
+ PrefabPatchResolvedPlan plan,
+ PrefabPatchDiagnosticSeverity severity,
+ string code,
+ string patchId,
+ string operationId,
+ string message
+ )
+ {
+ plan.Diagnostics.Add(
+ new PrefabPatchDiagnostic
+ {
+ Severity = severity,
+ Code = code,
+ TargetAddress = plan.TargetPrefab?.Address,
+ PatchId = patchId,
+ OperationId = operationId,
+ Message = message
+ }
+ );
+ }
+}
diff --git a/Runtime/PrefabPatching/PrefabPatchResolver.cs.meta b/Runtime/PrefabPatching/PrefabPatchResolver.cs.meta
new file mode 100644
index 0000000..e1eddc1
--- /dev/null
+++ b/Runtime/PrefabPatching/PrefabPatchResolver.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 5e78a05a731189c4d8d14426c36b04e1
\ No newline at end of file
diff --git a/Runtime/PrefabPatching/PrefabPatchRuntime.cs b/Runtime/PrefabPatching/PrefabPatchRuntime.cs
new file mode 100644
index 0000000..d64bdcd
--- /dev/null
+++ b/Runtime/PrefabPatching/PrefabPatchRuntime.cs
@@ -0,0 +1,1075 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+using System.Linq;
+using UnityEngine;
+using UnityEngine.AddressableAssets;
+using UnityEngine.ResourceManagement.AsyncOperations;
+using UnityEngine.ResourceManagement.ResourceLocations;
+using UnityEngine.ResourceManagement.ResourceProviders;
+using UnityEngine.Profiling;
+using Object = UnityEngine.Object;
+
+namespace PatchManager.PrefabPatching;
+
+///
+/// Coordinates the ordinary and prefab-patch sections of Patch Manager's
+/// single human-readable summary log.
+///
+public static class PatchManagerSummaryLog
+{
+ private const string SummaryPath = "./pm_summary.log";
+ private static readonly object Gate = new();
+ private static string _coreSummary;
+ private static string _prefabSummary;
+
+ /// Replaces the ordinary JSON-patch section of the summary.
+ public static void UpdateCoreSummary(string summary)
+ {
+ lock (Gate)
+ {
+ _coreSummary = Normalize(summary);
+ Write();
+ }
+ }
+
+ /// Replaces the prefab-patch section of the summary.
+ public static void UpdatePrefabSummary(string summary)
+ {
+ lock (Gate)
+ {
+ _prefabSummary = Normalize(summary);
+ Write();
+ }
+ }
+
+ /// Clears both in-memory summary sections for a new play session.
+ public static void Reset()
+ {
+ lock (Gate)
+ {
+ _coreSummary = null;
+ _prefabSummary = null;
+ }
+ }
+
+ private static string Normalize(string summary)
+ {
+ return string.IsNullOrWhiteSpace(summary)
+ ? null
+ : summary.TrimEnd();
+ }
+
+ private static void Write()
+ {
+ var sections = new[] { _coreSummary, _prefabSummary }
+ .Where(value => !string.IsNullOrEmpty(value));
+ File.WriteAllText(
+ SummaryPath,
+ string.Join(Environment.NewLine + Environment.NewLine, sections)
+ + Environment.NewLine
+ );
+ }
+}
+
+///
+/// Public registry and lazy effective-prefab runtime for the prefab domain.
+///
+///
+/// C# manifests are accepted until . During the
+/// loading flow, Addressables manifests are discovered by mod-owned labels,
+/// grouped by stock prefab, resolved through the plan cache, and exposed through
+/// a resource locator. Effective prefabs are composed on first request and kept
+/// alive with their Addressables handles until the play session ends.
+///
+public static class PrefabPatchRuntime
+{
+ /// Measurements and counts for the current discovery/composition session.
+ public sealed class Metrics
+ {
+ /// Total registered and Addressables-discovered manifests.
+ public int DiscoveredManifestCount;
+ /// Manifests registered directly through the C# API.
+ public int RegisteredManifestCount;
+ /// Manifests loaded from Addressables labels.
+ public int AddressableManifestCount;
+ /// Normalized owner-and-label descriptions used for discovery.
+ public string[] ManifestSources = Array.Empty();
+ /// Valid resolved plans exposed by the resource locator.
+ public int ResolvedPlanCount;
+ /// Plans loaded from the on-disk cache.
+ public int CacheHitCount;
+ /// Plans resolved because no valid cache entry existed.
+ public int CacheMissCount;
+ /// Total manifest discovery and resolution time.
+ public long StartupMilliseconds;
+ /// Total time spent on first-time prefab composition.
+ public long FirstCompositionMilliseconds;
+ /// Total time spent serving already-composed prefabs.
+ public long RepeatedRequestMilliseconds;
+ /// Requests served from an already-composed prefab.
+ public int RepeatedRequestCount;
+ /// Unity's allocated-memory measurement before discovery.
+ public long MemoryBeforeBytes;
+ /// Unity's allocated-memory measurement after the latest operation.
+ public long MemoryAfterBytes;
+ /// Addressables handles retained for effective-prefab lifetime.
+ public int RetainedAddressablesHandles;
+ }
+
+ internal sealed class Entry
+ {
+ public PrefabPatchResolvedPlan Plan;
+ public GameObject EffectivePrefab;
+ public AsyncOperationHandle StockHandle;
+ public List> ReferenceHandles = new();
+ public Dictionary References = new(
+ StringComparer.Ordinal
+ );
+ public bool Failed;
+ public Exception Failure;
+ public int RequestCount;
+ }
+
+ private sealed class ManifestLocation
+ {
+ public IResourceLocation Location;
+ public string OwnerModId;
+ public string LocatorId;
+ public string Label;
+ }
+
+ private const string PlanCacheDirectory = "./pm_cache/prefabs";
+ private static readonly List Registered = new();
+ private static readonly Dictionary Entries = new(
+ StringComparer.Ordinal
+ );
+ private static PrefabPatchResourceLocator _locator;
+ private static GameObject _effectivePrefabRoot;
+
+ /// Gets whether direct C# manifest registration is still accepted.
+ public static bool RegistrationOpen { get; private set; } = true;
+ /// Gets measurements for the current play session.
+ public static Metrics CurrentMetrics { get; private set; } = new();
+ /// Gets valid resolved plans keyed by stock prefab address.
+ public static IReadOnlyDictionary Plans =>
+ Entries.ToDictionary(pair => pair.Key, pair => pair.Value.Plan);
+
+ ///
+ /// Registers a fluent or generated C# manifest before registration closes.
+ /// Visual manifests are normally discovered through the public label.
+ ///
+ /// The owned manifest to register.
+ public static void Register(PrefabPatchManifest manifest)
+ {
+ if (!RegistrationOpen)
+ throw new InvalidOperationException(
+ "Prefab patch registration is closed for this run."
+ );
+ if (manifest == null)
+ throw new ArgumentNullException(nameof(manifest));
+ Registered.Add(manifest);
+ }
+
+ /// Closes direct C# registration before dependency resolution starts.
+ public static void CloseRegistration()
+ {
+ RegistrationOpen = false;
+ }
+
+ ///
+ /// Discovers independently built TextAsset manifests, resolves per-prefab
+ /// plans through the atomic cache, and writes a deterministic summary.
+ ///
+ /// The active SpaceWarp mod IDs.
+ /// Called after successful resolution.
+ /// Called with a diagnostic when discovery fails.
+ public static void DiscoverAndResolve(
+ ISet activeModIds,
+ Action resolve,
+ Action reject
+ ) =>
+ DiscoverAndResolve(
+ activeModIds,
+ Array.Empty(),
+ resolve,
+ reject
+ );
+
+ /// Discovers and resolves manifests from explicit mod-owned labels.
+ /// The active SpaceWarp mod IDs.
+ /// Mod ownership and Addressables label pairs.
+ /// Called after successful resolution.
+ /// Called with a diagnostic when discovery fails.
+ public static void DiscoverAndResolve(
+ ISet activeModIds,
+ IReadOnlyCollection manifestSources,
+ Action resolve,
+ Action reject
+ )
+ {
+ var stopwatch = Stopwatch.StartNew();
+ CurrentMetrics = new Metrics
+ {
+ MemoryBeforeBytes = Profiler.GetTotalAllocatedMemoryLong()
+ };
+ try
+ {
+ var manifests = new List(Registered);
+ CurrentMetrics.RegisteredManifestCount = Registered.Count;
+ CurrentMetrics.ManifestSources = DescribeManifestSources(manifestSources);
+ var locations = FindManifestLocations(manifestSources);
+ CurrentMetrics.AddressableManifestCount = locations.Count;
+ manifests.AddRange(LoadAddressableManifests(locations));
+
+ ResolveDiscoveredManifests(
+ manifests,
+ activeModIds,
+ stopwatch,
+ resolve
+ );
+ }
+ catch (Exception exception)
+ {
+ RejectDiscovery(stopwatch, reject, exception);
+ }
+ }
+
+ private static string[] DescribeManifestSources(
+ IEnumerable manifestSources
+ ) =>
+ (manifestSources ?? Array.Empty())
+ .Where(source =>
+ source != null
+ && !string.IsNullOrWhiteSpace(source.OwnerModId)
+ && !string.IsNullOrWhiteSpace(source.AddressablesLabel)
+ )
+ .Select(source =>
+ source.OwnerModId.Trim() + ": " + source.AddressablesLabel.Trim()
+ )
+ .Distinct(StringComparer.Ordinal)
+ .OrderBy(value => value, StringComparer.Ordinal)
+ .ToArray();
+
+ private static IEnumerable LoadAddressableManifests(
+ IEnumerable locations
+ )
+ {
+ foreach (var source in locations)
+ yield return LoadAddressableManifest(source);
+ }
+
+ private static PrefabPatchManifest LoadAddressableManifest(
+ ManifestLocation source
+ )
+ {
+ AsyncOperationHandle manifestHandle = default;
+ try
+ {
+ manifestHandle = Addressables.LoadAssetAsync(source.Location);
+ var asset = manifestHandle.WaitForCompletion();
+ if (
+ manifestHandle.Status != AsyncOperationStatus.Succeeded
+ || asset == null
+ )
+ {
+ throw manifestHandle.OperationException
+ ?? new InvalidOperationException(
+ "Prefab patch manifest load failed."
+ );
+ }
+
+ var manifest = PrefabPatchJson.Deserialize(
+ asset.text
+ );
+ return PrefabPatchOwnership.Bind(manifest, source.OwnerModId);
+ }
+ catch (Exception exception)
+ {
+ throw new InvalidDataException(
+ $"Could not load prefab patch manifest "
+ + $"'{source.Location.PrimaryKey}' from label "
+ + $"'{source.Label}' owned by '{source.OwnerModId}' in "
+ + $"catalog '{source.LocatorId}'.",
+ exception
+ );
+ }
+ finally
+ {
+ if (manifestHandle.IsValid())
+ Addressables.Release(manifestHandle);
+ }
+ }
+
+ private static void ResolveDiscoveredManifests(
+ IReadOnlyCollection manifests,
+ ISet activeModIds,
+ Stopwatch stopwatch,
+ Action resolve
+ )
+ {
+ CurrentMetrics.DiscoveredManifestCount = manifests.Count;
+ var cache = new PrefabPatchPlanCache(PlanCacheDirectory);
+ Entries.Clear();
+ foreach (
+ var group in manifests
+ .Where(value => value?.TargetPrefab != null)
+ .GroupBy(
+ value => value.TargetPrefab.Address,
+ StringComparer.Ordinal
+ )
+ .OrderBy(group => group.Key, StringComparer.Ordinal)
+ )
+ {
+ var result = cache.LoadOrResolve(
+ group,
+ activeModIds,
+ Application.unityVersion,
+ Application.platform.ToString()
+ );
+ if (result.CacheHit)
+ CurrentMetrics.CacheHitCount++;
+ else
+ CurrentMetrics.CacheMissCount++;
+ if (
+ result.Plan?.TargetPrefab != null
+ && result.Plan.IsValid
+ )
+ {
+ Entries[result.Plan.TargetPrefab.Address] = new Entry
+ {
+ Plan = result.Plan
+ };
+ }
+ }
+
+ CurrentMetrics.ResolvedPlanCount = Entries.Count;
+ WriteSummary();
+ stopwatch.Stop();
+ CurrentMetrics.StartupMilliseconds = stopwatch.ElapsedMilliseconds;
+ CurrentMetrics.MemoryAfterBytes =
+ Profiler.GetTotalAllocatedMemoryLong();
+ resolve();
+ }
+
+ private static void RejectDiscovery(
+ Stopwatch stopwatch,
+ Action reject,
+ Exception exception
+ )
+ {
+ stopwatch.Stop();
+ CurrentMetrics.StartupMilliseconds = stopwatch.ElapsedMilliseconds;
+ UnityEngine.Debug.LogException(exception);
+ reject(
+ "Patch Manager prefab discovery failed: "
+ + exception.Message
+ );
+ }
+
+ private static List FindManifestLocations(
+ IReadOnlyCollection manifestSources
+ )
+ {
+ var sources = (manifestSources
+ ?? Array.Empty())
+ .Where(source =>
+ source != null
+ && !string.IsNullOrWhiteSpace(source.OwnerModId)
+ && !string.IsNullOrWhiteSpace(source.AddressablesLabel)
+ )
+ .Select(source => new PrefabPatchManifestSource
+ {
+ OwnerModId = source.OwnerModId.Trim(),
+ AddressablesLabel = source.AddressablesLabel.Trim()
+ })
+ .Distinct(PrefabPatchManifestSourceComparer.Instance)
+ .OrderBy(source => source.OwnerModId, StringComparer.Ordinal)
+ .ThenBy(
+ source => source.AddressablesLabel,
+ StringComparer.Ordinal
+ )
+ .ToArray();
+
+ foreach (
+ var duplicate in sources
+ .GroupBy(
+ source => source.AddressablesLabel,
+ StringComparer.Ordinal
+ )
+ .Where(group =>
+ group.Select(source => source.OwnerModId)
+ .Distinct(StringComparer.Ordinal)
+ .Skip(1)
+ .Any()
+ )
+ )
+ {
+ throw new InvalidDataException(
+ $"Prefab patch Addressables label '{duplicate.Key}' is "
+ + "declared by multiple active mods: "
+ + string.Join(
+ ", ",
+ duplicate.Select(source => source.OwnerModId)
+ .Distinct(StringComparer.Ordinal)
+ )
+ );
+ }
+
+ return sources
+ .SelectMany(source =>
+ Addressables.ResourceLocators.SelectMany(locator =>
+ {
+ if (
+ locator == null
+ || !locator.Locate(
+ source.AddressablesLabel,
+ typeof(TextAsset),
+ out var locations
+ )
+ )
+ {
+ return Array.Empty();
+ }
+
+ return locations
+ .Where(location => location != null)
+ .Select(location => new ManifestLocation
+ {
+ Location = location,
+ OwnerModId = source.OwnerModId,
+ LocatorId = locator.LocatorId,
+ Label = source.AddressablesLabel
+ });
+ })
+ )
+ .GroupBy(
+ source =>
+ $"{source.Location.ProviderId}\0"
+ + $"{source.Location.InternalId}\0"
+ + $"{source.Location.PrimaryKey}\0"
+ + $"{source.Location.ResourceType?.AssemblyQualifiedName}",
+ StringComparer.Ordinal
+ )
+ .Select(group =>
+ {
+ var owners = group
+ .Select(value => value.OwnerModId)
+ .Distinct(StringComparer.Ordinal)
+ .ToArray();
+ if (owners.Length != 1)
+ {
+ throw new InvalidDataException(
+ $"Prefab patch location '{group.Key}' is exposed by "
+ + "multiple owning mods: "
+ + string.Join(", ", owners)
+ );
+ }
+ return group.First();
+ })
+ .OrderBy(
+ source => source.Location.PrimaryKey,
+ StringComparer.Ordinal
+ )
+ .ThenBy(source => source.OwnerModId, StringComparer.Ordinal)
+ .ThenBy(
+ source => source.Location.InternalId,
+ StringComparer.Ordinal
+ )
+ .ToList();
+ }
+
+ private sealed class PrefabPatchManifestSourceComparer :
+ IEqualityComparer
+ {
+ public static readonly PrefabPatchManifestSourceComparer Instance =
+ new();
+
+ public bool Equals(
+ PrefabPatchManifestSource left,
+ PrefabPatchManifestSource right
+ ) =>
+ ReferenceEquals(left, right)
+ || (
+ left != null
+ && right != null
+ && string.Equals(
+ left.OwnerModId,
+ right.OwnerModId,
+ StringComparison.Ordinal
+ )
+ && string.Equals(
+ left.AddressablesLabel,
+ right.AddressablesLabel,
+ StringComparison.Ordinal
+ )
+ );
+
+ public int GetHashCode(PrefabPatchManifestSource source)
+ {
+ if (source == null)
+ return 0;
+ unchecked
+ {
+ return (
+ StringComparer.Ordinal.GetHashCode(
+ source.OwnerModId ?? string.Empty
+ )
+ * 397
+ )
+ ^ StringComparer.Ordinal.GetHashCode(
+ source.AddressablesLabel ?? string.Empty
+ );
+ }
+ }
+ }
+
+ ///
+ /// Adds the public GameObject provider and locator to Patch Manager's
+ /// supported KSP AssetProvider interception boundary.
+ ///
+ /// The resource locator that substitutes patched prefab locations.
+ public static UnityEngine.AddressableAssets.ResourceLocators.IResourceLocator
+ RegisterResourceProvider()
+ {
+ var providers = Addressables.ResourceManager.ResourceProviders;
+ if (
+ !providers.Any(
+ provider => provider is PrefabPatchResourceProvider
+ )
+ )
+ {
+ providers.Add(new PrefabPatchResourceProvider());
+ }
+
+ _locator = new PrefabPatchResourceLocator(Entries);
+ return _locator;
+ }
+
+ internal static bool TryProvide(
+ string address,
+ out GameObject prefab,
+ out Exception failure
+ )
+ {
+ prefab = null;
+ failure = null;
+ if (!Entries.TryGetValue(address, out var entry))
+ {
+ failure = new KeyNotFoundException(
+ $"No resolved prefab patch plan exists for '{address}'."
+ );
+ return false;
+ }
+
+ var stopwatch = Stopwatch.StartNew();
+ entry.RequestCount++;
+ if (TryUseCompletedEntry(entry, stopwatch, out prefab, out failure))
+ return failure == null;
+
+ try
+ {
+ var stock = LoadStockPrefab(address, entry);
+ LoadAddressableReferences(entry);
+ var effectivePrefab = ComposeEffectivePrefab(stock, entry);
+ entry.EffectivePrefab = effectivePrefab;
+ RecordCompositionMetrics(stopwatch);
+ prefab = effectivePrefab;
+ return true;
+ }
+ catch (Exception exception)
+ {
+ RecordCompositionFailure(address, entry, stopwatch, exception);
+ failure = exception;
+ return false;
+ }
+ }
+
+ private static bool TryUseCompletedEntry(
+ Entry entry,
+ Stopwatch stopwatch,
+ out GameObject prefab,
+ out Exception failure
+ )
+ {
+ prefab = null;
+ failure = null;
+ if (entry.EffectivePrefab != null)
+ {
+ stopwatch.Stop();
+ CurrentMetrics.RepeatedRequestCount++;
+ CurrentMetrics.RepeatedRequestMilliseconds += stopwatch.ElapsedMilliseconds;
+ prefab = entry.EffectivePrefab;
+ return true;
+ }
+
+ if (!entry.Failed)
+ return false;
+
+ failure = entry.Failure;
+ return true;
+ }
+
+ private static GameObject LoadStockPrefab(string address, Entry entry)
+ {
+ var stockLocation = ResolveOriginalLocation(address, typeof(GameObject));
+ entry.StockHandle = Addressables.LoadAssetAsync(stockLocation);
+ var stock = entry.StockHandle.WaitForCompletion();
+ if (
+ entry.StockHandle.Status == AsyncOperationStatus.Succeeded
+ && stock != null
+ )
+ {
+ return stock;
+ }
+
+ throw entry.StockHandle.OperationException
+ ?? new InvalidOperationException(
+ $"Could not load stock prefab '{address}'."
+ );
+ }
+
+ private static void LoadAddressableReferences(Entry entry)
+ {
+ foreach (var reference in GetDistinctAddressableReferences(entry.Plan))
+ {
+ var location = ResolveOriginalLocation(reference.Address, typeof(Object));
+ var handle = Addressables.LoadAssetAsync