WindowBase.cs
1 using Ryujinx.Common.Configuration; 2 using Ryujinx.Common.Configuration.Hid; 3 using Ryujinx.Common.Logging; 4 using Ryujinx.Graphics.GAL; 5 using Ryujinx.Graphics.GAL.Multithreading; 6 using Ryujinx.Graphics.Gpu; 7 using Ryujinx.Graphics.OpenGL; 8 using Ryujinx.HLE.HOS.Applets; 9 using Ryujinx.HLE.HOS.Services.Am.AppletOE.ApplicationProxyService.ApplicationProxy.Types; 10 using Ryujinx.HLE.UI; 11 using Ryujinx.Input; 12 using Ryujinx.Input.HLE; 13 using Ryujinx.SDL2.Common; 14 using System; 15 using System.Collections.Concurrent; 16 using System.Collections.Generic; 17 using System.Diagnostics; 18 using System.IO; 19 using System.Runtime.InteropServices; 20 using System.Threading; 21 using static SDL2.SDL; 22 using AntiAliasing = Ryujinx.Common.Configuration.AntiAliasing; 23 using ScalingFilter = Ryujinx.Common.Configuration.ScalingFilter; 24 using Switch = Ryujinx.HLE.Switch; 25 26 namespace Ryujinx.Headless.SDL2 27 { 28 abstract partial class WindowBase : IHostUIHandler, IDisposable 29 { 30 protected const int DefaultWidth = 1280; 31 protected const int DefaultHeight = 720; 32 private const int TargetFps = 60; 33 private SDL_WindowFlags DefaultFlags = SDL_WindowFlags.SDL_WINDOW_ALLOW_HIGHDPI | SDL_WindowFlags.SDL_WINDOW_RESIZABLE | SDL_WindowFlags.SDL_WINDOW_INPUT_FOCUS | SDL_WindowFlags.SDL_WINDOW_SHOWN; 34 private SDL_WindowFlags FullscreenFlag = 0; 35 36 private static readonly ConcurrentQueue<Action> _mainThreadActions = new(); 37 38 [LibraryImport("SDL2")] 39 // TODO: Remove this as soon as SDL2-CS was updated to expose this method publicly 40 private static partial IntPtr SDL_LoadBMP_RW(IntPtr src, int freesrc); 41 42 public static void QueueMainThreadAction(Action action) 43 { 44 _mainThreadActions.Enqueue(action); 45 } 46 47 public NpadManager NpadManager { get; } 48 public TouchScreenManager TouchScreenManager { get; } 49 public Switch Device { get; private set; } 50 public IRenderer Renderer { get; private set; } 51 52 public event EventHandler<StatusUpdatedEventArgs> StatusUpdatedEvent; 53 54 protected IntPtr WindowHandle { get; set; } 55 56 public IHostUITheme HostUITheme { get; } 57 public int Width { get; private set; } 58 public int Height { get; private set; } 59 public int DisplayId { get; set; } 60 public bool IsFullscreen { get; set; } 61 public bool IsExclusiveFullscreen { get; set; } 62 public int ExclusiveFullscreenWidth { get; set; } 63 public int ExclusiveFullscreenHeight { get; set; } 64 public AntiAliasing AntiAliasing { get; set; } 65 public ScalingFilter ScalingFilter { get; set; } 66 public int ScalingFilterLevel { get; set; } 67 68 protected SDL2MouseDriver MouseDriver; 69 private readonly InputManager _inputManager; 70 private readonly IKeyboard _keyboardInterface; 71 private readonly GraphicsDebugLevel _glLogLevel; 72 private readonly Stopwatch _chrono; 73 private readonly long _ticksPerFrame; 74 private readonly CancellationTokenSource _gpuCancellationTokenSource; 75 private readonly ManualResetEvent _exitEvent; 76 private readonly ManualResetEvent _gpuDoneEvent; 77 78 private long _ticks; 79 private bool _isActive; 80 private bool _isStopped; 81 private uint _windowId; 82 83 private string _gpuDriverName; 84 85 private readonly AspectRatio _aspectRatio; 86 private readonly bool _enableMouse; 87 88 public WindowBase( 89 InputManager inputManager, 90 GraphicsDebugLevel glLogLevel, 91 AspectRatio aspectRatio, 92 bool enableMouse, 93 HideCursorMode hideCursorMode) 94 { 95 MouseDriver = new SDL2MouseDriver(hideCursorMode); 96 _inputManager = inputManager; 97 _inputManager.SetMouseDriver(MouseDriver); 98 NpadManager = _inputManager.CreateNpadManager(); 99 TouchScreenManager = _inputManager.CreateTouchScreenManager(); 100 _keyboardInterface = (IKeyboard)_inputManager.KeyboardDriver.GetGamepad("0"); 101 _glLogLevel = glLogLevel; 102 _chrono = new Stopwatch(); 103 _ticksPerFrame = Stopwatch.Frequency / TargetFps; 104 _gpuCancellationTokenSource = new CancellationTokenSource(); 105 _exitEvent = new ManualResetEvent(false); 106 _gpuDoneEvent = new ManualResetEvent(false); 107 _aspectRatio = aspectRatio; 108 _enableMouse = enableMouse; 109 HostUITheme = new HeadlessHostUiTheme(); 110 111 SDL2Driver.Instance.Initialize(); 112 } 113 114 public void Initialize(Switch device, List<InputConfig> inputConfigs, bool enableKeyboard, bool enableMouse) 115 { 116 Device = device; 117 118 IRenderer renderer = Device.Gpu.Renderer; 119 120 if (renderer is ThreadedRenderer tr) 121 { 122 renderer = tr.BaseRenderer; 123 } 124 125 Renderer = renderer; 126 127 NpadManager.Initialize(device, inputConfigs, enableKeyboard, enableMouse); 128 TouchScreenManager.Initialize(device); 129 } 130 131 private void SetWindowIcon() 132 { 133 Stream iconStream = typeof(WindowBase).Assembly.GetManifestResourceStream("Ryujinx.Headless.SDL2.Ryujinx.bmp"); 134 byte[] iconBytes = new byte[iconStream!.Length]; 135 136 if (iconStream.Read(iconBytes, 0, iconBytes.Length) != iconBytes.Length) 137 { 138 Logger.Error?.Print(LogClass.Application, "Failed to read icon to byte array."); 139 iconStream.Close(); 140 141 return; 142 } 143 144 iconStream.Close(); 145 146 unsafe 147 { 148 fixed (byte* iconPtr = iconBytes) 149 { 150 IntPtr rwOpsStruct = SDL_RWFromConstMem((IntPtr)iconPtr, iconBytes.Length); 151 IntPtr iconHandle = SDL_LoadBMP_RW(rwOpsStruct, 1); 152 153 SDL_SetWindowIcon(WindowHandle, iconHandle); 154 SDL_FreeSurface(iconHandle); 155 } 156 } 157 } 158 159 private void InitializeWindow() 160 { 161 var activeProcess = Device.Processes.ActiveApplication; 162 var nacp = activeProcess.ApplicationControlProperties; 163 int desiredLanguage = (int)Device.System.State.DesiredTitleLanguage; 164 165 string titleNameSection = string.IsNullOrWhiteSpace(nacp.Title[desiredLanguage].NameString.ToString()) ? string.Empty : $" - {nacp.Title[desiredLanguage].NameString.ToString()}"; 166 string titleVersionSection = string.IsNullOrWhiteSpace(nacp.DisplayVersionString.ToString()) ? string.Empty : $" v{nacp.DisplayVersionString.ToString()}"; 167 string titleIdSection = string.IsNullOrWhiteSpace(activeProcess.ProgramIdText) ? string.Empty : $" ({activeProcess.ProgramIdText.ToUpper()})"; 168 string titleArchSection = activeProcess.Is64Bit ? " (64-bit)" : " (32-bit)"; 169 170 Width = DefaultWidth; 171 Height = DefaultHeight; 172 173 if (IsExclusiveFullscreen) 174 { 175 Width = ExclusiveFullscreenWidth; 176 Height = ExclusiveFullscreenHeight; 177 178 DefaultFlags = SDL_WindowFlags.SDL_WINDOW_ALLOW_HIGHDPI; 179 FullscreenFlag = SDL_WindowFlags.SDL_WINDOW_FULLSCREEN; 180 } 181 else if (IsFullscreen) 182 { 183 DefaultFlags = SDL_WindowFlags.SDL_WINDOW_ALLOW_HIGHDPI; 184 FullscreenFlag = SDL_WindowFlags.SDL_WINDOW_FULLSCREEN_DESKTOP; 185 } 186 187 WindowHandle = SDL_CreateWindow($"Ryujinx {Program.Version}{titleNameSection}{titleVersionSection}{titleIdSection}{titleArchSection}", SDL_WINDOWPOS_CENTERED_DISPLAY(DisplayId), SDL_WINDOWPOS_CENTERED_DISPLAY(DisplayId), Width, Height, DefaultFlags | FullscreenFlag | GetWindowFlags()); 188 189 if (WindowHandle == IntPtr.Zero) 190 { 191 string errorMessage = $"SDL_CreateWindow failed with error \"{SDL_GetError()}\""; 192 193 Logger.Error?.Print(LogClass.Application, errorMessage); 194 195 throw new Exception(errorMessage); 196 } 197 198 SetWindowIcon(); 199 200 _windowId = SDL_GetWindowID(WindowHandle); 201 SDL2Driver.Instance.RegisterWindow(_windowId, HandleWindowEvent); 202 } 203 204 private void HandleWindowEvent(SDL_Event evnt) 205 { 206 if (evnt.type == SDL_EventType.SDL_WINDOWEVENT) 207 { 208 switch (evnt.window.windowEvent) 209 { 210 case SDL_WindowEventID.SDL_WINDOWEVENT_SIZE_CHANGED: 211 // Unlike on Windows, this event fires on macOS when triggering fullscreen mode. 212 // And promptly crashes the process because `Renderer?.window.SetSize` is undefined. 213 // As we don't need this to fire in either case we can test for fullscreen. 214 if (!IsFullscreen && !IsExclusiveFullscreen) 215 { 216 Width = evnt.window.data1; 217 Height = evnt.window.data2; 218 Renderer?.Window.SetSize(Width, Height); 219 MouseDriver.SetClientSize(Width, Height); 220 } 221 break; 222 223 case SDL_WindowEventID.SDL_WINDOWEVENT_CLOSE: 224 Exit(); 225 break; 226 } 227 } 228 else 229 { 230 MouseDriver.Update(evnt); 231 } 232 } 233 234 protected abstract void InitializeWindowRenderer(); 235 236 protected abstract void InitializeRenderer(); 237 238 protected abstract void FinalizeWindowRenderer(); 239 240 protected abstract void SwapBuffers(); 241 242 public abstract SDL_WindowFlags GetWindowFlags(); 243 244 private string GetGpuDriverName() 245 { 246 return Renderer.GetHardwareInfo().GpuDriver; 247 } 248 249 private void SetAntiAliasing() 250 { 251 Renderer?.Window.SetAntiAliasing((Graphics.GAL.AntiAliasing)AntiAliasing); 252 } 253 254 private void SetScalingFilter() 255 { 256 Renderer?.Window.SetScalingFilter((Graphics.GAL.ScalingFilter)ScalingFilter); 257 Renderer?.Window.SetScalingFilterLevel(ScalingFilterLevel); 258 } 259 260 public void Render() 261 { 262 InitializeWindowRenderer(); 263 264 Device.Gpu.Renderer.Initialize(_glLogLevel); 265 266 InitializeRenderer(); 267 268 SetAntiAliasing(); 269 270 SetScalingFilter(); 271 272 _gpuDriverName = GetGpuDriverName(); 273 274 Device.Gpu.Renderer.RunLoop(() => 275 { 276 Device.Gpu.SetGpuThread(); 277 Device.Gpu.InitializeShaderCache(_gpuCancellationTokenSource.Token); 278 279 while (_isActive) 280 { 281 if (_isStopped) 282 { 283 return; 284 } 285 286 _ticks += _chrono.ElapsedTicks; 287 288 _chrono.Restart(); 289 290 if (Device.WaitFifo()) 291 { 292 Device.Statistics.RecordFifoStart(); 293 Device.ProcessFrame(); 294 Device.Statistics.RecordFifoEnd(); 295 } 296 297 while (Device.ConsumeFrameAvailable()) 298 { 299 Device.PresentFrame(SwapBuffers); 300 } 301 302 if (_ticks >= _ticksPerFrame) 303 { 304 string dockedMode = Device.System.State.DockedMode ? "Docked" : "Handheld"; 305 float scale = GraphicsConfig.ResScale; 306 if (scale != 1) 307 { 308 dockedMode += $" ({scale}x)"; 309 } 310 311 StatusUpdatedEvent?.Invoke(this, new StatusUpdatedEventArgs( 312 Device.EnableDeviceVsync, 313 dockedMode, 314 Device.Configuration.AspectRatio.ToText(), 315 $"Game: {Device.Statistics.GetGameFrameRate():00.00} FPS ({Device.Statistics.GetGameFrameTime():00.00} ms)", 316 $"FIFO: {Device.Statistics.GetFifoPercent():0.00} %", 317 $"GPU: {_gpuDriverName}")); 318 319 _ticks = Math.Min(_ticks - _ticksPerFrame, _ticksPerFrame); 320 } 321 } 322 323 // Make sure all commands in the run loop are fully executed before leaving the loop. 324 if (Device.Gpu.Renderer is ThreadedRenderer threaded) 325 { 326 threaded.FlushThreadedCommands(); 327 } 328 329 _gpuDoneEvent.Set(); 330 }); 331 332 FinalizeWindowRenderer(); 333 } 334 335 public void Exit() 336 { 337 TouchScreenManager?.Dispose(); 338 NpadManager?.Dispose(); 339 340 if (_isStopped) 341 { 342 return; 343 } 344 345 _gpuCancellationTokenSource.Cancel(); 346 347 _isStopped = true; 348 _isActive = false; 349 350 _exitEvent.WaitOne(); 351 _exitEvent.Dispose(); 352 } 353 354 public static void ProcessMainThreadQueue() 355 { 356 while (_mainThreadActions.TryDequeue(out Action action)) 357 { 358 action(); 359 } 360 } 361 362 public void MainLoop() 363 { 364 while (_isActive) 365 { 366 UpdateFrame(); 367 368 SDL_PumpEvents(); 369 370 ProcessMainThreadQueue(); 371 372 // Polling becomes expensive if it's not slept 373 Thread.Sleep(1); 374 } 375 376 _exitEvent.Set(); 377 } 378 379 private void NvidiaStutterWorkaround() 380 { 381 while (_isActive) 382 { 383 // When NVIDIA Threaded Optimization is on, the driver will snapshot all threads in the system whenever the application creates any new ones. 384 // The ThreadPool has something called a "GateThread" which terminates itself after some inactivity. 385 // However, it immediately starts up again, since the rules regarding when to terminate and when to start differ. 386 // This creates a new thread every second or so. 387 // The main problem with this is that the thread snapshot can take 70ms, is on the OpenGL thread and will delay rendering any graphics. 388 // This is a little over budget on a frame time of 16ms, so creates a large stutter. 389 // The solution is to keep the ThreadPool active so that it never has a reason to terminate the GateThread. 390 391 // TODO: This should be removed when the issue with the GateThread is resolved. 392 393 ThreadPool.QueueUserWorkItem(state => { }); 394 Thread.Sleep(300); 395 } 396 } 397 398 private bool UpdateFrame() 399 { 400 if (!_isActive) 401 { 402 return true; 403 } 404 405 if (_isStopped) 406 { 407 return false; 408 } 409 410 NpadManager.Update(); 411 412 // Touchscreen 413 bool hasTouch = false; 414 415 // Get screen touch position 416 if (!_enableMouse) 417 { 418 hasTouch = TouchScreenManager.Update(true, (_inputManager.MouseDriver as SDL2MouseDriver).IsButtonPressed(MouseButton.Button1), _aspectRatio.ToFloat()); 419 } 420 421 if (!hasTouch) 422 { 423 TouchScreenManager.Update(false); 424 } 425 426 Device.Hid.DebugPad.Update(); 427 428 // TODO: Replace this with MouseDriver.CheckIdle() when mouse motion events are received on every supported platform. 429 MouseDriver.UpdatePosition(); 430 431 return true; 432 } 433 434 public void Execute() 435 { 436 _chrono.Restart(); 437 _isActive = true; 438 439 InitializeWindow(); 440 441 Thread renderLoopThread = new(Render) 442 { 443 Name = "GUI.RenderLoop", 444 }; 445 renderLoopThread.Start(); 446 447 Thread nvidiaStutterWorkaround = null; 448 if (Renderer is OpenGLRenderer) 449 { 450 nvidiaStutterWorkaround = new Thread(NvidiaStutterWorkaround) 451 { 452 Name = "GUI.NvidiaStutterWorkaround", 453 }; 454 nvidiaStutterWorkaround.Start(); 455 } 456 457 MainLoop(); 458 459 // NOTE: The render loop is allowed to stay alive until the renderer itself is disposed, as it may handle resource dispose. 460 // We only need to wait for all commands submitted during the main gpu loop to be processed. 461 _gpuDoneEvent.WaitOne(); 462 _gpuDoneEvent.Dispose(); 463 nvidiaStutterWorkaround?.Join(); 464 465 Exit(); 466 } 467 468 public bool DisplayInputDialog(SoftwareKeyboardUIArgs args, out string userText) 469 { 470 // SDL2 doesn't support input dialogs 471 userText = "Ryujinx"; 472 473 return true; 474 } 475 476 public bool DisplayMessageDialog(string title, string message) 477 { 478 SDL_ShowSimpleMessageBox(SDL_MessageBoxFlags.SDL_MESSAGEBOX_INFORMATION, title, message, WindowHandle); 479 480 return true; 481 } 482 483 public bool DisplayMessageDialog(ControllerAppletUIArgs args) 484 { 485 string playerCount = args.PlayerCountMin == args.PlayerCountMax ? $"exactly {args.PlayerCountMin}" : $"{args.PlayerCountMin}-{args.PlayerCountMax}"; 486 487 string message = $"Application requests {playerCount} player(s) with:\n\n" 488 + $"TYPES: {args.SupportedStyles}\n\n" 489 + $"PLAYERS: {string.Join(", ", args.SupportedPlayers)}\n\n" 490 + (args.IsDocked ? "Docked mode set. Handheld is also invalid.\n\n" : "") 491 + "Please reconfigure Input now and then press OK."; 492 493 return DisplayMessageDialog("Controller Applet", message); 494 } 495 496 public IDynamicTextInputHandler CreateDynamicTextInputHandler() 497 { 498 return new HeadlessDynamicTextInputHandler(); 499 } 500 501 public void ExecuteProgram(Switch device, ProgramSpecifyKind kind, ulong value) 502 { 503 device.Configuration.UserChannelPersistence.ExecuteProgram(kind, value); 504 505 Exit(); 506 } 507 508 public bool DisplayErrorAppletDialog(string title, string message, string[] buttonsText) 509 { 510 SDL_MessageBoxData data = new() 511 { 512 title = title, 513 message = message, 514 buttons = new SDL_MessageBoxButtonData[buttonsText.Length], 515 numbuttons = buttonsText.Length, 516 window = WindowHandle, 517 }; 518 519 for (int i = 0; i < buttonsText.Length; i++) 520 { 521 data.buttons[i] = new SDL_MessageBoxButtonData 522 { 523 buttonid = i, 524 text = buttonsText[i], 525 }; 526 } 527 528 SDL_ShowMessageBox(ref data, out int _); 529 530 return true; 531 } 532 533 public void Dispose() 534 { 535 Dispose(true); 536 } 537 538 protected virtual void Dispose(bool disposing) 539 { 540 if (disposing) 541 { 542 _isActive = false; 543 TouchScreenManager?.Dispose(); 544 NpadManager.Dispose(); 545 546 SDL2Driver.Instance.UnregisterWindow(_windowId); 547 548 SDL_DestroyWindow(WindowHandle); 549 550 SDL2Driver.Instance.Dispose(); 551 } 552 } 553 } 554 }