ModWatcher.vb 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391
  1. Public Module ModWatcher
  2. '对全体的监视
  3. Public McWatcherList As New List(Of Watcher)
  4. Private IsWatcherRunning As Boolean = False
  5. Public HasRunningMinecraft As Boolean = False
  6. Private Sub WatcherStateChanged()
  7. Dim IsRunning As Boolean = False
  8. Dim TriggerLauncherShutdown As Boolean = True
  9. For Each Watcher In McWatcherList
  10. If Watcher.State = Watcher.MinecraftState.Loading OrElse Watcher.State = Watcher.MinecraftState.Running OrElse
  11. (Watcher.State = Watcher.MinecraftState.Crashed AndAlso Not Watcher.GameProcess.HasExited) Then
  12. IsRunning = True
  13. Exit For
  14. ElseIf Watcher.State = Watcher.MinecraftState.Crashed OrElse Watcher.State = Watcher.MinecraftState.Canceled Then
  15. TriggerLauncherShutdown = False
  16. End If
  17. Next
  18. If IsWatcherRunning = IsRunning Then Return
  19. IsWatcherRunning = IsRunning
  20. If IsWatcherRunning Then
  21. MinecraftStart()
  22. Else
  23. MinecraftStop(TriggerLauncherShutdown)
  24. End If
  25. End Sub
  26. Private Sub MinecraftStart()
  27. McLaunchLog("[全局] 出现运行中的 Minecraft")
  28. HasRunningMinecraft = True
  29. FrmMain.BtnExtraShutdown.ShowRefresh()
  30. End Sub
  31. Private Sub MinecraftStop(TriggerLauncherShutdown As Boolean)
  32. McLaunchLog("[全局] 已无运行中的 Minecraft")
  33. HasRunningMinecraft = False
  34. FrmMain.BtnExtraShutdown.ShowRefresh()
  35. '音乐播放
  36. If Settings.Get(Of Boolean)("UiMusicStop") Then
  37. RunInUi(Sub() If MusicResume() Then Logger.Info("已根据设置,在结束后开始音乐播放"))
  38. ElseIf Settings.Get(Of Boolean)("UiMusicStart") Then
  39. RunInUi(Sub() If MusicPause() Then Logger.Info("已根据设置,在结束后暂停音乐播放"))
  40. End If
  41. '启动器可见性
  42. Select Case Settings.Get(Of Integer)("LaunchArgumentVisible")
  43. Case 2
  44. '直接关闭
  45. If TriggerLauncherShutdown Then
  46. RunInUi(Sub() FrmMain.EndProgram(False))
  47. Else
  48. RunInUi(Sub() FrmMain.Hidden = False)
  49. End If
  50. Case 3
  51. '恢复
  52. RunInUi(Sub() FrmMain.Hidden = False)
  53. End Select
  54. End Sub
  55. '对单个进程的监视
  56. Public Class Watcher
  57. '初始化
  58. Public GameProcess As Process
  59. Public Instance As McInstance
  60. Private WindowTitle As String = ""
  61. Private PID As Integer
  62. Public Loader As LoaderTask(Of Process, Integer)
  63. Public Sub New(Loader As LoaderTask(Of Process, Integer), Instance As McInstance, WindowTitle As String)
  64. Me.Loader = Loader
  65. Me.Instance = Instance
  66. Me.WindowTitle = WindowTitle
  67. Me.PID = Loader.Input.Id
  68. WatcherLog("开始 Minecraft 日志监控")
  69. If Me.WindowTitle <> "" Then WatcherLog("要求窗口标题:" & WindowTitle)
  70. '更改列表
  71. Dim NewWatcherList As New List(Of Watcher)
  72. For Each Watch In McWatcherList
  73. If Watch.State = MinecraftState.Crashed OrElse Watch.State = MinecraftState.Ended OrElse Watch.State = MinecraftState.Canceled Then Continue For
  74. NewWatcherList.Add(Watch)
  75. Next
  76. NewWatcherList.Add(Me)
  77. McWatcherList = NewWatcherList
  78. WatcherStateChanged()
  79. '初始化进程与日志读取
  80. GameProcess = Loader.Input
  81. GameProcess.BeginOutputReadLine()
  82. GameProcess.BeginErrorReadLine()
  83. AddHandler GameProcess.OutputDataReceived, AddressOf LogReceived
  84. AddHandler GameProcess.ErrorDataReceived, AddressOf LogReceived
  85. '初始化时钟
  86. RunInNewThread(
  87. Sub()
  88. Try
  89. Do Until State = MinecraftState.Ended OrElse State = MinecraftState.Canceled OrElse
  90. (Loader.State = LoadState.Canceled AndAlso State <> MinecraftState.Crashed) OrElse
  91. (State = MinecraftState.Crashed AndAlso GameProcess.HasExited)
  92. TimerWindow()
  93. TimerLog()
  94. '设置窗口标题
  95. For i = 1 To 3
  96. If IsWindowFinished AndAlso IsWindowAppeared AndAlso WindowTitle <> "" AndAlso State = MinecraftState.Running AndAlso Not GameProcess.HasExited Then
  97. Dim RealTitle As String = WindowTitle.Replace("{date}", Date.Now.ToString("yyyy'/'M'/'d")).Replace("{time}", Date.Now.ToString("HH':'mm':'ss"))
  98. SetWindowText(WindowHandle, RealTitle.ToCharArray)
  99. End If
  100. Thread.Sleep(64)
  101. Next
  102. Loop
  103. If State = MinecraftState.Crashed Then WatcherStateChanged()
  104. WatcherLog("Minecraft 日志监控已退出")
  105. Catch ex As Exception
  106. Logger.Error(ex, "Minecraft 日志监控主循环出错")
  107. State = MinecraftState.Ended
  108. End Try
  109. End Sub, "Minecraft Watcher PID " & PID)
  110. End Sub
  111. '状态
  112. Private _State As MinecraftState = MinecraftState.Loading
  113. Public Property State As MinecraftState
  114. Get
  115. Return _State
  116. End Get
  117. Set(value As MinecraftState)
  118. If _State = value Then Return
  119. _State = value
  120. WatcherStateChanged()
  121. End Set
  122. End Property
  123. Public Enum MinecraftState
  124. Loading
  125. Running
  126. Crashed
  127. Ended
  128. Canceled
  129. End Enum
  130. '日志
  131. Public WaitingLog As New List(Of String)(1000)
  132. Private ReadOnly WaitingLogLock As New Object
  133. Private Sub LogReceived(sender As Object, e As DataReceivedEventArgs)
  134. SyncLock WaitingLogLock
  135. WaitingLog.Add(e.Data)
  136. End SyncLock
  137. End Sub
  138. Private Sub TimerLog()
  139. Try
  140. '输出文本
  141. Dim Copyed As New List(Of String)
  142. SyncLock WaitingLogLock
  143. If Not WaitingLog.Any() Then Return
  144. Copyed = WaitingLog
  145. WaitingLog = New List(Of String)(1000)
  146. End SyncLock
  147. For Each Str As String In Copyed
  148. GameLog(Str)
  149. Next
  150. If State = MinecraftState.Loading Then ProgressUpdate()
  151. '游戏退出检查
  152. If GameProcess.HasExited Then
  153. WatcherLog("Minecraft 已退出,返回值:" & GameProcess.ExitCode)
  154. 'If Process.ExitCode = 1 Then
  155. ' '返回值为 1,考虑是任务管理器结束
  156. ' WatcherLog("Minecraft 返回值为 1,考虑为任务管理器结束") '并不,崩了照样是 1
  157. ' State = MinecraftState.Ended
  158. 'Else
  159. If State = MinecraftState.Loading Then
  160. '窗口未出现
  161. WatcherLog("Minecraft 尚未加载完成,可能已崩溃", LogLevel.Error)
  162. Crashed()
  163. ElseIf GameProcess.ExitCode <> 0 AndAlso State = MinecraftState.Running AndAlso Instance.ReleaseTime.Year >= 2012 Then
  164. '返回值不为 0 且未结束
  165. WatcherLog("Minecraft 返回值异常,可能已崩溃", LogLevel.Error)
  166. Crashed()
  167. ElseIf State <> MinecraftState.Crashed Then
  168. '正常关闭
  169. State = MinecraftState.Ended
  170. End If
  171. End If
  172. Catch ex As Exception
  173. Logger.Error(ex, "输出 Minecraft 日志失败")
  174. End Try
  175. End Sub
  176. Public LatestLog As New ConcurrentQueue(Of String)
  177. Private Sub GameLog(Text As String)
  178. '预处理
  179. If Text Is Nothing Then Return
  180. Text = Text.ReplaceLineEndings(vbCrLf)
  181. 'If Text.Contains("�����") Then Hint("检测到错误的日志编码:" & Text)
  182. '加入预存储
  183. LatestLog.Enqueue(Text)
  184. If LatestLog.Count >= 501 Then LatestLog.TryDequeue(Nothing)
  185. Logger.Trace(Text)
  186. '进度处理
  187. If LogProgress < 1 Then
  188. WatcherLog("日志 1/5:已出现日志输出")
  189. LogProgress = 1
  190. End If '可能第一句就是后面需要判断的 Log(重现:启动 1.15.2 原版)
  191. If LogProgress < 2 AndAlso Text.Contains("Setting user:") Then
  192. WatcherLog("日志 2/5:游戏用户已设置") '仅确保支持 Minecraft 1.7+
  193. LogProgress = 2
  194. ElseIf LogProgress < 3 AndAlso Text.ContainsIgnoreCase("lwjgl version") Then
  195. WatcherLog("日志 3/5:LWJGL 版本已确认")
  196. LogProgress = 3
  197. ElseIf LogProgress < 4 AndAlso (Text.Contains("OpenAL initialized") OrElse Text.Contains("Starting up SoundSystem")) Then
  198. WatcherLog("日志 4/5:OpenAL 已加载") '仅确保支持 Minecraft 1.7+
  199. LogProgress = 4
  200. ElseIf LogProgress < 5 AndAlso ((Text.Contains("Created") AndAlso Text.Contains("textures") AndAlso Text.Contains("-atlas")) OrElse Text.Contains("Found animation info")) Then
  201. WatcherLog("日志 5/5:材质已加载") '仅确保支持 Minecraft 1.7+
  202. LogProgress = 5
  203. End If
  204. '输出日志
  205. 'Log(Text)
  206. '关闭与崩溃检测
  207. If Not Text.Contains("[CHAT]") Then
  208. If Text.Contains("Someone is closing me!") OrElse Text.Contains("Restarting Minecraft with command") Then '#1258
  209. WatcherLog("识别为关闭的 Log:" & Text)
  210. State = MinecraftState.Ended
  211. ElseIf Text.Contains("Crash report saved to") OrElse Text.Contains("This crash report has been saved to:") Then
  212. ' Text.Contains("Minecraft ran into a problem! Report saved to:") Then
  213. 'Minecraft 崩溃,忽略 VanillaFix
  214. WatcherLog("识别为崩溃的 Log:" & Text, LogLevel.Error)
  215. Crashed()
  216. ElseIf Text.Contains("Could not save crash report to") Then
  217. 'Minecraft 崩溃,无法保存崩溃日志
  218. WatcherLog("识别为崩溃的 Log:" & Text, LogLevel.Error)
  219. Crashed()
  220. ElseIf Text.Contains("/ERROR]: Unable to launch") OrElse Text.Contains("An exception was thrown, the game will display an error screen and halt.") Then
  221. 'Forge 崩溃
  222. WatcherLog("识别为崩溃的 Log:" & Text, LogLevel.Error)
  223. Crashed()
  224. 'ElseIf Text.Contains("Shutdown failure!") Then
  225. ' 'Minecraft 强行崩溃,由于点 X 强行关闭也会触发这句话,所以不可用
  226. ' Crashed(Nothing)
  227. End If
  228. End If
  229. End Sub
  230. Private Sub WatcherLog(Text As String, Optional Level As LogLevel = LogLevel.Info)
  231. McLaunchLog("[" & PID & "] " & Text, Level)
  232. End Sub
  233. '进度更新
  234. Private LogProgress As Integer = 0
  235. Private Sub ProgressUpdate()
  236. Dim CurrentProgress As Double
  237. If IsWindowAppeared OrElse LogProgress = 5 Then
  238. CurrentProgress = 0.95
  239. WatcherLog("Minecraft 加载已完成")
  240. State = MinecraftState.Running
  241. Else
  242. CurrentProgress = Math.Min(LogProgress, 3) / 3 * 0.9
  243. End If
  244. Loader.Progress = CurrentProgress
  245. End Sub
  246. '窗口检查
  247. Private IsWindowAppeared As Boolean = False
  248. ''' <summary>
  249. ''' 窗口检查是否已经完成。这不一定代表着找到了窗口(如果没有找到,IsWindowAppeared 仍为 False)。
  250. ''' </summary>
  251. Private IsWindowFinished As Boolean = False
  252. Private WindowHandle As IntPtr
  253. Private Sub TimerWindow()
  254. Try
  255. If GameProcess.HasExited Then Return
  256. If IsWindowFinished Then Return
  257. '获取全部窗口,检查是否有新增的
  258. Dim MinecraftWindow As KeyValuePair(Of IntPtr, String)? = Nothing
  259. Try
  260. MinecraftWindow = TryGetMinecraftWindow()
  261. Catch ex As ComponentModel.Win32Exception
  262. '拒绝访问(#1062)
  263. Logger.Error(ex, "由于反作弊或安全软件拦截,PCL 无法操作游戏窗口", LogBehavior.Toast)
  264. IsWindowFinished = True
  265. End Try
  266. If MinecraftWindow Is Nothing Then Return
  267. Dim MinecraftWindowName = MinecraftWindow.Value.Value, MinecraftWindowHandle = MinecraftWindow.Value.Key
  268. '已找到窗口
  269. If Not MinecraftWindowName.StartsWithF("FML") AndAlso Not MinecraftWindowName.StartsWithF("Quilt Loader") Then
  270. '已找到 Minecraft 窗口
  271. WindowHandle = MinecraftWindowHandle
  272. WatcherLog($"Minecraft 窗口已加载:{MinecraftWindowName}({MinecraftWindowHandle.ToInt64})")
  273. IsWindowFinished = True
  274. '最大化
  275. If Settings.Get(Of Integer)("LaunchArgumentWindowType") = 4 Then
  276. RunInNewThread(
  277. Sub()
  278. Try
  279. '如果最大化导致屏幕渲染大小不对,那是 MC 的 Bug,不是我的 Bug
  280. '……虽然我很想这样说,但总有人反馈,算了
  281. Thread.Sleep(2000)
  282. ShowWindow(WindowHandle, 3)
  283. WatcherLog($"已最大化 Minecraft 窗口:{MinecraftWindowHandle.ToInt64}")
  284. Catch ex As Exception
  285. Logger.Warn(ex, "最大化 Minecraft 窗口时出现错误")
  286. End Try
  287. End Sub, "MinecraftWindowMaximize")
  288. End If
  289. ElseIf Not IsWindowAppeared Then
  290. '已找到 Mod Loader 窗口
  291. WatcherLog("Mod Loader 窗口已加载:" & MinecraftWindowName & "(" & MinecraftWindowHandle.ToInt64 & ")")
  292. End If
  293. IsWindowAppeared = True
  294. Catch ex As Exception
  295. Logger.Error(ex, "检查 Minecraft 窗口失败")
  296. End Try
  297. End Sub
  298. ''' <summary>
  299. ''' 获取可能是当前进程对应的 Minecraft 窗口的句柄和标题。
  300. ''' Nothing 代表未找到。
  301. ''' </summary>
  302. Private Function TryGetMinecraftWindow() As KeyValuePair(Of IntPtr, String)?
  303. Dim Result = Nothing
  304. EnumWindows(
  305. Sub(hwnd As IntPtr, lParam As Integer)
  306. If Result IsNot Nothing Then Return
  307. '检查类名
  308. Dim str As New StringBuilder(512)
  309. GetClassName(hwnd, str, str.Capacity)
  310. Dim ClassName As String = str.ToString
  311. If Not (ClassName = "GLFW30" OrElse ClassName = "SDL_app" OrElse ClassName = "LWJGL" OrElse ClassName = "SunAwtFrame") Then Return
  312. '获取窗口标题名
  313. str = New StringBuilder(512)
  314. GetWindowText(hwnd, str, str.Capacity)
  315. Dim WindowText As String = str.ToString
  316. '有的 Mod 可以修改窗口标题,所以不能检测是否为 Minecraft 打头,这并不准确
  317. '部分版本会搞个 GLFW message window,高版本还有个 Hidden Utility Window,都要反选
  318. If String.IsNullOrWhiteSpace(WindowText) OrElse WindowText = "SDL_app" OrElse WindowText = "PopupMessageWindow" OrElse
  319. WindowText.StartsWithF("GLFW") OrElse WindowText.EndsWithF("Hidden Utility Window") Then Return
  320. '获取窗口关联的进程
  321. Dim ProcessId As Integer
  322. GetWindowThreadProcessId(hwnd, ProcessId)
  323. Try
  324. If Process.GetProcessById(ProcessId).StartTime < GameProcess.StartTime Then Return '需要是此后启动的进程
  325. Catch ex As Exception
  326. Logger.Warn(ex, "枚举 Minecraft 窗口进程失败")
  327. Return
  328. End Try
  329. '返回
  330. Result = New KeyValuePair(Of IntPtr, String)(hwnd, WindowText)
  331. End Sub, 0)
  332. Return Result
  333. End Function
  334. Private Delegate Sub EnumWindowsSub(hwnd As IntPtr, lParam As Integer)
  335. Private Declare Function EnumWindows Lib "user32" (hWnd As EnumWindowsSub, lParam As Integer) As Boolean
  336. Private Declare Function GetClassName Lib "user32" Alias "GetClassNameA" (hWnd As Integer, str As StringBuilder, maxCount As Integer) As Integer
  337. Private Declare Function GetWindowText Lib "user32" Alias "GetWindowTextA" (hWnd As Integer, str As StringBuilder, maxCount As Integer) As Integer
  338. Private Declare Function SetWindowText Lib "user32" Alias "SetWindowTextA" (hWnd As Integer, str As String) As Boolean
  339. Private Declare Function ShowWindow Lib "user32" (hWnd As IntPtr, cmdWindow As UInteger) As Boolean
  340. Private Declare Function GetWindowThreadProcessId Lib "user32" (hWnd As IntPtr, ByRef lpdwProcessId As Integer) As Integer
  341. '崩溃处理
  342. Private Sub Crashed()
  343. If State = MinecraftState.Crashed OrElse State = MinecraftState.Ended Then Return
  344. State = MinecraftState.Crashed
  345. '崩溃分析
  346. WatcherLog("Minecraft 已崩溃,将在 2 秒后开始崩溃分析", LogLevel.Error)
  347. Hint("检测到 Minecraft 出现错误,错误分析已开始……", HintType.Red)
  348. FeedbackInfo()
  349. RunInNewThread(
  350. Sub()
  351. Try
  352. Thread.Sleep(2000)
  353. WatcherLog("崩溃分析开始")
  354. Dim Analyzer As New CrashAnalyzer(Instance)
  355. Analyzer.Collect(Instance.PathIndie, LatestLog.ToList)
  356. Analyzer.Prepare()
  357. Analyzer.Analyze()
  358. Analyzer.Output(False, New List(Of String) From
  359. {Instance.PathVersion & Instance.Name & ".json", Paths.Base & "PCL\Log1.txt", Paths.Base & "PCL\LatestLaunch.bat"})
  360. Catch ex As Exception
  361. Logger.Error(ex, "崩溃分析失败")
  362. End Try
  363. End Sub, "Crash Analyzer")
  364. End Sub
  365. '强制关闭
  366. Public Sub Kill()
  367. State = MinecraftState.Canceled
  368. WatcherLog("尝试强制结束 Minecraft 进程")
  369. Try
  370. If Not GameProcess.HasExited Then GameProcess.Kill()
  371. WatcherLog("已强制结束 Minecraft 进程")
  372. Catch ex As Exception
  373. Logger.Error(ex, "强制结束 Minecraft 进程失败", LogBehavior.Toast)
  374. End Try
  375. End Sub
  376. End Class
  377. End Module