diff --git a/Assets/Tests/Editor/PausePointTests.cs b/Assets/Tests/Editor/PausePointTests.cs index b9285d962d..6338740356 100644 --- a/Assets/Tests/Editor/PausePointTests.cs +++ b/Assets/Tests/Editor/PausePointTests.cs @@ -378,6 +378,63 @@ public async Task Clear_WhenIdIsEmptyAndAllIsFalse_ReturnsValidationFailureRespo Assert.That(response.Message, Is.EqualTo("Id must not be null or empty.")); } + [Test] + public void HitWithCapturedVariables_WhenPausePointIsEnabled_StoresCapturedVariablesInSnapshot() + { + // Verifies the source-pause-point hit path threads captured variables through to the snapshot. + UloopPausePointRegistry.Enable("jump", 30); + UloopCapturedVariable[] capturedVariables = + { + new("speed", UloopCapturedVariableScope.Local, "System.Int32", "5", string.Empty, string.Empty, 0) + }; + + UloopPausePointSnapshot snapshot = UloopPausePointRegistry.HitWithCapturedVariables( + "jump", capturedVariables, true); + + Assert.That(snapshot.CapturedVariables, Is.EqualTo(capturedVariables)); + Assert.That(snapshot.CapturedVariablesTruncated, Is.True); + Assert.That(_pauseController.PauseCount, Is.EqualTo(1)); + } + + [Test] + public void Hit_WhenPausePointIsEnabled_ReportsEmptyCapturedVariables() + { + // Verifies the plain marker path (no source pause point) reports an empty, non-null list. + UloopPausePointRegistry.Enable("jump", 30); + + UloopPausePoint.Pause("jump"); + + UloopPausePointSnapshot snapshot = UloopPausePointRegistry.GetStatus("jump"); + Assert.That(snapshot.CapturedVariables, Is.Empty); + Assert.That(snapshot.CapturedVariablesTruncated, Is.False); + } + + [Test] + public void IsArmed_WhenPausePointIsEnabled_ReturnsTrue() + { + // Verifies the injected Capture code's fast path recognizes an armed marker. + UloopPausePointRegistry.Enable("jump", 30); + + Assert.That(UloopPausePointRegistry.IsArmed("jump"), Is.True); + } + + [Test] + public void IsArmed_WhenPausePointIsNotEnabled_ReturnsFalse() + { + // Verifies the injected Capture code's fast path no-ops for an id that was never enabled. + Assert.That(UloopPausePointRegistry.IsArmed("jump"), Is.False); + } + + [Test] + public void IsArmed_WhenPausePointWasAlreadyHit_ReturnsFalse() + { + // Verifies a one-shot marker disarms itself so a second pass through the same line no-ops. + UloopPausePointRegistry.Enable("jump", 30); + UloopPausePoint.Pause("jump"); + + Assert.That(UloopPausePointRegistry.IsArmed("jump"), Is.False); + } + [Test] public void PauseMethod_WhenSourceIsScanned_UsesUnityEditorConditionalWithoutDebugBreak() { diff --git a/Assets/Tests/Editor/SourcePausePointCapture.meta b/Assets/Tests/Editor/SourcePausePointCapture.meta new file mode 100644 index 0000000000..62983de051 --- /dev/null +++ b/Assets/Tests/Editor/SourcePausePointCapture.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 52fc3ba85d1f42d5b6b3b8dc71c0b959 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/SourcePausePointCapture/SourcePausePointCaptureTests.cs b/Assets/Tests/Editor/SourcePausePointCapture/SourcePausePointCaptureTests.cs new file mode 100644 index 0000000000..403d2528fb --- /dev/null +++ b/Assets/Tests/Editor/SourcePausePointCapture/SourcePausePointCaptureTests.cs @@ -0,0 +1,115 @@ +using System; +using System.Collections; +using System.Linq; +using System.Threading.Tasks; + +using NUnit.Framework; + +using UnityEngine; +using UnityEngine.TestTools; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; +using io.github.hatayama.UnityCliLoop.Runtime; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Verifies the Harmony-injected landing point: the armed fast-path no-op and the + /// formatted-variables handoff into the registry. + /// + [TestFixture] + public sealed class SourcePausePointCaptureTests + { + private FakePausePointPauseController _pauseController; + + [SetUp] + public void SetUp() + { + _pauseController = new FakePausePointPauseController(); + UloopPausePointRegistry.ConfigureForTests(_pauseController, () => DateTime.UtcNow); + } + + [TearDown] + public void TearDown() + { + UloopPausePointRegistry.ResetForTests(); + } + + [Test] + public void Capture_WhenPausePointIsEnabled_RecordsFormattedVariablesInSnapshot() + { + // Verifies an armed marker's hit threads formatted locals/parameters into the snapshot. + UloopPausePointRegistry.Enable("jump", 30); + object[] parameters = { "damage", 3 }; + object[] locals = { "speed", 5 }; + + SourcePausePointCapture.Capture("jump", null, parameters, locals); + + UloopPausePointSnapshot snapshot = UloopPausePointRegistry.GetStatus("jump"); + Assert.That(snapshot.IsHit, Is.True); + Assert.That(snapshot.CapturedVariables.Select(v => v.Name), Is.EquivalentTo(new[] { "speed", "damage" })); + Assert.That(snapshot.CapturedVariablesTruncated, Is.False); + Assert.That(_pauseController.PauseCount, Is.EqualTo(1)); + } + + [Test] + public void Capture_WhenPausePointIsNotArmed_DoesNotPauseOrRecordAHit() + { + // Verifies the IsArmed fast path no-ops when the marker was never enabled. + SourcePausePointCapture.Capture("never-enabled", null, Array.Empty(), Array.Empty()); + + UloopPausePointSnapshot snapshot = UloopPausePointRegistry.GetStatus("never-enabled"); + Assert.That(snapshot.IsHit, Is.False); + Assert.That(_pauseController.PauseCount, Is.EqualTo(0)); + } + + [Test] + public void Capture_WhenPausePointWasAlreadyHit_IgnoresSecondCall() + { + // Verifies a one-shot marker disarms itself so a second pass through the same line no-ops. + UloopPausePointRegistry.Enable("jump", 30); + SourcePausePointCapture.Capture("jump", null, Array.Empty(), Array.Empty()); + + SourcePausePointCapture.Capture("jump", null, Array.Empty(), Array.Empty()); + + Assert.That(_pauseController.PauseCount, Is.EqualTo(1)); + } + + [UnityTest] + public IEnumerator Capture_WhenCalledOffMainThread_RecordsHitOnNextMainThreadTick() + { + // Verifies an off-main-thread Capture call is marshalled to the main thread + // (must-fix 2): EditorApplication.isPaused and the registry's own bookkeeping are + // main-thread-only, so the hit must land via MainThreadSwitcher's continuation queue + // rather than running inline on the calling background thread. + UloopPausePointRegistry.Enable("jump", 30); + object[] locals = { "speed", 5 }; + + Task.Run(() => SourcePausePointCapture.Capture("jump", null, Array.Empty(), locals)); + + float timeoutTime = Time.realtimeSinceStartup + 5f; + UloopPausePointSnapshot snapshot = UloopPausePointRegistry.GetStatus("jump"); + while (!snapshot.IsHit && Time.realtimeSinceStartup < timeoutTime) + { + yield return null; + snapshot = UloopPausePointRegistry.GetStatus("jump"); + } + + Assert.That(snapshot.IsHit, Is.True, "hit should be recorded on the main thread within timeout"); + Assert.That(snapshot.CapturedVariables.Select(v => v.Name), Is.EquivalentTo(new[] { "speed" })); + Assert.That(_pauseController.PauseCount, Is.EqualTo(1)); + } + + private sealed class FakePausePointPauseController : IUloopPausePointPauseController + { + public int PauseCount { get; private set; } + public bool IsPlaying => true; + public bool IsPaused => PauseCount > 0; + + public void Pause() + { + PauseCount++; + } + } + } +} diff --git a/Assets/Tests/Editor/SourcePausePointCapture/SourcePausePointCaptureTests.cs.meta b/Assets/Tests/Editor/SourcePausePointCapture/SourcePausePointCaptureTests.cs.meta new file mode 100644 index 0000000000..6b9118b796 --- /dev/null +++ b/Assets/Tests/Editor/SourcePausePointCapture/SourcePausePointCaptureTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3cf939bf5f69485c84de1befdd0b5bec +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/SourcePausePointCapture/SourcePausePointVariableFormatterTests.cs b/Assets/Tests/Editor/SourcePausePointCapture/SourcePausePointVariableFormatterTests.cs new file mode 100644 index 0000000000..951f2ce067 --- /dev/null +++ b/Assets/Tests/Editor/SourcePausePointCapture/SourcePausePointVariableFormatterTests.cs @@ -0,0 +1,403 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; + +using NUnit.Framework; + +using UnityEditor; +using UnityEditorInternal; +using UnityEngine; +using UnityEngine.TestTools; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; +using io.github.hatayama.UnityCliLoop.Runtime; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Verifies captured-variable formatting: scope ordering, UnityEngine.Object classification, + /// async/iterator hoisted-local demangling, and value/count truncation. + /// + [TestFixture] + public sealed class SourcePausePointVariableFormatterTests + { + private GameObject _testGameObject; + private ScriptableObject _testScriptableObject; + + [TearDown] + public void TearDown() + { + if (_testGameObject != null) + { + UnityEngine.Object.DestroyImmediate(_testGameObject); + _testGameObject = null; + } + + if (_testScriptableObject != null) + { + UnityEngine.Object.DestroyImmediate(_testScriptableObject); + _testScriptableObject = null; + } + } + + [Test] + public void Format_WithLocalsAndParameters_OrdersLocalsBeforeParameters() + { + // Verifies locals are reported before parameters, matching the response ordering. + object[] locals = { "speed", 5 }; + object[] parameters = { "damage", 3 }; + + (List variables, bool truncated) = SourcePausePointVariableFormatter.Format( + null, parameters, locals); + + Assert.That(variables.Select(v => v.Name), Is.EqualTo(new[] { "speed", "damage" })); + Assert.That(variables[0].Scope, Is.EqualTo(UloopCapturedVariableScope.Local)); + Assert.That(variables[0].Value, Is.EqualTo("5")); + Assert.That(variables[1].Scope, Is.EqualTo(UloopCapturedVariableScope.Parameter)); + Assert.That(variables[1].Value, Is.EqualTo("3")); + Assert.That(truncated, Is.False); + } + + [Test] + public void Format_WhenValueIsNull_ReportsNullWithoutUnityObjectFields() + { + // Verifies a real C# null reference reports "null" with no UnityObject classification. + object[] locals = { "target", null }; + + (List variables, _) = SourcePausePointVariableFormatter.Format( + null, Array.Empty(), locals); + + UloopCapturedVariable variable = variables.Single(); + Assert.That(variable.Value, Is.EqualTo("null")); + Assert.That(variable.UnityObjectKind, Is.Empty); + } + + [Test] + public void Format_WhenValueToStringThrows_ReturnsSafeToStringSentinel() + { + // Verifies the sanctioned SafeToString try-catch reports a sentinel instead of throwing. + LogAssert.Expect(LogType.Exception, "InvalidOperationException: boom"); + object[] locals = { "broken", new ThrowingToString() }; + + (List variables, _) = SourcePausePointVariableFormatter.Format( + null, Array.Empty(), locals); + + Assert.That(variables.Single().Value, Is.EqualTo("(toString threw InvalidOperationException)")); + } + + [Test] + public void Format_WhenValueExceedsMaxLength_TruncatesValueAndSetsTruncatedFlag() + { + // Verifies an over-long value is clipped to the configured cap and reports truncation. + string longValue = new string('a', SourcePausePointConstants.MaxCapturedVariableValueLength + 10); + object[] locals = { "text", longValue }; + + (List variables, bool truncated) = SourcePausePointVariableFormatter.Format( + null, Array.Empty(), locals); + + Assert.That(variables.Single().Value.Length, Is.EqualTo(SourcePausePointConstants.MaxCapturedVariableValueLength)); + Assert.That(truncated, Is.True); + } + + [Test] + public void Format_WhenOneValueExceedsMaxLength_StillCapturesSubsequentVariables() + { + // Regression test: an over-long value must only clip itself, never abort capture of + // the parameters/instance fields that come after it in the same call. + string longValue = new string('a', SourcePausePointConstants.MaxCapturedVariableValueLength + 10); + object[] locals = { "longText", longValue }; + object[] parameters = { "hp", 42 }; + InstanceFieldFixture instance = new() { PublicField = 5 }; + + (List variables, bool truncated) = SourcePausePointVariableFormatter.Format( + instance, parameters, locals); + + Assert.That(variables.Select(v => v.Name), Is.EqualTo(new[] { "longText", "hp", "PublicField" })); + Assert.That(variables.Single(v => v.Name == "hp").Value, Is.EqualTo("42")); + Assert.That(variables.Single(v => v.Name == "PublicField").Value, Is.EqualTo("5")); + Assert.That(truncated, Is.True); + } + + [Test] + public void Format_WhenVariableCountExceedsMax_StopsAtCapAndSetsTruncatedFlag() + { + // Verifies capture stops at MaxCapturedVariableCount rather than growing unbounded. + int localCount = SourcePausePointConstants.MaxCapturedVariableCount + 10; + object[] locals = new object[localCount * 2]; + for (int i = 0; i < localCount; i++) + { + locals[i * 2] = $"local{i}"; + locals[i * 2 + 1] = i; + } + + (List variables, bool truncated) = SourcePausePointVariableFormatter.Format( + null, Array.Empty(), locals); + + Assert.That(variables.Count, Is.EqualTo(SourcePausePointConstants.MaxCapturedVariableCount)); + Assert.That(truncated, Is.True); + } + + [Test] + public void Format_WithInstanceFields_CapturesThemAfterLocalsAndParameters() + { + // Verifies instance fields (Scope=InstanceField) are appended after locals/parameters, + // and compiler-generated backing fields ("k__BackingField") are skipped. + InstanceFieldFixture instance = new() { PublicField = 9 }; + instance.Prop = "hello"; + object[] locals = { "local", 1 }; + object[] parameters = { "param", 2 }; + + (List variables, _) = SourcePausePointVariableFormatter.Format( + instance, parameters, locals); + + Assert.That(variables.Select(v => v.Name), Is.EqualTo(new[] { "local", "param", "PublicField" })); + Assert.That(variables[2].Scope, Is.EqualTo(UloopCapturedVariableScope.InstanceField)); + Assert.That(variables[2].Value, Is.EqualTo("9")); + Assert.That(variables.Any(v => v.Name.Contains("Prop")), Is.False); + } + + [Test] + public void Format_WithHoistedAsyncLocalField_DemanglesFieldNameToLocalScope() + { + // Verifies Roslyn's hoisted "5__N" state-machine field demangles to Scope=Local. + AsyncStateMachineFixture fixture = new(); + (object stateMachine, Type stateMachineType) = CreateStateMachine(fixture); + SetHoistedField(stateMachine, stateMachineType, "localValue", 42); + + (List variables, _) = SourcePausePointVariableFormatter.Format( + stateMachine, Array.Empty(), Array.Empty()); + + UloopCapturedVariable variable = variables.Single(v => v.Name == "localValue"); + Assert.That(variable.Scope, Is.EqualTo(UloopCapturedVariableScope.Local)); + Assert.That(variable.Value, Is.EqualTo("42")); + } + + [Test] + public void Format_WithHoistedAsyncParameterField_ReportsItAsParameterScope() + { + // Verifies a hoisted parameter (stored under its plain source name, unlike locals) + // reports Scope=Parameter rather than InstanceField, since it belongs to the + // compiler-generated state machine type rather than the calling object's own class. + AsyncStateMachineFixture fixture = new(); + (object stateMachine, _) = CreateStateMachine(fixture); + + (List variables, _) = SourcePausePointVariableFormatter.Format( + stateMachine, Array.Empty(), Array.Empty()); + + UloopCapturedVariable variable = variables.Single(v => v.Name == "seed"); + Assert.That(variable.Scope, Is.EqualTo(UloopCapturedVariableScope.Parameter)); + } + + [Test] + public void Format_WithStateMachineOuterThisField_FollowsItOneLevelDeep() + { + // Verifies "<>4__this" is followed exactly one level to surface the real instance's fields. + AsyncStateMachineFixture fixture = new() { OuterField = 7 }; + (object stateMachine, Type stateMachineType) = CreateStateMachine(fixture); + FieldInfo outerThisField = stateMachineType.GetField( + "<>4__this", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); + Assert.That(outerThisField, Is.Not.Null, "compiler must hoist <>4__this for this fixture"); + object boxedStateMachine = stateMachine; + outerThisField.SetValue(boxedStateMachine, fixture); + + (List variables, _) = SourcePausePointVariableFormatter.Format( + boxedStateMachine, Array.Empty(), Array.Empty()); + + UloopCapturedVariable variable = variables.Single(v => v.Name == "OuterField"); + Assert.That(variable.Scope, Is.EqualTo(UloopCapturedVariableScope.InstanceField)); + Assert.That(variable.Value, Is.EqualTo("7")); + } + + [Test] + public void Format_WithSceneGameObjectValue_ClassifiesAsSceneObject() + { + // Verifies a scene-attached GameObject classifies as SceneObject with a hierarchy path. + _testGameObject = new GameObject("PausePointFormatterSceneFixture"); + object[] locals = { "target", _testGameObject }; + + (List variables, _) = SourcePausePointVariableFormatter.Format( + null, Array.Empty(), locals); + + UloopCapturedVariable variable = variables.Single(); + Assert.That(variable.UnityObjectKind, Is.EqualTo(UloopCapturedVariableUnityObjectKind.SceneObject)); + Assert.That(variable.Value, Is.EqualTo("PausePointFormatterSceneFixture")); + Assert.That(variable.UnityObjectPath, Does.Contain("PausePointFormatterSceneFixture")); + } + + [Test] + public void Format_WithSceneComponentValue_ClassifiesAsSceneObjectUsingComponentHandle() + { + // Verifies the Component branch (as opposed to GameObject) resolves its handle via + // the component itself, not the owning GameObject. + _testGameObject = new GameObject("PausePointFormatterComponentFixture"); + Transform componentValue = _testGameObject.transform; + object[] locals = { "target", componentValue }; + + (List variables, _) = SourcePausePointVariableFormatter.Format( + null, Array.Empty(), locals); + + UloopCapturedVariable variable = variables.Single(); + Assert.That(variable.UnityObjectKind, Is.EqualTo(UloopCapturedVariableUnityObjectKind.SceneObject)); + Assert.That(variable.UnityObjectPath, Does.Contain("PausePointFormatterComponentFixture")); + Assert.That(variable.UnityObjectInstanceId, Is.EqualTo(componentValue.GetInstanceID())); + } + + [Test] + public void Format_WithPrefabAssetGameObjectValue_ClassifiesAsPrefabAsset() + { + // Verifies a GameObject loaded from a saved prefab asset (invalid scene, resolvable + // asset path) classifies as PrefabAsset rather than SceneObject. + const string prefabPath = "Assets/PausePointFormatterPrefabAssetFixture.prefab"; + GameObject source = new("PausePointFormatterPrefabAssetFixture"); + GameObject prefabAsset; + try + { + prefabAsset = PrefabUtility.SaveAsPrefabAsset(source, prefabPath); + } + finally + { + UnityEngine.Object.DestroyImmediate(source); + } + + try + { + object[] locals = { "target", prefabAsset }; + + (List variables, _) = SourcePausePointVariableFormatter.Format( + null, Array.Empty(), locals); + + UloopCapturedVariable variable = variables.Single(); + Assert.That(variable.UnityObjectKind, Is.EqualTo(UloopCapturedVariableUnityObjectKind.PrefabAsset)); + Assert.That(variable.UnityObjectPath, Is.EqualTo(prefabPath)); + } + finally + { + AssetDatabase.DeleteAsset(prefabPath); + } + } + + [Test] + public void Format_WithPersistedAssetValue_ClassifiesAsAsset() + { + // Verifies a non-GameObject persisted asset classifies as Asset with its asset path. + // Loads this test project's own asmdef file rather than creating a throwaway asset. + const string assetPath = + "Assets/Tests/Editor/SourcePausePointCapture/UnityCLILoop.Tests.Editor.SourcePausePointCapture.asmdef"; + AssemblyDefinitionAsset asset = AssetDatabase.LoadAssetAtPath(assetPath); + Assert.That(asset, Is.Not.Null, "fixture asmdef asset must exist at the expected path"); + object[] locals = { "target", asset }; + + (List variables, _) = SourcePausePointVariableFormatter.Format( + null, Array.Empty(), locals); + + UloopCapturedVariable variable = variables.Single(); + Assert.That(variable.UnityObjectKind, Is.EqualTo(UloopCapturedVariableUnityObjectKind.Asset)); + Assert.That(variable.UnityObjectPath, Is.EqualTo(assetPath)); + } + + [Test] + public void Format_WithRuntimeOnlyScriptableObjectValue_ClassifiesAsRuntimeInstance() + { + // Verifies a ScriptableObject with no asset path classifies as RuntimeInstance. + _testScriptableObject = ScriptableObject.CreateInstance(); + object[] locals = { "target", _testScriptableObject }; + + (List variables, _) = SourcePausePointVariableFormatter.Format( + null, Array.Empty(), locals); + + Assert.That(variables.Single().UnityObjectKind, Is.EqualTo(UloopCapturedVariableUnityObjectKind.RuntimeInstance)); + } + + [Test] + public void Format_WithDestroyedUnityObjectValue_ClassifiesAsDestroyed() + { + // Verifies a destroyed (fake-null) UnityEngine.Object reports Destroyed, not real null. + GameObject destroyed = new("PausePointFormatterDestroyedFixture"); + UnityEngine.Object.DestroyImmediate(destroyed); + object[] locals = { "target", destroyed }; + + (List variables, _) = SourcePausePointVariableFormatter.Format( + null, Array.Empty(), locals); + + UloopCapturedVariable variable = variables.Single(); + Assert.That(variable.UnityObjectKind, Is.EqualTo(UloopCapturedVariableUnityObjectKind.Destroyed)); + Assert.That(variable.Value, Is.EqualTo("(destroyed)")); + } + + [UnityTest] + public IEnumerator Format_WhenCalledOffMainThread_DegradesUnityObjectValueWithoutEngineApiAccess() + { + // Verifies UnityEngine.Object values degrade to a placeholder off the main thread, + // since Transform/AssetDatabase/InstanceID access is unsafe there. Uses the same + // background-Task-plus-polling shape as MainThreadSwitcherTests to avoid blocking waits. + _testGameObject = new GameObject("PausePointFormatterOffThreadFixture"); + object[] locals = { "target", _testGameObject }; + List capturedVariables = null; + bool completed = false; + + Task.Run(() => + { + (capturedVariables, _) = SourcePausePointVariableFormatter.Format(null, Array.Empty(), locals); + completed = true; + }); + + float timeoutTime = Time.realtimeSinceStartup + 5f; + while (!completed && Time.realtimeSinceStartup < timeoutTime) + { + yield return null; + } + + Assert.That(completed, Is.True, "background formatting should complete within timeout"); + UloopCapturedVariable variable = capturedVariables.Single(); + Assert.That(variable.Value, Is.EqualTo("(captured off main thread)")); + Assert.That(variable.UnityObjectKind, Is.Empty); + } + + private static (object StateMachine, Type StateMachineType) CreateStateMachine(AsyncStateMachineFixture fixture) + { + Type stateMachineType = typeof(AsyncStateMachineFixture) + .GetNestedTypes(BindingFlags.NonPublic) + .Single(t => t.Name.StartsWith("d__", StringComparison.Ordinal)); + object stateMachine = Activator.CreateInstance(stateMachineType); + return (stateMachine, stateMachineType); + } + + private static void SetHoistedField(object stateMachine, Type stateMachineType, string localName, object value) + { + FieldInfo field = stateMachineType + .GetFields(BindingFlags.NonPublic | BindingFlags.Instance) + .Single(f => System.Text.RegularExpressions.Regex.IsMatch(f.Name, $@"^<{localName}>5__\d+$")); + field.SetValue(stateMachine, value); + } + + private sealed class ThrowingToString + { + public override string ToString() + { + throw new InvalidOperationException("boom"); + } + } + + private sealed class InstanceFieldFixture + { + public int PublicField; + public string Prop { get; set; } + } + + private sealed class AsyncStateMachineFixture + { + public int OuterField; + + public async Task RunAsync(int seed) + { + int localValue = seed * 2; + await Task.Yield(); + OuterField += localValue; + return localValue; + } + } + } +} diff --git a/Assets/Tests/Editor/SourcePausePointCapture/SourcePausePointVariableFormatterTests.cs.meta b/Assets/Tests/Editor/SourcePausePointCapture/SourcePausePointVariableFormatterTests.cs.meta new file mode 100644 index 0000000000..0abebb8dfd --- /dev/null +++ b/Assets/Tests/Editor/SourcePausePointCapture/SourcePausePointVariableFormatterTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5d3eeb5d4a88423ba58253faae93ce22 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/SourcePausePointCapture/UnityCLILoop.Tests.Editor.SourcePausePointCapture.asmdef b/Assets/Tests/Editor/SourcePausePointCapture/UnityCLILoop.Tests.Editor.SourcePausePointCapture.asmdef new file mode 100644 index 0000000000..8115b7de4e --- /dev/null +++ b/Assets/Tests/Editor/SourcePausePointCapture/UnityCLILoop.Tests.Editor.SourcePausePointCapture.asmdef @@ -0,0 +1,21 @@ +{ + "name": "UnityCLILoop.Tests.Editor.SourcePausePointCapture", + "rootNamespace": "io.github.hatayama.UnityCliLoop.Tests.Editor", + "references": [ + "GUID:0acc523941302664db1f4e527237feb3", + "GUID:27619889b8ba8c24980f49ee34dbb44a", + "GUID:94d8abc693f543a691a4645a5ff42e5c", + "GUID:527f26a36b5043c2bd4d4036d04cd76d" + ], + "includePlatforms": [ + "Editor" + ], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": false, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/Assets/Tests/Editor/SourcePausePointCapture/UnityCLILoop.Tests.Editor.SourcePausePointCapture.asmdef.meta b/Assets/Tests/Editor/SourcePausePointCapture/UnityCLILoop.Tests.Editor.SourcePausePointCapture.asmdef.meta new file mode 100644 index 0000000000..ec0f377167 --- /dev/null +++ b/Assets/Tests/Editor/SourcePausePointCapture/UnityCLILoop.Tests.Editor.SourcePausePointCapture.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: f20607bd87eb4ebfbc7dd045c0051447 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/AssemblyInfo.cs b/Packages/src/Editor/FirstPartyTools/PausePoint/AssemblyInfo.cs index f1b0227a75..fbf602caba 100644 --- a/Packages/src/Editor/FirstPartyTools/PausePoint/AssemblyInfo.cs +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/AssemblyInfo.cs @@ -1,3 +1,4 @@ using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("UnityCLILoop.Tests.Editor.SourcePausePointResolver")] +[assembly: InternalsVisibleTo("UnityCLILoop.Tests.Editor.SourcePausePointCapture")] diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointCapture.cs b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointCapture.cs new file mode 100644 index 0000000000..6bb61efeb1 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointCapture.cs @@ -0,0 +1,44 @@ +using System.Collections.Generic; + +using UnityEngine; + +using io.github.hatayama.UnityCliLoop.Runtime; +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// The method Harmony-injected IL calls at a source pause point. Checks the armed marker + /// first so an inactive (not-yet-enabled) patch costs a single dictionary lookup on the hot path. + /// + internal static class SourcePausePointCapture + { + public static void Capture( + string id, object instance, object[] parameterNamesAndValues, object[] localNamesAndValues) + { + Debug.Assert(!string.IsNullOrEmpty(id), "id must not be null or empty"); + Debug.Assert(parameterNamesAndValues != null, "parameterNamesAndValues must not be null"); + Debug.Assert(localNamesAndValues != null, "localNamesAndValues must not be null"); + + if (!UloopPausePointRegistry.IsArmed(id)) + { + return; + } + + (List variables, bool truncated) = SourcePausePointVariableFormatter.Format( + instance, parameterNamesAndValues, localNamesAndValues); + + if (MainThreadSwitcher.IsMainThread) + { + UloopPausePointRegistry.HitWithCapturedVariables(id, variables, truncated); + return; + } + + // EditorApplication.isPaused (and the registry's own bookkeeping) may only be + // touched from the main thread, so an off-thread hit is recorded on the next + // main-thread tick instead of inline. HitCore re-checks IsEnabled at that point, so a + // marker that already got disarmed by a faster hit safely no-ops there. + MainThreadSwitcher.AddContinuation(() => UloopPausePointRegistry.HitWithCapturedVariables(id, variables, truncated)); + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointCapture.cs.meta b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointCapture.cs.meta new file mode 100644 index 0000000000..23a0329306 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointCapture.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1ee0d17d134f44e19a45c4cca0b78f67 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointConstants.cs b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointConstants.cs index 005c0e32a5..a9c5400a2f 100644 --- a/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointConstants.cs +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointConstants.cs @@ -9,5 +9,10 @@ internal static class SourcePausePointConstants public const string CompiledAssemblyExtension = ".dll"; public const string DebugSymbolsExtension = ".pdb"; public const string IsByRefLikeAttributeFullName = "System.Runtime.CompilerServices.IsByRefLikeAttribute"; + + // Keeps a single hit's payload small enough for the CLI response and for the console-like + // pause-point evidence to stay skimmable, mirroring the truncation-by-cap pattern MatchingLogs uses. + public const int MaxCapturedVariableCount = 50; + public const int MaxCapturedVariableValueLength = 256; } } diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointUnityObjectClassifier.cs b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointUnityObjectClassifier.cs new file mode 100644 index 0000000000..6b683f4e8c --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointUnityObjectClassifier.cs @@ -0,0 +1,74 @@ +using UnityEditor; +using UnityEngine; + +using io.github.hatayama.UnityCliLoop.Runtime; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Classifies a live (non-null, non-destroyed) UnityEngine.Object reference into one of the + /// pause-point capture kinds, with the handle (Hierarchy path / asset path / InstanceID) an + /// AI needs in order to look the object up next. + /// + internal static class SourcePausePointUnityObjectClassifier + { + public readonly struct Classification + { + public Classification(string kind, string path, int instanceId) + { + Kind = kind; + Path = path; + InstanceId = instanceId; + } + + public string Kind { get; } + public string Path { get; } + public int InstanceId { get; } + } + + public static Classification Classify(Object unityObject) + { + Debug.Assert(unityObject != null, "unityObject must be a live (non-destroyed) reference."); + + if (unityObject is GameObject gameObject) + { + return ClassifyGameObjectOrComponent(gameObject, gameObject); + } + + if (unityObject is Component component) + { + return ClassifyGameObjectOrComponent(component.gameObject, component); + } + + string assetPath = AssetDatabase.GetAssetPath(unityObject); + return string.IsNullOrEmpty(assetPath) + ? new Classification( + UloopCapturedVariableUnityObjectKind.RuntimeInstance, unityObject.name, unityObject.GetInstanceID()) + : new Classification( + UloopCapturedVariableUnityObjectKind.Asset, assetPath, unityObject.GetInstanceID()); + } + + private static Classification ClassifyGameObjectOrComponent(GameObject gameObject, Object handleSource) + { + if (gameObject.scene.IsValid()) + { + return new Classification( + UloopCapturedVariableUnityObjectKind.SceneObject, + $"{gameObject.scene.name}:{BuildHierarchyPath(gameObject.transform)}", + handleSource.GetInstanceID()); + } + + return new Classification( + UloopCapturedVariableUnityObjectKind.PrefabAsset, + AssetDatabase.GetAssetPath(handleSource), + handleSource.GetInstanceID()); + } + + private static string BuildHierarchyPath(Transform transform) + { + return transform.parent == null + ? "/" + transform.name + : BuildHierarchyPath(transform.parent) + "/" + transform.name; + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointUnityObjectClassifier.cs.meta b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointUnityObjectClassifier.cs.meta new file mode 100644 index 0000000000..0386d55622 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointUnityObjectClassifier.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 177647b8fdf114311b8f528e56c923be +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointVariableFormatter.cs b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointVariableFormatter.cs new file mode 100644 index 0000000000..12733f7012 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointVariableFormatter.cs @@ -0,0 +1,250 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Text.RegularExpressions; + +using UnityEngine; + +using io.github.hatayama.UnityCliLoop.Runtime; +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Turns the raw name/value pairs a pause point captured into the DTOs a CLI response can + /// serialize: demangling compiler-hoisted local/`this` fields, classifying UnityEngine.Object + /// references, and capping both value length and variable count. + /// + internal static class SourcePausePointVariableFormatter + { + // Roslyn hoists a local that crosses an await/yield into a state machine field named + // "5__N"; this demangles it back to the source-level local name. + private static readonly Regex HoistedLocalFieldNamePattern = new(@"^<([^>]+)>5__\d+$", RegexOptions.Compiled); + private const string StateMachineOuterThisFieldName = "<>4__this"; + private const string OffMainThreadValue = "(captured off main thread)"; + private const string DestroyedValue = "(destroyed)"; + + public static (List Variables, bool Truncated) Format( + object instance, object[] parameterNamesAndValues, object[] localNamesAndValues) + { + Debug.Assert(parameterNamesAndValues != null, "parameterNamesAndValues must not be null"); + Debug.Assert(localNamesAndValues != null, "localNamesAndValues must not be null"); + Debug.Assert(parameterNamesAndValues.Length % 2 == 0, "parameterNamesAndValues must contain name/value pairs"); + Debug.Assert(localNamesAndValues.Length % 2 == 0, "localNamesAndValues must contain name/value pairs"); + + List results = new(); + // Reports whether ANY value was clipped or the count cap was hit; a single over-long + // value must not stop enumeration of the remaining locals/parameters/instance fields. + bool truncated = false; + // An async state machine's own fields include hoisted copies of the original method's + // parameters under their plain source name (only true locals get the "5__N" + // treatment); tracking already-captured names keeps those from being double-reported + // once as Parameter (from the array below) and again as InstanceField (from the walk). + HashSet capturedNames = new(); + + bool countCapReached = AppendPairs( + results, capturedNames, ref truncated, localNamesAndValues, UloopCapturedVariableScope.Local); + if (!countCapReached) + { + countCapReached = AppendPairs( + results, capturedNames, ref truncated, parameterNamesAndValues, UloopCapturedVariableScope.Parameter); + } + + if (instance != null && !countCapReached) + { + CollectInstanceFieldVariables(instance, results, capturedNames, ref truncated); + } + + return (results, truncated); + } + + // Returns true once the count cap is hit, so the caller can stop enumerating further + // arrays/fields; a per-value length truncation alone must not signal this. + private static bool AppendPairs( + List results, HashSet capturedNames, ref bool truncated, + object[] namesAndValues, string scope) + { + for (int i = 0; i < namesAndValues.Length; i += 2) + { + string name = (string)namesAndValues[i]; + object value = namesAndValues[i + 1]; + if (!TryAppendVariable(results, capturedNames, ref truncated, name, scope, value)) + { + return true; + } + } + + return false; + } + + // Async/iterator state machines hoist the original `this` into a `<>4__this` field; this + // follows it exactly one level deep to also capture the real instance's fields, without + // recursing into any further state-machine hop. + private static void CollectInstanceFieldVariables( + object instance, List results, HashSet capturedNames, ref bool truncated) + { + (object outerThis, bool countCapReached) = CollectDirectFieldVariables( + instance, results, capturedNames, ref truncated, followOuterThis: true); + if (countCapReached || outerThis == null) + { + return; + } + + CollectDirectFieldVariables(outerThis, results, capturedNames, ref truncated, followOuterThis: false); + } + + private static (object OuterThis, bool CountCapReached) CollectDirectFieldVariables( + object source, List results, HashSet capturedNames, ref bool truncated, + bool followOuterThis) + { + object outerThis = null; + // A compiler-generated state machine hoists the original method's parameters as + // plain-named fields (only true locals get the "5__N" treatment), so those + // fields are the method's Parameter scope, not this type's own InstanceField scope. + bool isCompilerGeneratedStateMachine = Attribute.IsDefined(source.GetType(), typeof(CompilerGeneratedAttribute)); + string plainFieldScope = isCompilerGeneratedStateMachine + ? UloopCapturedVariableScope.Parameter + : UloopCapturedVariableScope.InstanceField; + + foreach (FieldInfo field in EnumerateInstanceFields(source.GetType())) + { + if (followOuterThis && field.Name == StateMachineOuterThisFieldName) + { + outerThis = field.GetValue(source); + continue; + } + + Match hoistedLocalMatch = HoistedLocalFieldNamePattern.Match(field.Name); + if (hoistedLocalMatch.Success) + { + if (!TryAppendVariable( + results, capturedNames, ref truncated, hoistedLocalMatch.Groups[1].Value, + UloopCapturedVariableScope.Local, field.GetValue(source))) + { + return (outerThis, true); + } + + continue; + } + + if (field.Name.StartsWith("<", StringComparison.Ordinal)) + { + // Other compiler-generated plumbing (state machine "<>1__state", "<>t__builder", + // auto-property backing fields, etc.) carries no source-level meaning to capture. + continue; + } + + if (!TryAppendVariable(results, capturedNames, ref truncated, field.Name, plainFieldScope, field.GetValue(source))) + { + return (outerThis, true); + } + } + + return (outerThis, false); + } + + private static IEnumerable EnumerateInstanceFields(Type type) + { + const BindingFlags flags = + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly; + + for (Type current = type; + current != null && current != typeof(UnityEngine.Object) && current != typeof(object); + current = current.BaseType) + { + foreach (FieldInfo field in current.GetFields(flags)) + { + yield return field; + } + } + } + + private static bool TryAppendVariable( + List results, HashSet capturedNames, ref bool truncated, string name, + string scope, object rawValue) + { + if (!capturedNames.Add(name)) + { + return true; + } + + if (results.Count >= SourcePausePointConstants.MaxCapturedVariableCount) + { + truncated = true; + return false; + } + + results.Add(FormatVariable(name, scope, rawValue, ref truncated)); + return true; + } + + private static UloopCapturedVariable FormatVariable(string name, string scope, object rawValue, ref bool truncated) + { + if (rawValue == null) + { + return new UloopCapturedVariable(name, scope, string.Empty, "null", string.Empty, string.Empty, 0); + } + + string typeName = rawValue.GetType().FullName; + if (rawValue is UnityEngine.Object unityObjectCandidate) + { + return FormatUnityObjectVariable(name, scope, typeName, unityObjectCandidate); + } + + string value = ApplyValueLengthCap(SafeToString(rawValue), ref truncated); + return new UloopCapturedVariable(name, scope, typeName, value, string.Empty, string.Empty, 0); + } + + private static UloopCapturedVariable FormatUnityObjectVariable( + string name, string scope, string typeName, UnityEngine.Object unityObjectCandidate) + { + if (!MainThreadSwitcher.IsMainThread) + { + // Transform/AssetDatabase/InstanceID access all require the main thread; degrade + // to a plain type-tagged placeholder rather than risk touching engine state here. + return new UloopCapturedVariable(name, scope, typeName, OffMainThreadValue, string.Empty, string.Empty, 0); + } + + if (unityObjectCandidate == null) + { + // Fake-null: the managed wrapper is a live reference, so GetInstanceID() is still safe. + return new UloopCapturedVariable( + name, scope, typeName, DestroyedValue, + UloopCapturedVariableUnityObjectKind.Destroyed, string.Empty, unityObjectCandidate.GetInstanceID()); + } + + SourcePausePointUnityObjectClassifier.Classification classification = + SourcePausePointUnityObjectClassifier.Classify(unityObjectCandidate); + return new UloopCapturedVariable( + name, scope, typeName, unityObjectCandidate.name, + classification.Kind, classification.Path, classification.InstanceId); + } + + private static string ApplyValueLengthCap(string value, ref bool truncated) + { + if (value.Length <= SourcePausePointConstants.MaxCapturedVariableValueLength) + { + return value; + } + + truncated = true; + return value.Substring(0, SourcePausePointConstants.MaxCapturedVariableValueLength); + } + + // The single sanctioned try-catch in this codebase's capture path: user ToString() + // overrides are untrusted code we must not let crash a pause-point hit. + private static string SafeToString(object value) + { + try + { + return value.ToString(); + } + catch (Exception exception) + { + Debug.LogException(exception); + return $"(toString threw {exception.GetType().Name})"; + } + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointVariableFormatter.cs.meta b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointVariableFormatter.cs.meta new file mode 100644 index 0000000000..9d96c09832 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointVariableFormatter.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 31073ba6ca1e84070ae738d221a9619b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/ToolContracts/AssemblyInfo.cs b/Packages/src/Editor/ToolContracts/AssemblyInfo.cs index c746ba81eb..e0c8a9d0b8 100644 --- a/Packages/src/Editor/ToolContracts/AssemblyInfo.cs +++ b/Packages/src/Editor/ToolContracts/AssemblyInfo.cs @@ -3,6 +3,7 @@ [assembly: InternalsVisibleTo("UnityCLILoop.CompositionRoot.Editor")] [assembly: InternalsVisibleTo("UnityCLILoop.Application")] [assembly: InternalsVisibleTo("UnityCLILoop.FirstPartyTools.Screenshot.Editor")] +[assembly: InternalsVisibleTo("UnityCLILoop.FirstPartyTools.PausePoint.Editor")] [assembly: InternalsVisibleTo("UnityCLILoop.Tests.Editor")] [assembly: InternalsVisibleTo("UnityCLILoop.Tests.PlayMode")] [assembly: InternalsVisibleTo("UnityCLILoop.Dev")] diff --git a/Packages/src/Runtime/PausePoints/AssemblyInfo.cs b/Packages/src/Runtime/PausePoints/AssemblyInfo.cs index 0fe20cf7d3..08b99d4ab2 100644 --- a/Packages/src/Runtime/PausePoints/AssemblyInfo.cs +++ b/Packages/src/Runtime/PausePoints/AssemblyInfo.cs @@ -6,3 +6,4 @@ [assembly: InternalsVisibleTo("UnityCLILoop.Infrastructure")] [assembly: InternalsVisibleTo("UnityCLILoop.Tests.Editor")] [assembly: InternalsVisibleTo("UnityCLILoop.Tests.PlayMode")] +[assembly: InternalsVisibleTo("UnityCLILoop.Tests.Editor.SourcePausePointCapture")] diff --git a/Packages/src/Runtime/PausePoints/UloopCapturedVariable.cs b/Packages/src/Runtime/PausePoints/UloopCapturedVariable.cs new file mode 100644 index 0000000000..65df549f34 --- /dev/null +++ b/Packages/src/Runtime/PausePoints/UloopCapturedVariable.cs @@ -0,0 +1,43 @@ +#if UNITY_EDITOR +using UnityEngine; + +namespace io.github.hatayama.UnityCliLoop.Runtime +{ + /// + /// One formatted variable captured at a source pause point. Editor-only value types + /// (UnityEngine.Object references, reflected field values) are reduced to string/int here so + /// this DTO has no Editor API dependency and can be shared with the CLI bridge layer as-is. + /// + internal sealed class UloopCapturedVariable + { + public UloopCapturedVariable( + string name, + string scope, + string typeName, + string value, + string unityObjectKind, + string unityObjectPath, + int unityObjectInstanceId) + { + Debug.Assert(!string.IsNullOrEmpty(name), "name must not be null or empty"); + Debug.Assert(!string.IsNullOrEmpty(scope), "scope must not be null or empty"); + + Name = name; + Scope = scope; + TypeName = typeName ?? string.Empty; + Value = value ?? string.Empty; + UnityObjectKind = unityObjectKind ?? string.Empty; + UnityObjectPath = unityObjectPath ?? string.Empty; + UnityObjectInstanceId = unityObjectInstanceId; + } + + public string Name { get; } + public string Scope { get; } + public string TypeName { get; } + public string Value { get; } + public string UnityObjectKind { get; } + public string UnityObjectPath { get; } + public int UnityObjectInstanceId { get; } + } +} +#endif diff --git a/Packages/src/Runtime/PausePoints/UloopCapturedVariable.cs.meta b/Packages/src/Runtime/PausePoints/UloopCapturedVariable.cs.meta new file mode 100644 index 0000000000..8c70dd8395 --- /dev/null +++ b/Packages/src/Runtime/PausePoints/UloopCapturedVariable.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 55ffe21d8f18545048d3d61add8784cd +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Runtime/PausePoints/UloopCapturedVariableScope.cs b/Packages/src/Runtime/PausePoints/UloopCapturedVariableScope.cs new file mode 100644 index 0000000000..85f0b9bb03 --- /dev/null +++ b/Packages/src/Runtime/PausePoints/UloopCapturedVariableScope.cs @@ -0,0 +1,14 @@ +#if UNITY_EDITOR +namespace io.github.hatayama.UnityCliLoop.Runtime +{ + /// + /// Centralizes captured-variable scope names shared by Editor tools and the native CLI. + /// + internal static class UloopCapturedVariableScope + { + public const string Local = "Local"; + public const string Parameter = "Parameter"; + public const string InstanceField = "InstanceField"; + } +} +#endif diff --git a/Packages/src/Runtime/PausePoints/UloopCapturedVariableScope.cs.meta b/Packages/src/Runtime/PausePoints/UloopCapturedVariableScope.cs.meta new file mode 100644 index 0000000000..d25dc88348 --- /dev/null +++ b/Packages/src/Runtime/PausePoints/UloopCapturedVariableScope.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: bab853f0007da4992bb603c580429572 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Runtime/PausePoints/UloopCapturedVariableUnityObjectKind.cs b/Packages/src/Runtime/PausePoints/UloopCapturedVariableUnityObjectKind.cs new file mode 100644 index 0000000000..cfdc35e16a --- /dev/null +++ b/Packages/src/Runtime/PausePoints/UloopCapturedVariableUnityObjectKind.cs @@ -0,0 +1,17 @@ +#if UNITY_EDITOR +namespace io.github.hatayama.UnityCliLoop.Runtime +{ + /// + /// Centralizes the UnityEngine.Object classification names shared by Editor tools and the + /// native CLI. A real C# null reference has no kind (empty string) rather than one of these. + /// + internal static class UloopCapturedVariableUnityObjectKind + { + public const string SceneObject = "SceneObject"; + public const string PrefabAsset = "PrefabAsset"; + public const string Asset = "Asset"; + public const string RuntimeInstance = "RuntimeInstance"; + public const string Destroyed = "Destroyed"; + } +} +#endif diff --git a/Packages/src/Runtime/PausePoints/UloopCapturedVariableUnityObjectKind.cs.meta b/Packages/src/Runtime/PausePoints/UloopCapturedVariableUnityObjectKind.cs.meta new file mode 100644 index 0000000000..30352a606b --- /dev/null +++ b/Packages/src/Runtime/PausePoints/UloopCapturedVariableUnityObjectKind.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: cd0497e2fa0864072a10ca19a271ae9f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Runtime/PausePoints/UloopPausePointEntry.cs b/Packages/src/Runtime/PausePoints/UloopPausePointEntry.cs index f94332446f..bee8a9b96e 100644 --- a/Packages/src/Runtime/PausePoints/UloopPausePointEntry.cs +++ b/Packages/src/Runtime/PausePoints/UloopPausePointEntry.cs @@ -1,5 +1,6 @@ #if UNITY_EDITOR using System; +using System.Collections.Generic; using UnityEngine; namespace io.github.hatayama.UnityCliLoop.Runtime @@ -19,6 +20,7 @@ public UloopPausePointEntry(string id, int timeoutSeconds, DateTime enabledAtUtc Status = UloopPausePointStatus.Enabled; IsEnabled = true; Message = "Pause point enabled."; + CapturedVariables = Array.Empty(); } public string Id { get; } @@ -36,6 +38,8 @@ public UloopPausePointEntry(string id, int timeoutSeconds, DateTime enabledAtUtc public bool IsPlayingAtHit { get; private set; } public bool IsPausedAtHit { get; private set; } public string Message { get; private set; } + public IReadOnlyList CapturedVariables { get; private set; } + public bool CapturedVariablesTruncated { get; private set; } public void ExpireIfNeeded(DateTime nowUtc) { @@ -62,8 +66,21 @@ public void MarkCleared(string message = "Pause point cleared.") } public void RecordHit(DateTime nowUtc, bool isPlaying, bool isPaused, int hitSequence) + { + RecordHitWithCapturedVariables( + nowUtc, isPlaying, isPaused, hitSequence, Array.Empty(), false); + } + + public void RecordHitWithCapturedVariables( + DateTime nowUtc, + bool isPlaying, + bool isPaused, + int hitSequence, + IReadOnlyList capturedVariables, + bool capturedVariablesTruncated) { Debug.Assert(hitSequence > 0, "hitSequence must be greater than zero"); + Debug.Assert(capturedVariables != null, "capturedVariables must not be null"); if (HitCount == 0) { @@ -79,6 +96,8 @@ public void RecordHit(DateTime nowUtc, bool isPlaying, bool isPaused, int hitSeq IsEnabled = false; Status = UloopPausePointStatus.Hit; Message = "Pause point hit; Unity pause was requested."; + CapturedVariables = capturedVariables; + CapturedVariablesTruncated = capturedVariablesTruncated; } public UloopPausePointSnapshot ToSnapshot(DateTime nowUtc, IUloopPausePointPauseController pauseController) @@ -119,7 +138,9 @@ public UloopPausePointSnapshot ToSnapshot(DateTime nowUtc, IUloopPausePointPause FirstHitSequence, LastHitSequence, Message, - recommendedNextAction); + recommendedNextAction, + CapturedVariables, + CapturedVariablesTruncated); } private long CalculateRemainingMilliseconds(DateTime nowUtc) diff --git a/Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs b/Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs index 042a4f32af..ee29dc492b 100644 --- a/Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs +++ b/Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs @@ -1,18 +1,22 @@ #if UNITY_EDITOR using System; +using System.Collections.Concurrent; using System.Collections.Generic; using UnityEngine; namespace io.github.hatayama.UnityCliLoop.Runtime { /// - /// Stores enabled pause point state for the current Editor domain. + /// Stores enabled pause point state for the current Editor domain. All members except + /// IsArmed are main-thread-only by convention; IsArmed is the one entry point an + /// off-main-thread Harmony-injected Capture call may reach, so Entries is a + /// ConcurrentDictionary to make that cross-thread read safe. /// internal static class UloopPausePointRegistry { public const int DefaultTimeoutSeconds = 30; - private static readonly Dictionary Entries = new(); + private static readonly ConcurrentDictionary Entries = new(); private static IUloopPausePointPauseController _pauseController = new UnityEditorPausePointPauseController(); private static Func _nowProvider = () => DateTime.UtcNow; private static int _nextGeneration; @@ -98,6 +102,32 @@ public static UloopPausePointSnapshot GetStatus(string id) } public static UloopPausePointSnapshot Hit(string id) + { + return HitCore(id, Array.Empty(), false); + } + + public static UloopPausePointSnapshot HitWithCapturedVariables( + string id, IReadOnlyList capturedVariables, bool capturedVariablesTruncated) + { + Debug.Assert(capturedVariables != null, "capturedVariables must not be null"); + + return HitCore(id, capturedVariables, capturedVariablesTruncated); + } + + // Returns after a single dictionary lookup when the id is not armed. Harmony-injected + // Capture calls take this inactive path almost always, so keeping it allocation-free here matters. + public static bool IsArmed(string id) + { + if (string.IsNullOrWhiteSpace(id)) + { + return false; + } + + return Entries.TryGetValue(id, out UloopPausePointEntry entry) && entry.IsEnabled; + } + + private static UloopPausePointSnapshot HitCore( + string id, IReadOnlyList capturedVariables, bool capturedVariablesTruncated) { if (string.IsNullOrWhiteSpace(id)) { @@ -120,7 +150,9 @@ public static UloopPausePointSnapshot Hit(string id) _pauseController.Pause(); int hitSequence = ++_nextHitSequence; - entry.RecordHit(now, _pauseController.IsPlaying, _pauseController.IsPaused, hitSequence); + entry.RecordHitWithCapturedVariables( + now, _pauseController.IsPlaying, _pauseController.IsPaused, hitSequence, + capturedVariables, capturedVariablesTruncated); UloopPausePointSnapshot snapshot = entry.ToSnapshot(now, _pauseController); _latestHitSnapshot = snapshot; _hitSnapshots.RemoveAll(hitSnapshot => hitSnapshot.Id == id); diff --git a/Packages/src/Runtime/PausePoints/UloopPausePointSnapshot.cs b/Packages/src/Runtime/PausePoints/UloopPausePointSnapshot.cs index ecbea05309..3e54c987ac 100644 --- a/Packages/src/Runtime/PausePoints/UloopPausePointSnapshot.cs +++ b/Packages/src/Runtime/PausePoints/UloopPausePointSnapshot.cs @@ -1,4 +1,7 @@ #if UNITY_EDITOR +using System; +using System.Collections.Generic; + using UnityEngine; namespace io.github.hatayama.UnityCliLoop.Runtime @@ -26,7 +29,9 @@ public UloopPausePointSnapshot( int firstHitSequence, int lastHitSequence, string message, - string recommendedNextAction) + string recommendedNextAction, + IReadOnlyList capturedVariables, + bool capturedVariablesTruncated) { Debug.Assert(editorState != null, "editorState must not be null"); @@ -48,6 +53,8 @@ public UloopPausePointSnapshot( LastHitSequence = lastHitSequence; Message = message ?? string.Empty; RecommendedNextAction = recommendedNextAction ?? string.Empty; + CapturedVariables = capturedVariables ?? Array.Empty(); + CapturedVariablesTruncated = capturedVariablesTruncated; } public string Id { get; } @@ -68,6 +75,8 @@ public UloopPausePointSnapshot( public int LastHitSequence { get; } public string Message { get; } public string RecommendedNextAction { get; } + public IReadOnlyList CapturedVariables { get; } + public bool CapturedVariablesTruncated { get; } public static UloopPausePointSnapshot NotEnabled(string id, IUloopPausePointPauseController pauseController) { @@ -93,7 +102,9 @@ public static UloopPausePointSnapshot NotEnabled(string id, IUloopPausePointPaus 0, 0, "Pause point is not enabled.", - string.Empty); + string.Empty, + Array.Empty(), + false); } } }