TESTERS WANTED: RyuLDN implementation (#65)

These changes allow players to matchmake for local wireless using a LDN
server. The network implementation originates from Berry's public TCP
RyuLDN fork. Logo and unrelated changes have been removed.

Additionally displays LDN game status in the game selection window when
RyuLDN is enabled.

Functionality is only enabled while network mode is set to "RyuLDN" in
the settings.
This commit is contained in:
Vudjun 2024-11-11 22:06:50 +00:00 committed by GitHub
parent abfcfcaf0f
commit 6d8738c048
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
93 changed files with 4100 additions and 189 deletions

View file

@ -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)

View file

@ -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();

View file

@ -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;

View file

@ -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" />
<TextBlock
HorizontalAlignment="Stretch"
Text="{Binding Converter={helpers:MultiplayerInfoConverter}}"
TextAlignment="Start"
TextWrapping="Wrap"/>
</StackPanel>
<StackPanel
Grid.Column="4"

View file

@ -48,7 +48,7 @@ namespace Ryujinx.Ava.UI.Controls
if (contentManager.GetCurrentFirmwareVersion() != null)
Task.Run(() => 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);
}

View file

@ -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<Glyph>(key), out var val)
? val
? val
: string.Empty;
public override object ProvideValue(IServiceProvider serviceProvider) => this[_key];

View file

@ -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;
}
}
}

View file

@ -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();
}
}

View file

@ -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)

View file

@ -117,6 +117,8 @@ namespace Ryujinx.Ava.UI.ViewModels
public ApplicationData ListSelectedApplication;
public ApplicationData GridSelectedApplication;
public IEnumerable<LdnGameData> 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<ApplicationData>.Ascending(selector)
: SortExpressionComparer<ApplicationData>.Descending(selector);
private IComparer<ApplicationData> GetComparer()
private IComparer<ApplicationData> 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()

View file

@ -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);

View file

@ -40,8 +40,8 @@ namespace Ryujinx.Ava.UI.Views.Main
private CheckBox[] GenerateToggleFileTypeItems() =>
Enum.GetValues<FileTypes>()
.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),

View file

@ -1,4 +1,4 @@
<UserControl
<UserControl
x:Class="Ryujinx.Ava.UI.Views.Settings.SettingsNetworkView"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
@ -36,11 +36,57 @@
<ComboBoxItem>
<TextBlock Text="{ext:Locale MultiplayerModeDisabled}" />
</ComboBoxItem>
<ComboBoxItem>
<TextBlock Text="{ext:Locale MultiplayerModeLdnRyu}" />
</ComboBoxItem>
<ComboBoxItem>
<TextBlock Text="{ext:Locale MultiplayerModeLdnMitm}" />
</ComboBoxItem>
</ComboBox>
<CheckBox Margin="10,0,0,0" IsChecked="{Binding DisableP2P}">
<TextBlock Text="{ext:Locale MultiplayerDisableP2P}"
ToolTip.Tip="{ext:Locale MultiplayerDisableP2PTooltip}" />
</CheckBox>
</StackPanel>
<StackPanel Margin="10,0,0,0" Orientation="Horizontal">
<TextBlock VerticalAlignment="Center"
Text="{ext:Locale LdnPassphrase}"
ToolTip.Tip="{ext:Locale LdnPassphraseTooltip}"
Width="200" />
<TextBox Name="LdnPassphrase"
Text="{Binding LdnPassphrase}"
Width="250"
MaxLength="16"
ToolTip.Tip="{ext:Locale LdnPassphraseInputTooltip}"
Watermark="{ext:Locale LdnPassphraseInputPublic}" />
<Button
Name="GenLdnPassButton"
Grid.Column="1"
MinWidth="90"
Margin="10,0,0,0"
ToolTip.Tip="{ext:Locale GenLdnPassTooltip}"
Click="GenLdnPassButton_OnClick">
<TextBlock HorizontalAlignment="Center"
Text="{ext:Locale GenLdnPass}" />
</Button>
<Button
Name="ClearLdnPassButton"
Grid.Column="1"
MinWidth="90"
Margin="10,0,0,0"
ToolTip.Tip="{ext:Locale ClearLdnPassTooltip}"
Click="ClearLdnPassButton_OnClick">
<TextBlock HorizontalAlignment="Center"
Text="{ext:Locale ClearLdnPass}" />
</Button>
</StackPanel>
<TextBlock Margin="10,0,0,0"
VerticalAlignment="Center"
Name="InvalidLdnPassphraseBlock"
FontStyle="Italic"
IsVisible="{Binding IsInvalidLdnPassphraseVisible}"
Focusable="False"
Text="{ext:Locale InvalidLdnPassphrase}" />
<Separator Height="1" />
<TextBlock Classes="h1" Text="{ext:Locale SettingsTabNetworkConnection}" />
<CheckBox Margin="10,0,0,0" IsChecked="{Binding EnableInternetAccess}">

View file

@ -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 = "";
}
}
}

View file

@ -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> 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);

View file

@ -80,6 +80,7 @@ namespace Ryujinx.Ava.UI.Windows
NavPanel.Content = AudioPage;
break;
case "NetworkPage":
NetworkPage.ViewModel = ViewModel;
NavPanel.Content = NetworkPage;
break;
case "LoggingPage":

View file

@ -17,7 +17,7 @@ namespace Ryujinx.Ava.UI.Windows
LocaleManager.Instance.LocaleChanged += LocaleChanged;
LocaleChanged();
Icon = MainWindowViewModel.IconBitmap;
}