如何让Visual Studio Tools for Unity插件用于调试你自己的Mono嵌入应用程序
这篇文章是关于UnrealSharp开发过程中,支持使用Visual Studio调试集成到UnrealEngine5中去的C#代码的总结。
关于UnrealSharp: https://www.cnblogs.com/bodong/p/18063520
--------------------
最近在测试将mono嵌入到C++应用程序中,苦于没有调试器,有时候还是不怎么方便。网上搜了一下,有VS插件MDebug、VSMonoDebugger,实际试用了一下,有点麻烦,而且似乎对Windows+Visual Studio 2022支持不大好。因此想到了,Unity引擎是基于mono的,Visual Studio 2022也内置了针对Unity的调试器,名为:Visual Studio Tools for Unity。我想如果这个插件也能调试我的应用程序就好了。
打开VS,使用菜单中的“附加到Unity”菜单打开附加对话框。最后发现并不能识别我的mono嵌入应用程序。因此直接调试Visual Studio 2022,查找和研究VS发现Unity进程的方法。经过一系列的调试,发现查找Unity相关进程的代码位于:SyntaxTree.VisualStudio.Unity.Messaging.dll 中,文件路径:
// c:\program files\microsoft visual studio\2022\professional\common7\ide\extensions\microsoft\visual studio tools for unity\SyntaxTree.VisualStudio.Unity.Messaging.dll // SyntaxTree.VisualStudio.Unity.Messaging, Version=17.8.2.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a
具体路径可能你跟我不一样。下面这个函数位于:SyntaxTree.VisualStudio.Unity.Messaging.UnityProbe类中。
public static IEnumerable<UnityProcess> GetUnityProcesses(string informationFormat = null, bool localPlayerProcessDetection = false) { Process[] array = SafeProcess.GetProcesses().ToArray<Process>(); IEnumerable<UnityProcess> enumerable = from p in array where p.ProcessProperty((Process _) => _.ProcessName) == "Unity" select UnityProbe.UnityProcessFor(p, UnityProcessType.Editor, UnityProbe.GetDebuggerPort(p.Id), informationFormat); if (!localPlayerProcessDetection) { return enumerable; } IEnumerable<UnityProcess> enumerable2 = from p in array.Where(new Func<Process, bool>(UnityProbe.IsLocalPlayerProcess)) select UnityProbe.UnityProcessFor(p, UnityProcessType.Player, 0, informationFormat); return enumerable.Concat(enumerable2); }
我们可以看到,这里使用了两种方法来探查Unity相关进程。其一是直接查找名字叫Unity的进程,其二是探查可能是Unity Player(即Windows PC档)的进程。前者直接查看进程名称,后者通过下面这个函数来判断:
public static bool IsLocalPlayerProcess(Process process) { bool flag; try { if (process.MainWindowHandle == IntPtr.Zero) { flag = false; } else { ProcessModule mainModule = process.MainModule; if (mainModule == null) { flag = false; } else { string fileName = mainModule.FileName; string fileNameWithoutExtension = Path.GetFileNameWithoutExtension(fileName); string directoryName = Path.GetDirectoryName(fileName); if (directoryName == null) { flag = false; } else { flag = Directory.Exists(Path.Combine(directoryName, fileNameWithoutExtension + "_Data")) && File.Exists(Path.Combine(directoryName, "UnityPlayer.dll")); } } } } catch (Exception) { flag = false; } return flag; }
可以看到,只要目标进程目录下有一个 “进程名(无扩展名)” + "_Data"的目录,且该目录下有一个UnityPlayer.dll,即可被视为Unity相关进程。
因此要将我们自己的进程被该插件识别到,也有两种方法,假如我们的项目叫MyApp。那么其一是让我们生成的进程也叫Unity.exe;其二是在MyApp.exe所在目录下,新增一个MyApp_Data,然后再随便新建一个空白文本文件,把名称(含扩展名)改成"UnityPlayer.dll"即可。
这样执行后,你就会发现,你的进程出现在了搜索对话框中了。
当然,如果此时你直接双击连接是无法连接成功的,因为前面的任务只是让你可以被找到,如果要被连接上,还有另外一些额外的要求。让我们看一下下面这个函数:
public static int GetDebuggerPort(int processId) { return 56000 + processId % 1000; }
可以看到,调试器假定了目标端口和进程id之间的关联关系,因此在我们初始化mono的时候,也需要考虑到这一点,因此,我们初始化mono调试器的时候,应该这样:
int DebuggerPort = 56000 + GetProcessId() % 1000; std::string argument = std::string("--debugger-agent=transport=dt_socket,embedding=1,server=y,suspend=n,address=127.0.0.1:") + ToStlString(DebuggerPort); const char* options[] = { argument.c_str() }; mono_jit_parse_options(sizeof(options)/sizeof(options[0]), (char**)options); mono_debug_init(MONO_DEBUG_FORMAT_MONO);
这样你就可以白嫖Visual Studio Tools for Unity,用于调试你自己的mono嵌入程序了。
--------------------------------------------------------- --------------------------------------------------------- --------------------------------------------------------- --------------------------------------------------------- ---------------------------------------------------------
2024.7.17 更新
PS : 从VS2022的某一个版本开始,由于VS for Unity插件升级了,导致前面的规则失效了。相关的变更想必与适配新的Unity引擎有关系。对于UnityEditor来说,并没有区别,主要区别在于对UnityPlayer的处理。
下面是识别进程的相关代码,基本逻辑没变化。
using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; using System.Net; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Text.RegularExpressions; namespace SyntaxTree.VisualStudio.Unity.Messaging { // Token: 0x02000025 RID: 37 [NullableContext(1)] [Nullable(0)] public static class UnityProbe { // Token: 0x060000B7 RID: 183 RVA: 0x000047DB File Offset: 0x000029DB public static IEnumerable<UnityProcess> GetUnityProcesses([Nullable(2)] string informationFormat = null, bool localPlayerProcessDetection = false) { Process[] array = SafeProcess.GetProcesses().ToArray<Process>(); foreach (Process process in array) { DebuggerEngine debuggerEngine; if (UnityProbe.IsLocalEditorProcess(process, out debuggerEngine)) { yield return UnityProbe.UnityProcessFor(process, UnityProcessType.Editor, informationFormat, debuggerEngine); } else if (localPlayerProcessDetection && UnityProbe.IsLocalPlayerProcess(process, out debuggerEngine)) { yield return UnityProbe.UnityProcessFor(process, UnityProcessType.Player, informationFormat, debuggerEngine); } } Process[] array2 = null; yield break; } // Token: 0x060000B8 RID: 184 RVA: 0x000047F4 File Offset: 0x000029F4 public static bool IsLocalEditorProcess(Process process, out DebuggerEngine debuggerEngine) { debuggerEngine = DebuggerEngine.VSTU; bool flag; try { if (!process.ProcessProperty((Process p) => p.ProcessName.Contains("Unity"))) { flag = false; } else if (!process.ProcessProperty((Process p) => p.MainWindowHandle != IntPtr.Zero)) { flag = false; } else { string directoryName = Path.GetDirectoryName(process.ProcessProperty(delegate(Process p) { ProcessModule mainModule = p.MainModule; if (mainModule == null) { return null; } return mainModule.FileName; })); if (directoryName == null) { flag = false; } else { if (Directory.Exists(Path.Combine(directoryName, "CoreCLR"))) { debuggerEngine = DebuggerEngine.CoreCLR; } flag = Directory.Exists(Path.Combine(directoryName, "Data")); } } } catch (Exception) { flag = false; } return flag; } // Token: 0x060000B9 RID: 185 RVA: 0x000048D0 File Offset: 0x00002AD0 public static bool IsLocalPlayerProcess(Process process, out DebuggerEngine debuggerEngine) { debuggerEngine = DebuggerEngine.VSTU; bool flag; try { if (!process.ProcessProperty((Process p) => p.MainWindowHandle != IntPtr.Zero)) { flag = false; } else { string text = process.ProcessProperty(delegate(Process p) { ProcessModule mainModule = p.MainModule; if (mainModule == null) { return null; } return mainModule.FileName; }); string fileNameWithoutExtension = Path.GetFileNameWithoutExtension(text); string directoryName = Path.GetDirectoryName(text); if (directoryName == null) { flag = false; } else if (!Directory.Exists(Path.Combine(directoryName, fileNameWithoutExtension + "_Data"))) { flag = false; } else { if (Directory.Exists(Path.Combine(directoryName, "CoreCLR"))) { debuggerEngine = DebuggerEngine.CoreCLR; } flag = File.Exists(Path.Combine(directoryName, "UnityPlayer.dll")); } } } catch (Exception) { flag = false; } return flag; } // Token: 0x060000BA RID: 186 RVA: 0x000049A4 File Offset: 0x00002BA4 [NullableContext(2)] [return: Nullable(1)] public static string GetInformation(Process process, string informationFormat) { if (string.IsNullOrEmpty(informationFormat)) { return string.Empty; } string text = informationFormat.Replace(string.Format("{{{0}}}", InformationField.ProcessId), ((process != null) ? process.Id.ToString() : null) ?? string.Empty); string text2 = string.Format("{{{0}}}", InformationField.ProcessWindowTitle); string text3; if (process == null) { text3 = null; } else { text3 = process.ProcessProperty((Process p) => p.MainWindowTitle); } return text.Replace(text2, text3 ?? string.Empty); } // Token: 0x060000BB RID: 187 RVA: 0x00004A3C File Offset: 0x00002C3C public static UnityProcess UnityProcessFor(Process process, UnityProcessType type, [Nullable(2)] string informationFormat = null, DebuggerEngine debuggerEngine = DebuggerEngine.VSTU) { if (process == null) { throw new ArgumentNullException("process"); } string text = string.Empty; if (Platform.OnWindows()) { string text2 = process.ProcessProperty((Process p) => p.MainWindowTitle); text = ((type == UnityProcessType.Editor) ? UnityProbe.ExtractProject(text2) : text2); } UnityProcess unityProcess = new UnityProcess { Address = "127.0.0.1", Machine = Dns.GetHostName(), ProcessId = process.Id, ProjectName = text, ProfilerPort = 0, Type = type, DebuggerEngine = debuggerEngine, IsProcessDetection = true, Information = UnityProbe.GetInformation(process, informationFormat) }; if (type == UnityProcessType.Editor) { ushort debuggerPort = UnityPorts.GetDebuggerPort((long)process.Id); unityProcess.DebuggerPort = (int)debuggerPort; unityProcess.MessagerPort = (int)((debuggerPort > 0) ? (debuggerPort + 2) : 0); } if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { unityProcess.IsBackground = process.ProcessProperty((Process p) => p.MainWindowHandle == IntPtr.Zero); } else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) { unityProcess.IsBackground = DarwinProcess.IsBackgroundProcess(process.Id); } return unityProcess; } // Token: 0x060000BC RID: 188 RVA: 0x00004B70 File Offset: 0x00002D70 public static string ExtractProject(string windowTitle) { string text = "project"; if (string.IsNullOrEmpty(windowTitle)) { return string.Empty; } Match match = UnityProbe._legacyProjectRegex.Match(windowTitle); if (!match.Success) { match = UnityProbe._projectRegex.Match(windowTitle); if (!Platform.OnWindows()) { text = "scene"; } } if (!match.Success) { return string.Empty; } return UnityProbe.ExtractProjectName(match.Groups[text].Value.Trim()); } // Token: 0x060000BD RID: 189 RVA: 0x00004BE8 File Offset: 0x00002DE8 private static string ExtractProjectName(string unityProject) { if (unityProject.EndsWith("]", StringComparison.OrdinalIgnoreCase)) { int num = unityProject.LastIndexOf('['); if (num > 0) { return unityProject.Substring(0, num).Trim(); } } return unityProject; } // Token: 0x04000076 RID: 118 private static readonly Regex _legacyProjectRegex = new Regex("Unity( [^-]*)?( - \\[.*\\])? - (?<scene>.*(\\.unity)|(Untitled)) - (?<project>.*) - (?<platform>[^-]*)", RegexOptions.IgnoreCase | RegexOptions.ExplicitCapture); // Token: 0x04000077 RID: 119 private static readonly Regex _projectRegex = new Regex("(?<project>.*) - (?<scene>.*) - (?<platform>[^-]*) - Unity( [^-]*)?( - \\[.*\\])?", RegexOptions.IgnoreCase | RegexOptions.ExplicitCapture); } }
下面是进一步加工进程数据的代码:
public static UnityProcess UnityProcessFor(Process process, UnityProcessType type, [Nullable(2)] string informationFormat = null, DebuggerEngine debuggerEngine = DebuggerEngine.VSTU) { if (process == null) { throw new ArgumentNullException("process"); } string text = string.Empty; if (Platform.OnWindows()) { string text2 = process.ProcessProperty((Process p) => p.MainWindowTitle); text = ((type == UnityProcessType.Editor) ? UnityProbe.ExtractProject(text2) : text2); } UnityProcess unityProcess = new UnityProcess { Address = "127.0.0.1", Machine = Dns.GetHostName(), ProcessId = process.Id, ProjectName = text, ProfilerPort = 0, Type = type, DebuggerEngine = debuggerEngine, IsProcessDetection = true, Information = UnityProbe.GetInformation(process, informationFormat) }; if (type == UnityProcessType.Editor) { ushort debuggerPort = UnityPorts.GetDebuggerPort((long)process.Id); unityProcess.DebuggerPort = (int)debuggerPort; unityProcess.MessagerPort = (int)((debuggerPort > 0) ? (debuggerPort + 2) : 0); } if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { unityProcess.IsBackground = process.ProcessProperty((Process p) => p.MainWindowHandle == IntPtr.Zero); } else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) { unityProcess.IsBackground = DarwinProcess.IsBackgroundProcess(process.Id); } return unityProcess; }
可以看到,只有当进程是编辑器时才会分配对应的DebugerPort,这估计是为了兼容旧版本的Unity引擎。这样的特殊处理会由于下面的代码存在导致伪装的UnityPlayer不能被识别到:
private void AddProcess(UnityProcess unityProcess) { if (UnityPlayerEnhancer.RequiresEnhancement(unityProcess)) { if (!UnityPlayerEnhancer.TryEnhanceLocalPlayer(unityProcess, UnityInstanceSelectionDialog.RefreshTCPListeners())) { return; } UnityInstanceSelectionDialog.UpdateInformation(unityProcess); } UnityProcess unityProcess2; bool flag = this._processCache.TryGetValue(unityProcess, out unityProcess2); bool flag2 = flag && UnityPlayerEnhancer.RequiresEnhancement(unityProcess2); bool flag3 = flag && UnityInstanceSelectionDialog.IsExtraInformationPresent(unityProcess, unityProcess2); if (flag) { if (!flag2 && !flag3) { return; } this._processCache.Remove(unityProcess2); this._processes.Remove(unityProcess2); } this._processCache.Add(unityProcess); this._processes.Add(unityProcess); this.ProcessFilter.Items.Clear(); foreach (UnityProcess unityProcess3 in this._processes) { this.ProcessFilter.Items.Add(unityProcess3); } this.InstanceList.UpdateColumnWidths(); } public static bool RequiresEnhancement(UnityProcess unityProcess) { return UnityPlayerEnhancer.IsLocalPlayer(unityProcess) && ((unityProcess != null && unityProcess.IsProcessDetection && unityProcess.DebuggerEngine == DebuggerEngine.VSTU && unityProcess.DebuggerPort == 0) || (unityProcess != null && !unityProcess.IsProcessDetection && unityProcess.ProcessId == 0)); } private static bool TryEnhanceLocalDetectionPlayer(UnityProcess unityProcess, TcpListenerInfo[] listeners) { int num = UnityPlayerEnhancer.FindFirstPortInRange(unityProcess, listeners, 56000, 56999); int num2 = UnityPlayerEnhancer.FindFirstPortInRange(unityProcess, listeners, 55000, 55999); if (num2 == 0) { return false; // 如果没有Profiler监听端口,那么将无法被添加到列表中。 } unityProcess.DebuggerPort = num; unityProcess.ProfilerPort = num2; return true; } // Token: 0x0600009D RID: 157 RVA: 0x00003D98 File Offset: 0x00001F98 private static int FindFirstPortInRange(UnityProcess unityProcess, TcpListenerInfo[] listeners, int minPort, int maxPort) { return (from e in listeners where e.ProcessId == unityProcess.ProcessId && e.LocalPort >= minPort && e.LocalPort <= maxPort select e.LocalPort).FirstOrDefault<int>(); }
从这些代码我们可以看出,现在对于UnityPlayer来说,需要必须存在两个tcpip监听端口,一个是调试端口,范围56000到56999;另一个是Profiler端口,范围55000到55999。
using System; namespace SyntaxTree.VisualStudio.Unity.Messaging { // Token: 0x02000023 RID: 35 public class UnityPorts { // Token: 0x060000B5 RID: 181 RVA: 0x000047C1 File Offset: 0x000029C1 public static ushort GetDebuggerPort(long processIdOrGuid) { return (ushort)(56000L + processIdOrGuid % 1000L); } // Token: 0x0400006F RID: 111 public const int MinDebuggerPort = 56000; // Token: 0x04000070 RID: 112 public const int MaxDebuggerPort = 56999; // Token: 0x04000071 RID: 113 public const int MinProfilerPort = 55000; // Token: 0x04000072 RID: 114 public const int MaxProfilerPort = 55999; } }
现在问题明确了,解决方案也就明确了。一种办法是通过mono_profiler_load这个api初始化profiler。但是mono将这个代码提取到了一个独立的dll中,mono-profiler-log.dll等。在我的应用场景中,需要考虑支持dll可重入,而新的mono并不支持可重入。所以这个方案对我来说无效。当然如果你的应用场景中不考虑可重入,那么可以用这个方案。
我采用的另外一个方法,主动监听一个符合要求的端口,假装这个端口就是Unity调试插件需要的Profiler监听端口,在UnrealEngine5中的代码如下:
#include "MonoProfilerService.h" #if WITH_MONO #include "Networking.h" #include "Sockets.h" #include "SocketSubsystem.h" namespace UnrealSharp::Mono { FMonoProfilerService::FMonoProfilerService(int InPort) : DefaultPort(InPort) { FIPv4Address IPAddress; FIPv4Address::Parse(TEXT("127.0.0.1"), IPAddress); const FIPv4Endpoint Endpoint(IPAddress, DefaultPort); ListenerSocket = FTcpSocketBuilder(TEXT("TcpListener")) .AsReusable() .BoundToEndpoint(Endpoint) .Listening(8); } FMonoProfilerService::~FMonoProfilerService() { if(ListenerSocket) { ListenerSocket->Close(); ISocketSubsystem::Get(PLATFORM_SOCKETSUBSYSTEM)->DestroySocket(ListenerSocket); ListenerSocket = nullptr; } } }
有了这个支持之后,你伪装的UnityPlayer进程又回来了: