@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # @mflrevan/ucp
2
2
 
3
- Version `0.6.0` of the Unity Control Protocol CLI.
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.0";
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 = Application.unityVersion,
101
- projectName = Application.productName,
102
- projectPath = Path.GetDirectoryName(Application.dataPath)
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 { status = "ok", message = "Asset database refreshed and compilation requested" };
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 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,
@@ -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"] = ConvertToJson(value),
87
- ["type"] = value != null ? value.GetType().Name : "null"
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
- return fi.GetValue(comp);
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
- return pi.GetValue(comp);
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