Skip to content

Commit e219f4e

Browse files
TMHSDigitalclaude
andcommitted
fix: make C# snippets compile on Unity 6 and check them in CI
Snippets and templates are now compiled in CI against the real Unity 6 reference assemblies (extracted from the Linux editor archive, no license needed) in a player pass and an editor pass, with C# 9 and obsolete APIs as errors. Fixes found by the new check: - editor-window: drop unused fields that needed UnityEditor.UIElements - property-drawer, custom-inspector, LevelBuilderWindow: wrap editor code in UNITY_EDITOR so player builds work; EnemyConfig moves to the runtime namespace and the header says to save it as EnemyConfig.cs - save-load-json: replace the undefined PlayerController with a serialized Transform - input-system-actions: use project-wide actions instead of the generated PlayerInputActions class that is not shipped - event-system: use a UI Toolkit Label instead of TextMeshPro Fixes #27 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MpEiWjv38TrdFcgqU8ER5L
1 parent 0218a05 commit e219f4e

13 files changed

Lines changed: 361 additions & 52 deletions

‎.github/scripts/compile_csharp.py‎

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
"""Compile C# snippets and templates against Unity reference assemblies.
2+
3+
Each snippet file and each template folder is compiled on its own (they reuse
4+
type names such as GameManager), twice:
5+
6+
- player pass: UnityEngine modules only, no UNITY_EDITOR define. Fails if
7+
editor-only code leaks into what would be a player build.
8+
- editor pass: UNITY_EDITOR defined and UnityEditor modules referenced.
9+
10+
Uses of [Obsolete] APIs (CS0618) are errors so snippets stay on modern APIs.
11+
12+
Usage:
13+
python compile_csharp.py --unity <Editor/Data> --inputsystem <package dir>
14+
15+
The Editor/Data folder only needs Managed/UnityEngine/*.dll and
16+
NetStandard/ref/2.1.0/netstandard.dll (see fetch_unity_refs.py).
17+
"""
18+
19+
import argparse
20+
import os
21+
import re
22+
import shutil
23+
import subprocess
24+
import sys
25+
import tempfile
26+
from pathlib import Path
27+
28+
ROOT = Path(__file__).resolve().parents[2]
29+
LANG_VERSION = "9"
30+
31+
# Unity version defines for a 6000.0 editor
32+
UNITY_DEFINES = (
33+
[f"UNITY_5_{m}_OR_NEWER" for m in range(3, 7)]
34+
+ [f"UNITY_{y}_{m}_OR_NEWER" for y in range(2017, 2024) for m in range(1, 5)]
35+
+ ["UNITY_6000_0_OR_NEWER", "UNITY_6000_0", "UNITY_6000", "ENABLE_INPUT_SYSTEM"]
36+
)
37+
38+
# Unity serializes private fields, so "never assigned" and "unused" warnings are noise
39+
NOWARN = "0169,0414,0649,1701,1702"
40+
41+
42+
def find_csc() -> list:
43+
override = os.environ.get("CSC")
44+
if override:
45+
return [override]
46+
dotnet = shutil.which("dotnet")
47+
if not dotnet:
48+
sys.exit("dotnet SDK not found; install it or set CSC to a csc executable")
49+
sdks = subprocess.run([dotnet, "--list-sdks"], capture_output=True, text=True, check=True).stdout
50+
candidates = []
51+
for line in sdks.splitlines():
52+
match = re.match(r"(\S+) \[(.+)\]", line.strip())
53+
if match:
54+
csc = Path(match.group(2)) / match.group(1) / "Roslyn" / "bincore" / "csc.dll"
55+
if csc.exists():
56+
candidates.append(csc)
57+
if not candidates:
58+
sys.exit("csc.dll not found in any installed .NET SDK")
59+
return [dotnet, str(candidates[-1])]
60+
61+
62+
def compile_unit(csc, name, sources, refs, defines, out_dir, extra=()) -> bool:
63+
out = out_dir / f"{re.sub(r'[^A-Za-z0-9_.]', '_', name)}.dll"
64+
rsp = out_dir / f"{out.stem}.rsp"
65+
args = [
66+
"-nologo", "-noconfig", "-nostdlib+", "-target:library", "-deterministic",
67+
f"-langversion:{LANG_VERSION}", f"-nowarn:{NOWARN}", f"-out:{out}",
68+
"-define:" + ";".join(defines),
69+
*extra,
70+
*[f"-r:{r}" for r in refs],
71+
*[str(s) for s in sources],
72+
]
73+
rsp.write_text("\n".join(f'"{a}"' if " " in a else a for a in args), encoding="utf-8")
74+
result = subprocess.run([*csc, f"@{rsp}"], capture_output=True, text=True)
75+
if result.returncode != 0:
76+
print(f"FAIL {name}")
77+
for line in (result.stdout + result.stderr).splitlines():
78+
if "error" in line:
79+
print(" " + line.replace(str(ROOT) + os.sep, ""))
80+
return False
81+
print(f"ok {name}")
82+
return True
83+
84+
85+
def package_sources(package_root: Path):
86+
"""C# files of the package's main assembly, skipping nested assemblies and tests."""
87+
asm_root = package_root / "InputSystem"
88+
nested = {p.parent for p in asm_root.rglob("*.asmdef") if p.parent != asm_root}
89+
return sorted(
90+
p for p in asm_root.rglob("*.cs")
91+
if not any(n in p.parents for n in nested)
92+
)
93+
94+
95+
def main() -> int:
96+
parser = argparse.ArgumentParser()
97+
parser.add_argument("--unity", required=True, type=Path, help="Unity Editor/Data folder")
98+
parser.add_argument("--inputsystem", required=True, type=Path, help="com.unity.inputsystem package folder")
99+
args = parser.parse_args()
100+
101+
modules = args.unity / "Managed" / "UnityEngine"
102+
netstandard = args.unity / "NetStandard" / "ref" / "2.1.0" / "netstandard.dll"
103+
if not modules.is_dir() or not netstandard.is_file():
104+
sys.exit(f"Unity reference assemblies not found under {args.unity}")
105+
106+
engine_refs = [netstandard, *sorted(modules.glob("UnityEngine*.dll"))]
107+
editor_refs = [*engine_refs, *sorted(modules.glob("UnityEditor*.dll"))]
108+
109+
csc = find_csc()
110+
units = [(f"snippets/csharp/{p.name}", [p]) for p in sorted((ROOT / "snippets" / "csharp").glob("*.cs"))]
111+
units += [
112+
(f"templates/{d.name}", sorted(d.glob("*.cs")))
113+
for d in sorted((ROOT / "templates").iterdir())
114+
if d.is_dir() and any(d.glob("*.cs"))
115+
]
116+
117+
ok = True
118+
with tempfile.TemporaryDirectory() as tmp:
119+
tmp = Path(tmp)
120+
passes = {
121+
"player": (engine_refs, UNITY_DEFINES),
122+
"editor": (editor_refs, [*UNITY_DEFINES, "UNITY_EDITOR", "DEBUG", "TRACE"]),
123+
}
124+
for pass_name, (refs, defines) in passes.items():
125+
out_dir = tmp / pass_name
126+
out_dir.mkdir()
127+
print(f"== {pass_name} pass")
128+
129+
# Build the Input System package as a reference, like Unity would
130+
input_dll = out_dir / "Unity.InputSystem.dll"
131+
if not compile_unit(
132+
csc, "Unity.InputSystem", package_sources(args.inputsystem), refs,
133+
[*defines, "UNITY_INPUT_SYSTEM_ENABLE_PHYSICS", "UNITY_INPUT_SYSTEM_ENABLE_PHYSICS2D"],
134+
out_dir, extra=["-unsafe+", "-nowarn:0618,0108,0114,0162,0219,0414,0618,0672,1717"],
135+
):
136+
return 1
137+
138+
for name, sources in units:
139+
ok &= compile_unit(
140+
csc, name, sources, [*refs, input_dll], defines, out_dir,
141+
extra=["-warnaserror+:CS0618"],
142+
)
143+
return 0 if ok else 1
144+
145+
146+
if __name__ == "__main__":
147+
sys.exit(main())
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
"""Download Unity reference assemblies and the Input System package for compile_csharp.py.
2+
3+
Streams the Linux editor archive (several GB) and keeps only the managed
4+
reference assemblies, so nothing large is written to disk. No Unity license
5+
is needed because the editor is never run.
6+
7+
Usage:
8+
python fetch_unity_refs.py --unity-version 6000.0.84f1 --inputsystem-version 1.20.1 --out .unity-refs
9+
10+
Produces:
11+
<out>/Editor/Data/Managed/UnityEngine/*.dll
12+
<out>/Editor/Data/NetStandard/ref/2.1.0/netstandard.dll
13+
<out>/inputsystem/package/...
14+
"""
15+
16+
import argparse
17+
import io
18+
import json
19+
import lzma
20+
import sys
21+
import tarfile
22+
import urllib.parse
23+
import urllib.request
24+
from pathlib import Path
25+
26+
RELEASES_API = "https://services.api.unity.com/unity/editor/release/v1/releases"
27+
PACKAGES_API = "https://packages.unity.com"
28+
KEEP_PREFIXES = ("Editor/Data/Managed/UnityEngine/", "Editor/Data/NetStandard/ref/2.1.0/")
29+
30+
31+
def editor_url(version: str) -> str:
32+
query = urllib.parse.urlencode({
33+
"version": version,
34+
"platform": "LINUX",
35+
"architecture": "X86_64",
36+
"limit": 25,
37+
})
38+
with urllib.request.urlopen(f"{RELEASES_API}?{query}") as resp:
39+
releases = json.load(resp)["results"]
40+
for release in releases:
41+
if release["version"] == version:
42+
for download in release["downloads"]:
43+
if download["url"].endswith(".tar.xz"):
44+
return download["url"]
45+
sys.exit(f"No Linux editor download found for Unity {version}")
46+
47+
48+
def fetch_editor(version: str, out: Path) -> None:
49+
url = editor_url(version)
50+
print(f"Streaming {url}")
51+
count = 0
52+
with urllib.request.urlopen(url) as resp, lzma.open(resp) as xz, tarfile.open(fileobj=xz, mode="r|") as tar:
53+
for member in tar:
54+
name = member.name.lstrip("./")
55+
if member.isfile() and name.endswith(".dll") and name.startswith(KEEP_PREFIXES):
56+
dest = out / name
57+
dest.parent.mkdir(parents=True, exist_ok=True)
58+
with tar.extractfile(member) as src:
59+
dest.write_bytes(src.read())
60+
count += 1
61+
if count == 0:
62+
sys.exit("No reference assemblies found in the editor archive")
63+
print(f"Extracted {count} assemblies")
64+
65+
66+
def fetch_package(name: str, version: str, out: Path) -> None:
67+
with urllib.request.urlopen(f"{PACKAGES_API}/{name}") as resp:
68+
meta = json.load(resp)
69+
if version not in meta["versions"]:
70+
sys.exit(f"{name} {version} not found in the Unity package registry")
71+
tarball = meta["versions"][version]["dist"]["tarball"]
72+
print(f"Downloading {tarball}")
73+
with urllib.request.urlopen(tarball) as resp:
74+
data = resp.read()
75+
with tarfile.open(fileobj=io.BytesIO(data)) as tar:
76+
tar.extractall(out, filter="data")
77+
78+
79+
def main() -> None:
80+
parser = argparse.ArgumentParser()
81+
parser.add_argument("--unity-version", required=True)
82+
parser.add_argument("--inputsystem-version", required=True)
83+
parser.add_argument("--out", required=True, type=Path)
84+
args = parser.parse_args()
85+
86+
fetch_editor(args.unity_version, args.out)
87+
fetch_package("com.unity.inputsystem", args.inputsystem_version, args.out / "inputsystem")
88+
89+
90+
if __name__ == "__main__":
91+
main()

‎.github/workflows/validate.yml‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -405,3 +405,42 @@ jobs:
405405
406406
- name: Run MCP server tests
407407
run: python -m pytest mcp-server/tests -v
408+
409+
compile-csharp:
410+
name: Compile C# against Unity 6
411+
runs-on: ubuntu-latest
412+
env:
413+
# Bump together when moving to a newer Unity 6 LTS or Input System release
414+
UNITY_VERSION: 6000.0.84f1
415+
INPUTSYSTEM_VERSION: 1.20.1
416+
steps:
417+
- uses: actions/checkout@v6
418+
419+
- uses: actions/setup-python@v6
420+
with:
421+
python-version: "3.12"
422+
423+
- uses: actions/setup-dotnet@v6
424+
with:
425+
dotnet-version: "8.0.x"
426+
427+
- name: Cache Unity reference assemblies
428+
id: unity-refs
429+
uses: actions/cache@v6
430+
with:
431+
path: .unity-refs
432+
key: unity-refs-${{ env.UNITY_VERSION }}-inputsystem-${{ env.INPUTSYSTEM_VERSION }}
433+
434+
- name: Download Unity reference assemblies
435+
if: steps.unity-refs.outputs.cache-hit != 'true'
436+
run: >-
437+
python .github/scripts/fetch_unity_refs.py
438+
--unity-version "$UNITY_VERSION"
439+
--inputsystem-version "$INPUTSYSTEM_VERSION"
440+
--out .unity-refs
441+
442+
- name: Compile snippets and templates (player and editor passes)
443+
run: >-
444+
python .github/scripts/compile_csharp.py
445+
--unity .unity-refs/Editor/Data
446+
--inputsystem .unity-refs/inputsystem/package

‎.gitignore‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,3 +53,6 @@ RESEARCH.md
5353

5454
# DTD#27: release-doc-sync v1 migration verification
5555

56+
57+
# Unity reference assemblies for the C# compile check
58+
.unity-refs/

‎CONTRIBUTING.md‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,15 @@ Keep rules focused; prefer linking to a skill for long workflows.
107107

108108
1. Snippets live under `snippets/` grouped by language (`csharp/`, `shaders/`, `visual-scripting/`). Each file should be self-contained, target Unity 6.x APIs (Awaitable, `FindFirstObjectByType`, UI Toolkit), and free of hardcoded credentials.
109109
2. Templates live under `templates/`. A new template needs at least a top-level `README.md` describing usage, the canonical scripts, and any project setup notes (assembly definitions, package dependencies, scripting defines).
110-
3. Run the validators before opening a PR; CI checks JSON validity, plugin manifest completeness, file count consistency, em/en dash detection, and credential scanning.
110+
3. Editor-only code (`UnityEditor`, custom inspectors, drawers, windows) goes inside `#if UNITY_EDITOR` so the file is safe in any folder.
111+
4. CI compiles every C# snippet (one file at a time) and every template folder against Unity 6 reference assemblies, once as a player build and once as an editor build, with C# 9 and with obsolete APIs treated as errors. To run it locally (needs Python 3.12 and the .NET SDK; the first download streams the Unity editor archive, several GB):
112+
113+
```bash
114+
python .github/scripts/fetch_unity_refs.py --unity-version 6000.0.84f1 --inputsystem-version 1.20.1 --out .unity-refs
115+
python .github/scripts/compile_csharp.py --unity .unity-refs/Editor/Data --inputsystem .unity-refs/inputsystem/package
116+
```
117+
118+
5. Run the validators before opening a PR; CI checks JSON validity, plugin manifest completeness, file count consistency, em/en dash detection, and credential scanning.
111119

112120
## Pull Request Process
113121

‎docs/CONTRIBUTING.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,10 @@ Use conventional commits:
3535

3636
1. Add the file to `snippets/<language>/`
3737
2. Include a header comment explaining what the snippet does and when to use it
38-
3. Update the snippets count in README.md
38+
3. Wrap editor-only code in `#if UNITY_EDITOR`
39+
4. Update the snippets count in README.md
40+
41+
CI compiles every C# snippet and template against Unity 6 reference assemblies (player and editor builds). See the root `CONTRIBUTING.md` for running it locally.
3942

4043
## Adding a Template
4144

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
// Custom Inspector using UI Toolkit
22
// Override the default inspector for a MonoBehaviour or ScriptableObject.
3-
// Place in an Editor/ folder.
3+
// Save as EnemyConfig.cs in a runtime folder (the file name must match the MonoBehaviour).
4+
// The editor class is wrapped in UNITY_EDITOR so it is stripped from player builds.
5+
// For larger projects, move the editor class into its own file under an Editor/ folder instead.
46

7+
using UnityEngine;
8+
#if UNITY_EDITOR
59
using UnityEditor;
610
using UnityEditor.UIElements;
7-
using UnityEngine;
811
using UnityEngine.UIElements;
12+
#endif
913

10-
namespace MyGame.Editor
14+
namespace MyGame
1115
{
1216
// The target component
1317
public class EnemyConfig : MonoBehaviour
@@ -16,8 +20,17 @@ public class EnemyConfig : MonoBehaviour
1620
[SerializeField] [Range(1, 1000)] private int _maxHealth = 100;
1721
[SerializeField] private float _moveSpeed = 3f;
1822
[SerializeField] private Color _debugColor = Color.red;
23+
24+
public string EnemyName => _enemyName;
25+
public int MaxHealth => _maxHealth;
26+
public float MoveSpeed => _moveSpeed;
27+
public Color DebugColor => _debugColor;
1928
}
29+
}
2030

31+
#if UNITY_EDITOR
32+
namespace MyGame.Editor
33+
{
2134
[CustomEditor(typeof(EnemyConfig))]
2235
public class EnemyConfigEditor : UnityEditor.Editor
2336
{
@@ -28,18 +41,19 @@ public override VisualElement CreateInspectorGUI()
2841
// Draw default fields
2942
InspectorElement.FillDefaultInspector(root, serializedObject, this);
3043

31-
// Add a preview section
32-
var previewLabel = new Label("--- Preview ---");
44+
// Add a preview section that follows the serialized value
45+
var previewLabel = new Label("Preview");
3346
previewLabel.style.unityFontStyleAndWeight = FontStyle.Bold;
3447
previewLabel.style.marginTop = 10;
3548
root.Add(previewLabel);
3649

37-
var config = (EnemyConfig)target;
38-
var infoLabel = new Label($"Enemy: {serializedObject.FindProperty("_enemyName").stringValue}");
50+
var nameProperty = serializedObject.FindProperty("_enemyName");
51+
var infoLabel = new Label($"Enemy: {nameProperty.stringValue}");
52+
infoLabel.TrackPropertyValue(nameProperty, p => infoLabel.text = $"Enemy: {p.stringValue}");
3953
root.Add(infoLabel);
4054

4155
// Add a utility button
42-
var testButton = new Button(() => Debug.Log("Testing enemy config..."))
56+
var testButton = new Button(() => Debug.Log($"Testing {((EnemyConfig)target).EnemyName}"))
4357
{
4458
text = "Test Configuration"
4559
};
@@ -50,3 +64,4 @@ public override VisualElement CreateInspectorGUI()
5064
}
5165
}
5266
}
67+
#endif

0 commit comments

Comments
 (0)