UI: Compatibility List Viewer

This commit is contained in:
Evan Husted 2025-01-06 07:31:57 -06:00
parent 9726b0feb0
commit c4cc657b89
13 changed files with 612 additions and 1 deletions

View file

@ -44,6 +44,7 @@
<PackageVersion Include="Ryujinx.SDL2-CS" Version="2.30.0-build32" />
<PackageVersion Include="Gommon" Version="2.7.0.1" />
<PackageVersion Include="securifybv.ShellLink" Version="0.1.0" />
<PackageVersion Include="Sep" Version="0.6.0" />
<PackageVersion Include="shaderc.net" Version="0.1.0" />
<PackageVersion Include="SharpMetal" Version="1.0.0-preview21" />
<PackageVersion Include="SharpZipLib" Version="1.4.2" />

View file

@ -19,7 +19,7 @@ namespace Ryujinx.UI.LocaleGenerator
StringBuilder enumSourceBuilder = new();
enumSourceBuilder.AppendLine("namespace Ryujinx.Ava.Common.Locale;");
enumSourceBuilder.AppendLine("internal enum LocaleKeys");
enumSourceBuilder.AppendLine("public enum LocaleKeys");
enumSourceBuilder.AppendLine("{");
foreach (var line in lines)
{

View file

@ -22596,6 +22596,206 @@
"zh_CN": "降低自定义刷新率:",
"zh_TW": ""
}
},
{
"ID": "CompatibilityListSearchBoxWatermark",
"Translations": {
"ar_SA": "",
"de_DE": "",
"el_GR": "",
"en_US": "Search compatibility entries...",
"es_ES": "",
"fr_FR": "",
"he_IL": "",
"it_IT": "",
"ja_JP": "",
"ko_KR": "",
"no_NO": "",
"pl_PL": "",
"pt_BR": "",
"ru_RU": "",
"sv_SE": "",
"th_TH": "",
"tr_TR": "",
"uk_UA": "",
"zh_CN": "",
"zh_TW": ""
}
},
{
"ID": "CompatibilityListOpen",
"Translations": {
"ar_SA": "",
"de_DE": "",
"el_GR": "",
"en_US": "Open Compatibility List",
"es_ES": "",
"fr_FR": "",
"he_IL": "",
"it_IT": "",
"ja_JP": "",
"ko_KR": "",
"no_NO": "",
"pl_PL": "",
"pt_BR": "",
"ru_RU": "",
"sv_SE": "",
"th_TH": "",
"tr_TR": "",
"uk_UA": "",
"zh_CN": "",
"zh_TW": ""
}
},
{
"ID": "CompatibilityListOnlyShowOwnedGames",
"Translations": {
"ar_SA": "",
"de_DE": "",
"el_GR": "",
"en_US": "Only show owned games",
"es_ES": "",
"fr_FR": "",
"he_IL": "",
"it_IT": "",
"ja_JP": "",
"ko_KR": "",
"no_NO": "",
"pl_PL": "",
"pt_BR": "",
"ru_RU": "",
"sv_SE": "",
"th_TH": "",
"tr_TR": "",
"uk_UA": "",
"zh_CN": "",
"zh_TW": ""
}
},
{
"ID": "CompatibilityListPlayable",
"Translations": {
"ar_SA": "",
"de_DE": "",
"el_GR": "",
"en_US": "Playable",
"es_ES": "",
"fr_FR": "",
"he_IL": "",
"it_IT": "",
"ja_JP": "",
"ko_KR": "",
"no_NO": "",
"pl_PL": "",
"pt_BR": "",
"ru_RU": "",
"sv_SE": "",
"th_TH": "",
"tr_TR": "",
"uk_UA": "",
"zh_CN": "",
"zh_TW": ""
}
},
{
"ID": "CompatibilityListIngame",
"Translations": {
"ar_SA": "",
"de_DE": "",
"el_GR": "",
"en_US": "Ingame",
"es_ES": "",
"fr_FR": "",
"he_IL": "",
"it_IT": "",
"ja_JP": "",
"ko_KR": "",
"no_NO": "",
"pl_PL": "",
"pt_BR": "",
"ru_RU": "",
"sv_SE": "",
"th_TH": "",
"tr_TR": "",
"uk_UA": "",
"zh_CN": "",
"zh_TW": ""
}
},
{
"ID": "CompatibilityListMenus",
"Translations": {
"ar_SA": "",
"de_DE": "",
"el_GR": "",
"en_US": "Menus",
"es_ES": "",
"fr_FR": "",
"he_IL": "",
"it_IT": "",
"ja_JP": "",
"ko_KR": "",
"no_NO": "",
"pl_PL": "",
"pt_BR": "",
"ru_RU": "",
"sv_SE": "",
"th_TH": "",
"tr_TR": "",
"uk_UA": "",
"zh_CN": "",
"zh_TW": ""
}
},
{
"ID": "CompatibilityListBoots",
"Translations": {
"ar_SA": "",
"de_DE": "",
"el_GR": "",
"en_US": "Boots",
"es_ES": "",
"fr_FR": "",
"he_IL": "",
"it_IT": "",
"ja_JP": "",
"ko_KR": "",
"no_NO": "",
"pl_PL": "",
"pt_BR": "",
"ru_RU": "",
"sv_SE": "",
"th_TH": "",
"tr_TR": "",
"uk_UA": "",
"zh_CN": "",
"zh_TW": ""
}
},
{
"ID": "CompatibilityListNothing",
"Translations": {
"ar_SA": "",
"de_DE": "",
"el_GR": "",
"en_US": "Nothing",
"es_ES": "",
"fr_FR": "",
"he_IL": "",
"it_IT": "",
"ja_JP": "",
"ko_KR": "",
"no_NO": "",
"pl_PL": "",
"pt_BR": "",
"ru_RU": "",
"sv_SE": "",
"th_TH": "",
"tr_TR": "",
"uk_UA": "",
"zh_CN": "",
"zh_TW": ""
}
}
]
}

View file

@ -61,6 +61,7 @@
<PackageReference Include="Ryujinx.Graphics.Nvdec.Dependencies" />
<PackageReference Include="Ryujinx.Graphics.Vulkan.Dependencies.MoltenVK" Condition="'$(RuntimeIdentifier)' != 'linux-x64' AND '$(RuntimeIdentifier)' != 'linux-arm64' AND '$(RuntimeIdentifier)' != 'win-x64'" />
<PackageReference Include="securifybv.ShellLink" />
<PackageReference Include="Sep" />
<PackageReference Include="Silk.NET.Vulkan" />
<PackageReference Include="Silk.NET.Vulkan.Extensions.EXT" />
<PackageReference Include="Silk.NET.Vulkan.Extensions.KHR" />
@ -113,6 +114,10 @@
<AvaloniaResource Include="UI\**\*.xaml">
<SubType>Designer</SubType>
</AvaloniaResource>
<AvaloniaResource Include="Assets\Fonts\Mono\JetBrainsMonoNL-Bold.ttf" />
<AvaloniaResource Include="Assets\Fonts\Mono\JetBrainsMonoNL-BoldItalic.ttf" />
<AvaloniaResource Include="Assets\Fonts\Mono\JetBrainsMonoNL-Italic.ttf" />
<AvaloniaResource Include="Assets\Fonts\Mono\JetBrainsMonoNL-Regular.ttf" />
<AvaloniaResource Include="Assets\Fonts\SegoeFluentIcons.ttf" />
<AvaloniaResource Include="Assets\Styles\Themes.xaml">
<Generator>MSBuild:Compile</Generator>
@ -163,4 +168,13 @@
<ItemGroup>
<AdditionalFiles Include="Assets\locales.json" />
</ItemGroup>
<ItemGroup>
<Compile Update="Utilities\Compat\CompatibilityList.axaml.cs">
<DependentUpon>CompatibilityList.axaml</DependentUpon>
<SubType>Code</SubType>
</Compile>
</ItemGroup>
<ItemGroup>
<Folder Include="Assets\Fonts\Mono\" />
</ItemGroup>
</Project>

View file

@ -6,6 +6,9 @@
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary>
<FontFamily x:Key="JetBrainsMono">avares://Ryujinx/Assets/Fonts/Mono/#JetBrains Mono</FontFamily>
</ResourceDictionary>
<MergeResourceInclude Source="/Assets/Styles/Themes.xaml"/>
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>

View file

@ -0,0 +1,28 @@
using Avalonia.Data.Converters;
using Avalonia.Media;
using Gommon;
using Ryujinx.Ava.Common.Locale;
using System;
using System.Globalization;
namespace Ryujinx.Ava.UI.Helpers
{
public class PlayabilityStatusConverter : IValueConverter
{
private static readonly Lazy<PlayabilityStatusConverter> _shared = new(() => new());
public static PlayabilityStatusConverter Shared => _shared.Value;
public object Convert(object? value, Type _, object? __, CultureInfo ___) =>
value.Cast<LocaleKeys>() switch
{
LocaleKeys.CompatibilityListNothing or
LocaleKeys.CompatibilityListBoots or
LocaleKeys.CompatibilityListMenus => Brushes.Red,
LocaleKeys.CompatibilityListIngame => Brushes.Yellow,
_ => Brushes.ForestGreen
};
public object ConvertBack(object? value, Type _, object? __, CultureInfo ___)
=> throw new NotSupportedException();
}
}

View file

@ -304,6 +304,10 @@
Header="{ext:Locale MenuBarHelpCheckForUpdates}"
Icon="{ext:Icon mdi-update}"
ToolTip.Tip="{ext:Locale CheckUpdatesTooltip}" />
<MenuItem
Click="OpenCompatibilityList"
Header="{ext:Locale CompatibilityListOpen}"
Icon="{ext:Icon mdi-gamepad}"/>
<Separator />
<MenuItem VerticalAlignment="Center" Header="{ext:Locale MenuBarHelpFaqAndGuides}" Icon="{ext:Icon fa-solid fa-question}" >
<MenuItem

View file

@ -8,6 +8,7 @@ using Ryujinx.Ava.UI.Helpers;
using Ryujinx.Ava.UI.ViewModels;
using Ryujinx.Ava.UI.Windows;
using Ryujinx.Ava.Utilities;
using Ryujinx.Ava.Utilities.Compat;
using Ryujinx.Ava.Utilities.Configuration;
using Ryujinx.Common;
using Ryujinx.Common.Helper;
@ -225,5 +226,7 @@ namespace Ryujinx.Ava.UI.Views.Main
public async void OpenAboutWindow(object sender, RoutedEventArgs e) => await AboutWindow.Show();
public void CloseWindow(object sender, RoutedEventArgs e) => Window.Close();
private async void OpenCompatibilityList(object sender, RoutedEventArgs e) => await CompatibilityList.Show();
}
}

View file

@ -0,0 +1,152 @@
using Gommon;
using nietras.SeparatedValues;
using Ryujinx.Ava.Common.Locale;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
namespace Ryujinx.Ava.Utilities.Compat
{
public class CompatibilityCsv
{
public static CompatibilityCsv Shared { get; set; }
public CompatibilityCsv(SepReader reader)
{
var entries = new List<CompatibilityEntry>();
foreach (var row in reader)
{
entries.Add(new CompatibilityEntry(reader.Header, row));
}
Entries = entries.Where(x => x.Status != null)
.OrderBy(it => it.GameName).ToArray();
}
public CompatibilityEntry[] Entries { get; }
}
public class CompatibilityEntry
{
public CompatibilityEntry(SepReaderHeader header, SepReader.Row row)
{
IssueNumber = row[header.IndexOf("issue_number")].Parse<int>();
var titleIdRow = row[header.IndexOf("extracted_game_id")].ToString();
if (!string.IsNullOrEmpty(titleIdRow))
TitleId = titleIdRow;
var issueTitleRow = row[header.IndexOf("issue_title")].ToString();
if (TitleId.HasValue)
issueTitleRow = issueTitleRow.ReplaceIgnoreCase($" - {TitleId}", string.Empty);
GameName = issueTitleRow.Trim().Trim('"');
IssueLabels = row[header.IndexOf("issue_labels")].ToString().Split(';');
Status = row[header.IndexOf("extracted_status")].ToString().ToLower() switch
{
"playable" => LocaleKeys.CompatibilityListPlayable,
"ingame" => LocaleKeys.CompatibilityListIngame,
"menus" => LocaleKeys.CompatibilityListMenus,
"boots" => LocaleKeys.CompatibilityListBoots,
"nothing" => LocaleKeys.CompatibilityListNothing,
_ => null
};
if (row[header.IndexOf("last_event_date")].TryParse<DateTime>(out var dt))
LastEvent = dt;
if (row[header.IndexOf("events_count")].TryParse<int>(out var eventsCount))
EventCount = eventsCount;
}
public int IssueNumber { get; }
public string GameName { get; }
public Optional<string> TitleId { get; }
public string[] IssueLabels { get; }
public LocaleKeys? Status { get; }
public DateTime LastEvent { get; }
public int EventCount { get; }
public string LocalizedStatus => LocaleManager.Instance[Status!.Value];
public string FormattedTitleId => TitleId.OrElse(new string(' ', 16));
public string FormattedIssueLabels => IssueLabels
.Where(it => !it.StartsWithIgnoreCase("status"))
.Select(FormatLabelName)
.JoinToString(", ");
public override string ToString()
{
var sb = new StringBuilder("CompatibilityEntry: {");
sb.Append($"{nameof(IssueNumber)}={IssueNumber}, ");
sb.Append($"{nameof(GameName)}=\"{GameName}\", ");
sb.Append($"{nameof(TitleId)}={TitleId}, ");
sb.Append($"{nameof(IssueLabels)}=\"{IssueLabels}\", ");
sb.Append($"{nameof(Status)}=\"{Status}\", ");
sb.Append($"{nameof(LastEvent)}=\"{LastEvent}\", ");
sb.Append($"{nameof(EventCount)}={EventCount}");
sb.Append('}');
return sb.ToString();
}
public static string FormatLabelName(string labelName) => labelName.ToLower() switch
{
"audio" => "Audio",
"bug" => "Bug",
"cpu" => "CPU",
"gpu" => "GPU",
"gui" => "GUI",
"help wanted" => "Help Wanted",
"horizon" => "Horizon",
"infra" => "Project Infra",
"invalid" => "Invalid",
"kernel" => "Kernel",
"ldn" => "LDN",
"linux" => "Linux",
"macos" => "macOS",
"question" => "Question",
"windows" => "Windows",
"graphics-backend:opengl" => "Graphics: OpenGL",
"graphics-backend:vulkan" => "Graphics: Vulkan",
"ldn-works" => "LDN Works",
"ldn-untested" => "LDN Untested",
"ldn-broken" => "LDN Broken",
"ldn-partial" => "Partial LDN",
"nvdec" => "NVDEC",
"services" => "NX Services",
"services-horizon" => "Horizon OS Services",
"slow" => "Runs Slow",
"crash" => "Crashes",
"deadlock" => "Deadlock",
"regression" => "Regression",
"opengl" => "OpenGL",
"opengl-backend-bug" => "OpenGL Backend Bug",
"vulkan-backend-bug" => "Vulkan Backend Bug",
"mac-bug" => "Mac-specific Bug(s)",
"amd-vendor-bug" => "AMD GPU Bug",
"intel-vendor-bug" => "Intel GPU Bug",
"loader-allocator" => "Loader Allocator",
"audout" => "AudOut",
"32-bit" => "32-bit Game",
"UE4" => "Unreal Engine 4",
"homebrew" => "Homebrew Content",
"online-broken" => "Online Broken",
_ => Capitalize(labelName)
};
public static string Capitalize(string value)
{
if (value == string.Empty)
return string.Empty;
var firstChar = value[0];
var rest = value[1..];
return $"{char.ToUpper(firstChar)}{rest}";
}
}
}

View file

@ -0,0 +1,32 @@
using Gommon;
using nietras.SeparatedValues;
using Ryujinx.Common.Configuration;
using System.Net.Http;
using System.Threading.Tasks;
namespace Ryujinx.Ava.Utilities.Compat
{
public static class CompatibilityHelper
{
private static readonly string _downloadUrl =
"https://gist.githubusercontent.com/ezhevita/b41ed3bf64d0cc01269cab036e884f3d/raw/002b1a1c1a5f7a83276625e8c479c987a5f5b722/Ryujinx%2520Games%2520List%2520Compatibility.csv";
private static readonly FilePath _compatCsvPath = new FilePath(AppDataManager.BaseDirPath) / "system" / "compatibility.csv";
public static async Task<SepReader> DownloadAsync()
{
if (_compatCsvPath.ExistsAsFile)
return Sep.Reader().FromFile(_compatCsvPath.Path);
using var httpClient = new HttpClient();
var compatCsv = await httpClient.GetStringAsync(_downloadUrl);
_compatCsvPath.WriteAllText(compatCsv);
return Sep.Reader().FromText(compatCsv);
}
public static async Task InitAsync()
{
CompatibilityCsv.Shared = new CompatibilityCsv(await DownloadAsync());
}
}
}

View file

@ -0,0 +1,59 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="using:Ryujinx.Ava.Utilities.Compat"
xmlns:helpers="using:Ryujinx.Ava.UI.Helpers"
xmlns:ext="using:Ryujinx.Ava.Common.Markup"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="Ryujinx.Ava.Utilities.Compat.CompatibilityList"
x:DataType="local:CompatibilityViewModel">
<UserControl.DataContext>
<local:CompatibilityViewModel />
</UserControl.DataContext>
<StackPanel Orientation="Vertical">
<StackPanel HorizontalAlignment="Right" Orientation="Horizontal">
<TextBox Watermark="{ext:Locale CompatibilityListSearchBoxWatermark}" TextChanged="TextBox_OnTextChanged" />
<CheckBox Margin="7, 0, 0, 0" IsChecked="{Binding OnlyShowOwnedGames}" />
<TextBlock Margin="-3, 0, 0, 0" Text="{ext:Locale CompatibilityListOnlyShowOwnedGames}" />
</StackPanel>
<ScrollViewer>
<ListBox Margin="5"
Padding="10"
Background="Transparent"
ItemsSource="{Binding CurrentEntries}">
<ListBox.ItemTemplate>
<DataTemplate DataType="{x:Type local:CompatibilityEntry}">
<Grid HorizontalAlignment="Center" Width="500" ColumnDefinitions="Auto,Auto,Auto,*"
Margin="5">
<TextBlock Grid.Column="0"
FontFamily="{StaticResource JetBrainsMono}"
Text="{Binding GameName}"
Width="140"
TextWrapping="Wrap" />
<TextBlock Grid.Column="1"
Width="135"
Padding="7, 0, 0, 0"
FontFamily="{StaticResource JetBrainsMono}"
Text="{Binding FormattedTitleId}"
TextWrapping="Wrap" />
<TextBlock Grid.Column="2"
Padding="7, 0"
VerticalAlignment="Center"
FontFamily="{StaticResource JetBrainsMono}"
Text="{Binding LocalizedStatus}"
Width="85"
Foreground="{Binding Status, Converter={x:Static helpers:PlayabilityStatusConverter.Shared}}"
TextWrapping="NoWrap" />
<TextBlock Grid.Column="3"
VerticalAlignment="Center"
FontFamily="{StaticResource JetBrainsMono}"
Text="{Binding FormattedIssueLabels}"
TextWrapping="WrapWithOverflow" />
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</ScrollViewer>
</StackPanel>
</UserControl>

View file

@ -0,0 +1,56 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
using Avalonia.Styling;
using FluentAvalonia.UI.Controls;
using Ryujinx.Ava.Common.Locale;
using Ryujinx.Ava.UI.Helpers;
using Ryujinx.Ava.UI.Windows;
using System.Threading.Tasks;
namespace Ryujinx.Ava.Utilities.Compat
{
public partial class CompatibilityList : UserControl
{
public static async Task Show()
{
await CompatibilityHelper.InitAsync();
ContentDialog contentDialog = new()
{
PrimaryButtonText = string.Empty,
SecondaryButtonText = string.Empty,
CloseButtonText = LocaleManager.Instance[LocaleKeys.SettingsButtonClose],
Content = new CompatibilityList { DataContext = new CompatibilityViewModel(RyujinxApp.MainWindow.ViewModel.ApplicationLibrary) }
};
Style closeButton = new(x => x.Name("CloseButton"));
closeButton.Setters.Add(new Setter(WidthProperty, 80d));
Style closeButtonParent = new(x => x.Name("CommandSpace"));
closeButtonParent.Setters.Add(new Setter(HorizontalAlignmentProperty, Avalonia.Layout.HorizontalAlignment.Right));
contentDialog.Styles.Add(closeButton);
contentDialog.Styles.Add(closeButtonParent);
await ContentDialogHelper.ShowAsync(contentDialog);
}
public CompatibilityList()
{
InitializeComponent();
}
private void TextBox_OnTextChanged(object? sender, TextChangedEventArgs e)
{
if (DataContext is not CompatibilityViewModel cvm)
return;
if (sender is not TextBox searchBox)
return;
cvm.Search(searchBox.Text);
}
}
}

View file

@ -0,0 +1,59 @@
using CommunityToolkit.Mvvm.ComponentModel;
using ExCSS;
using Gommon;
using Ryujinx.Ava.Utilities.AppLibrary;
using System.Collections.Generic;
using System.Linq;
namespace Ryujinx.Ava.Utilities.Compat
{
public partial class CompatibilityViewModel : ObservableObject
{
[ObservableProperty] private bool _onlyShowOwnedGames;
private IEnumerable<CompatibilityEntry> _currentEntries = CompatibilityCsv.Shared.Entries;
private readonly string[] _ownedGameTitleIds = [];
private readonly ApplicationLibrary _appLibrary;
public IEnumerable<CompatibilityEntry> CurrentEntries => OnlyShowOwnedGames
? _currentEntries.Where(x =>
x.TitleId.Check(tid => _ownedGameTitleIds.ContainsIgnoreCase(tid))
|| _appLibrary.Applications.Items.Any(a => a.Name.EqualsIgnoreCase(x.GameName)))
: _currentEntries;
public CompatibilityViewModel() {}
public CompatibilityViewModel(ApplicationLibrary appLibrary)
{
_appLibrary = appLibrary;
_ownedGameTitleIds = appLibrary.Applications.Keys.Select(x => x.ToString("X16")).ToArray();
PropertyChanged += (_, args) =>
{
if (args.PropertyName is nameof(OnlyShowOwnedGames))
OnPropertyChanged(nameof(CurrentEntries));
};
}
public void Search(string searchTerm)
{
if (string.IsNullOrEmpty(searchTerm))
{
SetEntries(CompatibilityCsv.Shared.Entries);
return;
}
SetEntries(CompatibilityCsv.Shared.Entries.Where(x =>
x.GameName.ContainsIgnoreCase(searchTerm)
|| x.TitleId.Check(tid => tid.ContainsIgnoreCase(searchTerm))));
}
private void SetEntries(IEnumerable<CompatibilityEntry> entries)
{
#pragma warning disable MVVMTK0034
_currentEntries = entries.ToList();
#pragma warning restore MVVMTK0034
OnPropertyChanged(nameof(CurrentEntries));
}
}
}