@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.
@@ -1,7 +1,10 @@
1
1
  using System;
2
2
  using System.Collections.Generic;
3
+ using System.IO;
3
4
  using System.Linq;
5
+ using System.Reflection;
4
6
  using System.Text.RegularExpressions;
7
+ using UnityEditor;
5
8
  using UnityEngine;
6
9
 
7
10
  namespace UCP.Bridge
@@ -14,7 +17,10 @@ namespace UCP.Bridge
14
17
 
15
18
  private static readonly object s_historyLock = new object();
16
19
  private static readonly List<LogRecord> s_history = new List<LogRecord>();
20
+ private static Func<List<ConsoleBackfillEntry>> s_consoleBackfillProvider = CaptureRecentConsoleEntries;
17
21
  private static long s_nextId = 1;
22
+ private static StreamWriter s_captureWriter;
23
+ private static string s_capturePath;
18
24
 
19
25
  public static void Register(CommandRouter router)
20
26
  {
@@ -24,6 +30,8 @@ namespace UCP.Bridge
24
30
  router.Register("logs/search", HandleSearch);
25
31
  router.Register("logs/get", HandleGet);
26
32
  router.Register("logs/status", HandleStatus);
33
+ router.Register("logs/capture/start", HandleCaptureStart);
34
+ router.Register("logs/capture/stop", HandleCaptureStop);
27
35
  }
28
36
 
29
37
  public static Dictionary<string, object> RecordLog(string message, string stackTrace, LogType type)
@@ -37,6 +45,7 @@ namespace UCP.Bridge
37
45
  {
38
46
  s_history.Clear();
39
47
  s_nextId = 1;
48
+ s_consoleBackfillProvider = CaptureRecentConsoleEntries;
40
49
  }
41
50
  }
42
51
 
@@ -45,6 +54,84 @@ namespace UCP.Bridge
45
54
  return RecordLog(level, message, stackTrace);
46
55
  }
47
56
 
57
+ public static void SetConsoleBackfillProviderForTests(Func<List<ConsoleBackfillEntry>> provider)
58
+ {
59
+ lock (s_historyLock)
60
+ {
61
+ s_consoleBackfillProvider = provider ?? CaptureRecentConsoleEntries;
62
+ }
63
+ }
64
+
65
+ public static void SeedHistoryFromConsole()
66
+ {
67
+ lock (s_historyLock)
68
+ {
69
+ SeedHistoryFromConsoleLocked();
70
+ }
71
+ }
72
+
73
+ public static Dictionary<string, object> StartFileCapture(string path)
74
+ {
75
+ lock (s_historyLock)
76
+ {
77
+ StopFileCaptureLocked();
78
+
79
+ var resolvedPath = ResolveCapturePath(path);
80
+ var directory = Path.GetDirectoryName(resolvedPath);
81
+ if (!string.IsNullOrEmpty(directory))
82
+ Directory.CreateDirectory(directory);
83
+
84
+ s_captureWriter = new StreamWriter(resolvedPath, false);
85
+ s_captureWriter.AutoFlush = true;
86
+ s_capturePath = resolvedPath;
87
+ s_captureWriter.WriteLine($"# UCP play-mode log capture started {DateTime.UtcNow:o}");
88
+
89
+ return new Dictionary<string, object>
90
+ {
91
+ ["status"] = "ok",
92
+ ["path"] = resolvedPath
93
+ };
94
+ }
95
+ }
96
+
97
+ public static Dictionary<string, object> StopFileCapture()
98
+ {
99
+ lock (s_historyLock)
100
+ {
101
+ var path = s_capturePath;
102
+ StopFileCaptureLocked();
103
+ return new Dictionary<string, object>
104
+ {
105
+ ["status"] = "ok",
106
+ ["path"] = path ?? string.Empty,
107
+ ["active"] = false
108
+ };
109
+ }
110
+ }
111
+
112
+ public static long GetLatestId()
113
+ {
114
+ SeedHistoryFromConsole();
115
+ lock (s_historyLock)
116
+ {
117
+ return s_history.Count == 0 ? 0 : s_history[s_history.Count - 1].Id;
118
+ }
119
+ }
120
+
121
+ public static Dictionary<string, object> BuildStatusSummary(long afterId = 0)
122
+ {
123
+ SeedHistoryFromConsole();
124
+ lock (s_historyLock)
125
+ {
126
+ var ordered = s_history
127
+ .Where(entry => entry.Id > afterId)
128
+ .OrderBy(entry => entry.Id)
129
+ .ToList();
130
+
131
+ return BuildStatusResult(ordered, afterId);
132
+ }
133
+ }
134
+
48
135
  private static object HandleTail(string paramsJson)
49
136
  {
50
137
  var query = ParseQuery(paramsJson, includePattern: false);
@@ -65,6 +152,7 @@ namespace UCP.Bridge
65
152
 
66
153
  long id = Convert.ToInt64(idObj);
67
154
 
155
+ SeedHistoryFromConsole();
68
156
  lock (s_historyLock)
69
157
  {
70
158
  var entry = s_history.FirstOrDefault(record => record.Id == id);
@@ -77,106 +165,82 @@ namespace UCP.Bridge
77
165
 
78
166
  private static object HandleStatus(string paramsJson)
79
167
  {
80
- lock (s_historyLock)
81
- {
82
- var ordered = s_history.OrderBy(entry => entry.Id).ToList();
83
- var byLevel = new Dictionary<string, object>
84
- {
85
- ["info"] = ordered.Count(entry => entry.Level == "info"),
86
- ["warning"] = ordered.Count(entry => entry.Level == "warning"),
87
- ["error"] = ordered.Count(entry => entry.Level == "error"),
88
- ["exception"] = ordered.Count(entry => entry.Level == "exception")
89
- };
90
-
91
- var grouped = ordered
92
- .GroupBy(entry => $"{entry.Level}|{Fingerprint(entry.Message)}")
93
- .Select(group =>
94
- {
95
- var first = group.First();
96
- var last = group.Last();
97
- return new Dictionary<string, object>
98
- {
99
- ["level"] = first.Level,
100
- ["fingerprint"] = Fingerprint(first.Message),
101
- ["sampleMessage"] = Preview(first.Message, MaxPreviewLength),
102
- ["count"] = group.Count(),
103
- ["firstTimestamp"] = first.Timestamp,
104
- ["lastTimestamp"] = last.Timestamp,
105
- ["latestId"] = last.Id
106
- };
107
- })
108
- .OrderByDescending(entry => Convert.ToInt32(entry["count"]))
109
- .ThenBy(entry => entry["sampleMessage"].ToString())
110
- .ToList();
111
-
112
- var result = new Dictionary<string, object>
113
- {
114
- ["total"] = ordered.Count,
115
- ["byLevel"] = byLevel,
116
- ["uniqueCount"] = grouped.Count,
117
- ["topCategories"] = grouped.Take(8).Cast<object>().ToList()
118
- };
168
+ var p = MiniJson.Deserialize(paramsJson) as Dictionary<string, object>;
169
+ long afterId = 0;
170
+ if (p != null && p.TryGetValue("afterId", out var afterIdObj) && afterIdObj != null)
171
+ afterId = Math.Max(0, Convert.ToInt64(afterIdObj));
119
172
 
120
- if (ordered.Count > 0)
121
- {
122
- var first = ordered.First();
123
- var last = ordered.Last();
124
- result["firstTimestamp"] = first.Timestamp;
125
- result["lastTimestamp"] = last.Timestamp;
126
- result["historyWindowSeconds"] = Math.Max(0d, (last.TimestampUtc - first.TimestampUtc).TotalSeconds);
127
- result["latestId"] = last.Id;
128
- }
173
+ return BuildStatusSummary(afterId);
174
+ }
129
175
 
130
- var playSession = PlayModeController.GetSessionSnapshot();
131
- result["play"] = SerializePlaySession(playSession);
176
+ private static object HandleCaptureStart(string paramsJson)
177
+ {
178
+ var p = MiniJson.Deserialize(paramsJson) as Dictionary<string, object>;
179
+ if (p == null || !p.TryGetValue("path", out var pathObj) || pathObj == null)
180
+ throw new ArgumentException("Missing 'path' parameter");
132
181
 
133
- if (playSession.LastEnteredPlayAtUtc.HasValue)
134
- {
135
- var sessionEnd = playSession.Playing
136
- ? DateTime.UtcNow
137
- : (playSession.LastExitedPlayAtUtc ?? DateTime.UtcNow);
138
- var sessionLogs = ordered
139
- .Where(entry => entry.TimestampUtc >= playSession.LastEnteredPlayAtUtc.Value
140
- && entry.TimestampUtc <= sessionEnd)
141
- .ToList();
142
-
143
- result["lastPlayWindow"] = new Dictionary<string, object>
144
- {
145
- ["startedAt"] = playSession.LastEnteredPlayAtUtc.Value.ToString("o"),
146
- ["endedAt"] = sessionEnd.ToString("o"),
147
- ["durationSeconds"] = Math.Max(0d, (sessionEnd - playSession.LastEnteredPlayAtUtc.Value).TotalSeconds),
148
- ["total"] = sessionLogs.Count,
149
- ["warnings"] = sessionLogs.Count(entry => entry.Level == "warning"),
150
- ["errors"] = sessionLogs.Count(entry => entry.Level == "error" || entry.Level == "exception")
151
- };
152
- }
182
+ return StartFileCapture(pathObj.ToString());
183
+ }
153
184
 
154
- return result;
155
- }
185
+ private static object HandleCaptureStop(string paramsJson)
186
+ {
187
+ return StopFileCapture();
156
188
  }
157
189
 
158
190
  private static Dictionary<string, object> RecordLog(string level, string message, string stackTrace)
159
191
  {
160
192
  lock (s_historyLock)
161
193
  {
162
- var entry = new LogRecord
163
- {
164
- Id = s_nextId++,
165
- Level = NormalizeLevel(level),
166
- Message = message ?? string.Empty,
167
- StackTrace = stackTrace ?? string.Empty,
168
- TimestampUtc = DateTime.UtcNow
169
- };
170
- entry.Timestamp = entry.TimestampUtc.ToString("o");
171
-
172
- s_history.Add(entry);
173
- if (s_history.Count > MaxHistoryEntries)
174
- s_history.RemoveAt(0);
194
+ var entry = AppendRecordLocked(
195
+ NormalizeLevel(level),
196
+ message ?? string.Empty,
197
+ stackTrace ?? string.Empty,
198
+ DateTime.UtcNow
199
+ );
200
+ WriteCaptureLineLocked(entry);
175
201
 
176
202
  return SerializeFull(entry);
177
203
  }
178
204
  }
179
205
 
206
+ private static void WriteCaptureLineLocked(LogRecord entry)
207
+ {
208
+ if (s_captureWriter == null)
209
+ return;
210
+
211
+ var message = (entry.Message ?? string.Empty).Replace("\r", "\\r").Replace("\n", "\\n");
212
+ s_captureWriter.WriteLine($"{entry.Timestamp} [{entry.Level}] {message}");
213
+ if (!string.IsNullOrEmpty(entry.StackTrace))
214
+ {
215
+ var stack = entry.StackTrace.Replace("\r", "\\r").Replace("\n", "\\n");
216
+ s_captureWriter.WriteLine($"{entry.Timestamp} [stack] {stack}");
217
+ }
218
+ }
219
+
220
+ private static void StopFileCaptureLocked()
221
+ {
222
+ if (s_captureWriter != null)
223
+ {
224
+ s_captureWriter.WriteLine($"# UCP log capture stopped {DateTime.UtcNow:o}");
225
+ s_captureWriter.Dispose();
226
+ s_captureWriter = null;
227
+ }
228
+
229
+ s_capturePath = null;
230
+ }
231
+
232
+ private static string ResolveCapturePath(string path)
233
+ {
234
+ if (string.IsNullOrEmpty(path))
235
+ throw new ArgumentException("Log capture path cannot be empty");
236
+
237
+ if (Path.IsPathRooted(path))
238
+ return Path.GetFullPath(path);
239
+
240
+ var projectRoot = Path.GetDirectoryName(Application.dataPath);
241
+ return Path.GetFullPath(Path.Combine(projectRoot, path));
242
+ }
243
+
180
244
  private static LogQuery ParseQuery(string paramsJson, bool includePattern)
181
245
  {
182
246
  var p = MiniJson.Deserialize(paramsJson) as Dictionary<string, object>;
@@ -186,6 +250,8 @@ namespace UCP.Bridge
186
250
  {
187
251
  if (p.TryGetValue("level", out var levelObj) && levelObj != null)
188
252
  query.Level = NormalizeLevel(levelObj.ToString());
253
+ if (p.TryGetValue("channel", out var channelObj) && channelObj != null)
254
+ query.Channel = channelObj.ToString();
189
255
  if (p.TryGetValue("count", out var countObj) && countObj != null)
190
256
  query.Count = Math.Max(1, Convert.ToInt32(countObj));
191
257
  if (p.TryGetValue("beforeId", out var beforeObj) && beforeObj != null)
@@ -215,6 +281,7 @@ namespace UCP.Bridge
215
281
 
216
282
  private static LogQueryResult QueryHistory(LogQuery query)
217
283
  {
284
+ SeedHistoryFromConsole();
218
285
  lock (s_historyLock)
219
286
  {
220
287
  IEnumerable<LogRecord> candidates = s_history;
@@ -225,6 +292,10 @@ namespace UCP.Bridge
225
292
  candidates = candidates.Where(entry => entry.Id > query.AfterId.Value);
226
293
  if (!string.IsNullOrEmpty(query.Level))
227
294
  candidates = candidates.Where(entry => PassesLevel(entry.Level, query.Level));
295
+ if (!string.IsNullOrEmpty(query.Channel))
296
+ candidates = candidates.Where(entry =>
297
+ (entry.Message ?? string.Empty).IndexOf(query.Channel, StringComparison.OrdinalIgnoreCase) >= 0
298
+ || (entry.StackTrace ?? string.Empty).IndexOf(query.Channel, StringComparison.OrdinalIgnoreCase) >= 0);
228
299
 
229
300
  if (query.Regex != null)
230
301
  {
@@ -257,6 +328,83 @@ namespace UCP.Bridge
257
328
  };
258
329
  }
259
330
 
331
+ private static Dictionary<string, object> BuildStatusResult(List<LogRecord> ordered, long afterId)
332
+ {
333
+ var byLevel = new Dictionary<string, object>
334
+ {
335
+ ["info"] = ordered.Count(entry => entry.Level == "info"),
336
+ ["warning"] = ordered.Count(entry => entry.Level == "warning"),
337
+ ["error"] = ordered.Count(entry => entry.Level == "error"),
338
+ ["exception"] = ordered.Count(entry => entry.Level == "exception")
339
+ };
340
+
341
+ var grouped = ordered
342
+ .GroupBy(entry => $"{entry.Level}|{Fingerprint(entry.Message)}")
343
+ .Select(group =>
344
+ {
345
+ var first = group.First();
346
+ var last = group.Last();
347
+ return new Dictionary<string, object>
348
+ {
349
+ ["level"] = first.Level,
350
+ ["fingerprint"] = Fingerprint(first.Message),
351
+ ["sampleMessage"] = Preview(first.Message, MaxPreviewLength),
352
+ ["count"] = group.Count(),
353
+ ["firstTimestamp"] = first.Timestamp,
354
+ ["lastTimestamp"] = last.Timestamp,
355
+ ["latestId"] = last.Id
356
+ };
357
+ })
358
+ .OrderByDescending(entry => Convert.ToInt32(entry["count"]))
359
+ .ThenBy(entry => entry["sampleMessage"].ToString())
360
+ .ToList();
361
+
362
+ var result = new Dictionary<string, object>
363
+ {
364
+ ["afterId"] = afterId,
365
+ ["total"] = ordered.Count,
366
+ ["byLevel"] = byLevel,
367
+ ["uniqueCount"] = grouped.Count,
368
+ ["topCategories"] = grouped.Take(8).Cast<object>().ToList()
369
+ };
370
+
371
+ if (ordered.Count > 0)
372
+ {
373
+ var first = ordered.First();
374
+ var last = ordered.Last();
375
+ result["firstTimestamp"] = first.Timestamp;
376
+ result["lastTimestamp"] = last.Timestamp;
377
+ result["historyWindowSeconds"] = Math.Max(0d, (last.TimestampUtc - first.TimestampUtc).TotalSeconds);
378
+ result["latestId"] = last.Id;
379
+ }
380
+
381
+ var playSession = PlayModeController.GetSessionSnapshot();
382
+ result["play"] = SerializePlaySession(playSession);
383
+
384
+ if (playSession.LastEnteredPlayAtUtc.HasValue)
385
+ {
386
+ var sessionEnd = playSession.Playing
387
+ ? DateTime.UtcNow
388
+ : (playSession.LastExitedPlayAtUtc ?? DateTime.UtcNow);
389
+ var sessionLogs = ordered
390
+ .Where(entry => entry.TimestampUtc >= playSession.LastEnteredPlayAtUtc.Value
391
+ && entry.TimestampUtc <= sessionEnd)
392
+ .ToList();
393
+
394
+ result["lastPlayWindow"] = new Dictionary<string, object>
395
+ {
396
+ ["startedAt"] = playSession.LastEnteredPlayAtUtc.Value.ToString("o"),
397
+ ["endedAt"] = sessionEnd.ToString("o"),
398
+ ["durationSeconds"] = Math.Max(0d, (sessionEnd - playSession.LastEnteredPlayAtUtc.Value).TotalSeconds),
399
+ ["total"] = sessionLogs.Count,
400
+ ["warnings"] = sessionLogs.Count(entry => entry.Level == "warning"),
401
+ ["errors"] = sessionLogs.Count(entry => entry.Level == "error" || entry.Level == "exception")
402
+ };
403
+ }
404
+
405
+ return result;
406
+ }
407
+
260
408
  private static Dictionary<string, object> SerializeSummary(LogRecord entry)
261
409
  {
262
410
  return new Dictionary<string, object>
@@ -333,6 +481,184 @@ namespace UCP.Bridge
333
481
  }
334
482
  }
335
483
 
484
+ private static void SeedHistoryFromConsoleLocked()
485
+ {
486
+ if (s_history.Count > 0)
487
+ return;
488
+
489
+ List<ConsoleBackfillEntry> consoleEntries;
490
+ try
491
+ {
492
+ consoleEntries = s_consoleBackfillProvider != null
493
+ ? s_consoleBackfillProvider.Invoke()
494
+ : new List<ConsoleBackfillEntry>();
495
+ }
496
+ catch
497
+ {
498
+ return;
499
+ }
500
+
501
+ if (consoleEntries == null || consoleEntries.Count == 0)
502
+ return;
503
+
504
+ var baseTimestamp = DateTime.UtcNow;
505
+ for (var index = 0; index < consoleEntries.Count; index++)
506
+ {
507
+ var entry = consoleEntries[index];
508
+ if (entry == null || string.IsNullOrEmpty(entry.Message) || entry.Message.StartsWith("[UCP]", StringComparison.Ordinal))
509
+ continue;
510
+
511
+ AppendRecordLocked(
512
+ entry.Level,
513
+ entry.Message,
514
+ entry.StackTrace,
515
+ baseTimestamp.AddMilliseconds(index)
516
+ );
517
+ }
518
+ }
519
+
520
+ private static LogRecord AppendRecordLocked(string level, string message, string stackTrace, DateTime timestampUtc)
521
+ {
522
+ var entry = new LogRecord
523
+ {
524
+ Id = s_nextId++,
525
+ Level = NormalizeLevel(level),
526
+ Message = message ?? string.Empty,
527
+ StackTrace = stackTrace ?? string.Empty,
528
+ TimestampUtc = timestampUtc
529
+ };
530
+ entry.Timestamp = entry.TimestampUtc.ToString("o");
531
+
532
+ s_history.Add(entry);
533
+ if (s_history.Count > MaxHistoryEntries)
534
+ s_history.RemoveAt(0);
535
+
536
+ return entry;
537
+ }
538
+
539
+ private static List<ConsoleBackfillEntry> CaptureRecentConsoleEntries()
540
+ {
541
+ try
542
+ {
543
+ var assembly = typeof(Editor).Assembly;
544
+ var logEntriesType = assembly.GetType("UnityEditor.LogEntries");
545
+ var logEntryType = assembly.GetType("UnityEditor.LogEntry");
546
+ if (logEntriesType == null || logEntryType == null)
547
+ return new List<ConsoleBackfillEntry>();
548
+
549
+ var getCount = logEntriesType.GetMethod("GetCount", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static);
550
+ var getEntry = logEntriesType
551
+ .GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static)
552
+ .FirstOrDefault(method => method.Name == "GetEntryInternal");
553
+ var messageField = logEntryType.GetField("message", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
554
+ var modeField = logEntryType.GetField("mode", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
555
+ if (getCount == null || getEntry == null || messageField == null || modeField == null)
556
+ return new List<ConsoleBackfillEntry>();
557
+
558
+ var count = Convert.ToInt32(getCount.Invoke(null, null));
559
+ if (count <= 0)
560
+ return new List<ConsoleBackfillEntry>();
561
+
562
+ var captured = new List<ConsoleBackfillEntry>();
563
+ var start = Math.Max(0, count - MaxHistoryEntries);
564
+ for (var index = start; index < count; index++)
565
+ {
566
+ var args = new object[] { index, Activator.CreateInstance(logEntryType) };
567
+ if (!Convert.ToBoolean(getEntry.Invoke(null, args)))
568
+ continue;
569
+
570
+ var entry = CreateConsoleBackfillEntry(
571
+ messageField.GetValue(args[1]) as string,
572
+ Convert.ToInt32(modeField.GetValue(args[1]))
573
+ );
574
+ if (entry != null)
575
+ captured.Add(entry);
576
+ }
577
+
578
+ return captured;
579
+ }
580
+ catch
581
+ {
582
+ return new List<ConsoleBackfillEntry>();
583
+ }
584
+ }
585
+
586
+ private static ConsoleBackfillEntry CreateConsoleBackfillEntry(string rawMessage, int mode)
587
+ {
588
+ var normalized = (rawMessage ?? string.Empty).Replace("\r\n", "\n").TrimEnd();
589
+ if (string.IsNullOrEmpty(normalized) || normalized.StartsWith("[UCP]", StringComparison.Ordinal))
590
+ return null;
591
+
592
+ var split = SplitConsoleMessage(normalized);
593
+ return new ConsoleBackfillEntry
594
+ {
595
+ Level = InferConsoleLevel(mode, split.Message, split.StackTrace),
596
+ Message = split.Message,
597
+ StackTrace = split.StackTrace
598
+ };
599
+ }
600
+
601
+ private static SplitLogMessage SplitConsoleMessage(string rawMessage)
602
+ {
603
+ var firstNewline = rawMessage.IndexOf('\n');
604
+ if (firstNewline < 0)
605
+ {
606
+ return new SplitLogMessage
607
+ {
608
+ Message = rawMessage,
609
+ StackTrace = string.Empty
610
+ };
611
+ }
612
+
613
+ var firstLine = rawMessage.Substring(0, firstNewline).TrimEnd();
614
+ var remainder = rawMessage.Substring(firstNewline + 1).Trim();
615
+ if (LooksLikeStackTrace(remainder))
616
+ {
617
+ return new SplitLogMessage
618
+ {
619
+ Message = firstLine,
620
+ StackTrace = remainder
621
+ };
622
+ }
623
+
624
+ return new SplitLogMessage
625
+ {
626
+ Message = rawMessage,
627
+ StackTrace = string.Empty
628
+ };
629
+ }
630
+
631
+ private static bool LooksLikeStackTrace(string value)
632
+ {
633
+ if (string.IsNullOrEmpty(value))
634
+ return false;
635
+
636
+ return value.StartsWith("UnityEngine.", StringComparison.Ordinal)
637
+ || value.StartsWith("UnityEditor.", StringComparison.Ordinal)
638
+ || value.StartsWith("System.", StringComparison.Ordinal)
639
+ || value.StartsWith("---", StringComparison.Ordinal)
640
+ || value.Contains(" (at ")
641
+ || value.IndexOf(":Log", StringComparison.Ordinal) >= 0
642
+ || value.IndexOf(":LogWarning", StringComparison.Ordinal) >= 0
643
+ || value.IndexOf(":LogError", StringComparison.Ordinal) >= 0
644
+ || value.IndexOf(":LogException", StringComparison.Ordinal) >= 0;
645
+ }
646
+
647
+ private static string InferConsoleLevel(int mode, string message, string stackTrace)
648
+ {
649
+ var combined = (message ?? string.Empty) + "\n" + (stackTrace ?? string.Empty);
650
+ if ((mode & 0x100) != 0 || combined.IndexOf("Exception", StringComparison.OrdinalIgnoreCase) >= 0)
651
+ return combined.IndexOf("Exception", StringComparison.OrdinalIgnoreCase) >= 0 ? "exception" : "error";
652
+ if ((mode & 0x200) != 0)
653
+ return "warning";
654
+ if (Regex.IsMatch(combined, @"\bwarning\b", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant))
655
+ return "warning";
656
+ if (Regex.IsMatch(combined, @"\berror\b", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant))
657
+ return combined.IndexOf("Exception", StringComparison.OrdinalIgnoreCase) >= 0 ? "exception" : "error";
658
+
659
+ return "info";
660
+ }
661
+
336
662
  private static string Preview(string value, int maxChars)
337
663
  {
338
664
  if (string.IsNullOrEmpty(value) || value.Length <= maxChars)
@@ -389,9 +715,23 @@ namespace UCP.Bridge
389
715
  public DateTime TimestampUtc;
390
716
  }
391
717
 
718
+ public sealed class ConsoleBackfillEntry
719
+ {
720
+ public string Level;
721
+ public string Message;
722
+ public string StackTrace;
723
+ }
724
+
725
+ private sealed class SplitLogMessage
726
+ {
727
+ public string Message;
728
+ public string StackTrace;
729
+ }
730
+
392
731
  private sealed class LogQuery
393
732
  {
394
733
  public string Level;
734
+ public string Channel;
395
735
  public string Pattern;
396
736
  public Regex Regex;
397
737
  public int Count;
@@ -64,7 +64,10 @@ namespace UCP.Bridge
64
64
 
65
65
  var saveDirtyScenes = GetBoolParam(paramsJson, "saveDirtyScenes", true);
66
66
  var discardUntitled = GetBoolParam(paramsJson, "discardUntitled", true);
67
+ var logFile = GetStringParam(paramsJson, "logFile");
67
68
  SaveDirtyScenesIfRequested(saveDirtyScenes, discardUntitled);
69
+ if (!string.IsNullOrEmpty(logFile))
70
+ LogsController.StartFileCapture(logFile);
68
71
 
69
72
  lock (s_sessionLock)
70
73
  {
@@ -151,11 +154,21 @@ namespace UCP.Bridge
151
154
  break;
152
155
  case PlayModeStateChange.EnteredEditMode:
153
156
  s_lastExitedPlayAtUtc = DateTime.UtcNow;
157
+ LogsController.StopFileCapture();
154
158
  break;
155
159
  }
156
160
  }
157
161
  }
158
162
 
163
+ private static string GetStringParam(string paramsJson, string key)
164
+ {
165
+ var parameters = MiniJson.Deserialize(paramsJson) as Dictionary<string, object>;
166
+ if (parameters != null && parameters.TryGetValue(key, out var valueObj) && valueObj != null)
167
+ return valueObj.ToString();
168
+
169
+ return null;
170
+ }
171
+
159
172
  private static object SerializeSessionSnapshot(SessionSnapshot snapshot)
160
173
  {
161
174
  var now = DateTime.UtcNow;
@@ -44,20 +44,26 @@ namespace UCP.Bridge
44
44
  throw new System.ArgumentException("Missing 'path' parameter");
45
45
 
46
46
  var path = pathObj.ToString();
47
+ var additive = GetBoolParam(p, "additive", false);
47
48
  var saveDirtyScenes = GetBoolParam(p, "saveDirtyScenes", true);
48
49
  var discardUntitled = GetBoolParam(p, "discardUntitled", true);
49
50
 
50
51
  if (EditorApplication.isPlaying)
51
52
  {
52
- SceneManager.LoadScene(path);
53
+ SceneManager.LoadScene(path, additive ? LoadSceneMode.Additive : LoadSceneMode.Single);
53
54
  }
54
55
  else
55
56
  {
56
57
  SaveDirtyScenesIfRequested(saveDirtyScenes, discardUntitled);
57
- EditorSceneManager.OpenScene(path, OpenSceneMode.Single);
58
+ EditorSceneManager.OpenScene(path, additive ? OpenSceneMode.Additive : OpenSceneMode.Single);
58
59
  }
59
60
 
60
- return new { status = "ok", loaded = path };
61
+ return new Dictionary<string, object>
62
+ {
63
+ ["status"] = "ok",
64
+ ["loaded"] = path,
65
+ ["additive"] = additive
66
+ };
61
67
  }
62
68
 
63
69
  private static bool GetBoolParam(Dictionary<string, object> parameters, string key, bool defaultValue)