diff --git a/.github/workflows/canary.yml b/.github/workflows/canary.yml index 0fde764d5..d102beaa3 100644 --- a/.github/workflows/canary.yml +++ b/.github/workflows/canary.yml @@ -93,7 +93,8 @@ jobs: sed -r --in-place 's/\%\%RYUJINX_BUILD_GIT_HASH\%\%/${{ steps.version_info.outputs.git_short_hash }}/g;' src/Ryujinx.Common/ReleaseInformation.cs sed -r --in-place 's/\%\%RYUJINX_TARGET_RELEASE_CHANNEL_NAME\%\%/${{ env.RYUJINX_TARGET_RELEASE_CHANNEL_NAME }}/g;' src/Ryujinx.Common/ReleaseInformation.cs sed -r --in-place 's/\%\%RYUJINX_TARGET_RELEASE_CHANNEL_OWNER\%\%/${{ env.RYUJINX_TARGET_RELEASE_CHANNEL_OWNER }}/g;' src/Ryujinx.Common/ReleaseInformation.cs - sed -r --in-place 's/\%\%RYUJINX_TARGET_RELEASE_CHANNEL_REPO\%\%/${{ env.RYUJINX_TARGET_RELEASE_CHANNEL_SOURCE_REPO }}/g;' src/Ryujinx.Common/ReleaseInformation.cs + sed -r --in-place 's/\%\%RYUJINX_TARGET_RELEASE_CHANNEL_REPO\%\%/${{ env.RYUJINX_TARGET_RELEASE_CHANNEL_REPO }}/g;' src/Ryujinx.Common/ReleaseInformation.cs + sed -r --in-place 's/\%\%RYUJINX_TARGET_RELEASE_CHANNEL_SOURCE_REPO\%\%/${{ env.RYUJINX_TARGET_RELEASE_CHANNEL_SOURCE_REPO }}/g;' src/Ryujinx.Common/ReleaseInformation.cs sed -r --in-place 's/\%\%RYUJINX_CONFIG_FILE_NAME\%\%/Config\.json/g;' src/Ryujinx.Common/ReleaseInformation.cs shell: bash @@ -228,7 +229,8 @@ jobs: sed -r --in-place 's/\%\%RYUJINX_BUILD_GIT_HASH\%\%/${{ steps.version_info.outputs.git_short_hash }}/g;' src/Ryujinx.Common/ReleaseInformation.cs sed -r --in-place 's/\%\%RYUJINX_TARGET_RELEASE_CHANNEL_NAME\%\%/${{ env.RYUJINX_TARGET_RELEASE_CHANNEL_NAME }}/g;' src/Ryujinx.Common/ReleaseInformation.cs sed -r --in-place 's/\%\%RYUJINX_TARGET_RELEASE_CHANNEL_OWNER\%\%/${{ env.RYUJINX_TARGET_RELEASE_CHANNEL_OWNER }}/g;' src/Ryujinx.Common/ReleaseInformation.cs - sed -r --in-place 's/\%\%RYUJINX_TARGET_RELEASE_CHANNEL_REPO\%\%/${{ env.RYUJINX_TARGET_RELEASE_CHANNEL_SOURCE_REPO }}/g;' src/Ryujinx.Common/ReleaseInformation.cs + sed -r --in-place 's/\%\%RYUJINX_TARGET_RELEASE_CHANNEL_REPO\%\%/${{ env.RYUJINX_TARGET_RELEASE_CHANNEL_REPO }}/g;' src/Ryujinx.Common/ReleaseInformation.cs + sed -r --in-place 's/\%\%RYUJINX_TARGET_RELEASE_CHANNEL_SOURCE_REPO\%\%/${{ env.RYUJINX_TARGET_RELEASE_CHANNEL_SOURCE_REPO }}/g;' src/Ryujinx.Common/ReleaseInformation.cs sed -r --in-place 's/\%\%RYUJINX_CONFIG_FILE_NAME\%\%/Config\.json/g;' src/Ryujinx.Common/ReleaseInformation.cs shell: bash diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7a78718be..183d4c618 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -93,6 +93,7 @@ jobs: sed -r --in-place 's/\%\%RYUJINX_TARGET_RELEASE_CHANNEL_NAME\%\%/${{ env.RYUJINX_TARGET_RELEASE_CHANNEL_NAME }}/g;' src/Ryujinx.Common/ReleaseInformation.cs sed -r --in-place 's/\%\%RYUJINX_TARGET_RELEASE_CHANNEL_OWNER\%\%/${{ env.RYUJINX_TARGET_RELEASE_CHANNEL_OWNER }}/g;' src/Ryujinx.Common/ReleaseInformation.cs sed -r --in-place 's/\%\%RYUJINX_TARGET_RELEASE_CHANNEL_REPO\%\%/${{ env.RYUJINX_TARGET_RELEASE_CHANNEL_REPO }}/g;' src/Ryujinx.Common/ReleaseInformation.cs + sed -r --in-place 's/\%\%RYUJINX_TARGET_RELEASE_CHANNEL_SOURCE_REPO\%\%/${{ env.RYUJINX_TARGET_RELEASE_CHANNEL_REPO }}/g;' src/Ryujinx.Common/ReleaseInformation.cs sed -r --in-place 's/\%\%RYUJINX_CONFIG_FILE_NAME\%\%/Config\.json/g;' src/Ryujinx.Common/ReleaseInformation.cs shell: bash @@ -224,6 +225,7 @@ jobs: sed -r --in-place 's/\%\%RYUJINX_TARGET_RELEASE_CHANNEL_NAME\%\%/${{ env.RYUJINX_TARGET_RELEASE_CHANNEL_NAME }}/g;' src/Ryujinx.Common/ReleaseInformation.cs sed -r --in-place 's/\%\%RYUJINX_TARGET_RELEASE_CHANNEL_OWNER\%\%/${{ env.RYUJINX_TARGET_RELEASE_CHANNEL_OWNER }}/g;' src/Ryujinx.Common/ReleaseInformation.cs sed -r --in-place 's/\%\%RYUJINX_TARGET_RELEASE_CHANNEL_REPO\%\%/${{ env.RYUJINX_TARGET_RELEASE_CHANNEL_REPO }}/g;' src/Ryujinx.Common/ReleaseInformation.cs + sed -r --in-place 's/\%\%RYUJINX_TARGET_RELEASE_CHANNEL_SOURCE_REPO\%\%/${{ env.RYUJINX_TARGET_RELEASE_CHANNEL_REPO }}/g;' src/Ryujinx.Common/ReleaseInformation.cs sed -r --in-place 's/\%\%RYUJINX_CONFIG_FILE_NAME\%\%/Config\.json/g;' src/Ryujinx.Common/ReleaseInformation.cs shell: bash diff --git a/Directory.Packages.props b/Directory.Packages.props index fff045062..c0ace079d 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -33,6 +33,7 @@ + diff --git a/src/Ryujinx.Common/Configuration/Multiplayer/MultiplayerMode.cs b/src/Ryujinx.Common/Configuration/Multiplayer/MultiplayerMode.cs index 69f7d876d..be0e1518c 100644 --- a/src/Ryujinx.Common/Configuration/Multiplayer/MultiplayerMode.cs +++ b/src/Ryujinx.Common/Configuration/Multiplayer/MultiplayerMode.cs @@ -3,6 +3,7 @@ namespace Ryujinx.Common.Configuration.Multiplayer public enum MultiplayerMode { Disabled, + LdnRyu, LdnMitm, } } diff --git a/src/Ryujinx.Common/Memory/StructArrayHelpers.cs b/src/Ryujinx.Common/Memory/StructArrayHelpers.cs index 762c73889..fcb2229a7 100644 --- a/src/Ryujinx.Common/Memory/StructArrayHelpers.cs +++ b/src/Ryujinx.Common/Memory/StructArrayHelpers.cs @@ -803,18 +803,6 @@ namespace Ryujinx.Common.Memory public Span AsSpan() => MemoryMarshal.CreateSpan(ref _e0, Length); } - public struct Array256 : IArray where T : unmanaged - { - T _e0; - Array128 _other; - Array127 _other2; - public readonly int Length => 256; - public ref T this[int index] => ref AsSpan()[index]; - - [Pure] - public Span AsSpan() => MemoryMarshal.CreateSpan(ref _e0, Length); - } - public struct Array140 : IArray where T : unmanaged { T _e0; @@ -828,6 +816,18 @@ namespace Ryujinx.Common.Memory public Span AsSpan() => MemoryMarshal.CreateSpan(ref _e0, Length); } + public struct Array256 : IArray where T : unmanaged + { + T _e0; + Array128 _other; + Array127 _other2; + public readonly int Length => 256; + public ref T this[int index] => ref AsSpan()[index]; + + [Pure] + public Span AsSpan() => MemoryMarshal.CreateSpan(ref _e0, Length); + } + public struct Array384 : IArray where T : unmanaged { T _e0; diff --git a/src/Ryujinx.Common/ReleaseInformation.cs b/src/Ryujinx.Common/ReleaseInformation.cs index 523479d82..f4c62155a 100644 --- a/src/Ryujinx.Common/ReleaseInformation.cs +++ b/src/Ryujinx.Common/ReleaseInformation.cs @@ -15,6 +15,7 @@ namespace Ryujinx.Common private const string ConfigFileName = "%%RYUJINX_CONFIG_FILE_NAME%%"; public const string ReleaseChannelOwner = "%%RYUJINX_TARGET_RELEASE_CHANNEL_OWNER%%"; + public const string ReleaseChannelSourceRepo = "%%RYUJINX_TARGET_RELEASE_CHANNEL_SOURCE_REPO%%"; public const string ReleaseChannelRepo = "%%RYUJINX_TARGET_RELEASE_CHANNEL_REPO%%"; public static string ConfigName => !ConfigFileName.StartsWith("%%") ? ConfigFileName : "Config.json"; @@ -23,6 +24,7 @@ namespace Ryujinx.Common !BuildGitHash.StartsWith("%%") && !ReleaseChannelName.StartsWith("%%") && !ReleaseChannelOwner.StartsWith("%%") && + !ReleaseChannelSourceRepo.StartsWith("%%") && !ReleaseChannelRepo.StartsWith("%%") && !ConfigFileName.StartsWith("%%"); diff --git a/src/Ryujinx.Common/Utilities/NetworkHelpers.cs b/src/Ryujinx.Common/Utilities/NetworkHelpers.cs index 71e02184e..53d1e4f33 100644 --- a/src/Ryujinx.Common/Utilities/NetworkHelpers.cs +++ b/src/Ryujinx.Common/Utilities/NetworkHelpers.cs @@ -1,6 +1,7 @@ using System.Buffers.Binary; using System.Net; using System.Net.NetworkInformation; +using System.Runtime.InteropServices; namespace Ryujinx.Common.Utilities { @@ -65,6 +66,11 @@ namespace Ryujinx.Common.Utilities return (targetProperties, targetAddressInfo); } + public static bool SupportsDynamicDns() + { + return RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + } + public static uint ConvertIpv4Address(IPAddress ipAddress) { return BinaryPrimitives.ReadUInt32BigEndian(ipAddress.GetAddressBytes()); diff --git a/src/Ryujinx.Graphics.GAL/IRenderer.cs b/src/Ryujinx.Graphics.GAL/IRenderer.cs index 9b5e2cc42..c2fdcbe4b 100644 --- a/src/Ryujinx.Graphics.GAL/IRenderer.cs +++ b/src/Ryujinx.Graphics.GAL/IRenderer.cs @@ -13,7 +13,7 @@ namespace Ryujinx.Graphics.GAL IPipeline Pipeline { get; } IWindow Window { get; } - + uint ProgramCount { get; } void BackgroundContextAction(Action action, bool alwaysBackground = false); diff --git a/src/Ryujinx.Graphics.OpenGL/OpenGLRenderer.cs b/src/Ryujinx.Graphics.OpenGL/OpenGLRenderer.cs index 2deee045c..6ead314fd 100644 --- a/src/Ryujinx.Graphics.OpenGL/OpenGLRenderer.cs +++ b/src/Ryujinx.Graphics.OpenGL/OpenGLRenderer.cs @@ -97,7 +97,7 @@ namespace Ryujinx.Graphics.OpenGL public IProgram CreateProgram(ShaderSource[] shaders, ShaderInfo info) { ProgramCount++; - + return new Program(shaders, info.FragmentOutputMap); } diff --git a/src/Ryujinx.Graphics.Vulkan/VertexBufferState.cs b/src/Ryujinx.Graphics.Vulkan/VertexBufferState.cs index 6f27bb68b..ce1293589 100644 --- a/src/Ryujinx.Graphics.Vulkan/VertexBufferState.cs +++ b/src/Ryujinx.Graphics.Vulkan/VertexBufferState.cs @@ -55,8 +55,10 @@ namespace Ryujinx.Graphics.Vulkan if (_handle != BufferHandle.Null) { // May need to restride the vertex buffer. - - if (gd.NeedsVertexBufferAlignment(AttributeScalarAlignment, out int alignment) && (_stride % alignment) != 0) + // + // Fix divide by zero when recovering from missed draw (Oct. 16 2024) + // (fixes crash in 'Baldo: The Guardian Owls' opening cutscene) + if (gd.NeedsVertexBufferAlignment(AttributeScalarAlignment, out int alignment) && alignment != 0 && (_stride % alignment) != 0) { autoBuffer = gd.BufferManager.GetAlignedVertexBuffer(cbs, _handle, _offset, _size, _stride, alignment); diff --git a/src/Ryujinx.Graphics.Vulkan/VulkanRenderer.cs b/src/Ryujinx.Graphics.Vulkan/VulkanRenderer.cs index 8d324957a..cc2bc36c2 100644 --- a/src/Ryujinx.Graphics.Vulkan/VulkanRenderer.cs +++ b/src/Ryujinx.Graphics.Vulkan/VulkanRenderer.cs @@ -549,7 +549,7 @@ namespace Ryujinx.Graphics.Vulkan public IProgram CreateProgram(ShaderSource[] sources, ShaderInfo info) { ProgramCount++; - + bool isCompute = sources.Length == 1 && sources[0].Stage == ShaderStage.Compute; if (info.State.HasValue || isCompute) diff --git a/src/Ryujinx.HLE/HLEConfiguration.cs b/src/Ryujinx.HLE/HLEConfiguration.cs index 955fee4b5..70fcf278d 100644 --- a/src/Ryujinx.HLE/HLEConfiguration.cs +++ b/src/Ryujinx.HLE/HLEConfiguration.cs @@ -164,6 +164,21 @@ namespace Ryujinx.HLE /// public MultiplayerMode MultiplayerMode { internal get; set; } + /// + /// Disable P2P mode + /// + public bool MultiplayerDisableP2p { internal get; set; } + + /// + /// Multiplayer Passphrase + /// + public string MultiplayerLdnPassphrase { internal get; set; } + + /// + /// LDN Server + /// + public string MultiplayerLdnServer { internal get; set; } + /// /// An action called when HLE force a refresh of output after docked mode changed. /// @@ -194,7 +209,10 @@ namespace Ryujinx.HLE float audioVolume, bool useHypervisor, string multiplayerLanInterfaceId, - MultiplayerMode multiplayerMode) + MultiplayerMode multiplayerMode, + bool multiplayerDisableP2p, + string multiplayerLdnPassphrase, + string multiplayerLdnServer) { VirtualFileSystem = virtualFileSystem; LibHacHorizonManager = libHacHorizonManager; @@ -222,6 +240,9 @@ namespace Ryujinx.HLE UseHypervisor = useHypervisor; MultiplayerLanInterfaceId = multiplayerLanInterfaceId; MultiplayerMode = multiplayerMode; + MultiplayerDisableP2p = multiplayerDisableP2p; + MultiplayerLdnPassphrase = multiplayerLdnPassphrase; + MultiplayerLdnServer = multiplayerLdnServer; } } } diff --git a/src/Ryujinx.HLE/HOS/Services/Am/AppletAE/IAllSystemAppletProxiesService.cs b/src/Ryujinx.HLE/HOS/Services/Am/AppletAE/IAllSystemAppletProxiesService.cs index 0a032562a..b8741b22b 100644 --- a/src/Ryujinx.HLE/HOS/Services/Am/AppletAE/IAllSystemAppletProxiesService.cs +++ b/src/Ryujinx.HLE/HOS/Services/Am/AppletAE/IAllSystemAppletProxiesService.cs @@ -1,4 +1,5 @@ using Ryujinx.HLE.HOS.Services.Am.AppletAE.AllSystemAppletProxiesService; +using Ryujinx.HLE.HOS.Services.Am.AppletOE.ApplicationProxyService; namespace Ryujinx.HLE.HOS.Services.Am.AppletAE { @@ -25,5 +26,14 @@ namespace Ryujinx.HLE.HOS.Services.Am.AppletAE return ResultCode.Success; } + + [CommandCmif(350)] + // OpenSystemApplicationProxy(u64, pid, handle) -> object + public ResultCode OpenSystemApplicationProxy(ServiceCtx context) + { + MakeObject(context, new IApplicationProxy(context.Request.HandleDesc.PId)); + + return ResultCode.Success; + } } } diff --git a/src/Ryujinx.HLE/HOS/Services/Ldn/Types/NetworkConfig.cs b/src/Ryujinx.HLE/HOS/Services/Ldn/Types/NetworkConfig.cs index 4da5fe42b..c6d6ac944 100644 --- a/src/Ryujinx.HLE/HOS/Services/Ldn/Types/NetworkConfig.cs +++ b/src/Ryujinx.HLE/HOS/Services/Ldn/Types/NetworkConfig.cs @@ -3,7 +3,7 @@ using System.Runtime.InteropServices; namespace Ryujinx.HLE.HOS.Services.Ldn.Types { - [StructLayout(LayoutKind.Sequential, Size = 0x20)] + [StructLayout(LayoutKind.Sequential, Size = 0x20, Pack = 8)] struct NetworkConfig { public IntentId IntentId; diff --git a/src/Ryujinx.HLE/HOS/Services/Ldn/Types/ScanFilter.cs b/src/Ryujinx.HLE/HOS/Services/Ldn/Types/ScanFilter.cs index 449c923cc..f3ab1edd5 100644 --- a/src/Ryujinx.HLE/HOS/Services/Ldn/Types/ScanFilter.cs +++ b/src/Ryujinx.HLE/HOS/Services/Ldn/Types/ScanFilter.cs @@ -3,7 +3,7 @@ using System.Runtime.InteropServices; namespace Ryujinx.HLE.HOS.Services.Ldn.Types { - [StructLayout(LayoutKind.Sequential, Size = 0x60)] + [StructLayout(LayoutKind.Sequential, Size = 0x60, Pack = 8)] struct ScanFilter { public NetworkId NetworkId; diff --git a/src/Ryujinx.HLE/HOS/Services/Ldn/Types/SecurityConfig.cs b/src/Ryujinx.HLE/HOS/Services/Ldn/Types/SecurityConfig.cs index 5939a1394..f3968aab4 100644 --- a/src/Ryujinx.HLE/HOS/Services/Ldn/Types/SecurityConfig.cs +++ b/src/Ryujinx.HLE/HOS/Services/Ldn/Types/SecurityConfig.cs @@ -3,7 +3,7 @@ using System.Runtime.InteropServices; namespace Ryujinx.HLE.HOS.Services.Ldn.Types { - [StructLayout(LayoutKind.Sequential, Size = 0x44)] + [StructLayout(LayoutKind.Sequential, Size = 0x44, Pack = 2)] struct SecurityConfig { public SecurityMode SecurityMode; diff --git a/src/Ryujinx.HLE/HOS/Services/Ldn/Types/SecurityParameter.cs b/src/Ryujinx.HLE/HOS/Services/Ldn/Types/SecurityParameter.cs index dbcaa9eeb..e564a2ec9 100644 --- a/src/Ryujinx.HLE/HOS/Services/Ldn/Types/SecurityParameter.cs +++ b/src/Ryujinx.HLE/HOS/Services/Ldn/Types/SecurityParameter.cs @@ -3,7 +3,7 @@ using System.Runtime.InteropServices; namespace Ryujinx.HLE.HOS.Services.Ldn.Types { - [StructLayout(LayoutKind.Sequential, Size = 0x20)] + [StructLayout(LayoutKind.Sequential, Size = 0x20, Pack = 1)] struct SecurityParameter { public Array16 Data; diff --git a/src/Ryujinx.HLE/HOS/Services/Ldn/Types/UserConfig.cs b/src/Ryujinx.HLE/HOS/Services/Ldn/Types/UserConfig.cs index 3820f936e..7246f6f80 100644 --- a/src/Ryujinx.HLE/HOS/Services/Ldn/Types/UserConfig.cs +++ b/src/Ryujinx.HLE/HOS/Services/Ldn/Types/UserConfig.cs @@ -3,7 +3,7 @@ using System.Runtime.InteropServices; namespace Ryujinx.HLE.HOS.Services.Ldn.Types { - [StructLayout(LayoutKind.Sequential, Size = 0x30)] + [StructLayout(LayoutKind.Sequential, Size = 0x30, Pack = 1)] struct UserConfig { public Array33 UserName; diff --git a/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/AccessPoint.cs b/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/AccessPoint.cs index 78ebcac82..bd00a3139 100644 --- a/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/AccessPoint.cs +++ b/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/AccessPoint.cs @@ -15,6 +15,8 @@ namespace Ryujinx.HLE.HOS.Services.Ldn.UserServiceCreator public Array8 LatestUpdates = new(); public bool Connected { get; private set; } + public ProxyConfig Config => _parent.NetworkClient.Config; + public AccessPoint(IUserLocalCommunicationService parent) { _parent = parent; @@ -24,9 +26,12 @@ namespace Ryujinx.HLE.HOS.Services.Ldn.UserServiceCreator public void Dispose() { - _parent.NetworkClient.DisconnectNetwork(); + if (_parent?.NetworkClient != null) + { + _parent.NetworkClient.DisconnectNetwork(); - _parent.NetworkClient.NetworkChange -= NetworkChanged; + _parent.NetworkClient.NetworkChange -= NetworkChanged; + } } private void NetworkChanged(object sender, NetworkChangeEventArgs e) diff --git a/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/INetworkClient.cs b/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/INetworkClient.cs index 7ad6de51d..028ab6cfc 100644 --- a/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/INetworkClient.cs +++ b/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/INetworkClient.cs @@ -6,6 +6,7 @@ namespace Ryujinx.HLE.HOS.Services.Ldn.UserServiceCreator { interface INetworkClient : IDisposable { + ProxyConfig Config { get; } bool NeedsRealId { get; } event EventHandler NetworkChange; diff --git a/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/IUserLocalCommunicationService.cs b/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/IUserLocalCommunicationService.cs index 1d4b5485e..9f65aed4b 100644 --- a/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/IUserLocalCommunicationService.cs +++ b/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/IUserLocalCommunicationService.cs @@ -9,6 +9,8 @@ using Ryujinx.HLE.HOS.Ipc; using Ryujinx.HLE.HOS.Kernel.Threading; using Ryujinx.HLE.HOS.Services.Ldn.Types; using Ryujinx.HLE.HOS.Services.Ldn.UserServiceCreator.LdnMitm; +using Ryujinx.HLE.HOS.Services.Ldn.UserServiceCreator.LdnRyu; +using Ryujinx.HLE.HOS.Services.Ldn.UserServiceCreator.Types; using Ryujinx.Horizon.Common; using Ryujinx.Memory; using System; @@ -21,6 +23,9 @@ namespace Ryujinx.HLE.HOS.Services.Ldn.UserServiceCreator { class IUserLocalCommunicationService : IpcService, IDisposable { + public static string DefaultLanPlayHost = "ryuldn.vudjun.com"; + public static short LanPlayPort = 30456; + public INetworkClient NetworkClient { get; private set; } private const int NifmRequestID = 90; @@ -175,19 +180,37 @@ namespace Ryujinx.HLE.HOS.Services.Ldn.UserServiceCreator if (_state == NetworkState.AccessPointCreated || _state == NetworkState.StationConnected) { - (_, UnicastIPAddressInformation unicastAddress) = NetworkHelpers.GetLocalInterface(context.Device.Configuration.MultiplayerLanInterfaceId); - - if (unicastAddress == null) + ProxyConfig config = _state switch { - context.ResponseData.Write(NetworkHelpers.ConvertIpv4Address(DefaultIPAddress)); - context.ResponseData.Write(NetworkHelpers.ConvertIpv4Address(DefaultSubnetMask)); + NetworkState.AccessPointCreated => _accessPoint.Config, + NetworkState.StationConnected => _station.Config, + + _ => default + }; + + if (config.ProxyIp == 0) + { + (_, UnicastIPAddressInformation unicastAddress) = NetworkHelpers.GetLocalInterface(context.Device.Configuration.MultiplayerLanInterfaceId); + + if (unicastAddress == null) + { + context.ResponseData.Write(NetworkHelpers.ConvertIpv4Address(DefaultIPAddress)); + context.ResponseData.Write(NetworkHelpers.ConvertIpv4Address(DefaultSubnetMask)); + } + else + { + Logger.Info?.Print(LogClass.ServiceLdn, $"Console's LDN IP is \"{unicastAddress.Address}\"."); + + context.ResponseData.Write(NetworkHelpers.ConvertIpv4Address(unicastAddress.Address)); + context.ResponseData.Write(NetworkHelpers.ConvertIpv4Address(unicastAddress.IPv4Mask)); + } } else { - Logger.Info?.Print(LogClass.ServiceLdn, $"Console's LDN IP is \"{unicastAddress.Address}\"."); + Logger.Info?.Print(LogClass.ServiceLdn, $"LDN obtained proxy IP."); - context.ResponseData.Write(NetworkHelpers.ConvertIpv4Address(unicastAddress.Address)); - context.ResponseData.Write(NetworkHelpers.ConvertIpv4Address(unicastAddress.IPv4Mask)); + context.ResponseData.Write(config.ProxyIp); + context.ResponseData.Write(config.ProxySubnetMask); } } else @@ -1066,6 +1089,27 @@ namespace Ryujinx.HLE.HOS.Services.Ldn.UserServiceCreator switch (mode) { + case MultiplayerMode.LdnRyu: + try + { + string ldnServer = context.Device.Configuration.MultiplayerLdnServer; + if (string.IsNullOrEmpty(ldnServer)) + { + ldnServer = DefaultLanPlayHost; + } + if (!IPAddress.TryParse(ldnServer, out IPAddress ipAddress)) + { + ipAddress = Dns.GetHostEntry(ldnServer).AddressList[0]; + } + NetworkClient = new LdnMasterProxyClient(ipAddress.ToString(), LanPlayPort, context.Device.Configuration); + } + catch (Exception ex) + { + Logger.Error?.Print(LogClass.ServiceLdn, "Could not locate LdnRyu server. Defaulting to stubbed wireless."); + Logger.Error?.Print(LogClass.ServiceLdn, ex.Message); + NetworkClient = new LdnDisabledClient(); + } + break; case MultiplayerMode.LdnMitm: NetworkClient = new LdnMitmClient(context.Device.Configuration); break; @@ -1103,7 +1147,7 @@ namespace Ryujinx.HLE.HOS.Services.Ldn.UserServiceCreator _accessPoint?.Dispose(); _accessPoint = null; - NetworkClient?.Dispose(); + NetworkClient?.DisconnectAndStop(); NetworkClient = null; } } diff --git a/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnDisabledClient.cs b/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnDisabledClient.cs index e3385a1ed..2e8bb8d83 100644 --- a/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnDisabledClient.cs +++ b/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnDisabledClient.cs @@ -1,3 +1,4 @@ +using Ryujinx.Common.Logging; using Ryujinx.HLE.HOS.Services.Ldn.Types; using Ryujinx.HLE.HOS.Services.Ldn.UserServiceCreator.Types; using System; @@ -6,12 +7,14 @@ namespace Ryujinx.HLE.HOS.Services.Ldn.UserServiceCreator { class LdnDisabledClient : INetworkClient { + public ProxyConfig Config { get; } public bool NeedsRealId => true; public event EventHandler NetworkChange; public NetworkError Connect(ConnectRequest request) { + Logger.Warning?.PrintMsg(LogClass.ServiceLdn, "Attempted to connect to a network, but Multiplayer is disabled!"); NetworkChange?.Invoke(this, new NetworkChangeEventArgs(new NetworkInfo(), false)); return NetworkError.None; @@ -19,6 +22,7 @@ namespace Ryujinx.HLE.HOS.Services.Ldn.UserServiceCreator public NetworkError ConnectPrivate(ConnectPrivateRequest request) { + Logger.Warning?.PrintMsg(LogClass.ServiceLdn, "Attempted to connect to a network, but Multiplayer is disabled!"); NetworkChange?.Invoke(this, new NetworkChangeEventArgs(new NetworkInfo(), false)); return NetworkError.None; @@ -26,6 +30,7 @@ namespace Ryujinx.HLE.HOS.Services.Ldn.UserServiceCreator public bool CreateNetwork(CreateAccessPointRequest request, byte[] advertiseData) { + Logger.Warning?.PrintMsg(LogClass.ServiceLdn, "Attempted to create a network, but Multiplayer is disabled!"); NetworkChange?.Invoke(this, new NetworkChangeEventArgs(new NetworkInfo(), false)); return true; @@ -33,6 +38,7 @@ namespace Ryujinx.HLE.HOS.Services.Ldn.UserServiceCreator public bool CreateNetworkPrivate(CreateAccessPointPrivateRequest request, byte[] advertiseData) { + Logger.Warning?.PrintMsg(LogClass.ServiceLdn, "Attempted to create a network, but Multiplayer is disabled!"); NetworkChange?.Invoke(this, new NetworkChangeEventArgs(new NetworkInfo(), false)); return true; @@ -49,6 +55,7 @@ namespace Ryujinx.HLE.HOS.Services.Ldn.UserServiceCreator public NetworkInfo[] Scan(ushort channel, ScanFilter scanFilter) { + Logger.Warning?.PrintMsg(LogClass.ServiceLdn, "Attempted to scan for networks, but Multiplayer is disabled!"); return Array.Empty(); } diff --git a/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnMitm/LdnMitmClient.cs b/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnMitm/LdnMitmClient.cs index 273acdd5e..40697d122 100644 --- a/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnMitm/LdnMitmClient.cs +++ b/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnMitm/LdnMitmClient.cs @@ -12,6 +12,7 @@ namespace Ryujinx.HLE.HOS.Services.Ldn.UserServiceCreator.LdnMitm /// internal class LdnMitmClient : INetworkClient { + public ProxyConfig Config { get; } public bool NeedsRealId => false; public event EventHandler NetworkChange; diff --git a/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnRyu/IProxyClient.cs b/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnRyu/IProxyClient.cs new file mode 100644 index 000000000..a7c435506 --- /dev/null +++ b/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnRyu/IProxyClient.cs @@ -0,0 +1,7 @@ +namespace Ryujinx.HLE.HOS.Services.Ldn.UserServiceCreator.LdnRyu +{ + interface IProxyClient + { + bool SendAsync(byte[] buffer); + } +} diff --git a/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnRyu/LdnMasterProxyClient.cs b/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnRyu/LdnMasterProxyClient.cs new file mode 100644 index 000000000..4c7814b8e --- /dev/null +++ b/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnRyu/LdnMasterProxyClient.cs @@ -0,0 +1,645 @@ +using Ryujinx.Common.Logging; +using Ryujinx.Common.Utilities; +using Ryujinx.HLE.HOS.Services.Ldn.Types; +using Ryujinx.HLE.HOS.Services.Ldn.UserServiceCreator.LdnRyu.Proxy; +using Ryujinx.HLE.HOS.Services.Ldn.UserServiceCreator.LdnRyu.Types; +using Ryujinx.HLE.HOS.Services.Ldn.UserServiceCreator.Types; +using Ryujinx.HLE.HOS.Services.Sockets.Bsd.Proxy; +using Ryujinx.HLE.Utilities; +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.NetworkInformation; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using TcpClient = NetCoreServer.TcpClient; + +namespace Ryujinx.HLE.HOS.Services.Ldn.UserServiceCreator.LdnRyu +{ + class LdnMasterProxyClient : TcpClient, INetworkClient, IProxyClient + { + public bool NeedsRealId => true; + + private static InitializeMessage InitializeMemory = new InitializeMessage(); + + private const int InactiveTimeout = 6000; + private const int FailureTimeout = 4000; + private const int ScanTimeout = 1000; + + private bool _useP2pProxy; + private NetworkError _lastError; + + private readonly ManualResetEvent _connected = new ManualResetEvent(false); + private readonly ManualResetEvent _error = new ManualResetEvent(false); + private readonly ManualResetEvent _scan = new ManualResetEvent(false); + private readonly ManualResetEvent _reject = new ManualResetEvent(false); + private readonly AutoResetEvent _apConnected = new AutoResetEvent(false); + + private readonly RyuLdnProtocol _protocol; + private readonly NetworkTimeout _timeout; + + private readonly List _availableGames = new List(); + private DisconnectReason _disconnectReason; + + private P2pProxyServer _hostedProxy; + private P2pProxyClient _connectedProxy; + + private bool _networkConnected; + + private string _passphrase; + private byte[] _gameVersion = new byte[0x10]; + + private readonly HLEConfiguration _config; + + public event EventHandler NetworkChange; + + public ProxyConfig Config { get; private set; } + + public LdnMasterProxyClient(string address, int port, HLEConfiguration config) : base(address, port) + { + if (ProxyHelpers.SupportsNoDelay()) + { + OptionNoDelay = true; + } + + _protocol = new RyuLdnProtocol(); + _timeout = new NetworkTimeout(InactiveTimeout, TimeoutConnection); + + _protocol.Initialize += HandleInitialize; + _protocol.Connected += HandleConnected; + _protocol.Reject += HandleReject; + _protocol.RejectReply += HandleRejectReply; + _protocol.SyncNetwork += HandleSyncNetwork; + _protocol.ProxyConfig += HandleProxyConfig; + _protocol.Disconnected += HandleDisconnected; + + _protocol.ScanReply += HandleScanReply; + _protocol.ScanReplyEnd += HandleScanReplyEnd; + _protocol.ExternalProxy += HandleExternalProxy; + + _protocol.Ping += HandlePing; + _protocol.NetworkError += HandleNetworkError; + + _config = config; + _useP2pProxy = !config.MultiplayerDisableP2p; + } + + private void TimeoutConnection() + { + _connected.Reset(); + + DisconnectAsync(); + + while (IsConnected) + { + Thread.Yield(); + } + } + + private bool EnsureConnected() + { + if (IsConnected) + { + return true; + } + + _error.Reset(); + + ConnectAsync(); + + int index = WaitHandle.WaitAny(new WaitHandle[] { _connected, _error }, FailureTimeout); + + if (IsConnected) + { + SendAsync(_protocol.Encode(PacketId.Initialize, InitializeMemory)); + } + + return index == 0 && IsConnected; + } + + private void UpdatePassphraseIfNeeded() + { + string passphrase = _config.MultiplayerLdnPassphrase ?? ""; + if (passphrase != _passphrase) + { + _passphrase = passphrase; + + SendAsync(_protocol.Encode(PacketId.Passphrase, StringUtils.GetFixedLengthBytes(passphrase, 0x80, Encoding.UTF8))); + } + } + + protected override void OnConnected() + { + Logger.Info?.PrintMsg(LogClass.ServiceLdn, $"LDN TCP client connected a new session with Id {Id}"); + + UpdatePassphraseIfNeeded(); + + _connected.Set(); + } + + protected override void OnDisconnected() + { + Logger.Info?.PrintMsg(LogClass.ServiceLdn, $"LDN TCP client disconnected a session with Id {Id}"); + + _passphrase = null; + + _connected.Reset(); + + if (_networkConnected) + { + DisconnectInternal(); + } + } + + public void DisconnectAndStop() + { + _timeout.Dispose(); + + DisconnectAsync(); + + while (IsConnected) + { + Thread.Yield(); + } + + Dispose(); + } + + protected override void OnReceived(byte[] buffer, long offset, long size) + { + _protocol.Read(buffer, (int)offset, (int)size); + } + + protected override void OnError(SocketError error) + { + Logger.Info?.PrintMsg(LogClass.ServiceLdn, $"LDN TCP client caught an error with code {error}"); + + _error.Set(); + } + + + + private void HandleInitialize(LdnHeader header, InitializeMessage initialize) + { + InitializeMemory = initialize; + } + + private void HandleExternalProxy(LdnHeader header, ExternalProxyConfig config) + { + int length = config.AddressFamily switch + { + AddressFamily.InterNetwork => 4, + AddressFamily.InterNetworkV6 => 16, + _ => 0 + }; + + if (length == 0) + { + return; // Invalid external proxy. + } + + IPAddress address = new(config.ProxyIp.AsSpan()[..length].ToArray()); + P2pProxyClient proxy = new(address.ToString(), config.ProxyPort); + + _connectedProxy = proxy; + + bool success = proxy.PerformAuth(config); + + if (!success) + { + DisconnectInternal(); + } + } + + private void HandlePing(LdnHeader header, PingMessage ping) + { + if (ping.Requester == 0) // Server requested. + { + // Send the ping message back. + + SendAsync(_protocol.Encode(PacketId.Ping, ping)); + } + } + + private void HandleNetworkError(LdnHeader header, NetworkErrorMessage error) + { + if (error.Error == NetworkError.PortUnreachable) + { + _useP2pProxy = false; + } + else + { + _lastError = error.Error; + } + } + + private NetworkError ConsumeNetworkError() + { + NetworkError result = _lastError; + + _lastError = NetworkError.None; + + return result; + } + + private void HandleSyncNetwork(LdnHeader header, NetworkInfo info) + { + NetworkChange?.Invoke(this, new NetworkChangeEventArgs(info, true)); + } + + private void HandleConnected(LdnHeader header, NetworkInfo info) + { + _networkConnected = true; + _disconnectReason = DisconnectReason.None; + + _apConnected.Set(); + + NetworkChange?.Invoke(this, new NetworkChangeEventArgs(info, true)); + } + + private void HandleDisconnected(LdnHeader header, DisconnectMessage message) + { + DisconnectInternal(); + } + + private void HandleReject(LdnHeader header, RejectRequest reject) + { + // When the client receives a Reject request, we have been rejected and will be disconnected shortly. + _disconnectReason = reject.DisconnectReason; + } + + private void HandleRejectReply(LdnHeader header) + { + _reject.Set(); + } + + private void HandleScanReply(LdnHeader header, NetworkInfo info) + { + _availableGames.Add(info); + } + + private void HandleScanReplyEnd(LdnHeader obj) + { + _scan.Set(); + } + + private void DisconnectInternal() + { + if (_networkConnected) + { + _networkConnected = false; + + _hostedProxy?.Dispose(); + _hostedProxy = null; + + _connectedProxy?.Dispose(); + _connectedProxy = null; + + _apConnected.Reset(); + + NetworkChange?.Invoke(this, new NetworkChangeEventArgs(new NetworkInfo(), false, _disconnectReason)); + + if (IsConnected) + { + _timeout.RefreshTimeout(); + } + } + } + + public void DisconnectNetwork() + { + if (_networkConnected) + { + SendAsync(_protocol.Encode(PacketId.Disconnect, new DisconnectMessage())); + + DisconnectInternal(); + } + } + + public ResultCode Reject(DisconnectReason disconnectReason, uint nodeId) + { + if (_networkConnected) + { + _reject.Reset(); + + SendAsync(_protocol.Encode(PacketId.Reject, new RejectRequest(disconnectReason, nodeId))); + + int index = WaitHandle.WaitAny(new WaitHandle[] { _reject, _error }, InactiveTimeout); + + if (index == 0) + { + return (ConsumeNetworkError() != NetworkError.None) ? ResultCode.InvalidState : ResultCode.Success; + } + } + + return ResultCode.InvalidState; + } + + public void SetAdvertiseData(byte[] data) + { + // TODO: validate we're the owner (the server will do this anyways tho) + if (_networkConnected) + { + SendAsync(_protocol.Encode(PacketId.SetAdvertiseData, data)); + } + } + + public void SetGameVersion(byte[] versionString) + { + _gameVersion = versionString; + + if (_gameVersion.Length < 0x10) + { + Array.Resize(ref _gameVersion, 0x10); + } + } + + public void SetStationAcceptPolicy(AcceptPolicy acceptPolicy) + { + // TODO: validate we're the owner (the server will do this anyways tho) + if (_networkConnected) + { + SendAsync(_protocol.Encode(PacketId.SetAcceptPolicy, new SetAcceptPolicyRequest + { + StationAcceptPolicy = acceptPolicy + })); + } + } + + private void DisposeProxy() + { + _hostedProxy?.Dispose(); + _hostedProxy = null; + } + + private void ConfigureAccessPoint(ref RyuNetworkConfig request) + { + _gameVersion.AsSpan().CopyTo(request.GameVersion.AsSpan()); + + if (_useP2pProxy) + { + // Before sending the request, attempt to set up a proxy server. + // This can be on a range of private ports, which can be exposed on a range of public + // ports via UPnP. If any of this fails, we just fall back to using the master server. + + int i = 0; + for (; i < P2pProxyServer.PrivatePortRange; i++) + { + _hostedProxy = new P2pProxyServer(this, (ushort)(P2pProxyServer.PrivatePortBase + i), _protocol); + + try + { + _hostedProxy.Start(); + + break; + } + catch (SocketException e) + { + _hostedProxy.Dispose(); + _hostedProxy = null; + + if (e.SocketErrorCode != SocketError.AddressAlreadyInUse) + { + i = P2pProxyServer.PrivatePortRange; // Immediately fail. + } + } + } + + bool openSuccess = i < P2pProxyServer.PrivatePortRange; + + if (openSuccess) + { + Task natPunchResult = _hostedProxy.NatPunch(); + + try + { + if (natPunchResult.Result != 0) + { + // Tell the server that we are hosting the proxy. + request.ExternalProxyPort = natPunchResult.Result; + } + } + catch (Exception) { } + + if (request.ExternalProxyPort == 0) + { + Logger.Warning?.Print(LogClass.ServiceLdn, "Failed to open a port with UPnP for P2P connection. Proxying through the master server instead. Expect higher latency."); + _hostedProxy.Dispose(); + } + else + { + Logger.Info?.Print(LogClass.ServiceLdn, $"Created a wireless P2P network on port {request.ExternalProxyPort}."); + _hostedProxy.Start(); + + (_, UnicastIPAddressInformation unicastAddress) = NetworkHelpers.GetLocalInterface(); + + unicastAddress.Address.GetAddressBytes().AsSpan().CopyTo(request.PrivateIp.AsSpan()); + request.InternalProxyPort = _hostedProxy.PrivatePort; + request.AddressFamily = unicastAddress.Address.AddressFamily; + } + } + else + { + Logger.Warning?.Print(LogClass.ServiceLdn, "Cannot create a P2P server. Proxying through the master server instead. Expect higher latency."); + } + } + } + + private bool CreateNetworkCommon() + { + bool signalled = _apConnected.WaitOne(FailureTimeout); + + if (!_useP2pProxy && _hostedProxy != null) + { + Logger.Warning?.Print(LogClass.ServiceLdn, "Locally hosted proxy server was not externally reachable. Proxying through the master server instead. Expect higher latency."); + + DisposeProxy(); + } + + if (signalled && _connectedProxy != null) + { + _connectedProxy.EnsureProxyReady(); + + Config = _connectedProxy.ProxyConfig; + } + else + { + DisposeProxy(); + } + + return signalled; + } + + public bool CreateNetwork(CreateAccessPointRequest request, byte[] advertiseData) + { + _timeout.DisableTimeout(); + + ConfigureAccessPoint(ref request.RyuNetworkConfig); + + if (!EnsureConnected()) + { + DisposeProxy(); + + return false; + } + + UpdatePassphraseIfNeeded(); + + SendAsync(_protocol.Encode(PacketId.CreateAccessPoint, request, advertiseData)); + + // Send a network change event with dummy data immediately. Necessary to avoid crashes in some games + var networkChangeEvent = new NetworkChangeEventArgs(new NetworkInfo() + { + Common = new CommonNetworkInfo() + { + MacAddress = InitializeMemory.MacAddress, + Channel = request.NetworkConfig.Channel, + LinkLevel = 3, + NetworkType = 2, + Ssid = new Ssid() + { + Length = 32 + } + }, + Ldn = new LdnNetworkInfo() + { + AdvertiseDataSize = (ushort)advertiseData.Length, + AuthenticationId = 0, + NodeCount = 1, + NodeCountMax = request.NetworkConfig.NodeCountMax, + SecurityMode = (ushort)request.SecurityConfig.SecurityMode + } + }, true); + networkChangeEvent.Info.Ldn.Nodes[0] = new NodeInfo() + { + Ipv4Address = 175243265, + IsConnected = 1, + LocalCommunicationVersion = request.NetworkConfig.LocalCommunicationVersion, + MacAddress = InitializeMemory.MacAddress, + NodeId = 0, + UserName = request.UserConfig.UserName + }; + "12345678123456781234567812345678"u8.ToArray().CopyTo(networkChangeEvent.Info.Common.Ssid.Name.AsSpan()); + NetworkChange?.Invoke(this, networkChangeEvent); + + return CreateNetworkCommon(); + } + + public bool CreateNetworkPrivate(CreateAccessPointPrivateRequest request, byte[] advertiseData) + { + _timeout.DisableTimeout(); + + ConfigureAccessPoint(ref request.RyuNetworkConfig); + + if (!EnsureConnected()) + { + DisposeProxy(); + + return false; + } + + UpdatePassphraseIfNeeded(); + + SendAsync(_protocol.Encode(PacketId.CreateAccessPointPrivate, request, advertiseData)); + + return CreateNetworkCommon(); + } + + public NetworkInfo[] Scan(ushort channel, ScanFilter scanFilter) + { + if (!_networkConnected) + { + _timeout.RefreshTimeout(); + } + + _availableGames.Clear(); + + int index = -1; + + if (EnsureConnected()) + { + UpdatePassphraseIfNeeded(); + + _scan.Reset(); + + SendAsync(_protocol.Encode(PacketId.Scan, scanFilter)); + + index = WaitHandle.WaitAny(new WaitHandle[] { _scan, _error }, ScanTimeout); + } + + if (index != 0) + { + // An error occurred or timeout. Write 0 games. + return Array.Empty(); + } + + return _availableGames.ToArray(); + } + + private NetworkError ConnectCommon() + { + bool signalled = _apConnected.WaitOne(FailureTimeout); + + NetworkError error = ConsumeNetworkError(); + + if (error != NetworkError.None) + { + return error; + } + + if (signalled && _connectedProxy != null) + { + _connectedProxy.EnsureProxyReady(); + + Config = _connectedProxy.ProxyConfig; + } + + return signalled ? NetworkError.None : NetworkError.ConnectTimeout; + } + + public NetworkError Connect(ConnectRequest request) + { + _timeout.DisableTimeout(); + + if (!EnsureConnected()) + { + return NetworkError.Unknown; + } + + SendAsync(_protocol.Encode(PacketId.Connect, request)); + + var networkChangeEvent = new NetworkChangeEventArgs(new NetworkInfo() + { + Common = request.NetworkInfo.Common, + Ldn = request.NetworkInfo.Ldn + }, true); + + NetworkChange?.Invoke(this, networkChangeEvent); + + return ConnectCommon(); + } + + public NetworkError ConnectPrivate(ConnectPrivateRequest request) + { + _timeout.DisableTimeout(); + + if (!EnsureConnected()) + { + return NetworkError.Unknown; + } + + SendAsync(_protocol.Encode(PacketId.ConnectPrivate, request)); + + return ConnectCommon(); + } + + private void HandleProxyConfig(LdnHeader header, ProxyConfig config) + { + Config = config; + + SocketHelpers.RegisterProxy(new LdnProxy(config, this, _protocol)); + } + } +} diff --git a/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnRyu/NetworkTimeout.cs b/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnRyu/NetworkTimeout.cs new file mode 100644 index 000000000..5012d5d81 --- /dev/null +++ b/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnRyu/NetworkTimeout.cs @@ -0,0 +1,83 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Ryujinx.HLE.HOS.Services.Ldn.UserServiceCreator.LdnRyu +{ + class NetworkTimeout : IDisposable + { + private readonly int _idleTimeout; + private readonly Action _timeoutCallback; + private CancellationTokenSource _cancel; + + private readonly object _lock = new object(); + + public NetworkTimeout(int idleTimeout, Action timeoutCallback) + { + _idleTimeout = idleTimeout; + _timeoutCallback = timeoutCallback; + } + + private async Task TimeoutTask() + { + CancellationTokenSource cts; + + lock (_lock) + { + cts = _cancel; + } + + if (cts == null) + { + return; + } + + try + { + await Task.Delay(_idleTimeout, cts.Token); + } + catch (TaskCanceledException) + { + return; // Timeout cancelled. + } + + lock (_lock) + { + // Run the timeout callback. If the cancel token source has been replaced, we have _just_ been cancelled. + if (cts == _cancel) + { + _timeoutCallback(); + } + } + } + + public bool RefreshTimeout() + { + lock (_lock) + { + _cancel?.Cancel(); + + _cancel = new CancellationTokenSource(); + + Task.Run(TimeoutTask); + } + + return true; + } + + public void DisableTimeout() + { + lock (_lock) + { + _cancel?.Cancel(); + + _cancel = new CancellationTokenSource(); + } + } + + public void Dispose() + { + DisableTimeout(); + } + } +} diff --git a/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnRyu/Proxy/EphemeralPortPool.cs b/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnRyu/Proxy/EphemeralPortPool.cs new file mode 100644 index 000000000..bc3a5edf2 --- /dev/null +++ b/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnRyu/Proxy/EphemeralPortPool.cs @@ -0,0 +1,53 @@ +using System.Collections.Generic; + +namespace Ryujinx.HLE.HOS.Services.Ldn.UserServiceCreator.LdnRyu.Proxy +{ + public class EphemeralPortPool + { + private const ushort EphemeralBase = 49152; + + private readonly List _ephemeralPorts = new List(); + + private readonly object _lock = new object(); + + public ushort Get() + { + ushort port = EphemeralBase; + lock (_lock) + { + // Starting at the ephemeral port base, return an ephemeral port that is not in use. + // Returns 0 if the range is exhausted. + + for (int i = 0; i < _ephemeralPorts.Count; i++) + { + ushort existingPort = _ephemeralPorts[i]; + + if (existingPort > port) + { + // The port was free - take it. + _ephemeralPorts.Insert(i, port); + + return port; + } + + port++; + } + + if (port != 0) + { + _ephemeralPorts.Add(port); + } + + return port; + } + } + + public void Return(ushort port) + { + lock (_lock) + { + _ephemeralPorts.Remove(port); + } + } + } +} diff --git a/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnRyu/Proxy/LdnProxy.cs b/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnRyu/Proxy/LdnProxy.cs new file mode 100644 index 000000000..bb390d49a --- /dev/null +++ b/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnRyu/Proxy/LdnProxy.cs @@ -0,0 +1,254 @@ +using Ryujinx.Common.Logging; +using Ryujinx.HLE.HOS.Services.Ldn.UserServiceCreator.LdnRyu.Types; +using Ryujinx.HLE.HOS.Services.Ldn.UserServiceCreator.Types; +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Sockets; + +namespace Ryujinx.HLE.HOS.Services.Ldn.UserServiceCreator.LdnRyu.Proxy +{ + class LdnProxy : IDisposable + { + public EndPoint LocalEndpoint { get; } + public IPAddress LocalAddress { get; } + + private readonly List _sockets = new List(); + private readonly Dictionary _ephemeralPorts = new Dictionary(); + + private readonly IProxyClient _parent; + private RyuLdnProtocol _protocol; + private readonly uint _subnetMask; + private readonly uint _localIp; + private readonly uint _broadcast; + + public LdnProxy(ProxyConfig config, IProxyClient client, RyuLdnProtocol protocol) + { + _parent = client; + _protocol = protocol; + + _ephemeralPorts[ProtocolType.Udp] = new EphemeralPortPool(); + _ephemeralPorts[ProtocolType.Tcp] = new EphemeralPortPool(); + + byte[] address = BitConverter.GetBytes(config.ProxyIp); + Array.Reverse(address); + LocalAddress = new IPAddress(address); + + _subnetMask = config.ProxySubnetMask; + _localIp = config.ProxyIp; + _broadcast = _localIp | (~_subnetMask); + + RegisterHandlers(protocol); + } + + public bool Supported(AddressFamily domain, SocketType type, ProtocolType protocol) + { + if (protocol == ProtocolType.Tcp) + { + Logger.Error?.PrintMsg(LogClass.ServiceLdn, "Tcp proxy networking is untested. Please report this game so that it can be tested."); + } + return domain == AddressFamily.InterNetwork && (protocol == ProtocolType.Tcp || protocol == ProtocolType.Udp); + } + + private void RegisterHandlers(RyuLdnProtocol protocol) + { + protocol.ProxyConnect += HandleConnectionRequest; + protocol.ProxyConnectReply += HandleConnectionResponse; + protocol.ProxyData += HandleData; + protocol.ProxyDisconnect += HandleDisconnect; + + _protocol = protocol; + } + + public void UnregisterHandlers(RyuLdnProtocol protocol) + { + protocol.ProxyConnect -= HandleConnectionRequest; + protocol.ProxyConnectReply -= HandleConnectionResponse; + protocol.ProxyData -= HandleData; + protocol.ProxyDisconnect -= HandleDisconnect; + } + + public ushort GetEphemeralPort(ProtocolType type) + { + return _ephemeralPorts[type].Get(); + } + + public void ReturnEphemeralPort(ProtocolType type, ushort port) + { + _ephemeralPorts[type].Return(port); + } + + public void RegisterSocket(LdnProxySocket socket) + { + lock (_sockets) + { + _sockets.Add(socket); + } + } + + public void UnregisterSocket(LdnProxySocket socket) + { + lock (_sockets) + { + _sockets.Remove(socket); + } + } + + private void ForRoutedSockets(ProxyInfo info, Action action) + { + lock (_sockets) + { + foreach (LdnProxySocket socket in _sockets) + { + // Must match protocol and destination port. + if (socket.ProtocolType != info.Protocol || socket.LocalEndPoint is not IPEndPoint endpoint || endpoint.Port != info.DestPort) + { + continue; + } + + // We can assume packets routed to us have been sent to our destination. + // They will either be sent to us, or broadcast packets. + + action(socket); + } + } + } + + public void HandleConnectionRequest(LdnHeader header, ProxyConnectRequest request) + { + ForRoutedSockets(request.Info, (socket) => + { + socket.HandleConnectRequest(request); + }); + } + + public void HandleConnectionResponse(LdnHeader header, ProxyConnectResponse response) + { + ForRoutedSockets(response.Info, (socket) => + { + socket.HandleConnectResponse(response); + }); + } + + public void HandleData(LdnHeader header, ProxyDataHeader proxyHeader, byte[] data) + { + ProxyDataPacket packet = new ProxyDataPacket() { Header = proxyHeader, Data = data }; + + ForRoutedSockets(proxyHeader.Info, (socket) => + { + socket.IncomingData(packet); + }); + } + + public void HandleDisconnect(LdnHeader header, ProxyDisconnectMessage disconnect) + { + ForRoutedSockets(disconnect.Info, (socket) => + { + socket.HandleDisconnect(disconnect); + }); + } + + private uint GetIpV4(IPEndPoint endpoint) + { + if (endpoint.AddressFamily != AddressFamily.InterNetwork) + { + throw new NotSupportedException(); + } + + byte[] address = endpoint.Address.GetAddressBytes(); + Array.Reverse(address); + + return BitConverter.ToUInt32(address); + } + + private ProxyInfo MakeInfo(IPEndPoint localEp, IPEndPoint remoteEP, ProtocolType type) + { + return new ProxyInfo + { + SourceIpV4 = GetIpV4(localEp), + SourcePort = (ushort)localEp.Port, + + DestIpV4 = GetIpV4(remoteEP), + DestPort = (ushort)remoteEP.Port, + + Protocol = type + }; + } + + public void RequestConnection(IPEndPoint localEp, IPEndPoint remoteEp, ProtocolType type) + { + // We must ask the other side to initialize a connection, so they can accept a socket for us. + + ProxyConnectRequest request = new ProxyConnectRequest + { + Info = MakeInfo(localEp, remoteEp, type) + }; + + _parent.SendAsync(_protocol.Encode(PacketId.ProxyConnect, request)); + } + + public void SignalConnected(IPEndPoint localEp, IPEndPoint remoteEp, ProtocolType type) + { + // We must tell the other side that we have accepted their request for connection. + + ProxyConnectResponse request = new ProxyConnectResponse + { + Info = MakeInfo(localEp, remoteEp, type) + }; + + _parent.SendAsync(_protocol.Encode(PacketId.ProxyConnectReply, request)); + } + + public void EndConnection(IPEndPoint localEp, IPEndPoint remoteEp, ProtocolType type) + { + // We must tell the other side that our connection is dropped. + + ProxyDisconnectMessage request = new ProxyDisconnectMessage + { + Info = MakeInfo(localEp, remoteEp, type), + DisconnectReason = 0 // TODO + }; + + _parent.SendAsync(_protocol.Encode(PacketId.ProxyDisconnect, request)); + } + + public int SendTo(ReadOnlySpan buffer, SocketFlags flags, IPEndPoint localEp, IPEndPoint remoteEp, ProtocolType type) + { + // We send exactly as much as the user wants us to, currently instantly. + // TODO: handle over "virtual mtu" (we have a max packet size to worry about anyways). fragment if tcp? throw if udp? + + ProxyDataHeader request = new ProxyDataHeader + { + Info = MakeInfo(localEp, remoteEp, type), + DataLength = (uint)buffer.Length + }; + + _parent.SendAsync(_protocol.Encode(PacketId.ProxyData, request, buffer.ToArray())); + + return buffer.Length; + } + + public bool IsBroadcast(uint ip) + { + return ip == _broadcast; + } + + public bool IsMyself(uint ip) + { + return ip == _localIp; + } + + public void Dispose() + { + UnregisterHandlers(_protocol); + + lock (_sockets) + { + foreach (LdnProxySocket socket in _sockets) + { + socket.ProxyDestroyed(); + } + } + } + } +} diff --git a/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnRyu/Proxy/LdnProxySocket.cs b/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnRyu/Proxy/LdnProxySocket.cs new file mode 100644 index 000000000..ed7a9c751 --- /dev/null +++ b/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnRyu/Proxy/LdnProxySocket.cs @@ -0,0 +1,797 @@ +using Ryujinx.HLE.HOS.Services.Ldn.UserServiceCreator.LdnRyu.Types; +using Ryujinx.HLE.HOS.Services.Sockets.Bsd.Impl; +using Ryujinx.HLE.HOS.Services.Sockets.Bsd.Proxy; +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Sockets; +using System.Threading; + +namespace Ryujinx.HLE.HOS.Services.Ldn.UserServiceCreator.LdnRyu.Proxy +{ + /// + /// This socket is forwarded through a TCP stream that goes through the Ldn server. + /// The Ldn server will then route the packets we send (or need to receive) within the virtual adhoc network. + /// + class LdnProxySocket : ISocketImpl + { + private readonly LdnProxy _proxy; + + private bool _isListening; + private readonly List _listenSockets = new List(); + + private readonly Queue _connectRequests = new Queue(); + + private readonly AutoResetEvent _acceptEvent = new AutoResetEvent(false); + private readonly int _acceptTimeout = -1; + + private readonly Queue _errors = new Queue(); + + private readonly AutoResetEvent _connectEvent = new AutoResetEvent(false); + private ProxyConnectResponse _connectResponse; + + private int _receiveTimeout = -1; + private readonly AutoResetEvent _receiveEvent = new AutoResetEvent(false); + private readonly Queue _receiveQueue = new Queue(); + + // private int _sendTimeout = -1; // Sends are techically instant right now, so not _really_ used. + + private bool _connecting; + private bool _broadcast; + private bool _readShutdown; + // private bool _writeShutdown; + private bool _closed; + + private readonly Dictionary _socketOptions = new Dictionary() + { + { SocketOptionName.Broadcast, 0 }, //TODO: honor this value + { SocketOptionName.DontLinger, 0 }, + { SocketOptionName.Debug, 0 }, + { SocketOptionName.Error, 0 }, + { SocketOptionName.KeepAlive, 0 }, + { SocketOptionName.OutOfBandInline, 0 }, + { SocketOptionName.ReceiveBuffer, 131072 }, + { SocketOptionName.ReceiveTimeout, -1 }, + { SocketOptionName.SendBuffer, 131072 }, + { SocketOptionName.SendTimeout, -1 }, + { SocketOptionName.Type, 0 }, + { SocketOptionName.ReuseAddress, 0 } //TODO: honor this value + }; + + public EndPoint RemoteEndPoint { get; private set; } + + public EndPoint LocalEndPoint { get; private set; } + + public bool Connected { get; private set; } + + public bool IsBound { get; private set; } + + public AddressFamily AddressFamily { get; } + + public SocketType SocketType { get; } + + public ProtocolType ProtocolType { get; } + + public bool Blocking { get; set; } + + public int Available + { + get + { + int result = 0; + + lock (_receiveQueue) + { + foreach (ProxyDataPacket data in _receiveQueue) + { + result += data.Data.Length; + } + } + + return result; + } + } + + public bool Readable + { + get + { + if (_isListening) + { + lock (_connectRequests) + { + return _connectRequests.Count > 0; + } + } + else + { + if (_readShutdown) + { + return true; + } + + lock (_receiveQueue) + { + return _receiveQueue.Count > 0; + } + } + + } + } + public bool Writable => Connected || ProtocolType == ProtocolType.Udp; + public bool Error => false; + + public LdnProxySocket(AddressFamily addressFamily, SocketType socketType, ProtocolType protocolType, LdnProxy proxy) + { + AddressFamily = addressFamily; + SocketType = socketType; + ProtocolType = protocolType; + + _proxy = proxy; + _socketOptions[SocketOptionName.Type] = (int)socketType; + + proxy.RegisterSocket(this); + } + + private IPEndPoint EnsureLocalEndpoint(bool replace) + { + if (LocalEndPoint != null) + { + if (replace) + { + _proxy.ReturnEphemeralPort(ProtocolType, (ushort)((IPEndPoint)LocalEndPoint).Port); + } + else + { + return (IPEndPoint)LocalEndPoint; + } + } + + IPEndPoint localEp = new IPEndPoint(_proxy.LocalAddress, _proxy.GetEphemeralPort(ProtocolType)); + LocalEndPoint = localEp; + + return localEp; + } + + public LdnProxySocket AsAccepted(IPEndPoint remoteEp) + { + Connected = true; + RemoteEndPoint = remoteEp; + + IPEndPoint localEp = EnsureLocalEndpoint(true); + + _proxy.SignalConnected(localEp, remoteEp, ProtocolType); + + return this; + } + + private void SignalError(WsaError error) + { + lock (_errors) + { + _errors.Enqueue((int)error); + } + } + + private IPEndPoint GetEndpoint(uint ipv4, ushort port) + { + byte[] address = BitConverter.GetBytes(ipv4); + Array.Reverse(address); + + return new IPEndPoint(new IPAddress(address), port); + } + + public void IncomingData(ProxyDataPacket packet) + { + bool isBroadcast = _proxy.IsBroadcast(packet.Header.Info.DestIpV4); + + if (!_closed && (_broadcast || !isBroadcast)) + { + lock (_receiveQueue) + { + _receiveQueue.Enqueue(packet); + } + } + } + + public ISocketImpl Accept() + { + if (!_isListening) + { + throw new InvalidOperationException(); + } + + // Accept a pending request to this socket. + + lock (_connectRequests) + { + if (!Blocking && _connectRequests.Count == 0) + { + throw new SocketException((int)WsaError.WSAEWOULDBLOCK); + } + } + + while (true) + { + _acceptEvent.WaitOne(_acceptTimeout); + + lock (_connectRequests) + { + while (_connectRequests.Count > 0) + { + ProxyConnectRequest request = _connectRequests.Dequeue(); + + if (_connectRequests.Count > 0) + { + _acceptEvent.Set(); // Still more accepts to do. + } + + // Is this request made for us? + IPEndPoint endpoint = GetEndpoint(request.Info.DestIpV4, request.Info.DestPort); + + if (Equals(endpoint, LocalEndPoint)) + { + // Yes - let's accept. + IPEndPoint remoteEndpoint = GetEndpoint(request.Info.SourceIpV4, request.Info.SourcePort); + + LdnProxySocket socket = new LdnProxySocket(AddressFamily, SocketType, ProtocolType, _proxy).AsAccepted(remoteEndpoint); + + lock (_listenSockets) + { + _listenSockets.Add(socket); + } + + return socket; + } + } + } + } + } + + public void Bind(EndPoint localEP) + { + ArgumentNullException.ThrowIfNull(localEP); + + if (LocalEndPoint != null) + { + _proxy.ReturnEphemeralPort(ProtocolType, (ushort)((IPEndPoint)LocalEndPoint).Port); + } + var asIPEndpoint = (IPEndPoint)localEP; + if (asIPEndpoint.Port == 0) + { + asIPEndpoint.Port = (ushort)_proxy.GetEphemeralPort(ProtocolType); + } + + LocalEndPoint = (IPEndPoint)localEP; + + IsBound = true; + } + + public void Close() + { + _closed = true; + + _proxy.UnregisterSocket(this); + + if (Connected) + { + Disconnect(false); + } + + lock (_listenSockets) + { + foreach (LdnProxySocket socket in _listenSockets) + { + socket.Close(); + } + } + + _isListening = false; + } + + public void Connect(EndPoint remoteEP) + { + if (_isListening || !IsBound) + { + throw new InvalidOperationException(); + } + + if (remoteEP is not IPEndPoint) + { + throw new NotSupportedException(); + } + + IPEndPoint localEp = EnsureLocalEndpoint(true); + + _connecting = true; + + _proxy.RequestConnection(localEp, (IPEndPoint)remoteEP, ProtocolType); + + if (!Blocking && ProtocolType == ProtocolType.Tcp) + { + throw new SocketException((int)WsaError.WSAEWOULDBLOCK); + } + + _connectEvent.WaitOne(); //timeout? + + if (_connectResponse.Info.SourceIpV4 == 0) + { + throw new SocketException((int)WsaError.WSAECONNREFUSED); + } + + _connectResponse = default; + } + + public void HandleConnectResponse(ProxyConnectResponse obj) + { + if (!_connecting) + { + return; + } + + _connecting = false; + + if (_connectResponse.Info.SourceIpV4 != 0) + { + IPEndPoint remoteEp = GetEndpoint(obj.Info.SourceIpV4, obj.Info.SourcePort); + RemoteEndPoint = remoteEp; + + Connected = true; + } + else + { + // Connection failed + + SignalError(WsaError.WSAECONNREFUSED); + } + } + + public void Disconnect(bool reuseSocket) + { + if (Connected) + { + ConnectionEnded(); + + // The other side needs to be notified that connection ended. + _proxy.EndConnection(LocalEndPoint as IPEndPoint, RemoteEndPoint as IPEndPoint, ProtocolType); + } + } + + private void ConnectionEnded() + { + if (Connected) + { + RemoteEndPoint = null; + Connected = false; + } + } + + public void GetSocketOption(SocketOptionLevel optionLevel, SocketOptionName optionName, byte[] optionValue) + { + if (optionLevel != SocketOptionLevel.Socket) + { + throw new NotImplementedException(); + } + + if (_socketOptions.TryGetValue(optionName, out int result)) + { + byte[] data = BitConverter.GetBytes(result); + Array.Copy(data, 0, optionValue, 0, Math.Min(data.Length, optionValue.Length)); + } + else + { + throw new NotImplementedException(); + } + } + + public void Listen(int backlog) + { + if (!IsBound) + { + throw new SocketException(); + } + + _isListening = true; + } + + public void HandleConnectRequest(ProxyConnectRequest obj) + { + lock (_connectRequests) + { + _connectRequests.Enqueue(obj); + } + + _connectEvent.Set(); + } + + public void HandleDisconnect(ProxyDisconnectMessage message) + { + Disconnect(false); + } + + public int Receive(Span buffer) + { + EndPoint dummy = new IPEndPoint(IPAddress.Any, 0); + + return ReceiveFrom(buffer, SocketFlags.None, ref dummy); + } + + public int Receive(Span buffer, SocketFlags flags) + { + EndPoint dummy = new IPEndPoint(IPAddress.Any, 0); + + return ReceiveFrom(buffer, flags, ref dummy); + } + + public int Receive(Span buffer, SocketFlags flags, out SocketError socketError) + { + EndPoint dummy = new IPEndPoint(IPAddress.Any, 0); + + return ReceiveFrom(buffer, flags, out socketError, ref dummy); + } + + public int ReceiveFrom(Span buffer, SocketFlags flags, ref EndPoint remoteEp) + { + // We just receive all packets meant for us anyways regardless of EP in the actual implementation. + // The point is mostly to return the endpoint that we got the data from. + + if (!Connected && ProtocolType == ProtocolType.Tcp) + { + throw new SocketException((int)WsaError.WSAECONNRESET); + } + + lock (_receiveQueue) + { + if (_receiveQueue.Count > 0) + { + return ReceiveFromQueue(buffer, flags, ref remoteEp); + } + else if (_readShutdown) + { + return 0; + } + else if (!Blocking) + { + throw new SocketException((int)WsaError.WSAEWOULDBLOCK); + } + } + + int timeout = _receiveTimeout; + + _receiveEvent.WaitOne(timeout == 0 ? -1 : timeout); + + if (!Connected && ProtocolType == ProtocolType.Tcp) + { + throw new SocketException((int)WsaError.WSAECONNRESET); + } + + lock (_receiveQueue) + { + if (_receiveQueue.Count > 0) + { + return ReceiveFromQueue(buffer, flags, ref remoteEp); + } + else if (_readShutdown) + { + return 0; + } + else + { + throw new SocketException((int)WsaError.WSAETIMEDOUT); + } + } + } + + public int ReceiveFrom(Span buffer, SocketFlags flags, out SocketError socketError, ref EndPoint remoteEp) + { + // We just receive all packets meant for us anyways regardless of EP in the actual implementation. + // The point is mostly to return the endpoint that we got the data from. + + if (!Connected && ProtocolType == ProtocolType.Tcp) + { + socketError = SocketError.ConnectionReset; + return -1; + } + + lock (_receiveQueue) + { + if (_receiveQueue.Count > 0) + { + return ReceiveFromQueue(buffer, flags, out socketError, ref remoteEp); + } + else if (_readShutdown) + { + socketError = SocketError.Success; + return 0; + } + else if (!Blocking) + { + throw new SocketException((int)WsaError.WSAEWOULDBLOCK); + } + } + + int timeout = _receiveTimeout; + + _receiveEvent.WaitOne(timeout == 0 ? -1 : timeout); + + if (!Connected && ProtocolType == ProtocolType.Tcp) + { + throw new SocketException((int)WsaError.WSAECONNRESET); + } + + lock (_receiveQueue) + { + if (_receiveQueue.Count > 0) + { + return ReceiveFromQueue(buffer, flags, out socketError, ref remoteEp); + } + else if (_readShutdown) + { + socketError = SocketError.Success; + return 0; + } + else + { + socketError = SocketError.TimedOut; + return -1; + } + } + } + + private int ReceiveFromQueue(Span buffer, SocketFlags flags, ref EndPoint remoteEp) + { + int size = buffer.Length; + + // Assumes we have the receive queue lock, and at least one item in the queue. + ProxyDataPacket packet = _receiveQueue.Peek(); + + remoteEp = GetEndpoint(packet.Header.Info.SourceIpV4, packet.Header.Info.SourcePort); + + bool peek = (flags & SocketFlags.Peek) != 0; + + int read; + + if (packet.Data.Length > size) + { + read = size; + + // Cannot fit in the output buffer. Copy up to what we've got. + packet.Data.AsSpan(0, size).CopyTo(buffer); + + if (ProtocolType == ProtocolType.Udp) + { + // Udp overflows, loses the data, then throws an exception. + + if (!peek) + { + _receiveQueue.Dequeue(); + } + + throw new SocketException((int)WsaError.WSAEMSGSIZE); + } + else if (ProtocolType == ProtocolType.Tcp) + { + // Split the data at the buffer boundary. It will stay on the recieve queue. + + byte[] newData = new byte[packet.Data.Length - size]; + Array.Copy(packet.Data, size, newData, 0, newData.Length); + + packet.Data = newData; + } + } + else + { + read = packet.Data.Length; + + packet.Data.AsSpan(0, packet.Data.Length).CopyTo(buffer); + + if (!peek) + { + _receiveQueue.Dequeue(); + } + } + + return read; + } + + private int ReceiveFromQueue(Span buffer, SocketFlags flags, out SocketError socketError, ref EndPoint remoteEp) + { + int size = buffer.Length; + + // Assumes we have the receive queue lock, and at least one item in the queue. + ProxyDataPacket packet = _receiveQueue.Peek(); + + remoteEp = GetEndpoint(packet.Header.Info.SourceIpV4, packet.Header.Info.SourcePort); + + bool peek = (flags & SocketFlags.Peek) != 0; + + int read; + + if (packet.Data.Length > size) + { + read = size; + + // Cannot fit in the output buffer. Copy up to what we've got. + packet.Data.AsSpan(0, size).CopyTo(buffer); + + if (ProtocolType == ProtocolType.Udp) + { + // Udp overflows, loses the data, then throws an exception. + + if (!peek) + { + _receiveQueue.Dequeue(); + } + + socketError = SocketError.MessageSize; + return -1; + } + else if (ProtocolType == ProtocolType.Tcp) + { + // Split the data at the buffer boundary. It will stay on the recieve queue. + + byte[] newData = new byte[packet.Data.Length - size]; + Array.Copy(packet.Data, size, newData, 0, newData.Length); + + packet.Data = newData; + } + } + else + { + read = packet.Data.Length; + + packet.Data.AsSpan(0, packet.Data.Length).CopyTo(buffer); + + if (!peek) + { + _receiveQueue.Dequeue(); + } + } + + socketError = SocketError.Success; + + return read; + } + + public int Send(ReadOnlySpan buffer) + { + // Send to the remote host chosen when we "connect" or "accept". + if (!Connected) + { + throw new SocketException(); + } + + return SendTo(buffer, SocketFlags.None, RemoteEndPoint); + } + + public int Send(ReadOnlySpan buffer, SocketFlags flags) + { + // Send to the remote host chosen when we "connect" or "accept". + if (!Connected) + { + throw new SocketException(); + } + + return SendTo(buffer, flags, RemoteEndPoint); + } + + public int Send(ReadOnlySpan buffer, SocketFlags flags, out SocketError socketError) + { + // Send to the remote host chosen when we "connect" or "accept". + if (!Connected) + { + throw new SocketException(); + } + + return SendTo(buffer, flags, out socketError, RemoteEndPoint); + } + + public int SendTo(ReadOnlySpan buffer, SocketFlags flags, EndPoint remoteEP) + { + if (!Connected && ProtocolType == ProtocolType.Tcp) + { + throw new SocketException((int)WsaError.WSAECONNRESET); + } + + IPEndPoint localEp = EnsureLocalEndpoint(false); + + if (remoteEP is not IPEndPoint) + { + throw new NotSupportedException(); + } + + return _proxy.SendTo(buffer, flags, localEp, (IPEndPoint)remoteEP, ProtocolType); + } + + public int SendTo(ReadOnlySpan buffer, SocketFlags flags, out SocketError socketError, EndPoint remoteEP) + { + if (!Connected && ProtocolType == ProtocolType.Tcp) + { + socketError = SocketError.ConnectionReset; + return -1; + } + + IPEndPoint localEp = EnsureLocalEndpoint(false); + + if (remoteEP is not IPEndPoint) + { + // throw new NotSupportedException(); + socketError = SocketError.OperationNotSupported; + return -1; + } + + socketError = SocketError.Success; + + return _proxy.SendTo(buffer, flags, localEp, (IPEndPoint)remoteEP, ProtocolType); + } + + public bool Poll(int microSeconds, SelectMode mode) + { + return mode switch + { + SelectMode.SelectRead => Readable, + SelectMode.SelectWrite => Writable, + SelectMode.SelectError => Error, + _ => false + }; + } + + public void SetSocketOption(SocketOptionLevel optionLevel, SocketOptionName optionName, int optionValue) + { + if (optionLevel != SocketOptionLevel.Socket) + { + throw new NotImplementedException(); + } + + switch (optionName) + { + case SocketOptionName.SendTimeout: + //_sendTimeout = optionValue; + break; + case SocketOptionName.ReceiveTimeout: + _receiveTimeout = optionValue; + break; + case SocketOptionName.Broadcast: + _broadcast = optionValue != 0; + break; + } + + lock (_socketOptions) + { + _socketOptions[optionName] = optionValue; + } + } + + public void SetSocketOption(SocketOptionLevel optionLevel, SocketOptionName optionName, object optionValue) + { + // Just linger uses this for now in BSD, which we ignore. + } + + public void Shutdown(SocketShutdown how) + { + switch (how) + { + case SocketShutdown.Both: + _readShutdown = true; + // _writeShutdown = true; + break; + case SocketShutdown.Receive: + _readShutdown = true; + break; + case SocketShutdown.Send: + // _writeShutdown = true; + break; + } + } + + public void ProxyDestroyed() + { + // Do nothing, for now. Will likely be more useful with TCP. + } + + public void Dispose() + { + + } + } +} diff --git a/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnRyu/Proxy/P2pProxyClient.cs b/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnRyu/Proxy/P2pProxyClient.cs new file mode 100644 index 000000000..7da1aa998 --- /dev/null +++ b/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnRyu/Proxy/P2pProxyClient.cs @@ -0,0 +1,93 @@ +using Ryujinx.Common.Logging; +using Ryujinx.HLE.HOS.Services.Ldn.UserServiceCreator.LdnRyu.Types; +using Ryujinx.HLE.HOS.Services.Ldn.UserServiceCreator.Types; +using Ryujinx.HLE.HOS.Services.Sockets.Bsd.Proxy; +using System.Net.Sockets; +using System.Threading; +using TcpClient = NetCoreServer.TcpClient; + +namespace Ryujinx.HLE.HOS.Services.Ldn.UserServiceCreator.LdnRyu.Proxy +{ + class P2pProxyClient : TcpClient, IProxyClient + { + private const int FailureTimeout = 4000; + + public ProxyConfig ProxyConfig { get; private set; } + + private readonly RyuLdnProtocol _protocol; + + private readonly ManualResetEvent _connected = new ManualResetEvent(false); + private readonly ManualResetEvent _ready = new ManualResetEvent(false); + private readonly AutoResetEvent _error = new AutoResetEvent(false); + + public P2pProxyClient(string address, int port) : base(address, port) + { + if (ProxyHelpers.SupportsNoDelay()) + { + OptionNoDelay = true; + } + + _protocol = new RyuLdnProtocol(); + + _protocol.ProxyConfig += HandleProxyConfig; + + ConnectAsync(); + } + + protected override void OnConnected() + { + Logger.Info?.PrintMsg(LogClass.ServiceLdn, $"Proxy TCP client connected a new session with Id {Id}"); + + _connected.Set(); + } + + protected override void OnDisconnected() + { + Logger.Info?.PrintMsg(LogClass.ServiceLdn, $"Proxy TCP client disconnected a session with Id {Id}"); + + SocketHelpers.UnregisterProxy(); + + _connected.Reset(); + } + + protected override void OnReceived(byte[] buffer, long offset, long size) + { + _protocol.Read(buffer, (int)offset, (int)size); + } + + protected override void OnError(SocketError error) + { + Logger.Info?.PrintMsg(LogClass.ServiceLdn, $"Proxy TCP client caught an error with code {error}"); + + _error.Set(); + } + + private void HandleProxyConfig(LdnHeader header, ProxyConfig config) + { + ProxyConfig = config; + + SocketHelpers.RegisterProxy(new LdnProxy(config, this, _protocol)); + + _ready.Set(); + } + + public bool EnsureProxyReady() + { + return _ready.WaitOne(FailureTimeout); + } + + public bool PerformAuth(ExternalProxyConfig config) + { + bool signalled = _connected.WaitOne(FailureTimeout); + + if (!signalled) + { + return false; + } + + SendAsync(_protocol.Encode(PacketId.ExternalProxy, config)); + + return true; + } + } +} diff --git a/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnRyu/Proxy/P2pProxyServer.cs b/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnRyu/Proxy/P2pProxyServer.cs new file mode 100644 index 000000000..598fb654f --- /dev/null +++ b/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnRyu/Proxy/P2pProxyServer.cs @@ -0,0 +1,388 @@ +using NetCoreServer; +using Open.Nat; +using Ryujinx.Common.Logging; +using Ryujinx.HLE.HOS.Services.Ldn.UserServiceCreator.LdnRyu.Types; +using Ryujinx.HLE.HOS.Services.Ldn.UserServiceCreator.Types; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Net; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; + +namespace Ryujinx.HLE.HOS.Services.Ldn.UserServiceCreator.LdnRyu.Proxy +{ + class P2pProxyServer : TcpServer, IDisposable + { + public const ushort PrivatePortBase = 39990; + public const int PrivatePortRange = 10; + + private const ushort PublicPortBase = 39990; + private const int PublicPortRange = 10; + + private const ushort PortLeaseLength = 60; + private const ushort PortLeaseRenew = 50; + + private const ushort AuthWaitSeconds = 1; + + private readonly ReaderWriterLockSlim _lock = new ReaderWriterLockSlim(LockRecursionPolicy.SupportsRecursion); + + public ushort PrivatePort { get; } + + private ushort _publicPort; + + private bool _disposed; + private readonly CancellationTokenSource _disposedCancellation = new CancellationTokenSource(); + + private NatDevice _natDevice; + private Mapping _portMapping; + + private readonly List _players = new List(); + + private readonly List _waitingTokens = new List(); + private readonly AutoResetEvent _tokenEvent = new AutoResetEvent(false); + + private uint _broadcastAddress; + + private readonly LdnMasterProxyClient _master; + private readonly RyuLdnProtocol _masterProtocol; + private readonly RyuLdnProtocol _protocol; + + public P2pProxyServer(LdnMasterProxyClient master, ushort port, RyuLdnProtocol masterProtocol) : base(IPAddress.Any, port) + { + if (ProxyHelpers.SupportsNoDelay()) + { + OptionNoDelay = true; + } + + PrivatePort = port; + + _master = master; + _masterProtocol = masterProtocol; + + _masterProtocol.ExternalProxyState += HandleStateChange; + _masterProtocol.ExternalProxyToken += HandleToken; + + _protocol = new RyuLdnProtocol(); + } + + private void HandleToken(LdnHeader header, ExternalProxyToken token) + { + _lock.EnterWriteLock(); + + _waitingTokens.Add(token); + + _lock.ExitWriteLock(); + + _tokenEvent.Set(); + } + + private void HandleStateChange(LdnHeader header, ExternalProxyConnectionState state) + { + if (!state.Connected) + { + _lock.EnterWriteLock(); + + _waitingTokens.RemoveAll(token => token.VirtualIp == state.IpAddress); + + _players.RemoveAll(player => + { + if (player.VirtualIpAddress == state.IpAddress) + { + player.DisconnectAndStop(); + + return true; + } + + return false; + }); + + _lock.ExitWriteLock(); + } + } + + public void Configure(ProxyConfig config) + { + _broadcastAddress = config.ProxyIp | (~config.ProxySubnetMask); + } + + public async Task NatPunch() + { + NatDiscoverer discoverer = new NatDiscoverer(); + CancellationTokenSource cts = new CancellationTokenSource(1000); + + NatDevice device; + + try + { + device = await discoverer.DiscoverDeviceAsync(PortMapper.Upnp, cts); + } + catch (NatDeviceNotFoundException) + { + return 0; + } + + _publicPort = PublicPortBase; + + for (int i = 0; i < PublicPortRange; i++) + { + try + { + _portMapping = new Mapping(Protocol.Tcp, PrivatePort, _publicPort, PortLeaseLength, "Ryujinx Local Multiplayer"); + + await device.CreatePortMapAsync(_portMapping); + + break; + } + catch (MappingException) + { + _publicPort++; + } + catch (Exception) + { + return 0; + } + + if (i == PublicPortRange - 1) + { + _publicPort = 0; + } + } + + if (_publicPort != 0) + { + _ = Task.Delay(PortLeaseRenew * 1000, _disposedCancellation.Token).ContinueWith((task) => Task.Run(RefreshLease)); + } + + _natDevice = device; + + return _publicPort; + } + + // Proxy handlers + + private void RouteMessage(P2pProxySession sender, ref ProxyInfo info, Action action) + { + if (info.SourceIpV4 == 0) + { + // If they sent from a connection bound on 0.0.0.0, make others see it as them. + info.SourceIpV4 = sender.VirtualIpAddress; + } + else if (info.SourceIpV4 != sender.VirtualIpAddress) + { + // Can't pretend to be somebody else. + return; + } + + uint destIp = info.DestIpV4; + + if (destIp == 0xc0a800ff) + { + destIp = _broadcastAddress; + } + + bool isBroadcast = destIp == _broadcastAddress; + + _lock.EnterReadLock(); + + if (isBroadcast) + { + _players.ForEach(player => + { + action(player); + }); + } + else + { + P2pProxySession target = _players.FirstOrDefault(player => player.VirtualIpAddress == destIp); + + if (target != null) + { + action(target); + } + } + + _lock.ExitReadLock(); + } + + public void HandleProxyDisconnect(P2pProxySession sender, LdnHeader header, ProxyDisconnectMessage message) + { + RouteMessage(sender, ref message.Info, (target) => + { + target.SendAsync(sender.Protocol.Encode(PacketId.ProxyDisconnect, message)); + }); + } + + public void HandleProxyData(P2pProxySession sender, LdnHeader header, ProxyDataHeader message, byte[] data) + { + RouteMessage(sender, ref message.Info, (target) => + { + target.SendAsync(sender.Protocol.Encode(PacketId.ProxyData, message, data)); + }); + } + + public void HandleProxyConnectReply(P2pProxySession sender, LdnHeader header, ProxyConnectResponse message) + { + RouteMessage(sender, ref message.Info, (target) => + { + target.SendAsync(sender.Protocol.Encode(PacketId.ProxyConnectReply, message)); + }); + } + + public void HandleProxyConnect(P2pProxySession sender, LdnHeader header, ProxyConnectRequest message) + { + RouteMessage(sender, ref message.Info, (target) => + { + target.SendAsync(sender.Protocol.Encode(PacketId.ProxyConnect, message)); + }); + } + + // End proxy handlers + + private async Task RefreshLease() + { + if (_disposed || _natDevice == null) + { + return; + } + + try + { + await _natDevice.CreatePortMapAsync(_portMapping); + } + catch (Exception) + { + + } + + _ = Task.Delay(PortLeaseRenew, _disposedCancellation.Token).ContinueWith((task) => Task.Run(RefreshLease)); + } + + public bool TryRegisterUser(P2pProxySession session, ExternalProxyConfig config) + { + _lock.EnterWriteLock(); + + // Attempt to find matching configuration. If we don't find one, wait for a bit and try again. + // Woken by new tokens coming in from the master server. + + IPAddress address = (session.Socket.RemoteEndPoint as IPEndPoint).Address; + byte[] addressBytes = ProxyHelpers.AddressTo16Byte(address); + + long time; + long endTime = Stopwatch.GetTimestamp() + Stopwatch.Frequency * AuthWaitSeconds; + + do + { + for (int i = 0; i < _waitingTokens.Count; i++) + { + ExternalProxyToken waitToken = _waitingTokens[i]; + + // Allow any client that has a private IP to connect. (indicated by the server as all 0 in the token) + + bool isPrivate = waitToken.PhysicalIp.AsSpan().SequenceEqual(new byte[16]); + bool ipEqual = isPrivate || waitToken.AddressFamily == address.AddressFamily && waitToken.PhysicalIp.AsSpan().SequenceEqual(addressBytes); + + if (ipEqual && waitToken.Token.AsSpan().SequenceEqual(config.Token.AsSpan())) + { + // This is a match. + + _waitingTokens.RemoveAt(i); + + session.SetIpv4(waitToken.VirtualIp); + + ProxyConfig pconfig = new ProxyConfig + { + ProxyIp = session.VirtualIpAddress, + ProxySubnetMask = 0xFFFF0000 // TODO: Use from server. + }; + + if (_players.Count == 0) + { + Configure(pconfig); + } + + _players.Add(session); + + session.SendAsync(_protocol.Encode(PacketId.ProxyConfig, pconfig)); + + _lock.ExitWriteLock(); + + return true; + } + } + + // Couldn't find the token. + // It may not have arrived yet, so wait for one to arrive. + + _lock.ExitWriteLock(); + + time = Stopwatch.GetTimestamp(); + int remainingMs = (int)((endTime - time) / (Stopwatch.Frequency / 1000)); + + if (remainingMs < 0) + { + remainingMs = 0; + } + + _tokenEvent.WaitOne(remainingMs); + + _lock.EnterWriteLock(); + + } while (time < endTime); + + _lock.ExitWriteLock(); + + return false; + } + + public void DisconnectProxyClient(P2pProxySession session) + { + _lock.EnterWriteLock(); + + bool removed = _players.Remove(session); + + if (removed) + { + _master.SendAsync(_masterProtocol.Encode(PacketId.ExternalProxyState, new ExternalProxyConnectionState + { + IpAddress = session.VirtualIpAddress, + Connected = false + })); + } + + _lock.ExitWriteLock(); + } + + public new void Dispose() + { + base.Dispose(); + + _disposed = true; + _disposedCancellation.Cancel(); + + try + { + Task delete = _natDevice?.DeletePortMapAsync(new Mapping(Protocol.Tcp, PrivatePort, _publicPort, 60, "Ryujinx Local Multiplayer")); + + // Just absorb any exceptions. + delete?.ContinueWith((task) => { }); + } + catch (Exception) + { + // Fail silently. + } + } + + protected override TcpSession CreateSession() + { + return new P2pProxySession(this); + } + + protected override void OnError(SocketError error) + { + Logger.Info?.PrintMsg(LogClass.ServiceLdn, $"Proxy TCP server caught an error with code {error}"); + } + } +} diff --git a/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnRyu/Proxy/P2pProxySession.cs b/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnRyu/Proxy/P2pProxySession.cs new file mode 100644 index 000000000..515feeac5 --- /dev/null +++ b/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnRyu/Proxy/P2pProxySession.cs @@ -0,0 +1,90 @@ +using NetCoreServer; +using Ryujinx.HLE.HOS.Services.Ldn.UserServiceCreator.LdnRyu.Types; +using System; + +namespace Ryujinx.HLE.HOS.Services.Ldn.UserServiceCreator.LdnRyu.Proxy +{ + class P2pProxySession : TcpSession + { + public uint VirtualIpAddress { get; private set; } + public RyuLdnProtocol Protocol { get; } + + private readonly P2pProxyServer _parent; + + private bool _masterClosed; + + public P2pProxySession(P2pProxyServer server) : base(server) + { + _parent = server; + + Protocol = new RyuLdnProtocol(); + + Protocol.ProxyDisconnect += HandleProxyDisconnect; + Protocol.ProxyData += HandleProxyData; + Protocol.ProxyConnectReply += HandleProxyConnectReply; + Protocol.ProxyConnect += HandleProxyConnect; + + Protocol.ExternalProxy += HandleAuthentication; + } + + private void HandleAuthentication(LdnHeader header, ExternalProxyConfig token) + { + if (!_parent.TryRegisterUser(this, token)) + { + Disconnect(); + } + } + + public void SetIpv4(uint ip) + { + VirtualIpAddress = ip; + } + + public void DisconnectAndStop() + { + _masterClosed = true; + + Disconnect(); + } + + protected override void OnDisconnected() + { + if (!_masterClosed) + { + _parent.DisconnectProxyClient(this); + } + } + + protected override void OnReceived(byte[] buffer, long offset, long size) + { + try + { + Protocol.Read(buffer, (int)offset, (int)size); + } + catch (Exception) + { + Disconnect(); + } + } + + private void HandleProxyDisconnect(LdnHeader header, ProxyDisconnectMessage message) + { + _parent.HandleProxyDisconnect(this, header, message); + } + + private void HandleProxyData(LdnHeader header, ProxyDataHeader message, byte[] data) + { + _parent.HandleProxyData(this, header, message, data); + } + + private void HandleProxyConnectReply(LdnHeader header, ProxyConnectResponse data) + { + _parent.HandleProxyConnectReply(this, header, data); + } + + private void HandleProxyConnect(LdnHeader header, ProxyConnectRequest message) + { + _parent.HandleProxyConnect(this, header, message); + } + } +} diff --git a/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnRyu/Proxy/ProxyHelpers.cs b/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnRyu/Proxy/ProxyHelpers.cs new file mode 100644 index 000000000..42b1ab6a2 --- /dev/null +++ b/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnRyu/Proxy/ProxyHelpers.cs @@ -0,0 +1,24 @@ +using System; +using System.Net; +using System.Runtime.InteropServices; + +namespace Ryujinx.HLE.HOS.Services.Ldn.UserServiceCreator.LdnRyu.Proxy +{ + static class ProxyHelpers + { + public static byte[] AddressTo16Byte(IPAddress address) + { + byte[] ipBytes = new byte[16]; + byte[] srcBytes = address.GetAddressBytes(); + + Array.Copy(srcBytes, 0, ipBytes, 0, srcBytes.Length); + + return ipBytes; + } + + public static bool SupportsNoDelay() + { + return RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + } + } +} diff --git a/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnRyu/RyuLdnProtocol.cs b/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnRyu/RyuLdnProtocol.cs new file mode 100644 index 000000000..d0eeaf125 --- /dev/null +++ b/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnRyu/RyuLdnProtocol.cs @@ -0,0 +1,380 @@ +using Ryujinx.Common.Utilities; +using Ryujinx.HLE.HOS.Services.Ldn.Types; +using Ryujinx.HLE.HOS.Services.Ldn.UserServiceCreator.LdnRyu.Types; +using Ryujinx.HLE.HOS.Services.Ldn.UserServiceCreator.Types; +using System; +using System.Runtime.InteropServices; + +namespace Ryujinx.HLE.HOS.Services.Ldn.UserServiceCreator.LdnRyu +{ + class RyuLdnProtocol + { + private const byte CurrentProtocolVersion = 1; + private const int Magic = ('R' << 0) | ('L' << 8) | ('D' << 16) | ('N' << 24); + private const int MaxPacketSize = 131072; + + private readonly int _headerSize = Marshal.SizeOf(); + + private readonly byte[] _buffer = new byte[MaxPacketSize]; + private int _bufferEnd = 0; + + // Client Packets. + public event Action Initialize; + public event Action Passphrase; + public event Action Connected; + public event Action SyncNetwork; + public event Action ScanReply; + public event Action ScanReplyEnd; + public event Action Disconnected; + + // External Proxy Packets. + public event Action ExternalProxy; + public event Action ExternalProxyState; + public event Action ExternalProxyToken; + + // Server Packets. + public event Action CreateAccessPoint; + public event Action CreateAccessPointPrivate; + public event Action Reject; + public event Action RejectReply; + public event Action SetAcceptPolicy; + public event Action SetAdvertiseData; + public event Action Connect; + public event Action ConnectPrivate; + public event Action Scan; + + // Proxy Packets. + public event Action ProxyConfig; + public event Action ProxyConnect; + public event Action ProxyConnectReply; + public event Action ProxyData; + public event Action ProxyDisconnect; + + // Lifecycle Packets. + public event Action NetworkError; + public event Action Ping; + + public RyuLdnProtocol() { } + + public void Reset() + { + _bufferEnd = 0; + } + + public void Read(byte[] data, int offset, int size) + { + int index = 0; + + while (index < size) + { + if (_bufferEnd < _headerSize) + { + // Assemble the header first. + + int copyable = Math.Min(size - index, Math.Min(size, _headerSize - _bufferEnd)); + + Array.Copy(data, index + offset, _buffer, _bufferEnd, copyable); + + index += copyable; + _bufferEnd += copyable; + } + + if (_bufferEnd >= _headerSize) + { + // The header is available. Make sure we received all the data (size specified in the header) + + LdnHeader ldnHeader = MemoryMarshal.Cast(_buffer)[0]; + + if (ldnHeader.Magic != Magic) + { + throw new InvalidOperationException("Invalid magic number in received packet."); + } + + if (ldnHeader.Version != CurrentProtocolVersion) + { + throw new InvalidOperationException($"Protocol version mismatch. Expected ${CurrentProtocolVersion}, was ${ldnHeader.Version}."); + } + + int finalSize = _headerSize + ldnHeader.DataSize; + + if (finalSize >= MaxPacketSize) + { + throw new InvalidOperationException($"Max packet size {MaxPacketSize} exceeded."); + } + + int copyable = Math.Min(size - index, Math.Min(size, finalSize - _bufferEnd)); + + Array.Copy(data, index + offset, _buffer, _bufferEnd, copyable); + + index += copyable; + _bufferEnd += copyable; + + if (finalSize == _bufferEnd) + { + // The full packet has been retrieved. Send it to be decoded. + + byte[] ldnData = new byte[ldnHeader.DataSize]; + + Array.Copy(_buffer, _headerSize, ldnData, 0, ldnData.Length); + + DecodeAndHandle(ldnHeader, ldnData); + + Reset(); + } + } + } + } + + private (T, byte[]) ParseWithData(byte[] data) where T : struct + { + T str = default; + int size = Marshal.SizeOf(str); + + byte[] remainder = new byte[data.Length - size]; + + if (remainder.Length > 0) + { + Array.Copy(data, size, remainder, 0, remainder.Length); + } + + return (MemoryMarshal.Read(data), remainder); + } + + private void DecodeAndHandle(LdnHeader header, byte[] data) + { + switch ((PacketId)header.Type) + { + // Client Packets. + case PacketId.Initialize: + { + Initialize?.Invoke(header, MemoryMarshal.Read(data)); + + break; + } + case PacketId.Passphrase: + { + Passphrase?.Invoke(header, MemoryMarshal.Read(data)); + + break; + } + case PacketId.Connected: + { + Connected?.Invoke(header, MemoryMarshal.Read(data)); + + break; + } + case PacketId.SyncNetwork: + { + SyncNetwork?.Invoke(header, MemoryMarshal.Read(data)); + + break; + } + case PacketId.ScanReply: + { + ScanReply?.Invoke(header, MemoryMarshal.Read(data)); + + break; + } + + case PacketId.ScanReplyEnd: + { + ScanReplyEnd?.Invoke(header); + + break; + } + case PacketId.Disconnect: + { + Disconnected?.Invoke(header, MemoryMarshal.Read(data)); + + break; + } + + // External Proxy Packets. + case PacketId.ExternalProxy: + { + ExternalProxy?.Invoke(header, MemoryMarshal.Read(data)); + + break; + } + case PacketId.ExternalProxyState: + { + ExternalProxyState?.Invoke(header, MemoryMarshal.Read(data)); + + break; + } + case PacketId.ExternalProxyToken: + { + ExternalProxyToken?.Invoke(header, MemoryMarshal.Read(data)); + + break; + } + + // Server Packets. + case PacketId.CreateAccessPoint: + { + (CreateAccessPointRequest packet, byte[] extraData) = ParseWithData(data); + CreateAccessPoint?.Invoke(header, packet, extraData); + break; + } + case PacketId.CreateAccessPointPrivate: + { + (CreateAccessPointPrivateRequest packet, byte[] extraData) = ParseWithData(data); + CreateAccessPointPrivate?.Invoke(header, packet, extraData); + break; + } + case PacketId.Reject: + { + Reject?.Invoke(header, MemoryMarshal.Read(data)); + + break; + } + case PacketId.RejectReply: + { + RejectReply?.Invoke(header); + + break; + } + case PacketId.SetAcceptPolicy: + { + SetAcceptPolicy?.Invoke(header, MemoryMarshal.Read(data)); + + break; + } + case PacketId.SetAdvertiseData: + { + SetAdvertiseData?.Invoke(header, data); + + break; + } + case PacketId.Connect: + { + Connect?.Invoke(header, MemoryMarshal.Read(data)); + + break; + } + case PacketId.ConnectPrivate: + { + ConnectPrivate?.Invoke(header, MemoryMarshal.Read(data)); + + break; + } + case PacketId.Scan: + { + Scan?.Invoke(header, MemoryMarshal.Read(data)); + + break; + } + + // Proxy Packets + case PacketId.ProxyConfig: + { + ProxyConfig?.Invoke(header, MemoryMarshal.Read(data)); + + break; + } + case PacketId.ProxyConnect: + { + ProxyConnect?.Invoke(header, MemoryMarshal.Read(data)); + + break; + } + case PacketId.ProxyConnectReply: + { + ProxyConnectReply?.Invoke(header, MemoryMarshal.Read(data)); + + break; + } + case PacketId.ProxyData: + { + (ProxyDataHeader packet, byte[] extraData) = ParseWithData(data); + + ProxyData?.Invoke(header, packet, extraData); + + break; + } + case PacketId.ProxyDisconnect: + { + ProxyDisconnect?.Invoke(header, MemoryMarshal.Read(data)); + + break; + } + + // Lifecycle Packets. + case PacketId.Ping: + { + Ping?.Invoke(header, MemoryMarshal.Read(data)); + + break; + } + case PacketId.NetworkError: + { + NetworkError?.Invoke(header, MemoryMarshal.Read(data)); + + break; + } + + default: + break; + } + } + + private static LdnHeader GetHeader(PacketId type, int dataSize) + { + return new LdnHeader() + { + Magic = Magic, + Version = CurrentProtocolVersion, + Type = (byte)type, + DataSize = dataSize + }; + } + + public byte[] Encode(PacketId type) + { + LdnHeader header = GetHeader(type, 0); + + return SpanHelpers.AsSpan(ref header).ToArray(); + } + + public byte[] Encode(PacketId type, byte[] data) + { + LdnHeader header = GetHeader(type, data.Length); + + byte[] result = SpanHelpers.AsSpan(ref header).ToArray(); + + Array.Resize(ref result, result.Length + data.Length); + Array.Copy(data, 0, result, Marshal.SizeOf(), data.Length); + + return result; + } + + public byte[] Encode(PacketId type, T packet) where T : unmanaged + { + byte[] packetData = SpanHelpers.AsSpan(ref packet).ToArray(); + + LdnHeader header = GetHeader(type, packetData.Length); + + byte[] result = SpanHelpers.AsSpan(ref header).ToArray(); + + Array.Resize(ref result, result.Length + packetData.Length); + Array.Copy(packetData, 0, result, Marshal.SizeOf(), packetData.Length); + + return result; + } + + public byte[] Encode(PacketId type, T packet, byte[] data) where T : unmanaged + { + byte[] packetData = SpanHelpers.AsSpan(ref packet).ToArray(); + + LdnHeader header = GetHeader(type, packetData.Length + data.Length); + + byte[] result = SpanHelpers.AsSpan(ref header).ToArray(); + + Array.Resize(ref result, result.Length + packetData.Length + data.Length); + Array.Copy(packetData, 0, result, Marshal.SizeOf(), packetData.Length); + Array.Copy(data, 0, result, Marshal.SizeOf() + packetData.Length, data.Length); + + return result; + } + } +} diff --git a/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnRyu/Types/DisconnectMessage.cs b/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnRyu/Types/DisconnectMessage.cs new file mode 100644 index 000000000..448d33f29 --- /dev/null +++ b/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnRyu/Types/DisconnectMessage.cs @@ -0,0 +1,10 @@ +using System.Runtime.InteropServices; + +namespace Ryujinx.HLE.HOS.Services.Ldn.UserServiceCreator.LdnRyu.Types +{ + [StructLayout(LayoutKind.Sequential, Size = 0x4)] + struct DisconnectMessage + { + public uint DisconnectIP; + } +} diff --git a/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnRyu/Types/ExternalProxyConfig.cs b/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnRyu/Types/ExternalProxyConfig.cs new file mode 100644 index 000000000..9cbb80242 --- /dev/null +++ b/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnRyu/Types/ExternalProxyConfig.cs @@ -0,0 +1,19 @@ +using Ryujinx.Common.Memory; +using System.Net.Sockets; +using System.Runtime.InteropServices; + +namespace Ryujinx.HLE.HOS.Services.Ldn.UserServiceCreator.LdnRyu.Types +{ + /// + /// Sent by the server to point a client towards an external server being used as a proxy. + /// The client then forwards this to the external proxy after connecting, to verify the connection worked. + /// + [StructLayout(LayoutKind.Sequential, Size = 0x26, Pack = 1)] + struct ExternalProxyConfig + { + public Array16 ProxyIp; + public AddressFamily AddressFamily; + public ushort ProxyPort; + public Array16 Token; + } +} diff --git a/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnRyu/Types/ExternalProxyConnectionState.cs b/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnRyu/Types/ExternalProxyConnectionState.cs new file mode 100644 index 000000000..ecf4e14f7 --- /dev/null +++ b/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnRyu/Types/ExternalProxyConnectionState.cs @@ -0,0 +1,18 @@ +using System.Runtime.InteropServices; + +namespace Ryujinx.HLE.HOS.Services.Ldn.UserServiceCreator.LdnRyu.Types +{ + /// + /// Indicates a change in connection state for the given client. + /// Is sent to notify the master server when connection is first established. + /// Can be sent by the external proxy to the master server to notify it of a proxy disconnect. + /// Can be sent by the master server to notify the external proxy of a user leaving a room. + /// Both will result in a force kick. + /// + [StructLayout(LayoutKind.Sequential, Size = 0x8, Pack = 4)] + struct ExternalProxyConnectionState + { + public uint IpAddress; + public bool Connected; + } +} diff --git a/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnRyu/Types/ExternalProxyToken.cs b/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnRyu/Types/ExternalProxyToken.cs new file mode 100644 index 000000000..0a8980c37 --- /dev/null +++ b/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnRyu/Types/ExternalProxyToken.cs @@ -0,0 +1,20 @@ +using Ryujinx.Common.Memory; +using System.Net.Sockets; +using System.Runtime.InteropServices; + +namespace Ryujinx.HLE.HOS.Services.Ldn.UserServiceCreator.LdnRyu.Types +{ + /// + /// Sent by the master server to an external proxy to tell them someone is going to connect. + /// This drives authentication, and lets the proxy know what virtual IP to give to each joiner, + /// as these are managed by the master server. + /// + [StructLayout(LayoutKind.Sequential, Size = 0x28)] + struct ExternalProxyToken + { + public uint VirtualIp; + public Array16 Token; + public Array16 PhysicalIp; + public AddressFamily AddressFamily; + } +} diff --git a/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnRyu/Types/InitializeMessage.cs b/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnRyu/Types/InitializeMessage.cs new file mode 100644 index 000000000..36ddc65fe --- /dev/null +++ b/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnRyu/Types/InitializeMessage.cs @@ -0,0 +1,20 @@ +using Ryujinx.Common.Memory; +using System.Runtime.InteropServices; + +namespace Ryujinx.HLE.HOS.Services.Ldn.UserServiceCreator.LdnRyu.Types +{ + /// + /// This message is first sent by the client to identify themselves. + /// If the server has a token+mac combo that matches the submission, then they are returned their new ID and mac address. (the mac is also reassigned to the new id) + /// Otherwise, they are returned a random mac address. + /// + [StructLayout(LayoutKind.Sequential, Size = 0x16)] + struct InitializeMessage + { + // All 0 if we don't have an ID yet. + public Array16 Id; + + // All 0 if we don't have a mac yet. + public Array6 MacAddress; + } +} diff --git a/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnRyu/Types/LdnHeader.cs b/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnRyu/Types/LdnHeader.cs new file mode 100644 index 000000000..f41f15ab4 --- /dev/null +++ b/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnRyu/Types/LdnHeader.cs @@ -0,0 +1,13 @@ +using System.Runtime.InteropServices; + +namespace Ryujinx.HLE.HOS.Services.Ldn.UserServiceCreator.LdnRyu.Types +{ + [StructLayout(LayoutKind.Sequential, Size = 0xA)] + struct LdnHeader + { + public uint Magic; + public byte Type; + public byte Version; + public int DataSize; + } +} diff --git a/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnRyu/Types/PacketId.cs b/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnRyu/Types/PacketId.cs new file mode 100644 index 000000000..b8ef5fbc1 --- /dev/null +++ b/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnRyu/Types/PacketId.cs @@ -0,0 +1,36 @@ +namespace Ryujinx.HLE.HOS.Services.Ldn.UserServiceCreator.LdnRyu.Types +{ + enum PacketId + { + Initialize, + Passphrase, + + CreateAccessPoint, + CreateAccessPointPrivate, + ExternalProxy, + ExternalProxyToken, + ExternalProxyState, + SyncNetwork, + Reject, + RejectReply, + Scan, + ScanReply, + ScanReplyEnd, + Connect, + ConnectPrivate, + Connected, + Disconnect, + + ProxyConfig, + ProxyConnect, + ProxyConnectReply, + ProxyData, + ProxyDisconnect, + + SetAcceptPolicy, + SetAdvertiseData, + + Ping = 254, + NetworkError = 255 + } +} diff --git a/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnRyu/Types/PassphraseMessage.cs b/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnRyu/Types/PassphraseMessage.cs new file mode 100644 index 000000000..0deba0b07 --- /dev/null +++ b/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnRyu/Types/PassphraseMessage.cs @@ -0,0 +1,11 @@ +using Ryujinx.Common.Memory; +using System.Runtime.InteropServices; + +namespace Ryujinx.HLE.HOS.Services.Ldn.UserServiceCreator.LdnRyu.Types +{ + [StructLayout(LayoutKind.Sequential, Size = 0x80)] + struct PassphraseMessage + { + public Array128 Passphrase; + } +} diff --git a/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnRyu/Types/PingMessage.cs b/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnRyu/Types/PingMessage.cs new file mode 100644 index 000000000..135e39caa --- /dev/null +++ b/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnRyu/Types/PingMessage.cs @@ -0,0 +1,11 @@ +using System.Runtime.InteropServices; + +namespace Ryujinx.HLE.HOS.Services.Ldn.UserServiceCreator.LdnRyu.Types +{ + [StructLayout(LayoutKind.Sequential, Size = 0x2)] + struct PingMessage + { + public byte Requester; + public byte Id; + } +} diff --git a/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnRyu/Types/ProxyConnectRequest.cs b/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnRyu/Types/ProxyConnectRequest.cs new file mode 100644 index 000000000..ffce77791 --- /dev/null +++ b/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnRyu/Types/ProxyConnectRequest.cs @@ -0,0 +1,10 @@ +using System.Runtime.InteropServices; + +namespace Ryujinx.HLE.HOS.Services.Ldn.UserServiceCreator.LdnRyu.Types +{ + [StructLayout(LayoutKind.Sequential, Size = 0x10)] + struct ProxyConnectRequest + { + public ProxyInfo Info; + } +} diff --git a/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnRyu/Types/ProxyConnectResponse.cs b/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnRyu/Types/ProxyConnectResponse.cs new file mode 100644 index 000000000..de2e430fb --- /dev/null +++ b/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnRyu/Types/ProxyConnectResponse.cs @@ -0,0 +1,10 @@ +using System.Runtime.InteropServices; + +namespace Ryujinx.HLE.HOS.Services.Ldn.UserServiceCreator.LdnRyu.Types +{ + [StructLayout(LayoutKind.Sequential, Size = 0x10)] + struct ProxyConnectResponse + { + public ProxyInfo Info; + } +} diff --git a/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnRyu/Types/ProxyDataHeader.cs b/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnRyu/Types/ProxyDataHeader.cs new file mode 100644 index 000000000..e46a40692 --- /dev/null +++ b/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnRyu/Types/ProxyDataHeader.cs @@ -0,0 +1,14 @@ +using System.Runtime.InteropServices; + +namespace Ryujinx.HLE.HOS.Services.Ldn.UserServiceCreator.LdnRyu.Types +{ + /// + /// Represents data sent over a transport layer. + /// + [StructLayout(LayoutKind.Sequential, Size = 0x14)] + struct ProxyDataHeader + { + public ProxyInfo Info; + public uint DataLength; // Followed by the data with the specified byte length. + } +} diff --git a/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnRyu/Types/ProxyDataPacket.cs b/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnRyu/Types/ProxyDataPacket.cs new file mode 100644 index 000000000..eb3648413 --- /dev/null +++ b/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnRyu/Types/ProxyDataPacket.cs @@ -0,0 +1,8 @@ +namespace Ryujinx.HLE.HOS.Services.Ldn.UserServiceCreator.LdnRyu.Types +{ + class ProxyDataPacket + { + public ProxyDataHeader Header; + public byte[] Data; + } +} diff --git a/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnRyu/Types/ProxyDisconnectMessage.cs b/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnRyu/Types/ProxyDisconnectMessage.cs new file mode 100644 index 000000000..2154ae109 --- /dev/null +++ b/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnRyu/Types/ProxyDisconnectMessage.cs @@ -0,0 +1,11 @@ +using System.Runtime.InteropServices; + +namespace Ryujinx.HLE.HOS.Services.Ldn.UserServiceCreator.LdnRyu.Types +{ + [StructLayout(LayoutKind.Sequential, Size = 0x14)] + struct ProxyDisconnectMessage + { + public ProxyInfo Info; + public int DisconnectReason; + } +} diff --git a/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnRyu/Types/ProxyInfo.cs b/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnRyu/Types/ProxyInfo.cs new file mode 100644 index 000000000..d9338f244 --- /dev/null +++ b/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnRyu/Types/ProxyInfo.cs @@ -0,0 +1,20 @@ +using System.Net.Sockets; +using System.Runtime.InteropServices; + +namespace Ryujinx.HLE.HOS.Services.Ldn.UserServiceCreator.LdnRyu.Types +{ + /// + /// Information included in all proxied communication. + /// + [StructLayout(LayoutKind.Sequential, Size = 0x10, Pack = 1)] + struct ProxyInfo + { + public uint SourceIpV4; + public ushort SourcePort; + + public uint DestIpV4; + public ushort DestPort; + + public ProtocolType Protocol; + } +} diff --git a/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnRyu/Types/RejectRequest.cs b/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnRyu/Types/RejectRequest.cs new file mode 100644 index 000000000..1c2ce1f8b --- /dev/null +++ b/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnRyu/Types/RejectRequest.cs @@ -0,0 +1,18 @@ +using Ryujinx.HLE.HOS.Services.Ldn.Types; +using System.Runtime.InteropServices; + +namespace Ryujinx.HLE.HOS.Services.Ldn.UserServiceCreator.LdnRyu.Types +{ + [StructLayout(LayoutKind.Sequential, Size = 0x8)] + struct RejectRequest + { + public uint NodeId; + public DisconnectReason DisconnectReason; + + public RejectRequest(DisconnectReason disconnectReason, uint nodeId) + { + DisconnectReason = disconnectReason; + NodeId = nodeId; + } + } +} diff --git a/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnRyu/Types/RyuNetworkConfig.cs b/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnRyu/Types/RyuNetworkConfig.cs new file mode 100644 index 000000000..f3bd72023 --- /dev/null +++ b/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnRyu/Types/RyuNetworkConfig.cs @@ -0,0 +1,23 @@ +using Ryujinx.Common.Memory; +using System.Net.Sockets; +using System.Runtime.InteropServices; + +namespace Ryujinx.HLE.HOS.Services.Ldn.UserServiceCreator.LdnRyu.Types +{ + [StructLayout(LayoutKind.Sequential, Size = 0x28, Pack = 1)] + struct RyuNetworkConfig + { + public Array16 GameVersion; + + // PrivateIp is included for external proxies for the case where a client attempts to join from + // their own LAN. UPnP forwarding can fail when connecting devices on the same network over the public IP, + // so if their public IP is identical, the internal address should be sent instead. + + // The fields below are 0 if not hosting a p2p proxy. + + public Array16 PrivateIp; + public AddressFamily AddressFamily; + public ushort ExternalProxyPort; + public ushort InternalProxyPort; + } +} diff --git a/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnRyu/Types/SetAcceptPolicyRequest.cs b/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnRyu/Types/SetAcceptPolicyRequest.cs new file mode 100644 index 000000000..c4a969901 --- /dev/null +++ b/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/LdnRyu/Types/SetAcceptPolicyRequest.cs @@ -0,0 +1,11 @@ +using Ryujinx.HLE.HOS.Services.Ldn.Types; +using System.Runtime.InteropServices; + +namespace Ryujinx.HLE.HOS.Services.Ldn.UserServiceCreator.LdnRyu.Types +{ + [StructLayout(LayoutKind.Sequential, Size = 0x1, Pack = 1)] + struct SetAcceptPolicyRequest + { + public AcceptPolicy StationAcceptPolicy; + } +} diff --git a/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/Station.cs b/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/Station.cs index e39c01978..fa43f789e 100644 --- a/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/Station.cs +++ b/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/Station.cs @@ -14,6 +14,8 @@ namespace Ryujinx.HLE.HOS.Services.Ldn.UserServiceCreator public bool Connected { get; private set; } + public ProxyConfig Config => _parent.NetworkClient.Config; + public Station(IUserLocalCommunicationService parent) { _parent = parent; @@ -48,9 +50,12 @@ namespace Ryujinx.HLE.HOS.Services.Ldn.UserServiceCreator public void Dispose() { - _parent.NetworkClient.DisconnectNetwork(); + if (_parent.NetworkClient != null) + { + _parent.NetworkClient.DisconnectNetwork(); - _parent.NetworkClient.NetworkChange -= NetworkChanged; + _parent.NetworkClient.NetworkChange -= NetworkChanged; + } } private ResultCode NetworkErrorToResult(NetworkError error) diff --git a/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/Types/CreateAccessPointPrivateRequest.cs b/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/Types/CreateAccessPointPrivateRequest.cs index ac0ff7d94..0972c21c0 100644 --- a/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/Types/CreateAccessPointPrivateRequest.cs +++ b/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/Types/CreateAccessPointPrivateRequest.cs @@ -1,4 +1,5 @@ using Ryujinx.HLE.HOS.Services.Ldn.Types; +using Ryujinx.HLE.HOS.Services.Ldn.UserServiceCreator.LdnRyu.Types; using System.Runtime.InteropServices; namespace Ryujinx.HLE.HOS.Services.Ldn.UserServiceCreator.Types @@ -14,5 +15,7 @@ namespace Ryujinx.HLE.HOS.Services.Ldn.UserServiceCreator.Types public UserConfig UserConfig; public NetworkConfig NetworkConfig; public AddressList AddressList; + + public RyuNetworkConfig RyuNetworkConfig; } } diff --git a/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/Types/CreateAccessPointRequest.cs b/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/Types/CreateAccessPointRequest.cs index f67f0aac9..d2dc5b698 100644 --- a/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/Types/CreateAccessPointRequest.cs +++ b/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/Types/CreateAccessPointRequest.cs @@ -1,4 +1,5 @@ using Ryujinx.HLE.HOS.Services.Ldn.Types; +using Ryujinx.HLE.HOS.Services.Ldn.UserServiceCreator.LdnRyu.Types; using System.Runtime.InteropServices; namespace Ryujinx.HLE.HOS.Services.Ldn.UserServiceCreator.Types @@ -6,11 +7,13 @@ namespace Ryujinx.HLE.HOS.Services.Ldn.UserServiceCreator.Types /// /// Advertise data is appended separately (remaining data in the buffer). /// - [StructLayout(LayoutKind.Sequential, Size = 0x94, CharSet = CharSet.Ansi)] + [StructLayout(LayoutKind.Sequential, Size = 0xBC, Pack = 1)] struct CreateAccessPointRequest { public SecurityConfig SecurityConfig; public UserConfig UserConfig; public NetworkConfig NetworkConfig; + + public RyuNetworkConfig RyuNetworkConfig; } } diff --git a/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/Types/ProxyConfig.cs b/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/Types/ProxyConfig.cs new file mode 100644 index 000000000..c89c08bbe --- /dev/null +++ b/src/Ryujinx.HLE/HOS/Services/Ldn/UserServiceCreator/Types/ProxyConfig.cs @@ -0,0 +1,11 @@ +using System.Runtime.InteropServices; + +namespace Ryujinx.HLE.HOS.Services.Ldn.UserServiceCreator.Types +{ + [StructLayout(LayoutKind.Sequential, Size = 0x8)] + struct ProxyConfig + { + public uint ProxyIp; + public uint ProxySubnetMask; + } +} diff --git a/src/Ryujinx.HLE/HOS/Services/Sockets/Bsd/IClient.cs b/src/Ryujinx.HLE/HOS/Services/Sockets/Bsd/IClient.cs index 21d48288e..3a40a4ac5 100644 --- a/src/Ryujinx.HLE/HOS/Services/Sockets/Bsd/IClient.cs +++ b/src/Ryujinx.HLE/HOS/Services/Sockets/Bsd/IClient.cs @@ -95,7 +95,7 @@ namespace Ryujinx.HLE.HOS.Services.Sockets.Bsd } } - ISocket newBsdSocket = new ManagedSocket(netDomain, (SocketType)type, protocol) + ISocket newBsdSocket = new ManagedSocket(netDomain, (SocketType)type, protocol, context.Device.Configuration.MultiplayerLanInterfaceId) { Blocking = !creationFlags.HasFlag(BsdSocketCreationFlags.NonBlocking), }; diff --git a/src/Ryujinx.HLE/HOS/Services/Sockets/Bsd/Impl/ManagedSocket.cs b/src/Ryujinx.HLE/HOS/Services/Sockets/Bsd/Impl/ManagedSocket.cs index c9b811cf5..981fe0a8f 100644 --- a/src/Ryujinx.HLE/HOS/Services/Sockets/Bsd/Impl/ManagedSocket.cs +++ b/src/Ryujinx.HLE/HOS/Services/Sockets/Bsd/Impl/ManagedSocket.cs @@ -1,4 +1,5 @@ using Ryujinx.Common.Logging; +using Ryujinx.HLE.HOS.Services.Sockets.Bsd.Proxy; using Ryujinx.HLE.HOS.Services.Sockets.Bsd.Types; using System; using System.Collections.Generic; @@ -21,21 +22,21 @@ namespace Ryujinx.HLE.HOS.Services.Sockets.Bsd.Impl public bool Blocking { get => Socket.Blocking; set => Socket.Blocking = value; } - public nint Handle => Socket.Handle; + public nint Handle => IntPtr.Zero; public IPEndPoint RemoteEndPoint => Socket.RemoteEndPoint as IPEndPoint; public IPEndPoint LocalEndPoint => Socket.LocalEndPoint as IPEndPoint; - public Socket Socket { get; } + public ISocketImpl Socket { get; } - public ManagedSocket(AddressFamily addressFamily, SocketType socketType, ProtocolType protocolType) + public ManagedSocket(AddressFamily addressFamily, SocketType socketType, ProtocolType protocolType, string lanInterfaceId) { - Socket = new Socket(addressFamily, socketType, protocolType); + Socket = SocketHelpers.CreateSocket(addressFamily, socketType, protocolType, lanInterfaceId); Refcount = 1; } - private ManagedSocket(Socket socket) + private ManagedSocket(ISocketImpl socket) { Socket = socket; Refcount = 1; @@ -185,6 +186,8 @@ namespace Ryujinx.HLE.HOS.Services.Sockets.Bsd.Impl } } + bool hasEmittedBlockingWarning = false; + public LinuxError Receive(out int receiveSize, Span buffer, BsdSocketFlags flags) { LinuxError result; @@ -199,6 +202,12 @@ namespace Ryujinx.HLE.HOS.Services.Sockets.Bsd.Impl shouldBlockAfterOperation = true; } + if (Blocking && !hasEmittedBlockingWarning) + { + Logger.Warning?.PrintMsg(LogClass.ServiceBsd, "Blocking socket operations are not yet working properly. Expect network errors."); + hasEmittedBlockingWarning = true; + } + receiveSize = Socket.Receive(buffer, ConvertBsdSocketFlags(flags)); result = LinuxError.SUCCESS; @@ -236,6 +245,12 @@ namespace Ryujinx.HLE.HOS.Services.Sockets.Bsd.Impl shouldBlockAfterOperation = true; } + if (Blocking && !hasEmittedBlockingWarning) + { + Logger.Warning?.PrintMsg(LogClass.ServiceBsd, "Blocking socket operations are not yet working properly. Expect network errors."); + hasEmittedBlockingWarning = true; + } + if (!Socket.IsBound) { receiveSize = -1; @@ -313,7 +328,7 @@ namespace Ryujinx.HLE.HOS.Services.Sockets.Bsd.Impl Logger.Warning?.Print(LogClass.ServiceBsd, $"Unsupported GetSockOpt Option: {option} Level: {level}"); optionValue.Clear(); - return LinuxError.SUCCESS; + return LinuxError.EOPNOTSUPP; } byte[] tempOptionValue = new byte[optionValue.Length]; @@ -347,7 +362,7 @@ namespace Ryujinx.HLE.HOS.Services.Sockets.Bsd.Impl { Logger.Warning?.Print(LogClass.ServiceBsd, $"Unsupported SetSockOpt Option: {option} Level: {level}"); - return LinuxError.SUCCESS; + return LinuxError.EOPNOTSUPP; } int value = optionValue.Length >= 4 ? MemoryMarshal.Read(optionValue) : MemoryMarshal.Read(optionValue); @@ -493,7 +508,7 @@ namespace Ryujinx.HLE.HOS.Services.Sockets.Bsd.Impl try { - int receiveSize = Socket.Receive(ConvertMessagesToBuffer(message), ConvertBsdSocketFlags(flags), out SocketError socketError); + int receiveSize = (Socket as DefaultSocket).BaseSocket.Receive(ConvertMessagesToBuffer(message), ConvertBsdSocketFlags(flags), out SocketError socketError); if (receiveSize > 0) { @@ -531,7 +546,7 @@ namespace Ryujinx.HLE.HOS.Services.Sockets.Bsd.Impl try { - int sendSize = Socket.Send(ConvertMessagesToBuffer(message), ConvertBsdSocketFlags(flags), out SocketError socketError); + int sendSize = (Socket as DefaultSocket).BaseSocket.Send(ConvertMessagesToBuffer(message), ConvertBsdSocketFlags(flags), out SocketError socketError); if (sendSize > 0) { diff --git a/src/Ryujinx.HLE/HOS/Services/Sockets/Bsd/Impl/ManagedSocketPollManager.cs b/src/Ryujinx.HLE/HOS/Services/Sockets/Bsd/Impl/ManagedSocketPollManager.cs index d0db44086..e870e8aea 100644 --- a/src/Ryujinx.HLE/HOS/Services/Sockets/Bsd/Impl/ManagedSocketPollManager.cs +++ b/src/Ryujinx.HLE/HOS/Services/Sockets/Bsd/Impl/ManagedSocketPollManager.cs @@ -1,4 +1,5 @@ using Ryujinx.Common.Logging; +using Ryujinx.HLE.HOS.Services.Sockets.Bsd.Proxy; using Ryujinx.HLE.HOS.Services.Sockets.Bsd.Types; using System.Collections.Generic; using System.Net.Sockets; @@ -26,45 +27,46 @@ namespace Ryujinx.HLE.HOS.Services.Sockets.Bsd.Impl public LinuxError Poll(List events, int timeoutMilliseconds, out int updatedCount) { - List readEvents = new(); - List writeEvents = new(); - List errorEvents = new(); + List readEvents = new(); + List writeEvents = new(); + List errorEvents = new(); updatedCount = 0; foreach (PollEvent evnt in events) { - ManagedSocket socket = (ManagedSocket)evnt.FileDescriptor; - - bool isValidEvent = evnt.Data.InputEvents == 0; - - errorEvents.Add(socket.Socket); - - if ((evnt.Data.InputEvents & PollEventTypeMask.Input) != 0) + if (evnt.FileDescriptor is ManagedSocket ms) { - readEvents.Add(socket.Socket); + bool isValidEvent = evnt.Data.InputEvents == 0; - isValidEvent = true; - } + errorEvents.Add(ms.Socket); - if ((evnt.Data.InputEvents & PollEventTypeMask.UrgentInput) != 0) - { - readEvents.Add(socket.Socket); + if ((evnt.Data.InputEvents & PollEventTypeMask.Input) != 0) + { + readEvents.Add(ms.Socket); - isValidEvent = true; - } + isValidEvent = true; + } - if ((evnt.Data.InputEvents & PollEventTypeMask.Output) != 0) - { - writeEvents.Add(socket.Socket); + if ((evnt.Data.InputEvents & PollEventTypeMask.UrgentInput) != 0) + { + readEvents.Add(ms.Socket); - isValidEvent = true; - } + isValidEvent = true; + } - if (!isValidEvent) - { - Logger.Warning?.Print(LogClass.ServiceBsd, $"Unsupported Poll input event type: {evnt.Data.InputEvents}"); - return LinuxError.EINVAL; + if ((evnt.Data.InputEvents & PollEventTypeMask.Output) != 0) + { + writeEvents.Add(ms.Socket); + + isValidEvent = true; + } + + if (!isValidEvent) + { + Logger.Warning?.Print(LogClass.ServiceBsd, $"Unsupported Poll input event type: {evnt.Data.InputEvents}"); + return LinuxError.EINVAL; + } } } @@ -72,7 +74,7 @@ namespace Ryujinx.HLE.HOS.Services.Sockets.Bsd.Impl { int actualTimeoutMicroseconds = timeoutMilliseconds == -1 ? -1 : timeoutMilliseconds * 1000; - Socket.Select(readEvents, writeEvents, errorEvents, actualTimeoutMicroseconds); + SocketHelpers.Select(readEvents, writeEvents, errorEvents, actualTimeoutMicroseconds); } catch (SocketException exception) { @@ -81,34 +83,37 @@ namespace Ryujinx.HLE.HOS.Services.Sockets.Bsd.Impl foreach (PollEvent evnt in events) { - Socket socket = ((ManagedSocket)evnt.FileDescriptor).Socket; - - PollEventTypeMask outputEvents = evnt.Data.OutputEvents & ~evnt.Data.InputEvents; - - if (errorEvents.Contains(socket)) + if (evnt.FileDescriptor is ManagedSocket ms) { - outputEvents |= PollEventTypeMask.Error; + ISocketImpl socket = ms.Socket; - if (!socket.Connected || !socket.IsBound) + PollEventTypeMask outputEvents = evnt.Data.OutputEvents & ~evnt.Data.InputEvents; + + if (errorEvents.Contains(ms.Socket)) { - outputEvents |= PollEventTypeMask.Disconnected; - } - } + outputEvents |= PollEventTypeMask.Error; - if (readEvents.Contains(socket)) - { - if ((evnt.Data.InputEvents & PollEventTypeMask.Input) != 0) + if (!socket.Connected || !socket.IsBound) + { + outputEvents |= PollEventTypeMask.Disconnected; + } + } + + if (readEvents.Contains(ms.Socket)) { - outputEvents |= PollEventTypeMask.Input; + if ((evnt.Data.InputEvents & PollEventTypeMask.Input) != 0) + { + outputEvents |= PollEventTypeMask.Input; + } } - } - if (writeEvents.Contains(socket)) - { - outputEvents |= PollEventTypeMask.Output; - } + if (writeEvents.Contains(ms.Socket)) + { + outputEvents |= PollEventTypeMask.Output; + } - evnt.Data.OutputEvents = outputEvents; + evnt.Data.OutputEvents = outputEvents; + } } updatedCount = readEvents.Count + writeEvents.Count + errorEvents.Count; @@ -118,53 +123,55 @@ namespace Ryujinx.HLE.HOS.Services.Sockets.Bsd.Impl public LinuxError Select(List events, int timeout, out int updatedCount) { - List readEvents = new(); - List writeEvents = new(); - List errorEvents = new(); + List readEvents = new(); + List writeEvents = new(); + List errorEvents = new(); updatedCount = 0; foreach (PollEvent pollEvent in events) { - ManagedSocket socket = (ManagedSocket)pollEvent.FileDescriptor; - - if (pollEvent.Data.InputEvents.HasFlag(PollEventTypeMask.Input)) + if (pollEvent.FileDescriptor is ManagedSocket ms) { - readEvents.Add(socket.Socket); - } + if (pollEvent.Data.InputEvents.HasFlag(PollEventTypeMask.Input)) + { + readEvents.Add(ms.Socket); + } - if (pollEvent.Data.InputEvents.HasFlag(PollEventTypeMask.Output)) - { - writeEvents.Add(socket.Socket); - } + if (pollEvent.Data.InputEvents.HasFlag(PollEventTypeMask.Output)) + { + writeEvents.Add(ms.Socket); + } - if (pollEvent.Data.InputEvents.HasFlag(PollEventTypeMask.Error)) - { - errorEvents.Add(socket.Socket); + if (pollEvent.Data.InputEvents.HasFlag(PollEventTypeMask.Error)) + { + errorEvents.Add(ms.Socket); + } } } - Socket.Select(readEvents, writeEvents, errorEvents, timeout); + SocketHelpers.Select(readEvents, writeEvents, errorEvents, timeout); updatedCount = readEvents.Count + writeEvents.Count + errorEvents.Count; foreach (PollEvent pollEvent in events) { - ManagedSocket socket = (ManagedSocket)pollEvent.FileDescriptor; - - if (readEvents.Contains(socket.Socket)) + if (pollEvent.FileDescriptor is ManagedSocket ms) { - pollEvent.Data.OutputEvents |= PollEventTypeMask.Input; - } + if (readEvents.Contains(ms.Socket)) + { + pollEvent.Data.OutputEvents |= PollEventTypeMask.Input; + } - if (writeEvents.Contains(socket.Socket)) - { - pollEvent.Data.OutputEvents |= PollEventTypeMask.Output; - } + if (writeEvents.Contains(ms.Socket)) + { + pollEvent.Data.OutputEvents |= PollEventTypeMask.Output; + } - if (errorEvents.Contains(socket.Socket)) - { - pollEvent.Data.OutputEvents |= PollEventTypeMask.Error; + if (errorEvents.Contains(ms.Socket)) + { + pollEvent.Data.OutputEvents |= PollEventTypeMask.Error; + } } } diff --git a/src/Ryujinx.HLE/HOS/Services/Sockets/Bsd/Proxy/DefaultSocket.cs b/src/Ryujinx.HLE/HOS/Services/Sockets/Bsd/Proxy/DefaultSocket.cs new file mode 100644 index 000000000..f1040e799 --- /dev/null +++ b/src/Ryujinx.HLE/HOS/Services/Sockets/Bsd/Proxy/DefaultSocket.cs @@ -0,0 +1,178 @@ +using Ryujinx.Common.Utilities; +using System; +using System.Net; +using System.Net.NetworkInformation; +using System.Net.Sockets; + +namespace Ryujinx.HLE.HOS.Services.Sockets.Bsd.Proxy +{ + class DefaultSocket : ISocketImpl + { + public Socket BaseSocket { get; } + + public EndPoint RemoteEndPoint => BaseSocket.RemoteEndPoint; + + public EndPoint LocalEndPoint => BaseSocket.LocalEndPoint; + + public bool Connected => BaseSocket.Connected; + + public bool IsBound => BaseSocket.IsBound; + + public AddressFamily AddressFamily => BaseSocket.AddressFamily; + + public SocketType SocketType => BaseSocket.SocketType; + + public ProtocolType ProtocolType => BaseSocket.ProtocolType; + + public bool Blocking { get => BaseSocket.Blocking; set => BaseSocket.Blocking = value; } + + public int Available => BaseSocket.Available; + + private readonly string _lanInterfaceId; + + public DefaultSocket(Socket baseSocket, string lanInterfaceId) + { + _lanInterfaceId = lanInterfaceId; + + BaseSocket = baseSocket; + } + + public DefaultSocket(AddressFamily domain, SocketType type, ProtocolType protocol, string lanInterfaceId) + { + _lanInterfaceId = lanInterfaceId; + + BaseSocket = new Socket(domain, type, protocol); + } + + private void EnsureNetworkInterfaceBound() + { + if (_lanInterfaceId != "0" && !BaseSocket.IsBound) + { + (_, UnicastIPAddressInformation ipInfo) = NetworkHelpers.GetLocalInterface(_lanInterfaceId); + + BaseSocket.Bind(new IPEndPoint(ipInfo.Address, 0)); + } + } + + public ISocketImpl Accept() + { + return new DefaultSocket(BaseSocket.Accept(), _lanInterfaceId); + } + + public void Bind(EndPoint localEP) + { + // NOTE: The guest is able to receive on 0.0.0.0 without it being limited to the chosen network interface. + // This is because it must get loopback traffic as well. This could allow other network traffic to leak in. + + BaseSocket.Bind(localEP); + } + + public void Close() + { + BaseSocket.Close(); + } + + public void Connect(EndPoint remoteEP) + { + EnsureNetworkInterfaceBound(); + + BaseSocket.Connect(remoteEP); + } + + public void Disconnect(bool reuseSocket) + { + BaseSocket.Disconnect(reuseSocket); + } + + public void GetSocketOption(SocketOptionLevel optionLevel, SocketOptionName optionName, byte[] optionValue) + { + BaseSocket.GetSocketOption(optionLevel, optionName, optionValue); + } + + public void Listen(int backlog) + { + BaseSocket.Listen(backlog); + } + + public int Receive(Span buffer) + { + EnsureNetworkInterfaceBound(); + + return BaseSocket.Receive(buffer); + } + + public int Receive(Span buffer, SocketFlags flags) + { + EnsureNetworkInterfaceBound(); + + return BaseSocket.Receive(buffer, flags); + } + + public int Receive(Span buffer, SocketFlags flags, out SocketError socketError) + { + EnsureNetworkInterfaceBound(); + + return BaseSocket.Receive(buffer, flags, out socketError); + } + + public int ReceiveFrom(Span buffer, SocketFlags flags, ref EndPoint remoteEP) + { + EnsureNetworkInterfaceBound(); + + return BaseSocket.ReceiveFrom(buffer, flags, ref remoteEP); + } + + public int Send(ReadOnlySpan buffer) + { + EnsureNetworkInterfaceBound(); + + return BaseSocket.Send(buffer); + } + + public int Send(ReadOnlySpan buffer, SocketFlags flags) + { + EnsureNetworkInterfaceBound(); + + return BaseSocket.Send(buffer, flags); + } + + public int Send(ReadOnlySpan buffer, SocketFlags flags, out SocketError socketError) + { + EnsureNetworkInterfaceBound(); + + return BaseSocket.Send(buffer, flags, out socketError); + } + + public int SendTo(ReadOnlySpan buffer, SocketFlags flags, EndPoint remoteEP) + { + EnsureNetworkInterfaceBound(); + + return BaseSocket.SendTo(buffer, flags, remoteEP); + } + + public bool Poll(int microSeconds, SelectMode mode) + { + return BaseSocket.Poll(microSeconds, mode); + } + + public void SetSocketOption(SocketOptionLevel optionLevel, SocketOptionName optionName, int optionValue) + { + BaseSocket.SetSocketOption(optionLevel, optionName, optionValue); + } + + public void SetSocketOption(SocketOptionLevel optionLevel, SocketOptionName optionName, object optionValue) + { + BaseSocket.SetSocketOption(optionLevel, optionName, optionValue); + } + + public void Shutdown(SocketShutdown how) + { + BaseSocket.Shutdown(how); + } + + public void Dispose() + { + BaseSocket.Dispose(); + } + } +} diff --git a/src/Ryujinx.HLE/HOS/Services/Sockets/Bsd/Proxy/ISocket.cs b/src/Ryujinx.HLE/HOS/Services/Sockets/Bsd/Proxy/ISocket.cs new file mode 100644 index 000000000..b7055f08b --- /dev/null +++ b/src/Ryujinx.HLE/HOS/Services/Sockets/Bsd/Proxy/ISocket.cs @@ -0,0 +1,47 @@ +using System; +using System.Net; +using System.Net.Sockets; + +namespace Ryujinx.HLE.HOS.Services.Sockets.Bsd.Proxy +{ + interface ISocketImpl : IDisposable + { + EndPoint RemoteEndPoint { get; } + EndPoint LocalEndPoint { get; } + bool Connected { get; } + bool IsBound { get; } + + AddressFamily AddressFamily { get; } + SocketType SocketType { get; } + ProtocolType ProtocolType { get; } + + bool Blocking { get; set; } + int Available { get; } + + int Receive(Span buffer); + int Receive(Span buffer, SocketFlags flags); + int Receive(Span buffer, SocketFlags flags, out SocketError socketError); + int ReceiveFrom(Span buffer, SocketFlags flags, ref EndPoint remoteEP); + + int Send(ReadOnlySpan buffer); + int Send(ReadOnlySpan buffer, SocketFlags flags); + int Send(ReadOnlySpan buffer, SocketFlags flags, out SocketError socketError); + int SendTo(ReadOnlySpan buffer, SocketFlags flags, EndPoint remoteEP); + + bool Poll(int microSeconds, SelectMode mode); + + ISocketImpl Accept(); + + void Bind(EndPoint localEP); + void Connect(EndPoint remoteEP); + void Listen(int backlog); + + void GetSocketOption(SocketOptionLevel optionLevel, SocketOptionName optionName, byte[] optionValue); + void SetSocketOption(SocketOptionLevel optionLevel, SocketOptionName optionName, int optionValue); + void SetSocketOption(SocketOptionLevel optionLevel, SocketOptionName optionName, object optionValue); + + void Shutdown(SocketShutdown how); + void Disconnect(bool reuseSocket); + void Close(); + } +} diff --git a/src/Ryujinx.HLE/HOS/Services/Sockets/Bsd/Proxy/SocketHelpers.cs b/src/Ryujinx.HLE/HOS/Services/Sockets/Bsd/Proxy/SocketHelpers.cs new file mode 100644 index 000000000..485a7f86b --- /dev/null +++ b/src/Ryujinx.HLE/HOS/Services/Sockets/Bsd/Proxy/SocketHelpers.cs @@ -0,0 +1,74 @@ +using Ryujinx.HLE.HOS.Services.Ldn.UserServiceCreator.LdnRyu.Proxy; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Sockets; + +namespace Ryujinx.HLE.HOS.Services.Sockets.Bsd.Proxy +{ + static class SocketHelpers + { + private static LdnProxy _proxy; + + public static void Select(List readEvents, List writeEvents, List errorEvents, int timeout) + { + var readDefault = readEvents.Select(x => (x as DefaultSocket)?.BaseSocket).Where(x => x != null).ToList(); + var writeDefault = writeEvents.Select(x => (x as DefaultSocket)?.BaseSocket).Where(x => x != null).ToList(); + var errorDefault = errorEvents.Select(x => (x as DefaultSocket)?.BaseSocket).Where(x => x != null).ToList(); + + if (readDefault.Count != 0 || writeDefault.Count != 0 || errorDefault.Count != 0) + { + Socket.Select(readDefault, writeDefault, errorDefault, timeout); + } + + void FilterSockets(List removeFrom, List selectedSockets, Func ldnCheck) + { + removeFrom.RemoveAll(socket => + { + switch (socket) + { + case DefaultSocket dsocket: + return !selectedSockets.Contains(dsocket.BaseSocket); + case LdnProxySocket psocket: + return !ldnCheck(psocket); + default: + throw new NotImplementedException(); + } + }); + }; + + FilterSockets(readEvents, readDefault, (socket) => socket.Readable); + FilterSockets(writeEvents, writeDefault, (socket) => socket.Writable); + FilterSockets(errorEvents, errorDefault, (socket) => socket.Error); + } + + public static void RegisterProxy(LdnProxy proxy) + { + if (_proxy != null) + { + UnregisterProxy(); + } + + _proxy = proxy; + } + + public static void UnregisterProxy() + { + _proxy?.Dispose(); + _proxy = null; + } + + public static ISocketImpl CreateSocket(AddressFamily domain, SocketType type, ProtocolType protocol, string lanInterfaceId) + { + if (_proxy != null) + { + if (_proxy.Supported(domain, type, protocol)) + { + return new LdnProxySocket(domain, type, protocol, _proxy); + } + } + + return new DefaultSocket(domain, type, protocol, lanInterfaceId); + } + } +} diff --git a/src/Ryujinx.HLE/HOS/Services/Sockets/Sfdnsres/IResolver.cs b/src/Ryujinx.HLE/HOS/Services/Sockets/Sfdnsres/IResolver.cs index 39af90383..5b2de13f0 100644 --- a/src/Ryujinx.HLE/HOS/Services/Sockets/Sfdnsres/IResolver.cs +++ b/src/Ryujinx.HLE/HOS/Services/Sockets/Sfdnsres/IResolver.cs @@ -292,7 +292,7 @@ namespace Ryujinx.HLE.HOS.Services.Sockets.Sfdnsres { string host = MemoryHelper.ReadAsciiString(context.Memory, inputBufferPosition, (int)inputBufferSize); - if (!context.Device.Configuration.EnableInternetAccess) + if (host != "localhost" && !context.Device.Configuration.EnableInternetAccess) { Logger.Info?.Print(LogClass.ServiceSfdnsres, $"Guest network access disabled, DNS Blocked: {host}"); diff --git a/src/Ryujinx.HLE/HOS/Services/Ssl/SslService/SslManagedSocketConnection.cs b/src/Ryujinx.HLE/HOS/Services/Ssl/SslService/SslManagedSocketConnection.cs index 8cc761baf..dc33dd6a5 100644 --- a/src/Ryujinx.HLE/HOS/Services/Ssl/SslService/SslManagedSocketConnection.cs +++ b/src/Ryujinx.HLE/HOS/Services/Ssl/SslService/SslManagedSocketConnection.cs @@ -1,5 +1,6 @@ using Ryujinx.HLE.HOS.Services.Sockets.Bsd; using Ryujinx.HLE.HOS.Services.Sockets.Bsd.Impl; +using Ryujinx.HLE.HOS.Services.Sockets.Bsd.Proxy; using Ryujinx.HLE.HOS.Services.Ssl.Types; using System; using System.IO; @@ -116,7 +117,7 @@ namespace Ryujinx.HLE.HOS.Services.Ssl.SslService public ResultCode Handshake(string hostName) { StartSslOperation(); - _stream = new SslStream(new NetworkStream(((ManagedSocket)Socket).Socket, false), false, null, null); + _stream = new SslStream(new NetworkStream(((DefaultSocket)((ManagedSocket)Socket).Socket).BaseSocket, false), false, null, null); hostName = RetrieveHostName(hostName); _stream.AuthenticateAsClient(hostName, null, TranslateSslVersion(_sslVersion), false); EndSslOperation(); diff --git a/src/Ryujinx.HLE/Loaders/Processes/ProcessResult.cs b/src/Ryujinx.HLE/Loaders/Processes/ProcessResult.cs index 10561a5a1..e187b2360 100644 --- a/src/Ryujinx.HLE/Loaders/Processes/ProcessResult.cs +++ b/src/Ryujinx.HLE/Loaders/Processes/ProcessResult.cs @@ -85,8 +85,8 @@ namespace Ryujinx.HLE.Loaders.Processes } // TODO: LibHac npdm currently doesn't support version field. - string version = ProgramId > 0x0100000000007FFF - ? DisplayVersion + string version = ProgramId > 0x0100000000007FFF + ? DisplayVersion : device.System.ContentManager.GetCurrentFirmwareVersion()?.VersionString ?? "?"; Logger.Info?.Print(LogClass.Loader, $"Application Loaded: {Name} v{version} [{ProgramIdText}] [{(Is64Bit ? "64-bit" : "32-bit")}]"); diff --git a/src/Ryujinx.HLE/Ryujinx.HLE.csproj b/src/Ryujinx.HLE/Ryujinx.HLE.csproj index a7bb3cd7f..5f7f6db69 100644 --- a/src/Ryujinx.HLE/Ryujinx.HLE.csproj +++ b/src/Ryujinx.HLE/Ryujinx.HLE.csproj @@ -29,6 +29,7 @@ + diff --git a/src/Ryujinx.Headless.SDL2/Program.cs b/src/Ryujinx.Headless.SDL2/Program.cs index 78cdb7718..e3bbd1e51 100644 --- a/src/Ryujinx.Headless.SDL2/Program.cs +++ b/src/Ryujinx.Headless.SDL2/Program.cs @@ -577,7 +577,10 @@ namespace Ryujinx.Headless.SDL2 options.AudioVolume, options.UseHypervisor ?? true, options.MultiplayerLanInterfaceId, - Common.Configuration.Multiplayer.MultiplayerMode.Disabled); + Common.Configuration.Multiplayer.MultiplayerMode.Disabled, + false, + "", + ""); return new Switch(configuration); } diff --git a/src/Ryujinx.UI.Common/App/ApplicationData.cs b/src/Ryujinx.UI.Common/App/ApplicationData.cs index 4c1c1a043..7aa0dccaa 100644 --- a/src/Ryujinx.UI.Common/App/ApplicationData.cs +++ b/src/Ryujinx.UI.Common/App/ApplicationData.cs @@ -27,6 +27,8 @@ namespace Ryujinx.UI.App.Common public ulong Id { get; set; } public string Developer { get; set; } = "Unknown"; public string Version { get; set; } = "0"; + public int PlayerCount { get; set; } + public int GameCount { get; set; } public TimeSpan TimePlayed { get; set; } public DateTime? LastPlayed { get; set; } public string FileExtension { get; set; } diff --git a/src/Ryujinx.UI.Common/App/ApplicationLibrary.cs b/src/Ryujinx.UI.Common/App/ApplicationLibrary.cs index 044eccbea..174db51ad 100644 --- a/src/Ryujinx.UI.Common/App/ApplicationLibrary.cs +++ b/src/Ryujinx.UI.Common/App/ApplicationLibrary.cs @@ -12,6 +12,7 @@ using LibHac.Tools.Fs; using LibHac.Tools.FsSystem; using LibHac.Tools.FsSystem.NcaUtils; using Ryujinx.Common.Configuration; +using Ryujinx.Common.Configuration.Multiplayer; using Ryujinx.Common.Logging; using Ryujinx.Common.Utilities; using Ryujinx.HLE.FileSystem; @@ -27,10 +28,12 @@ using System; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Net.Http; using System.Reflection; using System.Text; using System.Text.Json; using System.Threading; +using System.Threading.Tasks; using ContentType = LibHac.Ncm.ContentType; using MissingKeyException = LibHac.Common.Keys.MissingKeyException; using Path = System.IO.Path; @@ -41,8 +44,10 @@ namespace Ryujinx.UI.App.Common { public class ApplicationLibrary { + public static string DefaultLanPlayWebHost = "ryuldnweb.vudjun.com"; public Language DesiredLanguage { get; set; } public event EventHandler ApplicationCountUpdated; + public event EventHandler LdnGameDataReceived; public readonly IObservableCache Applications; public readonly IObservableCache<(TitleUpdateModel TitleUpdate, bool IsSelected), TitleUpdateModel> TitleUpdates; @@ -62,6 +67,7 @@ namespace Ryujinx.UI.App.Common private readonly SourceCache<(DownloadableContentModel Dlc, bool IsEnabled), DownloadableContentModel> _downloadableContents = new(it => it.Dlc); private static readonly ApplicationJsonSerializerContext _serializerContext = new(JsonHelper.GetDefaultSerializerOptions()); + private static readonly LdnGameDataSerializerContext _ldnDataSerializerContext = new(JsonHelper.GetDefaultSerializerOptions()); public ApplicationLibrary(VirtualFileSystem virtualFileSystem, IntegrityCheckLevel checkLevel) { @@ -687,7 +693,7 @@ namespace Ryujinx.UI.App.Common (Path.GetExtension(file).ToLower() is ".pfs0" && ConfigurationState.Instance.UI.ShownFileTypes.PFS0) || (Path.GetExtension(file).ToLower() is ".xci" && ConfigurationState.Instance.UI.ShownFileTypes.XCI) || (Path.GetExtension(file).ToLower() is ".nca" && ConfigurationState.Instance.UI.ShownFileTypes.NCA) || - (Path.GetExtension(file).ToLower() is ".nro" && ConfigurationState.Instance.UI.ShownFileTypes.NRO) || + (Path.GetExtension(file).ToLower() is ".nro" && ConfigurationState.Instance.UI.ShownFileTypes.NRO) || (Path.GetExtension(file).ToLower() is ".nso" && ConfigurationState.Instance.UI.ShownFileTypes.NSO) ); @@ -719,6 +725,7 @@ namespace Ryujinx.UI.App.Common } } + // Loops through applications list, creating a struct and then firing an event containing the struct for each application foreach (string applicationPath in applicationPaths) { @@ -775,6 +782,46 @@ namespace Ryujinx.UI.App.Common } } + public async Task RefreshLdn() + { + + if (ConfigurationState.Instance.Multiplayer.Mode == MultiplayerMode.LdnRyu) + { + try + { + string ldnWebHost = ConfigurationState.Instance.Multiplayer.LdnServer; + if (string.IsNullOrEmpty(ldnWebHost)) + { + ldnWebHost = DefaultLanPlayWebHost; + } + IEnumerable ldnGameDataArray = Array.Empty(); + using HttpClient httpClient = new HttpClient(); + string ldnGameDataArrayString = await httpClient.GetStringAsync($"https://{ldnWebHost}/api/public_games"); + ldnGameDataArray = JsonHelper.Deserialize(ldnGameDataArrayString, _ldnDataSerializerContext.IEnumerableLdnGameData); + var evt = new LdnGameDataReceivedEventArgs + { + LdnData = ldnGameDataArray + }; + LdnGameDataReceived?.Invoke(null, evt); + } + catch (Exception ex) + { + Logger.Warning?.Print(LogClass.Application, $"Failed to fetch the public games JSON from the API. Player and game count in the game list will be unavailable.\n{ex.Message}"); + LdnGameDataReceived?.Invoke(null, new LdnGameDataReceivedEventArgs() + { + LdnData = Array.Empty() + }); + } + } + else + { + LdnGameDataReceived?.Invoke(null, new LdnGameDataReceivedEventArgs() + { + LdnData = Array.Empty() + }); + } + } + // Replace the currently stored DLC state for the game with the provided DLC state. public void SaveDownloadableContentsForGame(ApplicationData application, List<(DownloadableContentModel, bool IsEnabled)> dlcs) { diff --git a/src/Ryujinx.UI.Common/App/LdnGameData.cs b/src/Ryujinx.UI.Common/App/LdnGameData.cs new file mode 100644 index 000000000..6c784c991 --- /dev/null +++ b/src/Ryujinx.UI.Common/App/LdnGameData.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; + +namespace Ryujinx.UI.App.Common +{ + public struct LdnGameData + { + public string Id { get; set; } + public int PlayerCount { get; set; } + public int MaxPlayerCount { get; set; } + public string GameName { get; set; } + public string TitleId { get; set; } + public string Mode { get; set; } + public string Status { get; set; } + public IEnumerable Players { get; set; } + } +} diff --git a/src/Ryujinx.UI.Common/App/LdnGameDataReceivedEventArgs.cs b/src/Ryujinx.UI.Common/App/LdnGameDataReceivedEventArgs.cs new file mode 100644 index 000000000..7c7454411 --- /dev/null +++ b/src/Ryujinx.UI.Common/App/LdnGameDataReceivedEventArgs.cs @@ -0,0 +1,10 @@ +using System; +using System.Collections.Generic; + +namespace Ryujinx.UI.App.Common +{ + public class LdnGameDataReceivedEventArgs : EventArgs + { + public IEnumerable LdnData { get; set; } + } +} diff --git a/src/Ryujinx.UI.Common/App/LdnGameDataSerializerContext.cs b/src/Ryujinx.UI.Common/App/LdnGameDataSerializerContext.cs new file mode 100644 index 000000000..ce8edcdb6 --- /dev/null +++ b/src/Ryujinx.UI.Common/App/LdnGameDataSerializerContext.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Ryujinx.UI.App.Common +{ + [JsonSerializable(typeof(IEnumerable))] + internal partial class LdnGameDataSerializerContext : JsonSerializerContext + { + + } +} diff --git a/src/Ryujinx.UI.Common/Configuration/ConfigurationFileFormat.cs b/src/Ryujinx.UI.Common/Configuration/ConfigurationFileFormat.cs index 77c6346f2..80ba1b186 100644 --- a/src/Ryujinx.UI.Common/Configuration/ConfigurationFileFormat.cs +++ b/src/Ryujinx.UI.Common/Configuration/ConfigurationFileFormat.cs @@ -392,6 +392,21 @@ namespace Ryujinx.UI.Common.Configuration /// public string MultiplayerLanInterfaceId { get; set; } + /// + /// Disable P2p Toggle + /// + public bool MultiplayerDisableP2p { get; set; } + + /// + /// Local network passphrase, for private networks. + /// + public string MultiplayerLdnPassphrase { get; set; } + + /// + /// Custom LDN Server + /// + public string LdnServer { get; set; } + /// /// Uses Hypervisor over JIT if available /// diff --git a/src/Ryujinx.UI.Common/Configuration/ConfigurationState.Migration.cs b/src/Ryujinx.UI.Common/Configuration/ConfigurationState.Migration.cs index 18326a3a4..65dd88106 100644 --- a/src/Ryujinx.UI.Common/Configuration/ConfigurationState.Migration.cs +++ b/src/Ryujinx.UI.Common/Configuration/ConfigurationState.Migration.cs @@ -1,4 +1,4 @@ -๏ปฟusing Ryujinx.Common.Configuration; +using Ryujinx.Common.Configuration; using Ryujinx.Common.Configuration.Hid; using Ryujinx.Common.Configuration.Hid.Controller; using Ryujinx.Common.Configuration.Hid.Keyboard; @@ -703,6 +703,9 @@ namespace Ryujinx.UI.Common.Configuration Multiplayer.LanInterfaceId.Value = configurationFileFormat.MultiplayerLanInterfaceId; Multiplayer.Mode.Value = configurationFileFormat.MultiplayerMode; + Multiplayer.DisableP2p.Value = configurationFileFormat.MultiplayerDisableP2p; + Multiplayer.LdnPassphrase.Value = configurationFileFormat.MultiplayerLdnPassphrase; + Multiplayer.LdnServer.Value = configurationFileFormat.LdnServer; if (configurationFileUpdated) { diff --git a/src/Ryujinx.UI.Common/Configuration/ConfigurationState.Model.cs b/src/Ryujinx.UI.Common/Configuration/ConfigurationState.Model.cs index 2b43b2032..9be8f4df7 100644 --- a/src/Ryujinx.UI.Common/Configuration/ConfigurationState.Model.cs +++ b/src/Ryujinx.UI.Common/Configuration/ConfigurationState.Model.cs @@ -13,7 +13,7 @@ namespace Ryujinx.UI.Common.Configuration { public partial class ConfigurationState { - /// + /// /// UI configuration section /// public class UISection @@ -25,6 +25,7 @@ namespace Ryujinx.UI.Common.Configuration public ReactiveObject AppColumn { get; private set; } public ReactiveObject DevColumn { get; private set; } public ReactiveObject VersionColumn { get; private set; } + public ReactiveObject LdnInfoColumn { get; private set; } public ReactiveObject TimePlayedColumn { get; private set; } public ReactiveObject LastPlayedColumn { get; private set; } public ReactiveObject FileExtColumn { get; private set; } @@ -38,6 +39,7 @@ namespace Ryujinx.UI.Common.Configuration AppColumn = new ReactiveObject(); DevColumn = new ReactiveObject(); VersionColumn = new ReactiveObject(); + LdnInfoColumn = new ReactiveObject(); TimePlayedColumn = new ReactiveObject(); LastPlayedColumn = new ReactiveObject(); FileExtColumn = new ReactiveObject(); @@ -572,11 +574,32 @@ namespace Ryujinx.UI.Common.Configuration /// public ReactiveObject Mode { get; private set; } + /// + /// Disable P2P + /// + public ReactiveObject DisableP2p { get; private set; } + + /// + /// LDN PassPhrase + /// + public ReactiveObject LdnPassphrase { get; private set; } + + /// + /// LDN Server + /// + public ReactiveObject LdnServer { get; private set; } + public MultiplayerSection() { LanInterfaceId = new ReactiveObject(); Mode = new ReactiveObject(); Mode.LogChangesToValue(nameof(MultiplayerMode)); + DisableP2p = new ReactiveObject(); + DisableP2p.LogChangesToValue(nameof(DisableP2p)); + LdnPassphrase = new ReactiveObject(); + LdnPassphrase.LogChangesToValue(nameof(LdnPassphrase)); + LdnServer = new ReactiveObject(); + LdnServer.LogChangesToValue(nameof(LdnServer)); } } diff --git a/src/Ryujinx.UI.Common/Configuration/ConfigurationState.cs b/src/Ryujinx.UI.Common/Configuration/ConfigurationState.cs index 6fd5cfb93..b3012568e 100644 --- a/src/Ryujinx.UI.Common/Configuration/ConfigurationState.cs +++ b/src/Ryujinx.UI.Common/Configuration/ConfigurationState.cs @@ -21,11 +21,11 @@ namespace Ryujinx.UI.Common.Configuration if (Instance != null) { throw new InvalidOperationException("Configuration is already initialized"); - } + } Instance = new ConfigurationState(); - } - + } + public ConfigurationFileFormat ToFileFormat() { ConfigurationFileFormat configurationFile = new() @@ -87,6 +87,7 @@ namespace Ryujinx.UI.Common.Configuration AppColumn = UI.GuiColumns.AppColumn, DevColumn = UI.GuiColumns.DevColumn, VersionColumn = UI.GuiColumns.VersionColumn, + LdnInfoColumn = UI.GuiColumns.LdnInfoColumn, TimePlayedColumn = UI.GuiColumns.TimePlayedColumn, LastPlayedColumn = UI.GuiColumns.LastPlayedColumn, FileExtColumn = UI.GuiColumns.FileExtColumn, @@ -136,6 +137,9 @@ namespace Ryujinx.UI.Common.Configuration PreferredGpu = Graphics.PreferredGpu, MultiplayerLanInterfaceId = Multiplayer.LanInterfaceId, MultiplayerMode = Multiplayer.Mode, + MultiplayerDisableP2p = Multiplayer.DisableP2p, + MultiplayerLdnPassphrase = Multiplayer.LdnPassphrase, + LdnServer = Multiplayer.LdnServer, }; return configurationFile; @@ -195,6 +199,9 @@ namespace Ryujinx.UI.Common.Configuration System.UseHypervisor.Value = true; Multiplayer.LanInterfaceId.Value = "0"; Multiplayer.Mode.Value = MultiplayerMode.Disabled; + Multiplayer.DisableP2p.Value = false; + Multiplayer.LdnPassphrase.Value = ""; + Multiplayer.LdnServer.Value = ""; UI.GuiColumns.FavColumn.Value = true; UI.GuiColumns.IconColumn.Value = true; UI.GuiColumns.AppColumn.Value = true; @@ -307,5 +314,5 @@ namespace Ryujinx.UI.Common.Configuration return GraphicsBackend.OpenGl; } - } -} + } + } diff --git a/src/Ryujinx.UI.Common/Configuration/LoggerModule.cs b/src/Ryujinx.UI.Common/Configuration/LoggerModule.cs index 7ac505349..a7913f142 100644 --- a/src/Ryujinx.UI.Common/Configuration/LoggerModule.cs +++ b/src/Ryujinx.UI.Common/Configuration/LoggerModule.cs @@ -21,7 +21,7 @@ namespace Ryujinx.UI.Common.Configuration ConfigurationState.Instance.Logger.EnableError.Event += (_, e) => Logger.SetEnable(LogLevel.Error, e.NewValue); ConfigurationState.Instance.Logger.EnableTrace.Event += - (_, e) => Logger.SetEnable(LogLevel.Error, e.NewValue); + (_, e) => Logger.SetEnable(LogLevel.Trace, e.NewValue); ConfigurationState.Instance.Logger.EnableGuest.Event += (_, e) => Logger.SetEnable(LogLevel.Guest, e.NewValue); ConfigurationState.Instance.Logger.EnableFsAccessLog.Event += diff --git a/src/Ryujinx.UI.Common/Configuration/UI/GuiColumns.cs b/src/Ryujinx.UI.Common/Configuration/UI/GuiColumns.cs index c778ef1f1..c486492e0 100644 --- a/src/Ryujinx.UI.Common/Configuration/UI/GuiColumns.cs +++ b/src/Ryujinx.UI.Common/Configuration/UI/GuiColumns.cs @@ -7,6 +7,7 @@ namespace Ryujinx.UI.Common.Configuration.UI public bool AppColumn { get; set; } public bool DevColumn { get; set; } public bool VersionColumn { get; set; } + public bool LdnInfoColumn { get; set; } public bool TimePlayedColumn { get; set; } public bool LastPlayedColumn { get; set; } public bool FileExtColumn { get; set; } diff --git a/src/Ryujinx.UI.Common/DiscordIntegrationModule.cs b/src/Ryujinx.UI.Common/DiscordIntegrationModule.cs index 780d94249..d4b2a4187 100644 --- a/src/Ryujinx.UI.Common/DiscordIntegrationModule.cs +++ b/src/Ryujinx.UI.Common/DiscordIntegrationModule.cs @@ -1,11 +1,10 @@ using DiscordRPC; using Humanizer; -using LibHac.Bcat; +using Humanizer.Localisation; using Ryujinx.Common; using Ryujinx.HLE.Loaders.Processes; using Ryujinx.UI.App.Common; using Ryujinx.UI.Common.Configuration; -using System.Collections.Generic; using System.Linq; using System.Text; @@ -20,7 +19,7 @@ namespace Ryujinx.UI.Common private static readonly string _description = ReleaseInformation.IsValid - ? $"{VersionString} {ReleaseInformation.ReleaseChannelOwner}/{ReleaseInformation.ReleaseChannelRepo}@{ReleaseInformation.BuildGitHash}" + ? $"{VersionString} {ReleaseInformation.ReleaseChannelOwner}/{ReleaseInformation.ReleaseChannelSourceRepo}@{ReleaseInformation.BuildGitHash}" : "dev build"; private const string ApplicationId = "1293250299716173864"; @@ -78,13 +77,13 @@ namespace Ryujinx.UI.Common Assets = new Assets { LargeImageKey = _discordGameAssetKeys.Contains(procRes.ProgramIdText) ? procRes.ProgramIdText : "game", - LargeImageText = TruncateToByteLength($"{appMeta.Title} | {procRes.DisplayVersion}"), + LargeImageText = TruncateToByteLength($"{appMeta.Title} (v{procRes.DisplayVersion})"), SmallImageKey = "ryujinx", SmallImageText = TruncateToByteLength(_description) }, Details = TruncateToByteLength($"Playing {appMeta.Title}"), State = appMeta.LastPlayed.HasValue && appMeta.TimePlayed.TotalSeconds > 5 - ? $"Total play time: {appMeta.TimePlayed.Humanize(2, false)}" + ? $"Total play time: {appMeta.TimePlayed.Humanize(2, false, maxUnit: TimeUnit.Hour)}" : "Never played", Timestamps = Timestamps.Now }); diff --git a/src/Ryujinx/AppHost.cs b/src/Ryujinx/AppHost.cs index dc4f4ff36..7246be4b9 100644 --- a/src/Ryujinx/AppHost.cs +++ b/src/Ryujinx/AppHost.cs @@ -207,6 +207,9 @@ namespace Ryujinx.Ava ConfigurationState.Instance.System.EnableInternetAccess.Event += UpdateEnableInternetAccessState; ConfigurationState.Instance.Multiplayer.LanInterfaceId.Event += UpdateLanInterfaceIdState; ConfigurationState.Instance.Multiplayer.Mode.Event += UpdateMultiplayerModeState; + ConfigurationState.Instance.Multiplayer.LdnPassphrase.Event += UpdateLdnPassphraseState; + ConfigurationState.Instance.Multiplayer.LdnServer.Event += UpdateLdnServerState; + ConfigurationState.Instance.Multiplayer.DisableP2p.Event += UpdateDisableP2pState; _gpuCancellationTokenSource = new CancellationTokenSource(); _gpuDoneEvent = new ManualResetEvent(false); @@ -491,6 +494,21 @@ namespace Ryujinx.Ava Device.Configuration.MultiplayerMode = e.NewValue; } + private void UpdateLdnPassphraseState(object sender, ReactiveEventArgs e) + { + Device.Configuration.MultiplayerLdnPassphrase = e.NewValue; + } + + private void UpdateLdnServerState(object sender, ReactiveEventArgs e) + { + Device.Configuration.MultiplayerLdnServer = e.NewValue; + } + + private void UpdateDisableP2pState(object sender, ReactiveEventArgs e) + { + Device.Configuration.MultiplayerDisableP2p = e.NewValue; + } + public void ToggleVSync() { Device.EnableDeviceVsync = !Device.EnableDeviceVsync; @@ -863,10 +881,11 @@ namespace Ryujinx.Ava ConfigurationState.Instance.Graphics.AspectRatio, ConfigurationState.Instance.System.AudioVolume, ConfigurationState.Instance.System.UseHypervisor, - ConfigurationState.Instance.Multiplayer.LanInterfaceId, - ConfigurationState.Instance.Multiplayer.Mode - ) - ); + ConfigurationState.Instance.Multiplayer.LanInterfaceId.Value, + ConfigurationState.Instance.Multiplayer.Mode, + ConfigurationState.Instance.Multiplayer.DisableP2p, + ConfigurationState.Instance.Multiplayer.LdnPassphrase, + ConfigurationState.Instance.Multiplayer.LdnServer)); } private static IHardwareDeviceDriver InitializeAudio() @@ -1050,7 +1069,7 @@ namespace Ryujinx.Ava string dockedMode = ConfigurationState.Instance.System.EnableDockedMode ? LocaleManager.Instance[LocaleKeys.Docked] : LocaleManager.Instance[LocaleKeys.Handheld]; UpdateShaderCount(); - + if (GraphicsConfig.ResScale != 1) { dockedMode += $" ({GraphicsConfig.ResScale}x)"; diff --git a/src/Ryujinx/Assets/Locales/en_US.json b/src/Ryujinx/Assets/Locales/en_US.json index 88eb43ce4..fdd2d4df2 100644 --- a/src/Ryujinx/Assets/Locales/en_US.json +++ b/src/Ryujinx/Assets/Locales/en_US.json @@ -848,5 +848,17 @@ "MultiplayerMode": "Mode:", "MultiplayerModeTooltip": "Change LDN multiplayer mode.\n\nLdnMitm will modify local wireless/local play functionality in games to function as if it were LAN, allowing for local, same-network connections with other Ryujinx instances and hacked Nintendo Switch consoles that have the ldn_mitm module installed.\n\nMultiplayer requires all players to be on the same game version (i.e. Super Smash Bros. Ultimate v13.0.1 can't connect to v13.0.0).\n\nLeave DISABLED if unsure.", "MultiplayerModeDisabled": "Disabled", - "MultiplayerModeLdnMitm": "ldn_mitm" + "MultiplayerModeLdnMitm": "ldn_mitm", + "MultiplayerModeLdnRyu": "RyuLDN", + "MultiplayerDisableP2P": "Disable P2P Network Hosting (may increase latency)", + "MultiplayerDisableP2PTooltip": "Disable P2P network hosting, peers will proxy through the master server instead of connecting to you directly.", + "LdnPassphrase": "Network Passphrase:", + "LdnPassphraseTooltip": "You will only be able to see hosted games with the same passphrase as you.", + "LdnPassphraseInputTooltip": "Enter a passphrase in the format Ryujinx-<8 hex chars>. You will only be able to see hosted games with the same passphrase as you.", + "LdnPassphraseInputPublic": "(public)", + "GenLdnPass": "Generate Random", + "GenLdnPassTooltip": "Generates a new passphrase, which can be shared with other players.", + "ClearLdnPass": "Clear", + "ClearLdnPassTooltip": "Clears the current passphrase, returning to the public network.", + "InvalidLdnPassphrase": "Invalid Passphrase! Must be in the format \"Ryujinx-<8 hex chars>\"" } diff --git a/src/Ryujinx/Assets/Locales/ko_KR.json b/src/Ryujinx/Assets/Locales/ko_KR.json index 2a62c415d..bcac40138 100644 --- a/src/Ryujinx/Assets/Locales/ko_KR.json +++ b/src/Ryujinx/Assets/Locales/ko_KR.json @@ -2,97 +2,101 @@ "Language": "ํ•œ๊ตญ์–ด", "MenuBarFileOpenApplet": "์• ํ”Œ๋ฆฟ ์—ด๊ธฐ", "MenuBarFileOpenAppletOpenMiiAppletToolTip": "๋…๋ฆฝ ์‹คํ–‰ํ˜• ๋ชจ๋“œ๋กœ Mii ํŽธ์ง‘๊ธฐ ์• ํ”Œ๋ฆฟ ์—ด๊ธฐ", - "SettingsTabInputDirectMouseAccess": "๋‹ค์ด๋ ‰ํŠธ ๋งˆ์šฐ์Šค ์ ‘๊ทผ", - "SettingsTabSystemMemoryManagerMode": "๋ฉ”๋ชจ๋ฆฌ ๊ด€๋ฆฌ์ž ๋ชจ๋“œ:", + "SettingsTabInputDirectMouseAccess": "๋งˆ์šฐ์Šค ์ง์ ‘ ์ ‘๊ทผ", + "SettingsTabSystemMemoryManagerMode": "๋ฉ”๋ชจ๋ฆฌ ๊ด€๋ฆฌ์ž ๋ชจ๋“œ :", "SettingsTabSystemMemoryManagerModeSoftware": "์†Œํ”„ํŠธ์›จ์–ด", - "SettingsTabSystemMemoryManagerModeHost": "ํ˜ธ์ŠคํŠธ (๋น ๋ฆ„)", - "SettingsTabSystemMemoryManagerModeHostUnchecked": "ํ˜ธ์ŠคํŠธ ํ™•์ธ ์•ˆํ•จ (๊ฐ€์žฅ ๋น ๋ฅด๋‚˜ ์•ˆ์ „ํ•˜์ง€ ์•Š์Œ)", - "SettingsTabSystemUseHypervisor": "ํ•˜์ดํผ๋ฐ”์ด์ € ์‚ฌ์šฉํ•˜๊ธฐ", - "MenuBarFile": "_ํŒŒ์ผ", - "MenuBarFileOpenFromFile": "_ํŒŒ์ผ์—์„œ ์‘์šฉ ํ”„๋กœ๊ทธ๋žจ ๋ถˆ๋Ÿฌ์˜ค๊ธฐ", - "MenuBarFileOpenFromFileError": "No applications found in selected file.", - "MenuBarFileOpenUnpacked": "_์••์ถ•์„ ํ‘ผ ๊ฒŒ์ž„ ๋ถˆ๋Ÿฌ์˜ค๊ธฐ", - "MenuBarFileLoadDlcFromFolder": "Load DLC From Folder", - "MenuBarFileLoadTitleUpdatesFromFolder": "Load Title Updates From Folder", + "SettingsTabSystemMemoryManagerModeHost": "ํ˜ธ์ŠคํŠธ(๋น ๋ฆ„)", + "SettingsTabSystemMemoryManagerModeHostUnchecked": "ํ˜ธ์ŠคํŠธ ํ™•์ธ ์•ˆํ•จ(๊ฐ€์žฅ ๋น ๋ฅด๋‚˜ ์œ„ํ—˜)", + "SettingsTabSystemUseHypervisor": "ํ•˜์ดํผ๋ฐ”์ด์ € ์‚ฌ์šฉ", + "MenuBarFile": "ํŒŒ์ผ(_F)", + "MenuBarFileOpenFromFile": "ํŒŒ์ผ์—์„œ ์•ฑ ๋ถˆ๋Ÿฌ์˜ค๊ธฐ(_L)", + "MenuBarFileOpenFromFileError": "์„ ํƒํ•œ ํŒŒ์ผ์—์„œ ์•ฑ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.", + "MenuBarFileOpenUnpacked": "์••์ถ• ํ‘ผ ๊ฒŒ์ž„ ๋ถˆ๋Ÿฌ์˜ค๊ธฐ(_U)", + "MenuBarFileLoadDlcFromFolder": "ํด๋”์—์„œ DLC ๋ถˆ๋Ÿฌ์˜ค๊ธฐ", + "MenuBarFileLoadTitleUpdatesFromFolder": "ํด๋”์—์„œ ํƒ€์ดํ‹€ ์—…๋ฐ์ดํŠธ ๋ถˆ๋Ÿฌ์˜ค๊ธฐ", "MenuBarFileOpenEmuFolder": "Ryujinx ํด๋” ์—ด๊ธฐ", "MenuBarFileOpenLogsFolder": "๋กœ๊ทธ ํด๋” ์—ด๊ธฐ", - "MenuBarFileExit": "_์ข…๋ฃŒ", + "MenuBarFileExit": "์ข…๋ฃŒ(_E)", "MenuBarOptions": "์˜ต์…˜(_O)", - "MenuBarOptionsToggleFullscreen": "์ „์ฒดํ™”๋ฉด ์ „ํ™˜", - "MenuBarOptionsStartGamesInFullscreen": "์ „์ฒด ํ™”๋ฉด ๋ชจ๋“œ์—์„œ ๊ฒŒ์ž„ ์‹œ์ž‘", + "MenuBarOptionsToggleFullscreen": "์ „์ฒด ํ™”๋ฉด ์ „ํ™˜", + "MenuBarOptionsStartGamesInFullscreen": "์ „์ฒด ํ™”๋ฉด ๋ชจ๋“œ๋กœ ๊ฒŒ์ž„ ์‹œ์ž‘", "MenuBarOptionsStopEmulation": "์—๋ฎฌ๋ ˆ์ด์…˜ ์ค‘์ง€", - "MenuBarOptionsSettings": "_์„ค์ •", - "MenuBarOptionsManageUserProfiles": "_์‚ฌ์šฉ์ž ํ”„๋กœํŒŒ์ผ ๊ด€๋ฆฌ", - "MenuBarActions": "_๋™์ž‘", - "MenuBarOptionsSimulateWakeUpMessage": "๊นจ์šฐ๊ธฐ ๋ฉ”์‹œ์ง€ ์‹œ๋ฎฌ๋ ˆ์ด์…˜", + "MenuBarOptionsSettings": "์„ค์ •(_S)", + "MenuBarOptionsManageUserProfiles": "์‚ฌ์šฉ์ž ํ”„๋กœํ•„ ๊ด€๋ฆฌ(_M)", + "MenuBarActions": "๋™์ž‘(_A)", + "MenuBarOptionsSimulateWakeUpMessage": "์›จ์ดํฌ์—… ๋ฉ”์‹œ์ง€ ์‹œ๋ฎฌ๋ ˆ์ด์…˜", "MenuBarActionsScanAmiibo": "Amiibo ์Šค์บ”", - "MenuBarTools": "_๋„๊ตฌ", + "MenuBarTools": "๋„๊ตฌ(_T)", "MenuBarToolsInstallFirmware": "ํŽŒ์›จ์–ด ์„ค์น˜", - "MenuBarFileToolsInstallFirmwareFromFile": "XCI ๋˜๋Š” ZIP์—์„œ ํŽŒ์›จ์–ด ์„ค์น˜", + "MenuBarFileToolsInstallFirmwareFromFile": "XCI ๋˜๋Š” ZIP์œผ๋กœ ํŽŒ์›จ์–ด ์„ค์น˜", "MenuBarFileToolsInstallFirmwareFromDirectory": "๋””๋ ‰ํ„ฐ๋ฆฌ์—์„œ ํŽŒ์›จ์–ด ์„ค์น˜", "MenuBarToolsManageFileTypes": "ํŒŒ์ผ ํ˜•์‹ ๊ด€๋ฆฌ", "MenuBarToolsInstallFileTypes": "ํŒŒ์ผ ํ˜•์‹ ์„ค์น˜", - "MenuBarToolsUninstallFileTypes": "ํŒŒ์ผ ํ˜•์‹ ์„ค์น˜ ์ œ๊ฑฐ", - "MenuBarView": "_๋ณด๊ธฐ", - "MenuBarViewWindow": "์ฐฝ ํฌ๊ธฐ", + "MenuBarToolsUninstallFileTypes": "ํŒŒ์ผ ํ˜•์‹ ์ œ๊ฑฐ", + "MenuBarToolsXCITrimmer": "XCI ํŒŒ์ผ ํŠธ๋ฆฌ๋จธ", + "MenuBarView": "๋ณด๊ธฐ(_V)", + "MenuBarViewWindow": "์œˆ๋„ ์ฐฝ", "MenuBarViewWindow720": "720p", "MenuBarViewWindow1080": "1080p", "MenuBarHelp": "๋„์›€๋ง(_H)", "MenuBarHelpCheckForUpdates": "์—…๋ฐ์ดํŠธ ํ™•์ธ", "MenuBarHelpAbout": "์ •๋ณด", - "MenuSearch": "๊ฒ€์ƒ‰...", + "MenuSearch": "์ฐพ๊ธฐ...", "GameListHeaderFavorite": "์ฆ๊ฒจ์ฐพ๊ธฐ", "GameListHeaderIcon": "์•„์ด์ฝ˜", "GameListHeaderApplication": "์ด๋ฆ„", "GameListHeaderDeveloper": "๊ฐœ๋ฐœ์ž", "GameListHeaderVersion": "๋ฒ„์ „", - "GameListHeaderTimePlayed": "ํ”Œ๋ ˆ์ด ์‹œ๊ฐ„", + "GameListHeaderTimePlayed": "ํ”Œ๋ ˆ์ด ํƒ€์ž„", "GameListHeaderLastPlayed": "๋งˆ์ง€๋ง‰ ํ”Œ๋ ˆ์ด", "GameListHeaderFileExtension": "ํŒŒ์ผ ํ™•์žฅ์ž", "GameListHeaderFileSize": "ํŒŒ์ผ ํฌ๊ธฐ", "GameListHeaderPath": "๊ฒฝ๋กœ", "GameListContextMenuOpenUserSaveDirectory": "์‚ฌ์šฉ์ž ์ €์žฅ ๋””๋ ‰ํ„ฐ๋ฆฌ ์—ด๊ธฐ", - "GameListContextMenuOpenUserSaveDirectoryToolTip": "์‘์šฉํ”„๋กœ๊ทธ๋žจ์˜ ์‚ฌ์šฉ์ž ์ €์žฅ์ด ํฌํ•จ๋œ ๋””๋ ‰ํ„ฐ๋ฆฌ ์—ด๊ธฐ", - "GameListContextMenuOpenDeviceSaveDirectory": "์‚ฌ์šฉ์ž ์žฅ์น˜ ๋””๋ ‰ํ† ๋ฆฌ ์—ด๊ธฐ", - "GameListContextMenuOpenDeviceSaveDirectoryToolTip": "์‘์šฉํ”„๋กœ๊ทธ๋žจ์˜ ์žฅ์น˜ ์ €์žฅ์ด ํฌํ•จ๋œ ๋””๋ ‰ํ„ฐ๋ฆฌ ์—ด๊ธฐ", + "GameListContextMenuOpenUserSaveDirectoryToolTip": "์•ฑ์˜ ์‚ฌ์šฉ์ž ์ €์žฅ์ด ํฌํ•จ๋œ ๋””๋ ‰ํ„ฐ๋ฆฌ ์—ด๊ธฐ", + "GameListContextMenuOpenDeviceSaveDirectory": "๊ธฐ๊ธฐ ์ €์žฅ ๋””๋ ‰ํ„ฐ๋ฆฌ ์—ด๊ธฐ", + "GameListContextMenuOpenDeviceSaveDirectoryToolTip": "์•ฑ์˜ ์žฅ์น˜ ์ €์žฅ์ด ํฌํ•จ๋œ ๋””๋ ‰ํ„ฐ๋ฆฌ ์—ด๊ธฐ", "GameListContextMenuOpenBcatSaveDirectory": "BCAT ์ €์žฅ ๋””๋ ‰ํ„ฐ๋ฆฌ ์—ด๊ธฐ", - "GameListContextMenuOpenBcatSaveDirectoryToolTip": "์‘์šฉํ”„๋กœ๊ทธ๋žจ์˜ BCAT ์ €์žฅ์ด ํฌํ•จ๋œ ๋””๋ ‰ํ„ฐ๋ฆฌ ์—ด๊ธฐ", + "GameListContextMenuOpenBcatSaveDirectoryToolTip": "์•ฑ์˜ BCAT ์ €์žฅ์ด ํฌํ•จ๋œ ๋””๋ ‰ํ„ฐ๋ฆฌ ์—ด๊ธฐ", "GameListContextMenuManageTitleUpdates": "ํƒ€์ดํ‹€ ์—…๋ฐ์ดํŠธ ๊ด€๋ฆฌ", "GameListContextMenuManageTitleUpdatesToolTip": "ํƒ€์ดํ‹€ ์—…๋ฐ์ดํŠธ ๊ด€๋ฆฌ ์ฐฝ ์—ด๊ธฐ", "GameListContextMenuManageDlc": "DLC ๊ด€๋ฆฌ", "GameListContextMenuManageDlcToolTip": "DLC ๊ด€๋ฆฌ ์ฐฝ ์—ด๊ธฐ", "GameListContextMenuCacheManagement": "์บ์‹œ ๊ด€๋ฆฌ", "GameListContextMenuCacheManagementPurgePptc": "๋Œ€๊ธฐ์—ด PPTC ์žฌ๊ตฌ์„ฑ", - "GameListContextMenuCacheManagementPurgePptcToolTip": "๋‹ค์Œ ๊ฒŒ์ž„ ์‹œ์ž‘์—์„œ ๋ถ€ํŒ… ์‹œ PPTC๊ฐ€ ๋‹ค์‹œ ๋นŒ๋“œํ•˜๋„๋ก ํŠธ๋ฆฌ๊ฑฐ", - "GameListContextMenuCacheManagementPurgeShaderCache": "์…ฐ์ด๋” ์บ์‹œ ์ œ๊ฑฐ", - "GameListContextMenuCacheManagementPurgeShaderCacheToolTip": "์‘์šฉํ”„๋กœ๊ทธ๋žจ ์…ฐ์ด๋” ์บ์‹œ ์‚ญ์ œ\n", + "GameListContextMenuCacheManagementPurgePptcToolTip": "๋‹ค์Œ ๊ฒŒ์ž„ ์‹คํ–‰ ๋ถ€ํŒ… ์‹œ, PPTC๋ฅผ ํŠธ๋ฆฌ๊ฑฐํ•˜์—ฌ ๋‹ค์‹œ ๊ตฌ์„ฑ", + "GameListContextMenuCacheManagementPurgeShaderCache": "ํผ์ง€ ์…ฐ์ด๋” ์บ์‹œ", + "GameListContextMenuCacheManagementPurgeShaderCacheToolTip": "์•ฑ์˜ ์…ฐ์ด๋” ์บ์‹œ ์‚ญ์ œ", "GameListContextMenuCacheManagementOpenPptcDirectory": "PPTC ๋””๋ ‰ํ„ฐ๋ฆฌ ์—ด๊ธฐ", - "GameListContextMenuCacheManagementOpenPptcDirectoryToolTip": "์‘์šฉํ”„๋กœ๊ทธ๋žจ PPTC ์บ์‹œ๊ฐ€ ํฌํ•จ๋œ ๋””๋ ‰ํ„ฐ๋ฆฌ ์—ด๊ธฐ", + "GameListContextMenuCacheManagementOpenPptcDirectoryToolTip": "์•ฑ์˜ PPTC ์บ์‹œ๊ฐ€ ํฌํ•จ๋œ ๋””๋ ‰ํ„ฐ๋ฆฌ ์—ด๊ธฐ", "GameListContextMenuCacheManagementOpenShaderCacheDirectory": "์…ฐ์ด๋” ์บ์‹œ ๋””๋ ‰ํ„ฐ๋ฆฌ ์—ด๊ธฐ", - "GameListContextMenuCacheManagementOpenShaderCacheDirectoryToolTip": "์‘์šฉํ”„๋กœ๊ทธ๋žจ ์…ฐ์ด๋” ์บ์‹œ๊ฐ€ ํฌํ•จ๋œ ๋””๋ ‰ํ„ฐ๋ฆฌ ์—ด๊ธฐ", + "GameListContextMenuCacheManagementOpenShaderCacheDirectoryToolTip": "์•ฑ์˜ ์…ฐ์ด๋” ์บ์‹œ๊ฐ€ ํฌํ•จ๋œ ๋””๋ ‰ํ„ฐ๋ฆฌ ์—ด๊ธฐ", "GameListContextMenuExtractData": "๋ฐ์ดํ„ฐ ์ถ”์ถœ", "GameListContextMenuExtractDataExeFS": "ExeFS", - "GameListContextMenuExtractDataExeFSToolTip": "์‘์šฉํ”„๋กœ๊ทธ๋žจ์˜ ํ˜„์žฌ ๊ตฌ์„ฑ์—์„œ ExeFS ์ถ”์ถœ (์—…๋ฐ์ดํŠธ ํฌํ•จ)", + "GameListContextMenuExtractDataExeFSToolTip": "์•ฑ์˜ ํ˜„์žฌ ๊ตฌ์„ฑ์—์„œ ExeFS ์ถ”์ถœ(์—…๋ฐ์ดํŠธ ํฌํ•จ)", "GameListContextMenuExtractDataRomFS": "RomFS", - "GameListContextMenuExtractDataRomFSToolTip": "์‘์šฉ ํ”„๋กœ๊ทธ๋žจ์˜ ํ˜„์žฌ ๊ตฌ์„ฑ์—์„œ RomFS ์ถ”์ถœ (์—…๋ฐ์ดํŠธ ํฌํ•จ)", + "GameListContextMenuExtractDataRomFSToolTip": "์•ฑ์˜ ํ˜„์žฌ ๊ตฌ์„ฑ์—์„œ RomFS ์ถ”์ถœ(์—…๋ฐ์ดํŠธ ํฌํ•จ)", "GameListContextMenuExtractDataLogo": "๋กœ๊ณ ", - "GameListContextMenuExtractDataLogoToolTip": "์‘์šฉํ”„๋กœ๊ทธ๋žจ์˜ ํ˜„์žฌ ๊ตฌ์„ฑ์—์„œ ๋กœ๊ณ  ์„น์…˜ ์ถ”์ถœ (์—…๋ฐ์ดํŠธ ํฌํ•จ)", - "GameListContextMenuCreateShortcut": "์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ๋ฐ”๋กœ ๊ฐ€๊ธฐ ๋งŒ๋“ค๊ธฐ", - "GameListContextMenuCreateShortcutToolTip": "์„ ํƒํ•œ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์„ ์‹คํ–‰ํ•˜๋Š” ๋ฐ”ํƒ• ํ™”๋ฉด ๋ฐ”๋กœ ๊ฐ€๊ธฐ๋ฅผ ๋งŒ๋“ญ๋‹ˆ๋‹ค.", - "GameListContextMenuCreateShortcutToolTipMacOS": "ํ•ด๋‹น ๊ฒŒ์ž„์„ ์‹คํ–‰ํ•  ์ˆ˜ ์žˆ๋Š” ๋ฐ”๋กœ๊ฐ€๊ธฐ๋ฅผ macOS์˜ ์‘์šฉ ํ”„๋กœ๊ทธ๋žจ ํด๋”์— ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค.", - "GameListContextMenuOpenModsDirectory": "Mod ๋””๋ ‰ํ„ฐ๋ฆฌ ์—ด๊ธฐ", - "GameListContextMenuOpenModsDirectoryToolTip": "ํ•ด๋‹น ๊ฒŒ์ž„์˜ Mod๊ฐ€ ์ €์žฅ๋œ ๋””๋ ‰ํ„ฐ๋ฆฌ ์—ด๊ธฐ", - "GameListContextMenuOpenSdModsDirectory": "Atmosphere Mod ๋””๋ ‰ํ„ฐ๋ฆฌ ์—ด๊ธฐ", - "GameListContextMenuOpenSdModsDirectoryToolTip": "ํ•ด๋‹น ๊ฒŒ์ž„์˜ Mod๊ฐ€ ํฌํ•จ๋œ ๋Œ€์ฒด SD ์นด๋“œ Atmosphere ๋””๋ ‰ํ„ฐ๋ฆฌ๋ฅผ ์—ฝ๋‹ˆ๋‹ค. ์‹ค์ œ ํ•˜๋“œ์›จ์–ด์šฉ์œผ๋กœ ํŒจํ‚ค์ง•๋œ Mod์— ์œ ์šฉํ•ฉ๋‹ˆ๋‹ค.", + "GameListContextMenuExtractDataLogoToolTip": "์•ฑ์˜ ํ˜„์žฌ ๊ตฌ์„ฑ์—์„œ ๋กœ๊ณ  ์„น์…˜ ์ถ”์ถœ (์—…๋ฐ์ดํŠธ ํฌํ•จ)", + "GameListContextMenuCreateShortcut": "๋ฐ”๋กœ ๊ฐ€๊ธฐ ๋งŒ๋“ค๊ธฐ", + "GameListContextMenuCreateShortcutToolTip": "์„ ํƒํ•œ ์•ฑ์„ ์‹คํ–‰ํ•˜๋Š” ๋ฐ”ํƒ• ํ™”๋ฉด์— ๋ฐ”๋กœ ๊ฐ€๊ธฐ๋ฅผ ์ƒ์„ฑ", + "GameListContextMenuCreateShortcutToolTipMacOS": "์„ ํƒํ•œ ์•ฑ์„ ์‹คํ–‰ํ•˜๋Š” macOS ์•ฑ ํด๋”์— ๋ฐ”๋กœ ๊ฐ€๊ธฐ ๋งŒ๋“ค๊ธฐ", + "GameListContextMenuOpenModsDirectory": "๋ชจ๋“œ ๋””๋ ‰ํ„ฐ๋ฆฌ ์—ด๊ธฐ", + "GameListContextMenuOpenModsDirectoryToolTip": "์•ฑ์˜ ๋ชจ๋“œ๊ฐ€ ํฌํ•จ๋œ ๋””๋ ‰ํ„ฐ๋ฆฌ ์—ด๊ธฐ", + "GameListContextMenuOpenSdModsDirectory": "Atmosphere ๋ชจ๋“œ ๋””๋ ‰ํ„ฐ๋ฆฌ ์—ด๊ธฐ", + "GameListContextMenuOpenSdModsDirectoryToolTip": "ํ•ด๋‹น ๊ฒŒ์ž„์˜ ๋ชจ๋“œ๊ฐ€ ํฌํ•จ๋œ ๋Œ€์ฒด SD ์นด๋“œ Atmosphere ๋””๋ ‰ํ„ฐ๋ฆฌ๋ฅผ ์—ฝ๋‹ˆ๋‹ค. ์‹ค์ œ ํ•˜๋“œ์›จ์–ด์šฉ์œผ๋กœ ํŒจํ‚ค์ง•๋œ ๋ชจ๋“œ์— ์œ ์šฉํ•ฉ๋‹ˆ๋‹ค.", + "GameListContextMenuTrimXCI": "XCI ํŒŒ์ผ ํ™•์ธ ๋ฐ ํŠธ๋ฆผ", + "GameListContextMenuTrimXCIToolTip": "๋””์Šคํฌ ๊ณต๊ฐ„์„ ์ ˆ์•ฝํ•˜๊ธฐ ์œ„ํ•ด XCI ํŒŒ์ผ ํ™•์ธ ๋ฐ ํŠธ๋ฆผ", "StatusBarGamesLoaded": "{0}/{1}๊ฐœ์˜ ๊ฒŒ์ž„ ๋ถˆ๋Ÿฌ์˜ด", "StatusBarSystemVersion": "์‹œ์Šคํ…œ ๋ฒ„์ „ : {0}", - "LinuxVmMaxMapCountDialogTitle": "๊ฐ์ง€๋œ ๋ฉ”๋ชจ๋ฆฌ ๋งคํ•‘์˜ ํ•˜ํ•œ์„ ", + "StatusBarXCIFileTrimming": "XCI ํŒŒ์ผ '{0}' ํŠธ๋ฆฌ๋ฐ", + "LinuxVmMaxMapCountDialogTitle": "๋ฉ”๋ชจ๋ฆฌ ๋งคํ•‘ ํ•œ๊ณ„ ๊ฐ์ง€", "LinuxVmMaxMapCountDialogTextPrimary": "vm.max_map_count์˜ ๊ฐ’์„ {0}์œผ๋กœ ๋Š˜๋ฆฌ์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?", - "LinuxVmMaxMapCountDialogTextSecondary": "์ผ๋ถ€ ๊ฒŒ์ž„์€ ํ˜„์žฌ ํ—ˆ์šฉ๋œ ๊ฒƒ๋ณด๋‹ค ๋” ๋งŽ์€ ๋ฉ”๋ชจ๋ฆฌ ๋งคํ•‘์„ ์ƒ์„ฑํ•˜๋ ค๊ณ  ์‹œ๋„ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์ด ์ œํ•œ์„ ์ดˆ๊ณผํ•˜๋Š” ์ฆ‰์‹œ Ryujinx์— ๋ฌธ์ œ๊ฐ€ ๋ฐœ์ƒํ•ฉ๋‹ˆ๋‹ค.", + "LinuxVmMaxMapCountDialogTextSecondary": "์ผ๋ถ€ ๊ฒŒ์ž„์€ ํ˜„์žฌ ํ—ˆ์šฉ๋œ ๊ฒƒ๋ณด๋‹ค ๋” ๋งŽ์€ ๋ฉ”๋ชจ๋ฆฌ ๋งคํ•‘์„ ๋งŒ๋“ค๋ ค๊ณ  ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์ด ์ œํ•œ์„ ์ดˆ๊ณผํ•˜๋ฉด Ryujinx๊ฐ€ ์ถฉ๋Œ์ด ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.", "LinuxVmMaxMapCountDialogButtonUntilRestart": "์˜ˆ, ๋‹ค์Œ์— ๋‹ค์‹œ ์‹œ์ž‘ํ•  ๋•Œ๊นŒ์ง€", "LinuxVmMaxMapCountDialogButtonPersistent": "์˜ˆ, ์˜๊ตฌ์ ์œผ๋กœ", - "LinuxVmMaxMapCountWarningTextPrimary": "๋ฉ”๋ชจ๋ฆฌ ๋งคํ•‘์˜ ์ตœ๋Œ€ ์šฉ๋Ÿ‰์ด ๊ถŒ์žฅ ์šฉ๋Ÿ‰๋ณด๋‹ค ์ ์Šต๋‹ˆ๋‹ค.", - "LinuxVmMaxMapCountWarningTextSecondary": "vm.max_map_count({0})์˜ ํ˜„์žฌ ๊ฐ’์ด {1}๋ณด๋‹ค ๋‚ฎ์Šต๋‹ˆ๋‹ค. ์ผ๋ถ€ ๊ฒŒ์ž„์€ ํ˜„์žฌ ํ—ˆ์šฉ๋œ ๊ฒƒ๋ณด๋‹ค ๋” ๋งŽ์€ ๋ฉ”๋ชจ๋ฆฌ ๋งคํ•‘์„ ์ƒ์„ฑํ•˜๋ ค๊ณ  ์‹œ๋„ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์ด ์ œํ•œ์„ ์ดˆ๊ณผํ•˜๋Š” ์ฆ‰์‹œ Ryujinx์— ๋ฌธ์ œ๊ฐ€ ๋ฐœ์ƒํ•ฉ๋‹ˆ๋‹ค.\n\n์ˆ˜๋™์œผ๋กœ ์ œํ•œ์„ ๋Š˜๋ฆฌ๊ฑฐ๋‚˜ Ryujinx์˜ ๋„์›€์„ ๋ฐ›์„ ์ˆ˜ ์žˆ๋Š” pkexec์„ ์„ค์น˜ํ•˜๋Š” ๊ฒƒ์ด ์ข‹์Šต๋‹ˆ๋‹ค.", + "LinuxVmMaxMapCountWarningTextPrimary": "๋ฉ”๋ชจ๋ฆฌ ๋งคํ•‘์˜ ์ตœ๋Œ€ ์šฉ๋Ÿ‰์ด ๊ถŒ์žฅ ์šฉ๋Ÿ‰๋ณด๋‹ค ๋ถ€์กฑํ•ฉ๋‹ˆ๋‹ค.", + "LinuxVmMaxMapCountWarningTextSecondary": "vm.max_map_count({0})์˜ ํ˜„์žฌ ๊ฐ’์€ {1}๋ณด๋‹ค ๋‚ฎ์Šต๋‹ˆ๋‹ค. ์ผ๋ถ€ ๊ฒŒ์ž„์€ ํ˜„์žฌ ํ—ˆ์šฉ๋œ ๊ฒƒ๋ณด๋‹ค ๋” ๋งŽ์€ ๋ฉ”๋ชจ๋ฆฌ ๋งคํ•‘์„ ๋งŒ๋“ค๋ ค๊ณ  ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. Ryujinx๋Š” ์ด ์ œํ•œ์„ ์ดˆ๊ณผํ•˜์ž๋งˆ์ž ์ถฉ๋Œํ•  ๊ฒƒ์ž…๋‹ˆ๋‹ค.\n\n์ œํ•œ์„ ์ˆ˜๋™์œผ๋กœ ๋Š˜๋ฆฌ๊ฑฐ๋‚˜ Ryujinx๊ฐ€ ์ด๋ฅผ ์ง€์›ํ•  ์ˆ˜ ์žˆ๋„๋ก pkexec๋ฅผ ์„ค์น˜ํ•˜๋Š” ๊ฒƒ์„ ์ถ”์ฒœํ•ฉ๋‹ˆ๋‹ค.", "Settings": "์„ค์ •", "SettingsTabGeneral": "์‚ฌ์šฉ์ž ์ธํ„ฐํŽ˜์ด์Šค", "SettingsTabGeneralGeneral": "์ผ๋ฐ˜", @@ -100,19 +104,19 @@ "SettingsTabGeneralCheckUpdatesOnLaunch": "์‹œ์ž‘ ์‹œ, ์—…๋ฐ์ดํŠธ ํ™•์ธ", "SettingsTabGeneralShowConfirmExitDialog": "\"์ข…๋ฃŒ ํ™•์ธ\" ๋Œ€ํ™” ์ƒ์ž ํ‘œ์‹œ", "SettingsTabGeneralRememberWindowState": "์ฐฝ ํฌ๊ธฐ/์œ„์น˜ ๊ธฐ์–ต", - "SettingsTabGeneralShowTitleBar": "Show Title Bar (Requires restart)", - "SettingsTabGeneralHideCursor": "๋งˆ์šฐ์Šค ์ปค์„œ ์ˆจ๊ธฐ๊ธฐ", + "SettingsTabGeneralShowTitleBar": "์ œ๋ชฉ ํ‘œ์‹œ์ค„ ํ‘œ์‹œ(๋‹ค์‹œ ์‹œ์ž‘ํ•ด์•ผ ํ•จ)", + "SettingsTabGeneralHideCursor": "์ปค์„œ ์ˆจ๊ธฐ๊ธฐ :", "SettingsTabGeneralHideCursorNever": "์ ˆ๋Œ€ ์•ˆ ํ•จ", "SettingsTabGeneralHideCursorOnIdle": "์œ ํœด ์ƒํƒœ", - "SettingsTabGeneralHideCursorAlways": "์–ธ์ œ๋‚˜", - "SettingsTabGeneralGameDirectories": "๊ฒŒ์ž„ ๋””๋ ‰ํ„ฐ๋ฆฌ", - "SettingsTabGeneralAutoloadDirectories": "Autoload DLC/Updates Directories", - "SettingsTabGeneralAutoloadNote": "DLC and Updates which refer to missing files will be unloaded automatically", + "SettingsTabGeneralHideCursorAlways": "ํ•ญ์ƒ", + "SettingsTabGeneralGameDirectories": "๊ฒŒ์ž„ ๋ฐ๋ฆญํ„ฐ๋ฆฌ", + "SettingsTabGeneralAutoloadDirectories": "DLC/์—…๋ฐ์ดํŠธ ๋””๋ ‰ํ„ฐ๋ฆฌ ์ž๋™ ๋ถˆ๋Ÿฌ์˜ค๊ธฐ", + "SettingsTabGeneralAutoloadNote": "๋ˆ„๋ฝ๋œ ํŒŒ์ผ์„ ์ฐธ์กฐํ•˜๋Š” DLC ๋ฐ ์—…๋ฐ์ดํŠธ๊ฐ€ ์ž๋™์œผ๋กœ ์–ธ๋กœ๋“œ", "SettingsTabGeneralAdd": "์ถ”๊ฐ€", "SettingsTabGeneralRemove": "์ œ๊ฑฐ", "SettingsTabSystem": "์‹œ์Šคํ…œ", "SettingsTabSystemCore": "์ฝ”์–ด", - "SettingsTabSystemSystemRegion": "์‹œ์Šคํ…œ ์ง€์—ญ:", + "SettingsTabSystemSystemRegion": "์‹œ์Šคํ…œ ์ง€์—ญ :", "SettingsTabSystemSystemRegionJapan": "์ผ๋ณธ", "SettingsTabSystemSystemRegionUSA": "๋ฏธ๊ตญ", "SettingsTabSystemSystemRegionEurope": "์œ ๋Ÿฝ", @@ -122,7 +126,7 @@ "SettingsTabSystemSystemRegionTaiwan": "๋Œ€๋งŒ", "SettingsTabSystemSystemLanguage": "์‹œ์Šคํ…œ ์–ธ์–ด :", "SettingsTabSystemSystemLanguageJapanese": "์ผ๋ณธ์–ด", - "SettingsTabSystemSystemLanguageAmericanEnglish": "์˜์–ด(๋ฏธ๊ตญ)", + "SettingsTabSystemSystemLanguageAmericanEnglish": "๋ฏธ๊ตญ ์˜์–ด", "SettingsTabSystemSystemLanguageFrench": "ํ”„๋ž‘์Šค์–ด", "SettingsTabSystemSystemLanguageGerman": "๋…์ผ์–ด", "SettingsTabSystemSystemLanguageItalian": "์ดํƒˆ๋ฆฌ์•„์–ด", @@ -133,29 +137,29 @@ "SettingsTabSystemSystemLanguagePortuguese": "ํฌ๋ฅดํˆฌ๊ฐˆ์–ด", "SettingsTabSystemSystemLanguageRussian": "๋Ÿฌ์‹œ์•„์–ด", "SettingsTabSystemSystemLanguageTaiwanese": "๋Œ€๋งŒ์–ด", - "SettingsTabSystemSystemLanguageBritishEnglish": "์˜์–ด(์˜๊ตญ)", - "SettingsTabSystemSystemLanguageCanadianFrench": "ํ”„๋ž‘์Šค์–ด(์บ๋‚˜๋‹ค)", - "SettingsTabSystemSystemLanguageLatinAmericanSpanish": "์ŠคํŽ˜์ธ์–ด(๋ผํ‹ด ์•„๋ฉ”๋ฆฌ์นด)", + "SettingsTabSystemSystemLanguageBritishEnglish": "์˜๊ตญ ์˜์–ด", + "SettingsTabSystemSystemLanguageCanadianFrench": "์บ๋‚˜๋‹ค ํ”„๋ž‘์Šค์–ด", + "SettingsTabSystemSystemLanguageLatinAmericanSpanish": "๋‚จ๋ฏธ ์ŠคํŽ˜์ธ์–ด", "SettingsTabSystemSystemLanguageSimplifiedChinese": "์ค‘๊ตญ์–ด ๊ฐ„์ฒด", "SettingsTabSystemSystemLanguageTraditionalChinese": "์ค‘๊ตญ์–ด ๋ฒˆ์ฒด", - "SettingsTabSystemSystemTimeZone": "์‹œ์Šคํ…œ ์‹œ๊ฐ„๋Œ€:", - "SettingsTabSystemSystemTime": "์‹œ์Šคํ…œ ์‹œ๊ฐ„:", + "SettingsTabSystemSystemTimeZone": "์‹œ์Šคํ…œ ์‹œ๊ฐ„๋Œ€ :", + "SettingsTabSystemSystemTime": "์‹œ์Šคํ…œ ์‹œ๊ฐ„ :", "SettingsTabSystemEnableVsync": "์ˆ˜์ง ๋™๊ธฐํ™”", "SettingsTabSystemEnablePptc": "PPTC(ํ”„๋กœํŒŒ์ผ๋œ ์˜๊ตฌ ๋ฒˆ์—ญ ์บ์‹œ)", - "SettingsTabSystemEnableLowPowerPptc": "Low-power PPTC", + "SettingsTabSystemEnableLowPowerPptc": "์ €์ „๋ ฅ PPTC ์บ์‹œ", "SettingsTabSystemEnableFsIntegrityChecks": "ํŒŒ์ผ ์‹œ์Šคํ…œ ๋ฌด๊ฒฐ์„ฑ ๊ฒ€์‚ฌ", "SettingsTabSystemAudioBackend": "์Œํ–ฅ ํ›„๋‹จ๋ถ€ :", "SettingsTabSystemAudioBackendDummy": "๋”๋ฏธ", "SettingsTabSystemAudioBackendOpenAL": "OpenAL", - "SettingsTabSystemAudioBackendSoundIO": "์‚ฌ์šด๋“œIO", + "SettingsTabSystemAudioBackendSoundIO": "SoundIO", "SettingsTabSystemAudioBackendSDL2": "SDL2", - "SettingsTabSystemHacks": "ํ•ดํ‚น", + "SettingsTabSystemHacks": "ํ•ต", "SettingsTabSystemHacksNote": "๋ถˆ์•ˆ์ •์„ฑ์„ ์œ ๋ฐœํ•  ์ˆ˜ ์žˆ์Œ", - "SettingsTabSystemDramSize": "๋Œ€์ฒด ๋ฉ”๋ชจ๋ฆฌ ๋ ˆ์ด์•„์›ƒ ์‚ฌ์šฉ(๊ฐœ๋ฐœ์ž)", - "SettingsTabSystemDramSize4GiB": "4GiB", - "SettingsTabSystemDramSize6GiB": "6GiB", - "SettingsTabSystemDramSize8GiB": "8GiB", - "SettingsTabSystemDramSize12GiB": "12GiB", + "SettingsTabSystemDramSize": "DRAM ํฌ๊ธฐ :", + "SettingsTabSystemDramSize4GiB": "4GB", + "SettingsTabSystemDramSize6GiB": "6GB", + "SettingsTabSystemDramSize8GiB": "8GB", + "SettingsTabSystemDramSize12GiB": "12GB", "SettingsTabSystemIgnoreMissingServices": "๋ˆ„๋ฝ๋œ ์„œ๋น„์Šค ๋ฌด์‹œ", "SettingsTabSystemIgnoreApplet": "์• ํ”Œ๋ฆฟ ๋ฌด์‹œ", "SettingsTabGraphics": "๊ทธ๋ž˜ํ”ฝ", @@ -172,38 +176,38 @@ "SettingsTabGraphicsResolutionScaleNative": "์›๋ณธ(720p/1080p)", "SettingsTabGraphicsResolutionScale2x": "2๋ฐฐ(1440p/2160p)", "SettingsTabGraphicsResolutionScale3x": "3๋ฐฐ(2160p/3240p)", - "SettingsTabGraphicsResolutionScale4x": "4x (2880p/4320p) (๊ถŒ์žฅํ•˜์ง€ ์•Š์Œ)", + "SettingsTabGraphicsResolutionScale4x": "4๋ฐฐ(2880p/4320p) (๊ถŒ์žฅํ•˜์ง€ ์•Š์Œ)", "SettingsTabGraphicsAspectRatio": "์ข…ํšก๋น„ :", "SettingsTabGraphicsAspectRatio4x3": "4:3", "SettingsTabGraphicsAspectRatio16x9": "16:9", "SettingsTabGraphicsAspectRatio16x10": "16:10", "SettingsTabGraphicsAspectRatio21x9": "21:9", "SettingsTabGraphicsAspectRatio32x9": "32:9", - "SettingsTabGraphicsAspectRatioStretch": "์ฐฝ์— ๋งž๊ฒŒ ๋Š˜๋ฆฌ๊ธฐ", + "SettingsTabGraphicsAspectRatioStretch": "์ฐฝ์— ๋งž์ถฐ ๋Š˜๋ฆฌ๊ธฐ", "SettingsTabGraphicsDeveloperOptions": "๊ฐœ๋ฐœ์ž ์˜ต์…˜", "SettingsTabGraphicsShaderDumpPath": "๊ทธ๋ž˜ํ”ฝ ์…ฐ์ด๋” ๋คํ”„ ๊ฒฝ๋กœ :", "SettingsTabLogging": "๋กœ๊ทธ ๊ธฐ๋ก", "SettingsTabLoggingLogging": "๋กœ๊ทธ ๊ธฐ๋ก", "SettingsTabLoggingEnableLoggingToFile": "ํŒŒ์ผ์— ๋กœ๊ทธ ๊ธฐ๋ก ํ™œ์„ฑํ™”", - "SettingsTabLoggingEnableStubLogs": "์Šคํ… ๋กœ๊ทธ ํ™œ์„ฑํ™”", - "SettingsTabLoggingEnableInfoLogs": "์ •๋ณด ๋กœ๊ทธ ํ™œ์„ฑํ™”", - "SettingsTabLoggingEnableWarningLogs": "๊ฒฝ๊ณ  ๋กœ๊ทธ ํ™œ์„ฑํ™”", - "SettingsTabLoggingEnableErrorLogs": "์˜ค๋ฅ˜ ๋กœ๊ทธ ํ™œ์„ฑํ™”", - "SettingsTabLoggingEnableTraceLogs": "์ถ”์  ๋กœ๊ทธ ํ™œ์„ฑํ™”", - "SettingsTabLoggingEnableGuestLogs": "๊ฒŒ์ŠคํŠธ ๋กœ๊ทธ ํ™œ์„ฑํ™”", - "SettingsTabLoggingEnableFsAccessLogs": "Fs ์ ‘์† ๋กœ๊ทธ ํ™œ์„ฑํ™”", - "SettingsTabLoggingFsGlobalAccessLogMode": "Fs ์ „์—ญ ์ ‘์† ๋กœ๊ทธ ๋ชจ๋“œ :", + "SettingsTabLoggingEnableStubLogs": "์กฐ๊ฐ ๊ธฐ๋ก ํ™œ์„ฑํ™”", + "SettingsTabLoggingEnableInfoLogs": "์ •๋ณด ๊ธฐ๋ก ํ™œ์„ฑํ™”", + "SettingsTabLoggingEnableWarningLogs": "๊ฒฝ๊ณ  ๊ธฐ๋ก ํ™œ์„ฑํ™”", + "SettingsTabLoggingEnableErrorLogs": "์˜ค๋ฅ˜ ๊ธฐ๋ก ํ™œ์„ฑํ™”", + "SettingsTabLoggingEnableTraceLogs": "์ถ”์  ๊ธฐ๋ก ํ™œ์„ฑํ™”", + "SettingsTabLoggingEnableGuestLogs": "๋ฐฉ๋ฌธ ๊ธฐ๋ก ํ™œ์„ฑํ™”", + "SettingsTabLoggingEnableFsAccessLogs": "ํŒŒ์ผ ์‹œ์Šคํ…œ ์ ‘์† ๊ธฐ๋ก ํ™œ์„ฑํ™”", + "SettingsTabLoggingFsGlobalAccessLogMode": "ํŒŒ์ผ ์‹œ์Šคํ…œ ์ „์—ญ ์ ‘์† ๋กœ๊ทธ ๋ชจ๋“œ :", "SettingsTabLoggingDeveloperOptions": "๊ฐœ๋ฐœ์ž ์˜ต์…˜", - "SettingsTabLoggingDeveloperOptionsNote": "๊ฒฝ๊ณ : ์„ฑ๋Šฅ์ด ์ €ํ•˜๋จ", - "SettingsTabLoggingGraphicsBackendLogLevel": "๊ทธ๋ž˜ํ”ฝ ํ›„๋‹จ๋ถ€ ๋กœ๊ทธ ์ˆ˜์ค€ :", + "SettingsTabLoggingDeveloperOptionsNote": "๊ฒฝ๊ณ  : ์„ฑ๋Šฅ์ด ๊ฐ์†Œํ•ฉ๋‹ˆ๋‹ค.", + "SettingsTabLoggingGraphicsBackendLogLevel": "๊ทธ๋ž˜ํ”ฝ ํ›„๋‹จ๋ถ€ ๊ธฐ๋ก ๋ ˆ๋ฒจ :", "SettingsTabLoggingGraphicsBackendLogLevelNone": "์—†์Œ", "SettingsTabLoggingGraphicsBackendLogLevelError": "์˜ค๋ฅ˜", - "SettingsTabLoggingGraphicsBackendLogLevelPerformance": "๋А๋ ค์ง", + "SettingsTabLoggingGraphicsBackendLogLevelPerformance": "๊ฐ์†", "SettingsTabLoggingGraphicsBackendLogLevelAll": "๋ชจ๋‘", - "SettingsTabLoggingEnableDebugLogs": "๋””๋ฒ„๊ทธ ๋กœ๊ทธ ํ™œ์„ฑํ™”", + "SettingsTabLoggingEnableDebugLogs": "๋””๋ฒ„๊ทธ ๊ธฐ๋ก ํ™œ์„ฑํ™”", "SettingsTabInput": "์ž…๋ ฅ", "SettingsTabInputEnableDockedMode": "๋„ํ‚น ๋ชจ๋“œ", - "SettingsTabInputDirectKeyboardAccess": "์ง์ ‘ ํ‚ค๋ณด๋“œ ์ ‘์†", + "SettingsTabInputDirectKeyboardAccess": "ํ‚ค๋ณด๋“œ ์ง์ ‘ ์ ‘์†", "SettingsButtonSave": "์ €์žฅ", "SettingsButtonClose": "๋‹ซ๊ธฐ", "SettingsButtonOk": "ํ™•์ธ", @@ -218,12 +222,12 @@ "ControllerSettingsPlayer6": "ํ”Œ๋ ˆ์ด์–ด 6", "ControllerSettingsPlayer7": "ํ”Œ๋ ˆ์ด์–ด 7", "ControllerSettingsPlayer8": "ํ”Œ๋ ˆ์ด์–ด 8", - "ControllerSettingsHandheld": "ํœด๋Œ€ ๋ชจ๋“œ", + "ControllerSettingsHandheld": "ํœด๋Œ€", "ControllerSettingsInputDevice": "์ž…๋ ฅ ์žฅ์น˜", "ControllerSettingsRefresh": "์ƒˆ๋กœ ๊ณ ์นจ", "ControllerSettingsDeviceDisabled": "๋น„ํ™œ์„ฑํ™”๋จ", "ControllerSettingsControllerType": "์ปจํŠธ๋กค๋Ÿฌ ์œ ํ˜•", - "ControllerSettingsControllerTypeHandheld": "ํœด๋Œ€ ๋ชจ๋“œ", + "ControllerSettingsControllerTypeHandheld": "ํœด๋Œ€์šฉ", "ControllerSettingsControllerTypeProController": "ํ”„๋กœ ์ปจํŠธ๋กค๋Ÿฌ", "ControllerSettingsControllerTypeJoyConPair": "์กฐ์ด์ฝ˜ ํŽ˜์–ด๋ง", "ControllerSettingsControllerTypeJoyConLeft": "์ขŒ์ธก ์กฐ์ด์ฝ˜", @@ -240,7 +244,7 @@ "ControllerSettingsButtonY": "Y", "ControllerSettingsButtonPlus": "+", "ControllerSettingsButtonMinus": "-", - "ControllerSettingsDPad": "๋ฐฉํ–ฅ ํŒจ๋“œ", + "ControllerSettingsDPad": "๋ฐฉํ–ฅํ‚ค", "ControllerSettingsDPadUp": "โ†‘", "ControllerSettingsDPadDown": "โ†“", "ControllerSettingsDPadLeft": "โ†", @@ -250,17 +254,17 @@ "ControllerSettingsStickDown": "โ†“", "ControllerSettingsStickLeft": "โ†", "ControllerSettingsStickRight": "โ†’", - "ControllerSettingsStickStick": "์Šคํ‹ฑ", - "ControllerSettingsStickInvertXAxis": "์Šคํ‹ฑ X ์ถ• ๋ฐ˜์ „", - "ControllerSettingsStickInvertYAxis": "์Šคํ‹ฑ Y ์ถ• ๋ฐ˜์ „", - "ControllerSettingsStickDeadzone": "์‚ฌ๊ฐ์ง€๋Œ€ :", + "ControllerSettingsStickStick": "์Šคํ‹ด", + "ControllerSettingsStickInvertXAxis": "์Šคํ‹ฑ X์ถ• ๋ฐ˜์ „", + "ControllerSettingsStickInvertYAxis": "์Šคํ‹ฑ Y์ถ• ๋ฐ˜์ „", + "ControllerSettingsStickDeadzone": "๋ฐ๋“œ์กด :", "ControllerSettingsLStick": "์ขŒ์ธก ์Šคํ‹ฑ", "ControllerSettingsRStick": "์šฐ์ธก ์Šคํ‹ฑ", "ControllerSettingsTriggersLeft": "์ขŒ์ธก ํŠธ๋ฆฌ๊ฑฐ", "ControllerSettingsTriggersRight": "์šฐ์ธก ํŠธ๋ฆฌ๊ฑฐ", "ControllerSettingsTriggersButtonsLeft": "์ขŒ์ธก ํŠธ๋ฆฌ๊ฑฐ ๋ฒ„ํŠผ", "ControllerSettingsTriggersButtonsRight": "์šฐ์ธก ํŠธ๋ฆฌ๊ฑฐ ๋ฒ„ํŠผ", - "ControllerSettingsTriggers": "ํŠธ๋ฆฌ๊ฑฐ ๋ฒ„ํŠผ", + "ControllerSettingsTriggers": "ํŠธ๋ฆฌ๊ฑฐ", "ControllerSettingsTriggerL": "L", "ControllerSettingsTriggerR": "R", "ControllerSettingsTriggerZL": "ZL", @@ -273,50 +277,50 @@ "ControllerSettingsExtraButtonsRight": "์šฐ์ธก ๋ฒ„ํŠผ", "ControllerSettingsMisc": "๊ธฐํƒ€", "ControllerSettingsTriggerThreshold": "ํŠธ๋ฆฌ๊ฑฐ ์ž„๊ณ„๊ฐ’ :", - "ControllerSettingsMotion": "๋™์ž‘", + "ControllerSettingsMotion": "๋ชจ์…˜", "ControllerSettingsMotionUseCemuhookCompatibleMotion": "CemuHook ํ˜ธํ™˜ ๋ชจ์…˜ ์‚ฌ์šฉ", "ControllerSettingsMotionControllerSlot": "์ปจํŠธ๋กค๋Ÿฌ ์Šฌ๋กฏ :", "ControllerSettingsMotionMirrorInput": "๋ฏธ๋Ÿฌ ์ž…๋ ฅ", - "ControllerSettingsMotionRightJoyConSlot": "์šฐ์ธก ์กฐ์ด์ฝ˜ ์Šฌ๋กฏ :", + "ControllerSettingsMotionRightJoyConSlot": "์šฐ์ธก ์กฐ์ด์ฝ˜ ์Šฌ๋กฏ:", "ControllerSettingsMotionServerHost": "์„œ๋ฒ„ ํ˜ธ์ŠคํŠธ :", "ControllerSettingsMotionGyroSensitivity": "์ž์ด๋กœ ๊ฐ๋„ :", - "ControllerSettingsMotionGyroDeadzone": "์ž์ด๋กœ ์‚ฌ๊ฐ์ง€๋Œ€ :", + "ControllerSettingsMotionGyroDeadzone": "์ž์ด๋กœ ๋ฐ๋“œ์กด :", "ControllerSettingsSave": "์ €์žฅ", "ControllerSettingsClose": "๋‹ซ๊ธฐ", "KeyUnknown": "์•Œ ์ˆ˜ ์—†์Œ", - "KeyShiftLeft": "์™ผ์ชฝ Shift", - "KeyShiftRight": "์˜ค๋ฅธ์ชฝ Shift", - "KeyControlLeft": "์™ผ์ชฝ Ctrl", - "KeyMacControlLeft": "์™ผ์ชฝ ^", - "KeyControlRight": "์˜ค๋ฅธ์ชฝ Ctrl", - "KeyMacControlRight": "์˜ค๋ฅธ์ชฝ ^", - "KeyAltLeft": "์™ผ์ชฝ Alt", - "KeyMacAltLeft": "์™ผ์ชฝ โŒฅ", - "KeyAltRight": "์˜ค๋ฅธ์ชฝ Alt", - "KeyMacAltRight": "์˜ค๋ฅธ์ชฝ โŒฅ", - "KeyWinLeft": "์™ผ์ชฝ โŠž", - "KeyMacWinLeft": "์™ผ์ชฝ โŒ˜", - "KeyWinRight": "์˜ค๋ฅธ์ชฝ โŠž", - "KeyMacWinRight": "์˜ค๋ฅธ์ชฝ โŒ˜", + "KeyShiftLeft": "์ขŒ์ธก Shift", + "KeyShiftRight": "์šฐ์ธก Shift", + "KeyControlLeft": "์ขŒ์ธก Ctrl", + "KeyMacControlLeft": "์ขŒ์ธก โŒƒ", + "KeyControlRight": "์šฐ์ธก Ctrl", + "KeyMacControlRight": "์šฐ์ธก โŒƒ", + "KeyAltLeft": "์ขŒ์ธก Alt", + "KeyMacAltLeft": "์ขŒ์ธก โŒฅ", + "KeyAltRight": "์šฐ์ธก Alt", + "KeyMacAltRight": "์šฐ์ธก โŒฅ", + "KeyWinLeft": "์ขŒ์ธก โŠž", + "KeyMacWinLeft": "์ขŒ์ธก โŒ˜", + "KeyWinRight": "์šฐ์ธก โŠž", + "KeyMacWinRight": "์šฐ์ธก โŒ˜", "KeyMenu": "๋ฉ”๋‰ด", "KeyUp": "โ†‘", "KeyDown": "โ†“", "KeyLeft": "โ†", "KeyRight": "โ†’", "KeyEnter": "์—”ํ„ฐ", - "KeyEscape": "์ด์Šค์ผ€์ดํ”„", + "KeyEscape": "Esc", "KeySpace": "์ŠคํŽ˜์ด์Šค", "KeyTab": "ํƒญ", "KeyBackSpace": "๋ฐฑ์ŠคํŽ˜์ด์Šค", - "KeyInsert": "Ins", - "KeyDelete": "Del", + "KeyInsert": "Insert", + "KeyDelete": "Delete", "KeyPageUp": "Page Up", "KeyPageDown": "Page Down", "KeyHome": "Home", "KeyEnd": "End", "KeyCapsLock": "Caps Lock", "KeyScrollLock": "Scroll Lock", - "KeyPrintScreen": "ํ”„๋ฆฐํŠธ ์Šคํฌ๋ฆฐ", + "KeyPrintScreen": "Print Screen", "KeyPause": "Pause", "KeyNumLock": "Num Lock", "KeyClear": "์ง€์šฐ๊ธฐ", @@ -335,7 +339,7 @@ "KeyKeypadSubtract": "ํ‚คํŒจ๋“œ ๋นผ๊ธฐ", "KeyKeypadAdd": "ํ‚คํŒจ๋“œ ์ถ”๊ฐ€", "KeyKeypadDecimal": "์ˆซ์ž ํ‚คํŒจ๋“œ", - "KeyKeypadEnter": "ํ‚คํŒจ๋“œ ์—”ํ„ฐ", + "KeyKeypadEnter": "ํ‚คํŒจ๋“œ ์ž…๋ ฅ", "KeyNumber0": "0", "KeyNumber1": "1", "KeyNumber2": "2", @@ -358,9 +362,9 @@ "KeyPeriod": ".", "KeySlash": "/", "KeyBackSlash": "\\", - "KeyUnbound": "๋ฐ”์ธ๋”ฉ ํ•ด์ œ", - "GamepadLeftStick": "L ์Šคํ‹ฑ ๋ฒ„ํŠผ", - "GamepadRightStick": "R ์Šคํ‹ฑ ๋ฒ„ํŠผ", + "KeyUnbound": "์—ฐ๋™ ํ•ด์ œ", + "GamepadLeftStick": "์ขŒ์ธก ์Šคํ‹ฑ ๋ฒ„ํŠผ", + "GamepadRightStick": "์šฐ์ธก ์Šคํ‹ฑ ๋ฒ„ํŠผ", "GamepadLeftShoulder": "์ขŒ์ธก ์ˆ„๋”", "GamepadRightShoulder": "์šฐ์ธก ์ˆ„๋”", "GamepadLeftTrigger": "์ขŒ์ธก ํŠธ๋ฆฌ๊ฑฐ", @@ -371,183 +375,186 @@ "GamepadDpadRight": "โ†’", "GamepadMinus": "-", "GamepadPlus": "+", - "GamepadGuide": "์•ˆ๋‚ด", + "GamepadGuide": "๊ฐ€์ด๋“œ", "GamepadMisc1": "๊ธฐํƒ€", "GamepadPaddle1": "ํŒจ๋“ค 1", "GamepadPaddle2": "ํŒจ๋“ค 2", "GamepadPaddle3": "ํŒจ๋“ค 3", "GamepadPaddle4": "ํŒจ๋“ค 4", "GamepadTouchpad": "ํ„ฐ์น˜ํŒจ๋“œ", - "GamepadSingleLeftTrigger0": "์™ผ์ชฝ ํŠธ๋ฆฌ๊ฑฐ 0", - "GamepadSingleRightTrigger0": "์˜ค๋ฅธ์ชฝ ํŠธ๋ฆฌ๊ฑฐ 0", - "GamepadSingleLeftTrigger1": "์™ผ์ชฝ ํŠธ๋ฆฌ๊ฑฐ 1", - "GamepadSingleRightTrigger1": "์˜ค๋ฅธ์ชฝ ํŠธ๋ฆฌ๊ฑฐ 1", + "GamepadSingleLeftTrigger0": "์ขŒ์ธก ํŠธ๋ฆฌ๊ฑฐ 0", + "GamepadSingleRightTrigger0": "์šฐ์ธก ํŠธ๋ฆฌ๊ฑฐ 0", + "GamepadSingleLeftTrigger1": "์ขŒ์ธก ํŠธ๋ฆฌ๊ฑฐ 1", + "GamepadSingleRightTrigger1": "์šฐ์ธก ํŠธ๋ฆฌ๊ฑฐ 1", "StickLeft": "์ขŒ์ธก ์Šคํ‹ฑ", "StickRight": "์šฐ์ธก ์Šคํ‹ฑ", - "UserProfilesSelectedUserProfile": "์„ ํƒํ•œ ์‚ฌ์šฉ์ž ํ”„๋กœํ•„ :", + "UserProfilesSelectedUserProfile": "์„ ํƒ๋œ ์‚ฌ์šฉ์ž ํ”„๋กœํ•„ :", "UserProfilesSaveProfileName": "ํ”„๋กœํ•„ ์ด๋ฆ„ ์ €์žฅ", "UserProfilesChangeProfileImage": "ํ”„๋กœํ•„ ์ด๋ฏธ์ง€ ๋ณ€๊ฒฝ", "UserProfilesAvailableUserProfiles": "์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ์‚ฌ์šฉ์ž ํ”„๋กœํ•„ :", - "UserProfilesAddNewProfile": "ํ”„๋กœํ•„ ์ƒ์„ฑ", + "UserProfilesAddNewProfile": "ํ”„๋กœํ•„ ๋งŒ๋“ค๊ธฐ", "UserProfilesDelete": "์‚ญ์ œ", "UserProfilesClose": "๋‹ซ๊ธฐ", - "ProfileNameSelectionWatermark": "๋‹‰๋„ค์ž„์„ ์ž…๋ ฅํ•˜์„ธ์š”", + "ProfileNameSelectionWatermark": "๋ณ„๋ช… ์„ ํƒ", "ProfileImageSelectionTitle": "ํ”„๋กœํ•„ ์ด๋ฏธ์ง€ ์„ ํƒ", - "ProfileImageSelectionHeader": "ํ”„๋กœํ•„ ์ด๋ฏธ์ง€ ์„ ํƒ", + "ProfileImageSelectionHeader": "ํ”„๋กœํ•„ ์ด๋ฏธ์ง€๋ฅผ ์„ ํƒ", "ProfileImageSelectionNote": "์‚ฌ์šฉ์ž ์ง€์ • ํ”„๋กœํ•„ ์ด๋ฏธ์ง€๋ฅผ ๊ฐ€์ ธ์˜ค๊ฑฐ๋‚˜ ์‹œ์Šคํ…œ ํŽŒ์›จ์–ด์—์„œ ์•„๋ฐ”ํƒ€ ์„ ํƒ ๊ฐ€๋Šฅ", "ProfileImageSelectionImportImage": "์ด๋ฏธ์ง€ ํŒŒ์ผ ๊ฐ€์ ธ์˜ค๊ธฐ", "ProfileImageSelectionSelectAvatar": "ํŽŒ์›จ์–ด ์•„๋ฐ”ํƒ€ ์„ ํƒ", - "InputDialogTitle": "์ž…๋ ฅ ๋Œ€ํ™”์ƒ์ž", + "InputDialogTitle": "๋Œ€ํ™” ์ƒ์ž ์ž…๋ ฅ", "InputDialogOk": "ํ™•์ธ", "InputDialogCancel": "์ทจ์†Œ", + "InputDialogCancelling": "์ทจ์†Œํ•˜๊ธฐ", + "InputDialogClose": "๋‹ซ๊ธฐ", "InputDialogAddNewProfileTitle": "ํ”„๋กœํ•„ ์ด๋ฆ„ ์„ ํƒ", - "InputDialogAddNewProfileHeader": "ํ”„๋กœํ•„ ์ด๋ฆ„ ์ž…๋ ฅ", + "InputDialogAddNewProfileHeader": "ํ”„๋กœํ•„ ์ด๋ฆ„์„ ์ž…๋ ฅ", "InputDialogAddNewProfileSubtext": "(์ตœ๋Œ€ ๊ธธ์ด : {0})", - "AvatarChoose": "์„ ํƒ", + "AvatarChoose": "์•„๋ฐ”ํƒ€ ์„ ํƒ", "AvatarSetBackgroundColor": "๋ฐฐ๊ฒฝ์ƒ‰ ์„ค์ •", "AvatarClose": "๋‹ซ๊ธฐ", "ControllerSettingsLoadProfileToolTip": "ํ”„๋กœํ•„ ๋ถˆ๋Ÿฌ์˜ค๊ธฐ", - "ControllerSettingsViewProfileToolTip": "View Profile", + "ControllerSettingsViewProfileToolTip": "ํ”„๋กœํ•„ ๋ณด๊ธฐ", "ControllerSettingsAddProfileToolTip": "ํ”„๋กœํ•„ ์ถ”๊ฐ€", - "ControllerSettingsRemoveProfileToolTip": "ํ”„๋กœํ•„ ์ œ๊ฑฐ", - "ControllerSettingsSaveProfileToolTip": "ํ”„๋กœํ•„ ์ €์žฅ", - "MenuBarFileToolsTakeScreenshot": "์Šคํฌ๋ฆฐ ์ƒท ์ฐ๊ธฐ", + "ControllerSettingsRemoveProfileToolTip": "ํ”„๋กœํ•„ ์‚ญ์ œ", + "ControllerSettingsSaveProfileToolTip": "ํ”„๋กœํ•„ ์ถ”๊ฐ€", + "MenuBarFileToolsTakeScreenshot": "์Šคํฌ๋ฆฐ์ƒท ์ฐ๊ธฐ", "MenuBarFileToolsHideUi": "UI ์ˆจ๊ธฐ๊ธฐ", - "GameListContextMenuRunApplication": "์‘์šฉํ”„๋กœ๊ทธ๋žจ ์‹คํ–‰", + "GameListContextMenuRunApplication": "์•ฑ ์‹คํ–‰", "GameListContextMenuToggleFavorite": "์ฆ๊ฒจ์ฐพ๊ธฐ ์ „ํ™˜", - "GameListContextMenuToggleFavoriteToolTip": "๊ฒŒ์ž„ ์ฆ๊ฒจ์ฐพ๊ธฐ ์ƒํƒœ ์ „ํ™˜", - "SettingsTabGeneralTheme": "ํ…Œ๋งˆ:", - "SettingsTabGeneralThemeAuto": "Auto", - "SettingsTabGeneralThemeDark": "์–ด๋‘์šดย ํ…Œ๋งˆ", - "SettingsTabGeneralThemeLight": "๋ฐ์€ ํ…Œ๋งˆ", - "ControllerSettingsConfigureGeneral": "๊ตฌ์„ฑ", + "GameListContextMenuToggleFavoriteToolTip": "๊ฒŒ์ž„์˜ ์ฆ๊ฒจ์ฐพ๊ธฐ ์ƒํƒœ ์ „ํ™˜", + "SettingsTabGeneralTheme": "ํ…Œ๋งˆ :", + "SettingsTabGeneralThemeAuto": "์ž๋™", + "SettingsTabGeneralThemeDark": "๋‹คํฌ", + "SettingsTabGeneralThemeLight": "๋ผ์ดํŠธ", + "ControllerSettingsConfigureGeneral": "์„ค์ •", "ControllerSettingsRumble": "์ง„๋™", "ControllerSettingsRumbleStrongMultiplier": "๊ฐ•๋ ฅํ•œ ์ง„๋™ ์ฆํญ๊ธฐ", "ControllerSettingsRumbleWeakMultiplier": "์•ฝํ•œ ์ง„๋™ ์ฆํญ๊ธฐ", "DialogMessageSaveNotAvailableMessage": "{0} [{1:x16}]์— ๋Œ€ํ•œ ์ €์žฅ ๋ฐ์ดํ„ฐ๊ฐ€ ์—†์Œ", - "DialogMessageSaveNotAvailableCreateSaveMessage": "์ด ๊ฒŒ์ž„์— ๋Œ€ํ•œ ์ €์žฅ ๋ฐ์ดํ„ฐ๋ฅผ ์ƒ์„ฑํ•˜๊ฒ ์Šต๋‹ˆ๊นŒ?", + "DialogMessageSaveNotAvailableCreateSaveMessage": "์ด ๊ฒŒ์ž„์˜ ์ €์žฅ ๋ฐ์ดํ„ฐ๋ฅผ ๋งŒ๋“ค๊ฒ ์Šต๋‹ˆ๊นŒ?", "DialogConfirmationTitle": "Ryujinx - ํ™•์ธ", "DialogUpdaterTitle": "Ryujinx - ์—…๋ฐ์ดํ„ฐ", "DialogErrorTitle": "Ryujinx - ์˜ค๋ฅ˜", "DialogWarningTitle": "Ryujinx - ๊ฒฝ๊ณ ", "DialogExitTitle": "Ryujinx - ์ข…๋ฃŒ", - "DialogErrorMessage": "Ryujinx ์˜ค๋ฅ˜ ๋ฐœ์ƒ", - "DialogExitMessage": "Ryujinx๋ฅผ ์ข…๋ฃŒํ•˜๊ฒ ์Šต๋‹ˆ๊นŒ?", - "DialogExitSubMessage": "์ €์žฅํ•˜์ง€ ์•Š์€ ๋ชจ๋“  ๋ฐ์ดํ„ฐ๋Š” ์†์‹ค๋ฉ๋‹ˆ๋‹ค!", - "DialogMessageCreateSaveErrorMessage": "์ง€์ •๋œ ์ €์žฅ ๋ฐ์ดํ„ฐ๋ฅผ ์ž‘์„ฑํ•˜๋Š” ์ค‘์— ์˜ค๋ฅ˜ ๋ฐœ์ƒ: {0}", - "DialogMessageFindSaveErrorMessage": "์ง€์ •๋œ ์ €์žฅ ๋ฐ์ดํ„ฐ๋ฅผ ์ฐพ๋Š” ์ค‘์— ์˜ค๋ฅ˜ ๋ฐœ์ƒ: {0}", - "FolderDialogExtractTitle": "์ถ”์ถœํ•  ํด๋” ์„ ํƒ", - "DialogNcaExtractionMessage": "{1}์—์„œ {0} ์„น์…˜์„ ์ถ”์ถœํ•˜๋Š” ์ค‘...", - "DialogNcaExtractionTitle": "NCA ์„น์…˜ ์ถ”์ถœ๊ธฐ", - "DialogNcaExtractionMainNcaNotFoundErrorMessage": "์ถ”์ถœ ์‹คํŒจํ•˜์˜€์Šต๋‹ˆ๋‹ค. ์„ ํƒํ•œ ํŒŒ์ผ์— ๊ธฐ๋ณธ NCA๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.", - "DialogNcaExtractionCheckLogErrorMessage": "์ถ”์ถœ ์‹คํŒจํ•˜์˜€์Šต๋‹ˆ๋‹ค. ์ž์„ธํ•œ ๋‚ด์šฉ์€ ๋กœ๊ทธ ํŒŒ์ผ์„ ์ฝ์œผ์„ธ์š”.", - "DialogNcaExtractionSuccessMessage": "์ถ”์ถœ์ด ์„ฑ๊ณต์ ์œผ๋กœ ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.", - "DialogUpdaterConvertFailedMessage": "ํ˜„์žฌ Ryujinx ๋ฒ„์ „์„ ๋ณ€ํ™˜ํ•˜์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.", - "DialogUpdaterCancelUpdateMessage": "์—…๋ฐ์ดํŠธ ์ทจ์†Œ ์ค‘ ์ž…๋‹ˆ๋‹ค!", - "DialogUpdaterAlreadyOnLatestVersionMessage": "์ด๋ฏธ ์ตœ์‹  ๋ฒ„์ „์˜ Ryujinx๋ฅผ ์‚ฌ์šฉํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค!", - "DialogUpdaterFailedToGetVersionMessage": "GitHub ๋ฆด๋ฆฌ์Šค์—์„œ ๋ฆด๋ฆฌ์Šค ์ •๋ณด๋ฅผ ๊ฐ€์ ธ์˜ค๋Š” ์ค‘์— ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค. ์ด๋Š” GitHub Actions์—์„œ ์ƒˆ ๋ฆด๋ฆฌ์Šค๋ฅผ ์ปดํŒŒ์ผํ•˜๋Š” ๊ฒฝ์šฐ ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ๋ช‡ ๋ถ„ ํ›„์— ๋‹ค์‹œ ์‹œ๋„ํ•˜์„ธ์š”.", - "DialogUpdaterConvertFailedGithubMessage": "Github ๊ฐœ์ •์—์„œ ๋ฐ›์€ Ryujinx ๋ฒ„์ „์„ ๋ณ€ํ™˜ํ•˜์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.", - "DialogUpdaterDownloadingMessage": "์—…๋ฐ์ดํŠธ ๋‹ค์šด๋กœ๋“œ ์ค‘...", + "DialogErrorMessage": "Ryujinx์—์„œ ์˜ค๋ฅ˜ ๋ฐœ์ƒ", + "DialogExitMessage": "์ •๋ง Ryujinx๋ฅผ ๋‹ซ์œผ์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?", + "DialogExitSubMessage": "์ €์žฅ๋˜์ง€ ์•Š์€ ๋ชจ๋“  ๋ฐ์ดํ„ฐ๋Š” ์†์‹ค๋ฉ๋‹ˆ๋‹ค!", + "DialogMessageCreateSaveErrorMessage": "์ง€์ •๋œ ์ €์žฅ ๋ฐ์ดํ„ฐ๋ฅผ ์ƒ์„ฑํ•˜๋Š” ๋™์•ˆ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒ : {0}", + "DialogMessageFindSaveErrorMessage": "์ง€์ •๋œ ์ €์žฅ ๋ฐ์ดํ„ฐ๋ฅผ ์ฐพ๋Š” ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒ : {0}", + "FolderDialogExtractTitle": "์••์ถ•์„ ํ’€ ํด๋”๋ฅผ ์„ ํƒ", + "DialogNcaExtractionMessage": "{1}์—์„œ {0} ๋‹จ๋ฉด ์ถ”์ถœ ์ค‘...", + "DialogNcaExtractionTitle": "NCA ๋‹จ๋ฉด ์ถ”์ถœ๊ธฐ", + "DialogNcaExtractionMainNcaNotFoundErrorMessage": "์ถ”์ถœ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค. ์„ ํƒํ•œ ํŒŒ์ผ์— ๊ธฐ๋ณธ NCA๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.", + "DialogNcaExtractionCheckLogErrorMessage": "์ถ”์ถœ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค. ์ž์„ธํ•œ ๋‚ด์šฉ์€ ๋กœ๊ทธ ํŒŒ์ผ์„ ํ™•์ธํ•˜์‹œ๊ธฐ ๋ฐ”๋ž๋‹ˆ๋‹ค.", + "DialogNcaExtractionSuccessMessage": "์„ฑ๊ณต์ ์œผ๋กœ ์ถ”์ถœ์ด ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.", + "DialogUpdaterConvertFailedMessage": "ํ˜„์žฌ Ryujinx ๋ฒ„์ „์„ ๋ณ€ํ™˜ํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.", + "DialogUpdaterCancelUpdateMessage": "์—…๋ฐ์ดํŠธ๊ฐ€ ์ทจ์†Œ๋˜์—ˆ์Šต๋‹ˆ๋‹ค!", + "DialogUpdaterAlreadyOnLatestVersionMessage": "์ด๋ฏธ ์ตœ์‹  ๋ฒ„์ „์˜ Ryujinx๋ฅผ ์‚ฌ์šฉ ์ค‘์ž…๋‹ˆ๋‹ค!", + "DialogUpdaterFailedToGetVersionMessage": "GitHub์—์„œ ๋ฆด๋ฆฌ์Šค ์ •๋ณด๋ฅผ ๊ฒ€์ƒ‰ํ•˜๋Š” ๋™์•ˆ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค. ํ˜„์žฌ GitHub Actions์—์„œ ์ƒˆ ๋ฆด๋ฆฌ์Šค๋ฅผ ์ปดํŒŒ์ผํ•˜๋Š” ์ค‘์ผ ๋•Œ ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ๋ช‡ ๋ถ„ ํ›„์— ๋‹ค์‹œ ์‹œ๋„ํ•ด ์ฃผ์„ธ์š”.", + "DialogUpdaterConvertFailedGithubMessage": "GitHub์—์„œ ๋ฐ›์€ Ryujinx ๋ฒ„์ „์„ ๋ณ€ํ™˜ํ•˜์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.", + "DialogUpdaterDownloadingMessage": "์—…๋ฐ์ดํŠธ ๋‚ด๋ ค๋ฐ›๋Š” ์ค‘...", "DialogUpdaterExtractionMessage": "์—…๋ฐ์ดํŠธ ์ถ”์ถœ ์ค‘...", - "DialogUpdaterRenamingMessage": "์—…๋ฐ์ดํŠธ ์ด๋ฆ„ ๋ฐ”๊พธ๋Š” ์ค‘...", + "DialogUpdaterRenamingMessage": "์ด๋ฆ„ ๋ณ€๊ฒฝ ์—…๋ฐ์ดํŠธ...", "DialogUpdaterAddingFilesMessage": "์ƒˆ ์—…๋ฐ์ดํŠธ ์ถ”๊ฐ€ ์ค‘...", - "DialogUpdaterCompleteMessage": "์—…๋ฐ์ดํŠธ๋ฅผ ์™„๋ฃŒํ–ˆ์Šต๋‹ˆ๋‹ค!", - "DialogUpdaterRestartMessage": "์ง€๊ธˆ Ryujinx๋ฅผ ๋‹ค์‹œ ์‹œ์ž‘ํ•˜๊ฒ ์Šต๋‹ˆ๊นŒ?", + "DialogUpdaterCompleteMessage": "์—…๋ฐ์ดํŠธ๊ฐ€ ์™„๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค!", + "DialogUpdaterRestartMessage": "์ง€๊ธˆ Ryujinx๋ฅผ ๋‹ค์‹œ ์‹œ์ž‘ํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?", "DialogUpdaterNoInternetMessage": "์ธํ„ฐ๋„ท์— ์—ฐ๊ฒฐ๋˜์–ด ์žˆ์ง€ ์•Š์Šต๋‹ˆ๋‹ค!", - "DialogUpdaterNoInternetSubMessage": "์ธํ„ฐ๋„ท ์—ฐ๊ฒฐ์ด ์ž‘๋™ํ•˜๋Š”์ง€ ํ™•์ธํ•˜์„ธ์š”!", - "DialogUpdaterDirtyBuildMessage": "Ryujinx์˜ ๋‚˜์œ ๋นŒ๋“œ๋Š” ์—…๋ฐ์ดํŠธํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค!\n", - "DialogUpdaterDirtyBuildSubMessage": "์ง€์›๋˜๋Š” ๋ฒ„์ „์„ ์ฐพ๊ณ  ์žˆ๋‹ค๋ฉด https://https://github.com/GreemDev/Ryujinx/releases/์—์„œ Ryujinx๋ฅผ ๋‹ค์šด๋กœ๋“œํ•˜์„ธ์š”.", - "DialogRestartRequiredMessage": "์žฌ์‹œ์ž‘ ํ•„์š”", - "DialogThemeRestartMessage": "ํ…Œ๋งˆ๊ฐ€ ์ €์žฅ๋˜์—ˆ์Šต๋‹ˆ๋‹ค. ํ…Œ๋งˆ๋ฅผ ์ ์šฉํ•˜๋ ค๋ฉด ๋‹ค์‹œ ์‹œ์ž‘ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.", - "DialogThemeRestartSubMessage": "๋‹ค์‹œ ์‹œ์ž‘ํ•˜๊ฒ ์Šต๋‹ˆ๊นŒ?", - "DialogFirmwareInstallEmbeddedMessage": "์ด ๊ฒŒ์ž„์— ๋‚ด์žฅ๋œ ํŽŒ์›จ์–ด๋ฅผ ์„ค์น˜ํ•˜๊ฒ ์Šต๋‹ˆ๊นŒ? (ํŽŒ์›จ์–ด {0})", - "DialogFirmwareInstallEmbeddedSuccessMessage": "์„ค์น˜๋œ ํŽŒ์›จ์–ด๊ฐ€ ์—†์ง€๋งŒ Ryujinx๊ฐ€ ์ œ๊ณต๋œ ๊ฒŒ์ž„์—์„œ ํŽŒ์›จ์–ด {0}์„(๋ฅผ) ์„ค์น˜ํ•  ์ˆ˜ ์žˆ์—ˆ์Šต๋‹ˆ๋‹ค.\n์ด์ œ ์—๋ฎฌ๋ ˆ์ดํ„ฐ๊ฐ€ ์‹œ์ž‘๋ฉ๋‹ˆ๋‹ค.", - "DialogFirmwareNoFirmwareInstalledMessage": "์„ค์น˜๋œ ํŽŒ์›จ์–ด ์—†์Œ", + "DialogUpdaterNoInternetSubMessage": "์ธํ„ฐ๋„ท์ด ์ œ๋Œ€๋กœ ์—ฐ๊ฒฐ๋˜์–ด ์žˆ๋Š”์ง€ ํ™•์ธํ•˜์„ธ์š”!", + "DialogUpdaterDirtyBuildMessage": "Ryujinx์˜ ๋”ํ‹ฐ ๋นŒ๋“œ๋Š” ์—…๋ฐ์ดํŠธํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค!", + "DialogUpdaterDirtyBuildSubMessage": "์ง€์›๋˜๋Š” ๋ฒ„์ „์„ ์ฐพ์œผ์‹ ๋‹ค๋ฉด https://github.com/GreemDev/Ryujinx/releases/์—์„œ Ryujinx๋ฅผ ๋‚ด๋ ค๋ฐ›์œผ์„ธ์š”.", + "DialogRestartRequiredMessage": "๋‹ค์‹œ ์‹œ์ž‘ ํ•„์š”", + "DialogThemeRestartMessage": "ํ…Œ๋งˆ๋ฅผ ์ €์žฅํ–ˆ์Šต๋‹ˆ๋‹ค. ํ…Œ๋งˆ๋ฅผ ์ ์šฉํ•˜๋ ค๋ฉด ๋‹ค์‹œ ์‹œ์ž‘ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.", + "DialogThemeRestartSubMessage": "๋‹ค์‹œ ์‹œ์ž‘ํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?", + "DialogFirmwareInstallEmbeddedMessage": "์ด ๊ฒŒ์ž„์— ํฌํ•จ๋œ ํŽŒ์›จ์–ด๋ฅผ ์„ค์น˜ํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?(Firmware {0})", + "DialogFirmwareInstallEmbeddedSuccessMessage": "์„ค์น˜๋œ ํŽŒ์›จ์–ด๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์ง€๋งŒ Ryujinx๋Š” ์ œ๊ณต๋œ ๊ฒŒ์ž„์—์„œ ํŽŒ์›จ์–ด {0}์„(๋ฅผ) ์„ค์น˜ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.\n์ด์ œ ์—๋ฎฌ๋ ˆ์ดํ„ฐ๊ฐ€ ์‹œ์ž‘๋ฉ๋‹ˆ๋‹ค.", + "DialogFirmwareNoFirmwareInstalledMessage": "ํŽŒ์›จ์–ด๊ฐ€ ์„ค์น˜๋˜์–ด ์žˆ์ง€ ์•Š์Œ", "DialogFirmwareInstalledMessage": "ํŽŒ์›จ์–ด {0}์ด(๊ฐ€) ์„ค์น˜๋จ", "DialogInstallFileTypesSuccessMessage": "ํŒŒ์ผ ํ˜•์‹์„ ์„ฑ๊ณต์ ์œผ๋กœ ์„ค์น˜ํ–ˆ์Šต๋‹ˆ๋‹ค!", "DialogInstallFileTypesErrorMessage": "ํŒŒ์ผ ํ˜•์‹์„ ์„ค์น˜ํ•˜์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.", - "DialogUninstallFileTypesSuccessMessage": "ํŒŒ์ผ ํ˜•์‹์„ ์„ฑ๊ณต์ ์œผ๋กœ ์ œ๊ฑฐํ–ˆ์Šต๋‹ˆ๋‹ค!", + "DialogUninstallFileTypesSuccessMessage": "ํŒŒ์ผ ํ˜•์‹์ด ์„ฑ๊ณต์ ์œผ๋กœ ์ œ๊ฑฐ๋˜์—ˆ์Šต๋‹ˆ๋‹ค!", "DialogUninstallFileTypesErrorMessage": "ํŒŒ์ผ ํ˜•์‹์„ ์ œ๊ฑฐํ•˜์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.", "DialogOpenSettingsWindowLabel": "์„ค์ • ์ฐฝ ์—ด๊ธฐ", + "DialogOpenXCITrimmerWindowLabel": "XCI ํŠธ๋ฆฌ๋จธ ์ฐฝ", "DialogControllerAppletTitle": "์ปจํŠธ๋กค๋Ÿฌ ์• ํ”Œ๋ฆฟ", - "DialogMessageDialogErrorExceptionMessage": "๋ฉ”์‹œ์ง€ ๋Œ€ํ™”์ƒ์ž๋ฅผ ํ‘œ์‹œํ•˜๋Š” ๋™์•ˆ ์˜ค๋ฅ˜ ๋ฐœ์ƒ : {0}", - "DialogSoftwareKeyboardErrorExceptionMessage": "์†Œํ”„ํŠธ์›จ์–ด ํ‚ค๋ณด๋“œ๋ฅผ ํ‘œ์‹œํ•˜๋Š” ๋™์•ˆ ์˜ค๋ฅ˜ ๋ฐœ์ƒ : {0}", - "DialogErrorAppletErrorExceptionMessage": "์˜ค๋ฅ˜์—ํ”Œ๋ฆฟ ๋Œ€ํ™”์ƒ์ž๋ฅผ ํ‘œ์‹œํ•˜๋Š” ๋™์•ˆ ์˜ค๋ฅ˜ ๋ฐœ์ƒ : {0}", + "DialogMessageDialogErrorExceptionMessage": "๋ฉ”์‹œ์ง€ ๋Œ€ํ™” ์ƒ์ž ํ‘œ์‹œ ์˜ค๋ฅ˜ : {0}", + "DialogSoftwareKeyboardErrorExceptionMessage": "์†Œํ”„ํŠธ์›จ์–ด ํ‚ค๋ณด๋“œ ํ‘œ์‹œ ์˜ค๋ฅ˜ : {0}", + "DialogErrorAppletErrorExceptionMessage": "ErrorApplet ๋Œ€ํ™” ์ƒ์ž ํ‘œ์‹œ ์˜ค๋ฅ˜ : {0}", "DialogUserErrorDialogMessage": "{0}: {1}", - "DialogUserErrorDialogInfoMessage": "\n์ด ์˜ค๋ฅ˜๋ฅผ ์ˆ˜์ •ํ•˜๋Š” ๋ฐฉ๋ฒ•์— ๋Œ€ํ•œ ์ž์„ธํ•œ ๋‚ด์šฉ์€ ์„ค์ • ๊ฐ€์ด๋“œ๋ฅผ ๋”ฐ๋ฅด์„ธ์š”.", - "DialogUserErrorDialogTitle": "Ryuijnx ์˜ค๋ฅ˜ ({0})", + "DialogUserErrorDialogInfoMessage": "\n์ด ์˜ค๋ฅ˜๋ฅผ ํ•ด๊ฒฐํ•˜๋Š” ๋ฐฉ๋ฒ•์— ๋Œ€ํ•œ ์ž์„ธํ•œ ๋‚ด์šฉ์€ ์„ค์ • ๊ฐ€์ด๋“œ๋ฅผ ์ฐธ์กฐํ•˜์„ธ์š”.", + "DialogUserErrorDialogTitle": "Ryujinx ์˜ค๋ฅ˜ ({0})", "DialogAmiiboApiTitle": "Amiibo API", - "DialogAmiiboApiFailFetchMessage": "API์—์„œ ์ •๋ณด๋ฅผ ๊ฐ€์ ธ์˜ค๋Š” ๋™์•ˆ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.", - "DialogAmiiboApiConnectErrorMessage": "Amiibo API ์„œ๋ฒ„์— ์—ฐ๊ฒฐํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. ์„œ๋น„์Šค๊ฐ€ ๋‹ค์šด๋˜์—ˆ๊ฑฐ๋‚˜ ์ธํ„ฐ๋„ท ์—ฐ๊ฒฐ์ด ์˜จ๋ผ์ธ ์ƒํƒœ์ธ์ง€ ํ™•์ธํ•ด์•ผ ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.", - "DialogProfileInvalidProfileErrorMessage": "{0} ํ”„๋กœํ•„์€ ํ˜„์žฌ ์ž…๋ ฅ ๊ตฌ์„ฑ ์‹œ์Šคํ…œ๊ณผ ํ˜ธํ™˜๋˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.", - "DialogProfileDefaultProfileOverwriteErrorMessage": "๊ธฐ๋ณธ ํ”„๋กœํ•„์„ ๋ฎ์–ด์“ธ ์ˆ˜ ์—†์Œ", - "DialogProfileDeleteProfileTitle": "ํ”„๋กœํ•„ ์‚ญ์ œ", - "DialogProfileDeleteProfileMessage": "์ด ์ž‘์—…์€ ๋˜๋Œ๋ฆด ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. ๊ณ„์†ํ•˜๊ฒ ์Šต๋‹ˆ๊นŒ?", + "DialogAmiiboApiFailFetchMessage": "API์—์„œ ์ •๋ณด๋ฅผ ๊ฐ€์ ธ์˜ค๋Š” ์ค‘์— ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.", + "DialogAmiiboApiConnectErrorMessage": "Amiibo API ์„œ๋ฒ„์— ์—ฐ๊ฒฐํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. ์„œ๋น„์Šค๊ฐ€ ๋‹ค์šด๋˜์—ˆ๊ฑฐ๋‚˜ ์ธํ„ฐ๋„ท ์—ฐ๊ฒฐ์ด ์˜จ๋ผ์ธ ์ƒํƒœ์ธ์ง€ ํ™•์ธ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.", + "DialogProfileInvalidProfileErrorMessage": "ํ”„๋กœํ•„ {0}์€(๋Š”) ํ˜„์žฌ ์ž…๋ ฅ ๊ตฌ์„ฑ ์‹œ์Šคํ…œ๊ณผ ํ˜ธํ™˜๋˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.", + "DialogProfileDefaultProfileOverwriteErrorMessage": "๊ธฐ๋ณธ ํ”„๋กœํ•„์€ ๋ฎ์–ด์“ธ ์ˆ˜ ์—†์Œ", + "DialogProfileDeleteProfileTitle": "ํ”„๋กœํ•„ ์‚ญ์ œํ•˜๊ธฐ", + "DialogProfileDeleteProfileMessage": "์ด ์ž‘์—…์€ ๋˜๋Œ๋ฆด ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. ๊ณ„์†ํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?", "DialogWarning": "๊ฒฝ๊ณ ", - "DialogPPTCDeletionMessage": "๋‹ค์Œ ๋ถ€ํŒ… ์‹œ, PPTC ์žฌ๊ตฌ์ถ•์„ ๋Œ€๊ธฐ์—ด์— ์ถ”๊ฐ€ :\n\n{0}\n\n๊ณ„์†ํ•˜๊ฒ ์Šต๋‹ˆ๊นŒ?", - "DialogPPTCDeletionErrorMessage": "{0}์—์„œ PPTC ์บ์‹œ ์‚ญ์ œ ์˜ค๋ฅ˜ : {1}", - "DialogShaderDeletionMessage": "๋‹ค์Œ์— ๋Œ€ํ•œ ์…ฐ์ด๋” ์บ์‹œ ์‚ญ์ œ :\n\n{0}\n\n๊ณ„์†ํ•˜๊ฒ ์Šต๋‹ˆ๊นŒ?", - "DialogShaderDeletionErrorMessage": "{0}์—์„œ ์…ฐ์ด๋” ์บ์‹œ ์ œ๊ฑฐ ์˜ค๋ฅ˜ : {1}", - "DialogRyujinxErrorMessage": "Ryujinx์— ์˜ค๋ฅ˜ ๋ฐœ์ƒ", + "DialogPPTCDeletionMessage": "๋‹ค์Œ์— ๋ถ€ํŒ…ํ•  ๋•Œ, PPTC ์žฌ๊ตฌ์ถ•์„ ๋Œ€๊ธฐ์—ด์— ์ถ”๊ฐ€ํ•˜๋ ค๊ณ  ํ•ฉ๋‹ˆ๋‹ค.\n\n{0}\n\n๊ณ„์†ํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?", + "DialogPPTCDeletionErrorMessage": "{0}์—์„œ PPTC ์บ์‹œ๋ฅผ ์ง€์šฐ๋Š” ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ : {1}", + "DialogShaderDeletionMessage": "๋‹ค์Œ ์…ฐ์ด๋” ์บ์‹œ๋ฅผ ์‚ญ์ œ :\n\n{0}\n\n๊ณ„์†ํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?", + "DialogShaderDeletionErrorMessage": "{0}์—์„œ ์…ฐ์ด๋” ์บ์‹œ๋ฅผ ์‚ญ์ œํ•˜๋Š” ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ : {1}", + "DialogRyujinxErrorMessage": "Ryujinx์—์„œ ์˜ค๋ฅ˜ ๋ฐœ์ƒ", "DialogInvalidTitleIdErrorMessage": "UI ์˜ค๋ฅ˜ : ์„ ํƒํ•œ ๊ฒŒ์ž„์— ์œ ํšจํ•œ ํƒ€์ดํ‹€ ID๊ฐ€ ์—†์Œ", "DialogFirmwareInstallerFirmwareNotFoundErrorMessage": "{0}์—์„œ ์œ ํšจํ•œ ์‹œ์Šคํ…œ ํŽŒ์›จ์–ด๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.", "DialogFirmwareInstallerFirmwareInstallTitle": "ํŽŒ์›จ์–ด {0} ์„ค์น˜", "DialogFirmwareInstallerFirmwareInstallMessage": "์‹œ์Šคํ…œ ๋ฒ„์ „ {0}์ด(๊ฐ€) ์„ค์น˜๋ฉ๋‹ˆ๋‹ค.", - "DialogFirmwareInstallerFirmwareInstallSubMessage": "\n\n์ด๊ฒƒ์€ ํ˜„์žฌ ์‹œ์Šคํ…œ ๋ฒ„์ „ {0}์„(๋ฅผ) ๋Œ€์ฒดํ•ฉ๋‹ˆ๋‹ค.", - "DialogFirmwareInstallerFirmwareInstallConfirmMessage": "\n\n๊ณ„์†ํ•˜๊ฒ ์Šต๋‹ˆ๊นŒ?", + "DialogFirmwareInstallerFirmwareInstallSubMessage": "\n\nํ˜„์žฌ ์‹œ์Šคํ…œ ๋ฒ„์ „ {0}์„(๋ฅผ) ๋Œ€์ฒดํ•ฉ๋‹ˆ๋‹ค.", + "DialogFirmwareInstallerFirmwareInstallConfirmMessage": "\n\n๊ณ„์†ํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?", "DialogFirmwareInstallerFirmwareInstallWaitMessage": "ํŽŒ์›จ์–ด ์„ค์น˜ ์ค‘...", - "DialogFirmwareInstallerFirmwareInstallSuccessMessage": "์‹œ์Šคํ…œ ๋ฒ„์ „ {0}์ด(๊ฐ€) ์„ฑ๊ณต์ ์œผ๋กœ ์„ค์น˜๋˜์—ˆ์Šต๋‹ˆ๋‹ค.", - "DialogUserProfileDeletionWarningMessage": "์„ ํƒํ•œ ํ”„๋กœํŒŒ์ผ์ด ์‚ญ์ œ๋˜๋ฉด ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ๋‹ค๋ฅธ ํ”„๋กœํŒŒ์ผ์ด ์—†์Œ", - "DialogUserProfileDeletionConfirmMessage": "์„ ํƒํ•œ ํ”„๋กœํŒŒ์ผ์„ ์‚ญ์ œํ•˜๊ฒ ์Šต๋‹ˆ๊นŒ?", - "DialogUserProfileUnsavedChangesTitle": "๊ฒฝ๊ณ  - ๋ณ€๊ฒฝ์‚ฌํ•ญ ์ €์žฅ๋˜์ง€ ์•Š์Œ", - "DialogUserProfileUnsavedChangesMessage": "์ €์žฅ๋˜์ง€ ์•Š์€ ์‚ฌ์šฉ์ž ํ”„๋กœํŒŒ์ผ์„ ์ˆ˜์ •ํ–ˆ์Šต๋‹ˆ๋‹ค.", - "DialogUserProfileUnsavedChangesSubMessage": "๋ณ€๊ฒฝ์‚ฌํ•ญ์„ ์ €์žฅํ•˜์ง€ ์•Š์œผ์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?", + "DialogFirmwareInstallerFirmwareInstallSuccessMessage": "์‹œ์Šคํ…œ ๋ฒ„์ „ {0}์ด(๊ฐ€) ์„ค์น˜๋˜์—ˆ์Šต๋‹ˆ๋‹ค.", + "DialogUserProfileDeletionWarningMessage": "์„ ํƒํ•œ ํ”„๋กœํ•„์„ ์‚ญ์ œํ•˜๋ฉด ๋‹ค๋ฅธ ํ”„๋กœํ•„์„ ์—ด ์ˆ˜ ์—†์Œ", + "DialogUserProfileDeletionConfirmMessage": "์„ ํƒํ•œ ํ”„๋กœํ•„์„ ์‚ญ์ œํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?", + "DialogUserProfileUnsavedChangesTitle": "๊ฒฝ๊ณ  - ์ €์žฅ๋˜์ง€ ์•Š์€ ๋ณ€๊ฒฝ ์‚ฌํ•ญ", + "DialogUserProfileUnsavedChangesMessage": "์ €์žฅ๋˜์ง€ ์•Š์€ ์‚ฌ์šฉ์ž ํ”„๋กœํ•„์˜ ๋ณ€๊ฒฝ ์‚ฌํ•ญ์ด ์žˆ์Šต๋‹ˆ๋‹ค.", + "DialogUserProfileUnsavedChangesSubMessage": "๋ณ€๊ฒฝ ์‚ฌํ•ญ์„ ์ทจ์†Œํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?", "DialogControllerSettingsModifiedConfirmMessage": "ํ˜„์žฌ ์ปจํŠธ๋กค๋Ÿฌ ์„ค์ •์ด ์—…๋ฐ์ดํŠธ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.", - "DialogControllerSettingsModifiedConfirmSubMessage": "์ €์žฅํ•˜๊ฒ ์Šต๋‹ˆ๊นŒ?", - "DialogLoadFileErrorMessage": "{0}. ์˜ค๋ฅ˜ ๋ฐœ์ƒ ํŒŒ์ผ : {1}", - "DialogModAlreadyExistsMessage": "Mod๊ฐ€ ์ด๋ฏธ ์กด์žฌํ•ฉ๋‹ˆ๋‹ค.", - "DialogModInvalidMessage": "์ง€์ •๋œ ๋””๋ ‰ํ„ฐ๋ฆฌ์— Mod๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค!", - "DialogModDeleteNoParentMessage": "์‚ญ์ œ ์‹คํŒจ: \"{0}\" Mod์˜ ์ƒ์œ„ ๋””๋ ‰ํ„ฐ๋ฆฌ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค!", - "DialogDlcNoDlcErrorMessage": "์ง€์ •๋œ ํŒŒ์ผ์— ์„ ํƒํ•œ ํƒ€์ดํ‹€์— ๋Œ€ํ•œ DLC๊ฐ€ ํฌํ•จ๋˜์–ด ์žˆ์ง€ ์•Š์Šต๋‹ˆ๋‹ค!", - "DialogPerformanceCheckLoggingEnabledMessage": "๊ฐœ๋ฐœ์ž๋งŒ ์‚ฌ์šฉํ•˜๋„๋ก ์„ค๊ณ„๋œ ์ถ”์  ๋กœ๊ทธ ๊ธฐ๋ก์ด ํ™œ์„ฑํ™”๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค.", - "DialogPerformanceCheckLoggingEnabledConfirmMessage": "์ตœ์ ์˜ ์„ฑ๋Šฅ์„ ์œ„ํ•ด ์ถ”์  ๋กœ๊ทธ ์ƒ์„ฑ์„ ๋น„ํ™œ์„ฑํ™”ํ•˜๋Š” ๊ฒƒ์ด ์ข‹์Šต๋‹ˆ๋‹ค. ์ง€๊ธˆ ์ถ”์  ๋กœ๊ทธ ๊ธฐ๋ก์„ ๋น„ํ™œ์„ฑํ™”ํ•˜๊ฒ ์Šต๋‹ˆ๊นŒ?", - "DialogPerformanceCheckShaderDumpEnabledMessage": "๊ฐœ๋ฐœ์ž๋งŒ ์‚ฌ์šฉํ•˜๋„๋ก ์„ค๊ณ„๋œ ์…ฐ์ด๋” ๋คํ”„๋ฅผ ํ™œ์„ฑํ™”ํ–ˆ์Šต๋‹ˆ๋‹ค.", - "DialogPerformanceCheckShaderDumpEnabledConfirmMessage": "์ตœ์ ์˜ ์„ฑ๋Šฅ์„ ์œ„ํ•ด ์„ธ์ด๋” ๋คํ•‘์„ ๋น„ํ™œ์„ฑํ™”ํ•˜๋Š” ๊ฒƒ์ด ์ข‹์Šต๋‹ˆ๋‹ค. ์ง€๊ธˆ ์„ธ์ด๋” ๋คํ•‘์„ ๋น„ํ™œ์„ฑํ™”ํ•˜๊ฒ ์Šต๋‹ˆ๊นŒ?", - "DialogLoadAppGameAlreadyLoadedMessage": "์ด๋ฏธ ๊ฒŒ์ž„ ๋ถˆ๋Ÿฌ์˜ด", - "DialogLoadAppGameAlreadyLoadedSubMessage": "๋‹ค๋ฅธ ๊ฒŒ์ž„์„ ์‹œ์ž‘ํ•˜๊ธฐ ์ „์— ์—๋ฎฌ๋ ˆ์ด์…˜์„ ์ค‘์ง€ํ•˜๊ฑฐ๋‚˜ ์—๋ฎฌ๋ ˆ์ดํ„ฐ๋ฅผ ๋‹ซ์œผ์„ธ์š”.", - "DialogUpdateAddUpdateErrorMessage": "์ง€์ •๋œ ํŒŒ์ผ์— ์„ ํƒํ•œ ์ œ๋ชฉ์— ๋Œ€ํ•œ ์—…๋ฐ์ดํŠธ๊ฐ€ ํฌํ•จ๋˜์–ด ์žˆ์ง€ ์•Š์Šต๋‹ˆ๋‹ค!", + "DialogControllerSettingsModifiedConfirmSubMessage": "์ €์žฅํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?", + "DialogLoadFileErrorMessage": "{0}. ์˜ค๋ฅ˜ ํŒŒ์ผ : {1}", + "DialogModAlreadyExistsMessage": "์ด๋ฏธ ์กด์žฌํ•˜๋Š” ๋ชจ๋“œ", + "DialogModInvalidMessage": "์ง€์ •ํ•œ ๋””๋ ‰ํ„ฐ๋ฆฌ์— ๋ชจ๋“œ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค!", + "DialogModDeleteNoParentMessage": "์‚ญ์ œ ์‹คํŒจ : \"{0}\" ๋ชจ๋“œ์˜ ์ƒ์œ„ ๋””๋ ‰ํ„ฐ๋ฆฌ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค!", + "DialogDlcNoDlcErrorMessage": "์ง€์ •๋œ ํŒŒ์ผ์— ์„ ํƒํ•œ ํƒ€์ดํ‹€์˜ DLC๊ฐ€ ํฌํ•จ๋˜์–ด ์žˆ์ง€ ์•Š์Šต๋‹ˆ๋‹ค!", + "DialogPerformanceCheckLoggingEnabledMessage": "๊ฐœ๋ฐœ์ž๋งŒ ์‚ฌ์šฉํ•˜๋„๋ก ์„ค๊ณ„๋œ ์ถ”์  ๊ธฐ๋ก์ด ํ™œ์„ฑํ™”๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค.", + "DialogPerformanceCheckLoggingEnabledConfirmMessage": "์ตœ์ ์˜ ์„ฑ๋Šฅ์„ ์œ„ํ•ด์„œ๋Š” ์ถ”์  ๊ธฐ๋ก์„ ๋น„ํ™œ์„ฑํ™”ํ•˜๋Š” ๊ฒƒ์ด ์ข‹์Šต๋‹ˆ๋‹ค. ์ง€๊ธˆ ์ถ”์  ๊ธฐ๋ก์„ ๋น„ํ™œ์„ฑํ™”ํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?", + "DialogPerformanceCheckShaderDumpEnabledMessage": "๊ฐœ๋ฐœ์ž๋งŒ ์‚ฌ์šฉํ•˜๋„๋ก ์„ค๊ณ„๋œ ์…ฐ์ด๋” ๋คํ•‘์ด ํ™œ์„ฑํ™”๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค.", + "DialogPerformanceCheckShaderDumpEnabledConfirmMessage": "์ตœ์ ์˜ ์„ฑ๋Šฅ์„ ์œ„ํ•ด์„œ๋Š” ์…ฐ์ด๋” ๋คํ•‘์„ ๋น„ํ™œ์„ฑํ™”ํ•˜๋Š” ๊ฒƒ์ด ์ข‹์Šต๋‹ˆ๋‹ค. ์ง€๊ธˆ ์…ฐ์ด๋” ๋คํ•‘์„ ๋น„ํ™œ์„ฑํ™”ํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?", + "DialogLoadAppGameAlreadyLoadedMessage": "์ด๋ฏธ ๊ฒŒ์ž„์„ ๋ถˆ๋Ÿฌ์˜ด", + "DialogLoadAppGameAlreadyLoadedSubMessage": "๋‹ค๋ฅธ ๊ฒŒ์ž„์„ ์‹คํ–‰ํ•˜๊ธฐ ์ „์— ์—๋ฎฌ๋ ˆ์ด์…˜์„ ์ค‘์ง€ํ•˜๊ฑฐ๋‚˜ ์—๋ฎฌ๋ ˆ์ดํ„ฐ๋ฅผ ๋‹ซ์œผ์„ธ์š”.", + "DialogUpdateAddUpdateErrorMessage": "์ง€์ •ํ•œ ํŒŒ์ผ์— ์„ ํƒํ•œ ํƒ€์ดํ‹€์— ๋Œ€ํ•œ ์—…๋ฐ์ดํŠธ๊ฐ€ ํฌํ•จ๋˜์–ด ์žˆ์ง€ ์•Š์Šต๋‹ˆ๋‹ค!", "DialogSettingsBackendThreadingWarningTitle": "๊ฒฝ๊ณ  - ํ›„๋‹จ๋ถ€ ์Šค๋ ˆ๋”ฉ", - "DialogSettingsBackendThreadingWarningMessage": "๋ณ€๊ฒฝ ์‚ฌํ•ญ์„ ์™„์ „ํžˆ ์ ์šฉํ•˜๋ ค๋ฉด ์ด ์˜ต์…˜์„ ๋ณ€๊ฒฝํ•œ ํ›„, Ryujinx๋ฅผ ๋‹ค์‹œ ์‹œ์ž‘ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ํ”Œ๋žซํผ์— ๋”ฐ๋ผ Ryujinx๋ฅผ ์‚ฌ์šฉํ•  ๋•Œ ๋“œ๋ผ์ด๋ฒ„ ์ž์ฒด์˜ ๋ฉ€ํ‹ฐ์Šค๋ ˆ๋”ฉ์„ ์ˆ˜๋™์œผ๋กœ ๋น„ํ™œ์„ฑํ™”ํ•ด์•ผ ํ•  ์ˆ˜๋„ ์žˆ์Šต๋‹ˆ๋‹ค.", - "DialogModManagerDeletionWarningMessage": "ํ•ด๋‹น Mod๋ฅผ ์‚ญ์ œํ•˜๋ ค๊ณ  ํ•ฉ๋‹ˆ๋‹ค: {0}\n\n์ •๋ง๋กœ ์‚ญ์ œํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?", - "DialogModManagerDeletionAllWarningMessage": "ํ•ด๋‹น ํƒ€์ดํ‹€์— ๋Œ€ํ•œ ๋ชจ๋“  Mod๋“ค์„ ์‚ญ์ œํ•˜๋ ค๊ณ  ํ•ฉ๋‹ˆ๋‹ค.\n\n์ •๋ง๋กœ ์‚ญ์ œํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?", + "DialogSettingsBackendThreadingWarningMessage": "์™„์ „ํžˆ ์ ์šฉํ•˜๋ ค๋ฉด ์ด ์˜ต์…˜์„ ๋ณ€๊ฒฝํ•œ ํ›„ Ryujinx๋ฅผ ๋‹ค์‹œ ์‹œ์ž‘ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ํ”Œ๋žซํผ์— ๋”ฐ๋ผ Ryujinx๋ฅผ ์‚ฌ์šฉํ•  ๋•Œ ๋“œ๋ผ์ด๋ฒ„ ์ž์ฒด์˜ ๋‹ค์ค‘ ์Šค๋ ˆ๋”ฉ์„ ์ˆ˜๋™์œผ๋กœ ๋น„ํ™œ์„ฑํ™”ํ•ด์•ผ ํ•  ์ˆ˜๋„ ์žˆ์Šต๋‹ˆ๋‹ค.", + "DialogModManagerDeletionWarningMessage": "๋ชจ๋“œ ์‚ญ์ œ : {0}\n\n๊ณ„์†ํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?", + "DialogModManagerDeletionAllWarningMessage": "์ด ํƒ€์ดํ‹€์— ๋Œ€ํ•œ ๋ชจ๋“œ๋ฅผ ๋ชจ๋‘ ์‚ญ์ œํ•˜๋ ค๊ณ  ํ•ฉ๋‹ˆ๋‹ค.\n\n๊ณ„์†ํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?", "SettingsTabGraphicsFeaturesOptions": "๊ธฐ๋Šฅ", - "SettingsTabGraphicsBackendMultithreading": "๊ทธ๋ž˜ํ”ฝ ํ›„๋‹จ๋ถ€ ๋ฉ€ํ‹ฐ์Šค๋ ˆ๋”ฉ :", + "SettingsTabGraphicsBackendMultithreading": "๊ทธ๋ž˜ํ”ฝ ํ›„๋‹จ๋ถ€ ๋‹ค์ค‘ ์Šค๋ ˆ๋”ฉ :", "CommonAuto": "์ž๋™", "CommonOff": "๋”", "CommonOn": "์ผฌ", "InputDialogYes": "์˜ˆ", "InputDialogNo": "์•„๋‹ˆ์˜ค", "DialogProfileInvalidProfileNameErrorMessage": "ํŒŒ์ผ ์ด๋ฆ„์— ์ž˜๋ชป๋œ ๋ฌธ์ž๊ฐ€ ํฌํ•จ๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค. ๋‹ค์‹œ ์‹œ๋„ํ•˜์„ธ์š”.", - "MenuBarOptionsPauseEmulation": "์ผ์‹œ ์ •์ง€", + "MenuBarOptionsPauseEmulation": "์ผ์‹œ ์ค‘์ง€", "MenuBarOptionsResumeEmulation": "๋‹ค์‹œ ์‹œ์ž‘", - "AboutUrlTooltipMessage": "๊ธฐ๋ณธ ๋ธŒ๋ผ์šฐ์ €์—์„œ Ryujinx ์›น์‚ฌ์ดํŠธ๋ฅผ ์—ด๋ ค๋ฉด ํด๋ฆญํ•˜์„ธ์š”.", - "AboutDisclaimerMessage": "Ryujinx๋Š” ๋‹Œํ…๋„โ„ข,\n๋˜๋Š” ๊ทธ ํŒŒํŠธ๋„ˆ์™€ ์ œํœดํ•œ ๋ฐ”๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.", - "AboutAmiiboDisclaimerMessage": "AmiiboAPI (www.amiiboapi.com)๋Š”\nAmiibo ์—๋ฎฌ๋ ˆ์ด์…˜์— ์‚ฌ์šฉ๋ฉ๋‹ˆ๋‹ค.", - "AboutPatreonUrlTooltipMessage": "๊ธฐ๋ณธ ๋ธŒ๋ผ์šฐ์ €์—์„œ Ryujinx Patreon ํŽ˜์ด์ง€๋ฅผ ์—ด๋ ค๋ฉด ํด๋ฆญํ•˜์„ธ์š”.", - "AboutGithubUrlTooltipMessage": "๊ธฐ๋ณธ ๋ธŒ๋ผ์šฐ์ €์—์„œ Ryujinx GitHub ํŽ˜์ด์ง€๋ฅผ ์—ด๋ ค๋ฉด ํด๋ฆญํ•˜์„ธ์š”.", - "AboutDiscordUrlTooltipMessage": "๊ธฐ๋ณธ ๋ธŒ๋ผ์šฐ์ €์—์„œ Ryujinx ๋””์Šค์ฝ”๋“œ ์„œ๋ฒ„์— ๋Œ€ํ•œ ์ดˆ๋Œ€๋ฅผ ์—ด๋ ค๋ฉด ํด๋ฆญํ•˜์„ธ์š”.", - "AboutTwitterUrlTooltipMessage": "๊ธฐ๋ณธ ๋ธŒ๋ผ์šฐ์ €์—์„œ Ryujinx ํŠธ์œ„ํ„ฐ ํŽ˜์ด์ง€๋ฅผ ์—ด๋ ค๋ฉด ํด๋ฆญํ•˜์„ธ์š”.", + "AboutUrlTooltipMessage": "ํด๋ฆญํ•˜๋ฉด ๊ธฐ๋ณธ ๋ธŒ๋ผ์šฐ์ €์—์„œ Ryujinx ์›น์‚ฌ์ดํŠธ๊ฐ€ ์—ด๋ฆฝ๋‹ˆ๋‹ค.", + "AboutDisclaimerMessage": "Ryujinx๋Š” Nintendoโ„ข\n๋˜๋Š” ๊ทธ ํŒŒํŠธ๋„ˆ์™€ ์ œํœดํ•œ ๋ฐ”๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.", + "AboutAmiiboDisclaimerMessage": "AmiiboAPI(www.amiiboapi.com)๋Š”\nAmiibo ์—๋ฎฌ๋ ˆ์ด์…˜์— ์‚ฌ์šฉ๋ฉ๋‹ˆ๋‹ค.", + "AboutPatreonUrlTooltipMessage": "ํด๋ฆญํ•˜๋ฉด ๊ธฐ๋ณธ ๋ธŒ๋ผ์šฐ์ €์—์„œ Ryujinx Patreon ํŽ˜์ด์ง€๊ฐ€ ์—ด๋ฆฝ๋‹ˆ๋‹ค.", + "AboutGithubUrlTooltipMessage": "ํด๋ฆญํ•˜๋ฉด ๊ธฐ๋ณธ ๋ธŒ๋ผ์šฐ์ €์—์„œ Ryujinx GitHub ํŽ˜์ด์ง€๊ฐ€ ์—ด๋ฆฝ๋‹ˆ๋‹ค.", + "AboutDiscordUrlTooltipMessage": "ํด๋ฆญํ•˜๋ฉด ๊ธฐ๋ณธ ๋ธŒ๋ผ์šฐ์ €์—์„œ Ryujinx ๋””์Šค์ฝ”๋“œ ์„œ๋ฒ„ ์ดˆ๋Œ€์žฅ์ด ์—ด๋ฆฝ๋‹ˆ๋‹ค.", + "AboutTwitterUrlTooltipMessage": "ํด๋ฆญํ•˜๋ฉด ๊ธฐ๋ณธ ๋ธŒ๋ผ์šฐ์ €์—์„œ Ryujinx ํŠธ์œ„ํ„ฐ ํŽ˜์ด์ง€๊ฐ€ ์—ด๋ฆฝ๋‹ˆ๋‹ค.", "AboutRyujinxAboutTitle": "์ •๋ณด :", - "AboutRyujinxAboutContent": "Ryujinx๋Š” ๋‹Œํ…๋„ ์Šค์œ„์น˜โ„ข์šฉ ์—๋ฎฌ๋ ˆ์ดํ„ฐ์ž…๋‹ˆ๋‹ค.\nPatreon์—์„œ ์ง€์›ํ•ด ์ฃผ์„ธ์š”.\nํŠธ์œ„ํ„ฐ๋‚˜ ๋””์Šค์ฝ”๋“œ์—์„œ ์ตœ์‹  ์†Œ์‹์„ ๋ฐ›์•„๋ณด์„ธ์š”.\n๊ธฐ์—ฌ์— ์ฐธ์—ฌํ•˜๊ณ ์ž ํ•˜๋Š” ๊ฐœ๋ฐœ์ž๋Š” GitHub ๋˜๋Š” ๋””์Šค์ฝ”๋“œ์—์„œ ์ž์„ธํ•œ ๋‚ด์šฉ์„ ํ™•์ธํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.", + "AboutRyujinxAboutContent": "Ryujinx๋Š” Nintendo Switchโ„ข์šฉ ์—๋ฎฌ๋ ˆ์ดํ„ฐ์ž…๋‹ˆ๋‹ค.\nPatreon์—์„œ ์ €ํฌ๋ฅผ ํ›„์›ํ•ด ์ฃผ์„ธ์š”.\nTwitter๋‚˜ Discord์—์„œ ์ตœ์‹  ๋‰ด์Šค๋ฅผ ๋ชจ๋‘ ๋ฐ›์•„๋ณด์„ธ์š”.\n๊ธฐ์—ฌ์— ๊ด€์‹ฌ์ด ์žˆ๋Š” ๊ฐœ๋ฐœ์ž๋Š” GitHub์ด๋‚˜ Discord์—์„œ ์ž์„ธํ•œ ๋‚ด์šฉ์„ ์•Œ์•„๋ณผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.", "AboutRyujinxMaintainersTitle": "์œ ์ง€ ๊ด€๋ฆฌ :", - "AboutRyujinxMaintainersContentTooltipMessage": "๊ธฐ๋ณธ ๋ธŒ๋ผ์šฐ์ €์—์„œ ๊ธฐ์—ฌ์ž ํŽ˜์ด์ง€๋ฅผ ์—ด๋ ค๋ฉด ํด๋ฆญํ•˜์„ธ์š”.", - "AboutRyujinxSupprtersTitle": "Patreon์—์„œ ํ›„์›:", + "AboutRyujinxMaintainersContentTooltipMessage": "ํด๋ฆญํ•˜๋ฉด ๊ธฐ๋ณธ ๋ธŒ๋ผ์šฐ์ €์—์„œ ๊ธฐ์—ฌ์ž ํŽ˜์ด์ง€๊ฐ€ ์—ด๋ฆฝ๋‹ˆ๋‹ค.", + "AboutRyujinxSupprtersTitle": "Patreon์—์„œ ํ›„์› :", "AmiiboSeriesLabel": "Amiibo ์‹œ๋ฆฌ์ฆˆ", "AmiiboCharacterLabel": "์บ๋ฆญํ„ฐ", - "AmiiboScanButtonLabel": "์Šค์บ”", + "AmiiboScanButtonLabel": "์Šค์บ”ํ•˜๊ธฐ", "AmiiboOptionsShowAllLabel": "๋ชจ๋“  Amiibo ํ‘œ์‹œ", - "AmiiboOptionsUsRandomTagLabel": "ํ•ดํ‚น: ์ž„์˜์˜ ํƒœ๊ทธ UUID ์‚ฌ์šฉ", - "DlcManagerTableHeadingEnabledLabel": "ํ™œ์„ฑํ™”๋จ", + "AmiiboOptionsUsRandomTagLabel": "ํ•ต : ๋ฌด์ž‘์œ„ ํƒœ๊ทธ Uuid ์‚ฌ์šฉ", + "DlcManagerTableHeadingEnabledLabel": "ํ™œ์„ฑํ™”", "DlcManagerTableHeadingTitleIdLabel": "ํƒ€์ดํ‹€ ID", "DlcManagerTableHeadingContainerPathLabel": "์ปจํ…Œ์ด๋„ˆ ๊ฒฝ๋กœ", "DlcManagerTableHeadingFullPathLabel": "์ „์ฒด ๊ฒฝ๋กœ", @@ -556,152 +563,158 @@ "DlcManagerDisableAllButton": "๋ชจ๋‘ ๋น„ํ™œ์„ฑํ™”", "ModManagerDeleteAllButton": "๋ชจ๋‘ ์‚ญ์ œ", "MenuBarOptionsChangeLanguage": "์–ธ์–ด ๋ณ€๊ฒฝ", - "MenuBarShowFileTypes": "ํŒŒ์ผ ์œ ํ˜• ํ‘œ์‹œ", + "MenuBarShowFileTypes": "ํŒŒ์ผ ํ˜•์‹ ํ‘œ์‹œ", "CommonSort": "์ •๋ ฌ", "CommonShowNames": "์ด๋ฆ„ ํ‘œ์‹œ", "CommonFavorite": "์ฆ๊ฒจ์ฐพ๊ธฐ", "OrderAscending": "์˜ค๋ฆ„์ฐจ์ˆœ", "OrderDescending": "๋‚ด๋ฆผ์ฐจ์ˆœ", - "SettingsTabGraphicsFeatures": "๊ธฐ๋Šฅใ†๊ฐœ์„  ์‚ฌํ•ญ", + "SettingsTabGraphicsFeatures": "๊ธฐ๋Šฅ ๋ฐ ๊ฐœ์„  ์‚ฌํ•ญ", "ErrorWindowTitle": "์˜ค๋ฅ˜ ์ฐฝ", - "ToggleDiscordTooltip": "\"ํ˜„์žฌ ์žฌ์ƒ ์ค‘์ธ\" ๋””์Šค์ฝ”๋“œ ํ™œ๋™์— Ryujinx๋ฅผ ํ‘œ์‹œํ• ์ง€ ์—ฌ๋ถ€ ์„ ํƒ", - "AddGameDirBoxTooltip": "๋ชฉ๋ก์— ์ถ”๊ฐ€ํ•  ๊ฒŒ์ž„ ๋””๋ ‰ํ„ฐ๋ฆฌ ์ž…๋ ฅ", + "ToggleDiscordTooltip": "\"ํ˜„์žฌ ์ง„ํ–‰ ์ค‘์ธ\" ๋””์Šค์ฝ”๋“œ ํ™œ๋™์— Ryujinx๋ฅผ ํ‘œ์‹œํ• ์ง€ ์—ฌ๋ถ€๋ฅผ ์„ ํƒ", + "AddGameDirBoxTooltip": "๋ชฉ๋ก์— ์ถ”๊ฐ€ํ•  ๊ฒŒ์ž„ ๋””๋ ‰ํ„ฐ๋ฆฌ๋ฅผ ์ž…๋ ฅ", "AddGameDirTooltip": "๋ชฉ๋ก์— ๊ฒŒ์ž„ ๋””๋ ‰ํ„ฐ๋ฆฌ ์ถ”๊ฐ€", "RemoveGameDirTooltip": "์„ ํƒํ•œ ๊ฒŒ์ž„ ๋””๋ ‰ํ„ฐ๋ฆฌ ์ œ๊ฑฐ", - "AddAutoloadDirBoxTooltip": "Enter an autoload directory to add to the list", - "AddAutoloadDirTooltip": "Add an autoload directory to the list", - "RemoveAutoloadDirTooltip": "Remove selected autoload directory", - "CustomThemeCheckTooltip": "GUI์— ์‚ฌ์šฉ์ž ์ง€์ • Avalonia ํ…Œ๋งˆ๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ์—๋ฎฌ๋ ˆ์ดํ„ฐ ๋ฉ”๋‰ด์˜ ๋ชจ์–‘ ๋ณ€๊ฒฝ", + "AddAutoloadDirBoxTooltip": "๋ชฉ๋ก์— ์ถ”๊ฐ€ํ•  ์ž๋™ ๋ถˆ๋Ÿฌ์˜ค๊ธฐ ๋””๋ ‰ํ„ฐ๋ฆฌ๋ฅผ ์ž…๋ ฅ", + "AddAutoloadDirTooltip": "๋ชฉ๋ก์— ์ž๋™ ๋ถˆ๋Ÿฌ์˜ค๊ธฐ ๋””๋ ‰ํ„ฐ๋ฆฌ ์ถ”๊ฐ€", + "RemoveAutoloadDirTooltip": "์„ ํƒํ•œ ์ž๋™ ๋ถˆ๋Ÿฌ์˜ค๊ธฐ ๋””๋ ‰ํ„ฐ๋ฆฌ ์ œ๊ฑฐ", + "CustomThemeCheckTooltip": "GUI์šฉ ์‚ฌ์šฉ์ž ์ •์˜ Avalonia ํ…Œ๋งˆ๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ์—๋ฎฌ๋ ˆ์ดํ„ฐ ๋ฉ”๋‰ด์˜ ๋ชจ์–‘ ๋ณ€๊ฒฝ", "CustomThemePathTooltip": "์‚ฌ์šฉ์ž ์ •์˜ GUI ํ…Œ๋งˆ ๊ฒฝ๋กœ", "CustomThemeBrowseTooltip": "์‚ฌ์šฉ์ž ์ •์˜ GUI ํ…Œ๋งˆ ์ฐพ์•„๋ณด๊ธฐ", - "DockModeToggleTooltip": "๋… ๋ชจ๋“œ์—์„œ๋Š” ์—๋ฎฌ๋ ˆ์ดํŠธ๋œ ์‹œ์Šคํ…œ์ด ๋„ํ‚น๋œ ๋‹Œํ…๋„ ์Šค์œ„์น˜์ฒ˜๋Ÿผ ์ž‘๋™ํ•ฉ๋‹ˆ๋‹ค. ์ด๊ฒƒ์€ ๋Œ€๋ถ€๋ถ„์˜ ๊ฒŒ์ž„์—์„œ ๊ทธ๋ž˜ํ”ฝ ํ’ˆ์งˆ์„ ํ–ฅ์ƒ์‹œํ‚ต๋‹ˆ๋‹ค. ๋ฐ˜๋Œ€๋กœ ์ด ๊ธฐ๋Šฅ์„ ๋น„ํ™œ์„ฑํ™”ํ•˜๋ฉด ์—๋ฎฌ๋ ˆ์ดํŠธ๋œ ์‹œ์Šคํ…œ์ด ํœด๋Œ€์šฉ ๋‹Œํ…๋„ ์Šค์œ„์น˜์ฒ˜๋Ÿผ ์ž‘๋™ํ•˜์—ฌ ๊ทธ๋ž˜ํ”ฝ ํ’ˆ์งˆ์ด ์ €ํ•˜๋ฉ๋‹ˆ๋‹ค.\n\n๋… ๋ชจ๋“œ๋ฅผ ์‚ฌ์šฉํ•˜๋ ค๋Š” ๊ฒฝ์šฐ ํ”Œ๋ ˆ์ด์–ด 1์˜ ์ปจํŠธ๋กค์„ ๊ตฌ์„ฑํ•˜์„ธ์š”. ํœด๋Œ€ ๋ชจ๋“œ๋ฅผ ์‚ฌ์šฉํ•˜๋ ค๋Š” ๊ฒฝ์šฐ ํœด๋Œ€์šฉ ์ปจํŠธ๋กค์„ ๊ตฌ์„ฑํ•˜์„ธ์š”.\n\nํ™•์‹คํ•˜์ง€ ์•Š์œผ๋ฉด ์ผœ ๋‘์„ธ์š”.", - "DirectKeyboardTooltip": "๋‹ค์ด๋ ‰ํŠธ ํ‚ค๋ณด๋“œ ์ ‘๊ทผ(HID)์€ ๊ฒŒ์ž„์—์„œ ์‚ฌ์šฉ์ž์˜ ํ‚ค๋ณด๋“œ๋ฅผ ํ…์ŠคํŠธ ์ž…๋ ฅ ์žฅ์น˜๋กœ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๊ฒŒ๋” ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค.\n\n์Šค์œ„์น˜ ํ•˜๋“œ์›จ์–ด์—์„œ ํ‚ค๋ณด๋“œ ์‚ฌ์šฉ์„ ๋„ค์ดํ‹ฐ๋ธŒ๋กœ ์ง€์›ํ•˜๋Š” ๊ฒŒ์ž„์—์„œ๋งŒ ์ž‘๋™ํ•ฉ๋‹ˆ๋‹ค.\n\n์ด ์˜ต์…˜์— ๋Œ€ํ•ด ์ž˜ ๋ชจ๋ฅธ๋‹ค๋ฉด ๋„๊ธฐ๋ฅผ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค.", - "DirectMouseTooltip": "๋‹ค์ด๋ ‰ํŠธ ๋งˆ์šฐ์Šค ์ ‘๊ทผ(HID)์€ ๊ฒŒ์ž„์—์„œ ์‚ฌ์šฉ์ž์˜ ๋งˆ์šฐ์Šค๋ฅผ ํฌ์ธํ„ฐ ์žฅ์น˜๋กœ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๊ฒŒ๋” ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค.\n\n์Šค์œ„์น˜ ํ•˜๋“œ์›จ์–ด์—์„œ ๋งˆ์šฐ์Šค ์‚ฌ์šฉ์„ ๋„ค์ดํ‹ฐ๋ธŒ๋กœ ์ง€์›ํ•˜๋Š” ๊ทนํžˆ ์ผ๋ถ€ ๊ฒŒ์ž„์—์„œ๋งŒ ์ž‘๋™ํ•ฉ๋‹ˆ๋‹ค.\n\n์ด ์˜ต์…˜์ด ํ™œ์„ฑํ™”๋œ ๊ฒฝ์šฐ, ํ„ฐ์น˜ ์Šคํฌ๋ฆฐ ๊ธฐ๋Šฅ์ด ์ž‘๋™ํ•˜์ง€ ์•Š์„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.\n\n์ด ์˜ต์…˜์— ๋Œ€ํ•ด ์ž˜ ๋ชจ๋ฅธ๋‹ค๋ฉด ๋„๊ธฐ๋ฅผ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค.", + "DockModeToggleTooltip": "๋„ํ‚น ๋ชจ๋“œ๋ฅผ ์‚ฌ์šฉํ•˜๋ฉด ์—๋ฎฌ๋ ˆ์ดํŠธ๋œ ์‹œ์Šคํ…œ์ด ๋„ํ‚น๋œ Nintendo Switch์ฒ˜๋Ÿผ ๋™์ž‘ํ•ฉ๋‹ˆ๋‹ค. ์ด ๊ฒฝ์šฐ, ๋Œ€๋ถ€๋ถ„์˜ ๊ฒŒ์ž„์—์„œ ๊ทธ๋ž˜ํ”ฝ ์ถฉ์‹ค๋„๋ฅผ ํ–ฅ์ƒ์‹œํ‚ต๋‹ˆ๋‹ค. ๋ฐ˜๋Œ€๋กœ ์ด ๊ธฐ๋Šฅ์„ ๋น„ํ™œ์„ฑํ™”ํ•˜๋ฉด ์—๋ฎฌ๋ ˆ์ดํŠธ๋œ ์‹œ์Šคํ…œ์ด ํœด๋Œ€์šฉ Nintendo Switch์ฒ˜๋Ÿผ ์ž‘๋™ํ•˜์—ฌ ๊ทธ๋ž˜ํ”ฝ ํ’ˆ์งˆ์ด ์ €ํ•˜๋ฉ๋‹ˆ๋‹ค.\n\n๋„ํ‚น ๋ชจ๋“œ๋ฅผ ์‚ฌ์šฉํ•  ๊ณ„ํš์ด๋ผ๋ฉด ํ”Œ๋ ˆ์ด์–ด 1 ์ปจํŠธ๋กค์„ ๊ตฌ์„ฑํ•˜์„ธ์š”. ํœด๋Œ€์šฉ ๋ชจ๋“œ๋ฅผ ์‚ฌ์šฉํ•˜๋ ค๋Š” ๊ฒฝ์šฐ ํœด๋Œ€์šฉ ์ปจํŠธ๋กค์„ ๊ตฌ์„ฑํ•˜์‹ญ์‹œ์˜ค.\n\n๋ชจ๋ฅด๋ฉด ์ผฌ์œผ๋กœ ๋‘์„ธ์š”.", + "DirectKeyboardTooltip": "ํ‚ค๋ณด๋“œ ์ง์ ‘ ์ ‘์†(HID)์„ ์ง€์›ํ•ฉ๋‹ˆ๋‹ค. ํ…์ŠคํŠธ ์ž…๋ ฅ ์žฅ์น˜๋กœ ํ‚ค๋ณด๋“œ์— ๋Œ€ํ•œ ๊ฒŒ์ž„ ์ ‘์†์„ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค.\n\nSwitch ํ•˜๋“œ์›จ์–ด์—์„œ ํ‚ค๋ณด๋“œ ์‚ฌ์šฉ์„ ๊ธฐ๋ณธ์ ์œผ๋กœ ์ง€์›ํ•˜๋Š” ๊ฒŒ์ž„์—์„œ๋งŒ ์ž‘๋™ํ•ฉ๋‹ˆ๋‹ค.\n\n๋ชจ๋ฅด๋ฉด ๋”์œผ๋กœ ๋‘์„ธ์š”.", + "DirectMouseTooltip": "๋งˆ์šฐ์Šค ์ง์ ‘ ์ ‘์†(HID)์„ ์ง€์›ํ•ฉ๋‹ˆ๋‹ค. ๋งˆ์šฐ์Šค์— ๋Œ€ํ•œ ๊ฒŒ์ž„ ์ ‘์†์„ ํฌ์ธํŒ… ์žฅ์น˜๋กœ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค.\n\nSwitch ํ•˜๋“œ์›จ์–ด์—์„œ ๋งˆ์šฐ์Šค ์ปจํŠธ๋กค์„ ๊ธฐ๋ณธ์ ์œผ๋กœ ์ง€์›ํ•˜๋Š” ๊ฒŒ์ž„์—์„œ๋งŒ ์ž‘๋™ํ•˜๋ฉฐ ๊ฑฐ์˜ ์—†์Šต๋‹ˆ๋‹ค.\n\nํ™œ์„ฑํ™”ํ•˜๋ฉด ํ„ฐ์น˜ ์Šคํฌ๋ฆฐ ๊ธฐ๋Šฅ์ด ์ž‘๋™ํ•˜์ง€ ์•Š์„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.\n\n๋ชจ๋ฅด๋ฉด ๋”์œผ๋กœ ๋‘์„ธ์š”.", "RegionTooltip": "์‹œ์Šคํ…œ ์ง€์—ญ ๋ณ€๊ฒฝ", "LanguageTooltip": "์‹œ์Šคํ…œ ์–ธ์–ด ๋ณ€๊ฒฝ", "TimezoneTooltip": "์‹œ์Šคํ…œ ์‹œ๊ฐ„๋Œ€ ๋ณ€๊ฒฝ", "TimeTooltip": "์‹œ์Šคํ…œ ์‹œ๊ฐ„ ๋ณ€๊ฒฝ", - "VSyncToggleTooltip": "์—๋ฎฌ๋ ˆ์ดํŠธ๋œ ์ฝ˜์†”์˜ ์ˆ˜์ง ๋™๊ธฐํ™”. ๊ธฐ๋ณธ์ ์œผ๋กœ ๋Œ€๋ถ€๋ถ„์˜ ๊ฒŒ์ž„์— ๋Œ€ํ•œ ํ”„๋ ˆ์ž„ ์ œํ•œ ์žฅ์น˜๋กœ, ๋น„ํ™œ์„ฑํ™”์‹œ ๊ฒŒ์ž„์ด ๋” ๋น ๋ฅธ ์†๋„๋กœ ์‹คํ–‰๋˜๊ฑฐ๋‚˜ ๋กœ๋”ฉ ํ™”๋ฉด์ด ๋” ์˜ค๋ž˜ ๊ฑธ๋ฆฌ๊ฑฐ๋‚˜ ๋ฉˆ์ถœ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.\n\n๊ฒŒ์ž„ ๋‚ด์—์„œ ์„ ํ˜ธํ•˜๋Š” ํ•ซํ‚ค๋กœ ์ „ํ™˜ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค(๊ธฐ๋ณธ๊ฐ’ F1). ํ•ซํ‚ค๋ฅผ ๋น„ํ™œ์„ฑํ™”ํ•  ๊ณ„ํš์ด๋ผ๋ฉด ์ด ์ž‘์—…์„ ์ˆ˜ํ–‰ํ•˜๋Š” ๊ฒƒ์ด ์ข‹์Šต๋‹ˆ๋‹ค.\n\n์ด ์˜ต์…˜์— ๋Œ€ํ•ด ์ž˜ ๋ชจ๋ฅธ๋‹ค๋ฉด ์ผœ๊ธฐ๋ฅผ ๊ถŒ์žฅ๋“œ๋ฆฝ๋‹ˆ๋‹ค.", - "PptcToggleTooltip": "๊ฒŒ์ž„์ด ๋ถˆ๋Ÿฌ์˜ฌ ๋•Œ๋งˆ๋‹ค ๋ฒˆ์—ญํ•  ํ•„์š”๊ฐ€ ์—†๋„๋ก ๋ฒˆ์—ญ๋œ JIT ๊ธฐ๋Šฅ์„ ์ €์žฅํ•ฉ๋‹ˆ๋‹ค.\n\n๊ฒŒ์ž„์„ ์ฒ˜์Œ ๋ถ€ํŒ…ํ•œ ํ›„ ๋Š๊น€ ํ˜„์ƒ์„ ์ค„์ด๊ณ  ๋ถ€ํŒ… ์‹œ๊ฐ„์„ ํฌ๊ฒŒ ๋‹จ์ถ•ํ•ฉ๋‹ˆ๋‹ค.\n\nํ™•์‹คํ•˜์ง€ ์•Š์œผ๋ฉด ์ผœ ๋‘์„ธ์š”.", - "LowPowerPptcToggleTooltip": "Load the PPTC using a third of the amount of cores.", - "FsIntegrityToggleTooltip": "๊ฒŒ์ž„์„ ๋ถ€ํŒ…ํ•  ๋•Œ ์†์ƒ๋œ ํŒŒ์ผ์„ ํ™•์ธํ•˜๊ณ  ์†์ƒ๋œ ํŒŒ์ผ์ด ๊ฐ์ง€๋˜๋ฉด ๋กœ๊ทธ์— ํ•ด์‹œ ์˜ค๋ฅ˜๋ฅผ ํ‘œ์‹œํ•ฉ๋‹ˆ๋‹ค.\n\n์„ฑ๋Šฅ์— ์˜ํ–ฅ์„ ๋ฏธ์น˜์ง€ ์•Š์œผ๋ฉฐ ๋ฌธ์ œ ํ•ด๊ฒฐ์— ๋„์›€์ด ๋ฉ๋‹ˆ๋‹ค.\n\nํ™•์‹คํ•˜์ง€ ์•Š์œผ๋ฉด ์ผœ ๋‘์„ธ์š”.", - "AudioBackendTooltip": "์˜ค๋””์˜ค๋ฅผ ๋ Œ๋”๋งํ•˜๋Š” ๋ฐ ์‚ฌ์šฉ๋˜๋Š” ๋ฐฑ์—”๋“œ๋ฅผ ๋ณ€๊ฒฝํ•ฉ๋‹ˆ๋‹ค.\n\nSDL2๊ฐ€ ์„ ํ˜ธ๋˜๋Š” ๋ฐ˜๋ฉด OpenAL ๋ฐ ์‚ฌ์šด๋“œIO๋Š” ํด๋ฐฑ์œผ๋กœ ์‚ฌ์šฉ๋ฉ๋‹ˆ๋‹ค. ๋”๋ฏธ๋Š” ์†Œ๋ฆฌ๊ฐ€ ๋‚˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.\n\nํ™•์‹คํ•˜์ง€ ์•Š์œผ๋ฉด SDL2๋กœ ์„ค์ •ํ•˜์„ธ์š”.", - "MemoryManagerTooltip": "๊ฒŒ์ŠคํŠธ ๋ฉ”๋ชจ๋ฆฌ๊ฐ€ ๋งคํ•‘๋˜๊ณ  ์ ‘์†๋˜๋Š” ๋ฐฉ์‹์„ ๋ณ€๊ฒฝํ•ฉ๋‹ˆ๋‹ค. ์—๋ฎฌ๋ ˆ์ดํŠธ๋œ CPU ์„ฑ๋Šฅ์— ํฌ๊ฒŒ ์˜ํ–ฅ์„ ๋ฏธ์นฉ๋‹ˆ๋‹ค.\n\nํ™•์‹คํ•˜์ง€ ์•Š์€ ๊ฒฝ์šฐ ํ˜ธ์ŠคํŠธ ํ™•์ธ ์•ˆํ•จ์œผ๋กœ ์„ค์ •ํ•˜์„ธ์š”.", - "MemoryManagerSoftwareTooltip": "์ฃผ์†Œ ๋ณ€ํ™˜์„ ์œ„ํ•ด ์†Œํ”„ํŠธ์›จ์–ด ํŽ˜์ด์ง€ ํ…Œ์ด๋ธ”์„ ์‚ฌ์šฉํ•˜์„ธ์š”. ์ •ํ™•๋„๋Š” ๊ฐ€์žฅ ๋†’์ง€๋งŒ ์„ฑ๋Šฅ์€ ๊ฐ€์žฅ ๋А๋ฆฝ๋‹ˆ๋‹ค.", - "MemoryManagerHostTooltip": "ํ˜ธ์ŠคํŠธ ์ฃผ์†Œ ๊ณต๊ฐ„์˜ ๋ฉ”๋ชจ๋ฆฌ๋ฅผ ์ง์ ‘ ๋งคํ•‘ํ•ฉ๋‹ˆ๋‹ค. ํ›จ์”ฌ ๋น ๋ฅธ JIT ์ปดํŒŒ์ผ ๋ฐ ์‹คํ–‰ํ•ฉ๋‹ˆ๋‹ค.", - "MemoryManagerUnsafeTooltip": "๋ฉ”๋ชจ๋ฆฌ๋ฅผ ์ง์ ‘ ๋งคํ•‘ํ•˜์ง€๋งŒ ์ ‘์†ํ•˜๊ธฐ ์ „์— ๊ฒŒ์ŠคํŠธ ์ฃผ์†Œ ๊ณต๊ฐ„ ๋‚ด์˜ ์ฃผ์†Œ๋ฅผ ๋งˆ์Šคํ‚นํ•˜์ง€ ๋งˆ์‹ญ์‹œ์˜ค. ๋” ๋น ๋ฅด์ง€๋งŒ ์•ˆ์ „์„ ํฌ์ƒํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ๊ฒŒ์ŠคํŠธ ์‘์šฉ ํ”„๋กœ๊ทธ๋žจ์€ Ryujinx์˜ ์–ด๋””์—์„œ๋‚˜ ๋ฉ”๋ชจ๋ฆฌ์— ์ ‘์†ํ•  ์ˆ˜ ์žˆ์œผ๋ฏ€๋กœ ์ด ๋ชจ๋“œ์—์„œ๋Š” ์‹ ๋ขฐํ•  ์ˆ˜ ์žˆ๋Š” ํ”„๋กœ๊ทธ๋žจ๋งŒ ์‹คํ–‰ํ•˜์„ธ์š”.", - "UseHypervisorTooltip": "JIT ๋Œ€์‹  ํ•˜์ดํผ๋ฐ”์ด์ €๋ฅผ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค. ํ•˜์ดํผ๋ฐ”์ด์ €๋ฅผ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์„ ๋•Œ ์„ฑ๋Šฅ์„ ํ–ฅ์ƒ์‹œํ‚ค์ง€๋งŒ, ํ˜„์žฌ ์ƒํƒœ์—์„œ๋Š” ๋ถˆ์•ˆ์ •ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.", - "DRamTooltip": "๋Œ€์ฒด ๋ฉ”๋ชจ๋ฆฌ๋ชจ๋“œ ๋ ˆ์ด์•„์›ƒ์„ ํ™œ์šฉํ•˜์—ฌ ์Šค์œ„์น˜ ๊ฐœ๋ฐœ ๋ชจ๋ธ์„ ๋ชจ๋ฐฉํ•ฉ๋‹ˆ๋‹ค.\n\n๊ณ ํ•ด์ƒ๋„ ํ…์Šค์ฒ˜ ํŒฉ ๋˜๋Š” 4k ํ•ด์ƒ๋„ ๋ชจ๋“œ์—๋งŒ ์œ ์šฉํ•ฉ๋‹ˆ๋‹ค. ์„ฑ๋Šฅ์„ ํ–ฅ์ƒ์‹œํ‚ค์ง€ ์•Š์Šต๋‹ˆ๋‹ค.\n\nํ™•์‹คํ•˜์ง€ ์•Š์œผ๋ฉด ๊บผ ๋‘์„ธ์š”.", - "IgnoreMissingServicesTooltip": "๊ตฌํ˜„๋˜์ง€ ์•Š์€ ํ˜ธ๋ผ์ด์ฆŒ OS ์„œ๋น„์Šค๋ฅผ ๋ฌด์‹œํ•ฉ๋‹ˆ๋‹ค. ์ด๊ฒƒ์€ ํŠน์ • ๊ฒŒ์ž„์„ ๋ถ€ํŒ…ํ•  ๋•Œ ์ถฉ๋Œ์„ ์šฐํšŒํ•˜๋Š” ๋ฐ ๋„์›€์ด ๋  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.\n\nํ™•์‹คํ•˜์ง€ ์•Š์œผ๋ฉด ๊บผ ๋‘์„ธ์š”.", - "IgnoreAppletTooltip": "๊ฒŒ์ž„ ํ”Œ๋ ˆ์ด ์ค‘์— ๊ฒŒ์ž„ํŒจ๋“œ์˜ ์—ฐ๊ฒฐ์ด ๋Š์–ด์ง€๋ฉด ์™ธ๋ถ€ ๋Œ€ํ™” ์ƒ์ž '์ปจํŠธ๋กค๋Ÿฌ ์• ํ”Œ๋ฆฟ'์ด ๋‚˜ํƒ€๋‚˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ๋Œ€ํ™” ์ƒ์ž๋ฅผ ๋‹ซ๊ฑฐ๋‚˜ ์ƒˆ ์ปจํŠธ๋กค๋Ÿฌ๋ฅผ ์„ค์ •ํ•˜๋ผ๋Š” ๋ฉ”์‹œ์ง€๋„ ํ‘œ์‹œ๋˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ์ด์ „์— ์—ฐ๊ฒฐ์ด ๋Š์–ด์ง„ ์ปจํŠธ๋กค๋Ÿฌ๊ฐ€ ๋‹ค์‹œ ์—ฐ๊ฒฐ๋˜๋ฉด ๊ฒŒ์ž„์ด ์ž๋™์œผ๋กœ ์žฌ๊ฐœ๋ฉ๋‹ˆ๋‹ค.", - "GraphicsBackendThreadingTooltip": "๋‘ ๋ฒˆ์งธ ์Šค๋ ˆ๋“œ์—์„œ ๊ทธ๋ž˜ํ”ฝ ๋ฐฑ์—”๋“œ ๋ช…๋ น์„ ์‹คํ–‰ํ•ฉ๋‹ˆ๋‹ค.\n\n์„ธ์ด๋” ์ปดํŒŒ์ผ ์†๋„๋ฅผ ๋†’์ด๊ณ  ๋Š๊น€ ํ˜„์ƒ์„ ์ค„์ด๋ฉฐ ์ž์ฒด ๋ฉ€ํ‹ฐ์Šค๋ ˆ๋”ฉ ์ง€์› ์—†์ด GPU ๋“œ๋ผ์ด๋ฒ„์˜ ์„ฑ๋Šฅ์„ ํ–ฅ์ƒ์‹œํ‚ต๋‹ˆ๋‹ค. ๋ฉ€ํ‹ฐ์Šค๋ ˆ๋”ฉ์ด ์žˆ๋Š” ๋“œ๋ผ์ด๋ฒ„์—์„œ ์„ฑ๋Šฅ์ด ์•ฝ๊ฐ„ ํ–ฅ์ƒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.\n\n์ž˜ ๋ชจ๋ฅด๊ฒ ์œผ๋ฉด ์ž๋™์œผ๋กœ ์„ค์ •ํ•˜์„ธ์š”.", - "GalThreadingTooltip": "๋‘ ๋ฒˆ์งธ ์Šค๋ ˆ๋“œ์—์„œ ๊ทธ๋ž˜ํ”ฝ ๋ฐฑ์—”๋“œ ๋ช…๋ น์„ ์‹คํ–‰ํ•ฉ๋‹ˆ๋‹ค.\n\n์„ธ์ด๋” ์ปดํŒŒ์ผ ์†๋„๋ฅผ ๋†’์ด๊ณ  ๋Š๊น€ ํ˜„์ƒ์„ ์ค„์ด๋ฉฐ ์ž์ฒด ๋ฉ€ํ‹ฐ์Šค๋ ˆ๋”ฉ ์ง€์› ์—†์ด GPU ๋“œ๋ผ์ด๋ฒ„์˜ ์„ฑ๋Šฅ์„ ํ–ฅ์ƒ์‹œํ‚ต๋‹ˆ๋‹ค. ๋ฉ€ํ‹ฐ์Šค๋ ˆ๋”ฉ์ด ์žˆ๋Š” ๋“œ๋ผ์ด๋ฒ„์—์„œ ์„ฑ๋Šฅ์ด ์•ฝ๊ฐ„ ํ–ฅ์ƒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.\n\n์ž˜ ๋ชจ๋ฅด๊ฒ ์œผ๋ฉด ์ž๋™์œผ๋กœ ์„ค์ •ํ•˜์„ธ์š”.", - "ShaderCacheToggleTooltip": "ํ›„์† ์‹คํ–‰์—์„œ ๋Š๊น€ ํ˜„์ƒ์„ ์ค„์ด๋Š” ๋””์Šคํฌ ์„ธ์ด๋” ์บ์‹œ๋ฅผ ์ €์žฅํ•ฉ๋‹ˆ๋‹ค.\n\nํ™•์‹คํ•˜์ง€ ์•Š์œผ๋ฉด ์ผœ ๋‘์„ธ์š”.", - "ResolutionScaleTooltip": "๊ฒŒ์ž„์˜ ๋ Œ๋”๋ง ํ•ด์ƒ๋„๋ฅผ ๋Š˜๋ฆฝ๋‹ˆ๋‹ค.\n\n์ผ๋ถ€ ๊ฒŒ์ž„์—์„œ๋Š” ํ•ด๋‹น ๊ธฐ๋Šฅ์„ ์ง€์›ํ•˜์ง€ ์•Š๊ฑฐ๋‚˜ ํ•ด์ƒ๋„๊ฐ€ ๋Š˜์–ด๋‚ฌ์Œ์—๋„ ํ”ฝ์…€์ด ์ž๊ธ€์ž๊ธ€ํ•ด ๋ณด์ผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค; ์ด๋Ÿฌํ•œ ๊ฒŒ์ž„๋“ค์˜ ๊ฒฝ์šฐ ์‚ฌ์šฉ์ž๊ฐ€ ์ง์ ‘ ์•ˆํ‹ฐ ์•จ๋ฆฌ์–ด์‹ฑ ๊ธฐ๋Šฅ์„ ๋„๋Š” Mod๋‚˜ ๋‚ด๋ถ€ ๋ Œ๋”๋ง ํ•ด์ƒ๋„๋ฅผ ์ฆ๊ฐ€์‹œํ‚ค๋Š” Mod ๋“ฑ์„ ์ฐพ์•„๋ณด์•„์•ผ ํ•ฉ๋‹ˆ๋‹ค. ํ›„์ž์˜ Mod๋ฅผ ์‚ฌ์šฉ ์‹œ์—๋Š” ํ•ด๋‹น ์˜ต์…˜์„ ๋„ค์ดํ‹ฐ๋ธŒ๋กœ ๋‘์‹œ๋Š” ๊ฒƒ์ด ์ข‹์Šต๋‹ˆ๋‹ค.\n\n์ด ์˜ต์…˜์€ ๊ฒŒ์ž„์ด ๊ตฌ๋™์ค‘์ผ ๋•Œ์—๋„ ์•„๋ž˜ Apply ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ์„œ ๋ณ€๊ฒฝํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค; ์„ค์ • ์ฐฝ์„ ๊ฒŒ์ž„ ์ฐฝ ์˜†์— ๋‘๊ณ  ์‚ฌ์šฉ์ž๊ฐ€ ์„ ํ˜ธํ•˜๋Š” ํ•ด์ƒ๋„๋ฅผ ์‹คํ—˜ํ•˜์—ฌ ๊ณ ๋ฅผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.\n\n4x ์„ค์ •์€ ์–ด๋–ค ์…‹์—…์—์„œ๋„ ๋ฌด๋ฆฌ์ธ ์ ์„ ์œ ์˜ํ•˜์„ธ์š”.", - "ResolutionScaleEntryTooltip": "1.5์™€ ๊ฐ™์€ ๋ถ€๋™ ์†Œ์ˆ˜์  ๋ถ„ํ•ด๋Šฅ ์Šค์ผ€์ผ์ž…๋‹ˆ๋‹ค. ๋น„ํ†ตํ•ฉ ์ฒ™๋„๋Š” ๋ฌธ์ œ๋‚˜ ์ถฉ๋Œ์„ ์ผ์œผํ‚ฌ ๊ฐ€๋Šฅ์„ฑ์ด ๋” ํฝ๋‹ˆ๋‹ค.", - "AnisotropyTooltip": "๋น„๋“ฑ๋ฐฉ์„ฑ ํ•„ํ„ฐ๋ง ๋ ˆ๋ฒจ. ๊ฒŒ์ž„์—์„œ ์š”์ฒญํ•œ ๊ฐ’์„ ์‚ฌ์šฉํ•˜๋ ค๋ฉด ์ž๋™์œผ๋กœ ์„ค์ •ํ•˜์„ธ์š”.", - "AspectRatioTooltip": "๋ Œ๋”๋Ÿฌ ์ฐฝ์— ์ ์šฉ๋  ํ™”๋ฉด๋น„.\n\nํ™”๋ฉด๋น„๋ฅผ ๋ณ€๊ฒฝํ•˜๋Š” Mod๋ฅผ ์‚ฌ์šฉํ•  ๋•Œ์—๋งŒ ์ด ์˜ต์…˜์„ ๋ฐ”๊พธ์„ธ์š”, ๊ทธ๋ ‡์ง€ ์•Š์„ ๊ฒฝ์šฐ ๊ทธ๋ž˜ํ”ฝ์ด ๋Š˜์–ด๋‚˜ ๋ณด์ผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.\n\n์ด ์˜ต์…˜์— ๋Œ€ํ•ด ์ž˜ ๋ชจ๋ฅธ๋‹ค๋ฉด 16:9๋กœ ์„ค์ •ํ•˜์„ธ์š”.", + "VSyncToggleTooltip": "์—๋ฎฌ๋ ˆ์ดํŠธ๋œ ์ฝ˜์†”์˜ ์ˆ˜์ง ๋™๊ธฐํ™”์ž…๋‹ˆ๋‹ค. ๊ธฐ๋ณธ์ ์œผ๋กœ ๋Œ€๋ถ€๋ถ„์˜ ๊ฒŒ์ž„์—์„œ ํ”„๋ ˆ์ž„ ์ œํ•œ ๊ธฐ๋Šฅ์œผ๋กœ, ๋น„ํ™œ์„ฑํ™”ํ•˜๋ฉด ๊ฒŒ์ž„์ด ๋” ๋น ๋ฅธ ์†๋„๋กœ ์‹คํ–‰๋˜๊ฑฐ๋‚˜ ๋กœ๋”ฉ ํ™”๋ฉด์ด ๋” ์˜ค๋ž˜ ๊ฑธ๋ฆฌ๊ฑฐ๋‚˜ ๋ฉˆ์ถœ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.\n\n๊ฒŒ์ž„ ๋‚ด์—์„œ ์›ํ•˜๋Š” ๋‹จ์ถ•ํ‚ค(๊ธฐ๋ณธ๊ฐ’์€ F1)๋กœ ์ „ํ™˜ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ๋น„ํ™œ์„ฑํ™”ํ•˜๋ ค๋ฉด ์ด ์ž‘์—…์„ ์ˆ˜ํ–‰ํ•˜๋Š” ๊ฒƒ์ด ์ข‹์Šต๋‹ˆ๋‹ค.\n\n๋ชจ๋ฅด๋ฉด ์ผฌ์œผ๋กœ ๋‘์„ธ์š”.", + "PptcToggleTooltip": "๋ฒˆ์—ญ๋œ JIT ํ•จ์ˆ˜๋ฅผ ์ €์žฅํ•˜์—ฌ ๊ฒŒ์ž„์„ ๋ถˆ๋Ÿฌ์˜ฌ ๋•Œ๋งˆ๋‹ค ๋ฒˆ์—ญํ•  ํ•„์š”๊ฐ€ ์—†๋„๋ก ํ•ฉ๋‹ˆ๋‹ค.\n\n๊ฒŒ์ž„์„ ์ฒ˜์Œ ๋ถ€ํŒ…ํ•œ ํ›„ ๋Š๊น€ ํ˜„์ƒ์„ ์ค„์ด๊ณ  ๋ถ€ํŒ… ์‹œ๊ฐ„์„ ํฌ๊ฒŒ ๋‹จ์ถ•ํ•ฉ๋‹ˆ๋‹ค.\n\n๋ชจ๋ฅด๋ฉด ์ผฌ์œผ๋กœ ๋‘์„ธ์š”.", + "LowPowerPptcToggleTooltip": "์ฝ”์–ด์˜ 3๋ถ„์˜ 1์„ ์‚ฌ์šฉํ•˜์—ฌ PPTC๋ฅผ ๋ถˆ๋Ÿฌ์˜ต๋‹ˆ๋‹ค.", + "FsIntegrityToggleTooltip": "๊ฒŒ์ž„์„ ๋ถ€ํŒ…ํ•  ๋•Œ ์†์ƒ๋œ ํŒŒ์ผ์„ ํ™•์ธํ•˜๊ณ , ์†์ƒ๋œ ํŒŒ์ผ์ด ๊ฐ์ง€๋˜๋ฉด ๋กœ๊ทธ์— ํ•ด์‹œ ์˜ค๋ฅ˜๋ฅผ ํ‘œ์‹œํ•ฉ๋‹ˆ๋‹ค.\n\n์„ฑ๋Šฅ์— ์˜ํ–ฅ์„ ๋ฏธ์น˜์ง€ ์•Š์œผ๋ฉฐ ๋ฌธ์ œ ํ•ด๊ฒฐ์— ๋„์›€์ด ๋ฉ๋‹ˆ๋‹ค.\n\n๋ชจ๋ฅด๋ฉด ์ผฌ์œผ๋กœ ๋‘์„ธ์š”.", + "AudioBackendTooltip": "์˜ค๋””์˜ค ๋ Œ๋”๋ง์— ์‚ฌ์šฉ๋˜๋Š” ๋ฐฑ์—”๋“œ๋ฅผ ๋ณ€๊ฒฝํ•ฉ๋‹ˆ๋‹ค.\n\nSDL2๊ฐ€ ์„ ํ˜ธ๋˜๋Š” ๋ฐ˜๋ฉด OpenAL ๋ฐ SoundIO๋Š” ๋Œ€์ฒด ์ˆ˜๋‹จ์œผ๋กœ ์‚ฌ์šฉ๋ฉ๋‹ˆ๋‹ค. ๋”๋ฏธ์—๋Š” ์†Œ๋ฆฌ๊ฐ€ ๋‚˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.\n\n๋ชจ๋ฅด๋ฉด SDL2๋กœ ์„ค์ •ํ•˜์„ธ์š”.", + "MemoryManagerTooltip": "๊ฒŒ์ŠคํŠธ ๋ฉ”๋ชจ๋ฆฌ ๋งคํ•‘ ๋ฐ ์ ‘์† ๋ฐฉ๋ฒ•์„ ๋ณ€๊ฒฝํ•ฉ๋‹ˆ๋‹ค. ์—๋ฎฌ๋ ˆ์ดํŠธ๋œ CPU ์„ฑ๋Šฅ์— ํฐ ์˜ํ–ฅ์„ ๋ฏธ์นฉ๋‹ˆ๋‹ค.\n\n๋ชจ๋ฅด๋ฉด ํ˜ธ์ŠคํŠธ ํ™•์ธ ์•ˆ ํ•จ์œผ๋กœ ์„ค์ •ํ•ฉ๋‹ˆ๋‹ค.", + "MemoryManagerSoftwareTooltip": "์ฃผ์†Œ ๋ฒˆ์—ญ์— ์†Œํ”„ํŠธ์›จ์–ด ํŽ˜์ด์ง€ ํ…Œ์ด๋ธ”์„ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค. ์ •ํ™•๋„๋Š” ๊ฐ€์žฅ ๋†’์ง€๋งŒ ๊ฐ€์žฅ ๋А๋ฆฝ๋‹ˆ๋‹ค.", + "MemoryManagerHostTooltip": "ํ˜ธ์ŠคํŠธ ์ฃผ์†Œ ๊ณต๊ฐ„์— ๋ฉ”๋ชจ๋ฆฌ๋ฅผ ์ง์ ‘ ๋งคํ•‘ํ•ฉ๋‹ˆ๋‹ค. JIT ์ปดํŒŒ์ผ ๋ฐ ์‹คํ–‰ ์†๋„๊ฐ€ ํ›จ์”ฌ ๋นจ๋ผ์ง‘๋‹ˆ๋‹ค.", + "MemoryManagerUnsafeTooltip": "๋ฉ”๋ชจ๋ฆฌ๋ฅผ ์ง์ ‘ ๋งคํ•‘ํ•˜๋˜ ์ ‘์†ํ•˜๊ธฐ ์ „์— ๊ฒŒ์ŠคํŠธ ์ฃผ์†Œ ๊ณต๊ฐ„ ๋‚ด์˜ ์ฃผ์†Œ๋ฅผ ๋งˆ์Šคํ‚นํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ๋” ๋น ๋ฅด์ง€๋งŒ ์•ˆ์ „์„ฑ์ด ํฌ์ƒ๋ฉ๋‹ˆ๋‹ค. ๊ฒŒ์ŠคํŠธ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์€ Ryujinx์˜ ์–ด๋А ๊ณณ์—์„œ๋‚˜ ๋ฉ”๋ชจ๋ฆฌ์— ์ ‘์†ํ•  ์ˆ˜ ์žˆ์œผ๋ฏ€๋กœ ์ด ๋ชจ๋“œ์—์„œ๋Š” ์‹ ๋ขฐํ•  ์ˆ˜ ์žˆ๋Š” ํ”„๋กœ๊ทธ๋žจ๋งŒ ์‹คํ–‰ํ•˜์„ธ์š”.", + "UseHypervisorTooltip": "JIT ๋Œ€์‹  Hypervisor๋ฅผ ์‚ฌ์šฉํ•˜์„ธ์š”. ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ๊ฒฝ์šฐ ์„ฑ๋Šฅ์ด ํฌ๊ฒŒ ํ–ฅ์ƒ๋˜์ง€๋งŒ ํ˜„์žฌ ์ƒํƒœ์—์„œ๋Š” ๋ถˆ์•ˆ์ •ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.", + "DRamTooltip": "Switch ๊ฐœ๋ฐœ ๋ชจ๋ธ์„ ๋ชจ๋ฐฉํ•˜๊ธฐ ์œ„ํ•ด 8GB DRAM์ด ํฌํ•จ๋œ ๋Œ€์ฒด ๋ฉ”๋ชจ๋ฆฌ ๋ชจ๋“œ๋ฅผ ํ™œ์šฉํ•ฉ๋‹ˆ๋‹ค.\n\n์ด๋Š” ๊ณ ํ•ด์ƒ๋„ ํ…์Šค์ฒ˜ ํŒฉ ๋˜๋Š” 4K ํ•ด์ƒ๋„ ๋ชจ๋“œ์—๋งŒ ์œ ์šฉํ•ฉ๋‹ˆ๋‹ค. ์„ฑ๋Šฅ์„ ๊ฐœ์„ ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.\n\n๋ชจ๋ฅด๋ฉด ๋”์œผ๋กœ ๋‘์„ธ์š”.", + "IgnoreMissingServicesTooltip": "๊ตฌํ˜„๋˜์ง€ ์•Š์€ Horizon OS ์„œ๋น„์Šค๋Š” ๋ฌด์‹œ๋ฉ๋‹ˆ๋‹ค. ํŠน์ • ๊ฒŒ์ž„์„ ๋ถ€ํŒ…ํ•  ๋•Œ, ๋ฐœ์ƒํ•˜๋Š” ์ถฉ๋Œ์„ ์šฐํšŒํ•˜๋Š” ๋ฐ ๋„์›€์ด ๋  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.\n\n๋ชจ๋ฅด๋ฉด ๋”์œผ๋กœ ๋‘์„ธ์š”.", + "IgnoreAppletTooltip": "๊ฒŒ์ž„ ํ”Œ๋ ˆ์ด ์ค‘์— ๊ฒŒ์ž„ํŒจ๋“œ ์—ฐ๊ฒฐ์ด ๋Š์–ด์ง€๋ฉด ์™ธ๋ถ€ ๋Œ€ํ™” ์ƒ์ž \"์ปจํŠธ๋กค๋Ÿฌ ์• ํ”Œ๋ฆฟ\"์ด ๋‚˜ํƒ€๋‚˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ๋Œ€ํ™” ์ƒ์ž๋ฅผ ๋‹ซ๊ฑฐ๋‚˜ ์ƒˆ ์ปจํŠธ๋กค๋Ÿฌ๋ฅผ ์„ค์ •ํ•˜๋ผ๋Š” ๋ฉ”์‹œ์ง€๊ฐ€ ํ‘œ์‹œ๋˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ์ด์ „์— ์—ฐ๊ฒฐ์ด ๋Š์–ด์ง„ ์ปจํŠธ๋กค๋Ÿฌ๊ฐ€ ๋‹ค์‹œ ์—ฐ๊ฒฐ๋˜๋ฉด ๊ฒŒ์ž„์ด ์ž๋™์œผ๋กœ ๋‹ค์‹œ ์‹œ์ž‘๋ฉ๋‹ˆ๋‹ค.", + "GraphicsBackendThreadingTooltip": "2๋ฒˆ์งธ ์Šค๋ ˆ๋“œ์—์„œ ๊ทธ๋ž˜ํ”ฝ ํ›„๋‹จ๋ถ€ ๋ช…๋ น์„ ์‹คํ–‰ํ•ฉ๋‹ˆ๋‹ค.\n\n์…ฐ์ด๋” ์ปดํŒŒ์ผ ์†๋„๋ฅผ ๋†’์ด๊ณ , ๋Š๊น€ ํ˜„์ƒ์„ ์ค„์ด๋ฉฐ, ์ž์ฒด ๋‹ค์ค‘ ์Šค๋ ˆ๋”ฉ ์ง€์› ์—†์ด GPU ๋“œ๋ผ์ด๋ฒ„์˜ ์„ฑ๋Šฅ์„ ํ–ฅ์ƒ์‹œํ‚ต๋‹ˆ๋‹ค. ๋‹ค์ค‘ ์Šค๋ ˆ๋”ฉ์ด ์žˆ๋Š” ๋“œ๋ผ์ด๋ฒ„์—์„œ ์„ฑ๋Šฅ์ด ์ข€ ๋” ์ข‹์Šต๋‹ˆ๋‹ค.\n\n๋ชจ๋ฅด๋ฉด ์ž๋™์œผ๋กœ ์„ค์ •ํ•ฉ๋‹ˆ๋‹ค.", + "GalThreadingTooltip": "2๋ฒˆ์งธ ์Šค๋ ˆ๋“œ์—์„œ ๊ทธ๋ž˜ํ”ฝ ํ›„๋‹จ๋ถ€ ๋ช…๋ น์„ ์‹คํ–‰ํ•ฉ๋‹ˆ๋‹ค.\n\n์…ฐ์ด๋” ์ปดํŒŒ์ผ ์†๋„๋ฅผ ๋†’์ด๊ณ  ๋Š๊น€ ํ˜„์ƒ์„ ์ค„์ด๋ฉฐ ์ž์ฒด ๋‹ค์ค‘ ์Šค๋ ˆ๋”ฉ ์ง€์› ์—†์ด GPU ๋“œ๋ผ์ด๋ฒ„์˜ ์„ฑ๋Šฅ์„ ํ–ฅ์ƒ์‹œํ‚ต๋‹ˆ๋‹ค. ๋‹ค์ค‘ ์Šค๋ ˆ๋”ฉ์ด ์žˆ๋Š” ๋“œ๋ผ์ด๋ฒ„์—์„œ ์„ฑ๋Šฅ์ด ์ข€ ๋” ์ข‹์Šต๋‹ˆ๋‹ค.\n\n๋ชจ๋ฅด๋ฉด ์ž๋™์œผ๋กœ ์„ค์ •ํ•ฉ๋‹ˆ๋‹ค.", + "ShaderCacheToggleTooltip": "ํ›„์† ์‹คํ–‰ ์‹œ ๋Š๊น€ ํ˜„์ƒ์„ ์ค„์ด๋Š” ๋””์Šคํฌ ์…ฐ์ด๋” ์บ์‹œ๋ฅผ ์ €์žฅํ•ฉ๋‹ˆ๋‹ค.\n\n๋ชจ๋ฅด๋ฉด ์ผฌ์œผ๋กœ ๋‘์„ธ์š”.", + "ResolutionScaleTooltip": "๊ฒŒ์ž„์˜ ๋ Œ๋”๋ง ํ•ด์ƒ๋„๋ฅผ ๋ฐฐ๊ฐ€์‹œํ‚ต๋‹ˆ๋‹ค.\n\n์ผ๋ถ€ ๊ฒŒ์ž„์—์„œ๋Š” ์ด ๊ธฐ๋Šฅ์ด ์ž‘๋™ํ•˜์ง€ ์•Š๊ณ  ํ•ด์ƒ๋„๊ฐ€ ๋†’์•„์ ธ๋„ ํ”ฝ์…€ํ™”๋˜์–ด ๋ณด์ผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ํ•ด๋‹น ๊ฒŒ์ž„์˜ ๊ฒฝ์šฐ ์•คํ‹ฐ ์•จ๋ฆฌ์–ด์‹ฑ์„ ์ œ๊ฑฐํ•˜๊ฑฐ๋‚˜ ๋‚ด๋ถ€ ๋ Œ๋”๋ง ํ•ด์ƒ๋„๋ฅผ ๋†’์ด๋Š” ๋ชจ๋“œ๋ฅผ ์ฐพ์•„์•ผ ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ํ›„์ž๋ฅผ ์‚ฌ์šฉํ•˜๋ ค๋ฉด ๊ธฐ๋ณธ์„ ์„ ํƒํ•˜๋Š” ๊ฒƒ์ด ์ข‹์Šต๋‹ˆ๋‹ค.\n\n์ด ์˜ต์…˜์€ ์•„๋ž˜์˜ \"์ ์šฉ\"์„ ํด๋ฆญํ•˜์—ฌ ๊ฒŒ์ž„์ด ์‹คํ–‰๋˜๋Š” ๋™์•ˆ ๋ณ€๊ฒฝํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์„ค์ • ์ฐฝ์„ ์˜†์œผ๋กœ ์˜ฎ๊ธฐ๊ณ  ์›ํ•˜๋Š” ๊ฒŒ์ž„ ๋ชจ์–‘์„ ์ฐพ์„ ๋•Œ๊นŒ์ง€ ์‹คํ—˜ํ•ด ๋ณด์„ธ์š”.\n\n4๋ฐฐ๋Š” ๊ฑฐ์˜ ๋ชจ๋“  ์„ค์ •์—์„œ ๊ณผํ•˜๋‹ค๋Š” ์ ์„ ๋ช…์‹ฌํ•˜์„ธ์š”.", + "ResolutionScaleEntryTooltip": "๋ถ€๋™ ์†Œ์ˆ˜์  ํ•ด์ƒ๋„ ์Šค์ผ€์ผ(์˜ˆ: 1.5)์ž…๋‹ˆ๋‹ค. ์ ๋ถ„์ด ์•„๋‹Œ ์Šค์ผ€์ผ์€ ๋ฌธ์ œ๋‚˜ ์ถฉ๋Œ์„ ์ผ์œผํ‚ฌ ๊ฐ€๋Šฅ์„ฑ์ด ๋†’์Šต๋‹ˆ๋‹ค.", + "AnisotropyTooltip": "์ด๋ฐฉ์„ฑ ํ•„ํ„ฐ๋ง ์ˆ˜์ค€์ž…๋‹ˆ๋‹ค. ๊ฒŒ์ž„์—์„œ ์š”์ฒญํ•œ ๊ฐ’์„ ์‚ฌ์šฉํ•˜๋ ค๋ฉด ์ž๋™์œผ๋กœ ์„ค์ •ํ•˜์„ธ์š”.", + "AspectRatioTooltip": "๋ Œ๋”๋Ÿฌ ์ฐฝ์— ์ ์šฉ๋˜๋Š” ์ข…ํšก๋น„์ž…๋‹ˆ๋‹ค.\n\n๊ฒŒ์ž„์— ์ข…ํšก๋น„ ๋ชจ๋“œ๋ฅผ ์‚ฌ์šฉํ•˜๋Š” ๊ฒฝ์šฐ์—๋งŒ ์ด ์„ค์ •์„ ๋ณ€๊ฒฝํ•˜์„ธ์š”. ๊ทธ๋ ‡์ง€ ์•Š์œผ๋ฉด ๊ทธ๋ž˜ํ”ฝ์ด ๋Š˜์–ด๋‚ฉ๋‹ˆ๋‹ค.\n\n๋ชจ๋ฅด๋ฉด 16:9๋กœ ๋‘์„ธ์š”.", "ShaderDumpPathTooltip": "๊ทธ๋ž˜ํ”ฝ ์…ฐ์ด๋” ๋คํ”„ ๊ฒฝ๋กœ", - "FileLogTooltip": "๋””์Šคํฌ์˜ ๋กœ๊ทธ ํŒŒ์ผ์— ์ฝ˜์†” ๋กœ๊น…์„ ์ €์žฅํ•ฉ๋‹ˆ๋‹ค. ์„ฑ๋Šฅ์— ์˜ํ–ฅ์„ ๋ฏธ์น˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.", - "StubLogTooltip": "์ฝ˜์†”์— ์Šคํ… ๋กœ๊ทธ ๋ฉ”์‹œ์ง€๋ฅผ ์ธ์‡„ํ•ฉ๋‹ˆ๋‹ค. ์„ฑ๋Šฅ์— ์˜ํ–ฅ์„ ๋ฏธ์น˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.", - "InfoLogTooltip": "์ฝ˜์†”์— ์ •๋ณด ๋กœ๊ทธ ๋ฉ”์‹œ์ง€๋ฅผ ์ธ์‡„ํ•ฉ๋‹ˆ๋‹ค. ์„ฑ๋Šฅ์— ์˜ํ–ฅ์„ ๋ฏธ์น˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.", - "WarnLogTooltip": "์ฝ˜์†”์— ๊ฒฝ๊ณ  ๋กœ๊ทธ ๋ฉ”์‹œ์ง€๋ฅผ ์ธ์‡„ํ•ฉ๋‹ˆ๋‹ค. ์„ฑ๋Šฅ์— ์˜ํ–ฅ์„ ๋ฏธ์น˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.", - "ErrorLogTooltip": "์ฝ˜์†”์— ์˜ค๋ฅ˜ ๋กœ๊ทธ ๋ฉ”์‹œ์ง€๋ฅผ ์ธ์‡„ํ•ฉ๋‹ˆ๋‹ค. ์„ฑ๋Šฅ์— ์˜ํ–ฅ์„ ๋ฏธ์น˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.", - "TraceLogTooltip": "์ฝ˜์†”์— ์ถ”์  ๋กœ๊ทธ ๋ฉ”์‹œ์ง€๋ฅผ ์ธ์‡„ํ•ฉ๋‹ˆ๋‹ค. ์„ฑ๋Šฅ์— ์˜ํ–ฅ์„ ๋ฏธ์น˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.", - "GuestLogTooltip": "์ฝ˜์†”์— ๊ฒŒ์ŠคํŠธ ๋กœ๊ทธ ๋ฉ”์‹œ์ง€๋ฅผ ์ธ์‡„ํ•ฉ๋‹ˆ๋‹ค. ์„ฑ๋Šฅ์— ์˜ํ–ฅ์„ ๋ฏธ์น˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.", - "FileAccessLogTooltip": "์ฝ˜์†”์— ํŒŒ์ผ ์•ก์„ธ์Šค ๋กœ๊ทธ ๋ฉ”์‹œ์ง€๋ฅผ ์ธ์‡„ํ•ฉ๋‹ˆ๋‹ค.", - "FSAccessLogModeTooltip": "์ฝ˜์†”์— ๋Œ€ํ•œ FS ์ ‘์† ๋กœ๊ทธ ์ถœ๋ ฅ์„ ํ™œ์„ฑํ™”ํ•ฉ๋‹ˆ๋‹ค. ๊ฐ€๋Šฅํ•œ ๋ชจ๋“œ๋Š” 0-3\t\t\t\t", + "FileLogTooltip": "๋””์Šคํฌ์˜ ๋กœ๊ทธ ํŒŒ์ผ์— ์ฝ˜์†” ๊ธฐ๋ก์„ ์ €์žฅํ•ฉ๋‹ˆ๋‹ค. ์„ฑ๋Šฅ์— ์˜ํ–ฅ์„ ์ฃผ์ง€ ์•Š์Šต๋‹ˆ๋‹ค.", + "StubLogTooltip": "์ฝ˜์†”์— ์กฐ๊ฐ ๊ธฐ๋ก ๋ฉ”์‹œ์ง€๋ฅผ ์ถœ๋ ฅํ•ฉ๋‹ˆ๋‹ค. ์„ฑ๋Šฅ์— ์˜ํ–ฅ์„ ์ฃผ์ง€ ์•Š์Šต๋‹ˆ๋‹ค.", + "InfoLogTooltip": "์ฝ˜์†”์— ์ •๋ณด ๊ธฐ๋ก ๋ฉ”์‹œ์ง€๋ฅผ ์ถœ๋ ฅํ•ฉ๋‹ˆ๋‹ค. ์„ฑ๋Šฅ์— ์˜ํ–ฅ์„ ์ฃผ์ง€ ์•Š์Šต๋‹ˆ๋‹ค.", + "WarnLogTooltip": "์ฝ˜์†”์— ๊ฒฝ๊ณ  ๊ธฐ๋ก ๋ฉ”์‹œ์ง€๋ฅผ ์ถœ๋ ฅํ•ฉ๋‹ˆ๋‹ค. ์„ฑ๋Šฅ์— ์˜ํ–ฅ์„ ์ฃผ์ง€ ์•Š์Šต๋‹ˆ๋‹ค.", + "ErrorLogTooltip": "์ฝ˜์†”์— ์˜ค๋ฅ˜ ๊ธฐ๋ก ๋ฉ”์‹œ์ง€๋ฅผ ์ถœ๋ ฅํ•ฉ๋‹ˆ๋‹ค. ์„ฑ๋Šฅ์— ์˜ํ–ฅ์„ ์ฃผ์ง€ ์•Š์Šต๋‹ˆ๋‹ค.", + "TraceLogTooltip": "์ฝ˜์†”์— ์ถ”์  ๊ธฐ๋ก ๋ฉ”์‹œ์ง€๋ฅผ ์ถœ๋ ฅํ•ฉ๋‹ˆ๋‹ค. ์„ฑ๋Šฅ์— ์˜ํ–ฅ์„ ์ฃผ์ง€ ์•Š์Šต๋‹ˆ๋‹ค.", + "GuestLogTooltip": "์ฝ˜์†”์— ๊ฒŒ์ŠคํŠธ ๋กœ๊ทธ ๋ฉ”์‹œ์ง€๋ฅผ ์ถœ๋ ฅํ•ฉ๋‹ˆ๋‹ค. ์„ฑ๋Šฅ์— ์˜ํ–ฅ์„ ์ฃผ์ง€ ์•Š์Šต๋‹ˆ๋‹ค.", + "FileAccessLogTooltip": "์ฝ˜์†”์— ํŒŒ์ผ ์ ‘์† ๊ธฐ๋ก ๋ฉ”์‹œ์ง€๋ฅผ ์ถœ๋ ฅํ•ฉ๋‹ˆ๋‹ค.", + "FSAccessLogModeTooltip": "์ฝ˜์†”์— ํŒŒ์ผ ์‹œ์Šคํ…œ ์ ‘์† ๊ธฐ๋ก ์ถœ๋ ฅ์„ ํ™œ์„ฑํ™”ํ•ฉ๋‹ˆ๋‹ค. ๊ฐ€๋Šฅํ•œ ๋ชจ๋“œ๋Š” 0-3", "DeveloperOptionTooltip": "์ฃผ์˜ํ•ด์„œ ์‚ฌ์šฉ", - "OpenGlLogLevel": "์ ์ ˆํ•œ ๋กœ๊ทธ ์ˆ˜์ค€์„ ํ™œ์„ฑํ™”ํ•ด์•ผ ํ•จ", - "DebugLogTooltip": "์ฝ˜์†”์— ๋””๋ฒ„๊ทธ ๋กœ๊ทธ ๋ฉ”์‹œ์ง€๋ฅผ ์ธ์‡„ํ•ฉ๋‹ˆ๋‹ค.\n\n๋กœ๊ทธ๋ฅผ ์ฝ๊ธฐ ์–ด๋ ต๊ฒŒ ๋งŒ๋“ค๊ณ  ์—๋ฎฌ๋ ˆ์ดํ„ฐ ์„ฑ๋Šฅ์„ ์•…ํ™”์‹œํ‚ค๋ฏ€๋กœ ์ง์›์ด ๊ตฌ์ฒด์ ์œผ๋กœ ์ง€์‹œํ•œ ๊ฒฝ์šฐ์—๋งŒ ์‚ฌ์šฉํ•˜์„ธ์š”.", - "LoadApplicationFileTooltip": "ํŒŒ์ผ ํƒ์ƒ‰๊ธฐ๋ฅผ ์—ด์–ด ๋ถˆ๋Ÿฌ์˜ฌ ์Šค์œ„์น˜ ํ˜ธํ™˜ ํŒŒ์ผ ์„ ํƒ", - "LoadApplicationFolderTooltip": "ํŒŒ์ผ ํƒ์ƒ‰๊ธฐ๋ฅผ ์—ด์–ด ๋ถˆ๋Ÿฌ์˜ฌ ์Šค์œ„์น˜ ํ˜ธํ™˜ ์••์ถ• ํ•ด์ œ ์‘์šฉ ํ”„๋กœ๊ทธ๋žจ ์„ ํƒ", - "LoadDlcFromFolderTooltip": "Open a file explorer to choose one or more folders to bulk load DLC from", - "LoadTitleUpdatesFromFolderTooltip": "Open a file explorer to choose one or more folders to bulk load title updates from", + "OpenGlLogLevel": "์ ์ ˆํ•œ ๊ธฐ๋ก ์ˆ˜์ค€์ด ํ™œ์„ฑํ™”๋˜์–ด ์žˆ์–ด์•ผ ํ•จ", + "DebugLogTooltip": "์ฝ˜์†”์— ๋””๋ฒ„๊ทธ ๊ธฐ๋ก ๋ฉ”์‹œ์ง€๋ฅผ ์ถœ๋ ฅํ•ฉ๋‹ˆ๋‹ค.\n\n๋‹ด๋‹น์ž๊ฐ€ ํŠน๋ณ„ํžˆ ์š”์ฒญํ•œ ๊ฒฝ์šฐ์—๋งŒ ์ด ๊ธฐ๋Šฅ์„ ์‚ฌ์šฉํ•˜์‹ญ์‹œ์˜ค. ๋กœ๊ทธ๋ฅผ ์ฝ๊ธฐ ์–ด๋ ต๊ฒŒ ๋งŒ๋“ค๊ณ  ์—๋ฎฌ๋ ˆ์ดํ„ฐ ์„ฑ๋Šฅ์„ ์ €ํ•˜์‹œํ‚ฌ ์ˆ˜ ์žˆ๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค.", + "LoadApplicationFileTooltip": "ํŒŒ์ผ ํƒ์ƒ‰๊ธฐ๋ฅผ ์—ด์–ด ๋ถˆ๋Ÿฌ์˜ฌ Switch ํ˜ธํ™˜ ํŒŒ์ผ์„ ์„ ํƒ", + "LoadApplicationFolderTooltip": "Switch์™€ ํ˜ธํ™˜๋˜๋Š” ์••์ถ• ํ•ด์ œ๋œ ์•ฑ์„ ์„ ํƒํ•˜์—ฌ ๋ถˆ๋Ÿฌ์˜ค๋ ค๋ฉด ํŒŒ์ผ ํƒ์ƒ‰๊ธฐ๋ฅผ ์—ฝ๋‹ˆ๋‹ค.", + "LoadDlcFromFolderTooltip": "ํŒŒ์ผ ํƒ์ƒ‰๊ธฐ๋ฅผ ์—ด์–ด DLC๋ฅผ ์ผ๊ด„ ๋ถˆ๋Ÿฌ์˜ค๊ธฐํ•  ํด๋”๋ฅผ ํ•˜๋‚˜ ์ด์ƒ ์„ ํƒ", + "LoadTitleUpdatesFromFolderTooltip": "ํŒŒ์ผ ํƒ์ƒ‰๊ธฐ๋ฅผ ์—ด์–ด ํ•˜๋‚˜ ์ด์ƒ์˜ ํด๋”๋ฅผ ์„ ํƒํ•˜์—ฌ ๋Œ€๋Ÿ‰์œผ๋กœ ํƒ€์ดํ‹€ ์—…๋ฐ์ดํŠธ ๋ถˆ๋Ÿฌ์˜ค๊ธฐ", "OpenRyujinxFolderTooltip": "Ryujinx ํŒŒ์ผ ์‹œ์Šคํ…œ ํด๋” ์—ด๊ธฐ", - "OpenRyujinxLogsTooltip": "๋กœ๊ทธ๊ฐ€ ๊ธฐ๋ก๋œ ํด๋” ์—ด๊ธฐ", + "OpenRyujinxLogsTooltip": "๋กœ๊ทธ๊ฐ€ ๊ธฐ๋ก๋˜๋Š” ํด๋” ์—ด๊ธฐ", "ExitTooltip": "Ryujinx ์ข…๋ฃŒ", "OpenSettingsTooltip": "์„ค์ • ์ฐฝ ์—ด๊ธฐ", - "OpenProfileManagerTooltip": "์‚ฌ์šฉ์ž ํ”„๋กœํŒŒ์ผ ๊ด€๋ฆฌ์ž ์ฐฝ ์—ด๊ธฐ", - "StopEmulationTooltip": "ํ˜„์žฌ ๊ฒŒ์ž„์˜ ์—๋ฎฌ๋ ˆ์ด์…˜์„ ์ค‘์ง€ํ•˜๊ณ  ๊ฒŒ์ž„ ์„ ํƒ์œผ๋กœ ๋Œ์•„๊ฐ", + "OpenProfileManagerTooltip": "์‚ฌ์šฉ์ž ํ”„๋กœํ•„ ๊ด€๋ฆฌ์ž ์ฐฝ ์—ด๊ธฐ", + "StopEmulationTooltip": "ํ˜„์žฌ ๊ฒŒ์ž„์˜ ์—๋ฎฌ๋ ˆ์ด์…˜์„ ์ค‘์ง€ํ•˜๊ณ  ๊ฒŒ์ž„ ์„ ํƒ์œผ๋กœ ๋Œ์•„๊ฐ€๊ธฐ", "CheckUpdatesTooltip": "Ryujinx ์—…๋ฐ์ดํŠธ ํ™•์ธ", "OpenAboutTooltip": "์ •๋ณด ์ฐฝ ์—ด๊ธฐ", - "GridSize": "๊ฒฉ์ž ํฌ๊ธฐ", - "GridSizeTooltip": "๊ฒฉ์ž ํ•ญ๋ชฉ์˜ ํฌ๊ธฐ ๋ณ€๊ฒฝ", - "SettingsTabSystemSystemLanguageBrazilianPortuguese": "ํฌ๋ฅดํˆฌ๊ฐˆ์–ด(๋ธŒ๋ผ์งˆ)", + "GridSize": "๊ทธ๋ฆฌ๋“œ ํฌ๊ธฐ", + "GridSizeTooltip": "๊ทธ๋ฆฌ๋“œ ํ•ญ๋ชฉ์˜ ํฌ๊ธฐ ๋ณ€๊ฒฝ", + "SettingsTabSystemSystemLanguageBrazilianPortuguese": "๋ธŒ๋ผ์งˆ ํฌ๋ฅดํˆฌ๊ฐˆ์–ด", "AboutRyujinxContributorsButtonHeader": "๋ชจ๋“  ๊ธฐ์—ฌ์ž ๋ณด๊ธฐ", "SettingsTabSystemAudioVolume": "์Œ๋Ÿ‰ : ", - "AudioVolumeTooltip": "์Œํ–ฅ ์Œ๋Ÿ‰ ๋ณ€๊ฒฝ", + "AudioVolumeTooltip": "์Œ๋Ÿ‰ ๋ณ€๊ฒฝ", "SettingsTabSystemEnableInternetAccess": "๊ฒŒ์ŠคํŠธ ์ธํ„ฐ๋„ท ์ ‘์†/LAN ๋ชจ๋“œ", - "EnableInternetAccessTooltip": "์—๋ฎฌ๋ ˆ์ด์…˜๋œ ์‘์šฉํ”„๋กœ๊ทธ๋žจ์ด ์ธํ„ฐ๋„ท์— ์—ฐ๊ฒฐ๋˜๋„๋ก ํ—ˆ์šฉํ•ฉ๋‹ˆ๋‹ค.\n\nLAN ๋ชจ๋“œ๊ฐ€ ์žˆ๋Š” ๊ฒŒ์ž„์€ ์ด ๋ชจ๋“œ๊ฐ€ ํ™œ์„ฑํ™”๋˜๊ณ  ์‹œ์Šคํ…œ์ด ๋™์ผํ•œ ์ ‘์† ํฌ์ธํŠธ์— ์—ฐ๊ฒฐ๋œ ๊ฒฝ์šฐ ์„œ๋กœ ์—ฐ๊ฒฐํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์—ฌ๊ธฐ์—๋Š” ์‹ค์ œ ์ฝ˜์†”๋„ ํฌํ•จ๋ฉ๋‹ˆ๋‹ค.\n\n๋‹Œํ…๋„ ์„œ๋ฒ„์— ์—ฐ๊ฒฐํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. ์ธํ„ฐ๋„ท์— ์—ฐ๊ฒฐ์„ ์‹œ๋„ํ•˜๋Š” ํŠน์ • ๊ฒŒ์ž„์—์„œ ์ถฉ๋Œ์ด ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.\n\nํ™•์‹คํ•˜์ง€ ์•Š์œผ๋ฉด ๊บผ๋‘์„ธ์š”.", + "EnableInternetAccessTooltip": "์—๋ฎฌ๋ ˆ์ดํŠธ๋œ ์•ฑ์„ ์ธํ„ฐ๋„ท์— ์—ฐ๊ฒฐํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.\n\nLAN ๋ชจ๋“œ๊ฐ€ ์žˆ๋Š” ๊ฒŒ์ž„์€ ์ด ๊ธฐ๋Šฅ์ด ํ™œ์„ฑํ™”๋˜๊ณ  ์‹œ์Šคํ…œ์ด ๋™์ผํ•œ ์ ‘์† ํฌ์ธํŠธ์— ์—ฐ๊ฒฐ๋˜์–ด ์žˆ์„ ๋•Œ ์„œ๋กœ ์—ฐ๊ฒฐํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์ด๋Š” ์‹ค์ œ ์ฝ˜์†”๋„ ํฌํ•จ๋ฉ๋‹ˆ๋‹ค.\n\nNintendo ์„œ๋ฒ„ ์—ฐ๊ฒฐ์„ ํ—ˆ์šฉํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ์ธํ„ฐ๋„ท์— ์—ฐ๊ฒฐ์„ ์‹œ๋„ํ•˜๋Š” ํŠน์ • ๊ฒŒ์ž„์—์„œ ์ถฉ๋Œ์ด ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.\n\n๋ชจ๋ฅด๋ฉด ๋”์œผ๋กœ ๋‘์„ธ์š”.", "GameListContextMenuManageCheatToolTip": "์น˜ํŠธ ๊ด€๋ฆฌ", "GameListContextMenuManageCheat": "์น˜ํŠธ ๊ด€๋ฆฌ", - "GameListContextMenuManageModToolTip": "Mod ๊ด€๋ฆฌ", - "GameListContextMenuManageMod": "Mod ๊ด€๋ฆฌ", + "GameListContextMenuManageModToolTip": "๋ชจ๋“œ ๊ด€๋ฆฌ", + "GameListContextMenuManageMod": "๋ชจ๋“œ ๊ด€๋ฆฌ", "ControllerSettingsStickRange": "๋ฒ”์œ„ :", "DialogStopEmulationTitle": "Ryujinx - ์—๋ฎฌ๋ ˆ์ด์…˜ ์ค‘์ง€", - "DialogStopEmulationMessage": "์—๋ฎฌ๋ ˆ์ด์…˜์„ ์ค‘์ง€ํ•˜๊ฒ ์Šต๋‹ˆ๊นŒ?", + "DialogStopEmulationMessage": "์—๋ฎฌ๋ ˆ์ด์…˜์„ ์ค‘์ง€ํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?", "SettingsTabCpu": "CPU", - "SettingsTabAudio": "์˜ค๋””์˜ค", + "SettingsTabAudio": "์Œํ–ฅ", "SettingsTabNetwork": "๋„คํŠธ์›Œํฌ", "SettingsTabNetworkConnection": "๋„คํŠธ์›Œํฌ ์—ฐ๊ฒฐ", "SettingsTabCpuCache": "CPU ์บ์‹œ", "SettingsTabCpuMemory": "CPU ๋ชจ๋“œ", "DialogUpdaterFlatpakNotSupportedMessage": "FlatHub๋ฅผ ํ†ตํ•ด Ryujinx๋ฅผ ์—…๋ฐ์ดํŠธํ•˜์„ธ์š”.", - "UpdaterDisabledWarningTitle": "์—…๋ฐ์ดํ„ฐ ๋น„ํ™œ์„ฑํ™”์ž…๋‹ˆ๋‹ค!", + "UpdaterDisabledWarningTitle": "์—…๋ฐ์ดํ„ฐ๊ฐ€ ๋น„ํ™œ์„ฑํ™”๋˜์—ˆ์Šต๋‹ˆ๋‹ค!", "ControllerSettingsRotate90": "์‹œ๊ณ„ ๋ฐฉํ–ฅ์œผ๋กœ 90ยฐ ํšŒ์ „", "IconSize": "์•„์ด์ฝ˜ ํฌ๊ธฐ", "IconSizeTooltip": "๊ฒŒ์ž„ ์•„์ด์ฝ˜ ํฌ๊ธฐ ๋ณ€๊ฒฝ", "MenuBarOptionsShowConsole": "์ฝ˜์†” ํ‘œ์‹œ", - "ShaderCachePurgeError": "{0}์—์„œ ์…ฐ์ด๋” ์บ์‹œ๋ฅผ ์ œ๊ฑฐํ•˜๋Š” ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ: {1}", + "ShaderCachePurgeError": "{0}์—์„œ ์…ฐ์ด๋” ์บ์‹œ๋ฅผ ์‚ญ์ œํ•˜๋Š” ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ : {1}", "UserErrorNoKeys": "ํ‚ค๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Œ", "UserErrorNoFirmware": "ํŽŒ์›จ์–ด๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Œ", "UserErrorFirmwareParsingFailed": "ํŽŒ์›จ์–ด ๊ตฌ๋ฌธ ๋ถ„์„ ์˜ค๋ฅ˜", - "UserErrorApplicationNotFound": "์‘์šฉ ํ”„๋กœ๊ทธ๋žจ์„ ์ฐพ์„ ์ˆ˜ ์—†์Œ", + "UserErrorApplicationNotFound": "์•ฑ์„ ์ฐพ์„ ์ˆ˜ ์—†์Œ", "UserErrorUnknown": "์•Œ ์ˆ˜ ์—†๋Š” ์˜ค๋ฅ˜", "UserErrorUndefined": "์ •์˜๋˜์ง€ ์•Š์€ ์˜ค๋ฅ˜", - "UserErrorNoKeysDescription": "Ryujinx๊ฐ€ 'prod.keys' ํŒŒ์ผ์„ ์ฐพ์„ ์ˆ˜ ์—†์Œ", - "UserErrorNoFirmwareDescription": "Ryujinx๊ฐ€ ์„ค์น˜๋œ ํŽŒ์›จ์–ด๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Œ", - "UserErrorFirmwareParsingFailedDescription": "Ryujinx๊ฐ€ ์ œ๊ณต๋œ ํŽŒ์›จ์–ด๋ฅผ ๊ตฌ๋ฌธ ๋ถ„์„ํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. ์ผ๋ฐ˜์ ์œผ๋กœ ์˜ค๋ž˜๋œ ํ‚ค๊ฐ€ ์›์ธ์ž…๋‹ˆ๋‹ค.", - "UserErrorApplicationNotFoundDescription": "Ryujinx๊ฐ€ ์ง€์ •๋œ ๊ฒฝ๋กœ์—์„œ ์œ ํšจํ•œ ์‘์šฉ ํ”„๋กœ๊ทธ๋žจ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.", + "UserErrorNoKeysDescription": "Ryujinx๊ฐ€ 'prod.keys' ํŒŒ์ผ์„ ์ฐพ์ง€ ๋ชปํ•จ", + "UserErrorNoFirmwareDescription": "์„ค์น˜๋œ ํŽŒ์›จ์–ด๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Œ", + "UserErrorFirmwareParsingFailedDescription": "Ryujinx๊ฐ€ ์ œ๊ณต๋œ ํŽŒ์›จ์–ด๋ฅผ ๊ตฌ๋ฌธ ๋ถ„์„ํ•˜์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค. ์ด๋Š” ์ผ๋ฐ˜์ ์œผ๋กœ ์˜ค๋ž˜๋œ ํ‚ค๋กœ ์ธํ•ด ๋ฐœ์ƒํ•ฉ๋‹ˆ๋‹ค.", + "UserErrorApplicationNotFoundDescription": "Ryujinx๊ฐ€ ํ•ด๋‹น ๊ฒฝ๋กœ์—์„œ ์œ ํšจํ•œ ์•ฑ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.", "UserErrorUnknownDescription": "์•Œ ์ˆ˜ ์—†๋Š” ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค!", - "UserErrorUndefinedDescription": "์ •์˜๋˜์ง€ ์•Š์€ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค! ์ด๋Ÿฐ ์ผ์ด ๋ฐœ์ƒํ•˜๋ฉด ์•ˆ ๋˜๋ฏ€๋กœ, ๊ฐœ๋ฐœ์ž์—๊ฒŒ ๋ฌธ์˜ํ•˜์„ธ์š”!", + "UserErrorUndefinedDescription": "์ •์˜๋˜์ง€ ์•Š์€ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค! ์ด๋Ÿฐ ์ผ์ด ๋ฐœ์ƒํ•˜๋ฉด ์•ˆ ๋˜๋‹ˆ ๊ฐœ๋ฐœ์ž์—๊ฒŒ ๋ฌธ์˜ํ•˜์„ธ์š”!", "OpenSetupGuideMessage": "์„ค์ • ๊ฐ€์ด๋“œ ์—ด๊ธฐ", "NoUpdate": "์—…๋ฐ์ดํŠธ ์—†์Œ", "TitleUpdateVersionLabel": "๋ฒ„์ „ {0}", - "TitleBundledUpdateVersionLabel": "Bundled: Version {0}", - "TitleBundledDlcLabel": "Bundled:", + "TitleBundledUpdateVersionLabel": "๋ฒˆ๋“ค : ๋ฒ„์ „ {0}", + "TitleBundledDlcLabel": "๋ฒˆ๋“ค :", + "TitleXCIStatusPartialLabel": "์ผ๋ถ€", + "TitleXCIStatusTrimmableLabel": "ํŠธ๋ฆฌ๋ฐ๋˜์ง€ ์•Š์Œ", + "TitleXCIStatusUntrimmableLabel": "ํŠธ๋ฆฌ๋ฐ๋จ", + "TitleXCIStatusFailedLabel": "(์‹คํŒจ)", + "TitleXCICanSaveLabel": "{0:n0} Mb ์ €์žฅ", + "TitleXCISavingLabel": "{0:n0}Mb ์ €์žฅ๋จ", "RyujinxInfo": "Ryujinx - ์ •๋ณด", "RyujinxConfirm": "Ryujinx - ํ™•์ธ", - "FileDialogAllTypes": "๋ชจ๋“  ์œ ํ˜•", + "FileDialogAllTypes": "๋ชจ๋“  ํ˜•์‹", "Never": "์ ˆ๋Œ€ ์•ˆ ํ•จ", "SwkbdMinCharacters": "{0}์ž ์ด์ƒ์ด์–ด์•ผ ํ•จ", - "SwkbdMinRangeCharacters": "{0}-{1}์ž์—ฌ์•ผ ํ•จ", + "SwkbdMinRangeCharacters": "{0}-{1}์ž ๊ธธ์ด์—ฌ์•ผ ํ•จ", "SoftwareKeyboard": "์†Œํ”„ํŠธ์›จ์–ด ํ‚ค๋ณด๋“œ", - "SoftwareKeyboardModeNumeric": "'0~9' ๋˜๋Š” '.'๋งŒ ๊ฐ€๋Šฅ", - "SoftwareKeyboardModeAlphabet": "ํ•œ์ค‘์ผ ๋ฌธ์ž๊ฐ€ ์•„๋‹Œ ๋ฌธ์ž๋งŒ ๊ฐ€๋Šฅ", + "SoftwareKeyboardModeNumeric": "0-9 ๋˜๋Š” '.'๋งŒ ๊ฐ€๋Šฅ", + "SoftwareKeyboardModeAlphabet": "CJK ๋ฌธ์ž๊ฐ€ ์•„๋‹Œ ๋ฌธ์ž๋งŒ ๊ฐ€๋Šฅ", "SoftwareKeyboardModeASCII": "ASCII ํ…์ŠคํŠธ๋งŒ ๊ฐ€๋Šฅ", - "ControllerAppletControllers": "์ง€์›ํ•˜๋Š” ์ปจํŠธ๋กค๋Ÿฌ:", - "ControllerAppletPlayers": "ํ”Œ๋ ˆ์ด์–ด:", - "ControllerAppletDescription": "ํ˜„์žฌ ์„ค์ •์€ ์œ ํšจํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ์„ค์ •์„ ์—ด์–ด ์ž…๋ ฅ ์žฅ์น˜๋ฅผ ๋‹ค์‹œ ์„ค์ •ํ•˜์„ธ์š”.", - "ControllerAppletDocked": "๋… ๋ชจ๋“œ๊ฐ€ ์„ค์ •๋˜์—ˆ์Šต๋‹ˆ๋‹ค. ํ•ธ๋“œํ—ฌ๋“œ ์ปจํŠธ๋กค์€ ๋น„ํ™œ์„ฑํ™”๋ฉ๋‹ˆ๋‹ค.", - "UpdaterRenaming": "์ด์ „ ํŒŒ์ผ ์ด๋ฆ„ ๋ฐ”๊พธ๋Š” ์ค‘...", - "UpdaterRenameFailed": "์—…๋ฐ์ดํ„ฐ๊ฐ€ ํŒŒ์ผ ์ด๋ฆ„์„ ๋ฐ”๊ฟ€ ์ˆ˜ ์—†์Œ: {0}", - "UpdaterAddingFiles": "์ƒˆ๋กœ์šด ํŒŒ์ผ์„ ์ถ”๊ฐ€ํ•˜๋Š” ์ค‘...", - "UpdaterExtracting": "์—…๋ฐ์ดํŠธ๋ฅผ ์ถ”์ถœํ•˜๋Š” ์ค‘...", - "UpdaterDownloading": "์—…๋ฐ์ดํŠธ ๋‹ค์šด๋กœ๋“œ ์ค‘...", + "ControllerAppletControllers": "์ง€์›๋˜๋Š” ์ปจํŠธ๋กค๋Ÿฌ :", + "ControllerAppletPlayers": "ํ”Œ๋ ˆ์ด์–ด :", + "ControllerAppletDescription": "ํ˜„์žฌ ๊ตฌ์„ฑ์ด ์œ ํšจํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ์„ค์ •์„ ์—ด๊ณ  ์ž…๋ ฅ์„ ๋‹ค์‹œ ๊ตฌ์„ฑํ•˜์‹ญ์‹œ์˜ค.", + "ControllerAppletDocked": "๋„ํ‚น ๋ชจ๋“œ๊ฐ€ ์„ค์ •๋˜์—ˆ์Šต๋‹ˆ๋‹ค. ํœด๋Œ€์šฉ ์ œ์–ด ๊ธฐ๋Šฅ์„ ๋น„ํ™œ์„ฑํ™”ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.", + "UpdaterRenaming": "์˜ค๋ž˜๋œ ํŒŒ์ผ ์ด๋ฆ„ ๋ฐ”๊พธ๊ธฐ...", + "UpdaterRenameFailed": "์—…๋ฐ์ดํ„ฐ๊ฐ€ ํŒŒ์ผ ์ด๋ฆ„์„ ๋ฐ”๊ฟ€ ์ˆ˜ ์—†์Œ : {0}", + "UpdaterAddingFiles": "์ƒˆ ํŒŒ์ผ ์ถ”๊ฐ€...", + "UpdaterExtracting": "์—…๋ฐ์ดํŠธ ์ถ”์ถœ...", + "UpdaterDownloading": "์—…๋ฐ์ดํŠธ ๋‚ด๋ ค๋ฐ›๊ธฐ ์ค‘...", "Game": "๊ฒŒ์ž„", - "Docked": "๋„ํ‚น๋จ", - "Handheld": "ํœด๋Œ€์šฉ", - "ConnectionError": "์—ฐ๊ฒฐ ์˜ค๋ฅ˜์ž…๋‹ˆ๋‹ค.", - "AboutPageDeveloperListMore": "{0} ๋“ฑ...", - "ApiError": "API ์˜ค๋ฅ˜์ž…๋‹ˆ๋‹ค.", - "LoadingHeading": "{0} ๋กœ๋”ฉ ์ค‘", - "CompilingPPTC": "PTC ์ปดํŒŒ์ผ ์ค‘", - "CompilingShaders": "์…ฐ์ด๋” ์ปดํŒŒ์ผ ์ค‘", + "Docked": "๋„ํ‚น", + "Handheld": "ํœด๋Œ€", + "ConnectionError": "์—ฐ๊ฒฐ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.", + "AboutPageDeveloperListMore": "{0} ์™ธ...", + "ApiError": "API ์˜ค๋ฅ˜.", + "LoadingHeading": "{0} ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘", + "CompilingPPTC": "PTC ์ปดํŒŒ์ผ", + "CompilingShaders": "์…ฐ์ด๋” ์ปดํŒŒ์ผ", "AllKeyboards": "๋ชจ๋“  ํ‚ค๋ณด๋“œ", - "OpenFileDialogTitle": "์ง€์›๋˜๋Š” ํŒŒ์ผ์„ ์„ ํƒ", - "OpenFolderDialogTitle": "์••์ถ•์„ ํ‘ผ ๊ฒŒ์ž„์ด ์žˆ๋Š” ํด๋” ์„ ํƒ", + "OpenFileDialogTitle": "์ง€์›๋˜๋Š” ํŒŒ์ผ์„ ์„ ํƒํ•˜์—ฌ ์—ด๊ธฐ", + "OpenFolderDialogTitle": "์••์ถ• ํ•ด์ œ๋œ ๊ฒŒ์ž„์ด ์žˆ๋Š” ํด๋”๋ฅผ ์„ ํƒ", "AllSupportedFormats": "์ง€์›๋˜๋Š” ๋ชจ๋“  ํ˜•์‹", "RyujinxUpdater": "Ryujinx ์—…๋ฐ์ดํ„ฐ", "SettingsTabHotkeys": "ํ‚ค๋ณด๋“œ ๋‹จ์ถ•ํ‚ค", @@ -709,9 +722,9 @@ "SettingsTabHotkeysToggleVsyncHotkey": "์ˆ˜์ง ๋™๊ธฐํ™” ์ „ํ™˜ :", "SettingsTabHotkeysScreenshotHotkey": "์Šคํฌ๋ฆฐ์ƒท :", "SettingsTabHotkeysShowUiHotkey": "UI ํ‘œ์‹œ :", - "SettingsTabHotkeysPauseHotkey": "์ผ์‹œ ์ค‘์ง€ :", - "SettingsTabHotkeysToggleMuteHotkey": "์Œ ์†Œ๊ฑฐ :", - "ControllerMotionTitle": "๋™์ž‘ ์ œ์–ด ์„ค์ •", + "SettingsTabHotkeysPauseHotkey": "์ค‘์ง€ :", + "SettingsTabHotkeysToggleMuteHotkey": "์Œ์†Œ๊ฑฐ :", + "ControllerMotionTitle": "๋ชจ์…˜ ์ปจํŠธ๋กค ์„ค์ •", "ControllerRumbleTitle": "์ง„๋™ ์„ค์ •", "SettingsSelectThemeFileDialogTitle": "ํ…Œ๋งˆ ํŒŒ์ผ ์„ ํƒ", "SettingsXamlThemeFile": "Xaml ํ…Œ๋งˆ ํŒŒ์ผ", @@ -722,91 +735,130 @@ "Writable": "์“ฐ๊ธฐ ๊ฐ€๋Šฅ", "SelectDlcDialogTitle": "DLC ํŒŒ์ผ ์„ ํƒ", "SelectUpdateDialogTitle": "์—…๋ฐ์ดํŠธ ํŒŒ์ผ ์„ ํƒ", - "SelectModDialogTitle": "Mod ๋””๋ ‰ํ„ฐ๋ฆฌ ์„ ํƒ", - "UserProfileWindowTitle": "์‚ฌ์šฉ์ž ํ”„๋กœํŒŒ์ผ ๊ด€๋ฆฌ์ž", + "SelectModDialogTitle": "๋ชจ๋“œ ๋””๋ ‰ํ„ฐ๋ฆฌ ์„ ํƒ", + "TrimXCIFileDialogTitle": "XCI ํŒŒ์ผ ํ™•์ธ ๋ฐ ์ •๋ฆฌ", + "TrimXCIFileDialogPrimaryText": "์ด ๊ธฐ๋Šฅ์€ ๋จผ์ € ์ถฉ๋ถ„ํ•œ ๊ณต๊ฐ„์„ ํ™•๋ณดํ•œ ๋‹ค์Œ XCI ํŒŒ์ผ์„ ํŠธ๋ฆฌ๋ฐํ•˜์—ฌ ๋””์Šคํฌ ๊ณต๊ฐ„์„ ์ ˆ์•ฝํ•ฉ๋‹ˆ๋‹ค.", + "TrimXCIFileDialogSecondaryText": "ํ˜„์žฌ ํŒŒ์ผ ํฌ๊ธฐ : {0:n}MB\n๊ฒŒ์ž„ ๋ฐ์ดํ„ฐ ํฌ๊ธฐ : {1:n}MB\n๋””์Šคํฌ ๊ณต๊ฐ„ ์ ˆ์•ฝ : {2:n}MB", + "TrimXCIFileNoTrimNecessary": "XCI ํŒŒ์ผ์€ ํŠธ๋ฆฌ๋ฐํ•  ํ•„์š”๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค. ์ž์„ธํ•œ ๋‚ด์šฉ์€ ๋กœ๊ทธ๋ฅผ ํ™•์ธ", + "TrimXCIFileNoUntrimPossible": "XCI ํŒŒ์ผ์€ ํŠธ๋ฆฌ๋ฐ์„ ํ•ด์ œํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. ์ž์„ธํ•œ ๋‚ด์šฉ์€ ๋กœ๊ทธ๋ฅผ ํ™•์ธ", + "TrimXCIFileReadOnlyFileCannotFix": "XCI ํŒŒ์ผ์€ ์ฝ๊ธฐ ์ „์šฉ์ด๋ฏ€๋กœ ์“ฐ๊ธฐ ๊ฐ€๋Šฅํ•˜๊ฒŒ ๋งŒ๋“ค ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. ์ž์„ธํ•œ ๋‚ด์šฉ์€ ๋กœ๊ทธ๋ฅผ ํ™•์ธ", + "TrimXCIFileFileSizeChanged": "XCI ํŒŒ์ผ์ด ์Šค์บ”๋œ ํ›„ ํฌ๊ธฐ๊ฐ€ ๋ณ€๊ฒฝ๋˜์—ˆ์Šต๋‹ˆ๋‹ค. ํŒŒ์ผ์ด ์“ฐ์—ฌ์ง€๊ณ  ์žˆ์ง€ ์•Š์€์ง€ ํ™•์ธํ•˜๊ณ  ๋‹ค์‹œ ์‹œ๋„ํ•˜์„ธ์š”.", + "TrimXCIFileFreeSpaceCheckFailed": "XCI ํŒŒ์ผ์— ์—ฌ์œ  ๊ณต๊ฐ„ ์˜์—ญ์— ๋ฐ์ดํ„ฐ๊ฐ€ ์žˆ์œผ๋ฏ€๋กœ ํŠธ๋ฆฌ๋ฐํ•˜๋Š” ๊ฒƒ์ด ์•ˆ์ „ํ•˜์ง€ ์•Š์Œ", + "TrimXCIFileInvalidXCIFile": "XCI ํŒŒ์ผ์— ์œ ํšจํ•˜์ง€ ์•Š์€ ๋ฐ์ดํ„ฐ๊ฐ€ ํฌํ•จ๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค. ์ž์„ธํ•œ ๋‚ด์šฉ์€ ๋กœ๊ทธ๋ฅผ ํ™•์ธ", + "TrimXCIFileFileIOWriteError": "XCI ํŒŒ์ผ์„ ์“ฐ๊ธฐ ์œ„ํ•ด ์—ด ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. ์ž์„ธํ•œ ๋‚ด์šฉ์€ ๋กœ๊ทธ๋ฅผ ํ™•์ธ", + "TrimXCIFileFailedPrimaryText": "XCI ํŒŒ์ผ ํŠธ๋ฆฌ๋ฐ์— ์‹คํŒจ", + "TrimXCIFileCancelled": "์ž‘์—…์ด ์ทจ์†Œ๋จ", + "TrimXCIFileFileUndertermined": "์ž‘์—…์ด ์ˆ˜ํ–‰๋˜์ง€ ์•Š์Œ", + "UserProfileWindowTitle": "์‚ฌ์šฉ์ž ํ”„๋กœํ•„ ๊ด€๋ฆฌ์ž", "CheatWindowTitle": "์น˜ํŠธ ๊ด€๋ฆฌ์ž", - "DlcWindowTitle": "{0} ({1})์˜ ๋‹ค์šด๋กœ๋“œ ๊ฐ€๋Šฅํ•œ ์ฝ˜ํ…์ธ  ๊ด€๋ฆฌ", - "ModWindowTitle": "{0} ({1})์˜ Mod ๊ด€๋ฆฌ", + "DlcWindowTitle": "{0} ({1})์˜ ๋‚ด๋ ค๋ฐ›๊ธฐ ๊ฐ€๋Šฅํ•œ ์ฝ˜ํ…์ธ  ๊ด€๋ฆฌ", + "ModWindowTitle": "{0}({1})์˜ ๋ชจ๋“œ ๊ด€๋ฆฌ", "UpdateWindowTitle": "ํƒ€์ดํ‹€ ์—…๋ฐ์ดํŠธ ๊ด€๋ฆฌ์ž", - "UpdateWindowUpdateAddedMessage": "{0} new update(s) added", - "UpdateWindowBundledContentNotice": "Bundled updates cannot be removed, only disabled.", - "CheatWindowHeading": "{0} [{1}]์— ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋Š” ์น˜ํŠธ", - "BuildId": "๋นŒ๋“œID :", - "DlcWindowBundledContentNotice": "Bundled DLC cannot be removed, only disabled.", - "DlcWindowHeading": "{0} ๋‚ด๋ ค๋ฐ›๊ธฐ ๊ฐ€๋Šฅํ•œ ์ฝ˜ํ…์ธ ", - "DlcWindowDlcAddedMessage": "{0} new downloadable content(s) added", - "AutoloadDlcAddedMessage": "{0} new downloadable content(s) added", - "AutoloadDlcRemovedMessage": "{0} missing downloadable content(s) removed", - "AutoloadUpdateAddedMessage": "{0} new update(s) added", - "AutoloadUpdateRemovedMessage": "{0} missing update(s) removed", - "ModWindowHeading": "{0} Mod(s)", - "UserProfilesEditProfile": "์„ ํƒ๋œ ํ•ญ๋ชฉ ํŽธ์ง‘", + "XCITrimmerWindowTitle": "XCI ํŒŒ์ผ ํŠธ๋ฆฌ๋จธ", + "XCITrimmerTitleStatusCount": "{1}๊ฐœ ํƒ€์ดํ‹€ ์ค‘ {0}๊ฐœ ์„ ํƒ๋จ", + "XCITrimmerTitleStatusCountWithFilter": "{1}๊ฐœ ํƒ€์ดํ‹€ ์ค‘ {0}๊ฐœ ์„ ํƒ๋จ({2}๊ฐœ ํ‘œ์‹œ๋จ)", + "XCITrimmerTitleStatusTrimming": "{0}๊ฐœ์˜ ํƒ€์ดํ‹€์„ ํŠธ๋ฆฌ๋ฐ ์ค‘...", + "XCITrimmerTitleStatusUntrimming": "{0}๊ฐœ์˜ ํƒ€์ดํ‹€์„ ํŠธ๋ฆฌ๋ฐ ํ•ด์ œ ์ค‘...", + "XCITrimmerTitleStatusFailed": "์‹คํŒจ", + "XCITrimmerPotentialSavings": "์ž ์žฌ์  ๋น„์šฉ ์ ˆ๊ฐ", + "XCITrimmerActualSavings": "์‹ค์ œ ๋น„์šฉ ์ ˆ๊ฐ", + "XCITrimmerSavingsMb": "{0:n0} Mb", + "XCITrimmerSelectDisplayed": "ํ‘œ์‹œ๋จ ์„ ํƒ", + "XCITrimmerDeselectDisplayed": "ํ‘œ์‹œ๋จ ์„ ํƒ ์ทจ์†Œ", + "XCITrimmerSortName": "ํƒ€์ดํ‹€", + "XCITrimmerSortSaved": "๊ณต๊ฐ„ ์ ˆ์•ฝs", + "UpdateWindowUpdateAddedMessage": "{0}๊ฐœ์˜ ์ƒˆ ์—…๋ฐ์ดํŠธ๊ฐ€ ์ถ”๊ฐ€๋จ", + "UpdateWindowBundledContentNotice": "๋ฒˆ๋“ค ์—…๋ฐ์ดํŠธ๋Š” ์ œ๊ฑฐํ•  ์ˆ˜ ์—†์œผ๋ฉฐ, ๋น„ํ™œ์„ฑํ™”๋งŒ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค.", + "CheatWindowHeading": "{0} [{1}]์— ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ์น˜ํŠธ", + "BuildId": "๋นŒ๋“œID:", + "DlcWindowBundledContentNotice": "๋ฒˆ๋“ค DLC๋Š” ์ œ๊ฑฐํ•  ์ˆ˜ ์—†์œผ๋ฉฐ ๋น„ํ™œ์„ฑํ™”๋งŒ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค.", + "DlcWindowHeading": "{1} ({2})์— ๋‚ด๋ ค๋ฐ›๊ธฐ ๊ฐ€๋Šฅํ•œ ์ฝ˜ํ…์ธ  {0}๊ฐœ ์‚ฌ์šฉ ๊ฐ€๋Šฅ", + "DlcWindowDlcAddedMessage": "{0}๊ฐœ์˜ ์ƒˆ๋กœ์šด ๋‚ด๋ ค๋ฐ›๊ธฐ ๊ฐ€๋Šฅํ•œ ์ฝ˜ํ…์ธ ๊ฐ€ ์ถ”๊ฐ€๋จ", + "AutoloadDlcAddedMessage": "{0}๊ฐœ์˜ ์ƒˆ๋กœ์šด ๋‚ด๋ ค๋ฐ›๊ธฐ ๊ฐ€๋Šฅํ•œ ์ฝ˜ํ…์ธ ๊ฐ€ ์ถ”๊ฐ€๋จ", + "AutoloadDlcRemovedMessage": "{0}๊ฐœ์˜ ๋‚ด๋ ค๋ฐ›๊ธฐ ๊ฐ€๋Šฅํ•œ ์ฝ˜ํ…์ธ ๊ฐ€ ์ œ๊ฑฐ๋จ", + "AutoloadUpdateAddedMessage": "{0}๊ฐœ์˜ ์ƒˆ ์—…๋ฐ์ดํŠธ๊ฐ€ ์ถ”๊ฐ€๋จ", + "AutoloadUpdateRemovedMessage": "๋ˆ„๋ฝ๋œ ์—…๋ฐ์ดํŠธ {0}๊ฐœ ์‚ญ์ œ", + "ModWindowHeading": "{0} ๋ชจ๋“œ", + "UserProfilesEditProfile": "์„ ํƒ ํ•ญ๋ชฉ ํŽธ์ง‘", + "Continue": "๊ณ„์†", "Cancel": "์ทจ์†Œ", "Save": "์ €์žฅ", - "Discard": "์‚ญ์ œ", - "Paused": "์ผ์‹œ ์ค‘์ง€", - "UserProfilesSetProfileImage": "ํ”„๋กœํŒŒ์ผ ์ด๋ฏธ์ง€ ์„ค์ •", - "UserProfileEmptyNameError": "์ด๋ฆ„ ํ•„์š”", - "UserProfileNoImageError": "ํ”„๋กœํŒŒ์ผ ์ด๋ฏธ์ง€๋ฅผ ์„ค์ •ํ•ด์•ผ ํ•จ", + "Discard": "ํ๊ธฐ", + "Paused": "์ผ์‹œ ์ค‘์ง€๋จ", + "UserProfilesSetProfileImage": "ํ”„๋กœํ•„ ์ด๋ฏธ์ง€ ์„ค์ •", + "UserProfileEmptyNameError": "์ด๋ฆ„ ํ•„์ˆ˜ ์ž…๋ ฅ", + "UserProfileNoImageError": "ํ”„๋กœํ•„ ์ด๋ฏธ์ง€๋ฅผ ์„ค์ •ํ•ด์•ผ ํ•จ", "GameUpdateWindowHeading": "{0} ({1})์— ๋Œ€ํ•œ ์—…๋ฐ์ดํŠธ ๊ด€๋ฆฌ", "SettingsTabHotkeysResScaleUpHotkey": "ํ•ด์ƒ๋„ ์ฆ๊ฐ€ :", "SettingsTabHotkeysResScaleDownHotkey": "ํ•ด์ƒ๋„ ๊ฐ์†Œ :", "UserProfilesName": "์ด๋ฆ„ :", "UserProfilesUserId": "์‚ฌ์šฉ์ž ID :", "SettingsTabGraphicsBackend": "๊ทธ๋ž˜ํ”ฝ ํ›„๋‹จ๋ถ€", - "SettingsTabGraphicsBackendTooltip": "์—๋ฎฌ๋ ˆ์ดํ„ฐ์— ์‚ฌ์šฉ๋  ๊ทธ๋ž˜ํ”ฝ ๋ฐฑ์—”๋“œ๋ฅผ ์„ ํƒํ•ฉ๋‹ˆ๋‹ค.\n\nVulkan์ด ๋“œ๋ผ์ด๋ฒ„๊ฐ€ ์ตœ์‹ ์ด๊ธฐ ๋•Œ๋ฌธ์— ๋ชจ๋“  ํ˜„๋Œ€ ๊ทธ๋ž˜ํ”ฝ ์นด๋“œ๋“ค์—์„œ ๋” ์ข‹์€ ์„ฑ๋Šฅ์„ ๋ฐœํœ˜ํ•ฉ๋‹ˆ๋‹ค. ๋˜ํ•œ Vulkan์€ ๋ชจ๋“  ๋ฒค๋”์‚ฌ์˜ GPU์—์„œ ๋” ๋น ๋ฅธ ์‰์ด๋” ์ปดํŒŒ์ผ์„ ์ง€์›ํ•˜์—ฌ ์Šคํ„ฐํ„ฐ๋ง์ด ์ ์Šต๋‹ˆ๋‹ค.\n\nOpenGL์˜ ๊ฒฝ์šฐ ์˜ค๋ž˜๋œ Nvidia GPU๋‚˜ ์˜ค๋ž˜๋œ AMD GPU(๋ฆฌ๋ˆ…์Šค ํ•œ์ •), ํ˜น์€ VRAM์ด ์ ์€ GPU์—์„œ ๋” ๋‚˜์€ ์„ฑ๋Šฅ์„ ๋ฐœํœ˜ํ•  ์ˆ˜๋Š” ์žˆ์œผ๋‚˜ ์‰์ด๋” ์ปดํŒŒ์ผ๋กœ ์ธํ•œ ์Šคํ„ฐํ„ฐ๋ง์ด Vulkan๋ณด๋‹ค ์‹ฌํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.\n\n์ด ์˜ต์…˜์— ๋Œ€ํ•ด ์ž˜ ๋ชจ๋ฅธ๋‹ค๋ฉด Vulkan์œผ๋กœ ์„ค์ •ํ•˜์„ธ์š”. ์‚ฌ์šฉํ•˜๋Š” GPU๊ฐ€ ์ตœ์‹  ๊ทธ๋ž˜ํ”ฝ ๋“œ๋ผ์ด๋ฒ„์—์„œ๋„ Vulkan์„ ์ง€์›ํ•˜์ง€ ์•Š๋Š”๋‹ค๋ฉด ๊ทธ ๋• OpenGL๋กœ ์„ค์ •ํ•˜์„ธ์š”.", + "SettingsTabGraphicsBackendTooltip": "์—๋ฎฌ๋ ˆ์ดํ„ฐ์—์„œ ์‚ฌ์šฉํ•  ๊ทธ๋ž˜ํ”ฝ ํ›„๋‹จ๋ถ€๋ฅผ ์„ ํƒํ•ฉ๋‹ˆ๋‹ค.\n\nVulkan์€ ๋“œ๋ผ์ด๋ฒ„๊ฐ€ ์ตœ์‹  ์ƒํƒœ์ธ ํ•œ ๋ชจ๋“  ์ตœ์‹  ๊ทธ๋ž˜ํ”ฝ ์นด๋“œ์— ์ „๋ฐ˜์ ์œผ๋กœ ๋” ์ข‹์Šต๋‹ˆ๋‹ค. Vulkan์€ ๋˜ํ•œ ๋ชจ๋“  GPU ๊ณต๊ธ‰์—…์ฒด์—์„œ ๋” ๋น ๋ฅธ ์…ฐ์ด๋” ์ปดํŒŒ์ผ(๋œ ๋Š๊น€)์„ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค.\n\nOpenGL์€ ์˜ค๋ž˜๋œ Nvidia GPU, Linux์˜ ์˜ค๋ž˜๋œ AMD GPU ๋˜๋Š” VRAM์ด ๋‚ฎ์€ GPU์—์„œ ๋” ๋‚˜์€ ๊ฒฐ๊ณผ๋ฅผ ์–ป์„ ์ˆ˜ ์žˆ์ง€๋งŒ ์…ฐ์ด๋” ์ปดํŒŒ์ผ ๋Š๊น€์ด ๋” ํฝ๋‹ˆ๋‹ค.\n\n๋ชจ๋ฅด๋ฉด Vulkan์œผ๋กœ ์„ค์ •ํ•ฉ๋‹ˆ๋‹ค. ์ตœ์‹  ๊ทธ๋ž˜ํ”ฝ ๋“œ๋ผ์ด๋ฒ„๋ฅผ ์‚ฌ์šฉํ•ด๋„ GPU๊ฐ€ Vulkan์„ ์ง€์›ํ•˜์ง€ ์•Š๋Š” ๊ฒฝ์šฐ OpenGL๋กœ ์„ค์ •ํ•˜์„ธ์š”..", "SettingsEnableTextureRecompression": "ํ…์Šค์ฒ˜ ์žฌ์••์ถ• ํ™œ์„ฑํ™”", - "SettingsEnableTextureRecompressionTooltip": "ASTC ํ…์Šค์ฒ˜๋ฅผ ์••์ถ•ํ•˜์—ฌ VRAM ์‚ฌ์šฉ๋Ÿ‰์„ ์ค„์ž…๋‹ˆ๋‹ค.\n\n์• ์ŠคํŠธ๋Ÿด ์ฒด์ธ, ๋ฐ”์š”๋„คํƒ€ 3, ํŒŒ์ด์–ด ์— ๋ธ”๋ ˜ ์ธ๊ฒŒ์ด์ง€, ๋ฉ”ํŠธ๋กœ์ด๋“œ ํ”„๋ผ์ž„ ๋ฆฌ๋งˆ์Šคํ„ฐ, ์Šˆํผ ๋งˆ๋ฆฌ์˜ค๋ธŒ๋ผ๋”์Šค ์›๋”, ์ ค๋‹ค์˜ ์ „์„ค: ํ‹ฐ์–ด์Šค ์˜ค๋ธŒ ๋” ํ‚น๋ค ๋“ฑ์ด ์ด๋Ÿฌํ•œ ํ…์Šค์ฒ˜ ํฌ๋งท์„ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค.\n\nVRAM์ด 4GiB ์ดํ•˜์ธ ๊ทธ๋ž˜ํ”ฝ ์นด๋“œ๋กœ ์œ„์™€ ๊ฐ™์€ ๊ฒŒ์ž„๋“ค์„ ๊ตฌ๋™ํ• ์‹œ ํŠน์ • ์ง€์ ์—์„œ ํฌ๋ž˜์‹œ๊ฐ€ ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.\n\n์œ„์— ์„œ์ˆ ๋œ ๊ฒŒ์ž„๋“ค์—์„œ VRAM์ด ๋ถ€์กฑํ•œ ๊ฒฝ์šฐ์—๋งŒ ํ•ด๋‹น ์˜ต์…˜์„ ์ผœ๊ณ , ๊ทธ ์™ธ์˜ ๊ฒฝ์šฐ์—๋Š” ๋„๊ธฐ๋ฅผ ๊ถŒ์žฅ๋“œ๋ฆฝ๋‹ˆ๋‹ค.", - "SettingsTabGraphicsPreferredGpu": "์„ ํ˜ธํ•˜๋Š” GPU", - "SettingsTabGraphicsPreferredGpuTooltip": "Vulkan ๊ทธ๋ž˜ํ”ฝ ํ›„๋‹จ๋ถ€์™€ ํ•จ๊ป˜ ์‚ฌ์šฉํ•  ๊ทธ๋ž˜ํ”ฝ ์นด๋“œ๋ฅผ ์„ ํƒํ•˜์„ธ์š”.\n\nOpenGL์ด ์‚ฌ์šฉํ•  GPU์—๋Š” ์˜ํ–ฅ์„ ๋ฏธ์น˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.\n\nํ™•์‹คํ•˜์ง€ ์•Š์€ ๊ฒฝ์šฐ \"dGPU\" ํ”Œ๋ž˜๊ทธ๊ฐ€ ์ง€์ •๋œ GPU๋กœ ์„ค์ •ํ•˜์„ธ์š”. ์—†๋Š” ๊ฒฝ์šฐ, ๊ทธ๋Œ€๋กœ ๋‘์„ธ์š”.", + "SettingsEnableTextureRecompressionTooltip": "VRAM ์‚ฌ์šฉ๋Ÿ‰์„ ์ค„์ด๊ธฐ ์œ„ํ•ด ASTC ํ…์Šค์ฒ˜๋ฅผ ์••์ถ•ํ•ฉ๋‹ˆ๋‹ค.\n\n์ด ํ…์Šค์ฒ˜ ํ˜•์‹์„ ์‚ฌ์šฉํ•˜๋Š” ๊ฒŒ์ž„์—๋Š” Astral Chain, Bayonetta 3, Fire Emblem Engage, Metroid Prime Remastered, Super Mario Bros. Wonder, The Legend of Zelda: Tears of the Kingdom์ด ์žˆ์Šต๋‹ˆ๋‹ค.\n\n4GiB VRAM ์ดํ•˜์˜ ๊ทธ๋ž˜ํ”ฝ ์นด๋“œ๋Š” ์ด๋Ÿฌํ•œ ๊ฒŒ์ž„์„ ์‹คํ–‰ํ•˜๋Š” ๋™์•ˆ ์–ด๋А ์‹œ์ ์—์„œ ์ถฉ๋Œํ•  ๊ฐ€๋Šฅ์„ฑ์ด ์žˆ์Šต๋‹ˆ๋‹ค.\n\n์œ„์—์„œ ์–ธ๊ธ‰ํ•œ ๊ฒŒ์ž„์—์„œ VRAM์ด ๋ถ€์กฑํ•œ ๊ฒฝ์šฐ์—๋งŒ ํ™œ์„ฑํ™”ํ•ฉ๋‹ˆ๋‹ค. ๋ชจ๋ฅด๋ฉด ๋”์œผ๋กœ ๋‘์„ธ์š”.", + "SettingsTabGraphicsPreferredGpu": "๊ธฐ๋ณธ GPU", + "SettingsTabGraphicsPreferredGpuTooltip": "Vulkan ๊ทธ๋ž˜ํ”ฝ ํ›„๋‹จ๋ถ€์™€ ํ•จ๊ป˜ ์‚ฌ์šฉํ•  ๊ทธ๋ž˜ํ”ฝ ์นด๋“œ๋ฅผ ์„ ํƒํ•˜์„ธ์š”.\n\nOpenGL์—์„œ ์‚ฌ์šฉํ•  GPU์—๋Š” ์˜ํ–ฅ์„ ๋ฏธ์น˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.\n\n๋ชจ๋ฅด๋ฉด \"dGPU\"๋กœ ํ”Œ๋ž˜๊ทธ๊ฐ€ ์ง€์ •๋œ GPU๋กœ ์„ค์ •ํ•˜์„ธ์š”. ์—†์œผ๋ฉด ๊ทธ๋Œ€๋กœ ๋‘์„ธ์š”.", "SettingsAppRequiredRestartMessage": "Ryujinx ๋‹ค์‹œ ์‹œ์ž‘ ํ•„์š”", - "SettingsGpuBackendRestartMessage": "๊ทธ๋ž˜ํ”ฝ ํ›„๋‹จ๋ถ€ ๋˜๋Š” GPU ์„ค์ •์ด ์ˆ˜์ •๋˜์—ˆ์Šต๋‹ˆ๋‹ค. ์ ์šฉํ•˜๋ ค๋ฉด ๋‹ค์‹œ ์‹œ์ž‘ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.", - "SettingsGpuBackendRestartSubMessage": "์ง€๊ธˆ ๋‹ค์‹œ ์‹œ์ž‘ํ•˜๊ฒ ์Šต๋‹ˆ๊นŒ?", - "RyujinxUpdaterMessage": "Ryujinx๋ฅผ ์ตœ์‹  ๋ฒ„์ „์œผ๋กœ ์—…๋ฐ์ดํŠธํ•˜๊ฒ ์Šต๋‹ˆ๊นŒ?", + "SettingsGpuBackendRestartMessage": "๊ทธ๋ž˜ํ”ฝ ํ›„๋‹จ๋ถ€ ๋˜๋Š” GPU ์„ค์ •์ด ์ˆ˜์ •๋˜์—ˆ์Šต๋‹ˆ๋‹ค. ์ด๋ฅผ ์ ์šฉํ•˜๋ ค๋ฉด ๋‹ค์‹œ ์‹œ์ž‘์ด ํ•„์š”", + "SettingsGpuBackendRestartSubMessage": "์ง€๊ธˆ ๋‹ค์‹œ ์‹œ์ž‘ํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?", + "RyujinxUpdaterMessage": "Ryujinx๋ฅผ ์ตœ์‹  ๋ฒ„์ „์œผ๋กœ ์—…๋ฐ์ดํŠธํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?", "SettingsTabHotkeysVolumeUpHotkey": "์Œ๋Ÿ‰ ์ฆ๊ฐ€ :", "SettingsTabHotkeysVolumeDownHotkey": "์Œ๋Ÿ‰ ๊ฐ์†Œ :", "SettingsEnableMacroHLE": "๋งคํฌ๋กœ HLE ํ™œ์„ฑํ™”", - "SettingsEnableMacroHLETooltip": "GPU ๋งคํฌ๋กœ ์ฝ”๋“œ์˜ ๋†’์€ ์ˆ˜์ค€ ์—๋ฎฌ๋ ˆ์ด์…˜์ž…๋‹ˆ๋‹ค.\n\n์„ฑ๋Šฅ์ด ํ–ฅ์ƒ๋˜์ง€๋งŒ ์ผ๋ถ€ ๊ฒŒ์ž„์—์„œ ๊ทธ๋ž˜ํ”ฝ ๊ฒฐํ•จ์ด ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.\n\nํ™•์‹คํ•˜์ง€ ์•Š์œผ๋ฉด ์ผœ ๋‘์„ธ์š”.", + "SettingsEnableMacroHLETooltip": "GPU ๋งคํฌ๋กœ ์ฝ”๋“œ์˜ ๊ณ ์ˆ˜์ค€ ์—๋ฎฌ๋ ˆ์ด์…˜์ž…๋‹ˆ๋‹ค.\n\n์„ฑ๋Šฅ์€ ํ–ฅ์ƒ๋˜์ง€๋งŒ ์ผ๋ถ€ ๊ฒŒ์ž„์—์„œ ๊ทธ๋ž˜ํ”ฝ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.\n\n๋ชจ๋ฅด๋ฉด ์ผฌ์œผ๋กœ ๋‘์„ธ์š”.", "SettingsEnableColorSpacePassthrough": "์ƒ‰ ๊ณต๊ฐ„ ํ†ต๊ณผ", - "SettingsEnableColorSpacePassthroughTooltip": "์ƒ‰ ๊ณต๊ฐ„์„ ์ง€์ •ํ•˜์ง€ ์•Š๊ณ  ์ƒ‰์ƒ ์ •๋ณด๋ฅผ ์ „๋‹ฌํ•˜๋„๋ก Vulkan ํ›„๋‹จ์— ์ง€์‹œํ•ฉ๋‹ˆ๋‹ค. ์™€์ด๋“œ ๊ฐ€๋ฉ‹ ๋””์Šคํ”Œ๋ ˆ์ด๋ฅผ ์‚ฌ์šฉํ•˜๋Š” ์‚ฌ์šฉ์ž์˜ ๊ฒฝ์šฐ ์ƒ‰ ์ •ํ™•๋„๊ฐ€ ์ €ํ•˜๋˜์ง€๋งŒ ๋” ์ƒ์ƒํ•œ ์ƒ‰์ƒ์„ ์–ป์„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.", + "SettingsEnableColorSpacePassthroughTooltip": "Vulkan ํ›„๋‹จ๋ถ€๊ฐ€ ์ƒ‰ ๊ณต๊ฐ„์„ ์ง€์ •ํ•˜์ง€ ์•Š๊ณ  ์ƒ‰์ƒ ์ •๋ณด๋ฅผ ์ „๋‹ฌํ•˜๋„๋ก ์ง€์‹œํ•ฉ๋‹ˆ๋‹ค. ๋„“์€ ์ƒ‰์—ญ ํ™”๋ฉด ํ‘œ์‹œ ์žฅ์น˜๋ฅผ ์‚ฌ์šฉํ•˜๋Š” ์‚ฌ์šฉ์ž์˜ ๊ฒฝ์šฐ ์ƒ‰์ƒ ์ •ํ™•์„ฑ์„ ํฌ์ƒํ•˜๊ณ  ๋” ์ƒ์ƒํ•œ ์ƒ‰์ƒ์ด ๋‚˜์˜ฌ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.", "VolumeShort": "์Œ๋Ÿ‰", "UserProfilesManageSaves": "์ €์žฅ ๊ด€๋ฆฌ", - "DeleteUserSave": "์ด ๊ฒŒ์ž„์— ๋Œ€ํ•œ ์‚ฌ์šฉ์ž ์ €์žฅ์„ ์‚ญ์ œํ•˜๊ฒ ์Šต๋‹ˆ๊นŒ?", + "DeleteUserSave": "์ด ๊ฒŒ์ž„์˜ ์‚ฌ์šฉ์ž ์ €์žฅ์„ ์‚ญ์ œํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?", "IrreversibleActionNote": "์ด ์ž‘์—…์€ ๋˜๋Œ๋ฆด ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.", - "SaveManagerHeading": "{0} ({1})์˜ ์ €์žฅ ๊ด€๋ฆฌ", - "SaveManagerTitle": "์ €์žฅ ๊ด€๋ฆฌ์ž", + "SaveManagerHeading": "{0} ({1})์— ๋Œ€ํ•œ ์ €์žฅ ๊ด€๋ฆฌ", + "SaveManagerTitle": "๊ด€๋ฆฌ์ž ์ €์žฅ", "Name": "์ด๋ฆ„", "Size": "ํฌ๊ธฐ", - "Search": "๊ฒ€์ƒ‰", + "Search": "์ฐพ๊ธฐ", "UserProfilesRecoverLostAccounts": "์žƒ์–ด๋ฒ„๋ฆฐ ๊ณ„์ • ๋ณต๊ตฌ", "Recover": "๋ณต๊ตฌ", "UserProfilesRecoverHeading": "๋‹ค์Œ ๊ณ„์ •์— ๋Œ€ํ•œ ์ €์žฅ ๋ฐœ๊ฒฌ", - "UserProfilesRecoverEmptyList": "๋ณต๊ตฌํ•  ํ”„๋กœํŒŒ์ผ์ด ์—†์Šต๋‹ˆ๋‹ค", - "GraphicsAATooltip": "๊ฒŒ์ž„ ๋ Œ๋”์— ์•ˆํ‹ฐ ์•จ๋ฆฌ์–ด์‹ฑ์„ ์ ์šฉํ•ฉ๋‹ˆ๋‹ค.\n\nFXAA๋Š” ๋Œ€๋ถ€๋ถ„์˜ ์ด๋ฏธ์ง€๋ฅผ ๋ฟŒ์˜‡๊ฒŒ ๋งŒ๋“ค์ง€๋งŒ, SMAA๋Š” ๋“ค์ญ‰๋‚ ์ญ‰ํ•œ ๋ชจ์„œ๋ฆฌ ๋ถ€๋ถ„๋“ค์„ ์ฐพ์•„ ๋ถ€๋“œ๋Ÿฝ๊ฒŒ ๋งŒ๋“ญ๋‹ˆ๋‹ค.\n\nFSR ์Šค์ผ€์ผ๋ง ํ•„ํ„ฐ์™€ ๊ฐ™์ด ์‚ฌ์šฉํ•˜๋Š” ๊ฒƒ์€ ๊ถŒ์žฅํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.\n\n์ด ์˜ต์…˜์€ ๊ฒŒ์ž„์ด ๊ตฌ๋™์ค‘์ผ ๋•Œ์—๋„ ์•„๋ž˜ Apply ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ์„œ ๋ณ€๊ฒฝํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค; ์„ค์ • ์ฐฝ์„ ๊ฒŒ์ž„ ์ฐฝ ์˜†์— ๋‘๊ณ  ์‚ฌ์šฉ์ž๊ฐ€ ์„ ํ˜ธํ•˜๋Š” ์˜ต์…˜์„ ์‹คํ—˜ํ•˜์—ฌ ๊ณ ๋ฅผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.\n\n์ด ์˜ต์…˜์— ๋Œ€ํ•ด ์ž˜ ๋ชจ๋ฅธ๋‹ค๋ฉด ๋„๊ธฐ๋ฅผ ๊ถŒ์žฅ๋“œ๋ฆฝ๋‹ˆ๋‹ค.", - "GraphicsAALabel": "์•ˆํ‹ฐ ์•จ๋ฆฌ์–ด์‹ฑ:", - "GraphicsScalingFilterLabel": "์Šค์ผ€์ผ๋ง ํ•„ํ„ฐ:", - "GraphicsScalingFilterTooltip": "ํ•ด์ƒ๋„ ์Šค์ผ€์ผ์— ์‚ฌ์šฉ๋  ์Šค์ผ€์ผ๋ง ํ•„ํ„ฐ๋ฅผ ์„ ํƒํ•˜์„ธ์š”.\n\nBilinear๋Š” 3D ๊ฒŒ์ž„์—์„œ ์ž˜ ์ž‘๋™ํ•˜๋ฉฐ ์•ˆ์ „ํ•œ ๊ธฐ๋ณธ๊ฐ’์ž…๋‹ˆ๋‹ค.\n\nNearest๋Š” ํ”ฝ์…€ ์•„ํŠธ ๊ฒŒ์ž„์— ์ถ”์ฒœํ•ฉ๋‹ˆ๋‹ค.\n\nFSR 1.0์€ ๊ทธ์ € ์ƒคํ”„๋‹ ํ•„ํ„ฐ์ž„์œผ๋กœ, FXAA๋‚˜ SMAA์™€ ๊ฐ™์ด ์‚ฌ์šฉํ•˜๋Š” ๊ฒƒ์€ ๊ถŒ์žฅํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.\n\n์ด ์˜ต์…˜์€ ๊ฒŒ์ž„์ด ๊ตฌ๋™์ค‘์ผ ๋•Œ์—๋„ ์•„๋ž˜ Apply ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ์„œ ๋ณ€๊ฒฝํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค; ์„ค์ • ์ฐฝ์„ ๊ฒŒ์ž„ ์ฐฝ ์˜†์— ๋‘๊ณ  ์‚ฌ์šฉ์ž๊ฐ€ ์„ ํ˜ธํ•˜๋Š” ์˜ต์…˜์„ ์‹คํ—˜ํ•˜์—ฌ ๊ณ ๋ฅผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.\n\n์ด ์˜ต์…˜์— ๋Œ€ํ•ด ์ž˜ ๋ชจ๋ฅธ๋‹ค๋ฉด BILINEAR๋กœ ๋‘์„ธ์š”.", - "GraphicsScalingFilterBilinear": "Bilinear", - "GraphicsScalingFilterNearest": "Nearest", + "UserProfilesRecoverEmptyList": "๋ณต๊ตฌํ•  ํ”„๋กœํ•„ ์—†์Œ", + "GraphicsAATooltip": "๊ฒŒ์ž„ ๋ Œ๋”์— ์•คํ‹ฐ ์•จ๋ฆฌ์–ด์‹ฑ์„ ์ ์šฉํ•ฉ๋‹ˆ๋‹ค.\n\nFXAA๋Š” ์ด๋ฏธ์ง€ ๋Œ€๋ถ€๋ถ„์„ ํ๋ฆฌ๊ฒŒ ์ฒ˜๋ฆฌํ•˜์ง€๋งŒ SMAA๋Š” ๋“ค์ญ‰๋‚ ์ญ‰ํ•œ ๊ฐ€์žฅ์ž๋ฆฌ๋ฅผ ์ฐพ์•„ ๋ถ€๋“œ๋Ÿฝ๊ฒŒ ์ฒ˜๋ฆฌํ•ฉ๋‹ˆ๋‹ค.\n\nFSR ์Šค์ผ€์ผ๋ง ํ•„ํ„ฐ์™€ ํ•จ๊ป˜ ์‚ฌ์šฉํ•˜์ง€ ์•Š๋Š” ๊ฒƒ์ด ์ข‹์Šต๋‹ˆ๋‹ค.\n\n์ด ์˜ต์…˜์€ ์•„๋ž˜์˜ \"์ ์šฉ\"์„ ํด๋ฆญํ•˜์—ฌ ๊ฒŒ์ž„์„ ์‹คํ–‰ํ•˜๋Š” ๋™์•ˆ ๋ณ€๊ฒฝํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์„ค์ • ์ฐฝ์„ ์˜†์œผ๋กœ ์˜ฎ๊ฒจ ์›ํ•˜๋Š” ๊ฒŒ์ž„์˜ ๋ชจ์Šต์„ ์ฐพ์„ ๋•Œ๊นŒ์ง€ ์‹คํ—˜ํ•ด ๋ณผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.\n\n๋ชจ๋ฅด๋ฉด ์—†์Œ์œผ๋กœ ๋‘์„ธ์š”.", + "GraphicsAALabel": "์•คํ‹ฐ ์•จ๋ฆฌ์–ด์‹ฑ :", + "GraphicsScalingFilterLabel": "ํฌ๊ธฐ ์กฐ์ • ํ•„ํ„ฐ :", + "GraphicsScalingFilterTooltip": "ํ•ด์ƒ๋„ ์Šค์ผ€์ผ์„ ์‚ฌ์šฉํ•  ๋•Œ ์ ์šฉ๋  ์Šค์ผ€์ผ๋ง ํ•„ํ„ฐ๋ฅผ ์„ ํƒํ•ฉ๋‹ˆ๋‹ค.\n\n์Œ์„ ํ˜•์€ 3D ๊ฒŒ์ž„์— ์ ํ•ฉํ•˜๋ฉฐ ์•ˆ์ „ํ•œ ๊ธฐ๋ณธ ์˜ต์…˜์ž…๋‹ˆ๋‹ค.\n\nNearest๋Š” ํ”ฝ์…€ ์•„ํŠธ ๊ฒŒ์ž„์— ๊ถŒ์žฅ๋ฉ๋‹ˆ๋‹ค.\n\nFSR 1.0์€ ๋‹จ์ˆœํžˆ ์„ ๋ช…๋„ ํ•„ํ„ฐ์ผ ๋ฟ์ด๋ฉฐ FXAA ๋˜๋Š” SMAA์™€ ํ•จ๊ป˜ ์‚ฌ์šฉํ•˜๋Š” ๊ฒƒ์€ ๊ถŒ์žฅ๋˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.\n\nArea ์Šค์ผ€์ผ๋ง์€ ์ถœ๋ ฅ ์ฐฝ๋ณด๋‹ค ํฐ ํ•ด์ƒ๋„๋ฅผ ๋‹ค์šด์Šค์ผ€์ผ๋งํ•  ๋•Œ ๊ถŒ์žฅ๋ฉ๋‹ˆ๋‹ค. 2๋ฐฐ ์ด์ƒ ๋‹ค์šด์Šค์ผ€์ผ๋งํ•  ๋•Œ ์Šˆํผ์ƒ˜ํ”Œ๋ง๋œ ์•คํ‹ฐ์•จ๋ฆฌ์–ด์‹ฑ ํšจ๊ณผ๋ฅผ ์–ป๋Š” ๋ฐ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.\n\n์ด ์˜ต์…˜์€ ์•„๋ž˜์˜ \"์ ์šฉ\"์„ ํด๋ฆญํ•˜์—ฌ ๊ฒŒ์ž„์„ ์‹คํ–‰ํ•˜๋Š” ๋™์•ˆ ๋ณ€๊ฒฝํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์„ค์ • ์ฐฝ์„ ์˜†์œผ๋กœ ์˜ฎ๊ฒจ ์›ํ•˜๋Š” ๊ฒŒ์ž„ ๋ชจ์–‘์„ ์ฐพ์„ ๋•Œ๊นŒ์ง€ ์‹คํ—˜ํ•˜๋ฉด ๋ฉ๋‹ˆ๋‹ค.\n\n๋ชจ๋ฅด๋ฉด ์Œ์„ ํ˜•์„ ๊ทธ๋Œ€๋กœ ๋‘์„ธ์š”.", + "GraphicsScalingFilterBilinear": "์Œ์„ ํ˜•", + "GraphicsScalingFilterNearest": "๊ทผ๋ฆฐ", "GraphicsScalingFilterFsr": "FSR", - "GraphicsScalingFilterArea": "Area", - "GraphicsScalingFilterLevelLabel": "์ˆ˜์ค€", - "GraphicsScalingFilterLevelTooltip": "FSR 1.0์˜ ์ƒคํ”„๋‹ ๋ ˆ๋ฒจ์„ ์„ค์ •ํ•˜์„ธ์š”. ๋†’์„์ˆ˜๋ก ๋” ๋˜๋ ทํ•ด์ง‘๋‹ˆ๋‹ค.", + "GraphicsScalingFilterArea": "์˜์—ญ", + "GraphicsScalingFilterLevelLabel": "๋ ˆ๋ฒจ", + "GraphicsScalingFilterLevelTooltip": "FSR 1.0 ์„ ๋ช…๋„ ๋ ˆ๋ฒจ์„ ์„ค์ •ํ•ฉ๋‹ˆ๋‹ค. ๋†’์„์ˆ˜๋ก ๋” ์„ ๋ช…ํ•ฉ๋‹ˆ๋‹ค.", "SmaaLow": "SMAA ๋‚ฎ์Œ", "SmaaMedium": "SMAA ์ค‘๊ฐ„", "SmaaHigh": "SMAA ๋†’์Œ", "SmaaUltra": "SMAA ์šธํŠธ๋ผ", - "UserEditorTitle": "์‚ฌ์šฉ์ž ์ˆ˜์ •", - "UserEditorTitleCreate": "์‚ฌ์šฉ์ž ์ƒ์„ฑ", + "UserEditorTitle": "์‚ฌ์šฉ์ž ํŽธ์ง‘", + "UserEditorTitleCreate": "์‚ฌ์šฉ์ž ๋งŒ๋“ค๊ธฐ", "SettingsTabNetworkInterface": "๋„คํŠธ์›Œํฌ ์ธํ„ฐํŽ˜์ด์Šค:", - "NetworkInterfaceTooltip": "LAN/LDN ๊ธฐ๋Šฅ์— ์‚ฌ์šฉ๋  ๋„คํŠธ์›Œํฌ ์ธํ„ฐํŽ˜์ด์Šค์ž…๋‹ˆ๋‹ค.\n\nLAN ๊ธฐ๋Šฅ์„ ์ง€์›ํ•˜๋Š” ๊ฒŒ์ž„์—์„œ VPN์ด๋‚˜ XLink Kai ๋“ฑ์„ ๋™์‹œ์— ์‚ฌ์šฉํ•˜๋ฉด, ์ธํ„ฐ๋„ท์„ ํ†ตํ•ด ๋™์ผ ๋„คํŠธ์›Œํฌ ์—ฐ๊ฒฐ์ธ ๊ฒƒ์„ ์†์ผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.\n\n์ด ์˜ต์…˜์— ๋Œ€ํ•ด ์ž˜ ๋ชจ๋ฅธ๋‹ค๋ฉด ๊ธฐ๋ณธ๊ฐ’์œผ๋กœ ์„ค์ •ํ•˜์„ธ์š”.", - "NetworkInterfaceDefault": "๊ธฐ๋ณธ", - "PackagingShaders": "์…ฐ์ด๋” ํŒจํ‚ค์ง• ์ค‘", - "AboutChangelogButton": "GitHub์—์„œ ๋ณ€๊ฒฝ ๋กœ๊ทธ ๋ณด๊ธฐ", - "AboutChangelogButtonTooltipMessage": "๊ธฐ๋ณธ ๋ธŒ๋ผ์šฐ์ €์—์„œ ์ด ๋ฒ„์ „์˜ ๋ณ€๊ฒฝ ๋กœ๊ทธ๋ฅผ ์—ด๋ ค๋ฉด ํด๋ฆญํ•ฉ๋‹ˆ๋‹ค.", - "SettingsTabNetworkMultiplayer": "๋ฉ€ํ‹ฐ ํ”Œ๋ ˆ์ด์–ด", + "NetworkInterfaceTooltip": "LAN/LDN ๊ธฐ๋Šฅ์— ์‚ฌ์šฉ๋˜๋Š” ๋„คํŠธ์›Œํฌ ์ธํ„ฐํŽ˜์ด์Šค์ž…๋‹ˆ๋‹ค.\n\nVPN์ด๋‚˜ โ€‹โ€‹XLink Kai์™€ LAN ์ง€์› ๊ฒŒ์ž„๊ณผ ํ•จ๊ป˜ ์‚ฌ์šฉํ•˜๋ฉด ์ธํ„ฐ๋„ท์„ ํ†ตํ•œ ๋™์ผ ๋„คํŠธ์›Œํฌ ์—ฐ๊ฒฐ์„ ์Šคํ‘ธํ•‘ํ•˜๋Š” ๋ฐ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.\n\n๋ชจ๋ฅด๋ฉด ๊ธฐ๋ณธ๊ฐ’์œผ๋กœ ๋‘์„ธ์š”.", + "NetworkInterfaceDefault": "๊ธฐ๋ณธ๊ฐ’", + "PackagingShaders": "ํŒจํ‚ค์ง• ์…ฐ์ด๋”", + "AboutChangelogButton": "GitHub์—์„œ ๋ณ€๊ฒฝ ๋‚ด์—ญ ๋ณด๊ธฐ", + "AboutChangelogButtonTooltipMessage": "๊ธฐ๋ณธ ๋ธŒ๋ผ์šฐ์ €์—์„œ ์ด ๋ฒ„์ „์˜ ๋ณ€๊ฒฝ ๋‚ด์—ญ์„ ์—ด๋žŒํ•˜๋ ค๋ฉด ํด๋ฆญํ•˜์„ธ์š”.", + "SettingsTabNetworkMultiplayer": "๋ฉ€ํ‹ฐํ”Œ๋ ˆ์ด์–ด", "MultiplayerMode": "๋ชจ๋“œ :", - "MultiplayerModeTooltip": "LDN ๋ฉ€ํ‹ฐํ”Œ๋ ˆ์ด์–ด ๋ชจ๋“œ๋ฅผ ๋ณ€๊ฒฝํ•ฉ๋‹ˆ๋‹ค.\n\nLdnMitm์€ ๋กœ์ปฌ ๋ฌด์„ /๋กœ์ปฌ ํ”Œ๋ ˆ์ด ๊ธฐ๋Šฅ์„ ์ˆ˜์ •ํ•˜์—ฌ LAN ๋ชจ๋“œ์— ์žˆ๋Š” ๊ฒƒ์ฒ˜๋Ÿผ ๋งŒ๋“ค์–ด ๋กœ์ปฌ์ด๋‚˜ ๋™์ผํ•œ ๋„คํŠธ์›Œํฌ ์ƒ์— ์žˆ๋Š” ๋‹ค๋ฅธ Ryujinx ์ธ์Šคํ„ด์Šค๋‚˜ ์ปคํŽŒ๋œ ๋‹Œํ…๋„ ์Šค์œ„์น˜ ์ฝ˜์†”(ldn_mitm ๋ชจ๋“ˆ ์„ค์น˜ ํ•„์š”)๊ณผ ์—ฐ๊ฒฐํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.\n\n๋ฉ€ํ‹ฐํ”Œ๋ ˆ์ด์–ด ๋ชจ๋“œ๋Š” ๋ชจ๋“  ํ”Œ๋ ˆ์ด์–ด๋“ค์ด ๋™์ผํ•œ ๊ฒŒ์ž„ ๋ฒ„์ „์„ ์š”๊ตฌํ•ฉ๋‹ˆ๋‹ค. ์˜ˆ๋ฅผ ๋“ค์–ด ์Šˆํผ ์Šค๋งค์‹œ๋ธŒ๋ผ๋”์Šค ์–ผํ‹ฐ๋ฐ‹ v13.0.1 ์‚ฌ์šฉ์ž๋Š” v13.0.0 ์‚ฌ์šฉ์ž์™€ ์—ฐ๊ฒฐํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.\n\nํ•ด๋‹น ์˜ต์…˜์— ๋Œ€ํ•ด ์ž˜ ๋ชจ๋ฅธ๋‹ค๋ฉด ๋น„ํ™œ์„ฑํ™”ํ•ด๋‘์„ธ์š”.", + "MultiplayerModeTooltip": "LDN ๋ฉ€ํ‹ฐํ”Œ๋ ˆ์ด์–ด ๋ชจ๋“œ๋ฅผ ๋ณ€๊ฒฝํ•ฉ๋‹ˆ๋‹ค.\n\nLdnMitm์€ ๊ฒŒ์ž„์˜ ๋กœ์ปฌ ๋ฌด์„ /๋กœ์ปฌ ํ”Œ๋ ˆ์ด ๊ธฐ๋Šฅ์„ LAN์ฒ˜๋Ÿผ ์ž‘๋™ํ•˜๋„๋ก ์ˆ˜์ •ํ•˜์—ฌ ๋‹ค๋ฅธ Ryujinx ์ธ์Šคํ„ด์Šค๋‚˜ ldn_mitm ๋ชจ๋“ˆ์ด ์„ค์น˜๋œ ํ•ดํ‚น๋œ Nintendo Switch ์ฝ˜์†”๊ณผ ๋กœ์ปฌ, ๋™์ผ ๋„คํŠธ์›Œํฌ ์—ฐ๊ฒฐ์ด ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค.\n\n๋ฉ€ํ‹ฐํ”Œ๋ ˆ์ด์–ด๋Š” ๋ชจ๋“  ํ”Œ๋ ˆ์ด์–ด๊ฐ€ ๋™์ผํ•œ ๊ฒŒ์ž„ ๋ฒ„์ „์„ ์‚ฌ์šฉํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค(์˜ˆ: Super Smash Bros. Ultimate v13.0.1์€ v13.0.0์— ์—ฐ๊ฒฐํ•  ์ˆ˜ ์—†์Œ).\n\n๋ชจ๋ฅด๋ฉด ๋น„ํ™œ์„ฑํ™” ์ƒํƒœ๋กœ ๋‘์„ธ์š”.", "MultiplayerModeDisabled": "๋น„ํ™œ์„ฑํ™”๋จ", - "MultiplayerModeLdnMitm": "ldn_mitm" -} + "MultiplayerModeLdnMitm": "ldn_mitm", + "MultiplayerModeLdnRyu": "RyuLDN", + "MultiplayerDisableP2P": "P2P ๋„คํŠธ์›Œํฌ ํ˜ธ์ŠคํŒ… ๋น„ํ™œ์„ฑํ™”(๋Œ€๊ธฐ ์‹œ๊ฐ„์ด ๋Š˜์–ด๋‚  ์ˆ˜ ์žˆ์Œ)", + "MultiplayerDisableP2PTooltip": "P2P ๋„คํŠธ์›Œํฌ ํ˜ธ์ŠคํŒ…์„ ๋น„ํ™œ์„ฑํ™”ํ•˜๋ฉด ํ”ผ์–ด๊ฐ€ ์ง์ ‘ ์—ฐ๊ฒฐํ•˜์ง€ ์•Š๊ณ  ๋งˆ์Šคํ„ฐ ์„œ๋ฒ„๋ฅผ ํ†ตํ•ด ํ”„๋ก์‹œํ•ฉ๋‹ˆ๋‹ค.", + "LdnPassphrase": "๋„คํŠธ์›Œํฌ ์•”ํ˜ธ ๋ฌธ๊ตฌ :", + "LdnPassphraseTooltip": "๊ท€ํ•˜๋Š” ๊ท€ํ•˜์™€ ๋™์ผํ•œ ์•”ํ˜ธ๋ฅผ ์‚ฌ์šฉํ•˜๋Š” ํ˜ธ์ŠคํŒ… ๊ฒŒ์ž„๋งŒ ๋ณผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.", + "LdnPassphraseInputTooltip": "Ryujinx-<8 hex chars> ํ˜•์‹์œผ๋กœ ์•”ํ˜ธ๋ฅผ ์ž…๋ ฅํ•˜์„ธ์š”. ๊ท€ํ•˜๋Š” ๊ท€ํ•˜์™€ ๋™์ผํ•œ ์•”ํ˜ธ๋ฅผ ์‚ฌ์šฉํ•˜๋Š” ํ˜ธ์ŠคํŒ… ๊ฒŒ์ž„๋งŒ ๋ณผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.", + "LdnPassphraseInputPublic": "(์ผ๋ฐ˜)", + "GenLdnPass": "๋ฌด์ž‘์œ„ ์ƒ์„ฑ", + "GenLdnPassTooltip": "๋‹ค๋ฅธ ํ”Œ๋ ˆ์ด์–ด์™€ ๊ณต์œ ํ•  ์ˆ˜ ์žˆ๋Š” ์ƒˆ๋กœ์šด ์•”ํ˜ธ ๋ฌธ๊ตฌ๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค.", + "ClearLdnPass": "์ง€์šฐ๊ธฐ", + "ClearLdnPassTooltip": "ํ˜„์žฌ ์•”ํ˜ธ๋ฅผ ์ง€์šฐ๊ณ  ๊ณต์šฉ ๋„คํŠธ์›Œํฌ๋กœ ๋Œ์•„๊ฐ‘๋‹ˆ๋‹ค.", + "InvalidLdnPassphrase": "์œ ํšจํ•˜์ง€ ์•Š์€ ์•”ํ˜ธ์ž…๋‹ˆ๋‹ค! \"Ryujinx-<8 hex chars>\" ํ˜•์‹์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค." + } diff --git a/src/Ryujinx/Common/LocaleManager.cs b/src/Ryujinx/Common/LocaleManager.cs index 3247a55f8..b57caa468 100644 --- a/src/Ryujinx/Common/LocaleManager.cs +++ b/src/Ryujinx/Common/LocaleManager.cs @@ -99,7 +99,7 @@ namespace Ryujinx.Ava.Common.Locale _ => false }; - public static string FormatDynamicValue(LocaleKeys key, params object[] values) + public static string FormatDynamicValue(LocaleKeys key, params object[] values) => Instance.UpdateAndGetDynamicValue(key, values); public string UpdateAndGetDynamicValue(LocaleKeys key, params object[] values) diff --git a/src/Ryujinx/Program.cs b/src/Ryujinx/Program.cs index 76512a34a..05fd66b90 100644 --- a/src/Ryujinx/Program.cs +++ b/src/Ryujinx/Program.cs @@ -103,7 +103,7 @@ namespace Ryujinx.Ava Console.Title = $"{App.FullAppName} Console {Version}"; // Hook unhandled exception and process exit events. - AppDomain.CurrentDomain.UnhandledException += (sender, e) + AppDomain.CurrentDomain.UnhandledException += (sender, e) => ProcessUnhandledException(sender, e.ExceptionObject as Exception, e.IsTerminating); AppDomain.CurrentDomain.ProcessExit += (_, _) => Exit(); @@ -224,16 +224,14 @@ namespace Ryujinx.Ava private static void PrintSystemInfo() { - Logger.Notice.Print(LogClass.Application, $"Ryujinx Version: {Version}"); + Logger.Notice.Print(LogClass.Application, $"{App.FullAppName} Version: {Version}"); SystemInfo.Gather().Print(); var enabledLogLevels = Logger.GetEnabledLevels().ToArray(); - Logger.Notice.Print(LogClass.Application, $"Logs Enabled: { - (enabledLogLevels.Length is 0 + Logger.Notice.Print(LogClass.Application, $"Logs Enabled: {(enabledLogLevels.Length is 0 ? "" - : enabledLogLevels.JoinToString(", ")) - }"); + : enabledLogLevels.JoinToString(", "))}"); Logger.Notice.Print(LogClass.Application, AppDataManager.Mode == AppDataManager.LaunchMode.Custom @@ -245,13 +243,13 @@ namespace Ryujinx.Ava { Logger.Log log = Logger.Error ?? Logger.Notice; string message = $"Unhandled exception caught: {ex}"; - + // ReSharper disable once ConstantConditionalAccessQualifier - if (sender?.GetType()?.AsPrettyString() is {} senderName) + if (sender?.GetType()?.AsPrettyString() is { } senderName) log.Print(LogClass.Application, message, senderName); else log.PrintMsg(LogClass.Application, message); - + if (isTerminating) Exit(); } diff --git a/src/Ryujinx/UI/Applet/AvaHostUIHandler.cs b/src/Ryujinx/UI/Applet/AvaHostUIHandler.cs index 1dbf37255..2ebba7ac0 100644 --- a/src/Ryujinx/UI/Applet/AvaHostUIHandler.cs +++ b/src/Ryujinx/UI/Applet/AvaHostUIHandler.cs @@ -31,7 +31,7 @@ namespace Ryujinx.Ava.UI.Applet public bool DisplayMessageDialog(ControllerAppletUIArgs args) { ManualResetEvent dialogCloseEvent = new(false); - + bool okPressed = false; if (ConfigurationState.Instance.IgnoreApplet) diff --git a/src/Ryujinx/UI/Applet/AvaloniaDynamicTextInputHandler.cs b/src/Ryujinx/UI/Applet/AvaloniaDynamicTextInputHandler.cs index 5ec7737ed..0cd3f18e5 100644 --- a/src/Ryujinx/UI/Applet/AvaloniaDynamicTextInputHandler.cs +++ b/src/Ryujinx/UI/Applet/AvaloniaDynamicTextInputHandler.cs @@ -24,7 +24,7 @@ namespace Ryujinx.Ava.UI.Applet public AvaloniaDynamicTextInputHandler(MainWindow parent) { _parent = parent; - + if (_parent.InputManager.KeyboardDriver is AvaloniaKeyboardDriver avaloniaKeyboardDriver) { avaloniaKeyboardDriver.KeyPressed += AvaloniaDynamicTextInputHandler_KeyPressed; @@ -121,7 +121,7 @@ namespace Ryujinx.Ava.UI.Applet avaloniaKeyboardDriver.KeyRelease -= AvaloniaDynamicTextInputHandler_KeyRelease; avaloniaKeyboardDriver.TextInput -= AvaloniaDynamicTextInputHandler_TextInput; } - + _textChangedSubscription?.Dispose(); _selectionStartChangedSubscription?.Dispose(); _selectionEndtextChangedSubscription?.Dispose(); diff --git a/src/Ryujinx/UI/Applet/ControllerAppletDialog.axaml.cs b/src/Ryujinx/UI/Applet/ControllerAppletDialog.axaml.cs index 3a8350893..ee0e884d2 100644 --- a/src/Ryujinx/UI/Applet/ControllerAppletDialog.axaml.cs +++ b/src/Ryujinx/UI/Applet/ControllerAppletDialog.axaml.cs @@ -37,8 +37,8 @@ namespace Ryujinx.Ava.UI.Applet public ControllerAppletDialog(MainWindow mainWindow, ControllerAppletUIArgs args) { - PlayerCount = args.PlayerCountMin == args.PlayerCountMax - ? args.PlayerCountMin.ToString() + PlayerCount = args.PlayerCountMin == args.PlayerCountMax + ? args.PlayerCountMin.ToString() : $"{args.PlayerCountMin} - {args.PlayerCountMax}"; SupportsProController = (args.SupportedStyles & ControllerType.ProController) != 0; diff --git a/src/Ryujinx/UI/Controls/ApplicationListView.axaml b/src/Ryujinx/UI/Controls/ApplicationListView.axaml index ea748a3bf..0daa77ac4 100644 --- a/src/Ryujinx/UI/Controls/ApplicationListView.axaml +++ b/src/Ryujinx/UI/Controls/ApplicationListView.axaml @@ -7,6 +7,7 @@ xmlns:helpers="clr-namespace:Ryujinx.Ava.UI.Helpers" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:ui="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia" + xmlns:converters="clr-namespace:Avalonia.Data.Converters;assembly=Avalonia.Base" d:DesignHeight="450" d:DesignWidth="800" Focusable="True" @@ -110,6 +111,11 @@ Text="{Binding FileExtension}" TextAlignment="Start" TextWrapping="Wrap" /> + UserFirmwareAvatarSelectorViewModel.PreloadAvatars(contentManager, virtualFileSystem)); - + InitializeComponent(); } @@ -60,13 +60,13 @@ namespace Ryujinx.Ava.UI.Controls LoadProfiles(); } - public void Navigate(Type sourcePageType, object parameter) + public void Navigate(Type sourcePageType, object parameter) => ContentFrame.Navigate(sourcePageType, parameter); public static async Task Show( - AccountManager ownerAccountManager, + AccountManager ownerAccountManager, ContentManager ownerContentManager, - VirtualFileSystem ownerVirtualFileSystem, + VirtualFileSystem ownerVirtualFileSystem, HorizonClient ownerHorizonClient) { var content = new NavigationDialogHost(ownerAccountManager, ownerContentManager, ownerVirtualFileSystem, ownerHorizonClient); @@ -158,9 +158,9 @@ namespace Ryujinx.Ava.UI.Controls _ = Dispatcher.UIThread.InvokeAsync(async () => await ContentDialogHelper.CreateErrorDialog( LocaleManager.Instance[LocaleKeys.DialogUserProfileDeletionWarningMessage])); - + return; - } + } AccountManager.OpenUser(profile.UserId); } diff --git a/src/Ryujinx/UI/Helpers/GlyphValueConverter.cs b/src/Ryujinx/UI/Helpers/GlyphValueConverter.cs index 94c3ab35d..6196421c8 100644 --- a/src/Ryujinx/UI/Helpers/GlyphValueConverter.cs +++ b/src/Ryujinx/UI/Helpers/GlyphValueConverter.cs @@ -22,9 +22,9 @@ namespace Ryujinx.Ava.UI.Helpers _key = key; } - public string this[string key] => + public string this[string key] => _glyphs.TryGetValue(Enum.Parse(key), out var val) - ? val + ? val : string.Empty; public override object ProvideValue(IServiceProvider serviceProvider) => this[_key]; diff --git a/src/Ryujinx/UI/Helpers/MultiplayerInfoConverter.cs b/src/Ryujinx/UI/Helpers/MultiplayerInfoConverter.cs new file mode 100644 index 000000000..8bd8b5f0d --- /dev/null +++ b/src/Ryujinx/UI/Helpers/MultiplayerInfoConverter.cs @@ -0,0 +1,44 @@ +using Avalonia.Data.Converters; +using Avalonia.Markup.Xaml; +using Ryujinx.Ava.Common.Locale; +using Ryujinx.UI.App.Common; +using Ryujinx.UI.Common.Helper; +using System; +using System.Globalization; + +namespace Ryujinx.Ava.UI.Helpers +{ + internal class MultiplayerInfoConverter : MarkupExtension, IValueConverter + { + private static readonly MultiplayerInfoConverter _instance = new(); + + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is ApplicationData applicationData) + { + if (applicationData.PlayerCount != 0 && applicationData.GameCount != 0) + { + return $"Hosted Games: {applicationData.GameCount}\nOnline Players: {applicationData.PlayerCount}"; + } + else + { + return ""; + } + } + else + { + return ""; + } + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotSupportedException(); + } + + public override object ProvideValue(IServiceProvider serviceProvider) + { + return _instance; + } + } +} diff --git a/src/Ryujinx/UI/Helpers/TimeZoneConverter.cs b/src/Ryujinx/UI/Helpers/TimeZoneConverter.cs index 5edc6482e..0e5525c7b 100644 --- a/src/Ryujinx/UI/Helpers/TimeZoneConverter.cs +++ b/src/Ryujinx/UI/Helpers/TimeZoneConverter.cs @@ -9,12 +9,12 @@ namespace Ryujinx.Ava.UI.Helpers { public static TimeZoneConverter Instance = new(); - public object Convert(object value, Type targetType, object parameter, CultureInfo culture) - => value is TimeZone timeZone - ? $"{timeZone.UtcDifference} {timeZone.Location} {timeZone.Abbreviation}" + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + => value is TimeZone timeZone + ? $"{timeZone.UtcDifference} {timeZone.Location} {timeZone.Abbreviation}" : null; - public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) => throw new NotImplementedException(); } } diff --git a/src/Ryujinx/UI/Models/StatusUpdatedEventArgs.cs b/src/Ryujinx/UI/Models/StatusUpdatedEventArgs.cs index f12cf0aa6..40f783c44 100644 --- a/src/Ryujinx/UI/Models/StatusUpdatedEventArgs.cs +++ b/src/Ryujinx/UI/Models/StatusUpdatedEventArgs.cs @@ -10,7 +10,7 @@ namespace Ryujinx.Ava.UI.Models public string DockedMode { get; } public string FifoStatus { get; } public string GameStatus { get; } - + public uint ShaderCount { get; } public StatusUpdatedEventArgs(bool vSyncEnabled, string volumeStatus, string dockedMode, string aspectRatio, string gameStatus, string fifoStatus, uint shaderCount) diff --git a/src/Ryujinx/UI/ViewModels/MainWindowViewModel.cs b/src/Ryujinx/UI/ViewModels/MainWindowViewModel.cs index 437f9861d..53263847b 100644 --- a/src/Ryujinx/UI/ViewModels/MainWindowViewModel.cs +++ b/src/Ryujinx/UI/ViewModels/MainWindowViewModel.cs @@ -117,6 +117,8 @@ namespace Ryujinx.Ava.UI.ViewModels public ApplicationData ListSelectedApplication; public ApplicationData GridSelectedApplication; + public IEnumerable LastLdnGameData; + public static readonly Bitmap IconBitmap = new(Assembly.GetAssembly(typeof(ConfigurationState))!.GetManifestResourceStream("Ryujinx.UI.Common.Resources.Logo_Ryujinx.png")!); @@ -173,7 +175,7 @@ namespace Ryujinx.Ava.UI.ViewModels SwitchToGameControl = switchToGameControl; SetMainContent = setMainContent; TopLevel = topLevel; - + #if DEBUG topLevel.AttachDevTools(new KeyGesture(Avalonia.Input.Key.F12, KeyModifiers.Control)); #endif @@ -268,7 +270,7 @@ namespace Ryujinx.Ava.UI.ViewModels public bool ShowFirmwareStatus => !ShowLoadProgress; - public bool ShowRightmostSeparator + public bool ShowRightmostSeparator { get => _showRightmostSeparator; set @@ -553,7 +555,7 @@ namespace Ryujinx.Ava.UI.ViewModels OnPropertyChanged(); } } - + public string ShaderCountText { get => _shaderCountText; @@ -1021,7 +1023,7 @@ namespace Ryujinx.Ava.UI.ViewModels ? SortExpressionComparer.Ascending(selector) : SortExpressionComparer.Descending(selector); - private IComparer GetComparer() + private IComparer GetComparer() => SortMode switch { #pragma warning disable IDE0055 // Disable formatting @@ -1251,7 +1253,7 @@ namespace Ryujinx.Ava.UI.ViewModels private void InitializeGame() { RendererHostControl.WindowCreated += RendererHost_Created; - + AppHost.StatusUpdatedEvent += Update_StatusBar; AppHost.AppExit += AppHost_AppExit; @@ -1300,9 +1302,9 @@ namespace Ryujinx.Ava.UI.ViewModels GameStatusText = args.GameStatus; VolumeStatusText = args.VolumeStatus; FifoStatusText = args.FifoStatus; - - ShaderCountText = (ShowRightmostSeparator = args.ShaderCount > 0) - ? $"{LocaleManager.Instance[LocaleKeys.CompilingShaders]}: {args.ShaderCount}" + + ShaderCountText = (ShowRightmostSeparator = args.ShaderCount > 0) + ? $"{LocaleManager.Instance[LocaleKeys.CompilingShaders]}: {args.ShaderCount}" : string.Empty; ShowStatusSeparator = true; @@ -1707,7 +1709,7 @@ namespace Ryujinx.Ava.UI.ViewModels RendererHostControl.Focus(); }); - public static void UpdateGameMetadata(string titleId) + public static void UpdateGameMetadata(string titleId) => ApplicationLibrary.LoadAndSaveMetaData(titleId, appMetadata => appMetadata.UpdatePostGame()); public void RefreshFirmwareStatus() diff --git a/src/Ryujinx/UI/ViewModels/SettingsViewModel.cs b/src/Ryujinx/UI/ViewModels/SettingsViewModel.cs index f069896f8..2da252d00 100644 --- a/src/Ryujinx/UI/ViewModels/SettingsViewModel.cs +++ b/src/Ryujinx/UI/ViewModels/SettingsViewModel.cs @@ -25,12 +25,13 @@ using System.Collections.ObjectModel; using System.Linq; using System.Net.NetworkInformation; using System.Runtime.InteropServices; +using System.Text.RegularExpressions; using System.Threading.Tasks; using TimeZone = Ryujinx.Ava.UI.Models.TimeZone; namespace Ryujinx.Ava.UI.ViewModels { - public class SettingsViewModel : BaseModel + public partial class SettingsViewModel : BaseModel { private readonly VirtualFileSystem _virtualFileSystem; private readonly ContentManager _contentManager; @@ -56,6 +57,8 @@ namespace Ryujinx.Ava.UI.ViewModels public event Action SaveSettingsEvent; private int _networkInterfaceIndex; private int _multiplayerModeIndex; + private string _ldnPassphrase; + private string _LdnServer; public int ResolutionScale { @@ -180,10 +183,24 @@ namespace Ryujinx.Ava.UI.ViewModels public bool IsVulkanSelected => GraphicsBackendIndex == 0; public bool UseHypervisor { get; set; } + public bool DisableP2P { get; set; } public string TimeZone { get; set; } public string ShaderDumpPath { get; set; } + public string LdnPassphrase + { + get => _ldnPassphrase; + set + { + _ldnPassphrase = value; + IsInvalidLdnPassphraseVisible = !ValidateLdnPassphrase(value); + + OnPropertyChanged(); + OnPropertyChanged(nameof(IsInvalidLdnPassphraseVisible)); + } + } + public int Language { get; set; } public int Region { get; set; } public int FsGlobalAccessLogMode { get; set; } @@ -276,6 +293,21 @@ namespace Ryujinx.Ava.UI.ViewModels } } + [GeneratedRegex("Ryujinx-[0-9a-f]{8}")] + private static partial Regex LdnPassphraseRegex(); + + public bool IsInvalidLdnPassphraseVisible { get; set; } + + public string LdnServer + { + get => _LdnServer; + set + { + _LdnServer = value; + OnPropertyChanged(); + } + } + public SettingsViewModel(VirtualFileSystem virtualFileSystem, ContentManager contentManager) : this() { _virtualFileSystem = virtualFileSystem; @@ -393,6 +425,11 @@ namespace Ryujinx.Ava.UI.ViewModels Dispatcher.UIThread.Post(() => OnPropertyChanged(nameof(NetworkInterfaceIndex))); } + private bool ValidateLdnPassphrase(string passphrase) + { + return string.IsNullOrEmpty(passphrase) || (passphrase.Length == 16 && LdnPassphraseRegex().IsMatch(passphrase)); + } + public void ValidateAndSetTimeZone(string location) { if (_validTzRegions.Contains(location)) @@ -497,6 +534,9 @@ namespace Ryujinx.Ava.UI.ViewModels OpenglDebugLevel = (int)config.Logger.GraphicsDebugLevel.Value; MultiplayerModeIndex = (int)config.Multiplayer.Mode.Value; + DisableP2P = config.Multiplayer.DisableP2p.Value; + LdnPassphrase = config.Multiplayer.LdnPassphrase.Value; + LdnServer = config.Multiplayer.LdnServer.Value; } public void SaveSettings() @@ -613,6 +653,9 @@ namespace Ryujinx.Ava.UI.ViewModels config.Multiplayer.LanInterfaceId.Value = _networkInterfaces[NetworkInterfaceList[NetworkInterfaceIndex]]; config.Multiplayer.Mode.Value = (MultiplayerMode)MultiplayerModeIndex; + config.Multiplayer.DisableP2p.Value = DisableP2P; + config.Multiplayer.LdnPassphrase.Value = LdnPassphrase; + config.Multiplayer.LdnServer.Value = LdnServer; config.ToFileFormat().SaveConfig(Program.ConfigurationPath); diff --git a/src/Ryujinx/UI/Views/Main/MainMenuBarView.axaml.cs b/src/Ryujinx/UI/Views/Main/MainMenuBarView.axaml.cs index 1acee3af5..ce4d9fd59 100644 --- a/src/Ryujinx/UI/Views/Main/MainMenuBarView.axaml.cs +++ b/src/Ryujinx/UI/Views/Main/MainMenuBarView.axaml.cs @@ -40,8 +40,8 @@ namespace Ryujinx.Ava.UI.Views.Main private CheckBox[] GenerateToggleFileTypeItems() => Enum.GetValues() .Select(it => (FileName: Enum.GetName(it)!, FileType: it)) - .Select(it => - new CheckBox + .Select(it => + new CheckBox { Content = $".{it.FileName}", IsChecked = it.FileType.GetConfigValue(ConfigurationState.Instance.UI.ShownFileTypes), diff --git a/src/Ryujinx/UI/Views/Settings/SettingsNetworkView.axaml b/src/Ryujinx/UI/Views/Settings/SettingsNetworkView.axaml index c7736bf8d..2fc59f04d 100644 --- a/src/Ryujinx/UI/Views/Settings/SettingsNetworkView.axaml +++ b/src/Ryujinx/UI/Views/Settings/SettingsNetworkView.axaml @@ -1,4 +1,4 @@ -๏ปฟ + + + + + + + + + + + + + diff --git a/src/Ryujinx/UI/Views/Settings/SettingsNetworkView.axaml.cs b/src/Ryujinx/UI/Views/Settings/SettingsNetworkView.axaml.cs index b771933eb..c69307522 100644 --- a/src/Ryujinx/UI/Views/Settings/SettingsNetworkView.axaml.cs +++ b/src/Ryujinx/UI/Views/Settings/SettingsNetworkView.axaml.cs @@ -1,12 +1,29 @@ using Avalonia.Controls; +using Avalonia.Interactivity; +using Ryujinx.Ava.UI.ViewModels; +using System; namespace Ryujinx.Ava.UI.Views.Settings { public partial class SettingsNetworkView : UserControl { + public SettingsViewModel ViewModel; + public SettingsNetworkView() { InitializeComponent(); } + + private void GenLdnPassButton_OnClick(object sender, RoutedEventArgs e) + { + byte[] code = new byte[4]; + new Random().NextBytes(code); + ViewModel.LdnPassphrase = $"Ryujinx-{BitConverter.ToUInt32(code):x8}"; + } + + private void ClearLdnPassButton_OnClick(object sender, RoutedEventArgs e) + { + ViewModel.LdnPassphrase = ""; + } } } diff --git a/src/Ryujinx/UI/Windows/MainWindow.axaml.cs b/src/Ryujinx/UI/Windows/MainWindow.axaml.cs index 24a1b62a2..a43c29518 100644 --- a/src/Ryujinx/UI/Windows/MainWindow.axaml.cs +++ b/src/Ryujinx/UI/Windows/MainWindow.axaml.cs @@ -154,6 +154,36 @@ namespace Ryujinx.Ava.UI.Windows }); } + private void ApplicationLibrary_LdnGameDataReceived(object sender, LdnGameDataReceivedEventArgs e) + { + Dispatcher.UIThread.Post(() => + { + var ldnGameDataArray = e.LdnData; + ViewModel.LastLdnGameData = ldnGameDataArray; + foreach (var application in ViewModel.Applications) + { + UpdateApplicationWithLdnData(application); + } + ViewModel.RefreshView(); + }); + } + + private void UpdateApplicationWithLdnData(ApplicationData application) + { + if (application.ControlHolder.ByteSpan.Length > 0 && ViewModel.LastLdnGameData != null) + { + IEnumerable ldnGameData = ViewModel.LastLdnGameData.Where(game => application.ControlHolder.Value.LocalCommunicationId.Items.Contains(Convert.ToUInt64(game.TitleId, 16))); + + application.PlayerCount = ldnGameData.Sum(game => game.PlayerCount); + application.GameCount = ldnGameData.Count(); + } + else + { + application.PlayerCount = 0; + application.GameCount = 0; + } + } + public void Application_Opened(object sender, ApplicationOpenedEventArgs args) { if (args.Application != null) @@ -450,7 +480,20 @@ namespace Ryujinx.Ava.UI.Windows .Connect() .ObserveOn(SynchronizationContext.Current!) .Bind(ViewModel.Applications) + .OnItemAdded(UpdateApplicationWithLdnData) .Subscribe(); + ApplicationLibrary.LdnGameDataReceived += ApplicationLibrary_LdnGameDataReceived; + + ConfigurationState.Instance.Multiplayer.Mode.Event += (sender, evt) => + { + _ = Task.Run(ViewModel.ApplicationLibrary.RefreshLdn); + }; + + ConfigurationState.Instance.Multiplayer.LdnServer.Event += (sender, evt) => + { + _ = Task.Run(ViewModel.ApplicationLibrary.RefreshLdn); + }; + _ = Task.Run(ViewModel.ApplicationLibrary.RefreshLdn); ViewModel.RefreshFirmwareStatus(); @@ -459,7 +502,7 @@ namespace Ryujinx.Ava.UI.Windows { LoadApplications(); } - + _ = CheckLaunchState(); } @@ -588,13 +631,26 @@ namespace Ryujinx.Ava.UI.Windows { switch (fileType) { - case "NSP": ConfigurationState.Instance.UI.ShownFileTypes.NSP.Toggle(); break; - case "PFS0": ConfigurationState.Instance.UI.ShownFileTypes.PFS0.Toggle(); break; - case "XCI": ConfigurationState.Instance.UI.ShownFileTypes.XCI.Toggle(); break; - case "NCA": ConfigurationState.Instance.UI.ShownFileTypes.NCA.Toggle(); break; - case "NRO": ConfigurationState.Instance.UI.ShownFileTypes.NRO.Toggle(); break; - case "NSO": ConfigurationState.Instance.UI.ShownFileTypes.NSO.Toggle(); break; - default: throw new ArgumentOutOfRangeException(fileType); + case "NSP": + ConfigurationState.Instance.UI.ShownFileTypes.NSP.Toggle(); + break; + case "PFS0": + ConfigurationState.Instance.UI.ShownFileTypes.PFS0.Toggle(); + break; + case "XCI": + ConfigurationState.Instance.UI.ShownFileTypes.XCI.Toggle(); + break; + case "NCA": + ConfigurationState.Instance.UI.ShownFileTypes.NCA.Toggle(); + break; + case "NRO": + ConfigurationState.Instance.UI.ShownFileTypes.NRO.Toggle(); + break; + case "NSO": + ConfigurationState.Instance.UI.ShownFileTypes.NSO.Toggle(); + break; + default: + throw new ArgumentOutOfRangeException(fileType); } ConfigurationState.Instance.ToFileFormat().SaveConfig(Program.ConfigurationPath); diff --git a/src/Ryujinx/UI/Windows/SettingsWindow.axaml.cs b/src/Ryujinx/UI/Windows/SettingsWindow.axaml.cs index 1a177d182..d8c88bed8 100644 --- a/src/Ryujinx/UI/Windows/SettingsWindow.axaml.cs +++ b/src/Ryujinx/UI/Windows/SettingsWindow.axaml.cs @@ -80,6 +80,7 @@ namespace Ryujinx.Ava.UI.Windows NavPanel.Content = AudioPage; break; case "NetworkPage": + NetworkPage.ViewModel = ViewModel; NavPanel.Content = NetworkPage; break; case "LoggingPage": diff --git a/src/Ryujinx/UI/Windows/StyleableWindow.cs b/src/Ryujinx/UI/Windows/StyleableWindow.cs index 493214ee2..9e4eed2e4 100644 --- a/src/Ryujinx/UI/Windows/StyleableWindow.cs +++ b/src/Ryujinx/UI/Windows/StyleableWindow.cs @@ -17,7 +17,7 @@ namespace Ryujinx.Ava.UI.Windows LocaleManager.Instance.LocaleChanged += LocaleChanged; LocaleChanged(); - + Icon = MainWindowViewModel.IconBitmap; }