From f4ac41c88cf3129f7b10776b0a720340ffdd4862 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Bure=C5=A1?= Date: Tue, 28 Jul 2026 18:58:23 +0200 Subject: [PATCH 01/17] Add composable prefab patch domain --- Runtime/Core/Assets/Locators.cs | 24 +- Runtime/Core/CoreModule.cs | 32 +- Runtime/PrefabPatching.meta | 8 + Runtime/PrefabPatching/PrefabPatchBuilder.cs | 216 +++++ .../PrefabPatching/PrefabPatchBuilder.cs.meta | 2 + Runtime/PrefabPatching/PrefabPatchComposer.cs | 649 ++++++++++++++ .../PrefabPatchComposer.cs.meta | 2 + Runtime/PrefabPatching/PrefabPatchJson.cs | 54 ++ .../PrefabPatching/PrefabPatchJson.cs.meta | 2 + Runtime/PrefabPatching/PrefabPatchModel.cs | 334 +++++++ .../PrefabPatching/PrefabPatchModel.cs.meta | 2 + Runtime/PrefabPatching/PrefabPatchObjectId.cs | 19 + .../PrefabPatchObjectId.cs.meta | 2 + .../PrefabPatching/PrefabPatchPlanCache.cs | 245 +++++ .../PrefabPatchPlanCache.cs.meta | 2 + Runtime/PrefabPatching/PrefabPatchResolver.cs | 842 ++++++++++++++++++ .../PrefabPatchResolver.cs.meta | 2 + Runtime/PrefabPatching/PrefabPatchRuntime.cs | 563 ++++++++++++ .../PrefabPatching/PrefabPatchRuntime.cs.meta | 2 + .../PrefabPatching/PrefabPatchStructure.cs | 77 ++ .../PrefabPatchStructure.cs.meta | 2 + 21 files changed, 3077 insertions(+), 4 deletions(-) create mode 100644 Runtime/PrefabPatching.meta create mode 100644 Runtime/PrefabPatching/PrefabPatchBuilder.cs create mode 100644 Runtime/PrefabPatching/PrefabPatchBuilder.cs.meta create mode 100644 Runtime/PrefabPatching/PrefabPatchComposer.cs create mode 100644 Runtime/PrefabPatching/PrefabPatchComposer.cs.meta create mode 100644 Runtime/PrefabPatching/PrefabPatchJson.cs create mode 100644 Runtime/PrefabPatching/PrefabPatchJson.cs.meta create mode 100644 Runtime/PrefabPatching/PrefabPatchModel.cs create mode 100644 Runtime/PrefabPatching/PrefabPatchModel.cs.meta create mode 100644 Runtime/PrefabPatching/PrefabPatchObjectId.cs create mode 100644 Runtime/PrefabPatching/PrefabPatchObjectId.cs.meta create mode 100644 Runtime/PrefabPatching/PrefabPatchPlanCache.cs create mode 100644 Runtime/PrefabPatching/PrefabPatchPlanCache.cs.meta create mode 100644 Runtime/PrefabPatching/PrefabPatchResolver.cs create mode 100644 Runtime/PrefabPatching/PrefabPatchResolver.cs.meta create mode 100644 Runtime/PrefabPatching/PrefabPatchRuntime.cs create mode 100644 Runtime/PrefabPatching/PrefabPatchRuntime.cs.meta create mode 100644 Runtime/PrefabPatching/PrefabPatchStructure.cs create mode 100644 Runtime/PrefabPatching/PrefabPatchStructure.cs.meta 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/CoreModule.cs b/Runtime/Core/CoreModule.cs index 9e2f801..2a329e5 100644 --- a/Runtime/Core/CoreModule.cs +++ b/Runtime/Core/CoreModule.cs @@ -6,6 +6,7 @@ using PatchManager.Core.Assets; using PatchManager.Core.Cache; using PatchManager.LuaPatching; +using PatchManager.PrefabPatching; using PatchManager.Shared; using PatchManager.Shared.Modules; using ReduxLib.Configuration; @@ -78,6 +79,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 @@ -94,9 +101,22 @@ 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 + ) + { + PrefabPatchRuntime.DiscoverAndResolve( + PatchingManager.Universe.AllMods, + resolve, + reject + ); + } + private void SavePatchSummary(Action resolve, Action reject) { PatchingManager.Universe.Summary.RecognizedModIds = PatchingManager.Universe.AllMods; @@ -149,6 +169,7 @@ private void RegisterResourceLocator(Action resolve, Action reject) } Locators.Register(new ArchiveResourceLocator()); + PrefabPatchRuntime.RegisterResourceProvider(); GameManager.Instance.Game.UI.UitkLoadingCurtain.Data.PatchManagerDefinitionsModifiedCount = CacheManager.Inventory.DefinitionCount; GameManager.Instance.Game.UI.UitkLoadingCurtain.Data.PatchManagerNewAssetCount = @@ -192,6 +213,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); @@ -212,4 +242,4 @@ public override void BindConfiguration(IConfigFile modConfiguration) [PublicAPI] public static Universe CurrentUniverse => PatchingManager.Universe; } -} \ No newline at end of file +} 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/PrefabPatchBuilder.cs b/Runtime/PrefabPatching/PrefabPatchBuilder.cs new file mode 100644 index 0000000..bc6b4cb --- /dev/null +++ b/Runtime/PrefabPatching/PrefabPatchBuilder.cs @@ -0,0 +1,216 @@ +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. +/// +public sealed class PrefabPatchBuilder +{ + 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); + + public PrefabPatchBuilder( + string modId, + string patchName, + PrefabPatchPrefabIdentity target, + string modVersion = null + ) + { + 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) + ); + _manifest = new PrefabPatchManifest + { + PatchId = + patchName.IndexOf(':') >= 0 + ? patchName + : modId + ":" + patchName, + ModId = modId, + ModVersion = modVersion, + TargetPrefab = + target ?? throw new ArgumentNullException(nameof(target)) + }; + } + + public PrefabPatchBuilder Early() + { + _manifest.Pass = PrefabPatchPass.Early; + return this; + } + + public PrefabPatchBuilder Late() + { + _manifest.Pass = PrefabPatchPass.Late; + return this; + } + + public PrefabPatchBuilder First() + { + _manifest.Ordering = PrefabPatchOrdering.First; + return this; + } + + public PrefabPatchBuilder Last() + { + _manifest.Ordering = PrefabPatchOrdering.Last; + return this; + } + + public PrefabPatchBuilder NeedsMod(params string[] ids) => + Add(_needsMods, ids); + + public PrefabPatchBuilder ConflictsMod(params string[] ids) => + Add(_conflictsMods, ids); + + public PrefabPatchBuilder NeedsPatch(params string[] ids) => + Add(_needsPatches, Normalize(ids)); + + public PrefabPatchBuilder ConflictsPatch(params string[] ids) => + Add(_conflictsPatches, Normalize(ids)); + + public PrefabPatchBuilder BeforePatch(params string[] ids) => + Add(_beforePatches, Normalize(ids)); + + public PrefabPatchBuilder AfterPatch(params string[] ids) => + Add(_afterPatches, Normalize(ids)); + + public PrefabPatchBuilder BeforeMod(params string[] ids) => + Add(_beforeMods, ids); + + public PrefabPatchBuilder AfterMod(params string[] ids) => + Add(_afterMods, ids); + + public PrefabPatchBuilder AddOperation(PrefabPatchOperation operation) + { + if (operation == null) + throw new ArgumentNullException(nameof(operation)); + operation.PatchId = _manifest.PatchId; + _manifest.Operations.Add(operation); + return this; + } + + 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 + } + ); + + public PrefabPatchBuilder SetActive( + string operationId, + PrefabPatchObjectTarget target, + bool active + ) => + AddOperation( + new PrefabPatchOperation + { + OperationId = operationId, + Kind = PrefabPatchOperationKind.SetActive, + Target = target, + Value = PrefabPatchValue.FromBoolean(active) + } + ); + + public PrefabPatchBuilder AddObject( + string operationId, + PrefabPatchObjectTarget parent, + PrefabPatchObjectFragment fragment + ) => + AddOperation( + new PrefabPatchOperation + { + OperationId = operationId, + Kind = PrefabPatchOperationKind.AddObject, + Target = parent, + AddedObject = fragment + } + ); + + 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.Operations = _manifest.Operations + .OrderBy(value => value.OperationId, StringComparer.Ordinal) + .ToList(); + _manifest.DeclaredCapabilities = _manifest.Operations + .Select(value => value.Kind.ToString()) + .Distinct(StringComparer.Ordinal) + .OrderBy(value => value, StringComparer.Ordinal) + .ToArray(); + _manifest.ManifestHash = PrefabPatchJson.CalculateManifestHash(_manifest); + return _manifest; + } + + 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 IEnumerable Normalize(IEnumerable ids) => + (ids ?? Array.Empty()).Select( + id => + id.IndexOf(':') >= 0 + ? id + : _manifest.ModId + ":" + id + ); + + private static string[] Sorted(IEnumerable values) => + values.OrderBy(value => value, StringComparer.Ordinal).ToArray(); +} 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..3730032 --- /dev/null +++ b/Runtime/PrefabPatching/PrefabPatchComposer.cs @@ -0,0 +1,649 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.Linq; +using System.Reflection; +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. +/// +public static class PrefabPatchComposer +{ + public sealed class Result + { + public bool Success; + public string Failure; + public long ElapsedMilliseconds; + public int AppliedOperationCount; + public Dictionary PatchOwnedObjects = new( + StringComparer.Ordinal + ); + } + + public static Result ApplySynchronously( + GameObject prefab, + PrefabPatchResolvedPlan plan, + IReadOnlyDictionary references + ) + { + var stopwatch = Stopwatch.StartNew(); + var result = new Result(); + 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.Equals( + actualFingerprint, + plan.TargetPrefab.StructuralFingerprint, + StringComparison.Ordinal + ) + ) + { + throw new InvalidOperationException( + $"Stock prefab '{plan.TargetPrefab.Address}' structural " + + $"fingerprint changed. Expected " + + $"'{plan.TargetPrefab.StructuralFingerprint}', got " + + $"'{actualFingerprint}'. Recompile or repair the patch." + ); + } + + var unusedDeferredDestroy = false; + foreach (var operation in plan.Operations) + { + ApplyOne( + prefab, + operation, + references, + result.PatchOwnedObjects, + ref unusedDeferredDestroy, + true + ); + result.AppliedOperationCount++; + } + + result.Success = true; + } + catch (Exception exception) + { + result.Failure = exception.ToString(); + } + finally + { + stopwatch.Stop(); + result.ElapsedMilliseconds = stopwatch.ElapsedMilliseconds; + } + + return result; + } + + private static void ApplyOne( + GameObject root, + PrefabPatchOperation operation, + IReadOnlyDictionary references, + IDictionary patchOwned, + ref bool deferredDestroy, + bool immediateDestroy + ) + { + if (operation.Kind == PrefabPatchOperationKind.AddObject) + { + var parentObject = operation.Target == null + ? root + : AsGameObject(Resolve(root, operation.Target, patchOwned)); + if (parentObject == null) + throw MissingTarget(operation); + CreateFragment( + operation.PatchId, + operation.AddedObject, + parentObject.transform, + references, + patchOwned + ); + return; + } + + var target = Resolve(root, operation.Target, patchOwned); + 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 + || !references.TryGetValue( + operation.ObjectReference.Address, + out var reference + ) + || reference == null + ) + { + throw new InvalidOperationException( + $"Operation '{operation.OperationId}' could not resolve " + + $"object reference " + + $"'{operation.ObjectReference?.Address}'." + ); + } + + SetRawValue(target, operation.PropertyPath, reference); + 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 + ); + 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) + Object.DestroyImmediate(component); + 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 + ) + { + Transform transform; + if (target.Kind == PrefabPatchTargetKind.Stock) + { + transform = 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 + ) + { + 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 gameObject = new GameObject(fragment.Name ?? fragment.ObjectId); + gameObject.transform.SetParent(parent, false); + gameObject.transform.localPosition = ToVector3( + fragment.LocalPosition, + Vector3.zero + ); + gameObject.transform.localRotation = ToQuaternion( + fragment.LocalRotation, + Quaternion.identity + ); + gameObject.transform.localScale = ToVector3( + fragment.LocalScale, + Vector3.one + ); + gameObject.SetActive(fragment.Active); + gameObject.AddComponent().Id = fragment.ObjectId; + patchOwned.Add(key, gameObject); + foreach (var component in fragment.Components) + AddComponent(gameObject, component, references); + foreach (var child in fragment.Children) + CreateFragment(patchId, child, gameObject.transform, references, patchOwned); + return gameObject; + } + + private static Component AddComponent( + GameObject target, + PrefabPatchComponentFragment fragment, + IReadOnlyDictionary references + ) + { + if (target == null || fragment == null) + throw new InvalidOperationException( + "An added component has no target or payload." + ); + switch (fragment.Kind) + { + case PrefabPatchComponentKind.BoxCollider: + { + var value = target.AddComponent(); + value.enabled = fragment.Enabled; + value.center = ToVector3(fragment.Center, Vector3.zero); + value.size = ToVector3(fragment.Size, Vector3.one); + value.isTrigger = fragment.IsTrigger; + return value; + } + case PrefabPatchComponentKind.SphereCollider: + { + var value = target.AddComponent(); + value.enabled = fragment.Enabled; + value.center = ToVector3(fragment.Center, Vector3.zero); + value.radius = (float)fragment.Radius; + value.isTrigger = fragment.IsTrigger; + return value; + } + case PrefabPatchComponentKind.MeshFilter: + { + var value = target.AddComponent(); + if (fragment.Mesh != null) + { + if ( + !references.TryGetValue( + fragment.Mesh.Address, + out var reference + ) + || reference is not Mesh mesh + ) + { + throw new InvalidOperationException( + $"Could not resolve Mesh reference " + + $"'{fragment.Mesh.Address}'." + ); + } + + value.sharedMesh = mesh; + } + + return value; + } + case PrefabPatchComponentKind.MeshRenderer: + return target.AddComponent(); + default: + throw new NotSupportedException( + $"Added component kind '{fragment.Kind}' is unsupported." + ); + } + } + + private static void SetValue( + Object target, + string propertyPath, + PrefabPatchValue value + ) + { + if (value == null) + throw new InvalidOperationException( + $"Property '{propertyPath}' has no typed value." + ); + 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); + 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 void SetMemberPath( + object root, + string path, + object value + ) + { + var segments = path.Split('.'); + SetMemberRecursive(root, segments, 0, value); + } + + 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 member = FindMember(current.GetType(), segments[index]); + if (member == null) + throw new MissingMemberException( + current.GetType().FullName, + segments[index] + ); + var memberType = GetMemberType(member); + if (index == segments.Count - 1) + { + var converted = ConvertForType(value, memberType); + SetMemberValue(member, current, converted); + return current; + } + + var child = GetMemberValue(member, current); + var updatedChild = SetMemberRecursive( + child, + segments, + index + 1, + value + ); + if (memberType.IsValueType) + SetMemberValue(member, current, updatedChild); + return current; + } + + 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; + } + + 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 == null || targetType.IsInstanceOfType(value)) + return value; + if (targetType.IsEnum) + return Enum.ToObject(targetType, value); + return Convert.ChangeType( + value, + targetType, + CultureInfo.InvariantCulture + ); + } + + 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 + ), + _ => 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 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/PrefabPatchJson.cs b/Runtime/PrefabPatching/PrefabPatchJson.cs new file mode 100644 index 0000000..9185598 --- /dev/null +++ b/Runtime/PrefabPatching/PrefabPatchJson.cs @@ -0,0 +1,54 @@ +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 +{ + public static readonly JsonSerializerSettings Settings = new() + { + ContractResolver = new DefaultContractResolver + { + NamingStrategy = new CamelCaseNamingStrategy() + }, + Formatting = Formatting.Indented, + NullValueHandling = NullValueHandling.Ignore, + DefaultValueHandling = DefaultValueHandling.Include, + Converters = { new StringEnumConverter() } + }; + + public static string Serialize(object value) => + JsonConvert.SerializeObject(value, Settings); + + public static T Deserialize(string json) => + JsonConvert.DeserializeObject(json, Settings); + + 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(); + } + + 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..1d5419d --- /dev/null +++ b/Runtime/PrefabPatching/PrefabPatchModel.cs @@ -0,0 +1,334 @@ +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 +{ + public const int Version = 1; + public const int ComposerVersion = 1; + public const string AddressablesLabel = "patch-manager-prefab-patches"; +} + +[JsonConverter(typeof(StringEnumConverter))] +public enum PrefabPatchPass +{ + Early, + Default, + Late +} + +[JsonConverter(typeof(StringEnumConverter))] +public enum PrefabPatchOrdering +{ + First, + Default, + Last +} + +[JsonConverter(typeof(StringEnumConverter))] +public enum PrefabPatchTargetKind +{ + Stock, + PatchOwned +} + +[JsonConverter(typeof(StringEnumConverter))] +public enum PrefabPatchRuntimeTargetKind +{ + GameObject, + Component +} + +[JsonConverter(typeof(StringEnumConverter))] +public enum PrefabPatchOperationKind +{ + SetValue, + SetObjectReference, + SetActive, + AddObject, + SuppressObject, + AddComponent, + RemoveComponent +} + +[JsonConverter(typeof(StringEnumConverter))] +public enum PrefabPatchValueKind +{ + Boolean, + Integer, + Float, + String, + Vector2, + Vector3, + Vector4, + Quaternion, + Color +} + +[JsonConverter(typeof(StringEnumConverter))] +public enum PrefabPatchComponentKind +{ + BoxCollider, + SphereCollider, + MeshFilter, + MeshRenderer +} + +/// +/// Canonical identity and compatibility data for one stock Addressable prefab. +/// +[Serializable] +public sealed class PrefabPatchPrefabIdentity +{ + public string Address; + public string CatalogId; + public string CatalogHash; + public string SourceBundleFileName; + public string SourceBundleHash; + public string SourceSerializedFileName; + public long SourcePathId; + public string AssetType; + public string StructuralFingerprint; + + [JsonIgnore] + public string CanonicalKey => + $"{CatalogId}|{SourceSerializedFileName}|{SourcePathId}|{AssetType}"; +} + +/// +/// Deterministic runtime traversal hint. It is validated against the manifest's +/// source identity and structural fingerprint; it is never the canonical ID. +/// +[Serializable] +public sealed class PrefabPatchRuntimeLocator +{ + public int[] SiblingIndices = Array.Empty(); + public PrefabPatchRuntimeTargetKind TargetKind; + public string ComponentType; + public int ComponentOrdinal; + public string DisplayPath; +} + +/// +/// An inherited source object or an object introduced by a named patch. +/// +[Serializable] +public sealed class PrefabPatchObjectTarget +{ + public PrefabPatchTargetKind Kind; + public string SourceSerializedFileName; + public long SourcePathId; + public string ObjectType; + public string OwnerPatchId; + public string ObjectId; + public PrefabPatchRuntimeLocator RuntimeLocator; + + [JsonIgnore] + public string CanonicalKey => + Kind == PrefabPatchTargetKind.Stock + ? $"stock:{SourceSerializedFileName}:{SourcePathId}:{ObjectType}" + : $"patch:{OwnerPatchId}:{ObjectId}"; +} + +/// +/// JSON-safe typed value. Numeric vectors use X/Y/Z/W in their normal Unity +/// component order. +/// +[Serializable] +public sealed class PrefabPatchValue +{ + public PrefabPatchValueKind Kind; + public bool Boolean; + public long Integer; + public double Float; + public string String; + public double X; + public double Y; + public double Z; + public double W; + + public static PrefabPatchValue FromBoolean(bool value) => + new() { Kind = PrefabPatchValueKind.Boolean, Boolean = value }; + + public static PrefabPatchValue FromInteger(long value) => + new() { Kind = PrefabPatchValueKind.Integer, Integer = value }; + + public static PrefabPatchValue FromFloat(double value) => + new() { Kind = PrefabPatchValueKind.Float, Float = value }; + + public static PrefabPatchValue FromString(string value) => + new() { Kind = PrefabPatchValueKind.String, String = value }; +} + +/// +/// Addressable Unity object reference used by SetObjectReference or a component +/// fragment. Source identity is retained for compatibility diagnostics. +/// +[Serializable] +public sealed class PrefabPatchObjectReference +{ + public string Address; + public string ExpectedType; + public string CatalogId; + public string SourceBundleFileName; + public string SourceSerializedFileName; + public long SourcePathId; +} + +/// +/// Constrained component payload used for added patch-owned objects and +/// AddComponent operations. +/// +[Serializable] +public sealed class PrefabPatchComponentFragment +{ + public PrefabPatchComponentKind Kind; + public string ComponentId; + public bool Enabled = true; + public PrefabPatchValue Center; + public PrefabPatchValue Size; + public double Radius; + public bool IsTrigger; + public PrefabPatchObjectReference Mesh; +} + +/// +/// 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 +{ + public string ObjectId; + public string Name; + public bool Active = true; + public PrefabPatchValue LocalPosition; + public PrefabPatchValue LocalRotation; + public PrefabPatchValue LocalScale; + public List Components = new(); + public List Children = new(); +} + +/// +/// One normalized declarative operation. +/// +[Serializable] +public sealed class PrefabPatchOperation +{ + public string OperationId; + public string PatchId; + public PrefabPatchOperationKind Kind; + public PrefabPatchObjectTarget Target; + public string PropertyPath; + public PrefabPatchValue Value; + public PrefabPatchObjectReference ObjectReference; + public PrefabPatchObjectFragment AddedObject; + public PrefabPatchComponentFragment AddedComponent; + public string ExpectedOriginalFingerprint; + public string AuthoringAssetPath; + public string AuthoringPropertyPath; + + [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 +{ + public int SchemaVersion = PrefabPatchSchema.Version; + public int ComposerVersion = PrefabPatchSchema.ComposerVersion; + public string PatchId; + public string ModId; + public string ModVersion; + public PrefabPatchPrefabIdentity TargetPrefab; + public PrefabPatchPass Pass = PrefabPatchPass.Default; + public PrefabPatchOrdering Ordering = PrefabPatchOrdering.Default; + public string[] NeedsMods = Array.Empty(); + public string[] ConflictsMods = Array.Empty(); + public string[] NeedsPatches = Array.Empty(); + public string[] ConflictsPatches = Array.Empty(); + public string[] BeforePatches = Array.Empty(); + public string[] AfterPatches = Array.Empty(); + public string[] BeforeMods = Array.Empty(); + public string[] AfterMods = Array.Empty(); + public string[] ConfigurationInputs = Array.Empty(); + public string[] DeclaredCapabilities = Array.Empty(); + public List Operations = new(); + public string ManifestHash; +} + +[JsonConverter(typeof(StringEnumConverter))] +public enum PrefabPatchDiagnosticSeverity +{ + Info, + Warning, + Error +} + +/// +/// Machine-readable ordering, compatibility, conflict, cache, or composition +/// diagnostic. +/// +[Serializable] +public sealed class PrefabPatchDiagnostic +{ + public PrefabPatchDiagnosticSeverity Severity; + public string Code; + public string TargetAddress; + public string PatchId; + public string OperationId; + 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 +{ + public int SchemaVersion = PrefabPatchSchema.Version; + public int ComposerVersion = PrefabPatchSchema.ComposerVersion; + public string CacheKey; + public string SourceFingerprint; + public string InputHash; + public PrefabPatchPrefabIdentity TargetPrefab; + public string[] OrderedPatchIds = Array.Empty(); + public List Operations = new(); + public List Diagnostics = new(); + public bool IsValid; + 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..9d1cc3b --- /dev/null +++ b/Runtime/PrefabPatching/PrefabPatchObjectId.cs @@ -0,0 +1,19 @@ +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; + + 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..9430efb --- /dev/null +++ b/Runtime/PrefabPatching/PrefabPatchObjectId.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 93da1f45b46a3f243ae12acca9ebff3a \ No newline at end of file diff --git a/Runtime/PrefabPatching/PrefabPatchPlanCache.cs b/Runtime/PrefabPatching/PrefabPatchPlanCache.cs new file mode 100644 index 0000000..9f5314f --- /dev/null +++ b/Runtime/PrefabPatching/PrefabPatchPlanCache.cs @@ -0,0 +1,245 @@ +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 +{ + public sealed class Result + { + public PrefabPatchResolvedPlan Plan; + public bool CacheHit; + public bool RecoveredBackup; + public long ElapsedMilliseconds; + public string Path; + } + + private readonly string _directory; + + public PrefabPatchPlanCache(string directory) + { + _directory = Path.GetFullPath(directory); + } + + 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 + }; + } + + public string GetPath(PrefabPatchPrefabIdentity target) + { + var identity = target?.CanonicalKey ?? target?.Address ?? "invalid"; + return Path.Combine( + _directory, + PrefabPatchJson.Sha256(identity) + ".plan.json" + ); + } + + 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..d36defa --- /dev/null +++ b/Runtime/PrefabPatching/PrefabPatchResolver.cs @@ -0,0 +1,842 @@ +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. +/// +public static class PrefabPatchResolver +{ + 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[0].TargetPrefab; + 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 ( + !string.Equals( + manifest.TargetPrefab.CanonicalKey, + plan.TargetPrefab.CanonicalKey, + StringComparison.Ordinal + ) + ) + { + Add( + plan, + PrefabPatchDiagnosticSeverity.Error, + "PM-PREFAB-MIXED-TARGET", + manifest.PatchId, + null, + $"Patch '{manifest.PatchId}' targets " + + $"'{manifest.TargetPrefab.CanonicalKey}', not " + + $"'{plan.TargetPrefab.CanonicalKey}'." + ); + 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 + ) + { + Add( + plan, + PrefabPatchDiagnosticSeverity.Error, + "PM-PREFAB-PATCH-ID", + manifest.PatchId, + null, + "Prefab patch IDs must be namespaced as 'mod-id:patch-id'." + ); + 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) + || string.IsNullOrWhiteSpace( + manifest.TargetPrefab.SourceSerializedFileName + ) + || manifest.TargetPrefab.SourcePathId == 0 + || string.IsNullOrWhiteSpace( + manifest.TargetPrefab.StructuralFingerprint + ) + ) + { + Add( + plan, + PrefabPatchDiagnosticSeverity.Error, + "PM-PREFAB-TARGET-IDENTITY", + manifest.PatchId, + null, + $"Patch '{manifest.PatchId}' has an incomplete canonical " + + "prefab identity or structural fingerprint." + ); + 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 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) + ) + ) + { + var members = enabled + .Select(id => byId[id]) + .Where( + manifest => + manifest.Pass == pass && manifest.Ordering == bucket + ) + .ToDictionary( + manifest => manifest.PatchId, + StringComparer.Ordinal + ); + var incoming = members.Keys.ToDictionary( + id => id, + _ => new HashSet(StringComparer.Ordinal), + StringComparer.Ordinal + ); + var outgoing = members.Keys.ToDictionary( + id => id, + _ => new HashSet(StringComparer.Ordinal), + StringComparer.Ordinal + ); + + foreach (var manifest in members.Values) + { + foreach (var dependency in Safe(manifest.NeedsPatches)) + { + if (!enabled.Contains(dependency)) + continue; + var dependencyManifest = byId[dependency]; + if ( + Rank(dependencyManifest) > 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 + ); + } + } + + AddSameBucketEdges( + manifest, + manifest.AfterPatches, + after: true, + members, + incoming, + outgoing, + plan + ); + AddSameBucketEdges( + manifest, + manifest.BeforePatches, + after: false, + members, + incoming, + outgoing, + plan + ); + AddModEdges( + manifest, + manifest.AfterMods, + after: true, + members, + incoming, + outgoing + ); + AddModEdges( + manifest, + manifest.BeforeMods, + after: false, + members, + incoming, + outgoing + ); + } + + 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 patchOrder = ordered + .Select((manifest, index) => (manifest.PatchId, index)) + .ToDictionary(pair => pair.PatchId, pair => pair.index); + var introduced = new Dictionary(StringComparer.Ordinal); + var writes = new Dictionary( + StringComparer.Ordinal + ); + + foreach (var manifest in ordered) + { + var needs = new HashSet( + Safe(manifest.NeedsPatches), + StringComparer.Ordinal + ); + foreach ( + var operation in manifest.Operations + .Where(value => value != null) + .OrderBy(value => value.OperationId, StringComparer.Ordinal) + ) + { + operation.PatchId = manifest.PatchId; + if (string.IsNullOrWhiteSpace(operation.OperationId)) + { + Add( + plan, + PrefabPatchDiagnosticSeverity.Error, + "PM-PREFAB-OPERATION-ID", + manifest.PatchId, + null, + $"Patch '{manifest.PatchId}' contains an operation " + + "without an ID." + ); + fatal = true; + continue; + } + + if ( + operation.Kind != PrefabPatchOperationKind.AddObject + && operation.Target == null + ) + { + Add( + plan, + PrefabPatchDiagnosticSeverity.Error, + "PM-PREFAB-MISSING-TARGET", + manifest.PatchId, + operation.OperationId, + $"Operation '{operation.OperationId}' has no target." + ); + fatal = true; + continue; + } + + if ( + operation.Target?.Kind + == PrefabPatchTargetKind.PatchOwned + ) + { + var owner = operation.Target.OwnerPatchId; + var objectKey = $"{owner}:{operation.Target.ObjectId}"; + if ( + !string.Equals( + owner, + manifest.PatchId, + StringComparison.Ordinal + ) + && !needs.Contains(owner) + ) + { + Add( + plan, + PrefabPatchDiagnosticSeverity.Error, + "PM-PREFAB-OWNER-NOT-REQUIRED", + manifest.PatchId, + operation.OperationId, + $"Operation '{operation.OperationId}' targets " + + $"'{objectKey}' but does not require owning " + + $"patch '{owner}'." + ); + fatal = true; + continue; + } + + if ( + !introduced.ContainsKey(objectKey) + || patchOrder[owner] >= patchOrder[manifest.PatchId] + ) + { + Add( + plan, + PrefabPatchDiagnosticSeverity.Error, + "PM-PREFAB-PATCH-OWNED-TARGET", + manifest.PatchId, + operation.OperationId, + $"Patch-owned target '{objectKey}' is not introduced " + + "by an earlier required operation." + ); + fatal = true; + continue; + } + } + + if (operation.Kind == PrefabPatchOperationKind.AddObject) + { + var objectId = operation.AddedObject?.ObjectId; + var objectKey = $"{manifest.PatchId}:{objectId}"; + if (string.IsNullOrWhiteSpace(objectId)) + { + Add( + plan, + PrefabPatchDiagnosticSeverity.Error, + "PM-PREFAB-ADDED-OBJECT-ID", + manifest.PatchId, + operation.OperationId, + $"Added object operation '{operation.OperationId}' " + + "has no patch-local object ID." + ); + fatal = true; + continue; + } + + if (!introduced.TryAdd(objectKey, operation.OperationId)) + { + Add( + plan, + PrefabPatchDiagnosticSeverity.Error, + "PM-PREFAB-DUPLICATE-OBJECT-ID", + manifest.PatchId, + operation.OperationId, + $"Patch-owned object '{objectKey}' is introduced " + + "more than once." + ); + fatal = true; + continue; + } + } + + var conflictKey = operation.ConflictKey; + if ( + IsWrite(operation.Kind) + && writes.TryGetValue(conflictKey, out var previous) + ) + { + if ( + string.Equals( + OperationPayload(previous), + OperationPayload(operation), + StringComparison.Ordinal + ) + ) + { + Add( + plan, + PrefabPatchDiagnosticSeverity.Info, + "PM-PREFAB-IDENTICAL-WRITE", + manifest.PatchId, + operation.OperationId, + $"'{manifest.PatchId}' repeats the identical write " + + $"from '{previous.PatchId}' to '{conflictKey}'." + ); + } + else + { + Add( + plan, + PrefabPatchDiagnosticSeverity.Warning, + "PM-PREFAB-SOFT-CONFLICT", + manifest.PatchId, + operation.OperationId, + $"Different writes target '{conflictKey}'. " + + $"Deterministic order selects " + + $"'{manifest.PatchId}' over " + + $"'{previous.PatchId}'." + ); + } + } + + if (IsWrite(operation.Kind)) + writes[conflictKey] = operation; + plan.Operations.Add(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 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..204521c --- /dev/null +++ b/Runtime/PrefabPatching/PrefabPatchRuntime.cs @@ -0,0 +1,563 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using PatchManager.Core.Assets; +using PatchManager.Shared; +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; + +/// +/// Public registry and lazy effective-prefab runtime for the prefab domain. +/// +public static class PrefabPatchRuntime +{ + public sealed class Metrics + { + public int DiscoveredManifestCount; + public int ResolvedPlanCount; + public int CacheHitCount; + public int CacheMissCount; + public long StartupMilliseconds; + public long FirstCompositionMilliseconds; + public long RepeatedRequestMilliseconds; + public int RepeatedRequestCount; + public long MemoryBeforeBytes; + public long MemoryAfterBytes; + 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 const string PlanCacheDirectory = "./pm_cache/prefabs"; + private const string SummaryPath = "./pm_prefab_summary.log"; + private static readonly List Registered = new(); + private static readonly Dictionary Entries = new( + StringComparer.Ordinal + ); + private static PrefabPatchResourceLocator _locator; + + public static bool RegistrationOpen { get; private set; } = true; + public static Metrics CurrentMetrics { get; private set; } = new(); + 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. + /// + 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); + } + + public static void CloseRegistration() + { + RegistrationOpen = false; + } + + /// + /// Discovers independently built TextAsset manifests, resolves per-prefab + /// plans through the atomic cache, and writes a deterministic summary. + /// + public static void DiscoverAndResolve( + ISet activeModIds, + Action resolve, + Action reject + ) + { + var stopwatch = Stopwatch.StartNew(); + CurrentMetrics = new Metrics + { + MemoryBeforeBytes = Profiler.GetTotalAllocatedMemoryLong() + }; + try + { + var manifests = new List(Registered); + var locationsHandle = + Addressables.LoadResourceLocationsAsync( + PrefabPatchSchema.AddressablesLabel, + typeof(TextAsset) + ); + var locations = locationsHandle.WaitForCompletion(); + if ( + locationsHandle.Status == AsyncOperationStatus.Succeeded + && locations != null + && locations.Count > 0 + ) + { + var manifestHandle = Addressables.LoadAssetsAsync( + locations, + null + ); + var assets = manifestHandle.WaitForCompletion(); + if (manifestHandle.Status != AsyncOperationStatus.Succeeded) + { + throw manifestHandle.OperationException + ?? new InvalidOperationException( + "Prefab patch manifest load failed." + ); + } + + foreach (var asset in assets.Where(value => value != null)) + { + try + { + manifests.Add( + PrefabPatchJson.Deserialize( + asset.text + ) + ); + } + catch (Exception exception) + { + throw new InvalidDataException( + $"Could not parse prefab patch manifest " + + $"'{asset.name}'.", + exception + ); + } + } + + Addressables.Release(manifestHandle); + } + + Addressables.Release(locationsHandle); + 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.CanonicalKey, + 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(); + } + catch (Exception exception) + { + stopwatch.Stop(); + CurrentMetrics.StartupMilliseconds = stopwatch.ElapsedMilliseconds; + Logging.LogError(exception); + reject( + "Patch Manager prefab discovery failed: " + + exception.Message + ); + } + } + + /// + /// Adds the public GameObject provider and locator to Patch Manager's + /// supported KSP AssetProvider interception boundary. + /// + public static void RegisterResourceProvider() + { + var providers = Addressables.ResourceManager.ResourceProviders; + if ( + !providers.Any( + provider => provider is PrefabPatchResourceProvider + ) + ) + { + providers.Add(new PrefabPatchResourceProvider()); + } + + _locator = new PrefabPatchResourceLocator(Entries); + Locators.Register(_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 (entry.EffectivePrefab != null) + { + stopwatch.Stop(); + CurrentMetrics.RepeatedRequestCount++; + CurrentMetrics.RepeatedRequestMilliseconds += + stopwatch.ElapsedMilliseconds; + prefab = entry.EffectivePrefab; + return true; + } + + if (entry.Failed) + { + failure = entry.Failure; + return false; + } + + try + { + var stockLocation = ResolveOriginalLocation( + address, + typeof(GameObject) + ); + entry.StockHandle = + Addressables.LoadAssetAsync(stockLocation); + var stock = entry.StockHandle.WaitForCompletion(); + if ( + entry.StockHandle.Status != AsyncOperationStatus.Succeeded + || stock == null + ) + { + throw entry.StockHandle.OperationException + ?? new InvalidOperationException( + $"Could not load stock prefab '{address}'." + ); + } + + foreach ( + var reference in entry.Plan.Operations + .SelectMany(GetReferences) + .Where(value => value != null) + .GroupBy(value => value.Address, StringComparer.Ordinal) + .Select(group => group.First()) + .OrderBy(value => value.Address, StringComparer.Ordinal) + ) + { + var location = ResolveOriginalLocation( + reference.Address, + typeof(Object) + ); + var handle = Addressables.LoadAssetAsync(location); + var value = handle.WaitForCompletion(); + if ( + handle.Status != AsyncOperationStatus.Succeeded + || value == null + ) + { + throw handle.OperationException + ?? new InvalidOperationException( + $"Could not load prefab patch reference " + + $"'{reference.Address}'." + ); + } + + entry.ReferenceHandles.Add(handle); + entry.References.Add(reference.Address, value); + } + + var result = PrefabPatchComposer.ApplySynchronously( + stock, + entry.Plan, + entry.References + ); + if (!result.Success) + throw new InvalidOperationException(result.Failure); + entry.EffectivePrefab = stock; + stopwatch.Stop(); + CurrentMetrics.FirstCompositionMilliseconds += + stopwatch.ElapsedMilliseconds; + CurrentMetrics.RetainedAddressablesHandles = + Entries.Values.Sum( + value => + (value.StockHandle.IsValid() ? 1 : 0) + + value.ReferenceHandles.Count( + handle => handle.IsValid() + ) + ); + CurrentMetrics.MemoryAfterBytes = + Profiler.GetTotalAllocatedMemoryLong(); + prefab = stock; + return true; + } + catch (Exception exception) + { + stopwatch.Stop(); + entry.Failed = true; + entry.Failure = exception; + failure = exception; + Logging.LogError( + $"Prefab composition failed for '{address}': {exception}" + ); + WriteSummary(); + return false; + } + } + + private static IResourceLocation ResolveOriginalLocation( + string address, + Type type + ) + { + var handle = + type == typeof(Object) + ? Addressables.LoadResourceLocationsAsync(address) + : Addressables.LoadResourceLocationsAsync(address, type); + var locations = handle.WaitForCompletion(); + try + { + if ( + handle.Status != AsyncOperationStatus.Succeeded + || locations == null + || locations.Count == 0 + ) + { + throw handle.OperationException + ?? new InvalidOperationException( + $"No original Addressables location exists for " + + $"'{address}' as '{type.FullName}'." + ); + } + + return locations + .Where( + location => + !string.Equals( + location.ProviderId, + typeof(PrefabPatchResourceProvider).FullName, + StringComparison.Ordinal + ) + ) + .OrderBy(location => location.PrimaryKey, StringComparer.Ordinal) + .ThenBy(location => location.InternalId, StringComparer.Ordinal) + .FirstOrDefault() + ?? throw new InvalidOperationException( + $"Only recursive prefab-patch locations exist for " + + $"'{address}'." + ); + } + finally + { + Addressables.Release(handle); + } + } + + private static IEnumerable GetReferences( + PrefabPatchOperation operation + ) + { + if (operation.ObjectReference != null) + yield return operation.ObjectReference; + if (operation.AddedComponent?.Mesh != null) + yield return operation.AddedComponent.Mesh; + if (operation.AddedObject == null) + yield break; + foreach (var reference in GetReferences(operation.AddedObject)) + yield return reference; + } + + private static IEnumerable GetReferences( + PrefabPatchObjectFragment fragment + ) + { + foreach (var component in fragment.Components) + { + if (component.Mesh != null) + yield return component.Mesh; + } + + foreach (var child in fragment.Children) + { + foreach (var reference in GetReferences(child)) + yield return reference; + } + } + + private static void WriteSummary() + { + var lines = new List + { + "Patch Manager prefab patch summary", + $"Schema: {PrefabPatchSchema.Version}", + $"Composer: {PrefabPatchSchema.ComposerVersion}", + $"Discovered manifests: {CurrentMetrics.DiscoveredManifestCount}", + $"Resolved plans: {CurrentMetrics.ResolvedPlanCount}", + $"Plan cache hits: {CurrentMetrics.CacheHitCount}", + $"Plan cache misses: {CurrentMetrics.CacheMissCount}" + }; + foreach ( + var pair in Entries.OrderBy( + value => value.Key, + StringComparer.Ordinal + ) + ) + { + lines.Add(""); + lines.Add($"Target: {pair.Key}"); + lines.Add($"Cache key: {pair.Value.Plan.CacheKey}"); + lines.Add( + "Ordered patches: " + + string.Join( + ", ", + pair.Value.Plan.OrderedPatchIds + ) + ); + foreach (var diagnostic in pair.Value.Plan.Diagnostics) + { + lines.Add( + $"[{diagnostic.Severity}] {diagnostic.Code} " + + $"{diagnostic.PatchId} {diagnostic.OperationId}: " + + diagnostic.Message + ); + } + + if (pair.Value.Failed) + lines.Add("Composition failure: " + pair.Value.Failure); + } + + File.WriteAllLines(SummaryPath, lines); + } + + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] + private static void ResetStaticState() + { + Registered.Clear(); + Entries.Clear(); + _locator = null; + RegistrationOpen = true; + CurrentMetrics = new Metrics(); + } +} + +internal sealed class PrefabPatchResourceLocator : + UnityEngine.AddressableAssets.ResourceLocators.IResourceLocator +{ + private readonly IReadOnlyDictionary _entries; + + public PrefabPatchResourceLocator( + IReadOnlyDictionary entries + ) + { + _entries = entries; + } + + public string LocatorId => GetType().FullName; + public IEnumerable Keys => _entries.Keys; + + public bool Locate( + object key, + Type type, + out IList locations + ) + { + var address = key?.ToString(); + if ( + string.IsNullOrWhiteSpace(address) + || !_entries.ContainsKey(address) + || ( + type != typeof(object) + && type != typeof(Object) + && type != typeof(GameObject) + && !typeof(Component).IsAssignableFrom(type) + ) + ) + { + locations = Array.Empty(); + return false; + } + + locations = new IResourceLocation[] + { + new ResourceLocationBase( + "prefab-patch:" + address, + address, + typeof(PrefabPatchResourceProvider).FullName, + typeof(GameObject) + ) + }; + return true; + } +} + +internal sealed class PrefabPatchResourceProvider : ResourceProviderBase +{ + public override void Provide(ProvideHandle provideHandle) + { + if ( + PrefabPatchRuntime.TryProvide( + provideHandle.Location.InternalId, + out var prefab, + out var failure + ) + ) + { + provideHandle.Complete(prefab, true, null); + } + else + { + provideHandle.Complete(null, false, failure); + } + } + + public override Type GetDefaultType(IResourceLocation location) => + typeof(GameObject); + + public override void Release(IResourceLocation location, object obj) + { + // Effective prefabs and their stock/mod Addressables handles are retained + // for the game session. They are cleared by SubsystemRegistration. + } +} diff --git a/Runtime/PrefabPatching/PrefabPatchRuntime.cs.meta b/Runtime/PrefabPatching/PrefabPatchRuntime.cs.meta new file mode 100644 index 0000000..ab4579e --- /dev/null +++ b/Runtime/PrefabPatching/PrefabPatchRuntime.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 2c3c89da17222224780397bedec236ed \ No newline at end of file diff --git a/Runtime/PrefabPatching/PrefabPatchStructure.cs b/Runtime/PrefabPatching/PrefabPatchStructure.cs new file mode 100644 index 0000000..46d464c --- /dev/null +++ b/Runtime/PrefabPatching/PrefabPatchStructure.cs @@ -0,0 +1,77 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using UnityEngine; + +namespace PatchManager.PrefabPatching; + +/// +/// Runtime-computable hierarchy fingerprint and sibling-index traversal. +/// Canonical identity still comes from BundleKit CAB/path IDs. +/// +public static class PrefabPatchStructure +{ + public static string Calculate(GameObject root) + { + if (root == null) + return null; + var builder = new StringBuilder(); + Append(root.transform, builder); + return PrefabPatchJson.Sha256(builder.ToString()); + } + + public static int[] GetSiblingPath(Transform root, Transform target) + { + var reverse = new List(); + var current = target; + while (current != null && current != root) + { + reverse.Add(current.GetSiblingIndex()); + current = current.parent; + } + + if (current != root) + throw new InvalidOperationException( + $"Transform '{target?.name}' is not beneath '{root?.name}'." + ); + reverse.Reverse(); + return reverse.ToArray(); + } + + public static Transform Resolve(Transform root, IEnumerable path) + { + var current = root; + foreach (var index in path ?? Array.Empty()) + { + if (index < 0 || index >= current.childCount) + return null; + current = current.GetChild(index); + } + + return current; + } + + private static void Append(Transform transform, StringBuilder builder) + { + builder.Append('[') + .Append(transform.GetSiblingIndex()) + .Append('|') + .Append(transform.name) + .Append('|') + .Append(transform.gameObject.activeSelf ? '1' : '0'); + foreach ( + var component in transform.gameObject + .GetComponents() + .Where(value => value != null) + ) + { + builder.Append('|') + .Append(component.GetType().AssemblyQualifiedName); + } + + builder.Append(']'); + for (var i = 0; i < transform.childCount; i++) + Append(transform.GetChild(i), builder); + } +} diff --git a/Runtime/PrefabPatching/PrefabPatchStructure.cs.meta b/Runtime/PrefabPatching/PrefabPatchStructure.cs.meta new file mode 100644 index 0000000..f744673 --- /dev/null +++ b/Runtime/PrefabPatching/PrefabPatchStructure.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: bc85d159dc8e85540bde3db0e49075e8 \ No newline at end of file From 2b0f842dab5df14eabb7c1d6c98f97d800ed8881 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Bure=C5=A1?= Date: Tue, 28 Jul 2026 19:05:12 +0200 Subject: [PATCH 02/17] Expose prefab patch schema to editor frontends --- Runtime/Core/CoreModule.cs | 2 +- .../PatchManager.PrefabPatching.asmdef | 17 +++++++++++++++++ .../PatchManager.PrefabPatching.asmdef.meta | 7 +++++++ Runtime/PrefabPatching/PrefabPatchRuntime.cs | 11 +++++------ 4 files changed, 30 insertions(+), 7 deletions(-) create mode 100644 Runtime/PrefabPatching/PatchManager.PrefabPatching.asmdef create mode 100644 Runtime/PrefabPatching/PatchManager.PrefabPatching.asmdef.meta diff --git a/Runtime/Core/CoreModule.cs b/Runtime/Core/CoreModule.cs index 2a329e5..4167ce9 100644 --- a/Runtime/Core/CoreModule.cs +++ b/Runtime/Core/CoreModule.cs @@ -169,7 +169,7 @@ private void RegisterResourceLocator(Action resolve, Action reject) } Locators.Register(new ArchiveResourceLocator()); - PrefabPatchRuntime.RegisterResourceProvider(); + Locators.Register(PrefabPatchRuntime.RegisterResourceProvider()); GameManager.Instance.Game.UI.UitkLoadingCurtain.Data.PatchManagerDefinitionsModifiedCount = CacheManager.Inventory.DefinitionCount; GameManager.Instance.Game.UI.UitkLoadingCurtain.Data.PatchManagerNewAssetCount = 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/PrefabPatchRuntime.cs b/Runtime/PrefabPatching/PrefabPatchRuntime.cs index 204521c..2209991 100644 --- a/Runtime/PrefabPatching/PrefabPatchRuntime.cs +++ b/Runtime/PrefabPatching/PrefabPatchRuntime.cs @@ -3,8 +3,6 @@ using System.Diagnostics; using System.IO; using System.Linq; -using PatchManager.Core.Assets; -using PatchManager.Shared; using UnityEngine; using UnityEngine.AddressableAssets; using UnityEngine.ResourceManagement.AsyncOperations; @@ -196,7 +194,7 @@ var group in manifests { stopwatch.Stop(); CurrentMetrics.StartupMilliseconds = stopwatch.ElapsedMilliseconds; - Logging.LogError(exception); + UnityEngine.Debug.LogException(exception); reject( "Patch Manager prefab discovery failed: " + exception.Message @@ -208,7 +206,8 @@ var group in manifests /// Adds the public GameObject provider and locator to Patch Manager's /// supported KSP AssetProvider interception boundary. /// - public static void RegisterResourceProvider() + public static UnityEngine.AddressableAssets.ResourceLocators.IResourceLocator + RegisterResourceProvider() { var providers = Addressables.ResourceManager.ResourceProviders; if ( @@ -221,7 +220,7 @@ public static void RegisterResourceProvider() } _locator = new PrefabPatchResourceLocator(Entries); - Locators.Register(_locator); + return _locator; } internal static bool TryProvide( @@ -339,7 +338,7 @@ var reference in entry.Plan.Operations entry.Failed = true; entry.Failure = exception; failure = exception; - Logging.LogError( + UnityEngine.Debug.LogError( $"Prefab composition failed for '{address}': {exception}" ); WriteSummary(); From ad38751793f8e115e5b197b7764fd07882b3766e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Bure=C5=A1?= Date: Tue, 28 Jul 2026 20:01:45 +0200 Subject: [PATCH 03/17] Stabilize prefab structure fingerprints --- Runtime/PrefabPatching/PrefabPatchComposer.cs | 6 ++++- Runtime/PrefabPatching/PrefabPatchModel.cs | 1 + Runtime/PrefabPatching/PrefabPatchObjectId.cs | 27 ++++++++++--------- .../PrefabPatchObjectId.cs.meta | 11 +++++++- .../PrefabPatching/PrefabPatchStructure.cs | 14 +++++++--- 5 files changed, 41 insertions(+), 18 deletions(-) diff --git a/Runtime/PrefabPatching/PrefabPatchComposer.cs b/Runtime/PrefabPatching/PrefabPatchComposer.cs index 3730032..5c72b59 100644 --- a/Runtime/PrefabPatching/PrefabPatchComposer.cs +++ b/Runtime/PrefabPatching/PrefabPatchComposer.cs @@ -55,7 +55,11 @@ IReadOnlyDictionary references $"Stock prefab '{plan.TargetPrefab.Address}' structural " + $"fingerprint changed. Expected " + $"'{plan.TargetPrefab.StructuralFingerprint}', got " - + $"'{actualFingerprint}'. Recompile or repair the patch." + + $"'{actualFingerprint}'. Expected structure: " + + $"'{plan.TargetPrefab.StructuralDescription}'. " + + $"Actual structure: " + + $"'{PrefabPatchStructure.Describe(prefab)}'. " + + "Recompile or repair the patch." ); } diff --git a/Runtime/PrefabPatching/PrefabPatchModel.cs b/Runtime/PrefabPatching/PrefabPatchModel.cs index 1d5419d..f557bf4 100644 --- a/Runtime/PrefabPatching/PrefabPatchModel.cs +++ b/Runtime/PrefabPatching/PrefabPatchModel.cs @@ -94,6 +94,7 @@ public sealed class PrefabPatchPrefabIdentity public string SourceSerializedFileName; public long SourcePathId; public string AssetType; + public string StructuralDescription; public string StructuralFingerprint; [JsonIgnore] diff --git a/Runtime/PrefabPatching/PrefabPatchObjectId.cs b/Runtime/PrefabPatching/PrefabPatchObjectId.cs index 9d1cc3b..dc028da 100644 --- a/Runtime/PrefabPatching/PrefabPatchObjectId.cs +++ b/Runtime/PrefabPatching/PrefabPatchObjectId.cs @@ -1,19 +1,20 @@ 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 +namespace PatchManager.PrefabPatching { - [SerializeField] private string _id; - - public string Id + /// + /// 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 { - get => _id; - set => _id = value; + [SerializeField] private string _id; + + public string Id + { + get => _id; + set => _id = value; + } } } diff --git a/Runtime/PrefabPatching/PrefabPatchObjectId.cs.meta b/Runtime/PrefabPatching/PrefabPatchObjectId.cs.meta index 9430efb..3fcc5e8 100644 --- a/Runtime/PrefabPatching/PrefabPatchObjectId.cs.meta +++ b/Runtime/PrefabPatching/PrefabPatchObjectId.cs.meta @@ -1,2 +1,11 @@ fileFormatVersion: 2 -guid: 93da1f45b46a3f243ae12acca9ebff3a \ No newline at end of file +guid: 93da1f45b46a3f243ae12acca9ebff3a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/PrefabPatching/PrefabPatchStructure.cs b/Runtime/PrefabPatching/PrefabPatchStructure.cs index 46d464c..698f1ff 100644 --- a/Runtime/PrefabPatching/PrefabPatchStructure.cs +++ b/Runtime/PrefabPatching/PrefabPatchStructure.cs @@ -13,12 +13,20 @@ namespace PatchManager.PrefabPatching; public static class PrefabPatchStructure { public static string Calculate(GameObject root) + { + var description = Describe(root); + return description == null + ? null + : PrefabPatchJson.Sha256(description); + } + + public static string Describe(GameObject root) { if (root == null) return null; var builder = new StringBuilder(); Append(root.transform, builder); - return PrefabPatchJson.Sha256(builder.ToString()); + return builder.ToString(); } public static int[] GetSiblingPath(Transform root, Transform target) @@ -57,7 +65,7 @@ private static void Append(Transform transform, StringBuilder builder) builder.Append('[') .Append(transform.GetSiblingIndex()) .Append('|') - .Append(transform.name) + .Append(transform.parent == null ? "" : transform.name) .Append('|') .Append(transform.gameObject.activeSelf ? '1' : '0'); foreach ( @@ -67,7 +75,7 @@ var component in transform.gameObject ) { builder.Append('|') - .Append(component.GetType().AssemblyQualifiedName); + .Append(component.GetType().FullName); } builder.Append(']'); From 6fe6fdc954ce71764efc9e468ba0e96246f5a841 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Bure=C5=A1?= Date: Wed, 29 Jul 2026 01:51:55 +0200 Subject: [PATCH 04/17] Support RectTransform prefab patch values --- Runtime/PrefabPatching/PrefabPatchComposer.cs | 108 ++++++++++++++++++ 1 file changed, 108 insertions(+) diff --git a/Runtime/PrefabPatching/PrefabPatchComposer.cs b/Runtime/PrefabPatching/PrefabPatchComposer.cs index 5c72b59..aa55417 100644 --- a/Runtime/PrefabPatching/PrefabPatchComposer.cs +++ b/Runtime/PrefabPatching/PrefabPatchComposer.cs @@ -383,6 +383,19 @@ object value 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": @@ -418,6 +431,101 @@ object value } } + 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, From 3967ebefc1d71b066c2c0c848c1599b55aea601e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Bure=C5=A1?= Date: Wed, 29 Jul 2026 05:49:48 +0200 Subject: [PATCH 05/17] Discover prefab patches across loaded catalogs --- Runtime/PrefabPatching/PrefabPatchRuntime.cs | 40 ++++++++++++++------ 1 file changed, 28 insertions(+), 12 deletions(-) diff --git a/Runtime/PrefabPatching/PrefabPatchRuntime.cs b/Runtime/PrefabPatching/PrefabPatchRuntime.cs index 2209991..b82f112 100644 --- a/Runtime/PrefabPatching/PrefabPatchRuntime.cs +++ b/Runtime/PrefabPatching/PrefabPatchRuntime.cs @@ -98,17 +98,8 @@ Action reject try { var manifests = new List(Registered); - var locationsHandle = - Addressables.LoadResourceLocationsAsync( - PrefabPatchSchema.AddressablesLabel, - typeof(TextAsset) - ); - var locations = locationsHandle.WaitForCompletion(); - if ( - locationsHandle.Status == AsyncOperationStatus.Succeeded - && locations != null - && locations.Count > 0 - ) + var locations = FindManifestLocations(); + if (locations.Count > 0) { var manifestHandle = Addressables.LoadAssetsAsync( locations, @@ -146,7 +137,6 @@ Action reject Addressables.Release(manifestHandle); } - Addressables.Release(locationsHandle); CurrentMetrics.DiscoveredManifestCount = manifests.Count; var cache = new PrefabPatchPlanCache(PlanCacheDirectory); Entries.Clear(); @@ -202,6 +192,32 @@ var group in manifests } } + private static List FindManifestLocations() + { + return Addressables.ResourceLocators + .SelectMany(locator => + locator.Locate( + PrefabPatchSchema.AddressablesLabel, + typeof(TextAsset), + out var locations + ) + ? locations + : Array.Empty() + ) + .Where(location => location != null) + .GroupBy( + location => + $"{location.ProviderId}\0{location.InternalId}\0" + + $"{location.PrimaryKey}\0" + + $"{location.ResourceType?.AssemblyQualifiedName}", + StringComparer.Ordinal + ) + .Select(group => group.First()) + .OrderBy(location => location.PrimaryKey, StringComparer.Ordinal) + .ThenBy(location => location.InternalId, StringComparer.Ordinal) + .ToList(); + } + /// /// Adds the public GameObject provider and locator to Patch Manager's /// supported KSP AssetProvider interception boundary. From b4b0c7a99f079b14d072ab6098739a9f9cf2baef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Bure=C5=A1?= Date: Wed, 29 Jul 2026 12:47:05 +0200 Subject: [PATCH 06/17] Restore prefab patch loading actions between play sessions --- Runtime/Core/CoreModule.cs | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/Runtime/Core/CoreModule.cs b/Runtime/Core/CoreModule.cs index 4167ce9..753f565 100644 --- a/Runtime/Core/CoreModule.cs +++ b/Runtime/Core/CoreModule.cs @@ -47,6 +47,31 @@ public class CoreModule : BaseModule /// (after the per-plugin body phase) rather than here in Init, which runs in PM's Awake, before any body. /// public override void Init() + { + RegisterLoadingActions(); + } + + [RuntimeInitializeOnLoadMethod( + RuntimeInitializeLoadType.AfterAssembliesLoaded + )] + private static void RestoreLoadingActionsWithoutDomainReload() + { + // SpaceWarp resets GeneralLoadingActions at SubsystemRegistration. + // With domain reload disabled PatchManager's MonoBehaviour and module + // instances survive, so Awake/Init do not run again to repopulate it. + // AfterAssembliesLoaded runs after that reset and before SpaceWarp + // builds the new play session's loading flow. + foreach (var module in ModuleManager.Modules) + { + if (module is CoreModule core) + { + core.RegisterLoadingActions(); + return; + } + } + } + + private void RegisterLoadingActions() { SpaceWarp2.API.Loading.Loading.GeneralLoadingActions.Insert(0, () => new FlowAction("Patch Manager: Closing Registration", CloseRegistration)); From 25b3fa4bbb65dc47a067e07c1d8a768517b91b0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Bure=C5=A1?= Date: Wed, 29 Jul 2026 15:42:25 +0200 Subject: [PATCH 07/17] Fix prefab patch runtime lifecycle --- Editor/PrefabPatchPlayModeCleanup.cs | 20 ++ Editor/PrefabPatchPlayModeCleanup.cs.meta | 2 + Runtime/PrefabPatching/PrefabPatchComposer.cs | 12 + Runtime/PrefabPatching/PrefabPatchRuntime.cs | 340 +++++++++++++----- .../PrefabPatching/PrefabPatchStructure.cs | 14 +- 5 files changed, 303 insertions(+), 85 deletions(-) create mode 100644 Editor/PrefabPatchPlayModeCleanup.cs create mode 100644 Editor/PrefabPatchPlayModeCleanup.cs.meta 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/Runtime/PrefabPatching/PrefabPatchComposer.cs b/Runtime/PrefabPatching/PrefabPatchComposer.cs index aa55417..2286220 100644 --- a/Runtime/PrefabPatching/PrefabPatchComposer.cs +++ b/Runtime/PrefabPatching/PrefabPatchComposer.cs @@ -170,7 +170,19 @@ out var reference } 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); diff --git a/Runtime/PrefabPatching/PrefabPatchRuntime.cs b/Runtime/PrefabPatching/PrefabPatchRuntime.cs index b82f112..501b643 100644 --- a/Runtime/PrefabPatching/PrefabPatchRuntime.cs +++ b/Runtime/PrefabPatching/PrefabPatchRuntime.cs @@ -54,6 +54,7 @@ internal sealed class Entry StringComparer.Ordinal ); private static PrefabPatchResourceLocator _locator; + private static GameObject _effectivePrefabRoot; public static bool RegistrationOpen { get; private set; } = true; public static Metrics CurrentMetrics { get; private set; } = new(); @@ -105,91 +106,137 @@ Action reject locations, null ); - var assets = manifestHandle.WaitForCompletion(); - if (manifestHandle.Status != AsyncOperationStatus.Succeeded) - { - throw manifestHandle.OperationException - ?? new InvalidOperationException( - "Prefab patch manifest load failed." - ); - } - - foreach (var asset in assets.Where(value => value != null)) + manifestHandle.Completed += operation => { try { - manifests.Add( - PrefabPatchJson.Deserialize( - asset.text - ) + if (operation.Status != AsyncOperationStatus.Succeeded) + { + throw operation.OperationException + ?? new InvalidOperationException( + "Prefab patch manifest load failed." + ); + } + + foreach ( + var asset in operation + .Result.Where(value => value != null) + ) + { + try + { + manifests.Add( + PrefabPatchJson.Deserialize< + PrefabPatchManifest + >(asset.text) + ); + } + catch (Exception exception) + { + throw new InvalidDataException( + $"Could not parse prefab patch manifest " + + $"'{asset.name}'.", + exception + ); + } + } + + ResolveDiscoveredManifests( + manifests, + activeModIds, + stopwatch, + resolve ); } catch (Exception exception) { - throw new InvalidDataException( - $"Could not parse prefab patch manifest " - + $"'{asset.name}'.", - exception - ); + RejectDiscovery(stopwatch, reject, exception); } - } - - Addressables.Release(manifestHandle); - } - - 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.CanonicalKey, - 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 + finally { - Plan = result.Plan - }; - } + Addressables.Release(operation); + } + }; + return; } - CurrentMetrics.ResolvedPlanCount = Entries.Count; - WriteSummary(); - stopwatch.Stop(); - CurrentMetrics.StartupMilliseconds = stopwatch.ElapsedMilliseconds; - CurrentMetrics.MemoryAfterBytes = - Profiler.GetTotalAllocatedMemoryLong(); - resolve(); + ResolveDiscoveredManifests( + manifests, + activeModIds, + stopwatch, + resolve + ); } catch (Exception exception) { - stopwatch.Stop(); - CurrentMetrics.StartupMilliseconds = stopwatch.ElapsedMilliseconds; - UnityEngine.Debug.LogException(exception); - reject( - "Patch Manager prefab discovery failed: " - + exception.Message + RejectDiscovery(stopwatch, reject, exception); + } + } + + 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.CanonicalKey, + 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() @@ -324,14 +371,19 @@ var reference in entry.Plan.Operations entry.References.Add(reference.Address, value); } + var effectivePrefab = CreateEffectivePrefab(stock); var result = PrefabPatchComposer.ApplySynchronously( - stock, + effectivePrefab, entry.Plan, entry.References ); if (!result.Success) + { + Object.DestroyImmediate(effectivePrefab); throw new InvalidOperationException(result.Failure); - entry.EffectivePrefab = stock; + } + + entry.EffectivePrefab = effectivePrefab; stopwatch.Stop(); CurrentMetrics.FirstCompositionMilliseconds += stopwatch.ElapsedMilliseconds; @@ -345,7 +397,7 @@ var reference in entry.Plan.Operations ); CurrentMetrics.MemoryAfterBytes = Profiler.GetTotalAllocatedMemoryLong(); - prefab = stock; + prefab = effectivePrefab; return true; } catch (Exception exception) @@ -362,6 +414,32 @@ var reference in entry.Plan.Operations } } + private static GameObject CreateEffectivePrefab(GameObject stock) + { + if (_effectivePrefabRoot == null) + { + _effectivePrefabRoot = new GameObject( + "PatchManager Effective Prefabs" + ); + _effectivePrefabRoot.hideFlags = HideFlags.HideAndDontSave; + _effectivePrefabRoot.SetActive(false); + Object.DontDestroyOnLoad(_effectivePrefabRoot); + } + + // AssetBundle assets are read-only native objects. Patching them in + // place is not stable: Unity can restore removed components before a + // later instantiation. Keep an inactive, session-owned template + // instead. Parenting under an inactive root prevents prefab + // MonoBehaviours from initializing while the patch is composed. + var effectivePrefab = Object.Instantiate( + stock, + _effectivePrefabRoot.transform, + false + ); + effectivePrefab.name = stock.name; + return effectivePrefab; + } + private static IResourceLocation ResolveOriginalLocation( string address, Type type @@ -489,12 +567,43 @@ var pair in Entries.OrderBy( [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] private static void ResetStaticState() { + ReleaseSessionResources(); Registered.Clear(); Entries.Clear(); _locator = null; RegistrationOpen = true; CurrentMetrics = new Metrics(); } + + /// + /// Releases effective prefab templates and the Addressables handles that + /// keep their stock assets and referenced objects alive. + /// + public static void ReleaseSessionResources() + { + foreach (var entry in Entries.Values) + { + if (entry.EffectivePrefab != null) + Object.DestroyImmediate(entry.EffectivePrefab); + entry.EffectivePrefab = null; + + foreach (var handle in entry.ReferenceHandles) + { + if (handle.IsValid()) + handle.Release(); + } + + entry.ReferenceHandles.Clear(); + entry.References.Clear(); + if (entry.StockHandle.IsValid()) + entry.StockHandle.Release(); + entry.StockHandle = default; + } + + if (_effectivePrefabRoot != null) + Object.DestroyImmediate(_effectivePrefabRoot); + _effectivePrefabRoot = null; + } } internal sealed class PrefabPatchResourceLocator : @@ -521,7 +630,6 @@ out IList locations var address = key?.ToString(); if ( string.IsNullOrWhiteSpace(address) - || !_entries.ContainsKey(address) || ( type != typeof(object) && type != typeof(Object) @@ -534,16 +642,88 @@ out IList locations return false; } - locations = new IResourceLocation[] + if (_entries.ContainsKey(address)) { - new ResourceLocationBase( - "prefab-patch:" + address, - address, + locations = new IResourceLocation[] { CreatePatchLocation(address) }; + return true; + } + + var resolved = new List(); + var identities = new HashSet(StringComparer.Ordinal); + var replacedAny = false; + foreach (var locator in Addressables.ResourceLocators) + { + if ( + !locator.Locate(key, typeof(GameObject), out var sourceLocations) + || sourceLocations == null + ) + continue; + + foreach (var sourceLocation in sourceLocations) + { + var sourceAddress = sourceLocation?.PrimaryKey; + IResourceLocation resolvedLocation = sourceLocation; + if ( + !string.IsNullOrWhiteSpace(sourceAddress) + && _entries.ContainsKey(sourceAddress) + ) + { + resolvedLocation = CreatePatchLocation(sourceAddress); + replacedAny = true; + } + + if ( + resolvedLocation != null + && identities.Add(GetLocationIdentity(resolvedLocation)) + ) + resolved.Add(resolvedLocation); + } + } + + locations = replacedAny + ? resolved + : Array.Empty(); + return replacedAny; + } + + private static IResourceLocation CreatePatchLocation(string address) + { + return new ResourceLocationBase( + "prefab-patch:" + address, + address, + typeof(PrefabPatchResourceProvider).FullName, + typeof(GameObject) + ); + } + + private static string GetLocationIdentity(IResourceLocation location) + { + if ( + string.Equals( + location.ProviderId, typeof(PrefabPatchResourceProvider).FullName, - typeof(GameObject) + StringComparison.Ordinal ) - }; - return true; + ) + return "patch|" + location.PrimaryKey; + + var dependencies = location.Dependencies == null + ? string.Empty + : string.Join( + ";", + location.Dependencies.Select(dependency => + (dependency?.PrimaryKey ?? string.Empty) + + "|" + + (dependency?.InternalId ?? string.Empty) + ) + ); + return (location.PrimaryKey ?? string.Empty) + + "|" + + (location.InternalId ?? string.Empty) + + "|" + + (location.ResourceType?.AssemblyQualifiedName ?? string.Empty) + + "|" + + dependencies; } } diff --git a/Runtime/PrefabPatching/PrefabPatchStructure.cs b/Runtime/PrefabPatching/PrefabPatchStructure.cs index 698f1ff..5c036e0 100644 --- a/Runtime/PrefabPatching/PrefabPatchStructure.cs +++ b/Runtime/PrefabPatching/PrefabPatchStructure.cs @@ -25,7 +25,7 @@ public static string Describe(GameObject root) if (root == null) return null; var builder = new StringBuilder(); - Append(root.transform, builder); + Append(root.transform, builder, true); return builder.ToString(); } @@ -60,12 +60,16 @@ public static Transform Resolve(Transform root, IEnumerable path) return current; } - private static void Append(Transform transform, StringBuilder builder) + private static void Append( + Transform transform, + StringBuilder builder, + bool isRoot + ) { builder.Append('[') - .Append(transform.GetSiblingIndex()) + .Append(isRoot ? 0 : transform.GetSiblingIndex()) .Append('|') - .Append(transform.parent == null ? "" : transform.name) + .Append(isRoot ? "" : transform.name) .Append('|') .Append(transform.gameObject.activeSelf ? '1' : '0'); foreach ( @@ -80,6 +84,6 @@ var component in transform.gameObject builder.Append(']'); for (var i = 0; i < transform.childCount; i++) - Append(transform.GetChild(i), builder); + Append(transform.GetChild(i), builder, false); } } From 03c96e46ba0e5f3140b47ae36dbd221fc4ec99ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Bure=C5=A1?= Date: Wed, 29 Jul 2026 17:06:58 +0200 Subject: [PATCH 08/17] Load prefab patch manifests directly in editor --- Runtime/PrefabPatching/PrefabPatchRuntime.cs | 55 ++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/Runtime/PrefabPatching/PrefabPatchRuntime.cs b/Runtime/PrefabPatching/PrefabPatchRuntime.cs index 501b643..049f83d 100644 --- a/Runtime/PrefabPatching/PrefabPatchRuntime.cs +++ b/Runtime/PrefabPatching/PrefabPatchRuntime.cs @@ -9,6 +9,9 @@ using UnityEngine.ResourceManagement.ResourceLocations; using UnityEngine.ResourceManagement.ResourceProviders; using UnityEngine.Profiling; +#if UNITY_EDITOR +using UnityEditor; +#endif using Object = UnityEngine.Object; namespace PatchManager.PrefabPatching; @@ -99,6 +102,16 @@ Action reject try { var manifests = new List(Registered); +#if UNITY_EDITOR + manifests.AddRange(LoadEditorProjectManifests()); + ResolveDiscoveredManifests( + manifests, + activeModIds, + stopwatch, + resolve + ); + return; +#else var locations = FindManifestLocations(); if (locations.Count > 0) { @@ -166,6 +179,7 @@ var asset in operation stopwatch, resolve ); +#endif } catch (Exception exception) { @@ -173,6 +187,47 @@ var asset in operation } } +#if UNITY_EDITOR + private static IEnumerable LoadEditorProjectManifests() + { + foreach ( + var path in AssetDatabase + .GetAllAssetPaths() + .Where(path => + path.EndsWith( + ".prefabpatch.json", + StringComparison.OrdinalIgnoreCase + ) + ) + .OrderBy(path => path, StringComparer.Ordinal) + ) + { + var asset = AssetDatabase.LoadAssetAtPath(path); + if (asset == null) + continue; + + PrefabPatchManifest manifest; + try + { + manifest = + PrefabPatchJson.Deserialize( + asset.text + ); + } + catch (Exception exception) + { + throw new InvalidDataException( + $"Could not parse prefab patch manifest '{path}'.", + exception + ); + } + + if (manifest != null) + yield return manifest; + } + } +#endif + private static void ResolveDiscoveredManifests( IReadOnlyCollection manifests, ISet activeModIds, From ae9c9f08c322af208827ac6e4d9d32f24bb36bde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Bure=C5=A1?= Date: Thu, 30 Jul 2026 11:59:07 +0200 Subject: [PATCH 09/17] Support arbitrary prefab patch components --- PREFAB_PATCHING.md | 160 ++++ PREFAB_PATCHING.md.meta | 2 + README.md | 3 + .../LuaPatching/Builtin/PatchManagerCore.cs | 37 + .../Builtin/PrefabPatchLuaBuilder.cs | 337 ++++++++ .../Builtin/PrefabPatchLuaBuilder.cs.meta | 2 + Runtime/PrefabPatching/PrefabPatchBuilder.cs | 122 ++- Runtime/PrefabPatching/PrefabPatchComposer.cs | 802 ++++++++++++++++-- .../PrefabPatchFragmentBuilder.cs | 211 +++++ .../PrefabPatchFragmentBuilder.cs.meta | 2 + Runtime/PrefabPatching/PrefabPatchModel.cs | 100 ++- Runtime/PrefabPatching/PrefabPatchResolver.cs | 189 ++++- Runtime/PrefabPatching/PrefabPatchRuntime.cs | 28 +- Stubs/pm-prefabs.d.lua | 152 ++++ Stubs/pm-prefabs.d.lua.meta | 2 + 15 files changed, 2025 insertions(+), 124 deletions(-) create mode 100644 PREFAB_PATCHING.md create mode 100644 PREFAB_PATCHING.md.meta create mode 100644 Runtime/LuaPatching/Builtin/PrefabPatchLuaBuilder.cs create mode 100644 Runtime/LuaPatching/Builtin/PrefabPatchLuaBuilder.cs.meta create mode 100644 Runtime/PrefabPatching/PrefabPatchFragmentBuilder.cs create mode 100644 Runtime/PrefabPatching/PrefabPatchFragmentBuilder.cs.meta create mode 100644 Stubs/pm-prefabs.d.lua create mode 100644 Stubs/pm-prefabs.d.lua.meta diff --git a/PREFAB_PATCHING.md b/PREFAB_PATCHING.md new file mode 100644 index 0000000..bcd21a2 --- /dev/null +++ b/PREFAB_PATCHING.md @@ -0,0 +1,160 @@ +# Declarative prefab patches + +Patch Manager prefab patches modify stock Addressable prefabs before the game +instantiates them. Visual prefab-variant authoring, C#, and Lua all generate the +same versioned JSON manifest and use the same resolver, ordering rules, cache, +and runtime composer. + +The schema is type-agnostic for Unity `Component` types. An added component is +identified by its assembly-qualified CLR type and contains: + +- every serialized value, using Unity `SerializedProperty` paths; +- every Unity object reference as a separate fixup; +- a stable patch-local component ID. + +The runtime constructs all patch-owned objects and components beneath an +inactive root, applies collection sizes and serialized values, and then resolves +references. This permits references between sibling objects, between arbitrary +components, into nested serialized arrays/lists, to stock prefab objects, and +to mod or stock Addressables. It also prevents `OnEnable` from observing a +partially hydrated effective prefab. + +The first implementation was never released. Schema 2 is therefore the only +accepted schema; there is no schema-1 migration path. Recompile any local +experimental manifests from their authoring variants. + +## Visual authoring + +In Redux SDK: + +1. Import the stock prefab with BundleKit's Linked Addressables Browser. +2. Right-click the linked prefab and choose **Redux SDK > Create Prefab Patch + from Linked Prefab**. +3. Edit the generated prefab variant normally. +4. Add `PrefabPatchAuthoringObjectId` to every newly added GameObject and give + each one a stable ID. +5. Save the variant. With auto-compile enabled, its `.prefabpatch.json` manifest + is rebuilt immediately. + +New `GameObject`, `RectTransform`, UGUI, TMP, game, and mod component types use +the same general compiler. The compiler records the complete serialized +component state; it does not select from a component whitelist. Local +GameObject/component references are translated to patch-owned IDs. Linked game +assets and mod-owned Addressables remain address references and are loaded +before composition. + +## C# authoring + +Register C# patches before `PrefabPatchRuntime.CloseRegistration()`: + +```csharp +using PatchManager.PrefabPatching; +using UnityEngine; +using UnityEngine.UI; + +var button = PrefabPatchComponentBuilder + .For 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; + } + public sealed class Result { public bool Success; @@ -24,6 +48,9 @@ public sealed class Result public Dictionary PatchOwnedObjects = new( StringComparer.Ordinal ); + public Dictionary PatchOwnedComponents = new( + StringComparer.Ordinal + ); } public static Result ApplySynchronously( @@ -34,6 +61,9 @@ IReadOnlyDictionary references { var stopwatch = Stopwatch.StartNew(); var result = new Result(); + Transform originalParent = null; + var originalSiblingIndex = 0; + GameObject safetyRoot = null; try { if (prefab == null) @@ -63,7 +93,19 @@ IReadOnlyDictionary references ); } + 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( @@ -71,12 +113,39 @@ IReadOnlyDictionary references 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) @@ -85,6 +154,16 @@ IReadOnlyDictionary references } 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; } @@ -97,6 +176,8 @@ private static void ApplyOne( PrefabPatchOperation operation, IReadOnlyDictionary references, IDictionary patchOwned, + IDictionary patchComponents, + ICollection pendingReferences, ref bool deferredDestroy, bool immediateDestroy ) @@ -105,7 +186,14 @@ bool immediateDestroy { var parentObject = operation.Target == null ? root - : AsGameObject(Resolve(root, operation.Target, patchOwned)); + : AsGameObject( + Resolve( + root, + operation.Target, + patchOwned, + patchComponents + ) + ); if (parentObject == null) throw MissingTarget(operation); CreateFragment( @@ -113,12 +201,20 @@ bool immediateDestroy operation.AddedObject, parentObject.transform, references, - patchOwned + patchOwned, + patchComponents, + pendingReferences, + operation.OperationId ); return; } - var target = Resolve(root, operation.Target, patchOwned); + var target = Resolve( + root, + operation.Target, + patchOwned, + patchComponents + ); if (target == null) throw MissingTarget(operation); switch (operation.Kind) @@ -127,23 +223,20 @@ bool immediateDestroy SetValue(target, operation.PropertyPath, operation.Value); return; case PrefabPatchOperationKind.SetObjectReference: - if ( - operation.ObjectReference == null - || !references.TryGetValue( - operation.ObjectReference.Address, - out var reference - ) - || reference == null - ) - { + if (operation.ObjectReference == null) throw new InvalidOperationException( - $"Operation '{operation.OperationId}' could not resolve " - + $"object reference " - + $"'{operation.ObjectReference?.Address}'." + $"Operation '{operation.OperationId}' has no object " + + "reference payload." ); - } - - SetRawValue(target, operation.PropertyPath, reference); + pendingReferences.Add( + new PendingReference + { + Target = target, + PropertyPath = operation.PropertyPath, + Reference = operation.ObjectReference, + OperationId = operation.OperationId + } + ); return; case PrefabPatchOperationKind.SetActive: AsGameObject(target)?.SetActive( @@ -157,7 +250,12 @@ out var reference AddComponent( AsGameObject(target), operation.AddedComponent, - references + references, + patchOwned, + patchComponents, + pendingReferences, + operation.PatchId, + operation.OperationId ); return; case PrefabPatchOperationKind.RemoveComponent: @@ -200,9 +298,22 @@ out var reference private static Object Resolve( GameObject root, PrefabPatchObjectTarget target, - IDictionary patchOwned + 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) { @@ -245,7 +356,10 @@ private static GameObject CreateFragment( PrefabPatchObjectFragment fragment, Transform parent, IReadOnlyDictionary references, - IDictionary patchOwned + IDictionary patchOwned, + IDictionary patchComponents, + ICollection pendingReferences, + string operationId ) { if (fragment == null || string.IsNullOrWhiteSpace(fragment.ObjectId)) @@ -258,8 +372,33 @@ IDictionary patchOwned $"Patch-owned object '{key}' already exists." ); - var gameObject = new GameObject(fragment.Name ?? fragment.ObjectId); + 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 @@ -272,79 +411,200 @@ IDictionary patchOwned 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); + AddComponent( + gameObject, + component, + references, + patchOwned, + patchComponents, + pendingReferences, + patchId, + operationId + ); foreach (var child in fragment.Children) - CreateFragment(patchId, child, gameObject.transform, references, patchOwned); + CreateFragment( + patchId, + child, + gameObject.transform, + references, + patchOwned, + patchComponents, + pendingReferences, + operationId + ); return gameObject; } private static Component AddComponent( GameObject target, PrefabPatchComponentFragment fragment, - IReadOnlyDictionary references + 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." ); - switch (fragment.Kind) + 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)) { - case PrefabPatchComponentKind.BoxCollider: - { - var value = target.AddComponent(); - value.enabled = fragment.Enabled; - value.center = ToVector3(fragment.Center, Vector3.zero); - value.size = ToVector3(fragment.Size, Vector3.one); - value.isTrigger = fragment.IsTrigger; - return value; - } - case PrefabPatchComponentKind.SphereCollider: - { - var value = target.AddComponent(); - value.enabled = fragment.Enabled; - value.center = ToVector3(fragment.Center, Vector3.zero); - value.radius = (float)fragment.Radius; - value.isTrigger = fragment.IsTrigger; - return value; - } - case PrefabPatchComponentKind.MeshFilter: - { - var value = target.AddComponent(); - if (fragment.Mesh != null) - { - if ( - !references.TryGetValue( - fragment.Mesh.Address, - out var reference - ) - || reference is not Mesh mesh - ) - { - throw new InvalidOperationException( - $"Could not resolve Mesh reference " - + $"'{fragment.Mesh.Address}'." - ); - } + 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; + } - value.sharedMesh = mesh; + 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 } + ); + } + } - return value; - } - case PrefabPatchComponentKind.MeshRenderer: - return target.AddComponent(); - default: - throw new NotSupportedException( - $"Added component kind '{fragment.Kind}' is unsupported." - ); + 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, @@ -355,6 +615,15 @@ PrefabPatchValue value 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)); } @@ -544,28 +813,280 @@ private static void SetMemberPath( object value ) { - var segments = path.Split('.'); + 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, + IReadOnlyList segments, int index, object value ) { if (current == null) throw new InvalidOperationException( - $"Cannot traverse null while setting '{string.Join(".", segments)}'." + $"Cannot traverse null while setting " + + $"'{string.Join(".", segments)}'." ); - var member = FindMember(current.GetType(), segments[index]); + var segment = segments[index]; + var member = FindMember(current.GetType(), segment.Name); if (member == null) throw new MissingMemberException( current.GetType().FullName, - segments[index] + 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); @@ -573,9 +1094,8 @@ object value return current; } - var child = GetMemberValue(member, current); var updatedChild = SetMemberRecursive( - child, + memberValue, segments, index + 1, value @@ -585,6 +1105,15 @@ object value 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 = @@ -601,6 +1130,36 @@ private static MemberInfo FindMember(Type type, string name) 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; } @@ -628,6 +1187,34 @@ object 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) @@ -639,6 +1226,56 @@ private static object ConvertForType(object value, Type targetType) ); } + 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 { @@ -671,6 +1308,7 @@ private static object ConvertValue(PrefabPatchValue value) => (float)value.Z, (float)value.W ), + PrefabPatchValueKind.Json => value, _ => throw new NotSupportedException( $"Typed value kind '{value.Kind}' is unsupported." ) @@ -684,6 +1322,14 @@ Vector3 fallback ? 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 diff --git a/Runtime/PrefabPatching/PrefabPatchFragmentBuilder.cs b/Runtime/PrefabPatching/PrefabPatchFragmentBuilder.cs new file mode 100644 index 0000000..89324ae --- /dev/null +++ b/Runtime/PrefabPatching/PrefabPatchFragmentBuilder.cs @@ -0,0 +1,211 @@ +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; + + 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 + }; + } + + public static PrefabPatchComponentBuilder For(string componentId) + where T : Component => + new(componentId, typeof(T)); + + public PrefabPatchComponentBuilder Value( + string propertyPath, + PrefabPatchValue value + ) + { + _fragment.Values.Add( + new PrefabPatchSerializedValue + { + PropertyPath = propertyPath, + Value = value + } + ); + return this; + } + + public PrefabPatchComponentBuilder Reference( + string propertyPath, + PrefabPatchObjectReference reference + ) + { + _fragment.References.Add( + new PrefabPatchSerializedReference + { + PropertyPath = propertyPath, + Reference = reference + } + ); + return this; + } + + public PrefabPatchComponentFragment Build() => _fragment; +} + +/// +/// Fluent C# frontend for an inline patch-owned hierarchy. +/// +public sealed class PrefabPatchObjectBuilder +{ + private readonly PrefabPatchObjectFragment _fragment; + + 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) + }; + } + + public PrefabPatchObjectBuilder RectTransform() + { + _fragment.TransformType = typeof(RectTransform).AssemblyQualifiedName; + return this; + } + + public PrefabPatchObjectBuilder Active(bool value) + { + _fragment.Active = value; + return this; + } + + public PrefabPatchObjectBuilder Layer(int value) + { + _fragment.Layer = value; + return this; + } + + public PrefabPatchObjectBuilder Tag(string value) + { + _fragment.Tag = value; + return this; + } + + public PrefabPatchObjectBuilder Static(bool value = true) + { + _fragment.IsStatic = value; + return this; + } + + public PrefabPatchObjectBuilder Transform( + Vector3 localPosition, + Quaternion localRotation, + Vector3 localScale + ) + { + _fragment.LocalPosition = Vector3Value(localPosition); + _fragment.LocalRotation = QuaternionValue(localRotation); + _fragment.LocalScale = Vector3Value(localScale); + return this; + } + + 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; + } + + public PrefabPatchObjectBuilder Component( + PrefabPatchComponentFragment component + ) + { + _fragment.Components.Add( + component ?? throw new ArgumentNullException(nameof(component)) + ); + return this; + } + + public PrefabPatchObjectBuilder Child( + PrefabPatchObjectFragment child + ) + { + _fragment.Children.Add( + child ?? throw new ArgumentNullException(nameof(child)) + ); + return this; + } + + 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/PrefabPatchModel.cs b/Runtime/PrefabPatching/PrefabPatchModel.cs index f557bf4..1215ef5 100644 --- a/Runtime/PrefabPatching/PrefabPatchModel.cs +++ b/Runtime/PrefabPatching/PrefabPatchModel.cs @@ -10,8 +10,8 @@ namespace PatchManager.PrefabPatching; /// public static class PrefabPatchSchema { - public const int Version = 1; - public const int ComposerVersion = 1; + public const int Version = 2; + public const int ComposerVersion = 2; public const string AddressablesLabel = "patch-manager-prefab-patches"; } @@ -35,7 +35,8 @@ public enum PrefabPatchOrdering public enum PrefabPatchTargetKind { Stock, - PatchOwned + PatchOwned, + PatchComponent } [JsonConverter(typeof(StringEnumConverter))] @@ -68,16 +69,17 @@ public enum PrefabPatchValueKind Vector3, Vector4, Quaternion, - Color + Color, + ArraySize, + ManagedReference, + Json } [JsonConverter(typeof(StringEnumConverter))] -public enum PrefabPatchComponentKind +public enum PrefabPatchObjectReferenceKind { - BoxCollider, - SphereCollider, - MeshFilter, - MeshRenderer + Addressable, + Target } /// @@ -128,13 +130,16 @@ public sealed class PrefabPatchObjectTarget public string ObjectType; public string OwnerPatchId; public string ObjectId; + public string ComponentId; public PrefabPatchRuntimeLocator RuntimeLocator; [JsonIgnore] public string CanonicalKey => Kind == PrefabPatchTargetKind.Stock ? $"stock:{SourceSerializedFileName}:{SourcePathId}:{ObjectType}" - : $"patch:{OwnerPatchId}:{ObjectId}"; + : Kind == PrefabPatchTargetKind.PatchComponent + ? $"patch-component:{OwnerPatchId}:{ComponentId}" + : $"patch:{OwnerPatchId}:{ObjectId}"; } /// @@ -149,6 +154,7 @@ public sealed class PrefabPatchValue public long Integer; public double Float; public string String; + public string SerializedType; public double X; public double Y; public double Z; @@ -168,35 +174,78 @@ public static PrefabPatchValue FromString(string value) => } /// -/// Addressable Unity object reference used by SetObjectReference or a component -/// fragment. Source identity is retained for compatibility diagnostics. +/// Addressable or target-local Unity object reference used by +/// SetObjectReference or a component fragment. Source identity is retained for +/// compatibility diagnostics. /// [Serializable] public sealed class PrefabPatchObjectReference { + public PrefabPatchObjectReferenceKind Kind; public string Address; public string ExpectedType; public string CatalogId; public string SourceBundleFileName; public string SourceSerializedFileName; public long SourcePathId; + public PrefabPatchObjectTarget Target; + + public static PrefabPatchObjectReference FromAddress( + string address, + string expectedType = null + ) => + new() + { + Kind = PrefabPatchObjectReferenceKind.Addressable, + Address = address, + ExpectedType = expectedType + }; + + 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 +{ + public string PropertyPath; + public PrefabPatchValue Value; } /// -/// Constrained component payload used for added patch-owned objects and -/// AddComponent operations. +/// 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 +{ + public string PropertyPath; + public PrefabPatchObjectReference Reference; +} + +/// +/// Serialized payload used for added patch-owned objects and AddComponent +/// operations. /// [Serializable] public sealed class PrefabPatchComponentFragment { - public PrefabPatchComponentKind Kind; public string ComponentId; - public bool Enabled = true; - public PrefabPatchValue Center; - public PrefabPatchValue Size; - public double Radius; - public bool IsTrigger; - public PrefabPatchObjectReference Mesh; + public string ComponentType; + public List Values = new(); + public List References = new(); } /// @@ -208,10 +257,19 @@ public sealed class PrefabPatchObjectFragment { public string ObjectId; public string Name; + public string TransformType; public bool Active = true; + public int Layer; + public string Tag = "Untagged"; + public bool IsStatic; public PrefabPatchValue LocalPosition; public PrefabPatchValue LocalRotation; public PrefabPatchValue LocalScale; + public PrefabPatchValue AnchorMin; + public PrefabPatchValue AnchorMax; + public PrefabPatchValue AnchoredPosition; + public PrefabPatchValue SizeDelta; + public PrefabPatchValue Pivot; public List Components = new(); public List Children = new(); } diff --git a/Runtime/PrefabPatching/PrefabPatchResolver.cs b/Runtime/PrefabPatching/PrefabPatchResolver.cs index d36defa..56d87d0 100644 --- a/Runtime/PrefabPatching/PrefabPatchResolver.cs +++ b/Runtime/PrefabPatching/PrefabPatchResolver.cs @@ -495,6 +495,9 @@ ref bool fatal .Select((manifest, index) => (manifest.PatchId, index)) .ToDictionary(pair => pair.PatchId, pair => pair.index); var introduced = new Dictionary(StringComparer.Ordinal); + var introducedComponents = new Dictionary( + StringComparer.Ordinal + ); var writes = new Dictionary( StringComparer.Ordinal ); @@ -508,7 +511,6 @@ ref bool fatal foreach ( var operation in manifest.Operations .Where(value => value != null) - .OrderBy(value => value.OperationId, StringComparer.Ordinal) ) { operation.PatchId = manifest.PatchId; @@ -546,11 +548,19 @@ var operation in manifest.Operations if ( operation.Target?.Kind - == PrefabPatchTargetKind.PatchOwned + == PrefabPatchTargetKind.PatchOwned + || operation.Target?.Kind + == PrefabPatchTargetKind.PatchComponent ) { var owner = operation.Target.OwnerPatchId; - var objectKey = $"{owner}:{operation.Target.ObjectId}"; + var isComponent = + operation.Target.Kind + == PrefabPatchTargetKind.PatchComponent; + var ownedId = isComponent + ? operation.Target.ComponentId + : operation.Target.ObjectId; + var objectKey = $"{owner}:{ownedId}"; if ( !string.Equals( owner, @@ -575,8 +585,24 @@ var operation in manifest.Operations } if ( - !introduced.ContainsKey(objectKey) - || patchOrder[owner] >= patchOrder[manifest.PatchId] + !(isComponent + ? introducedComponents.ContainsKey(objectKey) + : introduced.ContainsKey(objectKey)) + || ( + !string.Equals( + owner, + manifest.PatchId, + StringComparison.Ordinal + ) + && ( + !patchOrder.TryGetValue( + owner, + out var ownerOrder + ) + || ownerOrder + >= patchOrder[manifest.PatchId] + ) + ) ) { Add( @@ -585,7 +611,9 @@ var operation in manifest.Operations "PM-PREFAB-PATCH-OWNED-TARGET", manifest.PatchId, operation.OperationId, - $"Patch-owned target '{objectKey}' is not introduced " + $"Patch-owned " + + (isComponent ? "component" : "object") + + $" target '{objectKey}' is not introduced " + "by an earlier required operation." ); fatal = true; @@ -595,33 +623,60 @@ var operation in manifest.Operations if (operation.Kind == PrefabPatchOperationKind.AddObject) { - var objectId = operation.AddedObject?.ObjectId; - var objectKey = $"{manifest.PatchId}:{objectId}"; - if (string.IsNullOrWhiteSpace(objectId)) + if ( + !RegisterFragment( + operation.AddedObject, + manifest.PatchId, + operation.OperationId, + introduced, + introducedComponents, + plan + ) + ) + fatal = true; + } + else if ( + operation.Kind == PrefabPatchOperationKind.AddComponent + ) + { + var componentId = operation.AddedComponent?.ComponentId; + var componentKey = + $"{manifest.PatchId}:{componentId}"; + if ( + string.IsNullOrWhiteSpace(componentId) + || string.IsNullOrWhiteSpace( + operation.AddedComponent?.ComponentType + ) + ) { Add( plan, PrefabPatchDiagnosticSeverity.Error, - "PM-PREFAB-ADDED-OBJECT-ID", + "PM-PREFAB-ADDED-COMPONENT-ID", manifest.PatchId, operation.OperationId, - $"Added object operation '{operation.OperationId}' " - + "has no patch-local object ID." + $"Added component operation " + + $"'{operation.OperationId}' needs a stable " + + "component ID and assembly-qualified type." ); fatal = true; continue; } - - if (!introduced.TryAdd(objectKey, operation.OperationId)) + if ( + !introducedComponents.TryAdd( + componentKey, + operation.OperationId + ) + ) { Add( plan, PrefabPatchDiagnosticSeverity.Error, - "PM-PREFAB-DUPLICATE-OBJECT-ID", + "PM-PREFAB-DUPLICATE-COMPONENT-ID", manifest.PatchId, operation.OperationId, - $"Patch-owned object '{objectKey}' is introduced " - + "more than once." + $"Patch-owned component '{componentKey}' is " + + "introduced more than once." ); fatal = true; continue; @@ -712,6 +767,106 @@ string targetPlatform 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, diff --git a/Runtime/PrefabPatching/PrefabPatchRuntime.cs b/Runtime/PrefabPatching/PrefabPatchRuntime.cs index 049f83d..8ddf802 100644 --- a/Runtime/PrefabPatching/PrefabPatchRuntime.cs +++ b/Runtime/PrefabPatching/PrefabPatchRuntime.cs @@ -398,7 +398,13 @@ out Exception failure foreach ( var reference in entry.Plan.Operations .SelectMany(GetReferences) - .Where(value => value != null) + .Where( + value => + value != null + && value.Kind + == PrefabPatchObjectReferenceKind.Addressable + && !string.IsNullOrWhiteSpace(value.Address) + ) .GroupBy(value => value.Address, StringComparer.Ordinal) .Select(group => group.First()) .OrderBy(value => value.Address, StringComparer.Ordinal) @@ -549,8 +555,14 @@ PrefabPatchOperation operation { if (operation.ObjectReference != null) yield return operation.ObjectReference; - if (operation.AddedComponent?.Mesh != null) - yield return operation.AddedComponent.Mesh; + foreach ( + var reference in operation.AddedComponent?.References + ?? Enumerable.Empty() + ) + { + if (reference?.Reference != null) + yield return reference.Reference; + } if (operation.AddedObject == null) yield break; foreach (var reference in GetReferences(operation.AddedObject)) @@ -563,8 +575,14 @@ PrefabPatchObjectFragment fragment { foreach (var component in fragment.Components) { - if (component.Mesh != null) - yield return component.Mesh; + foreach ( + var reference in component.References + ?? Enumerable.Empty() + ) + { + if (reference?.Reference != null) + yield return reference.Reference; + } } foreach (var child in fragment.Children) diff --git a/Stubs/pm-prefabs.d.lua b/Stubs/pm-prefabs.d.lua new file mode 100644 index 0000000..525393a --- /dev/null +++ b/Stubs/pm-prefabs.d.lua @@ -0,0 +1,152 @@ +---@meta +-- Patch Manager declarative prefab-patch frontend. + +---@alias PrefabPatchTarget table +---| { kind: '"Stock"', sourceSerializedFileName: string, sourcePathId: integer, objectType: string, runtimeLocator: table } +---| { kind: '"PatchOwned"', ownerPatchId: string, objectId: string, objectType: string?, runtimeLocator: table? } +---| { kind: '"PatchComponent"', ownerPatchId: string, componentId: string, objectType: string? } + +---@alias PrefabPatchReference table +---| { kind: '"Addressable"', address: string, expectedType: string? } +---| { kind: '"Target"', target: PrefabPatchTarget, expectedType: string? } + +---@class PrefabPatchValue +---@field kind '"Boolean"'|'"Integer"'|'"Float"'|'"String"'|'"Vector2"'|'"Vector3"'|'"Vector4"'|'"Quaternion"'|'"Color"'|'"ArraySize"'|'"ManagedReference"'|'"Json"' +---@field boolean boolean? +---@field integer integer? +---@field float number? +---@field string string? +---@field serializedType string? +---@field x number? +---@field y number? +---@field z number? +---@field w number? + +---@class PrefabPatchSerializedValue +---@field propertyPath string +---@field value PrefabPatchValue + +---@class PrefabPatchSerializedReference +---@field propertyPath string +---@field reference PrefabPatchReference + +---@class PrefabPatchComponentFragment +---@field componentId string Stable ID unique within the patch. +---@field componentType string Assembly-qualified CLR component type. +---@field values PrefabPatchSerializedValue[]? +---@field references PrefabPatchSerializedReference[]? + +---@class PrefabPatchObjectFragment +---@field objectId string Stable ID unique within the patch. +---@field name string? +---@field transformType string? Assembly-qualified Transform or RectTransform type. +---@field active boolean? +---@field layer integer? +---@field tag string? +---@field isStatic boolean? +---@field localPosition PrefabPatchValue? +---@field localRotation PrefabPatchValue? +---@field localScale PrefabPatchValue? +---@field anchorMin PrefabPatchValue? +---@field anchorMax PrefabPatchValue? +---@field anchoredPosition PrefabPatchValue? +---@field sizeDelta PrefabPatchValue? +---@field pivot PrefabPatchValue? +---@field components PrefabPatchComponentFragment[]? +---@field children PrefabPatchObjectFragment[]? + +---@class PrefabPatchIdentity +---@field address string +---@field catalogId string +---@field catalogHash string? +---@field sourceBundleFileName string +---@field sourceBundleHash string? +---@field sourceSerializedFileName string +---@field sourcePathId integer +---@field assetType string +---@field structuralDescription string +---@field structuralFingerprint string + +---@class PrefabPatchLuaBuilder +PrefabPatchLuaBuilder = {} + +---@return PrefabPatchLuaBuilder self +function PrefabPatchLuaBuilder:Early() end +---@return PrefabPatchLuaBuilder self +function PrefabPatchLuaBuilder:Late() end +---@return PrefabPatchLuaBuilder self +function PrefabPatchLuaBuilder:First() end +---@return PrefabPatchLuaBuilder self +function PrefabPatchLuaBuilder:Last() end +---@param ... string +---@return PrefabPatchLuaBuilder self +function PrefabPatchLuaBuilder:Needs(...) end +---@param ... string +---@return PrefabPatchLuaBuilder self +function PrefabPatchLuaBuilder:Conflicts(...) end +---@param ... string +---@return PrefabPatchLuaBuilder self +function PrefabPatchLuaBuilder:NeedsPatch(...) end +---@param ... string +---@return PrefabPatchLuaBuilder self +function PrefabPatchLuaBuilder:ConflictsPatch(...) end +---@param ... string +---@return PrefabPatchLuaBuilder self +function PrefabPatchLuaBuilder:BeforePatch(...) end +---@param ... string +---@return PrefabPatchLuaBuilder self +function PrefabPatchLuaBuilder:AfterPatch(...) end +---@param ... string +---@return PrefabPatchLuaBuilder self +function PrefabPatchLuaBuilder:Before(...) end +---@param ... string +---@return PrefabPatchLuaBuilder self +function PrefabPatchLuaBuilder:After(...) end +---@param ... string +---@return PrefabPatchLuaBuilder self +function PrefabPatchLuaBuilder:Configuration(...) end +---@param operationId string +---@param target PrefabPatchTarget +---@param propertyPath string +---@param value boolean|number|string|PrefabPatchValue +---@return PrefabPatchLuaBuilder self +function PrefabPatchLuaBuilder:Set(operationId, target, propertyPath, value) end +---@param operationId string +---@param target PrefabPatchTarget +---@param propertyPath string +---@param reference PrefabPatchReference +---@return PrefabPatchLuaBuilder self +function PrefabPatchLuaBuilder:Reference(operationId, target, propertyPath, reference) end +---@param operationId string +---@param target PrefabPatchTarget +---@param active boolean +---@return PrefabPatchLuaBuilder self +function PrefabPatchLuaBuilder:Active(operationId, target, active) end +---@param operationId string +---@param target PrefabPatchTarget +---@return PrefabPatchLuaBuilder self +function PrefabPatchLuaBuilder:Suppress(operationId, target) end +---@param operationId string +---@param parent PrefabPatchTarget? +---@param fragment PrefabPatchObjectFragment +---@return PrefabPatchLuaBuilder self +function PrefabPatchLuaBuilder:AddObject(operationId, parent, fragment) end +---@param operationId string +---@param target PrefabPatchTarget +---@param component PrefabPatchComponentFragment +---@return PrefabPatchLuaBuilder self +function PrefabPatchLuaBuilder:AddComponent(operationId, target, component) end +---@param operationId string +---@param target PrefabPatchTarget +---@return PrefabPatchLuaBuilder self +function PrefabPatchLuaBuilder:RemoveComponent(operationId, target) end +---@return table manifest +function PrefabPatchLuaBuilder:Build() end +---@return table manifest +function PrefabPatchLuaBuilder:Register() end + +---@param name string Patch-local name; ModId is prepended automatically. +---@param target PrefabPatchIdentity +---@param modVersion string? +---@return PrefabPatchLuaBuilder builder +function PatchManagerCore:Prefab(name, target, modVersion) end diff --git a/Stubs/pm-prefabs.d.lua.meta b/Stubs/pm-prefabs.d.lua.meta new file mode 100644 index 0000000..cd93e96 --- /dev/null +++ b/Stubs/pm-prefabs.d.lua.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 1134442416264cf8a191c417a7642ff0 From 5292e8085bfa4b762d87f0563856031c21fc3c8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Bure=C5=A1?= Date: Thu, 30 Jul 2026 13:08:12 +0200 Subject: [PATCH 10/17] Derive prefab patch ownership from swinfo --- PREFAB_PATCHING.md | 23 ++- .../PrefabPatchingCSharpPatching.cs | 19 ++ .../PrefabPatchingCSharpPatching.cs.meta | 2 + Runtime/Core/CoreModule.cs | 22 +++ .../LuaPatching/Builtin/PatchManagerCore.cs | 6 +- Runtime/PrefabPatching/PrefabPatchBuilder.cs | 33 ++-- Runtime/PrefabPatching/PrefabPatchModel.cs | 9 +- .../PrefabPatching/PrefabPatchOwnership.cs | 156 +++++++++++++++ .../PrefabPatchOwnership.cs.meta | 2 + Runtime/PrefabPatching/PrefabPatchResolver.cs | 4 +- Runtime/PrefabPatching/PrefabPatchRuntime.cs | 187 ++++++++++++------ Stubs/pm-prefabs.d.lua | 3 +- 12 files changed, 378 insertions(+), 88 deletions(-) create mode 100644 Runtime/CSharpPatching/PrefabPatchingCSharpPatching.cs create mode 100644 Runtime/CSharpPatching/PrefabPatchingCSharpPatching.cs.meta create mode 100644 Runtime/PrefabPatching/PrefabPatchOwnership.cs create mode 100644 Runtime/PrefabPatching/PrefabPatchOwnership.cs.meta diff --git a/PREFAB_PATCHING.md b/PREFAB_PATCHING.md index bcd21a2..614cd2a 100644 --- a/PREFAB_PATCHING.md +++ b/PREFAB_PATCHING.md @@ -19,9 +19,17 @@ components, into nested serialized arrays/lists, to stock prefab objects, and to mod or stock Addressables. It also prevents `OnEnable` from observing a partially hydrated effective prefab. -The first implementation was never released. Schema 2 is therefore the only -accepted schema; there is no schema-1 migration path. Recompile any local -experimental manifests from their authoring variants. +This is still the initial prerelease schema (`schemaVersion: 1`, +`composerVersion: 1`). There is no compatibility or migration layer. Recompile +local experimental manifests from their authoring variants whenever the schema +changes. + +Compiled JSON contains the local `patchName`, operations, dependencies, and +target identity. It does not serialize a mod ID or mod version. In editor Play +Mode, the owning Mod authoring asset supplies the ID. In a player, Patch +Manager associates each manifest's Addressables catalog with the SpaceWarp +descriptor that loaded it and uses that descriptor's `swinfo` ID. The +fully-qualified `mod-id:patch-name` exists only in the resolved runtime model. ## Visual authoring @@ -49,6 +57,7 @@ Register C# patches before `PrefabPatchRuntime.CloseRegistration()`: ```csharp using PatchManager.PrefabPatching; +using PatchManager.CSharpPatching; using UnityEngine; using UnityEngine.UI; @@ -59,7 +68,7 @@ var button = PrefabPatchComponentBuilder "m_TargetGraphic", PrefabPatchBuilder.TargetReference( PrefabPatchBuilder.PatchComponent( - "my-mod:toolbar", + "toolbar", "toolbar:image" ), typeof(Graphic) @@ -70,7 +79,7 @@ var image = PrefabPatchComponentBuilder .For("toolbar:image") .Build(); -new PrefabPatchBuilder("my-mod", "toolbar", targetIdentity, "1.0.0") +Patching.Mod.PatchPrefab("toolbar", targetIdentity) .AddObject( "01-add-toolbar", stockParentTarget, @@ -141,7 +150,9 @@ Pure Lua/C# patches still need the stock prefab's canonical identity and structural fingerprint, plus canonical stock-object targets. Those values are source-build-specific safety data, not name-based hierarchy paths. They can be copied from a visual compiler manifest for the same linked prefab. Patch-owned -targets only need the owning patch ID plus stable object/component ID. +targets only need the local owning patch name plus stable object/component ID. +An explicit `other-mod:patch-name` is used only when targeting another mod's +required patch. ## Runtime and compatibility behavior diff --git a/Runtime/CSharpPatching/PrefabPatchingCSharpPatching.cs b/Runtime/CSharpPatching/PrefabPatchingCSharpPatching.cs new file mode 100644 index 0000000..9b0d845 --- /dev/null +++ b/Runtime/CSharpPatching/PrefabPatchingCSharpPatching.cs @@ -0,0 +1,19 @@ +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. + /// + public static PrefabPatchBuilder PatchPrefab( + this PmScope scope, + string name, + PrefabPatchPrefabIdentity target + ) => new(scope.ModId, name, target); + } +} 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/CoreModule.cs b/Runtime/Core/CoreModule.cs index 753f565..c54170a 100644 --- a/Runtime/Core/CoreModule.cs +++ b/Runtime/Core/CoreModule.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using JetBrains.Annotations; using KSP.Game; using KSP.Game.Flow; @@ -11,6 +12,7 @@ using PatchManager.Shared.Modules; using ReduxLib.Configuration; using ReduxLib.Configuration.Attributes; +using SpaceWarp2.API.Mods; using UnityEngine; using UnityEngine.AddressableAssets; using UnityEngine.UIElements; @@ -135,8 +137,28 @@ private static void ResolvePrefabPatchPlans( Action reject ) { + var catalogOwners = PluginList.AllEnabledAndActivePlugins + .SelectMany(descriptor => + descriptor.AddressableResourceLocators.Select(locator => + new + { + locator.LocatorId, + descriptor.Guid + } + ) + ) + .GroupBy(value => value.LocatorId, StringComparer.Ordinal) + .ToDictionary( + group => group.Key, + group => + group.Select(value => value.Guid) + .Distinct(StringComparer.Ordinal) + .Single(), + StringComparer.Ordinal + ); PrefabPatchRuntime.DiscoverAndResolve( PatchingManager.Universe.AllMods, + catalogOwners, resolve, reject ); diff --git a/Runtime/LuaPatching/Builtin/PatchManagerCore.cs b/Runtime/LuaPatching/Builtin/PatchManagerCore.cs index 8acaa17..bcc5f79 100644 --- a/Runtime/LuaPatching/Builtin/PatchManagerCore.cs +++ b/Runtime/LuaPatching/Builtin/PatchManagerCore.cs @@ -93,8 +93,7 @@ public PatchDefinition Patch(ScriptExecutionContext context, string converter, s public PrefabPatchLuaBuilder Prefab( ScriptExecutionContext context, string name, - DynValue target, - string modVersion = null + DynValue target ) { if (!_universe.RegistrationOpen) @@ -116,8 +115,7 @@ public PrefabPatchLuaBuilder Prefab( new global::PatchManager.PrefabPatching.PrefabPatchBuilder( modId, name, - identity, - modVersion + identity ) ); } diff --git a/Runtime/PrefabPatching/PrefabPatchBuilder.cs b/Runtime/PrefabPatching/PrefabPatchBuilder.cs index 8fada27..8f25204 100644 --- a/Runtime/PrefabPatching/PrefabPatchBuilder.cs +++ b/Runtime/PrefabPatching/PrefabPatchBuilder.cs @@ -10,6 +10,7 @@ namespace PatchManager.PrefabPatching; /// public sealed class PrefabPatchBuilder { + private readonly string _modId; private readonly PrefabPatchManifest _manifest; private readonly HashSet _needsMods = new(StringComparer.Ordinal); private readonly HashSet _conflictsMods = new( @@ -29,8 +30,7 @@ public sealed class PrefabPatchBuilder public PrefabPatchBuilder( string modId, string patchName, - PrefabPatchPrefabIdentity target, - string modVersion = null + PrefabPatchPrefabIdentity target ) { if (string.IsNullOrWhiteSpace(modId)) @@ -40,14 +40,15 @@ public PrefabPatchBuilder( "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 { - PatchId = - patchName.IndexOf(':') >= 0 - ? patchName - : modId + ":" + patchName, - ModId = modId, - ModVersion = modVersion, + PatchName = patchName.Trim(), TargetPrefab = target ?? throw new ArgumentNullException(nameof(target)) }; @@ -105,7 +106,10 @@ public PrefabPatchBuilder AddOperation(PrefabPatchOperation operation) { if (operation == null) throw new ArgumentNullException(nameof(operation)); - operation.PatchId = _manifest.PatchId; + operation.PatchId = PrefabPatchOwnership.Qualify( + _modId, + _manifest.PatchName + ); _manifest.Operations.Add(operation); return this; } @@ -292,7 +296,7 @@ public PrefabPatchManifest Build() .OrderBy(value => value, StringComparer.Ordinal) .ToArray(); _manifest.ManifestHash = PrefabPatchJson.CalculateManifestHash(_manifest); - return _manifest; + return PrefabPatchOwnership.Bind(_manifest, _modId); } public PrefabPatchManifest Register() @@ -319,13 +323,8 @@ var id in (ids ?? Array.Empty()).Where( return this; } - private IEnumerable Normalize(IEnumerable ids) => - (ids ?? Array.Empty()).Select( - id => - id.IndexOf(':') >= 0 - ? id - : _manifest.ModId + ":" + id - ); + private static IEnumerable Normalize(IEnumerable ids) => + ids ?? Array.Empty(); private static string[] Sorted(IEnumerable values) => values.OrderBy(value => value, StringComparer.Ordinal).ToArray(); diff --git a/Runtime/PrefabPatching/PrefabPatchModel.cs b/Runtime/PrefabPatching/PrefabPatchModel.cs index 1215ef5..03c7599 100644 --- a/Runtime/PrefabPatching/PrefabPatchModel.cs +++ b/Runtime/PrefabPatching/PrefabPatchModel.cs @@ -10,8 +10,8 @@ namespace PatchManager.PrefabPatching; /// public static class PrefabPatchSchema { - public const int Version = 2; - public const int ComposerVersion = 2; + public const int Version = 1; + public const int ComposerVersion = 1; public const string AddressablesLabel = "patch-manager-prefab-patches"; } @@ -281,6 +281,7 @@ public sealed class PrefabPatchObjectFragment public sealed class PrefabPatchOperation { public string OperationId; + [JsonIgnore] public string PatchId; public PrefabPatchOperationKind Kind; public PrefabPatchObjectTarget Target; @@ -329,9 +330,11 @@ public sealed class PrefabPatchManifest { public int SchemaVersion = PrefabPatchSchema.Version; public int ComposerVersion = PrefabPatchSchema.ComposerVersion; + public string PatchName; + [JsonIgnore] public string PatchId; + [JsonIgnore] public string ModId; - public string ModVersion; public PrefabPatchPrefabIdentity TargetPrefab; public PrefabPatchPass Pass = PrefabPatchPass.Default; public PrefabPatchOrdering Ordering = PrefabPatchOrdering.Default; diff --git a/Runtime/PrefabPatching/PrefabPatchOwnership.cs b/Runtime/PrefabPatching/PrefabPatchOwnership.cs new file mode 100644 index 0000000..a10a517 --- /dev/null +++ b/Runtime/PrefabPatching/PrefabPatchOwnership.cs @@ -0,0 +1,156 @@ +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 +{ + 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; + } + + 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/PrefabPatchResolver.cs b/Runtime/PrefabPatching/PrefabPatchResolver.cs index 56d87d0..40ab350 100644 --- a/Runtime/PrefabPatching/PrefabPatchResolver.cs +++ b/Runtime/PrefabPatching/PrefabPatchResolver.cs @@ -140,6 +140,7 @@ PrefabPatchResolvedPlan plan if ( string.IsNullOrWhiteSpace(manifest.PatchId) || manifest.PatchId.IndexOf(':') <= 0 + || string.IsNullOrWhiteSpace(manifest.PatchName) ) { Add( @@ -148,7 +149,8 @@ PrefabPatchResolvedPlan plan "PM-PREFAB-PATCH-ID", manifest.PatchId, null, - "Prefab patch IDs must be namespaced as 'mod-id:patch-id'." + "Prefab patch ownership has not been bound from its " + + "containing mod." ); return false; } diff --git a/Runtime/PrefabPatching/PrefabPatchRuntime.cs b/Runtime/PrefabPatching/PrefabPatchRuntime.cs index 8ddf802..772d90f 100644 --- a/Runtime/PrefabPatching/PrefabPatchRuntime.cs +++ b/Runtime/PrefabPatching/PrefabPatchRuntime.cs @@ -50,6 +50,13 @@ internal sealed class Entry public int RequestCount; } + private sealed class ManifestLocation + { + public IResourceLocation Location; + public string OwnerModId; + public string LocatorId; + } + private const string PlanCacheDirectory = "./pm_cache/prefabs"; private const string SummaryPath = "./pm_prefab_summary.log"; private static readonly List Registered = new(); @@ -63,6 +70,13 @@ internal sealed class Entry public static Metrics CurrentMetrics { get; private set; } = new(); public static IReadOnlyDictionary Plans => Entries.ToDictionary(pair => pair.Key, pair => pair.Value.Plan); +#if UNITY_EDITOR + /// + /// Editor integration hook that maps a compiled manifest asset path to + /// the Mod authoring asset (and therefore swinfo ID) that owns it. + /// + public static Func EditorManifestOwnerResolver { get; set; } +#endif /// /// Registers a fluent or generated C# manifest before registration closes. @@ -92,6 +106,19 @@ public static void DiscoverAndResolve( ISet activeModIds, Action resolve, Action reject + ) => + DiscoverAndResolve( + activeModIds, + new Dictionary(StringComparer.Ordinal), + resolve, + reject + ); + + public static void DiscoverAndResolve( + ISet activeModIds, + IReadOnlyDictionary manifestCatalogOwners, + Action resolve, + Action reject ) { var stopwatch = Stopwatch.StartNew(); @@ -112,65 +139,56 @@ Action reject ); return; #else - var locations = FindManifestLocations(); + var locations = FindManifestLocations(manifestCatalogOwners); if (locations.Count > 0) { - var manifestHandle = Addressables.LoadAssetsAsync( - locations, - null - ); - manifestHandle.Completed += operation => + foreach (var source in locations) { + AsyncOperationHandle manifestHandle = default; try { - if (operation.Status != AsyncOperationStatus.Succeeded) + manifestHandle = Addressables.LoadAssetAsync( + source.Location + ); + var asset = manifestHandle.WaitForCompletion(); + if ( + manifestHandle.Status + != AsyncOperationStatus.Succeeded + || asset == null + ) { - throw operation.OperationException + throw manifestHandle.OperationException ?? new InvalidOperationException( "Prefab patch manifest load failed." ); } - foreach ( - var asset in operation - .Result.Where(value => value != null) - ) - { - try - { - manifests.Add( - PrefabPatchJson.Deserialize< - PrefabPatchManifest - >(asset.text) - ); - } - catch (Exception exception) - { - throw new InvalidDataException( - $"Could not parse prefab patch manifest " - + $"'{asset.name}'.", - exception - ); - } - } - - ResolveDiscoveredManifests( - manifests, - activeModIds, - stopwatch, - resolve + var manifest = + PrefabPatchJson.Deserialize( + asset.text + ); + manifests.Add( + PrefabPatchOwnership.Bind( + manifest, + source.OwnerModId + ) ); } catch (Exception exception) { - RejectDiscovery(stopwatch, reject, exception); + throw new InvalidDataException( + $"Could not load prefab patch manifest " + + $"'{source.Location.PrimaryKey}' from " + + $"catalog '{source.LocatorId}'.", + exception + ); } finally { - Addressables.Release(operation); + if (manifestHandle.IsValid()) + Addressables.Release(manifestHandle); } - }; - return; + } } ResolveDiscoveredManifests( @@ -222,8 +240,17 @@ var path in AssetDatabase ); } - if (manifest != null) - yield return manifest; + if (manifest == null) + continue; + var owner = EditorManifestOwnerResolver?.Invoke(path); + if (string.IsNullOrWhiteSpace(owner)) + { + throw new InvalidDataException( + $"Could not determine the owning Mod asset for prefab " + + $"patch manifest '{path}'." + ); + } + yield return PrefabPatchOwnership.Bind(manifest, owner); } } #endif @@ -294,29 +321,79 @@ Exception exception ); } - private static List FindManifestLocations() + private static List FindManifestLocations( + IReadOnlyDictionary catalogOwners + ) { return Addressables.ResourceLocators .SelectMany(locator => - locator.Locate( + { + if ( + locator == null + || !locator.Locate( PrefabPatchSchema.AddressablesLabel, typeof(TextAsset), out var locations ) - ? locations - : Array.Empty() - ) - .Where(location => location != null) + ) + { + return Array.Empty(); + } + + string ownerModId = null; + catalogOwners?.TryGetValue( + locator.LocatorId, + out ownerModId + ); + if (string.IsNullOrWhiteSpace(ownerModId)) + { + throw new InvalidDataException( + $"Addressables catalog '{locator.LocatorId}' contains " + + "prefab patch manifests but is not associated " + + "with a loaded mod swinfo descriptor." + ); + } + return locations + .Where(location => location != null) + .Select(location => new ManifestLocation + { + Location = location, + OwnerModId = ownerModId, + LocatorId = locator.LocatorId + }); + }) .GroupBy( - location => - $"{location.ProviderId}\0{location.InternalId}\0" - + $"{location.PrimaryKey}\0" - + $"{location.ResourceType?.AssemblyQualifiedName}", + 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.Location.InternalId, StringComparer.Ordinal ) - .Select(group => group.First()) - .OrderBy(location => location.PrimaryKey, StringComparer.Ordinal) - .ThenBy(location => location.InternalId, StringComparer.Ordinal) .ToList(); } diff --git a/Stubs/pm-prefabs.d.lua b/Stubs/pm-prefabs.d.lua index 525393a..676c581 100644 --- a/Stubs/pm-prefabs.d.lua +++ b/Stubs/pm-prefabs.d.lua @@ -147,6 +147,5 @@ function PrefabPatchLuaBuilder:Register() end ---@param name string Patch-local name; ModId is prepended automatically. ---@param target PrefabPatchIdentity ----@param modVersion string? ---@return PrefabPatchLuaBuilder builder -function PatchManagerCore:Prefab(name, target, modVersion) end +function PatchManagerCore:Prefab(name, target) end From 9ed941a129aeb66d1a2315153aa594e337a2cd0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Bure=C5=A1?= Date: Thu, 30 Jul 2026 14:11:40 +0200 Subject: [PATCH 11/17] Document exact Lua prefab path IDs --- PREFAB_PATCHING.md | 6 ++++++ Stubs/pm-prefabs.d.lua | 4 ++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/PREFAB_PATCHING.md b/PREFAB_PATCHING.md index 614cd2a..ce9b999 100644 --- a/PREFAB_PATCHING.md +++ b/PREFAB_PATCHING.md @@ -113,6 +113,12 @@ Lua uses `PM:Prefab`. Tables use the camel-case names from the public JSON schema; empty Lua tables are normalized to empty arrays where the model expects a collection. +Write `sourcePathId` values as quoted decimal strings when copying canonical +identities or stock targets into Lua. Unity path IDs are signed 64-bit integers, +while Lua numbers cannot exactly represent every value in that range. Patch +Manager accepts either form and converts decimal strings to `Int64` without +losing precision. + ```lua local patch = PM:Prefab("toolbar", targetIdentity) :Needs("some-required-mod") diff --git a/Stubs/pm-prefabs.d.lua b/Stubs/pm-prefabs.d.lua index 676c581..6350d64 100644 --- a/Stubs/pm-prefabs.d.lua +++ b/Stubs/pm-prefabs.d.lua @@ -2,7 +2,7 @@ -- Patch Manager declarative prefab-patch frontend. ---@alias PrefabPatchTarget table ----| { kind: '"Stock"', sourceSerializedFileName: string, sourcePathId: integer, objectType: string, runtimeLocator: table } +---| { kind: '"Stock"', sourceSerializedFileName: string, sourcePathId: integer|string, objectType: string, runtimeLocator: table } ---| { kind: '"PatchOwned"', ownerPatchId: string, objectId: string, objectType: string?, runtimeLocator: table? } ---| { kind: '"PatchComponent"', ownerPatchId: string, componentId: string, objectType: string? } @@ -62,7 +62,7 @@ ---@field sourceBundleFileName string ---@field sourceBundleHash string? ---@field sourceSerializedFileName string ----@field sourcePathId integer +---@field sourcePathId integer|string Use a decimal string for 64-bit IDs outside Lua's exact numeric range. ---@field assetType string ---@field structuralDescription string ---@field structuralFingerprint string From f1d2b2051baa83a905396144461bcf5bcee911fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Bure=C5=A1?= Date: Thu, 30 Jul 2026 14:23:29 +0200 Subject: [PATCH 12/17] Merge prefab diagnostics into patch summary --- PREFAB_PATCHING.md | 3 + Runtime/Core/Cache/CacheManager.cs | 3 +- Runtime/PrefabPatching/PrefabPatchRuntime.cs | 89 +++++++++++++++++--- 3 files changed, 80 insertions(+), 15 deletions(-) diff --git a/PREFAB_PATCHING.md b/PREFAB_PATCHING.md index ce9b999..301928a 100644 --- a/PREFAB_PATCHING.md +++ b/PREFAB_PATCHING.md @@ -162,6 +162,9 @@ required patch. ## Runtime and compatibility behavior +- Prefab discovery, plan-cache, ordering, diagnostics, and composition failures + are written as a `Prefab Patches` section in the existing + `pm_summary.log`; prefab updates preserve the ordinary JSON-patch summary. - Composition occurs once per resolved stock prefab and the effective prefab is cached for repeated provider requests. - Resolver ordering, required/conflicting mods and patches, and field conflicts diff --git a/Runtime/Core/Cache/CacheManager.cs b/Runtime/Core/Cache/CacheManager.cs index 1551ea5..3ef552e 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 @@ -165,7 +166,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/PrefabPatching/PrefabPatchRuntime.cs b/Runtime/PrefabPatching/PrefabPatchRuntime.cs index 772d90f..1a038d6 100644 --- a/Runtime/PrefabPatching/PrefabPatchRuntime.cs +++ b/Runtime/PrefabPatching/PrefabPatchRuntime.cs @@ -16,6 +16,63 @@ 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; + + public static void UpdateCoreSummary(string summary) + { + lock (Gate) + { + _coreSummary = Normalize(summary); + Write(); + } + } + + public static void UpdatePrefabSummary(string summary) + { + lock (Gate) + { + _prefabSummary = Normalize(summary); + Write(); + } + } + + 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. /// @@ -58,7 +115,6 @@ private sealed class ManifestLocation } private const string PlanCacheDirectory = "./pm_cache/prefabs"; - private const string SummaryPath = "./pm_prefab_summary.log"; private static readonly List Registered = new(); private static readonly Dictionary Entries = new( StringComparer.Ordinal @@ -673,13 +729,13 @@ private static void WriteSummary() { var lines = new List { - "Patch Manager prefab patch summary", - $"Schema: {PrefabPatchSchema.Version}", - $"Composer: {PrefabPatchSchema.ComposerVersion}", - $"Discovered manifests: {CurrentMetrics.DiscoveredManifestCount}", - $"Resolved plans: {CurrentMetrics.ResolvedPlanCount}", - $"Plan cache hits: {CurrentMetrics.CacheHitCount}", - $"Plan cache misses: {CurrentMetrics.CacheMissCount}" + "Prefab Patches:", + $" Schema: {PrefabPatchSchema.Version}", + $" Composer: {PrefabPatchSchema.ComposerVersion}", + $" Discovered Manifests: {CurrentMetrics.DiscoveredManifestCount}", + $" Resolved Plans: {CurrentMetrics.ResolvedPlanCount}", + $" Plan Cache Hits: {CurrentMetrics.CacheHitCount}", + $" Plan Cache Misses: {CurrentMetrics.CacheMissCount}" }; foreach ( var pair in Entries.OrderBy( @@ -689,10 +745,10 @@ var pair in Entries.OrderBy( ) { lines.Add(""); - lines.Add($"Target: {pair.Key}"); - lines.Add($"Cache key: {pair.Value.Plan.CacheKey}"); + lines.Add($" Target - {pair.Key}:"); + lines.Add($" Cache Key: {pair.Value.Plan.CacheKey}"); lines.Add( - "Ordered patches: " + " Ordered Patches: " + string.Join( ", ", pair.Value.Plan.OrderedPatchIds @@ -701,22 +757,27 @@ var pair in Entries.OrderBy( foreach (var diagnostic in pair.Value.Plan.Diagnostics) { lines.Add( - $"[{diagnostic.Severity}] {diagnostic.Code} " + $" [{diagnostic.Severity}] {diagnostic.Code} " + $"{diagnostic.PatchId} {diagnostic.OperationId}: " + diagnostic.Message ); } if (pair.Value.Failed) - lines.Add("Composition failure: " + pair.Value.Failure); + lines.Add( + " Composition Failure: " + pair.Value.Failure + ); } - File.WriteAllLines(SummaryPath, lines); + PatchManagerSummaryLog.UpdatePrefabSummary( + string.Join(Environment.NewLine, lines) + ); } [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] private static void ResetStaticState() { + PatchManagerSummaryLog.Reset(); ReleaseSessionResources(); Registered.Clear(); Entries.Clear(); From 79d719ee477d4e54ff71bcad377f0079d7517733 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Bure=C5=A1?= Date: Thu, 30 Jul 2026 15:29:55 +0200 Subject: [PATCH 13/17] Add key-first prefab patch authoring --- PREFAB_PATCHING.md | 108 ++++++------------ .../PrefabPatchingCSharpPatching.cs | 11 ++ .../LuaPatching/Builtin/PatchManagerCore.cs | 14 ++- .../Builtin/PrefabPatchLuaBuilder.cs | 36 ++++++ Runtime/PrefabPatching/PrefabPatchBuilder.cs | 88 ++++++++++++++ Runtime/PrefabPatching/PrefabPatchComposer.cs | 20 +++- Runtime/PrefabPatching/PrefabPatchModel.cs | 69 +++++++++-- .../PrefabPatching/PrefabPatchPlanCache.cs | 2 +- Runtime/PrefabPatching/PrefabPatchResolver.cs | 71 +++++++++--- Runtime/PrefabPatching/PrefabPatchRuntime.cs | 2 +- .../PrefabPatching/PrefabPatchStructure.cs | 51 +++++++++ Stubs/pm-prefabs.d.lua | 18 ++- 12 files changed, 376 insertions(+), 114 deletions(-) diff --git a/PREFAB_PATCHING.md b/PREFAB_PATCHING.md index 301928a..e8dbec7 100644 --- a/PREFAB_PATCHING.md +++ b/PREFAB_PATCHING.md @@ -53,47 +53,25 @@ before composition. ## C# authoring -Register C# patches before `PrefabPatchRuntime.CloseRegistration()`: +Register C# patches before `PrefabPatchRuntime.CloseRegistration()`. Handwritten +patches target a stock Addressables key and select objects by their ordinary +prefab hierarchy path: ```csharp using PatchManager.PrefabPatching; using PatchManager.CSharpPatching; -using UnityEngine; using UnityEngine.UI; -var button = PrefabPatchComponentBuilder - .For