Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
5 changes: 5 additions & 0 deletions magic-compiler/src/magic/core.clj
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
21 changes: 18 additions & 3 deletions magic-unity-smoke/Assets/Clojure/smoke/polymorphism.clj
Original file line number Diff line number Diff line change
@@ -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})
Expand Down Expand Up @@ -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")
Expand All @@ -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"
Expand Down
139 changes: 133 additions & 6 deletions magic-unity/Editor/GenerateGenericWorkaroundMethods.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using Mono.Cecil;
using Mono.Cecil.Rocks;
using Mono.Cecil.Cil;
using UnityEditor.Compilation;

namespace Magic.Unity
{
Expand All @@ -21,10 +22,119 @@ struct DynamicCallSiteInfo
static List<MethodDefinition> AllMethods = new List<MethodDefinition>();
static TypeDefinition MagicRuntimeDelegateHelpers = null;

static bool ShouldCollectReferencedAssembly(AssemblyDefinition assy)
static HashSet<string> PlayerReferenceNames = null;
static HashSet<string> 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<string> CollectPlayerReferenceNames()
{
return !assy.FullName.StartsWith("Unity") || assy.FullName.StartsWith("UnityEngine");
var names = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var responseFileNames = new HashSet<string>(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<string> ResponseFileReferenceNames(UnityEditor.Compilation.Assembly assembly)
{
var names = new List<string>();
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<string> CollectPlayerReferenceDirectories()
{
var editorInstall = System.IO.Path.GetDirectoryName(UnityEditor.EditorApplication.applicationPath);
var directories = new HashSet<string>(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<AssemblyDefinition> CollectAllReferencedAssemblies(AssemblyDefinition assydef, HashSet<AssemblyDefinition> seen = null)
Expand All @@ -34,10 +144,14 @@ static HashSet<AssemblyDefinition> 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
Expand All @@ -51,7 +165,7 @@ static HashSet<AssemblyDefinition> CollectAllReferencedAssemblies(AssemblyDefini
}
else
{
UnityEngine.Debug.Log($"[CollectAllReferencedAssemblies] Skip {resolved.Module.Assembly}");
UnityEngine.Debug.Log($"[CollectAllReferencedAssemblies] Skip {resolved.Module.Assembly} (not a player compilation reference)");
}
}
}
Expand All @@ -66,6 +180,19 @@ static HashSet<AssemblyDefinition> 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)}");
Expand All @@ -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();
Expand Down
13 changes: 5 additions & 8 deletions magic-unity/Editor/IL2CPPWorkarounds.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
using UnityEditor;
using UnityEditor.Build;
using UnityEngine;
using System.Reflection;

namespace Magic.Unity
{
Expand All @@ -22,25 +21,23 @@ 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<string> 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);
}
}

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();
Expand Down
6 changes: 2 additions & 4 deletions magic-unity/Editor/LinkXmlGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
4 changes: 1 addition & 3 deletions magic-unity/Editor/Magic.Unity.Editor.asmdef
Original file line number Diff line number Diff line change
Expand Up @@ -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": [],
Expand Down
1 change: 1 addition & 0 deletions magic-unity/Editor/MagicPreprocessor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ public void OnPreprocessBuild(BuildReport report)

try
{
StockClojureCoexistence.Reconcile();
IL2CPPWorkarounds.RewriteAssemblies();
LinkXmlGenerator.BuildLinkXml();
} catch (Exception e)
Expand Down
40 changes: 40 additions & 0 deletions magic-unity/Editor/PackageExportPath.cs
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
11 changes: 11 additions & 0 deletions magic-unity/Editor/PackageExportPath.cs.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading