using System; using System.Collections.Generic; using System.IO; using System.Text.RegularExpressions; using Jovian.SaveSystem; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using UnityEditor; using UnityEngine; namespace Jovian.InGameLogging.Editor { public class GameLogViewerWindow : EditorWindow { private static readonly Regex richTextRegex = new(@"<[^>]+>", RegexOptions.Compiled); private Vector2 slotListScroll; private Vector2 logEntriesScroll; private List sessions = new(); private SaveSlotEntry selectedSlot; private List loadedEntries = new(); private List filteredEntries = new(); private List channelNames = new(); private int selectedChannelIndex; private string savePath; private GUIStyle channelTagStyle; private GUIStyle timeStyle; private GUIStyle messageStyle; private GUIStyle slotButtonStyle; private GUIStyle noDataStyle; private bool stylesInitialized; [MenuItem("Jovian/In-Game Logging/Log Viewer")] public static void ShowWindow() { var window = GetWindow("In-Game Log Viewer"); window.minSize = new Vector2(500, 400); window.Show(); } private void OnEnable() { ScanSaves(); } private void InitStyles() { if(stylesInitialized) { return; } channelTagStyle = new GUIStyle(EditorStyles.miniLabel) { fontStyle = FontStyle.Bold, normal = { textColor = new Color(0.5f, 0.8f, 1f) }, fixedWidth = 120, alignment = TextAnchor.MiddleLeft }; timeStyle = new GUIStyle(EditorStyles.miniLabel) { normal = { textColor = new Color(0.6f, 0.6f, 0.6f) }, fixedWidth = 70, alignment = TextAnchor.MiddleRight }; messageStyle = new GUIStyle(EditorStyles.label) { wordWrap = false, richText = false }; slotButtonStyle = new GUIStyle(EditorStyles.toolbarButton) { alignment = TextAnchor.MiddleLeft, fixedHeight = 22 }; noDataStyle = new GUIStyle(EditorStyles.centeredGreyMiniLabel) { fontSize = 12, wordWrap = true }; stylesInitialized = true; } private void OnGUI() { InitStyles(); DrawToolbar(); if(sessions.Count == 0) { EditorGUILayout.LabelField("No save files found.\nPlay the game and save to generate log data.", noDataStyle, GUILayout.ExpandHeight(true)); return; } DrawSlotList(); DrawChannelFilter(); DrawLogEntries(); } private void DrawToolbar() { EditorGUILayout.BeginHorizontal(EditorStyles.toolbar); GUILayout.FlexibleSpace(); if(GUILayout.Button("Refresh", EditorStyles.toolbarButton, GUILayout.Width(60))) { ScanSaves(); } EditorGUILayout.EndHorizontal(); } private void DrawSlotList() { EditorGUILayout.LabelField("Save Slots", EditorStyles.boldLabel); slotListScroll = EditorGUILayout.BeginScrollView(slotListScroll, GUILayout.MaxHeight(200)); foreach(var session in sessions) { session.foldout = EditorGUILayout.Foldout(session.foldout, session.displayName, true); if(!session.foldout) { continue; } EditorGUI.indentLevel++; foreach(var slot in session.slots) { var isSelected = selectedSlot != null && selectedSlot.filePath == slot.filePath; var style = new GUIStyle(slotButtonStyle); if(isSelected) { style.fontStyle = FontStyle.Bold; } if(GUILayout.Button($" {slot.displayName}", style)) { SelectSlot(slot); } } EditorGUI.indentLevel--; } EditorGUILayout.EndScrollView(); DrawSeparator(); } private void DrawChannelFilter() { if(loadedEntries.Count == 0) { return; } EditorGUILayout.BeginHorizontal(); EditorGUILayout.LabelField("Channel filter:", GUILayout.Width(90)); var newIndex = EditorGUILayout.Popup(selectedChannelIndex, channelNames.ToArray()); if(newIndex != selectedChannelIndex) { selectedChannelIndex = newIndex; ApplyChannelFilter(); } EditorGUILayout.EndHorizontal(); } private void DrawLogEntries() { if(selectedSlot == null) { EditorGUILayout.LabelField("Select a save slot to view its log entries.", noDataStyle, GUILayout.ExpandHeight(true)); return; } if(filteredEntries.Count == 0) { EditorGUILayout.LabelField("No log entries in this save.", noDataStyle, GUILayout.ExpandHeight(true)); return; } EditorGUILayout.LabelField($"{filteredEntries.Count} entries", EditorStyles.miniLabel); logEntriesScroll = EditorGUILayout.BeginScrollView(logEntriesScroll, GUILayout.ExpandHeight(true)); foreach(var entry in filteredEntries) { EditorGUILayout.BeginHorizontal(); EditorGUILayout.LabelField($"[{entry.channel}]", channelTagStyle); EditorGUILayout.LabelField(FormatTime(entry.gameTime), timeStyle); EditorGUILayout.LabelField(StripRichText(entry.message), messageStyle); EditorGUILayout.EndHorizontal(); } EditorGUILayout.EndScrollView(); } private void DrawSeparator() { var rect = EditorGUILayout.GetControlRect(false, 1); EditorGUI.DrawRect(rect, new Color(0.3f, 0.3f, 0.3f)); } private void ScanSaves() { sessions.Clear(); selectedSlot = null; loadedEntries.Clear(); filteredEntries.Clear(); channelNames.Clear(); selectedChannelIndex = 0; var settings = SaveSystemSettings.Load(); savePath = Path.Combine(Application.persistentDataPath, settings.saveDirectoryName); var indexPath = Path.Combine(savePath, "index.json"); if(!File.Exists(indexPath)) { return; } try { var indexJson = File.ReadAllText(indexPath); var index = JsonConvert.DeserializeObject(indexJson); if(index?.sessions == null || index.slots == null) { return; } var slotsBySession = new Dictionary>(); foreach(var slot in index.slots) { if(!slotsBySession.ContainsKey(slot.sessionId)) { slotsBySession[slot.sessionId] = new List(); } slotsBySession[slot.sessionId].Add(new SaveSlotEntry { sessionId = slot.sessionId, displayName = slot.DisplayLabel + " " + DateTimeOffset.FromUnixTimeMilliseconds(slot.timestampUtc).LocalDateTime.ToString("HH:mm"), filePath = slot.filePath, timestampUtc = slot.timestampUtc }); } foreach(var session in index.sessions) { if(!slotsBySession.TryGetValue(session.sessionId, out var slots)) { continue; } slots.Sort((a, b) => b.timestampUtc.CompareTo(a.timestampUtc)); sessions.Add(new SessionGroup { sessionId = session.sessionId, displayName = $"Session ({DateTimeOffset.FromUnixTimeMilliseconds(session.lastSaveDateUtc).LocalDateTime:yyyy-MM-dd HH:mm})", foldout = true, slots = slots }); } sessions.Sort((a, b) => string.Compare(b.sessionId, a.sessionId, StringComparison.Ordinal)); } catch(Exception e) { Debug.LogError($"[GameLogViewer] Failed to read save index: {e.Message}"); } } private void SelectSlot(SaveSlotEntry slot) { selectedSlot = slot; loadedEntries.Clear(); filteredEntries.Clear(); channelNames.Clear(); channelNames.Add("All"); selectedChannelIndex = 0; var fullPath = Path.Combine(savePath, slot.filePath); if(!File.Exists(fullPath)) { Debug.LogWarning($"[GameLogViewer] Save file not found: {fullPath}"); return; } try { var bytes = File.ReadAllBytes(fullPath); var serializer = new JsonSaveSerializer(); var envelope = serializer.Deserialize(bytes); if(envelope?.payload == null) { return; } // Navigate via JToken to avoid coupling to NoxSavedDataSet var gameLogToken = envelope.payload["gameLogData"]; if(gameLogToken == null) { return; } var entriesToken = gameLogToken["entries"]; if(entriesToken == null) { return; } var logData = entriesToken.ToObject>(); if(logData == null) { return; } loadedEntries.AddRange(logData); // Build channel list var uniqueChannels = new HashSet(); foreach(var entry in loadedEntries) { var channelId = entry.channel.Id; if(!string.IsNullOrEmpty(channelId) && uniqueChannels.Add(channelId)) { channelNames.Add(channelId); } } ApplyChannelFilter(); } catch(Exception e) { Debug.LogError($"[GameLogViewer] Failed to load save: {e.Message}"); } } private void ApplyChannelFilter() { filteredEntries.Clear(); if(selectedChannelIndex == 0) { filteredEntries.AddRange(loadedEntries); } else { var channelId = channelNames[selectedChannelIndex]; foreach(var entry in loadedEntries) { if(entry.channel.Id == channelId) { filteredEntries.Add(entry); } } } } private static string StripRichText(string text) { return string.IsNullOrEmpty(text) ? string.Empty : richTextRegex.Replace(text, string.Empty); } private static string FormatTime(float gameTime) { var minutes = (int)(gameTime / 60f); var seconds = gameTime % 60f; return $"{minutes}:{seconds:00.0}"; } // Internal types for deserialization -- mirrors save system index structure [Serializable] private sealed class SaveIndex { public List sessions; public List slots; } private sealed class SessionGroup { public string sessionId; public string displayName; public bool foldout; public List slots; } private sealed class SaveSlotEntry { public string sessionId; public string displayName; public string filePath; public long timestampUtc; } } }