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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ obj/
*.sln
!nostrand/references/*.dll
!magic-unity/Runtime/Infrastructure/Export/*.dll
!magic-unity-dual/Runtime/Infrastructure/Export/*.dll
!unity-examples/magic-unity-coexist/Assets/Plugins/clojure-clr/**/*.dll

# macOS
.DS_Store
Expand Down
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
# Changelog

## v0.7.0 - 2026-06-09

A second Unity package variant for projects that keep stock ClojureCLR as the editor runtime.

### Magic.Unity
- New `sg.flybot.magic.unity.dual` variant: the runtime `*.clj.dll` carry a `!UNITY_EDITOR` define constraint, so Unity excludes them from the editor and a coexistence project no longer logs the `Assembly is incompatible with the editor` lines on every domain reload. The default `sg.flybot.magic.unity` is unchanged and still runs MAGIC in editor Play mode - [#30](https://github.com/flybot-sg/magic/issues/30).
- New `docs/unity-integration.md` covers the consumer workflow and how to choose a variant.

### Tooling
- `bb gen-unity-dual` generates the dual variant from `magic-unity` (drift-checked by `bb check-drift`); `bb coexist-noise` reproduces the console noise in-repo via `unity-examples/magic-unity-coexist`. Example Unity projects moved under `unity-examples/`.

## v0.6.0 - 2026-06-07

Stock-ClojureCLR coexistence for Unity consumers that keep ClojureCLR as the editor runtime, plus IL2CPP workaround-selection fixes.
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ MAGIC is a monorepo. Every issue should carry one or more `comp:` labels naming
- `comp:magic-compiler`
- `comp:nostrand`
- `comp:magic-unity`
- `comp:magic-unity-smoke`
- `comp:unity-examples`

## Pull requests

Expand Down
29 changes: 5 additions & 24 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,9 @@ MAGIC is a self-hosting compiler: it is written in Clojure and compiles itself t
| [mage](./mage) | Symbolic MSIL bytecode emitter | Clojure |
| [magic-compiler](./magic-compiler) | The compiler (s-expressions to MSIL) | Clojure |
| [nostrand](./nostrand) | Task runner, dependency manager, REPL | C# + Clojure |
| [magic-unity](./magic-unity) | Unity integration package (IL2CPP support) | C# |
| [magic-unity-smoke](./magic-unity-smoke) | Standalone Unity project that exercises MAGIC under IL2CPP. Regression suite for AOT-only bugs, runs by hand on the verified Unity version (`2022.3.62f3`). | Clojure + C# |
| [magic-unity](./magic-unity) | Unity integration package (IL2CPP support), default variant | C# |
| [magic-unity-dual](./magic-unity-dual) | Coexistence variant of magic-unity with the runtime excluded from the Editor (for projects that keep stock ClojureCLR). Generated by `bb gen-unity-dual`; see [Unity integration](./docs/unity-integration.md). | C# |
| [magic-unity-smoke](./unity-examples/magic-unity-smoke) | Standalone Unity project that exercises MAGIC under IL2CPP. Regression suite for AOT-only bugs, runs by hand on the verified Unity version (`2022.3.62f3`). | Clojure + C# |

Each component has its own README with detailed documentation.

Expand Down Expand Up @@ -86,27 +87,7 @@ You need three things: the `nos` CLI (build-time), the `magic-unity` UPM package

Defaults install to `$HOME/.local/nostrand/` with the launcher symlinked to `$HOME/.local/bin/nos`. Override with `INSTALL_DIR=` / `INSTALL_LINK=` env vars if needed.

2. **Add the Unity package** to your `Packages/manifest.json`, pinned to a tag from the [releases page](https://github.com/flybot-sg/magic/releases):

```json
{
"dependencies": {
"sg.flybot.magic.unity": "https://github.com/flybot-sg/magic.git?path=magic-unity#<tag>"
}
}
```

3. **Add `deps.edn` and `dotnet.clj`** at your Unity project root. Copy the templates from [`magic-unity-smoke/`](./magic-unity-smoke/): `deps.edn` declares your source `:paths` and any `:deps` (`nos` resolves them at boot, cloning git deps into `~/.nostrand/gitlibs`), and `dotnet.clj` holds the build/test tasks. The `nostrand.tasks` helpers (`compile-project`, `run-clojure-tests`) pin the shipped compiler flags and can derive the namespace set from your `deps.edn` paths, so `dotnet.clj` stays small. See [Porting a Clojure library to MAGIC](./docs/porting-libraries-to-magic.md) for the `dotnet.clj` patterns and the full option set.

4. **Compile your Clojure** before opening Unity:

```bash
nos dotnet/build
```

Drops your `.clj.dll` files into `Assets/Plugins/Magic/` where Unity will load them.

5. **Open Unity, hit Play.** For CI, define a `nos dotnet/run-tests` task in your `dotnet.clj` to run Mono-side tests independent of Unity (see [`magic-unity-smoke/dotnet.clj`](magic-unity-smoke/dotnet.clj)). IL2CPP-only regressions need an actual Unity build; [`magic-unity-smoke`](./magic-unity-smoke) is the reference pattern.
2. **Add the package, compile, open Unity.** The [Unity integration guide](./docs/unity-integration.md) covers the `Packages/manifest.json` pin (and which of the two variants to pick: default, or `.dual` for projects that keep stock ClojureCLR in the Editor), the `deps.edn` / `dotnet.clj` templates, `nos dotnet/build`, and IL2CPP.

### Use `nos` for non-Unity Clojure-on-CLR

Expand Down Expand Up @@ -284,7 +265,7 @@ cd magic-compiler && mono ../nostrand/bin/Release/net471/NostrandMain.exe test/a

The MAGIC compiler itself uses the `test/all` entrypoint in [magic-compiler/test.clj](magic-compiler/test.clj). Downstream projects use their own `nos dotnet/run-tests` (see the Getting Started section above for the pattern).

For IL2CPP-specific regressions (AOT-only bugs that the Mono editor cannot catch), [magic-unity-smoke](./magic-unity-smoke) drives MAGIC's compile output through Unity's IL2CPP pipeline and reports pass/fail in the built player. Run by hand on the verified Unity version after touching the compiler, the runtimes, or `magic-unity` itself.
For IL2CPP-specific regressions (AOT-only bugs that the Mono editor cannot catch), [magic-unity-smoke](./unity-examples/magic-unity-smoke) drives MAGIC's compile output through Unity's IL2CPP pipeline and reports pass/fail in the built player. Run by hand on the verified Unity version after touching the compiler, the runtimes, or `magic-unity` itself.

## Git History

Expand Down
36 changes: 34 additions & 2 deletions bb.edn
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
;; Run `bb tasks` for the public list.

{:min-bb-version "1.3.0"
:paths ["bb"]
:tasks
{:requires ([babashka.fs :as fs]
[clojure.string :as str])
Expand Down Expand Up @@ -89,6 +90,7 @@

check-drift
{:doc "Verify generated files, stdlib bootstrap DLLs, and UPM version are current. Runs regen + refresh-stdlib + sync-upm-version and fails if the working tree changed. Use in CI and before opening a PR."
:requires ([magic.unity :as unity])
:depends [regen-callsites refresh-stdlib sync-upm-version]
;; Drift signals checked:
;; - magic-runtime/Magic.Runtime/Generated: .g.cs files produced by Mustache
Expand All @@ -100,9 +102,21 @@
;; non-deterministic (gensyms, hash tokens) and would always fail.
;; - magic-unity/package.json: UPM manifest version field. Synced from
;; version.edn by sync-upm-version (above).
;; - magic-unity-dual: the generated coexistence variant. gen-unity-dual
;; copies magic-unity verbatim, then constrains the 46 runtime metas and
;; renames the package. refresh-stdlib (above) redeploys freshly-compiled,
;; non-deterministic DLLs into magic-unity/Runtime/Infrastructure/Export,
;; so we revert that byproduct first; the dual is then a copy of the
;; committed binaries and the diff isolates the deterministic transforms
;; (the meta constraints, the package rename) plus any DLL the maintainer
;; refreshed in magic-unity without regenerating the dual.
:task (let [checked-paths ["magic-runtime/Magic.Runtime/Generated"
"magic-compiler/stdlib-manifest.edn"
"magic-unity/package.json"]
"magic-unity/package.json"
"magic-unity-dual"]
_ (shell "git" "checkout" "--"
"magic-unity/Runtime/Infrastructure/Export/")
_ (unity/gen-dual!)
{:keys [out]} (apply shell {:continue true :out :string}
"git" "diff" "--" checked-paths)]
(when-not (str/blank? out)
Expand All @@ -111,12 +125,14 @@
(println "Drift detected.")
(println "Either a .mustache template was edited without regenerating .g.cs,")
(println "a stdlib .clj source was edited without refreshing its .clj.dll,")
(println "or version.edn was bumped without syncing magic-unity/package.json.")
(println "version.edn was bumped without syncing magic-unity/package.json,")
(println "or magic-unity changed without regenerating magic-unity-dual.")
(println)
(println "Fix locally:")
(println " bb regen-callsites # if magic-runtime Generated/ drifted")
(println " bb refresh-stdlib # if stdlib-manifest.edn drifted")
(println " bb sync-upm-version # if magic-unity/package.json drifted")
(println " bb gen-unity-dual # if magic-unity-dual drifted from magic-unity")
(println " git add" (str/join " " checked-paths)
"nostrand/references magic-unity/Runtime/Infrastructure/Export")
(println)
Expand Down Expand Up @@ -212,6 +228,22 @@
(when-not (str/includes? line ":tag :ret")
(recur)))))))}

;; ============================================================
;; Unity packaging (two variants) + coexistence repro
;; ============================================================

gen-unity-dual
{:doc "Generate the magic-unity-dual UPM package from magic-unity. Byte-identical except the 46 runtime *.clj.dll plugins carry defineConstraints ['!UNITY_EDITOR'] so Unity leaves them out of the editor (a project running stock ClojureCLR in the editor then sees no 'incompatible with the editor' narration), and package.json is renamed sg.flybot.magic.unity.dual. The default sg.flybot.magic.unity stays editor-loadable so MAGIC also runs in the editor's Play mode. Output is committed and drift-checked; regenerate after any magic-unity change."
:requires ([magic.unity :as unity])
:task (println "Generated magic-unity-dual -" (unity/gen-dual!)
"runtime plugins constrained to !UNITY_EDITOR")}

coexist-noise
{:doc "Reproduce / regression-check the coexistence editor noise in-repo (the #25 'Assembly is incompatible with the editor' narration). Packs a magic-unity variant into an immutable tarball, points the magic-unity-coexist project (which vendors stock ClojureCLR) at it via a fresh PackageCache resolve (the only state that shows the noise: a mutable file: install writes the editor-off flip back and it never appears), launches Unity 2022.3.62f3 headless twice, and reports the narration-line count plus CoexistenceProbe state. 'bb coexist-noise' tests the dual variant (expect 0 lines, the shipped fix); 'bb coexist-noise magic-only' tests the default variant (expect 46 lines, the problem the dual variant solves). Quit any GUI Unity holding magic-unity-coexist first (batchmode exits 134)."
:requires ([magic.unity :as unity])
:depends [gen-unity-dual]
:task (unity/coexist-noise! (or (first *command-line-args*) "dual"))}

;; ============================================================
;; Hygiene
;; ============================================================
Expand Down
158 changes: 158 additions & 0 deletions bb/magic/unity.clj
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
(ns magic.unity
"Babashka helpers for the two MAGIC Unity package variants: generate the
editor-excluded `magic-unity-dual` from `magic-unity`, and drive the
`magic-unity-coexist` repro that proves the dual variant is silent."
(:require [babashka.fs :as fs]
[babashka.tasks :refer [shell]]
[clojure.pprint :as pp]
[clojure.string :as str]))

(def ^:private default-pkg "magic-unity")
(def ^:private dual-pkg "magic-unity-dual")
(def ^:private coexist-proj "unity-examples/magic-unity-coexist")
(def ^:private unity
"/Applications/Unity/Hub/Editor/2022.3.62f3/Unity.app/Contents/MacOS/Unity")

;;; Dual variant generation

(defn- exclude-from-editor [meta-yaml]
(str/replace meta-yaml
" defineConstraints: []"
" defineConstraints:\n - '!UNITY_EDITOR'"))

(defn- rename-package [package-json]
(-> package-json
(str/replace "\"name\": \"sg.flybot.magic.unity\""
"\"name\": \"sg.flybot.magic.unity.dual\"")
(str/replace "\"displayName\": \"MAGIC Unity Integration\""
"\"displayName\": \"MAGIC Unity Integration (dual: stock editor + MAGIC players)\"")))

(defn- edit-file!
"Rewrite path through f. Throws when f changes nothing, the signal that the
source format drifted from what the generator expects."
[path f]
(let [content (slurp path)
edited (f content)]
(when (= content edited)
(throw (ex-info (str "gen-unity-dual: no substitution applied to " path) {:path path})))
(spit path edited)))

(defn gen-dual!
"Regenerate magic-unity-dual as a verbatim copy of magic-unity with the runtime
clj.dll plugins constrained to !UNITY_EDITOR and the package renamed. Returns
the number of constrained plugins."
[]
(fs/delete-tree dual-pkg)
(shell "cp" "-R" default-pkg dual-pkg)
(let [metas (fs/glob (str dual-pkg "/Runtime/Infrastructure/Export") "*.clj.dll.meta")]
(when (empty? metas)
(throw (ex-info "gen-unity-dual: no runtime *.clj.dll.meta in copy" {})))
(run! #(edit-file! (str %) exclude-from-editor) metas)
(edit-file! (str dual-pkg "/package.json") rename-package)
(count metas)))

;;; Coexistence repro

(def ^:private variants
{"dual" {:pkg dual-pkg :dependency "sg.flybot.magic.unity.dual" :expected 0}
"magic-only" {:pkg default-pkg :dependency "sg.flybot.magic.unity" :expected 46}})

(defn- coexist-path [& parts]
(str/join "/" (cons coexist-proj parts)))

(defn- pack-tarball!
"Pack pkg into a UPM tarball at tgz: a single top-level package/ directory."
[pkg tgz]
(let [staging (fs/create-temp-dir {:prefix "magic-coexist-pkg"})
pkgdir (str (fs/path staging "package"))]
(fs/create-dirs pkgdir)
(shell "cp" "-R" (str pkg "/.") (str pkgdir "/"))
(shell "tar" "czf" tgz "-C" (str staging) "package")
(fs/delete-tree staging)))

(defn- write-manifest!
"Pin the coexist project to dependency, which must equal the tarball's
package.json name or UPM refuses to resolve it."
[dependency]
(spit (coexist-path "Packages" "manifest.json")
(str "{\n \"dependencies\": {\n \"" dependency
"\": \"file:../magic-unity.tgz\"\n }\n}\n")))

(defn- reset-package-cache! []
(fs/delete-if-exists (coexist-path "Packages" "packages-lock.json"))
(let [cache (coexist-path "Library" "PackageCache")]
(when (fs/exists? cache)
(run! fs/delete-tree (fs/glob cache "sg.flybot.magic.unity*")))))

(defn- run-editor!
"Launch Unity headless on the coexist project, logging to log; extra args
(e.g. -executeMethod) are appended. A non-zero exit is tolerated because the
log, not the exit code, is the signal."
[log & args]
(fs/create-dirs (coexist-path "Logs"))
(fs/delete-if-exists log)
(apply shell {:continue true} unity
"-batchmode" "-quit" "-nographics"
"-projectPath" coexist-proj "-logFile" log args))

(defn- parse-log [log-text]
(let [lines (str/split-lines log-text)
containing (fn [substr] (filter #(str/includes? % substr) lines))]
{:narration (count (containing "Assembly is incompatible with the editor"))
:dedup (count (containing "Duplicate assembly 'Clojure.dll'"))
:probe (first (containing "[CoexistenceProbe]"))}))

(defn- verdict
"Classify a parsed run for the variant as [status message]."
[variant {:keys [narration probe]}]
(let [silent? (zero? narration)
probe-fixed? (boolean (some-> probe (str/includes? "core-clj-loadable=false")))]
(cond
(and (= variant "dual") silent? probe-fixed?)
[:pass "dual variant is silent; probe confirms #25 stays fixed (core-clj-loadable=false)"]
(and (= variant "dual") silent?)
[:inconclusive "0 narration but probe did not confirm core-clj-loadable=false; did the package resolve?"]
(= variant "dual")
[:fail (str "dual variant narrated " narration " lines; the !UNITY_EDITOR constraint is missing or ineffective")]
(pos? narration)
[:reproduced "reproduced the noise on the magic-only variant (expected); this is what the dual variant fixes"]
:else
[:unexpected "magic-only produced 0 narration lines"])))

(defn- result-summary
"Report map for a parsed run: status and message from the verdict, plus the
raw counts and the probe line."
[variant expected {:keys [narration dedup probe] :as result}]
(let [[status message] (verdict variant result)]
(array-map
:variant variant
:status status
:message message
:narration narration
:expected expected
:dedup dedup
:probe probe)))

(defn coexist-noise!
"Reproduce / regression-check the coexistence noise for variant \"dual\" (the
shipped fix, expect 0 narration lines) or \"magic-only\" (the default, expect
46). Packs the variant into an immutable tarball, resolves it fresh, then runs
Unity headless twice: a cold import, then a domain reload that runs
CoexistenceProbe. The probe runs only on the second pass because a flip
applies to the next domain load, so a same-session probe reads stale state."
[variant]
(let [{:keys [pkg dependency expected]}
(or (variants variant)
(throw (ex-info (str "unknown variant: " variant " (dual|magic-only)") {})))]
(when-not (fs/exists? unity)
(throw (ex-info (str "Unity 2022.3.62f3 not found: " unity) {})))
(let [editor-log (coexist-path "Logs" "coexist-noise.editor.log")]
(println "Variant:" variant "- packing" pkg "(expecting" expected "narration lines)")
(pack-tarball! pkg (coexist-path "magic-unity.tgz"))
(write-manifest! dependency)
(reset-package-cache!)
(println "Run 1/2: cold import (slow)...")
(run-editor! (coexist-path "Logs" "coexist-noise.import.log"))
(println "Run 2/2: domain reload (narration + probe)...")
(run-editor! editor-log "-executeMethod" "CoexistenceProbe.Run")
(pp/pprint (result-summary variant expected (parse-log (slurp editor-log)))))))
2 changes: 1 addition & 1 deletion docs/porting-libraries-to-magic.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ nos dotnet/run-tests # requires the namespaces, runs clojure.test, exits non-

`run-tests` executes under Mono and does not cover IL2CPP codegen; for Unity,
an actual IL2CPP build is the only way to catch AOT-only regressions (see
[`magic-unity-smoke`](../magic-unity-smoke)).
[`magic-unity-smoke`](../unity-examples/magic-unity-smoke)).

`:clean? true` wipes the output dir before compiling, so the task does not need
a `rm -rf build` shell step in front of it.
Expand Down
Loading
Loading