aboutsummaryrefslogtreecommitdiff
path: root/Ryujinx.Debugger
diff options
context:
space:
mode:
Diffstat (limited to 'Ryujinx.Debugger')
-rw-r--r--Ryujinx.Debugger/Debugger.cs32
-rw-r--r--Ryujinx.Debugger/Profiler/DumpProfile.cs35
-rw-r--r--Ryujinx.Debugger/Profiler/InternalProfile.cs223
-rw-r--r--Ryujinx.Debugger/Profiler/Profile.cs141
-rw-r--r--Ryujinx.Debugger/Profiler/ProfileConfig.cs254
-rw-r--r--Ryujinx.Debugger/Profiler/ProfileSorters.cs32
-rw-r--r--Ryujinx.Debugger/Profiler/ProfilerConfiguration.cs68
-rw-r--r--Ryujinx.Debugger/Profiler/Settings.cs17
-rw-r--r--Ryujinx.Debugger/Profiler/TimingFlag.cs17
-rw-r--r--Ryujinx.Debugger/Profiler/TimingInfo.cs174
-rw-r--r--Ryujinx.Debugger/ProfilerConfig.jsonc28
-rw-r--r--Ryujinx.Debugger/Ryujinx.Debugger.csproj42
-rw-r--r--Ryujinx.Debugger/UI/DebuggerWidget.cs42
-rw-r--r--Ryujinx.Debugger/UI/DebuggerWidget.glade44
-rw-r--r--Ryujinx.Debugger/UI/ProfilerWidget.cs801
-rw-r--r--Ryujinx.Debugger/UI/ProfilerWidget.glade232
-rw-r--r--Ryujinx.Debugger/UI/SkRenderer.cs23
17 files changed, 2205 insertions, 0 deletions
diff --git a/Ryujinx.Debugger/Debugger.cs b/Ryujinx.Debugger/Debugger.cs
new file mode 100644
index 00000000..6dd3354c
--- /dev/null
+++ b/Ryujinx.Debugger/Debugger.cs
@@ -0,0 +1,32 @@
+using System;
+using Ryujinx.Debugger.UI;
+
+namespace Ryujinx.Debugger
+{
+ public class Debugger : IDisposable
+ {
+ public DebuggerWidget Widget { get; set; }
+
+ public Debugger()
+ {
+ Widget = new DebuggerWidget();
+ }
+
+ public void Enable()
+ {
+ Widget.Enable();
+ }
+
+ public void Disable()
+ {
+ Widget.Disable();
+ }
+
+ public void Dispose()
+ {
+ Disable();
+
+ Widget.Dispose();
+ }
+ }
+}
diff --git a/Ryujinx.Debugger/Profiler/DumpProfile.cs b/Ryujinx.Debugger/Profiler/DumpProfile.cs
new file mode 100644
index 00000000..e73314d4
--- /dev/null
+++ b/Ryujinx.Debugger/Profiler/DumpProfile.cs
@@ -0,0 +1,35 @@
+using Ryujinx.Common;
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+
+namespace Ryujinx.Debugger.Profiler
+{
+ public static class DumpProfile
+ {
+ public static void ToFile(string path, InternalProfile profile)
+ {
+ String fileData = "Category,Session Group,Session Item,Count,Average(ms),Total(ms)\r\n";
+
+ foreach (KeyValuePair<ProfileConfig, TimingInfo> time in profile.Timers.OrderBy(key => key.Key.Tag))
+ {
+ fileData += $"{time.Key.Category}," +
+ $"{time.Key.SessionGroup}," +
+ $"{time.Key.SessionItem}," +
+ $"{time.Value.Count}," +
+ $"{time.Value.AverageTime / PerformanceCounter.TicksPerMillisecond}," +
+ $"{time.Value.TotalTime / PerformanceCounter.TicksPerMillisecond}\r\n";
+ }
+
+ // Ensure file directory exists before write
+ FileInfo fileInfo = new FileInfo(path);
+ if (fileInfo == null)
+ throw new Exception("Unknown logging error, probably a bad file path");
+ if (fileInfo.Directory != null && !fileInfo.Directory.Exists)
+ Directory.CreateDirectory(fileInfo.Directory.FullName);
+
+ File.WriteAllText(fileInfo.FullName, fileData);
+ }
+ }
+}
diff --git a/Ryujinx.Debugger/Profiler/InternalProfile.cs b/Ryujinx.Debugger/Profiler/InternalProfile.cs
new file mode 100644
index 00000000..0bda9e04
--- /dev/null
+++ b/Ryujinx.Debugger/Profiler/InternalProfile.cs
@@ -0,0 +1,223 @@
+using Ryujinx.Common;
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Ryujinx.Debugger.Profiler
+{
+ public class InternalProfile
+ {
+ private struct TimerQueueValue
+ {
+ public ProfileConfig Config;
+ public long Time;
+ public bool IsBegin;
+ }
+
+ internal Dictionary<ProfileConfig, TimingInfo> Timers { get; set; }
+
+ private readonly object _timerQueueClearLock = new object();
+ private ConcurrentQueue<TimerQueueValue> _timerQueue;
+
+ private int _sessionCounter = 0;
+
+ // Cleanup thread
+ private readonly Thread _cleanupThread;
+ private bool _cleanupRunning;
+ private readonly long _history;
+ private long _preserve;
+
+ // Timing flags
+ private TimingFlag[] _timingFlags;
+ private long[] _timingFlagAverages;
+ private long[] _timingFlagLast;
+ private long[] _timingFlagLastDelta;
+ private int _timingFlagCount;
+ private int _timingFlagIndex;
+
+ private int _maxFlags;
+
+ private Action<TimingFlag> _timingFlagCallback;
+
+ public InternalProfile(long history, int maxFlags)
+ {
+ _maxFlags = maxFlags;
+ Timers = new Dictionary<ProfileConfig, TimingInfo>();
+ _timingFlags = new TimingFlag[_maxFlags];
+ _timingFlagAverages = new long[(int)TimingFlagType.Count];
+ _timingFlagLast = new long[(int)TimingFlagType.Count];
+ _timingFlagLastDelta = new long[(int)TimingFlagType.Count];
+ _timerQueue = new ConcurrentQueue<TimerQueueValue>();
+ _history = history;
+ _cleanupRunning = true;
+
+ // Create cleanup thread.
+ _cleanupThread = new Thread(CleanupLoop)
+ {
+ Name = "Profiler.CleanupThread"
+ };
+ _cleanupThread.Start();
+ }
+
+ private void CleanupLoop()
+ {
+ bool queueCleared = false;
+
+ while (_cleanupRunning)
+ {
+ // Ensure we only ever have 1 instance modifying timers or timerQueue
+ if (Monitor.TryEnter(_timerQueueClearLock))
+ {
+ queueCleared = ClearTimerQueue();
+
+ // Calculate before foreach to mitigate redundant calculations
+ long cleanupBefore = PerformanceCounter.ElapsedTicks - _history;
+ long preserveStart = _preserve - _history;
+
+ // Each cleanup is self contained so run in parallel for maximum efficiency
+ Parallel.ForEach(Timers, (t) => t.Value.Cleanup(cleanupBefore, preserveStart, _preserve));
+
+ Monitor.Exit(_timerQueueClearLock);
+ }
+
+ // Only sleep if queue was successfully cleared
+ if (queueCleared)
+ {
+ Thread.Sleep(5);
+ }
+ }
+ }
+
+ private bool ClearTimerQueue()
+ {
+ int count = 0;
+
+ while (_timerQueue.TryDequeue(out TimerQueueValue item))
+ {
+ if (!Timers.TryGetValue(item.Config, out TimingInfo value))
+ {
+ value = new TimingInfo();
+ Timers.Add(item.Config, value);
+ }
+
+ if (item.IsBegin)
+ {
+ value.Begin(item.Time);
+ }
+ else
+ {
+ value.End(item.Time);
+ }
+
+ // Don't block for too long as memory disposal is blocked while this function runs
+ if (count++ > 10000)
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ public void FlagTime(TimingFlagType flagType)
+ {
+ int flagId = (int)flagType;
+
+ _timingFlags[_timingFlagIndex] = new TimingFlag()
+ {
+ FlagType = flagType,
+ Timestamp = PerformanceCounter.ElapsedTicks
+ };
+
+ _timingFlagCount = Math.Max(_timingFlagCount + 1, _maxFlags);
+
+ // Work out average
+ if (_timingFlagLast[flagId] != 0)
+ {
+ _timingFlagLastDelta[flagId] = _timingFlags[_timingFlagIndex].Timestamp - _timingFlagLast[flagId];
+ _timingFlagAverages[flagId] = (_timingFlagAverages[flagId] == 0) ? _timingFlagLastDelta[flagId] :
+ (_timingFlagLastDelta[flagId] + _timingFlagAverages[flagId]) >> 1;
+ }
+ _timingFlagLast[flagId] = _timingFlags[_timingFlagIndex].Timestamp;
+
+ // Notify subscribers
+ _timingFlagCallback?.Invoke(_timingFlags[_timingFlagIndex]);
+
+ if (++_timingFlagIndex >= _maxFlags)
+ {
+ _timingFlagIndex = 0;
+ }
+ }
+
+ public void BeginProfile(ProfileConfig config)
+ {
+ _timerQueue.Enqueue(new TimerQueueValue()
+ {
+ Config = config,
+ IsBegin = true,
+ Time = PerformanceCounter.ElapsedTicks,
+ });
+ }
+
+ public void EndProfile(ProfileConfig config)
+ {
+ _timerQueue.Enqueue(new TimerQueueValue()
+ {
+ Config = config,
+ IsBegin = false,
+ Time = PerformanceCounter.ElapsedTicks,
+ });
+ }
+
+ public string GetSession()
+ {
+ // Can be called from multiple threads so we need to ensure no duplicate sessions are generated
+ return Interlocked.Increment(ref _sessionCounter).ToString();
+ }
+
+ public List<KeyValuePair<ProfileConfig, TimingInfo>> GetProfilingData()
+ {
+ _preserve = PerformanceCounter.ElapsedTicks;
+
+ lock (_timerQueueClearLock)
+ {
+ ClearTimerQueue();
+ return Timers.ToList();
+ }
+ }
+
+ public TimingFlag[] GetTimingFlags()
+ {
+ int count = Math.Max(_timingFlagCount, _maxFlags);
+ TimingFlag[] outFlags = new TimingFlag[count];
+
+ for (int i = 0, sourceIndex = _timingFlagIndex; i < count; i++, sourceIndex++)
+ {
+ if (sourceIndex >= _maxFlags)
+ sourceIndex = 0;
+ outFlags[i] = _timingFlags[sourceIndex];
+ }
+
+ return outFlags;
+ }
+
+ public (long[], long[]) GetTimingAveragesAndLast()
+ {
+ return (_timingFlagAverages, _timingFlagLastDelta);
+ }
+
+ public void RegisterFlagReceiver(Action<TimingFlag> receiver)
+ {
+ _timingFlagCallback = receiver;
+ }
+
+ public void Dispose()
+ {
+ _cleanupRunning = false;
+ _cleanupThread.Join();
+ }
+ }
+}
diff --git a/Ryujinx.Debugger/Profiler/Profile.cs b/Ryujinx.Debugger/Profiler/Profile.cs
new file mode 100644
index 00000000..862aa845
--- /dev/null
+++ b/Ryujinx.Debugger/Profiler/Profile.cs
@@ -0,0 +1,141 @@
+using Ryujinx.Common;
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+
+namespace Ryujinx.Debugger.Profiler
+{
+ public static class Profile
+ {
+ public static float UpdateRate => _settings.UpdateRate;
+ public static long HistoryLength => _settings.History;
+
+ private static InternalProfile _profileInstance;
+ private static ProfilerSettings _settings;
+
+ [Conditional("USE_DEBUGGING")]
+ public static void Initialize()
+ {
+ var config = ProfilerConfiguration.Load(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "ProfilerConfig.jsonc"));
+
+ _settings = new ProfilerSettings()
+ {
+ Enabled = config.Enabled,
+ FileDumpEnabled = config.DumpPath != "",
+ DumpLocation = config.DumpPath,
+ UpdateRate = (config.UpdateRate <= 0) ? -1 : 1.0f / config.UpdateRate,
+ History = (long)(config.History * PerformanceCounter.TicksPerSecond),
+ MaxLevel = config.MaxLevel,
+ MaxFlags = config.MaxFlags,
+ };
+ }
+
+ public static bool ProfilingEnabled()
+ {
+#if USE_DEBUGGING
+ if (!_settings.Enabled)
+ return false;
+
+ if (_profileInstance == null)
+ _profileInstance = new InternalProfile(_settings.History, _settings.MaxFlags);
+
+ return true;
+#else
+ return false;
+#endif
+ }
+
+ [Conditional("USE_DEBUGGING")]
+ public static void FinishProfiling()
+ {
+ if (!ProfilingEnabled())
+ return;
+
+ if (_settings.FileDumpEnabled)
+ DumpProfile.ToFile(_settings.DumpLocation, _profileInstance);
+
+ _profileInstance.Dispose();
+ }
+
+ [Conditional("USE_DEBUGGING")]
+ public static void FlagTime(TimingFlagType flagType)
+ {
+ if (!ProfilingEnabled())
+ return;
+ _profileInstance.FlagTime(flagType);
+ }
+
+ [Conditional("USE_DEBUGGING")]
+ public static void RegisterFlagReceiver(Action<TimingFlag> receiver)
+ {
+ if (!ProfilingEnabled())
+ return;
+ _profileInstance.RegisterFlagReceiver(receiver);
+ }
+
+ [Conditional("USE_DEBUGGING")]
+ public static void Begin(ProfileConfig config)
+ {
+ if (!ProfilingEnabled())
+ return;
+ if (config.Level > _settings.MaxLevel)
+ return;
+ _profileInstance.BeginProfile(config);
+ }
+
+ [Conditional("USE_DEBUGGING")]
+ public static void End(ProfileConfig config)
+ {
+ if (!ProfilingEnabled())
+ return;
+ if (config.Level > _settings.MaxLevel)
+ return;
+ _profileInstance.EndProfile(config);
+ }
+
+ public static string GetSession()
+ {
+#if USE_DEBUGGING
+ if (!ProfilingEnabled())
+ return null;
+ return _profileInstance.GetSession();
+#else
+ return "";
+#endif
+ }
+
+ public static List<KeyValuePair<ProfileConfig, TimingInfo>> GetProfilingData()
+ {
+#if USE_DEBUGGING
+ if (!ProfilingEnabled())
+ return new List<KeyValuePair<ProfileConfig, TimingInfo>>();
+ return _profileInstance.GetProfilingData();
+#else
+ return new List<KeyValuePair<ProfileConfig, TimingInfo>>();
+#endif
+ }
+
+ public static TimingFlag[] GetTimingFlags()
+ {
+#if USE_DEBUGGING
+ if (!ProfilingEnabled())
+ return new TimingFlag[0];
+ return _profileInstance.GetTimingFlags();
+#else
+ return new TimingFlag[0];
+#endif
+ }
+
+ public static (long[], long[]) GetTimingAveragesAndLast()
+ {
+#if USE_DEBUGGING
+ if (!ProfilingEnabled())
+ return (new long[0], new long[0]);
+ return _profileInstance.GetTimingAveragesAndLast();
+#else
+ return (new long[0], new long[0]);
+#endif
+ }
+ }
+}
diff --git a/Ryujinx.Debugger/Profiler/ProfileConfig.cs b/Ryujinx.Debugger/Profiler/ProfileConfig.cs
new file mode 100644
index 00000000..0ec3e26d
--- /dev/null
+++ b/Ryujinx.Debugger/Profiler/ProfileConfig.cs
@@ -0,0 +1,254 @@
+using System;
+
+namespace Ryujinx.Debugger.Profiler
+{
+ public struct ProfileConfig : IEquatable<ProfileConfig>
+ {
+ public string Category;
+ public string SessionGroup;
+ public string SessionItem;
+
+ public int Level;
+
+ // Private cached variables
+ private string _cachedTag;
+ private string _cachedSession;
+ private string _cachedSearch;
+
+ // Public helpers to get config in more user friendly format,
+ // Cached because they never change and are called often
+ public string Search
+ {
+ get
+ {
+ if (_cachedSearch == null)
+ {
+ _cachedSearch = $"{Category}.{SessionGroup}.{SessionItem}";
+ }
+
+ return _cachedSearch;
+ }
+ }
+
+ public string Tag
+ {
+ get
+ {
+ if (_cachedTag == null)
+ _cachedTag = $"{Category}{(Session == "" ? "" : $" ({Session})")}";
+ return _cachedTag;
+ }
+ }
+
+ public string Session
+ {
+ get
+ {
+ if (_cachedSession == null)
+ {
+ if (SessionGroup != null && SessionItem != null)
+ {
+ _cachedSession = $"{SessionGroup}: {SessionItem}";
+ }
+ else if (SessionGroup != null)
+ {
+ _cachedSession = $"{SessionGroup}";
+ }
+ else if (SessionItem != null)
+ {
+ _cachedSession = $"---: {SessionItem}";
+ }
+ else
+ {
+ _cachedSession = "";
+ }
+ }
+
+ return _cachedSession;
+ }
+ }
+
+ /// <summary>
+ /// The default comparison is far too slow for the number of comparisons needed because it doesn't know what's important to compare
+ /// </summary>
+ /// <param name="obj">Object to compare to</param>
+ /// <returns></returns>
+ public bool Equals(ProfileConfig cmpObj)
+ {
+ // Order here is important.
+ // Multiple entries with the same item is considerable less likely that multiple items with the same group.
+ // Likewise for group and category.
+ return (cmpObj.SessionItem == SessionItem &&
+ cmpObj.SessionGroup == SessionGroup &&
+ cmpObj.Category == Category);
+ }
+ }
+
+ /// <summary>
+ /// Predefined configs to make profiling easier,
+ /// nested so you can reference as Profiles.Category.Group.Item where item and group may be optional
+ /// </summary>
+ public static class Profiles
+ {
+ public static class CPU
+ {
+ public static ProfileConfig TranslateTier0 = new ProfileConfig()
+ {
+ Category = "CPU",
+ SessionGroup = "TranslateTier0"
+ };
+
+ public static ProfileConfig TranslateTier1 = new ProfileConfig()
+ {
+ Category = "CPU",
+ SessionGroup = "TranslateTier1"
+ };
+ }
+
+ public static class Input
+ {
+ public static ProfileConfig ControllerInput = new ProfileConfig
+ {
+ Category = "Input",
+ SessionGroup = "ControllerInput"
+ };
+
+ public static ProfileConfig TouchInput = new ProfileConfig
+ {
+ Category = "Input",
+ SessionGroup = "TouchInput"
+ };
+ }
+
+ public static class GPU
+ {
+ public static class Engine2d
+ {
+ public static ProfileConfig TextureCopy = new ProfileConfig()
+ {
+ Category = "GPU.Engine2D",
+ SessionGroup = "TextureCopy"
+ };
+ }
+
+ public static class Engine3d
+ {
+ public static ProfileConfig CallMethod = new ProfileConfig()
+ {
+ Category = "GPU.Engine3D",
+ SessionGroup = "CallMethod",
+ };
+
+ public static ProfileConfig VertexEnd = new ProfileConfig()
+ {
+ Category = "GPU.Engine3D",
+ SessionGroup = "VertexEnd"
+ };
+
+ public static ProfileConfig ClearBuffers = new ProfileConfig()
+ {
+ Category = "GPU.Engine3D",
+ SessionGroup = "ClearBuffers"
+ };
+
+ public static ProfileConfig SetFrameBuffer = new ProfileConfig()
+ {
+ Category = "GPU.Engine3D",
+ SessionGroup = "SetFrameBuffer",
+ };
+
+ public static ProfileConfig SetZeta = new ProfileConfig()
+ {
+ Category = "GPU.Engine3D",
+ SessionGroup = "SetZeta"
+ };
+
+ public static ProfileConfig UploadShaders = new ProfileConfig()
+ {
+ Category = "GPU.Engine3D",
+ SessionGroup = "UploadShaders"
+ };
+
+ public static ProfileConfig UploadTextures = new ProfileConfig()
+ {
+ Category = "GPU.Engine3D",
+ SessionGroup = "UploadTextures"
+ };
+
+ public static ProfileConfig UploadTexture = new ProfileConfig()
+ {
+ Category = "GPU.Engine3D",
+ SessionGroup = "UploadTexture"
+ };
+
+ public static ProfileConfig UploadConstBuffers = new ProfileConfig()
+ {
+ Category = "GPU.Engine3D",
+ SessionGroup = "UploadConstBuffers"
+ };
+
+ public static ProfileConfig UploadVertexArrays = new ProfileConfig()
+ {
+ Category = "GPU.Engine3D",
+ SessionGroup = "UploadVertexArrays"
+ };
+
+ public static ProfileConfig ConfigureState = new ProfileConfig()
+ {
+ Category = "GPU.Engine3D",
+ SessionGroup = "ConfigureState"
+ };
+ }
+
+ public static class EngineM2mf
+ {
+ public static ProfileConfig CallMethod = new ProfileConfig()
+ {
+ Category = "GPU.EngineM2mf",
+ SessionGroup = "CallMethod",
+ };
+
+ public static ProfileConfig Execute = new ProfileConfig()
+ {
+ Category = "GPU.EngineM2mf",
+ SessionGroup = "Execute",
+ };
+ }
+
+ public static class EngineP2mf
+ {
+ public static ProfileConfig CallMethod = new ProfileConfig()
+ {
+ Category = "GPU.EngineP2mf",
+ SessionGroup = "CallMethod",
+ };
+
+ public static ProfileConfig Execute = new ProfileConfig()
+ {
+ Category = "GPU.EngineP2mf",
+ SessionGroup = "Execute",
+ };
+
+ public static ProfileConfig PushData = new ProfileConfig()
+ {
+ Category = "GPU.EngineP2mf",
+ SessionGroup = "PushData",
+ };
+ }
+
+ public static class Shader
+ {
+ public static ProfileConfig Decompile = new ProfileConfig()
+ {
+ Category = "GPU.Shader",
+ SessionGroup = "Decompile",
+ };
+ }
+ }
+
+ public static ProfileConfig ServiceCall = new ProfileConfig()
+ {
+ Category = "ServiceCall",
+ };
+ }
+}
diff --git a/Ryujinx.Debugger/Profiler/ProfileSorters.cs b/Ryujinx.Debugger/Profiler/ProfileSorters.cs
new file mode 100644
index 00000000..2b730af5
--- /dev/null
+++ b/Ryujinx.Debugger/Profiler/ProfileSorters.cs
@@ -0,0 +1,32 @@
+using System;
+using System.Collections.Generic;
+
+namespace Ryujinx.Debugger.Profiler
+{
+ public static class ProfileSorters
+ {
+ public class InstantAscending : IComparer<KeyValuePair<ProfileConfig, TimingInfo>>
+ {
+ public int Compare(KeyValuePair<ProfileConfig, TimingInfo> pair1, KeyValuePair<ProfileConfig, TimingInfo> pair2)
+ => pair2.Value.Instant.CompareTo(pair1.Value.Instant);
+ }
+
+ public class AverageAscending : IComparer<KeyValuePair<ProfileConfig, TimingInfo>>
+ {
+ public int Compare(KeyValuePair<ProfileConfig, TimingInfo> pair1, KeyValuePair<ProfileConfig, TimingInfo> pair2)
+ => pair2.Value.AverageTime.CompareTo(pair1.Value.AverageTime);
+ }
+
+ public class TotalAscending : IComparer<KeyValuePair<ProfileConfig, TimingInfo>>
+ {
+ public int Compare(KeyValuePair<ProfileConfig, TimingInfo> pair1, KeyValuePair<ProfileConfig, TimingInfo> pair2)
+ => pair2.Value.TotalTime.CompareTo(pair1.Value.TotalTime);
+ }
+
+ public class TagAscending : IComparer<KeyValuePair<ProfileConfig, TimingInfo>>
+ {
+ public int Compare(KeyValuePair<ProfileConfig, TimingInfo> pair1, KeyValuePair<ProfileConfig, TimingInfo> pair2)
+ => StringComparer.CurrentCulture.Compare(pair1.Key.Search, pair2.Key.Search);
+ }
+ }
+}
diff --git a/Ryujinx.Debugger/Profiler/ProfilerConfiguration.cs b/Ryujinx.Debugger/Profiler/ProfilerConfiguration.cs
new file mode 100644
index 00000000..e0842f2e
--- /dev/null
+++ b/Ryujinx.Debugger/Profiler/ProfilerConfiguration.cs
@@ -0,0 +1,68 @@
+using Gdk;
+using System;
+using System.IO;
+using Utf8Json;
+using Utf8Json.Resolvers;
+
+namespace Ryujinx.Debugger.Profiler
+{
+ public class ProfilerConfiguration
+ {
+ public bool Enabled { get; private set; }
+ public string DumpPath { get; private set; }
+ public float UpdateRate { get; private set; }
+ public int MaxLevel { get; private set; }
+ public int MaxFlags { get; private set; }
+ public float History { get; private set; }
+
+ /// <summary>
+ /// Loads a configuration file from disk
+ /// </summary>
+ /// <param name="path">The path to the JSON configuration file</param>
+ public static ProfilerConfiguration Load(string path)
+ {
+ var resolver = CompositeResolver.Create(
+ new[] { new ConfigurationEnumFormatter<Key>() },
+ new[] { StandardResolver.AllowPrivateSnakeCase }
+ );
+
+ if (!File.Exists(path))
+ {
+ throw new FileNotFoundException($"Profiler configuration file {path} not found");
+ }
+
+ using (Stream stream = File.OpenRead(path))
+ {
+ return JsonSerializer.Deserialize<ProfilerConfiguration>(stream, resolver);
+ }
+ }
+
+ private class ConfigurationEnumFormatter<T> : IJsonFormatter<T>
+ where T : struct
+ {
+ public void Serialize(ref JsonWriter writer, T value, IJsonFormatterResolver formatterResolver)
+ {
+ formatterResolver.GetFormatterWithVerify<string>()
+ .Serialize(ref writer, value.ToString(), formatterResolver);
+ }
+
+ public T Deserialize(ref JsonReader reader, IJsonFormatterResolver formatterResolver)
+ {
+ if (reader.ReadIsNull())
+ {
+ return default(T);
+ }
+
+ string enumName = formatterResolver.GetFormatterWithVerify<string>()
+ .Deserialize(ref reader, formatterResolver);
+
+ if (Enum.TryParse<T>(enumName, out T result))
+ {
+ return result;
+ }
+
+ return default(T);
+ }
+ }
+ }
+}
diff --git a/Ryujinx.Debugger/Profiler/Settings.cs b/Ryujinx.Debugger/Profiler/Settings.cs
new file mode 100644
index 00000000..52aa0d84
--- /dev/null
+++ b/Ryujinx.Debugger/Profiler/Settings.cs
@@ -0,0 +1,17 @@
+namespace Ryujinx.Debugger.Profiler
+{
+ public class ProfilerSettings
+ {
+ // Default settings for profiler
+ public bool Enabled { get; set; } = false;
+ public bool FileDumpEnabled { get; set; } = false;
+ public string DumpLocation { get; set; } = "";
+ public float UpdateRate { get; set; } = 0.1f;
+ public int MaxLevel { get; set; } = 0;
+ public int MaxFlags { get; set; } = 1000;
+
+ // 19531225 = 5 seconds in ticks on most pc's.
+ // It should get set on boot to the time specified in config
+ public long History { get; set; } = 19531225;
+ }
+}
diff --git a/Ryujinx.Debugger/Profiler/TimingFlag.cs b/Ryujinx.Debugger/Profiler/TimingFlag.cs
new file mode 100644
index 00000000..8a34ac99
--- /dev/null
+++ b/Ryujinx.Debugger/Profiler/TimingFlag.cs
@@ -0,0 +1,17 @@
+namespace Ryujinx.Debugger.Profiler
+{
+ public enum TimingFlagType
+ {
+ FrameSwap = 0,
+ SystemFrame = 1,
+
+ // Update this for new flags
+ Count = 2,
+ }
+
+ public struct TimingFlag
+ {
+ public TimingFlagType FlagType;
+ public long Timestamp;
+ }
+}
diff --git a/Ryujinx.Debugger/Profiler/TimingInfo.cs b/Ryujinx.Debugger/Profiler/TimingInfo.cs
new file mode 100644
index 00000000..90bd63d2
--- /dev/null
+++ b/Ryujinx.Debugger/Profiler/TimingInfo.cs
@@ -0,0 +1,174 @@
+using System;
+using System.Collections.Generic;
+
+namespace Ryujinx.Debugger.Profiler
+{
+ public struct Timestamp
+ {
+ public long BeginTime;
+ public long EndTime;
+ }
+
+ public class TimingInfo
+ {
+ // Timestamps
+ public long TotalTime { get; set; }
+ public long Instant { get; set; }
+
+ // Measurement counts
+ public int Count { get; set; }
+ public int InstantCount { get; set; }
+
+ // Work out average
+ public long AverageTime => (Count == 0) ? -1 : TotalTime / Count;
+
+ // Intentionally not locked as it's only a get count
+ public bool IsActive => _timestamps.Count > 0;
+
+ public long BeginTime
+ {
+ get
+ {
+ lock (_timestampLock)
+ {
+ if (_depth > 0)
+ {
+ return _currentTimestamp.BeginTime;
+ }
+
+ return -1;
+ }
+ }
+ }
+
+ // Timestamp collection
+ private List<Timestamp> _timestamps;
+ private readonly object _timestampLock = new object();
+ private readonly object _timestampListLock = new object();
+ private Timestamp _currentTimestamp;
+
+ // Depth of current timer,
+ // each begin call increments and each end call decrements
+ private int _depth;
+
+ public TimingInfo()
+ {
+ _timestamps = new List<Timestamp>();
+ _depth = 0;
+ }
+
+ public void Begin(long beginTime)
+ {
+ lock (_timestampLock)
+ {
+ // Finish current timestamp if already running
+ if (_depth > 0)
+ {
+ EndUnsafe(beginTime);
+ }
+
+ BeginUnsafe(beginTime);
+ _depth++;
+ }
+ }
+
+ private void BeginUnsafe(long beginTime)
+ {
+ _currentTimestamp.BeginTime = beginTime;
+ _currentTimestamp.EndTime = -1;
+ }
+
+ public void End(long endTime)
+ {
+ lock (_timestampLock)
+ {
+ _depth--;
+
+ if (_depth < 0)
+ {
+ throw new Exception("Timing info end called without corresponding begin");
+ }
+
+ EndUnsafe(endTime);
+
+ // Still have others using this timing info so recreate start for them
+ if (_depth > 0)
+ {
+ BeginUnsafe(endTime);
+ }
+ }
+ }
+
+ private void EndUnsafe(long endTime)
+ {
+ _currentTimestamp.EndTime = endTime;
+ lock (_timestampListLock)
+ {
+ _timestamps.Add(_currentTimestamp);
+ }
+
+ long delta = _currentTimestamp.EndTime - _currentTimestamp.BeginTime;
+ TotalTime += delta;
+ Instant += delta;
+
+ Count++;
+ InstantCount++;
+ }
+
+ // Remove any timestamps before given timestamp to free memory
+ public void Cleanup(long before, long preserveStart, long preserveEnd)
+ {
+ lock (_timestampListLock)
+ {
+ int toRemove = 0;
+ int toPreserveStart = 0;
+ int toPreserveLen = 0;
+
+ for (int i = 0; i < _timestamps.Count; i++)
+ {
+ if (_timestamps[i].EndTime < preserveStart)
+ {
+ toPreserveStart++;
+ InstantCount--;
+ Instant -= _timestamps[i].EndTime - _timestamps[i].BeginTime;
+ }
+ else if (_timestamps[i].EndTime < preserveEnd)
+ {
+ toPreserveLen++;
+ }
+ else if (_timestamps[i].EndTime < before)
+ {
+ toRemove++;
+ InstantCount--;
+ Instant -= _timestamps[i].EndTime - _timestamps[i].BeginTime;
+ }
+ else
+ {
+ // Assume timestamps are in chronological order so no more need to be removed
+ break;
+ }
+ }
+
+ if (toPreserveStart > 0)
+ {
+ _timestamps.RemoveRange(0, toPreserveStart);
+ }
+
+ if (toRemove > 0)
+ {
+ _timestamps.RemoveRange(toPreserveLen, toRemove);
+ }
+ }
+ }
+
+ public Timestamp[] GetAllTimestamps()
+ {
+ lock (_timestampListLock)
+ {
+ Timestamp[] returnTimestamps = new Timestamp[_timestamps.Count];
+ _timestamps.CopyTo(returnTimestamps);
+ return returnTimestamps;
+ }
+ }
+ }
+}
diff --git a/Ryujinx.Debugger/ProfilerConfig.jsonc b/Ryujinx.Debugger/ProfilerConfig.jsonc
new file mode 100644
index 00000000..e6714386
--- /dev/null
+++ b/Ryujinx.Debugger/ProfilerConfig.jsonc
@@ -0,0 +1,28 @@
+{
+ // Enable profiling (Only available on a profiling enabled builds)
+ "enabled": true,
+
+ // Set profile file dump location, if blank file dumping disabled. (e.g. `ProfileDump.csv`)
+ "dump_path": "",
+
+ // Update rate for profiler UI, in hertz. -1 updates every time a frame is issued
+ "update_rate": 4.0,
+
+ // Set how long to keep profiling data in seconds, reduce if profiling is taking too much RAM
+ "history": 5.0,
+
+ // Set the maximum profiling level. Higher values may cause a heavy load on your system but will allow you to profile in more detail
+ "max_level": 0,
+
+ // Sets the maximum number of flags to keep
+ "max_flags": 1000,
+
+ // Keyboard Controls
+ // https://github.com/opentk/opentk/blob/master/src/OpenTK/Input/Key.cs
+ "controls": {
+ "buttons": {
+ // Show/Hide the profiler
+ "toggle_profiler": "F2"
+ }
+ }
+} \ No newline at end of file
diff --git a/Ryujinx.Debugger/Ryujinx.Debugger.csproj b/Ryujinx.Debugger/Ryujinx.Debugger.csproj
new file mode 100644
index 00000000..a67662cc
--- /dev/null
+++ b/Ryujinx.Debugger/Ryujinx.Debugger.csproj
@@ -0,0 +1,42 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+ <PropertyGroup>
+ <TargetFramework>netcoreapp3.0</TargetFramework>
+ <Configurations>Debug;Release;Profile Release;Profile Debug</Configurations>
+ </PropertyGroup>
+
+ <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Profile Debug|AnyCPU'">
+ <DefineConstants>TRACE;USE_DEBUGGING</DefineConstants>
+ </PropertyGroup>
+
+ <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Profile Release|AnyCPU'">
+ <DefineConstants>TRACE;USE_DEBUGGING</DefineConstants>
+ </PropertyGroup>
+
+ <ItemGroup>
+ <None Remove="UI\DebuggerWidget.glade" />
+ <None Remove="UI\ProfilerWidget.glade" />
+ </ItemGroup>
+
+ <ItemGroup>
+ <EmbeddedResource Include="UI\DebuggerWidget.glade" />
+ <EmbeddedResource Include="UI\ProfilerWidget.glade" />
+ </ItemGroup>
+
+ <ItemGroup>
+ <PackageReference Include="GtkSharp" Version="3.22.25.56" />
+ <PackageReference Include="SkiaSharp.NativeAssets.Linux" Version="1.68.1.1" />
+ <PackageReference Include="SkiaSharp.Views.Gtk3" Version="1.68.1.1" />
+ </ItemGroup>
+
+ <ItemGroup>
+ <ProjectReference Include="..\Ryujinx.Common\Ryujinx.Common.csproj" />
+ </ItemGroup>
+
+ <ItemGroup>
+ <None Update="ProfilerConfig.jsonc">
+ <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+ </None>
+ </ItemGroup>
+
+</Project>
diff --git a/Ryujinx.Debugger/UI/DebuggerWidget.cs b/Ryujinx.Debugger/UI/DebuggerWidget.cs
new file mode 100644
index 00000000..b2d8458d
--- /dev/null
+++ b/Ryujinx.Debugger/UI/DebuggerWidget.cs
@@ -0,0 +1,42 @@
+using Gtk;
+using System;
+using GUI = Gtk.Builder.ObjectAttribute;
+
+namespace Ryujinx.Debugger.UI
+{
+ public class DebuggerWidget : Box
+ {
+ public event EventHandler DebuggerEnabled;
+ public event EventHandler DebuggerDisabled;
+
+ [GUI] Notebook _widgetNotebook;
+
+ public DebuggerWidget() : this(new Builder("Ryujinx.Debugger.UI.DebuggerWidget.glade")) { }
+
+ public DebuggerWidget(Builder builder) : base(builder.GetObject("_debuggerBox").Handle)
+ {
+ builder.Autoconnect(this);
+
+ LoadProfiler();
+ }
+
+ public void LoadProfiler()
+ {
+ ProfilerWidget widget = new ProfilerWidget();
+
+ widget.RegisterParentDebugger(this);
+
+ _widgetNotebook.AppendPage(widget, new Label("Profiler"));
+ }
+
+ public void Enable()
+ {
+ DebuggerEnabled.Invoke(this, null);
+ }
+
+ public void Disable()
+ {
+ DebuggerDisabled.Invoke(this, null);
+ }
+ }
+}
diff --git a/Ryujinx.Debugger/UI/DebuggerWidget.glade b/Ryujinx.Debugger/UI/DebuggerWidget.glade
new file mode 100644
index 00000000..7e6e691d
--- /dev/null
+++ b/Ryujinx.Debugger/UI/DebuggerWidget.glade
@@ -0,0 +1,44 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- Generated with glade 3.21.0 -->
+<interface>
+ <requires lib="gtk+" version="3.20"/>
+ <object class="GtkBox" id="_debuggerBox">
+ <property name="name">DebuggerBox</property>
+ <property name="width_request">1024</property>
+ <property name="height_request">720</property>
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="orientation">vertical</property>
+ <child>
+ <object class="GtkNotebook" id="_widgetNotebook">
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="hexpand">True</property>
+ <property name="vexpand">True</property>
+ <child>
+ <placeholder/>
+ </child>
+ <child type="tab">
+ <placeholder/>
+ </child>
+ <child>
+ <placeholder/>
+ </child>
+ <child type="tab">
+ <placeholder/>
+ </child>
+ <child>
+ <placeholder/>
+ </child>
+ <child type="tab">
+ <placeholder/>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">True</property>
+ <property name="fill">True</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ </object>
+</interface>
diff --git a/Ryujinx.Debugger/UI/ProfilerWidget.cs b/Ryujinx.Debugger/UI/ProfilerWidget.cs
new file mode 100644
index 00000000..0dc4b84f
--- /dev/null
+++ b/Ryujinx.Debugger/UI/ProfilerWidget.cs
@@ -0,0 +1,801 @@
+using Gtk;
+using Ryujinx.Common;
+using Ryujinx.Debugger.Profiler;
+using SkiaSharp;
+using SkiaSharp.Views.Desktop;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.RegularExpressions;
+using System.Threading;
+
+using GUI = Gtk.Builder.ObjectAttribute;
+
+namespace Ryujinx.Debugger.UI
+{
+ public class ProfilerWidget : Box
+ {
+ private Thread _profilerThread;
+ private double _prevTime;
+ private bool _profilerRunning;
+
+ private TimingFlag[] _timingFlags;
+
+ private bool _initComplete = false;
+ private bool _redrawPending = true;
+ private bool _doStep = false;
+
+ // Layout
+ private const int LineHeight = 16;
+ private const int MinimumColumnWidth = 200;
+ private const int TitleHeight = 24;
+ private const int TitleFontHeight = 16;
+ private const int LinePadding = 2;
+ private const int ColumnSpacing = 15;
+ private const int FilterHeight = 24;
+ private const int BottomBarHeight = FilterHeight + LineHeight;
+
+ // Sorting
+ private List<KeyValuePair<ProfileConfig, TimingInfo>> _unsortedProfileData;
+ private IComparer<KeyValuePair<ProfileConfig, TimingInfo>> _sortAction = new ProfileSorters.TagAscending();
+
+ // Flag data
+ private long[] _timingFlagsAverages;
+ private long[] _timingFlagsLast;
+
+ // Filtering
+ private string _filterText = "";
+ private bool _regexEnabled = false;
+
+ // Scrolling
+ private float _scrollPos = 0;
+
+ // Profile data storage
+ private List<KeyValuePair<ProfileConfig, TimingInfo>> _sortedProfileData;
+ private long _captureTime;
+
+ // Graph
+ private SKColor[] _timingFlagColors = new[]
+ {
+ new SKColor(150, 25, 25, 50), // FrameSwap = 0
+ new SKColor(25, 25, 150, 50), // SystemFrame = 1
+ };
+
+ private const float GraphMoveSpeed = 40000;
+ private const float GraphZoomSpeed = 50;
+
+ private float _graphZoom = 1;
+ private float _graphPosition = 0;
+ private int _rendererHeight => _renderer.AllocatedHeight;
+ private int _rendererWidth => _renderer.AllocatedWidth;
+
+ // Event management
+ private long _lastOutputUpdate;
+ private long _lastOutputDraw;
+ private long _lastOutputUpdateDuration;
+ private long _lastOutputDrawDuration;
+ private double _lastFrameTimeMs;
+ private double _updateTimer;
+ private bool _profileUpdated = false;
+ private readonly object _profileDataLock = new object();
+
+ private SkRenderer _renderer;
+
+ [GUI] ScrolledWindow _scrollview;
+ [GUI] CheckButton _enableCheckbutton;
+ [GUI] Scrollbar _outputScrollbar;
+ [GUI] Entry _filterBox;
+ [GUI] ComboBox _modeBox;
+ [GUI] CheckButton _showFlags;
+ [GUI] CheckButton _showInactive;
+ [GUI] Button _stepButton;
+ [GUI] CheckButton _pauseCheckbutton;
+
+ public ProfilerWidget() : this(new Builder("Ryujinx.Debugger.UI.ProfilerWidget.glade")) { }
+
+ public ProfilerWidget(Builder builder) : base(builder.GetObject("_profilerBox").Handle)
+ {
+ builder.Autoconnect(this);
+
+ this.KeyPressEvent += ProfilerWidget_KeyPressEvent;
+
+ this.Expand = true;
+
+ _renderer = new SkRenderer();
+ _renderer.Expand = true;
+
+ _outputScrollbar.ValueChanged += _outputScrollbar_ValueChanged;
+
+ _renderer.DrawGraphs += _renderer_DrawGraphs;
+
+ _filterBox.Changed += _filterBox_Changed;
+
+ _stepButton.Clicked += _stepButton_Clicked;
+
+ _scrollview.Add(_renderer);
+
+ if (Profile.UpdateRate <= 0)
+ {
+ // Perform step regardless of flag type
+ Profile.RegisterFlagReceiver((t) =>
+ {
+ if (_pauseCheckbutton.Active)
+ {
+ _doStep = true;
+ }
+ });
+ }
+ }
+
+ private void _stepButton_Clicked(object sender, EventArgs e)
+ {
+ if (_pauseCheckbutton.Active)
+ {
+ _doStep = true;
+ }
+
+ _profileUpdated = true;
+ }
+
+ private void _filterBox_Changed(object sender, EventArgs e)
+ {
+ _filterText = _filterBox.Text;
+ _profileUpdated = true;
+ }
+
+ private void _outputScrollbar_ValueChanged(object sender, EventArgs e)
+ {
+ _scrollPos = -(float)Math.Max(0, _outputScrollbar.Value);
+ _profileUpdated = true;
+ }
+
+ private void _renderer_DrawGraphs(object sender, EventArgs e)
+ {
+ if (e is SKPaintSurfaceEventArgs se)
+ {
+ Draw(se.Surface.Canvas);
+ }
+ }
+
+ public void RegisterParentDebugger(DebuggerWidget debugger)
+ {
+ debugger.DebuggerEnabled += Debugger_DebuggerAttached;
+ debugger.DebuggerDisabled += Debugger_DebuggerDettached;
+ }
+
+ private void Debugger_DebuggerDettached(object sender, EventArgs e)
+ {
+ _profilerRunning = false;
+
+ if (_profilerThread != null)
+ {
+ _profilerThread.Join();
+ }
+ }
+
+ private void Debugger_DebuggerAttached(object sender, EventArgs e)
+ {
+ _profilerRunning = false;
+
+ if (_profilerThread != null)
+ {
+ _profilerThread.Join();
+ }
+
+ _profilerRunning = true;
+
+ _profilerThread = new Thread(UpdateLoop)
+ {
+ Name = "Profiler.UpdateThread"
+ };
+ _profilerThread.Start();
+ }
+
+ private void ProfilerWidget_KeyPressEvent(object o, Gtk.KeyPressEventArgs args)
+ {
+ switch (args.Event.Key)
+ {
+ case Gdk.Key.Left:
+ _graphPosition += (long)(GraphMoveSpeed * _lastFrameTimeMs);
+ break;
+
+ case Gdk.Key.Right:
+ _graphPosition = Math.Max(_graphPosition - (long)(GraphMoveSpeed * _lastFrameTimeMs), 0);
+ break;
+
+ case Gdk.Key.Up:
+ _graphZoom = MathF.Min(_graphZoom + (float)(GraphZoomSpeed * _lastFrameTimeMs), 100.0f);
+ break;
+
+ case Gdk.Key.Down:
+ _graphZoom = MathF.Max(_graphZoom - (float)(GraphZoomSpeed * _lastFrameTimeMs), 1f);
+ break;
+ }
+ _profileUpdated = true;
+ }
+
+ public void UpdateLoop()
+ {
+ _lastOutputUpdate = PerformanceCounter.ElapsedTicks;
+ _lastOutputDraw = PerformanceCounter.ElapsedTicks;
+
+ while (_profilerRunning)
+ {
+ _lastOutputUpdate = PerformanceCounter.ElapsedTicks;
+ int timeToSleepMs = (_pauseCheckbutton.Active || !_enableCheckbutton.Active) ? 33 : 1;
+
+ if (Profile.ProfilingEnabled() && _enableCheckbutton.Active)
+ {
+ double time = (double)PerformanceCounter.ElapsedTicks / PerformanceCounter.TicksPerSecond;
+
+ Update(time - _prevTime);
+
+ _lastOutputUpdateDuration = PerformanceCounter.ElapsedTicks - _lastOutputUpdate;
+ _prevTime = time;
+
+ Gdk.Threads.AddIdle(1000, ()=>
+ {
+ _renderer.QueueDraw();
+
+ return true;
+ });
+ }
+
+ Thread.Sleep(timeToSleepMs);
+ }
+ }
+
+ public void Update(double frameTime)
+ {
+ _lastFrameTimeMs = frameTime;
+
+ // Get timing data if enough time has passed
+ _updateTimer += frameTime;
+
+ if (_doStep || ((Profile.UpdateRate > 0) && (!_pauseCheckbutton.Active && (_updateTimer > Profile.UpdateRate))))
+ {
+ _updateTimer = 0;
+ _captureTime = PerformanceCounter.ElapsedTicks;
+ _timingFlags = Profile.GetTimingFlags();
+ _doStep = false;
+ _profileUpdated = true;
+
+ _unsortedProfileData = Profile.GetProfilingData();
+
+ (_timingFlagsAverages, _timingFlagsLast) = Profile.GetTimingAveragesAndLast();
+ }
+
+ // Filtering
+ if (_profileUpdated)
+ {
+ lock (_profileDataLock)
+ {
+ _sortedProfileData = _showInactive.Active ? _unsortedProfileData : _unsortedProfileData.FindAll(kvp => kvp.Value.IsActive);
+
+ if (_sortAction != null)
+ {
+ _sortedProfileData.Sort(_sortAction);
+ }
+
+ if (_regexEnabled)
+ {
+ try
+ {
+ Regex filterRegex = new Regex(_filterText, RegexOptions.IgnoreCase);
+ if (_filterText != "")
+ {
+ _sortedProfileData = _sortedProfileData.Where((pair => filterRegex.IsMatch(pair.Key.Search))).ToList();
+ }
+ }
+ catch (ArgumentException argException)
+ {
+ // Skip filtering for invalid regex
+ }
+ }
+ else
+ {
+ // Regular filtering
+ _sortedProfileData = _sortedProfileData.Where((pair => pair.Key.Search.ToLower().Contains(_filterText.ToLower()))).ToList();
+ }
+ }
+
+ _profileUpdated = false;
+ _redrawPending = true;
+ _initComplete = true;
+ }
+ }
+
+ private string GetTimeString(long timestamp)
+ {
+ float time = (float)timestamp / PerformanceCounter.TicksPerMillisecond;
+
+ return (time < 1) ? $"{time * 1000:F3}us" : $"{time:F3}ms";
+ }
+
+ private void FilterBackspace()
+ {
+ if (_filterText.Length <= 1)
+ {
+ _filterText = "";
+ }
+ else
+ {
+ _filterText = _filterText.Remove(_filterText.Length - 1, 1);
+ }
+ }
+
+ private float GetLineY(float offset, float lineHeight, float padding, bool centre, int line)
+ {
+ return offset + lineHeight + padding + ((lineHeight + padding) * line) - ((centre) ? padding : 0);
+ }
+
+ public void Draw(SKCanvas canvas)
+ {
+ _lastOutputDraw = PerformanceCounter.ElapsedTicks;
+ if (!Visible ||
+ !_initComplete ||
+ !_enableCheckbutton.Active ||
+ !_redrawPending)
+ {
+ return;
+ }
+
+ float viewTop = TitleHeight + 5;
+ float viewBottom = _rendererHeight - FilterHeight - LineHeight;
+
+ float columnWidth;
+ float maxColumnWidth = MinimumColumnWidth;
+ float yOffset = _scrollPos + viewTop;
+ float xOffset = 10;
+ float timingWidth;
+
+ float contentHeight = GetLineY(0, LineHeight, LinePadding, false, _sortedProfileData.Count - 1);
+
+ _outputScrollbar.Adjustment.Upper = contentHeight;
+ _outputScrollbar.Adjustment.Lower = 0;
+ _outputScrollbar.Adjustment.PageSize = viewBottom - viewTop;
+
+
+ SKPaint textFont = new SKPaint()
+ {
+ Color = SKColors.White,
+ TextSize = LineHeight
+ };
+
+ SKPaint titleFont = new SKPaint()
+ {
+ Color = SKColors.White,
+ TextSize = TitleFontHeight
+ };
+
+ SKPaint evenItemBackground = new SKPaint()
+ {
+ Color = SKColors.Gray
+ };
+
+ canvas.Save();
+ canvas.ClipRect(new SKRect(0, viewTop, _rendererWidth, viewBottom), SKClipOperation.Intersect);
+
+ for (int i = 1; i < _sortedProfileData.Count; i += 2)
+ {
+ float top = GetLineY(yOffset, LineHeight, LinePadding, false, i - 1);
+ float bottom = GetLineY(yOffset, LineHeight, LinePadding, false, i);
+
+ canvas.DrawRect(new SKRect(0, top, _rendererWidth, bottom), evenItemBackground);
+ }
+
+ lock (_profileDataLock)
+ {
+ // Display category
+
+ for (int verticalIndex = 0; verticalIndex < _sortedProfileData.Count; verticalIndex++)
+ {
+ KeyValuePair<ProfileConfig, TimingInfo> entry = _sortedProfileData[verticalIndex];
+
+ if (entry.Key.Category == null)
+ {
+ continue;
+ }
+
+ float y = GetLineY(yOffset, LineHeight, LinePadding, true, verticalIndex);
+
+ canvas.DrawText(entry.Key.Category, new SKPoint(xOffset, y), textFont);
+
+ columnWidth = textFont.MeasureText(entry.Key.Category);
+
+ if (columnWidth > maxColumnWidth)
+ {
+ maxColumnWidth = columnWidth;
+ }
+ }
+
+ canvas.Restore();
+ canvas.DrawText("Category", new SKPoint(xOffset, TitleFontHeight + 2), titleFont);
+
+ columnWidth = titleFont.MeasureText("Category");
+
+ if (columnWidth > maxColumnWidth)
+ {
+ maxColumnWidth = columnWidth;
+ }
+
+ xOffset += maxColumnWidth + ColumnSpacing;
+
+ canvas.DrawLine(new SKPoint(xOffset - ColumnSpacing / 2, 0), new SKPoint(xOffset - ColumnSpacing / 2, viewBottom), textFont);
+
+ // Display session group
+ maxColumnWidth = MinimumColumnWidth;
+
+ canvas.Save();
+ canvas.ClipRect(new SKRect(0, viewTop, _rendererWidth, viewBottom), SKClipOperation.Intersect);
+
+ for (int verticalIndex = 0; verticalIndex < _sortedProfileData.Count; verticalIndex++)
+ {
+ KeyValuePair<ProfileConfig, TimingInfo> entry = _sortedProfileData[verticalIndex];
+
+ if (entry.Key.SessionGroup == null)
+ {
+ continue;
+ }
+
+ float y = GetLineY(yOffset, LineHeight, LinePadding, true, verticalIndex);
+
+ canvas.DrawText(entry.Key.SessionGroup, new SKPoint(xOffset, y), textFont);
+
+ columnWidth = textFont.MeasureText(entry.Key.SessionGroup);
+
+ if (columnWidth > maxColumnWidth)
+ {
+ maxColumnWidth = columnWidth;
+ }
+ }
+
+ canvas.Restore();
+ canvas.DrawText("Group", new SKPoint(xOffset, TitleFontHeight + 2), titleFont);
+
+ columnWidth = titleFont.MeasureText("Group");
+
+ if (columnWidth > maxColumnWidth)
+ {
+ maxColumnWidth = columnWidth;
+ }
+
+ xOffset += maxColumnWidth + ColumnSpacing;
+
+ canvas.DrawLine(new SKPoint(xOffset - ColumnSpacing / 2, 0), new SKPoint(xOffset - ColumnSpacing / 2, viewBottom), textFont);
+
+ // Display session item
+ maxColumnWidth = MinimumColumnWidth;
+
+ canvas.Save();
+ canvas.ClipRect(new SKRect(0, viewTop, _rendererWidth, viewBottom), SKClipOperation.Intersect);
+
+ for (int verticalIndex = 0; verticalIndex < _sortedProfileData.Count; verticalIndex++)
+ {
+ KeyValuePair<ProfileConfig, TimingInfo> entry = _sortedProfileData[verticalIndex];
+
+ if (entry.Key.SessionItem == null)
+ {
+ continue;
+ }
+
+ float y = GetLineY(yOffset, LineHeight, LinePadding, true, verticalIndex);
+
+ canvas.DrawText(entry.Key.SessionGroup, new SKPoint(xOffset, y), textFont);
+
+ columnWidth = textFont.MeasureText(entry.Key.SessionItem);
+
+ if (columnWidth > maxColumnWidth)
+ {
+ maxColumnWidth = columnWidth;
+ }
+ }
+
+ canvas.Restore();
+ canvas.DrawText("Item", new SKPoint(xOffset, TitleFontHeight + 2), titleFont);
+
+ columnWidth = titleFont.MeasureText("Item");
+
+ if (columnWidth > maxColumnWidth)
+ {
+ maxColumnWidth = columnWidth;
+ }
+
+ xOffset += maxColumnWidth + ColumnSpacing;
+
+ timingWidth = _rendererWidth - xOffset - 370;
+
+ canvas.Save();
+ canvas.ClipRect(new SKRect(0, viewTop, _rendererWidth, viewBottom), SKClipOperation.Intersect);
+ canvas.DrawLine(new SKPoint(xOffset, 0), new SKPoint(xOffset, _rendererHeight), textFont);
+
+ int mode = _modeBox.Active;
+
+ canvas.Save();
+ canvas.ClipRect(new SKRect(xOffset, yOffset,xOffset + timingWidth,yOffset + contentHeight),
+ SKClipOperation.Intersect);
+
+ switch (mode)
+ {
+ case 0:
+ DrawGraph(xOffset, yOffset, timingWidth, canvas);
+ break;
+ case 1:
+ DrawBars(xOffset, yOffset, timingWidth, canvas);
+
+ canvas.DrawText("Blue: Instant, Green: Avg, Red: Total",
+ new SKPoint(xOffset, _rendererHeight - TitleFontHeight), titleFont);
+ break;
+ }
+
+ canvas.Restore();
+ canvas.DrawLine(new SKPoint(xOffset + timingWidth, 0), new SKPoint(xOffset + timingWidth, _rendererHeight), textFont);
+
+ xOffset = _rendererWidth - 360;
+
+ // Display timestamps
+ long totalInstant = 0;
+ long totalAverage = 0;
+ long totalTime = 0;
+ long totalCount = 0;
+
+ for (int verticalIndex = 0; verticalIndex < _sortedProfileData.Count; verticalIndex++)
+ {
+ KeyValuePair<ProfileConfig, TimingInfo> entry = _sortedProfileData[verticalIndex];
+
+ float y = GetLineY(yOffset, LineHeight, LinePadding, true, verticalIndex);
+
+ canvas.DrawText($"{GetTimeString(entry.Value.Instant)} ({entry.Value.InstantCount})", new SKPoint(xOffset, y), textFont);
+ canvas.DrawText(GetTimeString(entry.Value.AverageTime), new SKPoint(150 + xOffset, y), textFont);
+ canvas.DrawText(GetTimeString(entry.Value.TotalTime), new SKPoint(260 + xOffset, y), textFont);
+
+ totalInstant += entry.Value.Instant;
+ totalAverage += entry.Value.AverageTime;
+ totalTime += entry.Value.TotalTime;
+ totalCount += entry.Value.InstantCount;
+ }
+
+ canvas.Restore();
+ canvas.DrawLine(new SKPoint(0, viewTop), new SKPoint(_rendererWidth, viewTop), titleFont);
+
+ float yHeight = 0 + TitleFontHeight;
+
+ canvas.DrawText("Instant (Count)", new SKPoint(xOffset, yHeight), titleFont);
+ canvas.DrawText("Average", new SKPoint(150 + xOffset, yHeight), titleFont);
+ canvas.DrawText("Total (ms)", new SKPoint(260 + xOffset, yHeight), titleFont);
+
+ // Totals
+ yHeight = _rendererHeight - FilterHeight + 3;
+
+ int textHeight = LineHeight - 2;
+
+ SKPaint detailFont = new SKPaint()
+ {
+ Color = new SKColor(100, 100, 255, 255),
+ TextSize = textHeight
+ };
+
+ canvas.DrawLine(new SkiaSharp.SKPoint(0, viewBottom), new SkiaSharp.SKPoint(_rendererWidth,viewBottom), textFont);
+
+ string hostTimeString = $"Host {GetTimeString(_timingFlagsLast[(int)TimingFlagType.SystemFrame])} " +
+ $"({GetTimeString(_timingFlagsAverages[(int)TimingFlagType.SystemFrame])})";
+
+ canvas.DrawText(hostTimeString, new SKPoint(5, yHeight), detailFont);
+
+ float tempWidth = detailFont.MeasureText(hostTimeString);
+
+ detailFont.Color = SKColors.Red;
+
+ string gameTimeString = $"Game {GetTimeString(_timingFlagsLast[(int)TimingFlagType.FrameSwap])} " +
+ $"({GetTimeString(_timingFlagsAverages[(int)TimingFlagType.FrameSwap])})";
+
+ canvas.DrawText(gameTimeString, new SKPoint(15 + tempWidth, yHeight), detailFont);
+
+ tempWidth += detailFont.MeasureText(gameTimeString);
+
+ detailFont.Color = SKColors.White;
+
+ canvas.DrawText($"Profiler: Update {GetTimeString(_lastOutputUpdateDuration)} Draw {GetTimeString(_lastOutputDrawDuration)}",
+ new SKPoint(20 + tempWidth, yHeight), detailFont);
+
+ detailFont.Color = SKColors.White;
+
+ canvas.DrawText($"{GetTimeString(totalInstant)} ({totalCount})", new SKPoint(xOffset, yHeight), detailFont);
+ canvas.DrawText(GetTimeString(totalAverage), new SKPoint(150 + xOffset, yHeight), detailFont);
+ canvas.DrawText(GetTimeString(totalTime), new SKPoint(260 + xOffset, yHeight), detailFont);
+
+ _lastOutputDrawDuration = PerformanceCounter.ElapsedTicks - _lastOutputDraw;
+ }
+ }
+
+ private void DrawGraph(float xOffset, float yOffset, float width, SKCanvas canvas)
+ {
+ if (_sortedProfileData.Count != 0)
+ {
+ int left, right;
+ float top, bottom;
+
+ float graphRight = xOffset + width;
+ float barHeight = (LineHeight - LinePadding);
+ long history = Profile.HistoryLength;
+ double timeWidthTicks = history / (double)_graphZoom;
+ long graphPositionTicks = (long)(_graphPosition * PerformanceCounter.TicksPerMillisecond);
+ long ticksPerPixel = (long)(timeWidthTicks / width);
+
+ // Reset start point if out of bounds
+ if (timeWidthTicks + graphPositionTicks > history)
+ {
+ graphPositionTicks = history - (long)timeWidthTicks;
+ _graphPosition = (float)graphPositionTicks / PerformanceCounter.TicksPerMillisecond;
+ }
+
+ graphPositionTicks = _captureTime - graphPositionTicks;
+
+ // Draw timing flags
+ if (_showFlags.Active)
+ {
+ TimingFlagType prevType = TimingFlagType.Count;
+
+ SKPaint timingPaint = new SKPaint
+ {
+ Color = _timingFlagColors.First()
+ };
+
+ foreach (TimingFlag timingFlag in _timingFlags)
+ {
+ if (prevType != timingFlag.FlagType)
+ {
+ prevType = timingFlag.FlagType;
+ timingPaint.Color = _timingFlagColors[(int)prevType];
+ }
+
+ int x = (int)(graphRight - ((graphPositionTicks - timingFlag.Timestamp) / timeWidthTicks) * width);
+
+ if (x > xOffset)
+ {
+ canvas.DrawLine(new SKPoint(x, yOffset), new SKPoint(x, _rendererHeight), timingPaint);
+ }
+ }
+ }
+
+ SKPaint barPaint = new SKPaint()
+ {
+ Color = SKColors.Green,
+ };
+
+ // Draw bars
+ for (int verticalIndex = 0; verticalIndex < _sortedProfileData.Count; verticalIndex++)
+ {
+ KeyValuePair<ProfileConfig, TimingInfo> entry = _sortedProfileData[verticalIndex];
+ long furthest = 0;
+
+ bottom = GetLineY(yOffset, LineHeight, LinePadding, false, verticalIndex);
+ top = bottom + barHeight;
+
+ // Skip rendering out of bounds bars
+ if (top < 0 || bottom > _rendererHeight)
+ {
+ continue;
+ }
+
+ barPaint.Color = SKColors.Green;
+
+ foreach (Timestamp timestamp in entry.Value.GetAllTimestamps())
+ {
+ // Skip drawing multiple timestamps on same pixel
+ if (timestamp.EndTime < furthest)
+ {
+ continue;
+ }
+
+ furthest = timestamp.EndTime + ticksPerPixel;
+
+ left = (int)(graphRight - ((graphPositionTicks - timestamp.BeginTime) / timeWidthTicks) * width);
+ right = (int)(graphRight - ((graphPositionTicks - timestamp.EndTime) / timeWidthTicks) * width);
+
+ left = (int)Math.Max(xOffset +1, left);
+
+ // Make sure width is at least 1px
+ right = Math.Max(left + 1, right);
+
+ canvas.DrawRect(new SKRect(left, top, right, bottom), barPaint);
+ }
+
+ // Currently capturing timestamp
+ barPaint.Color = SKColors.Red;
+
+ long entryBegin = entry.Value.BeginTime;
+
+ if (entryBegin != -1)
+ {
+ left = (int)(graphRight - ((graphPositionTicks - entryBegin) / timeWidthTicks) * width);
+
+ // Make sure width is at least 1px
+ left = Math.Min(left - 1, (int)graphRight);
+
+ left = (int)Math.Max(xOffset + 1, left);
+
+ canvas.DrawRect(new SKRect(left, top, graphRight, bottom), barPaint);
+ }
+ }
+
+ string label = $"-{MathF.Round(_graphPosition, 2)} ms";
+
+ SKPaint labelPaint = new SKPaint()
+ {
+ Color = SKColors.White,
+ TextSize = LineHeight
+ };
+
+ float labelWidth = labelPaint.MeasureText(label);
+
+ canvas.DrawText(label,new SKPoint(graphRight - labelWidth - LinePadding, FilterHeight + LinePadding) , labelPaint);
+
+ canvas.DrawText($"-{MathF.Round((float)((timeWidthTicks / PerformanceCounter.TicksPerMillisecond) + _graphPosition), 2)} ms",
+ new SKPoint(xOffset + LinePadding, FilterHeight + LinePadding), labelPaint);
+ }
+ }
+
+ private void DrawBars(float xOffset, float yOffset, float width, SKCanvas canvas)
+ {
+ if (_sortedProfileData.Count != 0)
+ {
+ long maxAverage = 0;
+ long maxTotal = 0;
+ long maxInstant = 0;
+
+ float barHeight = (LineHeight - LinePadding) / 3.0f;
+
+ // Get max values
+ foreach (KeyValuePair<ProfileConfig, TimingInfo> kvp in _sortedProfileData)
+ {
+ maxInstant = Math.Max(maxInstant, kvp.Value.Instant);
+ maxAverage = Math.Max(maxAverage, kvp.Value.AverageTime);
+ maxTotal = Math.Max(maxTotal, kvp.Value.TotalTime);
+ }
+
+ SKPaint barPaint = new SKPaint()
+ {
+ Color = SKColors.Blue
+ };
+
+ for (int verticalIndex = 0; verticalIndex < _sortedProfileData.Count; verticalIndex++)
+ {
+ KeyValuePair<ProfileConfig, TimingInfo> entry = _sortedProfileData[verticalIndex];
+ // Instant
+ barPaint.Color = SKColors.Blue;
+
+ float bottom = GetLineY(yOffset, LineHeight, LinePadding, false, verticalIndex);
+ float top = bottom + barHeight;
+ float right = (float)entry.Value.Instant / maxInstant * width + xOffset;
+
+ // Skip rendering out of bounds bars
+ if (top < 0 || bottom > _rendererHeight)
+ {
+ continue;
+ }
+
+ canvas.DrawRect(new SKRect(xOffset, top, right, bottom), barPaint);
+
+ // Average
+ barPaint.Color = SKColors.Green;
+
+ top += barHeight;
+ bottom += barHeight;
+ right = (float)entry.Value.AverageTime / maxAverage * width + xOffset;
+
+ canvas.DrawRect(new SKRect(xOffset, top, right, bottom), barPaint);
+
+ // Total
+ barPaint.Color = SKColors.Red;
+
+ top += barHeight;
+ bottom += barHeight;
+ right = (float)entry.Value.TotalTime / maxTotal * width + xOffset;
+
+ canvas.DrawRect(new SKRect(xOffset, top, right, bottom), barPaint);
+ }
+ }
+ }
+ }
+}
diff --git a/Ryujinx.Debugger/UI/ProfilerWidget.glade b/Ryujinx.Debugger/UI/ProfilerWidget.glade
new file mode 100644
index 00000000..00dd4f70
--- /dev/null
+++ b/Ryujinx.Debugger/UI/ProfilerWidget.glade
@@ -0,0 +1,232 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- Generated with glade 3.21.0 -->
+<interface>
+ <requires lib="gtk+" version="3.20"/>
+ <object class="GtkListStore" id="viewMode">
+ <columns>
+ <!-- column-name mode -->
+ <column type="gint"/>
+ <!-- column-name label -->
+ <column type="gchararray"/>
+ </columns>
+ <data>
+ <row>
+ <col id="0">0</col>
+ <col id="1" translatable="yes">Graph</col>
+ </row>
+ <row>
+ <col id="0">1</col>
+ <col id="1" translatable="yes">Bars</col>
+ </row>
+ </data>
+ </object>
+ <object class="GtkBox" id="_profilerBox">
+ <property name="name">ProfilerBox</property>
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="margin_left">5</property>
+ <property name="margin_right">5</property>
+ <property name="margin_top">5</property>
+ <property name="margin_bottom">5</property>
+ <property name="orientation">vertical</property>
+ <property name="spacing">10</property>
+ <child>
+ <object class="GtkCheckButton" id="_enableCheckbutton">
+ <property name="label" translatable="yes">Enable Profiler</property>
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="receives_default">False</property>
+ <property name="draw_indicator">True</property>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">True</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkBox">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <child>
+ <object class="GtkScrolledWindow" id="_scrollview">
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="vscrollbar_policy">never</property>
+ <property name="shadow_type">in</property>
+ <child>
+ <placeholder/>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">True</property>
+ <property name="fill">True</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkScrollbar" id="_outputScrollbar">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="orientation">vertical</property>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">True</property>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">True</property>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkBox">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="spacing">10</property>
+ <child>
+ <object class="GtkCheckButton" id="_showInactive">
+ <property name="label" translatable="yes">Show Inactive</property>
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="receives_default">False</property>
+ <property name="active">True</property>
+ <property name="draw_indicator">True</property>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">True</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkCheckButton" id="_showFlags">
+ <property name="label" translatable="yes">Show Flags</property>
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="receives_default">False</property>
+ <property name="active">True</property>
+ <property name="draw_indicator">True</property>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">True</property>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkCheckButton" id="_pauseCheckbutton">
+ <property name="label" translatable="yes">Paused</property>
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="receives_default">False</property>
+ <property name="draw_indicator">True</property>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">True</property>
+ <property name="position">2</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkBox">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <child>
+ <object class="GtkLabel">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="label" translatable="yes">View Mode: </property>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">True</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkComboBox" id="_modeBox">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="model">viewMode</property>
+ <property name="active">0</property>
+ <child>
+ <object class="GtkCellRendererText" id="modeTextRenderer"/>
+ <attributes>
+ <attribute name="text">1</attribute>
+ </attributes>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">True</property>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">True</property>
+ <property name="position">3</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkBox">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <child>
+ <object class="GtkLabel">
+ <property name="visible">True</property>
+ <property name="can_focus">False</property>
+ <property name="label" translatable="yes">Filter: </property>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">True</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkEntry" id="_filterBox">
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">True</property>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">True</property>
+ <property name="position">4</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkButton" id="_stepButton">
+ <property name="label" translatable="yes">Step</property>
+ <property name="visible">True</property>
+ <property name="can_focus">True</property>
+ <property name="receives_default">True</property>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">True</property>
+ <property name="position">5</property>
+ </packing>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">True</property>
+ <property name="position">2</property>
+ </packing>
+ </child>
+ </object>
+</interface>
diff --git a/Ryujinx.Debugger/UI/SkRenderer.cs b/Ryujinx.Debugger/UI/SkRenderer.cs
new file mode 100644
index 00000000..a95e4542
--- /dev/null
+++ b/Ryujinx.Debugger/UI/SkRenderer.cs
@@ -0,0 +1,23 @@
+using SkiaSharp;
+using SkiaSharp.Views.Gtk;
+using System;
+
+namespace Ryujinx.Debugger.UI
+{
+ public class SkRenderer : SKDrawingArea
+ {
+ public event EventHandler DrawGraphs;
+
+ public SkRenderer()
+ {
+ this.PaintSurface += SkRenderer_PaintSurface;
+ }
+
+ private void SkRenderer_PaintSurface(object sender, SkiaSharp.Views.Desktop.SKPaintSurfaceEventArgs e)
+ {
+ e.Surface.Canvas.Clear(SKColors.Black);
+
+ DrawGraphs.Invoke(this, e);
+ }
+ }
+}