Initial commit: AIGame Unity client with URP, HybridCLR, YooAsset and tooling.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,455 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using Unity.Pipeline.Commands;
|
||||
using Unity.Pipeline.Compilation;
|
||||
using Unity.Pipeline.CodeReload;
|
||||
using Unity.Pipeline.Models;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unity.Pipeline.Runtime.Commands
|
||||
{
|
||||
/// <summary>
|
||||
/// CLI commands for code reload operations: apply [CodeReload] edits from a source file, inspect
|
||||
/// the registry, and clean up saved reload assemblies.
|
||||
/// </summary>
|
||||
static class CodeReloadCommands
|
||||
{
|
||||
[CliCommand("reload_file", "Compile and apply in-place [CodeReload] edits from a source file", MainThreadRequired = true, Tags = new[] { "scripts/codereload" })]
|
||||
public static CodeReloadResponse ReloadFile(
|
||||
[CliArg("filename", "Source file containing [CodeReload] methods (e.g. Assets/Scripts/Player.cs)", Required = true)] string filename,
|
||||
[CliArg("timeout", "Compilation timeout in milliseconds")] int timeout = 30000,
|
||||
[CliArg("assemblyDir", "Directory to save compiled assemblies to disk (optional, default is in-memory only)")] string assemblyDir = null,
|
||||
[CliArg("pdb", "Emit debug symbols (portable PDB) mapped to the original source so breakpoints bind in your editor. Compiles unoptimized.")] bool pdb = false)
|
||||
{
|
||||
return ReloadFileCore("reload_file", filename, timeout, assemblyDir, pdb, useInterpreter: false);
|
||||
}
|
||||
|
||||
[CliCommand("reload_file_editor_interpreter", "Compile in-place [CodeReload] edits and run them through the IlInterpreter VM in this process instead of Assembly.Load (IL2CPP-safe; only a minimal host API + the target type are available)", MainThreadRequired = true, Tags = new[] { "scripts/codereload" })]
|
||||
public static CodeReloadResponse ReloadFileEditorInterpreter(
|
||||
[CliArg("filename", "Source file containing [CodeReload] methods (e.g. Assets/Scripts/Player.cs)", Required = true)] string filename,
|
||||
[CliArg("timeout", "Compilation timeout in milliseconds")] int timeout = 30000,
|
||||
[CliArg("assemblyDir", "Directory to save compiled assemblies to disk (optional, default is in-memory only)")] string assemblyDir = null,
|
||||
[CliArg("pdb", "Emit debug symbols (portable PDB) mapped to the original source so breakpoints bind in your editor. Compiles unoptimized.")] bool pdb = false)
|
||||
{
|
||||
return ReloadFileCore("reload_file_editor_interpreter", filename, timeout, assemblyDir, pdb, useInterpreter: true);
|
||||
}
|
||||
|
||||
/// <summary>Shared body of <c>reload_file</c> and <c>reload_file_editor_interpreter</c>:
|
||||
/// the two commands differ only in the backend the reloaded methods run on.</summary>
|
||||
private static CodeReloadResponse ReloadFileCore(string commandName, string filename, int timeout, string assemblyDir, bool pdb, bool useInterpreter)
|
||||
{
|
||||
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(filename))
|
||||
{
|
||||
stopwatch.Stop();
|
||||
return CodeReloadResponse.CmdFailure(
|
||||
"Bad Request",
|
||||
"Filename parameter is required and cannot be empty",
|
||||
stopwatch.ElapsedMilliseconds);
|
||||
}
|
||||
|
||||
if (!filename.EndsWith(".cs", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
filename += ".cs";
|
||||
}
|
||||
|
||||
var fullPath = ResolveSourceFilePath(filename);
|
||||
if (string.IsNullOrEmpty(fullPath))
|
||||
{
|
||||
stopwatch.Stop();
|
||||
return CodeReloadResponse.CmdFailure(
|
||||
"File Not Found",
|
||||
$"Could not locate source file: {filename}",
|
||||
stopwatch.ElapsedMilliseconds);
|
||||
}
|
||||
|
||||
// In a Player build, the file must be inside the project's build-baked roots. The
|
||||
// project layout cannot be resolved from a running build, so this is skipped in the
|
||||
// editor (which resolves files against the live project).
|
||||
if (!Application.isEditor)
|
||||
{
|
||||
var scopeCheck = ValidateReloadPath(fullPath, CodeReloadRegistry.AllowedReloadRoots);
|
||||
if (!scopeCheck.IsValid)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
return CodeReloadResponse.CmdFailure(
|
||||
scopeCheck.Error,
|
||||
scopeCheck.ErrorDetails,
|
||||
stopwatch.ElapsedMilliseconds);
|
||||
}
|
||||
}
|
||||
|
||||
Debug.Log($"CodeReload: Executing {commandName} command: {fullPath}, timeout: {timeout}ms, assemblyDir: {assemblyDir ?? "in-memory"}, pdb: {pdb}, interpreter: {useInterpreter}");
|
||||
|
||||
var task = InPlaceReloadProcessor.ProcessSourceFileAsync(fullPath, assemblyDir, pdb, useInterpreter);
|
||||
task.Wait();
|
||||
var result = task.Result;
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
if (result.Success)
|
||||
{
|
||||
// Everything matches the compiled baseline: an honest no-op success, not
|
||||
// "reload successful with 0 methods" against a null assembly name. Reverting
|
||||
// stale overrides (the edit was undone) is part of the outcome — say so
|
||||
// instead of unregistering silently.
|
||||
if (result.AllUpToDate)
|
||||
{
|
||||
var upToDate = CodeReloadResponse.CmdSuccess(
|
||||
string.Empty,
|
||||
$"Up to date: all {result.UpToDateMethods.Count} [CodeReload] method(s) match the compiled baseline — nothing reloaded" +
|
||||
(result.RevertedMethods.Count > 0
|
||||
? $"; reverted {result.RevertedMethods.Count} stale override(s): {string.Join(", ", result.RevertedMethods)}"
|
||||
: "") + ".",
|
||||
result.RegisteredMethods,
|
||||
stopwatch.ElapsedMilliseconds);
|
||||
upToDate.Diagnostics = result.CompilationDiagnostics;
|
||||
return upToDate;
|
||||
}
|
||||
|
||||
// "Compiled OK but bound nothing" is a failed reload in practice: the compile
|
||||
// succeeded but every method was skipped downstream (e.g. an interpreter-unsupported
|
||||
// construct, or no RuntimePipelineDriver registered the target). Surface the
|
||||
// reasons from CompilationDiagnostics as a failure instead of a silent success.
|
||||
if (result.RegisteredMethods.Count == 0 && result.ExtractedMethods.Count > 0)
|
||||
{
|
||||
var details = result.CompilationDiagnostics != null && result.CompilationDiagnostics.Count > 0
|
||||
? "Compiled, but no methods were applied:\n- " + string.Join("\n- ", result.CompilationDiagnostics)
|
||||
: "Compiled, but no methods were applied. Is the target component in the scene " +
|
||||
"and in play mode (with the runtime Pipeline driver active)?";
|
||||
return CodeReloadResponse.CmdFailure(
|
||||
"No Methods Applied",
|
||||
details,
|
||||
stopwatch.ElapsedMilliseconds,
|
||||
result.CompilationDiagnostics);
|
||||
}
|
||||
|
||||
CodeReloadRegistry.InvokeReloadCallbacks(result.RegisteredMethods);
|
||||
|
||||
var response = CodeReloadResponse.CmdSuccess(
|
||||
result.AssemblyName,
|
||||
$"In-place code reload successful: {result.AssemblyName} with {result.RegisteredMethods.Count} methods" +
|
||||
(result.RevertedMethods.Count > 0
|
||||
? $"; reverted {result.RevertedMethods.Count} stale override(s): {string.Join(", ", result.RevertedMethods)}"
|
||||
: ""),
|
||||
result.RegisteredMethods,
|
||||
stopwatch.ElapsedMilliseconds);
|
||||
// Surface partial-skip reasons (some methods bound, others skipped) on success too.
|
||||
response.Diagnostics = result.CompilationDiagnostics;
|
||||
return response;
|
||||
}
|
||||
|
||||
return CodeReloadResponse.CmdFailure(
|
||||
"In-Place Reload Failed",
|
||||
result.ErrorMessage ?? "In-place code reload processing failed",
|
||||
stopwatch.ElapsedMilliseconds,
|
||||
result.CompilationDiagnostics);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
Debug.LogError($"CodeReload: {commandName} command failed: {ex.Message}");
|
||||
Debug.LogError($"CodeReload: Stack trace: {ex.StackTrace}");
|
||||
|
||||
return CodeReloadResponse.CmdFailure(
|
||||
"Execution Failed",
|
||||
ex.ToString(),
|
||||
stopwatch.ElapsedMilliseconds);
|
||||
}
|
||||
}
|
||||
|
||||
[CliCommand("cleanup_codereload", "Remove old code reload DLL versions and clear registry", MainThreadRequired = true, RuntimeOnly = true, Tags = new[] { "scripts/codereload" })]
|
||||
public static CodeReloadResponse CleanupCodeReload(
|
||||
[CliArg("assemblyDir", "Directory containing assemblies to cleanup", Required = true)] string assemblyDir,
|
||||
[CliArg("force_domain_reload", "Force Unity domain reload after cleanup")] bool forceDomainReload = true)
|
||||
{
|
||||
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
Debug.Log($"CodeReload: Executing cleanup_codereload command for directory: {assemblyDir}");
|
||||
|
||||
// Execute cleanup
|
||||
var cleanupResult = CodeReloadCompiler.CleanupCodeReloadDlls(assemblyDir);
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
if (cleanupResult.Success)
|
||||
{
|
||||
var message = $"Cleanup successful: {cleanupResult.Message}";
|
||||
if (cleanupResult.DeletedFiles.Count > 0)
|
||||
{
|
||||
message += $" Files removed: {string.Join(", ", cleanupResult.DeletedFiles)}";
|
||||
}
|
||||
|
||||
// Force domain reload if requested (interrupts gameplay but ensures clean state)
|
||||
if (forceDomainReload && Application.isEditor)
|
||||
{
|
||||
#if UNITY_EDITOR
|
||||
Debug.Log("CodeReload: Requesting Unity domain reload to clean memory state");
|
||||
UnityEditor.EditorUtility.RequestScriptReload();
|
||||
message += " Unity domain reload requested.";
|
||||
#endif
|
||||
}
|
||||
|
||||
Debug.Log($"CodeReload: cleanup_codereload completed successfully in {stopwatch.ElapsedMilliseconds}ms");
|
||||
|
||||
return CodeReloadResponse.CmdSuccess(
|
||||
"cleanup_completed",
|
||||
message,
|
||||
cleanupResult.DeletedFiles,
|
||||
stopwatch.ElapsedMilliseconds);
|
||||
}
|
||||
else
|
||||
{
|
||||
return CodeReloadResponse.CmdFailure(
|
||||
"Cleanup Failed",
|
||||
cleanupResult.Message,
|
||||
stopwatch.ElapsedMilliseconds);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
Debug.LogError($"CodeReload: cleanup_codereload command failed: {ex.Message}");
|
||||
|
||||
return CodeReloadResponse.CmdFailure(
|
||||
"Execution Failed",
|
||||
ex.ToString(),
|
||||
stopwatch.ElapsedMilliseconds);
|
||||
}
|
||||
}
|
||||
|
||||
[CliCommand("codereload_status", "Show current code reload registry status and statistics", MainThreadRequired = true, RuntimeOnly = true, Tags = new[] { "scripts/codereload" })]
|
||||
public static CodeReloadResponse CodeReloadStatus()
|
||||
{
|
||||
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
// Get current registry stats
|
||||
var stats = CodeReloadRegistry.GetStats();
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
var statusMessage = $"Code Reload Status - " +
|
||||
$"Reloadable Methods: {stats.ReloadableMethodCount}, " +
|
||||
$"Active Overrides: {stats.ActiveOverrideCount}, " +
|
||||
$"Loaded Types: {stats.LoadedTypeCount}";
|
||||
|
||||
Debug.Log($"CodeReload: {statusMessage}");
|
||||
|
||||
return CodeReloadResponse.CmdSuccess(
|
||||
"status_retrieved",
|
||||
statusMessage,
|
||||
stats.ActiveOverrideIds,
|
||||
stopwatch.ElapsedMilliseconds);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
Debug.LogError($"CodeReload: codereload_status command failed: {ex.Message}");
|
||||
|
||||
return CodeReloadResponse.CmdFailure(
|
||||
"Execution Failed",
|
||||
ex.ToString(),
|
||||
stopwatch.ElapsedMilliseconds);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validate that a resolved file path lives inside one of the allowed project roots and
|
||||
/// exists on disk. "In scope" means under the Assets folder or a loaded package's location.
|
||||
/// The path is normalized (collapsing any <c>..</c> segments) before the scope check, so a
|
||||
/// path that escapes the allowed roots via traversal is rejected. A null or empty root set
|
||||
/// matches nothing, so the path is rejected as out of scope. This is a security boundary —
|
||||
/// it prevents files outside the project from being compiled and injected into the assembly.
|
||||
/// </summary>
|
||||
public static PathValidationResult ValidateReloadPath(string resolvedPath, System.Collections.Generic.IReadOnlyCollection<string> allowedRoots)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(resolvedPath))
|
||||
{
|
||||
return PathValidationResult.Invalid("Bad Request", "Resolved file path is empty.");
|
||||
}
|
||||
|
||||
string fullPath;
|
||||
try
|
||||
{
|
||||
fullPath = Path.GetFullPath(resolvedPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return PathValidationResult.Invalid("Bad Request", $"Invalid path: {ex.Message}");
|
||||
}
|
||||
|
||||
var underAnyRoot = false;
|
||||
if (allowedRoots != null)
|
||||
{
|
||||
foreach (var root in allowedRoots)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(root))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string fullRoot;
|
||||
try
|
||||
{
|
||||
fullRoot = Path.GetFullPath(root);
|
||||
}
|
||||
catch
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (IsUnderDirectory(fullPath, fullRoot))
|
||||
{
|
||||
underAnyRoot = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!underAnyRoot)
|
||||
{
|
||||
return PathValidationResult.Invalid(
|
||||
"Out Of Project Scope",
|
||||
$"Code reload only accepts files inside the project's baked roots (Assets/ or a loaded " +
|
||||
$"package). '{fullPath}' is outside the project scope and will not be compiled.");
|
||||
}
|
||||
|
||||
if (!File.Exists(fullPath))
|
||||
{
|
||||
return PathValidationResult.Invalid("File Not Found", $"Source file not found: {fullPath}");
|
||||
}
|
||||
|
||||
return PathValidationResult.Valid();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when <paramref name="path"/> is contained within <paramref name="directory"/>.
|
||||
/// Both are expected to be normalized absolute paths. A trailing separator is appended to
|
||||
/// the directory so that a sibling such as "AssetsExtra" is not treated as being under "Assets".
|
||||
/// </summary>
|
||||
private static bool IsUnderDirectory(string path, string directory)
|
||||
{
|
||||
var prefix = directory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
|
||||
+ Path.DirectorySeparatorChar;
|
||||
|
||||
// Windows file systems are case-insensitive; Unix file systems are case-sensitive.
|
||||
var comparison = Path.DirectorySeparatorChar == '\\'
|
||||
? StringComparison.OrdinalIgnoreCase
|
||||
: StringComparison.Ordinal;
|
||||
|
||||
return path.StartsWith(prefix, comparison);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve source file path from various possible locations.
|
||||
/// </summary>
|
||||
private static string ResolveSourceFilePath(string filename)
|
||||
{
|
||||
if (Path.IsPathRooted(filename) && File.Exists(filename))
|
||||
{
|
||||
return filename;
|
||||
}
|
||||
|
||||
var potentialPaths = new[]
|
||||
{
|
||||
Path.Combine("Assets", filename),
|
||||
Path.Combine("Assets", "Scripts", filename),
|
||||
filename // Project root
|
||||
};
|
||||
|
||||
foreach (var path in potentialPaths)
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
return Path.GetFullPath(path);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response model for code reload CLI commands.
|
||||
/// Provides consistent response format for all code reload operations.
|
||||
/// </summary>
|
||||
class CodeReloadResponse : CommandExecutionResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Assembly name or operation identifier for successful operations.
|
||||
/// </summary>
|
||||
public string AssemblyName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// List of registered method IDs or files processed.
|
||||
/// </summary>
|
||||
public System.Collections.Generic.List<string> Items { get; set; } = new System.Collections.Generic.List<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Compilation or processing diagnostics.
|
||||
/// </summary>
|
||||
public System.Collections.Generic.List<string> Diagnostics { get; set; } = new System.Collections.Generic.List<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Create a successful code reload response.
|
||||
/// </summary>
|
||||
public static CodeReloadResponse CmdSuccess(string assemblyName, string message, System.Collections.Generic.List<string> items, long executionTimeMs)
|
||||
{
|
||||
return new CodeReloadResponse
|
||||
{
|
||||
Success = true,
|
||||
AssemblyName = assemblyName,
|
||||
Message = message,
|
||||
Items = items ?? new System.Collections.Generic.List<string>(),
|
||||
ExecutionTimeMs = executionTimeMs,
|
||||
ExecutedAt = DateTime.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a failed code reload response.
|
||||
/// </summary>
|
||||
public static CodeReloadResponse CmdFailure(string error, string errorDetails, long executionTimeMs, System.Collections.Generic.List<string> diagnostics = null)
|
||||
{
|
||||
return new CodeReloadResponse
|
||||
{
|
||||
Success = false,
|
||||
Error = error,
|
||||
ErrorDetails = errorDetails,
|
||||
ExecutionTimeMs = executionTimeMs,
|
||||
Diagnostics = diagnostics ?? new System.Collections.Generic.List<string>(),
|
||||
ExecutedAt = DateTime.UtcNow
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of validating a code reload source path. <see cref="Error"/> is a short category and
|
||||
/// <see cref="ErrorDetails"/> the human-readable explanation, matching the shape consumed by
|
||||
/// <see cref="CodeReloadResponse.CmdFailure"/>.
|
||||
/// </summary>
|
||||
class PathValidationResult
|
||||
{
|
||||
public bool IsValid { get; private set; }
|
||||
public string Error { get; private set; }
|
||||
public string ErrorDetails { get; private set; }
|
||||
|
||||
public static PathValidationResult Valid()
|
||||
{
|
||||
return new PathValidationResult { IsValid = true };
|
||||
}
|
||||
|
||||
public static PathValidationResult Invalid(string error, string errorDetails)
|
||||
{
|
||||
return new PathValidationResult { IsValid = false, Error = error, ErrorDetails = errorDetails };
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user