Compare commits

...

36 Commits

Author SHA1 Message Date
efa0cc7554 UI: Show issue labels of games in the tooltip on playability status 2025-02-09 21:15:38 -06:00
1c0813d09d misc: chore: [ci skip] shorten lines in FormatterSpecBase.Format & consistently format them 2025-02-09 00:50:11 -06:00
8bec09d7ff Merge remote-tracking branch 'origin/master' 2025-02-09 00:45:54 -06:00
e4b4e94b56 misc: chore: cleanup Specs.cs 2025-02-09 00:45:46 -06:00
764c9e9d4e misc: chore: unify GameSpec creation 2025-02-09 00:37:54 -06:00
05e991db87 misc: chore: collapse all NSO emulator formatters into one method 2025-02-09 00:37:35 -06:00
2cd876b1cb Update Zh-CN Simplified Chinese (#642) 2025-02-08 21:33:20 -06:00
93a298523f Dynamic Presence support for every NSO emulator 2025-02-08 16:52:07 -06:00
253cbb2810 Initial Implementation of SSBU PlayReport usage! (#638)
Currently, this has as many game modes as I could find to implement,
along with a list of all the characters in the game and their code ID.
2025-02-08 16:23:24 -06:00
9c226dcc7a misc: chore: [ci skip] rename ValueFormatter to SingleValueFormatter and some minor cleanups 2025-02-08 01:34:44 -06:00
30a534edcd misc: chore: [ci skip] generify Formatter Specs to be able to run formatters of different types at interleaving priorities 2025-02-08 01:26:23 -06:00
1d88771d1b Play Report Analyzer v4
You can now access the *entire* play report data in any given value formatter.
The input types have been restructured and, notably, not every instance of Value has an ApplicationMetadata on it. It's now on the container type that also contains the matched values and the entire play report.
2025-02-08 00:22:34 -06:00
4e8157688e UI: See what games do/don't have an image & dynamic RPC support in the Game Info popup 2025-02-07 18:34:11 -06:00
5085af0050 UI: Changed the color of "Ingame" from yellow to orange to stand out better in light mode 2025-02-07 18:28:32 -06:00
2c8edaf89e PlayReport: Add Sparse Multi Value formatters 2025-02-07 15:43:50 -06:00
aa8ba8b503 Merge remote-tracking branch 'origin/master' 2025-02-06 22:56:33 -06:00
a4211fec33 UI: Properly space the play time & last play date in the game info popup 2025-02-06 22:56:25 -06:00
54b233dd78 Updated the compat list. (#618) 2025-02-06 04:46:23 -06:00
d1da937fce misc: chore: [ci skip] XMLdocs on new Play Report Analyzer members 2025-02-05 19:51:43 -06:00
4a8f98126f [ci skip] remove test 2025-02-05 19:45:29 -06:00
e55629a908 misc: chore: [ci skip] Play Report Analyzer: Added Multi Value formatters 2025-02-05 19:42:36 -06:00
c638a7daf8 misc: chore: Move Play Report analyzer into a dedicated namespace and remove the PlayReport name prefix on types 2025-02-05 19:27:44 -06:00
5e5e180fea PlayReportAnalyzer: Added Pokemon Scarlet and Violet (#630)
Every base game location excluding buildings are done, DLC locations
will be added at a later point
2025-02-05 18:32:27 -06:00
131fe71205 Update Korean translation (#624) 2025-02-05 02:40:37 -06:00
6af388c623 misc: chore: [ci skip] oops forgot to localize the reset button & confirmation 2025-02-05 02:01:33 -06:00
45cec4e7cf UI: In-app Configuration resetting 2025-02-05 01:42:27 -06:00
479b38f035 Add tooltips to game status (#625) 2025-02-05 00:42:20 -06:00
3ecc7819cc UI: Fix the app list sort types using the newly changed localization keys 2025-02-04 23:47:24 -06:00
4b1d94ccd8 misc: chore: [ci skip] use MultiplayerInfoConverter instance instead of constructing for every use 2025-02-04 23:36:36 -06:00
4ae9f1c0d2 UI: Use Hosted Games & Player Count localization keys in list view too 2025-02-04 23:31:31 -06:00
717851985e UI: Reorganize Game Info dialog popup + localization 2025-02-04 23:28:37 -06:00
bd08a111a8 UI: Show what each value is in the Game Info dialog, add game icon 2025-02-04 22:47:12 -06:00
1972a47f39 UI: Game stats button on right click for Grid view users 2025-02-04 19:32:17 -06:00
222ceb818b misc: chore: Use ApplicationLibrary helpers for getting DLCs & Updates for a game 2025-02-04 18:21:49 -06:00
b0fcc5bee1 misc: chore: Simplify HasCompatibilityEntry
(Totally didn't realize that SelectedApplication is already an ApplicationData)
2025-02-04 18:21:24 -06:00
820e8f7375 [ci skip] UI: Strip dumped file information out of the DLC name 2025-02-04 18:10:28 -06:00
35 changed files with 2465 additions and 535 deletions

View File

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

1 title_id game_name labels status last_updated
332 0100E680149DC000 Arcaea playable 2023-03-16 19:31:21
333 01003C2010C78000 Archaica: The Path Of Light crash nothing 2020-10-16 13:22:26
334 01004DA012976000 Area 86 playable 2020-12-16 16:45:52
335 01008d8006a6a000 Arena of Valor crash boots 2025-02-03 22:19:34
336 0100691013C46000 ARIA CHRONICLE playable 2022-11-16 13:50:55
337 0100D4A00B284000 ARK: Survival Evolved gpu;nvdec;online-broken;UE4;ldn-untested ingame 2024-04-16 00:53:56
338 0100C56012C96000 Arkanoid vs. Space Invaders services ingame 2021-01-21 12:50:30
427 0100E48013A34000 Balan Wonderworld Demo gpu;services;UE4;demo ingame 2023-02-16 20:05:07
428 0100CD801CE5E000 Balatro ingame 2024-04-21 02:01:53
429 010010A00DA48000 Baldur's Gate and Baldur's Gate II: Enhanced Editions 32-bit playable 2022-09-12 23:52:15
430 0100fd1014726000 Baldur's Gate: Dark Alliance ldn-untested ingame 2025-02-03 22:21:00
431 0100BC400FB64000 Balthazar's Dream playable 2022-09-13 00:13:22
432 01008D30128E0000 Bamerang playable 2022-10-26 00:29:39
433 010013C010C5C000 Banner of the Maid playable 2021-06-14 15:23:37
530 01005950022EC000 Blade Strangers nvdec playable 2022-07-17 19:02:43
531 0100DF0011A6A000 Bladed Fury playable 2022-10-26 11:36:26
532 0100CFA00CC74000 Blades of Time deadlock;online boots 2022-07-17 19:19:58
533 01003d700dd8a000 Blades boots 2025-02-03 22:22:00
534 01006CC01182C000 Blair Witch nvdec;UE4 playable 2022-10-01 14:06:16
535 010039501405E000 Blanc gpu;slow ingame 2023-02-22 14:00:13
536 0100698009C6E000 Blasphemous nvdec playable 2021-03-01 12:15:31
958 010012800EBAE000 Disney TSUM TSUM FESTIVAL crash menus 2020-07-14 14:05:28
959 01009740120FE000 DISTRAINT 2 playable 2020-09-03 16:08:12
960 010075B004DD2000 DISTRAINT: Deluxe Edition playable 2020-06-15 23:42:24
961 010027400CDC6000 Divinity: Original Sin 2 - Definitive Edition services;crash;online-broken;regression menus ingame 2023-08-13 17:20:03 2025-02-03 22:12:30
962 01001770115C8000 Dodo Peak nvdec;UE4 playable 2022-10-04 16:13:05
963 010077B0100DA000 Dogurai playable 2020-10-04 02:40:16
964 010048100D51A000 Dokapon Up! Mugen no Roulette gpu;Needs Update menus 2022-12-08 19:39:10
1657 0100A73006E74000 Legendary Eleven playable 2021-06-08 12:09:03
1658 0100A7700B46C000 Legendary Fishing online playable 2021-04-14 15:08:46
1659 0100739018020000 LEGO® 2K Drive gpu;ldn-works ingame 2024-04-09 02:05:12
1660 01003A30012C0000 010085500130a000 LEGO® CITY Undercover nvdec playable 2024-09-30 08:44:27
1661 010070D009FEC000 LEGO® DC Super-Villains playable 2021-05-27 18:10:37
1662 010052A00B5D2000 LEGO® Harry Potter™ Collection crash ingame 2024-01-31 10:28:07
1663 010073C01AF34000 LEGO® Horizon Adventures™ vulkan-backend-bug;opengl-backend-bug;UE4 ingame 2025-01-07 04:24:56
1916 010073E008E6E000 Mugsters playable 2021-01-28 17:57:17
1917 0100A8400471A000 MUJO playable 2020-05-08 16:31:04
1918 0100211005E94000 Mulaka playable 2021-01-28 18:07:20
1919 01008e2013fb4000 Multi Quiz ldn-untested ingame 2025-02-03 22:26:00
1920 010038B00B9AE000 Mummy Pinball playable 2022-08-05 16:08:11
1921 01008E200C5C2000 Muse Dash playable 2020-06-06 14:41:29
1922 010035901046C000 Mushroom Quest playable 2020-05-17 13:07:08
2032 010003C00B868000 Ninjin: Clash of Carrots online-broken playable 2024-07-10 05:12:26
2033 0100746010E4C000 NinNinDays playable 2022-11-20 15:17:29
2034 0100C9A00ECE6000 Nintendo 64™ – Nintendo Switch Online gpu;vulkan ingame 2024-04-23 20:21:07
2035 0100e0601c632000 Nintendo 64™ – Nintendo Switch Online: MATURE 17+ ingame 2025-02-03 22:27:00
2036 0100D870045B6000 Nintendo Entertainment System™ - Nintendo Switch Online online playable 2022-07-01 15:45:06
2037 0100C4B0034B2000 Nintendo Labo Toy-Con 01 Variety Kit gpu ingame 2022-08-07 12:56:07
2038 01001E9003502000 Nintendo Labo Toy-Con 03 Vehicle Kit services;crash menus 2022-08-03 17:20:11
2537 0100C3E00B700000 SEGA AGES Space Harrier playable 2021-01-11 12:57:40
2538 010054400D2E6000 SEGA AGES Virtua Racing online-broken playable 2023-01-29 17:08:39
2539 01001E700AC60000 SEGA AGES Wonder Boy: Monster Land online playable 2021-05-05 16:28:25
2540 0100B3C014BDA000 SEGA Genesis™ – Nintendo Switch Online crash;regression nothing ingame 2022-04-11 07:27:21 2025-02-03 22:13:30
2541 0100F7300B24E000 SEGA Mega Drive Classics online playable 2021-01-05 11:08:00
2542 01009840046BC000 Semispheres playable 2021-01-06 23:08:31
2543 0100D1800D902000 SENRAN KAGURA Peach Ball playable 2021-06-03 15:12:10
2969 0100C38004DCC000 The Flame In The Flood: Complete Edition gpu;nvdec;UE4 ingame 2022-08-22 16:23:49
2970 010007700D4AC000 The Forbidden Arts playable 2021-01-26 16:26:24
2971 010030700CBBC000 The friends of Ringo Ishikawa playable 2022-08-22 16:33:17
2972 0100b620139d8000 The Game of Life 2 ldn-untested ingame 2025-02-03 22:30:00
2973 01006350148DA000 The Gardener and the Wild Vines gpu ingame 2024-04-29 16:32:10
2974 0100B13007A6A000 The Gardens Between playable 2021-01-29 16:16:53
2975 010036E00FB20000 The Great Ace Attorney Chronicles playable 2023-06-22 21:26:29
2987 010015D003EE4000 The Jackbox Party Pack 2 online-working playable 2022-08-22 18:23:40
2988 0100CC80013D6000 The Jackbox Party Pack 3 slow;online-working playable 2022-08-22 18:41:06
2989 0100E1F003EE8000 The Jackbox Party Pack 4 online-working playable 2022-08-22 18:56:34
2990 01006fe0096ac000 The Jackbox Party Pack 5 ldn-untested boots 2025-02-03 22:32:00
2991 01005a400db52000 The Jackbox Party Pack 6 ldn-untested boots 2025-02-03 22:32:00
2992 010052C00B184000 The Journey Down: Chapter One nvdec playable 2021-02-24 13:32:41
2993 01006BC00B188000 The Journey Down: Chapter Three nvdec playable 2021-02-24 13:45:27
2994 01009AB00B186000 The Journey Down: Chapter Two nvdec playable 2021-02-24 13:32:13
3167 010055E00CA68000 Trine 4: The Nightmare Prince gpu nothing 2025-01-07 05:47:46
3168 0100D9000A930000 Trine Enchanted Edition ldn-untested;nvdec playable 2021-06-03 11:28:15
3169 01002D7010A54000 Trinity Trigger crash ingame 2023-03-03 03:09:09
3170 010020700a5e0000 TRIVIAL PURSUIT Live! ldn-untested ingame 2025-02-03 22:35:00
3171 0100868013FFC000 TRIVIAL PURSUIT Live! 2 boots 2022-12-19 00:04:33
3172 0100F78002040000 Troll and I™ gpu;nvdec ingame 2021-06-04 16:58:50
3173 0100145011008000 Trollhunters: Defenders of Arcadia gpu;nvdec ingame 2020-11-30 13:27:09
3217 0100AB2010B4C000 Unlock The King playable 2020-09-01 13:58:27
3218 0100A3E011CB0000 Unlock the King 2 playable 2021-06-15 20:43:55
3219 01005AA00372A000 UNO® for Nintendo Switch nvdec;ldn-untested playable 2022-07-28 14:49:47
3220 0100b6e012ebe000 UNO ldn-untested ingame 2025-02-03 22:40:00
3221 0100E5D00CC0C000 Unravel Two nvdec playable 2024-05-23 15:45:05
3222 010001300CC4A000 Unruly Heroes playable 2021-01-07 18:09:31
3223 0100B410138C0000 Unspottable playable 2022-10-25 19:28:49
3382 0100F47016F26000 Yomawari 3 playable 2022-05-10 08:26:51
3383 010012F00B6F2000 Yomawari: The Long Night Collection playable 2022-09-03 14:36:59
3384 0100CC600ABB2000 Yonder: The Cloud Catcher Chronicles (Retail Only) playable 2021-01-28 14:06:25
3385 0100534009ff2000 Yonder: The Cloud Catcher Chronicles playable 2025-02-03 22:19:13
3386 0100BE50042F6000 Yono and the Celestial Elephants playable 2021-01-28 18:23:58
3387 0100F110029C8000 Yooka-Laylee playable 2021-01-28 14:21:45
3388 010022F00DA66000 Yooka-Laylee and the Impossible Lair playable 2021-03-05 17:32:21

View File

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

View File

@ -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<MessagePackObject> PlayReport;
public static event Action<PlayReport> PlayReport;
[field: ThreadStatic]
public static HorizonOptions Options { get; private set; }

View File

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

View File

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

View File

@ -584,7 +584,7 @@
"he_IL": "",
"it_IT": "",
"ja_JP": "",
"ko_KR": "",
"ko_KR": "UI를 숨긴 상태에서 게임 시작",
"no_NO": "",
"pl_PL": "",
"pt_BR": "",
@ -1524,6 +1524,156 @@
},
{
"ID": "GameListHeaderDeveloper",
"Translations": {
"ar_SA": "",
"de_DE": "",
"el_GR": "",
"en_US": "Developed by {0}",
"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": "由 {0} 开发",
"zh_TW": ""
}
},
{
"ID": "GameListHeaderVersion",
"Translations": {
"ar_SA": "",
"de_DE": "",
"el_GR": "Έκδοση: {0}",
"en_US": "Version: {0}",
"es_ES": "Versión: {0}",
"fr_FR": "",
"he_IL": "",
"it_IT": "Versione: {0}",
"ja_JP": "バージョン: {0}",
"ko_KR": "버전: {0}",
"no_NO": "Versjon: {0}",
"pl_PL": "Wersja: {0}",
"pt_BR": "Versão: {0}",
"ru_RU": "Версия: {0}",
"sv_SE": "",
"th_TH": "เวอร์ชั่น: {0}",
"tr_TR": "Sürüm: {0}",
"uk_UA": "Версія: {0}",
"zh_CN": "版本: {0}",
"zh_TW": "版本: {0}"
}
},
{
"ID": "GameListHeaderTimePlayed",
"Translations": {
"ar_SA": "",
"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:",
"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: ",
"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:",
"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": "最近遊玩:"
}
},
{
"ID": "GameListHeaderFileExtension",
"Translations": {
"ar_SA": "",
"de_DE": "Dateiformat: {0}",
"el_GR": "Κατάληξη: {0}",
"en_US": "Extension: {0}",
"es_ES": "Extensión: {0}",
"fr_FR": "Extension du Fichier: {0}",
"he_IL": "",
"it_IT": "Estensione: {0}",
"ja_JP": "ファイル拡張子: {0}",
"ko_KR": "파일 확장자: {0}",
"no_NO": "Fil Eks.: {0}",
"pl_PL": "Rozszerzenie pliku: {0}",
"pt_BR": "Extensão: {0}",
"ru_RU": "Расширение файла: {0}",
"sv_SE": "Filänd: {0}",
"th_TH": "นามสกุลไฟล์: {0}",
"tr_TR": "Dosya Uzantısı: {0}",
"uk_UA": "Розширення файлу: {0}",
"zh_CN": "扩展名: {0}",
"zh_TW": "副檔名: {0}"
}
},
{
"ID": "GameListHeaderFileSize",
"Translations": {
"ar_SA": "",
"de_DE": "Dateigröße: {0}",
"el_GR": "Μέγεθος Αρχείου: {0}",
"en_US": "File Size: {0}",
"es_ES": "Tamaño del archivo: {0}",
"fr_FR": "Taille du Fichier: {0}",
"he_IL": "",
"it_IT": "Dimensione file: {0}",
"ja_JP": "ファイルサイズ: {0}",
"ko_KR": "파일 크기: {0}",
"no_NO": "Fil Størrelse: {0}",
"pl_PL": "Rozmiar pliku: {0}",
"pt_BR": "Tamanho: {0}",
"ru_RU": "Размер файла: {0}",
"sv_SE": "Filstorlek: {0}",
"th_TH": "ขนาดไฟล์: {0}",
"tr_TR": "Dosya Boyutu: {0}",
"uk_UA": "Розмір файлу: {0}",
"zh_CN": "大小: {0}",
"zh_TW": "檔案大小: {0}"
}
},
{
"ID": "GameListSortDeveloper",
"Translations": {
"ar_SA": "المطور",
"de_DE": "Entwickler",
@ -1548,32 +1698,7 @@
}
},
{
"ID": "GameListHeaderVersion",
"Translations": {
"ar_SA": "الإصدار",
"de_DE": "",
"el_GR": "Έκδοση",
"en_US": "Version",
"es_ES": "Versión",
"fr_FR": "",
"he_IL": "גרסה",
"it_IT": "Versione",
"ja_JP": "バージョン",
"ko_KR": "버전",
"no_NO": "Versjon",
"pl_PL": "Wersja",
"pt_BR": "Versão",
"ru_RU": "Версия",
"sv_SE": "",
"th_TH": "เวอร์ชั่น",
"tr_TR": "Sürüm",
"uk_UA": "Версія",
"zh_CN": "版本",
"zh_TW": "版本"
}
},
{
"ID": "GameListHeaderTimePlayed",
"ID": "GameListSortTimePlayed",
"Translations": {
"ar_SA": "وقت اللعب",
"de_DE": "Spielzeit",
@ -1598,7 +1723,7 @@
}
},
{
"ID": "GameListHeaderLastPlayed",
"ID": "GameListSortLastPlayed",
"Translations": {
"ar_SA": "آخر مرة لُعبت",
"de_DE": "Zuletzt gespielt",
@ -1623,7 +1748,7 @@
}
},
{
"ID": "GameListHeaderFileExtension",
"ID": "GameListSortFileExtension",
"Translations": {
"ar_SA": "صيغة الملف",
"de_DE": "Dateiformat",
@ -1648,7 +1773,7 @@
}
},
{
"ID": "GameListHeaderFileSize",
"ID": "GameListSortFileSize",
"Translations": {
"ar_SA": "حجم الملف",
"de_DE": "Dateigröße",
@ -1673,7 +1798,7 @@
}
},
{
"ID": "GameListHeaderPath",
"ID": "GameListSortPath",
"Translations": {
"ar_SA": "المسار",
"de_DE": "Pfad",
@ -1697,6 +1822,106 @@
"zh_TW": "路徑"
}
},
{
"ID": "GameListHeaderCompatibilityStatus",
"Translations": {
"ar_SA": "",
"de_DE": "",
"el_GR": "",
"en_US": "Compatibility:",
"es_ES": "",
"fr_FR": "",
"he_IL": "",
"it_IT": "",
"ja_JP": "",
"ko_KR": "",
"no_NO": "",
"pl_PL": "",
"pt_BR": "",
"ru_RU": "",
"sv_SE": "",
"th_TH": "",
"tr_TR": "",
"uk_UA": "",
"zh_CN": "兼容性:",
"zh_TW": ""
}
},
{
"ID": "GameListHeaderTitleId",
"Translations": {
"ar_SA": "",
"de_DE": "",
"el_GR": "",
"en_US": "Title ID:",
"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": "标题 ID:",
"zh_TW": ""
}
},
{
"ID": "GameListHeaderHostedGames",
"Translations": {
"ar_SA": "",
"de_DE": "",
"el_GR": "",
"en_US": "Hosted Games: {0}",
"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": "服务的游戏: {0}",
"zh_TW": ""
}
},
{
"ID": "GameListHeaderPlayerCount",
"Translations": {
"ar_SA": "",
"de_DE": "",
"el_GR": "",
"en_US": "Online Players: {0}",
"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": "在线玩家: {0}",
"zh_TW": ""
}
},
{
"ID": "GameListContextMenuOpenUserSaveDirectory",
"Translations": {
@ -2034,7 +2259,7 @@
"he_IL": "",
"it_IT": "",
"ja_JP": "",
"ko_KR": "",
"ko_KR": "PPTC 캐시 제거",
"no_NO": "",
"pl_PL": "",
"pt_BR": "",
@ -2043,7 +2268,7 @@
"th_TH": "",
"tr_TR": "",
"uk_UA": "",
"zh_CN": "",
"zh_CN": "清理 PPTC 缓存",
"zh_TW": ""
}
},
@ -2059,7 +2284,7 @@
"he_IL": "",
"it_IT": "",
"ja_JP": "",
"ko_KR": "",
"ko_KR": "앱의 모든 PPTC 캐시 파일 삭제",
"no_NO": "",
"pl_PL": "",
"pt_BR": "",
@ -2068,7 +2293,7 @@
"th_TH": "",
"tr_TR": "",
"uk_UA": "",
"zh_CN": "",
"zh_CN": "删除应用程序的所有 PPTC 缓存",
"zh_TW": ""
}
},
@ -2384,7 +2609,7 @@
"he_IL": "",
"it_IT": "",
"ja_JP": "",
"ko_KR": "",
"ko_KR": "선택한 DLC 파일에서 RomFS 추출",
"no_NO": "Pakk ut RomFS filene fra valgt DLC fil",
"pl_PL": "",
"pt_BR": "",
@ -2534,7 +2759,7 @@
"he_IL": "",
"it_IT": "",
"ja_JP": "",
"ko_KR": "",
"ko_KR": "호환성 항목 표시",
"no_NO": "",
"pl_PL": "",
"pt_BR": "",
@ -2543,7 +2768,7 @@
"th_TH": "",
"tr_TR": "",
"uk_UA": "",
"zh_CN": "",
"zh_CN": "显示兼容性项目",
"zh_TW": ""
}
},
@ -2559,7 +2784,7 @@
"he_IL": "",
"it_IT": "",
"ja_JP": "",
"ko_KR": "",
"ko_KR": "일반적으로 도움말 메뉴를 통해 접근할 수 있는 호환성 목록에 선택한 게임을 표시합니다.",
"no_NO": "",
"pl_PL": "",
"pt_BR": "",
@ -2568,7 +2793,57 @@
"th_TH": "",
"tr_TR": "",
"uk_UA": "",
"zh_CN": "",
"zh_CN": "在兼容性列表中显示选定的游戏,您通常可以通过帮助菜单访问。",
"zh_TW": ""
}
},
{
"ID": "GameListContextMenuShowGameData",
"Translations": {
"ar_SA": "",
"de_DE": "",
"el_GR": "",
"en_US": "Show Game Info",
"es_ES": "",
"fr_FR": "",
"he_IL": "",
"it_IT": "",
"ja_JP": "",
"ko_KR": "게임 통계 표시",
"no_NO": "",
"pl_PL": "",
"pt_BR": "",
"ru_RU": "",
"sv_SE": "",
"th_TH": "",
"tr_TR": "",
"uk_UA": "",
"zh_CN": "显示游戏信息",
"zh_TW": ""
}
},
{
"ID": "GameListContextMenuShowGameDataToolTip",
"Translations": {
"ar_SA": "",
"de_DE": "",
"el_GR": "",
"en_US": "Show stats & details about the currently selected game.",
"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": ""
}
},
@ -3334,7 +3609,7 @@
"he_IL": "",
"it_IT": "Aggiornamenti e DLC che fanno riferimento a file mancanti verranno disabilitati automaticamente",
"ja_JP": "",
"ko_KR": "누락된 파일을 참조하는 DLC 및 업데이트가 자동으로 언로드",
"ko_KR": "누락된 파일을 참조하는 DLC 및 업데이트가 자동으로 불러오기 취소",
"no_NO": "DLC og oppdateringer som henviser til manglende filer, vil bli lastet ned automatisk",
"pl_PL": "",
"pt_BR": "DLCs e Atualizações que se referem a arquivos ausentes serão descarregadas automaticamente",
@ -4109,7 +4384,7 @@
"he_IL": "",
"it_IT": "",
"ja_JP": "",
"ko_KR": "",
"ko_KR": "스웨덴어",
"no_NO": "",
"pl_PL": "",
"pt_BR": "",
@ -4134,7 +4409,7 @@
"he_IL": "",
"it_IT": "",
"ja_JP": "",
"ko_KR": "",
"ko_KR": "노르웨이어",
"no_NO": "Norsk",
"pl_PL": "",
"pt_BR": "",
@ -4209,7 +4484,7 @@
"he_IL": "",
"it_IT": "",
"ja_JP": "",
"ko_KR": "",
"ko_KR": "매치 시스템 시간",
"no_NO": "",
"pl_PL": "",
"pt_BR": "",
@ -4218,7 +4493,7 @@
"th_TH": "",
"tr_TR": "",
"uk_UA": "",
"zh_CN": "",
"zh_CN": "与系统时间同步",
"zh_TW": ""
}
},
@ -5847,6 +6122,56 @@
"zh_TW": "關閉"
}
},
{
"ID": "SettingsButtonReset",
"Translations": {
"ar_SA": "",
"de_DE": "",
"el_GR": "",
"en_US": "Reset Settings",
"es_ES": "",
"fr_FR": "",
"he_IL": "",
"it_IT": "",
"ja_JP": "",
"ko_KR": "",
"no_NO": "",
"pl_PL": "",
"pt_BR": "",
"ru_RU": "",
"sv_SE": "",
"th_TH": "",
"tr_TR": "",
"uk_UA": "",
"zh_CN": "重置设置",
"zh_TW": ""
}
},
{
"ID": "SettingsButtonResetConfirm",
"Translations": {
"ar_SA": "",
"de_DE": "",
"el_GR": "",
"en_US": "I want to reset my settings.",
"es_ES": "",
"fr_FR": "",
"he_IL": "",
"it_IT": "",
"ja_JP": "",
"ko_KR": "",
"no_NO": "",
"pl_PL": "",
"pt_BR": "",
"ru_RU": "",
"sv_SE": "",
"th_TH": "",
"tr_TR": "",
"uk_UA": "",
"zh_CN": "我要重置我的设置。",
"zh_TW": ""
}
},
{
"ID": "SettingsButtonOk",
"Translations": {
@ -7759,7 +8084,7 @@
"he_IL": "",
"it_IT": "",
"ja_JP": "",
"ko_KR": "",
"ko_KR": "비활성화",
"no_NO": "",
"pl_PL": "",
"pt_BR": "",
@ -7784,7 +8109,7 @@
"he_IL": "",
"it_IT": "",
"ja_JP": "",
"ko_KR": "",
"ko_KR": "레인보우",
"no_NO": "",
"pl_PL": "",
"pt_BR": "",
@ -7809,7 +8134,7 @@
"he_IL": "",
"it_IT": "",
"ja_JP": "",
"ko_KR": "",
"ko_KR": "레인보우 속도",
"no_NO": "",
"pl_PL": "",
"pt_BR": "",
@ -7818,7 +8143,7 @@
"th_TH": "",
"tr_TR": "",
"uk_UA": "",
"zh_CN": "",
"zh_CN": "彩虹滚动速度",
"zh_TW": ""
}
},
@ -7834,7 +8159,7 @@
"he_IL": "",
"it_IT": "",
"ja_JP": "",
"ko_KR": "",
"ko_KR": "색상",
"no_NO": "",
"pl_PL": "",
"pt_BR": "",
@ -13084,7 +13409,7 @@
"he_IL": "",
"it_IT": "",
"ja_JP": "",
"ko_KR": "",
"ko_KR": "다음에서 모든 PPTC 데이터를 제거하려고 합니다:\n\n{0}\n\n계속하시겠습니까?",
"no_NO": "",
"pl_PL": "",
"pt_BR": "",
@ -13093,7 +13418,7 @@
"th_TH": "",
"tr_TR": "",
"uk_UA": "",
"zh_CN": "",
"zh_CN": "您正要清理 PPTC 数据:\n\n{0}\n\n您确实要继续吗?",
"zh_TW": ""
}
},
@ -19084,7 +19409,7 @@
"he_IL": "",
"it_IT": "",
"ja_JP": "",
"ko_KR": "",
"ko_KR": "LED 설정",
"no_NO": "",
"pl_PL": "",
"pt_BR": "",
@ -22009,7 +22334,7 @@
"he_IL": "ממשק רשת",
"it_IT": "Interfaccia di rete:",
"ja_JP": "ネットワークインタフェース:",
"ko_KR": "네트워크 인터페이스:",
"ko_KR": "네트워크 인터페이스 :",
"no_NO": "Nettverksgrensesnitt",
"pl_PL": "Interfejs sieci:",
"pt_BR": "Interface de rede:",
@ -22984,7 +23309,7 @@
"he_IL": "",
"it_IT": "",
"ja_JP": "",
"ko_KR": "",
"ko_KR": "최종 업데이트 : {0}",
"no_NO": "Sist oppdatert: {0}",
"pl_PL": "",
"pt_BR": "",
@ -23222,6 +23547,131 @@
"zh_TW": "無法啟動"
}
},
{
"ID": "CompatibilityListPlayableTooltip",
"Translations": {
"ar_SA": "",
"de_DE": "",
"el_GR": "",
"en_US": "Boots and plays without any crashes or GPU bugs of any kind, and at a speed fast enough to reasonably enjoy on an average PC.",
"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": "启动和游戏时不会出现任何崩溃或任何类型的 GPU bug 且速度足够快可以在一般 PC 上尽情游玩。",
"zh_TW": ""
}
},
{
"ID": "CompatibilityListIngameTooltip",
"Translations": {
"ar_SA": "",
"de_DE": "",
"el_GR": "",
"en_US": "Boots and goes in-game but suffers from one or more of the following: crashes, deadlocks, GPU bugs, distractingly bad audio, or is simply too slow. Game still might able to be played all the way through, but not as the game is intended to play.",
"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": "可以成功启动并进入游戏但可能会遇到以下一种或多种问题: 崩溃、卡死、GPU bug、令人无法接受的音频,或者只是太慢。仍然可以继续进行游戏,但是可能无法达到预期。",
"zh_TW": ""
}
},
{
"ID": "CompatibilityListMenusTooltip",
"Translations": {
"ar_SA": "",
"de_DE": "",
"el_GR": "",
"en_US": "Boots and goes past the title screen but does not make it into main gameplay.",
"es_ES": "",
"fr_FR": "",
"he_IL": "",
"it_IT": "",
"ja_JP": "",
"ko_KR": "",
"no_NO": "",
"pl_PL": "",
"pt_BR": "",
"ru_RU": "",
"sv_SE": "",
"th_TH": "",
"tr_TR": "",
"uk_UA": "",
"zh_CN": "可以启动并通过标题画面但是无法进入到主要的游戏流程。",
"zh_TW": ""
}
},
{
"ID": "CompatibilityListBootsTooltip",
"Translations": {
"ar_SA": "",
"de_DE": "",
"el_GR": "",
"en_US": "Boots but does not make it past the title screen.",
"es_ES": "",
"fr_FR": "",
"he_IL": "",
"it_IT": "",
"ja_JP": "",
"ko_KR": "",
"no_NO": "",
"pl_PL": "",
"pt_BR": "",
"ru_RU": "",
"sv_SE": "",
"th_TH": "",
"tr_TR": "",
"uk_UA": "",
"zh_CN": "可以启动但是无法通过标题画面。",
"zh_TW": ""
}
},
{
"ID": "CompatibilityListNothingTooltip",
"Translations": {
"ar_SA": "",
"de_DE": "",
"el_GR": "",
"en_US": "Does not boot or shows no signs of activity.",
"es_ES": "",
"fr_FR": "",
"he_IL": "",
"it_IT": "",
"ja_JP": "",
"ko_KR": "",
"no_NO": "",
"pl_PL": "",
"pt_BR": "",
"ru_RU": "",
"sv_SE": "",
"th_TH": "",
"tr_TR": "",
"uk_UA": "",
"zh_CN": "无法启动或显示无任何动静。",
"zh_TW": ""
}
},
{
"ID": "ExtractAocListHeader",
"Translations": {
@ -23234,7 +23684,7 @@
"he_IL": "",
"it_IT": "",
"ja_JP": "",
"ko_KR": "",
"ko_KR": "추출할 DLC 선택",
"no_NO": "Velg en DLC og hente ut",
"pl_PL": "",
"pt_BR": "",
@ -23246,6 +23696,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": ""
}
}
]
}

View File

@ -4,15 +4,13 @@ using MsgPack;
using Ryujinx.Ava.Utilities;
using Ryujinx.Ava.Utilities.AppLibrary;
using Ryujinx.Ava.Utilities.Configuration;
using Ryujinx.Ava.Utilities.PlayReport;
using Ryujinx.Common;
using Ryujinx.Common.Helper;
using Ryujinx.Common.Logging;
using Ryujinx.HLE;
using Ryujinx.HLE.Loaders.Processes;
using Ryujinx.Horizon;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using Ryujinx.Horizon.Prepo.Types;
using System.Linq;
using System.Text;
@ -41,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
@ -124,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;
PlayReportAnalyzer.FormattedValue formattedValue =
PlayReport.Analyzer.Format(TitleIDs.CurrentApplication.Value, _currentApp, playReport);
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

View File

@ -32,6 +32,9 @@ namespace Ryujinx.Ava
public static MainWindow MainWindow => Current!
.ApplicationLifetime.Cast<IClassicDesktopStyleApplicationLifetime>()
.MainWindow.Cast<MainWindow>();
public static IClassicDesktopStyleApplicationLifetime AppLifetime => Current!
.ApplicationLifetime.Cast<IClassicDesktopStyleApplicationLifetime>();
public static bool IsClipboardAvailable(out IClipboard clipboard)
{

View File

@ -25,6 +25,11 @@
Header="{ext:Locale GameListContextMenuShowCompatEntry}"
Icon="{ext:Icon mdi-gamepad}"
ToolTip.Tip="{ext:Locale GameListContextMenuShowCompatEntryToolTip}"/>
<MenuItem
Click="OpenApplicationData_Click"
Header="{ext:Locale GameListContextMenuShowGameData}"
Icon="{ext:Icon mdi-chart-line}"
ToolTip.Tip="{ext:Locale GameListContextMenuShowGameDataToolTip}"/>
<Separator />
<MenuItem
Click="OpenUserSaveDirectory_Click"

View File

@ -392,6 +392,12 @@ namespace Ryujinx.Ava.UI.Controls
if (sender is MenuItem { DataContext: MainWindowViewModel { SelectedApplication: not null } viewModel })
await CompatibilityList.Show(viewModel.SelectedApplication.IdString);
}
public async void OpenApplicationData_Click(object sender, RoutedEventArgs args)
{
if (sender is MenuItem { DataContext: MainWindowViewModel { SelectedApplication: not null } viewModel })
await ApplicationDataView.Show(viewModel.SelectedApplication);
}
public async void RunApplication_Click(object sender, RoutedEventArgs args)
{
@ -401,12 +407,8 @@ namespace Ryujinx.Ava.UI.Controls
public async void TrimXCI_Click(object sender, RoutedEventArgs args)
{
MainWindowViewModel viewModel = (sender as MenuItem)?.DataContext as MainWindowViewModel;
if (viewModel?.SelectedApplication != null)
{
if (sender is MenuItem { DataContext: MainWindowViewModel { SelectedApplication: not null } viewModel })
await viewModel.TrimXCIFile(viewModel.SelectedApplication.Path);
}
}
}
}

View File

@ -0,0 +1,183 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:helpers="clr-namespace:Ryujinx.Ava.UI.Helpers"
xmlns:ext="using:Ryujinx.Ava.Common.Markup"
xmlns:viewModels="using:Ryujinx.Ava.UI.ViewModels"
xmlns:ui="using:FluentAvalonia.UI.Controls"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="Ryujinx.Ava.UI.Controls.ApplicationDataView"
x:DataType="viewModels:ApplicationDataViewModel">
<StackPanel Orientation="Horizontal">
<Image Margin="0"
MaxWidth="256"
MinWidth="256"
Source="{Binding AppData.Icon, Converter={x:Static helpers:BitmapArrayValueConverter.Instance}}" />
<Border Margin="5, 0" Width="1" Height="256" BorderBrush="Gray" Background="Gray" />
<StackPanel Orientation="Vertical">
<Grid
RowDefinitions="Auto,Auto,Auto"
ColumnDefinitions="*">
<StackPanel Grid.Row="0">
<TextBlock HorizontalAlignment="Left"
Text="{Binding FormattedVersion}"
TextAlignment="Start"
TextWrapping="Wrap" />
<TextBlock HorizontalAlignment="Left"
Text="{Binding FormattedDeveloper}"
TextAlignment="Start"
TextWrapping="Wrap" />
<TextBlock HorizontalAlignment="Stretch"
Text="{Binding FormattedFileExtension}"
TextAlignment="Start"
TextWrapping="Wrap" />
<TextBlock HorizontalAlignment="Stretch"
Text="{Binding FormattedFileSize}"
TextAlignment="Start"
TextWrapping="Wrap" />
</StackPanel>
<Separator Grid.Row="1" Margin="0, 10, 0, 10" Height="1" BorderBrush="Gray" Background="Gray" />
<StackPanel Grid.Row="2"
HorizontalAlignment="Left"
Orientation="Vertical"
Spacing="5">
<StackPanel Orientation="Horizontal" IsVisible="{Binding AppData.HasPlayabilityInfo}">
<TextBlock Padding="0, 0, 5, 0" Text="{ext:Locale GameListHeaderCompatibilityStatus}" />
<Button
Click="PlayabilityStatus_OnClick"
HorizontalContentAlignment="Left"
VerticalAlignment="Center"
Background="{DynamicResource AppListBackgroundColor}"
Padding="0">
<TextBlock
Margin="1.5"
Tag="{Binding AppData.IdString}"
Text="{Binding AppData.LocalizedStatus}"
Foreground="{Binding AppData.PlayabilityStatus, Converter={x:Static helpers:PlayabilityStatusConverter.Shared}}"
TextAlignment="Start"
TextWrapping="Wrap">
<ToolTip.Tip>
<StackPanel Orientation="Vertical">
<TextBlock
Text="{Binding AppData.LocalizedStatusTooltip}" />
<Separator
Margin="0, 10, 0, 10"
IsVisible="{Binding AppData.HasCompatibilityLabels}" />
<TextBlock
IsVisible="{Binding AppData.HasCompatibilityLabels}"
Text="{Binding AppData.FormattedCompatibilityLabels}" />
</StackPanel>
</ToolTip.Tip>
</TextBlock>
<Button.Styles>
<Style Selector="Button">
<Setter Property="MinWidth"
Value="0" />
<!-- avoids very wide buttons from the overall project avalonia style -->
</Style>
</Button.Styles>
</Button>
</StackPanel>
<StackPanel Orientation="Horizontal">
<TextBlock Padding="0, 0, 5, 0" Text="{ext:Locale GameListHeaderTitleId}" />
<Button
Click="IdString_OnClick"
HorizontalContentAlignment="Left"
VerticalAlignment="Center"
Background="{DynamicResource AppListBackgroundColor}"
Padding="0">
<TextBlock
Margin="1.5"
HorizontalAlignment="Stretch"
Text="{Binding AppData.IdString}"
TextAlignment="Start"
TextWrapping="Wrap" />
</Button>
</StackPanel>
</StackPanel>
</Grid>
<Separator Margin="0, 10, 0, 10" Height="1" BorderBrush="Gray" Background="Gray" />
<StackPanel Orientation="Vertical" Spacing="5">
<StackPanel Orientation="Horizontal" Spacing="5">
<ui:SymbolIcon Foreground="ForestGreen" Symbol="Checkmark" IsVisible="{Binding AppData.HasRichPresenceAsset}"/>
<TextBlock
Foreground="ForestGreen"
HorizontalAlignment="Stretch"
IsVisible="{Binding AppData.HasRichPresenceAsset}"
Text="{ext:Locale GameInfoRpcImage}"
TextAlignment="Start"
TextWrapping="Wrap" >
</TextBlock>
<ui:SymbolIcon Foreground="Red" Symbol="Cancel" IsVisible="{Binding !AppData.HasRichPresenceAsset}"/>
<TextBlock
Foreground="Red"
HorizontalAlignment="Stretch"
IsVisible="{Binding !AppData.HasRichPresenceAsset}"
Text="{ext:Locale GameInfoRpcImage}"
TextAlignment="Start"
TextWrapping="Wrap" >
</TextBlock>
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="5">
<ui:SymbolIcon Foreground="ForestGreen" Symbol="Checkmark" IsVisible="{Binding AppData.HasDynamicRichPresenceSupport}"/>
<TextBlock
Foreground="ForestGreen"
HorizontalAlignment="Stretch"
IsVisible="{Binding AppData.HasDynamicRichPresenceSupport}"
Text="{ext:Locale GameInfoRpcDynamic}"
TextAlignment="Start"
TextWrapping="Wrap" >
</TextBlock>
<ui:SymbolIcon Foreground="Red" Symbol="Cancel" IsVisible="{Binding !AppData.HasDynamicRichPresenceSupport}"/>
<TextBlock
Foreground="Red"
HorizontalAlignment="Stretch"
IsVisible="{Binding !AppData.HasDynamicRichPresenceSupport}"
Text="{ext:Locale GameInfoRpcDynamic}"
TextAlignment="Start"
TextWrapping="Wrap" >
</TextBlock>
</StackPanel>
</StackPanel>
<Separator Margin="0, 10, 0, 10" Height="1" BorderBrush="Gray" Background="Gray" />
<TextBlock
HorizontalAlignment="Stretch"
IsVisible="{Binding AppData.HasLdnGames}"
Text="{Binding FormattedLdnInfo}"
TextAlignment="Start"
TextWrapping="Wrap" />
<Separator IsVisible="{Binding AppData.HasLdnGames}" Margin="0, 10, 0, 10" Height="1" BorderBrush="Gray" Background="Gray" />
<StackPanel Orientation="Vertical" Spacing="5">
<Grid
ColumnDefinitions="Auto,*,Auto">
<TextBlock
Grid.Column="0"
Text="{ext:Locale GameListHeaderLastPlayed}"
VerticalAlignment="Top"
TextAlignment="Start"
TextWrapping="NoWrap" />
<TextBlock
Grid.Column="2"
Text="{Binding AppData.LastPlayedString}"
TextAlignment="End"
TextWrapping="Wrap" />
</Grid>
<Grid
ColumnDefinitions="Auto,*,Auto"
IsVisible="{Binding AppData.HasPlayedPreviously}">
<TextBlock
Grid.Column="0"
Text="{ext:Locale GameListHeaderTimePlayed}"
VerticalAlignment="Top"
TextAlignment="Start"
TextWrapping="NoWrap" />
<TextBlock Grid.Column="2"
Text="{Binding AppData.TimePlayedString}"
TextAlignment="End"
TextWrapping="Wrap" />
</Grid>
</StackPanel>
</StackPanel>
</StackPanel>
</UserControl>

View File

@ -0,0 +1,86 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input.Platform;
using Avalonia.Interactivity;
using Avalonia.Styling;
using FluentAvalonia.UI.Controls;
using Ryujinx.Ava;
using Ryujinx.Ava.Common.Locale;
using Ryujinx.Ava.UI.Controls;
using Ryujinx.Ava.UI.Helpers;
using Ryujinx.Ava.UI.ViewModels;
using Ryujinx.Ava.UI.Windows;
using Ryujinx.Ava.Utilities.AppLibrary;
using Ryujinx.Ava.Utilities.Compat;
using System.Linq;
using System.Threading.Tasks;
namespace Ryujinx.Ava.UI.Controls
{
public partial class ApplicationDataView : UserControl
{
public static async Task Show(ApplicationData appData)
{
ContentDialog contentDialog = new()
{
Title = appData.Name,
PrimaryButtonText = string.Empty,
SecondaryButtonText = string.Empty,
CloseButtonText = LocaleManager.Instance[LocaleKeys.SettingsButtonClose],
MinWidth = 256,
Content = new ApplicationDataView { DataContext = new ApplicationDataViewModel(appData) }
};
Style closeButton = new(x => x.Name("CloseButton"));
closeButton.Setters.Add(new Setter(WidthProperty, 160d));
Style closeButtonParent = new(x => x.Name("CommandSpace"));
closeButtonParent.Setters.Add(new Setter(HorizontalAlignmentProperty,
Avalonia.Layout.HorizontalAlignment.Center));
contentDialog.Styles.Add(closeButton);
contentDialog.Styles.Add(closeButtonParent);
await ContentDialogHelper.ShowAsync(contentDialog);
}
public ApplicationDataView()
{
InitializeComponent();
}
private async void PlayabilityStatus_OnClick(object sender, RoutedEventArgs e)
{
if (sender is not Button { Content: TextBlock playabilityLabel })
return;
if (RyujinxApp.AppLifetime.Windows.TryGetFirst(x => x is ContentDialogOverlayWindow, out Window window))
window.Close(ContentDialogResult.None);
await CompatibilityList.Show((string)playabilityLabel.Tag);
}
private async void IdString_OnClick(object sender, RoutedEventArgs e)
{
if (DataContext is not MainWindowViewModel mwvm)
return;
if (sender is not Button { Content: TextBlock idText })
return;
if (!RyujinxApp.IsClipboardAvailable(out IClipboard clipboard))
return;
ApplicationData appData = mwvm.Applications.FirstOrDefault(it => it.IdString == idText.Text);
if (appData is null)
return;
await clipboard.SetTextAsync(appData.IdString);
NotificationHelper.ShowInformation(
"Copied Title ID",
$"{appData.Name} ({appData.IdString})");
}
}
}

View File

@ -93,7 +93,19 @@
IsVisible="{Binding HasPlayabilityInfo}"
Background="{DynamicResource AppListBackgroundColor}"
Margin="-1, 0, 0, 0"
Padding="0" >
Padding="0">
<ToolTip.Tip>
<StackPanel Orientation="Vertical">
<TextBlock
Text="{Binding LocalizedStatusTooltip}" />
<Separator
Margin="0, 10, 0, 10"
IsVisible="{Binding HasCompatibilityLabels}" />
<TextBlock
IsVisible="{Binding HasCompatibilityLabels}"
Text="{Binding FormattedCompatibilityLabels}" />
</StackPanel>
</ToolTip.Tip>
<TextBlock
Margin="1.5"
Tag="{Binding IdString}"
@ -140,7 +152,8 @@
TextWrapping="Wrap" />
<TextBlock
HorizontalAlignment="Stretch"
Text="{Binding Converter={helpers:MultiplayerInfoConverter}}"
IsVisible="{Binding HasLdnGames}"
Text="{Binding Converter={x:Static helpers:MultiplayerInfoConverter.Instance}}"
TextAlignment="Start"
TextWrapping="Wrap"/>
</StackPanel>

View File

@ -1,27 +1,31 @@
using Avalonia.Data.Converters;
using Avalonia.Markup.Xaml;
using Gommon;
using Ryujinx.Ava.Common.Locale;
using Ryujinx.Ava.Utilities.AppLibrary;
using System;
using System.Globalization;
using System.Text;
namespace Ryujinx.Ava.UI.Helpers
{
internal class MultiplayerInfoConverter : MarkupExtension, IValueConverter
{
private static readonly MultiplayerInfoConverter _instance = new();
public static readonly MultiplayerInfoConverter Instance = new();
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value is ApplicationData applicationData)
{
if (applicationData.PlayerCount != 0 && applicationData.GameCount != 0)
{
return $"Hosted Games: {applicationData.GameCount}\nOnline Players: {applicationData.PlayerCount}";
}
}
return "";
if (value is not ApplicationData { HasLdnGames: true } applicationData)
return "";
return new StringBuilder()
.AppendLine(
LocaleManager.Instance[LocaleKeys.GameListHeaderHostedGames]
.Format(applicationData.GameCount))
.Append(
LocaleManager.Instance[LocaleKeys.GameListHeaderPlayerCount]
.Format(applicationData.PlayerCount))
.ToString();
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
@ -31,7 +35,7 @@ namespace Ryujinx.Ava.UI.Helpers
public override object ProvideValue(IServiceProvider serviceProvider)
{
return _instance;
return Instance;
}
}
}

View File

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

View File

@ -0,0 +1,23 @@
using Gommon;
using Ryujinx.Ava.Common.Locale;
using Ryujinx.Ava.Utilities.AppLibrary;
namespace Ryujinx.Ava.UI.ViewModels
{
public class ApplicationDataViewModel : BaseModel
{
public ApplicationData AppData { get; }
public ApplicationDataViewModel(ApplicationData appData) => AppData = appData;
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 FormattedFileSize => LocaleManager.Instance[LocaleKeys.GameListHeaderFileSize].Format(AppData.FileSizeString);
public string FormattedLdnInfo =>
$"{LocaleManager.Instance[LocaleKeys.GameListHeaderHostedGames].Format(AppData.GameCount)}" +
$"\n" +
$"{LocaleManager.Instance[LocaleKeys.GameListHeaderPlayerCount].Format(AppData.PlayerCount)}";
}
}

View File

@ -69,8 +69,7 @@ namespace Ryujinx.Ava.UI.ViewModels
private void LoadDownloadableContents()
{
IEnumerable<(DownloadableContentModel Dlc, bool IsEnabled)> dlcs = _applicationLibrary.DownloadableContents.Items
.Where(it => it.Dlc.TitleIdBase == _applicationData.IdBase);
(DownloadableContentModel Dlc, bool IsEnabled)[] dlcs = _applicationLibrary.FindDlcConfigurationFor(_applicationData.Id);
bool hasBundledContent = false;
foreach ((DownloadableContentModel dlc, bool isEnabled) in dlcs)

View File

@ -349,16 +349,7 @@ namespace Ryujinx.Ava.UI.ViewModels
}
}
public bool HasCompatibilityEntry
{
get
{
DynamicData.Kernel.Optional<ApplicationData> appData =
ApplicationLibrary.Applications.Lookup(SelectedApplication.Id);
return appData.HasValue && appData.Value.HasPlayabilityInfo;
}
}
public bool HasCompatibilityEntry => SelectedApplication.HasPlayabilityInfo;
public bool HasDlc => ApplicationLibrary.HasDlcs(SelectedApplication.Id);
@ -642,15 +633,15 @@ namespace Ryujinx.Ava.UI.ViewModels
{
return SortMode switch
{
ApplicationSort.Title => LocaleManager.Instance[LocaleKeys.GameListHeaderApplication],
ApplicationSort.Developer => LocaleManager.Instance[LocaleKeys.GameListHeaderDeveloper],
ApplicationSort.LastPlayed => LocaleManager.Instance[LocaleKeys.GameListHeaderLastPlayed],
ApplicationSort.TotalTimePlayed => LocaleManager.Instance[LocaleKeys.GameListHeaderTimePlayed],
ApplicationSort.FileType => LocaleManager.Instance[LocaleKeys.GameListHeaderFileExtension],
ApplicationSort.FileSize => LocaleManager.Instance[LocaleKeys.GameListHeaderFileSize],
ApplicationSort.Path => LocaleManager.Instance[LocaleKeys.GameListHeaderPath],
ApplicationSort.Favorite => LocaleManager.Instance[LocaleKeys.CommonFavorite],
ApplicationSort.TitleId => LocaleManager.Instance[LocaleKeys.DlcManagerTableHeadingTitleIdLabel],
ApplicationSort.Title => LocaleManager.Instance[LocaleKeys.GameListHeaderApplication],
ApplicationSort.Developer => LocaleManager.Instance[LocaleKeys.GameListSortDeveloper],
ApplicationSort.LastPlayed => LocaleManager.Instance[LocaleKeys.GameListSortLastPlayed],
ApplicationSort.TotalTimePlayed => LocaleManager.Instance[LocaleKeys.GameListSortTimePlayed],
ApplicationSort.FileType => LocaleManager.Instance[LocaleKeys.GameListSortFileExtension],
ApplicationSort.FileSize => LocaleManager.Instance[LocaleKeys.GameListSortFileSize],
ApplicationSort.Path => LocaleManager.Instance[LocaleKeys.GameListSortPath],
_ => string.Empty,
};
}

View File

@ -2,7 +2,7 @@ using Avalonia.Collections;
using Avalonia.Controls;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using Gommon;
using CommunityToolkit.Mvvm.Input;
using LibHac.Tools.FsSystem;
using Ryujinx.Audio.Backends.OpenAL;
using Ryujinx.Audio.Backends.SDL2;
@ -28,8 +28,6 @@ using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Net.NetworkInformation;
using System.Runtime.InteropServices;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using TimeZone = Ryujinx.Ava.UI.Models.TimeZone;
@ -722,6 +720,25 @@ namespace Ryujinx.Ava.UI.ViewModels
CloseWindow?.Invoke();
}
[ObservableProperty] private bool _wantsToReset;
public AsyncRelayCommand ResetButton => Commands.Create(async () =>
{
if (!WantsToReset) return;
CloseWindow?.Invoke();
ConfigurationState.Instance.LoadDefault();
ConfigurationState.Instance.ToFileFormat().SaveConfig(Program.ConfigurationPath);
RyujinxApp.MainWindow.LoadApplications();
await ContentDialogHelper.CreateInfoDialog(
$"Your {RyujinxApp.FullAppName} configuration has been reset.",
"",
string.Empty,
LocaleManager.Instance[LocaleKeys.SettingsButtonClose],
"Configuration Reset");
});
public void CancelButton()
{
RevertIfNotSaved();

View File

@ -41,8 +41,7 @@ namespace Ryujinx.Ava.UI.ViewModels
private void LoadUpdates()
{
IEnumerable<(TitleUpdateModel TitleUpdate, bool IsSelected)> updates = ApplicationLibrary.TitleUpdates.Items
.Where(it => it.TitleUpdate.TitleIdBase == ApplicationData.IdBase);
(TitleUpdateModel TitleUpdate, bool IsSelected)[] updates = ApplicationLibrary.FindUpdateConfigurationFor(ApplicationData.Id);
bool hasBundledContent = false;
SelectedUpdate = new TitleUpdateViewModelNoUpdate();

View File

@ -113,37 +113,37 @@
Tag="TitleId" />
<RadioButton
Checked="Sort_Checked"
Content="{ext:Locale GameListHeaderDeveloper}"
Content="{ext:Locale GameListSortDeveloper}"
GroupName="Sort"
IsChecked="{Binding IsSortedByDeveloper, Mode=OneTime}"
Tag="Developer" />
<RadioButton
Checked="Sort_Checked"
Content="{ext:Locale GameListHeaderTimePlayed}"
Content="{ext:Locale GameListSortTimePlayed}"
GroupName="Sort"
IsChecked="{Binding IsSortedByTimePlayed, Mode=OneTime}"
Tag="TotalTimePlayed" />
<RadioButton
Checked="Sort_Checked"
Content="{ext:Locale GameListHeaderLastPlayed}"
Content="{ext:Locale GameListSortLastPlayed}"
GroupName="Sort"
IsChecked="{Binding IsSortedByLastPlayed, Mode=OneTime}"
Tag="LastPlayed" />
<RadioButton
Checked="Sort_Checked"
Content="{ext:Locale GameListHeaderFileExtension}"
Content="{ext:Locale GameListSortFileExtension}"
GroupName="Sort"
IsChecked="{Binding IsSortedByType, Mode=OneTime}"
Tag="FileType" />
<RadioButton
Checked="Sort_Checked"
Content="{ext:Locale GameListHeaderFileSize}"
Content="{ext:Locale GameListSortFileSize}"
GroupName="Sort"
IsChecked="{Binding IsSortedBySize, Mode=OneTime}"
Tag="FileSize" />
<RadioButton
Checked="Sort_Checked"
Content="{ext:Locale GameListHeaderPath}"
Content="{ext:Locale GameListSortPath}"
GroupName="Sort"
IsChecked="{Binding IsSortedByPath, Mode=OneTime}"
Tag="Path" />

View File

@ -108,24 +108,36 @@
</Style>
</ui:NavigationView.Styles>
</ui:NavigationView>
<ReversibleStackPanel
Grid.Row="2"
Margin="10"
Spacing="10"
Orientation="Horizontal"
HorizontalAlignment="Right"
ReverseOrder="{x:Static helper:RunningPlatform.IsMacOS}">
<Button
Classes="accent"
Content="{ext:Locale SettingsButtonOk}"
Command="{Binding OkButton}" />
<Button
HotKey="Escape"
Content="{ext:Locale SettingsButtonCancel}"
Command="{Binding CancelButton}" />
<Button
Content="{ext:Locale SettingsButtonApply}"
Command="{Binding ApplyButton}" />
</ReversibleStackPanel>
<Grid Grid.Row="2"
ColumnDefinitions="Auto,*,Auto">
<StackPanel Grid.Column="0" Orientation="Horizontal">
<Button
IsEnabled="{Binding WantsToReset}"
Margin="10"
Content="{ext:Locale SettingsButtonReset}"
Command="{Binding ResetButton}" />
<CheckBox IsChecked="{Binding WantsToReset}"/>
<TextBlock Text="{ext:Locale SettingsButtonResetConfirm}"/>
</StackPanel>
<ReversibleStackPanel
Grid.Column="2"
Margin="10"
Spacing="10"
Orientation="Horizontal"
HorizontalAlignment="Right"
ReverseOrder="{x:Static helper:RunningPlatform.IsMacOS}">
<Button
Classes="accent"
Content="{ext:Locale SettingsButtonOk}"
Command="{Binding OkButton}" />
<Button
HotKey="Escape"
Content="{ext:Locale SettingsButtonCancel}"
Command="{Binding CancelButton}" />
<Button
Content="{ext:Locale SettingsButtonApply}"
Command="{Binding ApplyButton}" />
</ReversibleStackPanel>
</Grid>
</Grid>
</window:StyleableAppWindow>

View File

@ -1,3 +1,4 @@
using Gommon;
using LibHac.Common;
using LibHac.Fs;
using LibHac.Fs.Fsa;
@ -14,6 +15,7 @@ using Ryujinx.HLE.FileSystem;
using Ryujinx.HLE.Loaders.Processes.Extensions;
using System;
using System.IO;
using System.Text;
using System.Text.Json.Serialization;
namespace Ryujinx.Ava.Utilities.AppLibrary
@ -32,23 +34,20 @@ namespace Ryujinx.Ava.Utilities.AppLibrary
set
{
_id = value;
PlayabilityStatus = CompatibilityCsv.GetStatus(Id);
Compatibility = CompatibilityCsv.Find(Id);
}
}
public string Developer { get; set; } = "Unknown";
public string Version { get; set; } = "0";
public bool HasPlayabilityInfo => PlayabilityStatus != null;
public string LocalizedStatus =>
PlayabilityStatus.HasValue
? LocaleManager.Instance[PlayabilityStatus!.Value]
: string.Empty;
public LocaleKeys? PlayabilityStatus { get; set; }
public int PlayerCount { get; set; }
public int GameCount { get; set; }
public bool HasLdnGames => 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; }
public string FileExtension { get; set; }
@ -65,6 +64,53 @@ namespace Ryujinx.Ava.Utilities.AppLibrary
public string LastPlayedString => ValueFormatUtils.FormatDateTime(LastPlayed)?.Replace(" ", "\n");
public string FileSizeString => ValueFormatUtils.FormatFileSize(FileSize);
public Optional<CompatibilityEntry> Compatibility { get; private set; }
public bool HasPlayabilityInfo => Compatibility.HasValue;
public string LocalizedStatus => Compatibility.Convert(x => x.LocalizedStatus);
public bool HasCompatibilityLabels => !FormattedCompatibilityLabels.Equals(string.Empty);
public string FormattedCompatibilityLabels
=> Compatibility.Convert(x => x.FormattedIssueLabels).OrElse(string.Empty);
public LocaleKeys? PlayabilityStatus => Compatibility.Convert(x => x.Status).OrElse(null);
public string CompatibilityToolTip
{
get
{
StringBuilder sb = new(LocalizedStatusTooltip);
string formattedCompatibilityLabels = FormattedCompatibilityLabels;
if (!string.IsNullOrEmpty(formattedCompatibilityLabels))
{
sb.AppendLine()
.AppendLine()
.Append(formattedCompatibilityLabels);
}
return sb.ToString();
}
}
public string LocalizedStatusTooltip =>
Compatibility.Convert(x =>
#pragma warning disable CS8509 It is exhaustive for all possible values this can contain.
LocaleManager.Instance[x.Status switch
#pragma warning restore CS8509
{
LocaleKeys.CompatibilityListPlayable => LocaleKeys.CompatibilityListPlayableTooltip,
LocaleKeys.CompatibilityListIngame => LocaleKeys.CompatibilityListIngameTooltip,
LocaleKeys.CompatibilityListMenus => LocaleKeys.CompatibilityListMenusTooltip,
LocaleKeys.CompatibilityListBoots => LocaleKeys.CompatibilityListBootsTooltip,
LocaleKeys.CompatibilityListNothing => LocaleKeys.CompatibilityListNothingTooltip,
}]
).OrElse(string.Empty);
[JsonIgnore] public string IdString => Id.ToString("x16");

View File

@ -128,11 +128,16 @@ namespace Ryujinx.Ava.Utilities.AppLibrary
DynamicData.Kernel.Optional<ApplicationData> appData = Applications.Lookup(id);
if (appData.HasValue)
return appData.Value.Name;
if (DownloadableContents.Keys.FindFirst(x => x.TitleId == id).TryGet(out DownloadableContentModel dlcData))
return Path.GetFileNameWithoutExtension(dlcData.FileName);
return id.ToString("X16");
if (!DownloadableContents.Keys.FindFirst(x => x.TitleId == id).TryGet(out DownloadableContentModel dlcData))
return id.ToString("X16");
string name = Path.GetFileNameWithoutExtension(dlcData.FileName)!;
int idx = name.IndexOf('[');
if (idx != -1)
name = name[..idx];
return name;
}
public bool FindApplication(ulong id, out ApplicationData foundData)
@ -155,9 +160,15 @@ namespace Ryujinx.Ava.Utilities.AppLibrary
public TitleUpdateModel[] FindUpdatesFor(ulong id)
=> TitleUpdates.Keys.Where(x => x.TitleIdBase == (id & ~0x1FFFUL)).ToArray();
public (TitleUpdateModel TitleUpdate, bool IsSelected)[] FindUpdateConfigurationFor(ulong id)
=> TitleUpdates.Items.Where(x => x.TitleUpdate.TitleIdBase == (id & ~0x1FFFUL)).ToArray();
public DownloadableContentModel[] FindDlcsFor(ulong id)
=> DownloadableContents.Keys.Where(x => x.TitleIdBase == (id & ~0x1FFFUL)).ToArray();
public (DownloadableContentModel Dlc, bool IsEnabled)[] FindDlcConfigurationFor(ulong id)
=> DownloadableContents.Items.Where(x => x.Dlc.TitleIdBase == (id & ~0x1FFFUL)).ToArray();
public bool HasDlcs(ulong id)
=> DownloadableContents.Keys.Any(x => x.TitleIdBase == (id & ~0x1FFFUL));

View File

@ -60,10 +60,21 @@ namespace Ryujinx.Ava.Utilities.Compat
}
}
public static CompatibilityEntry Find(string titleId)
=> Entries.FirstOrDefault(x => x.TitleId.HasValue && x.TitleId.Value.EqualsIgnoreCase(titleId));
public static CompatibilityEntry Find(ulong titleId)
=> Find(titleId.ToString("X16"));
public static LocaleKeys? GetStatus(string titleId)
=> Entries.FirstOrDefault(x => x.TitleId.HasValue && x.TitleId.Value.EqualsIgnoreCase(titleId))?.Status;
=> Find(titleId)?.Status;
public static LocaleKeys? GetStatus(ulong titleId) => GetStatus(titleId.ToString("X16"));
public static string GetLabels(string titleId)
=> Find(titleId)?.FormattedIssueLabels;
public static string GetLabels(ulong titleId) => GetLabels(titleId.ToString("X16"));
}
public class CompatibilityEntry
@ -100,12 +111,25 @@ namespace Ryujinx.Ava.Utilities.Compat
public Optional<string> TitleId { get; }
public string[] Labels { get; }
public LocaleKeys? Status { get; }
public LocaleKeys? StatusDescription
=> Status switch
{
LocaleKeys.CompatibilityListPlayable => LocaleKeys.CompatibilityListPlayableTooltip,
LocaleKeys.CompatibilityListIngame => LocaleKeys.CompatibilityListIngameTooltip,
LocaleKeys.CompatibilityListMenus => LocaleKeys.CompatibilityListMenusTooltip,
LocaleKeys.CompatibilityListBoots => LocaleKeys.CompatibilityListBootsTooltip,
LocaleKeys.CompatibilityListNothing => LocaleKeys.CompatibilityListNothingTooltip,
_ => null
};
public DateTime LastUpdated { get; }
public string LocalizedLastUpdated =>
LocaleManager.FormatDynamicValue(LocaleKeys.CompatibilityListLastUpdated, LastUpdated.Humanize());
public string LocalizedStatus => LocaleManager.Instance[Status!.Value];
public string LocalizedStatusDescription => LocaleManager.Instance[StatusDescription!.Value];
public string FormattedTitleId => TitleId
.OrElse(new string(' ', 16));

View File

@ -64,6 +64,8 @@
VerticalAlignment="Center"
Text="{Binding LocalizedStatus}"
Width="85"
Background="Transparent"
ToolTip.Tip="{Binding LocalizedStatusDescription}"
Foreground="{Binding Status, Converter={x:Static helpers:PlayabilityStatusConverter.Shared}}"
TextWrapping="NoWrap" />
<TextBlock Grid.Column="3"

View File

@ -1,85 +0,0 @@
using PlayReportFormattedValue = Ryujinx.Ava.Utilities.PlayReportAnalyzer.FormattedValue;
namespace Ryujinx.Ava.Utilities
{
public static class PlayReport
{
public static PlayReportAnalyzer Analyzer { get; } = new PlayReportAnalyzer()
.AddSpec(
"01007ef00011e000",
spec => spec
.AddValueFormatter("IsHardMode", BreathOfTheWild_MasterMode)
// reset to normal status when switching between normal & master mode in title screen
.AddValueFormatter("AoCVer", PlayReportFormattedValue.AlwaysResets)
)
.AddSpec(
"0100f2c0115b6000",
spec => spec.AddValueFormatter("PlayerPosY", TearsOfTheKingdom_CurrentField))
.AddSpec(
"0100000000010000",
spec =>
spec.AddValueFormatter("is_kids_mode", SuperMarioOdyssey_AssistMode)
)
.AddSpec(
"010075000ecbe000",
spec =>
spec.AddValueFormatter("is_kids_mode", SuperMarioOdysseyChina_AssistMode)
)
.AddSpec(
"010028600ebda000",
spec => spec.AddValueFormatter("mode", SuperMario3DWorldOrBowsersFury)
)
.AddSpec( // Global & China IDs
["0100152000022000", "010075100e8ec000"],
spec => spec.AddValueFormatter("To", MarioKart8Deluxe_Mode)
);
private static PlayReportFormattedValue BreathOfTheWild_MasterMode(PlayReportValue value)
=> value.BoxedValue is 1 ? "Playing Master Mode" : PlayReportFormattedValue.ForceReset;
private static PlayReportFormattedValue TearsOfTheKingdom_CurrentField(PlayReportValue value) =>
value.DoubleValue switch
{
> 800d => "Exploring the Sky Islands",
< -201d => "Exploring the Depths",
_ => "Roaming Hyrule"
};
private static PlayReportFormattedValue SuperMarioOdyssey_AssistMode(PlayReportValue value)
=> value.BoxedValue is 1 ? "Playing in Assist Mode" : "Playing in Regular Mode";
private static PlayReportFormattedValue SuperMarioOdysseyChina_AssistMode(PlayReportValue value)
=> value.BoxedValue is 1 ? "Playing in 帮助模式" : "Playing in 普通模式";
private static PlayReportFormattedValue SuperMario3DWorldOrBowsersFury(PlayReportValue value)
=> value.BoxedValue is 0 ? "Playing Super Mario 3D World" : "Playing Bowser's Fury";
private static PlayReportFormattedValue MarioKart8Deluxe_Mode(PlayReportValue 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",
_ => PlayReportFormattedValue.ForceReset
};
}
}

View File

@ -0,0 +1,124 @@
using Gommon;
using MsgPack;
using Ryujinx.Ava.Utilities.AppLibrary;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Globalization;
using System.Linq;
namespace Ryujinx.Ava.Utilities.PlayReport
{
/// <summary>
/// The entrypoint for the Play Report analysis system.
/// </summary>
public class Analyzer
{
private readonly List<GameSpec> _specs = [];
public string[] TitleIds => Specs.SelectMany(x => x.TitleIds).ToArray();
public IReadOnlyList<GameSpec> Specs => new ReadOnlyCollection<GameSpec>(_specs);
/// <summary>
/// Add an analysis spec matching a specific game by title ID, with the provided spec configuration.
/// </summary>
/// <param name="titleId">The ID of the game to listen to Play Reports in.</param>
/// <param name="transform">The configuration function for the analysis spec.</param>
/// <returns>The current <see cref="Analyzer"/>, for chaining convenience.</returns>
public Analyzer AddSpec(string titleId, Func<GameSpec, GameSpec> transform)
{
Guard.Ensure(ulong.TryParse(titleId, NumberStyles.HexNumber, null, out _),
$"Cannot use a non-hexadecimal string as the Title ID for a {nameof(GameSpec)}.");
return AddSpec(transform(GameSpec.Create(titleId)));
}
/// <summary>
/// Add an analysis spec matching a specific game by title ID, with the provided spec configuration.
/// </summary>
/// <param name="titleId">The ID of the game to listen to Play Reports in.</param>
/// <param name="transform">The configuration function for the analysis spec.</param>
/// <returns>The current <see cref="Analyzer"/>, for chaining convenience.</returns>
public Analyzer AddSpec(string titleId, Action<GameSpec> transform)
{
Guard.Ensure(ulong.TryParse(titleId, NumberStyles.HexNumber, null, out _),
$"Cannot use a non-hexadecimal string as the Title ID for a {nameof(GameSpec)}.");
return AddSpec(GameSpec.Create(titleId).Apply(transform));
}
/// <summary>
/// Add an analysis spec matching a specific set of games by title IDs, with the provided spec configuration.
/// </summary>
/// <param name="titleIds">The IDs of the games to listen to Play Reports in.</param>
/// <param name="transform">The configuration function for the analysis spec.</param>
/// <returns>The current <see cref="Analyzer"/>, for chaining convenience.</returns>
public Analyzer AddSpec(IEnumerable<string> titleIds,
Func<GameSpec, GameSpec> transform)
{
string[] tids = titleIds.ToArray();
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)}.");
return AddSpec(transform(GameSpec.Create(tids)));
}
/// <summary>
/// Add an analysis spec matching a specific set of games by title IDs, with the provided spec configuration.
/// </summary>
/// <param name="titleIds">The IDs of the games to listen to Play Reports in.</param>
/// <param name="transform">The configuration function for the analysis spec.</param>
/// <returns>The current <see cref="Analyzer"/>, for chaining convenience.</returns>
public Analyzer AddSpec(IEnumerable<string> titleIds, Action<GameSpec> transform)
{
string[] tids = titleIds.ToArray();
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)}.");
return AddSpec(GameSpec.Create(tids).Apply(transform));
}
/// <summary>
/// Add an analysis spec matching a specific game by title ID, with the provided pre-configured spec.
/// </summary>
/// <param name="spec">The <see cref="GameSpec"/> to add.</param>
/// <returns>The current <see cref="Analyzer"/>, for chaining convenience.</returns>
public Analyzer AddSpec(GameSpec spec)
{
_specs.Add(spec);
return this;
}
/// <summary>
/// Runs the configured <see cref="FormatterSpec"/> for the specified game title ID.
/// </summary>
/// <param name="runningGameId">The game currently running.</param>
/// <param name="appMeta">The Application metadata information, including localized game name and play time information.</param>
/// <param name="playReport">The Play Report received from HLE.</param>
/// <returns>A struct representing a possible formatted value.</returns>
public FormattedValue Format(
string runningGameId,
ApplicationMetadata appMeta,
Horizon.Prepo.Types.PlayReport playReport
)
{
if (!playReport.ReportData.IsDictionary)
return FormattedValue.Unhandled;
if (!_specs.TryGetFirst(s => runningGameId.EqualsAnyIgnoreCase(s.TitleIds), out GameSpec spec))
return FormattedValue.Unhandled;
foreach (FormatterSpecBase formatSpec in spec.ValueFormatters.OrderBy(x => x.Priority))
{
if (!formatSpec.Format(appMeta, playReport, out FormattedValue value))
continue;
return value;
}
return FormattedValue.Unhandled;
}
}
}

View File

@ -0,0 +1,40 @@
namespace Ryujinx.Ava.Utilities.PlayReport
{
/// <summary>
/// The delegate type that powers single value formatters.<br/>
/// Takes in the result value from the Play Report, and outputs:
/// <br/>
/// a formatted string,
/// <br/>
/// a signal that nothing was available to handle it,
/// <br/>
/// OR a signal to reset the value that the caller is using the <see cref="Analyzer"/> for.
/// </summary>
public delegate FormattedValue SingleValueFormatter(SingleValue value);
/// <summary>
/// The delegate type that powers multiple value formatters.<br/>
/// Takes in the result values from the Play Report, and outputs:
/// <br/>
/// a formatted string,
/// <br/>
/// a signal that nothing was available to handle it,
/// <br/>
/// OR a signal to reset the value that the caller is using the <see cref="Analyzer"/> for.
/// </summary>
public delegate FormattedValue MultiValueFormatter(MultiValue value);
/// <summary>
/// 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.<br/>
/// Takes in the result values from the Play Report, and outputs:
/// <br/>
/// a formatted string,
/// <br/>
/// a signal that nothing was available to handle it,
/// <br/>
/// OR a signal to reset the value that the caller is using the <see cref="Analyzer"/> for.
/// </summary>
public delegate FormattedValue SparseMultiValueFormatter(SparseMultiValue value);
}

View File

@ -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<T>
{
protected MatchedValue(T matched)
{
Matched = matched;
}
/// <summary>
/// The currently running application's <see cref="ApplicationMetadata"/>.
/// </summary>
public ApplicationMetadata Application { get; init; }
/// <summary>
/// The entire play report.
/// </summary>
public Horizon.Prepo.Types.PlayReport PlayReport { get; init; }
/// <summary>
/// The matched value from the Play Report.
/// </summary>
public T Matched { get; init; }
}
/// <summary>
/// The input data to a <see cref="SingleValueFormatter"/>,
/// containing the currently running application's <see cref="ApplicationMetadata"/>,
/// and the matched <see cref="MessagePackObject"/> from the Play Report.
/// </summary>
public class SingleValue : MatchedValue<Value>
{
public SingleValue(Value matched) : base(matched)
{
}
}
/// <summary>
/// The input data to a <see cref="MultiValueFormatter"/>,
/// containing the currently running application's <see cref="ApplicationMetadata"/>,
/// and the matched <see cref="MessagePackObject"/>s from the Play Report.
/// </summary>
public class MultiValue : MatchedValue<Value[]>
{
public MultiValue(Value[] matched) : base(matched)
{
}
public MultiValue(IEnumerable<MessagePackObject> matched) : base(Value.ConvertPackedObjects(matched))
{
}
}
/// <summary>
/// The input data to a <see cref="SparseMultiValueFormatter"/>,
/// containing the currently running application's <see cref="ApplicationMetadata"/>,
/// and the matched <see cref="MessagePackObject"/>s from the Play Report.
/// </summary>
public class SparseMultiValue : MatchedValue<Dictionary<string, Value>>
{
public SparseMultiValue(Dictionary<string, Value> matched) : base(matched)
{
}
public SparseMultiValue(Dictionary<string, MessagePackObject> matched) : base(Value.ConvertPackedObjectMap(matched))
{
}
}
}

View File

@ -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<string, Value> 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("Iggys 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("Kirbys 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("Spankys™ 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
};
}
}

View File

@ -0,0 +1,73 @@
using System;
using System.Buffers.Binary;
using System.Collections.Generic;
using System.Linq;
namespace Ryujinx.Ava.Utilities.PlayReport
{
public static partial class PlayReports
{
public static Analyzer Analyzer { get; } = new Analyzer()
.AddSpec(
"01007ef00011e000",
spec => spec
.AddValueFormatter("IsHardMode", BreathOfTheWild_MasterMode)
// reset to normal status when switching between normal & master mode in title screen
.AddValueFormatter("AoCVer", FormattedValue.SingleAlwaysResets)
)
.AddSpec(
"0100f2c0115b6000",
spec => spec
.AddValueFormatter("PlayerPosY", TearsOfTheKingdom_CurrentField))
.AddSpec(
"0100000000010000",
spec =>
spec.AddValueFormatter("is_kids_mode", SuperMarioOdyssey_AssistMode)
)
.AddSpec(
"010075000ecbe000",
spec =>
spec.AddValueFormatter("is_kids_mode", SuperMarioOdysseyChina_AssistMode)
)
.AddSpec(
"010028600ebda000",
spec => spec.AddValueFormatter("mode", SuperMario3DWorldOrBowsersFury)
)
.AddSpec( // Global & China IDs
["0100152000022000", "010075100e8ec000"],
spec => spec.AddValueFormatter("To", MarioKart8Deluxe_Mode)
)
.AddSpec(
["0100a3d008c5c000", "01008f6008c5e000"],
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 string Playing(string game) => $"Playing {game}";
}
}

View File

@ -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
{
/// <summary>
/// A mapping of title IDs to value formatter specs.
///
/// <remarks>Generally speaking, use the <see cref="Analyzer"/>.AddSpec(...) methods instead of creating this class yourself.</remarks>
/// </summary>
public class GameSpec
{
public static GameSpec Create(string requiredTitleId, params IEnumerable<string> otherTitleIds)
=> new() { TitleIds = otherTitleIds.Prepend(requiredTitleId).ToArray() };
public static GameSpec Create(IEnumerable<string> titleIds)
=> new() { TitleIds = titleIds.ToArray() };
private int _lastPriority;
public required string[] TitleIds { get; init; }
public List<FormatterSpecBase> ValueFormatters { get; } = [];
/// <summary>
/// Add a value formatter to the current <see cref="GameSpec"/>
/// matching a specific key that could exist in a Play Report for the previously specified title IDs.
/// </summary>
/// <param name="reportKey">The key name to match.</param>
/// <param name="valueFormatter">The function which can return a potential formatted value.</param>
/// <returns>The current <see cref="GameSpec"/>, for chaining convenience.</returns>
public GameSpec AddValueFormatter(
string reportKey,
SingleValueFormatter valueFormatter
) => AddValueFormatter(_lastPriority++, reportKey, valueFormatter);
/// <summary>
/// Add a value formatter at a specific priority to the current <see cref="GameSpec"/>
/// matching a specific key that could exist in a Play Report for the previously specified title IDs.
/// </summary>
/// <param name="priority">The resolution priority of this value formatter. Higher resolves sooner.</param>
/// <param name="reportKey">The key name to match.</param>
/// <param name="valueFormatter">The function which can return a potential formatted value.</param>
/// <returns>The current <see cref="GameSpec"/>, for chaining convenience.</returns>
public GameSpec AddValueFormatter(
int priority,
string reportKey,
SingleValueFormatter valueFormatter
) => AddValueFormatter(new FormatterSpec
{
Priority = priority, ReportKeys = [reportKey], Formatter = valueFormatter
});
/// <summary>
/// Add a multi-value formatter to the current <see cref="GameSpec"/>
/// matching a specific set of keys that could exist in a Play Report for the previously specified title IDs.
/// </summary>
/// <param name="reportKeys">The key names to match.</param>
/// <param name="valueFormatter">The function which can format the values.</param>
/// <returns>The current <see cref="GameSpec"/>, for chaining convenience.</returns>
public GameSpec AddMultiValueFormatter(
string[] reportKeys,
MultiValueFormatter valueFormatter
) => AddMultiValueFormatter(_lastPriority++, reportKeys, valueFormatter);
/// <summary>
/// Add a multi-value formatter at a specific priority to the current <see cref="GameSpec"/>
/// matching a specific set of keys that could exist in a Play Report for the previously specified title IDs.
/// </summary>
/// <param name="priority">The resolution priority of this value formatter. Higher resolves sooner.</param>
/// <param name="reportKeys">The key names to match.</param>
/// <param name="valueFormatter">The function which can format the values.</param>
/// <returns>The current <see cref="GameSpec"/>, for chaining convenience.</returns>
public GameSpec AddMultiValueFormatter(
int priority,
string[] reportKeys,
MultiValueFormatter valueFormatter
) => AddValueFormatter(new MultiFormatterSpec
{
Priority = priority, ReportKeys = reportKeys, Formatter = valueFormatter
});
/// <summary>
/// Add a multi-value formatter to the current <see cref="GameSpec"/>
/// matching a specific set of keys that could exist in a Play Report for the previously specified title IDs.
/// <br/><br/>
/// The 'Sparse' multi-value formatters do not require every key to be present.
/// If you need this requirement, use <see cref="AddMultiValueFormatter(string[], Ryujinx.Ava.Utilities.PlayReport.MultiValueFormatter)"/>.
/// </summary>
/// <param name="reportKeys">The key names to match.</param>
/// <param name="valueFormatter">The function which can format the values.</param>
/// <returns>The current <see cref="GameSpec"/>, for chaining convenience.</returns>
public GameSpec AddSparseMultiValueFormatter(
string[] reportKeys,
SparseMultiValueFormatter valueFormatter
) => AddSparseMultiValueFormatter(_lastPriority++, reportKeys, valueFormatter);
/// <summary>
/// Add a multi-value formatter at a specific priority to the current <see cref="GameSpec"/>
/// matching a specific set of keys that could exist in a Play Report for the previously specified title IDs.
/// <br/><br/>
/// The 'Sparse' multi-value formatters do not require every key to be present.
/// If you need this requirement, use <see cref="AddMultiValueFormatter(int, string[], Ryujinx.Ava.Utilities.PlayReport.MultiValueFormatter)"/>.
/// </summary>
/// <param name="priority">The resolution priority of this value formatter. Higher resolves sooner.</param>
/// <param name="reportKeys">The key names to match.</param>
/// <param name="valueFormatter">The function which can format the values.</param>
/// <returns>The current <see cref="GameSpec"/>, for chaining convenience.</returns>
public GameSpec AddSparseMultiValueFormatter(
int priority,
string[] reportKeys,
SparseMultiValueFormatter valueFormatter
) => AddValueFormatter(new SparseMultiFormatterSpec
{
Priority = priority, ReportKeys = reportKeys, Formatter = valueFormatter
});
private GameSpec AddValueFormatter<T>(T formatterSpec) where T : FormatterSpecBase
{
ValueFormatters.Add(formatterSpec);
return this;
}
}
/// <summary>
/// A struct containing the data for a mapping of a key in a Play Report to a formatter for its potential value.
/// </summary>
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;
}
}
/// <summary>
/// A struct containing the data for a mapping of an arbitrary key set in a Play Report to a formatter for their potential values.
/// </summary>
public class MultiFormatterSpec : FormatterSpecBase
{
public override bool GetData(Horizon.Prepo.Types.PlayReport playReport, out object result)
{
List<MessagePackObject> 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;
}
}
/// <summary>
/// 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.
/// </summary>
public class SparseMultiFormatterSpec : FormatterSpecBase
{
public override bool GetData(Horizon.Prepo.Types.PlayReport playReport, out object result)
{
Dictionary<string, MessagePackObject> 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<MessagePackObject> matches:
formattedValue = mvf(
new MultiValue(matches) { Application = appMeta, PlayReport = playReport }
);
return true;
case SparseMultiValueFormatter smvf when data is Dictionary<string, MessagePackObject> sparseMatches:
formattedValue = smvf(
new SparseMultiValue(sparseMatches) { Application = appMeta, PlayReport = playReport }
);
return true;
default:
throw new InvalidOperationException("Formatter delegate is not of a known type!");
}
}
}
}

View File

@ -0,0 +1,160 @@
using MsgPack;
using System;
using System.Collections.Generic;
using System.Linq;
namespace Ryujinx.Ava.Utilities.PlayReport
{
/// <summary>
/// The base input data to a ValueFormatter delegate,
/// and the matched <see cref="MessagePackObject"/> from the Play Report.
/// </summary>
public readonly struct Value
{
public Value(MessagePackObject packedValue)
{
PackedValue = packedValue;
}
/// <summary>
/// The matched value from the Play Report.
/// </summary>
public MessagePackObject PackedValue { get; init; }
/// <summary>
/// Access the <see cref="PackedValue"/> as its underlying .NET type.<br/>
///
/// Does not seem to work well with comparing numeric types,
/// so use XValue properties for that.
/// </summary>
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<MessagePackObject> packObjects)
=> packObjects.Select(packObject => new Value(packObject)).ToArray();
public static Dictionary<string, Value> ConvertPackedObjectMap(Dictionary<string, MessagePackObject> 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<byte> BinaryValue => PackedValue.AsBinary();
#endregion
}
/// <summary>
/// A potential formatted value returned by a ValueFormatter delegate.
/// </summary>
public readonly struct FormattedValue
{
/// <summary>
/// Was any handler able to match anything in the Play Report?
/// </summary>
public bool Handled { get; private init; }
/// <summary>
/// Did the handler request the caller of the <see cref="Analyzer"/> to reset the existing value?
/// </summary>
public bool Reset { get; private init; }
/// <summary>
/// The formatted value, only present if <see cref="Handled"/> is true, and <see cref="Reset"/> is false.
/// </summary>
public string FormattedString { get; private init; }
/// <summary>
/// The intended path of execution for having a string to return: simply return the string.
/// This implicit conversion will make the struct for you.<br/><br/>
///
/// If the input is null, <see cref="Unhandled"/> is returned.
/// </summary>
/// <param name="formattedValue">The formatted string value.</param>
/// <returns>The automatically constructed <see cref="FormattedValue"/> struct.</returns>
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 "<Unhandled>";
if (Reset)
return "<Reset>";
return FormattedString;
}
/// <summary>
/// Return this to tell the caller there is no value to return.
/// </summary>
public static readonly FormattedValue Unhandled = default;
/// <summary>
/// Return this to suggest the caller reset the value it's using the <see cref="Analyzer"/> for.
/// </summary>
public static readonly FormattedValue ForceReset = new() { Handled = true, Reset = true };
/// <summary>
/// A delegate singleton you can use to always return <see cref="ForceReset"/> in a <see cref="SingleValueFormatter"/>.
/// </summary>
public static readonly SingleValueFormatter SingleAlwaysResets = _ => ForceReset;
/// <summary>
/// A delegate singleton you can use to always return <see cref="ForceReset"/> in a <see cref="MultiValueFormatter"/>.
/// </summary>
public static readonly MultiValueFormatter MultiAlwaysResets = _ => ForceReset;
/// <summary>
/// A delegate singleton you can use to always return <see cref="ForceReset"/> in a <see cref="SparseMultiValueFormatter"/>.
/// </summary>
public static readonly SparseMultiValueFormatter SparseMultiAlwaysResets = _ => ForceReset;
/// <summary>
/// A delegate factory you can use to always return the specified
/// <paramref name="formattedValue"/> in a <see cref="SingleValueFormatter"/>.
/// </summary>
/// <param name="formattedValue">The string to always return for this delegate instance.</param>
public static SingleValueFormatter SingleAlwaysReturns(string formattedValue) => _ => formattedValue;
/// <summary>
/// A delegate factory you can use to always return the specified
/// <paramref name="formattedValue"/> in a <see cref="MultiValueFormatter"/>.
/// </summary>
/// <param name="formattedValue">The string to always return for this delegate instance.</param>
public static MultiValueFormatter MultiAlwaysReturns(string formattedValue) => _ => formattedValue;
/// <summary>
/// A delegate factory you can use to always return the specified
/// <paramref name="formattedValue"/> in a <see cref="SparseMultiValueFormatter"/>.
/// </summary>
/// <param name="formattedValue">The string to always return for this delegate instance.</param>
public static SparseMultiValueFormatter SparseMultiAlwaysReturns(string formattedValue) => _ => formattedValue;
}
}

View File

@ -1,282 +0,0 @@
using Gommon;
using MsgPack;
using Ryujinx.Ava.Utilities.AppLibrary;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
namespace Ryujinx.Ava.Utilities
{
/// <summary>
/// The entrypoint for the Play Report analysis system.
/// </summary>
public class PlayReportAnalyzer
{
private readonly List<PlayReportGameSpec> _specs = [];
/// <summary>
/// Add an analysis spec matching a specific game by title ID, with the provided spec configuration.
/// </summary>
/// <param name="titleId">The ID of the game to listen to Play Reports in.</param>
/// <param name="transform">The configuration function for the analysis spec.</param>
/// <returns>The current <see cref="PlayReportAnalyzer"/>, for chaining convenience.</returns>
public PlayReportAnalyzer AddSpec(string titleId, Func<PlayReportGameSpec, PlayReportGameSpec> transform)
{
Guard.Ensure(ulong.TryParse(titleId, NumberStyles.HexNumber, null, out _),
$"Cannot use a non-hexadecimal string as the Title ID for a {nameof(PlayReportGameSpec)}.");
_specs.Add(transform(new PlayReportGameSpec { TitleIds = [titleId] }));
return this;
}
/// <summary>
/// Add an analysis spec matching a specific game by title ID, with the provided spec configuration.
/// </summary>
/// <param name="titleId">The ID of the game to listen to Play Reports in.</param>
/// <param name="transform">The configuration function for the analysis spec.</param>
/// <returns>The current <see cref="PlayReportAnalyzer"/>, for chaining convenience.</returns>
public PlayReportAnalyzer AddSpec(string titleId, Action<PlayReportGameSpec> transform)
{
Guard.Ensure(ulong.TryParse(titleId, NumberStyles.HexNumber, null, out _),
$"Cannot use a non-hexadecimal string as the Title ID for a {nameof(PlayReportGameSpec)}.");
_specs.Add(new PlayReportGameSpec { TitleIds = [titleId] }.Apply(transform));
return this;
}
/// <summary>
/// Add an analysis spec matching a specific set of games by title IDs, with the provided spec configuration.
/// </summary>
/// <param name="titleIds">The IDs of the games to listen to Play Reports in.</param>
/// <param name="transform">The configuration function for the analysis spec.</param>
/// <returns>The current <see cref="PlayReportAnalyzer"/>, for chaining convenience.</returns>
public PlayReportAnalyzer AddSpec(IEnumerable<string> titleIds,
Func<PlayReportGameSpec, PlayReportGameSpec> transform)
{
string[] tids = titleIds.ToArray();
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(PlayReportGameSpec)}.");
_specs.Add(transform(new PlayReportGameSpec { TitleIds = [..tids] }));
return this;
}
/// <summary>
/// Add an analysis spec matching a specific set of games by title IDs, with the provided spec configuration.
/// </summary>
/// <param name="titleIds">The IDs of the games to listen to Play Reports in.</param>
/// <param name="transform">The configuration function for the analysis spec.</param>
/// <returns>The current <see cref="PlayReportAnalyzer"/>, for chaining convenience.</returns>
public PlayReportAnalyzer AddSpec(IEnumerable<string> titleIds, Action<PlayReportGameSpec> transform)
{
string[] tids = titleIds.ToArray();
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(PlayReportGameSpec)}.");
_specs.Add(new PlayReportGameSpec { TitleIds = [..tids] }.Apply(transform));
return this;
}
/// <summary>
/// Runs the configured <see cref="PlayReportGameSpec.FormatterSpec"/> for the specified game title ID.
/// </summary>
/// <param name="runningGameId">The game currently running.</param>
/// <param name="appMeta">The Application metadata information, including localized game name and play time information.</param>
/// <param name="playReport">The Play Report received from HLE.</param>
/// <returns>A struct representing a possible formatted value.</returns>
public FormattedValue Format(
string runningGameId,
ApplicationMetadata appMeta,
MessagePackObject playReport
)
{
if (!playReport.IsDictionary)
return FormattedValue.Unhandled;
if (!_specs.TryGetFirst(s => runningGameId.EqualsAnyIgnoreCase(s.TitleIds), out PlayReportGameSpec spec))
return FormattedValue.Unhandled;
foreach (PlayReportGameSpec.FormatterSpec formatSpec in spec.SimpleValueFormatters.OrderBy(x => x.Priority))
{
if (!playReport.AsDictionary().TryGetValue(formatSpec.ReportKey, out MessagePackObject valuePackObject))
continue;
return formatSpec.ValueFormatter(new PlayReportValue
{
Application = appMeta, PackedValue = valuePackObject
});
}
return FormattedValue.Unhandled;
}
/// <summary>
/// A potential formatted value returned by a <see cref="PlayReportValueFormatter"/>.
/// </summary>
public readonly struct FormattedValue
{
/// <summary>
/// Was any handler able to match anything in the Play Report?
/// </summary>
public bool Handled { get; private init; }
/// <summary>
/// Did the handler request the caller of the <see cref="PlayReportAnalyzer"/> to reset the existing value?
/// </summary>
public bool Reset { get; private init; }
/// <summary>
/// The formatted value, only present if <see cref="Handled"/> is true, and <see cref="Reset"/> is false.
/// </summary>
public string FormattedString { get; private init; }
/// <summary>
/// The intended path of execution for having a string to return: simply return the string.
/// This implicit conversion will make the struct for you.<br/><br/>
///
/// If the input is null, <see cref="Unhandled"/> is returned.
/// </summary>
/// <param name="formattedValue">The formatted string value.</param>
/// <returns>The automatically constructed <see cref="FormattedValue"/> struct.</returns>
public static implicit operator FormattedValue(string formattedValue)
=> formattedValue is not null
? new FormattedValue { Handled = true, FormattedString = formattedValue }
: Unhandled;
/// <summary>
/// Return this to tell the caller there is no value to return.
/// </summary>
public static FormattedValue Unhandled => default;
/// <summary>
/// Return this to suggest the caller reset the value it's using the <see cref="PlayReportAnalyzer"/> for.
/// </summary>
public static FormattedValue ForceReset => new() { Handled = true, Reset = true };
/// <summary>
/// A delegate singleton you can use to always return <see cref="ForceReset"/> in a <see cref="PlayReportValueFormatter"/>.
/// </summary>
public static readonly PlayReportValueFormatter AlwaysResets = _ => ForceReset;
/// <summary>
/// A delegate factory you can use to always return the specified
/// <paramref name="formattedValue"/> in a <see cref="PlayReportValueFormatter"/>.
/// </summary>
/// <param name="formattedValue">The string to always return for this delegate instance.</param>
public static PlayReportValueFormatter AlwaysReturns(string formattedValue) => _ => formattedValue;
}
}
/// <summary>
/// A mapping of title IDs to value formatter specs.
///
/// <remarks>Generally speaking, use the <see cref="PlayReportAnalyzer"/>.AddSpec(...) methods instead of creating this class yourself.</remarks>
/// </summary>
public class PlayReportGameSpec
{
public required string[] TitleIds { get; init; }
public List<FormatterSpec> SimpleValueFormatters { get; } = [];
/// <summary>
/// Add a value formatter to the current <see cref="PlayReportGameSpec"/>
/// matching a specific key that could exist in a Play Report for the previously specified title IDs.
/// </summary>
/// <param name="reportKey">The key name to match.</param>
/// <param name="valueFormatter">The function which can return a potential formatted value.</param>
/// <returns>The current <see cref="PlayReportGameSpec"/>, for chaining convenience.</returns>
public PlayReportGameSpec AddValueFormatter(string reportKey, PlayReportValueFormatter valueFormatter)
{
SimpleValueFormatters.Add(new FormatterSpec
{
Priority = SimpleValueFormatters.Count, ReportKey = reportKey, ValueFormatter = valueFormatter
});
return this;
}
/// <summary>
/// Add a value formatter at a specific priority to the current <see cref="PlayReportGameSpec"/>
/// matching a specific key that could exist in a Play Report for the previously specified title IDs.
/// </summary>
/// <param name="priority">The resolution priority of this value formatter. Higher resolves sooner.</param>
/// <param name="reportKey">The key name to match.</param>
/// <param name="valueFormatter">The function which can return a potential formatted value.</param>
/// <returns>The current <see cref="PlayReportGameSpec"/>, for chaining convenience.</returns>
public PlayReportGameSpec AddValueFormatter(int priority, string reportKey,
PlayReportValueFormatter valueFormatter)
{
SimpleValueFormatters.Add(new FormatterSpec
{
Priority = priority, ReportKey = reportKey, ValueFormatter = valueFormatter
});
return this;
}
/// <summary>
/// A struct containing the data for a mapping of a key in a Play Report to a formatter for its potential value.
/// </summary>
public struct FormatterSpec
{
public required int Priority { get; init; }
public required string ReportKey { get; init; }
public PlayReportValueFormatter ValueFormatter { get; init; }
}
}
/// <summary>
/// The input data to a <see cref="PlayReportValueFormatter"/>,
/// containing the currently running application's <see cref="ApplicationMetadata"/>,
/// and the matched <see cref="MessagePackObject"/> from the Play Report.
/// </summary>
public class PlayReportValue
{
/// <summary>
/// The currently running application's <see cref="ApplicationMetadata"/>.
/// </summary>
public ApplicationMetadata Application { get; init; }
/// <summary>
/// The matched value from the Play Report.
/// </summary>
public MessagePackObject PackedValue { get; init; }
/// <summary>
/// Access the <see cref="PackedValue"/> as its underlying .NET type.<br/>
///
/// Does not seem to work well with comparing numeric types,
/// so use <see cref="PackedValue"/> and the AsX (where X is a numerical type name i.e. Int32) methods for that.
/// </summary>
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<byte> BinaryValue => PackedValue.AsBinary();
#endregion
}
/// <summary>
/// The delegate type that powers the entire analysis system (as it currently is).<br/>
/// Takes in the result value from the Play Report, and outputs:
/// <br/>
/// a formatted string,
/// <br/>
/// a signal that nothing was available to handle it,
/// <br/>
/// OR a signal to reset the value that the caller is using the <see cref="PlayReportAnalyzer"/> for.
/// </summary>
public delegate PlayReportAnalyzer.FormattedValue PlayReportValueFormatter(PlayReportValue value);
}