diff --git a/CHANGELOG.md b/CHANGELOG.md index ab7b1435..26cdac68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## v0.6.0 - 2026-06-07 + +Stock-ClojureCLR coexistence for Unity consumers that keep ClojureCLR as the editor runtime, plus IL2CPP workaround-selection fixes. + +### Compiler +- `set!` on a hinted mutable deftype field emits a `castclass`, fixing unverifiable IL that IL2CPP's transpiler rejects - [#27](https://github.com/flybot-sg/magic/issues/27). + +### Magic.Unity +- Coexistence: while a strong-named `Clojure.dll` is under `Assets`, fork `.clj.dll` plugins are excluded from the editor (and restored when it leaves), keeping stock RT's `clojure.core.clj` probe away from fork assemblies - [#25](https://github.com/flybot-sg/magic/issues/25). Editor scripts compile against the stock assembly in that state - [#24](https://github.com/flybot-sg/magic/issues/24). +- IL2CPP workaround signatures come from player compilation references instead of an editor AppDomain scan, keeping editor-only assemblies (e.g. `Mono.WebBrowser` on Windows) out of the signature pool - [#23](https://github.com/flybot-sg/magic/issues/23). +- The workaround resolver searches project-local player reference directories - [#26](https://github.com/flybot-sg/magic/issues/26). +- `csc.rsp` `-r:` references count as player references and are logged for build-log verification. +- README documents the benign coexistence console lines (`Assembly is incompatible with the editor`). + ## v0.5.0 - 2026-06-04 Consumer quality-of-life fixes from the 0.4.0 rollout. diff --git a/magic-compiler/src/magic/core.clj b/magic-compiler/src/magic/core.clj index 7a803a77..cb6b19da 100644 --- a/magic-compiler/src/magic/core.clj +++ b/magic-compiler/src/magic/core.clj @@ -2161,6 +2161,11 @@ (when value-used? [(il/stloc val-local) (il/ldloc val-local)]) + ;; without this the stack value keeps its static type + ;; (often Object from an invoke return) and stfld into + ;; a hinted field emits unverifiable IL: Mono accepts + ;; it, IL2CPP rejects the generated C++. + (convert val (.FieldType field)) (when (volatile? field) (il/volatile)) (il/stfld field) diff --git a/magic-unity-smoke/Assets/Clojure/smoke/polymorphism.clj b/magic-unity-smoke/Assets/Clojure/smoke/polymorphism.clj index d3d010cf..d76b69b0 100644 --- a/magic-unity-smoke/Assets/Clojure/smoke/polymorphism.clj +++ b/magic-unity-smoke/Assets/Clojure/smoke/polymorphism.clj @@ -1,9 +1,12 @@ (ns smoke.polymorphism "Polymorphism mechanisms: protocols, reify, deftype, defrecord, and multimethods. Exercises protocol dispatch, generic sharing, - and the protocol method-cache under IL2CPP. No develop-branch - fix is bound specifically to one of these checks; the suite is - broad construct coverage to catch dispatch-related regressions.") + and the protocol method-cache under IL2CPP. The mutable-field + set! check is bound to the deftype set! castclass fix: stfld of + an invoke return into a type-hinted mutable field used to emit + without castclass, unverifiable IL that Mono accepts and IL2CPP + rejects at C++ compile time. The rest of the suite is broad + construct coverage to catch dispatch-related regressions.") (defn- pass [n] {:name n :pass? true}) (defn- fail [n detail] {:name n :pass? false :detail detail}) @@ -31,6 +34,15 @@ (area [_] (* w h)) (label [_] "box")) +(defprotocol IAccum + (add-item [a x]) + (item-vec [a])) + +(deftype Accum [^:unsynchronized-mutable ^clojure.lang.PersistentVector items] + IAccum + (add-item [this x] (set! items (conj items x)) this) + (item-vec [_] items)) + (defmulti animal-sound :kind) (defmethod animal-sound :dog [_] "woof") (defmethod animal-sound :cat [_] "meow") @@ -57,6 +69,9 @@ (ToString [] "i-am-proxy"))] (.ToString o)) "i-am-proxy") + (check "deftype set! hinted mutable field from invoke return" + #(item-vec (-> (->Accum []) (add-item 1) (add-item 2))) + [1 2]) (check "multimethod :dog" #(animal-sound {:kind :dog}) "woof") (check "multimethod default" diff --git a/magic-unity/Editor/GenerateGenericWorkaroundMethods.cs b/magic-unity/Editor/GenerateGenericWorkaroundMethods.cs index 3bc258d1..bdc1498e 100644 --- a/magic-unity/Editor/GenerateGenericWorkaroundMethods.cs +++ b/magic-unity/Editor/GenerateGenericWorkaroundMethods.cs @@ -5,6 +5,7 @@ using Mono.Cecil; using Mono.Cecil.Rocks; using Mono.Cecil.Cil; +using UnityEditor.Compilation; namespace Magic.Unity { @@ -21,10 +22,119 @@ struct DynamicCallSiteInfo static List AllMethods = new List(); static TypeDefinition MagicRuntimeDelegateHelpers = null; - static bool ShouldCollectReferencedAssembly(AssemblyDefinition assy) + static HashSet PlayerReferenceNames = null; + static HashSet PlayerReferenceDirectories = null; + + // The collected assemblies feed AllMethods, the pool of candidate dynamic + // dispatch targets whose GetMethodDelegateFast instantiations get emitted + // into the shipped .clj.dlls. A signature referencing an assembly absent + // from the player build breaks the IL2CPP build; the type-reference + // closure resolves against the editor's full desktop BCL, which on + // Windows reaches editor-only assemblies like Mono.WebBrowser. So collect + // an assembly only if player scripts compile against it. Desktop-only BCL + // assemblies are never player compilation references, while UnityEngine + // modules, the player BCL profile, plugins (including .clj.dlls), and + // user script assemblies all are. + static HashSet CollectPlayerReferenceNames() { - return !assy.FullName.StartsWith("Unity") || assy.FullName.StartsWith("UnityEngine"); + var names = new HashSet(StringComparer.OrdinalIgnoreCase); + var responseFileNames = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var assembly in CompilationPipeline.GetAssemblies(AssembliesType.PlayerWithoutTestAssemblies)) + { + names.Add(assembly.name); + foreach (var reference in assembly.allReferences) + { + names.Add(System.IO.Path.GetFileNameWithoutExtension(reference)); + } + foreach (var reference in ResponseFileReferenceNames(assembly)) + { + names.Add(reference); + responseFileNames.Add(reference); + } + } + // The full reference list below can exceed Unity's log line limit, + // so the response file contribution gets its own observable line. + UnityEngine.Debug.Log($"[Magic.Unity] response file references: {(responseFileNames.Count == 0 ? "(none)" : string.Join(",", responseFileNames.OrderBy(n => n)))}"); + return names; + } + // allReferences never lists references added through compiler + // response files (csc.rsp -r:System.Web.dll), but player code + // compiles and ships against them, so their signatures deserve + // workarounds too. + static IEnumerable ResponseFileReferenceNames(UnityEditor.Compilation.Assembly assembly) + { + var names = new List(); + foreach (var responseFile in assembly.compilerOptions.ResponseFiles) + { + string[] lines; + try + { + lines = System.IO.File.ReadAllLines(responseFile); + } + catch (Exception e) + { + UnityEngine.Debug.LogWarning($"[Magic.Unity] could not read response file {responseFile}: {e.Message}"); + continue; + } + foreach (var rawLine in lines) + { + var line = rawLine.Trim(); + string value = null; + if (line.StartsWith("-r:") || line.StartsWith("/r:")) + { + value = line.Substring(3); + } + else if (line.StartsWith("-reference:") || line.StartsWith("/reference:")) + { + value = line.Substring(11); + } + if (value == null) + { + continue; + } + // extern alias form: -r:alias=Assembly.dll + var aliasSeparator = value.IndexOf('='); + if (aliasSeparator >= 0) + { + value = value.Substring(aliasSeparator + 1); + } + names.Add(System.IO.Path.GetFileNameWithoutExtension(value.Trim().Trim('"'))); + } + } + return names; + } + + // Player assemblies live in more places than the four directories + // below: UPM packages resolve under Library/PackageCache and + // precompiled plugins sit anywhere in Assets. Without their + // directories the resolver silently drops those assemblies from the + // reference walk and their signatures get no workarounds. Editor + // install paths stay excluded on purpose: searching the BCL profile + // directories makes the netstandard facade resolve, which expands + // the workaround closure into desktop BCL assemblies (System.Data + // and friends) and forces them into every player build. + static HashSet CollectPlayerReferenceDirectories() + { + var editorInstall = System.IO.Path.GetDirectoryName(UnityEditor.EditorApplication.applicationPath); + var directories = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var assembly in CompilationPipeline.GetAssemblies(AssembliesType.PlayerWithoutTestAssemblies)) + { + foreach (var reference in assembly.allReferences) + { + var physical = System.IO.Path.GetFullPath(PackageExportPath.PhysicalPath(reference)); + if (!physical.StartsWith(editorInstall, StringComparison.OrdinalIgnoreCase)) + { + directories.Add(System.IO.Path.GetDirectoryName(physical)); + } + } + } + return directories; + } + + static bool ShouldCollectReferencedAssembly(AssemblyDefinition assy) + { + return PlayerReferenceNames.Contains(assy.Name.Name); } static HashSet CollectAllReferencedAssemblies(AssemblyDefinition assydef, HashSet seen = null) @@ -34,10 +144,14 @@ static HashSet CollectAllReferencedAssemblies(AssemblyDefini seen.Add(assydef); var resolver = assydef.MainModule.AssemblyResolver as DefaultAssemblyResolver; resolver.AddSearchDirectory("Library/ScriptAssemblies"); - resolver.AddSearchDirectory(System.IO.Path.GetDirectoryName(typeof(clojure.lang.RT).Assembly.Location)); + resolver.AddSearchDirectory(PackageExportPath.ExportDirectory); resolver.AddSearchDirectory(System.IO.Path.GetDirectoryName(typeof(string).Assembly.Location)); resolver.AddSearchDirectory(System.IO.Path.GetDirectoryName(typeof(UnityEngine.GameObject).Assembly.Location)); - + foreach (var directory in PlayerReferenceDirectories) + { + resolver.AddSearchDirectory(directory); + } + foreach (var tr in assydef.MainModule.GetTypeReferences()) { try @@ -51,7 +165,7 @@ static HashSet CollectAllReferencedAssemblies(AssemblyDefini } else { - UnityEngine.Debug.Log($"[CollectAllReferencedAssemblies] Skip {resolved.Module.Assembly}"); + UnityEngine.Debug.Log($"[CollectAllReferencedAssemblies] Skip {resolved.Module.Assembly} (not a player compilation reference)"); } } } @@ -66,6 +180,19 @@ static HashSet CollectAllReferencedAssemblies(AssemblyDefini public static void Init() { + PlayerReferenceNames = CollectPlayerReferenceNames(); + PlayerReferenceDirectories = CollectPlayerReferenceDirectories(); + // A degenerate reference set would silently turn workaround + // generation into a no-op: the build stays green and devices throw + // ExecutionEngineException at runtime. Fail the build instead. Any + // sane player reference set contains a core library and at least + // one UnityEngine module. + if (!(PlayerReferenceNames.Contains("mscorlib") || PlayerReferenceNames.Contains("netstandard")) + || !PlayerReferenceNames.Any(n => n.StartsWith("UnityEngine"))) + { + throw new InvalidOperationException($"[Magic.Unity] player compilation reference set looks degenerate ({PlayerReferenceNames.Count} entries), refusing to generate IL2CPP workarounds from it"); + } + UnityEngine.Debug.Log($"[CollectAllReferencedAssemblies] player compilation references: {string.Join(",", PlayerReferenceNames.OrderBy(n => n))}"); var assemblyCSharp = AssemblyDefinition.ReadAssembly("Library/ScriptAssemblies/Assembly-CSharp.dll"); var referencedAssemblies = CollectAllReferencedAssemblies(assemblyCSharp); UnityEngine.Debug.Log($"[CollectAllReferencedAssemblies] {string.Join(",", referencedAssemblies)}"); @@ -77,7 +204,7 @@ public static void Init() .ToList(); MagicRuntimeDelegateHelpers = AssemblyDefinition - .ReadAssembly(typeof(Magic.Runtime).Assembly.Location) + .ReadAssembly(PackageExportPath.MagicRuntimeDll) .MainModule .Types .Where(t => t.FullName == "Magic.DelegateHelpers").Single(); diff --git a/magic-unity/Editor/IL2CPPWorkarounds.cs b/magic-unity/Editor/IL2CPPWorkarounds.cs index 95473de2..c1679c62 100644 --- a/magic-unity/Editor/IL2CPPWorkarounds.cs +++ b/magic-unity/Editor/IL2CPPWorkarounds.cs @@ -8,7 +8,6 @@ using UnityEditor; using UnityEditor.Build; using UnityEngine; -using System.Reflection; namespace Magic.Unity { @@ -22,17 +21,15 @@ static bool IsIL2CPPEnabled() public static void RewriteAssemblies() { - var cljAssemblies = AppDomain.CurrentDomain - .GetAssemblies() - .Where(a => a.FullName.Contains(".clj")) - .Select(a => a.Location); - RewriteAssemblies(cljAssemblies); + RewriteAssemblies(PlayerCljAssemblies.Paths()); } public static void RewriteAssemblies(IEnumerable files) { + var fileList = files.ToList(); + Debug.LogFormat("[Magic.Unity] rewriting {0} clj assemblies", fileList.Count); GenerateGenericWorkaroundMethods.Init(); - foreach (var file in files) + foreach (var file in fileList) { RewriteAssembly(file); } @@ -40,7 +37,7 @@ public static void RewriteAssemblies(IEnumerable files) static void RewriteAssembly(string file) { - var runtimeLocation = Path.GetDirectoryName(Assembly.Load("Magic.Runtime").Location); + var runtimeLocation = PackageExportPath.ExportDirectory; Debug.LogFormat($"[Magic.Unity] runtime location {runtimeLocation}"); var resolver = new DefaultAssemblyResolver(); diff --git a/magic-unity/Editor/LinkXmlGenerator.cs b/magic-unity/Editor/LinkXmlGenerator.cs index 993e1e43..7b4ed31e 100644 --- a/magic-unity/Editor/LinkXmlGenerator.cs +++ b/magic-unity/Editor/LinkXmlGenerator.cs @@ -12,10 +12,8 @@ static class LinkXmlGenerator { public static void BuildLinkXml() { - var cljAssemblies = AppDomain.CurrentDomain - .GetAssemblies() - .Where(a => a.FullName.Contains(".clj")) - .Select(a => a.FullName) + var cljAssemblies = PlayerCljAssemblies.Paths() + .Select(Path.GetFileNameWithoutExtension) .Concat(new[] { "Clojure", "Magic.Runtime" }); BuildLinkXml(cljAssemblies); diff --git a/magic-unity/Editor/Magic.Unity.Editor.asmdef b/magic-unity/Editor/Magic.Unity.Editor.asmdef index 197fa240..fc693059 100644 --- a/magic-unity/Editor/Magic.Unity.Editor.asmdef +++ b/magic-unity/Editor/Magic.Unity.Editor.asmdef @@ -12,9 +12,7 @@ "Mono.Cecil.dll", "Mono.Cecil.Rocks.dll", "Mono.Cecil.Mdb.dll", - "Mono.Cecil.Pdb.dll", - "Clojure.dll", - "Magic.Runtime.dll" + "Mono.Cecil.Pdb.dll" ], "autoReferenced": true, "defineConstraints": [], diff --git a/magic-unity/Editor/MagicPreprocessor.cs b/magic-unity/Editor/MagicPreprocessor.cs index a69efd15..da965abd 100644 --- a/magic-unity/Editor/MagicPreprocessor.cs +++ b/magic-unity/Editor/MagicPreprocessor.cs @@ -28,6 +28,7 @@ public void OnPreprocessBuild(BuildReport report) try { + StockClojureCoexistence.Reconcile(); IL2CPPWorkarounds.RewriteAssemblies(); LinkXmlGenerator.BuildLinkXml(); } catch (Exception e) diff --git a/magic-unity/Editor/PackageExportPath.cs b/magic-unity/Editor/PackageExportPath.cs new file mode 100644 index 00000000..4192c76c --- /dev/null +++ b/magic-unity/Editor/PackageExportPath.cs @@ -0,0 +1,40 @@ +using System; +using System.IO; +using UnityEditor.PackageManager; + +namespace Magic.Unity +{ + // The pre-build rewrite must read the package's own runtime DLLs. They + // cannot be located through typeof(...).Assembly.Location or + // Assembly.Load: when a consumer keeps a stock ClojureCLR in Assets, + // Unity dedups Clojure.dll by file name and those anchors bind to the + // stock copy. Resolve the package install path instead; resolvedPath is + // the physical location for git, registry, local and embedded packages. + internal static class PackageExportPath + { + static PackageInfo Package => PackageInfo.FindForAssembly(typeof(PackageExportPath).Assembly); + + internal static string ExportDirectory => Path.Combine(Package.resolvedPath, "Runtime", "Infrastructure", "Export"); + + internal static string MagicRuntimeDll => Path.Combine(ExportDirectory, "Magic.Runtime.dll"); + + internal static string ExportAssetPath => Package.assetPath + "/Runtime/Infrastructure/Export"; + + // Asset paths under Packages/ are virtual; map them to the physical + // location before any File or Cecil access. Assets/ paths and + // absolute paths pass through. + internal static string PhysicalPath(string assetPath) + { + if (assetPath.StartsWith("Packages/", StringComparison.Ordinal)) + { + var package = PackageInfo.FindForAssetPath(assetPath); + if (package != null) + { + var relative = assetPath.Substring(package.assetPath.Length + 1); + return Path.Combine(package.resolvedPath, relative); + } + } + return assetPath; + } + } +} diff --git a/magic-unity/Editor/PackageExportPath.cs.meta b/magic-unity/Editor/PackageExportPath.cs.meta new file mode 100644 index 00000000..b9737b2c --- /dev/null +++ b/magic-unity/Editor/PackageExportPath.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: fe3abe2787ece4661b1208b2730c70c8 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/magic-unity/Editor/PlayerCljAssemblies.cs b/magic-unity/Editor/PlayerCljAssemblies.cs new file mode 100644 index 00000000..d64976ba --- /dev/null +++ b/magic-unity/Editor/PlayerCljAssemblies.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using UnityEditor.Compilation; + +namespace Magic.Unity +{ + // The pre-build rewrite and the link.xml generator used to discover clj + // assemblies by scanning the loaded AppDomain. In a coexisting editor + // the fork runtime DLLs are excluded from the editor domain (see + // StockClojureCoexistence), which would turn that scan into a silent + // no-op: no workarounds generated, no link.xml entries, devices fail at + // runtime. Discover them from player compilation references instead; + // that is the set that actually ships, independent of editor state. + internal static class PlayerCljAssemblies + { + internal static List Paths() + { + var paths = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var assembly in CompilationPipeline.GetAssemblies(AssembliesType.PlayerWithoutTestAssemblies)) + { + foreach (var reference in assembly.allReferences) + { + if (reference.EndsWith(".clj.dll", StringComparison.OrdinalIgnoreCase)) + { + paths.Add(PackageExportPath.PhysicalPath(reference)); + } + } + } + if (paths.Count == 0) + { + throw new InvalidOperationException("[Magic.Unity] no .clj.dll player compilation references found, refusing to continue with an empty clj assembly set"); + } + return paths.OrderBy(p => p, StringComparer.OrdinalIgnoreCase).ToList(); + } + } +} diff --git a/magic-unity/Editor/PlayerCljAssemblies.cs.meta b/magic-unity/Editor/PlayerCljAssemblies.cs.meta new file mode 100644 index 00000000..06830c1f --- /dev/null +++ b/magic-unity/Editor/PlayerCljAssemblies.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 57669782459ef435cacc7ef76edd336a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/magic-unity/Editor/StockClojureCoexistence.cs b/magic-unity/Editor/StockClojureCoexistence.cs new file mode 100644 index 00000000..fda47bbd --- /dev/null +++ b/magic-unity/Editor/StockClojureCoexistence.cs @@ -0,0 +1,258 @@ +using System; +using System.IO; +using System.Linq; +using Mono.Cecil; +using UnityEditor; +using UnityEngine; + +namespace Magic.Unity +{ + // Stock clojure.lang.RT probes Assembly.Load("clojure.core.clj") during + // init; finding the fork-compiled DLL throws a TypeLoadException storm. + // So while a foreign (strong-named) Clojure.dll is present under + // Assets, fork .clj.dll plugins are imported with editor loading off. + // Only the clj.dlls: excluding the fork Clojure.dll itself would leave + // Magic.Unity with no Clojure to compile against once the stock copy + // is removed, breaking the revert under batch mode. + // + // Separate bootstrap class: import callbacks touch the helper class, + // and a static ctor that throws mid import (asset database calls do) + // would poison it for the whole domain. + [InitializeOnLoad] + internal static class StockClojureCoexistenceBootstrap + { + static StockClojureCoexistenceBootstrap() + { + // delayCall may never fire before a batch run ends; the direct + // call throws when the domain load is inside an import batch. + if (Application.isBatchMode) + { + try + { + StockClojureCoexistence.Reconcile(); + } + catch (UnityException) + { + EditorApplication.delayCall += StockClojureCoexistence.Reconcile; + } + } + else + { + EditorApplication.delayCall += StockClojureCoexistence.Reconcile; + } + } + } + + internal static class StockClojureCoexistence + { + // The state file shares the artifact database's lifetime: a Library + // wipe loses both, and the rebuilt artifacts go through the + // preprocessor with the current state anyway. + public static void Reconcile() + { + var foreign = ForeignClojurePresent(); + var current = foreign ? "foreign-clojure" : "pure-magic"; + if (LastAppliedState() == current) + { + return; + } + Debug.Log(foreign + ? "[Magic.Unity/StockClojureCoexistence] foreign Clojure.dll detected, reimporting fork clj.dll plugins with editor loading off" + : "[Magic.Unity/StockClojureCoexistence] no foreign Clojure.dll present, reimporting fork clj.dll plugins with their pristine settings"); + foreach (var importer in PluginImporter.GetAllImporters()) + { + if (!importer.isNativePlugin && importer.assetPath.EndsWith(".clj.dll", StringComparison.OrdinalIgnoreCase)) + { + AssetDatabase.ImportAsset(importer.assetPath); + } + } + try + { + File.WriteAllText(StateFilePath, current); + } + catch (Exception e) + { + Debug.LogWarning($"[Magic.Unity/StockClojureCoexistence] could not write {StateFilePath}: {e.Message}"); + } + } + + static string StateFilePath => + Path.Combine(Path.GetDirectoryName(Application.dataPath), "Library", "MagicUnityCoexistenceState.txt"); + + static string LastAppliedState() + { + try + { + return File.Exists(StateFilePath) ? File.ReadAllText(StateFilePath).Trim() : ""; + } + catch (Exception) + { + return ""; + } + } + + // Filesystem scan, not a PluginImporter scan: the asset database is + // not queryable inside import callbacks. A vendored fork copy in + // Assets has no strong name and stays non-foreign. + internal static bool ForeignClojurePresent() + { + try + { + return Directory.EnumerateFiles(Application.dataPath, "Clojure.dll", SearchOption.AllDirectories).Any(IsStockClojure); + } + catch (Exception e) + { + Debug.LogWarning($"[Magic.Unity/StockClojureCoexistence] foreign Clojure scan failed: {e.Message}"); + return false; + } + } + + static bool IsStockClojure(string path) + { + try + { + var token = System.Reflection.AssemblyName.GetAssemblyName(path).GetPublicKeyToken(); + return token != null && token.Length > 0; + } + catch (Exception e) + { + Debug.LogWarning($"[Magic.Unity/StockClojureCoexistence] could not inspect {path}: {e.Message}"); + return false; + } + } + + internal static bool ReferencesForkClojure(string path) + { + try + { + using (var assembly = AssemblyDefinition.ReadAssembly(path)) + { + var clojureReference = assembly.MainModule.AssemblyReferences.FirstOrDefault(r => r.Name == "Clojure"); + return clojureReference != null + && (clojureReference.PublicKeyToken == null || clojureReference.PublicKeyToken.Length == 0); + } + } + catch (Exception e) + { + Debug.LogWarning($"[Magic.Unity/StockClojureCoexistence] could not inspect {path}: {e.Message}"); + return false; + } + } + + internal static System.Collections.Generic.IEnumerable ValidBuildTargets() + { + foreach (BuildTarget target in Enum.GetValues(typeof(BuildTarget))) + { + if ((int)target < 0) + { + continue; + } + var field = typeof(BuildTarget).GetField(target.ToString()); + if (field == null || field.GetCustomAttributes(typeof(ObsoleteAttribute), false).Any()) + { + continue; + } + yield return target; + } + } + } + + // Importer changes made here are written back to the .meta on mutable + // installs (the userData marker drives the restore) and stay + // artifact-only on immutable installs, where the pristine .meta is the + // restore state. + internal class CljPluginPreprocessor : AssetPostprocessor + { + void OnPreprocessAsset() + { + if (!assetPath.EndsWith(".clj.dll", StringComparison.OrdinalIgnoreCase)) + { + return; + } + var importer = assetImporter as PluginImporter; + if (importer == null) + { + return; + } + if (StockClojureCoexistence.ForeignClojurePresent()) + { + DisableEditorLoading(importer); + } + else + { + RestoreEditorLoading(importer); + } + } + + // SetExcludeEditorFromAnyPlatform is not honored by the plugin + // loader, hence the switch to explicit per-platform compatibility. + // Restore goes by marker only, never touching a plugin the consumer + // made editor-only themselves. + const string AnyPlatformMarker = "Magic.Unity.StockClojureCoexistence:any-platform"; + const string CustomPlatformsMarker = "Magic.Unity.StockClojureCoexistence:custom-platforms"; + + static void DisableEditorLoading(PluginImporter importer) + { + if (HasMarker(importer) + || (!importer.GetCompatibleWithAnyPlatform() && !importer.GetCompatibleWithEditor())) + { + return; + } + if (!StockClojureCoexistence.ReferencesForkClojure(PackageExportPath.PhysicalPath(importer.assetPath))) + { + return; + } + string marker; + if (importer.GetCompatibleWithAnyPlatform()) + { + importer.SetCompatibleWithAnyPlatform(false); + foreach (var target in StockClojureCoexistence.ValidBuildTargets()) + { + try { importer.SetCompatibleWithPlatform(target, true); } catch { } + } + marker = AnyPlatformMarker; + } + else + { + marker = CustomPlatformsMarker; + } + AddMarker(importer, marker); + importer.SetCompatibleWithEditor(false); + Debug.Log($"[Magic.Unity/StockClojureCoexistence] editor loading off for {importer.assetPath}"); + } + + static void RestoreEditorLoading(PluginImporter importer) + { + if (!HasMarker(importer)) + { + return; + } + var wasAnyPlatform = importer.userData.Contains(AnyPlatformMarker); + RemoveMarker(importer); + importer.SetCompatibleWithEditor(true); + if (wasAnyPlatform) + { + importer.SetCompatibleWithAnyPlatform(true); + importer.SetExcludeEditorFromAnyPlatform(false); + } + Debug.Log($"[Magic.Unity/StockClojureCoexistence] editor loading on for {importer.assetPath}"); + } + + static bool HasMarker(PluginImporter importer) + { + return importer.userData != null && importer.userData.Contains("Magic.Unity.StockClojureCoexistence:"); + } + + static void AddMarker(PluginImporter importer, string marker) + { + importer.userData = string.IsNullOrEmpty(importer.userData) ? marker : marker + ";" + importer.userData; + } + + static void RemoveMarker(PluginImporter importer) + { + importer.userData = importer.userData + .Replace(AnyPlatformMarker + ";", "").Replace(AnyPlatformMarker, "") + .Replace(CustomPlatformsMarker + ";", "").Replace(CustomPlatformsMarker, ""); + } + } +} diff --git a/magic-unity/Editor/StockClojureCoexistence.cs.meta b/magic-unity/Editor/StockClojureCoexistence.cs.meta new file mode 100644 index 00000000..3a070498 --- /dev/null +++ b/magic-unity/Editor/StockClojureCoexistence.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9043d4d40082b4d17a1f739ceb0c212c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/magic-unity/README.md b/magic-unity/README.md index b77f7559..63cb4e89 100644 --- a/magic-unity/README.md +++ b/magic-unity/README.md @@ -28,6 +28,7 @@ See [magic-unity-smoke](../magic-unity-smoke) for a working IL2CPP regression pr 1. You compile your own Clojure namespaces to `.clj.dll` outside Unity via `nos dotnet/build`, writing them into `Assets/Plugins/Magic/` (see [magic-unity-smoke/dotnet.clj](../magic-unity-smoke/dotnet.clj) for the canonical task definition). The package does not include a compiler. 2. Unity opens the project. The prebuilt runtime + stdlib from `Runtime/Infrastructure/Export/` and your own `.clj.dll`s are both loaded as plain .NET assemblies. `Magic.Unity.Clojure.Boot()` initialises the runtime; `Require` / `GetVar` let C# scripts call into Clojure. 3. On every build, `MagicPreprocessor` runs first. When the build target uses IL2CPP, it rewrites the `.clj.dll` bodies in place so the IL2CPP transpiler can consume them (and writes `link.xml` entries); on a Mono build the preprocessor only sweeps any leftover IL2CPP-only workarounds from a previous build. The runtime DLLs are loaded the same way under either backend. +4. Coexistence with stock ClojureCLR: if a strong-named `Clojure.dll` is found under `Assets` (projects that keep ClojureCLR for Editor work and MAGIC for shipped builds), every MAGIC-compiled `.clj.dll` is imported with Editor loading off, because the stock runtime probe-loads `clojure.core.clj` at init and a MAGIC DLL answering that probe fails to load. With the exclusion active, the Editor logs `Assembly '...clj.dll' will not be loaded due to errors: Assembly is incompatible with the editor` for the package's `Export` DLLs on every domain reload. These lines are expected and benign in this setup: they are Unity reporting the intended Editor exclusion, not a real failure. Player builds are unaffected. ## API diff --git a/magic-unity/Runtime/Magic.Unity.cs b/magic-unity/Runtime/Magic.Unity.cs index 106b2bc6..82c3af94 100644 --- a/magic-unity/Runtime/Magic.Unity.cs +++ b/magic-unity/Runtime/Magic.Unity.cs @@ -1,4 +1,5 @@ using System; +using System.Reflection; using clojure.lang; namespace Magic.Unity @@ -23,20 +24,50 @@ public static void Boot() if (!_booted) { _booted = true; + BootMagicRuntime(); + RequireVar = RT.var("clojure.core", "require"); + } + } + + // The bootstrap below uses API that only exists in MAGIC's Clojure + // fork: RuntimeBootstrapFlag.CodeLoadOrder, RT.Initialize with the + // doRuntimePostBoostrap parameter, and RT.TryLoadInitType. A consumer + // may keep a stock ClojureCLR in Assets for in-editor runtime + // compilation; Unity dedups managed plugins by file name, so this + // file can end up compiled against stock Clojure.dll. Bind the + // fork-only members via reflection: when the fork is present this + // runs the exact same bootstrap as before, when it is absent (stock) + // the bootstrap is skipped and stock self-initializes on first + // RT.var. + static void BootMagicRuntime() + { + var clojureAssembly = typeof(RT).Assembly; + var bootstrapFlagType = clojureAssembly.GetType("clojure.lang.RuntimeBootstrapFlag"); + var codeLoadOrderField = bootstrapFlagType?.GetField("CodeLoadOrder", BindingFlags.Public | BindingFlags.Static); + var codeSourceType = bootstrapFlagType?.GetNestedType("CodeSource"); + var initializeMethod = typeof(RT).GetMethod("Initialize", BindingFlags.Public | BindingFlags.Static, null, new[] { typeof(bool), typeof(bool) }, null); + var tryLoadInitTypeMethod = typeof(RT).GetMethod("TryLoadInitType", BindingFlags.Public | BindingFlags.Static, null, new[] { typeof(string) }, null); + if (codeLoadOrderField == null || codeSourceType == null || initializeMethod == null || tryLoadInitTypeMethod == null) + { + return; + } #if UNITY_EDITOR - RuntimeBootstrapFlag.CodeLoadOrder = new[] { - RuntimeBootstrapFlag.CodeSource.InitType, - RuntimeBootstrapFlag.CodeSource.FileSystem - }; + SetCodeLoadOrder(codeLoadOrderField, codeSourceType, new[] { "InitType", "FileSystem" }); #elif ENABLE_IL2CPP - RuntimeBootstrapFlag.CodeLoadOrder = new[] { - RuntimeBootstrapFlag.CodeSource.InitType - }; + SetCodeLoadOrder(codeLoadOrderField, codeSourceType, new[] { "InitType" }); #endif - RT.Initialize(doRuntimePostBoostrap: false); - RT.TryLoadInitType("clojure/core"); - RequireVar = RT.var("clojure.core", "require"); + initializeMethod.Invoke(null, new object[] { true, false }); + tryLoadInitTypeMethod.Invoke(null, new object[] { "clojure/core" }); + } + + static void SetCodeLoadOrder(FieldInfo codeLoadOrderField, Type codeSourceType, string[] sourceNames) + { + var codeLoadOrder = Array.CreateInstance(codeSourceType, sourceNames.Length); + for (var i = 0; i < sourceNames.Length; i++) + { + codeLoadOrder.SetValue(Enum.Parse(codeSourceType, sourceNames[i]), i); } + codeLoadOrderField.SetValue(null, codeLoadOrder); } /// diff --git a/magic-unity/package.json b/magic-unity/package.json index 25423779..21d4efa9 100644 --- a/magic-unity/package.json +++ b/magic-unity/package.json @@ -1,6 +1,6 @@ { "name": "sg.flybot.magic.unity", - "version": "0.5.0", + "version": "0.6.0", "displayName": "MAGIC Unity Integration", "description": "The integration of the MAGIC Clojure compiler into Unity", "unity": "2021.2", diff --git a/nostrand/references/magic.core.clj.dll b/nostrand/references/magic.core.clj.dll index 427cc7a8..dabe2b0c 100755 Binary files a/nostrand/references/magic.core.clj.dll and b/nostrand/references/magic.core.clj.dll differ diff --git a/version.edn b/version.edn index 879b88c2..e07bf7ac 100644 --- a/version.edn +++ b/version.edn @@ -1 +1 @@ -{:version "0.5.0"} +{:version "0.6.0"}