@mflrevan/ucp 0.6.0 → 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.
- package/README.md +1 -1
- package/bridge/com.ucp.bridge/Editor/Bridge/BridgeServer.cs +36 -5
- package/bridge/com.ucp.bridge/Editor/Controllers/CompilationController.cs +137 -1
- package/bridge/com.ucp.bridge/Editor/Controllers/ProfilerController.cs +80 -5
- package/bridge/com.ucp.bridge/Editor/Controllers/SceneController.cs +24 -2
- package/bridge/com.ucp.bridge/Editor/Controllers/ScriptController.cs +82 -23
- package/bridge/com.ucp.bridge/Editor/Protocol/MiniJson.cs +409 -63
- package/bridge/com.ucp.bridge/Tests/Editor/ControllerSmokeTests.cs +68 -6
- package/bridge/com.ucp.bridge/Tests/Editor/MiniJsonSerializerTests.cs +221 -0
- package/bridge/com.ucp.bridge/Tests/Editor/MiniJsonSerializerTests.cs.meta +2 -0
- package/bridge/com.ucp.bridge/package.json +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @mflrevan/ucp
|
|
2
2
|
|
|
3
|
-
Version `0.6.
|
|
3
|
+
Version `0.6.1` 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
|
|
|
@@ -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.
|
|
29
|
+
private const string ProtocolVersion = "0.6.1";
|
|
30
30
|
|
|
31
31
|
private static TcpListener s_listener;
|
|
32
32
|
private static CancellationTokenSource s_cts;
|
|
@@ -36,6 +36,13 @@ namespace UCP.Bridge
|
|
|
36
36
|
private static string s_token;
|
|
37
37
|
private static bool s_running;
|
|
38
38
|
|
|
39
|
+
// Handshake payload, captured once on the main thread at startup. `Application.dataPath`
|
|
40
|
+
// and friends are main-thread-only, which is the sole reason the handshake used to be
|
|
41
|
+
// queued behind `EditorApplication.update` -- see the socket-thread fast path in Dispatch.
|
|
42
|
+
private static string s_unityVersion = string.Empty;
|
|
43
|
+
private static string s_projectName = string.Empty;
|
|
44
|
+
private static string s_projectPath = string.Empty;
|
|
45
|
+
|
|
39
46
|
// Main-thread action queue
|
|
40
47
|
private static readonly ConcurrentQueue<Action> s_mainThreadQueue = new();
|
|
41
48
|
|
|
@@ -71,6 +78,7 @@ namespace UCP.Bridge
|
|
|
71
78
|
|
|
72
79
|
try
|
|
73
80
|
{
|
|
81
|
+
CaptureEditorIdentity();
|
|
74
82
|
RegisterHandlers();
|
|
75
83
|
LogsController.SeedHistoryFromConsole();
|
|
76
84
|
|
|
@@ -88,18 +96,31 @@ namespace UCP.Bridge
|
|
|
88
96
|
}
|
|
89
97
|
}
|
|
90
98
|
|
|
99
|
+
/// <summary>
|
|
100
|
+
/// Read the editor/project identity once, on the main thread, so the handshake handler is
|
|
101
|
+
/// pure and can run anywhere.
|
|
102
|
+
/// </summary>
|
|
103
|
+
private static void CaptureEditorIdentity()
|
|
104
|
+
{
|
|
105
|
+
s_unityVersion = Application.unityVersion;
|
|
106
|
+
s_projectName = Application.productName;
|
|
107
|
+
s_projectPath = Path.GetDirectoryName(Application.dataPath);
|
|
108
|
+
}
|
|
109
|
+
|
|
91
110
|
private static void RegisterHandlers()
|
|
92
111
|
{
|
|
93
112
|
// Handshake
|
|
94
113
|
s_router.Register("handshake", (paramsJson) =>
|
|
95
114
|
{
|
|
115
|
+
// Touches no Unity API -- see CaptureEditorIdentity. Keep it that way: the
|
|
116
|
+
// socket-thread fast path in Dispatch depends on it.
|
|
96
117
|
return new
|
|
97
118
|
{
|
|
98
119
|
serverVersion = ProtocolVersion,
|
|
99
120
|
protocolVersion = ProtocolVersion,
|
|
100
|
-
unityVersion =
|
|
101
|
-
projectName =
|
|
102
|
-
projectPath =
|
|
121
|
+
unityVersion = s_unityVersion,
|
|
122
|
+
projectName = s_projectName,
|
|
123
|
+
projectPath = s_projectPath
|
|
103
124
|
};
|
|
104
125
|
});
|
|
105
126
|
|
|
@@ -393,12 +414,22 @@ namespace UCP.Bridge
|
|
|
393
414
|
lock (s_clientLock) { s_logSubscribers.Remove(ws); }
|
|
394
415
|
}
|
|
395
416
|
|
|
396
|
-
// Dispatch on main thread
|
|
397
417
|
var capturedId = id;
|
|
398
418
|
var capturedMethod = method;
|
|
399
419
|
var capturedParams = paramsJson;
|
|
400
420
|
var capturedWs = ws;
|
|
401
421
|
|
|
422
|
+
// Answer the handshake straight off the socket thread. Everything it returns is
|
|
423
|
+
// cached, so queueing it only bought a wait for the next `EditorApplication.update`
|
|
424
|
+
// tick -- roughly 90 ms on an idle editor, paid by *every* CLI command before its
|
|
425
|
+
// real request could even be dispatched.
|
|
426
|
+
if (capturedMethod == "handshake")
|
|
427
|
+
{
|
|
428
|
+
SendResponse(capturedWs, s_router.Dispatch(capturedMethod, capturedId, capturedParams));
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Dispatch on main thread
|
|
402
433
|
s_mainThreadQueue.Enqueue(() =>
|
|
403
434
|
{
|
|
404
435
|
var response = s_router.Dispatch(capturedMethod, capturedId, capturedParams);
|
|
@@ -9,19 +9,86 @@ namespace UCP.Bridge
|
|
|
9
9
|
{
|
|
10
10
|
public static class CompilationController
|
|
11
11
|
{
|
|
12
|
+
// SessionState survives domain reloads within one editor session. That is exactly the
|
|
13
|
+
// window we need: a compile that SUCCEEDS reloads the domain, a compile that FAILS keeps
|
|
14
|
+
// the old domain (no reload), and in both cases the captured diagnostics must still be
|
|
15
|
+
// readable by the CLI after the editor settles. CompileDiagnosticsTracker writes here;
|
|
16
|
+
// HandleDiagnostics reads it back.
|
|
17
|
+
internal const string DiagStateKey = "ucp.compile.diagnostics.state";
|
|
18
|
+
internal const string DiagRequestKey = "ucp.compile.diagnostics.requestId";
|
|
19
|
+
private const int MaxDiagnosticMessages = 200;
|
|
20
|
+
|
|
12
21
|
public static void Register(CommandRouter router)
|
|
13
22
|
{
|
|
14
23
|
router.Register("compile", HandleCompile);
|
|
24
|
+
router.Register("compile/diagnostics", HandleDiagnostics);
|
|
15
25
|
router.Register("refresh-assets", HandleRefresh);
|
|
16
26
|
router.Register("script/doctor", HandleScriptDoctor);
|
|
17
27
|
}
|
|
18
28
|
|
|
19
29
|
private static object HandleCompile(string paramsJson)
|
|
20
30
|
{
|
|
31
|
+
// Stamp a fresh request id and reset the diagnostics buffer so the CLI can tell THIS
|
|
32
|
+
// compile's result apart from a stale one. CompileDiagnosticsTracker fills in the
|
|
33
|
+
// per-assembly CompilerMessages as compilation progresses and finishes.
|
|
34
|
+
var requestId = SessionState.GetInt(DiagRequestKey, 0) + 1;
|
|
35
|
+
SessionState.SetInt(DiagRequestKey, requestId);
|
|
36
|
+
SessionState.SetString(DiagStateKey, MiniJson.Serialize(new Dictionary<string, object>
|
|
37
|
+
{
|
|
38
|
+
["status"] = "requested",
|
|
39
|
+
["requestId"] = requestId,
|
|
40
|
+
["errorCount"] = 0,
|
|
41
|
+
["warningCount"] = 0,
|
|
42
|
+
["messages"] = new List<object>()
|
|
43
|
+
}));
|
|
44
|
+
|
|
21
45
|
AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
|
|
22
46
|
CompilationPipeline.RequestScriptCompilation();
|
|
23
47
|
TrySyncSolution();
|
|
24
|
-
return new
|
|
48
|
+
return new Dictionary<string, object>
|
|
49
|
+
{
|
|
50
|
+
["status"] = "ok",
|
|
51
|
+
["message"] = "Asset database refreshed and compilation requested",
|
|
52
|
+
["requestId"] = requestId
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
private static object HandleDiagnostics(string paramsJson)
|
|
57
|
+
{
|
|
58
|
+
var raw = SessionState.GetString(DiagStateKey, string.Empty);
|
|
59
|
+
if (string.IsNullOrEmpty(raw)
|
|
60
|
+
|| !(MiniJson.Deserialize(raw) is Dictionary<string, object> state))
|
|
61
|
+
{
|
|
62
|
+
return new Dictionary<string, object>
|
|
63
|
+
{
|
|
64
|
+
["status"] = "idle",
|
|
65
|
+
["requestId"] = SessionState.GetInt(DiagRequestKey, 0),
|
|
66
|
+
["errorCount"] = 0,
|
|
67
|
+
["warningCount"] = 0,
|
|
68
|
+
["messages"] = new List<object>(),
|
|
69
|
+
["compiling"] = EditorApplication.isCompiling
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Surface live compile state too: a "completed" buffer while isCompiling is true means
|
|
74
|
+
// another compilation has already started after the one the CLI asked about.
|
|
75
|
+
state["compiling"] = EditorApplication.isCompiling;
|
|
76
|
+
return state;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
internal static void WriteDiagnosticsState(string status, int errorCount, int warningCount, List<object> messages)
|
|
80
|
+
{
|
|
81
|
+
var truncated = messages.Count > MaxDiagnosticMessages;
|
|
82
|
+
var trimmed = truncated ? messages.GetRange(0, MaxDiagnosticMessages) : messages;
|
|
83
|
+
SessionState.SetString(DiagStateKey, MiniJson.Serialize(new Dictionary<string, object>
|
|
84
|
+
{
|
|
85
|
+
["status"] = status,
|
|
86
|
+
["requestId"] = SessionState.GetInt(DiagRequestKey, 0),
|
|
87
|
+
["errorCount"] = errorCount,
|
|
88
|
+
["warningCount"] = warningCount,
|
|
89
|
+
["truncated"] = truncated,
|
|
90
|
+
["messages"] = new List<object>(trimmed)
|
|
91
|
+
}));
|
|
25
92
|
}
|
|
26
93
|
|
|
27
94
|
private static object HandleRefresh(string paramsJson)
|
|
@@ -110,4 +177,73 @@ namespace UCP.Bridge
|
|
|
110
177
|
}
|
|
111
178
|
}
|
|
112
179
|
}
|
|
180
|
+
|
|
181
|
+
/// <summary>
|
|
182
|
+
/// Captures per-assembly compiler messages so `compile` can report whether the build actually
|
|
183
|
+
/// succeeded instead of always claiming success. Subscribes once per domain load; accumulates
|
|
184
|
+
/// messages for the in-flight compilation in static fields (safe because a domain reload only
|
|
185
|
+
/// happens AFTER compilationFinished) and flushes them to SessionState, which persists across
|
|
186
|
+
/// the reload for the CLI to read once the editor settles.
|
|
187
|
+
/// </summary>
|
|
188
|
+
[InitializeOnLoad]
|
|
189
|
+
internal static class CompileDiagnosticsTracker
|
|
190
|
+
{
|
|
191
|
+
private static readonly List<object> s_messages = new List<object>();
|
|
192
|
+
private static int s_errorCount;
|
|
193
|
+
private static int s_warningCount;
|
|
194
|
+
|
|
195
|
+
static CompileDiagnosticsTracker()
|
|
196
|
+
{
|
|
197
|
+
CompilationPipeline.compilationStarted += OnCompilationStarted;
|
|
198
|
+
CompilationPipeline.assemblyCompilationFinished += OnAssemblyCompilationFinished;
|
|
199
|
+
CompilationPipeline.compilationFinished += OnCompilationFinished;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
private static void OnCompilationStarted(object context)
|
|
203
|
+
{
|
|
204
|
+
s_messages.Clear();
|
|
205
|
+
s_errorCount = 0;
|
|
206
|
+
s_warningCount = 0;
|
|
207
|
+
CompilationController.WriteDiagnosticsState("compiling", 0, 0, s_messages);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
private static void OnAssemblyCompilationFinished(string assemblyPath, CompilerMessage[] messages)
|
|
211
|
+
{
|
|
212
|
+
if (messages == null) return;
|
|
213
|
+
var assembly = Path.GetFileNameWithoutExtension(assemblyPath);
|
|
214
|
+
foreach (var message in messages)
|
|
215
|
+
{
|
|
216
|
+
string type;
|
|
217
|
+
switch (message.type)
|
|
218
|
+
{
|
|
219
|
+
case CompilerMessageType.Error:
|
|
220
|
+
type = "error";
|
|
221
|
+
s_errorCount++;
|
|
222
|
+
break;
|
|
223
|
+
case CompilerMessageType.Warning:
|
|
224
|
+
type = "warning";
|
|
225
|
+
s_warningCount++;
|
|
226
|
+
break;
|
|
227
|
+
default:
|
|
228
|
+
type = "info";
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
s_messages.Add(new Dictionary<string, object>
|
|
233
|
+
{
|
|
234
|
+
["assembly"] = assembly,
|
|
235
|
+
["type"] = type,
|
|
236
|
+
["message"] = message.message ?? string.Empty,
|
|
237
|
+
["file"] = message.file ?? string.Empty,
|
|
238
|
+
["line"] = message.line,
|
|
239
|
+
["column"] = message.column
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
private static void OnCompilationFinished(object context)
|
|
245
|
+
{
|
|
246
|
+
CompilationController.WriteDiagnosticsState("completed", s_errorCount, s_warningCount, s_messages);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
113
249
|
}
|
|
@@ -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
|
|
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
|
|
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.
|
|
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"] =
|
|
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,
|
|
@@ -168,19 +168,41 @@ namespace UCP.Bridge
|
|
|
168
168
|
sceneView.Repaint();
|
|
169
169
|
SceneView.RepaintAll();
|
|
170
170
|
|
|
171
|
+
ResolveSceneViewCamera(sceneView, out var cameraPosition, out var cameraRotation);
|
|
172
|
+
|
|
171
173
|
return new Dictionary<string, object>
|
|
172
174
|
{
|
|
173
175
|
["status"] = "ok",
|
|
174
176
|
["instanceId"] = instanceId,
|
|
175
177
|
["name"] = target.name,
|
|
176
178
|
["pivot"] = VectorToList(sceneView.pivot),
|
|
177
|
-
["cameraPosition"] = VectorToList(
|
|
178
|
-
["cameraRotationEuler"] = VectorToList(
|
|
179
|
+
["cameraPosition"] = VectorToList(cameraPosition),
|
|
180
|
+
["cameraRotationEuler"] = VectorToList(cameraRotation.eulerAngles),
|
|
179
181
|
["size"] = sceneView.size,
|
|
180
182
|
["axis"] = axis.HasValue ? VectorToList(axis.Value.normalized) : null
|
|
181
183
|
};
|
|
182
184
|
}
|
|
183
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
|
+
|
|
184
206
|
private static Vector3? TryReadAxis(Dictionary<string, object> parameters)
|
|
185
207
|
{
|
|
186
208
|
if (parameters == null || !parameters.TryGetValue("axis", out var axisObj) || axisObj == null)
|
|
@@ -12,29 +12,35 @@ namespace UCP.Bridge
|
|
|
12
12
|
router.Register("exec/run", HandleRun);
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
/// <summary>
|
|
16
|
+
/// Implementing types, cached for the lifetime of the app domain. The scan itself is the
|
|
17
|
+
/// expensive part -- `GetTypes()` over every loaded assembly -- and its result cannot go
|
|
18
|
+
/// stale without a domain reload, which resets this static anyway.
|
|
19
|
+
/// </summary>
|
|
20
|
+
private static Type[] s_scriptTypes;
|
|
21
|
+
|
|
22
|
+
private static Type[] DiscoverScriptTypes()
|
|
16
23
|
{
|
|
17
|
-
|
|
24
|
+
if (s_scriptTypes != null) return s_scriptTypes;
|
|
25
|
+
|
|
18
26
|
var interfaceType = typeof(IUCPScript);
|
|
27
|
+
var bridgeAssemblyName = interfaceType.Assembly.GetName().Name;
|
|
28
|
+
var types = new List<Type>();
|
|
19
29
|
|
|
20
30
|
foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
|
|
21
31
|
{
|
|
32
|
+
// A type can only implement IUCPScript if its assembly references the one that
|
|
33
|
+
// declares it. Checking cheap reference metadata first avoids materialising the
|
|
34
|
+
// full type list of every framework and Unity assembly in the domain.
|
|
35
|
+
if (assembly != interfaceType.Assembly && !ReferencesAssembly(assembly, bridgeAssemblyName))
|
|
36
|
+
continue;
|
|
37
|
+
|
|
22
38
|
try
|
|
23
39
|
{
|
|
24
40
|
foreach (var type in assembly.GetTypes())
|
|
25
41
|
{
|
|
26
42
|
if (interfaceType.IsAssignableFrom(type) && !type.IsAbstract && !type.IsInterface)
|
|
27
|
-
|
|
28
|
-
try
|
|
29
|
-
{
|
|
30
|
-
var instance = (IUCPScript)Activator.CreateInstance(type);
|
|
31
|
-
scripts.Add(instance);
|
|
32
|
-
}
|
|
33
|
-
catch (Exception ex)
|
|
34
|
-
{
|
|
35
|
-
Debug.LogWarning($"[UCP] Failed to instantiate script {type.Name}: {ex.Message}");
|
|
36
|
-
}
|
|
37
|
-
}
|
|
43
|
+
types.Add(type);
|
|
38
44
|
}
|
|
39
45
|
}
|
|
40
46
|
catch (System.Reflection.ReflectionTypeLoadException)
|
|
@@ -43,9 +49,71 @@ namespace UCP.Bridge
|
|
|
43
49
|
}
|
|
44
50
|
}
|
|
45
51
|
|
|
52
|
+
s_scriptTypes = types.ToArray();
|
|
53
|
+
return s_scriptTypes;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
private static bool ReferencesAssembly(System.Reflection.Assembly assembly, string name)
|
|
57
|
+
{
|
|
58
|
+
try
|
|
59
|
+
{
|
|
60
|
+
foreach (var reference in assembly.GetReferencedAssemblies())
|
|
61
|
+
{
|
|
62
|
+
if (string.Equals(reference.Name, name, StringComparison.Ordinal)) return true;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
catch
|
|
66
|
+
{
|
|
67
|
+
// Dynamic assemblies can refuse to report references; treat them as non-matching.
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private static IUCPScript Instantiate(Type type)
|
|
74
|
+
{
|
|
75
|
+
try
|
|
76
|
+
{
|
|
77
|
+
return (IUCPScript)Activator.CreateInstance(type);
|
|
78
|
+
}
|
|
79
|
+
catch (Exception ex)
|
|
80
|
+
{
|
|
81
|
+
Debug.LogWarning($"[UCP] Failed to instantiate script {type.Name}: {ex.Message}");
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private static List<IUCPScript> DiscoverScripts()
|
|
87
|
+
{
|
|
88
|
+
var scripts = new List<IUCPScript>();
|
|
89
|
+
foreach (var type in DiscoverScriptTypes())
|
|
90
|
+
{
|
|
91
|
+
var instance = Instantiate(type);
|
|
92
|
+
if (instance != null) scripts.Add(instance);
|
|
93
|
+
}
|
|
94
|
+
|
|
46
95
|
return scripts;
|
|
47
96
|
}
|
|
48
97
|
|
|
98
|
+
/// <summary>
|
|
99
|
+
/// Resolve one script by name, stopping at the first match.
|
|
100
|
+
/// `Name` is an instance member, so candidates must be constructed to be identified --
|
|
101
|
+
/// but running *every* script's constructor to invoke one of them is a side effect nobody
|
|
102
|
+
/// asked for, so stop as soon as the target is found.
|
|
103
|
+
/// </summary>
|
|
104
|
+
private static IUCPScript FindScript(string name)
|
|
105
|
+
{
|
|
106
|
+
foreach (var type in DiscoverScriptTypes())
|
|
107
|
+
{
|
|
108
|
+
var instance = Instantiate(type);
|
|
109
|
+
if (instance == null) continue;
|
|
110
|
+
if (string.Equals(instance.Name, name, StringComparison.OrdinalIgnoreCase))
|
|
111
|
+
return instance;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
49
117
|
private static object HandleList(string paramsJson)
|
|
50
118
|
{
|
|
51
119
|
var scripts = DiscoverScripts();
|
|
@@ -78,16 +146,7 @@ namespace UCP.Bridge
|
|
|
78
146
|
if (p.TryGetValue("params", out var paramsObj) && paramsObj != null)
|
|
79
147
|
scriptParams = MiniJson.Serialize(paramsObj);
|
|
80
148
|
|
|
81
|
-
var
|
|
82
|
-
IUCPScript target = null;
|
|
83
|
-
foreach (var s in scripts)
|
|
84
|
-
{
|
|
85
|
-
if (string.Equals(s.Name, name, StringComparison.OrdinalIgnoreCase))
|
|
86
|
-
{
|
|
87
|
-
target = s;
|
|
88
|
-
break;
|
|
89
|
-
}
|
|
90
|
-
}
|
|
149
|
+
var target = FindScript(name);
|
|
91
150
|
|
|
92
151
|
if (target == null)
|
|
93
152
|
throw new ArgumentException($"Script not found: {name}. Use exec/list to see available scripts.");
|