@mflrevan/ucp 0.5.2 → 0.6.1

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 (37) hide show
  1. package/README.md +1 -1
  2. package/bridge/com.ucp.bridge/Editor/AssemblyInfo.cs +3 -0
  3. package/bridge/com.ucp.bridge/Editor/AssemblyInfo.cs.meta +2 -0
  4. package/bridge/com.ucp.bridge/Editor/Bridge/BridgeServer.cs +45 -5
  5. package/bridge/com.ucp.bridge/Editor/Compatibility/UnityObjectCompat.cs +26 -0
  6. package/bridge/com.ucp.bridge/Editor/Controllers/AssetController.cs +2 -2
  7. package/bridge/com.ucp.bridge/Editor/Controllers/CompilationController.cs +137 -1
  8. package/bridge/com.ucp.bridge/Editor/Controllers/EditorModalGuard.cs +60 -0
  9. package/bridge/com.ucp.bridge/Editor/Controllers/EditorModalGuard.cs.meta +2 -0
  10. package/bridge/com.ucp.bridge/Editor/Controllers/HierarchyController.cs +56 -5
  11. package/bridge/com.ucp.bridge/Editor/Controllers/MaterialController.cs +2 -2
  12. package/bridge/com.ucp.bridge/Editor/Controllers/ObjectLocator.cs +207 -0
  13. package/bridge/com.ucp.bridge/Editor/Controllers/ObjectLocator.cs.meta +2 -0
  14. package/bridge/com.ucp.bridge/Editor/Controllers/ObjectReferenceResolver.cs +1 -1
  15. package/bridge/com.ucp.bridge/Editor/Controllers/PlayModeController.cs +1 -35
  16. package/bridge/com.ucp.bridge/Editor/Controllers/PrefabController.cs +3 -3
  17. package/bridge/com.ucp.bridge/Editor/Controllers/ProfilerController.cs +80 -5
  18. package/bridge/com.ucp.bridge/Editor/Controllers/PropertyController.cs +1 -1
  19. package/bridge/com.ucp.bridge/Editor/Controllers/ReferenceController.cs +1 -1
  20. package/bridge/com.ucp.bridge/Editor/Controllers/SceneChangeTracker.cs +6 -6
  21. package/bridge/com.ucp.bridge/Editor/Controllers/SceneController.cs +26 -36
  22. package/bridge/com.ucp.bridge/Editor/Controllers/ScriptController.cs +82 -23
  23. package/bridge/com.ucp.bridge/Editor/Controllers/SnapshotController.cs +4 -4
  24. package/bridge/com.ucp.bridge/Editor/Controllers/SpatialController.cs +322 -0
  25. package/bridge/com.ucp.bridge/Editor/Controllers/SpatialController.cs.meta +2 -0
  26. package/bridge/com.ucp.bridge/Editor/Controllers/TransformController.cs +249 -0
  27. package/bridge/com.ucp.bridge/Editor/Controllers/TransformController.cs.meta +2 -0
  28. package/bridge/com.ucp.bridge/Editor/Controllers/ViewController.cs +415 -0
  29. package/bridge/com.ucp.bridge/Editor/Controllers/ViewController.cs.meta +2 -0
  30. package/bridge/com.ucp.bridge/Editor/Protocol/MiniJson.cs +409 -63
  31. package/bridge/com.ucp.bridge/Tests/Editor/ControllerSmokeTests.cs +131 -13
  32. package/bridge/com.ucp.bridge/Tests/Editor/MiniJsonSerializerTests.cs +221 -0
  33. package/bridge/com.ucp.bridge/Tests/Editor/MiniJsonSerializerTests.cs.meta +2 -0
  34. package/bridge/com.ucp.bridge/Tests/Editor/SpatialVisualControllerTests.cs +252 -0
  35. package/bridge/com.ucp.bridge/Tests/Editor/SpatialVisualControllerTests.cs.meta +2 -0
  36. package/bridge/com.ucp.bridge/package.json +1 -1
  37. package/package.json +1 -1
@@ -0,0 +1,207 @@
1
+ using System;
2
+ using System.Collections.Generic;
3
+ using UnityEngine;
4
+ using UnityEngine.SceneManagement;
5
+
6
+ namespace UCP.Bridge
7
+ {
8
+ /// <summary>
9
+ /// Shared GameObject resolution for the spatial/visual controllers.
10
+ ///
11
+ /// A target may be addressed three ways, tried in priority order:
12
+ /// 1. instanceId (int) — canonical, survives nothing but a domain reload; preferred.
13
+ /// 2. path (string) — hierarchy path "Root/Child/Leaf" (leading '/' optional),
14
+ /// resolved across all loaded scenes; survives reloads.
15
+ /// 3. name (string) — first GameObject whose name matches; ambiguous under
16
+ /// duplicates, so it is the last resort.
17
+ ///
18
+ /// instanceId stays the deterministic handle. path/name are convenience fallbacks so an
19
+ /// agent does not have to re-snapshot after every reload just to re-acquire an id.
20
+ /// </summary>
21
+ internal static class ObjectLocator
22
+ {
23
+ /// <summary>
24
+ /// Resolve a GameObject from a params dictionary using whichever of
25
+ /// instanceId / id / path / name is present (in that priority order).
26
+ /// Throws ArgumentException if none are present or nothing resolves.
27
+ /// </summary>
28
+ internal static GameObject Resolve(Dictionary<string, object> p)
29
+ {
30
+ if (p == null)
31
+ throw new ArgumentException("Missing target: provide 'instanceId', 'path', or 'name'");
32
+
33
+ if ((p.TryGetValue("instanceId", out var idObj) || p.TryGetValue("id", out idObj)) && idObj != null)
34
+ {
35
+ var id = Convert.ToInt32(idObj);
36
+ var byId = FindByInstanceId(id);
37
+ if (byId != null)
38
+ return byId;
39
+ throw new ArgumentException($"GameObject not found for instanceId {id}");
40
+ }
41
+
42
+ if (p.TryGetValue("path", out var pathObj) && pathObj != null)
43
+ {
44
+ var path = pathObj.ToString();
45
+ var byPath = FindByPath(path);
46
+ if (byPath != null)
47
+ return byPath;
48
+ throw new ArgumentException($"GameObject not found for path '{path}'");
49
+ }
50
+
51
+ if (p.TryGetValue("name", out var nameObj) && nameObj != null)
52
+ {
53
+ var name = nameObj.ToString();
54
+ var byName = FindByName(name);
55
+ if (byName != null)
56
+ return byName;
57
+ throw new ArgumentException($"GameObject not found for name '{name}'");
58
+ }
59
+
60
+ throw new ArgumentException("Missing target: provide 'instanceId', 'path', or 'name'");
61
+ }
62
+
63
+ internal static GameObject FindByInstanceId(int instanceId)
64
+ {
65
+ var direct = UnityObjectCompat.ResolveByInstanceId<GameObject>(instanceId);
66
+ if (direct != null)
67
+ return direct;
68
+
69
+ for (var i = 0; i < SceneManager.sceneCount; i++)
70
+ {
71
+ var scene = SceneManager.GetSceneAt(i);
72
+ if (!scene.isLoaded)
73
+ continue;
74
+ foreach (var root in scene.GetRootGameObjects())
75
+ {
76
+ var found = FindInHierarchyById(root, instanceId);
77
+ if (found != null)
78
+ return found;
79
+ }
80
+ }
81
+
82
+ return null;
83
+ }
84
+
85
+ private static GameObject FindInHierarchyById(GameObject go, int instanceId)
86
+ {
87
+ if (go.GetId() == instanceId)
88
+ return go;
89
+ foreach (Transform child in go.transform)
90
+ {
91
+ var found = FindInHierarchyById(child.gameObject, instanceId);
92
+ if (found != null)
93
+ return found;
94
+ }
95
+ return null;
96
+ }
97
+
98
+ internal static GameObject FindByPath(string path)
99
+ {
100
+ if (string.IsNullOrEmpty(path))
101
+ return null;
102
+
103
+ var trimmed = path.Trim('/');
104
+ var segments = trimmed.Split('/');
105
+ if (segments.Length == 0)
106
+ return null;
107
+
108
+ for (var i = 0; i < SceneManager.sceneCount; i++)
109
+ {
110
+ var scene = SceneManager.GetSceneAt(i);
111
+ if (!scene.isLoaded)
112
+ continue;
113
+ foreach (var root in scene.GetRootGameObjects())
114
+ {
115
+ if (root.name != segments[0])
116
+ continue;
117
+ var resolved = WalkPath(root.transform, segments, 1);
118
+ if (resolved != null)
119
+ return resolved;
120
+ }
121
+ }
122
+
123
+ return null;
124
+ }
125
+
126
+ private static GameObject WalkPath(Transform current, string[] segments, int index)
127
+ {
128
+ if (index >= segments.Length)
129
+ return current.gameObject;
130
+
131
+ foreach (Transform child in current)
132
+ {
133
+ if (child.name == segments[index])
134
+ {
135
+ var resolved = WalkPath(child, segments, index + 1);
136
+ if (resolved != null)
137
+ return resolved;
138
+ }
139
+ }
140
+
141
+ return null;
142
+ }
143
+
144
+ internal static GameObject FindByName(string name)
145
+ {
146
+ if (string.IsNullOrEmpty(name))
147
+ return null;
148
+
149
+ for (var i = 0; i < SceneManager.sceneCount; i++)
150
+ {
151
+ var scene = SceneManager.GetSceneAt(i);
152
+ if (!scene.isLoaded)
153
+ continue;
154
+ foreach (var root in scene.GetRootGameObjects())
155
+ {
156
+ var found = FindInHierarchyByName(root, name);
157
+ if (found != null)
158
+ return found;
159
+ }
160
+ }
161
+
162
+ return null;
163
+ }
164
+
165
+ private static GameObject FindInHierarchyByName(GameObject go, string name)
166
+ {
167
+ if (go.name == name)
168
+ return go;
169
+ foreach (Transform child in go.transform)
170
+ {
171
+ var found = FindInHierarchyByName(child.gameObject, name);
172
+ if (found != null)
173
+ return found;
174
+ }
175
+ return null;
176
+ }
177
+
178
+ /// <summary>Compute a world-space AABB encapsulating the object's renderers and colliders.</summary>
179
+ internal static bool TryComputeWorldBounds(GameObject target, bool includeChildren, out Bounds bounds)
180
+ {
181
+ var hasBounds = false;
182
+ bounds = new Bounds(target.transform.position, Vector3.zero);
183
+
184
+ var renderers = includeChildren
185
+ ? target.GetComponentsInChildren<Renderer>()
186
+ : target.GetComponents<Renderer>();
187
+ foreach (var renderer in renderers)
188
+ {
189
+ if (!hasBounds) { bounds = renderer.bounds; hasBounds = true; }
190
+ else bounds.Encapsulate(renderer.bounds);
191
+ }
192
+
193
+ var colliders = includeChildren
194
+ ? target.GetComponentsInChildren<Collider>()
195
+ : target.GetComponents<Collider>();
196
+ foreach (var collider in colliders)
197
+ {
198
+ if (!hasBounds) { bounds = collider.bounds; hasBounds = true; }
199
+ else bounds.Encapsulate(collider.bounds);
200
+ }
201
+
202
+ return hasBounds;
203
+ }
204
+
205
+ internal static List<object> Vec3(Vector3 v) => new List<object> { v.x, v.y, v.z };
206
+ }
207
+ }
@@ -0,0 +1,2 @@
1
+ fileFormatVersion: 2
2
+ guid: 0a1b2c3d4e5f60718293a4b5c6d7e8f9
@@ -14,7 +14,7 @@ namespace UCP.Bridge
14
14
 
15
15
  var result = new Dictionary<string, object>
16
16
  {
17
- ["instanceId"] = obj.GetInstanceID(),
17
+ ["instanceId"] = obj.GetId(),
18
18
  ["name"] = obj.name,
19
19
  ["type"] = obj.GetType().Name
20
20
  };
@@ -1,6 +1,4 @@
1
1
  using UnityEditor;
2
- using UnityEditor.SceneManagement;
3
- using UnityEngine.SceneManagement;
4
2
  using System.Collections.Generic;
5
3
  using System;
6
4
 
@@ -65,7 +63,7 @@ namespace UCP.Bridge
65
63
  var saveDirtyScenes = GetBoolParam(paramsJson, "saveDirtyScenes", true);
66
64
  var discardUntitled = GetBoolParam(paramsJson, "discardUntitled", true);
67
65
  var logFile = GetStringParam(paramsJson, "logFile");
68
- SaveDirtyScenesIfRequested(saveDirtyScenes, discardUntitled);
66
+ EditorModalGuard.SaveOpenDirtyScenes(saveDirtyScenes, discardUntitled);
69
67
  if (!string.IsNullOrEmpty(logFile))
70
68
  LogsController.StartFileCapture(logFile);
71
69
 
@@ -91,38 +89,6 @@ namespace UCP.Bridge
91
89
  return defaultValue;
92
90
  }
93
91
 
94
- private static void SaveDirtyScenesIfRequested(bool saveDirtyScenes, bool discardUntitled)
95
- {
96
- if (!saveDirtyScenes)
97
- return;
98
-
99
- var requiresUntitledDiscard = false;
100
-
101
- for (var index = 0; index < SceneManager.sceneCount; index++)
102
- {
103
- var scene = SceneManager.GetSceneAt(index);
104
- if (!scene.isLoaded || !scene.isDirty)
105
- continue;
106
-
107
- if (string.IsNullOrEmpty(scene.path))
108
- {
109
- if (!discardUntitled)
110
- throw new System.InvalidOperationException("Dirty untitled scene cannot be auto-saved. Retry with discardUntitled=true.");
111
-
112
- requiresUntitledDiscard = true;
113
- continue;
114
- }
115
-
116
- if (!EditorSceneManager.SaveScene(scene))
117
- throw new System.InvalidOperationException($"Failed to auto-save dirty scene: {scene.path}");
118
-
119
- SceneChangeTracker.ClearScene(scene);
120
- }
121
-
122
- if (requiresUntitledDiscard)
123
- EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single);
124
- }
125
-
126
92
  private static object HandleStop(string paramsJson)
127
93
  {
128
94
  if (!EditorApplication.isPlaying)
@@ -179,8 +179,8 @@ namespace UCP.Bridge
179
179
  ["status"] = "ok",
180
180
  ["path"] = savePath,
181
181
  ["name"] = prefab.name,
182
- ["instanceId"] = prefab.GetInstanceID(),
183
- ["sceneInstanceId"] = go.GetInstanceID(),
182
+ ["instanceId"] = prefab.GetId(),
183
+ ["sceneInstanceId"] = go.GetId(),
184
184
  ["isPrefabInstance"] = PrefabUtility.IsPartOfPrefabInstance(go)
185
185
  };
186
186
  }
@@ -221,7 +221,7 @@ namespace UCP.Bridge
221
221
  added.Add(new Dictionary<string, object>
222
222
  {
223
223
  ["component"] = ac.instanceComponent.GetType().Name,
224
- ["instanceId"] = ac.instanceComponent.GetInstanceID()
224
+ ["instanceId"] = ac.instanceComponent.GetId()
225
225
  });
226
226
  }
227
227
 
@@ -15,6 +15,11 @@ namespace UCP.Bridge
15
15
  {
16
16
  private const int DefaultFrameListLimit = 20;
17
17
  private const int DefaultSummaryFrameWindow = 120;
18
+
19
+ // Summary aggregation walks every raw frame view on the main thread, so an unbounded
20
+ // range (e.g. --first-frame 0 on a long session) freezes the editor for seconds. Clamp it
21
+ // and say so in `warnings` rather than stalling.
22
+ private const int MaxSummaryFrameSpan = 600;
18
23
  private const int DefaultJsonExportFrameWindow = 120;
19
24
  private const int MaxThreadProbeCount = 128;
20
25
  private const long MinimumProfilerMemoryBytes = 16L * 1024L * 1024L;
@@ -296,6 +301,7 @@ namespace UCP.Bridge
296
301
  var limit = Math.Max(1, GetInt(parameters, "limit", 50));
297
302
  var sort = GetString(parameters, "sort") ?? "total-time";
298
303
  var maxDepth = GetNullableInt(parameters, "maxDepth");
304
+ var fields = GetFieldSet(parameters);
299
305
 
300
306
  using (var view = GetHierarchyFrameDataView(frameIndex, threadIndex))
301
307
  {
@@ -305,7 +311,8 @@ namespace UCP.Bridge
305
311
  var items = CollectHierarchyItems(view, maxDepth);
306
312
  items = SortHierarchyItems(items, sort);
307
313
 
308
- var truncated = items.Count > limit;
314
+ var totalCount = items.Count;
315
+ var truncated = totalCount > limit;
309
316
  if (truncated)
310
317
  items = items.Take(limit).ToList();
311
318
 
@@ -315,8 +322,11 @@ namespace UCP.Bridge
315
322
  ["thread"] = threadIndex,
316
323
  ["sort"] = sort,
317
324
  ["count"] = items.Count,
325
+ // How many rows existed before truncation. Without it a caller cannot tell
326
+ // "50 of 52" from "50 of 50,000", so it cannot decide whether to look further.
327
+ ["totalCount"] = totalCount,
318
328
  ["truncated"] = truncated,
319
- ["items"] = items.Select(item => (object)item.ToDictionary()).ToList(),
329
+ ["items"] = items.Select(item => (object)ProjectFields(item.ToDictionary(), fields)).ToList(),
320
330
  ["warnings"] = new List<object>()
321
331
  };
322
332
  }
@@ -330,6 +340,7 @@ namespace UCP.Bridge
330
340
  var limit = Math.Max(1, GetInt(parameters, "limit", 200));
331
341
  var maxDepth = GetNullableInt(parameters, "maxDepth");
332
342
  var includeMetadata = GetBool(parameters, "includeMetadata", false);
343
+ var fields = GetFieldSet(parameters);
333
344
 
334
345
  using (var view = GetRawFrameDataView(frameIndex, threadIndex))
335
346
  {
@@ -337,7 +348,8 @@ namespace UCP.Bridge
337
348
  throw new ArgumentException($"Raw profiler data is unavailable for frame {frameIndex}, thread {threadIndex}");
338
349
 
339
350
  var samples = CollectTimelineSamples(view, maxDepth, includeMetadata);
340
- var truncated = samples.Count > limit;
351
+ var totalCount = samples.Count;
352
+ var truncated = totalCount > limit;
341
353
  if (truncated)
342
354
  samples = samples.Take(limit).ToList();
343
355
 
@@ -346,8 +358,9 @@ namespace UCP.Bridge
346
358
  ["frame"] = frameIndex,
347
359
  ["thread"] = threadIndex,
348
360
  ["count"] = samples.Count,
361
+ ["totalCount"] = totalCount,
349
362
  ["truncated"] = truncated,
350
- ["samples"] = samples.Cast<object>().ToList(),
363
+ ["samples"] = samples.Select(sample => (object)ProjectFields(sample, fields)).ToList(),
351
364
  ["warnings"] = new List<object>()
352
365
  };
353
366
  }
@@ -427,10 +440,23 @@ namespace UCP.Bridge
427
440
  if (firstFrame > lastFrame)
428
441
  throw new ArgumentException("Requested frame range is empty");
429
442
 
443
+ var warnings = new List<object>();
444
+ var span = lastFrame - firstFrame + 1;
445
+ if (span > MaxSummaryFrameSpan)
446
+ {
447
+ // Aggregation runs on the editor's main thread; a huge span stalls the whole
448
+ // editor. Keep the most recent frames, which is what callers almost always want.
449
+ firstFrame = lastFrame - MaxSummaryFrameSpan + 1;
450
+ warnings.Add(
451
+ $"Requested {span} frames; aggregated the most recent {MaxSummaryFrameSpan} " +
452
+ $"(frames {firstFrame}-{lastFrame}) to avoid stalling the editor. " +
453
+ "Narrow the range with --first-frame/--last-frame for older windows.");
454
+ }
455
+
430
456
  return new Dictionary<string, object>
431
457
  {
432
458
  ["summary"] = BuildSummaryData(limit, threadIndex, firstFrame, lastFrame),
433
- ["warnings"] = new List<object>()
459
+ ["warnings"] = warnings
434
460
  };
435
461
  }
436
462
 
@@ -1365,6 +1391,55 @@ namespace UCP.Bridge
1365
1391
  StringComparison.OrdinalIgnoreCase);
1366
1392
  }
1367
1393
 
1394
+ /// <summary>
1395
+ /// Optional caller-supplied field allow-list (`fields: ["name","selfMs"]`). Profiler rows
1396
+ /// are wide and agents pay per token for columns they never read, so let them ask for the
1397
+ /// two or three they actually want. Null means "every field".
1398
+ /// </summary>
1399
+ private static HashSet<string> GetFieldSet(Dictionary<string, object> parameters)
1400
+ {
1401
+ if (parameters == null || !parameters.TryGetValue("fields", out var raw) || raw == null)
1402
+ return null;
1403
+
1404
+ var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
1405
+ if (raw is List<object> list)
1406
+ {
1407
+ foreach (var entry in list)
1408
+ {
1409
+ var name = entry?.ToString();
1410
+ if (!string.IsNullOrWhiteSpace(name)) set.Add(name.Trim());
1411
+ }
1412
+ }
1413
+ else
1414
+ {
1415
+ foreach (var name in raw.ToString().Split(','))
1416
+ {
1417
+ if (!string.IsNullOrWhiteSpace(name)) set.Add(name.Trim());
1418
+ }
1419
+ }
1420
+
1421
+ return set.Count == 0 ? null : set;
1422
+ }
1423
+
1424
+ /// Narrow one row to the requested fields. Unknown names are ignored rather than erroring,
1425
+ /// so a caller can ask for a superset across profiler surfaces without branching.
1426
+ private static Dictionary<string, object> ProjectFields(
1427
+ Dictionary<string, object> row,
1428
+ HashSet<string> fields)
1429
+ {
1430
+ if (fields == null || row == null) return row;
1431
+
1432
+ var projected = new Dictionary<string, object>();
1433
+ foreach (var pair in row)
1434
+ {
1435
+ if (fields.Contains(pair.Key)) projected[pair.Key] = pair.Value;
1436
+ }
1437
+
1438
+ // Never hand back an empty row: a caller that misspelled every field would otherwise
1439
+ // get a silent wall of `{}` instead of a usable result.
1440
+ return projected.Count == 0 ? row : projected;
1441
+ }
1442
+
1368
1443
  private static Dictionary<string, object> BuildSummaryData(
1369
1444
  int limit,
1370
1445
  int threadIndex,
@@ -538,7 +538,7 @@ namespace UCP.Bridge
538
538
 
539
539
  private static GameObject FindInHierarchy(GameObject go, int instanceId)
540
540
  {
541
- if (go.GetInstanceID() == instanceId) return go;
541
+ if (go.GetId() == instanceId) return go;
542
542
  for (int i = 0; i < go.transform.childCount; i++)
543
543
  {
544
544
  var found = FindInHierarchy(go.transform.GetChild(i).gameObject, instanceId);
@@ -31,7 +31,7 @@ namespace UCP.Bridge
31
31
  {
32
32
  { "serializationMode", mode },
33
33
  { "forceText", mode == 2 },
34
- { "visibleMetaFiles", EditorSettings.externalVersionControl == "Visible Meta Files" }
34
+ { "visibleMetaFiles", VersionControlSettings.mode == "Visible Meta Files" }
35
35
  };
36
36
  }
37
37
  finally
@@ -17,7 +17,7 @@ namespace UCP.Bridge
17
17
  public HashSet<string> Components = new();
18
18
  }
19
19
 
20
- private static readonly Dictionary<int, Dictionary<string, TrackedSceneChange>> s_changesByScene = new();
20
+ private static readonly Dictionary<long, Dictionary<string, TrackedSceneChange>> s_changesByScene = new();
21
21
 
22
22
  static SceneChangeTracker()
23
23
  {
@@ -33,7 +33,7 @@ namespace UCP.Bridge
33
33
  if (gameObject == null)
34
34
  return;
35
35
 
36
- RecordSceneChange(gameObject.scene, gameObject.GetInstanceID(), gameObject.name, componentName);
36
+ RecordSceneChange(gameObject.scene, gameObject.GetId(), gameObject.name, componentName);
37
37
  }
38
38
 
39
39
  public static void RecordDeletedObject(Scene scene, int instanceId, string name, string componentName)
@@ -56,7 +56,7 @@ namespace UCP.Bridge
56
56
  var modifications = new List<object>();
57
57
  var omittedCount = 0;
58
58
 
59
- if (scene.IsValid() && s_changesByScene.TryGetValue(scene.handle, out var trackedChanges))
59
+ if (scene.IsValid() && s_changesByScene.TryGetValue(UnityObjectCompat.GetSceneHandle(scene), out var trackedChanges))
60
60
  {
61
61
  var ordered = trackedChanges.Values
62
62
  .OrderBy(change => change.InstanceId.HasValue ? 0 : 1)
@@ -101,7 +101,7 @@ namespace UCP.Bridge
101
101
  if (!scene.IsValid())
102
102
  return;
103
103
 
104
- s_changesByScene.Remove(scene.handle);
104
+ s_changesByScene.Remove(UnityObjectCompat.GetSceneHandle(scene));
105
105
  }
106
106
 
107
107
  private static UndoPropertyModification[] OnPostprocessModifications(UndoPropertyModification[] modifications)
@@ -140,10 +140,10 @@ namespace UCP.Bridge
140
140
  if (!scene.IsValid() || !scene.isLoaded)
141
141
  return;
142
142
 
143
- if (!s_changesByScene.TryGetValue(scene.handle, out var sceneChanges))
143
+ if (!s_changesByScene.TryGetValue(UnityObjectCompat.GetSceneHandle(scene), out var sceneChanges))
144
144
  {
145
145
  sceneChanges = new Dictionary<string, TrackedSceneChange>();
146
- s_changesByScene[scene.handle] = sceneChanges;
146
+ s_changesByScene[UnityObjectCompat.GetSceneHandle(scene)] = sceneChanges;
147
147
  }
148
148
 
149
149
  var key = instanceId.HasValue ? instanceId.Value.ToString() : $"scene::{name}";
@@ -54,7 +54,7 @@ namespace UCP.Bridge
54
54
  }
55
55
  else
56
56
  {
57
- SaveDirtyScenesIfRequested(saveDirtyScenes, discardUntitled);
57
+ EditorModalGuard.SaveOpenDirtyScenes(saveDirtyScenes, discardUntitled);
58
58
  EditorSceneManager.OpenScene(path, additive ? OpenSceneMode.Additive : OpenSceneMode.Single);
59
59
  }
60
60
 
@@ -74,38 +74,6 @@ namespace UCP.Bridge
74
74
  return defaultValue;
75
75
  }
76
76
 
77
- private static void SaveDirtyScenesIfRequested(bool saveDirtyScenes, bool discardUntitled)
78
- {
79
- if (!saveDirtyScenes)
80
- return;
81
-
82
- var requiresUntitledDiscard = false;
83
-
84
- for (var index = 0; index < SceneManager.sceneCount; index++)
85
- {
86
- var scene = SceneManager.GetSceneAt(index);
87
- if (!scene.isLoaded || !scene.isDirty)
88
- continue;
89
-
90
- if (string.IsNullOrEmpty(scene.path))
91
- {
92
- if (!discardUntitled)
93
- throw new System.InvalidOperationException("Dirty untitled scene cannot be auto-saved. Retry with discardUntitled=true.");
94
-
95
- requiresUntitledDiscard = true;
96
- continue;
97
- }
98
-
99
- if (!EditorSceneManager.SaveScene(scene))
100
- throw new System.InvalidOperationException($"Failed to auto-save dirty scene: {scene.path}");
101
-
102
- SceneChangeTracker.ClearScene(scene);
103
- }
104
-
105
- if (requiresUntitledDiscard)
106
- EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single);
107
- }
108
-
109
77
  private static object HandleSaveActive(string paramsJson)
110
78
  {
111
79
  var scene = SceneManager.GetActiveScene();
@@ -200,19 +168,41 @@ namespace UCP.Bridge
200
168
  sceneView.Repaint();
201
169
  SceneView.RepaintAll();
202
170
 
171
+ ResolveSceneViewCamera(sceneView, out var cameraPosition, out var cameraRotation);
172
+
203
173
  return new Dictionary<string, object>
204
174
  {
205
175
  ["status"] = "ok",
206
176
  ["instanceId"] = instanceId,
207
177
  ["name"] = target.name,
208
178
  ["pivot"] = VectorToList(sceneView.pivot),
209
- ["cameraPosition"] = VectorToList(sceneView.camera.transform.position),
210
- ["cameraRotationEuler"] = VectorToList(sceneView.camera.transform.rotation.eulerAngles),
179
+ ["cameraPosition"] = VectorToList(cameraPosition),
180
+ ["cameraRotationEuler"] = VectorToList(cameraRotation.eulerAngles),
211
181
  ["size"] = sceneView.size,
212
182
  ["axis"] = axis.HasValue ? VectorToList(axis.Value.normalized) : null
213
183
  };
214
184
  }
215
185
 
186
+ /// <summary>
187
+ /// Derive the Scene view camera pose from the authoritative view state
188
+ /// (<c>pivot</c>/<c>rotation</c>/<c>cameraDistance</c>) rather than reading
189
+ /// <c>sceneView.camera.transform</c>.
190
+ ///
191
+ /// <c>LookAtDirect</c> updates the view state immediately, but the camera transform is only
192
+ /// synced when the Scene view actually repaints. <c>Repaint()</c> merely queues that, so in
193
+ /// batch mode - and for any caller reading the response in the same frame as the focus -
194
+ /// the camera transform still holds the *previous* pose and the reported values were simply
195
+ /// wrong.
196
+ /// </summary>
197
+ private static void ResolveSceneViewCamera(
198
+ SceneView sceneView,
199
+ out Vector3 position,
200
+ out Quaternion rotation)
201
+ {
202
+ rotation = sceneView.rotation;
203
+ position = sceneView.pivot - (rotation * Vector3.forward) * sceneView.cameraDistance;
204
+ }
205
+
216
206
  private static Vector3? TryReadAxis(Dictionary<string, object> parameters)
217
207
  {
218
208
  if (parameters == null || !parameters.TryGetValue("axis", out var axisObj) || axisObj == null)
@@ -302,7 +292,7 @@ namespace UCP.Bridge
302
292
 
303
293
  private static GameObject FindInHierarchy(GameObject gameObject, int instanceId)
304
294
  {
305
- if (gameObject.GetInstanceID() == instanceId)
295
+ if (gameObject.GetId() == instanceId)
306
296
  return gameObject;
307
297
 
308
298
  foreach (Transform child in gameObject.transform)