@mflrevan/ucp 0.6.2 → 0.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +1 -1
  2. package/bridge/com.ucp.bridge/CHANGELOG.md +48 -0
  3. package/bridge/com.ucp.bridge/Editor/Bridge/BridgeServer.cs +55 -6
  4. package/bridge/com.ucp.bridge/Editor/Bridge/EditorStateSummary.cs +165 -0
  5. package/bridge/com.ucp.bridge/Editor/Bridge/EditorStateSummary.cs.meta +11 -0
  6. package/bridge/com.ucp.bridge/Editor/Compatibility/UnityObjectCompat.cs +22 -5
  7. package/bridge/com.ucp.bridge/Editor/Controllers/EditorController.cs +1 -0
  8. package/bridge/com.ucp.bridge/Editor/Controllers/HierarchyController.cs +10 -10
  9. package/bridge/com.ucp.bridge/Editor/Controllers/LogsController.cs +71 -0
  10. package/bridge/com.ucp.bridge/Editor/Controllers/MaterialController.cs +2 -2
  11. package/bridge/com.ucp.bridge/Editor/Controllers/ObjectLocator.cs +4 -4
  12. package/bridge/com.ucp.bridge/Editor/Controllers/ObjectReferenceResolver.cs +3 -3
  13. package/bridge/com.ucp.bridge/Editor/Controllers/PrefabController.cs +6 -6
  14. package/bridge/com.ucp.bridge/Editor/Controllers/PropertyController.cs +7 -7
  15. package/bridge/com.ucp.bridge/Editor/Controllers/RecordingController.cs +8 -0
  16. package/bridge/com.ucp.bridge/Editor/Controllers/SceneChangeTracker.cs +3 -3
  17. package/bridge/com.ucp.bridge/Editor/Controllers/SceneController.cs +3 -3
  18. package/bridge/com.ucp.bridge/Editor/Controllers/SnapshotController.cs +5 -5
  19. package/bridge/com.ucp.bridge/Editor/Controllers/TestRunnerController.cs +1 -1
  20. package/bridge/com.ucp.bridge/Editor/Controllers/TransformController.cs +1 -1
  21. package/bridge/com.ucp.bridge/Editor/Controllers/UiController.cs +70 -0
  22. package/bridge/com.ucp.bridge/Editor/Controllers/UiController.cs.meta +11 -0
  23. package/bridge/com.ucp.bridge/Editor/Controllers/ViewController.cs +1 -1
  24. package/bridge/com.ucp.bridge/Editor/Protocol/MiniJson.cs +1 -1
  25. package/bridge/com.ucp.bridge/Editor/Ui/UiHost.cs +468 -0
  26. package/bridge/com.ucp.bridge/Editor/Ui/UiHost.cs.meta +11 -0
  27. package/bridge/com.ucp.bridge/Editor/Ui/UiLintService.cs +554 -0
  28. package/bridge/com.ucp.bridge/Editor/Ui/UiLintService.cs.meta +11 -0
  29. package/bridge/com.ucp.bridge/Editor/Ui/UiOperationManager.cs +870 -0
  30. package/bridge/com.ucp.bridge/Editor/Ui/UiOperationManager.cs.meta +11 -0
  31. package/bridge/com.ucp.bridge/Editor/Ui/UiScenarioApplier.cs +496 -0
  32. package/bridge/com.ucp.bridge/Editor/Ui/UiScenarioApplier.cs.meta +11 -0
  33. package/bridge/com.ucp.bridge/Editor/Ui/UiScenarioLoader.cs +854 -0
  34. package/bridge/com.ucp.bridge/Editor/Ui/UiScenarioLoader.cs.meta +11 -0
  35. package/bridge/com.ucp.bridge/Editor/Ui/UiScenarioModels.cs +374 -0
  36. package/bridge/com.ucp.bridge/Editor/Ui/UiScenarioModels.cs.meta +11 -0
  37. package/bridge/com.ucp.bridge/Editor/Ui/UiVisualTreeInspector.cs +690 -0
  38. package/bridge/com.ucp.bridge/Editor/Ui/UiVisualTreeInspector.cs.meta +11 -0
  39. package/bridge/com.ucp.bridge/{Runtime.meta → Editor/Ui.meta} +1 -1
  40. package/bridge/com.ucp.bridge/Tests/Editor/ControllerSmokeTests.cs +79 -4
  41. package/bridge/com.ucp.bridge/Tests/Editor/SpatialVisualControllerTests.cs +2 -2
  42. package/bridge/com.ucp.bridge/Tests/Editor/UiControllerTests.cs +547 -0
  43. package/bridge/com.ucp.bridge/Tests/Editor/UiControllerTests.cs.meta +11 -0
  44. package/bridge/com.ucp.bridge/Tests/Editor/UiLintServiceTests.cs +253 -0
  45. package/bridge/com.ucp.bridge/Tests/Editor/UiLintServiceTests.cs.meta +11 -0
  46. package/bridge/com.ucp.bridge/Tests/Editor/UiScenarioTests.cs +587 -0
  47. package/bridge/com.ucp.bridge/Tests/Editor/UiScenarioTests.cs.meta +11 -0
  48. package/bridge/com.ucp.bridge/package.json +1 -1
  49. package/package.json +1 -1
  50. package/bridge/com.ucp.bridge/Runtime/UCP.Bridge.Runtime.asmdef +0 -14
  51. package/bridge/com.ucp.bridge/Runtime/UCP.Bridge.Runtime.asmdef.meta +0 -7
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @mflrevan/ucp
2
2
 
3
- Version `0.6.2` of the Unity Control Protocol CLI.
3
+ Version `0.6.3` of the Unity Control Protocol CLI.
4
4
 
5
5
  This package installs the `ucp` command, downloads the matching published binary for your platform during `postinstall`, and ships the matching Unity bridge payload inside the npm package.
6
6
 
@@ -1,5 +1,53 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.6.3] - 2026-09-07
4
+
5
+ ### Added
6
+
7
+ - Every response produced on the main thread carries an `editor` summary next to `result` or
8
+ `error` (`EditorStateSummary`): mode, active scene dirtiness, Console badge counts read
9
+ through `LogEntries.GetCountsByType`, entries logged since the request was dispatched, and
10
+ conditional flags for compile errors, compiling, importing, building, prefab stage, and
11
+ recording. The handshake, which is answered off the main thread, reports
12
+ `mainThreadTickAgeMs` and `compiling` so a client can recognise a blocked main thread or a
13
+ pending domain reload before sending a request. Added `editor/state`
14
+ to fetch the summary on its own.
15
+ - Added the `ui/list`, `ui/lint`, `ui/inspect`, `ui/screenshot`, `ui/check`, and `ui/status`
16
+ RPCs plus the `ui/result` completion notification for UI Toolkit authoring on Unity 6 and
17
+ newer. On older editors the methods are registered but return an explicit unsupported error.
18
+ - Lint runs the synchronous UXML/USS/TSS importers, reads their import logs and
19
+ `importedWithErrors`/`importedWithWarnings` flags, follows UI dependencies, and clone-probes
20
+ each `VisualTreeAsset` under a scoped log capture. Immutable package assets are inspected from
21
+ their existing import artifacts without a forced reimport.
22
+ - Render operations (`inspect`, `screenshot`, `check`) are queued and ticked on
23
+ `EditorApplication.update` in a transient `HideAndDontSave` utility window, wait for stable
24
+ finite geometry and stable pixels, capture through `InternalEditorUtility.CaptureEditorWindow`
25
+ with DPI compensation, write PNGs under `Library/UCP/UiCaptures`, and restore the previously
26
+ focused window. A 300-second overall ceiling and per-state settle timeouts bound every run;
27
+ `BridgeServer.Shutdown` fails in-flight operations with `editor_shutdown` before a reload.
28
+ - Scenario support: strict `.ucp-ui.json` parsing with unknown-field rejection, deep data
29
+ overlays, an allowlisted `set` block, `repeat` and virtualized `list-view` collections driven
30
+ by JSON pointers, and automatic rewriting of `DataBinding` paths to dictionary keys through
31
+ `Unity.Properties`.
32
+
33
+ ### Changed
34
+
35
+ - Object ids are 64-bit end to end. `UnityObjectCompat.GetId()` returns a `long`
36
+ (`EntityId.ToULong` on 6000.5+, the instance id below that), `ResolveByInstanceId` takes a
37
+ `long`, and every controller reads `instanceId` parameters with `Convert.ToInt64`. On
38
+ 6000.5 an `EntityId` carries a per-session upper word, so the previous `int` truncation
39
+ produced ids that could not be resolved back.
40
+
41
+ ### Fixed
42
+
43
+ - Fixed the `tests/run` console guard counting log lines emitted inside a test's
44
+ `LogAssert.ignoreFailingMessages` window; the `IgnoreFailingMessages:true/false` markers the
45
+ test runner writes now delimit an exclusion range.
46
+ - Fixed the edit-mode test assembly failing to compile on Unity 6000.5 (`GetInstanceID()` is
47
+ obsolete-as-error there); tests use the `EntityId`-aware `GetId()` helper throughout.
48
+ - Removed the empty `UCP.Bridge.Runtime` assembly definition that logged an "will not be
49
+ compiled, because it has no scripts associated with it" warning on every import (#4).
50
+
3
51
  ## [0.6.2] - 2026-08-31
4
52
 
5
53
  ### Added
@@ -26,7 +26,7 @@ namespace UCP.Bridge
26
26
  private const int DefaultPort = 21342;
27
27
  private const int MaxPort = 21352;
28
28
  private const int MaxConnections = 4;
29
- private const string ProtocolVersion = "0.6.2";
29
+ private const string ProtocolVersion = "0.6.3";
30
30
 
31
31
  private static TcpListener s_listener;
32
32
  private static CancellationTokenSource s_cts;
@@ -45,6 +45,14 @@ namespace UCP.Bridge
45
45
 
46
46
  // Main-thread action queue
47
47
  private static readonly ConcurrentQueue<Action> s_mainThreadQueue = new();
48
+ // UTC ticks of the last EditorApplication.update we saw. Written by the main thread,
49
+ // read by the socket thread in the handshake so a client can tell "the editor's main
50
+ // thread is stalled" (modal dialog, synchronous import, domain reload) apart from "busy".
51
+ private static long s_lastMainThreadTick;
52
+ // EditorApplication.isCompiling as of the last pump, so the off-thread handshake can warn
53
+ // a client that a domain reload is imminent instead of letting its request race it.
54
+ private static int s_compiling;
55
+ private static bool s_summaryFailureLogged;
48
56
 
49
57
  // Command router
50
58
  private static readonly CommandRouter s_router = new();
@@ -120,7 +128,9 @@ namespace UCP.Bridge
120
128
  protocolVersion = ProtocolVersion,
121
129
  unityVersion = s_unityVersion,
122
130
  projectName = s_projectName,
123
- projectPath = s_projectPath
131
+ projectPath = s_projectPath,
132
+ mainThreadTickAgeMs = MainThreadTickAgeMs(),
133
+ compiling = System.Threading.Volatile.Read(ref s_compiling) == 1
124
134
  };
125
135
  });
126
136
 
@@ -145,6 +155,9 @@ namespace UCP.Bridge
145
155
  // Lightweight video recording
146
156
  RecordingController.Register(s_router);
147
157
 
158
+ // UI Toolkit authoring, inspection, linting, and capture
159
+ UiController.Register(s_router);
160
+
148
161
  // Logs
149
162
  LogsController.Register(s_router);
150
163
 
@@ -435,16 +448,25 @@ namespace UCP.Bridge
435
448
  // Dispatch on main thread
436
449
  s_mainThreadQueue.Enqueue(() =>
437
450
  {
451
+ var logCursor = LogsController.GetLatestId();
438
452
  var response = s_router.Dispatch(capturedMethod, capturedId, capturedParams);
439
- SendResponse(capturedWs, response);
453
+ SendResponse(capturedWs, response, logCursor);
440
454
  });
441
455
  }
442
456
 
443
- private static void SendResponse(WebSocket ws, JsonRpcResponse response)
457
+ /// <summary>Milliseconds since the main thread last pumped; -1 before the first pump.</summary>
458
+ internal static long MainThreadTickAgeMs()
459
+ {
460
+ var last = System.Threading.Volatile.Read(ref s_lastMainThreadTick);
461
+ if (last == 0) return -1;
462
+ return (DateTime.UtcNow.Ticks - last) / TimeSpan.TicksPerMillisecond;
463
+ }
464
+
465
+ private static void SendResponse(WebSocket ws, JsonRpcResponse response, long? logCursor = null)
444
466
  {
445
467
  try
446
468
  {
447
- var json = MiniJson.Serialize(ResponseToDict(response));
469
+ var json = MiniJson.Serialize(ResponseToDict(response, logCursor));
448
470
  var bytes = Encoding.UTF8.GetBytes(json);
449
471
  _ = ws.SendAsync(new ArraySegment<byte>(bytes),
450
472
  WebSocketMessageType.Text, true, CancellationToken.None);
@@ -521,6 +543,8 @@ namespace UCP.Bridge
521
543
 
522
544
  private static void PumpMainThread()
523
545
  {
546
+ System.Threading.Volatile.Write(ref s_lastMainThreadTick, DateTime.UtcNow.Ticks);
547
+ System.Threading.Volatile.Write(ref s_compiling, EditorApplication.isCompiling ? 1 : 0);
524
548
  int processed = 0;
525
549
  while (s_mainThreadQueue.TryDequeue(out var action) && processed < 50)
526
550
  {
@@ -574,6 +598,9 @@ namespace UCP.Bridge
574
598
 
575
599
  private static void Shutdown()
576
600
  {
601
+ #if UNITY_6000_0_OR_NEWER
602
+ UiOperationManager.Shutdown();
603
+ #endif
577
604
  if (!s_running) return;
578
605
  s_running = false;
579
606
 
@@ -608,7 +635,12 @@ namespace UCP.Bridge
608
635
  Application.logMessageReceivedThreaded -= OnLogMessage;
609
636
  }
610
637
 
611
- private static Dictionary<string, object> ResponseToDict(JsonRpcResponse r)
638
+ /// <param name="logCursor">
639
+ /// Present for responses produced on the main thread: the log id captured before dispatch.
640
+ /// The editor-state summary is attached as a sibling of result/error, and only then --
641
+ /// the handshake is answered off-thread where none of the editor APIs may be touched.
642
+ /// </param>
643
+ internal static Dictionary<string, object> ResponseToDict(JsonRpcResponse r, long? logCursor = null)
612
644
  {
613
645
  var dict = new Dictionary<string, object>
614
646
  {
@@ -616,6 +648,23 @@ namespace UCP.Bridge
616
648
  ["id"] = r.id
617
649
  };
618
650
 
651
+ if (logCursor.HasValue)
652
+ {
653
+ try
654
+ {
655
+ dict["editor"] = EditorStateSummary.Capture(logCursor.Value);
656
+ }
657
+ catch (Exception ex)
658
+ {
659
+ // Never let awareness break a response; note the failure once per domain.
660
+ if (!s_summaryFailureLogged)
661
+ {
662
+ s_summaryFailureLogged = true;
663
+ Debug.LogWarning($"[UCP] editor state summary unavailable: {ex.Message}");
664
+ }
665
+ }
666
+ }
667
+
619
668
  if (r.error != null)
620
669
  {
621
670
  dict["error"] = new Dictionary<string, object>
@@ -0,0 +1,165 @@
1
+ using System;
2
+ using System.Collections.Generic;
3
+ using System.Reflection;
4
+ using UnityEditor;
5
+ using UnityEditor.SceneManagement;
6
+ using UnityEngine;
7
+ using UnityEngine.SceneManagement;
8
+
9
+ namespace UCP.Bridge
10
+ {
11
+ /// <summary>
12
+ /// The compact editor state that rides on every main-thread response, so an agent is told
13
+ /// whether the console is red, whether the editor is playing or compiling, and whether the
14
+ /// scene is dirty without spending a request on it.
15
+ ///
16
+ /// Budget: this runs once per response on the main thread, so it may only use O(1) editor
17
+ /// queries and cached reflection. It must never allocate more than a few small dictionaries,
18
+ /// never touch the asset database, and never throw -- a summary failure is swallowed by the
19
+ /// caller so it can never break a response.
20
+ /// </summary>
21
+ public static class EditorStateSummary
22
+ {
23
+ private delegate void GetCountsByType(ref int errors, ref int warnings, ref int logs);
24
+
25
+ private static GetCountsByType s_getCountsByType;
26
+ private static bool s_countsLookedUp;
27
+
28
+ /// <param name="logsSinceId">
29
+ /// The log history cursor captured before the request was dispatched; entries after it
30
+ /// were produced by this request and are reported as "new".
31
+ /// </param>
32
+ public static Dictionary<string, object> Capture(long logsSinceId)
33
+ {
34
+ var summary = new Dictionary<string, object>(8);
35
+
36
+ var playing = EditorApplication.isPlaying;
37
+ summary["mode"] = playing
38
+ ? (EditorApplication.isPaused ? "paused" : "play")
39
+ : "edit";
40
+ if (EditorApplication.isPlayingOrWillChangePlaymode != playing)
41
+ summary["modeChanging"] = true;
42
+ if (EditorApplication.isCompiling)
43
+ summary["compiling"] = true;
44
+ if (EditorApplication.isUpdating)
45
+ summary["importing"] = true;
46
+ if (EditorUtility.scriptCompilationFailed)
47
+ summary["compileErrors"] = true;
48
+ if (BuildPipeline.isBuildingPlayer)
49
+ summary["building"] = true;
50
+
51
+ summary["scene"] = DescribeScenes();
52
+
53
+ var stage = PrefabStageUtility.GetCurrentPrefabStage();
54
+ if (stage != null)
55
+ summary["prefabStage"] = stage.assetPath;
56
+
57
+ summary["console"] = DescribeConsole(logsSinceId);
58
+
59
+ var recording = RecordingController.StateForSummary();
60
+ if (recording != null)
61
+ summary["recording"] = recording;
62
+
63
+ return summary;
64
+ }
65
+
66
+ private static Dictionary<string, object> DescribeScenes()
67
+ {
68
+ var active = SceneManager.GetActiveScene();
69
+ var scene = new Dictionary<string, object>(4)
70
+ {
71
+ ["name"] = active.name,
72
+ ["dirty"] = active.isDirty
73
+ };
74
+ if (string.IsNullOrEmpty(active.path))
75
+ scene["untitled"] = true;
76
+
77
+ var count = SceneManager.sceneCount;
78
+ if (count > 1)
79
+ {
80
+ var otherDirty = 0;
81
+ for (var index = 0; index < count; index++)
82
+ {
83
+ var candidate = SceneManager.GetSceneAt(index);
84
+ if (candidate != active && candidate.isDirty)
85
+ otherDirty++;
86
+ }
87
+ scene["loaded"] = count;
88
+ if (otherDirty > 0)
89
+ scene["otherDirty"] = otherDirty;
90
+ }
91
+
92
+ return scene;
93
+ }
94
+
95
+ private static Dictionary<string, object> DescribeConsole(long logsSinceId)
96
+ {
97
+ var console = new Dictionary<string, object>(4);
98
+ if (TryReadConsoleCounts(out var errors, out var warnings))
99
+ {
100
+ console["errors"] = errors;
101
+ console["warnings"] = warnings;
102
+ }
103
+ else
104
+ {
105
+ // Reflection into UnityEditor.LogEntries failed on this editor build; fall back to
106
+ // the bridge's own history, which at least covers everything since the bridge loaded.
107
+ LogsController.CountLevels(0, out errors, out warnings);
108
+ console["errors"] = errors;
109
+ console["warnings"] = warnings;
110
+ console["source"] = "bridge";
111
+ }
112
+
113
+ LogsController.CountLevels(logsSinceId, out var newErrors, out var newWarnings);
114
+ if (newErrors > 0)
115
+ console["newErrors"] = newErrors;
116
+ if (newWarnings > 0)
117
+ console["newWarnings"] = newWarnings;
118
+ return console;
119
+ }
120
+
121
+ /// <summary>
122
+ /// The counts the Console window's toolbar badges show, read through the same internal
123
+ /// API the window uses. Cached after the first lookup; the call itself is native and cheap.
124
+ /// </summary>
125
+ internal static bool TryReadConsoleCounts(out int errors, out int warnings)
126
+ {
127
+ errors = 0;
128
+ warnings = 0;
129
+ if (!s_countsLookedUp)
130
+ {
131
+ s_countsLookedUp = true;
132
+ try
133
+ {
134
+ var type = typeof(Editor).Assembly.GetType("UnityEditor.LogEntries");
135
+ var method = type?.GetMethod(
136
+ "GetCountsByType",
137
+ BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static);
138
+ if (method != null)
139
+ {
140
+ s_getCountsByType = (GetCountsByType)Delegate.CreateDelegate(
141
+ typeof(GetCountsByType), method, false);
142
+ }
143
+ }
144
+ catch
145
+ {
146
+ s_getCountsByType = null;
147
+ }
148
+ }
149
+
150
+ if (s_getCountsByType == null)
151
+ return false;
152
+
153
+ try
154
+ {
155
+ var logs = 0;
156
+ s_getCountsByType(ref errors, ref warnings, ref logs);
157
+ return true;
158
+ }
159
+ catch
160
+ {
161
+ return false;
162
+ }
163
+ }
164
+ }
165
+ }
@@ -0,0 +1,11 @@
1
+ fileFormatVersion: 2
2
+ guid: 81e4ee8b39d34106904926156faa3bfe
3
+ MonoImporter:
4
+ externalObjects: {}
5
+ serializedVersion: 2
6
+ defaultReferences: []
7
+ executionOrder: 0
8
+ icon: {instanceID: 0}
9
+ userData:
10
+ assetBundleName:
11
+ assetBundleVariant:
@@ -4,17 +4,30 @@ using UnityEngine.SceneManagement;
4
4
 
5
5
  namespace UCP.Bridge
6
6
  {
7
+ /// <summary>
8
+ /// Object identity across the Unity 6 line. Up to 6000.4 an object is identified by a 32-bit
9
+ /// instance id; from 6000.5 it is a 64-bit <c>EntityId</c> whose upper word is a session
10
+ /// stamp and whose lower word is the classic id, and neither sign- nor zero-extending the
11
+ /// lower word resolves the object again. The wire protocol therefore carries ids as 64-bit
12
+ /// integers everywhere. On older editors the values are the same numbers they always were.
13
+ /// </summary>
7
14
  internal static class UnityObjectCompat
8
15
  {
9
- public static int GetId(this Object obj)
16
+ public static long GetId(this Object obj)
10
17
  {
11
18
  #if UNITY_6000_5_OR_NEWER
12
- return unchecked((int)EntityId.ToULong(obj.GetEntityId()));
19
+ return unchecked((long)EntityId.ToULong(obj.GetEntityId()));
13
20
  #else
14
21
  return obj.GetInstanceID();
15
22
  #endif
16
23
  }
17
24
 
25
+ /// <summary>Reads an id parameter from a parsed JSON payload (int, long, or numeric string).</summary>
26
+ public static long ReadId(object value)
27
+ {
28
+ return System.Convert.ToInt64(value);
29
+ }
30
+
18
31
  public static long GetSceneHandle(Scene scene)
19
32
  {
20
33
  #if UNITY_6000_5_OR_NEWER
@@ -27,16 +40,20 @@ namespace UCP.Bridge
27
40
  #endif
28
41
  }
29
42
 
30
- public static Object ResolveByInstanceId(int instanceId)
43
+ public static Object ResolveByInstanceId(long instanceId)
31
44
  {
32
45
  #if UNITY_6000_5_OR_NEWER
33
46
  return EditorUtility.EntityIdToObject(EntityId.FromULong(unchecked((ulong)instanceId)));
34
47
  #else
35
- return EditorUtility.InstanceIDToObject(instanceId);
48
+ // 32-bit editors cannot have handed out anything wider; treat an out-of-range id as
49
+ // "no such object" rather than truncating it into a different object's id.
50
+ if (instanceId < int.MinValue || instanceId > int.MaxValue)
51
+ return null;
52
+ return EditorUtility.InstanceIDToObject((int)instanceId);
36
53
  #endif
37
54
  }
38
55
 
39
- public static T ResolveByInstanceId<T>(int instanceId) where T : Object
56
+ public static T ResolveByInstanceId<T>(long instanceId) where T : Object
40
57
  {
41
58
  return ResolveByInstanceId(instanceId) as T;
42
59
  }
@@ -7,6 +7,7 @@ namespace UCP.Bridge
7
7
  public static void Register(CommandRouter router)
8
8
  {
9
9
  router.Register("editor/status", HandleStatus);
10
+ router.Register("editor/state", _ => EditorStateSummary.Capture(LogsController.GetLatestId()));
10
11
  router.Register("editor/quit", HandleQuit);
11
12
  }
12
13
 
@@ -44,7 +44,7 @@ namespace UCP.Bridge
44
44
  // Optional parent
45
45
  if (p != null && p.TryGetValue("parent", out var parentObj))
46
46
  {
47
- int parentId = Convert.ToInt32(parentObj);
47
+ long parentId = Convert.ToInt64(parentObj);
48
48
  var parent = FindGameObject(parentId);
49
49
  go.transform.SetParent(parent.transform, false);
50
50
  }
@@ -84,7 +84,7 @@ namespace UCP.Bridge
84
84
  if (p == null || !p.TryGetValue("instanceId", out var idObj))
85
85
  throw new ArgumentException("Missing 'instanceId' parameter");
86
86
 
87
- int instanceId = Convert.ToInt32(idObj);
87
+ long instanceId = Convert.ToInt64(idObj);
88
88
  var go = FindGameObject(instanceId);
89
89
  string name = go.name;
90
90
  var scene = go.scene;
@@ -107,14 +107,14 @@ namespace UCP.Bridge
107
107
  if (p == null || !p.TryGetValue("instanceId", out var idObj))
108
108
  throw new ArgumentException("Missing 'instanceId' parameter");
109
109
 
110
- int instanceId = Convert.ToInt32(idObj);
110
+ long instanceId = Convert.ToInt64(idObj);
111
111
  var go = FindGameObject(instanceId);
112
112
 
113
113
  Undo.SetTransformParent(go.transform, null, "UCP Reparent");
114
114
 
115
115
  if (p.TryGetValue("parent", out var parentObj) && parentObj != null)
116
116
  {
117
- int parentId = Convert.ToInt32(parentObj);
117
+ long parentId = Convert.ToInt64(parentObj);
118
118
  var parent = FindGameObject(parentId);
119
119
  Undo.SetTransformParent(go.transform, parent.transform, "UCP Reparent");
120
120
  }
@@ -163,7 +163,7 @@ namespace UCP.Bridge
163
163
  // Instantiate from existing scene object (clone)
164
164
  else if (p.TryGetValue("sourceId", out var srcObj))
165
165
  {
166
- int srcId = Convert.ToInt32(srcObj);
166
+ long srcId = Convert.ToInt64(srcObj);
167
167
  source = FindGameObject(srcId);
168
168
  }
169
169
  else
@@ -183,7 +183,7 @@ namespace UCP.Bridge
183
183
  // Optional parent
184
184
  if (p.TryGetValue("parent", out var parentObj) && parentObj != null)
185
185
  {
186
- int parentId = Convert.ToInt32(parentObj);
186
+ long parentId = Convert.ToInt64(parentObj);
187
187
  var parent = FindGameObject(parentId);
188
188
  instance.transform.SetParent(parent.transform, false);
189
189
  }
@@ -222,7 +222,7 @@ namespace UCP.Bridge
222
222
  if (!p.TryGetValue("type", out var typeObj) || typeObj == null)
223
223
  throw new ArgumentException("Missing 'type' parameter");
224
224
 
225
- int instanceId = Convert.ToInt32(idObj);
225
+ long instanceId = Convert.ToInt64(idObj);
226
226
  var go = FindGameObject(instanceId);
227
227
  string typeName = typeObj.ToString();
228
228
 
@@ -252,7 +252,7 @@ namespace UCP.Bridge
252
252
  if (!p.TryGetValue("type", out var typeObj) || typeObj == null)
253
253
  throw new ArgumentException("Missing 'type' parameter");
254
254
 
255
- int instanceId = Convert.ToInt32(idObj);
255
+ long instanceId = Convert.ToInt64(idObj);
256
256
  var go = FindGameObject(instanceId);
257
257
  string typeName = typeObj.ToString();
258
258
 
@@ -344,7 +344,7 @@ namespace UCP.Bridge
344
344
  return null;
345
345
  }
346
346
 
347
- private static GameObject FindGameObject(int instanceId)
347
+ private static GameObject FindGameObject(long instanceId)
348
348
  {
349
349
  var obj = UnityObjectCompat.ResolveByInstanceId<GameObject>(instanceId);
350
350
  if (obj != null) return obj;
@@ -363,7 +363,7 @@ namespace UCP.Bridge
363
363
  throw new ArgumentException($"GameObject not found: {instanceId}");
364
364
  }
365
365
 
366
- private static GameObject FindInHierarchy(GameObject go, int instanceId)
366
+ private static GameObject FindInHierarchy(GameObject go, long instanceId)
367
367
  {
368
368
  if (go.GetId() == instanceId) return go;
369
369
  for (int i = 0; i < go.transform.childCount; i++)
@@ -109,6 +109,30 @@ namespace UCP.Bridge
109
109
  }
110
110
  }
111
111
 
112
+ /// <summary>
113
+ /// Error/exception and warning counts in the bridge history after <paramref name="afterId"/>.
114
+ /// Walks back from the newest entry and stops at the cursor, so the cost is proportional to
115
+ /// what the current request logged, not to the history size.
116
+ /// </summary>
117
+ public static void CountLevels(long afterId, out int errors, out int warnings)
118
+ {
119
+ errors = 0;
120
+ warnings = 0;
121
+ lock (s_historyLock)
122
+ {
123
+ for (var index = s_history.Count - 1; index >= 0; index--)
124
+ {
125
+ var entry = s_history[index];
126
+ if (entry.Id <= afterId)
127
+ break;
128
+ if (entry.Level == "error" || entry.Level == "exception")
129
+ errors++;
130
+ else if (entry.Level == "warning")
131
+ warnings++;
132
+ }
133
+ }
134
+ }
135
+
112
136
  public static long GetLatestId()
113
137
  {
114
138
  SeedHistoryFromConsole();
@@ -132,6 +156,53 @@ namespace UCP.Bridge
132
156
  }
133
157
  }
134
158
 
159
+ /// <summary>
160
+ /// Status summary for the test-run console guard. Unity's test runner writes
161
+ /// "IgnoreFailingMessages:true" / "IgnoreFailingMessages:false" log lines whenever a test
162
+ /// toggles <c>LogAssert.ignoreFailingMessages</c>; everything logged inside such a window is
163
+ /// noise the test declared as expected, so it must not fail the guard. The markers
164
+ /// themselves are dropped as well.
165
+ /// </summary>
166
+ public static Dictionary<string, object> BuildTestGuardSummary(long afterId)
167
+ {
168
+ SeedHistoryFromConsole();
169
+ lock (s_historyLock)
170
+ {
171
+ var ordered = s_history
172
+ .Where(entry => entry.Id > afterId)
173
+ .OrderBy(entry => entry.Id)
174
+ .ToList();
175
+
176
+ return BuildStatusResult(ExcludeExpectedNoise(ordered, entry => entry.Message), afterId);
177
+ }
178
+ }
179
+
180
+ internal const string IgnoreFailingMessagesOnMarker = "IgnoreFailingMessages:true";
181
+ internal const string IgnoreFailingMessagesOffMarker = "IgnoreFailingMessages:false";
182
+
183
+ internal static List<T> ExcludeExpectedNoise<T>(IEnumerable<T> entries, Func<T, string> message)
184
+ {
185
+ var result = new List<T>();
186
+ var ignoring = false;
187
+ foreach (var entry in entries)
188
+ {
189
+ var text = (message(entry) ?? string.Empty).Trim();
190
+ if (text == IgnoreFailingMessagesOnMarker)
191
+ {
192
+ ignoring = true;
193
+ continue;
194
+ }
195
+ if (text == IgnoreFailingMessagesOffMarker)
196
+ {
197
+ ignoring = false;
198
+ continue;
199
+ }
200
+ if (!ignoring)
201
+ result.Add(entry);
202
+ }
203
+ return result;
204
+ }
205
+
135
206
  private static object HandleTail(string paramsJson)
136
207
  {
137
208
  var query = ParseQuery(paramsJson, includePattern: false);
@@ -227,7 +227,7 @@ namespace UCP.Bridge
227
227
  // Resolve by instanceId (scene object's renderer material)
228
228
  if (p.TryGetValue("instanceId", out var idObj))
229
229
  {
230
- int instanceId = Convert.ToInt32(idObj);
230
+ long instanceId = Convert.ToInt64(idObj);
231
231
  var obj = UnityObjectCompat.ResolveByInstanceId(instanceId);
232
232
 
233
233
  if (obj is Material directMat)
@@ -330,7 +330,7 @@ namespace UCP.Bridge
330
330
  }
331
331
  else if (texDict.TryGetValue("instanceId", out var tid))
332
332
  {
333
- var tex = UnityObjectCompat.ResolveByInstanceId<Texture>(Convert.ToInt32(tid));
333
+ var tex = UnityObjectCompat.ResolveByInstanceId<Texture>(Convert.ToInt64(tid));
334
334
  mat.SetTexture(propName, tex);
335
335
  }
336
336
  }
@@ -9,7 +9,7 @@ namespace UCP.Bridge
9
9
  /// Shared GameObject resolution for the spatial/visual controllers.
10
10
  ///
11
11
  /// A target may be addressed three ways, tried in priority order:
12
- /// 1. instanceId (int) — canonical, survives nothing but a domain reload; preferred.
12
+ /// 1. instanceId (64-bit integer) — canonical, survives nothing but a domain reload; preferred.
13
13
  /// 2. path (string) — hierarchy path "Root/Child/Leaf" (leading '/' optional),
14
14
  /// resolved across all loaded scenes; survives reloads.
15
15
  /// 3. name (string) — first GameObject whose name matches; ambiguous under
@@ -32,7 +32,7 @@ namespace UCP.Bridge
32
32
 
33
33
  if ((p.TryGetValue("instanceId", out var idObj) || p.TryGetValue("id", out idObj)) && idObj != null)
34
34
  {
35
- var id = Convert.ToInt32(idObj);
35
+ var id = Convert.ToInt64(idObj);
36
36
  var byId = FindByInstanceId(id);
37
37
  if (byId != null)
38
38
  return byId;
@@ -60,7 +60,7 @@ namespace UCP.Bridge
60
60
  throw new ArgumentException("Missing target: provide 'instanceId', 'path', or 'name'");
61
61
  }
62
62
 
63
- internal static GameObject FindByInstanceId(int instanceId)
63
+ internal static GameObject FindByInstanceId(long instanceId)
64
64
  {
65
65
  var direct = UnityObjectCompat.ResolveByInstanceId<GameObject>(instanceId);
66
66
  if (direct != null)
@@ -82,7 +82,7 @@ namespace UCP.Bridge
82
82
  return null;
83
83
  }
84
84
 
85
- private static GameObject FindInHierarchyById(GameObject go, int instanceId)
85
+ private static GameObject FindInHierarchyById(GameObject go, long instanceId)
86
86
  {
87
87
  if (go.GetId() == instanceId)
88
88
  return go;