@mflrevan/ucp 0.5.0 → 0.5.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.5.0` of the Unity Control Protocol CLI.
3
+ Version `0.5.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
 
@@ -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.5.0";
29
+ private const string ProtocolVersion = "0.5.2";
30
30
 
31
31
  private static TcpListener s_listener;
32
32
  private static CancellationTokenSource s_cts;
@@ -72,11 +72,12 @@ namespace UCP.Bridge
72
72
  try
73
73
  {
74
74
  RegisterHandlers();
75
+ LogsController.SeedHistoryFromConsole();
75
76
 
76
77
  EditorApplication.update += PumpMainThread;
77
78
  EditorApplication.quitting += Shutdown;
78
79
  AssemblyReloadEvents.beforeAssemblyReload += Shutdown;
79
- Application.logMessageReceived += OnLogMessage;
80
+ Application.logMessageReceivedThreaded += OnLogMessage;
80
81
 
81
82
  s_token = Guid.NewGuid().ToString("N").Substring(0, 16);
82
83
  StartServer();
@@ -165,6 +166,9 @@ namespace UCP.Bridge
165
166
 
166
167
  // Reference search (bridge fallback)
167
168
  ReferenceController.Register(s_router);
169
+
170
+ // Shader diagnostics
171
+ ShaderController.Register(s_router);
168
172
  }
169
173
 
170
174
  private static void StartServer()
@@ -555,7 +559,7 @@ namespace UCP.Bridge
555
559
  CleanLockFile();
556
560
 
557
561
  EditorApplication.update -= PumpMainThread;
558
- Application.logMessageReceived -= OnLogMessage;
562
+ Application.logMessageReceivedThreaded -= OnLogMessage;
559
563
  }
560
564
 
561
565
  private static Dictionary<string, object> ResponseToDict(JsonRpcResponse r)
@@ -2,8 +2,10 @@ using System;
2
2
  using System.Collections.Generic;
3
3
  using System.IO;
4
4
  using System.Reflection;
5
+ using System.Text.RegularExpressions;
5
6
  using UnityEditor;
6
7
  using UnityEngine;
8
+ using UnityEngine.Rendering;
7
9
 
8
10
  namespace UCP.Bridge
9
11
  {
@@ -13,6 +15,7 @@ namespace UCP.Bridge
13
15
  {
14
16
  router.Register("asset/search", HandleSearch);
15
17
  router.Register("asset/info", HandleInfo);
18
+ router.Register("asset/inspect", HandleInspect);
16
19
  router.Register("asset/read", HandleReadScriptableObject);
17
20
  router.Register("asset/write", HandleWriteScriptableObject);
18
21
  router.Register("asset/write-batch", HandleWriteScriptableObjectBatch);
@@ -28,6 +31,7 @@ namespace UCP.Bridge
28
31
  string typeFilter = null;
29
32
  string nameFilter = null;
30
33
  string pathFilter = null;
34
+ bool useRegex = false;
31
35
  int maxResults = 100;
32
36
 
33
37
  if (p != null)
@@ -38,13 +42,28 @@ namespace UCP.Bridge
38
42
  nameFilter = nObj.ToString();
39
43
  if (p.TryGetValue("path", out var pObj) && pObj != null)
40
44
  pathFilter = pObj.ToString();
45
+ if (p.TryGetValue("regex", out var regexObj) && regexObj != null)
46
+ useRegex = Convert.ToBoolean(regexObj);
41
47
  if (p.TryGetValue("maxResults", out var mObj))
42
48
  maxResults = Convert.ToInt32(mObj);
43
49
  }
44
50
 
51
+ Regex nameRegex = null;
52
+ if (useRegex && !string.IsNullOrEmpty(nameFilter))
53
+ {
54
+ try
55
+ {
56
+ nameRegex = new Regex(nameFilter, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
57
+ }
58
+ catch (ArgumentException ex)
59
+ {
60
+ throw new ArgumentException($"Invalid regex for name filter: {ex.Message}");
61
+ }
62
+ }
63
+
45
64
  // Build search filter
46
65
  string filter = "";
47
- if (!string.IsNullOrEmpty(nameFilter))
66
+ if (!useRegex && !string.IsNullOrEmpty(nameFilter))
48
67
  filter += nameFilter + " ";
49
68
  if (!string.IsNullOrEmpty(typeFilter))
50
69
  filter += $"t:{GetSearchTypeFilter(typeFilter)}";
@@ -64,7 +83,7 @@ namespace UCP.Bridge
64
83
  for (int i = 0; i < guids.Length; i++)
65
84
  {
66
85
  string assetPath = AssetDatabase.GUIDToAssetPath(guids[i]);
67
- foreach (var asset in GetMatchingAssets(assetPath, typeFilter, nameFilter))
86
+ foreach (var asset in GetMatchingAssets(assetPath, typeFilter, nameFilter, nameRegex))
68
87
  {
69
88
  totalMatches++;
70
89
  if (results.Count >= maxResults)
@@ -135,6 +154,47 @@ namespace UCP.Bridge
135
154
  return info;
136
155
  }
137
156
 
157
+ private static object HandleInspect(string paramsJson)
158
+ {
159
+ var p = MiniJson.Deserialize(paramsJson) as Dictionary<string, object>;
160
+ if (p == null || !p.TryGetValue("path", out var pathObj))
161
+ throw new ArgumentException("Missing 'path' parameter");
162
+
163
+ string assetPath = pathObj.ToString();
164
+ int maxFields = 80;
165
+ if (p.TryGetValue("maxFields", out var maxObj) && maxObj != null)
166
+ maxFields = Math.Max(1, Convert.ToInt32(maxObj));
167
+
168
+ var asset = AssetDatabase.LoadAssetAtPath<UnityEngine.Object>(assetPath);
169
+ if (asset == null)
170
+ throw new ArgumentException($"Asset not found: {assetPath}");
171
+
172
+ var result = HandleInfo(paramsJson) as Dictionary<string, object>;
173
+ result["inspectedAtUtc"] = DateTime.UtcNow.ToString("o");
174
+
175
+ var importer = AssetImporter.GetAtPath(assetPath);
176
+ if (importer != null)
177
+ result["importer"] = InspectSerializedObject(importer, maxFields);
178
+
179
+ if (asset is Material material)
180
+ {
181
+ result["shader"] = material.shader != null ? material.shader.name : string.Empty;
182
+ result["shaderPath"] = material.shader != null ? AssetDatabase.GetAssetPath(material.shader) : string.Empty;
183
+ result["keywords"] = InspectMaterialKeywords(material);
184
+ result["properties"] = InspectMaterialProperties(material, maxFields);
185
+ }
186
+ else if (asset is GameObject gameObject)
187
+ {
188
+ result["renderers"] = InspectPrefabRenderers(gameObject);
189
+ }
190
+ else
191
+ {
192
+ result["fields"] = InspectSerializedObject(asset, maxFields);
193
+ }
194
+
195
+ return result;
196
+ }
197
+
138
198
  private static object HandleReadScriptableObject(string paramsJson)
139
199
  {
140
200
  var p = MiniJson.Deserialize(paramsJson) as Dictionary<string, object>;
@@ -194,6 +254,133 @@ namespace UCP.Bridge
194
254
  };
195
255
  }
196
256
 
257
+ private static List<object> InspectSerializedObject(UnityEngine.Object target, int maxFields)
258
+ {
259
+ var serializedObject = new SerializedObject(target);
260
+ var fields = new List<object>();
261
+ try
262
+ {
263
+ var iterator = serializedObject.GetIterator();
264
+ if (iterator.NextVisible(true))
265
+ {
266
+ do
267
+ {
268
+ if (iterator.name == "m_Script") continue;
269
+ fields.Add(SerializedPropertyControllerSupport.Describe(iterator));
270
+ }
271
+ while (fields.Count < maxFields && iterator.NextVisible(false));
272
+ }
273
+ }
274
+ finally
275
+ {
276
+ serializedObject.Dispose();
277
+ }
278
+
279
+ return fields;
280
+ }
281
+
282
+ private static List<object> InspectMaterialKeywords(Material material)
283
+ {
284
+ var keywords = new List<object>();
285
+ foreach (var keyword in material.enabledKeywords)
286
+ keywords.Add(keyword.name);
287
+ return keywords;
288
+ }
289
+
290
+ private static List<object> InspectMaterialProperties(Material material, int maxFields)
291
+ {
292
+ var properties = new List<object>();
293
+ var shader = material.shader;
294
+ if (shader == null)
295
+ return properties;
296
+
297
+ var count = Math.Min(shader.GetPropertyCount(), maxFields);
298
+ for (int i = 0; i < count; i++)
299
+ {
300
+ var name = shader.GetPropertyName(i);
301
+ var type = shader.GetPropertyType(i);
302
+ properties.Add(new Dictionary<string, object>
303
+ {
304
+ ["name"] = name,
305
+ ["description"] = shader.GetPropertyDescription(i),
306
+ ["type"] = type.ToString(),
307
+ ["value"] = ReadMaterialValue(material, name, type)
308
+ });
309
+ }
310
+
311
+ return properties;
312
+ }
313
+
314
+ private static object ReadMaterialValue(Material material, string name, ShaderPropertyType type)
315
+ {
316
+ switch (type)
317
+ {
318
+ case ShaderPropertyType.Color:
319
+ var color = material.GetColor(name);
320
+ return new List<object> { color.r, color.g, color.b, color.a };
321
+ case ShaderPropertyType.Vector:
322
+ var vector = material.GetVector(name);
323
+ return new List<object> { vector.x, vector.y, vector.z, vector.w };
324
+ case ShaderPropertyType.Float:
325
+ case ShaderPropertyType.Range:
326
+ return material.GetFloat(name);
327
+ case ShaderPropertyType.Texture:
328
+ var texture = material.GetTexture(name);
329
+ return texture != null
330
+ ? new Dictionary<string, object>
331
+ {
332
+ ["name"] = texture.name,
333
+ ["path"] = AssetDatabase.GetAssetPath(texture),
334
+ ["type"] = texture.GetType().Name
335
+ }
336
+ : null;
337
+ default:
338
+ return null;
339
+ }
340
+ }
341
+
342
+ private static List<object> InspectPrefabRenderers(GameObject gameObject)
343
+ {
344
+ var renderers = new List<object>();
345
+ foreach (var renderer in gameObject.GetComponentsInChildren<Renderer>(true))
346
+ {
347
+ var materials = new List<object>();
348
+ foreach (var material in renderer.sharedMaterials)
349
+ {
350
+ materials.Add(material != null
351
+ ? new Dictionary<string, object>
352
+ {
353
+ ["name"] = material.name,
354
+ ["path"] = AssetDatabase.GetAssetPath(material),
355
+ ["shader"] = material.shader != null ? material.shader.name : string.Empty
356
+ }
357
+ : null);
358
+ }
359
+
360
+ renderers.Add(new Dictionary<string, object>
361
+ {
362
+ ["path"] = GetTransformPath(renderer.transform),
363
+ ["type"] = renderer.GetType().Name,
364
+ ["enabled"] = renderer.enabled,
365
+ ["materials"] = materials
366
+ });
367
+ }
368
+
369
+ return renderers;
370
+ }
371
+
372
+ private static string GetTransformPath(Transform transform)
373
+ {
374
+ var names = new List<string>();
375
+ var current = transform;
376
+ while (current != null)
377
+ {
378
+ names.Insert(0, current.name);
379
+ current = current.parent;
380
+ }
381
+ return string.Join("/", names);
382
+ }
383
+
197
384
  private static object HandleWriteScriptableObject(string paramsJson)
198
385
  {
199
386
  var p = MiniJson.Deserialize(paramsJson) as Dictionary<string, object>;
@@ -354,6 +541,7 @@ namespace UCP.Bridge
354
541
  throw new ArgumentException("Missing 'moves' parameter");
355
542
 
356
543
  var continueOnError = TryGetOptionalBool(parameters, "continueOnError");
544
+ var dryRun = TryGetOptionalBool(parameters, "dryRun");
357
545
  var requests = ParseMoveRequests(movesObject);
358
546
  var results = new List<object>();
359
547
  var errors = new List<object>();
@@ -361,15 +549,14 @@ namespace UCP.Bridge
361
549
  var stopped = false;
362
550
  var anyChanged = false;
363
551
 
364
- AssetDatabase.StartAssetEditing();
365
- try
552
+ if (dryRun)
366
553
  {
367
554
  for (var index = 0; index < requests.Count; index++)
368
555
  {
369
556
  var request = requests[index];
370
557
  try
371
558
  {
372
- var moveResult = MoveAssetInternal(request.SourcePath, request.Destination, false);
559
+ var moveResult = PreviewMoveInternal(request.SourcePath, request.Destination);
373
560
  results.Add(AddMoveIndex(moveResult, index));
374
561
  if (GetBool(moveResult, "changed"))
375
562
  {
@@ -395,12 +582,75 @@ namespace UCP.Bridge
395
582
  }
396
583
  }
397
584
  }
398
- finally
585
+ else
399
586
  {
400
- AssetDatabase.StopAssetEditing();
587
+ var preparedMoves = new List<PreparedMove>();
588
+ for (var index = 0; index < requests.Count; index++)
589
+ {
590
+ var request = requests[index];
591
+ try
592
+ {
593
+ var preparedMove = PrepareMove(request.SourcePath, request.Destination, true);
594
+ preparedMoves.Add(new PreparedMove(index, request.SourcePath, preparedMove));
595
+ }
596
+ catch (Exception ex)
597
+ {
598
+ errors.Add(new Dictionary<string, object>
599
+ {
600
+ ["index"] = index,
601
+ ["sourcePath"] = request.SourcePath,
602
+ ["destinationPath"] = request.Destination,
603
+ ["message"] = ex.Message
604
+ });
605
+
606
+ if (!continueOnError)
607
+ {
608
+ stopped = true;
609
+ break;
610
+ }
611
+ }
612
+ }
613
+
614
+ AssetDatabase.StartAssetEditing();
615
+ try
616
+ {
617
+ foreach (var preparedMove in preparedMoves)
618
+ {
619
+ try
620
+ {
621
+ var moveResult = MovePreparedAsset(preparedMove.SourcePath, preparedMove.Move, false);
622
+ results.Add(AddMoveIndex(moveResult, preparedMove.Index));
623
+ if (GetBool(moveResult, "changed"))
624
+ {
625
+ movedCount++;
626
+ anyChanged = true;
627
+ }
628
+ }
629
+ catch (Exception ex)
630
+ {
631
+ errors.Add(new Dictionary<string, object>
632
+ {
633
+ ["index"] = preparedMove.Index,
634
+ ["sourcePath"] = preparedMove.SourcePath,
635
+ ["destinationPath"] = preparedMove.Move.ResolvedDestination,
636
+ ["message"] = ex.Message
637
+ });
638
+
639
+ if (!continueOnError)
640
+ {
641
+ stopped = true;
642
+ break;
643
+ }
644
+ }
645
+ }
646
+ }
647
+ finally
648
+ {
649
+ AssetDatabase.StopAssetEditing();
650
+ }
401
651
  }
402
652
 
403
- if (anyChanged)
653
+ if (!dryRun && anyChanged)
404
654
  {
405
655
  AssetDatabase.SaveAssets();
406
656
  AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
@@ -412,6 +662,7 @@ namespace UCP.Bridge
412
662
  ["requested"] = requests.Count,
413
663
  ["moved"] = movedCount,
414
664
  ["failed"] = errors.Count,
665
+ ["dryRun"] = dryRun,
415
666
  ["stopped"] = stopped,
416
667
  ["results"] = results,
417
668
  ["errors"] = errors
@@ -431,10 +682,19 @@ namespace UCP.Bridge
431
682
  }
432
683
  }
433
684
 
434
- private static IEnumerable<UnityEngine.Object> GetMatchingAssets(string assetPath, string typeFilter, string nameFilter)
685
+ private static IEnumerable<UnityEngine.Object> GetMatchingAssets(
686
+ string assetPath,
687
+ string typeFilter,
688
+ string nameFilter,
689
+ Regex nameRegex)
435
690
  {
436
691
  UnityEngine.Object[] candidates;
437
- if (string.IsNullOrEmpty(typeFilter) && string.IsNullOrEmpty(nameFilter))
692
+ if (string.IsNullOrEmpty(typeFilter) && string.IsNullOrEmpty(nameFilter) && nameRegex == null)
693
+ {
694
+ var mainAsset = AssetDatabase.LoadMainAssetAtPath(assetPath);
695
+ candidates = mainAsset != null ? new[] { mainAsset } : Array.Empty<UnityEngine.Object>();
696
+ }
697
+ else if (assetPath.EndsWith(".unity", StringComparison.OrdinalIgnoreCase))
438
698
  {
439
699
  var mainAsset = AssetDatabase.LoadMainAssetAtPath(assetPath);
440
700
  candidates = mainAsset != null ? new[] { mainAsset } : Array.Empty<UnityEngine.Object>();
@@ -447,17 +707,24 @@ namespace UCP.Bridge
447
707
  foreach (var candidate in candidates)
448
708
  {
449
709
  if (candidate == null) continue;
450
- if (!MatchesName(candidate, assetPath, nameFilter)) continue;
710
+ if (!MatchesName(candidate, assetPath, nameFilter, nameRegex)) continue;
451
711
  if (!MatchesType(candidate, assetPath, typeFilter)) continue;
452
712
  yield return candidate;
453
713
  }
454
714
  }
455
715
 
456
- private static bool MatchesName(UnityEngine.Object asset, string assetPath, string nameFilter)
716
+ private static bool MatchesName(UnityEngine.Object asset, string assetPath, string nameFilter, Regex nameRegex)
457
717
  {
458
- if (string.IsNullOrEmpty(nameFilter))
718
+ if (string.IsNullOrEmpty(nameFilter) && nameRegex == null)
459
719
  return true;
460
720
 
721
+ if (nameRegex != null)
722
+ {
723
+ var fileStem = System.IO.Path.GetFileNameWithoutExtension(assetPath);
724
+ return nameRegex.IsMatch(asset.name)
725
+ || (!string.IsNullOrEmpty(fileStem) && nameRegex.IsMatch(fileStem));
726
+ }
727
+
461
728
  return asset.name.Contains(nameFilter, StringComparison.OrdinalIgnoreCase)
462
729
  || System.IO.Path.GetFileNameWithoutExtension(assetPath).Contains(nameFilter, StringComparison.OrdinalIgnoreCase);
463
730
  }
@@ -507,30 +774,29 @@ namespace UCP.Bridge
507
774
 
508
775
  private static Dictionary<string, object> MoveAssetInternal(string sourcePath, string destination, bool finalize)
509
776
  {
510
- var normalizedSource = NormalizeMovePath(sourcePath);
511
- var isFolder = AssetDatabase.IsValidFolder(normalizedSource);
512
- if (!isFolder && !AssetDatabase.AssetPathExists(normalizedSource))
513
- throw new ArgumentException($"Asset not found: {normalizedSource}");
514
-
515
- if (!IsMovableAssetPath(normalizedSource))
516
- throw new ArgumentException($"Asset moves are only supported under Assets/: {normalizedSource}");
517
-
518
- var identity = DescribeExistingAsset(normalizedSource, isFolder);
519
- var resolvedDestination = ResolveDestinationPath(normalizedSource, destination);
520
- if (!IsMovableAssetPath(resolvedDestination))
521
- throw new ArgumentException($"Destination must be under Assets/: {resolvedDestination}");
522
-
523
- if (string.Equals(normalizedSource, resolvedDestination, StringComparison.OrdinalIgnoreCase))
524
- {
525
- return DescribeMovedAsset(normalizedSource, resolvedDestination, false, identity);
526
- }
777
+ var move = PrepareMove(sourcePath, destination, true);
778
+ return MovePreparedAsset(NormalizeMovePath(sourcePath), move, finalize);
779
+ }
527
780
 
528
- if (AssetDatabase.AssetPathExists(resolvedDestination) || AssetDatabase.IsValidFolder(resolvedDestination))
529
- throw new ArgumentException($"Destination already exists: {resolvedDestination}");
781
+ private static Dictionary<string, object> PreviewMoveInternal(string sourcePath, string destination)
782
+ {
783
+ var normalizedSource = NormalizeMovePath(sourcePath);
784
+ var move = PrepareMove(normalizedSource, destination, false);
785
+ return DescribeMovedAsset(normalizedSource, move.ResolvedDestination, move.Changed, move.Identity);
786
+ }
530
787
 
531
- EnsureParentFoldersExist(resolvedDestination);
788
+ private static ValidatedMove PrepareMove(string sourcePath, string destination, bool ensureParentFolders)
789
+ {
790
+ var normalizedSource = NormalizeMovePath(sourcePath);
791
+ var move = ValidateMoveRequest(normalizedSource, destination);
792
+ if (ensureParentFolders)
793
+ EnsureParentFoldersExist(move.ResolvedDestination);
794
+ return move;
795
+ }
532
796
 
533
- var moveError = AssetDatabase.MoveAsset(normalizedSource, resolvedDestination);
797
+ private static Dictionary<string, object> MovePreparedAsset(string normalizedSource, ValidatedMove move, bool finalize)
798
+ {
799
+ var moveError = AssetDatabase.MoveAsset(normalizedSource, move.ResolvedDestination);
534
800
  if (!string.IsNullOrEmpty(moveError))
535
801
  throw new InvalidOperationException(moveError);
536
802
 
@@ -540,7 +806,7 @@ namespace UCP.Bridge
540
806
  AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
541
807
  }
542
808
 
543
- return DescribeMovedAsset(normalizedSource, resolvedDestination, true, identity);
809
+ return DescribeMovedAsset(normalizedSource, move.ResolvedDestination, true, move.Identity);
544
810
  }
545
811
 
546
812
  private static Dictionary<string, object> DescribeMovedAsset(
@@ -648,6 +914,29 @@ namespace UCP.Bridge
648
914
  return path?.Trim().Replace('\\', '/');
649
915
  }
650
916
 
917
+ private static ValidatedMove ValidateMoveRequest(string normalizedSource, string destination)
918
+ {
919
+ var isFolder = AssetDatabase.IsValidFolder(normalizedSource);
920
+ if (!isFolder && !AssetDatabase.AssetPathExists(normalizedSource))
921
+ throw new ArgumentException(BuildAssetNotFoundMessage(normalizedSource));
922
+
923
+ if (!IsMovableAssetPath(normalizedSource))
924
+ throw new ArgumentException($"Asset moves are only supported under Assets/: {normalizedSource}");
925
+
926
+ var identity = DescribeExistingAsset(normalizedSource, isFolder);
927
+ var resolvedDestination = ResolveDestinationPath(normalizedSource, destination);
928
+ if (!IsMovableAssetPath(resolvedDestination))
929
+ throw new ArgumentException($"Destination must be under Assets/: {resolvedDestination}");
930
+
931
+ if (string.Equals(normalizedSource, resolvedDestination, StringComparison.OrdinalIgnoreCase))
932
+ return new ValidatedMove(identity, resolvedDestination, false);
933
+
934
+ if (AssetDatabase.AssetPathExists(resolvedDestination) || AssetDatabase.IsValidFolder(resolvedDestination))
935
+ throw new ArgumentException($"Destination already exists: {resolvedDestination}");
936
+
937
+ return new ValidatedMove(identity, resolvedDestination, true);
938
+ }
939
+
651
940
  private static bool IsMovableAssetPath(string path)
652
941
  {
653
942
  return !string.IsNullOrEmpty(path)
@@ -655,6 +944,60 @@ namespace UCP.Bridge
655
944
  || path.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase));
656
945
  }
657
946
 
947
+ private static string BuildAssetNotFoundMessage(string normalizedSource)
948
+ {
949
+ var message = $"Asset not found: {normalizedSource}.";
950
+ var suggestions = FindSimilarAssetPaths(normalizedSource, 3);
951
+ if (suggestions.Count > 0)
952
+ message += " Did you mean: " + string.Join(", ", suggestions) + "?";
953
+ message += " If the asset was created or renamed outside Unity, refresh or reimport so AssetDatabase can pick up the latest path.";
954
+ return message;
955
+ }
956
+
957
+ private static List<string> FindSimilarAssetPaths(string normalizedSource, int maxSuggestions)
958
+ {
959
+ var targetName = Path.GetFileNameWithoutExtension(normalizedSource) ?? normalizedSource;
960
+ var targetFileName = Path.GetFileName(normalizedSource) ?? normalizedSource;
961
+ var targetExtension = Path.GetExtension(normalizedSource) ?? string.Empty;
962
+ var matches = new List<(string path, int score)>();
963
+
964
+ foreach (var candidate in AssetDatabase.GetAllAssetPaths())
965
+ {
966
+ if (!candidate.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase))
967
+ continue;
968
+
969
+ var score = 0;
970
+ if (candidate.IndexOf(targetName, StringComparison.OrdinalIgnoreCase) >= 0)
971
+ score += 3;
972
+ if (string.Equals(Path.GetFileName(candidate), targetFileName, StringComparison.OrdinalIgnoreCase))
973
+ score += 4;
974
+ if (string.Equals(Path.GetExtension(candidate), targetExtension, StringComparison.OrdinalIgnoreCase))
975
+ score += 1;
976
+ if (score > 0)
977
+ matches.Add((candidate, score));
978
+ }
979
+
980
+ matches.Sort((left, right) =>
981
+ {
982
+ var scoreCompare = right.score.CompareTo(left.score);
983
+ return scoreCompare != 0
984
+ ? scoreCompare
985
+ : string.Compare(left.path, right.path, StringComparison.OrdinalIgnoreCase);
986
+ });
987
+
988
+ var suggestions = new List<string>();
989
+ foreach (var match in matches)
990
+ {
991
+ if (suggestions.Contains(match.path))
992
+ continue;
993
+ suggestions.Add(match.path);
994
+ if (suggestions.Count >= maxSuggestions)
995
+ break;
996
+ }
997
+
998
+ return suggestions;
999
+ }
1000
+
658
1001
  private static Dictionary<string, object> ParseParameters(string paramsJson)
659
1002
  {
660
1003
  return MiniJson.Deserialize(paramsJson) as Dictionary<string, object>
@@ -750,6 +1093,20 @@ namespace UCP.Bridge
750
1093
  public string Destination { get; }
751
1094
  }
752
1095
 
1096
+ private readonly struct PreparedMove
1097
+ {
1098
+ public PreparedMove(int index, string sourcePath, ValidatedMove move)
1099
+ {
1100
+ Index = index;
1101
+ SourcePath = sourcePath;
1102
+ Move = move;
1103
+ }
1104
+
1105
+ public int Index { get; }
1106
+ public string SourcePath { get; }
1107
+ public ValidatedMove Move { get; }
1108
+ }
1109
+
753
1110
  private readonly struct AssetIdentity
754
1111
  {
755
1112
  public AssetIdentity(string guid, string name, string type, bool isFolder)
@@ -766,5 +1123,19 @@ namespace UCP.Bridge
766
1123
  public bool IsFolder { get; }
767
1124
  }
768
1125
 
1126
+ private readonly struct ValidatedMove
1127
+ {
1128
+ public ValidatedMove(AssetIdentity identity, string resolvedDestination, bool changed)
1129
+ {
1130
+ Identity = identity;
1131
+ ResolvedDestination = resolvedDestination;
1132
+ Changed = changed;
1133
+ }
1134
+
1135
+ public AssetIdentity Identity { get; }
1136
+ public string ResolvedDestination { get; }
1137
+ public bool Changed { get; }
1138
+ }
1139
+
769
1140
  }
770
1141
  }