@mflrevan/ucp 0.6.0 → 0.6.2
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/CHANGELOG.md +62 -0
- package/bridge/com.ucp.bridge/Editor/Bridge/BridgeServer.cs +42 -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/PropertyController.cs +23 -7
- package/bridge/com.ucp.bridge/Editor/Controllers/RecordingController.cs +724 -0
- package/bridge/com.ucp.bridge/Editor/Controllers/RecordingController.cs.meta +11 -0
- 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/Controllers/TestRunnerController.cs +10 -1
- package/bridge/com.ucp.bridge/Editor/Protocol/MiniJson.cs +409 -63
- package/bridge/com.ucp.bridge/Tests/Editor/ControllerSmokeTests.cs +181 -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.2` 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,67 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.6.2] - 2026-08-31
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- Added native, scene-object-free `record/start`, `record/stop`, `record/status`, `record/arm`, and
|
|
8
|
+
`record/signal` RPCs for Game and Scene view video capture.
|
|
9
|
+
- Added aspect-preserving longest-edge sizing and explicit width/height canvases with letterboxing,
|
|
10
|
+
even-dimension normalization, configurable FPS/bitrate/duration, H.264/MP4 and VP8/WebM encoding,
|
|
11
|
+
and platform-aware format selection.
|
|
12
|
+
- Added play-enter, play-exit, bounded log-regex, and named-signal triggers. Armed play triggers use
|
|
13
|
+
`SessionState` to survive domain reloads, and `record/stop` also cancels a pending trigger.
|
|
14
|
+
- Added a `slowdown` parameter to `record/start` and `record/arm`. Frames are still captured at the
|
|
15
|
+
requested cadence in real time; only the encoder's declared frame rate is divided by the factor, so
|
|
16
|
+
playback is stretched without duplicating frames or re-encoding. `record/status` reports both
|
|
17
|
+
`slowdown` and the resulting `playbackFps`. Values outside 1-20 are rejected. This raises effective
|
|
18
|
+
temporal resolution for consumers that sample a clip at a fixed low rate rather than playing it.
|
|
19
|
+
- Documented that `record/start --view game` resolves `Camera.main`, not the Game view's composited
|
|
20
|
+
camera stack. Projects rendering through several enabled cameras record only the `MainCamera`-tagged
|
|
21
|
+
one, and camera depth does not change the selection; `--view scene` captures the Scene view camera,
|
|
22
|
+
which is unaffected by gameplay.
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
|
|
26
|
+
- Fixed `tests/run` counting an empty root suite as a passed test. When a filter matched nothing,
|
|
27
|
+
the run finished with a childless root whose result was collected as a leaf, so the summary
|
|
28
|
+
reported one passed test named after the project. Suite results are now skipped, so a run that
|
|
29
|
+
executed nothing reports a total of zero.
|
|
30
|
+
- Changed `tests/run` filtering from `Filter.testNames` to `Filter.groupNames`. `testNames` requires
|
|
31
|
+
an exact fully-qualified match, which silently selected nothing for a class or method name;
|
|
32
|
+
`groupNames` is matched as a regular expression against each test's full name, so partial names
|
|
33
|
+
select what a caller expects and fully-qualified names still match.
|
|
34
|
+
- Fixed `object/get-property` double-converting values that `SerializedPropertyToValue` had already
|
|
35
|
+
shaped for JSON. `GetPropertyValue` resolves a name through `SerializedObject.FindProperty` first
|
|
36
|
+
and returns a ready `List`/`Dictionary`; `ConvertToJson` then ran over that result, matched none
|
|
37
|
+
of its cases, and fell through to `value.ToString()`. A `Vector3` field came back as
|
|
38
|
+
``"System.Collections.Generic.List`1[System.Object]"`` with
|
|
39
|
+
``"type": "List`1"``. Every serialized Vector2/3/4, Quaternion, Color, Rect, Bounds and
|
|
40
|
+
object-reference field was affected, and float fields reported a `Double` type name.
|
|
41
|
+
`get-property` now takes its type name from the `SerializedProperty` so it agrees with
|
|
42
|
+
`get-fields`, converts only values read through the reflection fallback, and `ConvertToJson` is
|
|
43
|
+
idempotent for already-shaped `IList`/`IDictionary` values.
|
|
44
|
+
|
|
45
|
+
### Performance
|
|
46
|
+
|
|
47
|
+
- Frames render through a hidden reusable `RenderTexture` and reusable CPU readback texture into
|
|
48
|
+
Unity's native `MediaEncoder`; recording creates no scene objects or scripts and avoids per-frame
|
|
49
|
+
managed texture allocation.
|
|
50
|
+
|
|
51
|
+
### Reliability
|
|
52
|
+
|
|
53
|
+
- Added sibling `.partial` output and final rename, explicit overwrite handling, extension/format
|
|
54
|
+
validation, encoder and temporary-file cleanup on failure, detached safety deadlines, trigger wait
|
|
55
|
+
deadlines, dropped-frame accounting, and structured completed/failed status metadata.
|
|
56
|
+
- Active encoders finalize during assembly reload or editor shutdown. Play-boundary capture is
|
|
57
|
+
handled by the persisted `play-enter`/`play-exit` arm flow rather than attempting to retain a
|
|
58
|
+
native encoder across a domain reload.
|
|
59
|
+
|
|
60
|
+
### Tests
|
|
61
|
+
|
|
62
|
+
- Added editor smoke tests for recording RPC registration and idle status, aspect-preserving even
|
|
63
|
+
dimensions, signal matching, and cancellation of armed recordings.
|
|
64
|
+
|
|
3
65
|
## [0.4.1] - 2026-03-21
|
|
4
66
|
|
|
5
67
|
### 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.
|
|
29
|
+
private const string ProtocolVersion = "0.6.2";
|
|
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
|
|
|
@@ -121,6 +142,9 @@ namespace UCP.Bridge
|
|
|
121
142
|
// Screenshots
|
|
122
143
|
ScreenshotController.Register(s_router);
|
|
123
144
|
|
|
145
|
+
// Lightweight video recording
|
|
146
|
+
RecordingController.Register(s_router);
|
|
147
|
+
|
|
124
148
|
// Logs
|
|
125
149
|
LogsController.Register(s_router);
|
|
126
150
|
|
|
@@ -393,12 +417,22 @@ namespace UCP.Bridge
|
|
|
393
417
|
lock (s_clientLock) { s_logSubscribers.Remove(ws); }
|
|
394
418
|
}
|
|
395
419
|
|
|
396
|
-
// Dispatch on main thread
|
|
397
420
|
var capturedId = id;
|
|
398
421
|
var capturedMethod = method;
|
|
399
422
|
var capturedParams = paramsJson;
|
|
400
423
|
var capturedWs = ws;
|
|
401
424
|
|
|
425
|
+
// Answer the handshake straight off the socket thread. Everything it returns is
|
|
426
|
+
// cached, so queueing it only bought a wait for the next `EditorApplication.update`
|
|
427
|
+
// tick -- roughly 90 ms on an idle editor, paid by *every* CLI command before its
|
|
428
|
+
// real request could even be dispatched.
|
|
429
|
+
if (capturedMethod == "handshake")
|
|
430
|
+
{
|
|
431
|
+
SendResponse(capturedWs, s_router.Dispatch(capturedMethod, capturedId, capturedParams));
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Dispatch on main thread
|
|
402
436
|
s_mainThreadQueue.Enqueue(() =>
|
|
403
437
|
{
|
|
404
438
|
var response = s_router.Dispatch(capturedMethod, capturedId, capturedParams);
|
|
@@ -478,6 +512,7 @@ namespace UCP.Bridge
|
|
|
478
512
|
|
|
479
513
|
private static void OnLogMessage(string message, string stackTrace, LogType type)
|
|
480
514
|
{
|
|
515
|
+
RecordingController.NotifyLog(message);
|
|
481
516
|
// Don't forward our own log messages to avoid infinite recursion
|
|
482
517
|
if (message.StartsWith("[UCP]")) return;
|
|
483
518
|
|
|
@@ -544,6 +579,8 @@ namespace UCP.Bridge
|
|
|
544
579
|
|
|
545
580
|
Debug.Log("[UCP] Bridge server shutting down");
|
|
546
581
|
|
|
582
|
+
RecordingController.Shutdown();
|
|
583
|
+
|
|
547
584
|
s_cts?.Cancel();
|
|
548
585
|
|
|
549
586
|
// Stop listener first to release port immediately
|
|
@@ -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,
|
|
@@ -77,14 +77,14 @@ namespace UCP.Bridge
|
|
|
77
77
|
var comp = FindComponent(go, cObj.ToString());
|
|
78
78
|
string propName = propObj.ToString();
|
|
79
79
|
|
|
80
|
-
var value = GetPropertyValue(comp, propName);
|
|
80
|
+
var value = GetPropertyValue(comp, propName, out var typeName);
|
|
81
81
|
return new Dictionary<string, object>
|
|
82
82
|
{
|
|
83
83
|
["instanceId"] = instanceId,
|
|
84
84
|
["component"] = cObj.ToString(),
|
|
85
85
|
["property"] = propName,
|
|
86
|
-
["value"] =
|
|
87
|
-
["type"] =
|
|
86
|
+
["value"] = value,
|
|
87
|
+
["type"] = typeName
|
|
88
88
|
};
|
|
89
89
|
}
|
|
90
90
|
|
|
@@ -277,7 +277,7 @@ namespace UCP.Bridge
|
|
|
277
277
|
}
|
|
278
278
|
}
|
|
279
279
|
|
|
280
|
-
private static object GetPropertyValue(Component comp, string propertyName)
|
|
280
|
+
private static object GetPropertyValue(Component comp, string propertyName, out string typeName)
|
|
281
281
|
{
|
|
282
282
|
var so = new SerializedObject(comp);
|
|
283
283
|
try
|
|
@@ -285,22 +285,35 @@ namespace UCP.Bridge
|
|
|
285
285
|
so.Update();
|
|
286
286
|
var prop = so.FindProperty(propertyName);
|
|
287
287
|
if (prop != null)
|
|
288
|
+
{
|
|
289
|
+
// Already JSON-shaped -- reporting its type from the SerializedProperty keeps
|
|
290
|
+
// `get-property` and `get-fields` describing the same field the same way.
|
|
291
|
+
typeName = prop.propertyType.ToString();
|
|
288
292
|
return SerializedPropertyToValue(prop);
|
|
293
|
+
}
|
|
289
294
|
}
|
|
290
295
|
finally
|
|
291
296
|
{
|
|
292
297
|
so.Dispose();
|
|
293
298
|
}
|
|
294
299
|
|
|
295
|
-
// Fallback to reflection
|
|
300
|
+
// Fallback to reflection for names Unity does not serialize (e.g. Transform.position).
|
|
296
301
|
var type = comp.GetType();
|
|
297
302
|
var fi = type.GetField(propertyName, BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
|
|
298
303
|
if (fi != null)
|
|
299
|
-
|
|
304
|
+
{
|
|
305
|
+
var raw = fi.GetValue(comp);
|
|
306
|
+
typeName = raw != null ? raw.GetType().Name : fi.FieldType.Name;
|
|
307
|
+
return ConvertToJson(raw);
|
|
308
|
+
}
|
|
300
309
|
|
|
301
310
|
var pi = type.GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance);
|
|
302
311
|
if (pi != null && pi.CanRead)
|
|
303
|
-
|
|
312
|
+
{
|
|
313
|
+
var raw = pi.GetValue(comp);
|
|
314
|
+
typeName = raw != null ? raw.GetType().Name : pi.PropertyType.Name;
|
|
315
|
+
return ConvertToJson(raw);
|
|
316
|
+
}
|
|
304
317
|
|
|
305
318
|
throw new ArgumentException($"Property '{propertyName}' not found on {type.Name}");
|
|
306
319
|
}
|
|
@@ -488,6 +501,9 @@ namespace UCP.Bridge
|
|
|
488
501
|
return new List<object> { (double)c.r, (double)c.g, (double)c.b, (double)c.a };
|
|
489
502
|
if (value is UnityEngine.Object uObj)
|
|
490
503
|
return ObjectReferenceResolver.Serialize(uObj);
|
|
504
|
+
// Values that are already JSON-shaped pass through untouched.
|
|
505
|
+
if (value is IList || value is IDictionary)
|
|
506
|
+
return value;
|
|
491
507
|
return value.ToString();
|
|
492
508
|
}
|
|
493
509
|
|