using System; using System.Collections.Generic; using System.IO; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using UnityEditor; using UnityEngine; namespace Jovian.SaveSystem.Editor { public class SaveDataViewerWindow : EditorWindow { private Vector2 slotListScroll; private Vector2 dataScroll; private List sessions = new(); private SlotEntry selectedSlot; private string savePath; // Parsed data for the selected slot private SaveEnvelope loadedEnvelope; private List payloadFields = new(); private GUIStyle labelStyle; private GUIStyle valueStyle; private GUIStyle headerStyle; private GUIStyle slotButtonStyle; private GUIStyle noDataStyle; private GUIStyle metaKeyStyle; private GUIStyle metaValueStyle; private bool stylesInitialized; [MenuItem("Jovian/Save System/Save Data Viewer")] public static void ShowWindow() { var window = GetWindow("Save Data Viewer"); window.minSize = new Vector2(550, 450); window.Show(); } private void OnEnable() { ScanSaves(); } private void InitStyles() { if(stylesInitialized) { return; } headerStyle = new GUIStyle(EditorStyles.boldLabel) { fontSize = 12 }; labelStyle = new GUIStyle(EditorStyles.label) { fontStyle = FontStyle.Bold, normal = { textColor = new Color(0.55f, 0.8f, 1f) } }; valueStyle = new GUIStyle(EditorStyles.label) { wordWrap = true }; slotButtonStyle = new GUIStyle(EditorStyles.toolbarButton) { alignment = TextAnchor.MiddleLeft, fixedHeight = 22 }; noDataStyle = new GUIStyle(EditorStyles.centeredGreyMiniLabel) { fontSize = 12, wordWrap = true }; metaKeyStyle = new GUIStyle(EditorStyles.miniLabel) { fontStyle = FontStyle.Bold, fixedWidth = 100 }; metaValueStyle = new GUIStyle(EditorStyles.miniLabel); stylesInitialized = true; } private void OnGUI() { InitStyles(); DrawToolbar(); if(sessions.Count == 0) { EditorGUILayout.LabelField("No save files found.\nPlay the game and save to generate data.", noDataStyle, GUILayout.ExpandHeight(true)); return; } DrawSlotList(); DrawSaveData(); } 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", headerStyle); 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)) { LoadSlot(slot); } } EditorGUI.indentLevel--; } EditorGUILayout.EndScrollView(); DrawSeparator(); } private void DrawSaveData() { if(selectedSlot == null) { EditorGUILayout.LabelField("Select a save slot to view its data.", noDataStyle, GUILayout.ExpandHeight(true)); return; } if(loadedEnvelope == null) { EditorGUILayout.LabelField("Failed to load save data.", noDataStyle, GUILayout.ExpandHeight(true)); return; } // Envelope metadata EditorGUILayout.LabelField("Envelope", headerStyle); EditorGUILayout.BeginHorizontal(); EditorGUILayout.LabelField("Version:", metaKeyStyle); EditorGUILayout.LabelField(loadedEnvelope.version.ToString(), metaValueStyle); EditorGUILayout.EndHorizontal(); EditorGUILayout.BeginHorizontal(); EditorGUILayout.LabelField("Slot Type:", metaKeyStyle); EditorGUILayout.LabelField(loadedEnvelope.slotType.ToString(), metaValueStyle); EditorGUILayout.EndHorizontal(); EditorGUILayout.BeginHorizontal(); EditorGUILayout.LabelField("Timestamp:", metaKeyStyle); var timestamp = DateTimeOffset.FromUnixTimeMilliseconds(loadedEnvelope.timestampUtc).LocalDateTime; EditorGUILayout.LabelField(timestamp.ToString("yyyy-MM-dd HH:mm:ss"), metaValueStyle); EditorGUILayout.EndHorizontal(); DrawSeparator(); EditorGUILayout.Space(4); EditorGUILayout.LabelField("Payload", headerStyle); if(payloadFields.Count == 0) { EditorGUILayout.LabelField("(empty payload)", noDataStyle); return; } dataScroll = EditorGUILayout.BeginScrollView(dataScroll, GUILayout.ExpandHeight(true)); foreach(var field in payloadFields) { DrawPayloadField(field, 0); } EditorGUILayout.EndScrollView(); } private void DrawPayloadField(PayloadField field, int depth) { if(field.children != null && field.children.Count > 0) { EditorGUI.indentLevel = depth; field.foldout = EditorGUILayout.Foldout(field.foldout, field.key, true, EditorStyles.foldoutHeader); if(field.foldout) { foreach(var child in field.children) { DrawPayloadField(child, depth + 1); } } } else { EditorGUI.indentLevel = depth; EditorGUILayout.BeginHorizontal(); EditorGUILayout.LabelField(field.key, labelStyle, GUILayout.Width(180)); EditorGUILayout.LabelField(field.value, valueStyle); EditorGUILayout.EndHorizontal(); } } 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; loadedEnvelope = null; payloadFields.Clear(); 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 SlotEntry { 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($"[SaveDataViewer] Failed to read save index: {e.Message}"); } } private void LoadSlot(SlotEntry slot) { selectedSlot = slot; loadedEnvelope = null; payloadFields.Clear(); var fullPath = Path.Combine(savePath, slot.filePath); if(!File.Exists(fullPath)) { Debug.LogWarning($"[SaveDataViewer] Save file not found: {fullPath}"); return; } try { var bytes = File.ReadAllBytes(fullPath); var serializer = new JsonSaveSerializer(); loadedEnvelope = serializer.Deserialize(bytes); if(loadedEnvelope?.payload == null) { return; } payloadFields = ParseToken(loadedEnvelope.payload); } catch(Exception e) { Debug.LogError($"[SaveDataViewer] Failed to load save: {e.Message}"); } } private static List ParseToken(JToken token) { var fields = new List(); if(token is JObject obj) { foreach(var property in obj.Properties()) { fields.Add(ParseProperty(property.Name, property.Value)); } } else if(token is JArray arr) { for(var i = 0; i < arr.Count; i++) { fields.Add(ParseProperty($"[{i}]", arr[i])); } } return fields; } private static PayloadField ParseProperty(string key, JToken value) { switch(value.Type) { case JTokenType.Object: { var children = ParseToken(value); return new PayloadField { key = key, value = $"{{{children.Count} fields}}", children = children, foldout = false }; } case JTokenType.Array: { var arr = (JArray)value; var children = new List(); for(var i = 0; i < arr.Count; i++) { children.Add(ParseProperty($"[{i}]", arr[i])); } return new PayloadField { key = $"{key} ({arr.Count})", value = $"[{arr.Count} items]", children = children, foldout = false }; } case JTokenType.Null: return new PayloadField { key = key, value = "null" }; default: return new PayloadField { key = key, value = value.ToString() }; } } // Internal types [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 SlotEntry { public string sessionId; public string displayName; public string filePath; public long timestampUtc; } private sealed class PayloadField { public string key; public string value; public List children; public bool foldout; } } }