mirror of
https://git.ryujinx.app/ryubing/ryujinx.git
synced 2026-06-27 06:39:06 +00:00
Compare commits
8 Commits
Canary-1.3
...
Canary-1.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
905a41a643 | ||
|
|
54e9b40cd7 | ||
|
|
737b951ee9 | ||
|
|
5566e752a4 | ||
|
|
a5f72136b2 | ||
|
|
aa5d32a7b1 | ||
|
|
be5881f100 | ||
|
|
8b1b015572 |
@@ -3,11 +3,11 @@
|
||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageVersion Include="Avalonia" Version="11.3.17" />
|
||||
<PackageVersion Include="Avalonia" Version="11.3.18" />
|
||||
<PackageVersion Include="Avalonia.Controls.DataGrid" Version="11.3.13" />
|
||||
<PackageVersion Include="Avalonia.Desktop" Version="11.3.17" />
|
||||
<PackageVersion Include="Avalonia.Diagnostics" Version="11.3.17" />
|
||||
<PackageVersion Include="Avalonia.Markup.Xaml.Loader" Version="11.3.17" />
|
||||
<PackageVersion Include="Avalonia.Desktop" Version="11.3.18" />
|
||||
<PackageVersion Include="Avalonia.Diagnostics" Version="11.3.18" />
|
||||
<PackageVersion Include="Avalonia.Markup.Xaml.Loader" Version="11.3.18" />
|
||||
<PackageVersion Include="SharpCompress" Version="0.49.1" />
|
||||
<PackageVersion Include="Svg.Controls.Avalonia" Version="11.3.9.5" />
|
||||
<PackageVersion Include="Svg.Controls.Skia.Avalonia" Version="11.3.9.5" />
|
||||
|
||||
29
assets/Locales/Common_Search.json
Normal file
29
assets/Locales/Common_Search.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"Locales": [
|
||||
{
|
||||
"ID": "SearchWatermark",
|
||||
"Translations": {
|
||||
"ar_SA": "بحث",
|
||||
"de_DE": "Suche",
|
||||
"el_GR": "Αναζήτηση",
|
||||
"en_US": "Search",
|
||||
"es_ES": "Buscar",
|
||||
"fr_FR": "Rechercher",
|
||||
"he_IL": "חפש",
|
||||
"it_IT": "Cerca",
|
||||
"ja_JP": "検索",
|
||||
"ko_KR": "찾기",
|
||||
"no_NO": "Søk",
|
||||
"pl_PL": "Wyszukaj",
|
||||
"pt_BR": "Buscar",
|
||||
"ru_RU": "Поиск",
|
||||
"sv_SE": "Sök",
|
||||
"th_TH": "ค้นหา",
|
||||
"tr_TR": "Ara",
|
||||
"uk_UA": "Пошук",
|
||||
"zh_CN": "搜索",
|
||||
"zh_TW": "搜尋"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
129
assets/Locales/Common_Sort.json
Normal file
129
assets/Locales/Common_Sort.json
Normal file
@@ -0,0 +1,129 @@
|
||||
{
|
||||
"Locales": [
|
||||
{
|
||||
"ID": "NameLabel",
|
||||
"Translations": {
|
||||
"ar_SA": "الاسم",
|
||||
"de_DE": "",
|
||||
"el_GR": "Όνομα",
|
||||
"en_US": "Name",
|
||||
"es_ES": "Nombre",
|
||||
"fr_FR": "Nom",
|
||||
"he_IL": "שם",
|
||||
"it_IT": "Nome",
|
||||
"ja_JP": "名称",
|
||||
"ko_KR": "이름",
|
||||
"no_NO": "Navn",
|
||||
"pl_PL": "Nazwa",
|
||||
"pt_BR": "Nome",
|
||||
"ru_RU": "Название",
|
||||
"sv_SE": "Namn",
|
||||
"th_TH": "ชื่อ",
|
||||
"tr_TR": "İsim",
|
||||
"uk_UA": "Назва",
|
||||
"zh_CN": "名称",
|
||||
"zh_TW": "名稱"
|
||||
}
|
||||
},
|
||||
{
|
||||
"ID": "SavingsLabel",
|
||||
"Translations": {
|
||||
"ar_SA": "التوفير",
|
||||
"de_DE": "Einsparung",
|
||||
"el_GR": "Εξοικονόμηση",
|
||||
"en_US": "Savings",
|
||||
"es_ES": "Ahorro",
|
||||
"fr_FR": "Économies",
|
||||
"he_IL": "חיסכון",
|
||||
"it_IT": "Risparmio",
|
||||
"ja_JP": "節約",
|
||||
"ko_KR": "절약",
|
||||
"no_NO": "Besparelse",
|
||||
"pl_PL": "Oszczędność",
|
||||
"pt_BR": "Economia",
|
||||
"ru_RU": "Экономия",
|
||||
"sv_SE": "Besparing",
|
||||
"th_TH": "การประหยัด",
|
||||
"tr_TR": "Tasarruf",
|
||||
"uk_UA": "Економія",
|
||||
"zh_CN": "节省",
|
||||
"zh_TW": "節省"
|
||||
}
|
||||
},
|
||||
{
|
||||
"ID": "TrimStatusLabel",
|
||||
"Translations": {
|
||||
"ar_SA": "",
|
||||
"de_DE": "",
|
||||
"el_GR": "",
|
||||
"en_US": "Trim Status",
|
||||
"es_ES": "Estado del recorte",
|
||||
"fr_FR": "État de réduction",
|
||||
"he_IL": "",
|
||||
"it_IT": "Stato della riduzione",
|
||||
"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": "OrderAscending",
|
||||
"Translations": {
|
||||
"ar_SA": "تصاعدي",
|
||||
"de_DE": "Aufsteigend",
|
||||
"el_GR": "Αύξουσα",
|
||||
"en_US": "Ascending",
|
||||
"es_ES": "Ascendente",
|
||||
"fr_FR": "Croissant",
|
||||
"he_IL": "סדר עולה",
|
||||
"it_IT": "Crescente",
|
||||
"ja_JP": "昇順",
|
||||
"ko_KR": "오름차순",
|
||||
"no_NO": "Stigende",
|
||||
"pl_PL": "Rosnąco",
|
||||
"pt_BR": "Ascendente",
|
||||
"ru_RU": "По Возрастанию",
|
||||
"sv_SE": "Stigande",
|
||||
"th_TH": "จากน้อยไปมาก",
|
||||
"tr_TR": "Artan",
|
||||
"uk_UA": "За зростанням",
|
||||
"zh_CN": "升序",
|
||||
"zh_TW": "從小到大"
|
||||
}
|
||||
},
|
||||
{
|
||||
"ID": "OrderDescending",
|
||||
"Translations": {
|
||||
"ar_SA": "تنازلي",
|
||||
"de_DE": "Absteigend",
|
||||
"el_GR": "Φθίνουσα",
|
||||
"en_US": "Descending",
|
||||
"es_ES": "Descendente",
|
||||
"fr_FR": "Décroissant",
|
||||
"he_IL": "סדר יורד",
|
||||
"it_IT": "Decrescente",
|
||||
"ja_JP": "降順",
|
||||
"ko_KR": "내림차순",
|
||||
"no_NO": "Synkende",
|
||||
"pl_PL": "Malejąco",
|
||||
"pt_BR": "Descendente",
|
||||
"ru_RU": "По Убыванию",
|
||||
"sv_SE": "Fallande",
|
||||
"th_TH": "จากมากไปน้อย",
|
||||
"tr_TR": "Azalan",
|
||||
"uk_UA": "За спаданням",
|
||||
"zh_CN": "降序",
|
||||
"zh_TW": "從大到小"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
304
assets/Locales/Dialog_XCITrimmer.json
Normal file
304
assets/Locales/Dialog_XCITrimmer.json
Normal file
@@ -0,0 +1,304 @@
|
||||
{
|
||||
"Locales": [
|
||||
{
|
||||
"ID": "PrimaryMessage",
|
||||
"Translations": {
|
||||
"ar_SA": "",
|
||||
"de_DE": "",
|
||||
"el_GR": "",
|
||||
"en_US": "Removes unused space from the XCI to reduce its file size.",
|
||||
"es_ES": "Elimina el espacio no utilizado del XCI para reducir su tamaño.",
|
||||
"fr_FR": "Supprime l’espace inutilisé du XCI afin de réduire sa taille.",
|
||||
"he_IL": "",
|
||||
"it_IT": "Rimuove lo spazio inutilizzato dall'XCI per ridurne le dimensioni.",
|
||||
"ja_JP": "",
|
||||
"ko_KR": "XCI에서 사용되지 않는 공간을 제거하여 파일 크기를 줄입니다.",
|
||||
"no_NO": "Fjerner ubrukt plass fra XCI-filen for å redusere filstørrelsen.",
|
||||
"pl_PL": "Usuwa nieużywane miejsce z pliku XCI, aby zmniejszyć jego rozmiar.",
|
||||
"pt_BR": "Remove o espaço não utilizado do XCI para reduzir seu tamanho.",
|
||||
"ru_RU": "Удаляет неиспользуемое пространство из XCI, уменьшая размер файла.",
|
||||
"sv_SE": "Tar bort oanvänt utrymme från XCI-filen för att minska filstorleken.",
|
||||
"th_TH": "ลบพื้นที่ที่ไม่ได้ใช้งานออกจาก XCI เพื่อลดขนาดไฟล์",
|
||||
"tr_TR": "",
|
||||
"uk_UA": "Видаляє невикористаний простір із XCI, зменшуючи розмір файлу.",
|
||||
"zh_CN": "移除 XCI 中未使用的空间以减小文件大小。",
|
||||
"zh_TW": "移除 XCI 中未使用的空間以減少檔案大小。"
|
||||
}
|
||||
},
|
||||
{
|
||||
"ID": "SecondaryMessage",
|
||||
"Translations": {
|
||||
"ar_SA": "",
|
||||
"de_DE": "",
|
||||
"el_GR": "",
|
||||
"en_US": "File: {0:n} MB • Game: {1:n} MB\n\nSavings: {2:n} MB",
|
||||
"es_ES": "Archivo: {0:n} MB • Juego: {1:n} MB\n\nAhorro: {2:n} MB",
|
||||
"fr_FR": "Fichier: {0:n} Mo • Jeu: {1:n} Mo\n\nÉconomies: {2:n} Mo",
|
||||
"he_IL": "",
|
||||
"it_IT": "File: {0:n} MB • Gioco: {1:n} MB\n\nRisparmio: {2:n} MB",
|
||||
"ja_JP": "",
|
||||
"ko_KR": "파일: {0:n} MB • 게임: {1:n} MB\n\n절약: {2:n} MB",
|
||||
"no_NO": "Fil: {0:n} MB • Spill: {1:n} MB\n\nBesparelse: {2:n} MB",
|
||||
"pl_PL": "Plik: {0:n} MB • Gra: {1:n} MB\n\nOszczędności: {2:n} MB",
|
||||
"pt_BR": "Arquivo: {0:n} MB • Jogo: {1:n} MB\n\nEconomia: {2:n} MB",
|
||||
"ru_RU": "Файл: {0:n} МБ • Игра: {1:n} МБ\n\nЭкономия: {2:n} МБ",
|
||||
"sv_SE": "Fil: {0:n} MB • Spel: {1:n} MB\n\nBesparing: {2:n} MB",
|
||||
"th_TH": "ไฟล์: {0:n} MB • เกม: {1:n} MB\n\nการประหยัด: {2:n} MB",
|
||||
"tr_TR": "",
|
||||
"uk_UA": "Файл: {0:n} МБ • Гра: {1:n} МБ\n\nЕкономія: {2:n} МБ",
|
||||
"zh_CN": "文件: {0:n} MB • 游戏: {1:n} MB\n\n节省: {2:n} MB",
|
||||
"zh_TW": "檔案: {0:n} MB • 遊戲: {1:n} MB\n\n節省: {2:n} MB"
|
||||
}
|
||||
},
|
||||
{
|
||||
"ID": "NoTrimNecessaryMessage",
|
||||
"Translations": {
|
||||
"ar_SA": "",
|
||||
"de_DE": "",
|
||||
"el_GR": "",
|
||||
"en_US": "XCI does not require trimming. Check logs for details.",
|
||||
"es_ES": "El XCI no necesita ser recortado. Verifica los logs para detalles.",
|
||||
"fr_FR": "Le XCI n’a pas besoin d’être réduit. Référez-vous aux journaux pour détails.",
|
||||
"he_IL": "",
|
||||
"it_IT": "Non è necessario ridurre la dimensione del XCI. Controlla i log per dettagli.",
|
||||
"ja_JP": "",
|
||||
"ko_KR": "XCI는 트리밍할 필요가 없습니다. 자세한 내용은 로그를 확인.",
|
||||
"no_NO": "XCI trenger ikke å trimmes. Sjekk loggene for detaljer.",
|
||||
"pl_PL": "XCI nie wymaga przycinania. Sprawdź dzienniki, aby uzyskać szczegóły.",
|
||||
"pt_BR": "O XCI não precisa ser reduzido. Verifique os logs para detalhes.",
|
||||
"ru_RU": "XCI не требует обрезки. Проверьте логи для подробностей.",
|
||||
"sv_SE": "XCI behöver inte optimeras. Kontrollera loggen för detaljer.",
|
||||
"th_TH": "XCI ไม่จำเป็นต้องถูกตัดแต่ง โปรดตรวจสอบบันทึกสำหรับรายละเอียด",
|
||||
"tr_TR": "",
|
||||
"uk_UA": "XCI не потребує обрізання. Перевірте журнали для отримання деталей.",
|
||||
"zh_CN": "XCI 不需要被瘦身。查看日志以获得更多细节。",
|
||||
"zh_TW": "XCI 不需要修剪。檢查日誌以取得更多資訊。"
|
||||
}
|
||||
},
|
||||
{
|
||||
"ID": "NoUntrimPossibleMessage",
|
||||
"Translations": {
|
||||
"ar_SA": "",
|
||||
"de_DE": "",
|
||||
"el_GR": "",
|
||||
"en_US": "XCI cannot be untrimmed. Check logs for details.",
|
||||
"es_ES": "El recorte del XCI no puede ser deshecho. Verifica los registros para detalles.",
|
||||
"fr_FR": "Le XCI ne peut être restauré. Référez-vous aux journaux pour détails.",
|
||||
"he_IL": "",
|
||||
"it_IT": "XCI non può essere ripristinato. Controlla i log per dettagli.",
|
||||
"ja_JP": "",
|
||||
"ko_KR": "XCI는 복원할 수 없습니다. 자세한 내용은 로그를 확인.",
|
||||
"no_NO": "XCI kan ikke gjenopprettes. Sjekk loggene for detaljer.",
|
||||
"pl_PL": "XCI nie może zostać przywrócone. Sprawdź dzienniki, aby uzyskać szczegóły.",
|
||||
"pt_BR": "XCI não pode ser desfeito. Verifique os logs para detalhes.",
|
||||
"ru_RU": "XCI не может быть восстановлен. Проверьте журналы для подробностей.",
|
||||
"sv_SE": "XCI kan inte återställas. Kontrollera loggen för detaljer.",
|
||||
"th_TH": "ไม่สามารถคืนค่า XCI ได้ โปรดตรวจสอบบันทึกสำหรับรายละเอียด",
|
||||
"tr_TR": "",
|
||||
"uk_UA": "XCI не можна відновити. Перевірте журнали для деталей.",
|
||||
"zh_CN": "XCI 不能恢复。查看日志以获取详情。",
|
||||
"zh_TW": "XCI 無法恢復。檢查日誌以取得詳情。"
|
||||
}
|
||||
},
|
||||
{
|
||||
"ID": "ReadOnlyFileCannotFixMessage",
|
||||
"Translations": {
|
||||
"ar_SA": "",
|
||||
"de_DE": "",
|
||||
"el_GR": "",
|
||||
"en_US": "XCI is read-only and could not be made writable. Check logs for details.",
|
||||
"es_ES": "XCI es solo lectura y no se puede escribir. Verifica los registros para detalles.",
|
||||
"fr_FR": "XCI en lecture seule et n'a pas pu être rendu écrivable. Référez-vous aux journaux pour détails.",
|
||||
"he_IL": "",
|
||||
"it_IT": "XCI è solo lettura e non può essere scritto. Controlla i log per dettagli.",
|
||||
"ja_JP": "",
|
||||
"ko_KR": "XCI 파일은 읽기 전용이며 쓰기 불가. 로그를 확인하십시오.",
|
||||
"no_NO": "XCI er skrivebeskyttet og kunne ikke gjøres skrivbar. Sjekk loggene for detaljer.",
|
||||
"pl_PL": "XCI jest tylko do odczytu i nie można zapisać. Sprawdź logi dla szczegółów.",
|
||||
"pt_BR": "XCI é somente leitura e não pode ser gravado. Verifique os logs para detalhes.",
|
||||
"ru_RU": "XCI только для чтения и не стал доступен для записи. Проверьте журналы для подробностей.",
|
||||
"sv_SE": "XCI-filen är skrivskyddad och kunde inte göras skrivbar. Kontrollera loggen för mer information",
|
||||
"th_TH": "XCI เป็นอ่านอย่างเดียวและไม่สามารถเขียนได้ ตรวจสอบบันทึกสำหรับรายละเอียด",
|
||||
"tr_TR": "",
|
||||
"uk_UA": "XCI тільки для читання і не можна записати. Перевірте логи для деталей.",
|
||||
"zh_CN": "XCI 只读,无法写入。查看日志以获取详情。",
|
||||
"zh_TW": "XCI 檔案唯讀,無法寫入。檢查日誌以取得詳情。"
|
||||
}
|
||||
},
|
||||
{
|
||||
"ID": "SizeChangedMessage",
|
||||
"Translations": {
|
||||
"ar_SA": "",
|
||||
"de_DE": "",
|
||||
"el_GR": "",
|
||||
"en_US": "XCI size changed since last scan. Ensure the file is not being written to and try again.",
|
||||
"es_ES": "El tamaño de XCI ha cambiado desde que fue escaneado. Verifica que no se esté escribiendo al archivo y vuelve a intentarlo.",
|
||||
"fr_FR": "La taille de XCI a changé depuis son analyse. Vérifiez que le fichier n’est pas en cours d’écriture, puis réessayez.",
|
||||
"he_IL": "",
|
||||
"it_IT": "La dimensione di XCI è cambiata da quando è stato scansionato. Controlla che il file non sia scritto e riprova.",
|
||||
"ja_JP": "",
|
||||
"ko_KR": "XCI 크기가 스캔 후 변경되었습니다. 파일이 쓰여지고 있지 않은지 확인하고 다시 시도하세요.",
|
||||
"no_NO": "XCI har endret størrelse siden den ble skannet. Kontroller at det ikke skrives til filen, og prøv på nytt.",
|
||||
"pl_PL": "Rozmiar XCI zmienił się od momentu zeskanowania. Sprawdź, czy plik nie jest zapisywany, a następnie spróbuj ponownie.",
|
||||
"pt_BR": "O tamanho de XCI mudou desde que foi escaneado. Verifique se o arquivo não está sendo gravado e tente novamente.",
|
||||
"ru_RU": "Размер XCI изменился после сканирования. Проверьте, не записывается ли файл, и попробуйте снова.",
|
||||
"sv_SE": "XCI har ändrats i storlek sedan den lästes av. Kontrollera att filen inte skrivs till och försök igen.",
|
||||
"th_TH": "ขนาด XCI เปลี่ยนไปตั้งแต่การสแกนครั้งล่าสุด ตรวจสอบว่าไฟล์ไม่ได้ถูกเขียน และลองใหม่",
|
||||
"tr_TR": "",
|
||||
"uk_UA": "Розмір XCI змінився з моменту сканування. Перевірте, чи не записується файл, та спробуйте знову.",
|
||||
"zh_CN": "XCI 在扫描后大小发生了变化。请检查文件是否未被写入,然后重试。",
|
||||
"zh_TW": "XCI 檔案大小自上次掃描以來已經改變。請檢查檔案是否未被寫入,然後再嘗試。"
|
||||
}
|
||||
},
|
||||
{
|
||||
"ID": "FreeSpaceCheckFailedMessage",
|
||||
"Translations": {
|
||||
"ar_SA": "",
|
||||
"de_DE": "",
|
||||
"el_GR": "",
|
||||
"en_US": "XCI has data in the free space area. It is not safe to trim.",
|
||||
"es_ES": "XCI tiene datos en el área de espacio libre. No es seguro recortarlo.",
|
||||
"fr_FR": "XCI contient des données dans la zone d'espace libre. Il n'est pas sûr de le réduire.",
|
||||
"he_IL": "",
|
||||
"it_IT": "XCI contiene dati nell'area di spazio libero. Non è sicuro ridurre la sua dimensione.",
|
||||
"ja_JP": "",
|
||||
"ko_KR": "XCI 파일에 여유 공간 영역에 데이터가 있으므로 트리밍하는 것이 안전하지 않습니다.",
|
||||
"no_NO": "XCI har data i den ledige plassen. Det er ikke trygt å trimme den.",
|
||||
"pl_PL": "XCI zawiera dane w obszarze wolnego miejsca. Nie jest bezpieczne go przycinać.",
|
||||
"pt_BR": "XCI tem dados na área de espaço livre. Não é seguro reduzi-lo.",
|
||||
"ru_RU": "XCI содержит данные в свободной области. Его обрезка небезопасна.",
|
||||
"sv_SE": "XCI har data i det lediga utrymmet. Det är inte säkert att optimera.",
|
||||
"th_TH": "XCI มีข้อมูลในพื้นที่ว่าง จึงไม่ปลอดภัยที่จะทำการตัดแต่ง",
|
||||
"tr_TR": "",
|
||||
"uk_UA": "XCI містить дані в зоні вільного простору. Тому обрізка небезпечна.",
|
||||
"zh_CN": "XCI 文件的空闲区域内有数据。不能安全瘦身。",
|
||||
"zh_TW": "XCI 檔案有數據儲存於空閒區域。修剪不安全。"
|
||||
}
|
||||
},
|
||||
{
|
||||
"ID": "InvalidDataMessage",
|
||||
"Translations": {
|
||||
"ar_SA": "",
|
||||
"de_DE": "",
|
||||
"el_GR": "",
|
||||
"en_US": "XCI contains invalid data. Check logs for details.",
|
||||
"es_ES": "XCI contiene datos inválidos. Lee el registro para detalles.",
|
||||
"fr_FR": "XCI contient des données invalides. Référez-vous aux journaux pour détails.",
|
||||
"he_IL": "",
|
||||
"it_IT": "XCI contiene dati non validi. Controlla i log per dettagli.",
|
||||
"ja_JP": "",
|
||||
"ko_KR": "XCI 파일에 유효하지 않은 데이터가 포함되어 있습니다. 로그를 확인하세요.",
|
||||
"no_NO": "XCI-filen inneholder ugyldige data. Sjekk loggene for detaljer.",
|
||||
"pl_PL": "XCI zawiera nieprawidłowe dane. Sprawdź dzienniki, aby uzyskać szczegóły.",
|
||||
"pt_BR": "XCI contém dados inválidos. Verifique os logs para detalhes.",
|
||||
"ru_RU": "XCI содержит недопустимые данные. Проверьте журналы для подробностей.",
|
||||
"sv_SE": "XCI-filen innehåller ogiltig data. Kontrollera loggen för detaljer.",
|
||||
"th_TH": "XCI มีข้อมูลที่ไม่ถูกต้อง โปรดตรวจสอบบันทึกสำหรับรายละเอียด",
|
||||
"tr_TR": "",
|
||||
"uk_UA": "XCI містить недійсні дані. Перевірте журнали для деталей.",
|
||||
"zh_CN": "XCI 文件含有无效数据。查看日志以获得更多细节。",
|
||||
"zh_TW": "XCI 檔案帶有無效的數據。檢查日誌以取得更多資訊"
|
||||
}
|
||||
},
|
||||
{
|
||||
"ID": "WriteErrorMessage",
|
||||
"Translations": {
|
||||
"ar_SA": "",
|
||||
"de_DE": "",
|
||||
"el_GR": "",
|
||||
"en_US": "XCI could not be opened for writing. Check logs for details.",
|
||||
"es_ES": "XCI no se puede abrir para escribir. Lee el registro para detalles.",
|
||||
"fr_FR": "XCI n'a pas pu être ouvert pour écriture. Consultez les journaux pour détails.",
|
||||
"he_IL": "",
|
||||
"it_IT": "XCI non può essere aperto in scrittura. Controlla i log per dettagli.",
|
||||
"ja_JP": "",
|
||||
"ko_KR": "XCI를 쓰기 위해 열 수 없습니다. 로그를 확인하세요.",
|
||||
"no_NO": "XCI kunne ikke åpnes for skriving. Sjekk loggene for detaljer.",
|
||||
"pl_PL": "Nie można otworzyć XCI do zapisu. Sprawdź dzienniki, aby uzyskać szczegóły.",
|
||||
"pt_BR": "XCI não pôde ser aberto para gravação. Verifique os logs para detalhes.",
|
||||
"ru_RU": "Не удалось открыть XCI для записи. Проверьте журналы для подробностей.",
|
||||
"sv_SE": "XCI kunde inte öppnas för skrivning. Kontrollera loggen för detaljer.",
|
||||
"th_TH": "ไม่สามารถเปิด XCI เพื่อเขียนข้อมูลได้ โปรดตรวจสอบบันทึกสำหรับรายละเอียด",
|
||||
"tr_TR": "",
|
||||
"uk_UA": "Не вдалося відкрити XCI для запису. Перевірте журнали для деталей.",
|
||||
"zh_CN": "XCI 不能写入。查看日志以获得更多细节。",
|
||||
"zh_TW": "XCI 無法開啟以進行寫入。請檢查日誌以取得更多資訊。"
|
||||
}
|
||||
},
|
||||
{
|
||||
"ID": "TrimFailedMessage",
|
||||
"Translations": {
|
||||
"ar_SA": "",
|
||||
"de_DE": "",
|
||||
"el_GR": "",
|
||||
"en_US": "Failed to trim XCI.",
|
||||
"es_ES": "El recorte del XCI falló.",
|
||||
"fr_FR": "La réduction du XCI a échoué.",
|
||||
"he_IL": "",
|
||||
"it_IT": "Riduzione del XCI fallita.",
|
||||
"ja_JP": "",
|
||||
"ko_KR": "XCI 트리밍에 실패했습니다.",
|
||||
"no_NO": "Trimming av XCI mislyktes.",
|
||||
"pl_PL": "Nie udało się przyciąć XCI.",
|
||||
"pt_BR": "A redução do XCI falhou.",
|
||||
"ru_RU": "Обрезка XCI не удалась.",
|
||||
"sv_SE": "Optimering av XCI misslyckades.",
|
||||
"th_TH": "การตัดแต่ง XCI ล้มเหลว",
|
||||
"tr_TR": "",
|
||||
"uk_UA": "Не вдалося обрізати XCI.",
|
||||
"zh_CN": "XCI 瘦身失败。",
|
||||
"zh_TW": "修剪 XCI 失敗。"
|
||||
}
|
||||
},
|
||||
{
|
||||
"ID": "TrimCancelledMessage",
|
||||
"Translations": {
|
||||
"ar_SA": "",
|
||||
"de_DE": "",
|
||||
"el_GR": "",
|
||||
"en_US": "The operation was cancelled.",
|
||||
"es_ES": "La operación fue cancelada.",
|
||||
"fr_FR": "L'opération a été annulée.",
|
||||
"he_IL": "",
|
||||
"it_IT": "L'operazione è stata annullata.",
|
||||
"ja_JP": "",
|
||||
"ko_KR": "작업이 취소되었습니다.",
|
||||
"no_NO": "Operasjonen ble avbrutt.",
|
||||
"pl_PL": "",
|
||||
"pt_BR": "A operação foi cancelada.",
|
||||
"ru_RU": "Операция была отменена.",
|
||||
"sv_SE": "Åtgärden avbröts.",
|
||||
"th_TH": "การดำเนินการถูกยกเลิกแล้ว.",
|
||||
"tr_TR": "",
|
||||
"uk_UA": "Операцію перервано.",
|
||||
"zh_CN": "操作已取消。",
|
||||
"zh_TW": "操作已取消。"
|
||||
}
|
||||
},
|
||||
{
|
||||
"ID": "NoOperationPerformedMessage",
|
||||
"Translations": {
|
||||
"ar_SA": "",
|
||||
"de_DE": "",
|
||||
"el_GR": "",
|
||||
"en_US": "No operation was performed.",
|
||||
"es_ES": "No se realizó ninguna operación.",
|
||||
"fr_FR": "Aucune opération n'a été effectuée.",
|
||||
"he_IL": "",
|
||||
"it_IT": "Non è stata effettuata alcuna operazione.",
|
||||
"ja_JP": "",
|
||||
"ko_KR": "작업이 수행되지 않았습니다.",
|
||||
"no_NO": "Ingen operasjon ble utført.",
|
||||
"pl_PL": "",
|
||||
"pt_BR": "Nenhuma operação foi realizada.",
|
||||
"ru_RU": "Операция не была выполнена.",
|
||||
"sv_SE": "Ingen åtgärd genomfördes.",
|
||||
"th_TH": "ไม่มีการดำเนินการใด ๆ",
|
||||
"tr_TR": "",
|
||||
"uk_UA": "Операцію не було виконано.",
|
||||
"zh_CN": "未执行任何操作。",
|
||||
"zh_TW": "沒有執行任何操作。"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
29
assets/Locales/GameListContextMenu.json
Normal file
29
assets/Locales/GameListContextMenu.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"Locales": [
|
||||
{
|
||||
"ID": "TrimXCIButton",
|
||||
"Translations": {
|
||||
"ar_SA": "تقليم XCI",
|
||||
"de_DE": "Zuschneiden der XCI",
|
||||
"el_GR": "Κοπή XCI",
|
||||
"en_US": "Trim XCI",
|
||||
"es_ES": "Recortar XCI",
|
||||
"fr_FR": "Réduire le XCI",
|
||||
"he_IL": "חתוך XCI",
|
||||
"it_IT": "Riduci il XCI",
|
||||
"ja_JP": "XCIをトリム",
|
||||
"ko_KR": "XCI 트림",
|
||||
"no_NO": "Trim XCI-filen",
|
||||
"pl_PL": "Przytnij XCI",
|
||||
"pt_BR": "Reduzir o XCI",
|
||||
"ru_RU": "Обрезать XCI",
|
||||
"sv_SE": "Optimera XCI",
|
||||
"th_TH": "ลดขนาด XCI",
|
||||
"tr_TR": "XCI'yi Kırp",
|
||||
"uk_UA": "Нарізка XCI",
|
||||
"zh_CN": "精简 XCI",
|
||||
"zh_TW": "修剪 XCI"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -228,26 +228,26 @@
|
||||
{
|
||||
"ID": "XCITrimmerButton",
|
||||
"Translations": {
|
||||
"ar_SA": "",
|
||||
"de_DE": "XCI-Dateien trimmen",
|
||||
"ar_SA": "",
|
||||
"de_DE": "XCI-Trimmer",
|
||||
"el_GR": "",
|
||||
"en_US": "Trim XCI Files",
|
||||
"es_ES": "Recortar Archivos XCI",
|
||||
"fr_FR": "Réduire les Fichiers XCI",
|
||||
"en_US": "XCI Trimmer",
|
||||
"es_ES": "Recortador de XCI",
|
||||
"fr_FR": "Réducteur de XCI",
|
||||
"he_IL": "",
|
||||
"it_IT": "Riduci dimensioni dei file XCI",
|
||||
"it_IT": "Trimmer XCI",
|
||||
"ja_JP": "",
|
||||
"ko_KR": "XCI 파일 트리머",
|
||||
"no_NO": "Trim XCI-filer",
|
||||
"pl_PL": "",
|
||||
"pt_BR": "Reduzir Arquivos XCI",
|
||||
"ru_RU": "Обрезать XCI файлы",
|
||||
"sv_SE": "Optimera XCI-filer",
|
||||
"th_TH": "ตัดแต่งไฟล์ XCI",
|
||||
"tr_TR": "",
|
||||
"uk_UA": "Обрізати XCI файли",
|
||||
"zh_CN": "瘦身 XCI 文件",
|
||||
"zh_TW": "修剪 XCI 檔案"
|
||||
"pl_PL": "Przycinacz XCI",
|
||||
"pt_BR": "Cortador de XCI",
|
||||
"ru_RU": "Триммер XCI",
|
||||
"sv_SE": "XCI-trimmer",
|
||||
"th_TH": "",
|
||||
"tr_TR": "XCI Kesici",
|
||||
"uk_UA": "Тример XCI",
|
||||
"zh_CN": "",
|
||||
"zh_TW": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -450,6 +450,31 @@
|
||||
"zh_TW": "移除 Skylander"
|
||||
}
|
||||
},
|
||||
{
|
||||
"ID": "SimulateWakeUpMessageButton",
|
||||
"Translations": {
|
||||
"ar_SA": "محاكاة رسالة الاستيقاظ",
|
||||
"de_DE": "Aufwachnachricht simulieren",
|
||||
"el_GR": "Προσομοίωση Μηνύματος Αφύπνισης",
|
||||
"en_US": "Simulate Wake-Up Message",
|
||||
"es_ES": "Simular Mensaje de Reactivación",
|
||||
"fr_FR": "Simuler un Message de Réveil",
|
||||
"he_IL": "דמה הודעת השכמה",
|
||||
"it_IT": "Simula messaggio di risveglio",
|
||||
"ja_JP": "スリープ復帰メッセージをシミュレート",
|
||||
"ko_KR": "절전 모드 해제 메시지 시뮬레이션",
|
||||
"no_NO": "Simuler oppvåknings-melding",
|
||||
"pl_PL": "Symuluj wiadomość wybudzania",
|
||||
"pt_BR": "Simular Mensagem de Acordar o Console",
|
||||
"ru_RU": "Имитировать сообщение пробуждения",
|
||||
"sv_SE": "Simulera uppvakningsmeddelande",
|
||||
"th_TH": "จำลองการปลุกอุปกรณ์ให้ทำงาน",
|
||||
"tr_TR": "Uyandırma Mesajı Simüle Et",
|
||||
"uk_UA": "Симулювати повідомлення про пробудження",
|
||||
"zh_CN": "模拟唤醒消息",
|
||||
"zh_TW": "模擬喚醒訊息"
|
||||
}
|
||||
},
|
||||
{
|
||||
"ID": "TakeScreenshotButton",
|
||||
"Translations": {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,32 @@
|
||||
{
|
||||
"Locales": [
|
||||
{
|
||||
"ID": "TrimmingXCILabel",
|
||||
"Translations": {
|
||||
"ar_SA": "جاري تقليم: {0}",
|
||||
"de_DE": "Schneide: {0}",
|
||||
"el_GR": "Κόβει το: {0}",
|
||||
"en_US": "Trimming: {0}",
|
||||
"es_ES": "Recortando: {0}",
|
||||
"fr_FR": "Réduction de: {0}",
|
||||
"he_IL": "חיתוך: {0}",
|
||||
"it_IT": "Riduzione di: {0}",
|
||||
"ja_JP": "{0} をトリミング中:",
|
||||
"ko_KR": "{0} 트리밍:",
|
||||
"no_NO": "Trimming av: {0}",
|
||||
"pl_PL": "Przycinanie: {0}",
|
||||
"pt_BR": "Reduzindo: {0}",
|
||||
"ru_RU": "Обрезка: {0}",
|
||||
"sv_SE": "Trimmar: {0}",
|
||||
"th_TH": "กำลังตัด: {0}",
|
||||
"tr_TR": "{0} Kısaltılıyor:",
|
||||
"uk_UA": "Обрізка: {0}",
|
||||
"zh_CN": "正在修剪: {0}",
|
||||
"zh_TW": "正在修剪: {0}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"ID": "FirmwareVersion",
|
||||
"ID": "FirmwareVersionLabel",
|
||||
"Translations": {
|
||||
"ar_SA": "",
|
||||
"de_DE": "",
|
||||
|
||||
429
assets/Locales/XCITrimmer.json
Normal file
429
assets/Locales/XCITrimmer.json
Normal file
@@ -0,0 +1,429 @@
|
||||
{
|
||||
"Locales": [
|
||||
{
|
||||
"ID": "StatusCountLabel",
|
||||
"Translations": {
|
||||
"ar_SA": "",
|
||||
"de_DE": "",
|
||||
"el_GR": "",
|
||||
"en_US": "XCI Selected: {0}/{1}",
|
||||
"es_ES": "XCI Seleccionados: {0}/{1}",
|
||||
"fr_FR": "XCI Sélectionnés : {0}/{1}",
|
||||
"he_IL": "",
|
||||
"it_IT": "XCI Selezionati: {0}/{1}",
|
||||
"ja_JP": "",
|
||||
"ko_KR": "XCI 선택됨: {0}/{1}",
|
||||
"no_NO": "XCI Valgt: {0}/{1}",
|
||||
"pl_PL": "XCI Wybrane: {0}/{1}",
|
||||
"pt_BR": "XCI Selecionados: {0}/{1}",
|
||||
"ru_RU": "Выбрано XCI: {0}/{1}",
|
||||
"sv_SE": "XCI Valda: {0}/{1}",
|
||||
"th_TH": "XCI เลือกแล้ว: {0}/{1}",
|
||||
"tr_TR": "",
|
||||
"uk_UA": "Вибрано XCI: {0}/{1}",
|
||||
"zh_CN": "XCI 已选: {0}/{1}",
|
||||
"zh_TW": "XCI 已選擇: {0}/{1}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"ID": "StatusCountWithFilterLabel",
|
||||
"Translations": {
|
||||
"ar_SA": "",
|
||||
"de_DE": "",
|
||||
"el_GR": "",
|
||||
"en_US": "XCI Selected: {0}/{1} (Displayed: {2})",
|
||||
"es_ES": "XCI Seleccionados: {0}/{1} (Mostrados: {2})",
|
||||
"fr_FR": "XCI Sélectionnés : {0}/{1} (Affichés : {2})",
|
||||
"he_IL": "",
|
||||
"it_IT": "XCI Selezionati: {0}/{1} (Visualizzati: {2})",
|
||||
"ja_JP": "",
|
||||
"ko_KR": "XCI 선택됨: {0}/{1} (표시됨: {2})",
|
||||
"no_NO": "XCI Valgt: {0}/{1} (Vises: {2})",
|
||||
"pl_PL": "XCI Wybrane: {0}/{1} (Wyświetlone: {2})",
|
||||
"pt_BR": "XCI Selecionados: {0}/{1} (Exibidos: {2})",
|
||||
"ru_RU": "Выбрано XCI: {0}/{1} (Отображается: {2})",
|
||||
"sv_SE": "XCI Valda: {0}/{1} (Visas: {2})",
|
||||
"th_TH": "XCI เลือกแล้ว: {0}/{1} (แสดง: {2})",
|
||||
"tr_TR": "",
|
||||
"uk_UA": "Вибрано XCI: {0}/{1} (Відображається: {2})",
|
||||
"zh_CN": "XCI 已选: {0}/{1}(显示: {2})",
|
||||
"zh_TW": "XCI 已選擇: {0}/{1}(顯示: {2})"
|
||||
}
|
||||
},
|
||||
{
|
||||
"ID": "StatusTrimmingLabel",
|
||||
"Translations": {
|
||||
"ar_SA": "",
|
||||
"de_DE": "",
|
||||
"el_GR": "",
|
||||
"en_US": "Trimming: {0}/{1}...",
|
||||
"es_ES": "Recortando: {0}/{1}...",
|
||||
"fr_FR": "Réduction : {0}/{1}...",
|
||||
"he_IL": "",
|
||||
"it_IT": "Riduzione: {0}/{1}...",
|
||||
"ja_JP": "",
|
||||
"ko_KR": "트리밍: {0}/{1}...",
|
||||
"no_NO": "Trimmer: {0}/{1}...",
|
||||
"pl_PL": "",
|
||||
"pt_BR": "Recortando: {0}/{1}...",
|
||||
"ru_RU": "Обрезка: {0}/{1}...",
|
||||
"sv_SE": "Trimmar: {0}/{1}...",
|
||||
"th_TH": "กำลังตัดแต่ง: {0}/{1}...",
|
||||
"tr_TR": "",
|
||||
"uk_UA": "Обрізання: {0}/{1}...",
|
||||
"zh_CN": "正在修剪:{0}/{1}...",
|
||||
"zh_TW": "正在修剪:{0}/{1}..."
|
||||
}
|
||||
},
|
||||
{
|
||||
"ID": "StatusUntrimmingLabel",
|
||||
"Translations": {
|
||||
"ar_SA": "",
|
||||
"de_DE": "",
|
||||
"el_GR": "",
|
||||
"en_US": "Untrimming {0}...",
|
||||
"es_ES": "Deshaciendo el recorte {0}...",
|
||||
"fr_FR": "Restauration de {0}...",
|
||||
"he_IL": "",
|
||||
"it_IT": "Ripristino di {0}...",
|
||||
"ja_JP": "",
|
||||
"ko_KR": "{0} 복원 중...",
|
||||
"no_NO": "Gjenoppretter {0}...",
|
||||
"pl_PL": "",
|
||||
"pt_BR": "Restaurando {0}...",
|
||||
"ru_RU": "Восстановление {0}...",
|
||||
"sv_SE": "Återställer {0}...",
|
||||
"th_TH": "กำลังกู้คืน {0}...",
|
||||
"tr_TR": "",
|
||||
"uk_UA": "Відновлення {0}...",
|
||||
"zh_CN": "正在恢复 {0}...",
|
||||
"zh_TW": "正在還原 {0}..."
|
||||
}
|
||||
},
|
||||
{
|
||||
"ID": "SelectAllButton",
|
||||
"Translations": {
|
||||
"ar_SA": "اختر الكل",
|
||||
"de_DE": "Alles auswählen",
|
||||
"el_GR": "Επιλογή όλων",
|
||||
"en_US": "Select All",
|
||||
"es_ES": "Seleccionar Todo",
|
||||
"fr_FR": "Sélectionner Tout",
|
||||
"he_IL": "בחר הכל",
|
||||
"it_IT": "Seleziona tutto",
|
||||
"ja_JP": "すべて選択",
|
||||
"ko_KR": "모두 선택",
|
||||
"no_NO": "Velg alle",
|
||||
"pl_PL": "Zaznacz wszystko",
|
||||
"pt_BR": "Selecionar tudo",
|
||||
"ru_RU": "Выбрать все",
|
||||
"sv_SE": "Markera alla",
|
||||
"th_TH": "เลือกทั้งหมด",
|
||||
"tr_TR": "Hepsini seç",
|
||||
"uk_UA": "Вибрати все",
|
||||
"zh_CN": "选择全部",
|
||||
"zh_TW": "選擇全部"
|
||||
}
|
||||
},
|
||||
{
|
||||
"ID": "ClearSelectionButton",
|
||||
"Translations": {
|
||||
"ar_SA": "مسح التحديد",
|
||||
"de_DE": "Auswahl aufheben",
|
||||
"el_GR": "Εκκαθάριση επιλογής",
|
||||
"en_US": "Clear Selection",
|
||||
"es_ES": "Borrar selección",
|
||||
"fr_FR": "Effacer la sélection",
|
||||
"he_IL": "נקה בחירה",
|
||||
"it_IT": "Cancella selezione",
|
||||
"ja_JP": "選択をクリア",
|
||||
"ko_KR": "선택 해제",
|
||||
"no_NO": "Fjern utvalg",
|
||||
"pl_PL": "Wyczyść zaznaczenie",
|
||||
"pt_BR": "Limpar seleção",
|
||||
"ru_RU": "Очистить выделение",
|
||||
"sv_SE": "Rensa markering",
|
||||
"th_TH": "ล้างการเลือก",
|
||||
"tr_TR": "Seçimi temizle",
|
||||
"uk_UA": "Очистити вибір",
|
||||
"zh_CN": "清除选择",
|
||||
"zh_TW": "清除選擇"
|
||||
}
|
||||
},
|
||||
{
|
||||
"ID": "TrimmedLabel",
|
||||
"Translations": {
|
||||
"ar_SA": "",
|
||||
"de_DE": "",
|
||||
"el_GR": "",
|
||||
"en_US": "Trimmed",
|
||||
"es_ES": "Recortado",
|
||||
"fr_FR": "Réduit",
|
||||
"he_IL": "",
|
||||
"it_IT": "Dim. ridotta",
|
||||
"ja_JP": "",
|
||||
"ko_KR": "트리밍됨",
|
||||
"no_NO": "Trimmet",
|
||||
"pl_PL": "",
|
||||
"pt_BR": "Reduzido",
|
||||
"ru_RU": "Обрезан",
|
||||
"sv_SE": "Optimerad",
|
||||
"th_TH": "ตัดแต่งแล้ว",
|
||||
"tr_TR": "",
|
||||
"uk_UA": "Обрізані",
|
||||
"zh_CN": "经过瘦身的",
|
||||
"zh_TW": "已修剪"
|
||||
}
|
||||
},
|
||||
{
|
||||
"ID": "UntrimmedLabel",
|
||||
"Translations": {
|
||||
"ar_SA": "",
|
||||
"de_DE": "",
|
||||
"el_GR": "",
|
||||
"en_US": "Untrimmed",
|
||||
"es_ES": "Sin Recortar",
|
||||
"fr_FR": "Non Réduit",
|
||||
"he_IL": "",
|
||||
"it_IT": "Dim. originale",
|
||||
"ja_JP": "",
|
||||
"ko_KR": "트리밍되지 않음",
|
||||
"no_NO": "Ikke trimmet",
|
||||
"pl_PL": "",
|
||||
"pt_BR": "Não Reduzido",
|
||||
"ru_RU": "Не обрезан",
|
||||
"sv_SE": "Orörd",
|
||||
"th_TH": "ยังไม่ได้ตัดแต่ง",
|
||||
"tr_TR": "",
|
||||
"uk_UA": "Необрізані",
|
||||
"zh_CN": "没有瘦身的",
|
||||
"zh_TW": "未修剪"
|
||||
}
|
||||
},
|
||||
{
|
||||
"ID": "PartialLabel",
|
||||
"Translations": {
|
||||
"ar_SA": "",
|
||||
"de_DE": "",
|
||||
"el_GR": "",
|
||||
"en_US": "Partial",
|
||||
"es_ES": "Parcial",
|
||||
"fr_FR": "Partiel",
|
||||
"he_IL": "",
|
||||
"it_IT": "Parziale",
|
||||
"ja_JP": "",
|
||||
"ko_KR": "일부",
|
||||
"no_NO": "Delvis",
|
||||
"pl_PL": "",
|
||||
"pt_BR": "Parcial",
|
||||
"ru_RU": "Частично",
|
||||
"sv_SE": "Delvis",
|
||||
"th_TH": "ยังไม่สมบูรณ์",
|
||||
"tr_TR": "",
|
||||
"uk_UA": "Часткові",
|
||||
"zh_CN": "分区",
|
||||
"zh_TW": "部分"
|
||||
}
|
||||
},
|
||||
{
|
||||
"ID": "FailedLabel",
|
||||
"Translations": {
|
||||
"ar_SA": "",
|
||||
"de_DE": "",
|
||||
"el_GR": "",
|
||||
"en_US": "Failed",
|
||||
"es_ES": "Fallido",
|
||||
"fr_FR": "Échoué",
|
||||
"he_IL": "",
|
||||
"it_IT": "Fallito",
|
||||
"ja_JP": "",
|
||||
"ko_KR": "실패",
|
||||
"no_NO": "Mislyktes",
|
||||
"pl_PL": "",
|
||||
"pt_BR": "Falhou",
|
||||
"ru_RU": "Ошибка",
|
||||
"sv_SE": "Misslyckades",
|
||||
"th_TH": "ล้มเหลว",
|
||||
"tr_TR": "",
|
||||
"uk_UA": "Невдача",
|
||||
"zh_CN": "失败",
|
||||
"zh_TW": "失敗"
|
||||
}
|
||||
},
|
||||
{
|
||||
"ID": "UnknownLabel",
|
||||
"Translations": {
|
||||
"ar_SA": "مجهول",
|
||||
"de_DE": "Unbekannt",
|
||||
"el_GR": "Άγνωστο",
|
||||
"en_US": "Unknown",
|
||||
"es_ES": "Desconocido",
|
||||
"fr_FR": "Inconnu",
|
||||
"he_IL": "לא ידוע",
|
||||
"it_IT": "Sconosciuto",
|
||||
"ja_JP": "不明",
|
||||
"ko_KR": "알 수 없음",
|
||||
"no_NO": "Ukjent",
|
||||
"pl_PL": "Nieznany",
|
||||
"pt_BR": "Desconhecido",
|
||||
"ru_RU": "Неизвестно",
|
||||
"sv_SE": "Okänd",
|
||||
"th_TH": "ไม่รู้จัก",
|
||||
"tr_TR": "Bilinmeyen",
|
||||
"uk_UA": "Невідомо",
|
||||
"zh_CN": "未知",
|
||||
"zh_TW": "未知"
|
||||
}
|
||||
},
|
||||
{
|
||||
"ID": "CalculatedSavingsLabel",
|
||||
"Translations": {
|
||||
"ar_SA": null,
|
||||
"de_DE": null,
|
||||
"el_GR": null,
|
||||
"en_US": "{0} MB ({1}%)",
|
||||
"es_ES": null,
|
||||
"fr_FR": "{0} Mo ({1} %)",
|
||||
"he_IL": null,
|
||||
"it_IT": null,
|
||||
"ja_JP": "{0}MB({1}%)",
|
||||
"ko_KR": "{0}MB ({1}%)",
|
||||
"no_NO": null,
|
||||
"pl_PL": null,
|
||||
"pt_BR": null,
|
||||
"ru_RU": "{0} МБ ({1}%)",
|
||||
"sv_SE": null,
|
||||
"th_TH": null,
|
||||
"tr_TR": null,
|
||||
"uk_UA": "{0} МБ ({1}%)",
|
||||
"zh_CN": "{0} MB({1}%)",
|
||||
"zh_TW": "{0} MB({1}%)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"ID": "SavedLabel",
|
||||
"Translations": {
|
||||
"ar_SA": "",
|
||||
"de_DE": "",
|
||||
"el_GR": "",
|
||||
"en_US": "Saved:",
|
||||
"es_ES": "Ahorrado:",
|
||||
"fr_FR": "Économies :",
|
||||
"he_IL": "",
|
||||
"it_IT": "Risparmiato:",
|
||||
"ja_JP": "",
|
||||
"ko_KR": "",
|
||||
"no_NO": "",
|
||||
"pl_PL": "Zaoszczędzone:",
|
||||
"pt_BR": "Economizado:",
|
||||
"ru_RU": "Сохранено:",
|
||||
"sv_SE": "",
|
||||
"th_TH": "",
|
||||
"tr_TR": "",
|
||||
"uk_UA": "Збережено:",
|
||||
"zh_CN": "",
|
||||
"zh_TW": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"ID": "RemainingLabel",
|
||||
"Translations": {
|
||||
"ar_SA": "المتبقية:",
|
||||
"de_DE": "Verbleibend:",
|
||||
"el_GR": "Απομένουν:",
|
||||
"en_US": "Remaining:",
|
||||
"es_ES": "Restante:",
|
||||
"fr_FR": "Restant :",
|
||||
"he_IL": "נותרו:",
|
||||
"it_IT": "Rimanenti:",
|
||||
"ja_JP": "残り:",
|
||||
"ko_KR": "남은:",
|
||||
"no_NO": "Gjenstående:",
|
||||
"pl_PL": "Pozostało:",
|
||||
"pt_BR": "Restante:",
|
||||
"ru_RU": "Осталось:",
|
||||
"sv_SE": "Kvar:",
|
||||
"th_TH": "เหลือ:",
|
||||
"tr_TR": "Kalan:",
|
||||
"uk_UA": "Залишилося:",
|
||||
"zh_CN": "剩余:",
|
||||
"zh_TW": "剩餘:"
|
||||
}
|
||||
},
|
||||
{
|
||||
"ID": "MBLabel",
|
||||
"Translations": {
|
||||
"ar_SA": null,
|
||||
"de_DE": null,
|
||||
"el_GR": null,
|
||||
"en_US": "{0} MB",
|
||||
"es_ES": null,
|
||||
"fr_FR": "{0} Mo",
|
||||
"he_IL": null,
|
||||
"it_IT": null,
|
||||
"ja_JP": "{0}MB",
|
||||
"ko_KR": "{0}MB",
|
||||
"no_NO": null,
|
||||
"pl_PL": null,
|
||||
"pt_BR": null,
|
||||
"ru_RU": "{0} МБ",
|
||||
"sv_SE": null,
|
||||
"th_TH": null,
|
||||
"tr_TR": null,
|
||||
"uk_UA": "{0} МБ",
|
||||
"zh_CN": null,
|
||||
"zh_TW": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"ID": "TrimButton",
|
||||
"Translations": {
|
||||
"ar_SA": "",
|
||||
"de_DE": "",
|
||||
"el_GR": "",
|
||||
"en_US": "Trim",
|
||||
"es_ES": "Recortar",
|
||||
"fr_FR": "Réduire",
|
||||
"he_IL": "",
|
||||
"it_IT": "Riduci",
|
||||
"ja_JP": "",
|
||||
"ko_KR": "트림",
|
||||
"no_NO": "",
|
||||
"pl_PL": "Przytnij",
|
||||
"pt_BR": "Reduzir",
|
||||
"ru_RU": "Обрезать",
|
||||
"sv_SE": "Trimma",
|
||||
"th_TH": "ตัด",
|
||||
"tr_TR": "Kırp",
|
||||
"uk_UA": "Обрізати",
|
||||
"zh_CN": "瘦身",
|
||||
"zh_TW": "修剪"
|
||||
}
|
||||
},
|
||||
{
|
||||
"ID": "UntrimButton",
|
||||
"Translations": {
|
||||
"ar_SA": "",
|
||||
"de_DE": "",
|
||||
"el_GR": "",
|
||||
"en_US": "Untrim",
|
||||
"es_ES": "Desrecortar",
|
||||
"fr_FR": "Dé-Réduire",
|
||||
"he_IL": "",
|
||||
"it_IT": "Ripristina",
|
||||
"ja_JP": "",
|
||||
"ko_KR": "언트림",
|
||||
"no_NO": "Utrim",
|
||||
"pl_PL": "",
|
||||
"pt_BR": "Restaurar",
|
||||
"ru_RU": "Восстановить",
|
||||
"sv_SE": "Avoptimera",
|
||||
"th_TH": "กู้คืน",
|
||||
"tr_TR": "",
|
||||
"uk_UA": "Відновити",
|
||||
"zh_CN": "取消精简",
|
||||
"zh_TW": "反修剪"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
11
src/Ryujinx.Common/Configuration/Hid/AssignedInputDevice.cs
Normal file
11
src/Ryujinx.Common/Configuration/Hid/AssignedInputDevice.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
namespace Ryujinx.Common.Configuration.Hid
|
||||
{
|
||||
public class AssignedInputDevice
|
||||
{
|
||||
public AssignedInputDeviceType Type { get; set; }
|
||||
|
||||
public string Id { get; set; }
|
||||
|
||||
public string ProfileName { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Ryujinx.Common.Configuration.Hid
|
||||
{
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<AssignedInputDeviceType>))]
|
||||
public enum AssignedInputDeviceType
|
||||
{
|
||||
Keyboard,
|
||||
Controller,
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,12 @@ namespace Ryujinx.Common.Configuration.Hid
|
||||
/// </summary>
|
||||
public PlayerIndex PlayerIndex { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Allow a keyboard configuration to temporarily promote to a connected gamepad,
|
||||
/// while preserving the existing keyboard fallback path when that gamepad disappears.
|
||||
/// </summary>
|
||||
public bool EnableDynamicGamepadSwap { get; set; }
|
||||
|
||||
public event PropertyChangedEventHandler PropertyChanged;
|
||||
|
||||
protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null)
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Ryujinx.Common.Configuration.Hid
|
||||
{
|
||||
public class PlayerInputAssignment
|
||||
{
|
||||
public PlayerIndex PlayerIndex { get; set; }
|
||||
|
||||
public bool EnableDynamicInputSwap { get; set; }
|
||||
|
||||
public List<AssignedInputDevice> Devices { get; set; } = [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
using Ryujinx.Common.Configuration.Hid.Keyboard;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace Ryujinx.Common.Configuration.Hid
|
||||
{
|
||||
public static class PlayerInputAssignmentHelper
|
||||
{
|
||||
public static AssignedInputDevice CreatePrimaryDevice(InputConfig inputConfig)
|
||||
{
|
||||
if (inputConfig == null || string.IsNullOrWhiteSpace(inputConfig.Id))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new AssignedInputDevice
|
||||
{
|
||||
Type = inputConfig is StandardKeyboardInputConfig
|
||||
? AssignedInputDeviceType.Keyboard
|
||||
: AssignedInputDeviceType.Controller,
|
||||
Id = inputConfig.Id,
|
||||
};
|
||||
}
|
||||
|
||||
public static PlayerInputAssignment Normalize(PlayerInputAssignment assignment, AssignedInputDevice preferredDevice = null)
|
||||
{
|
||||
if (assignment == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
PlayerInputAssignment normalized = new()
|
||||
{
|
||||
PlayerIndex = assignment.PlayerIndex,
|
||||
EnableDynamicInputSwap = assignment.EnableDynamicInputSwap,
|
||||
};
|
||||
|
||||
List<AssignedInputDevice> distinctDevices = Deduplicate(assignment.Devices);
|
||||
|
||||
if (assignment.EnableDynamicInputSwap)
|
||||
{
|
||||
normalized.Devices.AddRange(distinctDevices.Select(Clone));
|
||||
return normalized;
|
||||
}
|
||||
|
||||
AssignedInputDevice primaryDevice = SelectPrimaryDevice(distinctDevices, preferredDevice) ?? Clone(preferredDevice);
|
||||
|
||||
if (primaryDevice != null)
|
||||
{
|
||||
normalized.Devices.Add(Clone(primaryDevice));
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
public static bool AreEquivalent(
|
||||
PlayerInputAssignment left,
|
||||
PlayerInputAssignment right,
|
||||
AssignedInputDevice leftPreferredDevice = null,
|
||||
AssignedInputDevice rightPreferredDevice = null)
|
||||
{
|
||||
if (left == null || right == null)
|
||||
{
|
||||
return left == right;
|
||||
}
|
||||
|
||||
PlayerInputAssignment normalizedLeft = Normalize(left, leftPreferredDevice);
|
||||
PlayerInputAssignment normalizedRight = Normalize(right, rightPreferredDevice);
|
||||
|
||||
if (normalizedLeft.EnableDynamicInputSwap != normalizedRight.EnableDynamicInputSwap)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
List<(AssignedInputDeviceType Type, string Id, string ProfileName)> leftDevices = normalizedLeft.Devices
|
||||
.Select(device => (Type: device.Type, Id: device.Id, ProfileName: device.ProfileName ?? string.Empty))
|
||||
.OrderBy(device => device.Type)
|
||||
.ThenBy(device => device.Id, StringComparer.Ordinal)
|
||||
.ThenBy(device => device.ProfileName, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
List<(AssignedInputDeviceType Type, string Id, string ProfileName)> rightDevices = normalizedRight.Devices
|
||||
.Select(device => (Type: device.Type, Id: device.Id, ProfileName: device.ProfileName ?? string.Empty))
|
||||
.OrderBy(device => device.Type)
|
||||
.ThenBy(device => device.Id, StringComparer.Ordinal)
|
||||
.ThenBy(device => device.ProfileName, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
return leftDevices.SequenceEqual(rightDevices);
|
||||
}
|
||||
|
||||
private static List<AssignedInputDevice> Deduplicate(IEnumerable<AssignedInputDevice> devices)
|
||||
{
|
||||
List<AssignedInputDevice> result = [];
|
||||
|
||||
if (devices == null)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
foreach (AssignedInputDevice device in devices)
|
||||
{
|
||||
if (device == null || string.IsNullOrWhiteSpace(device.Id))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
int existingIndex = result.FindIndex(existing =>
|
||||
existing.Type == device.Type &&
|
||||
string.Equals(existing.Id, device.Id, StringComparison.Ordinal));
|
||||
|
||||
if (existingIndex == -1)
|
||||
{
|
||||
result.Add(Clone(device));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(device.ProfileName) ||
|
||||
string.IsNullOrWhiteSpace(result[existingIndex].ProfileName))
|
||||
{
|
||||
result[existingIndex].ProfileName = device.ProfileName;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static AssignedInputDevice SelectPrimaryDevice(List<AssignedInputDevice> devices, AssignedInputDevice preferredDevice)
|
||||
{
|
||||
if (devices == null || devices.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (preferredDevice != null)
|
||||
{
|
||||
AssignedInputDevice matchedDevice = devices.FirstOrDefault(device =>
|
||||
device.Type == preferredDevice.Type &&
|
||||
string.Equals(device.Id, preferredDevice.Id, StringComparison.Ordinal));
|
||||
|
||||
if (matchedDevice != null)
|
||||
{
|
||||
return matchedDevice;
|
||||
}
|
||||
}
|
||||
|
||||
return devices[0];
|
||||
}
|
||||
|
||||
private static AssignedInputDevice Clone(AssignedInputDevice device)
|
||||
{
|
||||
if (device == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new AssignedInputDevice
|
||||
{
|
||||
Type = device.Type,
|
||||
Id = device.Id,
|
||||
ProfileName = device.ProfileName,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using ARMeilleure.State;
|
||||
using Ryujinx.Common.Logging;
|
||||
using Ryujinx.Cpu.AppleHv.Arm;
|
||||
using Ryujinx.Memory.Tracking;
|
||||
using System;
|
||||
@@ -17,9 +18,7 @@ namespace Ryujinx.Cpu.AppleHv
|
||||
{
|
||||
uint currentEl = Pstate & ~((uint)ExceptionLevel.PstateMask);
|
||||
if (currentEl == (uint)ExceptionLevel.EL1h)
|
||||
{
|
||||
return _impl.ElrEl1;
|
||||
}
|
||||
return _impl.Pc;
|
||||
}
|
||||
}
|
||||
@@ -69,9 +68,7 @@ namespace Ryujinx.Cpu.AppleHv
|
||||
set
|
||||
{
|
||||
if (value)
|
||||
{
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,11 +79,13 @@ namespace Ryujinx.Cpu.AppleHv
|
||||
private readonly IHvExecutionContext _shadowContext;
|
||||
private IHvExecutionContext _impl;
|
||||
private int _shouldStep;
|
||||
|
||||
private readonly ExceptionCallbacks _exceptionCallbacks;
|
||||
|
||||
private int _interruptRequested;
|
||||
|
||||
// GPU Sync control
|
||||
private int _syncCounter;
|
||||
private int _strongSyncCounter;
|
||||
|
||||
public HvExecutionContext(ICounter counter, ExceptionCallbacks exceptionCallbacks)
|
||||
{
|
||||
_counter = counter;
|
||||
@@ -108,38 +107,17 @@ namespace Ryujinx.Cpu.AppleHv
|
||||
/// <inheritdoc/>
|
||||
public void SetV(int index, V128 value) => _impl.SetV(index, value);
|
||||
|
||||
private void InterruptHandler()
|
||||
{
|
||||
_exceptionCallbacks.InterruptCallback?.Invoke(this);
|
||||
}
|
||||
|
||||
private void BreakHandler(ulong address, int imm)
|
||||
{
|
||||
_exceptionCallbacks.BreakCallback?.Invoke(this, address, imm);
|
||||
}
|
||||
|
||||
private void StepHandler()
|
||||
{
|
||||
_exceptionCallbacks.StepCallback?.Invoke(this);
|
||||
}
|
||||
|
||||
private void SupervisorCallHandler(ulong address, int imm)
|
||||
{
|
||||
_exceptionCallbacks.SupervisorCallback?.Invoke(this, address, imm);
|
||||
}
|
||||
|
||||
private void UndefinedHandler(ulong address, int opCode)
|
||||
{
|
||||
_exceptionCallbacks.UndefinedCallback?.Invoke(this, address, opCode);
|
||||
}
|
||||
private void InterruptHandler() => _exceptionCallbacks.InterruptCallback?.Invoke(this);
|
||||
private void BreakHandler(ulong address, int imm) => _exceptionCallbacks.BreakCallback?.Invoke(this, address, imm);
|
||||
private void StepHandler() => _exceptionCallbacks.StepCallback?.Invoke(this);
|
||||
private void SupervisorCallHandler(ulong address, int imm) => _exceptionCallbacks.SupervisorCallback?.Invoke(this, address, imm);
|
||||
private void UndefinedHandler(ulong address, int opCode) => _exceptionCallbacks.UndefinedCallback?.Invoke(this, address, opCode);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void RequestInterrupt()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _interruptRequested, 1) == 0 && _impl is HvExecutionContextVcpu impl)
|
||||
{
|
||||
impl.RequestInterrupt();
|
||||
}
|
||||
}
|
||||
|
||||
private bool GetAndClearInterruptRequested()
|
||||
@@ -161,13 +139,9 @@ namespace Ryujinx.Cpu.AppleHv
|
||||
{
|
||||
uint currentEl = Pstate & ~((uint)ExceptionLevel.PstateMask);
|
||||
if (currentEl == (uint)ExceptionLevel.EL1h)
|
||||
{
|
||||
_impl.ElrEl1 = value;
|
||||
}
|
||||
else
|
||||
{
|
||||
_impl.Pc = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,9 +155,11 @@ namespace Ryujinx.Cpu.AppleHv
|
||||
public unsafe void Execute(HvMemoryManager memoryManager, ulong address)
|
||||
{
|
||||
HvVcpu vcpu = HvVcpuPool.Instance.Create(memoryManager.AddressSpace, _shadowContext, SwapContext);
|
||||
|
||||
HvApi.hv_vcpu_set_reg(vcpu.Handle, HvReg.PC, address).ThrowOnError();
|
||||
|
||||
_syncCounter = 0;
|
||||
_strongSyncCounter = 0;
|
||||
|
||||
while (Running)
|
||||
{
|
||||
if (Interlocked.CompareExchange(ref _shouldStep, 0, 1) == 1)
|
||||
@@ -192,16 +168,23 @@ namespace Ryujinx.Cpu.AppleHv
|
||||
if (currentEl == (uint)ExceptionLevel.EL1h)
|
||||
{
|
||||
HvApi.hv_vcpu_get_sys_reg(vcpu.Handle, HvSysReg.SPSR_EL1, out ulong spsr).ThrowOnError();
|
||||
spsr |= (1 << 21);
|
||||
spsr |= (1U << 21);
|
||||
HvApi.hv_vcpu_set_sys_reg(vcpu.Handle, HvSysReg.SPSR_EL1, spsr);
|
||||
}
|
||||
else
|
||||
{
|
||||
Pstate |= (1 << 21);
|
||||
Pstate |= (1U << 21);
|
||||
}
|
||||
HvApi.hv_vcpu_set_sys_reg(vcpu.Handle, HvSysReg.MDSCR_EL1, 1);
|
||||
}
|
||||
|
||||
// Adaptive GPU synchronization to prevent 0 FPS
|
||||
if (++_syncCounter % 12 == 0)
|
||||
{
|
||||
TryGpuSync();
|
||||
_syncCounter = 0;
|
||||
}
|
||||
|
||||
HvApi.hv_vcpu_run(vcpu.Handle).ThrowOnError();
|
||||
|
||||
HvExitReason reason = vcpu.ExitInfo->Reason;
|
||||
@@ -212,9 +195,7 @@ namespace Ryujinx.Cpu.AppleHv
|
||||
ExceptionClass hvEc = (ExceptionClass)(hvEsr >> 26);
|
||||
|
||||
if (hvEc != ExceptionClass.HvcAarch64)
|
||||
{
|
||||
throw new Exception($"Unhandled exception from guest kernel with ESR 0x{hvEsr:X} ({hvEc}).");
|
||||
}
|
||||
|
||||
address = SynchronousException(memoryManager, ref vcpu);
|
||||
HvApi.hv_vcpu_set_reg(vcpu.Handle, HvReg.PC, address).ThrowOnError();
|
||||
@@ -245,10 +226,31 @@ namespace Ryujinx.Cpu.AppleHv
|
||||
HvVcpuPool.Instance.Destroy(vcpu, SwapContext);
|
||||
}
|
||||
|
||||
// TryGpuSync() is called periodically in the main Execute() loop. The "syncing" value can be tuned based on gameplay results.
|
||||
// This feature it to be followed-up and further completed in a future PR.
|
||||
private void TryGpuSync()
|
||||
{
|
||||
try
|
||||
{
|
||||
Thread.Yield();
|
||||
|
||||
if (++_strongSyncCounter % 6 == 0)
|
||||
{
|
||||
Thread.Yield();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (_strongSyncCounter % 100 == 0)
|
||||
{
|
||||
Logger.Warning?.Print(LogClass.Gpu, $"[AppleHv] GPU sync issue: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private ulong SynchronousException(HvMemoryManager memoryManager, ref HvVcpu vcpu)
|
||||
{
|
||||
ulong vcpuHandle = vcpu.Handle;
|
||||
|
||||
HvApi.hv_vcpu_get_sys_reg(vcpuHandle, HvSysReg.ELR_EL1, out ulong elr).ThrowOnError();
|
||||
HvApi.hv_vcpu_get_sys_reg(vcpuHandle, HvSysReg.ESR_EL1, out ulong esr).ThrowOnError();
|
||||
|
||||
@@ -259,16 +261,20 @@ namespace Ryujinx.Cpu.AppleHv
|
||||
case ExceptionClass.DataAbortLowerEl:
|
||||
DataAbort(memoryManager.Tracking, vcpuHandle, (uint)esr);
|
||||
break;
|
||||
|
||||
case ExceptionClass.TrappedMsrMrsSystem:
|
||||
InstructionTrap((uint)esr);
|
||||
HvApi.hv_vcpu_set_sys_reg(vcpuHandle, HvSysReg.ELR_EL1, elr + 4UL).ThrowOnError();
|
||||
break;
|
||||
|
||||
case ExceptionClass.SvcAarch64:
|
||||
ReturnToPool(vcpu);
|
||||
ushort id = (ushort)esr;
|
||||
SupervisorCallHandler(elr - 4UL, id);
|
||||
Thread.Yield(); // MoltenVK causes extremely frequent SVC exits, and HVF handles them in a busy loop. Hypervisor.Framework accelerates the guest CPU, and without periodic yielding/flushing, MoltenVK's presentation queue can starve, causing permanent 0 FPS deadlock.
|
||||
vcpu = RentFromPool(memoryManager.AddressSpace, vcpu);
|
||||
break;
|
||||
|
||||
case ExceptionClass.SoftwareStepLowerEl:
|
||||
HvApi.hv_vcpu_get_sys_reg(vcpuHandle, HvSysReg.SPSR_EL1, out ulong spsr).ThrowOnError();
|
||||
spsr &= ~((ulong)(1 << 21));
|
||||
@@ -278,21 +284,23 @@ namespace Ryujinx.Cpu.AppleHv
|
||||
StepHandler();
|
||||
vcpu = RentFromPool(memoryManager.AddressSpace, vcpu);
|
||||
break;
|
||||
|
||||
case ExceptionClass.BrkAarch64:
|
||||
ReturnToPool(vcpu);
|
||||
BreakHandler(elr, (ushort)esr);
|
||||
vcpu = RentFromPool(memoryManager.AddressSpace, vcpu);
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new Exception($"Unhandled guest exception {ec}.");
|
||||
}
|
||||
|
||||
// Make sure we will continue running at EL0.
|
||||
if (memoryManager.AddressSpace.GetAndClearUserTlbInvalidationPending())
|
||||
{
|
||||
|
||||
// TODO: Invalidate only the range that was modified?
|
||||
return HvAddressSpace.KernelRegionTlbiEretAddress;
|
||||
}
|
||||
|
||||
return HvAddressSpace.KernelRegionEretAddress;
|
||||
}
|
||||
|
||||
@@ -305,7 +313,6 @@ namespace Ryujinx.Cpu.AppleHv
|
||||
if (farValid)
|
||||
{
|
||||
HvApi.hv_vcpu_get_sys_reg(vcpu, HvSysReg.FAR_EL1, out ulong far).ThrowOnError();
|
||||
|
||||
ulong size = 1UL << accessSizeLog2;
|
||||
|
||||
if (!tracking.VirtualMemoryEvent(far, size, write))
|
||||
@@ -349,9 +356,7 @@ namespace Ryujinx.Cpu.AppleHv
|
||||
private void WriteRt(uint rt, ulong value)
|
||||
{
|
||||
if (rt < 31)
|
||||
{
|
||||
SetX((int)rt, value);
|
||||
}
|
||||
}
|
||||
|
||||
private void ReturnToPool(HvVcpu vcpu)
|
||||
@@ -369,8 +374,6 @@ namespace Ryujinx.Cpu.AppleHv
|
||||
_impl = newContext;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using ARMeilleure.State;
|
||||
using Ryujinx.Common.Logging;
|
||||
using Ryujinx.Memory;
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Runtime.Versioning;
|
||||
using System.Threading;
|
||||
@@ -14,8 +16,31 @@ namespace Ryujinx.Cpu.AppleHv
|
||||
private static readonly SetSimdFpReg _setSimdFpReg;
|
||||
private static readonly nint _setSimdFpRegNativePtr;
|
||||
|
||||
public static bool AggressiveMode { get; set; } = false;
|
||||
private bool _earlyBootPhase = true;
|
||||
|
||||
public ulong ThreadUid { get; set; }
|
||||
|
||||
private readonly ulong[] _x = new ulong[32];
|
||||
private readonly V128[] _v = new V128[32];
|
||||
|
||||
private ulong _pc;
|
||||
private ulong _elrEl1;
|
||||
private ulong _esrEl1;
|
||||
private ulong _tpidrEl0;
|
||||
private ulong _tpidrroEl0;
|
||||
private ulong _fpcr;
|
||||
private ulong _fpsr;
|
||||
private ulong _pstateRaw;
|
||||
|
||||
private long _fallbackCount;
|
||||
private long _lastWarningTicks;
|
||||
private const long WarningCooldownTicks = 500_000_000; // 0.5 seconds
|
||||
|
||||
private readonly ulong _vcpu;
|
||||
private int _interruptRequested;
|
||||
private readonly object _registerLock = new object();
|
||||
|
||||
static HvExecutionContextVcpu()
|
||||
{
|
||||
// .NET does not support passing vectors by value, so we need to pass a pointer and use a native
|
||||
@@ -33,155 +58,293 @@ namespace Ryujinx.Cpu.AppleHv
|
||||
}
|
||||
}
|
||||
|
||||
public HvExecutionContextVcpu(ulong vcpu)
|
||||
{
|
||||
_vcpu = vcpu;
|
||||
Reset();
|
||||
}
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
lock (_registerLock)
|
||||
{
|
||||
_pstateRaw = 0x80000000UL;
|
||||
_pc = 0;
|
||||
_elrEl1 = 0;
|
||||
_esrEl1 = 0;
|
||||
_tpidrEl0 = 0;
|
||||
_tpidrroEl0 = 0;
|
||||
_fpcr = 0;
|
||||
_fpsr = 0;
|
||||
|
||||
Array.Clear(_x, 0, _x.Length);
|
||||
Array.Clear(_v, 0, _v.Length);
|
||||
|
||||
_fallbackCount = 0;
|
||||
_lastWarningTicks = 0;
|
||||
_interruptRequested = 0;
|
||||
_earlyBootPhase = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void LogHvWarning(string operation, string regName, string extra = "")
|
||||
{
|
||||
if (AggressiveMode) return;
|
||||
|
||||
long now = DateTime.UtcNow.Ticks;
|
||||
if (now - _lastWarningTicks <= WarningCooldownTicks) return;
|
||||
|
||||
string msg = $"[AppleHv] BadArgument on {operation} {regName} | PC=0x{_pc:X16}";
|
||||
if (!string.IsNullOrEmpty(extra)) msg += $" | {extra}";
|
||||
msg += $" | Total: {Interlocked.Read(ref _fallbackCount)}";
|
||||
|
||||
Logger.Warning?.Print(LogClass.Cpu, msg);
|
||||
_lastWarningTicks = now;
|
||||
}
|
||||
|
||||
public ulong Pc
|
||||
{
|
||||
get
|
||||
{
|
||||
HvApi.hv_vcpu_get_reg(_vcpu, HvReg.PC, out ulong pc).ThrowOnError();
|
||||
return pc;
|
||||
}
|
||||
set
|
||||
{
|
||||
HvApi.hv_vcpu_set_reg(_vcpu, HvReg.PC, value).ThrowOnError();
|
||||
}
|
||||
get { lock (_registerLock) return GetRegCached(HvReg.PC, ref _pc, "PC"); }
|
||||
set { lock (_registerLock) SetRegCached(HvReg.PC, value, ref _pc, "PC"); }
|
||||
}
|
||||
|
||||
public ulong ElrEl1
|
||||
{
|
||||
get
|
||||
{
|
||||
HvApi.hv_vcpu_get_sys_reg(_vcpu, HvSysReg.ELR_EL1, out ulong elr).ThrowOnError();
|
||||
return elr;
|
||||
}
|
||||
set
|
||||
{
|
||||
HvApi.hv_vcpu_set_sys_reg(_vcpu, HvSysReg.ELR_EL1, value).ThrowOnError();
|
||||
}
|
||||
get { lock (_registerLock) return GetSysRegCached(HvSysReg.ELR_EL1, ref _elrEl1, "ELR_EL1"); }
|
||||
set { lock (_registerLock) SetSysRegCached(HvSysReg.ELR_EL1, value, ref _elrEl1, "ELR_EL1"); }
|
||||
}
|
||||
|
||||
public ulong EsrEl1
|
||||
{
|
||||
get
|
||||
{
|
||||
HvApi.hv_vcpu_get_sys_reg(_vcpu, HvSysReg.ESR_EL1, out ulong esr).ThrowOnError();
|
||||
return esr;
|
||||
}
|
||||
set
|
||||
{
|
||||
HvApi.hv_vcpu_set_sys_reg(_vcpu, HvSysReg.ESR_EL1, value).ThrowOnError();
|
||||
}
|
||||
get { lock (_registerLock) return GetSysRegCached(HvSysReg.ESR_EL1, ref _esrEl1, "ESR_EL1"); }
|
||||
set { lock (_registerLock) SetSysRegCached(HvSysReg.ESR_EL1, value, ref _esrEl1, "ESR_EL1"); }
|
||||
}
|
||||
|
||||
public long TpidrEl0
|
||||
{
|
||||
get
|
||||
{
|
||||
HvApi.hv_vcpu_get_sys_reg(_vcpu, HvSysReg.TPIDR_EL0, out ulong tpidrEl0).ThrowOnError();
|
||||
return (long)tpidrEl0;
|
||||
}
|
||||
set
|
||||
{
|
||||
HvApi.hv_vcpu_set_sys_reg(_vcpu, HvSysReg.TPIDR_EL0, (ulong)value).ThrowOnError();
|
||||
}
|
||||
get { lock (_registerLock) return (long)GetSysRegCached(HvSysReg.TPIDR_EL0, ref _tpidrEl0, "TPIDR_EL0"); }
|
||||
set { lock (_registerLock) SetSysRegCached(HvSysReg.TPIDR_EL0, (ulong)value, ref _tpidrEl0, "TPIDR_EL0"); }
|
||||
}
|
||||
|
||||
public long TpidrroEl0
|
||||
{
|
||||
get
|
||||
{
|
||||
HvApi.hv_vcpu_get_sys_reg(_vcpu, HvSysReg.TPIDRRO_EL0, out ulong tpidrroEl0).ThrowOnError();
|
||||
return (long)tpidrroEl0;
|
||||
}
|
||||
set
|
||||
{
|
||||
HvApi.hv_vcpu_set_sys_reg(_vcpu, HvSysReg.TPIDRRO_EL0, (ulong)value).ThrowOnError();
|
||||
}
|
||||
get { lock (_registerLock) return (long)GetSysRegCached(HvSysReg.TPIDRRO_EL0, ref _tpidrroEl0, "TPIDRRO_EL0"); }
|
||||
set { lock (_registerLock) SetSysRegCached(HvSysReg.TPIDRRO_EL0, (ulong)value, ref _tpidrroEl0, "TPIDRRO_EL0"); }
|
||||
}
|
||||
|
||||
public uint Pstate
|
||||
{
|
||||
get
|
||||
{
|
||||
HvApi.hv_vcpu_get_reg(_vcpu, HvReg.CPSR, out ulong cpsr).ThrowOnError();
|
||||
return (uint)cpsr;
|
||||
lock (_registerLock)
|
||||
{
|
||||
HvResult res = HvApi.hv_vcpu_get_reg(_vcpu, HvReg.CPSR, out ulong val);
|
||||
if (res == HvResult.BadArgument)
|
||||
{
|
||||
Interlocked.Increment(ref _fallbackCount);
|
||||
LogHvWarning("Get", "CPSR (Pstate)");
|
||||
return (uint)_pstateRaw;
|
||||
}
|
||||
res.ThrowOnError();
|
||||
_pstateRaw = val;
|
||||
return (uint)val;
|
||||
}
|
||||
}
|
||||
set
|
||||
{
|
||||
HvApi.hv_vcpu_set_reg(_vcpu, HvReg.CPSR, (ulong)value).ThrowOnError();
|
||||
lock (_registerLock)
|
||||
{
|
||||
HvResult res = HvApi.hv_vcpu_set_reg(_vcpu, HvReg.CPSR, value);
|
||||
if (res == HvResult.BadArgument)
|
||||
{
|
||||
Interlocked.Increment(ref _fallbackCount);
|
||||
LogHvWarning("Set", "CPSR (Pstate)", $"value=0x{value:X}");
|
||||
}
|
||||
else res.ThrowOnError();
|
||||
_pstateRaw = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public uint Fpcr
|
||||
{
|
||||
get
|
||||
{
|
||||
HvApi.hv_vcpu_get_reg(_vcpu, HvReg.FPCR, out ulong fpcr).ThrowOnError();
|
||||
return (uint)fpcr;
|
||||
}
|
||||
set
|
||||
{
|
||||
HvApi.hv_vcpu_set_reg(_vcpu, HvReg.FPCR, (ulong)value).ThrowOnError();
|
||||
}
|
||||
get { lock (_registerLock) return (uint)GetRegCached(HvReg.FPCR, ref _fpcr, "FPCR"); }
|
||||
set { lock (_registerLock) SetRegCached(HvReg.FPCR, value, ref _fpcr, "FPCR"); }
|
||||
}
|
||||
|
||||
public uint Fpsr
|
||||
{
|
||||
get
|
||||
{
|
||||
HvApi.hv_vcpu_get_reg(_vcpu, HvReg.FPSR, out ulong fpsr).ThrowOnError();
|
||||
return (uint)fpsr;
|
||||
}
|
||||
set
|
||||
{
|
||||
HvApi.hv_vcpu_set_reg(_vcpu, HvReg.FPSR, (ulong)value).ThrowOnError();
|
||||
}
|
||||
}
|
||||
|
||||
private readonly ulong _vcpu;
|
||||
private int _interruptRequested;
|
||||
|
||||
public HvExecutionContextVcpu(ulong vcpu)
|
||||
{
|
||||
_vcpu = vcpu;
|
||||
get { lock (_registerLock) return (uint)GetRegCached(HvReg.FPSR, ref _fpsr, "FPSR"); }
|
||||
set { lock (_registerLock) SetRegCached(HvReg.FPSR, value, ref _fpsr, "FPSR"); }
|
||||
}
|
||||
|
||||
public ulong GetX(int index)
|
||||
{
|
||||
if (index == 31)
|
||||
lock (_registerLock)
|
||||
{
|
||||
HvApi.hv_vcpu_get_sys_reg(_vcpu, HvSysReg.SP_EL0, out ulong value).ThrowOnError();
|
||||
return value;
|
||||
}
|
||||
else
|
||||
{
|
||||
HvApi.hv_vcpu_get_reg(_vcpu, HvReg.X0 + (uint)index, out ulong value).ThrowOnError();
|
||||
return value;
|
||||
ulong value;
|
||||
string regName = index == 31 ? "SP_EL0" : $"X{index}";
|
||||
|
||||
if (index == 31)
|
||||
{
|
||||
HvResult res = HvApi.hv_vcpu_get_sys_reg(_vcpu, HvSysReg.SP_EL0, out value);
|
||||
if (res == HvResult.BadArgument)
|
||||
{
|
||||
Interlocked.Increment(ref _fallbackCount);
|
||||
LogHvWarning("GetX", regName);
|
||||
return _x[31];
|
||||
}
|
||||
res.ThrowOnError();
|
||||
return _x[31] = value;
|
||||
}
|
||||
|
||||
if ((uint)index > 30) return 0;
|
||||
|
||||
if (index == 0 && _earlyBootPhase && _pc == 0)
|
||||
{
|
||||
return _x[0];
|
||||
}
|
||||
|
||||
HvResult resX = HvApi.hv_vcpu_get_reg(_vcpu, HvReg.X0 + (uint)index, out value);
|
||||
if (resX == HvResult.BadArgument)
|
||||
{
|
||||
Interlocked.Increment(ref _fallbackCount);
|
||||
LogHvWarning("GetX", regName);
|
||||
return _x[index];
|
||||
}
|
||||
resX.ThrowOnError();
|
||||
return _x[index] = value;
|
||||
}
|
||||
}
|
||||
|
||||
public void SetX(int index, ulong value)
|
||||
{
|
||||
if (index == 31)
|
||||
lock (_registerLock)
|
||||
{
|
||||
HvApi.hv_vcpu_set_sys_reg(_vcpu, HvSysReg.SP_EL0, value).ThrowOnError();
|
||||
}
|
||||
else
|
||||
{
|
||||
HvApi.hv_vcpu_set_reg(_vcpu, HvReg.X0 + (uint)index, value).ThrowOnError();
|
||||
string regName = index == 31 ? "SP_EL0" : $"X{index}";
|
||||
|
||||
if (index == 31)
|
||||
{
|
||||
HvResult res = HvApi.hv_vcpu_set_sys_reg(_vcpu, HvSysReg.SP_EL0, value);
|
||||
if (res == HvResult.BadArgument)
|
||||
{
|
||||
Interlocked.Increment(ref _fallbackCount);
|
||||
LogHvWarning("SetX", regName, $"value=0x{value:X16}");
|
||||
_x[31] = value;
|
||||
return;
|
||||
}
|
||||
res.ThrowOnError();
|
||||
_x[31] = value;
|
||||
}
|
||||
else if ((uint)index <= 30)
|
||||
{
|
||||
HvResult res = HvApi.hv_vcpu_set_reg(_vcpu, HvReg.X0 + (uint)index, value);
|
||||
if (res == HvResult.BadArgument)
|
||||
{
|
||||
Interlocked.Increment(ref _fallbackCount);
|
||||
LogHvWarning("SetX", regName, $"value=0x{value:X16}");
|
||||
_x[index] = value;
|
||||
return;
|
||||
}
|
||||
res.ThrowOnError();
|
||||
_x[index] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public V128 GetV(int index)
|
||||
{
|
||||
HvApi.hv_vcpu_get_simd_fp_reg(_vcpu, HvSimdFPReg.Q0 + (uint)index, out HvSimdFPUchar16 value).ThrowOnError();
|
||||
return new V128(value.Low, value.High);
|
||||
lock (_registerLock)
|
||||
{
|
||||
if ((uint)index > 31) return default;
|
||||
|
||||
HvResult res = HvApi.hv_vcpu_get_simd_fp_reg(_vcpu, HvSimdFPReg.Q0 + (uint)index, out HvSimdFPUchar16 val);
|
||||
if (res == HvResult.BadArgument)
|
||||
{
|
||||
Interlocked.Increment(ref _fallbackCount);
|
||||
LogHvWarning("GetV", $"Q{index}");
|
||||
return _v[index];
|
||||
}
|
||||
res.ThrowOnError();
|
||||
return _v[index] = new V128(val.Low, val.High);
|
||||
}
|
||||
}
|
||||
|
||||
public void SetV(int index, V128 value)
|
||||
{
|
||||
_setSimdFpReg(_vcpu, HvSimdFPReg.Q0 + (uint)index, value, _setSimdFpRegNativePtr).ThrowOnError();
|
||||
lock (_registerLock)
|
||||
{
|
||||
if ((uint)index > 31) return;
|
||||
|
||||
HvResult res = _setSimdFpReg(_vcpu, HvSimdFPReg.Q0 + (uint)index, value, _setSimdFpRegNativePtr);
|
||||
if (res == HvResult.BadArgument)
|
||||
{
|
||||
Interlocked.Increment(ref _fallbackCount);
|
||||
LogHvWarning("SetV", $"Q{index}");
|
||||
_v[index] = value;
|
||||
return;
|
||||
}
|
||||
res.ThrowOnError();
|
||||
_v[index] = value;
|
||||
}
|
||||
}
|
||||
|
||||
private ulong GetRegCached(HvReg reg, ref ulong cached, string name)
|
||||
{
|
||||
HvResult res = HvApi.hv_vcpu_get_reg(_vcpu, reg, out ulong val);
|
||||
if (res == HvResult.BadArgument)
|
||||
{
|
||||
Interlocked.Increment(ref _fallbackCount);
|
||||
LogHvWarning("GetReg", name);
|
||||
return cached;
|
||||
}
|
||||
res.ThrowOnError();
|
||||
return cached = val;
|
||||
}
|
||||
|
||||
private void SetRegCached(HvReg reg, ulong value, ref ulong cached, string name)
|
||||
{
|
||||
HvResult res = HvApi.hv_vcpu_set_reg(_vcpu, reg, value);
|
||||
if (res == HvResult.BadArgument)
|
||||
{
|
||||
Interlocked.Increment(ref _fallbackCount);
|
||||
LogHvWarning("SetReg", name, $"value=0x{value:X16}");
|
||||
cached = value;
|
||||
return;
|
||||
}
|
||||
res.ThrowOnError();
|
||||
cached = value;
|
||||
}
|
||||
|
||||
private ulong GetSysRegCached(HvSysReg reg, ref ulong cached, string name)
|
||||
{
|
||||
HvResult res = HvApi.hv_vcpu_get_sys_reg(_vcpu, reg, out ulong val);
|
||||
if (res == HvResult.BadArgument)
|
||||
{
|
||||
Interlocked.Increment(ref _fallbackCount);
|
||||
LogHvWarning("GetSysReg", name);
|
||||
return cached;
|
||||
}
|
||||
res.ThrowOnError();
|
||||
return cached = val;
|
||||
}
|
||||
|
||||
private void SetSysRegCached(HvSysReg reg, ulong value, ref ulong cached, string name)
|
||||
{
|
||||
HvResult res = HvApi.hv_vcpu_set_sys_reg(_vcpu, reg, value);
|
||||
if (res == HvResult.BadArgument)
|
||||
{
|
||||
Interlocked.Increment(ref _fallbackCount);
|
||||
LogHvWarning("SetSysReg", name, $"value=0x{value:X16}");
|
||||
cached = value;
|
||||
return;
|
||||
}
|
||||
res.ThrowOnError();
|
||||
cached = value;
|
||||
}
|
||||
|
||||
public long GetFallbackCount() => Interlocked.Read(ref _fallbackCount);
|
||||
|
||||
public void RequestInterrupt()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _interruptRequested, 1) == 0)
|
||||
|
||||
@@ -689,13 +689,26 @@ namespace Ryujinx.HLE.HOS.Kernel.Threading
|
||||
return context.Pstate & 0xFF0FFE20;
|
||||
}
|
||||
|
||||
private const long ContextCacheDurationTicks = 800_000; // ~0.8ms cache lifetime
|
||||
|
||||
private ThreadContext _cachedContext;
|
||||
private long _contextCacheTimestamp;
|
||||
private bool _hasValidContextCache;
|
||||
|
||||
private ThreadContext GetCurrentContext()
|
||||
{
|
||||
var now = DateTime.UtcNow.Ticks;
|
||||
|
||||
// Cache hit
|
||||
if (_hasValidContextCache && (now - _contextCacheTimestamp) < ContextCacheDurationTicks)
|
||||
{
|
||||
return _cachedContext;
|
||||
}
|
||||
|
||||
const int MaxRegistersAArch32 = 15;
|
||||
const int MaxFpuRegistersAArch32 = 16;
|
||||
|
||||
ThreadContext context = new();
|
||||
|
||||
Span<ulong> registersSpan = context.Registers.AsSpan();
|
||||
Span<V128> fpuRegistersSpan = context.FpuRegisters.AsSpan();
|
||||
|
||||
@@ -705,12 +718,10 @@ namespace Ryujinx.HLE.HOS.Kernel.Threading
|
||||
{
|
||||
registersSpan[i] = Context.GetX(i);
|
||||
}
|
||||
|
||||
for (int i = 0; i < fpuRegistersSpan.Length; i++)
|
||||
{
|
||||
fpuRegistersSpan[i] = Context.GetV(i);
|
||||
}
|
||||
|
||||
context.Fp = Context.GetX(29);
|
||||
context.Lr = Context.GetX(30);
|
||||
context.Sp = Context.GetX(31);
|
||||
@@ -724,12 +735,10 @@ namespace Ryujinx.HLE.HOS.Kernel.Threading
|
||||
{
|
||||
registersSpan[i] = (uint)Context.GetX(i);
|
||||
}
|
||||
|
||||
for (int i = 0; i < MaxFpuRegistersAArch32; i++)
|
||||
{
|
||||
fpuRegistersSpan[i] = Context.GetV(i);
|
||||
}
|
||||
|
||||
context.Pc = (uint)Context.Pc;
|
||||
context.Pstate = GetPsr(Context);
|
||||
context.Tpidr = (uint)Context.TpidrroEl0;
|
||||
@@ -738,6 +747,11 @@ namespace Ryujinx.HLE.HOS.Kernel.Threading
|
||||
context.Fpcr = (uint)Context.Fpcr;
|
||||
context.Fpsr = (uint)Context.Fpsr;
|
||||
|
||||
// Update cache
|
||||
_cachedContext = context;
|
||||
_contextCacheTimestamp = now;
|
||||
_hasValidContextCache = true;
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ namespace Ryujinx.HLE.HOS.Services.Hid
|
||||
private readonly bool[] _supportedPlayers;
|
||||
private VibrationValue _neutralVibrationValue = new()
|
||||
{
|
||||
AmplitudeLow = 0f,
|
||||
AmplitudeLow = 0.01f,
|
||||
FrequencyLow = 160f,
|
||||
AmplitudeHigh = 0f,
|
||||
FrequencyHigh = 320f,
|
||||
|
||||
@@ -13,84 +13,98 @@ namespace Ryujinx.Input.SDL3
|
||||
{
|
||||
private readonly SDL_hid_device* _hidHandle;
|
||||
|
||||
private byte[] _buffer;
|
||||
private static ushort _vendor;
|
||||
private static ushort _product;
|
||||
|
||||
private int _globalCount;
|
||||
private ulong _lastWriteTicks;
|
||||
|
||||
private NpadHdRumble(SDL_hid_device* hidHandle)
|
||||
{
|
||||
_hidHandle = hidHandle;
|
||||
InitializeDevice();
|
||||
}
|
||||
|
||||
public static NpadHdRumble Create(SDL_Gamepad* gamepadHandle)
|
||||
{
|
||||
ushort vendor = SDL_GetGamepadVendor(gamepadHandle);
|
||||
if (vendor != 0x057e)
|
||||
_vendor = SDL_GetGamepadVendor(gamepadHandle);
|
||||
if (!Enum.IsDefined(typeof(HDRumbleSupportedVendor), _vendor))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
ushort product = SDL_GetGamepadProduct(gamepadHandle);
|
||||
if (!Enum.IsDefined(typeof(HDRumbleSupported), product))
|
||||
_product = SDL_GetGamepadProduct(gamepadHandle);
|
||||
if (!Enum.IsDefined(typeof(HDRumbleSupportedProduct), _product))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new NpadHdRumble(SDL_hid_open(vendor, product, 0));
|
||||
int serialNumber = 0;
|
||||
string? serial = SDL_GetGamepadSerial(gamepadHandle);
|
||||
if (serial is not null)
|
||||
{
|
||||
int.TryParse(serial, out serialNumber);
|
||||
}
|
||||
|
||||
return new NpadHdRumble(SDL_hid_open(_vendor, _product, serialNumber));
|
||||
}
|
||||
|
||||
// Some of the code was translated from https://github.com/MIZUSHIKI/JoyShockLibrary-plus-HDRumble
|
||||
private bool WriteHdRumble(
|
||||
int encLeftLowFreq, int encLeftLowAmp,
|
||||
int encLeftHighFreq, int encLeftHighAmp,
|
||||
int encRightLowFreq, int encRightLowAmp,
|
||||
int encRightHighFreq, int encRightHighAmp)
|
||||
private bool WriteNintendoHdRumble(VibrationValue left, VibrationValue right)
|
||||
{
|
||||
byte[] buf = new byte[10];
|
||||
|
||||
buf[0] = 0x10;
|
||||
buf[1] = (byte)((++_globalCount) & 0xF);
|
||||
|
||||
buf[2] = (byte)(encLeftHighFreq & 0xFF);
|
||||
buf[3] = (byte)(encLeftHighAmp + ((encLeftHighFreq >> 8) & 0xFF));
|
||||
buf[4] = (byte)(encLeftLowFreq + ((encLeftLowAmp >> 8) & 0xFF));
|
||||
buf[5] = (byte)(encLeftLowAmp & 0xFF);
|
||||
|
||||
buf[6] = (byte)(encRightHighFreq & 0xFF);
|
||||
buf[7] = (byte)(encRightHighAmp + ((encRightHighFreq >> 8) & 0xFF));
|
||||
buf[8] = (byte)(encRightLowFreq + ((encRightLowAmp >> 8) & 0xFF));
|
||||
buf[9] = (byte)(encRightLowAmp & 0xFF);
|
||||
|
||||
int leftLowAmp = EncodeLowAmp(left.AmplitudeLow);
|
||||
int leftLowFreq = EncodeLowFreq(left.FrequencyLow) + (leftLowAmp >> 8);
|
||||
int leftHighFreq = EncodeHighFreq(left.FrequencyHigh);
|
||||
int leftHighAmp = EncodeHighAmp(left.AmplitudeHigh) + (leftHighFreq >> 8);
|
||||
|
||||
int rightLowAmp = EncodeLowAmp(right.AmplitudeLow);
|
||||
int rightLowFreq = EncodeLowFreq(right.FrequencyLow) + (rightLowAmp >> 8);
|
||||
int rightHighFreq = EncodeHighFreq(right.FrequencyHigh);
|
||||
int rightHighAmp = EncodeHighAmp(right.AmplitudeHigh) + (rightHighFreq >> 8);
|
||||
|
||||
_buffer[0] = 0x10;
|
||||
_buffer[1] = (byte)((_globalCount++) & 0xF);
|
||||
|
||||
// Left LRA
|
||||
_buffer[2] = (byte)(leftLowFreq & 0xFF);
|
||||
_buffer[3] = (byte)(leftHighAmp & 0xFF);
|
||||
_buffer[4] = (byte)(leftHighFreq & 0xFF);
|
||||
_buffer[5] = (byte)(leftLowAmp & 0xFF);
|
||||
|
||||
// Right LRA
|
||||
_buffer[6] = (byte)(rightLowFreq & 0xFF);
|
||||
_buffer[7] = (byte)(rightHighAmp & 0xFF);
|
||||
_buffer[8] = (byte)(rightHighFreq & 0xFF);
|
||||
_buffer[9] = (byte)(rightLowAmp & 0xFF);
|
||||
|
||||
if (_globalCount > 0xF)
|
||||
{
|
||||
_globalCount = 0x0;
|
||||
}
|
||||
|
||||
fixed (byte* ptr = buf)
|
||||
|
||||
fixed (byte* ptr = _buffer)
|
||||
{
|
||||
if (SendHDRumble(ptr, (nuint)buf.Length) >= 0)
|
||||
if (SendHdRumble(ptr, (nuint)_buffer.Length) >= 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!String.IsNullOrEmpty(SDL_GetError()))
|
||||
{
|
||||
Logger.Error?.PrintMsg(LogClass.Hid, SDL_GetError());
|
||||
SDL_ClearError();
|
||||
}
|
||||
return false;
|
||||
Logger.Error?.PrintMsg(LogClass.Hid, SDL_GetError());
|
||||
SDL_ClearError();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static int EncodeLowFreq(float lowFreq)
|
||||
{
|
||||
float lf = Math.Clamp(lowFreq, 40.875885f, 626.286133f);
|
||||
return (int) Math.Round(32 * Math.Log2(lf * 0.1f) - 0x40);
|
||||
return (int)Math.Clamp(32 * Math.Log2(lowFreq * 0.1f) - 0x40, 81.75177f, 1252.572266f);
|
||||
}
|
||||
|
||||
private static int EncodeHighFreq(float highFreq)
|
||||
{
|
||||
float hf = Math.Clamp(highFreq, 81.75177f, 1252.572266f);
|
||||
return (int) Math.Round((32 * Math.Log2(hf * 0.1f) - 0x60) * 4);
|
||||
return (int)Math.Clamp(32 * Math.Log2(highFreq * 0.1f) - 0x60, 81.75177f, 1252.572266f);
|
||||
}
|
||||
|
||||
private static int EncodeLowAmp(float rawAmp)
|
||||
@@ -98,23 +112,20 @@ namespace Ryujinx.Input.SDL3
|
||||
double encodedAmp = 0;
|
||||
|
||||
if (rawAmp is > 0 and < 0.012f)
|
||||
{
|
||||
encodedAmp = 1;
|
||||
}
|
||||
|
||||
else if (rawAmp is >= 0.012f and < 0.112f)
|
||||
{
|
||||
encodedAmp = 4 * Math.Log2(rawAmp * 110f);
|
||||
}
|
||||
|
||||
else if (rawAmp is >= 0.112f and < 0.225f)
|
||||
{
|
||||
encodedAmp = 16 * Math.Log2(rawAmp * 17f);
|
||||
}
|
||||
|
||||
else if (rawAmp is >= 0.225f and <= 1f)
|
||||
{
|
||||
encodedAmp = 32 * Math.Log2(rawAmp * 8.7f);
|
||||
}
|
||||
|
||||
return (int)Math.Floor(encodedAmp / 2.0) + 64;
|
||||
|
||||
encodedAmp = Math.Round((encodedAmp / 2.0) + 64.0);
|
||||
encodedAmp = Math.Clamp(encodedAmp, 0.0, 100.2867);
|
||||
return (int)Math.Round(encodedAmp);
|
||||
}
|
||||
|
||||
private static int EncodeHighAmp(float rawAmp)
|
||||
@@ -122,82 +133,156 @@ namespace Ryujinx.Input.SDL3
|
||||
double encodedAmp = 0;
|
||||
|
||||
if (rawAmp is > 0 and < 0.012f)
|
||||
{
|
||||
encodedAmp = 1;
|
||||
}
|
||||
|
||||
else if (rawAmp is >= 0.012f and < 0.112f)
|
||||
{
|
||||
encodedAmp = 4 * Math.Log2(rawAmp * 110f);
|
||||
}
|
||||
|
||||
else if (rawAmp is >= 0.112f and < 0.225f)
|
||||
{
|
||||
encodedAmp = 16 * Math.Log2(rawAmp * 17f);
|
||||
}
|
||||
|
||||
else if (rawAmp is >= 0.225f and <= 1f)
|
||||
{
|
||||
encodedAmp = 32 * Math.Log2(rawAmp * 8.7f);
|
||||
}
|
||||
|
||||
return (int) Math.Round(encodedAmp * 2);
|
||||
|
||||
encodedAmp = Math.Round(encodedAmp / 2.0);
|
||||
encodedAmp = Math.Clamp(encodedAmp, 0.0, 100.2867);
|
||||
return (int)encodedAmp;
|
||||
}
|
||||
|
||||
public bool HdRumble(VibrationValue left, VibrationValue right)
|
||||
{
|
||||
return WriteHdRumble(EncodeLowFreq(left.FrequencyLow),
|
||||
EncodeLowAmp(left.AmplitudeLow),
|
||||
EncodeHighFreq(left.FrequencyHigh),
|
||||
EncodeHighAmp(left.AmplitudeHigh),
|
||||
EncodeLowFreq(right.FrequencyLow),
|
||||
EncodeLowAmp(right.AmplitudeLow),
|
||||
EncodeHighFreq(right.FrequencyHigh),
|
||||
EncodeHighAmp(right.AmplitudeHigh));
|
||||
if(_product is (ushort) HDRumbleSupportedProduct.ProController
|
||||
or (ushort) HDRumbleSupportedProduct.JoyconLeft
|
||||
or (ushort) HDRumbleSupportedProduct.JoyconRight
|
||||
or (ushort) HDRumbleSupportedProduct.JoyconPair
|
||||
or (ushort) HDRumbleSupportedProduct.JoyconGrip)
|
||||
{
|
||||
return WriteNintendoHdRumble(left, right);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private int SendHDRumble(byte* data, nuint length)
|
||||
private int SendHdRumble(byte* data, nuint length)
|
||||
{
|
||||
int result = 0;
|
||||
ulong currentTicks = SDL_GetTicks();
|
||||
|
||||
// Ditch rumble if we haven't hit the poll-rate yet.
|
||||
// TODO: figure out a better way to do this
|
||||
// While the polling check makes the rumble accurate, it also causes it to miss signals.
|
||||
if ((currentTicks - _lastWriteTicks) < 8) // https://docs.handheldlegend.com/s/progcc-3/doc/lag-comparison-aAR1mV3JLX
|
||||
if ((currentTicks - _lastWriteTicks) <= GetPollRate())
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
SDL_LockJoysticks();
|
||||
result = SDL_hid_write(_hidHandle, data, length);
|
||||
if (result >= 0)
|
||||
{
|
||||
// Fun fact: Mario Kart 8 Deluxe sends rumble packets
|
||||
// where the amplitude is zero, but the frequency isn't.
|
||||
result = SDL_hid_write(_hidHandle, data, length);
|
||||
if (result >= 0)
|
||||
{
|
||||
_lastWriteTicks = currentTicks;
|
||||
}
|
||||
_lastWriteTicks = currentTicks;
|
||||
}
|
||||
SDL_UnlockJoysticks();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private void InitializeDevice()
|
||||
{
|
||||
if (_vendor is (ushort)HDRumbleSupportedVendor.Nintendo)
|
||||
{
|
||||
_buffer = new byte[10];
|
||||
byte[] init = new byte[64];
|
||||
|
||||
// Pro Controller and Charge Grip
|
||||
if (_product
|
||||
is (ushort)HDRumbleSupportedProduct.ProController
|
||||
or (ushort)HDRumbleSupportedProduct.JoyconGrip)
|
||||
{
|
||||
SDL_LockJoysticks();
|
||||
fixed (byte* ptr = init)
|
||||
{
|
||||
init[0] = 0x80;
|
||||
init[1] = 0x05; // Allow bluetooth timeout TODO: use 0x04 to force USB only (toggle?)
|
||||
SDL_hid_write(_hidHandle, ptr, 64);
|
||||
}
|
||||
SDL_UnlockJoysticks();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Joycons
|
||||
if (_product
|
||||
is (ushort)HDRumbleSupportedProduct.JoyconLeft
|
||||
or (ushort)HDRumbleSupportedProduct.JoyconRight
|
||||
or (ushort)HDRumbleSupportedProduct.JoyconPair)
|
||||
{
|
||||
|
||||
SDL_LockJoysticks();
|
||||
fixed (byte* ptr = init)
|
||||
{
|
||||
// we could write data to the controller here (see above)
|
||||
}
|
||||
SDL_UnlockJoysticks();
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private ulong GetPollRate()
|
||||
{
|
||||
ulong pollRate = 0;
|
||||
if (_vendor is (ushort)HDRumbleSupportedVendor.Nintendo)
|
||||
{
|
||||
// https://docs.handheldlegend.com/s/progcc-3/doc/lag-comparison-aAR1mV3JLX
|
||||
pollRate = (ulong) 16.67;
|
||||
if (_product is (ushort)HDRumbleSupportedProduct.ProController
|
||||
&& SDL_hid_get_device_info(_hidHandle)->bus_type == SDL_hid_bus_type.SDL_HID_API_BUS_USB)
|
||||
{
|
||||
pollRate = (ulong) 8.33;
|
||||
}
|
||||
}
|
||||
|
||||
return pollRate;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
GC.SuppressFinalize(this);
|
||||
SDL_hid_close(_hidHandle);
|
||||
}
|
||||
}
|
||||
|
||||
public enum HDRumbleSupported : ushort
|
||||
public enum HDRumbleSupportedVendor : ushort
|
||||
{
|
||||
JoyConLeft = 0x2006,
|
||||
JoyConRight = 0x2007,
|
||||
Nintendo = 0x057e,
|
||||
Valve = 0x28de,
|
||||
Sony = 0x054c
|
||||
}
|
||||
|
||||
public enum HDRumbleSupportedProduct : ushort
|
||||
{
|
||||
// TODO: Currently, HD Rumble only supports the Pro Controller and JoyCons.
|
||||
// We need to initialize and report to each device differently.
|
||||
|
||||
// Nintendo Switch: 0x057e
|
||||
JoyconLeft = 0x2006,
|
||||
JoyconRight = 0x2007,
|
||||
JoyconPair = 0x2008,
|
||||
ProController = 0x2009,
|
||||
JoyconGrip = 0x200e,
|
||||
|
||||
// Nintendo Switch 2: 0x057e
|
||||
Joycon2Right = 0x2066,
|
||||
Joycon2Left = 0x2067,
|
||||
Joycon2Pair = 0x2068,
|
||||
Switch2ProController = 0x2069,
|
||||
GamecubeController = 0x2073
|
||||
GamecubeController = 0x2073,
|
||||
|
||||
// Valve Steam Family: 0x28de
|
||||
// https://github.com/libsdl-org/SDL/issues/9148
|
||||
SteamDeck = 0x11ff,
|
||||
SteamDeckVirtualDevice = 0x1205,
|
||||
SteamController = 0x1106,
|
||||
|
||||
// PlayStation Dualsense: 0x054c
|
||||
Dualsense = 0x0ce6
|
||||
}
|
||||
}
|
||||
|
||||
@@ -275,7 +275,7 @@ namespace Ryujinx.Input.SDL3
|
||||
{
|
||||
_configuration = (StandardControllerInputConfig)configuration;
|
||||
|
||||
if ((Features & GamepadFeaturesFlag.Led) != 0 && _configuration.Led.EnableLed)
|
||||
if ((Features & GamepadFeaturesFlag.Led) != 0 && _configuration.Led?.EnableLed == true)
|
||||
{
|
||||
if (_configuration.Led.TurnOffLed)
|
||||
(this as IGamepad).ClearLed();
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
using Ryujinx.Common;
|
||||
using Ryujinx.Common.Configuration;
|
||||
using Ryujinx.Common.Configuration.Hid;
|
||||
using Ryujinx.Common.Configuration.Hid.Controller;
|
||||
using Ryujinx.Common.Configuration.Hid.Controller.Motion;
|
||||
using Ryujinx.Common.Configuration.Hid.Keyboard;
|
||||
using Ryujinx.Common.Logging;
|
||||
using Ryujinx.Common.Utilities;
|
||||
using Ryujinx.HLE.HOS.Services.Hid;
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Numerics;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using CemuHookClient = Ryujinx.Input.Motion.CemuHook.Client;
|
||||
using ConfigControllerType = Ryujinx.Common.Configuration.Hid.ControllerType;
|
||||
|
||||
@@ -16,6 +22,9 @@ namespace Ryujinx.Input.HLE
|
||||
{
|
||||
public class NpadController : IDisposable
|
||||
{
|
||||
private const string KeyboardString = "keyboard";
|
||||
private const string ControllerString = "controller";
|
||||
|
||||
private class HLEButtonMappingEntry
|
||||
{
|
||||
public readonly GamepadButtonInputId DriverInputId;
|
||||
@@ -211,14 +220,40 @@ namespace Ryujinx.Input.HLE
|
||||
private MotionInput _rightMotionInput;
|
||||
|
||||
private IGamepad _gamepad;
|
||||
private IGamepad _keyboardGamepad;
|
||||
private IGamepad _controllerGamepad;
|
||||
private readonly List<IGamepad> _assignedControllerGamepads = [];
|
||||
private readonly List<StandardControllerInputConfig> _assignedControllerConfigs = [];
|
||||
private InputConfig _config;
|
||||
private InputConfig _activeConfig;
|
||||
private StandardKeyboardInputConfig _keyboardConfig;
|
||||
private StandardControllerInputConfig _controllerConfig;
|
||||
private GamepadStateSnapshot _previousKeyboardState;
|
||||
private readonly List<GamepadStateSnapshot> _previousControllerStates = [];
|
||||
private DynamicInputSource _activeInputSource;
|
||||
private PlayerInputAssignment _playerInputAssignment;
|
||||
private bool _singleUsesKeyboardDriver;
|
||||
private IGamepadDriver _keyboardDriver;
|
||||
private IGamepadDriver _controllerDriver;
|
||||
private int _activeControllerIndex = -1;
|
||||
|
||||
public IGamepadDriver GamepadDriver { get; private set; }
|
||||
public GamepadStateSnapshot State { get; private set; }
|
||||
public InputConfig ActiveConfig => _activeConfig;
|
||||
|
||||
public string Id { get; private set; }
|
||||
|
||||
public bool IsAvailable => _gamepad != null || _keyboardGamepad != null || _assignedControllerGamepads.Count > 0;
|
||||
|
||||
private readonly CemuHookClient _cemuHookClient;
|
||||
private static readonly InputConfigJsonSerializerContext _serializerContext = new(JsonHelper.GetDefaultSerializerOptions());
|
||||
|
||||
private enum DynamicInputSource
|
||||
{
|
||||
None,
|
||||
Keyboard,
|
||||
Controller,
|
||||
}
|
||||
|
||||
public NpadController(CemuHookClient cemuHookClient)
|
||||
{
|
||||
@@ -227,31 +262,114 @@ namespace Ryujinx.Input.HLE
|
||||
_cemuHookClient = cemuHookClient;
|
||||
}
|
||||
|
||||
public bool UpdateDriverConfiguration(IGamepadDriver gamepadDriver, InputConfig config)
|
||||
public bool MatchesDriverConfiguration(InputConfig config, PlayerInputAssignment playerInputAssignment)
|
||||
{
|
||||
GamepadDriver = gamepadDriver;
|
||||
if (_config?.EnableDynamicGamepadSwap != config.EnableDynamicGamepadSwap)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_gamepad?.Dispose();
|
||||
if (playerInputAssignment?.EnableDynamicInputSwap == true)
|
||||
{
|
||||
if (_playerInputAssignment == null || _playerInputAssignment.EnableDynamicInputSwap != playerInputAssignment.EnableDynamicInputSwap)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
Id = config.Id;
|
||||
_gamepad = config is StandardKeyboardInputConfig && GamepadDriver is IKeyboardModeDriver keyboardModeDriver
|
||||
? keyboardModeDriver.GetKeyboard(Id, KeyboardInputMode.Physical)
|
||||
: GamepadDriver.GetGamepad(Id);
|
||||
return PlayerInputAssignmentHelper.AreEquivalent(_playerInputAssignment, playerInputAssignment);
|
||||
}
|
||||
|
||||
return _singleUsesKeyboardDriver == (config is StandardKeyboardInputConfig) &&
|
||||
Id == config.Id;
|
||||
}
|
||||
|
||||
public bool UpdateDriverConfiguration(IGamepadDriver keyboardDriver, IGamepadDriver gamepadDriver, InputConfig config, PlayerInputAssignment playerInputAssignment)
|
||||
{
|
||||
_keyboardDriver = keyboardDriver;
|
||||
_controllerDriver = gamepadDriver;
|
||||
_playerInputAssignment = playerInputAssignment;
|
||||
|
||||
DisposeOpenedGamepads();
|
||||
|
||||
_gamepad = null;
|
||||
_keyboardGamepad = null;
|
||||
_controllerGamepad = null;
|
||||
_assignedControllerGamepads.Clear();
|
||||
_assignedControllerConfigs.Clear();
|
||||
_previousKeyboardState = default;
|
||||
_previousControllerStates.Clear();
|
||||
_activeInputSource = DynamicInputSource.None;
|
||||
_activeControllerIndex = -1;
|
||||
|
||||
if (playerInputAssignment?.EnableDynamicInputSwap == true)
|
||||
{
|
||||
ConfigureDynamicGamepads(keyboardDriver, gamepadDriver, config);
|
||||
}
|
||||
else
|
||||
{
|
||||
_singleUsesKeyboardDriver = config is StandardKeyboardInputConfig;
|
||||
GamepadDriver = _singleUsesKeyboardDriver ? keyboardDriver : gamepadDriver;
|
||||
Id = config.Id;
|
||||
_gamepad = OpenSingleGamepad(GamepadDriver, config.Id, _singleUsesKeyboardDriver);
|
||||
}
|
||||
|
||||
UpdateUserConfiguration(config);
|
||||
|
||||
return _gamepad != null;
|
||||
return IsAvailable;
|
||||
}
|
||||
|
||||
public void UpdateUserConfiguration(InputConfig config)
|
||||
{
|
||||
InputConfig oldConfig = _config;
|
||||
|
||||
if (_playerInputAssignment?.EnableDynamicInputSwap == true)
|
||||
{
|
||||
StandardControllerInputConfig oldControllerConfig = _controllerConfig;
|
||||
|
||||
_config = config;
|
||||
UpdateDynamicConfigurations(config);
|
||||
|
||||
if (_controllerConfig?.Motion == null)
|
||||
{
|
||||
_leftMotionInput = null;
|
||||
_rightMotionInput = null;
|
||||
}
|
||||
else if (NeedsMotionInputUpdate(oldControllerConfig, _controllerConfig))
|
||||
{
|
||||
UpdateMotionInput(_controllerConfig.Motion);
|
||||
}
|
||||
|
||||
if (_keyboardConfig != null)
|
||||
{
|
||||
_keyboardGamepad?.SetConfiguration(_keyboardConfig);
|
||||
}
|
||||
|
||||
for (int i = 0; i < _assignedControllerGamepads.Count; i++)
|
||||
{
|
||||
StandardControllerInputConfig assignedControllerConfig = i < _assignedControllerConfigs.Count
|
||||
? _assignedControllerConfigs[i]
|
||||
: _controllerConfig;
|
||||
|
||||
if (assignedControllerConfig != null)
|
||||
{
|
||||
_assignedControllerGamepads[i].SetConfiguration(assignedControllerConfig);
|
||||
}
|
||||
}
|
||||
|
||||
UpdateActiveGamepad();
|
||||
return;
|
||||
}
|
||||
|
||||
_config = config;
|
||||
|
||||
if (config is StandardControllerInputConfig controllerConfig)
|
||||
{
|
||||
bool needsMotionInputUpdate = _config is not StandardControllerInputConfig oldControllerConfig ||
|
||||
((oldControllerConfig.Motion.EnableMotion != controllerConfig.Motion.EnableMotion) &&
|
||||
(oldControllerConfig.Motion.MotionBackend != controllerConfig.Motion.MotionBackend));
|
||||
|
||||
if (needsMotionInputUpdate)
|
||||
if (controllerConfig.Motion == null)
|
||||
{
|
||||
_leftMotionInput = null;
|
||||
_rightMotionInput = null;
|
||||
}
|
||||
else if (NeedsMotionInputUpdate(oldConfig as StandardControllerInputConfig, controllerConfig))
|
||||
{
|
||||
UpdateMotionInput(controllerConfig.Motion);
|
||||
}
|
||||
@@ -260,15 +378,23 @@ namespace Ryujinx.Input.HLE
|
||||
{
|
||||
// Non-controller doesn't have motions.
|
||||
_leftMotionInput = null;
|
||||
_rightMotionInput = null;
|
||||
}
|
||||
|
||||
_config = config;
|
||||
_activeConfig = config;
|
||||
|
||||
_gamepad?.SetConfiguration(config);
|
||||
}
|
||||
|
||||
private void UpdateMotionInput(MotionConfigController motionConfig)
|
||||
{
|
||||
if (motionConfig == null)
|
||||
{
|
||||
_leftMotionInput = null;
|
||||
_rightMotionInput = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (motionConfig.MotionBackend != MotionInputBackendType.CemuHook)
|
||||
{
|
||||
_leftMotionInput = new MotionInput();
|
||||
@@ -281,72 +407,50 @@ namespace Ryujinx.Input.HLE
|
||||
}
|
||||
}
|
||||
|
||||
private bool NeedsMotionInputUpdate(StandardControllerInputConfig oldConfig, StandardControllerInputConfig newConfig)
|
||||
{
|
||||
if (newConfig?.Motion == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
bool motionWasDisabled = oldConfig?.Motion == null;
|
||||
bool leftMotionMissing = _leftMotionInput == null;
|
||||
bool isJoyconPairNeedingRightMotion = newConfig.ControllerType == ConfigControllerType.JoyconPair && _rightMotionInput == null;
|
||||
bool motionEnabledChanged = oldConfig.Motion.EnableMotion != newConfig.Motion.EnableMotion;
|
||||
bool motionBackendChanged = oldConfig.Motion.MotionBackend != newConfig.Motion.MotionBackend;
|
||||
|
||||
return motionWasDisabled ||
|
||||
leftMotionMissing ||
|
||||
isJoyconPairNeedingRightMotion ||
|
||||
motionEnabledChanged ||
|
||||
motionBackendChanged;
|
||||
}
|
||||
|
||||
public void Update()
|
||||
{
|
||||
if (_playerInputAssignment?.EnableDynamicInputSwap == true)
|
||||
{
|
||||
UpdateDynamic();
|
||||
return;
|
||||
}
|
||||
|
||||
// _gamepad may be altered by other threads
|
||||
IGamepad gamepad = _gamepad;
|
||||
|
||||
if (gamepad != null && GamepadDriver != null)
|
||||
{
|
||||
State = gamepad.GetMappedStateSnapshot();
|
||||
_activeConfig = _config;
|
||||
|
||||
if (_config is StandardControllerInputConfig controllerConfig && controllerConfig.Motion.EnableMotion)
|
||||
if (_activeConfig is StandardControllerInputConfig controllerConfig && controllerConfig.Motion?.EnableMotion == true)
|
||||
{
|
||||
if (controllerConfig.Motion.MotionBackend == MotionInputBackendType.GamepadDriver)
|
||||
{
|
||||
if ((gamepad.Features & GamepadFeaturesFlag.Motion) != 0)
|
||||
{
|
||||
Vector3 accelerometer = gamepad.GetMotionData(MotionInputId.Accelerometer);
|
||||
Vector3 gyroscope = gamepad.GetMotionData(MotionInputId.Gyroscope);
|
||||
|
||||
accelerometer = new Vector3(accelerometer.X, -accelerometer.Z, accelerometer.Y);
|
||||
gyroscope = new Vector3(gyroscope.X, -gyroscope.Z, gyroscope.Y);
|
||||
|
||||
_leftMotionInput.Update(accelerometer, gyroscope, (ulong)PerformanceCounter.ElapsedNanoseconds / 1000, controllerConfig.Motion.Sensitivity, (float)controllerConfig.Motion.GyroDeadzone);
|
||||
|
||||
if (controllerConfig.ControllerType == ConfigControllerType.JoyconPair)
|
||||
{
|
||||
if (gamepad.Id == "JoyConPair")
|
||||
{
|
||||
Vector3 rightAccelerometer = gamepad.GetMotionData(MotionInputId.SecondAccelerometer);
|
||||
Vector3 rightGyroscope = gamepad.GetMotionData(MotionInputId.SecondGyroscope);
|
||||
|
||||
rightAccelerometer = new Vector3(rightAccelerometer.X, -rightAccelerometer.Z, rightAccelerometer.Y);
|
||||
rightGyroscope = new Vector3(rightGyroscope.X, -rightGyroscope.Z, rightGyroscope.Y);
|
||||
|
||||
_rightMotionInput.Update(rightAccelerometer, rightGyroscope, (ulong)PerformanceCounter.ElapsedNanoseconds / 1000, controllerConfig.Motion.Sensitivity, (float)controllerConfig.Motion.GyroDeadzone);
|
||||
}
|
||||
else
|
||||
{
|
||||
_rightMotionInput = _leftMotionInput;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (controllerConfig.Motion.MotionBackend == MotionInputBackendType.CemuHook && controllerConfig.Motion is CemuHookMotionConfigController cemuControllerConfig)
|
||||
{
|
||||
int clientId = (int)controllerConfig.PlayerIndex;
|
||||
|
||||
// First of all ensure we are registered
|
||||
_cemuHookClient.RegisterClient(clientId, cemuControllerConfig.DsuServerHost, cemuControllerConfig.DsuServerPort);
|
||||
|
||||
// Then request and retrieve the data
|
||||
_cemuHookClient.RequestData(clientId, cemuControllerConfig.Slot);
|
||||
_cemuHookClient.TryGetData(clientId, cemuControllerConfig.Slot, out _leftMotionInput);
|
||||
|
||||
if (controllerConfig.ControllerType == ConfigControllerType.JoyconPair)
|
||||
{
|
||||
if (!cemuControllerConfig.MirrorInput)
|
||||
{
|
||||
_cemuHookClient.RequestData(clientId, cemuControllerConfig.AltSlot);
|
||||
_cemuHookClient.TryGetData(clientId, cemuControllerConfig.AltSlot, out _rightMotionInput);
|
||||
}
|
||||
else
|
||||
{
|
||||
_rightMotionInput = _leftMotionInput;
|
||||
}
|
||||
}
|
||||
}
|
||||
UpdateControllerMotion(gamepad, controllerConfig);
|
||||
}
|
||||
else
|
||||
{
|
||||
_leftMotionInput = null;
|
||||
_rightMotionInput = null;
|
||||
}
|
||||
}
|
||||
else
|
||||
@@ -371,7 +475,7 @@ namespace Ryujinx.Input.HLE
|
||||
}
|
||||
}
|
||||
|
||||
if (_gamepad is IKeyboard)
|
||||
if (_activeConfig is StandardKeyboardInputConfig)
|
||||
{
|
||||
(float leftAxisX, float leftAxisY) = State.GetStick(StickInputId.Left);
|
||||
(float rightAxisX, float rightAxisY) = State.GetStick(StickInputId.Right);
|
||||
@@ -388,7 +492,7 @@ namespace Ryujinx.Input.HLE
|
||||
Dy = ClampAxis(rightAxisY),
|
||||
};
|
||||
}
|
||||
else if (_config is StandardControllerInputConfig controllerConfig)
|
||||
else if (_activeConfig is StandardControllerInputConfig controllerConfig)
|
||||
{
|
||||
(float leftAxisX, float leftAxisY) = State.GetStick(StickInputId.Left);
|
||||
(float rightAxisX, float rightAxisY) = State.GetStick(StickInputId.Right);
|
||||
@@ -509,9 +613,12 @@ namespace Ryujinx.Input.HLE
|
||||
return value;
|
||||
}
|
||||
|
||||
public static KeyboardInput GetHLEKeyboardInput(IGamepadDriver KeyboardDriver)
|
||||
public static KeyboardInput GetHLEKeyboardInput(IGamepadDriver keyboardDriver)
|
||||
{
|
||||
IKeyboard keyboard = KeyboardDriver.GetGamepad("0") as IKeyboard;
|
||||
if (keyboardDriver.GetGamepad("0") is not IKeyboard keyboard)
|
||||
{
|
||||
return default;
|
||||
}
|
||||
|
||||
KeyboardStateSnapshot keyboardState = keyboard.GetKeyboardStateSnapshot();
|
||||
|
||||
@@ -543,7 +650,7 @@ namespace Ryujinx.Input.HLE
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
_gamepad?.Dispose();
|
||||
DisposeOpenedGamepads();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -557,38 +664,565 @@ namespace Ryujinx.Input.HLE
|
||||
{
|
||||
if (queue.TryDequeue(out (VibrationValue, VibrationValue) dualVibrationValue))
|
||||
{
|
||||
if (_config is not StandardControllerInputConfig controllerConfig ||
|
||||
!controllerConfig.Rumble.EnableRumble)
|
||||
if (_controllerConfig is StandardControllerInputConfig dynamicControllerConfig &&
|
||||
_playerInputAssignment?.EnableDynamicInputSwap == true &&
|
||||
dynamicControllerConfig.Rumble?.EnableRumble == true)
|
||||
{
|
||||
return;
|
||||
ApplyRumble(_controllerGamepad ?? _assignedControllerGamepads.FirstOrDefault(), dynamicControllerConfig, dualVibrationValue);
|
||||
}
|
||||
else if (_config is StandardControllerInputConfig controllerConfig && controllerConfig.Rumble?.EnableRumble == true)
|
||||
{
|
||||
ApplyRumble(_gamepad, controllerConfig, dualVibrationValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasAssignedControllerId(string id)
|
||||
{
|
||||
if (string.IsNullOrEmpty(id))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_playerInputAssignment?.EnableDynamicInputSwap == true)
|
||||
{
|
||||
return _assignedControllerGamepads.Any(gamepad => gamepad?.Id == id);
|
||||
}
|
||||
|
||||
return Id == id;
|
||||
}
|
||||
|
||||
private void ApplyRumble(IGamepad gamepad, StandardControllerInputConfig controllerConfig, (VibrationValue, VibrationValue) dualVibrationValue)
|
||||
{
|
||||
if (gamepad == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
VibrationValue leftVibrationValue = dualVibrationValue.Item1;
|
||||
VibrationValue rightVibrationValue = dualVibrationValue.Item2;
|
||||
|
||||
leftVibrationValue.AmplitudeLow *= controllerConfig.Rumble.WeakRumble;
|
||||
leftVibrationValue.AmplitudeHigh *= controllerConfig.Rumble.StrongRumble;
|
||||
rightVibrationValue.AmplitudeLow *= controllerConfig.Rumble.WeakRumble;
|
||||
rightVibrationValue.AmplitudeHigh *= controllerConfig.Rumble.StrongRumble;
|
||||
|
||||
if (!controllerConfig.Rumble.UseHDRumble || gamepad.HDRumble(leftVibrationValue, rightVibrationValue) == false)
|
||||
{
|
||||
float low = Math.Min(1f, (float)(rightVibrationValue.AmplitudeLow * 0.85 + rightVibrationValue.AmplitudeHigh * 0.15));
|
||||
float high = Math.Min(1f, (float)(leftVibrationValue.AmplitudeLow * 0.15 + leftVibrationValue.AmplitudeHigh * 0.85));
|
||||
gamepad.Rumble(low, high, 0xFFFFFFFF);
|
||||
}
|
||||
|
||||
Logger.Debug?.Print(LogClass.Hid, $"Effect for {controllerConfig.PlayerIndex} " +
|
||||
// Value=value/multiplier * multiplier (result)
|
||||
$"L.low.amp={leftVibrationValue.AmplitudeLow / controllerConfig.Rumble.WeakRumble} * {controllerConfig.Rumble.WeakRumble} ({leftVibrationValue.AmplitudeLow}), " +
|
||||
$"L.high.amp={leftVibrationValue.AmplitudeHigh / controllerConfig.Rumble.WeakRumble} * {controllerConfig.Rumble.WeakRumble} ({leftVibrationValue.AmplitudeHigh}), " +
|
||||
$"L.low.freq={leftVibrationValue.FrequencyLow / controllerConfig.Rumble.WeakRumble} * {controllerConfig.Rumble.WeakRumble} ({leftVibrationValue.FrequencyLow}), " +
|
||||
$"L.high.freq={leftVibrationValue.FrequencyHigh / controllerConfig.Rumble.WeakRumble} * {controllerConfig.Rumble.WeakRumble} ({leftVibrationValue.FrequencyHigh}), " +
|
||||
$"R.low.amp={rightVibrationValue.AmplitudeLow / controllerConfig.Rumble.StrongRumble} * {controllerConfig.Rumble.StrongRumble} ({rightVibrationValue.AmplitudeLow}), " +
|
||||
$"R.high.amp={rightVibrationValue.AmplitudeHigh / controllerConfig.Rumble.StrongRumble} * {controllerConfig.Rumble.StrongRumble} ({rightVibrationValue.AmplitudeHigh}), " +
|
||||
$"R.low.freq={rightVibrationValue.FrequencyLow / controllerConfig.Rumble.StrongRumble} * {controllerConfig.Rumble.StrongRumble} ({rightVibrationValue.FrequencyLow}), " +
|
||||
$"R.high.freq={rightVibrationValue.FrequencyHigh / controllerConfig.Rumble.StrongRumble} * {controllerConfig.Rumble.StrongRumble} ({rightVibrationValue.FrequencyHigh})");
|
||||
}
|
||||
|
||||
private void ConfigureDynamicGamepads(IGamepadDriver keyboardDriver, IGamepadDriver gamepadDriver, InputConfig config)
|
||||
{
|
||||
AssignedInputDevice assignedKeyboard = _playerInputAssignment?.Devices.FirstOrDefault(device => device.Type == AssignedInputDeviceType.Keyboard);
|
||||
|
||||
if (!string.IsNullOrEmpty(assignedKeyboard?.Id))
|
||||
{
|
||||
_keyboardGamepad = OpenSingleGamepad(keyboardDriver, assignedKeyboard.Id, true);
|
||||
}
|
||||
|
||||
foreach (AssignedInputDevice assignedController in ResolveDynamicControllerAssignments(gamepadDriver, config))
|
||||
{
|
||||
IGamepad controllerGamepad = OpenSingleGamepad(gamepadDriver, assignedController.Id, false);
|
||||
|
||||
if (controllerGamepad != null)
|
||||
{
|
||||
_assignedControllerGamepads.Add(controllerGamepad);
|
||||
_assignedControllerConfigs.Add(null);
|
||||
_previousControllerStates.Add(default);
|
||||
}
|
||||
}
|
||||
|
||||
_controllerGamepad = _assignedControllerGamepads.FirstOrDefault();
|
||||
GamepadDriver = null;
|
||||
Id = _assignedControllerGamepads.FirstOrDefault()?.Id ?? config.Id;
|
||||
}
|
||||
|
||||
private IEnumerable<AssignedInputDevice> ResolveDynamicControllerAssignments(IGamepadDriver gamepadDriver, InputConfig config)
|
||||
{
|
||||
if (gamepadDriver == null)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
List<AssignedInputDevice> assignedControllers = _playerInputAssignment?.Devices
|
||||
.Where(device => device.Type == AssignedInputDeviceType.Controller)
|
||||
.ToList() ?? [];
|
||||
|
||||
if (_playerInputAssignment?.EnableDynamicInputSwap == true)
|
||||
{
|
||||
foreach (AssignedInputDevice assignedController in assignedControllers)
|
||||
{
|
||||
foreach (string gamepadId in gamepadDriver.GamepadsIds)
|
||||
{
|
||||
if (gamepadId == assignedController.Id)
|
||||
{
|
||||
yield return assignedController;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
VibrationValue leftVibrationValue = dualVibrationValue.Item1;
|
||||
VibrationValue rightVibrationValue = dualVibrationValue.Item2;
|
||||
yield break;
|
||||
}
|
||||
|
||||
leftVibrationValue.AmplitudeLow *= controllerConfig.Rumble.WeakRumble;
|
||||
leftVibrationValue.AmplitudeHigh *= controllerConfig.Rumble.StrongRumble;
|
||||
rightVibrationValue.AmplitudeLow *= controllerConfig.Rumble.WeakRumble;
|
||||
rightVibrationValue.AmplitudeHigh *= controllerConfig.Rumble.StrongRumble;
|
||||
|
||||
if (!controllerConfig.Rumble.UseHDRumble || _gamepad?.HDRumble(leftVibrationValue, rightVibrationValue) == false)
|
||||
if (config is StandardControllerInputConfig)
|
||||
{
|
||||
foreach (string gamepadId in gamepadDriver.GamepadsIds)
|
||||
{
|
||||
float low = Math.Min(1f, (float)((rightVibrationValue.AmplitudeLow * 0.85 + rightVibrationValue.AmplitudeHigh * 0.15)));
|
||||
float high = Math.Min(1f, (float)((leftVibrationValue.AmplitudeLow * 0.15 + leftVibrationValue.AmplitudeHigh * 0.85)));
|
||||
_gamepad?.Rumble(low, high, 0xFFFFFFFF);
|
||||
if (gamepadId == config.Id)
|
||||
{
|
||||
yield return new AssignedInputDevice
|
||||
{
|
||||
Type = AssignedInputDeviceType.Controller,
|
||||
Id = gamepadId,
|
||||
};
|
||||
yield break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!gamepadDriver.GamepadsIds.IsEmpty)
|
||||
{
|
||||
yield return new AssignedInputDevice
|
||||
{
|
||||
Type = AssignedInputDeviceType.Controller,
|
||||
Id = gamepadDriver.GamepadsIds[0],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private static IGamepad OpenSingleGamepad(IGamepadDriver driver, string id, bool keyboard)
|
||||
{
|
||||
if (driver == null || string.IsNullOrEmpty(id))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (keyboard && driver is IKeyboardModeDriver keyboardModeDriver)
|
||||
{
|
||||
return keyboardModeDriver.GetKeyboard(id, KeyboardInputMode.Physical);
|
||||
}
|
||||
|
||||
return driver.GetGamepad(id);
|
||||
}
|
||||
|
||||
private void UpdateDynamicConfigurations(InputConfig config)
|
||||
{
|
||||
if (config is StandardKeyboardInputConfig keyboardConfig)
|
||||
{
|
||||
AssignedInputDevice assignedKeyboard = _playerInputAssignment?.Devices.FirstOrDefault(device => device.Type == AssignedInputDeviceType.Keyboard);
|
||||
|
||||
_keyboardConfig = ResolveKeyboardConfiguration(assignedKeyboard, keyboardConfig, _keyboardGamepad);
|
||||
|
||||
_assignedControllerConfigs.Clear();
|
||||
|
||||
foreach (IGamepad controllerGamepad in _assignedControllerGamepads)
|
||||
{
|
||||
AssignedInputDevice assignedController = _playerInputAssignment?.Devices.FirstOrDefault(device =>
|
||||
device.Type == AssignedInputDeviceType.Controller &&
|
||||
device.Id == controllerGamepad.Id);
|
||||
|
||||
_assignedControllerConfigs.Add(ResolveControllerConfiguration(assignedController, keyboardConfig, controllerGamepad));
|
||||
}
|
||||
|
||||
Logger.Debug?.Print(LogClass.Hid, $"Effect for {controllerConfig.PlayerIndex} " +
|
||||
// Value=value/multiplier * multiplier (result)
|
||||
$"L.low.amp={leftVibrationValue.AmplitudeLow / controllerConfig.Rumble.WeakRumble} * {controllerConfig.Rumble.WeakRumble} ({leftVibrationValue.AmplitudeLow}), " +
|
||||
$"L.high.amp={leftVibrationValue.AmplitudeHigh / controllerConfig.Rumble.WeakRumble} * {controllerConfig.Rumble.WeakRumble} ({leftVibrationValue.AmplitudeHigh}), " +
|
||||
$"L.low.freq={leftVibrationValue.FrequencyLow / controllerConfig.Rumble.WeakRumble} * {controllerConfig.Rumble.WeakRumble} ({leftVibrationValue.FrequencyLow}), " +
|
||||
$"L.high.freq={leftVibrationValue.FrequencyHigh / controllerConfig.Rumble.WeakRumble} * {controllerConfig.Rumble.WeakRumble} ({leftVibrationValue.FrequencyHigh}), " +
|
||||
$"R.low.amp={rightVibrationValue.AmplitudeLow / controllerConfig.Rumble.StrongRumble} * {controllerConfig.Rumble.StrongRumble} ({rightVibrationValue.AmplitudeLow}), " +
|
||||
$"R.high.amp={rightVibrationValue.AmplitudeHigh / controllerConfig.Rumble.StrongRumble} * {controllerConfig.Rumble.StrongRumble} ({rightVibrationValue.AmplitudeHigh}), " +
|
||||
$"R.low.freq={rightVibrationValue.FrequencyLow / controllerConfig.Rumble.StrongRumble} * {controllerConfig.Rumble.StrongRumble} ({rightVibrationValue.FrequencyLow}), " +
|
||||
$"R.high.freq={rightVibrationValue.FrequencyHigh / controllerConfig.Rumble.StrongRumble} * {controllerConfig.Rumble.StrongRumble} ({rightVibrationValue.FrequencyHigh})");
|
||||
_controllerConfig = _assignedControllerConfigs.FirstOrDefault();
|
||||
}
|
||||
else if (config is StandardControllerInputConfig controllerConfig)
|
||||
{
|
||||
_assignedControllerConfigs.Clear();
|
||||
|
||||
foreach (IGamepad controllerGamepad in _assignedControllerGamepads)
|
||||
{
|
||||
AssignedInputDevice assignedController = _playerInputAssignment?.Devices.FirstOrDefault(device =>
|
||||
device.Type == AssignedInputDeviceType.Controller &&
|
||||
device.Id == controllerGamepad.Id);
|
||||
|
||||
_assignedControllerConfigs.Add(ResolveControllerConfiguration(assignedController, controllerConfig, controllerGamepad));
|
||||
}
|
||||
|
||||
_controllerConfig = _assignedControllerConfigs.FirstOrDefault() ?? controllerConfig;
|
||||
|
||||
if (_keyboardGamepad != null)
|
||||
{
|
||||
AssignedInputDevice assignedKeyboard = _playerInputAssignment?.Devices.FirstOrDefault(device => device.Type == AssignedInputDeviceType.Keyboard);
|
||||
|
||||
_keyboardConfig = ResolveKeyboardConfiguration(assignedKeyboard, controllerConfig, _keyboardGamepad);
|
||||
}
|
||||
else
|
||||
{
|
||||
_keyboardConfig = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private StandardKeyboardInputConfig ResolveKeyboardConfiguration(AssignedInputDevice assignedKeyboard, InputConfig baseConfig, IGamepad keyboardGamepad)
|
||||
{
|
||||
if (keyboardGamepad == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (TryLoadAssignedProfile<StandardKeyboardInputConfig>(assignedKeyboard, KeyboardString, keyboardGamepad, baseConfig, out StandardKeyboardInputConfig profileConfig))
|
||||
{
|
||||
return profileConfig;
|
||||
}
|
||||
|
||||
if (baseConfig is StandardKeyboardInputConfig keyboardBaseConfig)
|
||||
{
|
||||
StandardKeyboardInputConfig clonedConfig = CloneConfig(keyboardBaseConfig);
|
||||
|
||||
if (clonedConfig != null)
|
||||
{
|
||||
clonedConfig.Id = keyboardGamepad.Id;
|
||||
clonedConfig.Name = keyboardGamepad.Name;
|
||||
clonedConfig.PlayerIndex = baseConfig.PlayerIndex;
|
||||
clonedConfig.EnableDynamicGamepadSwap = true;
|
||||
return clonedConfig;
|
||||
}
|
||||
}
|
||||
|
||||
StandardKeyboardInputConfig defaultConfig = InputConfigDefaults.CreateDefaultKeyboardConfiguration(
|
||||
keyboardGamepad.Id,
|
||||
keyboardGamepad.Name,
|
||||
baseConfig.ControllerType,
|
||||
baseConfig.PlayerIndex);
|
||||
defaultConfig.EnableDynamicGamepadSwap = true;
|
||||
return defaultConfig;
|
||||
}
|
||||
|
||||
private StandardControllerInputConfig ResolveControllerConfiguration(AssignedInputDevice assignedController, InputConfig baseConfig, IGamepad controllerGamepad)
|
||||
{
|
||||
if (controllerGamepad == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (TryLoadAssignedProfile<StandardControllerInputConfig>(assignedController, ControllerString, controllerGamepad, baseConfig, out StandardControllerInputConfig profileConfig))
|
||||
{
|
||||
return profileConfig;
|
||||
}
|
||||
|
||||
if (baseConfig is StandardControllerInputConfig controllerBaseConfig)
|
||||
{
|
||||
StandardControllerInputConfig clonedConfig = CloneConfig(controllerBaseConfig);
|
||||
|
||||
if (clonedConfig != null)
|
||||
{
|
||||
clonedConfig.Id = controllerGamepad.Id;
|
||||
clonedConfig.Name = controllerGamepad.Name;
|
||||
clonedConfig.PlayerIndex = baseConfig.PlayerIndex;
|
||||
clonedConfig.EnableDynamicGamepadSwap = true;
|
||||
return clonedConfig;
|
||||
}
|
||||
}
|
||||
|
||||
StandardControllerInputConfig defaultConfig = InputConfigDefaults.CreateDefaultControllerConfiguration(
|
||||
controllerGamepad.Id,
|
||||
controllerGamepad.Name,
|
||||
baseConfig.ControllerType,
|
||||
baseConfig.PlayerIndex,
|
||||
controllerGamepad.Name?.Contains("Nintendo") == true);
|
||||
defaultConfig.EnableDynamicGamepadSwap = true;
|
||||
return defaultConfig;
|
||||
}
|
||||
|
||||
private static T CloneConfig<T>(T config) where T : InputConfig
|
||||
{
|
||||
return JsonHelper.Deserialize(
|
||||
JsonHelper.Serialize(config, _serializerContext.InputConfig),
|
||||
_serializerContext.InputConfig) as T;
|
||||
}
|
||||
|
||||
private static bool TryLoadAssignedProfile<T>(AssignedInputDevice assignedDevice, string profileDirectory, IGamepad gamepad, InputConfig baseConfig, out T config)
|
||||
where T : InputConfig
|
||||
{
|
||||
config = null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(assignedDevice?.ProfileName))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string path = Path.Combine(AppDataManager.ProfilesDirPath, profileDirectory, assignedDevice.ProfileName + ".json");
|
||||
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
config = JsonHelper.DeserializeFromFile(path, _serializerContext.InputConfig) as T;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (config == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
config.Id = gamepad.Id;
|
||||
config.Name = gamepad.Name;
|
||||
config.PlayerIndex = baseConfig.PlayerIndex;
|
||||
config.EnableDynamicGamepadSwap = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
private void UpdateDynamic()
|
||||
{
|
||||
GamepadStateSnapshot keyboardState = _keyboardGamepad?.GetMappedStateSnapshot() ?? default;
|
||||
bool keyboardHasInput = _keyboardGamepad != null && HasInput(keyboardState);
|
||||
bool keyboardNewInput = _keyboardGamepad != null && HasNewInput(keyboardState, _previousKeyboardState);
|
||||
int controllerWithNewInput = -1;
|
||||
int controllerWithHeldInput = -1;
|
||||
|
||||
// Note: dynamic swap is "last input wins", so we scan every assigned controller
|
||||
// and promote whichever one most recently produced a meaningful state change.
|
||||
for (int i = 0; i < _assignedControllerGamepads.Count; i++)
|
||||
{
|
||||
IGamepad controllerGamepad = _assignedControllerGamepads[i];
|
||||
GamepadStateSnapshot controllerState = controllerGamepad?.GetMappedStateSnapshot() ?? default;
|
||||
|
||||
if (HasNewInput(controllerState, _previousControllerStates[i]))
|
||||
{
|
||||
controllerWithNewInput = i;
|
||||
}
|
||||
|
||||
if (controllerWithHeldInput == -1 && HasInput(controllerState))
|
||||
{
|
||||
controllerWithHeldInput = i;
|
||||
}
|
||||
|
||||
_previousControllerStates[i] = controllerState;
|
||||
}
|
||||
|
||||
if (keyboardNewInput && controllerWithNewInput == -1)
|
||||
{
|
||||
_activeInputSource = DynamicInputSource.Keyboard;
|
||||
}
|
||||
else if (controllerWithNewInput != -1 && !keyboardNewInput)
|
||||
{
|
||||
_activeInputSource = DynamicInputSource.Controller;
|
||||
_activeControllerIndex = controllerWithNewInput;
|
||||
}
|
||||
else if (_activeInputSource == DynamicInputSource.Keyboard && !keyboardHasInput && controllerWithHeldInput != -1)
|
||||
{
|
||||
_activeInputSource = DynamicInputSource.Controller;
|
||||
_activeControllerIndex = controllerWithHeldInput;
|
||||
}
|
||||
else if (_activeInputSource == DynamicInputSource.Controller && controllerWithHeldInput == -1 && keyboardHasInput)
|
||||
{
|
||||
_activeInputSource = DynamicInputSource.Keyboard;
|
||||
}
|
||||
else if (_activeInputSource == DynamicInputSource.None)
|
||||
{
|
||||
_activeInputSource = _config switch
|
||||
{
|
||||
StandardKeyboardInputConfig when _keyboardGamepad != null => DynamicInputSource.Keyboard,
|
||||
StandardControllerInputConfig when _assignedControllerGamepads.Count > 0 => DynamicInputSource.Controller,
|
||||
_ when keyboardHasInput => DynamicInputSource.Keyboard,
|
||||
_ when controllerWithHeldInput != -1 => DynamicInputSource.Controller,
|
||||
_ when _keyboardGamepad != null => DynamicInputSource.Keyboard,
|
||||
_ when _assignedControllerGamepads.Count > 0 => DynamicInputSource.Controller,
|
||||
_ => DynamicInputSource.None,
|
||||
};
|
||||
|
||||
if (_activeInputSource == DynamicInputSource.Controller)
|
||||
{
|
||||
_activeControllerIndex = controllerWithHeldInput != -1 ? controllerWithHeldInput : 0;
|
||||
}
|
||||
}
|
||||
|
||||
UpdateActiveGamepad();
|
||||
|
||||
State = _activeInputSource switch
|
||||
{
|
||||
DynamicInputSource.Keyboard => keyboardState,
|
||||
DynamicInputSource.Controller when _activeControllerIndex >= 0 && _activeControllerIndex < _previousControllerStates.Count => _previousControllerStates[_activeControllerIndex],
|
||||
_ => default,
|
||||
};
|
||||
|
||||
if (_activeConfig is StandardControllerInputConfig controllerConfig && _controllerGamepad != null && _activeInputSource == DynamicInputSource.Controller)
|
||||
{
|
||||
UpdateControllerMotion(_controllerGamepad, controllerConfig);
|
||||
}
|
||||
else
|
||||
{
|
||||
_leftMotionInput = null;
|
||||
_rightMotionInput = null;
|
||||
}
|
||||
|
||||
_previousKeyboardState = keyboardState;
|
||||
}
|
||||
|
||||
private void UpdateActiveGamepad()
|
||||
{
|
||||
(_gamepad, _activeConfig, GamepadDriver) = _activeInputSource switch
|
||||
{
|
||||
DynamicInputSource.Keyboard => (_keyboardGamepad, _keyboardConfig, _keyboardDriver),
|
||||
DynamicInputSource.Controller =>
|
||||
(
|
||||
_activeControllerIndex >= 0 && _activeControllerIndex < _assignedControllerGamepads.Count
|
||||
? _assignedControllerGamepads[_activeControllerIndex]
|
||||
: _assignedControllerGamepads.FirstOrDefault(),
|
||||
_activeControllerIndex >= 0 && _activeControllerIndex < _assignedControllerConfigs.Count
|
||||
? _assignedControllerConfigs[_activeControllerIndex]
|
||||
: _assignedControllerConfigs.FirstOrDefault(),
|
||||
_controllerDriver
|
||||
),
|
||||
_ => ((IGamepad?)null, (InputConfig?)null, (IGamepadDriver?)null)
|
||||
};
|
||||
|
||||
_controllerGamepad = _gamepad;
|
||||
}
|
||||
|
||||
private void UpdateControllerMotion(IGamepad gamepad, StandardControllerInputConfig controllerConfig)
|
||||
{
|
||||
if (gamepad == null || controllerConfig?.Motion == null || !controllerConfig.Motion.EnableMotion)
|
||||
{
|
||||
_leftMotionInput = null;
|
||||
_rightMotionInput = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (controllerConfig.Motion.MotionBackend == MotionInputBackendType.GamepadDriver)
|
||||
{
|
||||
if ((gamepad.Features & GamepadFeaturesFlag.Motion) != 0)
|
||||
{
|
||||
_leftMotionInput ??= new MotionInput();
|
||||
_rightMotionInput ??= new MotionInput();
|
||||
|
||||
Vector3 accelerometer = gamepad.GetMotionData(MotionInputId.Accelerometer);
|
||||
Vector3 gyroscope = gamepad.GetMotionData(MotionInputId.Gyroscope);
|
||||
|
||||
accelerometer = new Vector3(accelerometer.X, -accelerometer.Z, accelerometer.Y);
|
||||
gyroscope = new Vector3(gyroscope.X, -gyroscope.Z, gyroscope.Y);
|
||||
|
||||
_leftMotionInput.Update(accelerometer, gyroscope, (ulong)PerformanceCounter.ElapsedNanoseconds / 1000, controllerConfig.Motion.Sensitivity, (float)controllerConfig.Motion.GyroDeadzone);
|
||||
|
||||
if (controllerConfig.ControllerType == ConfigControllerType.JoyconPair)
|
||||
{
|
||||
if (gamepad.Id == "JoyConPair")
|
||||
{
|
||||
Vector3 rightAccelerometer = gamepad.GetMotionData(MotionInputId.SecondAccelerometer);
|
||||
Vector3 rightGyroscope = gamepad.GetMotionData(MotionInputId.SecondGyroscope);
|
||||
|
||||
rightAccelerometer = new Vector3(rightAccelerometer.X, -rightAccelerometer.Z, rightAccelerometer.Y);
|
||||
rightGyroscope = new Vector3(rightGyroscope.X, -rightGyroscope.Z, rightGyroscope.Y);
|
||||
|
||||
_rightMotionInput.Update(rightAccelerometer, rightGyroscope, (ulong)PerformanceCounter.ElapsedNanoseconds / 1000, controllerConfig.Motion.Sensitivity, (float)controllerConfig.Motion.GyroDeadzone);
|
||||
}
|
||||
else
|
||||
{
|
||||
_rightMotionInput = _leftMotionInput;
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_leftMotionInput = null;
|
||||
_rightMotionInput = null;
|
||||
}
|
||||
}
|
||||
else if (controllerConfig.Motion.MotionBackend == MotionInputBackendType.CemuHook && controllerConfig.Motion is CemuHookMotionConfigController cemuControllerConfig)
|
||||
{
|
||||
int clientId = (int)controllerConfig.PlayerIndex;
|
||||
|
||||
_cemuHookClient.RegisterClient(clientId, cemuControllerConfig.DsuServerHost, cemuControllerConfig.DsuServerPort);
|
||||
_cemuHookClient.RequestData(clientId, cemuControllerConfig.Slot);
|
||||
_cemuHookClient.TryGetData(clientId, cemuControllerConfig.Slot, out _leftMotionInput);
|
||||
|
||||
if (controllerConfig.ControllerType == ConfigControllerType.JoyconPair)
|
||||
{
|
||||
if (!cemuControllerConfig.MirrorInput)
|
||||
{
|
||||
_cemuHookClient.RequestData(clientId, cemuControllerConfig.AltSlot);
|
||||
_cemuHookClient.TryGetData(clientId, cemuControllerConfig.AltSlot, out _rightMotionInput);
|
||||
}
|
||||
else
|
||||
{
|
||||
_rightMotionInput = _leftMotionInput;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool HasInput(GamepadStateSnapshot state)
|
||||
{
|
||||
for (GamepadButtonInputId inputId = GamepadButtonInputId.A; inputId < GamepadButtonInputId.Count; inputId++)
|
||||
{
|
||||
if (state.IsPressed(inputId))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return StickIsActive(state.GetStick(StickInputId.Left)) || StickIsActive(state.GetStick(StickInputId.Right));
|
||||
}
|
||||
|
||||
private static bool HasNewInput(GamepadStateSnapshot current, GamepadStateSnapshot previous)
|
||||
{
|
||||
for (GamepadButtonInputId inputId = GamepadButtonInputId.A; inputId < GamepadButtonInputId.Count; inputId++)
|
||||
{
|
||||
if (current.IsPressed(inputId) && !previous.IsPressed(inputId))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return StickBecameActive(current.GetStick(StickInputId.Left), previous.GetStick(StickInputId.Left)) ||
|
||||
StickBecameActive(current.GetStick(StickInputId.Right), previous.GetStick(StickInputId.Right));
|
||||
}
|
||||
|
||||
private static bool StickIsActive((float X, float Y) stick)
|
||||
{
|
||||
const float Threshold = 0.2f;
|
||||
|
||||
return MathF.Abs(stick.X) > Threshold || MathF.Abs(stick.Y) > Threshold;
|
||||
}
|
||||
|
||||
private static bool StickBecameActive((float X, float Y) current, (float X, float Y) previous)
|
||||
{
|
||||
bool currentActive = StickIsActive(current);
|
||||
bool previousActive = StickIsActive(previous);
|
||||
|
||||
return currentActive && (!previousActive || MathF.Abs(current.X - previous.X) > 0.1f || MathF.Abs(current.Y - previous.Y) > 0.1f);
|
||||
}
|
||||
|
||||
private void DisposeOpenedGamepads()
|
||||
{
|
||||
if (!ReferenceEquals(_gamepad, _keyboardGamepad) && !_assignedControllerGamepads.Contains(_gamepad))
|
||||
{
|
||||
_gamepad?.Dispose();
|
||||
}
|
||||
|
||||
_keyboardGamepad?.Dispose();
|
||||
|
||||
foreach (IGamepad controllerGamepad in _assignedControllerGamepads.Distinct())
|
||||
{
|
||||
controllerGamepad?.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ namespace Ryujinx.Input.HLE
|
||||
|
||||
private List<InputConfig> _inputConfig;
|
||||
private List<InputConfig> _requestedInputConfig;
|
||||
private List<PlayerInputAssignment> _playerInputAssignments;
|
||||
private bool _enableKeyboard;
|
||||
private bool _enableMouse;
|
||||
private Switch _device;
|
||||
@@ -54,6 +55,7 @@ namespace Ryujinx.Input.HLE
|
||||
_mouseDriver = mouseDriver;
|
||||
_inputConfig = [];
|
||||
_requestedInputConfig = [];
|
||||
_playerInputAssignments = [];
|
||||
|
||||
_gamepadDriver.OnGamepadConnected += HandleOnGamepadConnected;
|
||||
_gamepadDriver.OnGamepadDisconnected += HandleOnGamepadDisconnected;
|
||||
@@ -78,52 +80,98 @@ namespace Ryujinx.Input.HLE
|
||||
|
||||
private void HandleOnGamepadDisconnected(string obj)
|
||||
{
|
||||
// Force input reload
|
||||
List<InputConfig> requestedInputConfig;
|
||||
List<PlayerInputAssignment> playerInputAssignments;
|
||||
bool enableKeyboard;
|
||||
bool enableMouse;
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
// Forcibly disconnect any controllers with this ID.
|
||||
for (int i = 0; i < _controllers.Length; i++)
|
||||
{
|
||||
if (_controllers[i]?.Id == obj)
|
||||
if (_controllers[i]?.HasAssignedControllerId(obj) == true)
|
||||
{
|
||||
_controllers[i]?.Dispose();
|
||||
_controllers[i] = null;
|
||||
}
|
||||
}
|
||||
|
||||
ReloadConfiguration(_requestedInputConfig, _enableKeyboard, _enableMouse);
|
||||
requestedInputConfig = _requestedInputConfig;
|
||||
playerInputAssignments = _playerInputAssignments;
|
||||
enableKeyboard = _enableKeyboard;
|
||||
enableMouse = _enableMouse;
|
||||
}
|
||||
|
||||
// Force input reload.
|
||||
ReloadConfiguration(requestedInputConfig, playerInputAssignments, enableKeyboard, enableMouse);
|
||||
}
|
||||
|
||||
private void HandleOnGamepadConnected(string _)
|
||||
private void HandleOnGamepadConnected(string id)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
for (int i = 0; i < _controllers.Length; i++)
|
||||
{
|
||||
if (_controllers[i] != null && PlayerHasAssignedControllerId((PlayerIndex)i, id))
|
||||
{
|
||||
_controllers[i]?.Dispose();
|
||||
_controllers[i] = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Force input reload
|
||||
ReloadConfiguration(_requestedInputConfig, _enableKeyboard, _enableMouse);
|
||||
ReloadConfiguration(_requestedInputConfig, _playerInputAssignments, _enableKeyboard, _enableMouse);
|
||||
}
|
||||
|
||||
private bool PlayerHasAssignedControllerId(PlayerIndex playerIndex, string id)
|
||||
{
|
||||
if (string.IsNullOrEmpty(id))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
InputConfig inputConfig = _requestedInputConfig.FirstOrDefault(config => (int)config.PlayerIndex == (int)playerIndex);
|
||||
|
||||
if (inputConfig == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
PlayerInputAssignment playerInputAssignment = GetPlayerInputAssignment(inputConfig);
|
||||
|
||||
return playerInputAssignment.EnableDynamicInputSwap &&
|
||||
playerInputAssignment.Devices.Any(device =>
|
||||
device.Type == AssignedInputDeviceType.Controller &&
|
||||
string.Equals(device.Id, id, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private bool DriverConfigurationUpdate(ref NpadController controller, InputConfig config)
|
||||
private bool DriverConfigurationUpdate(ref NpadController controller, InputConfig config, PlayerInputAssignment playerInputAssignment)
|
||||
{
|
||||
IGamepadDriver targetDriver =
|
||||
config is StandardKeyboardInputConfig
|
||||
? _keyboardDriver
|
||||
: _gamepadDriver;
|
||||
Debug.Assert(_keyboardDriver != null, "Keyboard driver is not initialized!");
|
||||
Debug.Assert(_gamepadDriver != null, "Gamepad driver is not initialized!");
|
||||
|
||||
Debug.Assert(targetDriver != null, "Unknown input configuration!");
|
||||
|
||||
if (controller.GamepadDriver != targetDriver || controller.Id != config.Id)
|
||||
if (!controller.MatchesDriverConfiguration(config, playerInputAssignment))
|
||||
{
|
||||
return controller.UpdateDriverConfiguration(targetDriver, config);
|
||||
return controller.UpdateDriverConfiguration(_keyboardDriver, _gamepadDriver, config, playerInputAssignment);
|
||||
}
|
||||
|
||||
return controller.GamepadDriver != null;
|
||||
return controller.IsAvailable;
|
||||
}
|
||||
|
||||
public void ReloadConfiguration(List<InputConfig> inputConfig, bool enableKeyboard, bool enableMouse)
|
||||
{
|
||||
ReloadConfiguration(inputConfig, [], enableKeyboard, enableMouse);
|
||||
}
|
||||
|
||||
public void ReloadConfiguration(List<InputConfig> inputConfig, List<PlayerInputAssignment> playerInputAssignments, bool enableKeyboard, bool enableMouse)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_requestedInputConfig = inputConfig?.ToList() ?? [];
|
||||
_playerInputAssignments = playerInputAssignments?.ToList() ?? [];
|
||||
|
||||
NpadController[] oldControllers = _controllers.ToArray();
|
||||
|
||||
@@ -146,14 +194,17 @@ namespace Ryujinx.Input.HLE
|
||||
}
|
||||
|
||||
InputConfig activeConfig = inputConfigEntry;
|
||||
bool isValid = DriverConfigurationUpdate(ref controller, activeConfig);
|
||||
PlayerInputAssignment playerInputAssignment = GetPlayerInputAssignment(inputConfigEntry);
|
||||
|
||||
bool isValid = DriverConfigurationUpdate(ref controller, activeConfig, playerInputAssignment);
|
||||
|
||||
if (!isValid &&
|
||||
!playerInputAssignment.EnableDynamicInputSwap &&
|
||||
inputConfigEntry is StandardControllerInputConfig &&
|
||||
TryGetKeyboardFallback(inputConfigEntry, out StandardKeyboardInputConfig fallbackConfig))
|
||||
{
|
||||
activeConfig = fallbackConfig;
|
||||
isValid = DriverConfigurationUpdate(ref controller, activeConfig);
|
||||
isValid = DriverConfigurationUpdate(ref controller, activeConfig, playerInputAssignment);
|
||||
}
|
||||
|
||||
if (!isValid)
|
||||
@@ -184,6 +235,54 @@ namespace Ryujinx.Input.HLE
|
||||
}
|
||||
}
|
||||
|
||||
private PlayerInputAssignment GetPlayerInputAssignment(InputConfig inputConfig)
|
||||
{
|
||||
PlayerInputAssignment playerInputAssignment = _playerInputAssignments.FirstOrDefault(assignment => assignment.PlayerIndex == inputConfig.PlayerIndex);
|
||||
|
||||
if (playerInputAssignment != null)
|
||||
{
|
||||
PlayerInputAssignment normalizedAssignment = PlayerInputAssignmentHelper.Normalize(
|
||||
playerInputAssignment,
|
||||
PlayerInputAssignmentHelper.CreatePrimaryDevice(inputConfig));
|
||||
|
||||
if (normalizedAssignment.EnableDynamicInputSwap || normalizedAssignment.Devices.Count > 0)
|
||||
{
|
||||
return normalizedAssignment;
|
||||
}
|
||||
}
|
||||
|
||||
// Note: older configs only know about a single saved device per player,
|
||||
// so we synthesize a routing entry here until the user saves explicit assignments.
|
||||
playerInputAssignment = new PlayerInputAssignment
|
||||
{
|
||||
PlayerIndex = inputConfig.PlayerIndex,
|
||||
EnableDynamicInputSwap = inputConfig.EnableDynamicGamepadSwap,
|
||||
};
|
||||
|
||||
AssignedInputDevice primaryDevice = PlayerInputAssignmentHelper.CreatePrimaryDevice(inputConfig);
|
||||
|
||||
if (primaryDevice != null)
|
||||
{
|
||||
playerInputAssignment.Devices.Add(primaryDevice);
|
||||
}
|
||||
|
||||
if (playerInputAssignment.EnableDynamicInputSwap && inputConfig is StandardControllerInputConfig)
|
||||
{
|
||||
string keyboardId = _keyboardDriver.GamepadsIds.IsEmpty ? null : _keyboardDriver.GamepadsIds[0];
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(keyboardId))
|
||||
{
|
||||
playerInputAssignment.Devices.Add(new AssignedInputDevice
|
||||
{
|
||||
Type = AssignedInputDeviceType.Keyboard,
|
||||
Id = keyboardId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return playerInputAssignment;
|
||||
}
|
||||
|
||||
private bool TryGetKeyboardFallback(InputConfig inputConfig, out StandardKeyboardInputConfig fallbackConfig)
|
||||
{
|
||||
fallbackConfig = null;
|
||||
@@ -210,6 +309,8 @@ namespace Ryujinx.Input.HLE
|
||||
inputConfig.ControllerType,
|
||||
inputConfig.PlayerIndex);
|
||||
|
||||
fallbackConfig.EnableDynamicGamepadSwap = inputConfig.EnableDynamicGamepadSwap;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -257,11 +358,16 @@ namespace Ryujinx.Input.HLE
|
||||
}
|
||||
|
||||
public void Initialize(Switch device, List<InputConfig> inputConfig, bool enableKeyboard, bool enableMouse)
|
||||
{
|
||||
Initialize(device, inputConfig, [], enableKeyboard, enableMouse);
|
||||
}
|
||||
|
||||
public void Initialize(Switch device, List<InputConfig> inputConfig, List<PlayerInputAssignment> playerInputAssignments, bool enableKeyboard, bool enableMouse)
|
||||
{
|
||||
_device = device;
|
||||
_device.Configuration.RefreshInputConfig = RefreshInputConfigForHLE;
|
||||
|
||||
ReloadConfiguration(inputConfig, enableKeyboard, enableMouse);
|
||||
ReloadConfiguration(inputConfig, playerInputAssignments, enableKeyboard, enableMouse);
|
||||
}
|
||||
|
||||
public void Update(float aspectRatio = 1)
|
||||
@@ -286,7 +392,7 @@ namespace Ryujinx.Input.HLE
|
||||
// Do we allow input updates and is a controller connected?
|
||||
if (_inputUpdateBlockCount == 0 && controller != null)
|
||||
{
|
||||
DriverConfigurationUpdate(ref controller, inputConfig);
|
||||
DriverConfigurationUpdate(ref controller, inputConfig, GetPlayerInputAssignment(inputConfig));
|
||||
|
||||
controller.UpdateUserConfiguration(inputConfig);
|
||||
controller.Update();
|
||||
@@ -387,7 +493,9 @@ namespace Ryujinx.Input.HLE
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _inputConfig.FirstOrDefault(x => x.PlayerIndex == (Common.Configuration.Hid.PlayerIndex)index);
|
||||
NpadController controller = _controllers[index];
|
||||
|
||||
return controller?.ActiveConfig ?? _inputConfig.FirstOrDefault(x => x.PlayerIndex == (Common.Configuration.Hid.PlayerIndex)index);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -140,7 +140,7 @@ namespace Ryujinx.Input
|
||||
StrongRumble = 1f,
|
||||
WeakRumble = 1f,
|
||||
EnableRumble = false,
|
||||
UseHDRumble = true,
|
||||
UseHDRumble = false
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
216
src/Ryujinx/Assets/PlayReports/nsmbud.json
Normal file
216
src/Ryujinx/Assets/PlayReports/nsmbud.json
Normal file
@@ -0,0 +1,216 @@
|
||||
{
|
||||
"mario": {
|
||||
"1": {
|
||||
"1": "Acorn Plains Way",
|
||||
"2": "Tilted Tunnel",
|
||||
"21": "Crushing-Cogs Tower",
|
||||
"3": "Yoshi Hill",
|
||||
"4": "Mushroom Heights",
|
||||
"5": "Rise of the Piranha Plants",
|
||||
"23": "Lemmy's Swingback Castle",
|
||||
"13": "Blooper's Secret Lair"
|
||||
},
|
||||
"2": {
|
||||
"1": "Stone-Eye Zone",
|
||||
"2": "Perilous Pokey Cave",
|
||||
"3": "Fire Snake Cavern",
|
||||
"21": "Stoneslide Tower",
|
||||
"4": "Spike's Spouting Sands",
|
||||
"5": "Dry Desert Mushrooms",
|
||||
"6": "Blooming Lakitus",
|
||||
"23": "Morton's Compactor Castle",
|
||||
"14": "Piranha Plants on Ice"
|
||||
},
|
||||
"3": {
|
||||
"1": "Waterspout Beach",
|
||||
"2": "Tropical Refresher",
|
||||
"21": "Giant Skewer Tower",
|
||||
"20": "Haunted Shipwreck",
|
||||
"3": "Above the Cheep Cheep Seas",
|
||||
"4": "Urchin Shoals",
|
||||
"5": "Dragoneel's Undersea Grotto",
|
||||
"23": "Larry's Torpedo Castle",
|
||||
"15": "Skyward Stalk"
|
||||
},
|
||||
"4": {
|
||||
"1": "Spinning-Star Sky",
|
||||
"2": "Cooligan Fields",
|
||||
"21": "Freezing-Rain Tower",
|
||||
"3": "Prickly Goombas!",
|
||||
"4": "Scaling the Mountainside",
|
||||
"5": "Icicle Caverns",
|
||||
"20": "Swaying Ghost House",
|
||||
"23": "Wendy's Shifting Castle",
|
||||
"16": "Fliprus Lake"
|
||||
},
|
||||
"5": {
|
||||
"37": "The Mighty Cannonship",
|
||||
"1": "Jungle of the Giants",
|
||||
"2": "Bridge over Poisoned Waters",
|
||||
"3": "Bramball Woods",
|
||||
"21": "Snake Block Tower",
|
||||
"20": "Which-Way Labyrinth",
|
||||
"4": "Painted Swampland",
|
||||
"5": "Deepsea Ruins",
|
||||
"6": "Seesaw Bridge",
|
||||
"7": "Wiggler Stampede",
|
||||
"23": "Iggy's Volcanic Castle",
|
||||
"17": "Flight of the Para-Beetles"
|
||||
},
|
||||
"6": {
|
||||
"1": "Fuzzy Clifftop",
|
||||
"2": "Porcupuffer Falls",
|
||||
"21": "Grinding-Stone Tower",
|
||||
"3": "Wadlewing's Nest",
|
||||
"4": "Light Blocks, Dark Tower",
|
||||
"5": "Walking Piranha Plants!",
|
||||
"6": "Thrilling Spine Coaster",
|
||||
"22": "Screwtop Tower",
|
||||
"7": "Shifting-Floor Cave",
|
||||
"23": "Roy's Conveyor Castle"
|
||||
},
|
||||
"7": {
|
||||
"1": "Land of Flying Blocks",
|
||||
"2": "Seesaw Shrooms",
|
||||
"3": "Switchback Hill",
|
||||
"21": "Slide Lift Tower",
|
||||
"20": "Spinning Spirit House",
|
||||
"4": "Bouncy Cloud Boomerangs",
|
||||
"5": "A Quick Dip in the Sky",
|
||||
"6": "Snaking above Mist Valley",
|
||||
"23": "Ludwig's Clockwork Castle",
|
||||
"37": "Boarding the Airship"
|
||||
|
||||
},
|
||||
"8": {
|
||||
"1": "Meteor Moat",
|
||||
"2": "Magma-River Cruise",
|
||||
"3": "Rising Tides of Lava",
|
||||
"4": "firefall Cliffs",
|
||||
"42": "Red-Hot Elevator Ride",
|
||||
"43": "The Final Battle"
|
||||
},
|
||||
"9": {
|
||||
"1": "Spine-Tingling Spine Coaster",
|
||||
"2": "Run for It",
|
||||
"3": "Swim for Your Life!",
|
||||
"4": "Hammerswing Caverns",
|
||||
"5": "Spinning Platforms of Doom",
|
||||
"6": "Fire Bar Cliffs",
|
||||
"7": "Lakitu! Lakitu! Lakitu!",
|
||||
"8": "Pendulum Castle",
|
||||
"9": "Follow That Shell!"
|
||||
},
|
||||
"11": {
|
||||
"1": "",
|
||||
"2": "",
|
||||
"3": "",
|
||||
"4": "",
|
||||
"5": "",
|
||||
"6": "",
|
||||
"7": "",
|
||||
"8": ""
|
||||
}
|
||||
},
|
||||
"luigi": {
|
||||
"1": {
|
||||
"1": "Waddlewing Warning!",
|
||||
"2": "Crooked Cavern",
|
||||
"21": "Flame-Gear Tower",
|
||||
"3": "Rolling Yoshi Hills",
|
||||
"4": "Piranha Heights",
|
||||
"5": "Piranha Gardens",
|
||||
"23": "Lemmy's Lights-Out Castle",
|
||||
"13": "Cheep Chomp Chase"
|
||||
},
|
||||
"2": {
|
||||
"1": "Spike's Tumbling Desert",
|
||||
"2": "Underground Grrrols",
|
||||
"3": "Piranhas in the Dark",
|
||||
"21": "Wind-Up Tower",
|
||||
"4": "The Walls Have Eyes",
|
||||
"5": "Stone Spike Conveyors",
|
||||
"6": "Spinning Sandstones",
|
||||
"23": "Morton's Lava-Block Castle",
|
||||
"14": "Slippery Rope Ladders"
|
||||
},
|
||||
"3": {
|
||||
"1": "Huckit Beach Resort",
|
||||
"2": "Urchin Reef Romp",
|
||||
"21": "Shish-Kebab Tower",
|
||||
"20": "Haunted Cargo Hold",
|
||||
"3": "Waterspout Sprint",
|
||||
"4": "The Great Geysers",
|
||||
"5": "Dragoneel Depths",
|
||||
"23": "Larry's Trigger-Happy Castle",
|
||||
"15": "Beanstalk Jungle"
|
||||
},
|
||||
"4": {
|
||||
"1": "Broozers and Barrels",
|
||||
"2": "Cooligan Shrooms",
|
||||
"21": "Icicle Tower",
|
||||
"3": "Fire and Ice",
|
||||
"4": "Weighty Waddlewings",
|
||||
"5": "Ice-Slide Expressway",
|
||||
"20": "Peek-a-Boo Ghost House",
|
||||
"23": "Wendy's Thwomp Castle",
|
||||
"16": "Fliprus Floes"
|
||||
},
|
||||
"5": {
|
||||
"1": "Giant Swing-Along",
|
||||
"2": "Dancing Blocks, Poison Swamp",
|
||||
"3": "Heart of Bramball Woods",
|
||||
"21": "Stone-Snake Tower",
|
||||
"20": "Boo's Favorite Haunt",
|
||||
"4": "Painted Pipeworks",
|
||||
"5": "Deepsea Stone-Eyes",
|
||||
"6": "Sumo Bro Bridge",
|
||||
"7": "Wiggler Floodlands",
|
||||
"23": "Iggy's Swinging-Chains Castle",
|
||||
"17": "Para-Beetle Parade"
|
||||
},
|
||||
"6": {
|
||||
"1": "Mount Fuzzy",
|
||||
"2": "Porcupuffer Cavern",
|
||||
"21": "Smashing-Stone Tower",
|
||||
"3": "Spike's Seesaws",
|
||||
"4": "Light-Up-Lift Tower",
|
||||
"5": "Rising Piranhas",
|
||||
"6": "Spine Coaster Stowaways",
|
||||
"22": "Sumo Bro's Spinning Tower",
|
||||
"7": "Switch-Lift Express",
|
||||
"23": "Roy's Ironclad Castle"
|
||||
},
|
||||
"7": {
|
||||
"1": "Frozen Fuzzies",
|
||||
"2": "Wiggler Rodeo",
|
||||
"3": "Rainbow Skywalk",
|
||||
"21": "Stonecrush Tower",
|
||||
"20": "Vanishing Ghost House",
|
||||
"4": "Above The Bouncy Clouds",
|
||||
"5": "Flame Chomp Ferris Wheel",
|
||||
"6": "Three-Headed Snake Block",
|
||||
"23": "Ludwig's Block-Press Castle",
|
||||
"37": "Bowser Jr. Showdown"
|
||||
},
|
||||
"8": {
|
||||
"1": "Magma Moat",
|
||||
"2": "Magmaw River Cruise",
|
||||
"3": "Hot Cogs",
|
||||
"4": "Firefall Rising",
|
||||
"42": "Current Event",
|
||||
"43": "The Final Battle"
|
||||
},
|
||||
"9": {
|
||||
"1": "Spine Coaster Connections",
|
||||
"2": "P Switch Peril",
|
||||
"3": "Star Coin Deep Dive",
|
||||
"4": "Hammerswing Hideout",
|
||||
"5": "Under Construction",
|
||||
"6": "Fire Bar Sprint",
|
||||
"7": "Cloudy Capers",
|
||||
"8": "Impossible Pendulums",
|
||||
"9": "Flying Squirrel Ovation"
|
||||
}
|
||||
}
|
||||
}
|
||||
98
src/Ryujinx/Assets/Splashes.json
Normal file
98
src/Ryujinx/Assets/Splashes.json
Normal file
@@ -0,0 +1,98 @@
|
||||
{
|
||||
"Locales": {
|
||||
"ar_SA": [],
|
||||
"de_DE": [],
|
||||
"el_GR": [],
|
||||
"en_US": [
|
||||
"Ryubing is my middle name.",
|
||||
"Giving it 110 percent!",
|
||||
"I don't think therefore I don't am!",
|
||||
"All hail Egg.",
|
||||
"Insert cringy joke here.",
|
||||
"ITS RYUBINGING TIME!",
|
||||
"I hate Mondays...",
|
||||
"Fantastical!",
|
||||
"Now with 100% more humor!",
|
||||
"'Not S&P approved' has been approved by S&P.",
|
||||
"ARE YOU NOT ENTERTAINED?",
|
||||
"It's an emulator!",
|
||||
"Now the real game begins...",
|
||||
"Cooked fresh since 2018!",
|
||||
"Must've been the wind...",
|
||||
"I used to be an adventurer like you before I took an arrow to the knee.",
|
||||
"Ryubing!",
|
||||
"May contain nuts!",
|
||||
"May include occasional pop culture references!",
|
||||
"100% organically grown!",
|
||||
"Have a nice day : )",
|
||||
"Spoats car!",
|
||||
"Bottom text",
|
||||
"Im sorry Dave. I'm afraid I can't do that.",
|
||||
"That's no moon...",
|
||||
"Sir, finishing this fight.",
|
||||
"I see how it is...",
|
||||
"Space! The final frontier!",
|
||||
"If you could not tell already, I love making bad jokes : )",
|
||||
"this.",
|
||||
"Probably contains no baked beans.",
|
||||
"Y'all ready for this?",
|
||||
"Removed Herobrine.",
|
||||
"Right to repair!",
|
||||
"Programmed in C#!",
|
||||
"Forgejo has dethroned Gitlab!",
|
||||
"Any ideas what to put here?",
|
||||
"Good morning!",
|
||||
"Good afternoon!",
|
||||
"Good evening!",
|
||||
"I hope you are having a great day!",
|
||||
"Please insert disc two!",
|
||||
"I... AM RYUBING!",
|
||||
"Ryubingin' it up",
|
||||
"bing bing wahoo.",
|
||||
"egg",
|
||||
"No, lossless scaling is NOT supported.",
|
||||
"How do you people do anything?",
|
||||
"One dollar.",
|
||||
"Somebody once told me!",
|
||||
"Its that time of the year again!",
|
||||
"Brewed from only the finest memes.",
|
||||
"Async shader compilation would destroy my soul : (",
|
||||
"Trans rights are human rights!",
|
||||
":3",
|
||||
"Patched ':3' splash replication glitch.",
|
||||
"Please connect a controller!",
|
||||
"Never gonna give you up!",
|
||||
"The game was rigged from the start.",
|
||||
"Ganon is watching you!",
|
||||
"Now with 100% more JSON in the splash code!",
|
||||
"Countless hours of fun!",
|
||||
"Sorry, Link. I can't give credit. Come back when you're a little... mmmmmm... richer!",
|
||||
"Do a barrel roll!",
|
||||
"You've met with a terrible fate, haven't you?",
|
||||
"Yahaha! You found me!",
|
||||
"I would've been in real trouble if you hadn't shown up when you did, goro.",
|
||||
"Stay fresh!",
|
||||
"Yellow, black. Yellow, black. Yellow, black. Yellow, black. Ooh, black and yellow! Let's shake it up a little.",
|
||||
"Whaaa? You came to see me again? That makes Beedle SO HAPPY!",
|
||||
"Don't get cooked, stay off the hook!",
|
||||
"Now with 100% more good vibes in the splash code!",
|
||||
"It is Wednesday my dudes!"
|
||||
],
|
||||
"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": []
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using Ryujinx.Ava.Common.Locale;
|
||||
using Ryujinx.Ava.Systems.AppLibrary;
|
||||
using Ryujinx.Common.Logging;
|
||||
using Ryujinx.Common.Utilities;
|
||||
@@ -11,6 +12,7 @@ namespace Ryujinx.Ava.Common.Models
|
||||
bool Untrimmable,
|
||||
long PotentialSavingsB,
|
||||
long CurrentSavingsB,
|
||||
long OriginalSizeB,
|
||||
int? PercentageProgress,
|
||||
XCIFileTrimmer.OperationOutcome ProcessingOutcome)
|
||||
{
|
||||
@@ -25,31 +27,57 @@ namespace Ryujinx.Ava.Common.Models
|
||||
trimmer.CanBeUntrimmed,
|
||||
trimmer.DiskSpaceSavingsB,
|
||||
trimmer.DiskSpaceSavedB,
|
||||
applicationData.FileSize,
|
||||
null,
|
||||
XCIFileTrimmer.OperationOutcome.Undetermined
|
||||
);
|
||||
}
|
||||
|
||||
public bool IsFailed
|
||||
public bool IsFailed =>
|
||||
ProcessingOutcome is not XCIFileTrimmer.OperationOutcome.Undetermined
|
||||
and not XCIFileTrimmer.OperationOutcome.Successful;
|
||||
|
||||
public string StatusText
|
||||
{
|
||||
get
|
||||
{
|
||||
return ProcessingOutcome is not XCIFileTrimmer.OperationOutcome.Undetermined and
|
||||
not XCIFileTrimmer.OperationOutcome.Successful;
|
||||
if (IsFailed)
|
||||
return LocaleManager.Instance[LocaleKeys.XCITrimmer_FailedLabel];
|
||||
|
||||
return ProcessingOutcome switch
|
||||
{
|
||||
XCIFileTrimmer.OperationOutcome.Successful =>
|
||||
CurrentSavingsB > 0
|
||||
? LocaleManager.Instance[LocaleKeys.XCITrimmer_UntrimmedLabel]
|
||||
: LocaleManager.Instance[LocaleKeys.XCITrimmer_TrimmedLabel],
|
||||
|
||||
XCIFileTrimmer.OperationOutcome.Undetermined =>
|
||||
Trimmable && Untrimmable
|
||||
? LocaleManager.Instance[LocaleKeys.XCITrimmer_PartialLabel]
|
||||
|
||||
: Trimmable
|
||||
? LocaleManager.Instance[LocaleKeys.XCITrimmer_UntrimmedLabel]
|
||||
|
||||
: Untrimmable
|
||||
? LocaleManager.Instance[LocaleKeys.XCITrimmer_TrimmedLabel]
|
||||
|
||||
: LocaleManager.Instance[LocaleKeys.XCITrimmer_UnknownLabel],
|
||||
|
||||
_ => LocaleManager.Instance[LocaleKeys.XCITrimmer_UnknownLabel]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasStatusDetail =>
|
||||
ProcessingOutcome != XCIFileTrimmer.OperationOutcome.Undetermined;
|
||||
|
||||
public virtual bool Equals(XCITrimmerFileModel obj)
|
||||
{
|
||||
if (obj == null)
|
||||
if (obj is null)
|
||||
return false;
|
||||
|
||||
return this.Path == obj.Path;
|
||||
}
|
||||
|
||||
public override int GetHashCode()
|
||||
{
|
||||
return this.Path.GetHashCode();
|
||||
return Path == obj.Path;
|
||||
}
|
||||
public override int GetHashCode() => Path.GetHashCode();
|
||||
}
|
||||
}
|
||||
}
|
||||
64
src/Ryujinx/Common/SplashTextHelper.cs
Normal file
64
src/Ryujinx/Common/SplashTextHelper.cs
Normal file
@@ -0,0 +1,64 @@
|
||||
using System.Collections.Generic;
|
||||
using Ryujinx.Common.Logging;
|
||||
using Gommon;
|
||||
using Ryujinx.Ava.Systems.Configuration;
|
||||
using System;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Ryujinx.Common
|
||||
{
|
||||
public class SplashTextHelper
|
||||
{
|
||||
public static void PrintSplash()
|
||||
{
|
||||
Logger.Notice.Print(LogClass.Application, " ___ __ _ ");
|
||||
Logger.Notice.Print(LogClass.Application, @" / _ \ __ __ __ __ / / (_) ___ ___ _");
|
||||
Logger.Notice.Print(LogClass.Application, @" / , _/ / // // // / / _ \ / / / _ \ / _ `/");
|
||||
Logger.Notice.Print(LogClass.Application, @"/_/|_| \_, / \_,_/ /_.__//_/ /_//_/ \_, / ");
|
||||
Logger.Notice.Print(LogClass.Application, " /___/ /___/ ");
|
||||
Logger.Notice.Print(LogClass.Application, "");
|
||||
Logger.Notice.Print(LogClass.Application, GetSplash());
|
||||
Logger.Notice.Print(LogClass.Application, "");
|
||||
}
|
||||
|
||||
private static string s_finalSplash = "";
|
||||
|
||||
public static string GetSplash()
|
||||
{
|
||||
if (string.IsNullOrEmpty(s_finalSplash))
|
||||
{
|
||||
s_finalSplash = GetLangJson();
|
||||
if (string.IsNullOrEmpty(s_finalSplash))
|
||||
{
|
||||
s_finalSplash = "Splash Text";
|
||||
}
|
||||
}
|
||||
|
||||
return $"{s_finalSplash}";
|
||||
}
|
||||
|
||||
private static SplashLocales s_splashJson;
|
||||
|
||||
private static string GetLangJson()
|
||||
{
|
||||
try
|
||||
{
|
||||
string data;
|
||||
data = EmbeddedResources.ReadAllText("Ryujinx/Assets/Splashes.json");
|
||||
s_splashJson = JsonSerializer.Deserialize<SplashLocales>(data);
|
||||
return s_splashJson.Locales[ConfigurationState.Instance.UI.LanguageCode.Value].GetRandomElement();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private struct SplashLocales
|
||||
{
|
||||
public Dictionary<string, List<string>> Locales { get; set; }
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -26,9 +26,9 @@ namespace Ryujinx.Ava.Common
|
||||
|
||||
internal class TrimmerWindow : Ryujinx.Common.Logging.XCIFileTrimmerLog
|
||||
{
|
||||
private readonly XciTrimmerViewModel _viewModel;
|
||||
private readonly XCITrimmerViewModel _viewModel;
|
||||
|
||||
public TrimmerWindow(XciTrimmerViewModel viewModel)
|
||||
public TrimmerWindow(XCITrimmerViewModel viewModel)
|
||||
{
|
||||
_viewModel = viewModel;
|
||||
}
|
||||
|
||||
@@ -437,13 +437,9 @@ namespace Ryujinx.Ava
|
||||
|
||||
internal static void PrintSystemInfo()
|
||||
{
|
||||
Logger.Notice.Print(LogClass.Application, " ___ __ _ ");
|
||||
Logger.Notice.Print(LogClass.Application, @" / _ \ __ __ __ __ / / (_) ___ ___ _");
|
||||
Logger.Notice.Print(LogClass.Application, @" / , _/ / // // // / / _ \ / / / _ \ / _ `/");
|
||||
Logger.Notice.Print(LogClass.Application, @"/_/|_| \_, / \_,_/ /_.__//_/ /_//_/ \_, / ");
|
||||
Logger.Notice.Print(LogClass.Application, " /___/ /___/ ");
|
||||
|
||||
|
||||
// Print the ryubing logo + joke splash
|
||||
SplashTextHelper.PrintSplash();
|
||||
|
||||
Logger.Notice.Print(LogClass.Application, $"{RyujinxApp.FullAppName} Version: {Version}");
|
||||
Logger.Notice.Print(LogClass.Application, $".NET Runtime: {RuntimeInformation.FrameworkDescription}");
|
||||
SystemInfo.Gather().Print();
|
||||
|
||||
@@ -175,6 +175,8 @@
|
||||
<EmbeddedResource Include="Assets\UIImages\Logo_Ryujinx.png" />
|
||||
<EmbeddedResource Include="Assets\UIImages\Logo_Forgejo.png" />
|
||||
<EmbeddedResource Include="Assets\UIImages\Logo_Ryujinx_AntiAlias.png" />
|
||||
<EmbeddedResource Include="Assets\PlayReports\*.json" />
|
||||
<EmbeddedResource Include="Assets\Splashes.json" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<AdditionalFiles Include="..\..\assets\Locales\*.json" />
|
||||
|
||||
@@ -468,11 +468,11 @@ namespace Ryujinx.Ava.Systems
|
||||
|
||||
if (ConfigurationState.Instance.System.UseInputGlobalConfig.Value && Program.UseExtraConfig)
|
||||
{
|
||||
NpadManager.Initialize(Device, ConfigurationState.InstanceExtra.Hid.InputConfig, ConfigurationState.Instance.Hid.EnableKeyboard, ConfigurationState.Instance.Hid.EnableMouse);
|
||||
NpadManager.Initialize(Device, ConfigurationState.InstanceExtra.Hid.InputConfig, ConfigurationState.InstanceExtra.Hid.PlayerInputAssignments, ConfigurationState.Instance.Hid.EnableKeyboard, ConfigurationState.Instance.Hid.EnableMouse);
|
||||
}
|
||||
else
|
||||
{
|
||||
NpadManager.Initialize(Device, ConfigurationState.Instance.Hid.InputConfig, ConfigurationState.Instance.Hid.EnableKeyboard, ConfigurationState.Instance.Hid.EnableMouse);
|
||||
NpadManager.Initialize(Device, ConfigurationState.Instance.Hid.InputConfig, ConfigurationState.Instance.Hid.PlayerInputAssignments, ConfigurationState.Instance.Hid.EnableKeyboard, ConfigurationState.Instance.Hid.EnableMouse);
|
||||
}
|
||||
|
||||
TouchScreenManager.Initialize(Device);
|
||||
|
||||
@@ -426,6 +426,16 @@ namespace Ryujinx.Ava.Systems.Configuration
|
||||
/// </summary>
|
||||
public List<InputConfig> InputConfig { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Player-level input routing assignments
|
||||
/// </summary>
|
||||
public List<PlayerInputAssignment> PlayerInputAssignments { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to allow mapping the same input device to multiple players.
|
||||
/// </summary>
|
||||
public bool AllowDuplicateDeviceAssignment { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The speed of spectrum cycling for the Rainbow LED feature.
|
||||
/// </summary>
|
||||
|
||||
@@ -157,6 +157,8 @@ namespace Ryujinx.Ava.Systems.Configuration
|
||||
Hid.DisableInputWhenOutOfFocus.Value = shouldLoadFromFile ? cff.DisableInputWhenOutOfFocus : Hid.DisableInputWhenOutOfFocus.Value; // Get from global config only
|
||||
Hid.Hotkeys.Value = shouldLoadFromFile ? cff.Hotkeys : Hid.Hotkeys.Value; // Get from global config only
|
||||
Hid.InputConfig.Value = cff.InputConfig ?? [] ;
|
||||
Hid.PlayerInputAssignments.Value = cff.PlayerInputAssignments ?? [];
|
||||
Hid.AllowDuplicateDeviceAssignment.Value = cff.AllowDuplicateDeviceAssignment;
|
||||
Hid.RainbowSpeed.Value = cff.RainbowSpeed;
|
||||
|
||||
Multiplayer.LanInterfaceId.Value = cff.MultiplayerLanInterfaceId;
|
||||
@@ -336,7 +338,7 @@ namespace Ryujinx.Ava.Systems.Configuration
|
||||
EnableRumble = false,
|
||||
StrongRumble = 1f,
|
||||
WeakRumble = 1f,
|
||||
UseHDRumble = true
|
||||
UseHDRumble = false
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,6 +191,11 @@ namespace Ryujinx.Ava.Systems.Configuration
|
||||
/// </summary>
|
||||
public ReactiveObject<bool> IsAscendingOrder { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Show Dynamic Input Swap first-use warning
|
||||
/// </summary>
|
||||
public ReactiveObject<bool> ShowDynamicInputSwapWarning { get; private set; }
|
||||
|
||||
public UISection()
|
||||
{
|
||||
GuiColumns = new Columns();
|
||||
@@ -210,6 +215,8 @@ namespace Ryujinx.Ava.Systems.Configuration
|
||||
LanguageCode = new ReactiveObject<string>();
|
||||
ShowConsole = new ReactiveObject<bool>();
|
||||
ShowConsole.Event += static (_, e) => ConsoleHelper.SetConsoleWindowState(e.NewValue);
|
||||
ShowDynamicInputSwapWarning = new ReactiveObject<bool>();
|
||||
ShowDynamicInputSwapWarning.Value = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -513,6 +520,19 @@ namespace Ryujinx.Ava.Systems.Configuration
|
||||
/// </summary>
|
||||
public ReactiveObject<List<InputConfig>> InputConfig { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Player-level input routing assignments.
|
||||
/// NOTE: This keeps dynamic input swap and multi-device ownership attached to the player,
|
||||
/// not to the currently edited keyboard/controller profile.
|
||||
/// </summary>
|
||||
public ReactiveObject<List<PlayerInputAssignment>> PlayerInputAssignments { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to allow mapping the same input device to multiple players.
|
||||
/// This is a global setting shared across all players.
|
||||
/// </summary>
|
||||
public ReactiveObject<bool> AllowDuplicateDeviceAssignment { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// The speed of spectrum cycling for the Rainbow LED feature.
|
||||
/// </summary>
|
||||
@@ -525,6 +545,8 @@ namespace Ryujinx.Ava.Systems.Configuration
|
||||
DisableInputWhenOutOfFocus = new ReactiveObject<bool>();
|
||||
Hotkeys = new ReactiveObject<KeyboardHotkeys>();
|
||||
InputConfig = new ReactiveObject<List<InputConfig>>();
|
||||
PlayerInputAssignments = new ReactiveObject<List<PlayerInputAssignment>>();
|
||||
AllowDuplicateDeviceAssignment = new ReactiveObject<bool>();
|
||||
RainbowSpeed = new ReactiveObject<float>();
|
||||
RainbowSpeed.Event += (_, args) => Rainbow.Speed = args.NewValue;
|
||||
}
|
||||
|
||||
@@ -143,6 +143,8 @@ namespace Ryujinx.Ava.Systems.Configuration
|
||||
DisableInputWhenOutOfFocus = Hid.DisableInputWhenOutOfFocus,
|
||||
Hotkeys = Hid.Hotkeys,
|
||||
InputConfig = Hid.InputConfig,
|
||||
PlayerInputAssignments = Hid.PlayerInputAssignments,
|
||||
AllowDuplicateDeviceAssignment = Hid.AllowDuplicateDeviceAssignment,
|
||||
RainbowSpeed = Hid.RainbowSpeed,
|
||||
GraphicsBackend = Graphics.GraphicsBackend,
|
||||
PreferredGpu = Graphics.PreferredGpu,
|
||||
@@ -332,6 +334,22 @@ namespace Ryujinx.Ava.Systems.Configuration
|
||||
},
|
||||
}
|
||||
];
|
||||
Hid.PlayerInputAssignments.Value =
|
||||
[
|
||||
new PlayerInputAssignment
|
||||
{
|
||||
PlayerIndex = PlayerIndex.Player1,
|
||||
EnableDynamicInputSwap = false,
|
||||
Devices =
|
||||
[
|
||||
new AssignedInputDevice
|
||||
{
|
||||
Type = AssignedInputDeviceType.Keyboard,
|
||||
Id = "0",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
Debug.EnableGdbStub.Value = false;
|
||||
Debug.GdbStubPort.Value = 55555;
|
||||
Debug.DebuggerSuspendOnStart.Value = false;
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
using Gommon;
|
||||
using Humanizer;
|
||||
using MsgPack;
|
||||
using Ryujinx.Common;
|
||||
using System;
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Ryujinx.Ava.Systems.PlayReport
|
||||
{
|
||||
@@ -1116,5 +1118,87 @@ namespace Ryujinx.Ava.Systems.PlayReport
|
||||
_ => "Wandering"
|
||||
};
|
||||
}
|
||||
|
||||
private static FormattedValue NsmbudRpc(SparseMultiValue values)
|
||||
{
|
||||
if (values.Matched.TryGetValue("WorldNo", out Value world) && values.Matched.TryGetValue("CourseNo", out Value course) | values.Matched.TryGetValue("GameModeType", out Value gamemode))
|
||||
{
|
||||
string worldstr = world.ToString();
|
||||
string coursestr = course.ToString();
|
||||
int courseint = Int32.Parse(coursestr);
|
||||
string gamemodestr = gamemode.ToString();
|
||||
|
||||
try
|
||||
{
|
||||
Dictionary<string, Dictionary<string, Dictionary<string, string>>> output;
|
||||
string data;
|
||||
data = EmbeddedResources.ReadAllText("Ryujinx/Assets/PlayReports/nsmbud.json");
|
||||
output = JsonSerializer.Deserialize<Dictionary<string, Dictionary<string, Dictionary<string, string>>>>(data);
|
||||
if (SpecialMapNames(courseint) == "Hazard")
|
||||
{
|
||||
return $"Last Played: Course {worldstr}-Hazard";
|
||||
}
|
||||
string outputloc = output[MarioOrLuigiGamemode(gamemodestr)][worldstr][coursestr];
|
||||
return $"Last Played: Course {worldstr}-{SpecialMapNames(courseint)} | {outputloc}";
|
||||
}
|
||||
catch
|
||||
{
|
||||
return FormattedValue.ForceReset;
|
||||
}
|
||||
}
|
||||
|
||||
if (values.Matched.TryGetValue("RlId", out Value RlId) | values.Matched.TryGetValue("TotalPlayTime", out Value TotalPlayTime))
|
||||
{
|
||||
return "At the main menu";
|
||||
}
|
||||
|
||||
static string MarioOrLuigiGamemode(string? gamemode) => gamemode switch
|
||||
{
|
||||
"0" => "mario",
|
||||
"1" => "luigi",
|
||||
"4" => "mario",
|
||||
"5" => "mario",
|
||||
_ => gamemode
|
||||
};
|
||||
|
||||
static string OtherGameMode(string? gamemode) => gamemode switch
|
||||
{
|
||||
"2" => "Boost Rush",
|
||||
"3" => "Challenges",
|
||||
"4" => "Coin Battle",
|
||||
"5" => "Coin Battle Editor",
|
||||
_ => ""
|
||||
};
|
||||
|
||||
static string SpecialMapNames(int? course) => course switch
|
||||
{
|
||||
>= 1 and <= 9 => course.ToString(),
|
||||
13 => "Shortcut",
|
||||
14 => "Shortcut",
|
||||
15 => "Shortcut",
|
||||
16 => "Shortcut",
|
||||
17 => "Shortcut",
|
||||
20 => "Ghost",
|
||||
21 => "Tower",
|
||||
22 => "Tower",
|
||||
23 => "Castle",
|
||||
37 => "Airship",
|
||||
42 => "Castle",
|
||||
43 => "Castle",
|
||||
_ => "Hazard"
|
||||
};
|
||||
|
||||
// For future reference
|
||||
// Tower course = 21, Castle course = 23,Haunted Mansion/ship = 20
|
||||
// Tower course 2 (rock candy) = 22
|
||||
// Peach castle 1 = 42, Peach final battle = 43
|
||||
// airship = 37, jungle beetles = 17
|
||||
// Glacier seals = 16, water leaf = 15
|
||||
// desert ice = 14, acorn squid = 13
|
||||
// all other course numbers are to be considered a hazard
|
||||
|
||||
return "";
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,6 +138,12 @@ namespace Ryujinx.Ava.Systems.PlayReport
|
||||
.WithDescription("based on gold count, report info only in the mii selector, and gamestage (progression)")
|
||||
.AddSparseMultiValueFormatter(["gold", "secret", "stage"], MiitopiaRPC)
|
||||
)
|
||||
.AddSpec(
|
||||
"0100ea80032ea000", // New Super Mario Bros U Deluxe
|
||||
spec => spec
|
||||
.WithDescription("based on world map return info.")
|
||||
.AddSparseMultiValueFormatter(["WorldNo", "CourseNo", "RlId", "TotalPlayTime", "GameModeType"], NsmbudRpc)
|
||||
)
|
||||
);
|
||||
|
||||
private static string Playing(string game) => $"Playing {game}";
|
||||
|
||||
@@ -103,10 +103,10 @@
|
||||
<MenuItem
|
||||
Command="{Binding TrimXci}"
|
||||
CommandParameter="{Binding}"
|
||||
Header="{ext:Locale GameListContextMenuTrimXCI}"
|
||||
Header="{ext:Locale GameListContextMenu_TrimXCIButton}"
|
||||
IsEnabled="{Binding TrimXCIEnabled}"
|
||||
Icon="{ext:Icon fa-solid fa-scissors}"
|
||||
ToolTip.Tip="{ext:Locale GameListContextMenuTrimXCIToolTip}" />
|
||||
IsVisible="{Binding IsXCIFile}"
|
||||
Icon="{ext:Icon fa-solid fa-scissors}" />
|
||||
<MenuItem Header="{ext:Locale GameListContextMenuCacheManagement}" Icon="{ext:Icon fa-solid fa-memory}">
|
||||
<MenuItem
|
||||
Command="{Binding PurgePtcCache}"
|
||||
|
||||
@@ -18,6 +18,11 @@ using System.Threading.Tasks;
|
||||
|
||||
namespace Ryujinx.Ava.UI.Helpers
|
||||
{
|
||||
public class CheckBoxDialogResult
|
||||
{
|
||||
public bool IsChecked { get; set; }
|
||||
}
|
||||
|
||||
public static class ContentDialogHelper
|
||||
{
|
||||
private static bool _isChoiceDialogOpen;
|
||||
@@ -431,6 +436,67 @@ namespace Ryujinx.Ava.UI.Helpers
|
||||
return response == UserResult.Yes;
|
||||
}
|
||||
|
||||
internal static async Task<CheckBoxDialogResult> CreateCheckBoxDialog(string title, string primaryText, string checkBoxText, bool isCheckedDefault)
|
||||
{
|
||||
CheckBoxDialogResult result = new CheckBoxDialogResult { IsChecked = isCheckedDefault };
|
||||
|
||||
Grid content = new()
|
||||
{
|
||||
RowDefinitions = [new(), new(), new()],
|
||||
ColumnDefinitions = [new(GridLength.Auto), new()],
|
||||
MinHeight = 80,
|
||||
};
|
||||
|
||||
content.Children.Add(new SymbolIcon
|
||||
{
|
||||
Symbol = (Symbol)Symbol.Important,
|
||||
Margin = new Thickness(10),
|
||||
FontSize = 40,
|
||||
FlowDirection = FlowDirection.LeftToRight,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
GridColumn = 0,
|
||||
GridRow = 0,
|
||||
GridRowSpan = 2
|
||||
});
|
||||
|
||||
content.Children.Add(new TextBlock
|
||||
{
|
||||
Text = primaryText,
|
||||
Margin = new Thickness(5),
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
MaxWidth = 450,
|
||||
GridColumn = 1,
|
||||
GridRow = 0
|
||||
});
|
||||
|
||||
CheckBox checkBox = new()
|
||||
{
|
||||
Content = checkBoxText,
|
||||
IsChecked = isCheckedDefault,
|
||||
Margin = new Thickness(5),
|
||||
GridColumn = 1,
|
||||
GridRow = 1
|
||||
};
|
||||
|
||||
checkBox.IsCheckedChanged += (s, e) =>
|
||||
{
|
||||
result.IsChecked = checkBox.IsChecked == true;
|
||||
};
|
||||
|
||||
content.Children.Add(checkBox);
|
||||
|
||||
ContentDialog contentDialog = new()
|
||||
{
|
||||
Title = title,
|
||||
PrimaryButtonText = LocaleManager.Instance[LocaleKeys.InputDialogOk],
|
||||
Content = content,
|
||||
};
|
||||
|
||||
await ShowAsync(contentDialog);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
internal static async Task<UserResult> CreateUpdaterChoiceDialog(string title, string primary, string secondaryText, string changelogUrl)
|
||||
{
|
||||
if (_isChoiceDialogOpen)
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
using Avalonia.Data.Converters;
|
||||
using Ryujinx.Ava.UI.ViewModels.Input;
|
||||
using System;
|
||||
using System.Globalization;
|
||||
|
||||
namespace Ryujinx.Ava.UI.Helpers
|
||||
{
|
||||
public class ProfileNameLinkedConverter : IValueConverter
|
||||
{
|
||||
public static readonly ProfileNameLinkedConverter Instance = new();
|
||||
|
||||
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
{
|
||||
if (value is not string profileName || string.IsNullOrWhiteSpace(profileName))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (parameter is InputViewModel viewModel)
|
||||
{
|
||||
return viewModel.IsProfileNameLinked(profileName);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,32 +12,57 @@ namespace Ryujinx.Ava.UI.Helpers
|
||||
internal class XCITrimmerFileSpaceSavingsConverter : IValueConverter
|
||||
{
|
||||
private const long _bytesPerMB = 1024 * 1024;
|
||||
|
||||
public static readonly XCITrimmerFileSpaceSavingsConverter Instance = new();
|
||||
|
||||
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
{
|
||||
if (value is UnsetValueType)
|
||||
{
|
||||
if (value == null || value == AvaloniaProperty.UnsetValue)
|
||||
return BindingOperations.DoNothing;
|
||||
}
|
||||
|
||||
if (!targetType.IsAssignableFrom(typeof(string)))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (value is not XCITrimmerFileModel app)
|
||||
{
|
||||
return null;
|
||||
|
||||
long originalSize = app.OriginalSizeB;
|
||||
long currentSavings = app.CurrentSavingsB;
|
||||
long potentialSavings = app.PotentialSavingsB;
|
||||
|
||||
if (originalSize <= 0)
|
||||
{
|
||||
return GetFormattedString(app, 0, 0);
|
||||
}
|
||||
|
||||
long mbValue = 0;
|
||||
double percentage = 0;
|
||||
|
||||
if (currentSavings > 0)
|
||||
{
|
||||
mbValue = (currentSavings / _bytesPerMB).CoerceAtLeast(0);
|
||||
percentage = (currentSavings / (double)originalSize) * 100;
|
||||
}
|
||||
else if (potentialSavings > 0)
|
||||
{
|
||||
mbValue = (potentialSavings / _bytesPerMB).CoerceAtLeast(0);
|
||||
percentage = (potentialSavings / (double)originalSize) * 100;
|
||||
}
|
||||
|
||||
return GetFormattedString(app, mbValue, percentage);
|
||||
}
|
||||
|
||||
private string GetFormattedString(XCITrimmerFileModel app, long mb, double percentage)
|
||||
{
|
||||
// Round percentage to 1 decimal place
|
||||
double roundedPercentage = Math.Round(percentage, 1);
|
||||
|
||||
if (app.CurrentSavingsB < app.PotentialSavingsB)
|
||||
{
|
||||
return LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.TitleXCICanSaveLabel, ((app.PotentialSavingsB - app.CurrentSavingsB) / _bytesPerMB).CoerceAtLeast(0));
|
||||
return LocaleManager.Instance.UpdateAndGetDynamicValue(
|
||||
LocaleKeys.XCITrimmer_CalculatedSavingsLabel, mb, roundedPercentage);
|
||||
}
|
||||
else
|
||||
{
|
||||
return LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.TitleXCISavingLabel, (app.CurrentSavingsB / _bytesPerMB).CoerceAtLeast(0));
|
||||
return LocaleManager.Instance.UpdateAndGetDynamicValue(
|
||||
LocaleKeys.XCITrimmer_CalculatedSavingsLabel, mb, roundedPercentage);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Data;
|
||||
using Avalonia.Data.Converters;
|
||||
using Ryujinx.Ava.Common.Locale;
|
||||
using FluentAvalonia.UI.Controls;
|
||||
using Ryujinx.Ava.Common.Models;
|
||||
using System;
|
||||
using System.Globalization;
|
||||
@@ -16,26 +16,30 @@ namespace Ryujinx.Ava.UI.Helpers
|
||||
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
{
|
||||
if (value is UnsetValueType)
|
||||
{
|
||||
return BindingOperations.DoNothing;
|
||||
}
|
||||
|
||||
if (!targetType.IsAssignableFrom(typeof(string)))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (value is not XCITrimmerFileModel app)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return default(Symbol);
|
||||
|
||||
bool isProcessing = app.PercentageProgress != null;
|
||||
|
||||
if (isProcessing)
|
||||
return Symbol.Sync;
|
||||
|
||||
return app.PercentageProgress != null ? String.Empty :
|
||||
app.ProcessingOutcome is not OperationOutcome.Successful and not OperationOutcome.Undetermined ? LocaleManager.Instance[LocaleKeys.TitleXCIStatusFailedLabel] :
|
||||
app.Trimmable & app.Untrimmable ? LocaleManager.Instance[LocaleKeys.TitleXCIStatusPartialLabel] :
|
||||
app.Trimmable ? LocaleManager.Instance[LocaleKeys.TitleXCIStatusTrimmableLabel] :
|
||||
app.Untrimmable ? LocaleManager.Instance[LocaleKeys.TitleXCIStatusUntrimmableLabel] :
|
||||
String.Empty;
|
||||
if (app.ProcessingOutcome is not OperationOutcome.Successful
|
||||
and not OperationOutcome.Undetermined)
|
||||
return Symbol.ImportantFilled;
|
||||
|
||||
if (app.Trimmable && app.Untrimmable)
|
||||
return Symbol.Repair;
|
||||
|
||||
if (app.Trimmable)
|
||||
return Symbol.Clear;
|
||||
|
||||
if (app.Untrimmable)
|
||||
return Symbol.Checkmark;
|
||||
|
||||
return Symbol.Help;
|
||||
}
|
||||
|
||||
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
|
||||
@@ -9,17 +9,15 @@ namespace Ryujinx.Ava.UI.Helpers
|
||||
{
|
||||
public string LocalizedText => opOutcome switch
|
||||
{
|
||||
OperationOutcome.NoTrimNecessary => LocaleManager.Instance[LocaleKeys.TrimXCIFileNoTrimNecessary],
|
||||
OperationOutcome.NoUntrimPossible => LocaleManager.Instance[LocaleKeys.TrimXCIFileNoUntrimPossible],
|
||||
OperationOutcome.ReadOnlyFileCannotFix => LocaleManager.Instance[
|
||||
LocaleKeys.TrimXCIFileReadOnlyFileCannotFix],
|
||||
OperationOutcome.FreeSpaceCheckFailed => LocaleManager.Instance[
|
||||
LocaleKeys.TrimXCIFileFreeSpaceCheckFailed],
|
||||
OperationOutcome.InvalidXCIFile => LocaleManager.Instance[LocaleKeys.TrimXCIFileInvalidXCIFile],
|
||||
OperationOutcome.FileIOWriteError => LocaleManager.Instance[LocaleKeys.TrimXCIFileFileIOWriteError],
|
||||
OperationOutcome.FileSizeChanged => LocaleManager.Instance[LocaleKeys.TrimXCIFileFileSizeChanged],
|
||||
OperationOutcome.Cancelled => LocaleManager.Instance[LocaleKeys.TrimXCIFileCancelled],
|
||||
OperationOutcome.Undetermined => LocaleManager.Instance[LocaleKeys.TrimXCIFileFileUndertermined],
|
||||
OperationOutcome.NoTrimNecessary => LocaleManager.Instance[LocaleKeys.Dialog_XCITrimmer_NoTrimNecessaryMessage],
|
||||
OperationOutcome.NoUntrimPossible => LocaleManager.Instance[LocaleKeys.Dialog_XCITrimmer_NoUntrimPossibleMessage],
|
||||
OperationOutcome.ReadOnlyFileCannotFix => LocaleManager.Instance[LocaleKeys.Dialog_XCITrimmer_ReadOnlyFileCannotFixMessage],
|
||||
OperationOutcome.FreeSpaceCheckFailed => LocaleManager.Instance[LocaleKeys.Dialog_XCITrimmer_FreeSpaceCheckFailedMessage],
|
||||
OperationOutcome.InvalidXCIFile => LocaleManager.Instance[LocaleKeys.Dialog_XCITrimmer_InvalidDataMessage],
|
||||
OperationOutcome.FileIOWriteError => LocaleManager.Instance[LocaleKeys.Dialog_XCITrimmer_WriteErrorMessage],
|
||||
OperationOutcome.FileSizeChanged => LocaleManager.Instance[LocaleKeys.Dialog_XCITrimmer_SizeChangedMessage],
|
||||
OperationOutcome.Cancelled => LocaleManager.Instance[LocaleKeys.Dialog_XCITrimmer_TrimCancelledMessage],
|
||||
OperationOutcome.Undetermined => LocaleManager.Instance[LocaleKeys.Dialog_XCITrimmer_NoOperationPerformedMessage],
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
using Ryujinx.Ava.UI.ViewModels;
|
||||
using Ryujinx.Common.Configuration.Hid;
|
||||
using System;
|
||||
|
||||
namespace Ryujinx.Ava.UI.Models.Input
|
||||
{
|
||||
public class PlayerInputDeviceAssignmentItem : BaseModel
|
||||
{
|
||||
public DeviceType DeviceType { get; init; }
|
||||
|
||||
public string Id { get; init; }
|
||||
|
||||
public string Name { get; init; }
|
||||
|
||||
public AssignedInputDeviceType AssignedType =>
|
||||
DeviceType == DeviceType.Keyboard ? AssignedInputDeviceType.Keyboard : AssignedInputDeviceType.Controller;
|
||||
|
||||
public bool IsAssigned
|
||||
{
|
||||
get;
|
||||
set
|
||||
{
|
||||
field = value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasBoundProfileName => !string.IsNullOrWhiteSpace(BoundProfileName);
|
||||
|
||||
public string BoundProfileName
|
||||
{
|
||||
get;
|
||||
set
|
||||
{
|
||||
field = value;
|
||||
OnPropertyChanged();
|
||||
OnPropertyChanged(nameof(HasBoundProfileName));
|
||||
}
|
||||
}
|
||||
|
||||
public bool HasAssignedToPlayers => !string.IsNullOrWhiteSpace(AssignedToPlayers);
|
||||
|
||||
/// <summary>
|
||||
/// Comma-separated list of player names (e.g. "Player 1, Player 3")
|
||||
/// that have this device assigned. Empty if no other player uses it.
|
||||
/// </summary>
|
||||
public string AssignedToPlayers
|
||||
{
|
||||
get;
|
||||
set
|
||||
{
|
||||
field = value;
|
||||
OnPropertyChanged();
|
||||
OnPropertyChanged(nameof(HasAssignedToPlayers));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when this device is assigned to another player and
|
||||
/// AllowDuplicateDeviceAssignment is disabled, making it unclickable.
|
||||
/// </summary>
|
||||
public bool IsDisabledByOtherPlayer
|
||||
{
|
||||
get;
|
||||
set
|
||||
{
|
||||
field = value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -76,6 +76,8 @@ namespace Ryujinx.Ava.UI.ViewModels
|
||||
[ObservableProperty] public partial string LoadHeading { get; set; }
|
||||
|
||||
[ObservableProperty] public partial string CacheLoadStatus { get; set; }
|
||||
|
||||
[ObservableProperty] public partial string Splash { get; set; }
|
||||
|
||||
[ObservableProperty] public partial string DockedStatusText { get; set; }
|
||||
|
||||
@@ -494,6 +496,8 @@ namespace Ryujinx.Ava.UI.ViewModels
|
||||
|
||||
public bool HasCompatibilityEntry => SelectedApplication.HasPlayabilityInfo;
|
||||
|
||||
public bool IsXCIFile => Path.GetExtension(SelectedApplication.Path)?.ToLower() == ".xci";
|
||||
|
||||
public bool HasDlc => ApplicationLibrary.HasDlcs(SelectedApplication.Id);
|
||||
|
||||
public bool OpenUserSaveDirectoryEnabled => SelectedApplication.HasControlHolder &&
|
||||
@@ -788,7 +792,7 @@ namespace Ryujinx.Ava.UI.ViewModels
|
||||
{
|
||||
ApplicationSort.Favorite => LocaleManager.Instance[LocaleKeys.CommonFavorite],
|
||||
ApplicationSort.TitleId => LocaleManager.Instance[LocaleKeys.DlcManagerTableHeadingTitleIdLabel],
|
||||
ApplicationSort.Title => LocaleManager.Instance[LocaleKeys.GameListHeaderApplication],
|
||||
ApplicationSort.Title => LocaleManager.Instance[LocaleKeys.Common_Sort_NameLabel],
|
||||
ApplicationSort.Developer => LocaleManager.Instance[LocaleKeys.GameListSortDeveloper],
|
||||
ApplicationSort.LastPlayed => LocaleManager.Instance[LocaleKeys.GameListSortLastPlayed],
|
||||
ApplicationSort.TotalTimePlayed => LocaleManager.Instance[LocaleKeys.GameListSortTimePlayed],
|
||||
@@ -1256,6 +1260,7 @@ namespace Ryujinx.Ava.UI.ViewModels
|
||||
break;
|
||||
case ShaderCacheLoadingState shaderCacheState:
|
||||
CacheLoadStatus = $"{current} / {total}";
|
||||
Splash = $"\"{SplashTextHelper.GetSplash()}\"";
|
||||
switch (shaderCacheState)
|
||||
{
|
||||
case ShaderCacheLoadingState.Start:
|
||||
@@ -1932,13 +1937,13 @@ namespace Ryujinx.Ava.UI.ViewModels
|
||||
|
||||
if (version != null)
|
||||
{
|
||||
LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.StatusBar_FirmwareVersion, version.VersionString);
|
||||
LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.StatusBar_FirmwareVersionLabel, version.VersionString);
|
||||
|
||||
hasApplet = version.Major > 3;
|
||||
}
|
||||
else
|
||||
{
|
||||
LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.StatusBar_FirmwareVersion, "NaN");
|
||||
LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.StatusBar_FirmwareVersionLabel, "NaN");
|
||||
}
|
||||
|
||||
IsAppletMenuActive = hasApplet;
|
||||
@@ -2314,7 +2319,7 @@ namespace Ryujinx.Ava.UI.ViewModels
|
||||
if (notifyUser != null)
|
||||
{
|
||||
await ContentDialogHelper.CreateWarningDialog(
|
||||
LocaleManager.Instance[LocaleKeys.TrimXCIFileFailedPrimaryText],
|
||||
LocaleManager.Instance[LocaleKeys.Dialog_XCITrimmer_TrimFailedMessage],
|
||||
notifyUser
|
||||
);
|
||||
}
|
||||
@@ -2340,18 +2345,18 @@ namespace Ryujinx.Ava.UI.ViewModels
|
||||
|
||||
if (trimmer.CanBeTrimmed)
|
||||
{
|
||||
double savings = (double)trimmer.DiskSpaceSavingsB / 1024.0 / 1024.0;
|
||||
double currentFileSize = (double)trimmer.FileSizeB / 1024.0 / 1024.0;
|
||||
double cartDataSize = (double)trimmer.DataSizeB / 1024.0 / 1024.0;
|
||||
int savings = (int)Math.Round((double)trimmer.DiskSpaceSavingsB / 1024.0 / 1024.0);
|
||||
int currentFileSize = (int)Math.Round((double)trimmer.FileSizeB / 1024.0 / 1024.0);
|
||||
int cartDataSize = (int)Math.Round((double)trimmer.DataSizeB / 1024.0 / 1024.0);
|
||||
string secondaryText = LocaleManager.Instance.UpdateAndGetDynamicValue(
|
||||
LocaleKeys.TrimXCIFileDialogSecondaryText, currentFileSize, cartDataSize, savings);
|
||||
LocaleKeys.Dialog_XCITrimmer_SecondaryMessage, currentFileSize.ToString("0"), cartDataSize.ToString("0"), savings.ToString("0"));
|
||||
|
||||
UserResult result = await ContentDialogHelper.CreateConfirmationDialog(
|
||||
LocaleManager.Instance[LocaleKeys.TrimXCIFileDialogPrimaryText],
|
||||
LocaleManager.Instance[LocaleKeys.Dialog_XCITrimmer_PrimaryMessage],
|
||||
secondaryText,
|
||||
LocaleManager.Instance[LocaleKeys.Continue],
|
||||
LocaleManager.Instance[LocaleKeys.Cancel],
|
||||
LocaleManager.Instance[LocaleKeys.GameListContextMenuTrimXCI]
|
||||
LocaleManager.Instance[LocaleKeys.GameListContextMenu_TrimXCIButton]
|
||||
);
|
||||
|
||||
if (result == UserResult.Yes)
|
||||
@@ -2361,8 +2366,8 @@ namespace Ryujinx.Ava.UI.ViewModels
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
StatusBarProgressStatusText =
|
||||
LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.StatusBarXCIFileTrimming,
|
||||
Path.GetFileName(filename));
|
||||
LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.StatusBar_TrimmingXCILabel,
|
||||
Path.GetFileNameWithoutExtension(filename));
|
||||
StatusBarProgressStatusVisible = true;
|
||||
StatusBarProgressMaximum = 1;
|
||||
StatusBarProgressValue = 0;
|
||||
|
||||
@@ -16,7 +16,7 @@ using static Ryujinx.Common.Utilities.XCIFileTrimmer;
|
||||
|
||||
namespace Ryujinx.Ava.UI.ViewModels
|
||||
{
|
||||
public class XciTrimmerViewModel : BaseModel
|
||||
public class XCITrimmerViewModel : BaseModel
|
||||
{
|
||||
private const long BytesPerMb = 1024 * 1024;
|
||||
|
||||
@@ -29,11 +29,11 @@ namespace Ryujinx.Ava.UI.ViewModels
|
||||
public enum SortField
|
||||
{
|
||||
Name,
|
||||
Saved
|
||||
Savings,
|
||||
Status
|
||||
}
|
||||
|
||||
private const string _FileExtXCI = "XCI";
|
||||
|
||||
private const string _fileExtXCI = "XCI";
|
||||
private readonly Ryujinx.Common.Logging.XCIFileTrimmerLog _logger;
|
||||
private ApplicationLibrary ApplicationLibrary => _mainWindowViewModel.ApplicationLibrary;
|
||||
private Optional<XCITrimmerFileModel> _processingApplication = null;
|
||||
@@ -45,8 +45,11 @@ namespace Ryujinx.Ava.UI.ViewModels
|
||||
private string _search;
|
||||
private ProcessingMode _processingMode;
|
||||
private SortField _sortField = SortField.Name;
|
||||
private int _processingCurrent;
|
||||
private int _processingTotal;
|
||||
|
||||
|
||||
public XciTrimmerViewModel(MainWindowViewModel mainWindowViewModel)
|
||||
public XCITrimmerViewModel(MainWindowViewModel mainWindowViewModel)
|
||||
{
|
||||
_logger = new XCITrimmerLog.TrimmerWindow(this);
|
||||
_mainWindowViewModel = mainWindowViewModel;
|
||||
@@ -56,7 +59,7 @@ namespace Ryujinx.Ava.UI.ViewModels
|
||||
private void LoadXCIApplications()
|
||||
{
|
||||
IEnumerable<ApplicationData> apps = ApplicationLibrary.Applications.Items
|
||||
.Where(app => app.FileExtension == _FileExtXCI);
|
||||
.Where(app => app.FileExtension == _fileExtXCI);
|
||||
|
||||
foreach (ApplicationData xciApp in apps)
|
||||
AddOrUpdateXCITrimmerFile(CreateXCITrimmerFile(xciApp.Path));
|
||||
@@ -64,11 +67,9 @@ namespace Ryujinx.Ava.UI.ViewModels
|
||||
ApplicationsChanged();
|
||||
}
|
||||
|
||||
private XCITrimmerFileModel CreateXCITrimmerFile(
|
||||
string path,
|
||||
OperationOutcome operationOutcome = OperationOutcome.Undetermined)
|
||||
private XCITrimmerFileModel CreateXCITrimmerFile(string path, OperationOutcome operationOutcome = OperationOutcome.Undetermined)
|
||||
{
|
||||
ApplicationData xciApp = ApplicationLibrary.Applications.Items.First(app => app.FileExtension == _FileExtXCI && app.Path == path);
|
||||
ApplicationData xciApp = ApplicationLibrary.Applications.Items.First(app => app.FileExtension == _fileExtXCI && app.Path == path);
|
||||
return XCITrimmerFileModel.FromApplicationData(xciApp, _logger) with { ProcessingOutcome = operationOutcome };
|
||||
}
|
||||
|
||||
@@ -90,11 +91,15 @@ namespace Ryujinx.Ava.UI.ViewModels
|
||||
SortAndFilter();
|
||||
}
|
||||
|
||||
public bool AnySelected =>
|
||||
_selectedXCIFiles.Count > 0;
|
||||
|
||||
private void SortingChanged()
|
||||
{
|
||||
OnPropertiesChanged(
|
||||
nameof(IsSortedByName),
|
||||
nameof(IsSortedBySaved),
|
||||
nameof(IsSortedBySavings),
|
||||
nameof(IsSortedByStatus),
|
||||
nameof(SortingAscending),
|
||||
nameof(SortingField),
|
||||
nameof(SortingFieldName));
|
||||
@@ -114,6 +119,7 @@ namespace Ryujinx.Ava.UI.ViewModels
|
||||
nameof(Status),
|
||||
nameof(PotentialSavings),
|
||||
nameof(ActualSavings),
|
||||
nameof(SavingsDifference),
|
||||
nameof(CanTrim),
|
||||
nameof(CanUntrim));
|
||||
|
||||
@@ -123,16 +129,30 @@ namespace Ryujinx.Ava.UI.ViewModels
|
||||
|
||||
private void SelectionChanged(bool displayedChanged = true)
|
||||
{
|
||||
OnPropertiesChanged(
|
||||
nameof(Status),
|
||||
nameof(CanTrim),
|
||||
nameof(CanUntrim),
|
||||
nameof(SelectedXCIFiles));
|
||||
OnPropertyChanged(nameof(Status));
|
||||
OnPropertyChanged(nameof(CanTrim));
|
||||
OnPropertyChanged(nameof(CanUntrim));
|
||||
OnPropertyChanged(nameof(SelectedXCIFiles));
|
||||
OnPropertyChanged(nameof(AnySelected));
|
||||
OnPropertyChanged(nameof(SelectToggleText));
|
||||
|
||||
if (displayedChanged)
|
||||
OnPropertyChanged(nameof(SelectedDisplayedXCIFiles));
|
||||
}
|
||||
|
||||
public void ToggleSelect()
|
||||
{
|
||||
if (AnySelected)
|
||||
DeselectAll();
|
||||
else
|
||||
SelectAll();
|
||||
}
|
||||
|
||||
public string SelectToggleText =>
|
||||
AnySelected
|
||||
? LocaleManager.Instance[LocaleKeys.XCITrimmer_ClearSelectionButton]
|
||||
: LocaleManager.Instance[LocaleKeys.XCITrimmer_SelectAllButton];
|
||||
|
||||
private void ProcessingChanged()
|
||||
{
|
||||
OnPropertiesChanged(
|
||||
@@ -167,6 +187,14 @@ namespace Ryujinx.Ava.UI.ViewModels
|
||||
(processingMode == ProcessingMode.Trimming && xci.Trimmable)
|
||||
)).ToList();
|
||||
|
||||
_processingTotal = toProcess.Count;
|
||||
_processingCurrent = 0;
|
||||
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
OnPropertyChanged(nameof(Status));
|
||||
});
|
||||
|
||||
List<XCITrimmerFileModel> viewsSaved = DisplayedXCIFiles.ToList();
|
||||
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
@@ -219,6 +247,12 @@ namespace Ryujinx.Ava.UI.ViewModels
|
||||
ProcessingApplication = null;
|
||||
});
|
||||
}
|
||||
_processingCurrent++;
|
||||
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
OnPropertyChanged(nameof(Status));
|
||||
});
|
||||
}
|
||||
}
|
||||
finally
|
||||
@@ -226,9 +260,20 @@ namespace Ryujinx.Ava.UI.ViewModels
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
_displayedXCIFiles.AddOrReplaceMatching(_allXCIFiles, viewsSaved);
|
||||
_selectedXCIFiles.AddOrReplaceMatching(_allXCIFiles, toProcess);
|
||||
|
||||
Processing = false;
|
||||
ApplicationsChanged();
|
||||
|
||||
_selectedXCIFiles.Clear();
|
||||
|
||||
foreach (var processed in toProcess)
|
||||
{
|
||||
var updated = _allXCIFiles.FirstOrDefault(x => x.Path == processed.Path);
|
||||
if (updated != null)
|
||||
_selectedXCIFiles.Add(updated);
|
||||
}
|
||||
|
||||
SelectionChanged();
|
||||
});
|
||||
}
|
||||
})
|
||||
@@ -254,9 +299,9 @@ namespace Ryujinx.Ava.UI.ViewModels
|
||||
|
||||
private class CompareXCITrimmerFiles : IComparer<XCITrimmerFileModel>
|
||||
{
|
||||
private readonly XciTrimmerViewModel _viewModel;
|
||||
private readonly XCITrimmerViewModel _viewModel;
|
||||
|
||||
public CompareXCITrimmerFiles(XciTrimmerViewModel ViewModel)
|
||||
public CompareXCITrimmerFiles(XCITrimmerViewModel ViewModel)
|
||||
{
|
||||
_viewModel = ViewModel;
|
||||
}
|
||||
@@ -270,9 +315,13 @@ namespace Ryujinx.Ava.UI.ViewModels
|
||||
case SortField.Name:
|
||||
result = x.Name.CompareTo(y.Name);
|
||||
break;
|
||||
case SortField.Saved:
|
||||
case SortField.Savings:
|
||||
result = x.PotentialSavingsB.CompareTo(y.PotentialSavingsB);
|
||||
break;
|
||||
case SortField.Status:
|
||||
|
||||
result = x.CurrentSavingsB.CompareTo(y.CurrentSavingsB);
|
||||
break;
|
||||
}
|
||||
|
||||
if (!_viewModel.SortingAscending)
|
||||
@@ -312,15 +361,16 @@ namespace Ryujinx.Ava.UI.ViewModels
|
||||
}
|
||||
}
|
||||
|
||||
public void SelectDisplayed()
|
||||
public void SelectAll()
|
||||
{
|
||||
SelectedXCIFiles.Clear();
|
||||
SelectedXCIFiles.AddRange(DisplayedXCIFiles);
|
||||
SelectionChanged();
|
||||
}
|
||||
|
||||
public void DeselectDisplayed()
|
||||
public void DeselectAll()
|
||||
{
|
||||
SelectedXCIFiles.RemoveMany(DisplayedXCIFiles);
|
||||
SelectedXCIFiles.Clear();
|
||||
SelectionChanged();
|
||||
}
|
||||
|
||||
@@ -426,16 +476,23 @@ namespace Ryujinx.Ava.UI.ViewModels
|
||||
{
|
||||
return _processingMode switch
|
||||
{
|
||||
ProcessingMode.Trimming => string.Format(LocaleManager.Instance[LocaleKeys.XCITrimmerTitleStatusTrimming], DisplayedXCIFiles.Count),
|
||||
ProcessingMode.Untrimming => string.Format(LocaleManager.Instance[LocaleKeys.XCITrimmerTitleStatusUntrimming], DisplayedXCIFiles.Count),
|
||||
ProcessingMode.Trimming => string.Format(
|
||||
LocaleManager.Instance[LocaleKeys.XCITrimmer_StatusTrimmingLabel],
|
||||
_processingCurrent,
|
||||
_processingTotal),
|
||||
|
||||
ProcessingMode.Untrimming => string.Format(
|
||||
LocaleManager.Instance[LocaleKeys.XCITrimmer_StatusUntrimmingLabel],
|
||||
_processingCurrent,
|
||||
_processingTotal),
|
||||
_ => string.Empty
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
return string.IsNullOrEmpty(Search) ?
|
||||
string.Format(LocaleManager.Instance[LocaleKeys.XCITrimmerTitleStatusCount], SelectedXCIFiles.Count, AllXCIFiles.Count) :
|
||||
string.Format(LocaleManager.Instance[LocaleKeys.XCITrimmerTitleStatusCountWithFilter], SelectedXCIFiles.Count, AllXCIFiles.Count, DisplayedXCIFiles.Count);
|
||||
string.Format(LocaleManager.Instance[LocaleKeys.XCITrimmer_StatusCountLabel], SelectedXCIFiles.Count, AllXCIFiles.Count) :
|
||||
string.Format(LocaleManager.Instance[LocaleKeys.XCITrimmer_StatusCountWithFilterLabel], SelectedXCIFiles.Count, AllXCIFiles.Count, DisplayedXCIFiles.Count);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -466,8 +523,9 @@ namespace Ryujinx.Ava.UI.ViewModels
|
||||
{
|
||||
return SortingField switch
|
||||
{
|
||||
SortField.Name => LocaleManager.Instance[LocaleKeys.XCITrimmerSortName],
|
||||
SortField.Saved => LocaleManager.Instance[LocaleKeys.XCITrimmerSortSaved],
|
||||
SortField.Name => LocaleManager.Instance[LocaleKeys.Common_Sort_NameLabel],
|
||||
SortField.Savings => LocaleManager.Instance[LocaleKeys.Common_Sort_SavingsLabel],
|
||||
SortField.Status => LocaleManager.Instance[LocaleKeys.Common_Sort_TrimStatusLabel],
|
||||
_ => string.Empty,
|
||||
};
|
||||
}
|
||||
@@ -488,11 +546,13 @@ namespace Ryujinx.Ava.UI.ViewModels
|
||||
get => _sortField == SortField.Name;
|
||||
}
|
||||
|
||||
public bool IsSortedBySaved
|
||||
public bool IsSortedBySavings
|
||||
{
|
||||
get => _sortField == SortField.Saved;
|
||||
get => _sortField == SortField.Savings;
|
||||
}
|
||||
|
||||
public bool IsSortedByStatus => _sortField == SortField.Status;
|
||||
|
||||
public AvaloniaList<XCITrimmerFileModel> SelectedXCIFiles
|
||||
{
|
||||
get => _selectedXCIFiles;
|
||||
@@ -517,7 +577,7 @@ namespace Ryujinx.Ava.UI.ViewModels
|
||||
{
|
||||
get
|
||||
{
|
||||
return string.Format(LocaleManager.Instance[LocaleKeys.XCITrimmerSavingsMb], AllXCIFiles.Sum(xci => xci.PotentialSavingsB / BytesPerMb));
|
||||
return string.Format(LocaleManager.Instance[LocaleKeys.XCITrimmer_MBLabel], AllXCIFiles.Sum(xci => xci.PotentialSavingsB / BytesPerMb));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -525,7 +585,19 @@ namespace Ryujinx.Ava.UI.ViewModels
|
||||
{
|
||||
get
|
||||
{
|
||||
return string.Format(LocaleManager.Instance[LocaleKeys.XCITrimmerSavingsMb], AllXCIFiles.Sum(xci => xci.CurrentSavingsB / BytesPerMb));
|
||||
return string.Format(LocaleManager.Instance[LocaleKeys.XCITrimmer_MBLabel], AllXCIFiles.Sum(xci => xci.CurrentSavingsB / BytesPerMb));
|
||||
}
|
||||
}
|
||||
|
||||
public string SavingsDifference
|
||||
{
|
||||
get
|
||||
{
|
||||
long potentialSavings = AllXCIFiles.Sum(xci => xci.PotentialSavingsB);
|
||||
long actualSavings = AllXCIFiles.Sum(xci => xci.CurrentSavingsB);
|
||||
long differenceMb = (potentialSavings - actualSavings) / BytesPerMb;
|
||||
|
||||
return string.Format(LocaleManager.Instance[LocaleKeys.XCITrimmer_MBLabel], differenceMb);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@
|
||||
MinHeight="29"
|
||||
MaxHeight="29"
|
||||
HorizontalAlignment="Stretch"
|
||||
Watermark="{ext:Locale Search}"
|
||||
Watermark="{ext:Locale Common_Search_SearchWatermark}"
|
||||
Text="{Binding Search}" />
|
||||
</Grid>
|
||||
</Panel>
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
MinHeight="27"
|
||||
MaxHeight="27"
|
||||
HorizontalAlignment="Stretch"
|
||||
Watermark="{ext:Locale Search}"
|
||||
Watermark="{ext:Locale Common_Search_SearchWatermark}"
|
||||
Text="{Binding Search}" />
|
||||
</Grid>
|
||||
</Panel>
|
||||
|
||||
185
src/Ryujinx/UI/Views/Dialog/XCITrimmerView.axaml
Normal file
185
src/Ryujinx/UI/Views/Dialog/XCITrimmerView.axaml
Normal file
@@ -0,0 +1,185 @@
|
||||
<UserControl
|
||||
x:Class="Ryujinx.Ava.UI.Views.Dialog.XCITrimmerView"
|
||||
xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:ui="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia"
|
||||
xmlns:ext="clr-namespace:Ryujinx.Ava.Common.Markup"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:viewModels="clr-namespace:Ryujinx.Ava.UI.ViewModels"
|
||||
xmlns:helpers="clr-namespace:Ryujinx.Ava.UI.Helpers"
|
||||
xmlns:models="clr-namespace:Ryujinx.Ava.Common.Models"
|
||||
Width="700"
|
||||
Height="600"
|
||||
x:DataType="viewModels:XCITrimmerViewModel"
|
||||
Focusable="True"
|
||||
mc:Ignorable="d">
|
||||
<Grid Margin="25,10,25,0" RowDefinitions="Auto,Auto,*,Auto,Auto">
|
||||
<Panel Margin="0,0,0,10" Grid.Row="0">
|
||||
<TextBlock Text="{Binding Status}" />
|
||||
</Panel>
|
||||
<Grid Margin="0,0,0,10" Grid.Row="1" IsVisible="{Binding !Processing}" ColumnDefinitions="Auto,*">
|
||||
<StackPanel Grid.Column="0" Orientation="Horizontal" HorizontalAlignment="Left" VerticalAlignment="Center">
|
||||
<Button Margin="0,0,10,0" MinWidth="90" Click="ToggleSelect">
|
||||
<TextBlock Text="{Binding SelectToggleText}" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Grid.Column="1" HorizontalAlignment="Right">
|
||||
<DropDownButton Width="170" HorizontalAlignment="Right" VerticalAlignment="Center" Content="{Binding SortingFieldName}">
|
||||
<DropDownButton.Flyout>
|
||||
<Flyout Placement="Bottom">
|
||||
<StackPanel Margin="0" HorizontalAlignment="Stretch" Orientation="Vertical">
|
||||
<StackPanel>
|
||||
<RadioButton Checked="Sort_Checked" Content="{ext:Locale Common_Sort_NameLabel}" GroupName="Sort" IsChecked="{Binding IsSortedByName, Mode=OneTime}" Tag="Name" />
|
||||
<RadioButton Checked="Sort_Checked" Content="{ext:Locale Common_Sort_SavingsLabel}" GroupName="Sort" IsChecked="{Binding IsSortedBySavings, Mode=OneTime}" Tag="Savings" />
|
||||
<RadioButton Checked="Sort_Checked" Content="{ext:Locale Common_Sort_TrimStatusLabel}" GroupName="Sort" IsChecked="{Binding IsSortedByStatus, Mode=OneTime}" Tag="Status" />
|
||||
</StackPanel>
|
||||
<Border Width="60" Height="2" Margin="5" HorizontalAlignment="Stretch" BorderBrush="White" BorderThickness="0,1,0,0">
|
||||
<Separator Height="0" HorizontalAlignment="Stretch" />
|
||||
</Border>
|
||||
<RadioButton Checked="Order_Checked" Content="{ext:Locale Common_Sort_OrderAscending}" GroupName="Order" IsChecked="{Binding SortingAscending, Mode=OneTime}" Tag="Ascending" />
|
||||
<RadioButton Checked="Order_Checked" Content="{ext:Locale Common_Sort_OrderDescending}" GroupName="Order" IsChecked="{Binding !SortingAscending, Mode=OneTime}" Tag="Descending" />
|
||||
</StackPanel>
|
||||
</Flyout>
|
||||
</DropDownButton.Flyout>
|
||||
</DropDownButton>
|
||||
<TextBox
|
||||
Width="200"
|
||||
MaxWidth="200"
|
||||
Margin="5,0,0,0"
|
||||
VerticalAlignment="Center"
|
||||
HorizontalAlignment="Right"
|
||||
Watermark="{ext:Locale Common_Search_SearchWatermark}"
|
||||
Text="{Binding Search}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
<Border
|
||||
Grid.Row="2"
|
||||
Margin="0,0,0,20"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Stretch"
|
||||
BorderBrush="{DynamicResource AppListHoverBackgroundColor}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="5"
|
||||
Padding="2.5">
|
||||
<ListBox
|
||||
AutoScrollToSelectedItem="{Binding Processing}"
|
||||
SelectedItem="{Binding NullableProcessingApplication}"
|
||||
SelectionMode="Multiple, Toggle"
|
||||
Background="Transparent"
|
||||
SelectionChanged="OnSelectionChanged"
|
||||
SelectedItems="{Binding SelectedDisplayedXCIFiles, Mode=OneWay}"
|
||||
ItemsSource="{Binding DisplayedXCIFiles}"
|
||||
IsEnabled="{Binding !Processing}">
|
||||
<ListBox.Styles>
|
||||
<Style Selector="ListBoxItem">
|
||||
<Setter Property="Margin" Value="0" />
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
</Style>
|
||||
</ListBox.Styles>
|
||||
<ListBox.DataTemplates>
|
||||
<DataTemplate DataType="models:XCITrimmerFileModel">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<ui:SymbolIcon
|
||||
Grid.Column="0"
|
||||
Margin="10,0,20,0"
|
||||
Width="15"
|
||||
Height="15"
|
||||
FontSize="15"
|
||||
FlowDirection="LeftToRight"
|
||||
HorizontalAlignment="Left"
|
||||
VerticalAlignment="Center"
|
||||
Symbol="{Binding Converter={x:Static helpers:XCITrimmerFileStatusConverter.Instance}}">
|
||||
<ToolTip.Tip>
|
||||
<StackPanel>
|
||||
<TextBlock
|
||||
Text="{Binding StatusText}" />
|
||||
<TextBlock
|
||||
MaxLines="5"
|
||||
MaxWidth="200"
|
||||
TextWrapping="Wrap"
|
||||
IsVisible="{Binding HasStatusDetail}"
|
||||
Text="{Binding ., Converter={x:Static helpers:XCITrimmerFileStatusDetailConverter.Instance}}" />
|
||||
</StackPanel>
|
||||
</ToolTip.Tip>
|
||||
</ui:SymbolIcon>
|
||||
<TextBlock
|
||||
Grid.Column="1"
|
||||
Margin="0,0,10,0"
|
||||
VerticalAlignment="Center"
|
||||
MaxLines="2"
|
||||
TextWrapping="Wrap"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
Text="{Binding Name}" />
|
||||
<Grid
|
||||
Grid.Column="2"
|
||||
MinWidth="120"
|
||||
ColumnDefinitions="*">
|
||||
<ProgressBar
|
||||
Height="10"
|
||||
Margin="10,0,10,0"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Center"
|
||||
CornerRadius="5"
|
||||
IsVisible="{Binding $parent[UserControl].DataContext.Processing}"
|
||||
Maximum="100"
|
||||
Minimum="0"
|
||||
Value="{Binding PercentageProgress}" />
|
||||
<TextBlock
|
||||
Margin="10,0,10,0"
|
||||
FlowDirection="LeftToRight"
|
||||
HorizontalAlignment="Right"
|
||||
VerticalAlignment="Center"
|
||||
MaxLines="1"
|
||||
IsVisible="{Binding !$parent[UserControl].DataContext.Processing}"
|
||||
Text="{Binding ., Converter={x:Static helpers:XCITrimmerFileSpaceSavingsConverter.Instance}}" />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListBox.DataTemplates>
|
||||
</ListBox>
|
||||
</Border>
|
||||
<StackPanel Grid.Row="3" Margin="0,0,0,20" Spacing="5" FlowDirection="LeftToRight" HorizontalAlignment="Center" VerticalAlignment="Center" Orientation="Horizontal">
|
||||
<TextBlock Classes="h1" HorizontalAlignment="Center" VerticalAlignment="Center" MaxLines="1" Text="{ext:Locale XCITrimmer_SavedLabel}" />
|
||||
<TextBlock Classes="h1" HorizontalAlignment="Center" VerticalAlignment="Center" FontWeight="Regular" MaxLines="1" Text="{Binding ActualSavings}" />
|
||||
<TextBlock Classes="h1" HorizontalAlignment="Center" VerticalAlignment="Center" FontWeight="Regular" MaxLines="1" Text="•" />
|
||||
<TextBlock Classes="h1" HorizontalAlignment="Center" VerticalAlignment="Center" MaxLines="1" Text="{ext:Locale XCITrimmer_RemainingLabel}" />
|
||||
<TextBlock Classes="h1" HorizontalAlignment="Center" VerticalAlignment="Center" FontWeight="Regular" MaxLines="1" Text="{Binding SavingsDifference}" />
|
||||
</StackPanel>
|
||||
<Panel Grid.Row="4" Margin="0,10,0,0" HorizontalAlignment="Stretch">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="10" HorizontalAlignment="Left">
|
||||
<Button Name="TrimButton" MinWidth="90" Click="Trim" IsEnabled="{Binding CanTrim}">
|
||||
<TextBlock Text="{ext:Locale XCITrimmer_TrimButton}" />
|
||||
</Button>
|
||||
<Button Name="UntrimButton" MinWidth="90" Click="Untrim" IsEnabled="{Binding CanUntrim}">
|
||||
<TextBlock Text="{ext:Locale XCITrimmer_UntrimButton}" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="10" HorizontalAlignment="Right">
|
||||
<Button Name="CancellingButton" MinWidth="90" Click="Cancel" IsEnabled="False">
|
||||
<Button.IsVisible>
|
||||
<MultiBinding Converter="{x:Static BoolConverters.And}">
|
||||
<Binding Path="Processing" />
|
||||
<Binding Path="Cancel" />
|
||||
</MultiBinding>
|
||||
</Button.IsVisible>
|
||||
<TextBlock Text="{ext:Locale InputDialogCancelling}" />
|
||||
</Button>
|
||||
<Button Name="CancelButton" MinWidth="90" Click="Cancel">
|
||||
<Button.IsVisible>
|
||||
<MultiBinding Converter="{x:Static BoolConverters.And}">
|
||||
<Binding Path="Processing" />
|
||||
<Binding Path="!Cancel" />
|
||||
</MultiBinding>
|
||||
</Button.IsVisible>
|
||||
<TextBlock Text="{ext:Locale InputDialogCancel}" />
|
||||
</Button>
|
||||
<Button Name="CloseButton" MinWidth="90" Click="Close" IsVisible="{Binding !Processing}">
|
||||
<TextBlock Text="{ext:Locale SettingsButtonClose}" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Panel>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
@@ -11,13 +11,19 @@ using System.Threading.Tasks;
|
||||
|
||||
namespace Ryujinx.Ava.UI.Views.Dialog
|
||||
{
|
||||
public partial class XciTrimmerView : RyujinxControl<XciTrimmerViewModel>
|
||||
public partial class XCITrimmerView : RyujinxControl<XCITrimmerViewModel>
|
||||
{
|
||||
public XciTrimmerView()
|
||||
public XCITrimmerView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
private void ToggleSelect(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is XCITrimmerViewModel vm)
|
||||
vm.ToggleSelect();
|
||||
}
|
||||
|
||||
public static async Task Show()
|
||||
{
|
||||
ContentDialog contentDialog = new()
|
||||
@@ -25,11 +31,11 @@ namespace Ryujinx.Ava.UI.Views.Dialog
|
||||
PrimaryButtonText = string.Empty,
|
||||
SecondaryButtonText = string.Empty,
|
||||
CloseButtonText = string.Empty,
|
||||
Content = new XciTrimmerView
|
||||
Content = new XCITrimmerView
|
||||
{
|
||||
ViewModel = new XciTrimmerViewModel(RyujinxApp.MainWindow.ViewModel)
|
||||
ViewModel = new XCITrimmerViewModel(RyujinxApp.MainWindow.ViewModel)
|
||||
},
|
||||
Title = LocaleManager.Instance[LocaleKeys.XCITrimmerWindowTitle]
|
||||
Title = LocaleManager.Instance[LocaleKeys.MenuBar_Actions_XCITrimmerButton]
|
||||
};
|
||||
|
||||
Style bottomBorder = new(x => x.OfType<Grid>().Name("DialogSpace").Child().OfType<Border>());
|
||||
@@ -63,7 +69,7 @@ namespace Ryujinx.Ava.UI.Views.Dialog
|
||||
public void Sort_Checked(object sender, RoutedEventArgs args)
|
||||
{
|
||||
if (sender is RadioButton { Tag: string sortField })
|
||||
ViewModel.SortingField = Enum.Parse<XciTrimmerViewModel.SortField>(sortField);
|
||||
ViewModel.SortingField = Enum.Parse<XCITrimmerViewModel.SortField>(sortField);
|
||||
}
|
||||
|
||||
public void Order_Checked(object sender, RoutedEventArgs args)
|
||||
@@ -1,311 +0,0 @@
|
||||
<UserControl
|
||||
x:Class="Ryujinx.Ava.UI.Views.Dialog.XciTrimmerView"
|
||||
xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:ext="clr-namespace:Ryujinx.Ava.Common.Markup"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:viewModels="clr-namespace:Ryujinx.Ava.UI.ViewModels"
|
||||
xmlns:helpers="clr-namespace:Ryujinx.Ava.UI.Helpers"
|
||||
xmlns:models="clr-namespace:Ryujinx.Ava.Common.Models"
|
||||
Width="700"
|
||||
Height="600"
|
||||
x:DataType="viewModels:XciTrimmerViewModel"
|
||||
Focusable="True"
|
||||
mc:Ignorable="d">
|
||||
<Grid Margin="20 0 20 0" RowDefinitions="Auto,Auto,*,Auto,Auto">
|
||||
<Panel
|
||||
Margin="10 10 10 10"
|
||||
Grid.Row="0">
|
||||
<TextBlock Text="{Binding Status}" />
|
||||
</Panel>
|
||||
<Panel
|
||||
Margin="0 0 10 10"
|
||||
IsVisible="{Binding !Processing}"
|
||||
Grid.Row="1">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<StackPanel
|
||||
Grid.Column="0"
|
||||
Orientation="Horizontal">
|
||||
<DropDownButton
|
||||
Width="150"
|
||||
HorizontalAlignment="Left"
|
||||
VerticalAlignment="Center"
|
||||
Content="{Binding SortingFieldName}">
|
||||
<DropDownButton.Flyout>
|
||||
<Flyout Placement="Bottom">
|
||||
<StackPanel
|
||||
Margin="0"
|
||||
HorizontalAlignment="Stretch"
|
||||
Orientation="Vertical">
|
||||
<StackPanel>
|
||||
<RadioButton
|
||||
Checked="Sort_Checked"
|
||||
Content="{ext:Locale XCITrimmerSortName}"
|
||||
GroupName="Sort"
|
||||
IsChecked="{Binding IsSortedByName, Mode=OneTime}"
|
||||
Tag="Name" />
|
||||
<RadioButton
|
||||
Checked="Sort_Checked"
|
||||
Content="{ext:Locale XCITrimmerSortSaved}"
|
||||
GroupName="Sort"
|
||||
IsChecked="{Binding IsSortedBySaved, Mode=OneTime}"
|
||||
Tag="Saved" />
|
||||
</StackPanel>
|
||||
<Border
|
||||
Width="60"
|
||||
Height="2"
|
||||
Margin="5"
|
||||
HorizontalAlignment="Stretch"
|
||||
BorderBrush="White"
|
||||
BorderThickness="0,1,0,0">
|
||||
<Separator Height="0" HorizontalAlignment="Stretch" />
|
||||
</Border>
|
||||
<RadioButton
|
||||
Checked="Order_Checked"
|
||||
Content="{ext:Locale OrderAscending}"
|
||||
GroupName="Order"
|
||||
IsChecked="{Binding SortingAscending, Mode=OneTime}"
|
||||
Tag="Ascending" />
|
||||
<RadioButton
|
||||
Checked="Order_Checked"
|
||||
Content="{ext:Locale OrderDescending}"
|
||||
GroupName="Order"
|
||||
IsChecked="{Binding !SortingAscending, Mode=OneTime}"
|
||||
Tag="Descending" />
|
||||
</StackPanel>
|
||||
</Flyout>
|
||||
</DropDownButton.Flyout>
|
||||
</DropDownButton>
|
||||
</StackPanel>
|
||||
<TextBox
|
||||
Grid.Column="1"
|
||||
MinHeight="29"
|
||||
MaxHeight="29"
|
||||
Margin="5 0 5 0"
|
||||
HorizontalAlignment="Stretch"
|
||||
Watermark="{ext:Locale Search}"
|
||||
Text="{Binding Search}" />
|
||||
<StackPanel
|
||||
Grid.Column="2"
|
||||
Orientation="Horizontal">
|
||||
<Button
|
||||
Name="SelectDisplayedButton"
|
||||
MinWidth="90"
|
||||
Margin="5"
|
||||
Command="{Binding SelectDisplayed}">
|
||||
<TextBlock Text="{ext:Locale XCITrimmerSelectDisplayed}" />
|
||||
</Button>
|
||||
<Button
|
||||
Name="DeselectDisplayedButton"
|
||||
MinWidth="90"
|
||||
Margin="5"
|
||||
Command="{Binding DeselectDisplayed}">
|
||||
<TextBlock Text="{ext:Locale XCITrimmerDeselectDisplayed}" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Panel>
|
||||
<Border
|
||||
Grid.Row="2"
|
||||
Margin="0 0 0 10"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Stretch"
|
||||
BorderBrush="{DynamicResource AppListHoverBackgroundColor}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="5"
|
||||
Padding="2.5">
|
||||
<ListBox
|
||||
AutoScrollToSelectedItem="{Binding Processing}"
|
||||
SelectedItem="{Binding NullableProcessingApplication}"
|
||||
SelectionMode="Multiple, Toggle"
|
||||
Background="Transparent"
|
||||
SelectionChanged="OnSelectionChanged"
|
||||
SelectedItems="{Binding SelectedDisplayedXCIFiles, Mode=OneWay}"
|
||||
ItemsSource="{Binding DisplayedXCIFiles}"
|
||||
IsEnabled="{Binding !Processing}">
|
||||
<ListBox.DataTemplates>
|
||||
<DataTemplate
|
||||
DataType="models:XCITrimmerFileModel">
|
||||
<Panel Margin="10">
|
||||
<Grid ColumnDefinitions="65*,35*">
|
||||
<TextBlock
|
||||
Grid.Column="0"
|
||||
Margin="10 0 10 0"
|
||||
HorizontalAlignment="Left"
|
||||
VerticalAlignment="Center"
|
||||
MaxLines="2"
|
||||
TextWrapping="Wrap"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
Text="{Binding Name}">
|
||||
</TextBlock>
|
||||
<Grid Grid.Column="1" ColumnDefinitions="45*,55*">
|
||||
<ProgressBar
|
||||
Height="10"
|
||||
Margin="10 0 10 0"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Center"
|
||||
CornerRadius="5"
|
||||
IsVisible="{Binding $parent[UserControl].((viewModels:XciTrimmerViewModel)DataContext).Processing}"
|
||||
Maximum="100"
|
||||
Minimum="0"
|
||||
Value="{Binding PercentageProgress}" />
|
||||
<TextBlock
|
||||
Grid.Column="0"
|
||||
Margin="10 0 10 0"
|
||||
HorizontalAlignment="Left"
|
||||
VerticalAlignment="Center"
|
||||
MaxLines="1"
|
||||
Text="{Binding ., Converter={x:Static helpers:XCITrimmerFileStatusConverter.Instance}}">
|
||||
<ToolTip.Tip>
|
||||
<StackPanel
|
||||
IsVisible="{Binding IsFailed}">
|
||||
<TextBlock
|
||||
Classes="h1"
|
||||
Text="{ext:Locale XCITrimmerTitleStatusFailed}" />
|
||||
<TextBlock
|
||||
Text="{Binding ., Converter={x:Static helpers:XCITrimmerFileStatusDetailConverter.Instance}}"
|
||||
MaxLines="5"
|
||||
MaxWidth="200"
|
||||
MaxHeight="100"
|
||||
TextTrimming="None"
|
||||
TextWrapping="Wrap"/>
|
||||
</StackPanel>
|
||||
</ToolTip.Tip>
|
||||
</TextBlock>
|
||||
<TextBlock
|
||||
Grid.Column="1"
|
||||
Margin="10 0 10 0"
|
||||
HorizontalAlignment="Left"
|
||||
VerticalAlignment="Center"
|
||||
MaxLines="1"
|
||||
Text="{Binding ., Converter={x:Static helpers:XCITrimmerFileSpaceSavingsConverter.Instance}}">>
|
||||
</TextBlock>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Panel>
|
||||
</DataTemplate>
|
||||
</ListBox.DataTemplates>
|
||||
<ListBox.Styles>
|
||||
<Style Selector="ListBoxItem">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
</Style>
|
||||
</ListBox.Styles>
|
||||
</ListBox>
|
||||
</Border>
|
||||
<Border
|
||||
Grid.Row="3"
|
||||
Margin="0 0 0 10"
|
||||
HorizontalAlignment="Stretch"
|
||||
BorderBrush="{DynamicResource AppListHoverBackgroundColor}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="5"
|
||||
Padding="2.5">
|
||||
<Grid ColumnDefinitions="Auto,*" RowDefinitions="Auto,Auto">
|
||||
<TextBlock
|
||||
Grid.Column="0"
|
||||
Grid.Row="0"
|
||||
Classes="h1"
|
||||
Margin="5"
|
||||
HorizontalAlignment="Right"
|
||||
VerticalAlignment="Center"
|
||||
MaxLines="1"
|
||||
Text="{ext:Locale XCITrimmerPotentialSavings}" />
|
||||
<TextBlock
|
||||
Grid.Column="0"
|
||||
Grid.Row="1"
|
||||
Classes="h1"
|
||||
Margin="5"
|
||||
HorizontalAlignment="Right"
|
||||
VerticalAlignment="Center"
|
||||
MaxLines="1"
|
||||
Text="{ext:Locale XCITrimmerActualSavings}" />
|
||||
<TextBlock
|
||||
Grid.Column="1"
|
||||
Grid.Row="0"
|
||||
Margin="5"
|
||||
HorizontalAlignment="Left"
|
||||
VerticalAlignment="Center"
|
||||
MaxLines="1"
|
||||
Text="{Binding PotentialSavings}" />
|
||||
<TextBlock
|
||||
Grid.Column="1"
|
||||
Grid.Row="1"
|
||||
Margin="5"
|
||||
HorizontalAlignment="Left"
|
||||
VerticalAlignment="Center"
|
||||
MaxLines="1"
|
||||
Text="{Binding ActualSavings}" />
|
||||
</Grid>
|
||||
</Border>
|
||||
<Panel
|
||||
Grid.Row="4"
|
||||
HorizontalAlignment="Stretch">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel
|
||||
Grid.Column="0"
|
||||
Orientation="Horizontal"
|
||||
Spacing="10"
|
||||
HorizontalAlignment="Left">
|
||||
<Button
|
||||
Name="TrimButton"
|
||||
MinWidth="90"
|
||||
Margin="5"
|
||||
Click="Trim"
|
||||
IsEnabled="{Binding CanTrim}">
|
||||
<TextBlock Text="{ext:Locale XCITrimmerTrim}" />
|
||||
</Button>
|
||||
<Button
|
||||
Name="UntrimButton"
|
||||
MinWidth="90"
|
||||
Margin="5"
|
||||
Click="Untrim"
|
||||
IsEnabled="{Binding CanUntrim}">
|
||||
<TextBlock Text="{ext:Locale XCITrimmerUntrim}" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
<StackPanel
|
||||
Grid.Column="1"
|
||||
Orientation="Horizontal"
|
||||
Spacing="10"
|
||||
HorizontalAlignment="Right">
|
||||
<Button
|
||||
Name="CancellingButton"
|
||||
MinWidth="90"
|
||||
Margin="5"
|
||||
Click="Cancel"
|
||||
IsEnabled="False">
|
||||
<Button.IsVisible>
|
||||
<MultiBinding Converter="{x:Static BoolConverters.And}">
|
||||
<Binding Path="Processing" />
|
||||
<Binding Path="Cancel" />
|
||||
</MultiBinding>
|
||||
</Button.IsVisible>
|
||||
<TextBlock Text="{ext:Locale InputDialogCancelling}" />
|
||||
</Button>
|
||||
<Button
|
||||
Name="CancelButton"
|
||||
MinWidth="90"
|
||||
Margin="5"
|
||||
Click="Cancel">
|
||||
<Button.IsVisible>
|
||||
<MultiBinding Converter="{x:Static BoolConverters.And}">
|
||||
<Binding Path="Processing" />
|
||||
<Binding Path="!Cancel" />
|
||||
</MultiBinding>
|
||||
</Button.IsVisible>
|
||||
<TextBlock Text="{ext:Locale InputDialogCancel}" />
|
||||
</Button>
|
||||
<Button
|
||||
Name="CloseButton"
|
||||
MinWidth="90"
|
||||
Margin="5"
|
||||
Click="Close"
|
||||
IsVisible="{Binding !Processing}">
|
||||
<TextBlock Text="{ext:Locale InputDialogClose}" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Panel>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
89
src/Ryujinx/UI/Views/Input/AssignedDevicesInputView.axaml
Normal file
89
src/Ryujinx/UI/Views/Input/AssignedDevicesInputView.axaml
Normal file
@@ -0,0 +1,89 @@
|
||||
<UserControl
|
||||
xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:ui="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia"
|
||||
xmlns:ext="clr-namespace:Ryujinx.Ava.Common.Markup"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:models="clr-namespace:Ryujinx.Ava.UI.Models.Input"
|
||||
xmlns:viewModels="clr-namespace:Ryujinx.Ava.UI.ViewModels.Input"
|
||||
x:Class="Ryujinx.Ava.UI.Views.Input.AssignedDevicesInputView"
|
||||
x:DataType="viewModels:InputViewModel"
|
||||
x:CompileBindings="True"
|
||||
mc:Ignorable="d">
|
||||
<Grid Margin="10" RowDefinitions="Auto,Auto">
|
||||
<Border
|
||||
Grid.Row="0"
|
||||
Padding="10,8"
|
||||
BorderBrush="{DynamicResource ThemeControlBorderColor}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="5"
|
||||
HorizontalAlignment="Stretch">
|
||||
<ItemsControl
|
||||
ItemsSource="{Binding PlayerInputDevices}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate DataType="models:PlayerInputDeviceAssignmentItem">
|
||||
<Border
|
||||
Margin="0,3"
|
||||
Padding="10,8"
|
||||
CornerRadius="4"
|
||||
Background="{DynamicResource ControlFillColorTertiary}"
|
||||
BorderBrush="{DynamicResource ThemeControlBorderColor}"
|
||||
BorderThickness="1"
|
||||
PointerPressed="DeviceRow_OnPointerPressed"
|
||||
Cursor="Hand"
|
||||
IsEnabled="{Binding !IsDisabledByOtherPlayer}">
|
||||
<Grid ColumnDefinitions="Auto,*,200">
|
||||
<CheckBox
|
||||
Grid.Column="0"
|
||||
IsChecked="{Binding IsAssigned}"
|
||||
Checked="AssignedDeviceCheckBox_OnCheckedChanged"
|
||||
Unchecked="AssignedDeviceCheckBox_OnCheckedChanged"
|
||||
VerticalAlignment="Center"
|
||||
Margin="0"
|
||||
Padding="0" />
|
||||
<StackPanel
|
||||
Grid.Column="1"
|
||||
Margin="8,0,0,0"
|
||||
VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding Name}" />
|
||||
<TextBlock
|
||||
Opacity="0.6"
|
||||
FontSize="12"
|
||||
MaxWidth="160"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
TextWrapping="NoWrap"
|
||||
MaxLines="1"
|
||||
IsVisible="{Binding HasBoundProfileName}">
|
||||
<Run Text="{ext:Locale ControllerSettingsProfile}" />
|
||||
<Run Text=":" />
|
||||
<Run Text="{Binding BoundProfileName}" />
|
||||
</TextBlock>
|
||||
</StackPanel>
|
||||
<TextBlock
|
||||
Grid.Column="2"
|
||||
VerticalAlignment="Center"
|
||||
HorizontalAlignment="Center"
|
||||
Margin="10,0,0,0"
|
||||
Opacity="0.6"
|
||||
FontSize="12"
|
||||
Text="{Binding AssignedToPlayers}"
|
||||
TextWrapping="Wrap"
|
||||
MaxWidth="200"
|
||||
IsVisible="{Binding HasAssignedToPlayers}" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</Border>
|
||||
<CheckBox
|
||||
Grid.Row="1"
|
||||
Margin="0,8,0,0"
|
||||
IsChecked="{Binding AllowDuplicateDeviceAssignment}">
|
||||
<TextBlock
|
||||
VerticalAlignment="Center"
|
||||
Text="{ext:Locale ControllerSettingsAllowDuplicateDeviceAssignment}" />
|
||||
</CheckBox>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
97
src/Ryujinx/UI/Views/Input/AssignedDevicesInputView.axaml.cs
Normal file
97
src/Ryujinx/UI/Views/Input/AssignedDevicesInputView.axaml.cs
Normal file
@@ -0,0 +1,97 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.VisualTree;
|
||||
using FluentAvalonia.UI.Controls;
|
||||
using Ryujinx.Ava.Common.Locale;
|
||||
using Ryujinx.Ava.UI.Models.Input;
|
||||
using Ryujinx.Ava.UI.ViewModels.Input;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Ryujinx.Ava.UI.Views.Input
|
||||
{
|
||||
public partial class AssignedDevicesInputView : UserControl
|
||||
{
|
||||
public AssignedDevicesInputView()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
public AssignedDevicesInputView(InputViewModel viewModel)
|
||||
{
|
||||
DataContext = viewModel;
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
public static async Task Show(InputViewModel viewModel)
|
||||
{
|
||||
// Store original state to allow discarding changes
|
||||
var originalAssignments = viewModel.PlayerInputDevices
|
||||
.Select(item => new { item.Id, item.DeviceType, item.IsAssigned })
|
||||
.ToList();
|
||||
var originalAllowDuplicate = viewModel.AllowDuplicateDeviceAssignment;
|
||||
|
||||
AssignedDevicesInputView content = new(viewModel);
|
||||
|
||||
ContentDialog contentDialog = new()
|
||||
{
|
||||
Title = LocaleManager.Instance[LocaleKeys.ControllerSettingsAssignedInputDevices],
|
||||
PrimaryButtonText = LocaleManager.Instance[LocaleKeys.ControllerSettingsSave],
|
||||
SecondaryButtonText = string.Empty,
|
||||
CloseButtonText = LocaleManager.Instance[LocaleKeys.ControllerSettingsClose],
|
||||
Content = content,
|
||||
};
|
||||
|
||||
ContentDialogResult result = await contentDialog.ShowAsync();
|
||||
|
||||
if (result == ContentDialogResult.Primary)
|
||||
{
|
||||
viewModel.Save();
|
||||
}
|
||||
else
|
||||
{
|
||||
// Discard changes by reverting to original state
|
||||
foreach (var original in originalAssignments)
|
||||
{
|
||||
var item = viewModel.PlayerInputDevices.FirstOrDefault(d =>
|
||||
d.Id == original.Id && d.DeviceType == original.DeviceType);
|
||||
if (item != null && item.IsAssigned != original.IsAssigned)
|
||||
{
|
||||
// Use Toggle to revert, which will properly refresh state
|
||||
viewModel.ToggleAssignedPlayerInputDevice(item, original.IsAssigned);
|
||||
}
|
||||
}
|
||||
// Revert AllowDuplicateDeviceAssignment to original state
|
||||
if (viewModel.AllowDuplicateDeviceAssignment != originalAllowDuplicate)
|
||||
{
|
||||
viewModel.AllowDuplicateDeviceAssignment = originalAllowDuplicate;
|
||||
}
|
||||
viewModel.RefreshModifiedState();
|
||||
}
|
||||
}
|
||||
|
||||
private void AssignedDeviceCheckBox_OnCheckedChanged(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is CheckBox { DataContext: PlayerInputDeviceAssignmentItem item } checkBox)
|
||||
{
|
||||
_viewModel?.ToggleAssignedPlayerInputDevice(item, checkBox.IsChecked == true);
|
||||
}
|
||||
}
|
||||
|
||||
private void DeviceRow_OnPointerPressed(object sender, PointerPressedEventArgs e)
|
||||
{
|
||||
if (e.Source is Control control && control.FindAncestorOfType<CheckBox>() != null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (sender is Border { DataContext: PlayerInputDeviceAssignmentItem item })
|
||||
{
|
||||
_viewModel?.ToggleAssignedPlayerInputDevice(item, !item.IsAssigned);
|
||||
}
|
||||
}
|
||||
|
||||
private InputViewModel _viewModel => DataContext as InputViewModel;
|
||||
}
|
||||
}
|
||||
@@ -88,7 +88,7 @@
|
||||
Grid.Column="2"
|
||||
Margin="2"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Center" ColumnDefinitions="Auto,*,Auto,Auto,Auto">
|
||||
VerticalAlignment="Center" ColumnDefinitions="Auto,*,Auto,Auto,Auto,Auto">
|
||||
<TextBlock
|
||||
Margin="5,0,10,0"
|
||||
Width="90"
|
||||
@@ -101,19 +101,46 @@
|
||||
Name="ProfileBox"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Center"
|
||||
MaxHeight="32"
|
||||
Padding="8,0,44,0"
|
||||
IsEnabled="{Binding ShowSettings}"
|
||||
SelectedItem="{Binding ChosenProfile, Mode=TwoWay}"
|
||||
SelectionChanged="ComboBox_SelectionChanged"
|
||||
ItemsSource="{Binding ProfilesList}"
|
||||
Text="{Binding ProfileName, Mode=TwoWay}" />
|
||||
Text="{Binding ProfileName, Mode=TwoWay}">
|
||||
<ui:FAComboBox.Styles>
|
||||
<Style Selector="TextBlock">
|
||||
<Setter Property="TextTrimming" Value="CharacterEllipsis" />
|
||||
<Setter Property="TextWrapping" Value="NoWrap" />
|
||||
<Setter Property="MaxLines" Value="1" />
|
||||
<Setter Property="MaxWidth" Value="170" />
|
||||
</Style>
|
||||
<Style Selector="TextBox">
|
||||
<Setter Property="TextWrapping" Value="NoWrap" />
|
||||
<Setter Property="MaxLines" Value="1" />
|
||||
<Setter Property="Padding" Value="0,0,36,0" />
|
||||
</Style>
|
||||
</ui:FAComboBox.Styles>
|
||||
</ui:FAComboBox>
|
||||
<ui:SymbolIcon
|
||||
Grid.Column="1"
|
||||
Symbol="Link"
|
||||
FontSize="12"
|
||||
Opacity="0.6"
|
||||
IsVisible="{Binding IsProfileLinked}"
|
||||
HorizontalAlignment="Right"
|
||||
VerticalAlignment="Center"
|
||||
Margin="0,0,37,0" />
|
||||
<Button
|
||||
Grid.Column="2"
|
||||
MinWidth="0"
|
||||
Margin="5,0,0,0"
|
||||
VerticalAlignment="Center"
|
||||
ToolTip.Tip="{ext:Locale ControllerSettingsLoadProfileToolTip}"
|
||||
Command="{Binding LoadProfileButton}">
|
||||
ToolTip.Tip="{ext:Locale ControllerSettingsBindProfileToolTip}"
|
||||
IsEnabled="{Binding CanBindSelectedProfile}"
|
||||
Click="LinkProfileButton_OnClick">
|
||||
<ui:SymbolIcon
|
||||
Symbol="View"
|
||||
Symbol="Link"
|
||||
FontSize="15"
|
||||
Height="20" />
|
||||
</Button>
|
||||
@@ -122,19 +149,34 @@
|
||||
MinWidth="0"
|
||||
Margin="5,0,0,0"
|
||||
VerticalAlignment="Center"
|
||||
ToolTip.Tip="{ext:Locale ControllerSettingsSaveProfileToolTip}"
|
||||
Command="{Binding SaveProfile}">
|
||||
ToolTip.Tip="{ext:Locale ControllerSettingsLoadProfileToolTip}"
|
||||
IsEnabled="{Binding ShowSettings}"
|
||||
Click="LoadProfileButton_OnClick">
|
||||
<ui:SymbolIcon
|
||||
Symbol="Save"
|
||||
Symbol="OpenFolder"
|
||||
FontSize="15"
|
||||
Height="20" />
|
||||
</Button>
|
||||
<Button
|
||||
Grid.Column="4"
|
||||
MinWidth="0"
|
||||
Margin="5,0,0,0"
|
||||
VerticalAlignment="Center"
|
||||
ToolTip.Tip="{ext:Locale ControllerSettingsSaveProfileToolTip}"
|
||||
IsEnabled="{Binding CanDeleteOrSaveProfile}"
|
||||
Command="{Binding SaveProfile}">
|
||||
<ui:SymbolIcon
|
||||
Symbol="Save"
|
||||
FontSize="15"
|
||||
Height="20" />
|
||||
</Button>
|
||||
<Button
|
||||
Grid.Column="5"
|
||||
MinWidth="0"
|
||||
Margin="5,0,0,0"
|
||||
VerticalAlignment="Center"
|
||||
ToolTip.Tip="{ext:Locale ControllerSettingsRemoveProfileToolTip}"
|
||||
IsEnabled="{Binding CanDeleteOrSaveProfile}"
|
||||
Command="{Binding RemoveProfile}">
|
||||
<ui:SymbolIcon
|
||||
Symbol="Delete"
|
||||
@@ -149,7 +191,7 @@
|
||||
<Grid
|
||||
Grid.Column="0"
|
||||
Margin="2"
|
||||
HorizontalAlignment="Stretch" ColumnDefinitions="Auto,*,Auto,Auto">
|
||||
HorizontalAlignment="Stretch" ColumnDefinitions="Auto,*,Auto,Auto,Auto">
|
||||
<TextBlock
|
||||
Grid.Column="0"
|
||||
Margin="5,0,10,0"
|
||||
@@ -187,7 +229,21 @@
|
||||
MinWidth="0"
|
||||
Margin="5,0,0,0"
|
||||
VerticalAlignment="Center"
|
||||
ToolTip.Tip="{ext:Locale ControllerSettingsAssignedInputDevicesTooltip}"
|
||||
IsEnabled="{Binding CanOpenAssignedDevices}"
|
||||
Click="AssignedDevicesButton_OnClick">
|
||||
<ui:SymbolIcon
|
||||
Symbol="Settings"
|
||||
FontSize="15"
|
||||
Height="20"/>
|
||||
</Button>
|
||||
<Button
|
||||
Grid.Column="4"
|
||||
MinWidth="0"
|
||||
Margin="5,0,0,0"
|
||||
VerticalAlignment="Center"
|
||||
ToolTip.Tip="{ext:Locale ControllerSettingsResetKeybindsToDefault}"
|
||||
IsEnabled="{Binding ShowSettings}"
|
||||
Click="ResetCurrentDeviceToDefaultsButton_OnClick">
|
||||
<ui:SymbolIcon
|
||||
Symbol="Undo"
|
||||
@@ -210,6 +266,7 @@
|
||||
<ComboBox
|
||||
Grid.Column="1"
|
||||
HorizontalAlignment="Stretch"
|
||||
IsEnabled="{Binding ShowSettings}"
|
||||
ItemsSource="{Binding Controllers}"
|
||||
SelectedIndex="{Binding Controller}">
|
||||
<ComboBox.ItemTemplate>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Templates;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia;
|
||||
using Avalonia.Layout;
|
||||
@@ -45,12 +46,69 @@ namespace Ryujinx.Ava.UI.Views.Input
|
||||
ViewModel = new InputViewModel(this, useGlobalConfig); // Create new Input Page with the selected input config scope.
|
||||
InitializeComponent();
|
||||
|
||||
SetupProfileBoxItemTemplate();
|
||||
|
||||
if (VisualRoot is not null)
|
||||
{
|
||||
ViewModel.RetargetKeyboardDriver(this);
|
||||
}
|
||||
}
|
||||
|
||||
private void SetupProfileBoxItemTemplate()
|
||||
{
|
||||
var dataTemplate = new FuncDataTemplate<string>((profileName, scope) =>
|
||||
{
|
||||
var grid = new Grid
|
||||
{
|
||||
ColumnDefinitions = new ColumnDefinitions("*,Auto")
|
||||
};
|
||||
|
||||
var textBlock = new TextBlock
|
||||
{
|
||||
Text = profileName,
|
||||
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center,
|
||||
TextTrimming = TextTrimming.CharacterEllipsis,
|
||||
TextWrapping = TextWrapping.NoWrap,
|
||||
MaxWidth = 170
|
||||
};
|
||||
Grid.SetColumn(textBlock, 0);
|
||||
|
||||
var linkIcon = new FluentAvalonia.UI.Controls.SymbolIcon
|
||||
{
|
||||
Symbol = FluentAvalonia.UI.Controls.Symbol.Link,
|
||||
FontSize = 12,
|
||||
Opacity = 0.6,
|
||||
Margin = new Thickness(10, 0, 0, 0),
|
||||
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center
|
||||
};
|
||||
Grid.SetColumn(linkIcon, 1);
|
||||
|
||||
// Bind visibility to whether the profile is linked
|
||||
linkIcon.Bind(
|
||||
FluentAvalonia.UI.Controls.SymbolIcon.IsVisibleProperty,
|
||||
new Avalonia.Data.Binding(".")
|
||||
{
|
||||
Converter = ProfileNameLinkedConverter.Instance,
|
||||
ConverterParameter = ViewModel
|
||||
});
|
||||
|
||||
grid.Children.Add(textBlock);
|
||||
grid.Children.Add(linkIcon);
|
||||
|
||||
return grid;
|
||||
});
|
||||
|
||||
ProfileBox.ItemTemplate = dataTemplate;
|
||||
}
|
||||
|
||||
public void RefreshProfileBoxItemTemplate()
|
||||
{
|
||||
// Force the ComboBox to re-render its items
|
||||
var itemsSource = ProfileBox.ItemsSource;
|
||||
ProfileBox.ItemsSource = null;
|
||||
ProfileBox.ItemsSource = itemsSource;
|
||||
}
|
||||
|
||||
private async void PlayerIndexBox_OnSelectionChanged(object sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
if (PlayerIndexBox != null)
|
||||
@@ -102,8 +160,15 @@ namespace Ryujinx.Ava.UI.Views.Input
|
||||
if (sender is FAComboBox faComboBox)
|
||||
{
|
||||
faComboBox.IsDropDownOpen = false;
|
||||
ViewModel.RefreshModifiedState();
|
||||
}
|
||||
|
||||
ViewModel.RefreshModifiedState();
|
||||
}
|
||||
|
||||
private async void AssignedDevicesButton_OnClick(object sender, RoutedEventArgs e)
|
||||
{
|
||||
await AssignedDevicesInputView.Show(ViewModel);
|
||||
ViewModel.RefreshModifiedState();
|
||||
}
|
||||
|
||||
private async void ResetCurrentDeviceToDefaultsButton_OnClick(object sender, RoutedEventArgs e)
|
||||
@@ -155,6 +220,17 @@ namespace Ryujinx.Ava.UI.Views.Input
|
||||
}
|
||||
}
|
||||
|
||||
private void LinkProfileButton_OnClick(object sender, RoutedEventArgs e)
|
||||
{
|
||||
ViewModel?.LinkCurrentProfileToCurrentDevice();
|
||||
RefreshProfileBoxItemTemplate();
|
||||
}
|
||||
|
||||
private void LoadProfileButton_OnClick(object sender, RoutedEventArgs e)
|
||||
{
|
||||
ViewModel?.LoadProfile();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
ViewModel.Dispose();
|
||||
|
||||
@@ -163,7 +163,7 @@
|
||||
IsEnabled="{Binding HasSkylander}" />
|
||||
<MenuItem
|
||||
Command="{Binding SimulateWakeUpMessage}"
|
||||
Header="{ext:Locale MenuBarOptionsSimulateWakeUpMessage}"
|
||||
Header="{ext:Locale MenuBar_Actions_SimulateWakeUpMessageButton}"
|
||||
Icon="{ext:Icon fa-solid fa-sun}"
|
||||
InputGesture="Ctrl + M" />
|
||||
<Separator />
|
||||
@@ -216,7 +216,7 @@
|
||||
<MenuItem Header="{ext:Locale MenuBar_Actions_ToolsLabel}" Icon="{ext:Icon fa-solid fa-toolbox}">
|
||||
<MenuItem
|
||||
Name="MiiAppletMenuItem" Header="{ext:Locale MenuBar_Actions_MiiEditorButton}" Icon="{ext:Icon fa-solid fa-face-grin-wide}" />
|
||||
<MenuItem Name="XciTrimmerMenuItem" Header="{ext:Locale MenuBar_Actions_XCITrimmerButton}" Icon="{ext:Icon fa-solid fa-scissors}" />
|
||||
<MenuItem Name="XCITrimmerMenuItem" Header="{ext:Locale MenuBar_Actions_XCITrimmerButton}" Icon="{ext:Icon fa-solid fa-scissors}" />
|
||||
</MenuItem>
|
||||
</MenuItem>
|
||||
<MenuItem VerticalAlignment="Center" Header="{ext:Locale MenuBarView}">
|
||||
|
||||
@@ -43,7 +43,7 @@ namespace Ryujinx.Ava.UI.Views.Main
|
||||
ResumeEmulationMenuItem.Command = Commands.Create(() => ViewModel.AppHost?.Resume());
|
||||
StopEmulationMenuItem.Command = Commands.Create(() => ViewModel.AppHost?.ShowExitPrompt().OrCompleted());
|
||||
RestartEmulationMenuItem.Command = Commands.Create(() => ViewModel.RestartEmulation());
|
||||
XciTrimmerMenuItem.Command = Commands.Create(XciTrimmerView.Show);
|
||||
XCITrimmerMenuItem.Command = Commands.Create(XCITrimmerView.Show);
|
||||
AboutWindowMenuItem.Command = Commands.Create(AboutView.Show);
|
||||
CompatibilityListMenuItem.Command = Commands.Create(() => CompatibilityListWindow.Show());
|
||||
LdnGameListMenuItem.Command = Commands.Create(() => LdnGamesListWindow.Show());
|
||||
|
||||
@@ -50,33 +50,37 @@
|
||||
VerticalAlignment="Center"
|
||||
IsVisible="{Binding EnableNonGameRunningControls}"
|
||||
Text="{ext:Locale StatusBarGamesLoaded}" />
|
||||
<TextBlock
|
||||
Name="StatusBarProgressStatus"
|
||||
Grid.Column="2"
|
||||
MinWidth="200"
|
||||
Margin="10,0,5,0"
|
||||
VerticalAlignment="Center"
|
||||
IsVisible="{Binding StatusBarProgressStatusVisible}"
|
||||
Text="{Binding StatusBarProgressStatusText}" />
|
||||
<ProgressBar
|
||||
Name="LoadProgressBar"
|
||||
Grid.Column="3"
|
||||
MinWidth="200"
|
||||
Height="6"
|
||||
VerticalAlignment="Center"
|
||||
Margin="0, 0, 5, 0"
|
||||
Foreground="{DynamicResource SystemAccentColorLight2}"
|
||||
IsVisible="{Binding StatusBarVisible}"
|
||||
Maximum="{Binding StatusBarProgressMaximum}"
|
||||
Value="{Binding StatusBarProgressValue}" />
|
||||
<controls:MiniVerticalSeparator Grid.Column="4" IsVisible="{Binding ShowTotalTimePlayed}" />
|
||||
<controls:MiniVerticalSeparator Grid.Column="2" IsVisible="{Binding ShowTotalTimePlayed}" />
|
||||
<TextBlock
|
||||
Grid.Column="5"
|
||||
Grid.Column="3"
|
||||
Margin="5,0,5,0"
|
||||
VerticalAlignment="Center"
|
||||
IsVisible="{Binding ShowTotalTimePlayed}"
|
||||
Text="{ext:Locale GameListLabelTotalTimePlayed}">
|
||||
</TextBlock>
|
||||
<controls:MiniVerticalSeparator Grid.Column="4" IsVisible="{Binding StatusBarVisible}" />
|
||||
<controls:MiniVerticalSeparator Grid.Column="4" IsVisible="{Binding StatusBarProgressStatusVisible}" />
|
||||
<TextBlock
|
||||
Name="StatusBarProgressStatus"
|
||||
Grid.Column="5"
|
||||
Margin="5,0,0,0"
|
||||
VerticalAlignment="Center"
|
||||
IsVisible="{Binding StatusBarProgressStatusVisible}"
|
||||
Text="{Binding StatusBarProgressStatusText}"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
TextWrapping="NoWrap"
|
||||
MaxWidth="1000" />
|
||||
<ProgressBar
|
||||
Name="LoadProgressBar"
|
||||
Grid.Column="6"
|
||||
MinWidth="100"
|
||||
Height="6"
|
||||
VerticalAlignment="Center"
|
||||
Margin="5,0,5,0"
|
||||
Foreground="{DynamicResource SystemAccentColorLight2}"
|
||||
IsVisible="{Binding StatusBarVisible}"
|
||||
Maximum="{Binding StatusBarProgressMaximum}"
|
||||
Value="{Binding StatusBarProgressValue}" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
<StackPanel
|
||||
@@ -329,7 +333,7 @@
|
||||
Margin="5, 0, 0, 0"
|
||||
HorizontalAlignment="Right"
|
||||
VerticalAlignment="Center"
|
||||
Text="{ext:Locale StatusBar_FirmwareVersion}" />
|
||||
Text="{ext:Locale StatusBar_FirmwareVersionLabel}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
|
||||
@@ -95,7 +95,7 @@
|
||||
Tag="Favorite" />
|
||||
<RadioButton
|
||||
Checked="Sort_Checked"
|
||||
Content="{ext:Locale GameListHeaderApplication}"
|
||||
Content="{ext:Locale Common_Sort_NameLabel}"
|
||||
GroupName="Sort"
|
||||
IsChecked="{Binding IsSortedByTitle, Mode=OneTime}"
|
||||
Tag="Title" />
|
||||
@@ -153,13 +153,13 @@
|
||||
</Border>
|
||||
<RadioButton
|
||||
Checked="Order_Checked"
|
||||
Content="{ext:Locale OrderAscending}"
|
||||
Content="{ext:Locale Common_Sort_OrderAscending}"
|
||||
GroupName="Order"
|
||||
IsChecked="{Binding IsAscending, Mode=OneTime}"
|
||||
Tag="Ascending" />
|
||||
<RadioButton
|
||||
Checked="Order_Checked"
|
||||
Content="{ext:Locale OrderDescending}"
|
||||
Content="{ext:Locale Common_Sort_OrderDescending}"
|
||||
GroupName="Order"
|
||||
IsChecked="{Binding !IsAscending, Mode=OneTime}"
|
||||
Tag="Descending" />
|
||||
|
||||
@@ -41,6 +41,13 @@
|
||||
<StackPanel
|
||||
Orientation="Horizontal"
|
||||
Spacing="10">
|
||||
<CheckBox
|
||||
ToolTip.Tip="{ext:Locale DockModeToggleTooltip}"
|
||||
MinWidth="0"
|
||||
IsChecked="{Binding EnableDockedMode}">
|
||||
<TextBlock
|
||||
Text="{ext:Locale SettingsTabInputEnableDockedMode}" />
|
||||
</CheckBox>
|
||||
<CheckBox
|
||||
ToolTip.Tip="{ext:Locale UseGlobalInputTooltip}"
|
||||
MinWidth="0"
|
||||
@@ -49,11 +56,11 @@
|
||||
Text="{ext:Locale SettingsTabInputUseGlobalInput}" />
|
||||
</CheckBox>
|
||||
<CheckBox
|
||||
ToolTip.Tip="{ext:Locale DockModeToggleTooltip}"
|
||||
MinWidth="0"
|
||||
IsChecked="{Binding EnableDockedMode}">
|
||||
ToolTip.Tip="{ext:Locale ControllerSettingsDynamicInputSwapTooltip}"
|
||||
IsChecked="{Binding ElementName=InputView, Path=ViewModel.EnableDynamicGamepadSwap, Mode=TwoWay}"
|
||||
IsEnabled="{Binding ElementName=InputView, Path=ViewModel.ShowSettings}">
|
||||
<TextBlock
|
||||
Text="{ext:Locale SettingsTabInputEnableDockedMode}" />
|
||||
Text="{ext:Locale ControllerSettingsDynamicInputSwap}" />
|
||||
</CheckBox>
|
||||
<CheckBox
|
||||
ToolTip.Tip="{ext:Locale DirectKeyboardTooltip}"
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
HorizontalContentAlignment="Left"
|
||||
MinWidth="100">
|
||||
<ComboBoxItem
|
||||
Content="{ext:Locale Name}" />
|
||||
Content="{ext:Locale Common_Sort_NameLabel}" />
|
||||
<ComboBoxItem
|
||||
Content="{ext:Locale Size}" />
|
||||
<ComboBox.Styles>
|
||||
@@ -46,9 +46,9 @@
|
||||
HorizontalContentAlignment="Left"
|
||||
MinWidth="150">
|
||||
<ComboBoxItem
|
||||
Content="{ext:Locale OrderAscending}" />
|
||||
Content="{ext:Locale Common_Sort_OrderAscending}" />
|
||||
<ComboBoxItem
|
||||
Content="{ext:Locale OrderDescending}" />
|
||||
Content="{ext:Locale Common_Sort_OrderDescending}" />
|
||||
<ComboBox.Styles>
|
||||
<Style Selector="ContentControl#ContentPresenter">
|
||||
<Setter Property="HorizontalAlignment" Value="Left" />
|
||||
@@ -60,7 +60,7 @@
|
||||
Grid.Column="1"
|
||||
HorizontalAlignment="Stretch"
|
||||
Margin="10,0, 0, 0" ColumnDefinitions="Auto,*">
|
||||
<TextBlock Text="{ext:Locale Search}" VerticalAlignment="Center" />
|
||||
<TextBlock Text="{ext:Locale Common_Search_SearchWatermark}" VerticalAlignment="Center" />
|
||||
<TextBox
|
||||
Margin="10,0,0,0"
|
||||
Grid.Column="1"
|
||||
|
||||
@@ -135,7 +135,7 @@
|
||||
Grid.Column="1"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Center"
|
||||
IsVisible="{Binding ShowLoadProgress}" RowDefinitions="Auto,Auto,Auto">
|
||||
IsVisible="{Binding ShowLoadProgress}" RowDefinitions="Auto,Auto,Auto,Auto">
|
||||
<TextBlock
|
||||
Grid.Row="0"
|
||||
Margin="10"
|
||||
@@ -179,6 +179,16 @@
|
||||
Text="{Binding CacheLoadStatus}"
|
||||
TextAlignment="Start"
|
||||
MaxWidth="500" />
|
||||
<TextBlock
|
||||
Grid.Row="3"
|
||||
Margin="10"
|
||||
FontSize="14"
|
||||
FontStyle="Oblique"
|
||||
IsVisible="{Binding ShowLoadProgress}"
|
||||
Text="{Binding Splash}"
|
||||
Foreground="LightGray"
|
||||
TextAlignment="Start"
|
||||
MaxWidth="500" />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
Reference in New Issue
Block a user