From 8a3916d55d85ca615ace597677ae70c4746a7da9 Mon Sep 17 00:00:00 2001 From: hatayama Date: Sat, 11 Jul 2026 01:06:10 +0900 Subject: [PATCH 1/3] Add UloopCapturedVariable runtime DTO and registry capture path Source pause points need to report captured local/parameter/instance field values alongside a hit, in addition to the existing bare Hit(id) used by the handwritten UloopPausePoint.Pause("id") marker. Adds UloopCapturedVariable (string/int only, no Editor API dependency) plus scope/UnityObject-kind name constants, threads a captured variable list and truncation flag through UloopPausePointEntry/UloopPausePointSnapshot, and adds UloopPausePointRegistry.HitWithCapturedVariables (kept separate from Hit(id) per this project's no-overload rule) sharing a private HitCore with the existing Hit(id) path. Also adds IsArmed(id) so the upcoming injected Capture landing point can no-op on a single dictionary lookup without paying reflection/formatting cost when the marker isn't armed. Refs: Phase 3 (ToDo 3-1) of the source pause point execution plan. --- Assets/Tests/Editor/PausePointTests.cs | 57 +++++++++++++++++++ .../PausePoints/UloopCapturedVariable.cs | 43 ++++++++++++++ .../PausePoints/UloopCapturedVariable.cs.meta | 11 ++++ .../PausePoints/UloopCapturedVariableScope.cs | 14 +++++ .../UloopCapturedVariableScope.cs.meta | 11 ++++ .../UloopCapturedVariableUnityObjectKind.cs | 17 ++++++ ...oopCapturedVariableUnityObjectKind.cs.meta | 11 ++++ .../PausePoints/UloopPausePointEntry.cs | 23 +++++++- .../PausePoints/UloopPausePointRegistry.cs | 30 +++++++++- .../PausePoints/UloopPausePointSnapshot.cs | 15 ++++- 10 files changed, 228 insertions(+), 4 deletions(-) create mode 100644 Packages/src/Runtime/PausePoints/UloopCapturedVariable.cs create mode 100644 Packages/src/Runtime/PausePoints/UloopCapturedVariable.cs.meta create mode 100644 Packages/src/Runtime/PausePoints/UloopCapturedVariableScope.cs create mode 100644 Packages/src/Runtime/PausePoints/UloopCapturedVariableScope.cs.meta create mode 100644 Packages/src/Runtime/PausePoints/UloopCapturedVariableUnityObjectKind.cs create mode 100644 Packages/src/Runtime/PausePoints/UloopCapturedVariableUnityObjectKind.cs.meta 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/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..583c1a2b07 100644 --- a/Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs +++ b/Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs @@ -98,6 +98,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); + } + + // id が armed でなければ辞書引き 1 回で return する。Harmony が注入した Capture 呼び出しは + // ほぼ常にこの不活性パスを通るため、ここでの割り当て・整形コストをゼロに保つことが重要。 + 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 +146,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); } } } From d8bee87f04b74f81e28c0c51a615166b89f498e2 Mon Sep 17 00:00:00 2001 From: hatayama Date: Sat, 11 Jul 2026 01:24:54 +0900 Subject: [PATCH 2/3] Add source-pause-point variable formatter, UnityObject classifier, and capture entry point Implements ToDo 3-2/3-3: formats captured locals/parameters/instance fields (with async/iterator hoisted-local demangling and one-level <>4__this following), classifies live UnityEngine.Object references into the 6-category kind set, degrades gracefully off the main thread, and caps both value length and variable count. SourcePausePointCapture is the landing point Harmony will call in PR4, gated on the registry's armed check. --- .../Tests/Editor/SourcePausePointCapture.meta | 8 + .../SourcePausePointCaptureTests.cs | 85 +++++ .../SourcePausePointCaptureTests.cs.meta | 11 + .../SourcePausePointVariableFormatterTests.cs | 295 ++++++++++++++++++ ...cePausePointVariableFormatterTests.cs.meta | 11 + ...ests.Editor.SourcePausePointCapture.asmdef | 21 ++ ...Editor.SourcePausePointCapture.asmdef.meta | 7 + .../PausePoint/AssemblyInfo.cs | 1 + .../PausePoint/SourcePausePointCapture.cs | 33 ++ .../SourcePausePointCapture.cs.meta | 11 + .../PausePoint/SourcePausePointConstants.cs | 5 + .../SourcePausePointUnityObjectClassifier.cs | 74 +++++ ...rcePausePointUnityObjectClassifier.cs.meta | 11 + .../SourcePausePointVariableFormatter.cs | 234 ++++++++++++++ .../SourcePausePointVariableFormatter.cs.meta | 11 + .../src/Runtime/PausePoints/AssemblyInfo.cs | 1 + 16 files changed, 819 insertions(+) create mode 100644 Assets/Tests/Editor/SourcePausePointCapture.meta create mode 100644 Assets/Tests/Editor/SourcePausePointCapture/SourcePausePointCaptureTests.cs create mode 100644 Assets/Tests/Editor/SourcePausePointCapture/SourcePausePointCaptureTests.cs.meta create mode 100644 Assets/Tests/Editor/SourcePausePointCapture/SourcePausePointVariableFormatterTests.cs create mode 100644 Assets/Tests/Editor/SourcePausePointCapture/SourcePausePointVariableFormatterTests.cs.meta create mode 100644 Assets/Tests/Editor/SourcePausePointCapture/UnityCLILoop.Tests.Editor.SourcePausePointCapture.asmdef create mode 100644 Assets/Tests/Editor/SourcePausePointCapture/UnityCLILoop.Tests.Editor.SourcePausePointCapture.asmdef.meta create mode 100644 Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointCapture.cs create mode 100644 Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointCapture.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointUnityObjectClassifier.cs create mode 100644 Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointUnityObjectClassifier.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointVariableFormatter.cs create mode 100644 Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointVariableFormatter.cs.meta 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..535fb5002b --- /dev/null +++ b/Assets/Tests/Editor/SourcePausePointCapture/SourcePausePointCaptureTests.cs @@ -0,0 +1,85 @@ +using System; +using System.Linq; + +using NUnit.Framework; + +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)); + } + + 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..33590ba7e2 --- /dev/null +++ b/Assets/Tests/Editor/SourcePausePointCapture/SourcePausePointVariableFormatterTests.cs @@ -0,0 +1,295 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +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 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_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_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_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..1b66c16fe4 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointCapture.cs @@ -0,0 +1,33 @@ +using System.Collections.Generic; + +using UnityEngine; + +using io.github.hatayama.UnityCliLoop.Runtime; + +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); + + 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..a824d56235 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointVariableFormatter.cs @@ -0,0 +1,234 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +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(); + 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(); + + AppendPairs(results, capturedNames, ref truncated, localNamesAndValues, UloopCapturedVariableScope.Local); + AppendPairs(results, capturedNames, ref truncated, parameterNamesAndValues, UloopCapturedVariableScope.Parameter); + + if (instance != null && !truncated) + { + CollectInstanceFieldVariables(instance, results, capturedNames, ref truncated); + } + + return (results, truncated); + } + + private static void AppendPairs( + List results, HashSet capturedNames, ref bool truncated, + object[] namesAndValues, string scope) + { + for (int i = 0; i < namesAndValues.Length; i += 2) + { + if (truncated) + { + return; + } + + string name = (string)namesAndValues[i]; + object value = namesAndValues[i + 1]; + if (!TryAppendVariable(results, capturedNames, ref truncated, name, scope, value)) + { + return; + } + } + } + + // 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 = CollectDirectFieldVariables(instance, results, capturedNames, ref truncated, followOuterThis: true); + if (truncated || outerThis == null) + { + return; + } + + CollectDirectFieldVariables(outerThis, results, capturedNames, ref truncated, followOuterThis: false); + } + + private static object CollectDirectFieldVariables( + object source, List results, HashSet capturedNames, ref bool truncated, + bool followOuterThis) + { + object outerThis = null; + foreach (FieldInfo field in EnumerateInstanceFields(source.GetType())) + { + if (truncated) + { + return outerThis; + } + + if (followOuterThis && field.Name == StateMachineOuterThisFieldName) + { + outerThis = field.GetValue(source); + continue; + } + + Match hoistedLocalMatch = HoistedLocalFieldNamePattern.Match(field.Name); + if (hoistedLocalMatch.Success) + { + TryAppendVariable( + results, capturedNames, ref truncated, hoistedLocalMatch.Groups[1].Value, + UloopCapturedVariableScope.Local, field.GetValue(source)); + 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; + } + + TryAppendVariable( + results, capturedNames, ref truncated, field.Name, UloopCapturedVariableScope.InstanceField, + field.GetValue(source)); + } + + return outerThis; + } + + 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/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")] From 50c0e6141b6d313c0f1e78554a4c9d39c272f303 Mon Sep 17 00:00:00 2001 From: hatayama Date: Sat, 11 Jul 2026 01:49:46 +0900 Subject: [PATCH 3/3] Fix truncation conflation, marshal off-thread hits, and expand classification coverage Addresses Fable 5 review of PR #1682: - Separate "count cap reached" (stops enumeration) from "a value was truncated" (continues); a single over-long value no longer drops every subsequent local/parameter/instance field. - Marshal off-main-thread Capture hits onto the main thread via MainThreadSwitcher, since EditorApplication.isPaused and the registry's bookkeeping are main-thread-only; make the registry's Entries dictionary concurrent so IsArmed stays safe to read cross-thread while inactive markers remain allocation-free. - Report a hoisted async parameter as Scope=Parameter instead of InstanceField, since it belongs to the compiler-generated state machine rather than the calling object's own class. - Add PrefabAsset/Asset/Component classification tests and a regression test proving over-long values don't truncate capture. - Translate a stray Japanese comment to English per repo convention. --- .../SourcePausePointCaptureTests.cs | 30 +++++ .../SourcePausePointVariableFormatterTests.cs | 108 ++++++++++++++++++ .../PausePoint/SourcePausePointCapture.cs | 13 ++- .../SourcePausePointVariableFormatter.cs | 64 +++++++---- .../src/Editor/ToolContracts/AssemblyInfo.cs | 1 + .../PausePoints/UloopPausePointRegistry.cs | 12 +- 6 files changed, 199 insertions(+), 29 deletions(-) diff --git a/Assets/Tests/Editor/SourcePausePointCapture/SourcePausePointCaptureTests.cs b/Assets/Tests/Editor/SourcePausePointCapture/SourcePausePointCaptureTests.cs index 535fb5002b..403d2528fb 100644 --- a/Assets/Tests/Editor/SourcePausePointCapture/SourcePausePointCaptureTests.cs +++ b/Assets/Tests/Editor/SourcePausePointCapture/SourcePausePointCaptureTests.cs @@ -1,8 +1,13 @@ 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; @@ -70,6 +75,31 @@ public void Capture_WhenPausePointWasAlreadyHit_IgnoresSecondCall() 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; } diff --git a/Assets/Tests/Editor/SourcePausePointCapture/SourcePausePointVariableFormatterTests.cs b/Assets/Tests/Editor/SourcePausePointCapture/SourcePausePointVariableFormatterTests.cs index 33590ba7e2..951f2ce067 100644 --- a/Assets/Tests/Editor/SourcePausePointCapture/SourcePausePointVariableFormatterTests.cs +++ b/Assets/Tests/Editor/SourcePausePointCapture/SourcePausePointVariableFormatterTests.cs @@ -7,6 +7,8 @@ using NUnit.Framework; +using UnityEditor; +using UnityEditorInternal; using UnityEngine; using UnityEngine.TestTools; @@ -100,6 +102,25 @@ public void Format_WhenValueExceedsMaxLength_TruncatesValueAndSetsTruncatedFlag( 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() { @@ -154,6 +175,22 @@ public void Format_WithHoistedAsyncLocalField_DemanglesFieldNameToLocalScope() 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() { @@ -190,6 +227,77 @@ public void Format_WithSceneGameObjectValue_ClassifiesAsSceneObject() 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() { diff --git a/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointCapture.cs b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointCapture.cs index 1b66c16fe4..6bb61efeb1 100644 --- a/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointCapture.cs +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointCapture.cs @@ -3,6 +3,7 @@ using UnityEngine; using io.github.hatayama.UnityCliLoop.Runtime; +using io.github.hatayama.UnityCliLoop.ToolContracts; namespace io.github.hatayama.UnityCliLoop.FirstPartyTools { @@ -27,7 +28,17 @@ public static void Capture( (List variables, bool truncated) = SourcePausePointVariableFormatter.Format( instance, parameterNamesAndValues, localNamesAndValues); - UloopPausePointRegistry.HitWithCapturedVariables(id, variables, truncated); + 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/SourcePausePointVariableFormatter.cs b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointVariableFormatter.cs index a824d56235..12733f7012 100644 --- a/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointVariableFormatter.cs +++ b/Packages/src/Editor/FirstPartyTools/PausePoint/SourcePausePointVariableFormatter.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Reflection; +using System.Runtime.CompilerServices; using System.Text.RegularExpressions; using UnityEngine; @@ -33,6 +34,8 @@ public static (List Variables, bool Truncated) Format( 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" @@ -40,10 +43,15 @@ public static (List Variables, bool Truncated) Format( // once as Parameter (from the array below) and again as InstanceField (from the walk). HashSet capturedNames = new(); - AppendPairs(results, capturedNames, ref truncated, localNamesAndValues, UloopCapturedVariableScope.Local); - AppendPairs(results, capturedNames, ref truncated, parameterNamesAndValues, UloopCapturedVariableScope.Parameter); + bool countCapReached = AppendPairs( + results, capturedNames, ref truncated, localNamesAndValues, UloopCapturedVariableScope.Local); + if (!countCapReached) + { + countCapReached = AppendPairs( + results, capturedNames, ref truncated, parameterNamesAndValues, UloopCapturedVariableScope.Parameter); + } - if (instance != null && !truncated) + if (instance != null && !countCapReached) { CollectInstanceFieldVariables(instance, results, capturedNames, ref truncated); } @@ -51,24 +59,23 @@ public static (List Variables, bool Truncated) Format( return (results, truncated); } - private static void AppendPairs( + // 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) { - if (truncated) - { - return; - } - string name = (string)namesAndValues[i]; object value = namesAndValues[i + 1]; if (!TryAppendVariable(results, capturedNames, ref truncated, name, scope, value)) { - return; + return true; } } + + return false; } // Async/iterator state machines hoist the original `this` into a `<>4__this` field; this @@ -77,8 +84,9 @@ private static void AppendPairs( private static void CollectInstanceFieldVariables( object instance, List results, HashSet capturedNames, ref bool truncated) { - object outerThis = CollectDirectFieldVariables(instance, results, capturedNames, ref truncated, followOuterThis: true); - if (truncated || outerThis == null) + (object outerThis, bool countCapReached) = CollectDirectFieldVariables( + instance, results, capturedNames, ref truncated, followOuterThis: true); + if (countCapReached || outerThis == null) { return; } @@ -86,18 +94,21 @@ private static void CollectInstanceFieldVariables( CollectDirectFieldVariables(outerThis, results, capturedNames, ref truncated, followOuterThis: false); } - private static object CollectDirectFieldVariables( + 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 (truncated) - { - return outerThis; - } - if (followOuterThis && field.Name == StateMachineOuterThisFieldName) { outerThis = field.GetValue(source); @@ -107,9 +118,13 @@ private static object CollectDirectFieldVariables( Match hoistedLocalMatch = HoistedLocalFieldNamePattern.Match(field.Name); if (hoistedLocalMatch.Success) { - TryAppendVariable( + if (!TryAppendVariable( results, capturedNames, ref truncated, hoistedLocalMatch.Groups[1].Value, - UloopCapturedVariableScope.Local, field.GetValue(source)); + UloopCapturedVariableScope.Local, field.GetValue(source))) + { + return (outerThis, true); + } + continue; } @@ -120,12 +135,13 @@ private static object CollectDirectFieldVariables( continue; } - TryAppendVariable( - results, capturedNames, ref truncated, field.Name, UloopCapturedVariableScope.InstanceField, - field.GetValue(source)); + if (!TryAppendVariable(results, capturedNames, ref truncated, field.Name, plainFieldScope, field.GetValue(source))) + { + return (outerThis, true); + } } - return outerThis; + return (outerThis, false); } private static IEnumerable EnumerateInstanceFields(Type type) 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/UloopPausePointRegistry.cs b/Packages/src/Runtime/PausePoints/UloopPausePointRegistry.cs index 583c1a2b07..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; @@ -110,8 +114,8 @@ public static UloopPausePointSnapshot HitWithCapturedVariables( return HitCore(id, capturedVariables, capturedVariablesTruncated); } - // id が armed でなければ辞書引き 1 回で return する。Harmony が注入した Capture 呼び出しは - // ほぼ常にこの不活性パスを通るため、ここでの割り当て・整形コストをゼロに保つことが重要。 + // 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))