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/PREFAB_PATCHING.md b/PREFAB_PATCHING.md new file mode 100644 index 0000000..919c49b --- /dev/null +++ b/PREFAB_PATCHING.md @@ -0,0 +1,149 @@ +# 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. + +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. Each SpaceWarp +descriptor declares an `addressable_prefab_patch_label`; Patch Manager queries +that label in both editor Play Mode and players and assigns the descriptor's +`swinfo` ID to every returned manifest. The manifest asset's own Addressables +address is not patch identity. The fully-qualified `mod-id:patch-name` exists +only in the resolved runtime model. + +KSP2UnityTools-generated mods default to `_prefab_patches`. Redux uses +`redux_prefab_patches`. Script TextAssets use the parallel +`_patches`/`redux_patches` convention. Loose Lua files remain supported. + +## 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()`. 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.UI; + +var background = PrefabPatchBuilder.ComponentAt( + "KSP2UIWindow/Root/Window-App/Background" +); + +Patching.Mod.PatchPrefab("toolbar", "SomeWindow.prefab") + .SetValue( + "tint-background", + background, + "m_Color.r", + PrefabPatchValue.FromFloat(0.25) + ) + .Register(); +``` + +`PrefabPatchBuilder` also exposes value/reference writes, active/suppress, +add/remove component, ordering, dependency, conflict, and configuration-input +methods. `PatchObject` and `PatchComponent` create stable targets for objects +introduced by this or a required patch. Addressable references are created with +`Addressable`. + +Operation order is the fluent call order. This is significant when a later +operation targets an object or component introduced earlier in the same patch. + +## Lua authoring + +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. + +```lua +local patch = PM:Prefab("toolbar", "SomeWindow.prefab") + :Needs("some-required-mod") + :SetComponent( + "tint-background", + "KSP2UIWindow/Root/Window-App/Background", + "UnityEngine.UI.Image", + "m_Color.r", + 0.25 + ) + +patch:Register() +``` + +The returned builder supports `Early`, `Late`, `First`, `Last`, dependency and +ordering methods, `Set`, `Reference`, `Active`, `Suppress`, `AddObject`, +`AddComponent`, `RemoveComponent`, `Build`, and `Register`. `Build` is useful +for tools/tests; normal mods call `Register`. `GameObject(path)` and +`Component(path, type, ordinal)` return key-first targets that can be passed to +any generic operation method; `SetComponent` is shorthand for the common +scalar-component case. + +Visual, C#, and Lua manifests all identify the stock prefab by its Addressables +key. Compiled visual manifests additionally retain a structural fingerprint +and sibling-index runtime locators, but do not serialize catalog, bundle, CAB, +path-ID, or full structural-description metadata. Imperative patches resolve +named hierarchy paths at runtime. Duplicate child names at one hierarchy level +are rejected as ambiguous rather than resolved arbitrarily. + +Patch-owned targets use 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 + +- 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 + are identical for all three frontends. +- The player loads Addressable references before applying the plan. Target + references are resolved locally after every object/component has been + created. +- A missing CLR component type, missing Addressable, stale stock fingerprint, + invalid property path, or unknown project tag fails the patch with a precise + diagnostic instead of silently dropping data. +- Patch manifests and mod-owned assets are distributable. Linked stock game + assets remain external references and are not copied into a mod build by the + prefab patch schema. diff --git a/PREFAB_PATCHING.md.meta b/PREFAB_PATCHING.md.meta new file mode 100644 index 0000000..8126696 --- /dev/null +++ b/PREFAB_PATCHING.md.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 83f20edca85b417b85fd52fa4518a929 diff --git a/README.md b/README.md index 55e4a19..2ff3f03 100644 --- a/README.md +++ b/README.md @@ -2,3 +2,6 @@ A mod for generic patching needs similar to KSP 1's Module Manager. Documentation: https://ksp2community.github.io/PatchManagerDocs/ + +Prefab patch visual, C#, and Lua authoring is documented in +[PREFAB_PATCHING.md](PREFAB_PATCHING.md). diff --git a/Runtime/CSharpPatching/PrefabPatchingCSharpPatching.cs b/Runtime/CSharpPatching/PrefabPatchingCSharpPatching.cs new file mode 100644 index 0000000..9729e28 --- /dev/null +++ b/Runtime/CSharpPatching/PrefabPatchingCSharpPatching.cs @@ -0,0 +1,24 @@ +using PatchManager.PrefabPatching; + +namespace PatchManager.CSharpPatching +{ + /// + /// Mod-scoped C# frontend for declarative prefab patches. + /// + public static class PrefabPatchingCSharpPatching + { + /// + /// Creates a prefab patch owned by the calling mod's swinfo identity. + /// + /// + /// Creates a prefab patch targeting a stock Addressables key. + /// Canonical bundle and CAB metadata are not part of imperative + /// authoring. + /// + public static PrefabPatchBuilder PatchPrefab( + this PmScope scope, + string name, + string address + ) => new(scope.ModId, name, address); + } +} diff --git a/Runtime/CSharpPatching/PrefabPatchingCSharpPatching.cs.meta b/Runtime/CSharpPatching/PrefabPatchingCSharpPatching.cs.meta new file mode 100644 index 0000000..18b9209 --- /dev/null +++ b/Runtime/CSharpPatching/PrefabPatchingCSharpPatching.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a9063ecf1f9c8734abf1ed5334198e30 \ No newline at end of file diff --git a/Runtime/Core/Assets/Locators.cs b/Runtime/Core/Assets/Locators.cs index 2d808ec..280ed2e 100644 --- a/Runtime/Core/Assets/Locators.cs +++ b/Runtime/Core/Assets/Locators.cs @@ -38,15 +38,33 @@ public static void Register(IResourceLocator locator) /// List of locations of the found assets. /// True if any assets were found, false otherwise. public static bool LocateAll(object label, out List locations) + { + return LocateAll(label, typeof(TextAsset), out locations); + } + + /// + /// Locate assets by key and requested type across every Patch Manager + /// asset-domain locator. + /// + public static bool LocateAll( + object key, + System.Type type, + out List locations + ) { locations = new List(); foreach (var locator in ResourceLocators) { - locator.Locate(label, typeof(TextAsset), out var foundLocations); - locations.AddRange(foundLocations); + if ( + locator.Locate(key, type, out var foundLocations) + && foundLocations != null + ) + { + locations.AddRange(foundLocations); + } } return locations.Count > 0; } } -} \ No newline at end of file +} diff --git a/Runtime/Core/Cache/CacheManager.cs b/Runtime/Core/Cache/CacheManager.cs index 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/Core/CoreModule.cs b/Runtime/Core/CoreModule.cs index 9e2f801..da6412f 100644 --- a/Runtime/Core/CoreModule.cs +++ b/Runtime/Core/CoreModule.cs @@ -1,15 +1,18 @@ using System; using System.Collections.Generic; +using System.Linq; using JetBrains.Annotations; using KSP.Game; using KSP.Game.Flow; using PatchManager.Core.Assets; using PatchManager.Core.Cache; using PatchManager.LuaPatching; +using PatchManager.PrefabPatching; using PatchManager.Shared; using PatchManager.Shared.Modules; using ReduxLib.Configuration; using ReduxLib.Configuration.Attributes; +using SpaceWarp2.API.Mods; using UnityEngine; using UnityEngine.AddressableAssets; using UnityEngine.UIElements; @@ -46,6 +49,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)); @@ -78,6 +106,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 +128,39 @@ private void DecideCacheValidity(Action resolve, Action reject) private static void CloseRegistration(Action resolve, Action reject) { PatchingManager.Universe.RegistrationOpen = false; + PrefabPatchRuntime.CloseRegistration(); resolve(); } + private static void ResolvePrefabPatchPlans( + Action resolve, + Action reject + ) + { + var manifestSources = + PluginList.AllEnabledAndActivePlugins + .Where(descriptor => + !string.IsNullOrWhiteSpace( + descriptor.AddressablePrefabPatchLabel + ) + ) + .Select(descriptor => + new PrefabPatchManifestSource + { + OwnerModId = descriptor.Guid, + AddressablesLabel = + descriptor.AddressablePrefabPatchLabel + } + ) + .ToArray(); + PrefabPatchRuntime.DiscoverAndResolve( + PatchingManager.Universe.AllMods, + manifestSources, + resolve, + reject + ); + } + private void SavePatchSummary(Action resolve, Action reject) { PatchingManager.Universe.Summary.RecognizedModIds = PatchingManager.Universe.AllMods; @@ -149,6 +213,7 @@ private void RegisterResourceLocator(Action resolve, Action reject) } Locators.Register(new ArchiveResourceLocator()); + Locators.Register(PrefabPatchRuntime.RegisterResourceProvider()); GameManager.Instance.Game.UI.UitkLoadingCurtain.Data.PatchManagerDefinitionsModifiedCount = CacheManager.Inventory.DefinitionCount; GameManager.Instance.Game.UI.UitkLoadingCurtain.Data.PatchManagerNewAssetCount = @@ -192,6 +257,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 +286,4 @@ public override void BindConfiguration(IConfigFile modConfiguration) [PublicAPI] public static Universe CurrentUniverse => PatchingManager.Universe; } -} \ No newline at end of file +} diff --git a/Runtime/LuaPatching/Builtin/PatchManagerCore.cs b/Runtime/LuaPatching/Builtin/PatchManagerCore.cs index 0dec80b..d6b0903 100644 --- a/Runtime/LuaPatching/Builtin/PatchManagerCore.cs +++ b/Runtime/LuaPatching/Builtin/PatchManagerCore.cs @@ -85,6 +85,44 @@ public PatchDefinition Patch(ScriptExecutionContext context, string converter, s return newPatch; } + /// + /// Begins a declarative prefab patch for one stock Addressables key. + /// + public PrefabPatchLuaBuilder Prefab( + ScriptExecutionContext context, + string name, + DynValue target + ) + { + if (!_universe.RegistrationOpen) + { + throw new ScriptRuntimeException( + $"PM:Prefab('{name}') can only be called during patch " + + "registration." + ); + } + + var modId = context.CurrentGlobalEnv + .Get("ModId") + .CastToString(); + if (target.Type != DataType.String) + { + throw new ScriptRuntimeException( + "PM:Prefab expects the stock prefab's Addressables key." + ); + } + var identity = + global::PatchManager.PrefabPatching.PrefabPatchPrefabIdentity + .FromAddress(target.String); + return new PrefabPatchLuaBuilder( + new global::PatchManager.PrefabPatching.PrefabPatchBuilder( + modId, + name, + identity + ) + ); + } + /// /// Queues a brand-new asset for creation under the given label and address. /// diff --git a/Runtime/LuaPatching/Builtin/PrefabPatchLuaBuilder.cs b/Runtime/LuaPatching/Builtin/PrefabPatchLuaBuilder.cs new file mode 100644 index 0000000..8556c1b --- /dev/null +++ b/Runtime/LuaPatching/Builtin/PrefabPatchLuaBuilder.cs @@ -0,0 +1,384 @@ +using System; +using System.Collections; +using System.Linq; +using System.Reflection; +using MoonSharp.Interpreter; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using PatchManager.PrefabPatching; + +namespace PatchManager.LuaPatching.Builtin; + +/// +/// Lua frontend for the public declarative prefab-patch schema. Lua tables are +/// converted directly into the same model used by C# and visual authoring. +/// +[MoonSharpUserData] +public sealed class PrefabPatchLuaBuilder +{ + private readonly PrefabPatchBuilder _builder; + + internal PrefabPatchLuaBuilder(PrefabPatchBuilder builder) + { + _builder = builder; + } + + public PrefabPatchLuaBuilder Early() + { + _builder.Early(); + return this; + } + + public PrefabPatchLuaBuilder Late() + { + _builder.Late(); + return this; + } + + public PrefabPatchLuaBuilder First() + { + _builder.First(); + return this; + } + + public PrefabPatchLuaBuilder Last() + { + _builder.Last(); + return this; + } + + public PrefabPatchLuaBuilder Needs(params string[] ids) + { + _builder.NeedsMod(ids); + return this; + } + + public PrefabPatchLuaBuilder Conflicts(params string[] ids) + { + _builder.ConflictsMod(ids); + return this; + } + + public PrefabPatchLuaBuilder NeedsPatch(params string[] ids) + { + _builder.NeedsPatch(ids); + return this; + } + + public PrefabPatchLuaBuilder ConflictsPatch(params string[] ids) + { + _builder.ConflictsPatch(ids); + return this; + } + + public PrefabPatchLuaBuilder BeforePatch(params string[] ids) + { + _builder.BeforePatch(ids); + return this; + } + + public PrefabPatchLuaBuilder AfterPatch(params string[] ids) + { + _builder.AfterPatch(ids); + return this; + } + + public PrefabPatchLuaBuilder Before(params string[] modIds) + { + _builder.BeforeMod(modIds); + return this; + } + + public PrefabPatchLuaBuilder After(params string[] modIds) + { + _builder.AfterMod(modIds); + return this; + } + + public PrefabPatchLuaBuilder Configuration(params string[] inputs) + { + _builder.Configuration(inputs); + return this; + } + + public PrefabPatchLuaBuilder Set( + string operationId, + DynValue target, + string propertyPath, + DynValue value + ) + { + _builder.SetValue( + operationId, + Model(target, "operation target"), + propertyPath, + Value(value) + ); + return this; + } + + public PrefabPatchLuaBuilder SetComponent( + string operationId, + string hierarchyPath, + string componentType, + string propertyPath, + DynValue value, + int componentOrdinal = 0 + ) + { + _builder.SetValue( + operationId, + PrefabPatchBuilder.ComponentAt( + hierarchyPath, + componentType, + componentOrdinal + ), + propertyPath, + Value(value) + ); + return this; + } + + public PrefabPatchObjectTarget GameObject(string hierarchyPath) => + PrefabPatchBuilder.GameObjectAt(hierarchyPath); + + public PrefabPatchObjectTarget Component( + string hierarchyPath, + string componentType, + int componentOrdinal = 0 + ) => + PrefabPatchBuilder.ComponentAt( + hierarchyPath, + componentType, + componentOrdinal + ); + + public PrefabPatchLuaBuilder Reference( + string operationId, + DynValue target, + string propertyPath, + DynValue reference + ) + { + _builder.SetObjectReference( + operationId, + Model(target, "operation target"), + propertyPath, + Model( + reference, + "object reference" + ) + ); + return this; + } + + public PrefabPatchLuaBuilder Active( + string operationId, + DynValue target, + bool active + ) + { + _builder.SetActive( + operationId, + Model(target, "operation target"), + active + ); + return this; + } + + public PrefabPatchLuaBuilder Suppress( + string operationId, + DynValue target + ) + { + _builder.SuppressObject( + operationId, + Model(target, "operation target") + ); + return this; + } + + public PrefabPatchLuaBuilder AddObject( + string operationId, + DynValue parent, + DynValue fragment + ) + { + _builder.AddObject( + operationId, + parent.IsNil() + ? null + : Model( + parent, + "parent target" + ), + Model( + fragment, + "object fragment" + ) + ); + return this; + } + + public PrefabPatchLuaBuilder AddComponent( + string operationId, + DynValue target, + DynValue component + ) + { + _builder.AddComponent( + operationId, + Model(target, "operation target"), + Model( + component, + "component fragment" + ) + ); + return this; + } + + public PrefabPatchLuaBuilder RemoveComponent( + string operationId, + DynValue target + ) + { + _builder.RemoveComponent( + operationId, + Model(target, "component target") + ); + return this; + } + + public JsonUserData Build() => + new(JToken.Parse(PrefabPatchJson.Serialize(_builder.Build()))); + + public void Register() + { + _builder.Register(); + } + + private static PrefabPatchValue Value(DynValue value) + { + switch (value.Type) + { + case DataType.Boolean: + return PrefabPatchValue.FromBoolean(value.Boolean); + case DataType.Number: + return PrefabPatchValue.FromFloat(value.Number); + case DataType.String: + return PrefabPatchValue.FromString(value.String); + case DataType.Table: + case DataType.UserData: + return Model( + value, + "typed prefab-patch value" + ); + default: + throw new ScriptRuntimeException( + $"Prefab patch values cannot be '{value.Type}'." + ); + } + } + + internal static T Model(DynValue value, string description) + { + try + { + if ( + value.Type == DataType.UserData + && value.UserData?.Object is T model + ) + { + return model; + } + var token = JsonUserData.GetJTokenForDynValue(value); + token = NormalizeEmptyTables(token, typeof(T)); + var serializer = JsonSerializer.Create( + PrefabPatchJson.Settings + ); + var result = token.ToObject(serializer); + if (result == null) + { + throw new ScriptRuntimeException( + $"Expected {description}, got nil." + ); + } + return result; + } + catch (ScriptRuntimeException) + { + throw; + } + catch (Exception exception) + { + throw new ScriptRuntimeException( + $"Invalid {description}: {exception.Message}" + ); + } + } + + private static JToken NormalizeEmptyTables( + JToken token, + Type expectedType + ) + { + if ( + token is JObject emptyObject + && !emptyObject.Properties().Any() + && IsCollection(expectedType) + ) + return new JArray(); + if (token is JArray array) + { + var itemType = CollectionItemType(expectedType); + if (itemType != null) + { + for (var index = 0; index < array.Count; index++) + array[index] = NormalizeEmptyTables( + array[index], + itemType + ); + } + return array; + } + if (token is not JObject value) + return token; + + const BindingFlags flags = + BindingFlags.Instance | BindingFlags.Public; + foreach (var field in expectedType.GetFields(flags)) + { + var camelName = + char.ToLowerInvariant(field.Name[0]) + + field.Name.Substring(1); + var property = value.Property( + camelName, + StringComparison.OrdinalIgnoreCase + ) + ?? value.Property( + field.Name, + StringComparison.OrdinalIgnoreCase + ); + if (property != null) + property.Value = NormalizeEmptyTables( + property.Value, + field.FieldType + ); + } + return value; + } + + private static bool IsCollection(Type type) => + type.IsArray + || ( + type != typeof(string) + && typeof(IEnumerable).IsAssignableFrom(type) + ); + + private static Type CollectionItemType(Type type) => + type.IsArray + ? type.GetElementType() + : type.IsGenericType + ? type.GetGenericArguments()[0] + : null; +} diff --git a/Runtime/LuaPatching/Builtin/PrefabPatchLuaBuilder.cs.meta b/Runtime/LuaPatching/Builtin/PrefabPatchLuaBuilder.cs.meta new file mode 100644 index 0000000..87c7ded --- /dev/null +++ b/Runtime/LuaPatching/Builtin/PrefabPatchLuaBuilder.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b0a738bdf2e64d789dbfb74b4c881f1d diff --git a/Runtime/PrefabPatching.meta b/Runtime/PrefabPatching.meta new file mode 100644 index 0000000..6550ab1 --- /dev/null +++ b/Runtime/PrefabPatching.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 840fdb67dba5bef47b5ed07fb118481e +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/PrefabPatching/PatchManager.PrefabPatching.asmdef b/Runtime/PrefabPatching/PatchManager.PrefabPatching.asmdef new file mode 100644 index 0000000..3510d93 --- /dev/null +++ b/Runtime/PrefabPatching/PatchManager.PrefabPatching.asmdef @@ -0,0 +1,17 @@ +{ + "name": "PatchManager.PrefabPatching", + "rootNamespace": "PatchManager.PrefabPatching", + "references": [ + "Unity.Addressables", + "Unity.ResourceManager" + ], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/Runtime/PrefabPatching/PatchManager.PrefabPatching.asmdef.meta b/Runtime/PrefabPatching/PatchManager.PrefabPatching.asmdef.meta new file mode 100644 index 0000000..c8cee06 --- /dev/null +++ b/Runtime/PrefabPatching/PatchManager.PrefabPatching.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 8689a1c3eae3ae34a84c40b6173daf4f +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/PrefabPatching/PrefabPatchBuilder.cs b/Runtime/PrefabPatching/PrefabPatchBuilder.cs new file mode 100644 index 0000000..e5548d4 --- /dev/null +++ b/Runtime/PrefabPatching/PrefabPatchBuilder.cs @@ -0,0 +1,419 @@ +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 string _modId; + private readonly PrefabPatchManifest _manifest; + private readonly HashSet _needsMods = new(StringComparer.Ordinal); + private readonly HashSet _conflictsMods = new( + StringComparer.Ordinal + ); + private readonly HashSet _needsPatches = new(StringComparer.Ordinal); + private readonly HashSet _conflictsPatches = new( + StringComparer.Ordinal + ); + private readonly HashSet _beforePatches = new( + StringComparer.Ordinal + ); + private readonly HashSet _afterPatches = new(StringComparer.Ordinal); + private readonly HashSet _beforeMods = new(StringComparer.Ordinal); + private readonly HashSet _afterMods = new(StringComparer.Ordinal); + + public PrefabPatchBuilder( + string modId, + string patchName, + PrefabPatchPrefabIdentity target + ) + { + if (string.IsNullOrWhiteSpace(modId)) + throw new ArgumentException("Mod ID is required.", nameof(modId)); + if (string.IsNullOrWhiteSpace(patchName)) + throw new ArgumentException( + "Patch name is required.", + nameof(patchName) + ); + if (patchName.IndexOf(':') >= 0) + throw new ArgumentException( + "Patch name must be local to the mod and cannot contain ':'.", + nameof(patchName) + ); + _modId = modId.Trim(); + _manifest = new PrefabPatchManifest + { + PatchName = patchName.Trim(), + TargetPrefab = + target ?? throw new ArgumentNullException(nameof(target)) + }; + } + + public PrefabPatchBuilder( + string modId, + string patchName, + string address + ) : this( + modId, + patchName, + PrefabPatchPrefabIdentity.FromAddress(address) + ) { } + + 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 = PrefabPatchOwnership.Qualify( + _modId, + _manifest.PatchName + ); + _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 SetObjectReference( + string operationId, + PrefabPatchObjectTarget target, + string propertyPath, + PrefabPatchObjectReference reference + ) => + AddOperation( + new PrefabPatchOperation + { + OperationId = operationId, + Kind = PrefabPatchOperationKind.SetObjectReference, + Target = target, + PropertyPath = propertyPath, + ObjectReference = reference + } + ); + + public PrefabPatchBuilder SuppressObject( + string operationId, + PrefabPatchObjectTarget target + ) => + AddOperation( + new PrefabPatchOperation + { + OperationId = operationId, + Kind = PrefabPatchOperationKind.SuppressObject, + Target = target + } + ); + + public PrefabPatchBuilder AddObject( + string operationId, + PrefabPatchObjectTarget parent, + PrefabPatchObjectFragment fragment + ) => + AddOperation( + new PrefabPatchOperation + { + OperationId = operationId, + Kind = PrefabPatchOperationKind.AddObject, + Target = parent, + AddedObject = fragment + } + ); + + public PrefabPatchBuilder AddComponent( + string operationId, + PrefabPatchObjectTarget target, + PrefabPatchComponentFragment component + ) => + AddOperation( + new PrefabPatchOperation + { + OperationId = operationId, + Kind = PrefabPatchOperationKind.AddComponent, + Target = target, + AddedComponent = component + } + ); + + public PrefabPatchBuilder RemoveComponent( + string operationId, + PrefabPatchObjectTarget target + ) => + AddOperation( + new PrefabPatchOperation + { + OperationId = operationId, + Kind = PrefabPatchOperationKind.RemoveComponent, + Target = target + } + ); + + public PrefabPatchBuilder Configuration(params string[] inputs) + { + _manifest.ConfigurationInputs = Sorted( + (_manifest.ConfigurationInputs ?? Array.Empty()) + .Concat(inputs ?? Array.Empty()) + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Distinct(StringComparer.Ordinal) + ); + return this; + } + + public static PrefabPatchObjectTarget PatchObject( + string ownerPatchId, + string objectId + ) => + new() + { + Kind = PrefabPatchTargetKind.PatchOwned, + OwnerPatchId = ownerPatchId, + ObjectId = objectId, + RuntimeLocator = new PrefabPatchRuntimeLocator + { + TargetKind = PrefabPatchRuntimeTargetKind.GameObject, + SiblingIndices = Array.Empty() + } + }; + + public static PrefabPatchObjectTarget PatchComponent( + string ownerPatchId, + string componentId + ) => + new() + { + Kind = PrefabPatchTargetKind.PatchComponent, + OwnerPatchId = ownerPatchId, + ComponentId = componentId, + RuntimeLocator = new PrefabPatchRuntimeLocator + { + TargetKind = PrefabPatchRuntimeTargetKind.Component, + SiblingIndices = Array.Empty() + } + }; + + public static PrefabPatchObjectTarget GameObjectAt( + string hierarchyPath + ) => + StockTarget( + hierarchyPath, + PrefabPatchRuntimeTargetKind.GameObject, + typeof(UnityEngine.GameObject).AssemblyQualifiedName, + 0 + ); + + public static PrefabPatchObjectTarget ComponentAt( + string hierarchyPath, + int componentOrdinal = 0 + ) where TComponent : UnityEngine.Component => + ComponentAt( + hierarchyPath, + typeof(TComponent).AssemblyQualifiedName, + componentOrdinal + ); + + public static PrefabPatchObjectTarget ComponentAt( + string hierarchyPath, + string componentType, + int componentOrdinal = 0 + ) + { + if (string.IsNullOrWhiteSpace(componentType)) + throw new ArgumentException( + "Component type is required.", + nameof(componentType) + ); + return StockTarget( + hierarchyPath, + PrefabPatchRuntimeTargetKind.Component, + componentType, + componentOrdinal + ); + } + + public static PrefabPatchObjectReference Addressable( + string address, + Type expectedType = null + ) => + PrefabPatchObjectReference.FromAddress( + address, + expectedType?.AssemblyQualifiedName + ); + + public static PrefabPatchObjectReference TargetReference( + PrefabPatchObjectTarget target, + Type expectedType = null + ) => + PrefabPatchObjectReference.FromTarget( + target, + expectedType?.AssemblyQualifiedName + ); + + public PrefabPatchManifest Build() + { + _manifest.NeedsMods = Sorted(_needsMods); + _manifest.ConflictsMods = Sorted(_conflictsMods); + _manifest.NeedsPatches = Sorted(_needsPatches); + _manifest.ConflictsPatches = Sorted(_conflictsPatches); + _manifest.BeforePatches = Sorted(_beforePatches); + _manifest.AfterPatches = Sorted(_afterPatches); + _manifest.BeforeMods = Sorted(_beforeMods); + _manifest.AfterMods = Sorted(_afterMods); + _manifest.DeclaredCapabilities = _manifest.Operations + .Select(value => value.Kind.ToString()) + .Distinct(StringComparer.Ordinal) + .OrderBy(value => value, StringComparer.Ordinal) + .ToArray(); + _manifest.ManifestHash = PrefabPatchJson.CalculateManifestHash(_manifest); + return PrefabPatchOwnership.Bind(_manifest, _modId); + } + + public PrefabPatchManifest Register() + { + var manifest = Build(); + PrefabPatchRuntime.Register(manifest); + return manifest; + } + + private PrefabPatchBuilder Add( + ISet destination, + IEnumerable ids + ) + { + foreach ( + var id in (ids ?? Array.Empty()).Where( + value => !string.IsNullOrWhiteSpace(value) + ) + ) + { + destination.Add(id); + } + + return this; + } + + private static IEnumerable Normalize(IEnumerable ids) => + ids ?? Array.Empty(); + + private static string[] Sorted(IEnumerable values) => + values.OrderBy(value => value, StringComparer.Ordinal).ToArray(); + + private static PrefabPatchObjectTarget StockTarget( + string hierarchyPath, + PrefabPatchRuntimeTargetKind targetKind, + string objectType, + int componentOrdinal + ) + { + if (hierarchyPath == null) + throw new ArgumentNullException(nameof(hierarchyPath)); + if (componentOrdinal < 0) + throw new ArgumentOutOfRangeException( + nameof(componentOrdinal) + ); + var normalizedPath = string.Join( + "/", + hierarchyPath.Split( + new[] { '/' }, + StringSplitOptions.RemoveEmptyEntries + ) + ); + return new PrefabPatchObjectTarget + { + Kind = PrefabPatchTargetKind.Stock, + ObjectType = objectType, + RuntimeLocator = new PrefabPatchRuntimeLocator + { + HierarchyPath = normalizedPath, + TargetKind = targetKind, + ComponentType = + targetKind == PrefabPatchRuntimeTargetKind.Component + ? objectType + : null, + ComponentOrdinal = componentOrdinal, + DisplayPath = normalizedPath, + SiblingIndices = Array.Empty() + } + }; + } +} diff --git a/Runtime/PrefabPatching/PrefabPatchBuilder.cs.meta b/Runtime/PrefabPatching/PrefabPatchBuilder.cs.meta new file mode 100644 index 0000000..899b53c --- /dev/null +++ b/Runtime/PrefabPatching/PrefabPatchBuilder.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c2c844e10010e0643a7287766743f9b4 \ No newline at end of file diff --git a/Runtime/PrefabPatching/PrefabPatchComposer.cs b/Runtime/PrefabPatching/PrefabPatchComposer.cs new file mode 100644 index 0000000..ede1f9a --- /dev/null +++ b/Runtime/PrefabPatching/PrefabPatchComposer.cs @@ -0,0 +1,1427 @@ +using System; +using System.Collections.Generic; +using System.Collections; +using System.Diagnostics; +using System.Globalization; +using System.Linq; +using System.Reflection; +using Newtonsoft.Json; +using UnityEngine; +using Object = UnityEngine.Object; + +namespace PatchManager.PrefabPatching; + +/// +/// Applies a validated resolved plan once to a loaded external prefab asset. +/// The caller owns and retains the Addressables handles. +/// +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; + public string Failure; + public long ElapsedMilliseconds; + public int AppliedOperationCount; + public Dictionary PatchOwnedObjects = new( + StringComparer.Ordinal + ); + public Dictionary PatchOwnedComponents = new( + StringComparer.Ordinal + ); + } + + public static Result ApplySynchronously( + GameObject prefab, + PrefabPatchResolvedPlan plan, + IReadOnlyDictionary references + ) + { + var stopwatch = Stopwatch.StartNew(); + var result = new Result(); + Transform originalParent = null; + var originalSiblingIndex = 0; + GameObject safetyRoot = null; + try + { + if (prefab == null) + throw new ArgumentNullException(nameof(prefab)); + if (plan == null || !plan.IsValid) + throw new InvalidOperationException( + "The prefab patch plan is missing or invalid." + ); + var actualFingerprint = PrefabPatchStructure.Calculate(prefab); + if ( + !string.IsNullOrWhiteSpace( + plan.TargetPrefab.StructuralFingerprint + ) + && !string.Equals( + actualFingerprint, + plan.TargetPrefab.StructuralFingerprint, + StringComparison.Ordinal + ) + ) + { + throw new InvalidOperationException( + $"Stock prefab '{plan.TargetPrefab.Address}' structural " + + $"fingerprint changed. Expected " + + $"'{plan.TargetPrefab.StructuralFingerprint}', got " + + $"'{actualFingerprint}'. Actual structure: " + + $"'{PrefabPatchStructure.Describe(prefab)}'. " + + "Recompile or repair the patch." + ); + } + + if (prefab.activeInHierarchy) + { + originalParent = prefab.transform.parent; + originalSiblingIndex = prefab.transform.GetSiblingIndex(); + safetyRoot = new GameObject( + "PatchManager Composition Root" + ); + safetyRoot.hideFlags = HideFlags.HideAndDontSave; + safetyRoot.SetActive(false); + prefab.transform.SetParent(safetyRoot.transform, false); + } + var unusedDeferredDestroy = false; + var pendingReferences = new List(); + foreach (var operation in plan.Operations) + { + ApplyOne( + prefab, + operation, + references, + result.PatchOwnedObjects, + result.PatchOwnedComponents, + pendingReferences, + ref unusedDeferredDestroy, + true + ); + result.AppliedOperationCount++; + } + + foreach (var pending in pendingReferences) + { + var reference = ResolveReference( + prefab, + pending.Reference, + references, + result.PatchOwnedObjects, + result.PatchOwnedComponents + ); + if (reference == null && pending.Reference != null) + { + throw new InvalidOperationException( + $"Operation '{pending.OperationId}' could not resolve " + + $"object reference " + + $"'{DescribeReference(pending.Reference)}'." + ); + } + + SetRawValue( + pending.Target, + pending.PropertyPath, + reference + ); + } + + result.Success = true; + } + catch (Exception exception) + { + result.Failure = exception.ToString(); + } + finally + { + if (safetyRoot != null) + { + if (prefab != null) + { + prefab.transform.SetParent(originalParent, false); + if (originalParent != null) + prefab.transform.SetSiblingIndex(originalSiblingIndex); + } + Object.DestroyImmediate(safetyRoot); + } + stopwatch.Stop(); + result.ElapsedMilliseconds = stopwatch.ElapsedMilliseconds; + } + + return result; + } + + private static void ApplyOne( + GameObject root, + PrefabPatchOperation operation, + IReadOnlyDictionary references, + IDictionary patchOwned, + IDictionary patchComponents, + ICollection pendingReferences, + ref bool deferredDestroy, + bool immediateDestroy + ) + { + if (operation.Kind == PrefabPatchOperationKind.AddObject) + { + var parentObject = operation.Target == null + ? root + : AsGameObject( + Resolve( + root, + operation.Target, + patchOwned, + patchComponents + ) + ); + if (parentObject == null) + throw MissingTarget(operation); + CreateFragment( + operation.PatchId, + operation.AddedObject, + parentObject.transform, + references, + patchOwned, + patchComponents, + pendingReferences, + operation.OperationId + ); + return; + } + + var target = Resolve( + root, + operation.Target, + patchOwned, + patchComponents + ); + if (target == null) + throw MissingTarget(operation); + switch (operation.Kind) + { + case PrefabPatchOperationKind.SetValue: + SetValue(target, operation.PropertyPath, operation.Value); + return; + case PrefabPatchOperationKind.SetObjectReference: + if (operation.ObjectReference == null) + throw new InvalidOperationException( + $"Operation '{operation.OperationId}' has no object " + + "reference payload." + ); + pendingReferences.Add( + new PendingReference + { + Target = target, + PropertyPath = operation.PropertyPath, + Reference = operation.ObjectReference, + OperationId = operation.OperationId + } + ); + return; + case PrefabPatchOperationKind.SetActive: + AsGameObject(target)?.SetActive( + operation.Value?.Boolean ?? false + ); + return; + case PrefabPatchOperationKind.SuppressObject: + AsGameObject(target)?.SetActive(false); + return; + case PrefabPatchOperationKind.AddComponent: + AddComponent( + AsGameObject(target), + operation.AddedComponent, + references, + patchOwned, + patchComponents, + pendingReferences, + operation.PatchId, + operation.OperationId + ); + return; + case PrefabPatchOperationKind.RemoveComponent: + if (target is not Component component) + { + throw new InvalidOperationException( + $"RemoveComponent operation '{operation.OperationId}' " + + "did not resolve to a Component." + ); + } + + if (immediateDestroy) + { + // Runtime composition operates on a session-owned clone, + // never directly on the read-only AssetBundle asset. + Object.DestroyImmediate(component); + if (component != null) + { + throw new InvalidOperationException( + $"RemoveComponent operation " + + $"'{operation.OperationId}' did not destroy " + + $"'{operation.Target.ObjectType}'." + ); + } + } + else + { + Object.Destroy(component); + deferredDestroy = true; + } + return; + default: + throw new NotSupportedException( + $"Prefab operation kind '{operation.Kind}' is not supported " + + $"by composer {PrefabPatchSchema.ComposerVersion}." + ); + } + } + + private static Object Resolve( + GameObject root, + PrefabPatchObjectTarget target, + IDictionary patchOwned, + IDictionary patchComponents + ) + { + if (target.Kind == PrefabPatchTargetKind.PatchComponent) + { + var componentKey = + $"{target.OwnerPatchId}:{target.ComponentId}"; + return patchComponents.TryGetValue( + componentKey, + out var patchComponent + ) + ? patchComponent + : null; + } + + Transform transform; + if (target.Kind == PrefabPatchTargetKind.Stock) + { + transform = !string.IsNullOrWhiteSpace( + target.RuntimeLocator?.HierarchyPath + ) + ? PrefabPatchStructure.ResolveHierarchyPath( + root.transform, + target.RuntimeLocator.HierarchyPath + ) + : PrefabPatchStructure.Resolve( + root.transform, + target.RuntimeLocator?.SiblingIndices + ); + } + else + { + var key = $"{target.OwnerPatchId}:{target.ObjectId}"; + if (!patchOwned.TryGetValue(key, out var owned) || owned == null) + return null; + transform = owned.transform; + } + + if (transform == null) + return null; + if ( + target.RuntimeLocator == null + || target.RuntimeLocator.TargetKind + == PrefabPatchRuntimeTargetKind.GameObject + ) + { + return transform.gameObject; + } + + var type = ResolveType(target.RuntimeLocator.ComponentType); + if (type == null || !typeof(Component).IsAssignableFrom(type)) + return null; + var components = transform.gameObject.GetComponents(type); + var ordinal = target.RuntimeLocator.ComponentOrdinal; + return ordinal >= 0 && ordinal < components.Length + ? components[ordinal] + : null; + } + + private static GameObject CreateFragment( + string patchId, + PrefabPatchObjectFragment fragment, + Transform parent, + IReadOnlyDictionary references, + IDictionary patchOwned, + IDictionary patchComponents, + ICollection pendingReferences, + string operationId + ) + { + if (fragment == null || string.IsNullOrWhiteSpace(fragment.ObjectId)) + throw new InvalidOperationException( + $"Patch '{patchId}' contains an invalid added-object fragment." + ); + var key = $"{patchId}:{fragment.ObjectId}"; + if (patchOwned.ContainsKey(key)) + throw new InvalidOperationException( + $"Patch-owned object '{key}' already exists." + ); + + var transformType = ResolveType(fragment.TransformType); + var gameObject = + transformType != null + && typeof(RectTransform).IsAssignableFrom(transformType) + ? new GameObject( + fragment.Name ?? fragment.ObjectId, + typeof(RectTransform) + ) + : new GameObject(fragment.Name ?? fragment.ObjectId); + gameObject.transform.SetParent(parent, false); + gameObject.layer = fragment.Layer; + if (!string.IsNullOrWhiteSpace(fragment.Tag)) + { + try + { + gameObject.tag = fragment.Tag; + } + catch (UnityException exception) + { + throw new InvalidOperationException( + $"Patch-owned object '{key}' uses unknown tag " + + $"'{fragment.Tag}'.", + exception + ); + } + } + gameObject.isStatic = fragment.IsStatic; + gameObject.transform.localPosition = ToVector3( + fragment.LocalPosition, + Vector3.zero + ); + gameObject.transform.localRotation = ToQuaternion( + fragment.LocalRotation, + Quaternion.identity + ); + gameObject.transform.localScale = ToVector3( + fragment.LocalScale, + Vector3.one + ); + if (gameObject.transform is RectTransform rectTransform) + { + rectTransform.anchorMin = ToVector2( + fragment.AnchorMin, + rectTransform.anchorMin + ); + rectTransform.anchorMax = ToVector2( + fragment.AnchorMax, + rectTransform.anchorMax + ); + rectTransform.anchoredPosition = ToVector2( + fragment.AnchoredPosition, + rectTransform.anchoredPosition + ); + rectTransform.sizeDelta = ToVector2( + fragment.SizeDelta, + rectTransform.sizeDelta + ); + rectTransform.pivot = ToVector2( + fragment.Pivot, + rectTransform.pivot + ); + } + gameObject.SetActive(fragment.Active); + gameObject.AddComponent().Id = fragment.ObjectId; + patchOwned.Add(key, gameObject); + foreach (var component in fragment.Components) + AddComponent( + gameObject, + component, + references, + patchOwned, + patchComponents, + pendingReferences, + patchId, + operationId + ); + foreach (var child in fragment.Children) + CreateFragment( + patchId, + child, + gameObject.transform, + references, + patchOwned, + patchComponents, + pendingReferences, + operationId + ); + return gameObject; + } + + private static Component AddComponent( + GameObject target, + PrefabPatchComponentFragment fragment, + IReadOnlyDictionary references, + IDictionary patchOwned, + IDictionary patchComponents, + ICollection pendingReferences, + string patchId, + string operationId + ) + { + if (target == null || fragment == null) + throw new InvalidOperationException( + "An added component has no target or payload." + ); + if (string.IsNullOrWhiteSpace(fragment.ComponentType)) + throw new InvalidOperationException( + "An added component has no assembly-qualified component type." + ); + var type = ResolveType(fragment.ComponentType); + if (type == null || !typeof(Component).IsAssignableFrom(type)) + { + throw new InvalidOperationException( + $"Added component type '{fragment.ComponentType}' could " + + "not be resolved as a Unity Component." + ); + } + if (typeof(Transform).IsAssignableFrom(type)) + { + throw new InvalidOperationException( + $"Transform type '{fragment.ComponentType}' must be " + + "declared by the object fragment, not added as a " + + "component." + ); + } + + var component = target.AddComponent(type); + foreach (var value in fragment.Values ?? new()) + { + if ( + value == null + || string.IsNullOrWhiteSpace(value.PropertyPath) + ) + continue; + SetValue(component, value.PropertyPath, value.Value); + } + QueueComponentReferences( + component, + fragment, + pendingReferences, + operationId + ); + RegisterPatchComponent( + patchId, + fragment, + component, + patchComponents + ); + return component; + } + + private static void QueueComponentReferences( + Component component, + PrefabPatchComponentFragment fragment, + ICollection pendingReferences, + string operationId + ) + { + foreach (var reference in fragment.References ?? new()) + { + if ( + reference == null + || string.IsNullOrWhiteSpace(reference.PropertyPath) + ) + continue; + pendingReferences.Add( + new PendingReference + { + Target = component, + PropertyPath = reference.PropertyPath, + Reference = reference.Reference, + OperationId = operationId + } + ); + } + } + + private static void RegisterPatchComponent( + string patchId, + PrefabPatchComponentFragment fragment, + Component component, + IDictionary patchComponents + ) + { + if (string.IsNullOrWhiteSpace(fragment.ComponentId)) + return; + var key = $"{patchId}:{fragment.ComponentId}"; + if (patchComponents.ContainsKey(key)) + throw new InvalidOperationException( + $"Patch-owned component '{key}' already exists." + ); + patchComponents.Add(key, component); + } + + private static Object ResolveReference( + GameObject root, + PrefabPatchObjectReference reference, + IReadOnlyDictionary references, + IDictionary patchOwned, + IDictionary patchComponents + ) + { + if (reference == null) + return null; + if ( + reference.Kind == PrefabPatchObjectReferenceKind.Target + || reference.Target != null + ) + { + return Resolve( + root, + reference.Target, + patchOwned, + patchComponents + ); + } + + return !string.IsNullOrWhiteSpace(reference.Address) + && references.TryGetValue(reference.Address, out var value) + ? value + : null; + } + + private static string DescribeReference( + PrefabPatchObjectReference reference + ) => + reference == null + ? "" + : reference.Kind == PrefabPatchObjectReferenceKind.Target + || reference.Target != null + ? reference.Target?.CanonicalKey ?? "" + : reference.Address ?? ""; + + private static void SetValue( + Object target, + string propertyPath, + PrefabPatchValue value + ) + { + if (value == null) + throw new InvalidOperationException( + $"Property '{propertyPath}' has no typed value." + ); + if (value.Kind == PrefabPatchValueKind.ArraySize) + { + SetCollectionSize( + target, + propertyPath, + checked((int)value.Integer) + ); + return; + } + SetRawValue(target, propertyPath, ConvertValue(value)); + } + + private static void SetRawValue( + Object target, + string propertyPath, + object value + ) + { + if (target is Transform transform) + { + if (TrySetTransform(transform, propertyPath, value)) + return; + } + + if ( + target is GameObject gameObject + && ( + propertyPath == "m_IsActive" + || propertyPath == "activeSelf" + ) + ) + { + gameObject.SetActive(Convert.ToBoolean(value, CultureInfo.InvariantCulture)); + return; + } + + SetMemberPath(target, propertyPath, value); + } + + private static bool TrySetTransform( + Transform transform, + string propertyPath, + object value + ) + { + var segments = propertyPath.Split('.'); + if (segments.Length != 2) + return false; + var component = Convert.ToSingle(value, CultureInfo.InvariantCulture); + if ( + transform is RectTransform rectTransform + && TrySetRectTransform( + rectTransform, + segments[0], + segments[1], + component + ) + ) + { + return true; + } + + switch (segments[0]) + { + case "m_LocalPosition": + case "localPosition": + { + var vector = transform.localPosition; + SetVectorComponent(ref vector, segments[1], component); + transform.localPosition = vector; + return true; + } + case "m_LocalScale": + case "localScale": + { + var vector = transform.localScale; + SetVectorComponent(ref vector, segments[1], component); + transform.localScale = vector; + return true; + } + case "m_LocalRotation": + case "localRotation": + { + var quaternion = transform.localRotation; + SetQuaternionComponent( + ref quaternion, + segments[1], + component + ); + transform.localRotation = quaternion; + return true; + } + default: + return false; + } + } + + private static bool TrySetRectTransform( + RectTransform transform, + string property, + string componentName, + float componentValue + ) + { + switch (property) + { + case "m_AnchoredPosition": + case "anchoredPosition": + { + var value = transform.anchoredPosition; + SetVector2Component( + ref value, + componentName, + componentValue + ); + transform.anchoredPosition = value; + return true; + } + case "m_SizeDelta": + case "sizeDelta": + { + var value = transform.sizeDelta; + SetVector2Component( + ref value, + componentName, + componentValue + ); + transform.sizeDelta = value; + return true; + } + case "m_AnchorMin": + case "anchorMin": + { + var value = transform.anchorMin; + SetVector2Component( + ref value, + componentName, + componentValue + ); + transform.anchorMin = value; + return true; + } + case "m_AnchorMax": + case "anchorMax": + { + var value = transform.anchorMax; + SetVector2Component( + ref value, + componentName, + componentValue + ); + transform.anchorMax = value; + return true; + } + case "m_Pivot": + case "pivot": + { + var value = transform.pivot; + SetVector2Component( + ref value, + componentName, + componentValue + ); + transform.pivot = value; + return true; + } + default: + return false; + } + } + + private static void SetVector2Component( + ref Vector2 vector, + string component, + float value + ) + { + switch (component) + { + case "x": + vector.x = value; + return; + case "y": + vector.y = value; + return; + default: + throw new InvalidOperationException( + $"Unknown Vector2 component '{component}'." + ); + } + } + + private static void SetMemberPath( + object root, + string path, + object value + ) + { + var segments = ParsePath(path); + SetMemberRecursive(root, segments, 0, value); + } + + private static void SetCollectionSize( + object root, + string propertyPath, + int size + ) + { + const string suffix = ".Array.size"; + if ( + string.IsNullOrWhiteSpace(propertyPath) + || !propertyPath.EndsWith(suffix, StringComparison.Ordinal) + ) + { + throw new InvalidOperationException( + $"Collection-size path '{propertyPath}' does not end in " + + $"'{suffix}'." + ); + } + if (size < 0) + throw new ArgumentOutOfRangeException(nameof(size)); + var collectionPath = propertyPath.Substring( + 0, + propertyPath.Length - suffix.Length + ); + ResizeCollectionRecursive( + root, + ParsePath(collectionPath), + 0, + size + ); + } + + private static object ResizeCollectionRecursive( + object current, + IReadOnlyList segments, + int index, + int size + ) + { + if (current == null) + throw new InvalidOperationException( + "Cannot resize a collection through a null serialized value." + ); + var segment = segments[index]; + var member = FindMember(current.GetType(), segment.Name) + ?? throw new MissingMemberException( + current.GetType().FullName, + segment.Name + ); + var memberType = GetMemberType(member); + var memberValue = GetMemberValue(member, current); + if (segment.HasIndex) + { + if (memberValue is not IList list) + throw new InvalidOperationException( + $"Member '{segment.Name}' is not an indexed collection." + ); + var element = list[segment.Index]; + var updated = ResizeCollectionRecursive( + element, + segments, + index + 1, + size + ); + var elementType = GetCollectionElementType(memberType); + if (elementType.IsValueType) + list[segment.Index] = updated; + return current; + } + + if (index < segments.Count - 1) + { + var updated = ResizeCollectionRecursive( + memberValue, + segments, + index + 1, + size + ); + if (memberType.IsValueType) + SetMemberValue(member, current, updated); + return current; + } + + if (memberType.IsArray) + { + var elementType = + memberType.GetElementType() ?? typeof(object); + var previous = memberValue as Array; + var replacement = Array.CreateInstance(elementType, size); + if (previous != null) + { + Array.Copy( + previous, + replacement, + Math.Min(previous.Length, size) + ); + } + for ( + var itemIndex = previous?.Length ?? 0; + itemIndex < size; + itemIndex++ + ) + { + replacement.SetValue( + CreateCollectionElement(elementType), + itemIndex + ); + } + SetMemberValue(member, current, replacement); + return current; + } + + if (memberValue is not IList mutableList) + { + if ( + memberType.IsInterface + || memberType.IsAbstract + ) + { + throw new InvalidOperationException( + $"Collection member '{segment.Name}' of type " + + $"'{memberType.FullName}' is null and cannot be " + + "constructed." + ); + } + mutableList = (IList)Activator.CreateInstance(memberType); + SetMemberValue(member, current, mutableList); + } + var itemType = GetCollectionElementType(memberType); + while (mutableList.Count > size) + mutableList.RemoveAt(mutableList.Count - 1); + while (mutableList.Count < size) + mutableList.Add(CreateCollectionElement(itemType)); + return current; + } + + private static object CreateCollectionElement(Type itemType) + { + if ( + itemType == typeof(string) + || itemType.IsAbstract + || itemType.IsInterface + ) + return null; + try + { + return Activator.CreateInstance(itemType); + } + catch (MissingMethodException) + { + return System.Runtime.Serialization.FormatterServices + .GetUninitializedObject(itemType); + } + } + + private readonly struct MemberPathSegment + { + public readonly string Name; + public readonly int Index; + public readonly bool HasIndex; + + public MemberPathSegment(string name, int index, bool hasIndex) + { + Name = name; + Index = index; + HasIndex = hasIndex; + } + + public override string ToString() => + HasIndex ? $"{Name}[{Index}]" : Name; + } + + private static IReadOnlyList ParsePath(string path) + { + if (string.IsNullOrWhiteSpace(path)) + throw new ArgumentException( + "A serialized property path is required.", + nameof(path) + ); + var normalized = path.Replace(".Array.data[", "["); + var result = new List(); + foreach (var raw in normalized.Split('.')) + { + var bracket = raw.LastIndexOf('['); + if ( + bracket > 0 + && raw.EndsWith("]", StringComparison.Ordinal) + && int.TryParse( + raw.Substring(bracket + 1, raw.Length - bracket - 2), + NumberStyles.Integer, + CultureInfo.InvariantCulture, + out var index + ) + ) + { + result.Add( + new MemberPathSegment( + raw.Substring(0, bracket), + index, + true + ) + ); + } + else + { + result.Add(new MemberPathSegment(raw, 0, false)); + } + } + + return result; + } + + private static object SetMemberRecursive( + object current, + IReadOnlyList segments, + int index, + object value + ) + { + if (current == null) + throw new InvalidOperationException( + $"Cannot traverse null while setting " + + $"'{string.Join(".", segments)}'." + ); + var segment = segments[index]; + var member = FindMember(current.GetType(), segment.Name); + if (member == null) + throw new MissingMemberException( + current.GetType().FullName, + segment.Name + ); + var memberType = GetMemberType(member); + var memberValue = GetMemberValue(member, current); + if (segment.HasIndex) + { + if (memberValue is not IList list) + { + throw new InvalidOperationException( + $"Member '{segment.Name}' on " + + $"'{current.GetType().FullName}' is not an indexed " + + "serialized collection." + ); + } + if (segment.Index < 0 || segment.Index >= list.Count) + { + throw new IndexOutOfRangeException( + $"Serialized collection '{segment.Name}' has " + + $"{list.Count} item(s), but index {segment.Index} " + + "was requested." + ); + } + + var elementType = GetCollectionElementType(memberType); + if (index == segments.Count - 1) + { + list[segment.Index] = ConvertForType(value, elementType); + return current; + } + + var element = list[segment.Index]; + var updatedElement = SetMemberRecursive( + element, + segments, + index + 1, + value + ); + if (elementType.IsValueType) + list[segment.Index] = updatedElement; + return current; + } + + if (index == segments.Count - 1) + { + var converted = ConvertForType(value, memberType); + SetMemberValue(member, current, converted); + return current; + } + + var updatedChild = SetMemberRecursive( + memberValue, + segments, + index + 1, + value + ); + if (memberType.IsValueType) + SetMemberValue(member, current, updatedChild); + return current; + } + + private static Type GetCollectionElementType(Type collectionType) + { + if (collectionType.IsArray) + return collectionType.GetElementType() ?? typeof(object); + if (collectionType.IsGenericType) + return collectionType.GetGenericArguments()[0]; + return typeof(object); + } + + private static MemberInfo FindMember(Type type, string name) + { + const BindingFlags flags = + BindingFlags.Instance + | BindingFlags.Public + | BindingFlags.NonPublic; + for (var current = type; current != null; current = current.BaseType) + { + var field = current.GetField(name, flags); + if (field != null) + return field; + var property = current.GetProperty(name, flags); + if (property != null && property.CanRead && property.CanWrite) + return property; + } + + if ( + name.StartsWith("m_", StringComparison.Ordinal) + && name.Length > 2 + ) + { + var serializedName = + char.ToLowerInvariant(name[2]) + name.Substring(3); + var property = type.GetProperty(serializedName, flags); + if (property != null && property.CanRead && property.CanWrite) + return property; + + var alias = name switch + { + "m_Mesh" => "sharedMesh", + "m_Material" => "sharedMaterial", + "m_Materials" => "sharedMaterials", + _ => null + }; + if (alias != null) + { + property = type.GetProperty(alias, flags); + if ( + property != null + && property.CanRead + && property.CanWrite + ) + return property; + } + } + + return null; + } + + private static Type GetMemberType(MemberInfo member) => + member is FieldInfo field + ? field.FieldType + : ((PropertyInfo)member).PropertyType; + + private static object GetMemberValue(MemberInfo member, object target) => + member is FieldInfo field + ? field.GetValue(target) + : ((PropertyInfo)member).GetValue(target); + + private static void SetMemberValue( + MemberInfo member, + object target, + object value + ) + { + if (member is FieldInfo field) + field.SetValue(target, value); + else + ((PropertyInfo)member).SetValue(target, value); + } + + private static object ConvertForType(object value, Type targetType) + { + if (value is PrefabPatchValue patchValue) + { + if ( + patchValue.Kind + == PrefabPatchValueKind.ManagedReference + ) + { + var concreteType = ResolveType( + patchValue.SerializedType + ); + if ( + concreteType == null + || !targetType.IsAssignableFrom(concreteType) + ) + { + throw new InvalidOperationException( + $"Managed-reference type " + + $"'{patchValue.SerializedType}' is not " + + $"assignable to '{targetType.FullName}'." + ); + } + return CreateCollectionElement(concreteType); + } + if (patchValue.Kind != PrefabPatchValueKind.Json) + value = ConvertValue(patchValue); + else + return DeserializeJsonValue(patchValue, targetType); + } + if (value == null || targetType.IsInstanceOfType(value)) + return value; + if (targetType.IsEnum) + return Enum.ToObject(targetType, value); + return Convert.ChangeType( + value, + targetType, + CultureInfo.InvariantCulture + ); + } + + private static object DeserializeJsonValue( + PrefabPatchValue value, + Type targetType + ) + { + if (targetType == typeof(AnimationCurve)) + { + var payload = JsonConvert.DeserializeObject< + AnimationCurvePayload + >(value.String, PrefabPatchJson.Settings); + var curve = new AnimationCurve( + payload?.Keys ?? Array.Empty() + ) + { + preWrapMode = + (WrapMode)(payload?.PreWrapMode ?? (int)WrapMode.Default), + postWrapMode = + (WrapMode)(payload?.PostWrapMode ?? (int)WrapMode.Default) + }; + return curve; + } + if (targetType == typeof(Gradient)) + { + var payload = JsonConvert.DeserializeObject( + value.String, + PrefabPatchJson.Settings + ); + var gradient = new Gradient + { + mode = (GradientMode)( + payload?.Mode ?? (int)GradientMode.Blend + ) + }; + gradient.SetKeys( + payload?.ColorKeys ?? Array.Empty(), + payload?.AlphaKeys ?? Array.Empty() + ); + return gradient; + } + if (targetType == typeof(Hash128)) + return Hash128.Parse( + JsonConvert.DeserializeObject(value.String) + ); + return JsonConvert.DeserializeObject( + value.String ?? "null", + targetType, + PrefabPatchJson.Settings + ); + } + + private static object ConvertValue(PrefabPatchValue value) => + value.Kind switch + { + PrefabPatchValueKind.Boolean => value.Boolean, + PrefabPatchValueKind.Integer => value.Integer, + PrefabPatchValueKind.Float => value.Float, + PrefabPatchValueKind.String => value.String, + PrefabPatchValueKind.Vector2 => + new Vector2((float)value.X, (float)value.Y), + PrefabPatchValueKind.Vector3 => + new Vector3((float)value.X, (float)value.Y, (float)value.Z), + PrefabPatchValueKind.Vector4 => + new Vector4( + (float)value.X, + (float)value.Y, + (float)value.Z, + (float)value.W + ), + PrefabPatchValueKind.Quaternion => + new Quaternion( + (float)value.X, + (float)value.Y, + (float)value.Z, + (float)value.W + ), + PrefabPatchValueKind.Color => + new Color( + (float)value.X, + (float)value.Y, + (float)value.Z, + (float)value.W + ), + PrefabPatchValueKind.Json => value, + _ => throw new NotSupportedException( + $"Typed value kind '{value.Kind}' is unsupported." + ) + }; + + private static Vector3 ToVector3( + PrefabPatchValue value, + Vector3 fallback + ) => + value == null + ? fallback + : new Vector3((float)value.X, (float)value.Y, (float)value.Z); + + private static Vector2 ToVector2( + PrefabPatchValue value, + Vector2 fallback + ) => + value == null + ? fallback + : new Vector2((float)value.X, (float)value.Y); + + private static Quaternion ToQuaternion( + PrefabPatchValue value, + Quaternion fallback + ) => + value == null + ? fallback + : new Quaternion( + (float)value.X, + (float)value.Y, + (float)value.Z, + (float)value.W + ); + + private static void SetVectorComponent( + ref Vector3 value, + string component, + float replacement + ) + { + switch (component) + { + case "x": + value.x = replacement; + break; + case "y": + value.y = replacement; + break; + case "z": + value.z = replacement; + break; + default: + throw new ArgumentOutOfRangeException(nameof(component)); + } + } + + private static void SetQuaternionComponent( + ref Quaternion value, + string component, + float replacement + ) + { + switch (component) + { + case "x": + value.x = replacement; + break; + case "y": + value.y = replacement; + break; + case "z": + value.z = replacement; + break; + case "w": + value.w = replacement; + break; + default: + throw new ArgumentOutOfRangeException(nameof(component)); + } + } + + private static GameObject AsGameObject(Object target) => + target switch + { + GameObject gameObject => gameObject, + Component component => component.gameObject, + _ => null + }; + + private static Type ResolveType(string name) + { + if (string.IsNullOrWhiteSpace(name)) + return null; + var type = Type.GetType(name, false); + if (type != null) + return type; + return AppDomain.CurrentDomain + .GetAssemblies() + .Select(assembly => assembly.GetType(name, false)) + .FirstOrDefault(value => value != null); + } + + private static Exception MissingTarget(PrefabPatchOperation operation) => + new InvalidOperationException( + $"Operation '{operation.OperationId}' from '{operation.PatchId}' " + + $"could not resolve target '{operation.Target?.CanonicalKey}'." + ); +} diff --git a/Runtime/PrefabPatching/PrefabPatchComposer.cs.meta b/Runtime/PrefabPatching/PrefabPatchComposer.cs.meta new file mode 100644 index 0000000..4f95033 --- /dev/null +++ b/Runtime/PrefabPatching/PrefabPatchComposer.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 233910d5b0c5741478f5b3d1e07ea96f \ No newline at end of file diff --git a/Runtime/PrefabPatching/PrefabPatchFragmentBuilder.cs b/Runtime/PrefabPatching/PrefabPatchFragmentBuilder.cs new file mode 100644 index 0000000..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/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..d6b9f57 --- /dev/null +++ b/Runtime/PrefabPatching/PrefabPatchModel.cs @@ -0,0 +1,429 @@ +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 AddressablesLabelSuffix = "_prefab_patches"; +} + +/// +/// One active mod's Addressables discovery boundary for declarative prefab +/// patches. The mod descriptor supplies ownership; the manifest asset address +/// is deliberately not part of patch identity. +/// +public sealed class PrefabPatchManifestSource +{ + public string OwnerModId; + public string AddressablesLabel; +} + +[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, + PatchComponent +} + +[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, + ArraySize, + ManagedReference, + Json +} + +[JsonConverter(typeof(StringEnumConverter))] +public enum PrefabPatchObjectReferenceKind +{ + Addressable, + Target +} + +/// +/// Runtime identity and optional compatibility data for one stock Addressable +/// prefab. +/// +[Serializable] +public sealed class PrefabPatchPrefabIdentity +{ + public string Address; + public string AssetType; + public string StructuralFingerprint; + + [JsonIgnore] + public string CanonicalKey => $"address:{Address}"; + + public static PrefabPatchPrefabIdentity FromAddress(string address) + { + if (string.IsNullOrWhiteSpace(address)) + throw new ArgumentException( + "Addressables key is required.", + nameof(address) + ); + return new PrefabPatchPrefabIdentity + { + Address = address.Trim(), + AssetType = typeof(UnityEngine.GameObject).AssemblyQualifiedName + }; + } +} + +/// +/// Runtime traversal data. Visual compilation uses sibling indices backed by +/// canonical source identity; key-first C#/Lua authoring uses a hierarchy path. +/// +[Serializable] +public sealed class PrefabPatchRuntimeLocator +{ + public int[] SiblingIndices = Array.Empty(); + public string HierarchyPath; + 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 ObjectType; + public string OwnerPatchId; + public string ObjectId; + public string ComponentId; + public PrefabPatchRuntimeLocator RuntimeLocator; + + [JsonIgnore] + public string CanonicalKey + { + get + { + if (Kind == PrefabPatchTargetKind.PatchComponent) + return $"patch-component:{OwnerPatchId}:{ComponentId}"; + if (Kind == PrefabPatchTargetKind.PatchOwned) + return $"patch:{OwnerPatchId}:{ObjectId}"; + + var path = !string.IsNullOrWhiteSpace( + RuntimeLocator?.HierarchyPath + ) + ? RuntimeLocator.HierarchyPath + : RuntimeLocator?.DisplayPath; + if (!string.IsNullOrWhiteSpace(path)) + return $"path:{path}:{RuntimeLocator.TargetKind}:" + + $"{RuntimeLocator.ComponentType}:" + + $"{RuntimeLocator.ComponentOrdinal}"; + + var indices = RuntimeLocator?.SiblingIndices == null + ? "" + : string.Join(",", RuntimeLocator.SiblingIndices); + return $"indices:{indices}:{RuntimeLocator?.TargetKind}:" + + $"{RuntimeLocator?.ComponentType}:" + + $"{RuntimeLocator?.ComponentOrdinal}"; + } + } +} + +/// +/// JSON-safe typed value. Numeric vectors use X/Y/Z/W in their normal Unity +/// component order. +/// +[Serializable] +public sealed class PrefabPatchValue +{ + public PrefabPatchValueKind Kind; + public bool Boolean; + public long Integer; + public double Float; + public string String; + public string SerializedType; + 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 or target-local Unity object reference used by +/// SetObjectReference or a component fragment. +/// +[Serializable] +public sealed class PrefabPatchObjectReference +{ + public PrefabPatchObjectReferenceKind Kind; + public string Address; + public string ExpectedType; + 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; +} + +/// +/// 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 string ComponentId; + public string ComponentType; + public List Values = new(); + public List References = new(); +} + +/// +/// An inline, patch-owned hierarchy fragment. Every object has an explicit ID, +/// allowing later required patches to target it without hierarchy-name lookup. +/// +[Serializable] +public sealed class PrefabPatchObjectFragment +{ + 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(); +} + +/// +/// One normalized declarative operation. +/// +[Serializable] +public sealed class PrefabPatchOperation +{ + public string OperationId; + [JsonIgnore] + 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 PatchName; + [JsonIgnore] + public string PatchId; + [JsonIgnore] + public string ModId; + 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..dc028da --- /dev/null +++ b/Runtime/PrefabPatching/PrefabPatchObjectId.cs @@ -0,0 +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 + { + [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..3fcc5e8 --- /dev/null +++ b/Runtime/PrefabPatching/PrefabPatchObjectId.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 93da1f45b46a3f243ae12acca9ebff3a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/PrefabPatching/PrefabPatchOwnership.cs b/Runtime/PrefabPatching/PrefabPatchOwnership.cs new file mode 100644 index 0000000..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/PrefabPatchPlanCache.cs b/Runtime/PrefabPatching/PrefabPatchPlanCache.cs new file mode 100644 index 0000000..f7efd67 --- /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?.Address ?? target?.CanonicalKey ?? "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..713e7ae --- /dev/null +++ b/Runtime/PrefabPatching/PrefabPatchResolver.cs @@ -0,0 +1,1026 @@ +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 + .Select(manifest => manifest.TargetPrefab) + .Where(target => target != null) + .OrderByDescending( + target => + !string.IsNullOrWhiteSpace( + target.StructuralFingerprint + ) + ) + .FirstOrDefault(); + var address = plan.TargetPrefab?.Address; + var fatal = false; + var byId = new Dictionary( + StringComparer.Ordinal + ); + foreach (var manifest in manifests) + { + if (!ValidateManifest(manifest, plan)) + { + fatal = true; + continue; + } + + if (!TargetsMatch(manifest.TargetPrefab, plan.TargetPrefab)) + { + Add( + plan, + PrefabPatchDiagnosticSeverity.Error, + "PM-PREFAB-MIXED-TARGET", + manifest.PatchId, + null, + string.Equals( + manifest.TargetPrefab.Address, + plan.TargetPrefab.Address, + StringComparison.Ordinal + ) + ? $"Patch '{manifest.PatchId}' was compiled against " + + "a different structural version of " + + $"'{plan.TargetPrefab.Address}'." + : $"Patch '{manifest.PatchId}' targets " + + $"'{manifest.TargetPrefab.Address}', not " + + $"'{plan.TargetPrefab.Address}'." + ); + fatal = true; + continue; + } + + if (!byId.TryAdd(manifest.PatchId, manifest)) + { + Add( + plan, + PrefabPatchDiagnosticSeverity.Error, + "PM-PREFAB-DUPLICATE-PATCH-ID", + manifest.PatchId, + null, + $"Patch ID '{manifest.PatchId}' is registered more than once." + ); + fatal = true; + } + } + + var enabled = new HashSet(byId.Keys, StringComparer.Ordinal); + FilterModConstraints(byId, enabled, activeModIds, plan); + FilterPatchConstraints(byId, enabled, plan); + + var ordered = Order(byId, enabled, plan, ref fatal); + plan.OrderedPatchIds = ordered + .Select(manifest => manifest.PatchId) + .ToArray(); + + ValidateAndFlattenOperations(ordered, plan, ref fatal); + plan.InputHash = BuildInputHash( + ordered, + plan.TargetPrefab, + plan.Diagnostics, + unityVersion, + targetPlatform + ); + plan.CacheKey = PrefabPatchJson.Sha256( + $"{address}|{plan.TargetPrefab?.CanonicalKey}|{plan.InputHash}" + ); + plan.IsValid = !fatal; + return plan; + } + + private static bool ValidateManifest( + PrefabPatchManifest manifest, + PrefabPatchResolvedPlan plan + ) + { + if ( + manifest.SchemaVersion != PrefabPatchSchema.Version + || manifest.ComposerVersion != PrefabPatchSchema.ComposerVersion + ) + { + Add( + plan, + PrefabPatchDiagnosticSeverity.Error, + "PM-PREFAB-SCHEMA", + manifest.PatchId, + null, + $"Patch '{manifest.PatchId}' uses schema " + + $"{manifest.SchemaVersion}/composer " + + $"{manifest.ComposerVersion}; runtime requires " + + $"{PrefabPatchSchema.Version}/" + + $"{PrefabPatchSchema.ComposerVersion}." + ); + return false; + } + + if ( + string.IsNullOrWhiteSpace(manifest.PatchId) + || manifest.PatchId.IndexOf(':') <= 0 + || string.IsNullOrWhiteSpace(manifest.PatchName) + ) + { + Add( + plan, + PrefabPatchDiagnosticSeverity.Error, + "PM-PREFAB-PATCH-ID", + manifest.PatchId, + null, + "Prefab patch ownership has not been bound from its " + + "containing mod." + ); + return false; + } + + if ( + string.IsNullOrWhiteSpace(manifest.ModId) + || !manifest.PatchId.StartsWith( + manifest.ModId + ":", + StringComparison.Ordinal + ) + ) + { + Add( + plan, + PrefabPatchDiagnosticSeverity.Error, + "PM-PREFAB-MOD-NAMESPACE", + manifest.PatchId, + null, + $"Patch '{manifest.PatchId}' is not namespaced to owning mod " + + $"'{manifest.ModId}'." + ); + return false; + } + + if ( + manifest.TargetPrefab == null + || string.IsNullOrWhiteSpace(manifest.TargetPrefab.Address) + ) + { + Add( + plan, + PrefabPatchDiagnosticSeverity.Error, + "PM-PREFAB-TARGET-IDENTITY", + manifest.PatchId, + null, + $"Patch '{manifest.PatchId}' has no target Addressables key." + ); + return false; + } + + var calculatedHash = PrefabPatchJson.CalculateManifestHash(manifest); + if ( + !string.IsNullOrWhiteSpace(manifest.ManifestHash) + && !string.Equals( + calculatedHash, + manifest.ManifestHash, + StringComparison.OrdinalIgnoreCase + ) + ) + { + Add( + plan, + PrefabPatchDiagnosticSeverity.Error, + "PM-PREFAB-MANIFEST-HASH", + manifest.PatchId, + null, + $"Patch '{manifest.PatchId}' manifest hash does not match its " + + "normalized content." + ); + return false; + } + + manifest.ManifestHash = calculatedHash; + return true; + } + + private static bool TargetsMatch( + PrefabPatchPrefabIdentity left, + PrefabPatchPrefabIdentity right + ) + { + if ( + left == null + || right == null + || !string.Equals( + left.Address, + right.Address, + StringComparison.Ordinal + ) + ) + return false; + return string.IsNullOrWhiteSpace(left.StructuralFingerprint) + || string.IsNullOrWhiteSpace(right.StructuralFingerprint) + || string.Equals( + left.StructuralFingerprint, + right.StructuralFingerprint, + StringComparison.Ordinal + ); + } + + private static void FilterModConstraints( + IReadOnlyDictionary byId, + ISet enabled, + ISet activeModIds, + PrefabPatchResolvedPlan plan + ) + { + foreach (var manifest in byId.Values.OrderBy( + value => value.PatchId, + StringComparer.Ordinal + )) + { + var missing = Safe(manifest.NeedsMods) + .Where(id => !activeModIds.Contains(id)) + .OrderBy(id => id, StringComparer.Ordinal) + .ToArray(); + if (missing.Length > 0) + { + enabled.Remove(manifest.PatchId); + Add( + plan, + PrefabPatchDiagnosticSeverity.Error, + "PM-PREFAB-MISSING-MOD", + manifest.PatchId, + null, + $"Disabled '{manifest.PatchId}': missing required mod(s) " + + string.Join(", ", missing) + "." + ); + continue; + } + + var conflicts = Safe(manifest.ConflictsMods) + .Where(activeModIds.Contains) + .OrderBy(id => id, StringComparer.Ordinal) + .ToArray(); + if (conflicts.Length > 0) + { + enabled.Remove(manifest.PatchId); + Add( + plan, + PrefabPatchDiagnosticSeverity.Error, + "PM-PREFAB-CONFLICTING-MOD", + manifest.PatchId, + null, + $"Disabled '{manifest.PatchId}': conflicting mod(s) " + + string.Join(", ", conflicts) + " are active." + ); + } + } + } + + private static void FilterPatchConstraints( + IReadOnlyDictionary byId, + ISet enabled, + PrefabPatchResolvedPlan plan + ) + { + bool changed; + do + { + changed = false; + foreach (var patchId in enabled.OrderBy( + id => id, + StringComparer.Ordinal + ).ToArray()) + { + var manifest = byId[patchId]; + var missing = Safe(manifest.NeedsPatches) + .Where(id => !enabled.Contains(id)) + .OrderBy(id => id, StringComparer.Ordinal) + .ToArray(); + if (missing.Length > 0) + { + enabled.Remove(patchId); + changed = true; + Add( + plan, + PrefabPatchDiagnosticSeverity.Error, + "PM-PREFAB-MISSING-PATCH", + patchId, + null, + $"Disabled '{patchId}': missing required patch(es) " + + string.Join(", ", missing) + "." + ); + continue; + } + + var conflicts = Safe(manifest.ConflictsPatches) + .Where(enabled.Contains) + .OrderBy(id => id, StringComparer.Ordinal) + .ToArray(); + if (conflicts.Length > 0) + { + enabled.Remove(patchId); + changed = true; + Add( + plan, + PrefabPatchDiagnosticSeverity.Error, + "PM-PREFAB-CONFLICTING-PATCH", + patchId, + null, + $"Disabled '{patchId}': conflicting patch(es) " + + string.Join(", ", conflicts) + " are active." + ); + } + } + } while (changed); + } + + private static List Order( + IReadOnlyDictionary byId, + ISet enabled, + PrefabPatchResolvedPlan plan, + ref bool fatal + ) + { + var result = new List(); + foreach (PrefabPatchPass pass in Enum.GetValues(typeof(PrefabPatchPass))) + { + foreach ( + PrefabPatchOrdering bucket in Enum.GetValues( + typeof(PrefabPatchOrdering) + ) + ) + { + 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 introducedComponents = 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) + ) + { + 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 + || operation.Target?.Kind + == PrefabPatchTargetKind.PatchComponent + ) + { + var owner = operation.Target.OwnerPatchId; + var isComponent = + operation.Target.Kind + == PrefabPatchTargetKind.PatchComponent; + var ownedId = isComponent + ? operation.Target.ComponentId + : operation.Target.ObjectId; + var objectKey = $"{owner}:{ownedId}"; + 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 ( + !(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( + plan, + PrefabPatchDiagnosticSeverity.Error, + "PM-PREFAB-PATCH-OWNED-TARGET", + manifest.PatchId, + operation.OperationId, + $"Patch-owned " + + (isComponent ? "component" : "object") + + $" target '{objectKey}' is not introduced " + + "by an earlier required operation." + ); + fatal = true; + continue; + } + } + + if (operation.Kind == PrefabPatchOperationKind.AddObject) + { + 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-COMPONENT-ID", + manifest.PatchId, + operation.OperationId, + $"Added component operation " + + $"'{operation.OperationId}' needs a stable " + + "component ID and assembly-qualified type." + ); + fatal = true; + continue; + } + if ( + !introducedComponents.TryAdd( + componentKey, + operation.OperationId + ) + ) + { + Add( + plan, + PrefabPatchDiagnosticSeverity.Error, + "PM-PREFAB-DUPLICATE-COMPONENT-ID", + manifest.PatchId, + operation.OperationId, + $"Patch-owned component '{componentKey}' 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 bool RegisterFragment( + PrefabPatchObjectFragment fragment, + string patchId, + string operationId, + IDictionary objects, + IDictionary components, + PrefabPatchResolvedPlan plan + ) + { + if (fragment == null || string.IsNullOrWhiteSpace(fragment.ObjectId)) + { + Add( + plan, + PrefabPatchDiagnosticSeverity.Error, + "PM-PREFAB-ADDED-OBJECT-ID", + patchId, + operationId, + $"Added object operation '{operationId}' contains an object " + + "without a stable patch-local ID." + ); + return false; + } + + var valid = true; + var objectKey = $"{patchId}:{fragment.ObjectId}"; + if (!objects.TryAdd(objectKey, operationId)) + { + Add( + plan, + PrefabPatchDiagnosticSeverity.Error, + "PM-PREFAB-DUPLICATE-OBJECT-ID", + patchId, + operationId, + $"Patch-owned object '{objectKey}' is introduced more than " + + "once." + ); + valid = false; + } + + foreach ( + var component in fragment.Components + ?? new List() + ) + { + var componentId = component?.ComponentId; + var componentKey = $"{patchId}:{componentId}"; + if ( + component == null + || string.IsNullOrWhiteSpace(componentId) + || string.IsNullOrWhiteSpace(component.ComponentType) + ) + { + Add( + plan, + PrefabPatchDiagnosticSeverity.Error, + "PM-PREFAB-ADDED-COMPONENT-ID", + patchId, + operationId, + $"Patch-owned object '{objectKey}' contains a component " + + "without a stable ID or assembly-qualified type." + ); + valid = false; + continue; + } + if (!components.TryAdd(componentKey, operationId)) + { + Add( + plan, + PrefabPatchDiagnosticSeverity.Error, + "PM-PREFAB-DUPLICATE-COMPONENT-ID", + patchId, + operationId, + $"Patch-owned component '{componentKey}' is introduced " + + "more than once." + ); + valid = false; + } + } + + foreach ( + var child in fragment.Children + ?? new List() + ) + { + if ( + !RegisterFragment( + child, + patchId, + operationId, + objects, + components, + plan + ) + ) + valid = false; + } + + return valid; + } + + private static void AddSameBucketEdges( + PrefabPatchManifest manifest, + IEnumerable ids, + bool after, + IReadOnlyDictionary members, + IDictionary> incoming, + IDictionary> outgoing, + PrefabPatchResolvedPlan plan + ) + { + foreach (var id in Safe(ids)) + { + if (!members.ContainsKey(id)) + { + Add( + plan, + PrefabPatchDiagnosticSeverity.Info, + "PM-PREFAB-CROSS-BUCKET-ORDER-IGNORED", + manifest.PatchId, + null, + $"Ordering relation between '{manifest.PatchId}' and " + + $"'{id}' is absent or crosses a pass/bucket and was " + + "ignored." + ); + continue; + } + + AddEdge( + after ? id : manifest.PatchId, + after ? manifest.PatchId : id, + incoming, + outgoing + ); + } + } + + private static void AddModEdges( + PrefabPatchManifest manifest, + IEnumerable modIds, + bool after, + IReadOnlyDictionary members, + IDictionary> incoming, + IDictionary> outgoing + ) + { + foreach (var modId in Safe(modIds)) + { + foreach ( + var other in members.Values.Where( + value => + string.Equals( + value.ModId, + modId, + StringComparison.Ordinal + ) + && value.PatchId != manifest.PatchId + ) + ) + { + AddEdge( + after ? other.PatchId : manifest.PatchId, + after ? manifest.PatchId : other.PatchId, + incoming, + outgoing + ); + } + } + } + + private static void AddEdge( + string before, + string after, + IDictionary> incoming, + IDictionary> outgoing + ) + { + if (before == after) + return; + if (outgoing[before].Add(after)) + incoming[after].Add(before); + } + + private static int Rank(PrefabPatchManifest manifest) => + ((int)manifest.Pass * 3) + (int)manifest.Ordering; + + private static bool IsWrite(PrefabPatchOperationKind kind) => + kind == PrefabPatchOperationKind.SetValue + || kind == PrefabPatchOperationKind.SetObjectReference + || kind == PrefabPatchOperationKind.SetActive + || kind == PrefabPatchOperationKind.SuppressObject + || kind == PrefabPatchOperationKind.RemoveComponent; + + private static string OperationPayload(PrefabPatchOperation operation) => + PrefabPatchJson.Serialize( + new + { + operation.Kind, + operation.PropertyPath, + operation.Value, + operation.ObjectReference + } + ); + + private static IEnumerable Safe(IEnumerable values) => + values ?? Array.Empty(); + + private static void Add( + PrefabPatchResolvedPlan plan, + PrefabPatchDiagnosticSeverity severity, + string code, + string patchId, + string operationId, + string message + ) + { + plan.Diagnostics.Add( + new PrefabPatchDiagnostic + { + Severity = severity, + Code = code, + TargetAddress = plan.TargetPrefab?.Address, + PatchId = patchId, + OperationId = operationId, + Message = message + } + ); + } +} diff --git a/Runtime/PrefabPatching/PrefabPatchResolver.cs.meta b/Runtime/PrefabPatching/PrefabPatchResolver.cs.meta new file mode 100644 index 0000000..e1eddc1 --- /dev/null +++ b/Runtime/PrefabPatching/PrefabPatchResolver.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5e78a05a731189c4d8d14426c36b04e1 \ No newline at end of file diff --git a/Runtime/PrefabPatching/PrefabPatchRuntime.cs b/Runtime/PrefabPatching/PrefabPatchRuntime.cs new file mode 100644 index 0000000..c948026 --- /dev/null +++ b/Runtime/PrefabPatching/PrefabPatchRuntime.cs @@ -0,0 +1,1008 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using UnityEngine; +using UnityEngine.AddressableAssets; +using UnityEngine.ResourceManagement.AsyncOperations; +using UnityEngine.ResourceManagement.ResourceLocations; +using UnityEngine.ResourceManagement.ResourceProviders; +using UnityEngine.Profiling; +using Object = UnityEngine.Object; + +namespace PatchManager.PrefabPatching; + +/// +/// Coordinates the ordinary and prefab-patch sections of Patch Manager's +/// single human-readable summary log. +/// +public static class PatchManagerSummaryLog +{ + private const string SummaryPath = "./pm_summary.log"; + private static readonly object Gate = new(); + private static string _coreSummary; + private static string _prefabSummary; + + 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. +/// +public static class PrefabPatchRuntime +{ + public sealed class Metrics + { + public int DiscoveredManifestCount; + public int RegisteredManifestCount; + public int AddressableManifestCount; + public string[] ManifestSources = Array.Empty(); + 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 sealed class ManifestLocation + { + public IResourceLocation Location; + public string OwnerModId; + public string LocatorId; + public string Label; + } + + private const string PlanCacheDirectory = "./pm_cache/prefabs"; + private static readonly List Registered = new(); + private static readonly Dictionary Entries = new( + StringComparer.Ordinal + ); + private static PrefabPatchResourceLocator _locator; + private static GameObject _effectivePrefabRoot; + + 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 + ) => + DiscoverAndResolve( + activeModIds, + Array.Empty(), + resolve, + reject + ); + + public static void DiscoverAndResolve( + ISet activeModIds, + IReadOnlyCollection manifestSources, + Action resolve, + Action reject + ) + { + var stopwatch = Stopwatch.StartNew(); + CurrentMetrics = new Metrics + { + MemoryBeforeBytes = Profiler.GetTotalAllocatedMemoryLong() + }; + try + { + var manifests = new List(Registered); + CurrentMetrics.RegisteredManifestCount = Registered.Count; + CurrentMetrics.ManifestSources = (manifestSources + ?? Array.Empty()) + .Where(source => + source != null + && !string.IsNullOrWhiteSpace(source.OwnerModId) + && !string.IsNullOrWhiteSpace( + source.AddressablesLabel + ) + ) + .Select(source => + source.OwnerModId.Trim() + + ": " + + source.AddressablesLabel.Trim() + ) + .Distinct(StringComparer.Ordinal) + .OrderBy(value => value, StringComparer.Ordinal) + .ToArray(); + var locations = FindManifestLocations(manifestSources); + CurrentMetrics.AddressableManifestCount = locations.Count; + if (locations.Count > 0) + { + foreach (var source in locations) + { + AsyncOperationHandle manifestHandle = default; + try + { + manifestHandle = Addressables.LoadAssetAsync( + source.Location + ); + var asset = manifestHandle.WaitForCompletion(); + if ( + manifestHandle.Status + != AsyncOperationStatus.Succeeded + || asset == null + ) + { + throw manifestHandle.OperationException + ?? new InvalidOperationException( + "Prefab patch manifest load failed." + ); + } + + var manifest = + PrefabPatchJson.Deserialize( + asset.text + ); + manifests.Add( + PrefabPatchOwnership.Bind( + manifest, + source.OwnerModId + ) + ); + } + catch (Exception exception) + { + throw new InvalidDataException( + $"Could not load prefab patch manifest " + + $"'{source.Location.PrimaryKey}' from " + + $"label '{source.Label}' owned by " + + $"'{source.OwnerModId}' in catalog " + + $"'{source.LocatorId}'.", + exception + ); + } + finally + { + if (manifestHandle.IsValid()) + Addressables.Release(manifestHandle); + } + } + } + + ResolveDiscoveredManifests( + manifests, + activeModIds, + stopwatch, + resolve + ); + } + catch (Exception exception) + { + 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.Address, + StringComparer.Ordinal + ) + .OrderBy(group => group.Key, StringComparer.Ordinal) + ) + { + var result = cache.LoadOrResolve( + group, + activeModIds, + Application.unityVersion, + Application.platform.ToString() + ); + if (result.CacheHit) + CurrentMetrics.CacheHitCount++; + else + CurrentMetrics.CacheMissCount++; + if ( + result.Plan?.TargetPrefab != null + && result.Plan.IsValid + ) + { + Entries[result.Plan.TargetPrefab.Address] = new Entry + { + Plan = result.Plan + }; + } + } + + CurrentMetrics.ResolvedPlanCount = Entries.Count; + WriteSummary(); + stopwatch.Stop(); + CurrentMetrics.StartupMilliseconds = stopwatch.ElapsedMilliseconds; + CurrentMetrics.MemoryAfterBytes = + Profiler.GetTotalAllocatedMemoryLong(); + resolve(); + } + + private static void RejectDiscovery( + Stopwatch stopwatch, + Action reject, + Exception exception + ) + { + stopwatch.Stop(); + CurrentMetrics.StartupMilliseconds = stopwatch.ElapsedMilliseconds; + UnityEngine.Debug.LogException(exception); + reject( + "Patch Manager prefab discovery failed: " + + exception.Message + ); + } + + private static List FindManifestLocations( + IReadOnlyCollection manifestSources + ) + { + var sources = (manifestSources + ?? Array.Empty()) + .Where(source => + source != null + && !string.IsNullOrWhiteSpace(source.OwnerModId) + && !string.IsNullOrWhiteSpace(source.AddressablesLabel) + ) + .Select(source => new PrefabPatchManifestSource + { + OwnerModId = source.OwnerModId.Trim(), + AddressablesLabel = source.AddressablesLabel.Trim() + }) + .Distinct(PrefabPatchManifestSourceComparer.Instance) + .OrderBy(source => source.OwnerModId, StringComparer.Ordinal) + .ThenBy( + source => source.AddressablesLabel, + StringComparer.Ordinal + ) + .ToArray(); + + foreach ( + var duplicate in sources + .GroupBy( + source => source.AddressablesLabel, + StringComparer.Ordinal + ) + .Where(group => + group.Select(source => source.OwnerModId) + .Distinct(StringComparer.Ordinal) + .Skip(1) + .Any() + ) + ) + { + throw new InvalidDataException( + $"Prefab patch Addressables label '{duplicate.Key}' is " + + "declared by multiple active mods: " + + string.Join( + ", ", + duplicate.Select(source => source.OwnerModId) + .Distinct(StringComparer.Ordinal) + ) + ); + } + + return sources + .SelectMany(source => + Addressables.ResourceLocators.SelectMany(locator => + { + if ( + locator == null + || !locator.Locate( + source.AddressablesLabel, + typeof(TextAsset), + out var locations + ) + ) + { + return Array.Empty(); + } + + return locations + .Where(location => location != null) + .Select(location => new ManifestLocation + { + Location = location, + OwnerModId = source.OwnerModId, + LocatorId = locator.LocatorId, + Label = source.AddressablesLabel + }); + }) + ) + .GroupBy( + source => + $"{source.Location.ProviderId}\0" + + $"{source.Location.InternalId}\0" + + $"{source.Location.PrimaryKey}\0" + + $"{source.Location.ResourceType?.AssemblyQualifiedName}", + StringComparer.Ordinal + ) + .Select(group => + { + var owners = group + .Select(value => value.OwnerModId) + .Distinct(StringComparer.Ordinal) + .ToArray(); + if (owners.Length != 1) + { + throw new InvalidDataException( + $"Prefab patch location '{group.Key}' is exposed by " + + "multiple owning mods: " + + string.Join(", ", owners) + ); + } + return group.First(); + }) + .OrderBy( + source => source.Location.PrimaryKey, + StringComparer.Ordinal + ) + .ThenBy(source => source.OwnerModId, StringComparer.Ordinal) + .ThenBy( + source => source.Location.InternalId, + StringComparer.Ordinal + ) + .ToList(); + } + + private sealed class PrefabPatchManifestSourceComparer : + IEqualityComparer + { + public static readonly PrefabPatchManifestSourceComparer Instance = + new(); + + public bool Equals( + PrefabPatchManifestSource left, + PrefabPatchManifestSource right + ) => + ReferenceEquals(left, right) + || ( + left != null + && right != null + && string.Equals( + left.OwnerModId, + right.OwnerModId, + StringComparison.Ordinal + ) + && string.Equals( + left.AddressablesLabel, + right.AddressablesLabel, + StringComparison.Ordinal + ) + ); + + public int GetHashCode(PrefabPatchManifestSource source) + { + if (source == null) + return 0; + unchecked + { + return ( + StringComparer.Ordinal.GetHashCode( + source.OwnerModId ?? string.Empty + ) + * 397 + ) + ^ StringComparer.Ordinal.GetHashCode( + source.AddressablesLabel ?? string.Empty + ); + } + } + } + + /// + /// Adds the public GameObject provider and locator to Patch Manager's + /// supported KSP AssetProvider interception boundary. + /// + public static UnityEngine.AddressableAssets.ResourceLocators.IResourceLocator + RegisterResourceProvider() + { + var providers = Addressables.ResourceManager.ResourceProviders; + if ( + !providers.Any( + provider => provider is PrefabPatchResourceProvider + ) + ) + { + providers.Add(new PrefabPatchResourceProvider()); + } + + _locator = new PrefabPatchResourceLocator(Entries); + return _locator; + } + + internal static bool TryProvide( + string address, + out GameObject prefab, + out Exception failure + ) + { + prefab = null; + failure = null; + if (!Entries.TryGetValue(address, out var entry)) + { + failure = new KeyNotFoundException( + $"No resolved prefab patch plan exists for '{address}'." + ); + return false; + } + + var stopwatch = Stopwatch.StartNew(); + entry.RequestCount++; + if (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 + && value.Kind + == PrefabPatchObjectReferenceKind.Addressable + && !string.IsNullOrWhiteSpace(value.Address) + ) + .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 effectivePrefab = CreateEffectivePrefab(stock); + var result = PrefabPatchComposer.ApplySynchronously( + effectivePrefab, + entry.Plan, + entry.References + ); + if (!result.Success) + { + Object.DestroyImmediate(effectivePrefab); + throw new InvalidOperationException(result.Failure); + } + + entry.EffectivePrefab = effectivePrefab; + 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 = effectivePrefab; + return true; + } + catch (Exception exception) + { + stopwatch.Stop(); + entry.Failed = true; + entry.Failure = exception; + failure = exception; + UnityEngine.Debug.LogError( + $"Prefab composition failed for '{address}': {exception}" + ); + WriteSummary(); + return false; + } + } + + 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 + ) + { + 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; + 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)) + yield return reference; + } + + private static IEnumerable GetReferences( + PrefabPatchObjectFragment fragment + ) + { + foreach (var component in fragment.Components) + { + foreach ( + var reference in component.References + ?? Enumerable.Empty() + ) + { + if (reference?.Reference != null) + yield return reference.Reference; + } + } + + foreach (var child in fragment.Children) + { + foreach (var reference in GetReferences(child)) + yield return reference; + } + } + + private static void WriteSummary() + { + var lines = new List + { + "Prefab Patches:", + $" Schema: {PrefabPatchSchema.Version}", + $" Composer: {PrefabPatchSchema.ComposerVersion}", + $" Discovered Manifests: {CurrentMetrics.DiscoveredManifestCount}", + $" Registered Manifests: {CurrentMetrics.RegisteredManifestCount}", + $" Addressable Manifests: {CurrentMetrics.AddressableManifestCount}", + $" Resolved Plans: {CurrentMetrics.ResolvedPlanCount}", + $" Plan Cache Hits: {CurrentMetrics.CacheHitCount}", + $" Plan Cache Misses: {CurrentMetrics.CacheMissCount}" + }; + foreach (var source in CurrentMetrics.ManifestSources) + lines.Add($" Manifest Source: {source}"); + 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 + ); + } + + PatchManagerSummaryLog.UpdatePrefabSummary( + string.Join(Environment.NewLine, lines) + ); + } + + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] + private static void ResetStaticState() + { + PatchManagerSummaryLog.Reset(); + 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 : + 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) + || ( + type != typeof(object) + && type != typeof(Object) + && type != typeof(GameObject) + && !typeof(Component).IsAssignableFrom(type) + ) + ) + { + locations = Array.Empty(); + return false; + } + + if (_entries.ContainsKey(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, + StringComparison.Ordinal + ) + ) + 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; + } +} + +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..8d62cb5 --- /dev/null +++ b/Runtime/PrefabPatching/PrefabPatchStructure.cs @@ -0,0 +1,140 @@ +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) + { + 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, true); + return 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; + } + + public static Transform ResolveHierarchyPath( + Transform root, + string hierarchyPath + ) + { + if (root == null) + return null; + var parts = (hierarchyPath ?? string.Empty) + .Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries); + var index = 0; + if ( + parts.Length > 0 + && string.Equals( + parts[0], + root.name, + StringComparison.Ordinal + ) + ) + { + index = 1; + } + + var current = root; + for (; index < parts.Length; index++) + { + var name = parts[index]; + Transform match = null; + for (var childIndex = 0; childIndex < current.childCount; childIndex++) + { + var child = current.GetChild(childIndex); + if (!string.Equals(child.name, name, StringComparison.Ordinal)) + continue; + if (match != null) + { + throw new InvalidOperationException( + $"Hierarchy path '{hierarchyPath}' is ambiguous: " + + $"'{current.name}' has multiple children named " + + $"'{name}'. Use visual authoring for this target." + ); + } + match = child; + } + + if (match == null) + return null; + current = match; + } + + return current; + } + + private static void Append( + Transform transform, + StringBuilder builder, + bool isRoot + ) + { + builder.Append('[') + .Append(isRoot ? 0 : transform.GetSiblingIndex()) + .Append('|') + .Append(isRoot ? "" : 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().FullName); + } + + builder.Append(']'); + for (var i = 0; i < transform.childCount; i++) + Append(transform.GetChild(i), builder, false); + } +} 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 diff --git a/Stubs/pm-prefabs.d.lua b/Stubs/pm-prefabs.d.lua new file mode 100644 index 0000000..44873c0 --- /dev/null +++ b/Stubs/pm-prefabs.d.lua @@ -0,0 +1,305 @@ +---@meta +-- Patch Manager declarative prefab-patch frontend. +-- Source: ksp2redux/Assets/Modules/PatchManager/Runtime/LuaPatching/Builtin/PatchManagerCore.cs +-- Source: ksp2redux/Assets/Modules/PatchManager/Runtime/LuaPatching/Builtin/PrefabPatchLuaBuilder.cs +-- Source: ksp2redux/Assets/Modules/PatchManager/Runtime/PrefabPatching/PrefabPatchBuilder.cs +-- Source: ksp2redux/Assets/Modules/PatchManager/Runtime/PrefabPatching/PrefabPatchModel.cs + +---Describes how Patch Manager locates a GameObject or Component in the stock +---prefab. Key-first Lua authoring normally uses `hierarchyPath`; manifests +---compiled from visual prefab variants may use `siblingIndices` instead. +---@class PrefabPatchRuntimeLocator +---@field siblingIndices integer[]? Zero-based sibling indices from the prefab root to the target. +---@field hierarchyPath string? Slash-separated hierarchy path; the root name is optional. +---@field targetKind '"GameObject"'|'"Component"' Whether the locator resolves a GameObject or one of its Components. +---@field componentType string? Full CLR component type name when `targetKind` is `"Component"`. +---@field componentOrdinal integer? Zero-based ordinal when a GameObject has multiple Components of the same type. +---@field displayPath string? Human-readable path used in diagnostics. + +---Targets an object or Component inherited from the stock prefab. +---@class PrefabPatchStockTarget +---@field kind '"Stock"' +---@field objectType string Full CLR type name expected at the resolved location. +---@field runtimeLocator PrefabPatchRuntimeLocator Runtime traversal information for the stock prefab. + +---Targets a GameObject introduced by this patch or by another named patch. +---Use an unqualified `ownerPatchId` for a patch in the current mod; Patch +---Manager adds the current mod ID when the manifest is registered. +---@class PrefabPatchOwnedTarget +---@field kind '"PatchOwned"' +---@field ownerPatchId string Owning patch ID, either a local patch name or a fully qualified `modId:patchName`. +---@field objectId string Stable object ID declared by the owning patch's `PrefabPatchObjectFragment`. +---@field objectType string? Expected CLR object type; normally `UnityEngine.GameObject`. +---@field runtimeLocator PrefabPatchRuntimeLocator? Optional diagnostic locator; patch-owned identity is based on IDs. + +---Targets a Component introduced by this patch or by another named patch. +---Use an unqualified `ownerPatchId` for a patch in the current mod. +---@class PrefabPatchOwnedComponentTarget +---@field kind '"PatchComponent"' +---@field ownerPatchId string Owning patch ID, either a local patch name or a fully qualified `modId:patchName`. +---@field componentId string Stable component ID declared by the owning patch's `PrefabPatchComponentFragment`. +---@field objectType string? Expected full CLR component type name. + +---A stock target, a patch-owned GameObject, or a patch-owned Component. +---@alias PrefabPatchTarget +---| PrefabPatchStockTarget +---| PrefabPatchOwnedTarget +---| PrefabPatchOwnedComponentTarget + +---References an asset loaded through Addressables. +---@class PrefabPatchAddressableReference +---@field kind '"Addressable"' +---@field address string Addressables key of the referenced Unity object. +---@field expectedType string? Full CLR type name used to validate the loaded object. + +---References another object represented by a prefab-patch target. +---@class PrefabPatchTargetReference +---@field kind '"Target"' +---@field target PrefabPatchTarget Target whose resolved Unity object should be assigned. +---@field expectedType string? Full CLR type name used to validate the resolved object. + +---A Unity object reference assigned by a prefab-patch operation. +---@alias PrefabPatchReference +---| PrefabPatchAddressableReference +---| PrefabPatchTargetReference + +---The serialized value kinds accepted by the prefab-patch composer. +---@alias PrefabPatchValueKind +---| '"Boolean"' +---| '"Integer"' +---| '"Float"' +---| '"String"' +---| '"Vector2"' +---| '"Vector3"' +---| '"Vector4"' +---| '"Quaternion"' +---| '"Color"' +---| '"ArraySize"' +---| '"ManagedReference"' +---| '"Json"' + +---A JSON-safe typed value for a Unity serialized property. Only the payload +---fields required by `kind` need to be supplied. Plain Lua booleans, numbers, +---and strings can be passed to `Set`; use this class for vectors, colors, +---integers, array sizes, managed references, or explicit JSON payloads. +---@class PrefabPatchValue +---@field kind PrefabPatchValueKind Selects which payload fields Patch Manager reads. +---@field boolean boolean? Payload for `"Boolean"`. +---@field integer integer? Payload for `"Integer"` and `"ArraySize"`. +---@field float number? Payload for `"Float"`. +---@field string string? Payload for `"String"` or serialized `"Json"`. +---@field serializedType string? Assembly-qualified concrete type for `"ManagedReference"`. +---@field x number? X or red component for vector, quaternion, and color values. +---@field y number? Y or green component for vector, quaternion, and color values. +---@field z number? Z or blue component for Vector3, Vector4, Quaternion, and Color values. +---@field w number? W or alpha component for Vector4, Quaternion, and Color values. + +---One non-object serialized property captured for a patch-owned Component. +---@class PrefabPatchSerializedValue +---@field propertyPath string Unity `SerializedProperty` path relative to the Component. +---@field value PrefabPatchValue Typed value written to the property. + +---One Unity object reference captured separately from a Component's scalar +---serialized values so it can be restored after all patch-owned objects exist. +---@class PrefabPatchSerializedReference +---@field propertyPath string Unity `SerializedProperty` path relative to the Component. +---@field reference PrefabPatchReference Object reference written to the property. + +---Serialized definition of a Component introduced by a prefab patch. +---@class PrefabPatchComponentFragment +---@field componentId string Stable ID unique within the owning patch and usable by later patches. +---@field componentType string Assembly-qualified CLR component type to add. +---@field values PrefabPatchSerializedValue[]? Non-object serialized properties to initialize. +---@field references PrefabPatchSerializedReference[]? Unity object references restored after object creation. + +---Inline hierarchy definition for a GameObject introduced by a prefab patch. +---Every object has a stable `objectId`, allowing dependent patches to target it +---without relying on its display name or hierarchy position. +---@class PrefabPatchObjectFragment +---@field objectId string Stable ID unique within the owning patch. +---@field name string? GameObject name; defaults to the object ID when omitted. +---@field transformType string? Assembly-qualified Transform type; use RectTransform for UI objects. +---@field active boolean? Initial active state; defaults to true. +---@field layer integer? Initial Unity layer. +---@field tag string? Initial Unity tag; defaults to `Untagged`. +---@field isStatic boolean? Initial `GameObject.isStatic` value. +---@field localPosition PrefabPatchValue? Local Transform position. +---@field localRotation PrefabPatchValue? Local Transform rotation. +---@field localScale PrefabPatchValue? Local Transform scale. +---@field anchorMin PrefabPatchValue? RectTransform minimum anchor. +---@field anchorMax PrefabPatchValue? RectTransform maximum anchor. +---@field anchoredPosition PrefabPatchValue? RectTransform anchored position. +---@field sizeDelta PrefabPatchValue? RectTransform size delta. +---@field pivot PrefabPatchValue? RectTransform pivot. +---@field components PrefabPatchComponentFragment[]? Components introduced on this GameObject. +---@field children PrefabPatchObjectFragment[]? Nested patch-owned child GameObjects. + +---Fluent Lua frontend for one declarative prefab patch. Builder methods mutate +---the pending manifest and return the same builder unless documented otherwise. +---@class PrefabPatchLuaBuilder +PrefabPatchLuaBuilder = {} + +---Places the patch in the Early pass. +---@return PrefabPatchLuaBuilder self The same builder for chaining. +function PrefabPatchLuaBuilder:Early() end + +---Places the patch in the Late pass. +---@return PrefabPatchLuaBuilder self The same builder for chaining. +function PrefabPatchLuaBuilder:Late() end + +---Places the patch in the First ordering bucket within its pass. +---@return PrefabPatchLuaBuilder self The same builder for chaining. +function PrefabPatchLuaBuilder:First() end + +---Places the patch in the Last ordering bucket within its pass. +---@return PrefabPatchLuaBuilder self The same builder for chaining. +function PrefabPatchLuaBuilder:Last() end + +---Requires all listed mods. Patch Manager skips this patch when a required mod +---is unavailable. +---@param ... string The required mod IDs. +---@return PrefabPatchLuaBuilder self The same builder for chaining. +function PrefabPatchLuaBuilder:Needs(...) end + +---Declares this patch incompatible with the listed mods. +---@param ... string The conflicting mod IDs. +---@return PrefabPatchLuaBuilder self The same builder for chaining. +function PrefabPatchLuaBuilder:Conflicts(...) end + +---Requires all listed prefab patches. Local names are qualified with the +---current mod ID; use `modId:patchName` to reference another mod's patch. +---@param ... string Required local names or fully qualified patch IDs. +---@return PrefabPatchLuaBuilder self The same builder for chaining. +function PrefabPatchLuaBuilder:NeedsPatch(...) end + +---Declares this patch incompatible with the listed prefab patches. Local names +---are qualified with the current mod ID. +---@param ... string Conflicting local names or fully qualified patch IDs. +---@return PrefabPatchLuaBuilder self The same builder for chaining. +function PrefabPatchLuaBuilder:ConflictsPatch(...) end + +---Orders this patch before the listed patches when they are present. +---@param ... string Local names or fully qualified patch IDs to run before. +---@return PrefabPatchLuaBuilder self The same builder for chaining. +function PrefabPatchLuaBuilder:BeforePatch(...) end + +---Orders this patch after the listed patches when they are present. +---@param ... string Local names or fully qualified patch IDs to run after. +---@return PrefabPatchLuaBuilder self The same builder for chaining. +function PrefabPatchLuaBuilder:AfterPatch(...) end + +---Orders this patch before every present prefab patch owned by the listed mods. +---@param ... string Mod IDs whose patches should run later. +---@return PrefabPatchLuaBuilder self The same builder for chaining. +function PrefabPatchLuaBuilder:Before(...) end + +---Orders this patch after every present prefab patch owned by the listed mods. +---@param ... string Mod IDs whose patches should run earlier. +---@return PrefabPatchLuaBuilder self The same builder for chaining. +function PrefabPatchLuaBuilder:After(...) end + +---Declares configuration identifiers that affect the patch, making them part +---of the resolved-plan cache input. +---@param ... string Stable configuration identifiers used by this patch. +---@return PrefabPatchLuaBuilder self The same builder for chaining. +function PrefabPatchLuaBuilder:Configuration(...) end + +---Adds a typed serialized-property assignment to any prefab-patch target. +---Plain Lua numbers are emitted as `"Float"` values; use `PrefabPatchValue` +---when an integer or another explicit value kind is required. +---@param operationId string Stable operation ID unique within this patch. +---@param target PrefabPatchTarget Object or Component whose property should change. +---@param propertyPath string Unity `SerializedProperty` path to write. +---@param value boolean|number|string|PrefabPatchValue Value to serialize. +---@return PrefabPatchLuaBuilder self The same builder for chaining. +function PrefabPatchLuaBuilder:Set(operationId, target, propertyPath, value) end + +---Convenience form of `Set` for a Component inherited from the stock prefab. +---@param operationId string Stable operation ID unique within this patch. +---@param hierarchyPath string Slash-separated GameObject path; the root name is optional. +---@param componentType string Full CLR component type name, for example `UnityEngine.UI.Image`. +---@param propertyPath string Unity `SerializedProperty` path to write. +---@param value boolean|number|string|PrefabPatchValue Value to serialize. +---@param componentOrdinal? integer Zero-based ordinal when the GameObject has multiple Components of this type. +---@return PrefabPatchLuaBuilder self The same builder for chaining. +function PrefabPatchLuaBuilder:SetComponent( + operationId, + hierarchyPath, + componentType, + propertyPath, + value, + componentOrdinal +) +end + +---Creates a target for a GameObject inherited from the stock prefab. +---@param hierarchyPath string Slash-separated path; the root name is optional. +---@return PrefabPatchStockTarget target A reusable stock GameObject target. +function PrefabPatchLuaBuilder:GameObject(hierarchyPath) end + +---Creates a target for a Component inherited from the stock prefab. +---@param hierarchyPath string Slash-separated GameObject path; the root name is optional. +---@param componentType string Full CLR component type name. +---@param componentOrdinal? integer Zero-based ordinal when the GameObject has multiple Components of this type. +---@return PrefabPatchStockTarget target A reusable stock Component target. +function PrefabPatchLuaBuilder:Component(hierarchyPath, componentType, componentOrdinal) end + +---Adds a Unity object-reference assignment to a serialized property. +---@param operationId string Stable operation ID unique within this patch. +---@param target PrefabPatchTarget Object or Component whose property should change. +---@param propertyPath string Unity `SerializedProperty` path to write. +---@param reference PrefabPatchReference Addressable or prefab-target reference to assign. +---@return PrefabPatchLuaBuilder self The same builder for chaining. +function PrefabPatchLuaBuilder:Reference(operationId, target, propertyPath, reference) end + +---Sets a target GameObject active or inactive. +---@param operationId string Stable operation ID unique within this patch. +---@param target PrefabPatchTarget GameObject target whose active state should change. +---@param active boolean Desired active state. +---@return PrefabPatchLuaBuilder self The same builder for chaining. +function PrefabPatchLuaBuilder:Active(operationId, target, active) end + +---Deactivates a stock or patch-owned GameObject instead of destroying it, so +---later patches can still resolve the same target. +---@param operationId string Stable operation ID unique within this patch. +---@param target PrefabPatchTarget GameObject target to suppress. +---@return PrefabPatchLuaBuilder self The same builder for chaining. +function PrefabPatchLuaBuilder:Suppress(operationId, target) end + +---Adds a patch-owned GameObject hierarchy. Passing `nil` as `parent` attaches +---the fragment directly beneath the effective prefab root. +---@param operationId string Stable operation ID unique within this patch. +---@param parent PrefabPatchTarget? Parent GameObject target, or nil for the prefab root. +---@param fragment PrefabPatchObjectFragment Hierarchy fragment to create. +---@return PrefabPatchLuaBuilder self The same builder for chaining. +function PrefabPatchLuaBuilder:AddObject(operationId, parent, fragment) end + +---Adds a patch-owned Component to an existing GameObject. +---@param operationId string Stable operation ID unique within this patch. +---@param target PrefabPatchTarget GameObject target that receives the Component. +---@param component PrefabPatchComponentFragment Component definition to create. +---@return PrefabPatchLuaBuilder self The same builder for chaining. +function PrefabPatchLuaBuilder:AddComponent(operationId, target, component) end + +---Removes a Component resolved by the supplied target. +---@param operationId string Stable operation ID unique within this patch. +---@param target PrefabPatchTarget Component target to remove. +---@return PrefabPatchLuaBuilder self The same builder for chaining. +function PrefabPatchLuaBuilder:RemoveComponent(operationId, target) end + +---Finalizes the pending manifest and returns its JSON-compatible public form +---without registering it with the prefab-patch runtime. +---@return JsonUserData manifest Table-like serialized prefab-patch manifest. +function PrefabPatchLuaBuilder:Build() end + +---Finalizes and registers the prefab patch with Patch Manager. This method is +---terminal and intentionally returns no CLR manifest to Lua. +function PrefabPatchLuaBuilder:Register() end + +---Begins a declarative prefab patch for one stock Addressables prefab. This can +---only be called while Patch Manager's registration phase is open. +---@param name string Patch-local name; the current mod ID is prepended automatically. +---@param target string Stock prefab Addressables key. +---@return PrefabPatchLuaBuilder builder Builder used to declare and register the patch. +---@error Thrown outside patch registration, when `name` is invalid, or when `target` is not a string key. +function PatchManagerCore:Prefab(name, target) 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