Files
AIGame/AIClient/Packages/com.unity.pipeline/Tests~/Editor/IlInterpreterCodeReloadInterpreterTests.cs
T

1068 lines
39 KiB
C#

using System.IO;
using System.Text.RegularExpressions;
using NUnit.Framework;
using Unity.Pipeline.Compilation;
using Unity.Pipeline.CodeReload;
using Unity.Pipeline.Runtime.Commands;
using UnityEngine;
using UnityEngine.TestTools;
namespace Unity.Pipeline.Tests.Editor
{
/// <summary>
/// Top-level probe with a woven [CodeReload] method, reloaded through the <b>interpreter</b> backend.
/// Top-level (not nested) so the generated static override can name it from a separate assembly.
/// </summary>
public class IlInterpreterReloadProbe
{
public int baseline;
public int hotValue;
[CodeReload]
public void Compute()
{
baseline = 1; // original body
}
}
/// <summary>
/// Probe with a PRIVATE field, reloaded through the interpreter. Verifies that a [CodeReload] body can
/// read/write private state now that the override is compiled with access checks disabled and the host
/// binding registers non-public instance members. A public getter exposes the private field for asserts.
/// </summary>
public class IlInterpreterPrivateReloadProbe
{
private int m_Secret;
public int Secret => m_Secret;
[CodeReload]
public void Compute()
{
m_Secret = 1; // original body
}
}
/// <summary>
/// Probe whose reloaded body CALLS a private helper method (not just reads a private field). Mirrors
/// TankAI, whose reloaded SeekUpdate calls the private GetPathLength. The interpreter must resolve the
/// private method token to its registered binding (it looked methods up with BindingFlags.Public only,
/// so private calls hit "Host method not registered" and the whole override fell back).
/// </summary>
public class IlInterpreterPrivateMethodProbe
{
public int result;
private int Helper(int x) => x * 3;
[CodeReload]
public void Compute()
{
result = 0; // original body
}
}
/// <summary>
/// Probe with a PRIVATE nested enum field switched on in a [CodeReload] body — the TankAI pattern
/// (Update switches on a private <c>enum State</c>). The interpreter classified enum host fields as O
/// (boxed), so the switch matched no case and the method silently no-op'd; enum fields must read as
/// their underlying int.
/// </summary>
public class IlInterpreterEnumSwitchProbe
{
private enum Mode { Idle, Run, Stop }
#pragma warning disable CS0414 // read only by the reloaded body, which the compiler cannot see
private Mode m_Mode = Mode.Run;
#pragma warning restore CS0414
public int picked = -1;
[CodeReload]
public void Compute() { picked = -1; } // original body
}
/// <summary>
/// Probe whose reloaded Compute calls another reloaded method (Inner) on the
/// same instance — a host call that woven-dispatches back into the SAME interpreter VM (reentrant).
/// The VM ran every Invoke at frame base 0, so the nested call clobbered Compute's live frame; verify
/// Compute's locals survive the nested call. Mirrors TankAI.Update calling TankAI.SeekUpdate.
/// </summary>
public class IlInterpreterReentrancyProbe
{
public int result;
public int innerRan;
[CodeReload]
public void Inner() { innerRan = -1; } // original body
[CodeReload]
public void Compute() { result = -1; } // original body
}
/// <summary>
/// Probe whose reloaded body constructs a UnityEngine reference type (<c>new NavMeshPath()</c>) — a
/// <c>newobj</c> over a type NOT in the standard binding. Verifies the interpreter registers referenced
/// engine types so the ctor token resolves (otherwise lowering fails "opcode 0x0073 (newobj) not supported").
/// </summary>
public class IlInterpreterNewobjReloadProbe
{
public int made = -1;
[CodeReload]
public void Compute()
{
made = -1; // original body
}
}
/// <summary>
/// Probe whose reloaded body raises IMGUI's control-flow exception (GUIUtility.ExitGUI). The
/// dispatch must PROPAGATE the original exception object — not log-and-fall-back — so a
/// reloaded method behaves like its compiled equivalent (IMGUI catches ExitGUIException
/// by type). The VM wraps it in ScriptRuntimeException with the original as InnerException;
/// the registry unwraps and rethrows it.
/// </summary>
public class IlInterpreterExitGuiProbe
{
public int marker = -1;
[CodeReload]
public void Compute()
{
marker = 0; // original body
}
}
/// <summary>
/// Probe whose reloaded body throws at runtime. Verifies interpreter errors carry a source
/// location ("at line N (File.cs)") resolved from the embedded PDB + the transform's #line
/// directives — not a raw IL offset ("at IL+0x000D"), which users can't act on.
/// </summary>
public class IlInterpreterLineInfoProbe
{
public int made = -1;
[CodeReload]
public void Compute()
{
made = 0; // original body
}
}
/// <summary>
/// Probe with Nullable&lt;T&gt; fields — the TankMovement pattern (its reloaded methods read
/// <c>InputUser.controlScheme.Value.name</c>, an <c>InputControlScheme?</c>). Nullable members
/// are interpreter intrinsics over the boxed-T-or-null representation; before that,
/// <c>Nullable`1.get_Value</c> failed as "not registered in the binding".
/// </summary>
public class IlInterpreterNullableProbe
{
public float? maybeFloat = 2.5f;
public float? emptyFloat = null;
public int? maybeInt = 7;
public int? emptyInt = null;
public float sum;
public int result = -1;
[CodeReload]
public void Compute()
{
result = 0; // original body
}
}
/// <summary>
/// Probe whose reloaded body reads UnityEngine.Random statics — `value` (a float) and
/// `rotationUniform` (a STATIC property returning a flat struct, Quaternion). Repro for
/// "Random.rotationUniform does not code reload".
/// </summary>
public class IlInterpreterRandomProbe
{
public int made = -1;
[CodeReload]
public void Compute()
{
made = 0; // original body
}
}
/// <summary>
/// Probe whose compiled body mutates state; the empty-body reload test replaces it with a no-op.
/// </summary>
public class IlInterpreterEmptyBodyProbe
{
public int computedValue;
[CodeReload]
public void Compute()
{
computedValue = 42; // original body
}
}
/// <summary>
/// Probe for reloads that DECLARE a new method and call it: CompiledHelper is the compiled
/// twin (same body text as the LiveHelper the edited source adds) — its output is the oracle
/// the interpreted, live-added helper must match.
/// </summary>
public class IlInterpreterNewMethodProbe
{
public int result;
public int CompiledHelper(int x)
{
int acc = 0;
for (int i = 1; i <= x; i++)
acc += i * x - 1;
return acc + x / 2;
}
[CodeReload]
public void Compute()
{
result = -1; // original body
}
}
/// <summary>
/// Probe for a reload that adds a new Click method and wires it to a UITK-style callback —
/// a method group over a live-added method (the DebugUIController Button case). The event
/// and Fire() stand in for Button.clicked, which needs a live panel to raise.
/// </summary>
public class IlInterpreterClickProbe
{
public int clicks;
public event System.Action Clicked;
public void Fire() => Clicked?.Invoke();
[CodeReload]
public void Compute()
{
clicks = -1; // original body
}
}
/// <summary>
/// Probe for a reload that adds a NEW [CodeReload]-tagged method: the new method can't register
/// (no woven prologue exists for it) but must not poison the rest of the file's overrides.
/// </summary>
public class IlInterpreterNewTaggedProbe
{
public int result;
[CodeReload]
public void Compute()
{
result = -1; // original body
}
}
/// <summary>
/// End-to-end interpreter code reload: the same reload_file pipeline as the Assembly.Load path but
/// via <c>reload_file_editor_interpreter</c>, so the edited [CodeReload] body is dispatched through the
/// IlInterpreter VM (<c>InterpreterCodeReloadExecutor</c>) rather than reflection. Mirrors
/// <see cref="CodeReloadInPlaceTests"/>'s end-to-end test with the interpreter backend selected.
/// </summary>
class IlInterpreterCodeReloadInterpreterTests
{
[SetUp]
public void Setup() => CodeReloadRegistry.ClearAllForTesting();
[TearDown]
public void TearDown() => CodeReloadRegistry.ClearAllForTesting();
[Test]
public void ReloadFileInterpreter_EditedBody_AppliesViaInterpreterDispatch()
{
// Auto-discovery does this at play start; register the target explicitly for the test.
CodeReloadRegistry.RegisterReloadableMethod(
typeof(IlInterpreterReloadProbe).GetMethod(nameof(IlInterpreterReloadProbe.Compute)),
new CodeReloadAttribute());
var editedSource = @"
using Unity.Pipeline.CodeReload;
namespace Unity.Pipeline.Tests.Editor
{
public class IlInterpreterReloadProbe
{
public int baseline;
public int hotValue;
[CodeReload]
public void Compute()
{
hotValue = 99;
}
}
}";
var path = Path.Combine(Application.temporaryCachePath, "IlInterpreterReloadProbe_edit.cs");
File.WriteAllText(path, editedSource);
try
{
var response = CodeReloadCommands.ReloadFileEditorInterpreter(path);
Assert.IsTrue(response.Success,
$"reload_file_editor_interpreter should succeed. Error: {response.ErrorDetails}");
var probe = new IlInterpreterReloadProbe();
probe.Compute();
Assert.AreEqual(99, probe.hotValue,
"Edited [CodeReload] body should run via the interpreter dispatch");
Assert.AreEqual(0, probe.baseline,
"Original compiled body should be skipped when the interpreter override runs");
}
finally
{
if (File.Exists(path)) File.Delete(path);
}
}
[Test]
public void ReloadFileInterpreter_RandomStatics_DispatchViaInterpreter()
{
CodeReloadRegistry.RegisterReloadableMethod(
typeof(IlInterpreterRandomProbe).GetMethod(nameof(IlInterpreterRandomProbe.Compute)),
new CodeReloadAttribute());
// rotationUniform is a static property returning a flat struct (Quaternion); value is
// a static float property. A unit quaternion's squared magnitude is 1, so made == 2
// proves both statics dispatched with real values (a fallback leaves made == 0).
var editedSource = @"
using Unity.Pipeline.CodeReload;
namespace Unity.Pipeline.Tests.Editor
{
public class IlInterpreterRandomProbe
{
public int made = -1;
[CodeReload]
public void Compute()
{
made = 0;
var v = UnityEngine.Random.value;
if (v >= 0f && v <= 1f) made = made + 1;
var q = UnityEngine.Random.rotationUniform;
float mag = q.x * q.x + q.y * q.y + q.z * q.z + q.w * q.w;
if (mag > 0.99f && mag < 1.01f) made = made + 1;
}
}
}";
var path = Path.Combine(Application.temporaryCachePath, "IlInterpreterRandomProbe_edit.cs");
File.WriteAllText(path, editedSource);
try
{
var response = CodeReloadCommands.ReloadFileEditorInterpreter(path);
Assert.IsTrue(response.Success, $"reload should succeed. Error: {response.ErrorDetails}");
var probe = new IlInterpreterRandomProbe();
probe.Compute();
Assert.AreEqual(2, probe.made,
"Random.value and Random.rotationUniform should both dispatch via the interpreter");
}
finally
{
if (File.Exists(path)) File.Delete(path);
}
}
[Test]
public void ReloadFileInterpreter_EmptyBody_AppliesNoOpOverride()
{
CodeReloadRegistry.RegisterReloadableMethod(
typeof(IlInterpreterEmptyBodyProbe).GetMethod(nameof(IlInterpreterEmptyBodyProbe.Compute)),
new CodeReloadAttribute());
// Emptying a [CodeReload] body is a legitimate edit (e.g. deleting a Debug.Log): the
// reload must bind a no-op override, not fail with "No [CodeReload] methods found"
// (which would leave the previous override live).
var editedSource = @"
using Unity.Pipeline.CodeReload;
namespace Unity.Pipeline.Tests.Editor
{
public class IlInterpreterEmptyBodyProbe
{
public int computedValue;
[CodeReload]
public void Compute()
{
}
}
}";
var path = Path.Combine(Application.temporaryCachePath, "IlInterpreterEmptyBodyProbe_edit.cs");
File.WriteAllText(path, editedSource);
try
{
var response = CodeReloadCommands.ReloadFileEditorInterpreter(path);
Assert.IsTrue(response.Success,
$"reload of an emptied [CodeReload] body should succeed. Error: {response.ErrorDetails}");
var probe = new IlInterpreterEmptyBodyProbe();
probe.Compute();
Assert.AreEqual(0, probe.computedValue,
"Empty override should replace the original body (which sets 42)");
}
finally
{
if (File.Exists(path)) File.Delete(path);
}
}
[Test]
public void ReloadFileInterpreter_PrivateFieldAccess_AppliesViaInterpreterDispatch()
{
CodeReloadRegistry.RegisterReloadableMethod(
typeof(IlInterpreterPrivateReloadProbe).GetMethod(nameof(IlInterpreterPrivateReloadProbe.Compute)),
new CodeReloadAttribute());
var editedSource = @"
using Unity.Pipeline.CodeReload;
namespace Unity.Pipeline.Tests.Editor
{
public class IlInterpreterPrivateReloadProbe
{
private int m_Secret;
public int Secret => m_Secret;
[CodeReload]
public void Compute()
{
m_Secret = 77;
}
}
}";
var path = Path.Combine(Application.temporaryCachePath, "IlInterpreterPrivateReloadProbe_edit.cs");
File.WriteAllText(path, editedSource);
try
{
var response = CodeReloadCommands.ReloadFileEditorInterpreter(path);
Assert.IsTrue(response.Success,
$"reload_file_editor_interpreter should succeed for private field access. Error: {response.ErrorDetails}");
var probe = new IlInterpreterPrivateReloadProbe();
probe.Compute();
Assert.AreEqual(77, probe.Secret,
"Edited [CodeReload] body should write the private field via the interpreter dispatch");
}
finally
{
if (File.Exists(path)) File.Delete(path);
}
}
[Test]
public void ReloadFileInterpreter_PrivateMethodCall_AppliesViaInterpreterDispatch()
{
CodeReloadRegistry.RegisterReloadableMethod(
typeof(IlInterpreterPrivateMethodProbe).GetMethod(nameof(IlInterpreterPrivateMethodProbe.Compute)),
new CodeReloadAttribute());
// If the interpreter can't resolve the private Helper it throws "Host method not registered"
// and falls back (result stays 0); result==39 proves the private call dispatched.
var editedSource = @"
using Unity.Pipeline.CodeReload;
namespace Unity.Pipeline.Tests.Editor
{
public class IlInterpreterPrivateMethodProbe
{
public int result;
private int Helper(int x) => x * 3;
[CodeReload]
public void Compute()
{
result = Helper(13);
}
}
}";
var path = Path.Combine(Application.temporaryCachePath, "IlInterpreterPrivateMethodProbe_edit.cs");
File.WriteAllText(path, editedSource);
try
{
var response = CodeReloadCommands.ReloadFileEditorInterpreter(path);
Assert.IsTrue(response.Success,
$"reload_file_editor_interpreter should succeed for a private method call. Error: {response.ErrorDetails}");
var probe = new IlInterpreterPrivateMethodProbe();
probe.Compute();
Assert.AreEqual(39, probe.result,
"Edited body should call the private Helper via the interpreter dispatch (13*3)");
}
finally
{
if (File.Exists(path)) File.Delete(path);
}
}
[Test]
public void ReloadFileInterpreter_EnumFieldSwitch_DispatchesViaInterpreter()
{
CodeReloadRegistry.RegisterReloadableMethod(
typeof(IlInterpreterEnumSwitchProbe).GetMethod(nameof(IlInterpreterEnumSwitchProbe.Compute)),
new CodeReloadAttribute());
// m_Mode==Run, so case Run must set picked=20. If the enum field reads as a boxed
// enum (the bug), no case matches and picked stays -1.
var editedSource = @"
using Unity.Pipeline.CodeReload;
namespace Unity.Pipeline.Tests.Editor
{
public class IlInterpreterEnumSwitchProbe
{
private enum Mode { Idle, Run, Stop }
private Mode m_Mode = Mode.Run;
public int picked = -1;
[CodeReload]
public void Compute()
{
switch (m_Mode)
{
case Mode.Idle: picked = 10; break;
case Mode.Run: picked = 20; break;
case Mode.Stop: picked = 30; break;
}
}
}
}";
var path = Path.Combine(Application.temporaryCachePath, "IlInterpreterEnumSwitchProbe_edit.cs");
File.WriteAllText(path, editedSource);
try
{
var response = CodeReloadCommands.ReloadFileEditorInterpreter(path);
Assert.IsTrue(response.Success, $"reload should succeed. Error: {response.ErrorDetails}");
var probe = new IlInterpreterEnumSwitchProbe();
probe.Compute();
Assert.AreEqual(20, probe.picked,
"switch on the private enum field should hit case Run via the interpreter");
}
finally { if (File.Exists(path)) File.Delete(path); }
}
[Test]
public void ReloadFileInterpreter_ReentrantCall_PreservesCallerFrame()
{
CodeReloadRegistry.RegisterReloadableMethod(
typeof(IlInterpreterReentrancyProbe).GetMethod(nameof(IlInterpreterReentrancyProbe.Compute)),
new CodeReloadAttribute());
CodeReloadRegistry.RegisterReloadableMethod(
typeof(IlInterpreterReentrancyProbe).GetMethod(nameof(IlInterpreterReentrancyProbe.Inner)),
new CodeReloadAttribute());
// Compute keeps locals a/b live across a call to Inner (itself an interpreter override, invoked
// reentrantly via the host/woven dispatch). Inner uses its own locals; if the reentrant call ran
// at frame base 0 it would clobber a/b and result != 11.
var editedSource = @"
using Unity.Pipeline.CodeReload;
namespace Unity.Pipeline.Tests.Editor
{
public class IlInterpreterReentrancyProbe
{
public int result;
public int innerRan;
[Unity.Pipeline.CodeReload.CodeReload]
public void Inner() { int x = 99; int y = x + 1; innerRan = y; }
[Unity.Pipeline.CodeReload.CodeReload]
public void Compute() { int a = 5; int b = 6; Inner(); result = a + b; }
}
}";
var path = Path.Combine(Application.temporaryCachePath, "IlInterpreterReentrancyProbe_edit.cs");
File.WriteAllText(path, editedSource);
try
{
var response = CodeReloadCommands.ReloadFileEditorInterpreter(path);
Assert.IsTrue(response.Success, $"reload should succeed. Error: {response.ErrorDetails}");
var probe = new IlInterpreterReentrancyProbe();
probe.Compute();
Assert.AreEqual(100, probe.innerRan, "reentrant Inner override should have run");
Assert.AreEqual(11, probe.result,
"caller frame (locals a,b) must survive the reentrant Inner call");
}
finally { if (File.Exists(path)) File.Delete(path); }
}
[Test]
public void ReloadFileInterpreter_NewobjHostType_AppliesViaInterpreterDispatch()
{
CodeReloadRegistry.RegisterReloadableMethod(
typeof(IlInterpreterNewobjReloadProbe).GetMethod(nameof(IlInterpreterNewobjReloadProbe.Compute)),
new CodeReloadAttribute());
// NavMeshPath is not in the standard binding, so the newobj token only resolves if the
// interpreter registers referenced engine types (AllowReferencedTypes). If newobj or the
// instance call fails, the override falls back and `made` stays -1; made==1 proves the
// host-type construction dispatched.
var editedSource = @"
using Unity.Pipeline.CodeReload;
namespace Unity.Pipeline.Tests.Editor
{
public class IlInterpreterNewobjReloadProbe
{
public int made = -1;
[CodeReload]
public void Compute()
{
var p = new UnityEngine.AI.NavMeshPath();
p.ClearCorners();
made = 1;
}
}
}";
var path = Path.Combine(Application.temporaryCachePath, "IlInterpreterNewobjReloadProbe_edit.cs");
File.WriteAllText(path, editedSource);
try
{
var response = CodeReloadCommands.ReloadFileEditorInterpreter(path);
Assert.IsTrue(response.Success,
$"reload_file_editor_interpreter should succeed for `new NavMeshPath()`. Error: {response.ErrorDetails}");
var probe = new IlInterpreterNewobjReloadProbe();
probe.Compute();
Assert.AreEqual(1, probe.made,
"Edited body should construct NavMeshPath (newobj over a host type) and run via the interpreter");
}
finally
{
if (File.Exists(path)) File.Delete(path);
}
}
[Test]
public void ReloadFileInterpreter_NullableMembers_DispatchViaIntrinsics()
{
CodeReloadRegistry.RegisterReloadableMethod(
typeof(IlInterpreterNullableProbe).GetMethod(nameof(IlInterpreterNullableProbe.Compute)),
new CodeReloadAttribute());
// Exercises every Nullable<T> shape the intrinsics model: HasValue, Value (local and
// field receivers), `??` (GetValueOrDefault), a lifted null comparison, GetValueOrDefault
// with and without a fallback, and a T→T? conversion (Nullable ctor via ldloca+call).
var editedSource = @"
using Unity.Pipeline.CodeReload;
namespace Unity.Pipeline.Tests.Editor
{
public class IlInterpreterNullableProbe
{
public float? maybeFloat = 2.5f;
public float? emptyFloat = null;
public int? maybeInt = 7;
public int? emptyInt = null;
public float sum;
public int result = -1;
[CodeReload]
public void Compute()
{
float total = 0f;
var f = maybeFloat;
if (f.HasValue)
total += f.Value; // +2.5
total += emptyFloat ?? 3.0f; // +3.0 (null branch)
float? made = 4.0f; // Nullable ctor
if (made != null) // lifted comparison → HasValue
total += made.Value; // +4.0
sum = total; // 9.5
result = maybeInt.GetValueOrDefault() + emptyInt.GetValueOrDefault(2); // 7 + 2
}
}
}";
var path = Path.Combine(Application.temporaryCachePath, "IlInterpreterNullableProbe_edit.cs");
File.WriteAllText(path, editedSource);
try
{
var response = CodeReloadCommands.ReloadFileEditorInterpreter(path);
Assert.IsTrue(response.Success, $"reload should succeed. Error: {response.ErrorDetails}");
var probe = new IlInterpreterNullableProbe();
probe.Compute();
Assert.AreEqual(9.5f, probe.sum, 0.001f,
"HasValue/Value/??/ctor over float? should all dispatch via the interpreter");
Assert.AreEqual(9, probe.result,
"GetValueOrDefault (0- and 1-arg) over int? should dispatch via the interpreter");
}
finally
{
if (File.Exists(path)) File.Delete(path);
}
}
[Test]
public void ReloadFileInterpreter_RuntimeError_ReportsSourceLineAndFile()
{
CodeReloadRegistry.RegisterReloadableMethod(
typeof(IlInterpreterLineInfoProbe).GetMethod(nameof(IlInterpreterLineInfoProbe.Compute)),
new CodeReloadAttribute());
// The edited body dereferences a null host object on LINE 13 of this source (the
// `made = p.corners.Length;` statement — line 1 is the empty line after the opening
// quote). The interpreter's error must carry that line and the file name, resolved
// from the embedded PDB whose #line directives map the body to the pushed file.
var editedSource = @"
using Unity.Pipeline.CodeReload;
namespace Unity.Pipeline.Tests.Editor
{
public class IlInterpreterLineInfoProbe
{
public int made = -1;
[CodeReload]
public void Compute()
{
UnityEngine.AI.NavMeshPath p = null;
made = p.corners.Length;
}
}
}";
var path = Path.Combine(Application.temporaryCachePath, "IlInterpreterLineInfoProbe_edit.cs");
File.WriteAllText(path, editedSource);
try
{
var response = CodeReloadCommands.ReloadFileEditorInterpreter(path);
Assert.IsTrue(response.Success, $"reload should succeed. Error: {response.ErrorDetails}");
// The dispatch logs the interpreter error and falls back to the original body.
LogAssert.Expect(LogType.Error, new Regex(
@"CodeReload: Error invoking override for 'IlInterpreterLineInfoProbe\.Compute':.*" +
@"NullReferenceException.* at line 13 \(IlInterpreterLineInfoProbe_edit\.cs\)"));
LogAssert.Expect(LogType.Error, new Regex("CodeReload: Stack trace:"));
var probe = new IlInterpreterLineInfoProbe();
probe.Compute();
Assert.AreEqual(0, probe.made,
"After the override throws, the original body should run as the fallback");
}
finally
{
if (File.Exists(path)) File.Delete(path);
}
}
[Test]
public void ReloadFileInterpreter_RuntimeError_WithLineInfoDisabled_ReportsIlOffset()
{
CodeReloadRegistry.RegisterReloadableMethod(
typeof(IlInterpreterLineInfoProbe).GetMethod(nameof(IlInterpreterLineInfoProbe.Compute)),
new CodeReloadAttribute());
// Same failing body as the line-info test, but with the "Error Line Info" setting off:
// no embedded PDB is shipped, so the error falls back to the raw IL offset.
var editedSource = @"
using Unity.Pipeline.CodeReload;
namespace Unity.Pipeline.Tests.Editor
{
public class IlInterpreterLineInfoProbe
{
public int made = -1;
[CodeReload]
public void Compute()
{
UnityEngine.AI.NavMeshPath p = null;
made = p.corners.Length;
}
}
}";
var path = Path.Combine(Application.temporaryCachePath, "IlInterpreterLineInfoProbe_noinfo_edit.cs");
File.WriteAllText(path, editedSource);
var previous = CodeReloadCompiler.EmitSourceLineInfo;
CodeReloadCompiler.EmitSourceLineInfo = false;
try
{
var response = CodeReloadCommands.ReloadFileEditorInterpreter(path);
Assert.IsTrue(response.Success, $"reload should succeed. Error: {response.ErrorDetails}");
LogAssert.Expect(LogType.Error, new Regex(
@"CodeReload: Error invoking override for 'IlInterpreterLineInfoProbe\.Compute':.*" +
@"NullReferenceException.* at IL\+0x"));
LogAssert.Expect(LogType.Error, new Regex("CodeReload: Stack trace:"));
var probe = new IlInterpreterLineInfoProbe();
probe.Compute();
Assert.AreEqual(0, probe.made,
"After the override throws, the original body should run as the fallback");
}
finally
{
CodeReloadCompiler.EmitSourceLineInfo = previous;
if (File.Exists(path)) File.Delete(path);
}
}
[Test]
public void ReloadFileInterpreter_OverrideThrows_PropagatesOriginalException()
{
CodeReloadRegistry.RegisterReloadableMethod(
typeof(IlInterpreterExitGuiProbe).GetMethod(nameof(IlInterpreterExitGuiProbe.Compute)),
new CodeReloadAttribute());
var editedSource = @"
using Unity.Pipeline.CodeReload;
namespace Unity.Pipeline.Tests.Editor
{
public class IlInterpreterExitGuiProbe
{
public int marker = -1;
[CodeReload]
public void Compute()
{
marker = 42;
UnityEngine.GUIUtility.ExitGUI();
}
}
}";
var path = UnityEditor.FileUtil.GetUniqueTempPathInProject() + ".cs";
File.WriteAllText(path, editedSource);
try
{
var response = CodeReloadCommands.ReloadFileEditorInterpreter(path);
Assert.IsTrue(response.Success, $"reload should succeed. Error: {response.ErrorDetails}");
var probe = new IlInterpreterExitGuiProbe();
Assert.Throws<ExitGUIException>(() => probe.Compute(),
"The override body's exception must propagate with its type intact (IMGUI " +
"catches ExitGUIException by type), not be swallowed into a fallback");
Assert.AreEqual(42, probe.marker,
"The override ran up to the throw, and the original body (marker = 0) must " +
"NOT run as a fallback");
}
finally
{
if (File.Exists(path)) File.Delete(path);
}
}
[Test]
public void ReloadFileInterpreter_NewHelperMethod_MatchesCompiledTwin()
{
CodeReloadRegistry.RegisterReloadableMethod(
typeof(IlInterpreterNewMethodProbe).GetMethod(nameof(IlInterpreterNewMethodProbe.Compute)),
new CodeReloadAttribute());
// LiveHelper does not exist on the compiled type: the transform must co-emit it into
// the override class so the changed Compute reaches it via call_script. Same body text
// as CompiledHelper, so the compiled twin's output is the oracle.
var editedSource = @"
using Unity.Pipeline.CodeReload;
namespace Unity.Pipeline.Tests.Editor
{
public class IlInterpreterNewMethodProbe
{
public int result;
public int CompiledHelper(int x)
{
int acc = 0;
for (int i = 1; i <= x; i++)
acc += i * x - 1;
return acc + x / 2;
}
[CodeReload]
public void Compute()
{
result = LiveHelper(9);
}
int LiveHelper(int x)
{
int acc = 0;
for (int i = 1; i <= x; i++)
acc += i * x - 1;
return acc + x / 2;
}
}
}";
var path = Path.Combine(Application.temporaryCachePath, "IlInterpreterNewMethodProbe_edit.cs");
File.WriteAllText(path, editedSource);
try
{
var response = CodeReloadCommands.ReloadFileEditorInterpreter(path);
Assert.IsTrue(response.Success,
$"reload with a live-added helper should succeed. Error: {response.ErrorDetails}");
var probe = new IlInterpreterNewMethodProbe();
probe.Compute();
Assert.AreEqual(probe.CompiledHelper(9), probe.result,
"The live-added LiveHelper must produce the same output as its compiled twin");
}
finally
{
if (File.Exists(path)) File.Delete(path);
}
}
[Test]
public void ReloadFileInterpreter_NewClickMethodWiredToCallback_Fires()
{
CodeReloadRegistry.RegisterReloadableMethod(
typeof(IlInterpreterClickProbe).GetMethod(nameof(IlInterpreterClickProbe.Compute)),
new CodeReloadAttribute());
// Click does not exist compiled; the reloaded body subscribes it as a method group.
// The transform wraps it in a lambda over the co-emitted static, and the interpreter
// creates a real Action that re-enters the VM when the host fires it.
var editedSource = @"
using Unity.Pipeline.CodeReload;
namespace Unity.Pipeline.Tests.Editor
{
public class IlInterpreterClickProbe
{
public int clicks;
public event System.Action Clicked;
public void Fire() => Clicked?.Invoke();
[CodeReload]
public void Compute()
{
clicks = 0;
Clicked += Click;
}
void Click()
{
clicks = clicks + 1;
}
}
}";
var path = Path.Combine(Application.temporaryCachePath, "IlInterpreterClickProbe_edit.cs");
File.WriteAllText(path, editedSource);
try
{
var response = CodeReloadCommands.ReloadFileEditorInterpreter(path);
Assert.IsTrue(response.Success,
$"reload wiring a live-added Click method should succeed. Error: {response.ErrorDetails}");
var probe = new IlInterpreterClickProbe();
probe.Compute();
probe.Fire();
probe.Fire();
Assert.AreEqual(2, probe.clicks,
"The live-added Click must run interpreted each time the host raises the event");
}
finally
{
if (File.Exists(path)) File.Delete(path);
}
}
[Test]
public void ReloadFileInterpreter_NewTaggedMethod_DoesNotPoisonFile()
{
CodeReloadRegistry.RegisterReloadableMethod(
typeof(IlInterpreterNewTaggedProbe).GetMethod(nameof(IlInterpreterNewTaggedProbe.Compute)),
new CodeReloadAttribute());
// `Added` is a NEW [CodeReload] method, declared FIRST so it is names[0] at registration.
// It has no woven prologue and can't register — but Compute's override must still apply.
var editedSource = @"
using Unity.Pipeline.CodeReload;
namespace Unity.Pipeline.Tests.Editor
{
public class IlInterpreterNewTaggedProbe
{
public int result;
[CodeReload]
public void Added()
{
result = 1000;
}
[CodeReload]
public void Compute()
{
result = 7;
}
}
}";
var path = Path.Combine(Application.temporaryCachePath, "IlInterpreterNewTaggedProbe_edit.cs");
File.WriteAllText(path, editedSource);
try
{
var response = CodeReloadCommands.ReloadFileEditorInterpreter(path);
Assert.IsTrue(response.Success,
$"reload should succeed with the new method skipped, not poison the file. Error: {response.ErrorDetails}");
var probe = new IlInterpreterNewTaggedProbe();
probe.Compute();
Assert.AreEqual(7, probe.result,
"Compute's override must register even though the new 'Added' method can't");
}
finally
{
if (File.Exists(path)) File.Delete(path);
}
}
[Test]
public void ReloadFileInterpreter_HostValueTypeArray_AppliesViaInterpreterDispatch()
{
CodeReloadRegistry.RegisterReloadableMethod(
typeof(IlInterpreterNewobjReloadProbe).GetMethod(nameof(IlInterpreterNewobjReloadProbe.Compute)),
new CodeReloadAttribute());
// NavMeshPath.corners is a Vector3[] — a host-returned VALUE-TYPE array (not the interpreter's
// object?[]). Reading .Length exercises ldlen over a real typed array, which used to throw
// InvalidCastException (the (object?[]) cast fails for value-type arrays). An empty path → 0.
var editedSource = @"
using Unity.Pipeline.CodeReload;
namespace Unity.Pipeline.Tests.Editor
{
public class IlInterpreterNewobjReloadProbe
{
public int made = -1;
[CodeReload]
public void Compute()
{
var p = new UnityEngine.AI.NavMeshPath();
made = p.corners.Length;
}
}
}";
var path = Path.Combine(Application.temporaryCachePath, "IlInterpreterVtArrayProbe_edit.cs");
File.WriteAllText(path, editedSource);
try
{
var response = CodeReloadCommands.ReloadFileEditorInterpreter(path);
Assert.IsTrue(response.Success,
$"reload_file_editor_interpreter should succeed for a host Vector3[] read. Error: {response.ErrorDetails}");
var probe = new IlInterpreterNewobjReloadProbe();
probe.Compute();
Assert.AreEqual(0, probe.made,
"ldlen over a host Vector3[] should run via the interpreter (empty NavMeshPath → 0 corners)");
}
finally
{
if (File.Exists(path)) File.Delete(path);
}
}
}
}