diff --git a/docs/compatibility.csv b/docs/compatibility.csv index 53ad389b6..0fd8eadca 100644 --- a/docs/compatibility.csv +++ b/docs/compatibility.csv @@ -332,6 +332,7 @@ 0100E680149DC000,"Arcaea",,playable,2023-03-16 19:31:21 01003C2010C78000,"Archaica: The Path Of Light",crash,nothing,2020-10-16 13:22:26 01004DA012976000,"Area 86",,playable,2020-12-16 16:45:52 +01008d8006a6a000,"Arena of Valor",crash,boots,2025-02-03 22:19:34 0100691013C46000,"ARIA CHRONICLE",,playable,2022-11-16 13:50:55 0100D4A00B284000,"ARK: Survival Evolved",gpu;nvdec;online-broken;UE4;ldn-untested,ingame,2024-04-16 00:53:56 0100C56012C96000,"Arkanoid vs. Space Invaders",services,ingame,2021-01-21 12:50:30 @@ -426,6 +427,7 @@ 0100E48013A34000,"Balan Wonderworld Demo",gpu;services;UE4;demo,ingame,2023-02-16 20:05:07 0100CD801CE5E000,"Balatro",,ingame,2024-04-21 02:01:53 010010A00DA48000,"Baldur's Gate and Baldur's Gate II: Enhanced Editions",32-bit,playable,2022-09-12 23:52:15 +0100fd1014726000,"Baldur's Gate: Dark Alliance",ldn-untested,ingame,2025-02-03 22:21:00 0100BC400FB64000,"Balthazar's Dream",,playable,2022-09-13 00:13:22 01008D30128E0000,"Bamerang",,playable,2022-10-26 00:29:39 010013C010C5C000,"Banner of the Maid",,playable,2021-06-14 15:23:37 @@ -528,6 +530,7 @@ 01005950022EC000,"Blade Strangers",nvdec,playable,2022-07-17 19:02:43 0100DF0011A6A000,"Bladed Fury",,playable,2022-10-26 11:36:26 0100CFA00CC74000,"Blades of Time",deadlock;online,boots,2022-07-17 19:19:58 +01003d700dd8a000,"Blades",,boots,2025-02-03 22:22:00 01006CC01182C000,"Blair Witch",nvdec;UE4,playable,2022-10-01 14:06:16 010039501405E000,"Blanc",gpu;slow,ingame,2023-02-22 14:00:13 0100698009C6E000,"Blasphemous",nvdec,playable,2021-03-01 12:15:31 @@ -955,7 +958,7 @@ 010012800EBAE000,"Disney TSUM TSUM FESTIVAL",crash,menus,2020-07-14 14:05:28 01009740120FE000,"DISTRAINT 2",,playable,2020-09-03 16:08:12 010075B004DD2000,"DISTRAINT: Deluxe Edition",,playable,2020-06-15 23:42:24 -010027400CDC6000,"Divinity: Original Sin 2 - Definitive Edition",services;crash;online-broken;regression,menus,2023-08-13 17:20:03 +010027400CDC6000,"Divinity: Original Sin 2 - Definitive Edition",services;crash;online-broken;regression,ingame,2025-02-03 22:12:30 01001770115C8000,"Dodo Peak",nvdec;UE4,playable,2022-10-04 16:13:05 010077B0100DA000,"Dogurai",,playable,2020-10-04 02:40:16 010048100D51A000,"Dokapon Up! Mugen no Roulette",gpu;Needs Update,menus,2022-12-08 19:39:10 @@ -1654,7 +1657,7 @@ 0100A73006E74000,"Legendary Eleven",,playable,2021-06-08 12:09:03 0100A7700B46C000,"Legendary Fishing",online,playable,2021-04-14 15:08:46 0100739018020000,"LEGO® 2K Drive",gpu;ldn-works,ingame,2024-04-09 02:05:12 -01003A30012C0000,"LEGO® CITY Undercover",nvdec,playable,2024-09-30 08:44:27 +010085500130a000,"LEGO® CITY Undercover",nvdec,playable,2024-09-30 08:44:27 010070D009FEC000,"LEGO® DC Super-Villains",,playable,2021-05-27 18:10:37 010052A00B5D2000,"LEGO® Harry Potter™ Collection",crash,ingame,2024-01-31 10:28:07 010073C01AF34000,"LEGO® Horizon Adventures™",vulkan-backend-bug;opengl-backend-bug;UE4,ingame,2025-01-07 04:24:56 @@ -1913,6 +1916,7 @@ 010073E008E6E000,"Mugsters",,playable,2021-01-28 17:57:17 0100A8400471A000,"MUJO",,playable,2020-05-08 16:31:04 0100211005E94000,"Mulaka",,playable,2021-01-28 18:07:20 +01008e2013fb4000,"Multi Quiz",ldn-untested,ingame,2025-02-03 22:26:00 010038B00B9AE000,"Mummy Pinball",,playable,2022-08-05 16:08:11 01008E200C5C2000,"Muse Dash",,playable,2020-06-06 14:41:29 010035901046C000,"Mushroom Quest",,playable,2020-05-17 13:07:08 @@ -2028,6 +2032,7 @@ 010003C00B868000,"Ninjin: Clash of Carrots",online-broken,playable,2024-07-10 05:12:26 0100746010E4C000,"NinNinDays",,playable,2022-11-20 15:17:29 0100C9A00ECE6000,"Nintendo 64™ – Nintendo Switch Online",gpu;vulkan,ingame,2024-04-23 20:21:07 +0100e0601c632000,"Nintendo 64™ – Nintendo Switch Online: MATURE 17+",,ingame,2025-02-03 22:27:00 0100D870045B6000,"Nintendo Entertainment System™ - Nintendo Switch Online",online,playable,2022-07-01 15:45:06 0100C4B0034B2000,"Nintendo Labo Toy-Con 01 Variety Kit",gpu,ingame,2022-08-07 12:56:07 01001E9003502000,"Nintendo Labo Toy-Con 03 Vehicle Kit",services;crash,menus,2022-08-03 17:20:11 @@ -2532,7 +2537,7 @@ 0100C3E00B700000,"SEGA AGES Space Harrier",,playable,2021-01-11 12:57:40 010054400D2E6000,"SEGA AGES Virtua Racing",online-broken,playable,2023-01-29 17:08:39 01001E700AC60000,"SEGA AGES Wonder Boy: Monster Land",online,playable,2021-05-05 16:28:25 -0100B3C014BDA000,"SEGA Genesis™ – Nintendo Switch Online",crash;regression,nothing,2022-04-11 07:27:21 +0100B3C014BDA000,"SEGA Genesis™ – Nintendo Switch Online",crash;regression,ingame,2025-02-03 22:13:30 0100F7300B24E000,"SEGA Mega Drive Classics",online,playable,2021-01-05 11:08:00 01009840046BC000,"Semispheres",,playable,2021-01-06 23:08:31 0100D1800D902000,"SENRAN KAGURA Peach Ball",,playable,2021-06-03 15:12:10 @@ -2964,6 +2969,7 @@ 0100C38004DCC000,"The Flame In The Flood: Complete Edition",gpu;nvdec;UE4,ingame,2022-08-22 16:23:49 010007700D4AC000,"The Forbidden Arts",,playable,2021-01-26 16:26:24 010030700CBBC000,"The friends of Ringo Ishikawa",,playable,2022-08-22 16:33:17 +0100b620139d8000,"The Game of Life 2",ldn-untested,ingame,2025-02-03 22:30:00 01006350148DA000,"The Gardener and the Wild Vines",gpu,ingame,2024-04-29 16:32:10 0100B13007A6A000,"The Gardens Between",,playable,2021-01-29 16:16:53 010036E00FB20000,"The Great Ace Attorney Chronicles",,playable,2023-06-22 21:26:29 @@ -2981,6 +2987,8 @@ 010015D003EE4000,"The Jackbox Party Pack 2",online-working,playable,2022-08-22 18:23:40 0100CC80013D6000,"The Jackbox Party Pack 3",slow;online-working,playable,2022-08-22 18:41:06 0100E1F003EE8000,"The Jackbox Party Pack 4",online-working,playable,2022-08-22 18:56:34 +01006fe0096ac000,"The Jackbox Party Pack 5",ldn-untested,boots,2025-02-03 22:32:00 +01005a400db52000,"The Jackbox Party Pack 6",ldn-untested,boots,2025-02-03 22:32:00 010052C00B184000,"The Journey Down: Chapter One",nvdec,playable,2021-02-24 13:32:41 01006BC00B188000,"The Journey Down: Chapter Three",nvdec,playable,2021-02-24 13:45:27 01009AB00B186000,"The Journey Down: Chapter Two",nvdec,playable,2021-02-24 13:32:13 @@ -3159,6 +3167,7 @@ 010055E00CA68000,"Trine 4: The Nightmare Prince",gpu,nothing,2025-01-07 05:47:46 0100D9000A930000,"Trine Enchanted Edition",ldn-untested;nvdec,playable,2021-06-03 11:28:15 01002D7010A54000,"Trinity Trigger",crash,ingame,2023-03-03 03:09:09 +010020700a5e0000,"TRIVIAL PURSUIT Live!",ldn-untested,ingame,2025-02-03 22:35:00 0100868013FFC000,"TRIVIAL PURSUIT Live! 2",,boots,2022-12-19 00:04:33 0100F78002040000,"Troll and I™",gpu;nvdec,ingame,2021-06-04 16:58:50 0100145011008000,"Trollhunters: Defenders of Arcadia",gpu;nvdec,ingame,2020-11-30 13:27:09 @@ -3208,6 +3217,7 @@ 0100AB2010B4C000,"Unlock The King",,playable,2020-09-01 13:58:27 0100A3E011CB0000,"Unlock the King 2",,playable,2021-06-15 20:43:55 01005AA00372A000,"UNO® for Nintendo Switch",nvdec;ldn-untested,playable,2022-07-28 14:49:47 +0100b6e012ebe000,"UNO",ldn-untested,ingame,2025-02-03 22:40:00 0100E5D00CC0C000,"Unravel Two",nvdec,playable,2024-05-23 15:45:05 010001300CC4A000,"Unruly Heroes",,playable,2021-01-07 18:09:31 0100B410138C0000,"Unspottable",,playable,2022-10-25 19:28:49 @@ -3372,6 +3382,7 @@ 0100F47016F26000,"Yomawari 3",,playable,2022-05-10 08:26:51 010012F00B6F2000,"Yomawari: The Long Night Collection",,playable,2022-09-03 14:36:59 0100CC600ABB2000,"Yonder: The Cloud Catcher Chronicles (Retail Only)",,playable,2021-01-28 14:06:25 +0100534009ff2000,"Yonder: The Cloud Catcher Chronicles",,playable,2025-02-03 22:19:13 0100BE50042F6000,"Yono and the Celestial Elephants",,playable,2021-01-28 18:23:58 0100F110029C8000,"Yooka-Laylee",,playable,2021-01-28 14:21:45 010022F00DA66000,"Yooka-Laylee and the Impossible Lair",,playable,2021-03-05 17:32:21 diff --git a/src/Ryujinx.Common/TitleIDs.cs b/src/Ryujinx.Common/TitleIDs.cs index 42322c8a2..28d332a61 100644 --- a/src/Ryujinx.Common/TitleIDs.cs +++ b/src/Ryujinx.Common/TitleIDs.cs @@ -164,15 +164,16 @@ namespace Ryujinx.Common "0100ba0018500000", // Splatoon 3: Splatfest World Premiere //NSO Membership games - "0100ccf019c8c000", // F-ZERO 99 "0100c62011050000", // GB - Nintendo Switch Online "010012f017576000", // GBA - Nintendo Switch Online "0100c9a00ece6000", // N64 - Nintendo Switch Online "0100e0601c632000", // N64 - Nintendo Switch Online 18+ "0100d870045b6000", // NES - Nintendo Switch Online + "0100b3c014bda000", // SEGA Genesis - Nintendo Switch Online + "01008d300c50c000", // SNES - Nintendo Switch Online + "0100ccf019c8c000", // F-ZERO 99 "0100ad9012510000", // PAC-MAN 99 "010040600c5ce000", // Tetris 99 - "01008d300c50c000", // SNES - Nintendo Switch Online "0100277011f1a000", // Super Mario Bros. 35 //Misc Nintendo 1st party games diff --git a/src/Ryujinx.Horizon/HorizonStatic.cs b/src/Ryujinx.Horizon/HorizonStatic.cs index 15689f0c8..eb9dd4e31 100644 --- a/src/Ryujinx.Horizon/HorizonStatic.cs +++ b/src/Ryujinx.Horizon/HorizonStatic.cs @@ -1,5 +1,6 @@ using MsgPack; using Ryujinx.Horizon.Common; +using Ryujinx.Horizon.Prepo.Types; using Ryujinx.Memory; using System; using System.Threading; @@ -8,7 +9,7 @@ namespace Ryujinx.Horizon { public static class HorizonStatic { - internal static void HandlePlayReport(MessagePackObject report) => + internal static void HandlePlayReport(PlayReport report) => new Thread(() => PlayReport?.Invoke(report)) { Name = "HLE.PlayReportEvent", @@ -16,7 +17,7 @@ namespace Ryujinx.Horizon Priority = ThreadPriority.AboveNormal }.Start(); - public static event Action PlayReport; + public static event Action PlayReport; [field: ThreadStatic] public static HorizonOptions Options { get; private set; } diff --git a/src/Ryujinx.Horizon/Prepo/Ipc/PrepoService.cs b/src/Ryujinx.Horizon/Prepo/Ipc/PrepoService.cs index 2f8657e0b..0ca851e6e 100644 --- a/src/Ryujinx.Horizon/Prepo/Ipc/PrepoService.cs +++ b/src/Ryujinx.Horizon/Prepo/Ipc/PrepoService.cs @@ -1,4 +1,3 @@ -using Gommon; using MsgPack; using MsgPack.Serialization; using Ryujinx.Common.Logging; @@ -12,19 +11,12 @@ using Ryujinx.Horizon.Sdk.Sf; using Ryujinx.Horizon.Sdk.Sf.Hipc; using System; using System.Text; -using System.Threading; using ApplicationId = Ryujinx.Horizon.Sdk.Ncm.ApplicationId; namespace Ryujinx.Horizon.Prepo.Ipc { partial class PrepoService : IPrepoService { - enum PlayReportKind - { - Normal, - System, - } - private readonly ArpApi _arp; private readonly PrepoServicePermissionLevel _permissionLevel; private ulong _systemSessionId; @@ -196,10 +188,17 @@ namespace Ryujinx.Horizon.Prepo.Ipc { return PrepoResult.InvalidBufferSize; } - + StringBuilder builder = new(); MessagePackObject deserializedReport = MessagePackSerializer.UnpackMessagePackObject(reportBuffer.ToArray()); + PlayReport playReport = new() + { + Kind = playReportKind, + Room = gameRoom, + ReportData = deserializedReport + }; + builder.AppendLine(); builder.AppendLine("PlayReport log:"); builder.AppendLine($" Kind: {playReportKind}"); @@ -209,10 +208,12 @@ namespace Ryujinx.Horizon.Prepo.Ipc if (pid != 0) { builder.AppendLine($" Pid: {pid}"); + playReport.Pid = pid; } else { builder.AppendLine($" ApplicationId: {applicationId}"); + playReport.AppId = applicationId; } Result result = _arp.GetApplicationInstanceId(out ulong applicationInstanceId, pid); @@ -223,17 +224,20 @@ namespace Ryujinx.Horizon.Prepo.Ipc _arp.GetApplicationLaunchProperty(out ApplicationLaunchProperty applicationLaunchProperty, applicationInstanceId).AbortOnFailure(); + playReport.Version = applicationLaunchProperty.Version; + builder.AppendLine($" ApplicationVersion: {applicationLaunchProperty.Version}"); if (!userId.IsNull) { builder.AppendLine($" UserId: {userId}"); + playReport.UserId = userId; } builder.AppendLine($" Room: {gameRoom}"); builder.AppendLine($" Report: {MessagePackObjectFormatter.Format(deserializedReport)}"); - HorizonStatic.HandlePlayReport(deserializedReport); + HorizonStatic.HandlePlayReport(playReport); Logger.Info?.Print(LogClass.ServicePrepo, builder.ToString()); diff --git a/src/Ryujinx.Horizon/Prepo/Types/PlayReport.cs b/src/Ryujinx.Horizon/Prepo/Types/PlayReport.cs new file mode 100644 index 000000000..e896219d5 --- /dev/null +++ b/src/Ryujinx.Horizon/Prepo/Types/PlayReport.cs @@ -0,0 +1,24 @@ +using MsgPack; +using Ryujinx.Horizon.Sdk.Account; +using Ryujinx.Horizon.Sdk.Ncm; + +namespace Ryujinx.Horizon.Prepo.Types +{ + public struct PlayReport + { + public PlayReportKind Kind { get; init; } + public string Room { get; init; } + public MessagePackObject ReportData { get; init; } + + public ApplicationId? AppId; + public ulong? Pid; + public uint Version; + public Uid? UserId; + } + + public enum PlayReportKind + { + Normal, + System, + } +} diff --git a/src/Ryujinx/AppHost.cs b/src/Ryujinx/AppHost.cs index d49456b9a..c64e831cc 100644 --- a/src/Ryujinx/AppHost.cs +++ b/src/Ryujinx/AppHost.cs @@ -582,10 +582,13 @@ namespace Ryujinx.Ava Rainbow.Disable(); Rainbow.Reset(); - //Reload settings when the game is turned off - //(resets custom settings if there were any) + // Reload settings when the game is turned off + // (resets custom settings if there were any) Program.ReloadConfig(); + //Updates the gameList (changes the status of the user setting if it was added or removed during the game) + RyujinxApp.MainWindow.GameListUpdate(); + _isStopped = true; Stop(); } diff --git a/src/Ryujinx/Assets/Styles/Styles.xaml b/src/Ryujinx/Assets/Styles/Styles.xaml index c3bc27efd..45cc151f8 100644 --- a/src/Ryujinx/Assets/Styles/Styles.xaml +++ b/src/Ryujinx/Assets/Styles/Styles.xaml @@ -44,7 +44,6 @@ diff --git a/src/Ryujinx/Assets/locales.json b/src/Ryujinx/Assets/locales.json index 5657bb88e..7f6bf5b56 100644 --- a/src/Ryujinx/Assets/locales.json +++ b/src/Ryujinx/Assets/locales.json @@ -1543,7 +1543,7 @@ "th_TH": "", "tr_TR": "", "uk_UA": "", - "zh_CN": "", + "zh_CN": "由 {0} 开发", "zh_TW": "" } }, @@ -1576,50 +1576,50 @@ "ID": "GameListHeaderTimePlayed", "Translations": { "ar_SA": "", - "de_DE": "Spielzeit: {0}", - "el_GR": "Χρόνος: {0}", - "en_US": "Play Time: {0}", - "es_ES": "Tiempo jugado: {0}", - "fr_FR": "Temps de jeu: {0}", + "de_DE": "Spielzeit:", + "el_GR": "Χρόνος:", + "en_US": "Play Time:", + "es_ES": "Tiempo jugado:", + "fr_FR": "Temps de jeu:", "he_IL": "", - "it_IT": "Tempo di gioco: {0}", - "ja_JP": "プレイ時間: {0}", - "ko_KR": "플레이 타임: {0}", - "no_NO": "Spilletid: {0}", - "pl_PL": "Czas w grze: {0}", - "pt_BR": "Tempo de jogo: {0}", - "ru_RU": "Время в игре: {0}", - "sv_SE": "Speltid: {0}", - "th_TH": "เล่นไปแล้ว: {0}", - "tr_TR": "Oynama Süresi: {0}", - "uk_UA": "Зіграно часу: {0}", - "zh_CN": "游玩时长: {0}", - "zh_TW": "遊玩時數: {0}" + "it_IT": "Tempo di gioco:", + "ja_JP": "プレイ時間:", + "ko_KR": "플레이 타임:", + "no_NO": "Spilletid:", + "pl_PL": "Czas w grze:", + "pt_BR": "Tempo de jogo:", + "ru_RU": "Время в игре:", + "sv_SE": "Speltid:", + "th_TH": "เล่นไปแล้ว:", + "tr_TR": "Oynama Süresi:", + "uk_UA": "Зіграно часу:", + "zh_CN": "游玩时长:", + "zh_TW": "遊玩時數:" } }, { "ID": "GameListHeaderLastPlayed", "Translations": { "ar_SA": "", - "de_DE": "Zuletzt gespielt: {0}", - "el_GR": "Παίχτηκε: {0}", - "en_US": "Last Played: {0}", - "es_ES": "Jugado por última vez: {0}", - "fr_FR": "Dernière partie jouée: {0}", + "de_DE": "Zuletzt gespielt: ", + "el_GR": "Παίχτηκε: ", + "en_US": "Last Played:", + "es_ES": "Jugado por última vez:", + "fr_FR": "Dernière partie jouée:", "he_IL": "", - "it_IT": "Ultima partita: {0}", - "ja_JP": "最終プレイ日時: {0}", - "ko_KR": "마지막 플레이: {0}", - "no_NO": "Sist Spilt: {0}", - "pl_PL": "Ostatnio grane: {0}", - "pt_BR": "Último jogo: {0}", - "ru_RU": "Последний запуск: {0}", - "sv_SE": "Senast spelad: {0}", - "th_TH": "เล่นล่าสุด: {0}", - "tr_TR": "Son Oynama Tarihi: {0}", - "uk_UA": "Востаннє зіграно: {0}", - "zh_CN": "最近游玩: {0}", - "zh_TW": "最近遊玩: {0}" + "it_IT": "Ultima partita:", + "ja_JP": "最終プレイ日時:", + "ko_KR": "마지막 플레이:", + "no_NO": "Sist Spilt:", + "pl_PL": "Ostatnio grane:", + "pt_BR": "Último jogo:", + "ru_RU": "Последний запуск:", + "sv_SE": "Senast spelad:", + "th_TH": "เล่นล่าสุด:", + "tr_TR": "Son Oynama Tarihi:", + "uk_UA": "Востаннє зіграно:", + "zh_CN": "最近游玩:", + "zh_TW": "最近遊玩:" } }, { @@ -1843,7 +1843,7 @@ "th_TH": "", "tr_TR": "", "uk_UA": "", - "zh_CN": "", + "zh_CN": "兼容性:", "zh_TW": "" } }, @@ -1868,7 +1868,7 @@ "th_TH": "", "tr_TR": "", "uk_UA": "", - "zh_CN": "", + "zh_CN": "标题 ID:", "zh_TW": "" } }, @@ -1893,7 +1893,7 @@ "th_TH": "", "tr_TR": "", "uk_UA": "", - "zh_CN": "", + "zh_CN": "服务的游戏: {0}", "zh_TW": "" } }, @@ -1918,7 +1918,7 @@ "th_TH": "", "tr_TR": "", "uk_UA": "", - "zh_CN": "", + "zh_CN": "在线玩家: {0}", "zh_TW": "" } }, @@ -2268,7 +2268,7 @@ "th_TH": "", "tr_TR": "", "uk_UA": "", - "zh_CN": "", + "zh_CN": "清理 PPTC 缓存", "zh_TW": "" } }, @@ -2293,7 +2293,7 @@ "th_TH": "", "tr_TR": "", "uk_UA": "", - "zh_CN": "", + "zh_CN": "删除应用程序的所有 PPTC 缓存", "zh_TW": "" } }, @@ -2722,6 +2722,31 @@ "zh_TW": "建立桌面捷徑,啟動選取的應用程式" } }, + { + "ID": "GameListContextMenuEditGameConfiguration", + "Translations": { + "ar_SA": "تعديل تكوين اللعبة", + "de_DE": "Spielkonfiguration bearbeiten", + "el_GR": "Επεξεργασία ρυθμίσεων παιχνιδιού", + "en_US": "Edit Game Configuration", + "es_ES": "Editar configuración del juego", + "fr_FR": "Modifier la configuration du jeu", + "he_IL": "ערוך את הגדרת המשחק", + "it_IT": "Modifica configurazione gioco", + "ja_JP": "ゲーム設定を編集", + "ko_KR": "게임 설정 편집", + "no_NO": "Rediger spillkonfig.", + "pl_PL": "Edytuj konfigurację gry", + "pt_BR": "Editar configuração do jogo", + "ru_RU": "Редактировать конфигурацию игры", + "sv_SE": "Redigera spelkonfiguration", + "th_TH": "แก้ไขการตั้งค่าเกม", + "tr_TR": "Oyunun yapılandırmasını düzenle", + "uk_UA": "Редагувати конфігурацію гри", + "zh_CN": "编辑游戏配置", + "zh_TW": "編輯遊戲設定" + } + }, { "ID": "GameListContextMenuCreateShortcutToolTipMacOS", "Translations": { @@ -2793,7 +2818,7 @@ "th_TH": "", "tr_TR": "", "uk_UA": "", - "zh_CN": "", + "zh_CN": "显示兼容性项目", "zh_TW": "" } }, @@ -2818,7 +2843,7 @@ "th_TH": "", "tr_TR": "", "uk_UA": "", - "zh_CN": "", + "zh_CN": "在兼容性列表中显示选定的游戏,您通常可以通过帮助菜单访问。", "zh_TW": "" } }, @@ -2843,7 +2868,7 @@ "th_TH": "", "tr_TR": "", "uk_UA": "", - "zh_CN": "", + "zh_CN": "显示游戏信息", "zh_TW": "" } }, @@ -2868,7 +2893,7 @@ "th_TH": "", "tr_TR": "", "uk_UA": "", - "zh_CN": "", + "zh_CN": "显示当前选定游戏的状态与详细信息。", "zh_TW": "" } }, @@ -3297,6 +3322,31 @@ "zh_TW": "設定" } }, + { + "ID": "SettingsWithInfo", + "Translations": { + "ar_SA": "{0} - إعدادات", + "de_DE": "Einstellungen - {0}", + "el_GR": "Ρυθμίσεις - {0}", + "en_US": "Settings - {0}", + "es_ES": "Configuración - {0}", + "fr_FR": "Paramètres - {0}", + "he_IL": "{0} - הגדרות", + "it_IT": "Impostazioni - {0}", + "ja_JP": "設定 - {0}", + "ko_KR": "설정 - {0}", + "no_NO": "Innstillinger - {0}", + "pl_PL": "Ustawienia - {0}", + "pt_BR": "Configurações - {0}", + "ru_RU": "Параметры - {0}", + "sv_SE": "Inställningar - {0}", + "th_TH": "ตั้งค่า - {0}", + "tr_TR": "Ayarlar - {0}", + "uk_UA": "Налаштування - {0}", + "zh_CN": "设置 - {0}", + "zh_TW": "設定 - {0}" + } + }, { "ID": "SettingsTabGeneral", "Translations": { @@ -4518,7 +4568,7 @@ "th_TH": "", "tr_TR": "", "uk_UA": "", - "zh_CN": "", + "zh_CN": "与系统时间同步", "zh_TW": "" } }, @@ -6168,7 +6218,7 @@ "th_TH": "", "tr_TR": "", "uk_UA": "", - "zh_CN": "", + "zh_CN": "重置设置", "zh_TW": "" } }, @@ -6193,7 +6243,7 @@ "th_TH": "", "tr_TR": "", "uk_UA": "", - "zh_CN": "", + "zh_CN": "我要重置我的设置。", "zh_TW": "" } }, @@ -8168,7 +8218,7 @@ "th_TH": "", "tr_TR": "", "uk_UA": "", - "zh_CN": "", + "zh_CN": "彩虹滚动速度", "zh_TW": "" } }, @@ -13443,7 +13493,7 @@ "th_TH": "", "tr_TR": "", "uk_UA": "", - "zh_CN": "", + "zh_CN": "您正要清理 PPTC 数据:\n\n{0}\n\n您确实要继续吗?", "zh_TW": "" } }, @@ -23593,7 +23643,7 @@ "th_TH": "", "tr_TR": "", "uk_UA": "", - "zh_CN": "", + "zh_CN": "启动和游戏时不会出现任何崩溃或任何类型的 GPU bug 且速度足够快可以在一般 PC 上尽情游玩。", "zh_TW": "" } }, @@ -23618,7 +23668,7 @@ "th_TH": "", "tr_TR": "", "uk_UA": "", - "zh_CN": "", + "zh_CN": "可以成功启动并进入游戏但可能会遇到以下一种或多种问题: 崩溃、卡死、GPU bug、令人无法接受的音频,或者只是太慢。仍然可以继续进行游戏,但是可能无法达到预期。", "zh_TW": "" } }, @@ -23643,7 +23693,7 @@ "th_TH": "", "tr_TR": "", "uk_UA": "", - "zh_CN": "", + "zh_CN": "可以启动并通过标题画面但是无法进入到主要的游戏流程。", "zh_TW": "" } }, @@ -23668,7 +23718,7 @@ "th_TH": "", "tr_TR": "", "uk_UA": "", - "zh_CN": "", + "zh_CN": "可以启动但是无法通过标题画面。", "zh_TW": "" } }, @@ -23693,6 +23743,31 @@ "th_TH": "", "tr_TR": "", "uk_UA": "", + "zh_CN": "无法启动或显示无任何动静。", + "zh_TW": "" + } + }, + { + "ID": "UserConfigurationHeader", + "Translations": { + "ar_SA": "", + "de_DE": "", + "el_GR": "", + "en_US": "User Config", + "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": "" } @@ -23721,6 +23796,56 @@ "zh_CN": "选择一个要解压的 DLC", "zh_TW": "" } + }, + { + "ID": "GameInfoRpcImage", + "Translations": { + "ar_SA": "", + "de_DE": "", + "el_GR": "", + "en_US": "Rich Presence Image", + "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": "Rich Presence 图像", + "zh_TW": "" + } + }, + { + "ID": "GameInfoRpcDynamic", + "Translations": { + "ar_SA": "", + "de_DE": "", + "el_GR": "", + "en_US": "Dynamic Rich Presence", + "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": "动态 Rich Presence", + "zh_TW": "" + } } ] } \ No newline at end of file diff --git a/src/Ryujinx/DiscordIntegrationModule.cs b/src/Ryujinx/DiscordIntegrationModule.cs index d95bb80dd..1f820a223 100644 --- a/src/Ryujinx/DiscordIntegrationModule.cs +++ b/src/Ryujinx/DiscordIntegrationModule.cs @@ -10,6 +10,8 @@ using Ryujinx.Common.Logging; using Ryujinx.HLE; using Ryujinx.HLE.Loaders.Processes; using Ryujinx.Horizon; +using Ryujinx.Horizon.Prepo.Types; +using System.Linq; using System.Text; namespace Ryujinx.Ava @@ -37,6 +39,9 @@ namespace Ryujinx.Ava private static RichPresence _discordPresencePlaying; private static ApplicationMetadata _currentApp; + public static bool HasAssetImage(string titleId) => TitleIDs.DiscordGameAssetKeys.ContainsIgnoreCase(titleId); + public static bool HasAnalyzer(string titleId) => PlayReports.Analyzer.TitleIds.ContainsIgnoreCase(titleId); + public static void Initialize() { _discordPresenceMain = new RichPresence @@ -120,20 +125,22 @@ namespace Ryujinx.Ava _currentApp = null; } - private static void HandlePlayReport(MessagePackObject playReport) + private static void HandlePlayReport(PlayReport playReport) { if (_discordClient is null) return; if (!TitleIDs.CurrentApplication.Value.HasValue) return; if (_discordPresencePlaying is null) return; - Analyzer.FormattedValue formattedValue = + FormattedValue formattedValue = PlayReports.Analyzer.Format(TitleIDs.CurrentApplication.Value, _currentApp, playReport); if (!formattedValue.Handled) return; - _discordPresencePlaying.Details = formattedValue.Reset - ? $"Playing {_currentApp.Title}" - : formattedValue.FormattedString; + _discordPresencePlaying.Details = TruncateToByteLength( + formattedValue.Reset + ? $"Playing {_currentApp.Title}" + : formattedValue.FormattedString + ); if (_discordClient.CurrentPresence.Details.Equals(_discordPresencePlaying.Details)) return; //don't trigger an update if the set presence Details are identical to current diff --git a/src/Ryujinx/Program.cs b/src/Ryujinx/Program.cs index 39c2aea16..d86a9ea11 100644 --- a/src/Ryujinx/Program.cs +++ b/src/Ryujinx/Program.cs @@ -157,13 +157,14 @@ namespace Ryujinx.Ava } } - public static void ReloadGameConfig(string gamedir) + public static bool FindGameConfig(string gameDir) { - if (File.Exists(gamedir)) + if (File.Exists(gameDir)) { - ConfigurationPath = gamedir; + return true; } + return false; } public static string GetDirGameUserConfig(string gameId, bool rememberGlobalDir = false, bool changeFolderForGame = false) diff --git a/src/Ryujinx/UI/Controls/ApplicationContextMenu.axaml b/src/Ryujinx/UI/Controls/ApplicationContextMenu.axaml index 18a4067cf..0befd36af 100644 --- a/src/Ryujinx/UI/Controls/ApplicationContextMenu.axaml +++ b/src/Ryujinx/UI/Controls/ApplicationContextMenu.axaml @@ -21,7 +21,7 @@ ToolTip.Tip="{OnPlatform Default={ext:Locale GameListContextMenuCreateShortcutToolTip}, macOS={ext:Locale GameListContextMenuCreateShortcutToolTipMacOS}}" /> + + + + + + + + + + + + + + + + + + + - - - + + + + + + + + + diff --git a/src/Ryujinx/UI/Controls/ApplicationGridView.axaml b/src/Ryujinx/UI/Controls/ApplicationGridView.axaml index 7cf7b2e3c..62eb44b1a 100644 --- a/src/Ryujinx/UI/Controls/ApplicationGridView.axaml +++ b/src/Ryujinx/UI/Controls/ApplicationGridView.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:ext="clr-namespace:Ryujinx.Ava.Common.Markup" d:DesignHeight="450" d:DesignWidth="800" Focusable="True" @@ -80,7 +81,7 @@ TextWrapping="Wrap" /> @@ -101,7 +102,7 @@ Margin="15,35,5,15" HorizontalAlignment="Left" VerticalAlignment="Bottom" - Width="90" + Width="90" Height="20" CornerRadius="4" IsVisible="{Binding UserConfig}" @@ -109,9 +110,9 @@ + TextWrapping="Wrap" /> diff --git a/src/Ryujinx/UI/Controls/ApplicationListView.axaml b/src/Ryujinx/UI/Controls/ApplicationListView.axaml index e781d1fc2..bd89b2100 100644 --- a/src/Ryujinx/UI/Controls/ApplicationListView.axaml +++ b/src/Ryujinx/UI/Controls/ApplicationListView.axaml @@ -6,6 +6,7 @@ xmlns:helpers="clr-namespace:Ryujinx.Ava.UI.Helpers" xmlns:ui="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" + xmlns:ext="clr-namespace:Ryujinx.Ava.Common.Markup" d:DesignHeight="450" d:DesignWidth="800" Focusable="True" @@ -148,7 +149,7 @@ diff --git a/src/Ryujinx/UI/Helpers/Converters/PlayabilityStatusConverter.cs b/src/Ryujinx/UI/Helpers/Converters/PlayabilityStatusConverter.cs index 99c6a0fce..c8082ec62 100644 --- a/src/Ryujinx/UI/Helpers/Converters/PlayabilityStatusConverter.cs +++ b/src/Ryujinx/UI/Helpers/Converters/PlayabilityStatusConverter.cs @@ -18,7 +18,7 @@ namespace Ryujinx.Ava.UI.Helpers LocaleKeys.CompatibilityListNothing or LocaleKeys.CompatibilityListBoots or LocaleKeys.CompatibilityListMenus => Brushes.Red, - LocaleKeys.CompatibilityListIngame => Brushes.Yellow, + LocaleKeys.CompatibilityListIngame => Brushes.DarkOrange, _ => Brushes.ForestGreen }; diff --git a/src/Ryujinx/UI/ViewModels/ApplicationDataViewModel.cs b/src/Ryujinx/UI/ViewModels/ApplicationDataViewModel.cs index 9e0a3554a..592d5f81a 100644 --- a/src/Ryujinx/UI/ViewModels/ApplicationDataViewModel.cs +++ b/src/Ryujinx/UI/ViewModels/ApplicationDataViewModel.cs @@ -12,10 +12,7 @@ namespace Ryujinx.Ava.UI.ViewModels public string FormattedVersion => LocaleManager.Instance[LocaleKeys.GameListHeaderVersion].Format(AppData.Version); public string FormattedDeveloper => LocaleManager.Instance[LocaleKeys.GameListHeaderDeveloper].Format(AppData.Developer); - public string FormattedFileExtension => LocaleManager.Instance[LocaleKeys.GameListHeaderFileExtension].Format(AppData.FileExtension); - public string FormattedLastPlayed => LocaleManager.Instance[LocaleKeys.GameListHeaderLastPlayed].Format(AppData.LastPlayedString); - public string FormattedPlayTime => LocaleManager.Instance[LocaleKeys.GameListHeaderTimePlayed].Format(AppData.TimePlayedString); public string FormattedFileSize => LocaleManager.Instance[LocaleKeys.GameListHeaderFileSize].Format(AppData.FileSizeString); public string FormattedLdnInfo => diff --git a/src/Ryujinx/UI/ViewModels/MainWindowViewModel.cs b/src/Ryujinx/UI/ViewModels/MainWindowViewModel.cs index 61ece18dc..73455aa24 100644 --- a/src/Ryujinx/UI/ViewModels/MainWindowViewModel.cs +++ b/src/Ryujinx/UI/ViewModels/MainWindowViewModel.cs @@ -1523,8 +1523,34 @@ namespace Ryujinx.Ava.UI.ViewModels } } + public void InitializeUserConfig(ApplicationData application) + { + // Code where conditions will be met before loading the user configuration + BackendThreading backendThreadingValue = ConfigurationState.Instance.Graphics.BackendThreading.Value; + + // If a configuration is found in the "/games/xxxxxxxxxxxxxx" folder, the program will load the user setting. + string idGame = application.IdBaseString; + if (ConfigurationFileFormat.TryLoad(Program.GetDirGameUserConfig(idGame), out ConfigurationFileFormat configurationFileFormat)) + { + // Loads the user configuration, having previously changed the global configuration to the user configuration + ConfigurationState.Instance.Load(configurationFileFormat, Program.GetDirGameUserConfig(idGame, true, true), idGame); + } + + // Code where conditions will be executed after loading user configuration + if (ConfigurationState.Instance.Graphics.BackendThreading != backendThreadingValue) + { + /* + * The function to restart the emulator together with the selected game + Task.Run(async () => await Rebooter.RebootAppWithGame(application.Path)); + */ + } + } + public async Task LoadApplication(ApplicationData application, bool startFullscreen = false, BlitStruct? customNacpData = null) { + + InitializeUserConfig(application); + if (AppHost != null) { await ContentDialogHelper.CreateInfoDialog( @@ -1540,15 +1566,7 @@ namespace Ryujinx.Ava.UI.ViewModels #if RELEASE await PerformanceCheck(); #endif - // If a configuration is found in the "/games/xxxxxxxxxxxxxx" folder, the program will load the user setting. - string gameDir = Program.GetDirGameUserConfig(application.IdBaseString, true, true); - - if (ConfigurationFileFormat.TryLoad(gameDir, out ConfigurationFileFormat configurationFileFormat)) - { - //Program.GetDirGameUserConfig(application.IdBaseString, false); - ConfigurationState.Instance.Load(configurationFileFormat, gameDir, application.IdBaseString); - } - + Logger.RestartTime(); SelectedIcon ??= ApplicationLibrary.GetApplicationIcon(application.Path, ConfigurationState.Instance.System.Language, application.Id); diff --git a/src/Ryujinx/UI/ViewModels/SettingsViewModel.cs b/src/Ryujinx/UI/ViewModels/SettingsViewModel.cs index 8454eb142..3b82d9e5f 100644 --- a/src/Ryujinx/UI/ViewModels/SettingsViewModel.cs +++ b/src/Ryujinx/UI/ViewModels/SettingsViewModel.cs @@ -74,7 +74,6 @@ namespace Ryujinx.Ava.UI.ViewModels public string GameName { get; } private Bitmap _gameIcon; - private string _gameTitle; private string _gameId; public Bitmap GameIcon @@ -375,7 +374,7 @@ namespace Ryujinx.Ava.UI.ViewModels public bool IsInvalidLdnPassphraseVisible { get; set; } - public SettingsViewModel(VirtualFileSystem virtualFileSystem, ContentManager contentManager) : this() + public SettingsViewModel(VirtualFileSystem virtualFileSystem, ContentManager contentManager) : this(false) { _virtualFileSystem = virtualFileSystem; _contentManager = contentManager; @@ -388,7 +387,13 @@ namespace Ryujinx.Ava.UI.ViewModels } } - public SettingsViewModel(VirtualFileSystem virtualFileSystem, ContentManager contentManager, string gamePath, string gameName, string gameId, byte[] gameIconData) : this() + public SettingsViewModel(VirtualFileSystem virtualFileSystem, + ContentManager contentManager, + string gamePath, + string gameName, + string gameId, + byte[] gameIconData, + bool enableToLoadCustomConfig) : this(enableToLoadCustomConfig) { _virtualFileSystem = virtualFileSystem; _contentManager = contentManager; @@ -404,10 +409,14 @@ namespace Ryujinx.Ava.UI.ViewModels GameTitle = gameName; GameId = gameId; - string gameDir = Program.GetDirGameUserConfig(gameId,false,true); - if (ConfigurationFileFormat.TryLoad(gameDir, out ConfigurationFileFormat configurationFileFormat)) + if (enableToLoadCustomConfig) // During the game. If there is no user config, then load the global config window { - ConfigurationState.Instance.Load(configurationFileFormat, gameDir, gameId); + string gameDir = Program.GetDirGameUserConfig(gameId, false, true); + if (ConfigurationFileFormat.TryLoad(gameDir, out ConfigurationFileFormat configurationFileFormat)) + { + ConfigurationState.Instance.Load(configurationFileFormat, gameDir, gameId); + } + LoadCurrentConfiguration(); // Needed to load custom configuration } @@ -419,7 +428,7 @@ namespace Ryujinx.Ava.UI.ViewModels } } - public SettingsViewModel() + public SettingsViewModel(bool noLoadGlobalConfig = false) { GameDirectories = []; AutoloadDirectories = []; @@ -434,7 +443,11 @@ namespace Ryujinx.Ava.UI.ViewModels if (Program.PreviewerDetached) { Task.Run(LoadAvailableGpus); - LoadCurrentConfiguration(); + + if (!noLoadGlobalConfig)// Default is false, but loading custom config avoids double call + { + LoadCurrentConfiguration(); + } DirtyHacks = new SettingsHacksViewModel(this); } @@ -545,7 +558,8 @@ namespace Ryujinx.Ava.UI.ViewModels { ConfigurationState config = ConfigurationState.Instance; - if (string.IsNullOrEmpty(GameId)) + //It is necessary that the data is used from the global configuration file + if (string.IsNullOrEmpty(GameId)) { // User Interface EnableDiscordIntegration = config.EnableDiscordIntegration; @@ -568,6 +582,7 @@ namespace Ryujinx.Ava.UI.ViewModels "Dark" => 2, _ => 0 }; + } // Input @@ -588,7 +603,6 @@ namespace Ryujinx.Ava.UI.ViewModels DateTime currentDateTime = currentHostDateTime.Add(systemDateTimeOffset); CurrentDate = currentDateTime.Date; CurrentTime = currentDateTime.TimeOfDay; - MatchSystemTime = config.System.MatchSystemTime; EnableCustomVSyncInterval = config.Graphics.EnableCustomVSyncInterval; @@ -648,53 +662,13 @@ namespace Ryujinx.Ava.UI.ViewModels LdnPassphrase = config.Multiplayer.LdnPassphrase; LdnServer = config.Multiplayer.LdnServer; } - - public void SaveSettings2() - { - ConfigurationState config = ConfigurationState.Instance; - - // User Interface - config.EnableDiscordIntegration.Value = EnableDiscordIntegration; - config.CheckUpdatesOnStart.Value = CheckUpdatesOnStart; - config.ShowConfirmExit.Value = ShowConfirmExit; - config.RememberWindowState.Value = RememberWindowState; - config.ShowTitleBar.Value = ShowTitleBar; - config.HideCursor.Value = (HideCursorMode)HideCursor; - - if (GameDirectoryChanged) - { - config.UI.GameDirs.Value = [.. GameDirectories]; - } - - if (AutoloadDirectoryChanged) - { - config.UI.AutoloadDirs.Value = [.. AutoloadDirectories]; - } - - config.UI.BaseStyle.Value = BaseStyleIndex switch - { - 0 => "Auto", - 1 => "Light", - 2 => "Dark", - _ => "Auto" - }; - - if (!string.IsNullOrEmpty(GameId)) - { - config.ToFileFormat().SaveConfig(Program.ConfigurationPath); - } - else - { - config.ToFileFormat().SaveConfig(Program.ConfigurationPath); - } - } - + public void SaveSettings() { ConfigurationState config = ConfigurationState.Instance; + bool userConfigFile = string.IsNullOrEmpty(GameId); - - if (string.IsNullOrEmpty(GameId)) + if (userConfigFile) { // User Interface config.EnableDiscordIntegration.Value = EnableDiscordIntegration; @@ -844,6 +818,26 @@ namespace Ryujinx.Ava.UI.ViewModels SaveSettings(); } + public void DeleteConfigGame() + { + string gameDir = Program.GetDirGameUserConfig(GameId,false,false); + + if (File.Exists(gameDir)) + { + File.Delete(gameDir); + } + + RevertIfNotSaved(); + CloseWindow?.Invoke(); + } + + public void SaveUserConfig() + { + SaveSettings(); + RevertIfNotSaved(); // Revert global configuration after saving user configuration + CloseWindow?.Invoke(); + } + public void DeleteConfigGame() { string gameDir = Program.GetDirGameUserConfig(GameId,false,false); diff --git a/src/Ryujinx/UI/Views/Main/MainMenuBarView.axaml.cs b/src/Ryujinx/UI/Views/Main/MainMenuBarView.axaml.cs index a0bcd1aa2..4db662f0a 100644 --- a/src/Ryujinx/UI/Views/Main/MainMenuBarView.axaml.cs +++ b/src/Ryujinx/UI/Views/Main/MainMenuBarView.axaml.cs @@ -134,9 +134,26 @@ namespace Ryujinx.Ava.UI.Views.Main Window.SettingsWindow = new(Window.VirtualFileSystem, Window.ContentManager); Rainbow.Enable(); - - await Window.SettingsWindow.ShowDialog(Window); - + + if (ViewModel.SelectedApplication is null) // Checks if game data exists + { + await Window.SettingsWindow.ShowDialog(Window); + } + else + { + bool userConfigExist = Program.FindGameConfig(Program.GetDirGameUserConfig(ViewModel.SelectedApplication.IdString, false, false)); + + if (!ViewModel.IsGameRunning || !userConfigExist) + { + await Window.SettingsWindow.ShowDialog(Window); // The game is not running, or if the user configuration does not exist + } + else + { + // If there is a custom configuration in the folder + await new UserConfigWindows(ViewModel, userConfigExist).ShowDialog((Window)ViewModel.TopLevel); + } + } + Rainbow.Disable(); Rainbow.Reset(); diff --git a/src/Ryujinx/UI/Windows/MainWindow.axaml.cs b/src/Ryujinx/UI/Windows/MainWindow.axaml.cs index 669e338df..120b82ff3 100644 --- a/src/Ryujinx/UI/Windows/MainWindow.axaml.cs +++ b/src/Ryujinx/UI/Windows/MainWindow.axaml.cs @@ -645,6 +645,11 @@ namespace Ryujinx.Ava.UI.Windows ReloadGameList(); } + public void GameListUpdate() + { + ReloadGameList(); + } + public void ToggleFileType(string fileType) { switch (fileType) diff --git a/src/Ryujinx/UI/Windows/UserConfigWindows.axaml b/src/Ryujinx/UI/Windows/UserConfigWindows.axaml index f768ec168..ed3ab66f8 100644 --- a/src/Ryujinx/UI/Windows/UserConfigWindows.axaml +++ b/src/Ryujinx/UI/Windows/UserConfigWindows.axaml @@ -29,6 +29,7 @@ IsVisible="False" KeyboardNavigation.IsTabStop="False"/> + @@ -79,6 +80,10 @@ + PlayerCount != 0 && GameCount != 0; + + public bool HasRichPresenceAsset => DiscordIntegrationModule.HasAssetImage(IdString); + public bool HasDynamicRichPresenceSupport => DiscordIntegrationModule.HasAnalyzer(IdString); public TimeSpan TimePlayed { get; set; } public DateTime? LastPlayed { get; set; } diff --git a/src/Ryujinx/Utilities/AppLibrary/ApplicationLibrary.cs b/src/Ryujinx/Utilities/AppLibrary/ApplicationLibrary.cs index 05abcdf8d..ac402a00e 100644 --- a/src/Ryujinx/Utilities/AppLibrary/ApplicationLibrary.cs +++ b/src/Ryujinx/Utilities/AppLibrary/ApplicationLibrary.cs @@ -533,7 +533,7 @@ namespace Ryujinx.Ava.Utilities.AppLibrary data.Favorite = appMetadata.Favorite; data.TimePlayed = appMetadata.TimePlayed; data.LastPlayed = appMetadata.LastPlayed; - data.UserConfig = File.Exists(Program.GetDirGameUserConfig(data.IdBaseString, false, false)); // just check for file presence + data.UserConfig = File.Exists(Program.GetDirGameUserConfig(data.IdBaseString, false, false)); // Just check user config } data.FileExtension = Path.GetExtension(applicationPath).TrimStart('.').ToUpper(); diff --git a/src/Ryujinx/Utilities/Configuration/ConfigurationState.Migration.cs b/src/Ryujinx/Utilities/Configuration/ConfigurationState.Migration.cs index 0a355a0ed..8a1b03853 100644 --- a/src/Ryujinx/Utilities/Configuration/ConfigurationState.Migration.cs +++ b/src/Ryujinx/Utilities/Configuration/ConfigurationState.Migration.cs @@ -49,13 +49,13 @@ namespace Ryujinx.Ava.Utilities.Configuration configurationFileUpdated = true; } - EnableDiscordIntegration.Value = LoadSetting ? cff.EnableDiscordIntegration : EnableDiscordIntegration.Value; - CheckUpdatesOnStart.Value = LoadSetting ? cff.CheckUpdatesOnStart : CheckUpdatesOnStart.Value; - ShowConfirmExit.Value = LoadSetting ? cff.ShowConfirmExit : ShowConfirmExit.Value; - RememberWindowState.Value = LoadSetting ? cff.RememberWindowState : RememberWindowState.Value; - ShowTitleBar.Value = LoadSetting ? cff.ShowTitleBar : ShowTitleBar.Value; - EnableHardwareAcceleration.Value = LoadSetting ? cff.EnableHardwareAcceleration : EnableHardwareAcceleration.Value; - HideCursor.Value = LoadSetting ? cff.HideCursor : HideCursor.Value; + EnableDiscordIntegration.Value = LoadSetting ? cff.EnableDiscordIntegration : EnableDiscordIntegration.Value; // Get from global config only + CheckUpdatesOnStart.Value = LoadSetting ? cff.CheckUpdatesOnStart : CheckUpdatesOnStart.Value; // Get from global config only + ShowConfirmExit.Value = LoadSetting ? cff.ShowConfirmExit : ShowConfirmExit.Value; // Get from global config only + RememberWindowState.Value = LoadSetting ? cff.RememberWindowState : RememberWindowState.Value; // Get from global config only + ShowTitleBar.Value = LoadSetting ? cff.ShowTitleBar : ShowTitleBar.Value; // Get from global config only + EnableHardwareAcceleration.Value = LoadSetting ? cff.EnableHardwareAcceleration : EnableHardwareAcceleration.Value; // Get from global config only + HideCursor.Value = LoadSetting ? cff.HideCursor : HideCursor.Value; // Get from global config only Logger.EnableFileLog.Value = cff.EnableFileLog; Logger.EnableDebug.Value = cff.LoggingEnableDebug; @@ -91,7 +91,7 @@ namespace Ryujinx.Ava.Utilities.Configuration System.Language.Value = cff.SystemLanguage; System.Region.Value = cff.SystemRegion; System.TimeZone.Value = cff.SystemTimeZone; - System.SystemTimeOffset.Value = cff.SystemTimeOffset; + System.SystemTimeOffset.Value = LoadSetting ? cff.SystemTimeOffset : System.SystemTimeOffset.Value; // Get from global config only System.EnableDockedMode.Value = cff.DockedMode; System.EnablePtc.Value = cff.EnablePtc; System.EnableLowPowerPtc.Value = cff.EnableLowPowerPtc; @@ -145,7 +145,7 @@ namespace Ryujinx.Ava.Utilities.Configuration Hid.EnableKeyboard.Value = cff.EnableKeyboard; Hid.EnableMouse.Value = cff.EnableMouse; - Hid.Hotkeys.Value = cff.Hotkeys; + Hid.Hotkeys.Value = LoadSetting ? cff.Hotkeys : Hid.Hotkeys.Value; // Get from global config only Hid.InputConfig.Value = cff.InputConfig ?? []; Hid.RainbowSpeed.Value = cff.RainbowSpeed; diff --git a/src/Ryujinx/Utilities/PlayReport/Analyzer.cs b/src/Ryujinx/Utilities/PlayReport/Analyzer.cs index 84bdbf085..0b5284673 100644 --- a/src/Ryujinx/Utilities/PlayReport/Analyzer.cs +++ b/src/Ryujinx/Utilities/PlayReport/Analyzer.cs @@ -3,6 +3,7 @@ using MsgPack; using Ryujinx.Ava.Utilities.AppLibrary; using System; using System.Collections.Generic; +using System.Collections.ObjectModel; using System.Globalization; using System.Linq; @@ -15,6 +16,10 @@ namespace Ryujinx.Ava.Utilities.PlayReport { private readonly List _specs = []; + public string[] TitleIds => Specs.SelectMany(x => x.TitleIds).ToArray(); + + public IReadOnlyList Specs => new ReadOnlyCollection(_specs); + /// /// Add an analysis spec matching a specific game by title ID, with the provided spec configuration. /// @@ -26,8 +31,7 @@ namespace Ryujinx.Ava.Utilities.PlayReport Guard.Ensure(ulong.TryParse(titleId, NumberStyles.HexNumber, null, out _), $"Cannot use a non-hexadecimal string as the Title ID for a {nameof(GameSpec)}."); - _specs.Add(transform(new GameSpec { TitleIds = [titleId] })); - return this; + return AddSpec(transform(GameSpec.Create(titleId))); } /// @@ -41,8 +45,7 @@ namespace Ryujinx.Ava.Utilities.PlayReport Guard.Ensure(ulong.TryParse(titleId, NumberStyles.HexNumber, null, out _), $"Cannot use a non-hexadecimal string as the Title ID for a {nameof(GameSpec)}."); - _specs.Add(new GameSpec { TitleIds = [titleId] }.Apply(transform)); - return this; + return AddSpec(GameSpec.Create(titleId).Apply(transform)); } /// @@ -58,8 +61,7 @@ namespace Ryujinx.Ava.Utilities.PlayReport Guard.Ensure(tids.All(x => ulong.TryParse(x, NumberStyles.HexNumber, null, out _)), $"Cannot use a non-hexadecimal string as the Title ID for a {nameof(GameSpec)}."); - _specs.Add(transform(new GameSpec { TitleIds = [..tids] })); - return this; + return AddSpec(transform(GameSpec.Create(tids))); } /// @@ -74,13 +76,23 @@ namespace Ryujinx.Ava.Utilities.PlayReport Guard.Ensure(tids.All(x => ulong.TryParse(x, NumberStyles.HexNumber, null, out _)), $"Cannot use a non-hexadecimal string as the Title ID for a {nameof(GameSpec)}."); - _specs.Add(new GameSpec { TitleIds = [..tids] }.Apply(transform)); + return AddSpec(GameSpec.Create(tids).Apply(transform)); + } + + /// + /// Add an analysis spec matching a specific game by title ID, with the provided pre-configured spec. + /// + /// The to add. + /// The current , for chaining convenience. + public Analyzer AddSpec(GameSpec spec) + { + _specs.Add(spec); return this; } - + /// - /// Runs the configured for the specified game title ID. + /// Runs the configured for the specified game title ID. /// /// The game currently running. /// The Application metadata information, including localized game name and play time information. @@ -89,270 +101,24 @@ namespace Ryujinx.Ava.Utilities.PlayReport public FormattedValue Format( string runningGameId, ApplicationMetadata appMeta, - MessagePackObject playReport + Horizon.Prepo.Types.PlayReport playReport ) { - if (!playReport.IsDictionary) + if (!playReport.ReportData.IsDictionary) return FormattedValue.Unhandled; if (!_specs.TryGetFirst(s => runningGameId.EqualsAnyIgnoreCase(s.TitleIds), out GameSpec spec)) return FormattedValue.Unhandled; - foreach (GameSpec.FormatterSpec formatSpec in spec.SimpleValueFormatters.OrderBy(x => x.Priority)) + foreach (FormatterSpecBase formatSpec in spec.ValueFormatters.OrderBy(x => x.Priority)) { - if (!playReport.AsDictionary().TryGetValue(formatSpec.ReportKey, out MessagePackObject valuePackObject)) + if (!formatSpec.Format(appMeta, playReport, out FormattedValue value)) continue; - return formatSpec.ValueFormatter(new Value - { - Application = appMeta, PackedValue = valuePackObject - }); - } - - foreach (GameSpec.MultiFormatterSpec formatSpec in spec.MultiValueFormatters.OrderBy(x => x.Priority)) - { - List packedObjects = []; - foreach (var reportKey in formatSpec.ReportKeys) - { - if (!playReport.AsDictionary().TryGetValue(reportKey, out MessagePackObject valuePackObject)) - continue; - - packedObjects.Add(valuePackObject); - } - - if (packedObjects.Count != formatSpec.ReportKeys.Length) - return FormattedValue.Unhandled; - - return formatSpec.ValueFormatter(packedObjects - .Select(packObject => new Value { Application = appMeta, PackedValue = packObject }) - .ToArray()); + return value; } return FormattedValue.Unhandled; } - - /// - /// A potential formatted value returned by a . - /// - public readonly struct FormattedValue - { - /// - /// Was any handler able to match anything in the Play Report? - /// - public bool Handled { get; private init; } - - /// - /// Did the handler request the caller of the to reset the existing value? - /// - public bool Reset { get; private init; } - - /// - /// The formatted value, only present if is true, and is false. - /// - public string FormattedString { get; private init; } - - /// - /// The intended path of execution for having a string to return: simply return the string. - /// This implicit conversion will make the struct for you.

- /// - /// If the input is null, is returned. - ///
- /// The formatted string value. - /// The automatically constructed struct. - public static implicit operator FormattedValue(string formattedValue) - => formattedValue is not null - ? new FormattedValue { Handled = true, FormattedString = formattedValue } - : Unhandled; - - /// - /// Return this to tell the caller there is no value to return. - /// - public static FormattedValue Unhandled => default; - - /// - /// Return this to suggest the caller reset the value it's using the for. - /// - public static FormattedValue ForceReset => new() { Handled = true, Reset = true }; - - /// - /// A delegate singleton you can use to always return in a . - /// - public static readonly ValueFormatter AlwaysResets = _ => ForceReset; - - /// - /// A delegate factory you can use to always return the specified - /// in a . - /// - /// The string to always return for this delegate instance. - public static ValueFormatter AlwaysReturns(string formattedValue) => _ => formattedValue; - } } - - /// - /// A mapping of title IDs to value formatter specs. - /// - /// Generally speaking, use the .AddSpec(...) methods instead of creating this class yourself. - /// - public class GameSpec - { - public required string[] TitleIds { get; init; } - public List SimpleValueFormatters { get; } = []; - public List MultiValueFormatters { get; } = []; - - /// - /// Add a value formatter to the current - /// matching a specific key that could exist in a Play Report for the previously specified title IDs. - /// - /// The key name to match. - /// The function which can return a potential formatted value. - /// The current , for chaining convenience. - public GameSpec AddValueFormatter(string reportKey, ValueFormatter valueFormatter) - { - SimpleValueFormatters.Add(new FormatterSpec - { - Priority = SimpleValueFormatters.Count, ReportKey = reportKey, ValueFormatter = valueFormatter - }); - return this; - } - - /// - /// Add a value formatter at a specific priority to the current - /// matching a specific key that could exist in a Play Report for the previously specified title IDs. - /// - /// The resolution priority of this value formatter. Higher resolves sooner. - /// The key name to match. - /// The function which can return a potential formatted value. - /// The current , for chaining convenience. - public GameSpec AddValueFormatter(int priority, string reportKey, - ValueFormatter valueFormatter) - { - SimpleValueFormatters.Add(new FormatterSpec - { - Priority = priority, ReportKey = reportKey, ValueFormatter = valueFormatter - }); - return this; - } - - /// - /// Add a multi-value formatter to the current - /// matching a specific set of keys that could exist in a Play Report for the previously specified title IDs. - /// - /// The key names to match. - /// The function which can format the values. - /// The current , for chaining convenience. - public GameSpec AddMultiValueFormatter(string[] reportKeys, MultiValueFormatter valueFormatter) - { - MultiValueFormatters.Add(new MultiFormatterSpec - { - Priority = SimpleValueFormatters.Count, ReportKeys = reportKeys, ValueFormatter = valueFormatter - }); - return this; - } - - /// - /// Add a multi-value formatter at a specific priority to the current - /// matching a specific set of keys that could exist in a Play Report for the previously specified title IDs. - /// - /// The resolution priority of this value formatter. Higher resolves sooner. - /// The key names to match. - /// The function which can format the values. - /// The current , for chaining convenience. - public GameSpec AddMultiValueFormatter(int priority, string[] reportKeys, - MultiValueFormatter valueFormatter) - { - MultiValueFormatters.Add(new MultiFormatterSpec - { - Priority = priority, ReportKeys = reportKeys, ValueFormatter = valueFormatter - }); - return this; - } - - /// - /// A struct containing the data for a mapping of a key in a Play Report to a formatter for its potential value. - /// - public struct FormatterSpec - { - public required int Priority { get; init; } - public required string ReportKey { get; init; } - public ValueFormatter ValueFormatter { get; init; } - } - - /// - /// A struct containing the data for a mapping of an arbitrary key set in a Play Report to a formatter for their potential values. - /// - public struct MultiFormatterSpec - { - public required int Priority { get; init; } - public required string[] ReportKeys { get; init; } - public MultiValueFormatter ValueFormatter { get; init; } - } - } - - /// - /// The input data to a , - /// containing the currently running application's , - /// and the matched from the Play Report. - /// - public class Value - { - /// - /// The currently running application's . - /// - public ApplicationMetadata Application { get; init; } - - /// - /// The matched value from the Play Report. - /// - public MessagePackObject PackedValue { get; init; } - - /// - /// Access the as its underlying .NET type.
- /// - /// Does not seem to work well with comparing numeric types, - /// so use XValue properties for that. - ///
- public object BoxedValue => PackedValue.ToObject(); - - #region AsX accessors - - public bool BooleanValue => PackedValue.AsBoolean(); - public byte ByteValye => PackedValue.AsByte(); - public sbyte SByteValye => PackedValue.AsSByte(); - public short ShortValye => PackedValue.AsInt16(); - public ushort UShortValye => PackedValue.AsUInt16(); - public int IntValye => PackedValue.AsInt32(); - public uint UIntValye => PackedValue.AsUInt32(); - public long LongValye => PackedValue.AsInt64(); - public ulong ULongValye => PackedValue.AsUInt64(); - public float FloatValue => PackedValue.AsSingle(); - public double DoubleValue => PackedValue.AsDouble(); - public string StringValue => PackedValue.AsString(); - public Span BinaryValue => PackedValue.AsBinary(); - - #endregion - } - - /// - /// The delegate type that powers single value formatters.
- /// Takes in the result value from the Play Report, and outputs: - ///
- /// a formatted string, - ///
- /// a signal that nothing was available to handle it, - ///
- /// OR a signal to reset the value that the caller is using the for. - ///
- public delegate Analyzer.FormattedValue ValueFormatter(Value value); - - /// - /// The delegate type that powers multiple value formatters.
- /// Takes in the result value from the Play Report, and outputs: - ///
- /// a formatted string, - ///
- /// a signal that nothing was available to handle it, - ///
- /// OR a signal to reset the value that the caller is using the for. - ///
- public delegate Analyzer.FormattedValue MultiValueFormatter(Value[] value); } diff --git a/src/Ryujinx/Utilities/PlayReport/Delegates.cs b/src/Ryujinx/Utilities/PlayReport/Delegates.cs new file mode 100644 index 000000000..92569d32e --- /dev/null +++ b/src/Ryujinx/Utilities/PlayReport/Delegates.cs @@ -0,0 +1,40 @@ +namespace Ryujinx.Ava.Utilities.PlayReport +{ + /// + /// The delegate type that powers single value formatters.
+ /// Takes in the result value from the Play Report, and outputs: + ///
+ /// a formatted string, + ///
+ /// a signal that nothing was available to handle it, + ///
+ /// OR a signal to reset the value that the caller is using the for. + ///
+ public delegate FormattedValue SingleValueFormatter(SingleValue value); + + /// + /// The delegate type that powers multiple value formatters.
+ /// Takes in the result values from the Play Report, and outputs: + ///
+ /// a formatted string, + ///
+ /// a signal that nothing was available to handle it, + ///
+ /// OR a signal to reset the value that the caller is using the for. + ///
+ public delegate FormattedValue MultiValueFormatter(MultiValue value); + + /// + /// The delegate type that powers multiple value formatters. + /// The dictionary passed to this delegate is sparsely populated; + /// that is, not every key specified in the Play Report needs to match for this to be used.
+ /// Takes in the result values from the Play Report, and outputs: + ///
+ /// a formatted string, + ///
+ /// a signal that nothing was available to handle it, + ///
+ /// OR a signal to reset the value that the caller is using the for. + ///
+ public delegate FormattedValue SparseMultiValueFormatter(SparseMultiValue value); +} diff --git a/src/Ryujinx/Utilities/PlayReport/MatchedValues.cs b/src/Ryujinx/Utilities/PlayReport/MatchedValues.cs new file mode 100644 index 000000000..3086a9d65 --- /dev/null +++ b/src/Ryujinx/Utilities/PlayReport/MatchedValues.cs @@ -0,0 +1,74 @@ +using MsgPack; +using Ryujinx.Ava.Utilities.AppLibrary; +using System.Collections.Generic; +using System.Linq; + +namespace Ryujinx.Ava.Utilities.PlayReport +{ + public abstract class MatchedValue + { + protected MatchedValue(T matched) + { + Matched = matched; + } + + /// + /// The currently running application's . + /// + public ApplicationMetadata Application { get; init; } + + /// + /// The entire play report. + /// + public Horizon.Prepo.Types.PlayReport PlayReport { get; init; } + + /// + /// The matched value from the Play Report. + /// + public T Matched { get; init; } + } + + /// + /// The input data to a , + /// containing the currently running application's , + /// and the matched from the Play Report. + /// + public class SingleValue : MatchedValue + { + public SingleValue(Value matched) : base(matched) + { + } + } + + /// + /// The input data to a , + /// containing the currently running application's , + /// and the matched s from the Play Report. + /// + public class MultiValue : MatchedValue + { + public MultiValue(Value[] matched) : base(matched) + { + } + + public MultiValue(IEnumerable matched) : base(Value.ConvertPackedObjects(matched)) + { + } + } + + /// + /// The input data to a , + /// containing the currently running application's , + /// and the matched s from the Play Report. + /// + public class SparseMultiValue : MatchedValue> + { + public SparseMultiValue(Dictionary matched) : base(matched) + { + } + + public SparseMultiValue(Dictionary matched) : base(Value.ConvertPackedObjectMap(matched)) + { + } + } +} diff --git a/src/Ryujinx/Utilities/PlayReport/PlayReports.Formatters.cs b/src/Ryujinx/Utilities/PlayReport/PlayReports.Formatters.cs new file mode 100644 index 000000000..f0d7f87ba --- /dev/null +++ b/src/Ryujinx/Utilities/PlayReport/PlayReports.Formatters.cs @@ -0,0 +1,626 @@ +using System; +using System.Buffers.Binary; +using System.Collections.Generic; +using System.Linq; + +namespace Ryujinx.Ava.Utilities.PlayReport +{ + public partial class PlayReports + { + private static FormattedValue BreathOfTheWild_MasterMode(SingleValue value) + => value.Matched.BoxedValue is 1 ? "Playing Master Mode" : FormattedValue.ForceReset; + + private static FormattedValue TearsOfTheKingdom_CurrentField(SingleValue value) => + value.Matched.DoubleValue switch + { + > 800d => "Exploring the Sky Islands", + < -201d => "Exploring the Depths", + _ => "Roaming Hyrule" + }; + + private static FormattedValue SuperMarioOdyssey_AssistMode(SingleValue value) + => value.Matched.BoxedValue is 1 ? "Playing in Assist Mode" : "Playing in Regular Mode"; + + private static FormattedValue SuperMarioOdysseyChina_AssistMode(SingleValue value) + => value.Matched.BoxedValue is 1 ? "Playing in 帮助模式" : "Playing in 普通模式"; + + private static FormattedValue SuperMario3DWorldOrBowsersFury(SingleValue value) + => value.Matched.BoxedValue is 0 ? "Playing Super Mario 3D World" : "Playing Bowser's Fury"; + + private static FormattedValue MarioKart8Deluxe_Mode(SingleValue value) + => value.Matched.StringValue switch + { + // Single Player + "Single" => "Single Player", + // Multiplayer + "Multi-2players" => "Multiplayer 2 Players", + "Multi-3players" => "Multiplayer 3 Players", + "Multi-4players" => "Multiplayer 4 Players", + // Wireless/LAN Play + "Local-Single" => "Wireless/LAN Play", + "Local-2players" => "Wireless/LAN Play 2 Players", + // CC Classes + "50cc" => "50cc", + "100cc" => "100cc", + "150cc" => "150cc", + "Mirror" => "Mirror (150cc)", + "200cc" => "200cc", + // Modes + "GrandPrix" => "Grand Prix", + "TimeAttack" => "Time Trials", + "VS" => "VS Races", + "Battle" => "Battle Mode", + "RaceStart" => "Selecting a Course", + "Race" => "Racing", + _ => FormattedValue.ForceReset + }; + + private static FormattedValue PokemonSVUnionCircle(SingleValue value) + => value.Matched.BoxedValue is 0 ? "Playing Alone" : "Playing in a group"; + + private static FormattedValue PokemonSVArea(SingleValue value) + => value.Matched.StringValue switch + { + // Base Game Locations + "a_w01" => "South Area One", + "a_w02" => "Mesagoza", + "a_w03" => "The Pokemon League", + "a_w04" => "South Area Two", + "a_w05" => "South Area Four", + "a_w06" => "South Area Six", + "a_w07" => "South Area Five", + "a_w08" => "South Area Three", + "a_w09" => "West Area One", + "a_w10" => "Asado Desert", + "a_w11" => "West Area Two", + "a_w12" => "Medali", + "a_w13" => "Tagtree Thicket", + "a_w14" => "East Area Three", + "a_w15" => "Artazon", + "a_w16" => "East Area Two", + "a_w18" => "Casseroya Lake", + "a_w19" => "Glaseado Mountain", + "a_w20" => "North Area Three", + "a_w21" => "North Area One", + "a_w22" => "North Area Two", + "a_w23" => "The Great Crater of Paldea", + "a_w24" => "South Paldean Sea", + "a_w25" => "West Paldean Sea", + "a_w26" => "East Paldean Sea", + "a_w27" => "Nouth Paldean Sea", + //TODO DLC Locations + _ => FormattedValue.ForceReset + }; + + private static FormattedValue SuperSmashBrosUltimate_Mode(SparseMultiValue values) + { + // Check if the PlayReport is for a challenger approach or an achievement. + if (values.Matched.TryGetValue("fighter", out Value fighter) && values.Matched.ContainsKey("reason")) + { + return $"Challenger Approaches - {SuperSmashBrosUltimate_Character(fighter)}"; + } + + if (values.Matched.TryGetValue("fighter", out fighter) && values.Matched.ContainsKey("challenge_count")) + { + return $"Fighter Unlocked - {SuperSmashBrosUltimate_Character(fighter)}"; + } + + if (values.Matched.TryGetValue("anniversary", out Value anniversary)) + { + return $"Achievement Unlocked - ID: {anniversary}"; + } + + if (values.Matched.ContainsKey("adv_slot")) + { + return + "Playing Adventure Mode"; // Doing this as it can be a placeholder until we can grab the character. + } + + // Check if we have a match_mode at this point, if not, go to default. + if (!values.Matched.TryGetValue("match_mode", out Value matchMode)) + { + return "Smashing"; + } + + return matchMode.BoxedValue switch + { + 0 when values.Matched.TryGetValue("player_1_fighter", out Value player) && + values.Matched.TryGetValue("player_2_fighter", out Value challenger) + => $"Last Smashed: {SuperSmashBrosUltimate_Character(challenger)}'s Fighter Challenge - {SuperSmashBrosUltimate_Character(player)}", + 1 => $"Last Smashed: Normal Battle - {SuperSmashBrosUltimate_PlayerListing(values)}", + 2 when values.Matched.TryGetValue("player_1_rank", out Value team) + => team.BoxedValue is 0 + ? "Last Smashed: Squad Strike - Red Team Wins" + : "Last Smashed: Squad Strike - Blue Team Wins", + 3 => $"Last Smashed: Custom Smash - {SuperSmashBrosUltimate_PlayerListing(values)}", + 4 => $"Last Smashed: Super Sudden Death - {SuperSmashBrosUltimate_PlayerListing(values)}", + 5 => $"Last Smashed: Smashdown - {SuperSmashBrosUltimate_PlayerListing(values)}", + 6 => $"Last Smashed: Tourney Battle - {SuperSmashBrosUltimate_PlayerListing(values)}", + 7 when values.Matched.TryGetValue("player_1_fighter", out Value player) + => $"Last Smashed: Spirit Board Battle as {SuperSmashBrosUltimate_Character(player)}", + 8 when values.Matched.TryGetValue("player_1_fighter", out Value player) + => $"Playing Adventure Mode as {SuperSmashBrosUltimate_Character(player)}", + 10 when values.Matched.TryGetValue("match_submode", out Value battle) && + values.Matched.TryGetValue("player_1_fighter", out Value player) + => $"Last Smashed: Classic Mode, Battle {(int)battle.BoxedValue + 1}/8 as {SuperSmashBrosUltimate_Character(player)}", + 12 => $"Last Smashed: Century Smash - {SuperSmashBrosUltimate_PlayerListing(values)}", + 13 => $"Last Smashed: All-Star Smash - {SuperSmashBrosUltimate_PlayerListing(values)}", + 14 => $"Last Smashed: Cruel Smash - {SuperSmashBrosUltimate_PlayerListing(values)}", + 15 when values.Matched.TryGetValue("player_1_fighter", out Value player) + => $"Last Smashed: Home-Run Contest - {SuperSmashBrosUltimate_Character(player)}", + 16 when values.Matched.TryGetValue("player_1_fighter", out Value player1) && + values.Matched.TryGetValue("player_2_fighter", out Value player2) + => $"Last Smashed: Home-Run Content (Co-op) - {SuperSmashBrosUltimate_Character(player1)} and {SuperSmashBrosUltimate_Character(player2)}", + 17 => $"Last Smashed: Home-Run Contest (Versus) - {SuperSmashBrosUltimate_PlayerListing(values)}", + 18 when values.Matched.TryGetValue("player_1_fighter", out Value player1) && + values.Matched.TryGetValue("player_2_fighter", out Value player2) + => $"Fresh out of Training mode - {SuperSmashBrosUltimate_Character(player1)} with {SuperSmashBrosUltimate_Character(player2)}", + 58 => $"Last Smashed: LDN Battle - {SuperSmashBrosUltimate_PlayerListing(values)}", + 63 when values.Matched.TryGetValue("player_1_fighter", out Value player) + => $"Last Smashed: DLC Spirit Board Battle as {SuperSmashBrosUltimate_Character(player)}", + _ => "Smashing" + }; + } + + private static string SuperSmashBrosUltimate_Character(Value value) => + BinaryPrimitives.ReverseEndianness( + BitConverter.ToInt64(((MsgPack.MessagePackExtendedTypeObject)value.BoxedValue).GetBody(), 0)) switch + { + 0x0 => "Mario", + 0x1 => "Donkey Kong", + 0x2 => "Link", + 0x3 => "Samus", + 0x4 => "Dark Samus", + 0x5 => "Yoshi", + 0x6 => "Kirby", + 0x7 => "Fox", + 0x8 => "Pikachu", + 0x9 => "Luigi", + 0xA => "Ness", + 0xB => "Captain Falcon", + 0xC => "Jigglypuff", + 0xD => "Peach", + 0xE => "Daisy", + 0xF => "Bowser", + 0x10 => "Ice Climbers", + 0x11 => "Sheik", + 0x12 => "Zelda", + 0x13 => "Dr. Mario", + 0x14 => "Pichu", + 0x15 => "Falco", + 0x16 => "Marth", + 0x17 => "Lucina", + 0x18 => "Young Link", + 0x19 => "Ganondorf", + 0x1A => "Mewtwo", + 0x1B => "Roy", + 0x1C => "Chrom", + 0x1D => "Mr Game & Watch", + 0x1E => "Meta Knight", + 0x1F => "Pit", + 0x20 => "Dark Pit", + 0x21 => "Zero Suit Samus", + 0x22 => "Wario", + 0x23 => "Snake", + 0x24 => "Ike", + 0x25 => "Pokémon Trainer", + 0x26 => "Diddy Kong", + 0x27 => "Lucas", + 0x28 => "Sonic", + 0x29 => "King Dedede", + 0x2A => "Olimar", + 0x2B => "Lucario", + 0x2C => "R.O.B.", + 0x2D => "Toon Link", + 0x2E => "Wolf", + 0x2F => "Villager", + 0x30 => "Mega Man", + 0x31 => "Wii Fit Trainer", + 0x32 => "Rosalina & Luma", + 0x33 => "Little Mac", + 0x34 => "Greninja", + 0x35 => "Palutena", + 0x36 => "Pac-Man", + 0x37 => "Robin", + 0x38 => "Shulk", + 0x39 => "Bowser Jr.", + 0x3A => "Duck Hunt", + 0x3B => "Ryu", + 0x3C => "Ken", + 0x3D => "Cloud", + 0x3E => "Corrin", + 0x3F => "Bayonetta", + 0x40 => "Richter", + 0x41 => "Inkling", + 0x42 => "Ridley", + 0x43 => "King K. Rool", + 0x44 => "Simon", + 0x45 => "Isabelle", + 0x46 => "Incineroar", + 0x47 => "Mii Brawler", + 0x48 => "Mii Swordfighter", + 0x49 => "Mii Gunner", + 0x4A => "Piranha Plant", + 0x4B => "Joker", + 0x4C => "Hero", + 0x4D => "Banjo", + 0x4E => "Terry", + 0x4F => "Byleth", + 0x50 => "Min Min", + 0x51 => "Steve", + 0x52 => "Sephiroth", + 0x53 => "Pyra/Mythra", + 0x54 => "Kazuya", + 0x55 => "Sora", + 0xFE => "Random", + 0xFF => "Scripted Entity", + _ => "Unknown" + }; + + private static string SuperSmashBrosUltimate_PlayerListing(SparseMultiValue values) + { + List<(string Character, int PlayerNumber, int? Rank)> players = []; + + foreach (KeyValuePair player in values.Matched) + { + if (player.Key.StartsWith("player_") && player.Key.EndsWith("_fighter") && + player.Value.BoxedValue is not null) + { + if (!int.TryParse(player.Key.Split('_')[1], out int playerNumber)) + continue; + + string character = SuperSmashBrosUltimate_Character(player.Value); + int? rank = values.Matched.TryGetValue($"player_{playerNumber}_rank", out Value rankValue) + ? rankValue.IntValue + : null; + + players.Add((character, playerNumber, rank)); + } + } + + players = players.OrderBy(p => p.Rank ?? int.MaxValue).ToList(); + + return players.Count > 4 + ? $"{players.Count} Players - " + string.Join(", ", + players.Take(3).Select(p => $"{p.Character}({p.PlayerNumber}){RankMedal(p.Rank)}")) + : string.Join(", ", players.Select(p => $"{p.Character}({p.PlayerNumber}){RankMedal(p.Rank)}")); + + string RankMedal(int? rank) => rank switch + { + 0 => "🥇", + 1 => "🥈", + 2 => "🥉", + _ => "" + }; + } + + private static FormattedValue NsoEmulator_LaunchedGame(SingleValue value) => value.Matched.StringValue switch + { + #region SEGA Genesis + + "m_0054_e" => Playing("Alien Soldier"), + "m_3978_e" => Playing("Alien Storm"), + "m_5234_e" => Playing("ALISIA DRAGOON"), + "m_5003_e" => Playing("Streets of Rage 2"), + "m_4843_e" => Playing("Kid Chameleon"), + "m_2874_e" => Playing("Columns"), + "m_3167_e" => Playing("Comix Zone"), + "m_5007_e" => Playing("Contra: Hard Corps"), + "m_0865_e" => Playing("Ghouls 'n Ghosts"), + "m_0935_e" => Playing("Dynamite Headdy"), + "m_8314_e" => Playing("Earthworm Jim"), + "m_5012_e" => Playing("Ecco the Dolphin"), + "m_2207_e" => Playing("Flicky"), + "m_9432_e" => Playing("Golden Axe II"), + "m_5015_e" => Playing("Golden Axe"), + "m_5017_e" => Playing("Gunstar Heroes"), + "m_0732_e" => Playing("Altered Beast"), + "m_2245_e" or "m_2245_pd" or "m_2245_pf" => Playing("Landstalker"), + "m_1654_e" => Playing("Target Earth"), + "m_7050_e" => Playing("Light Crusader"), + "m_5027_e" => Playing("M.U.S.H.A."), + "m_5028_e" => Playing("Phantasy Star IV"), + "m_9155_e" => Playing("Pulseman"), + "m_5030_e" => Playing("Dr. Robotnik's Mean Bean Machine"), + "m_0098_e" => Playing("Crusader of Centy"), + "m_0098_k" => Playing("신창세기 라그나센티"), + "m_0098_pd" or "m_0098_pf" or "m_0098_ps" => Playing("Soleil"), + "m_5033_e" => Playing("Ristar"), + "m_1987_e" => Playing("MEGA MAN: THE WILY WARS"), + "m_2609_e" => Playing("WOLF OF THE BATTLEFIELD: MERCS"), + "m_3353_e" => Playing("Shining Force II"), + "m_5036_e" => Playing("Shining Force"), + "m_9866_e" => Playing("Sonic The Hedgehog Spinball"), + "m_5041_e" => Playing("Sonic The Hedgehog 2"), + "m_5523_e" => Playing("Space Harrier II"), + "m_0041_e" => Playing("STREET FIGHTER II' : SPECIAL CHAMPION EDITION"), + "m_5044_e" => Playing("STRIDER"), + "m_6353_e" => Playing("Super Fantasy Zone"), + "m_9569_e" => Playing("Beyond Oasis"), + "m_9569_k" => Playing("스토리 오브 도어"), + "m_9569_pd" or "m_9569_ps" => Playing("The Story of Thor"), + "m_9569_pf" => Playing("La Légende de Thor"), + "m_5049_e" => Playing("Shinobi III: Return of the Ninja Master"), + "m_6811_e" => Playing("The Revenge of Shinobi"), + "m_4372_e" => Playing("Thunder Force II"), + "m_1535_e" => Playing("ToeJam & Earl in Panic on Funkotron"), + "m_0432_e" => Playing("ToeJam & Earl"), + "m_5052_e" => Playing("Castlevania: BLOODLINES"), + "m_3626_e" => Playing("VectorMan"), + "m_7955_e" => Playing("Sword of Vermilion"), + "m_0394_e" => Playing("Virtua Fighter 2"), + "m_9417_e" => Playing("Zero Wing"), + + #endregion + + #region Nintendo 64 + + "n_1653_e" or "n_1653_p" => Playing("1080º ™ Snowboarding"), + "n_4868_e" or "n_4868_p" => Playing("Banjo Kazooie™"), + "n_1226_e" or "n_1226_p" => Playing("Banjo-Tooie™"), + "n_3083_e" or "n_3083_p" => Playing("Blast Corps"), + "n_3007_e" => Playing("Dr. Mario™ 64"), + "n_4238_e" => Playing("Excitebike™ 64"), + "n_1870_e" => Playing("Extreme G"), + "n_2456_e" => Playing("F-Zero™ X"), + "n_4631_e" => Playing("GoldenEye 007"), + "n_1635_e" => Playing("Harvest Moon 64"), + "n_2225_e" => Playing("Iggy’s Reckin’ Balls"), + "n_1625_e" or "n_1625_p" => Playing("JET FORCE GEMINI™"), + "n_3052_e" => Playing("Kirby 64™: The Crystal Shards"), + "n_4371_e" => Playing("Mario Golf™"), + "n_3013_e" => Playing("Mario Kart™ 64"), + "n_1053_e" or "n_1053_p" => Playing("Mario Party™ 2"), + "n_2965_e" or "n_2965_p" => Playing("Mario Party™ 3"), + "n_4737_e" or "n_4737_p" => Playing("Mario Party™"), + "n_3017_e" => Playing("Mario Tennis™"), + "n_2992_e" or "n_2992_p" => Playing("Paper Mario™"), + "n_3783_e" or "n_3783_p" => Playing("Pilotwings™ 64"), + "n_1848_e" or "n_1848_pd" or "n_1848_pf" => Playing("Pokémon™ Puzzle League"), + "n_3240_e" or "n_3240_pd" or "n_3240_pf" or "n_3240_pi" or "n_3240_ps" => Playing("Pokémon Snap™"), + "n_4590_e" or "n_4590_pd" or "n_4590_pf" or "n_4590_pi" or "n_4590_ps" => Playing("Pokémon Stadium™"), + "n_3309_e" or "n_3309_pd" or "n_3309_pf" or "n_3309_pi" or "n_3309_ps" => Playing("Pokémon Stadium 2™"), + "n_3029_e" => Playing("Sin & Punishment™"), + "n_3030_e" => Playing("Star Fox™ 64"), + "n_3030_p" => Playing("Lylat Wars™"), + "n_3031_e" or "n_3031_p" => Playing("Super Mario 64™"), + "n_4813_e" or "n_4813_p" => Playing("Wave Race™ 64"), + "n_3034_e" => Playing("WIN BACK: COVERT OPERATIONS"), + "n_3034_p" => Playing("OPERATION: WIN BACK"), + "n_3036_e" or "n_3036_p" => Playing("Yoshi's Story™"), + "n_1407_e" or "n_1407_p" => Playing("The Legend of Zelda™: Majora's Mask™"), + "n_3038_e" or "n_3038_p" => Playing("The Legend of Zelda™: Ocarina of Time™"), + + #endregion + + #region NES + + "clv_p_naaae" => Playing("Super Mario Bros.™"), + "clv_p_naabe" => Playing("Super Mario Bros.™: The Lost Levels"), + "clv_p_naace" or "clv_p_naace_sp1" => Playing("Super Mario Bros.™ 3"), + "clv_p_naade" => Playing("Super Mario Bros.™ 2"), + "clv_p_naaee" => Playing("Donkey Kong™"), + "clv_p_naafe" => Playing("Donkey Kong Jr.™"), + "clv_p_naage" => Playing("Donkey Kong™ 3"), + "clv_p_naahe" => Playing("Excitebike™"), + "clv_p_naaje" => Playing("EarthBound Beginnings"), + "clv_p_naame" => Playing("NES™ Open Tournament Golf"), + "clv_p_naane" or "clv_p_naane_sp1" => Playing("The Legend of Zelda™"), + "clv_p_naape" or "clv_p_naape_sp1" => Playing("Kirby's Adventure™"), + "clv_p_naaqe" or "clv_p_naaqe_sp1" or "clv_p_naaqe_sp2" => Playing("Metroid™"), + "clv_p_naare" => Playing("Balloon Fight™"), + "clv_p_naase" or "clv_p_naase_sp1" => Playing("Zelda II - The Adventure of Link™"), + "clv_p_naate" => Playing("Punch-Out!!™ Featuring Mr. Dream"), + "clv_p_naaue" => Playing("Ice Climber™"), + "clv_p_naave" or "clv_p_naave_sp1" => Playing("Kid Icarus™"), + "clv_p_naawe" => Playing("Mario Bros.™"), + "clv_p_naaxe" or "clv_p_naaxe_sp1" => Playing("Dr. Mario™"), + "clv_p_naaye" => Playing("Yoshi™"), + "clv_p_naaze" => Playing("StarTropics™"), + "clv_p_nabce" or "clv_p_nabce_sp1" => Playing("Ghosts'n Goblins™"), + "clv_p_nabre" or "clv_p_nabre_sp1" or "clv_p_nabre_sp2" => Playing("Gradius"), + "clv_p_nacbe" or "clv_p_nacbe_sp1" => Playing("Ninja Gaiden"), + "clv_p_nacce" => Playing("Solomon's Key"), + "clv_p_nacde" => Playing("Tecmo Bowl"), + "clv_p_nacfe" => Playing("Double Dragon"), + "clv_p_nache" => Playing("Double Dragon II: The Revenge"), + "clv_p_nacje" => Playing("River City Ransom"), + "clv_p_nacke" => Playing("Super Dodge Ball"), + "clv_p_nacle" => Playing("Downtown Nekketsu March Super-Awesome Field Day!"), + "clv_p_nacpe" => Playing("The Mystery of Atlantis"), + "clv_p_nacre" => Playing("Soccer"), + "clv_p_nacse" or "clv_p_nacse_sp1" => Playing("Ninja JaJaMaru-kun"), + "clv_p_nacte" => Playing("Ice Hockey"), + "clv_p_nacue" or "clv_p_nacue_sp1" => Playing("Blaster Master"), + "clv_p_nacwe" => Playing("ADVENTURES OF LOLO"), + "clv_p_nacxe" => Playing("Wario's Woods™"), + "clv_p_nacye" => Playing("Tennis"), + "clv_p_nacze" => Playing("Wrecking Crew™"), + "clv_p_nadbe" => Playing("Joy Mech Fight™"), + "clv_p_nadde" or "clv_p_nadde_sp1" => Playing("Star Soldier"), + "clv_p_nadke" => Playing("Tetris®"), + "clv_p_nadle" => Playing("Pro Wrestling"), + "clv_p_nadpe" => Playing("Baseball"), + "clv_p_nadte" or "clv_p_nadte_sp1" => Playing("TwinBee"), + "clv_p_nadue" or "clv_p_nadue_sp1" => Playing("Mighty Bomb Jack"), + "clv_p_nadve" => Playing("Kung-Fu Heroes"), + "clv_p_nadxe" => Playing("City Connection"), + "clv_p_nadye" => Playing("Rygar"), + "clv_p_naeae" => Playing("Crystalis"), + "clv_p_naece" => Playing("Vice: Project Doom"), + "clv_p_naehe" => Playing("Clu Clu Land™"), + "clv_p_naeie" => Playing("VS. Excitebike™"), + "clv_p_naeje" => Playing("Volleyball™"), + "clv_p_naeke" => Playing("JOURNEY TO SILIUS"), + "clv_p_naele" => Playing("S.C.A.T.: Special Cybernetic Attack Team"), + "clv_p_naeme" => Playing("Shadow of the Ninja"), + "clv_p_naene" => Playing("Nightshade"), + "clv_p_naepe" => Playing("The Immortal"), + "clv_p_naeqe" => Playing("Eliminator Boat Duel"), + "clv_p_naere" => Playing("Fire 'n Ice"), + "clv_p_nafce" => Playing("XEVIOUS"), + "clv_p_nagpe" => Playing("DAIVA STORY 6 IMPERIAL OF NIRSARTIA"), + "clv_p_nagqe" => Playing("DIG DUGⅡ"), + "clv_p_nague" => Playing("MAPPY-LAND"), + "clv_p_nahhe" => Playing("Mach Rider™"), + "clv_p_nahje" => Playing("Pinball"), + "clv_p_nahre" => Playing("Mystery Tower"), + "clv_p_nahte" => Playing("Urban Champion™"), + "clv_p_nahue" => Playing("Donkey Kong Jr.™ Math"), + "clv_p_nahve" => Playing("The Mysterious Murasame Castle"), + "clv_p_najae" => Playing("DEVIL WORLD™"), + "clv_p_najbe" => Playing("Golf"), + "clv_p_najpe" => Playing("R.C. PRO-AM™"), + "clv_p_najre" => Playing("COBRA TRIANGLE™"), + "clv_p_najse" => Playing("SNAKE RATTLE N ROLL™"), + "clv_p_najte" => Playing("SOLAR® JETMAN"), + + #endregion + + #region SNES + + "s_2180_e" => Playing("BATTLETOADS™ DOUBLE DRAGON™"), + "s_2179_e" => Playing("BATTLETOADS™ IN BATTLEMANIACS"), + "s_2182_e" => Playing("BIG RUN"), + "s_2156_e" => Playing("Bombuzal"), + "s_2002_e" => Playing("BRAWL BROTHERS"), + "s_2025_e" => Playing("Breath of Fire II"), + "s_2003_e" => Playing("Breath Of Fire"), + "s_2163_e" => Playing("Claymates"), + "s_2150_e" => Playing("Congo's Caper"), + "s_2171_e" => Playing("COSMO GANG THE PUZZLE"), + "s_2004_e" => Playing("Demon's Crest"), + "s_2026_e" => Playing("Kunio-kun no Dodgeball da yo Zen'in Shūgō!"), + "s_2060_e" => Playing("Donkey Kong Country 2: Diddy's Kong Quest"), + "s_2061_e" => Playing("Donkey Kong Country 3: Dixie Kong's Double Trouble!"), + "s_2055_e" => Playing("Donkey Kong Country"), + "s_2139_e" => Playing("DOOMSDAY WARRIOR"), + "s_2051_e" => Playing("EarthBound"), + "s_2162_e" => Playing("Earthworm Jim™ 2"), + "s_2005_e" => Playing("F-ZERO™"), + "s_2183_e" => Playing("FATAL FURY 2"), + "s_2174_e" => Playing("Fighter's History"), + "s_2037_e" => Playing("Harvest Moon"), + "s_2161_e" => Playing("Jelly Boy"), + "s_2006_e" => Playing("Joe & Mac 2: Lost in the Tropics"), + "s_2169_e" => Playing("Caveman Ninja"), + "s_2181_e" => Playing("KILLER INSTINCT™"), + "s_2029_e" or "s_2029_e_sp1" => Playing("Kirby Super Star™"), + "s_2121_e" => Playing("Kirby's Avalanche™"), + "s_2007_e" or "s_2007_e_sp1" => Playing("Kirby's Dream Course™"), + "s_2008_e" or "s_2008_e_sp1" => Playing("Kirby's Dream Land™ 3"), + "s_2172_e" => Playing("Kirby’s Star Stacker™"), + "s_2151_e" => Playing("Magical Drop2"), + "s_2044_e" => Playing("Mario's Super Picross"), + "s_2038_e" => Playing("Natsume Championship Wrestling"), + "s_2140_e" => Playing("Operation Logic Bomb"), + "s_2034_e" => Playing("Panel de Pon"), + "s_2009_e" => Playing("Pilotwings™"), + "s_2010_e" => Playing("Pop'n TwinBee"), + "s_2157_e" => Playing("Prehistorik Man"), + "s_2145_e" => Playing("Psycho Dream"), + "s_2141_e" => Playing("Rival Turf!"), + "s_2152_e" => Playing("SIDE POCKET"), + "s_2158_e" => Playing("Spanky’s™ Quest"), + "s_2031_e" => Playing("Star Fox™ 2"), + "s_2011_e" => Playing("Star Fox™"), + "s_2012_e" => Playing("Stunt Race FX™"), + "s_2032_e" => Playing("Amazing Hebereke"), + "s_2159_e" => Playing("Super Baseball Simulator 1.000"), + "s_2013_e" => Playing("SUPER E.D.F. EARTH DEFENSE FORCE"), + "s_2014_e" => Playing("Smash Tennis"), + "s_2015_e" => Playing("Super Ghouls'n Ghosts™"), + "s_2033_e" => Playing("Super Mario All-Stars™"), + "s_2016_e" or "s_2016_e_sp1" => Playing("Super Mario Kart™"), + "s_2017_e" or "s_2017_e_sp1" => Playing("Super Mario World™"), + "s_2018_e" or "s_2018_e_sp1" => Playing("Super Metroid™"), + "s_2184_e" => Playing("Super Ninja Boy"), + "s_2019_e" or "s_2019_e_sp1" => Playing("Super Punch-Out!!™"), + "s_2020_e" => Playing("Super Puyo Puyo 2"), + "s_2133_e" => Playing("SUPER R-TYPE"), + "s_2021_e" => Playing("Super Soccer"), + "s_2022_e" => Playing("Super Tennis"), + "s_2136_e" => Playing("Sutte Hakkun"), + "s_2142_e" => Playing("The Ignition Factor"), + "s_2143_e" => Playing("The Peace Keepers"), + "s_2146_e" => Playing("Tuff E Nuff"), + "s_2144_e" => Playing("SUPER VALIS Ⅳ"), + "s_2049_e" => Playing("Wild Guns"), + "s_2096_e" => Playing("Wrecking Crew™ '98"), + "s_2023_e" => Playing("Super Mario World™ 2: Yoshi's Island™"), + "s_2024_e" => Playing("The Legend of Zelda™: A Link to the Past™"), + + #endregion + + #region GameBoy + + "c_7224_e" or "c_7224_p" => Playing("Alone in the Dark: The New Nightmare"), + "c_5022_e" => Playing("Blaster Master: Enemy Below"), + "c_3381_e" => Playing("Game & Watch™ Gallery 3"), + "c_0282_e" => Playing("Kirby Tilt ‘n’ Tumble™"), + "c_4471_e" or "c_4471_p" => Playing("Mario Golf™"), + "c_9947_e" => Playing("Mario Tennis™"), + "c_3191_e" or "c_3191_p" or "c_3191_x" => Playing("Pokémon™ Trading Card Game"), + "c_8914_e" or "c_8914_p" => Playing("Quest for Camelot™"), + "c_2648_e" => Playing("Tetris® DX"), + "c_5928_e" => Playing("Wario Land™ 3"), + "c_3996_e" or "c_3996_pd" or "c_3996_pf" => Playing("The Legend of Zelda™: Link's Awakening DX™"), + "c_8852_e" or "c_8852_p" => Playing("The Legend of Zelda™: Oracle of Ages™"), + "c_9130_e" or "c_9130_p" => Playing("The Legend of Zelda™: Oracle of Seasons™"), + "d_6879_e" => Playing("Alleyway™"), + "d_7618_e" => Playing("Baseball"), + "d_6005_e" => Playing("BurgerTime Deluxe"), + "d_7120_e" => Playing("Castlevania Legends"), + "d_2744_e" => Playing("Dr. Mario™"), + "d_1593_e" => Playing("Donkey Kong Land 2™"), + "d_7216_e" => Playing("Donkey Kong Land III™"), + "d_4971_e" => Playing("Donkey Kong Land™"), + "d_7984_e" => Playing("GARGOYLE'S QUEST"), + "d_8212_e" => Playing("Kirby's Dream Land™ 2"), + "d_5661_e" => Playing("Kirby's Dream Land™"), + "d_3837_e" => Playing("MEGA MAN II"), + "d_1965_e" => Playing("MEGA MAN III"), + "d_0194_e" => Playing("MEGA MAN IV"), + "d_1425_e" => Playing("MEGA MAN V"), + "d_9324_e" => Playing("MEGA MAN: DR. WILY'S REVENGE"), + "d_1577_e" => Playing("Metroid™ II - Return of Samus™"), + "d_5124_e" => Playing("Super Mario Land™ 2 - 6 Golden Coins™"), + "d_7970_e" => Playing("Super Mario Land™"), + "d_8484_e" => Playing("Tetris®"), + + #endregion + + #region GameBoy Advance + + "a_9694_e" => Playing("Densetsu no Starfy 1"), + "a_5600_e" => Playing("Densetsu no Starfy 2"), + "a_7565_e" => Playing("Densetsu no Starfy 3"), + "a_6553_e" => Playing("F-ZERO CLIMAX"), + "a_7842_e" or "a_7842_p" => Playing("F-Zero™- GP Legend"), + "a_9283_e" => Playing("F-Zero™ Maximum Velocity"), + "a_3744_e" or "a_3744_x" or "a_3744_y" => Playing("Fire Emblem™"), + "a_8978_d" or "a_8978_e" or "a_8978_f" or "a_8978_i" or "a_8978_s" => Playing("Golden Sun™: The Lost Age"), + "a_3108_d" or "a_3108_e" or "a_3108_f" or "a_3108_i" or "a_3108_s" => Playing("Golden Sun™"), + "a_3654_e" or "a_3654_p" => Playing("Kirby™ & The Amazing Mirror"), + "a_7279_p" => Playing("Kuru Kuru Kururin™"), + "a_7311_e" or "a_7311_p" => Playing("Mario & Luigi™: Superstar Saga"), + "a_6845_e" => Playing("Mario Kart™: Super Circuit™"), + "a_4139_e" or "a_4139_p" => Playing("Metroid™ Fusion"), + "a_6834_e" or "a_6834_p" => Playing("Metroid™: Zero Mission"), + "a_8989_e" or "a_8989_p" => Playing("Pokémon™ Mystery Dungeon: Red Rescue Team"), + "a_9444_e" => Playing("Super Mario™ Advance"), + "a_9901_e" or "a_9901_p" => Playing("Super Mario™ Advance 4: Super Mario Bros.™ 3"), + "a_2939_e" => Playing("Super Mario World™: Super Mario Advance 2"), + "a_2939_p" => Playing("Super Mario World™: Super Mario Advance 2™"), + "a_1302_e" => Playing("WarioWare™, Inc.: Mega Microgame$!"), + "a_1302_p" => Playing("WarioWare™, Inc.: Minigame Mania."), + "a_6960_e" or "a_6960_p" => Playing("Yoshi's Island™: Super Mario™ Advance 3"), + "a_5190_e" or "a_5190_p" => Playing("The Legend of Zelda™: A Link to the Past™ Four Swords"), + "a_8665_e" or "a_8665_p" => Playing("The Legend of Zelda™: The Minish Cap"), + + #endregion + + _ => FormattedValue.ForceReset + }; + } +} diff --git a/src/Ryujinx/Utilities/PlayReport/PlayReports.cs b/src/Ryujinx/Utilities/PlayReport/PlayReports.cs index 25457744e..5f6ba3446 100644 --- a/src/Ryujinx/Utilities/PlayReport/PlayReports.cs +++ b/src/Ryujinx/Utilities/PlayReport/PlayReports.cs @@ -1,8 +1,11 @@ -using static Ryujinx.Ava.Utilities.PlayReport.Analyzer; +using System; +using System.Buffers.Binary; +using System.Collections.Generic; +using System.Linq; namespace Ryujinx.Ava.Utilities.PlayReport { - public static class PlayReports + public static partial class PlayReports { public static Analyzer Analyzer { get; } = new Analyzer() .AddSpec( @@ -10,7 +13,7 @@ namespace Ryujinx.Ava.Utilities.PlayReport spec => spec .AddValueFormatter("IsHardMode", BreathOfTheWild_MasterMode) // reset to normal status when switching between normal & master mode in title screen - .AddValueFormatter("AoCVer", FormattedValue.AlwaysResets) + .AddValueFormatter("AoCVer", FormattedValue.SingleAlwaysResets) ) .AddSpec( "0100f2c0115b6000", @@ -39,91 +42,32 @@ namespace Ryujinx.Ava.Utilities.PlayReport spec => spec .AddValueFormatter("area_no", PokemonSVArea) .AddValueFormatter("team_circle", PokemonSVUnionCircle) + ) + .AddSpec( + "01006a800016e000", + spec => spec + .AddSparseMultiValueFormatter( + [ + // Metadata to figure out what PlayReport we have. + "match_mode", "match_submode", "anniversary", "fighter", "reason", "challenge_count", + "adv_slot", + // List of Fighters + "player_1_fighter", "player_2_fighter", "player_3_fighter", "player_4_fighter", + "player_5_fighter", "player_6_fighter", "player_7_fighter", "player_8_fighter", + // List of rankings/placements + "player_1_rank", "player_2_rank", "player_3_rank", "player_4_rank", "player_5_rank", + "player_6_rank", "player_7_rank", "player_8_rank" + ], + SuperSmashBrosUltimate_Mode + ) + ) + .AddSpec( + [ + "0100c9a00ece6000", "01008d300c50c000", "0100d870045b6000", + "010012f017576000", "0100c62011050000", "0100b3c014bda000"], + spec => spec.AddValueFormatter("launch_title_id", NsoEmulator_LaunchedGame) ); - private static FormattedValue BreathOfTheWild_MasterMode(Value value) - => value.BoxedValue is 1 ? "Playing Master Mode" : FormattedValue.ForceReset; - - private static FormattedValue TearsOfTheKingdom_CurrentField(Value value) => - value.DoubleValue switch - { - > 800d => "Exploring the Sky Islands", - < -201d => "Exploring the Depths", - _ => "Roaming Hyrule" - }; - - private static FormattedValue SuperMarioOdyssey_AssistMode(Value value) - => value.BoxedValue is 1 ? "Playing in Assist Mode" : "Playing in Regular Mode"; - - private static FormattedValue SuperMarioOdysseyChina_AssistMode(Value value) - => value.BoxedValue is 1 ? "Playing in 帮助模式" : "Playing in 普通模式"; - - private static FormattedValue SuperMario3DWorldOrBowsersFury(Value value) - => value.BoxedValue is 0 ? "Playing Super Mario 3D World" : "Playing Bowser's Fury"; - - private static FormattedValue MarioKart8Deluxe_Mode(Value value) - => value.StringValue switch - { - // Single Player - "Single" => "Single Player", - // Multiplayer - "Multi-2players" => "Multiplayer 2 Players", - "Multi-3players" => "Multiplayer 3 Players", - "Multi-4players" => "Multiplayer 4 Players", - // Wireless/LAN Play - "Local-Single" => "Wireless/LAN Play", - "Local-2players" => "Wireless/LAN Play 2 Players", - // CC Classes - "50cc" => "50cc", - "100cc" => "100cc", - "150cc" => "150cc", - "Mirror" => "Mirror (150cc)", - "200cc" => "200cc", - // Modes - "GrandPrix" => "Grand Prix", - "TimeAttack" => "Time Trials", - "VS" => "VS Races", - "Battle" => "Battle Mode", - "RaceStart" => "Selecting a Course", - "Race" => "Racing", - _ => FormattedValue.ForceReset - }; - - private static FormattedValue PokemonSVUnionCircle(Value value) - => value.BoxedValue is 0 ? "Playing Alone" : "Playing in a group"; - - private static FormattedValue PokemonSVArea(Value value) - => value.StringValue switch - { - // Base Game Locations - "a_w01" => "South Area One", - "a_w02" => "Mesagoza", - "a_w03" => "The Pokemon League", - "a_w04" => "South Area Two", - "a_w05" => "South Area Four", - "a_w06" => "South Area Six", - "a_w07" => "South Area Five", - "a_w08" => "South Area Three", - "a_w09" => "West Area One", - "a_w10" => "Asado Desert", - "a_w11" => "West Area Two", - "a_w12" => "Medali", - "a_w13" => "Tagtree Thicket", - "a_w14" => "East Area Three", - "a_w15" => "Artazon", - "a_w16" => "East Area Two", - "a_w18" => "Casseroya Lake", - "a_w19" => "Glaseado Mountain", - "a_w20" => "North Area Three", - "a_w21" => "North Area One", - "a_w22" => "North Area Two", - "a_w23" => "The Great Crater of Paldea", - "a_w24" => "South Paldean Sea", - "a_w25" => "West Paldean Sea", - "a_w26" => "East Paldean Sea", - "a_w27" => "Nouth Paldean Sea", - //TODO DLC Locations - _ => FormattedValue.ForceReset - }; + private static string Playing(string game) => $"Playing {game}"; } } diff --git a/src/Ryujinx/Utilities/PlayReport/Specs.cs b/src/Ryujinx/Utilities/PlayReport/Specs.cs new file mode 100644 index 000000000..b81a599a2 --- /dev/null +++ b/src/Ryujinx/Utilities/PlayReport/Specs.cs @@ -0,0 +1,236 @@ +using FluentAvalonia.Core; +using MsgPack; +using Ryujinx.Ava.Utilities.AppLibrary; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Ryujinx.Ava.Utilities.PlayReport +{ + /// + /// A mapping of title IDs to value formatter specs. + /// + /// Generally speaking, use the .AddSpec(...) methods instead of creating this class yourself. + /// + public class GameSpec + { + public static GameSpec Create(string requiredTitleId, params IEnumerable otherTitleIds) + => new() { TitleIds = otherTitleIds.Prepend(requiredTitleId).ToArray() }; + + public static GameSpec Create(IEnumerable titleIds) + => new() { TitleIds = titleIds.ToArray() }; + + private int _lastPriority; + + public required string[] TitleIds { get; init; } + + public List ValueFormatters { get; } = []; + + + /// + /// Add a value formatter to the current + /// matching a specific key that could exist in a Play Report for the previously specified title IDs. + /// + /// The key name to match. + /// The function which can return a potential formatted value. + /// The current , for chaining convenience. + public GameSpec AddValueFormatter( + string reportKey, + SingleValueFormatter valueFormatter + ) => AddValueFormatter(_lastPriority++, reportKey, valueFormatter); + + /// + /// Add a value formatter at a specific priority to the current + /// matching a specific key that could exist in a Play Report for the previously specified title IDs. + /// + /// The resolution priority of this value formatter. Higher resolves sooner. + /// The key name to match. + /// The function which can return a potential formatted value. + /// The current , for chaining convenience. + public GameSpec AddValueFormatter( + int priority, + string reportKey, + SingleValueFormatter valueFormatter + ) => AddValueFormatter(new FormatterSpec + { + Priority = priority, ReportKeys = [reportKey], Formatter = valueFormatter + }); + + /// + /// Add a multi-value formatter to the current + /// matching a specific set of keys that could exist in a Play Report for the previously specified title IDs. + /// + /// The key names to match. + /// The function which can format the values. + /// The current , for chaining convenience. + public GameSpec AddMultiValueFormatter( + string[] reportKeys, + MultiValueFormatter valueFormatter + ) => AddMultiValueFormatter(_lastPriority++, reportKeys, valueFormatter); + + /// + /// Add a multi-value formatter at a specific priority to the current + /// matching a specific set of keys that could exist in a Play Report for the previously specified title IDs. + /// + /// The resolution priority of this value formatter. Higher resolves sooner. + /// The key names to match. + /// The function which can format the values. + /// The current , for chaining convenience. + public GameSpec AddMultiValueFormatter( + int priority, + string[] reportKeys, + MultiValueFormatter valueFormatter + ) => AddValueFormatter(new MultiFormatterSpec + { + Priority = priority, ReportKeys = reportKeys, Formatter = valueFormatter + }); + + /// + /// Add a multi-value formatter to the current + /// matching a specific set of keys that could exist in a Play Report for the previously specified title IDs. + ///

+ /// The 'Sparse' multi-value formatters do not require every key to be present. + /// If you need this requirement, use . + ///
+ /// The key names to match. + /// The function which can format the values. + /// The current , for chaining convenience. + public GameSpec AddSparseMultiValueFormatter( + string[] reportKeys, + SparseMultiValueFormatter valueFormatter + ) => AddSparseMultiValueFormatter(_lastPriority++, reportKeys, valueFormatter); + + /// + /// Add a multi-value formatter at a specific priority to the current + /// matching a specific set of keys that could exist in a Play Report for the previously specified title IDs. + ///

+ /// The 'Sparse' multi-value formatters do not require every key to be present. + /// If you need this requirement, use . + ///
+ /// The resolution priority of this value formatter. Higher resolves sooner. + /// The key names to match. + /// The function which can format the values. + /// The current , for chaining convenience. + public GameSpec AddSparseMultiValueFormatter( + int priority, + string[] reportKeys, + SparseMultiValueFormatter valueFormatter + ) => AddValueFormatter(new SparseMultiFormatterSpec + { + Priority = priority, ReportKeys = reportKeys, Formatter = valueFormatter + }); + + private GameSpec AddValueFormatter(T formatterSpec) where T : FormatterSpecBase + { + ValueFormatters.Add(formatterSpec); + return this; + } + } + + /// + /// A struct containing the data for a mapping of a key in a Play Report to a formatter for its potential value. + /// + public class FormatterSpec : FormatterSpecBase + { + public override bool GetData(Horizon.Prepo.Types.PlayReport playReport, out object result) + { + if (!playReport.ReportData.AsDictionary().TryGetValue(ReportKeys[0], out MessagePackObject valuePackObject)) + { + result = null; + return false; + } + + result = valuePackObject; + return true; + } + } + + /// + /// A struct containing the data for a mapping of an arbitrary key set in a Play Report to a formatter for their potential values. + /// + public class MultiFormatterSpec : FormatterSpecBase + { + public override bool GetData(Horizon.Prepo.Types.PlayReport playReport, out object result) + { + List packedObjects = []; + foreach (var reportKey in ReportKeys) + { + if (!playReport.ReportData.AsDictionary().TryGetValue(reportKey, out MessagePackObject valuePackObject)) + { + result = null; + return false; + } + + packedObjects.Add(valuePackObject); + } + + result = packedObjects; + return true; + } + } + + /// + /// A struct containing the data for a mapping of an arbitrary key set in a Play Report to a formatter for their sparsely populated potential values. + /// + public class SparseMultiFormatterSpec : FormatterSpecBase + { + public override bool GetData(Horizon.Prepo.Types.PlayReport playReport, out object result) + { + Dictionary packedObjects = []; + foreach (var reportKey in ReportKeys) + { + if (!playReport.ReportData.AsDictionary().TryGetValue(reportKey, out MessagePackObject valuePackObject)) + continue; + + packedObjects.Add(reportKey, valuePackObject); + } + + result = packedObjects; + return true; + } + } + + public abstract class FormatterSpecBase + { + public abstract bool GetData(Horizon.Prepo.Types.PlayReport playReport, out object data); + + public int Priority { get; init; } + public string[] ReportKeys { get; init; } + public Delegate Formatter { get; init; } + + public bool Format(ApplicationMetadata appMeta, Horizon.Prepo.Types.PlayReport playReport, + out FormattedValue formattedValue) + { + formattedValue = default; + if (!GetData(playReport, out object data)) + return false; + + if (data is FormattedValue fv) + { + formattedValue = fv; + return true; + } + + switch (Formatter) + { + case SingleValueFormatter svf when data is MessagePackObject match: + formattedValue = svf( + new SingleValue(match) { Application = appMeta, PlayReport = playReport } + ); + return true; + case MultiValueFormatter mvf when data is List matches: + formattedValue = mvf( + new MultiValue(matches) { Application = appMeta, PlayReport = playReport } + ); + return true; + case SparseMultiValueFormatter smvf when data is Dictionary sparseMatches: + formattedValue = smvf( + new SparseMultiValue(sparseMatches) { Application = appMeta, PlayReport = playReport } + ); + return true; + default: + throw new InvalidOperationException("Formatter delegate is not of a known type!"); + } + } + } +} diff --git a/src/Ryujinx/Utilities/PlayReport/Value.cs b/src/Ryujinx/Utilities/PlayReport/Value.cs new file mode 100644 index 000000000..b3108a41e --- /dev/null +++ b/src/Ryujinx/Utilities/PlayReport/Value.cs @@ -0,0 +1,160 @@ +using MsgPack; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Ryujinx.Ava.Utilities.PlayReport +{ + /// + /// The base input data to a ValueFormatter delegate, + /// and the matched from the Play Report. + /// + public readonly struct Value + { + public Value(MessagePackObject packedValue) + { + PackedValue = packedValue; + } + + /// + /// The matched value from the Play Report. + /// + public MessagePackObject PackedValue { get; init; } + + /// + /// Access the as its underlying .NET type.
+ /// + /// Does not seem to work well with comparing numeric types, + /// so use XValue properties for that. + ///
+ public object BoxedValue => PackedValue.ToObject(); + + public override string ToString() + { + object boxed = BoxedValue; + return boxed == null + ? "null" + : boxed.ToString(); + } + + public static implicit operator Value(MessagePackObject matched) => new(matched); + + public static Value[] ConvertPackedObjects(IEnumerable packObjects) + => packObjects.Select(packObject => new Value(packObject)).ToArray(); + + public static Dictionary ConvertPackedObjectMap(Dictionary packObjects) + => packObjects.ToDictionary( + x => x.Key, + x => new Value(x.Value) + ); + + #region AsX accessors + + public bool BooleanValue => PackedValue.AsBoolean(); + public byte ByteValue => PackedValue.AsByte(); + public sbyte SByteValue => PackedValue.AsSByte(); + public short ShortValue => PackedValue.AsInt16(); + public ushort UShortValue => PackedValue.AsUInt16(); + public int IntValue => PackedValue.AsInt32(); + public uint UIntValue => PackedValue.AsUInt32(); + public long LongValue => PackedValue.AsInt64(); + public ulong ULongValue => PackedValue.AsUInt64(); + public float FloatValue => PackedValue.AsSingle(); + public double DoubleValue => PackedValue.AsDouble(); + public string StringValue => PackedValue.AsString(); + public Span BinaryValue => PackedValue.AsBinary(); + + #endregion + } + + /// + /// A potential formatted value returned by a ValueFormatter delegate. + /// + public readonly struct FormattedValue + { + /// + /// Was any handler able to match anything in the Play Report? + /// + public bool Handled { get; private init; } + + /// + /// Did the handler request the caller of the to reset the existing value? + /// + public bool Reset { get; private init; } + + /// + /// The formatted value, only present if is true, and is false. + /// + public string FormattedString { get; private init; } + + /// + /// The intended path of execution for having a string to return: simply return the string. + /// This implicit conversion will make the struct for you.

+ /// + /// If the input is null, is returned. + ///
+ /// The formatted string value. + /// The automatically constructed struct. + public static implicit operator FormattedValue(string formattedValue) + => formattedValue is not null + ? new FormattedValue { Handled = true, FormattedString = formattedValue } + : Unhandled; + + public override string ToString() + { + if (!Handled) + return ""; + + if (Reset) + return ""; + + return FormattedString; + } + + /// + /// Return this to tell the caller there is no value to return. + /// + public static readonly FormattedValue Unhandled = default; + + /// + /// Return this to suggest the caller reset the value it's using the for. + /// + public static readonly FormattedValue ForceReset = new() { Handled = true, Reset = true }; + + /// + /// A delegate singleton you can use to always return in a . + /// + public static readonly SingleValueFormatter SingleAlwaysResets = _ => ForceReset; + + /// + /// A delegate singleton you can use to always return in a . + /// + public static readonly MultiValueFormatter MultiAlwaysResets = _ => ForceReset; + + /// + /// A delegate singleton you can use to always return in a . + /// + public static readonly SparseMultiValueFormatter SparseMultiAlwaysResets = _ => ForceReset; + + /// + /// A delegate factory you can use to always return the specified + /// in a . + /// + /// The string to always return for this delegate instance. + public static SingleValueFormatter SingleAlwaysReturns(string formattedValue) => _ => formattedValue; + + /// + /// A delegate factory you can use to always return the specified + /// in a . + /// + /// The string to always return for this delegate instance. + public static MultiValueFormatter MultiAlwaysReturns(string formattedValue) => _ => formattedValue; + + /// + /// A delegate factory you can use to always return the specified + /// in a . + /// + /// The string to always return for this delegate instance. + public static SparseMultiValueFormatter SparseMultiAlwaysReturns(string formattedValue) => _ => formattedValue; + } +}