Compare commits

...

17 Commits

Author SHA1 Message Date
Max d665de1f4f Include more games in Discord RPC (#157)
logos already included in dash

- Mary Skelter Finale
- DELTARUNE

Reviewed-on: https://git.ryujinx.app/projects/Ryubing/pulls/157
2026-07-05 20:33:38 +00:00
awesomeangotti 680b5b719a Discord Rich Presence: Allow selective writing to multiple (two) lines of RPC from play reports (#154)
This PR implements a method inside the DiscordIntegrationModule class to allow a playreport parser (IDK if that is the actual name) to return information for both lines usable by playreports. It has complete backwards compatibility with all previous methods.

Through the magic of JSON, multiline RPC is mine to command

Reviewed-on: https://git.ryujinx.app/projects/Ryubing/pulls/154
2026-07-05 20:29:39 +00:00
LotP b236732713 cleanup (#161)
general cleanup for some warnings in the project and some developer annoyances.

Reviewed-on: https://git.ryujinx.app/projects/Ryubing/pulls/161
2026-07-04 15:30:55 +00:00
awesomeangotti 97aed15d19 UI: Five minute coding adventure gone wrong (Fixing splashes) (#160)
With help from LotP I added error logging and made some minor adjustments to the display of splash text in cases where there was no splash to display. Thanks for dealing with my shenanigans LotP.

Reviewed-on: https://git.ryujinx.app/projects/Ryubing/pulls/160
2026-07-03 22:42:38 +00:00
der_zelda_player 134773e95f New German translations (#159)
Added new German translations.

Reviewed-on: https://git.ryujinx.app/projects/Ryubing/pulls/159
2026-07-03 22:41:46 +00:00
Martin Bai 5072904162 Fixed boot loop of Rhythm Heaven Groove (#156)
Fixed return value of GetSharedFontInOrderOfPriority to avoid being constantly rejected by the game, causing the boot loop.

Reviewed-on: https://git.ryujinx.app/projects/Ryubing/pulls/156
Reviewed-by: sh0inx <sh0inx@noreply.git.ryujinx.app>
2026-07-02 20:27:02 +00:00
LotP 6fb71b08ae instanced jit cache (#152)
- jit caches are no longer static.
- JitUnwindWindows now supports multiple jit caches.
- Jit caches should no longer cause crashes due to entry offset collisions.
- cpu tests now run concurrently.

Co-authored-by: LotP1 <68976644+LotP1@users.noreply.github.com>
Reviewed-on: https://git.ryujinx.app/projects/Ryubing/pulls/152
2026-06-30 13:40:18 +00:00
Babib3l 59a6a3a5f7 French Splash text + a few new english splash texts (#151)
Reviewed-on: https://git.ryujinx.app/projects/Ryubing/pulls/151
2026-06-29 04:44:01 +00:00
Babib3l 8b7d27a071 bug fixes + missing feats from !125 (#149)
addresses two crashes (https://github.com/Ryubing/Issues/issues/455 and another one pointed out over on discord),
code cleanup,
various fixes and improvements :
- fix assigned devices menu player display section being too large when under 4 players were selected
- fix 'profile : ...' shifting around in the UI when enabling / disabling the checkbox
- update the Dynamic Input Swap warning message to show which device is assigned to which player
- make players that have Dynamic Input Swap disabled show up in the Device assignement menu.

Reviewed-on: https://git.ryujinx.app/projects/Ryubing/pulls/149
2026-06-29 04:00:30 +00:00
Babib3l 45c66eaf66 Translation additions for French and spanish (#150)
Reviewed-on: https://git.ryujinx.app/projects/Ryubing/pulls/150
2026-06-28 16:10:17 +00:00
_Neo_ 905a41a643 UI: Actions Menu (Part 2 of 4) → XCI Trimmer (#146)
Ayyyy, welcome to the UI: Actions Menu → XCI Trimmer PR!

Let's keep it up!

This is the second PR in a series aimed at delivering the largest overhaul and improvements to the `Actions` menu yet.

This second PR introduces visual improvements to the `XCI Trimmer` and also fixes some bugs.

### GENERAL:
* **Renamed:** `XCI Trimmer` files to use the capitalised XCI instead of Xci
    * Files were inconsistently named with either Xci or XCI. As such, they were all renamed to use XCI.

### LOCALISATION:
* **Fractured:** More locales
    * `Common_Search.json` - search-related locales
    * `Common_Sort.json` - sorting fields, ascending/descending locales
    * `Dialog_XCITrimmer.json` - XCI trimmer dialogs
    * `GameListContextMenu.json` - all UI dialogs related to the game list menu (right-click game)
    * `XCITrimmer.json` - all UI-related locales for the XCI Trimmer window
* **Added:** Additional entry to `StatusBar.json`
* **Added:** `Simulate Wake-Up Message` to `MenuBar_Actions.json` (previously did not exist there, for some reason).

**NOTE:** `Common_Search.json` and `Common_Sort.json` were not fully populated, but many more locales will be added and cleaned up in future PRs.

### XCI TRIMMER:
* **Renamed:** `Trim XCI Files` menu → `XCI Trimmer`
* **XCI Trimmer (Window):**
    * **Renamed:** Window title from `XCI File Trimmer` → `XCI Trimmer`
    * **Renamed:** `X of Y Title(s) Selected` → `XCI Selected: X/Y`
        * `Z Displayed` was renamed to `Displayed: Z` and, as usual, appears only when searching for particular XCIs in the search bar.
    * **Fixed:** Counting bug
        * Pressing `Select Shown` continuously would increment the amount of XCIs selected to infinity (e.g. if 5 XCIs are in the list, then you could get 1201312 of 5 Title(s) Selected). Furthermore, when you were trimming a specific number of XCIs (not the all XCIs) at once, it would show that its trimming all titles from the list (e.g. "Trimming 5 Titles" when only 1 was being trimmed).
    * **Changed:** Button structure
        * Previous structure included 2 buttons: `Select Shown` and `Deselect Shown`. Initially, they were renamed to `Select All` and `Deselect All`, as that sounded more natural. However, after further consideration, they were both removed and replaced by a single button, whose action and label would change depending on the state it was in:
            * If no XCIs were selected → `Select All`
            * If at least one XCI is selected →`Clear Selection`
                * `Clear Selection` was used instead of Deselect All, because some translations were quite awkward.
                * Furthermore, this reflects the way the button works: you are not "deselecting all" when only 2 out of 5 XCIs are selected - you are clearing that particular selection of XCIs.
    * **Improved:** Button and Control Layout (above list)
        * Sorting dropdown was expanded a bit further (150 default to 170 width), as some locales would get cutoff. This will be further adjusted in a future PR, which will tackle all of Ryujinx's sorting features.
        * Added a new sorting: Trim Status
        * See images for visual comparison.
    * **Improved:** XCI List Layout
        * The entire list was revamped to be more modern and cleaner to look at.
        * Instead of displaying the file status (Trimmed, Untrimmed, Partial, Failed, Unknown), it now shows icons: Checkmark (Trimmed), Cross (Untrimmed), Wrench (Partial), Exclamation Mark (Failed), Question mark (everything else/Unknown), No Icon (when it's not XCI).
            * Furthermore, it shows a Sync icon when performing the trimming/untrimming operation.
            * Additionally, it shows the said status when you hover above the icons.
        * Save X MB and Saved Y MB were both removed and replaced with just the amount and a percentage value (how much you save per file).
        * See images for visual comparison.
        * NOTE: Icons are still under consideration and may be changed in the future.
    * **Improved:** Bottom Savings Display
        * Instead of showing `Potential Savings`" and `Actual Savings`, the bottom row now shows the amount `Saved` AND the amount `Remaining`.

### GAME LIST CONTEXT MENU (Right-Click Game):
* **Renamed:** `Check & Trim XCI File` → `Trim XCI`
    * The option already performs a check regardless.
*  **Remove:** `Trim XCI` Tooltip
    * The user can press the option and will be confronted with a dialog, which explains what the feature does. Furthermore, the action itself is reversible at any moment (i.e. before trimming and after trimming in the XCI Trimmer).
        * It is planned to either add a message that tells the user they can untrim the XCI in the XCI Trimmer, or to make the option work both ways (Trim & Untrim). This is still under consideration, but, if chosen to be implemented, it will be a part of the Game List Context Menu PR.
* **Added:** IsVisible parameter to `Trim XCI`
    * Instead of displaying `Trim XCI` for every single game (NSP, NSO, etc.), it will only be displayed for XCIs (as they are the only ones that can be trimmed). It is still _enabled_ only if an XCI is untrimmed.
* **Improved:** `Trim XCI` Dialog
    * Some minor visual adjustment and organisation improvements.
    * **Changed:** Value formatting for "File Size", "Game Size", and "Space Savings"
        * Previously they were very long numbers, expressed in MB, with all the decimals. Instead of displaying them as such, they were shortened to display the same amount as they do in the XCI Trimmer window already.
    * All further dialogs will be improved in a separate PR, because they desperately need consistency.

### STATUS BAR:
* **Adjusted:** Formatting of XCI Trimming Status on status bar
    * **Forced:** Filename without extension (as we are already trimming an XCI already).
    * **Fixed:** Trimming Position when Playtime is not available
    * **Fixed:** Progress bar position (will be further adjusted in a Status Bar PR…boy there will be a lot of PRs, which is good)

_If there are any features or changes that you wish to be implemented, please comment down below and I'll be happy to accommodate!_

Reviewed-on: https://git.ryujinx.app/projects/Ryubing/pulls/146
2026-06-27 02:11:31 +00:00
Babib3l 54e9b40cd7 Feat : Input : Dynamic Input Swap, Device Profile Linking and Player Device Assignement (#125)
Heyy everyone!
Continuing my work on the input system overhaul, this PR is mainly focused around three new features :
- Dynamic Input Swap
- Device Profile Linking
- Player Device Assignement

What are these new features and why are they bundled in the same PR you might ask?

- **Dynamic Input Swap**
Is a new feature that, when enabled, allows the user to switch between multiple input devices, without having to return to the settings menu. This feature can be enabled at the bottom of the Input Settings menu, next to the "global input" checkbox. A nifty new tooltip has been added for clarity.
- **Device Profile linking** :
While working on Dynamic Input Swap, one issue arose, which was that Ryujinx would use that device’s Default profile, without the user being able to specify a specific profile to use on that specific device. This feature fixes that, by allowing the user to link a profile as the device’s Default. This means that this profile will be auto loaded when selecting that same device, both when connecting it to the emulator, and when using Dynamic Input Swap. This feature can be found next to the Device Profiles dropdown, and includes a nifty new tooltip. By default, the "default" profile is linked to any device. There also is a small icon to the right of the profile name’s Right to visualise which profile is linked.
- **Player Device Assignement** :
Another issue that arose while working on Dynamic Input Swap was that enabling the feature would render multi player configurations unusable. This new feature addresses this issue by adding a new menu, which allows the user to assign different Input Devices to different players. Inside this new menu, you also get the device’s linked profile and the list of which players are already assigned to which input device, below and to the right of the input device name, respectively. You also get an option to allow mapping the same input device to different players.

### Nerd Zone

This PR adds a new player-level input routing layer on top of the existing `InputConfig` system.

Previously, each player effectively had one active input config, tied to one device. Given Dynamic Input Swap needs more state than that, this PR introduces a persisted `PlayerInputAssignment` model, which stores the player index, whether Dynamic Input Swap is enabled or not, the list of assigned input devices and an optional profile name bound to each assigned device.

The new assignment data lives coexists with the existing input configs instead of replacing them, which should keep the old single-device behavior intact when Dynamic Input Swap is disabled, while allowing dynamic players to own multiple devices.

New configl types include:

- `AssignedInputDevice`
- `AssignedInputDeviceType`
- `PlayerInputAssignment`
- `PlayerInputAssignmentHelper`
- `PlayerInputDeviceAssignmentItem`

`PlayerInputAssignmentHelper` normalizes assignments, deduplicates device entries, preserves a primary device, and compares assignments for dirty-checking.

On the runtime side, `NpadManager` now passes both the normal `InputConfig` and the player assignment to `NpadController`. When Dynamic Input Swap is disabled, Ryujinx switches to the old behavior : one player config opens one device. However, when it's enabled, `NpadController` opens every assigned keyboard/controller device it can resolve, tracks their state snapshots, and promotes the active source based on recent input.

Dynamic swap currently follows a “last meaningful input wins” model (annotated in the code) :

So if the keyboard produces new input, it becomes active; if an assigned controller produces new input, that controller becomes active; if the active device stops producing input and another assigned device is held/active, the active source can fall back; and if no device has produced input yet, the initial active source is chosen from the selected config and available assigned devices.

Per-device profile binding is handled by storing the profile name on the assigned device entry. When a device is selected or resolved through Dynamic Input Swap, Ryujinx tries to load that bound profile for the matching device type. If the profile is missing, invalid, or explicitly `Default`, it falls back to the generated default config for that device type.

The new restore-to-defaults behavior deliberately bypasses linked profiles. This means pressing the reset button loads the real generated Default profile for the currently selected device, not the profile linked to that device.

The input settings UI now dirty-checks both the currently edited input config and the player input assignment state, meaning that assigning/unassigning devices, changing Dynamic Input Swap, or changing profile bindings correctly marks the page as modified -> in continuation of my previous efforts in #13 to clean up its behaviour.

The Assigned Devices dialog is backed by the currently available keyboard/controller device list. It also checks other players’ persisted assignments so the UI can show which players already use a device. If duplicate assignment is disabled, devices already assigned to another dynamic-swap player are disabled for the current player.
If no explicit player assignments exist yet, Ryujinx synthesizes a default assignment from the existing input config. Dynamic swap is disabled by default until the user enables it.

⚠️⚠️⚠️⚠️⚠️Caution : this PR is still a WIP; and while all features described above have been fully implemented, various issues still remain, notably inside the Player assignement menu, that are getting worked on. Additionally, little bug testing has been done across the emulator, so it is not guaranteed that this build will be bug free.

Signed by : 🦫
With the very generous help and support from @neo 🤗

Reviewed-on: https://git.ryujinx.app/projects/Ryubing/pulls/125
2026-06-27 01:42:20 +00:00
Renovate Bot 737b951ee9 Update avalonia monorepo to 11.3.18 (#148)
This PR contains the following updates:

| Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) |
|---|---|---|---|
| [Avalonia](https://avaloniaui.net/?utm_source=nuget&utm_medium=referral&utm_content=project_homepage_link) ([source](https://github.com/AvaloniaUI/Avalonia)) | `11.3.17` → `11.3.18` | ![age](https://developer.mend.io/api/mc/badges/age/nuget/Avalonia/11.3.18?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/nuget/Avalonia/11.3.17/11.3.18?slim=true) |
| [Avalonia.Desktop](https://avaloniaui.net/?utm_source=nuget&utm_medium=referral&utm_content=project_homepage_link) ([source](https://github.com/AvaloniaUI/Avalonia)) | `11.3.17` → `11.3.18` | ![age](https://developer.mend.io/api/mc/badges/age/nuget/Avalonia.Desktop/11.3.18?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/nuget/Avalonia.Desktop/11.3.17/11.3.18?slim=true) |
| [Avalonia.Diagnostics](https://avaloniaui.net/?utm_source=nuget&utm_medium=referral&utm_content=project_homepage_link) ([source](https://github.com/AvaloniaUI/Avalonia)) | `11.3.17` → `11.3.18` | ![age](https://developer.mend.io/api/mc/badges/age/nuget/Avalonia.Diagnostics/11.3.18?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/nuget/Avalonia.Diagnostics/11.3.17/11.3.18?slim=true) |
| [Avalonia.Markup.Xaml.Loader](https://avaloniaui.net/?utm_source=nuget&utm_medium=referral&utm_content=project_homepage_link) ([source](https://github.com/AvaloniaUI/Avalonia)) | `11.3.17` → `11.3.18` | ![age](https://developer.mend.io/api/mc/badges/age/nuget/Avalonia.Markup.Xaml.Loader/11.3.18?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/nuget/Avalonia.Markup.Xaml.Loader/11.3.17/11.3.18?slim=true) |

---

### Release Notes

<details>
<summary>AvaloniaUI/Avalonia (Avalonia)</summary>

### [`v11.3.18`](https://github.com/AvaloniaUI/Avalonia/releases/tag/11.3.18)

[Compare Source](https://github.com/AvaloniaUI/Avalonia/compare/11.3.17...11.3.18)

##### What's Changed

##### Enhancements

- XAML – Enhance Roslyn-compiler visible metadata by [@&#8203;maxkatz6](https://github.com/maxkatz6) in [#&#8203;21546](https://github.com/AvaloniaUI/Avalonia/pull/21546)

##### Fixes

- Core – Fix StackOverflow when a `NaN` offset is set on `ScrollViewer` by [@&#8203;NicholasLachapelle](https://github.com/NicholasLachapelle) in [#&#8203;21558](https://github.com/AvaloniaUI/Avalonia/pull/21558)
- macOS – Handle `replacementRange` in `AvnView` by [@&#8203;MrJul](https://github.com/MrJul) in [#&#8203;21608](https://github.com/AvaloniaUI/Avalonia/pull/21608)

**Full Changelog**: <https://github.com/AvaloniaUI/Avalonia/compare/11.3.17...11.3.18>

</details>

---

### Configuration

📅 **Schedule**: (UTC)

- Branch creation
  - At any time (no schedule defined)
- Automerge
  - At any time (no schedule defined)

🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied.

♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about these updates again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Mend Renovate](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0My4xNzguMCIsInVwZGF0ZWRJblZlciI6IjQzLjE3OC4wIiwidGFyZ2V0QnJhbmNoIjoibWFzdGVyIiwibGFiZWxzIjpbXX0=-->

Reviewed-on: https://git.ryujinx.app/projects/Ryubing/pulls/148
2026-06-26 07:14:22 +00:00
Max 5566e752a4 [HID] Restructure HD Rumble class for future controller support (#109)
- Attempted fixing the strength: so far it hasn't been successful.
- Rumble should skip vibrations if they're not in-line with poll-rate: would like to come back to this. Queuing just does exactly what the hid buffer does, but our timer (poll rate) is not in sync with the rate the controller is reading at, which causes excess drops.
- Refactored the class so that implementing support for HD rumble for other controllers (DS5, Steam Controller) is much easier in the future.

Reviewed-on: https://git.ryujinx.app/projects/Ryubing/pulls/109
2026-06-26 05:11:19 +00:00
awesomeangotti a5f72136b2 UI: Add random splashes to loading screen (#128)
This PR introduces splash text messages that change per startup on the loading screen after selecting a game. It also moves the "RYUBING" logo splash in logs to be inside its own class, which also handles loading screen splashes and titlebar splashes. Credits to VewDev, Lotp, Sh0inx, yell0wsuit, and Greemdev for pointers and assistance throughout this PR.

Co-authored-by: Awesomeangotti <awesomeangotti@noreply.git.ryujinx.app>
Co-authored-by: Awesomeangotti <143439211+Awesomeangotti@users.noreply.github.com>
Reviewed-on: https://git.ryujinx.app/projects/Ryubing/pulls/128
2026-06-26 02:42:41 +00:00
awesomeangotti aa5d32a7b1 Change RPCData embedded resource to PlayReports (#147)
Change RPCData embedded resource to PlayReports

Reviewed-on: https://git.ryujinx.app/projects/Ryubing/pulls/147
2026-06-26 00:23:20 +00:00
awesomeangotti be5881f100 Discord Rich Presence: New Super Mario Bros U Deluxe (#130)
Add RPC support for NSMBUD play reports that generate on main menu and after finishing a course.

Tracked things:

Main menu
Last played course

Examples:

Main menu
![image](/attachments/9f1506bd-fc8c-4eca-930d-64f15a7c650d)

After finishing a course (By dying or by beating it)
![image](/attachments/a4af3f4f-230d-47ac-977a-20281a103cb6)

In the future should I be doing batch PRs for RPC related things? Yes.

Co-authored-by: Awesomeangotti <143439211+Awesomeangotti@users.noreply.github.com>
Reviewed-on: https://git.ryujinx.app/projects/Ryubing/pulls/130
2026-06-25 20:59:56 +00:00
93 changed files with 5640 additions and 2159 deletions
+4 -4
View File
@@ -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
View 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
View 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
View 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 lespace 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 na 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 nest 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
View 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"
}
}
]
}
+39 -14
View File
@@ -229,25 +229,25 @@
"ID": "XCITrimmerButton",
"Translations": {
"ar_SA": "",
"de_DE": "XCI-Dateien trimmen",
"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": {
+287 -1062
View File
File diff suppressed because it is too large Load Diff
+26 -1
View File
@@ -1,7 +1,32 @@
{
"Locales": [
{
"ID": "FirmwareVersion",
"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": "FirmwareVersionLabel",
"Translations": {
"ar_SA": "",
"de_DE": "",
+429
View 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": "反修剪"
}
}
]
}
+7 -5
View File
@@ -8,7 +8,7 @@ namespace ARMeilleure.CodeGen
/// <summary>
/// Represents a compiled function.
/// </summary>
readonly struct CompiledFunction
public readonly struct CompiledFunction
{
/// <summary>
/// Gets the machine code of the <see cref="CompiledFunction"/>.
@@ -44,10 +44,11 @@ namespace ARMeilleure.CodeGen
/// <typeparamref name="T"/> pointing to the mapped function.
/// </summary>
/// <typeparam name="T">Type of delegate</typeparam>
/// <param name="jitCache">The jit cache to map the function into</param>
/// <returns>A delegate of type <typeparamref name="T"/> pointing to the mapped function</returns>
public T Map<T>()
public T Map<T>(JitCache jitCache)
{
return MapWithPointer<T>(out _);
return MapWithPointer<T>(jitCache, out _);
}
/// <summary>
@@ -55,11 +56,12 @@ namespace ARMeilleure.CodeGen
/// <typeparamref name="T"/> pointing to the mapped function.
/// </summary>
/// <typeparam name="T">Type of delegate</typeparam>
/// <param name="jitCache">The jit cache to map the function into</param>
/// <param name="codePointer">Pointer to the function code in memory</param>
/// <returns>A delegate of type <typeparamref name="T"/> pointing to the mapped function</returns>
public T MapWithPointer<T>(out nint codePointer)
public T MapWithPointer<T>(JitCache jitCache, out nint codePointer)
{
codePointer = JitCache.Map(this);
codePointer = jitCache.Map(this);
return Marshal.GetDelegateForFunctionPointer<T>(codePointer);
}
@@ -3,7 +3,7 @@ namespace ARMeilleure.CodeGen.Linking
/// <summary>
/// Represents a relocation.
/// </summary>
readonly struct RelocEntry
public readonly struct RelocEntry
{
public const int Stride = 13; // Bytes.
+1 -1
View File
@@ -5,7 +5,7 @@ namespace ARMeilleure.CodeGen.Linking
/// <summary>
/// Represents relocation information about a <see cref="CompiledFunction"/>.
/// </summary>
readonly struct RelocInfo
public readonly struct RelocInfo
{
/// <summary>
/// Gets an empty <see cref="RelocInfo"/>.
+1 -1
View File
@@ -5,7 +5,7 @@ namespace ARMeilleure.CodeGen.Linking
/// <summary>
/// Represents a symbol.
/// </summary>
readonly struct Symbol
public readonly struct Symbol
{
private readonly ulong _value;
@@ -3,7 +3,7 @@ namespace ARMeilleure.CodeGen.Linking
/// <summary>
/// Types of <see cref="Symbol"/>.
/// </summary>
enum SymbolType : byte
public enum SymbolType : byte
{
/// <summary>
/// Refers to nothing, i.e no symbol.
@@ -1,6 +1,6 @@
namespace ARMeilleure.CodeGen.Unwinding
{
struct UnwindInfo
public struct UnwindInfo
{
public const int Stride = 4; // Bytes.
@@ -1,6 +1,6 @@
namespace ARMeilleure.CodeGen.Unwinding
{
enum UnwindPseudoOp
public enum UnwindPseudoOp
{
PushReg = 0,
SetFrame = 1,
@@ -1,6 +1,6 @@
namespace ARMeilleure.CodeGen.Unwinding
{
struct UnwindPushEntry
public struct UnwindPushEntry
{
public const int Stride = 16; // Bytes.
+1 -1
View File
@@ -6,7 +6,7 @@ namespace ARMeilleure.Common
public readonly struct AddressTableLevel
{
/// <summary>
/// Gets the index of the <see cref="Level"/> in the guest address.
/// Gets the index of the <see cref="Index"/> in the guest address.
/// </summary>
public int Index { get; }
+1 -1
View File
@@ -2,7 +2,7 @@ using System;
namespace ARMeilleure.Memory
{
public class ReservedRegion
public class ReservedRegion : IDisposable
{
public const int DefaultGranularity = 65536; // Mapping granularity in Windows.
+7 -6
View File
@@ -1,5 +1,6 @@
using ARMeilleure.IntermediateRepresentation;
using ARMeilleure.Translation;
using ARMeilleure.Translation.Cache;
using System.Runtime.InteropServices;
using static ARMeilleure.IntermediateRepresentation.Operand.Factory;
@@ -17,7 +18,7 @@ namespace ARMeilleure.Signal
public delegate int DebugThreadLocalMapGetOrReserve(int threadId, int initialState);
public delegate void DebugNativeWriteLoop(nint nativeWriteLoopPtr, nint writePtr);
public static DebugPartialUnmap GenerateDebugPartialUnmap()
public static DebugPartialUnmap GenerateDebugPartialUnmap(JitCache jitCache)
{
EmitterContext context = new();
@@ -31,10 +32,10 @@ namespace ARMeilleure.Signal
OperandType[] argTypes = [OperandType.I64];
return Compiler.Compile(cfg, argTypes, OperandType.I32, CompilerOptions.HighCq, RuntimeInformation.ProcessArchitecture).Map<DebugPartialUnmap>();
return Compiler.Compile(cfg, argTypes, OperandType.I32, CompilerOptions.HighCq, RuntimeInformation.ProcessArchitecture).Map<DebugPartialUnmap>(jitCache);
}
public static DebugThreadLocalMapGetOrReserve GenerateDebugThreadLocalMapGetOrReserve(nint structPtr)
public static DebugThreadLocalMapGetOrReserve GenerateDebugThreadLocalMapGetOrReserve(JitCache jitCache, nint structPtr)
{
EmitterContext context = new();
@@ -48,10 +49,10 @@ namespace ARMeilleure.Signal
OperandType[] argTypes = [OperandType.I64];
return Compiler.Compile(cfg, argTypes, OperandType.I32, CompilerOptions.HighCq, RuntimeInformation.ProcessArchitecture).Map<DebugThreadLocalMapGetOrReserve>();
return Compiler.Compile(cfg, argTypes, OperandType.I32, CompilerOptions.HighCq, RuntimeInformation.ProcessArchitecture).Map<DebugThreadLocalMapGetOrReserve>(jitCache);
}
public static DebugNativeWriteLoop GenerateDebugNativeWriteLoop()
public static DebugNativeWriteLoop GenerateDebugNativeWriteLoop(JitCache jitCache)
{
EmitterContext context = new();
@@ -77,7 +78,7 @@ namespace ARMeilleure.Signal
OperandType[] argTypes = [OperandType.I64];
return Compiler.Compile(cfg, argTypes, OperandType.None, CompilerOptions.HighCq, RuntimeInformation.ProcessArchitecture).Map<DebugNativeWriteLoop>();
return Compiler.Compile(cfg, argTypes, OperandType.None, CompilerOptions.HighCq, RuntimeInformation.ProcessArchitecture).Map<DebugNativeWriteLoop>(jitCache);
}
}
}
@@ -4,7 +4,7 @@ using System.Diagnostics.CodeAnalysis;
namespace ARMeilleure.Translation.Cache
{
readonly struct CacheEntry : IComparable<CacheEntry>
public readonly struct CacheEntry : IComparable<CacheEntry>
{
public int Offset { get; }
public int Size { get; }
@@ -22,12 +22,31 @@ namespace ARMeilleure.Translation.Cache
return Offset.CompareTo(other.Offset);
}
}
private readonly int _regionSize;
private int _regionCount;
private readonly List<MemoryBlock> _blocks = [];
public CacheMemoryAllocator(int capacity)
public CacheMemoryAllocator(int regionSize, int initialRegionCount = 1)
{
_blocks.Add(new MemoryBlock(0, capacity));
_regionCount = 0;
_regionSize = regionSize;
for (; initialRegionCount > 0; initialRegionCount--)
{
_blocks.Add(new MemoryBlock(_regionSize * _regionCount, _regionSize));
_regionCount++;
}
}
public void AddNewBlocks(int count)
{
for (; count > 0; count--)
{
_blocks.Add(new MemoryBlock(_regionSize * _regionCount, _regionSize));
_regionCount++;
}
}
public int Allocate(int size)
@@ -65,13 +84,14 @@ namespace ARMeilleure.Translation.Cache
{
index = ~index;
}
int endOffs = block.Offset + block.Size;
if (index < _blocks.Count)
// Don't merge blocks from different allocations
if (index < _blocks.Count && endOffs % _regionSize != 0)
{
MemoryBlock next = _blocks[index];
int endOffs = block.Offset + block.Size;
if (next.Offset == endOffs)
{
block = new MemoryBlock(block.Offset, block.Size + next.Size);
@@ -79,7 +99,8 @@ namespace ARMeilleure.Translation.Cache
}
}
if (index > 0)
// Don't merge blocks from different allocations
if (index > 0 && block.Offset % _regionSize != 0)
{
MemoryBlock prev = _blocks[index - 1];
+81 -92
View File
@@ -14,62 +14,35 @@ using System.Threading;
namespace ARMeilleure.Translation.Cache
{
static partial class JitCache
public partial class JitCache : IDisposable
{
private static readonly int _pageSize = (int)MemoryBlock.GetPageSize();
private static readonly int _pageMask = _pageSize - 1;
private const int CodeAlignment = 4; // Bytes.
private const int CacheSize = 256 * 1024 * 1024;
private const uint CacheSize = 256 * 1024 * 1024;
private static JitCacheInvalidation _jitCacheInvalidator;
private readonly JitCacheInvalidation _jitCacheInvalidator;
private static readonly List<CacheMemoryAllocator> _cacheAllocators = [];
private readonly CacheMemoryAllocator _cacheAllocator;
private static readonly List<CacheEntry> _cacheEntries = [];
private readonly List<CacheEntry> _cacheEntries = [];
private static readonly Lock _lock = new();
private static bool _initialized;
private readonly Lock _lock = new();
private static readonly List<ReservedRegion> _jitRegions = [];
private static int _activeRegionIndex = 0;
private readonly List<ReservedRegion> _jitRegions = [];
[SupportedOSPlatform("windows")]
[LibraryImport("kernel32.dll", SetLastError = true)]
public static partial nint FlushInstructionCache(nint hProcess, nint lpAddress, nuint dwSize);
private static partial nint FlushInstructionCache(nint hProcess, nint lpAddress, nuint dwSize);
public static void Initialize(IJitMemoryAllocator allocator)
public JitCache(IJitMemoryAllocator allocator)
{
lock (_lock)
{
if (_initialized)
{
if (OperatingSystem.IsWindows())
{
JitUnwindWindows.RemoveFunctionTableHandler(
_jitRegions[0].Pointer);
}
_jitRegions.Add(new(allocator, CacheSize));
for (int i = 0; i < _jitRegions.Count; i++)
{
_jitRegions[i].Dispose();
}
_jitRegions.Clear();
_cacheAllocators.Clear();
}
else
{
_initialized = true;
}
_activeRegionIndex = 0;
ReservedRegion firstRegion = new(allocator, CacheSize);
_jitRegions.Add(firstRegion);
CacheMemoryAllocator firstCacheAllocator = new(CacheSize);
_cacheAllocators.Add(firstCacheAllocator);
_cacheAllocator = new((int)CacheSize);
if (!OperatingSystem.IsWindows() && !OperatingSystem.IsMacOS())
{
@@ -79,23 +52,20 @@ namespace ARMeilleure.Translation.Cache
if (OperatingSystem.IsWindows())
{
JitUnwindWindows.InstallFunctionTableHandler(
firstRegion.Pointer, CacheSize, firstRegion.Pointer + Allocate(_pageSize)
this, _jitRegions[0].Pointer, CacheSize, _jitRegions[0].Pointer + Allocate(_pageSize)
);
}
}
}
public static nint Map(CompiledFunction func)
public nint Map(CompiledFunction func)
{
byte[] code = func.Code;
lock (_lock)
{
Debug.Assert(_initialized);
int funcOffset = Allocate(code.Length);
ReservedRegion targetRegion = _jitRegions[_activeRegionIndex];
nint funcPtr = targetRegion.Pointer + funcOffset;
nint funcPtr = GetFunctionPtr(funcOffset);
if (OperatingSystem.IsMacOS() && RuntimeInformation.ProcessArchitecture == Architecture.Arm64)
{
@@ -109,9 +79,9 @@ namespace ARMeilleure.Translation.Cache
}
else
{
ReprotectAsWritable(targetRegion, funcOffset, code.Length);
ReprotectAsWritable(funcOffset, code.Length);
Marshal.Copy(code, 0, funcPtr, code.Length);
ReprotectAsExecutable(targetRegion, funcOffset, code.Length);
ReprotectAsExecutable(funcOffset, code.Length);
if (OperatingSystem.IsWindows() && RuntimeInformation.ProcessArchitecture == Architecture.Arm64)
{
@@ -129,25 +99,24 @@ namespace ARMeilleure.Translation.Cache
}
}
public static void Unmap(nint pointer)
public void Unmap(nint pointer)
{
lock (_lock)
{
Debug.Assert(_initialized);
foreach (ReservedRegion region in _jitRegions)
for (int i = 0; i < _jitRegions.Count; i++)
{
if (pointer.ToInt64() < region.Pointer.ToInt64() ||
pointer.ToInt64() >= (region.Pointer + CacheSize).ToInt64())
ReservedRegion jitRegion = _jitRegions[i];
if (pointer.ToInt64() < jitRegion.Pointer.ToInt64() ||
pointer.ToInt64() >= (jitRegion.Pointer + (nint)CacheSize).ToInt64())
{
continue;
}
int funcOffset = (int)(pointer.ToInt64() - region.Pointer.ToInt64());
int funcOffset = (int)(pointer.ToInt64() - jitRegion.Pointer.ToInt64() + i * CacheSize);
if (TryFind(funcOffset, out CacheEntry entry, out int entryIndex) && entry.Offset == funcOffset)
{
_cacheAllocators[_activeRegionIndex].Free(funcOffset, AlignCodeSize(entry.Size));
_cacheAllocator.Free(funcOffset, AlignCodeSize(entry.Size));
_cacheEntries.RemoveAt(entryIndex);
}
@@ -156,53 +125,63 @@ namespace ARMeilleure.Translation.Cache
}
}
private static void ReprotectAsWritable(ReservedRegion region, int offset, int size)
private void ReprotectAsWritable(int offset, int size)
{
int endOffs = offset + size;
int regionStart = offset & ~_pageMask;
int regionEnd = (endOffs + _pageMask) & ~_pageMask;
region.Block.MapAsRwx((ulong)regionStart, (ulong)(regionEnd - regionStart));
int regionStart = (offset % (int)CacheSize) & ~_pageMask;
int regionEnd = ((endOffs % (int)CacheSize) + _pageMask) & ~_pageMask;
GetRegion(offset).Block.MapAsRwx((ulong)regionStart, (ulong)(regionEnd - regionStart));
}
private static void ReprotectAsExecutable(ReservedRegion region, int offset, int size)
private void ReprotectAsExecutable(int offset, int size)
{
int endOffs = offset + size;
int regionStart = offset & ~_pageMask;
int regionEnd = (endOffs + _pageMask) & ~_pageMask;
int regionStart = (offset % (int)CacheSize) & ~_pageMask;
int regionEnd = ((endOffs % (int)CacheSize) + _pageMask) & ~_pageMask;
region.Block.MapAsRx((ulong)regionStart, (ulong)(regionEnd - regionStart));
GetRegion(offset).Block.MapAsRx((ulong)regionStart, (ulong)(regionEnd - regionStart));
}
private static int Allocate(int codeSize)
private int Allocate(int codeSize)
{
codeSize = AlignCodeSize(codeSize);
int allocOffset = _cacheAllocators[_activeRegionIndex].Allocate(codeSize);
int allocOffset = _cacheAllocator.Allocate(codeSize);
if (allocOffset >= 0)
{
_jitRegions[_activeRegionIndex].ExpandIfNeeded((ulong)allocOffset + (ulong)codeSize);
GetRegion(allocOffset).ExpandIfNeeded((ulong)(allocOffset % (int)CacheSize) + (ulong)codeSize);
return allocOffset;
}
int exhaustedRegion = _activeRegionIndex;
_cacheAllocator.AddNewBlocks(1);
ReservedRegion newRegion = new(_jitRegions[0].Allocator, CacheSize);
Logger.Warning?.Print(LogClass.Cpu, $"JIT Cache of size {(_jitRegions.Count * CacheSize).Bytes()} exhausted, creating new Cache Region ({((_jitRegions.Count + 1) * CacheSize).Bytes()} Total Allocation).");
_jitRegions.Add(newRegion);
_activeRegionIndex = _jitRegions.Count - 1;
Logger.Warning?.Print(LogClass.Cpu, $"JIT Cache Region {exhaustedRegion} exhausted, creating new Cache Region {_activeRegionIndex} ({((long)(_activeRegionIndex + 1) * CacheSize).Bytes()} Total Allocation).");
_cacheAllocators.Add(new CacheMemoryAllocator(CacheSize));
int allocOffsetNew = _cacheAllocators[_activeRegionIndex].Allocate(codeSize);
if (allocOffsetNew < 0)
allocOffset = _cacheAllocator.Allocate(codeSize);
if (allocOffset < 0)
{
throw new OutOfMemoryException("Failed to allocate in new Cache Region!");
}
newRegion.ExpandIfNeeded((ulong)allocOffsetNew + (ulong)codeSize);
return allocOffsetNew;
GetRegion(allocOffset).ExpandIfNeeded((ulong)(allocOffset % (int)CacheSize) + (ulong)codeSize);
return allocOffset;
}
private nint GetFunctionPtr(int offset)
{
return GetRegion(offset).Pointer + (offset % (int)CacheSize);
}
private ReservedRegion GetRegion(int offset)
{
int index = offset / (int)CacheSize;
return _jitRegions[index];
}
private static int AlignCodeSize(int codeSize)
@@ -210,7 +189,7 @@ namespace ARMeilleure.Translation.Cache
return checked(codeSize + (CodeAlignment - 1)) & ~(CodeAlignment - 1);
}
private static void Add(int offset, int size, UnwindInfo unwindInfo)
private void Add(int offset, int size, UnwindInfo unwindInfo)
{
CacheEntry entry = new(offset, size, unwindInfo);
@@ -224,25 +203,22 @@ namespace ARMeilleure.Translation.Cache
_cacheEntries.Insert(index, entry);
}
public static bool TryFind(int offset, out CacheEntry entry, out int entryIndex)
public bool TryFind(int offset, out CacheEntry entry, out int entryIndex)
{
lock (_lock)
{
foreach (ReservedRegion _ in _jitRegions)
int index = _cacheEntries.BinarySearch(new CacheEntry(offset, 0, default));
if (index < 0)
{
int index = _cacheEntries.BinarySearch(new CacheEntry(offset, 0, default));
index = ~index - 1;
}
if (index < 0)
{
index = ~index - 1;
}
if (index >= 0)
{
entry = _cacheEntries[index];
entryIndex = index;
return true;
}
if (index >= 0)
{
entry = _cacheEntries[index];
entryIndex = index;
return true;
}
}
@@ -250,5 +226,18 @@ namespace ARMeilleure.Translation.Cache
entryIndex = 0;
return false;
}
public void Dispose()
{
if (OperatingSystem.IsWindows())
{
JitUnwindWindows.RemoveFunctionTableHandler(_jitRegions[0].Pointer);
}
foreach (ReservedRegion jitRegion in _jitRegions)
{
jitRegion.Dispose();
}
}
}
}
@@ -2,6 +2,8 @@
using ARMeilleure.CodeGen.Unwinding;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Runtime.InteropServices;
@@ -26,6 +28,29 @@ namespace ARMeilleure.Translation.Cache
public byte FrameRegister;
public unsafe fixed ushort UnwindCodes[MaxUnwindCodesArraySize];
}
private unsafe struct InternalFunctionHandler
{
public InternalFunctionHandler(JitCache jitCache, nint workBufferPtr)
{
_jitCache = jitCache;
_runtimeFunction = (RuntimeFunction*)workBufferPtr;
_unwindInfo = (UnwindInfo*)(workBufferPtr + _sizeOfRuntimeFunction);
}
readonly JitCache _jitCache;
readonly RuntimeFunction* _runtimeFunction;
readonly UnwindInfo* _unwindInfo;
public RuntimeFunction* FunctionTableHandler(ulong controlPc, nint context)
{
return JitUnwindWindows.FunctionTableHandler(_jitCache, _runtimeFunction, _unwindInfo, controlPc, context);
}
}
private enum UnwindOp
{
@@ -59,27 +84,28 @@ namespace ARMeilleure.Translation.Cache
private static GetRuntimeFunctionCallback _getRuntimeFunctionCallback;
private static int _sizeOfRuntimeFunction;
private static readonly int _sizeOfRuntimeFunction;
private static readonly ConcurrentDictionary<ulong, InternalFunctionHandler> _functionTableHandlers = new();
private unsafe static RuntimeFunction* _runtimeFunction;
static JitUnwindWindows()
{
_sizeOfRuntimeFunction = Marshal.SizeOf<RuntimeFunction>();
}
private unsafe static UnwindInfo* _unwindInfo;
public static void InstallFunctionTableHandler(nint codeCachePointer, uint codeCacheLength, nint workBufferPtr)
public static void InstallFunctionTableHandler(JitCache jitCache, nint codeCachePointer, uint codeCacheLength, nint workBufferPtr)
{
ulong codeCachePtr = (ulong)codeCachePointer.ToInt64();
_sizeOfRuntimeFunction = Marshal.SizeOf<RuntimeFunction>();
bool result;
InternalFunctionHandler handler;
unsafe
{
_runtimeFunction = (RuntimeFunction*)workBufferPtr;
handler = new InternalFunctionHandler(jitCache, workBufferPtr);
_unwindInfo = (UnwindInfo*)(workBufferPtr + _sizeOfRuntimeFunction);
_getRuntimeFunctionCallback = new GetRuntimeFunctionCallback(FunctionTableHandler);
_getRuntimeFunctionCallback = handler.FunctionTableHandler;
result = RtlInstallFunctionTableCallback(
codeCachePtr | 3,
@@ -94,6 +120,8 @@ namespace ARMeilleure.Translation.Cache
{
throw new InvalidOperationException("Failure installing function table callback.");
}
_functionTableHandlers.TryAdd(codeCachePtr, handler);
}
public static void RemoveFunctionTableHandler(nint codeCachePointer)
@@ -111,24 +139,26 @@ namespace ARMeilleure.Translation.Cache
{
throw new InvalidOperationException("Failure removing function table callback.");
}
_functionTableHandlers.Remove(codeCachePtr, out _);
}
private static unsafe RuntimeFunction* FunctionTableHandler(ulong controlPc, nint context)
private static unsafe RuntimeFunction* FunctionTableHandler(JitCache jitCache, RuntimeFunction* runtimeFunction, UnwindInfo* unwindInfo, ulong controlPc, nint context)
{
int offset = (int)((long)controlPc - context.ToInt64());
if (!JitCache.TryFind(offset, out CacheEntry funcEntry, out _))
if (!jitCache.TryFind(offset, out CacheEntry funcEntry, out _))
{
return null; // Not found.
}
CodeGen.Unwinding.UnwindInfo unwindInfo = funcEntry.UnwindInfo;
CodeGen.Unwinding.UnwindInfo funcUnwindInfo = funcEntry.UnwindInfo;
int codeIndex = 0;
for (int index = unwindInfo.PushEntries.Length - 1; index >= 0; index--)
for (int index = funcUnwindInfo.PushEntries.Length - 1; index >= 0; index--)
{
UnwindPushEntry entry = unwindInfo.PushEntries[index];
UnwindPushEntry entry = funcUnwindInfo.PushEntries[index];
switch (entry.PseudoOp)
{
@@ -140,14 +170,14 @@ namespace ARMeilleure.Translation.Cache
if (stackOffset <= 0xFFFF0)
{
_unwindInfo->UnwindCodes[codeIndex++] = PackUnwindOp(UnwindOp.SaveXmm128, entry.PrologOffset, entry.RegIndex);
_unwindInfo->UnwindCodes[codeIndex++] = (ushort)(stackOffset / 16);
unwindInfo->UnwindCodes[codeIndex++] = PackUnwindOp(UnwindOp.SaveXmm128, entry.PrologOffset, entry.RegIndex);
unwindInfo->UnwindCodes[codeIndex++] = (ushort)(stackOffset / 16);
}
else
{
_unwindInfo->UnwindCodes[codeIndex++] = PackUnwindOp(UnwindOp.SaveXmm128Far, entry.PrologOffset, entry.RegIndex);
_unwindInfo->UnwindCodes[codeIndex++] = (ushort)(stackOffset >> 0);
_unwindInfo->UnwindCodes[codeIndex++] = (ushort)(stackOffset >> 16);
unwindInfo->UnwindCodes[codeIndex++] = PackUnwindOp(UnwindOp.SaveXmm128Far, entry.PrologOffset, entry.RegIndex);
unwindInfo->UnwindCodes[codeIndex++] = (ushort)(stackOffset >> 0);
unwindInfo->UnwindCodes[codeIndex++] = (ushort)(stackOffset >> 16);
}
break;
@@ -161,18 +191,18 @@ namespace ARMeilleure.Translation.Cache
if (allocSize <= 128)
{
_unwindInfo->UnwindCodes[codeIndex++] = PackUnwindOp(UnwindOp.AllocSmall, entry.PrologOffset, (allocSize / 8) - 1);
unwindInfo->UnwindCodes[codeIndex++] = PackUnwindOp(UnwindOp.AllocSmall, entry.PrologOffset, (allocSize / 8) - 1);
}
else if (allocSize <= 0x7FFF8)
{
_unwindInfo->UnwindCodes[codeIndex++] = PackUnwindOp(UnwindOp.AllocLarge, entry.PrologOffset, 0);
_unwindInfo->UnwindCodes[codeIndex++] = (ushort)(allocSize / 8);
unwindInfo->UnwindCodes[codeIndex++] = PackUnwindOp(UnwindOp.AllocLarge, entry.PrologOffset, 0);
unwindInfo->UnwindCodes[codeIndex++] = (ushort)(allocSize / 8);
}
else
{
_unwindInfo->UnwindCodes[codeIndex++] = PackUnwindOp(UnwindOp.AllocLarge, entry.PrologOffset, 1);
_unwindInfo->UnwindCodes[codeIndex++] = (ushort)(allocSize >> 0);
_unwindInfo->UnwindCodes[codeIndex++] = (ushort)(allocSize >> 16);
unwindInfo->UnwindCodes[codeIndex++] = PackUnwindOp(UnwindOp.AllocLarge, entry.PrologOffset, 1);
unwindInfo->UnwindCodes[codeIndex++] = (ushort)(allocSize >> 0);
unwindInfo->UnwindCodes[codeIndex++] = (ushort)(allocSize >> 16);
}
break;
@@ -180,7 +210,7 @@ namespace ARMeilleure.Translation.Cache
case UnwindPseudoOp.PushReg:
{
_unwindInfo->UnwindCodes[codeIndex++] = PackUnwindOp(UnwindOp.PushNonvol, entry.PrologOffset, entry.RegIndex);
unwindInfo->UnwindCodes[codeIndex++] = PackUnwindOp(UnwindOp.PushNonvol, entry.PrologOffset, entry.RegIndex);
break;
}
@@ -192,16 +222,16 @@ namespace ARMeilleure.Translation.Cache
Debug.Assert(codeIndex <= MaxUnwindCodesArraySize);
_unwindInfo->VersionAndFlags = 1; // Flags: The function has no handler.
_unwindInfo->SizeOfProlog = (byte)unwindInfo.PrologSize;
_unwindInfo->CountOfUnwindCodes = (byte)codeIndex;
_unwindInfo->FrameRegister = 0;
unwindInfo->VersionAndFlags = 1; // Flags: The function has no handler.
unwindInfo->SizeOfProlog = (byte)funcUnwindInfo.PrologSize;
unwindInfo->CountOfUnwindCodes = (byte)codeIndex;
unwindInfo->FrameRegister = 0;
_runtimeFunction->BeginAddress = (uint)funcEntry.Offset;
_runtimeFunction->EndAddress = (uint)(funcEntry.Offset + funcEntry.Size);
_runtimeFunction->UnwindData = (uint)_sizeOfRuntimeFunction;
runtimeFunction->BeginAddress = (uint)funcEntry.Offset;
runtimeFunction->EndAddress = (uint)(funcEntry.Offset + funcEntry.Size);
runtimeFunction->UnwindData = (uint)_sizeOfRuntimeFunction;
return _runtimeFunction;
return runtimeFunction;
}
private static ushort PackUnwindOp(UnwindOp op, int prologOffset, int opInfo)
+9 -4
View File
@@ -4,6 +4,7 @@ using ARMeilleure.CodeGen.Unwinding;
using ARMeilleure.Common;
using ARMeilleure.Memory;
using ARMeilleure.State;
using ARMeilleure.Translation.Cache;
using Humanizer;
using Ryujinx.Common;
using Ryujinx.Common.Configuration;
@@ -33,7 +34,7 @@ namespace ARMeilleure.Translation.PTC
private const string OuterHeaderMagicString = "PTCohd\0\0";
private const string InnerHeaderMagicString = "PTCihd\0\0";
private const uint InternalVersion = 7010; //! To be incremented manually for each change to the ARMeilleure project.
private const uint InternalVersion = 7016; //! To be incremented manually for each change to the ARMeilleure project.
private const string ActualDir = "0";
private const string BackupDir = "1";
@@ -50,6 +51,8 @@ namespace ARMeilleure.Translation.PTC
private const CompressionLevel SaveCompressionLevel = CompressionLevel.Fastest;
public PtcProfiler Profiler { get; }
private readonly JitCache _jitCache;
// Carriers.
private MemoryStream _infosStream;
@@ -81,7 +84,7 @@ namespace ARMeilleure.Translation.PTC
private volatile int _translateTotalCount;
public event Action<PtcLoadingState, int, int> PtcStateChanged;
public Ptc()
public Ptc(JitCache jitCache)
{
Profiler = new PtcProfiler(this);
@@ -92,6 +95,8 @@ namespace ARMeilleure.Translation.PTC
_waitEvent = new ManualResetEvent(true);
_jitCache = jitCache;
_disposed = false;
TitleIdText = TitleIdTextDefault;
@@ -782,7 +787,7 @@ namespace ARMeilleure.Translation.PTC
return new UnwindInfo(pushEntries, prologueSize);
}
private static TranslatedFunction FastTranslate(
private TranslatedFunction FastTranslate(
byte[] code,
Counter<uint> callCounter,
ulong guestSize,
@@ -790,7 +795,7 @@ namespace ARMeilleure.Translation.PTC
bool highCq)
{
CompiledFunction cFunc = new(code, unwindInfo, RelocInfo.Empty);
GuestFunction gFunc = cFunc.MapWithPointer<GuestFunction>(out nint gFuncPointer);
GuestFunction gFunc = cFunc.MapWithPointer<GuestFunction>(_jitCache, out nint gFuncPointer);
return new TranslatedFunction(gFunc, gFuncPointer, callCounter, guestSize, highCq);
}
+7 -4
View File
@@ -24,6 +24,7 @@ namespace ARMeilleure.Translation
private readonly IJitMemoryAllocator _allocator;
private readonly ConcurrentQueue<KeyValuePair<ulong, TranslatedFunction>> _oldFuncs;
public readonly JitCache JitCache;
private readonly Ptc _ptc;
internal TranslatorCache<TranslatedFunction> Functions { get; }
@@ -43,16 +44,17 @@ namespace ARMeilleure.Translation
_oldFuncs = new ConcurrentQueue<KeyValuePair<ulong, TranslatedFunction>>();
_ptc = new Ptc();
JitCache = new JitCache(allocator);
_ptc = new Ptc(JitCache);
Queue = new TranslatorQueue();
JitCache.Initialize(allocator);
CountTable = new EntryTable<uint>();
Functions = new TranslatorCache<TranslatedFunction>();
FunctionTable = functionTable;
Stubs = new TranslatorStubs(FunctionTable);
Stubs = new TranslatorStubs(JitCache, FunctionTable);
FunctionTable.Fill = (ulong)Stubs.SlowDispatchStub;
}
@@ -167,6 +169,7 @@ namespace ARMeilleure.Translation
}
ClearJitCache();
JitCache.Dispose();
Stubs.Dispose();
FunctionTable.Dispose();
@@ -305,7 +308,7 @@ namespace ARMeilleure.Translation
_ptc.WriteCompiledFunction(address, funcSize, hash, highCq, compiledFunc);
}
GuestFunction func = compiledFunc.MapWithPointer<GuestFunction>(out nint funcPointer);
GuestFunction func = compiledFunc.MapWithPointer<GuestFunction>(JitCache, out nint funcPointer);
Allocators.ResetAll();
+12 -7
View File
@@ -14,6 +14,8 @@ namespace ARMeilleure.Translation
/// </summary>
class TranslatorStubs : IDisposable
{
private readonly JitCache _jitCache;
private readonly Lazy<nint> _slowDispatchStub;
private bool _disposed;
@@ -83,11 +85,14 @@ namespace ARMeilleure.Translation
/// Initializes a new instance of the <see cref="TranslatorStubs"/> class with the specified
/// <see cref="Translator"/> instance.
/// </summary>
/// <param name="jitCache">Jit cache to map functions in</param>
/// <param name="functionTable">Function table used to store pointers to the functions that the guest code will call</param>
/// <exception cref="ArgumentNullException"><paramref name="translator"/> is null</exception>
public TranslatorStubs(IAddressTable<ulong> functionTable)
public TranslatorStubs(JitCache jitCache, IAddressTable<ulong> functionTable)
{
ArgumentNullException.ThrowIfNull(functionTable);
_jitCache = jitCache;
_functionTable = functionTable;
_slowDispatchStub = new(GenerateSlowDispatchStub, isThreadSafe: true);
@@ -115,12 +120,12 @@ namespace ARMeilleure.Translation
{
if (_dispatchStub.IsValueCreated)
{
JitCache.Unmap(_dispatchStub.Value);
_jitCache.Unmap(_dispatchStub.Value);
}
if (_dispatchLoop.IsValueCreated)
{
JitCache.Unmap(Marshal.GetFunctionPointerForDelegate(_dispatchLoop.Value));
_jitCache.Unmap(Marshal.GetFunctionPointerForDelegate(_dispatchLoop.Value));
}
_disposed = true;
@@ -188,7 +193,7 @@ namespace ARMeilleure.Translation
OperandType retType = OperandType.I64;
OperandType[] argTypes = [OperandType.I64];
GuestFunction func = Compiler.Compile(cfg, argTypes, retType, CompilerOptions.HighCq, RuntimeInformation.ProcessArchitecture).Map<GuestFunction>();
GuestFunction func = Compiler.Compile(cfg, argTypes, retType, CompilerOptions.HighCq, RuntimeInformation.ProcessArchitecture).Map<GuestFunction>(_jitCache);
return Marshal.GetFunctionPointerForDelegate(func);
}
@@ -213,7 +218,7 @@ namespace ARMeilleure.Translation
OperandType retType = OperandType.I64;
OperandType[] argTypes = [OperandType.I64];
GuestFunction func = Compiler.Compile(cfg, argTypes, retType, CompilerOptions.HighCq, RuntimeInformation.ProcessArchitecture).Map<GuestFunction>();
GuestFunction func = Compiler.Compile(cfg, argTypes, retType, CompilerOptions.HighCq, RuntimeInformation.ProcessArchitecture).Map<GuestFunction>(_jitCache);
return Marshal.GetFunctionPointerForDelegate(func);
}
@@ -290,7 +295,7 @@ namespace ARMeilleure.Translation
OperandType retType = OperandType.None;
OperandType[] argTypes = [OperandType.I64, OperandType.I64];
return Compiler.Compile(cfg, argTypes, retType, CompilerOptions.HighCq, RuntimeInformation.ProcessArchitecture).Map<DispatcherFunction>();
return Compiler.Compile(cfg, argTypes, retType, CompilerOptions.HighCq, RuntimeInformation.ProcessArchitecture).Map<DispatcherFunction>(_jitCache);
}
/// <summary>
@@ -314,7 +319,7 @@ namespace ARMeilleure.Translation
OperandType retType = OperandType.I64;
OperandType[] argTypes = [OperandType.I64, OperandType.I64];
return Compiler.Compile(cfg, argTypes, retType, CompilerOptions.HighCq, RuntimeInformation.ProcessArchitecture).Map<WrapperFunction>();
return Compiler.Compile(cfg, argTypes, retType, CompilerOptions.HighCq, RuntimeInformation.ProcessArchitecture).Map<WrapperFunction>(_jitCache);
}
}
}
@@ -1,6 +1,7 @@
using ARMeilleure.CodeGen.X86;
using ARMeilleure.IntermediateRepresentation;
using ARMeilleure.State;
using ARMeilleure.Translation.Cache;
using System;
using System.Runtime.InteropServices;
using static ARMeilleure.IntermediateRepresentation.Operand.Factory;
@@ -59,7 +60,7 @@ namespace ARMeilleure.Translation
return context.VectorExtract(OperandType.I32, vec, 0);
}
public static FpFlagsPInvokeTest GenerateFpFlagsPInvokeTest()
public static FpFlagsPInvokeTest GenerateFpFlagsPInvokeTest(JitCache jitCache)
{
EmitterContext context = new();
@@ -141,7 +142,7 @@ namespace ARMeilleure.Translation
OperandType[] argTypes = [OperandType.I64];
return Compiler.Compile(cfg, argTypes, OperandType.I32, CompilerOptions.HighCq, RuntimeInformation.ProcessArchitecture).Map<FpFlagsPInvokeTest>();
return Compiler.Compile(cfg, argTypes, OperandType.I32, CompilerOptions.HighCq, RuntimeInformation.ProcessArchitecture).Map<FpFlagsPInvokeTest>(jitCache);
}
}
}
@@ -128,7 +128,7 @@ namespace Ryujinx.BuildValidationTasks
using (StreamWriter sw = new(path))
{
sw.Write(jsonString);
sw.Write($"{jsonString}\n");
}
}
@@ -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,
};
}
}
}
+2
View File
@@ -199,6 +199,7 @@ namespace Ryujinx.Common
"0100744001588000", // Cars 3: Driven to Win
"0100b41013c82000", // Cruis'n Blast
"010085900337e000", // Death Squared
"0100a0d022a68000", // DELTARUNE
"01001b300b9be000", // Diablo III: Eternal Collection
"010027400cdc6000", // Divinity Original 2 - Definitive Edition
"01008c8012920000", // Dying Light Platinum Edition
@@ -211,6 +212,7 @@ namespace Ryujinx.Common
"010013c00e930000", // Hollow Knight: Silksong
"010085500130a000", // Lego City: Undercover
"010073c01af34000", // LEGO Horizon Adventures
"0100419014456000", // Mary Skelter Finale
"0100d71004694000", // Minecraft
"01007430037f6000", // Monopoly
"0100853015e86000", // No Man's Sky
@@ -23,12 +23,31 @@ namespace Ryujinx.Cpu.LightningJit.Cache
return Offset.CompareTo(other.Offset);
}
}
private readonly int _regionSize;
private int _regionCount;
private readonly List<MemoryBlock> _blocks = [];
public CacheMemoryAllocator(int capacity)
public CacheMemoryAllocator(int regionSize, int initialRegionCount = 1)
{
_blocks.Add(new MemoryBlock(0, capacity));
_regionCount = 0;
_regionSize = regionSize;
for (; initialRegionCount > 0; initialRegionCount--)
{
_blocks.Add(new MemoryBlock(_regionSize * _regionCount, _regionSize));
_regionCount++;
}
}
public void AddNewBlocks(int count)
{
for (; count > 0; count--)
{
_blocks.Add(new MemoryBlock(_regionSize * _regionCount, _regionSize));
_regionCount++;
}
}
public int Allocate(int size)
@@ -100,13 +119,14 @@ namespace Ryujinx.Cpu.LightningJit.Cache
{
index = ~index;
}
int endOffs = block.Offset + block.Size;
if (index < _blocks.Count)
// Don't merge blocks from different allocations
if (index < _blocks.Count && endOffs % _regionSize != 0)
{
MemoryBlock next = _blocks[index];
int endOffs = block.Offset + block.Size;
if (next.Offset == endOffs)
{
block = new MemoryBlock(block.Offset, block.Size + next.Size);
@@ -114,7 +134,8 @@ namespace Ryujinx.Cpu.LightningJit.Cache
}
}
if (index > 0)
// Don't merge blocks from different allocations
if (index > 0 && block.Offset % _regionSize != 0)
{
MemoryBlock prev = _blocks[index - 1];
+66 -86
View File
@@ -11,76 +11,45 @@ using System.Threading;
namespace Ryujinx.Cpu.LightningJit.Cache
{
static partial class JitCache
partial class JitCache : IDisposable
{
private static readonly int _pageSize = (int)MemoryBlock.GetPageSize();
private static readonly int _pageMask = _pageSize - 1;
private const int CodeAlignment = 4; // Bytes.
// TODO: JIT Cache size should be application dependent, not global.
private const int CacheSize = 1024 * (1024 * 1024); // Megabytes * Size of Megabytes (since its in bytes).
private const uint CacheSize = 256 * 1024 * 1024; // Megabytes * Size of Megabytes (since its in bytes).
private static JitCacheInvalidation _jitCacheInvalidator;
private readonly JitCacheInvalidation _jitCacheInvalidator;
private static CacheMemoryAllocator _cacheAllocator;
private readonly CacheMemoryAllocator _cacheAllocator;
private static readonly List<CacheEntry> _cacheEntries = [];
private readonly List<CacheEntry> _cacheEntries = [];
private static readonly Lock _lock = new();
private static bool _initialized;
private static readonly List<ReservedRegion> _jitRegions = [];
private static int _activeRegionIndex = 0;
[SupportedOSPlatform("windows")]
[LibraryImport("kernel32.dll", SetLastError = true)]
public static partial nint FlushInstructionCache(nint hProcess, nint lpAddress, nuint dwSize);
private readonly Lock _lock = new();
[SupportedOSPlatform("macos")]
[LibraryImport("libSystem.dylib", EntryPoint = "sys_icache_invalidate")]
internal static partial void SysICacheInvalidate(nint start, nuint len);
private readonly List<ReservedRegion> _jitRegions = [];
[SupportedOSPlatform("linux")]
[LibraryImport("libgcc_s.so.1", EntryPoint = "__clear_cache")]
internal static partial void ClearCache(nint begin, nint end);
public static void Initialize(IJitMemoryAllocator allocator)
public JitCache(IJitMemoryAllocator allocator)
{
if (_initialized)
{
return;
}
lock (_lock)
{
if (_initialized)
{
return;
}
ReservedRegion firstRegion = new(allocator, CacheSize);
_jitRegions.Add(firstRegion);
_activeRegionIndex = 0;
_jitRegions.Add(new(allocator, CacheSize));
_cacheAllocator = new CacheMemoryAllocator((int)CacheSize);
if (!OperatingSystem.IsWindows() && !OperatingSystem.IsMacOS())
{
_jitCacheInvalidator = new JitCacheInvalidation(allocator);
}
_cacheAllocator = new CacheMemoryAllocator(CacheSize);
_initialized = true;
}
}
public unsafe static nint Map(ReadOnlySpan<byte> code)
public nint Map(ReadOnlySpan<byte> code)
{
lock (_lock)
{
Debug.Assert(_initialized);
int funcOffset = Allocate(code.Length);
ReservedRegion targetRegion = _jitRegions[_activeRegionIndex];
nint funcPtr = targetRegion.Pointer + funcOffset;
nint funcPtr = GetFunctionPtr(funcOffset);
if (OperatingSystem.IsMacOS() && RuntimeInformation.ProcessArchitecture == Architecture.Arm64)
{
@@ -94,9 +63,9 @@ namespace Ryujinx.Cpu.LightningJit.Cache
}
else
{
ReprotectAsWritable(targetRegion, funcOffset, code.Length);
ReprotectAsWritable(funcOffset, code.Length);
Marshal.Copy(code.ToArray(), 0, funcPtr, code.Length);
ReprotectAsExecutable(targetRegion, funcOffset, code.Length);
ReprotectAsExecutable(funcOffset, code.Length);
_jitCacheInvalidator?.Invalidate(funcPtr, (ulong)code.Length);
}
@@ -107,21 +76,20 @@ namespace Ryujinx.Cpu.LightningJit.Cache
}
}
public static void Unmap(nint pointer)
public void Unmap(nint pointer)
{
lock (_lock)
{
Debug.Assert(_initialized);
foreach (ReservedRegion region in _jitRegions)
for (int i = 0; i < _jitRegions.Count; i++)
{
ReservedRegion region = _jitRegions[i];
if (pointer.ToInt64() < region.Pointer.ToInt64() ||
pointer.ToInt64() >= (region.Pointer + CacheSize).ToInt64())
pointer.ToInt64() >= (region.Pointer + (nint)CacheSize).ToInt64())
{
continue;
}
int funcOffset = (int)(pointer.ToInt64() - region.Pointer.ToInt64());
int funcOffset = (int)(pointer.ToInt64() - region.Pointer.ToInt64() + i * CacheSize);
if (TryFind(funcOffset, out CacheEntry entry, out int entryIndex) && entry.Offset == funcOffset)
{
@@ -134,59 +102,63 @@ namespace Ryujinx.Cpu.LightningJit.Cache
}
}
private static void ReprotectAsWritable(ReservedRegion region, int offset, int size)
private void ReprotectAsWritable(int offset, int size)
{
int endOffs = offset + size;
int regionStart = offset & ~_pageMask;
int regionEnd = (endOffs + _pageMask) & ~_pageMask;
int regionStart = (offset % (int)CacheSize) & ~_pageMask;
int regionEnd = ((endOffs % (int)CacheSize) + _pageMask) & ~_pageMask;
region.Block.MapAsRwx((ulong)regionStart, (ulong)(regionEnd - regionStart));
GetRegion(offset).Block.MapAsRwx((ulong)regionStart, (ulong)(regionEnd - regionStart));
}
private static void ReprotectAsExecutable(ReservedRegion region, int offset, int size)
private void ReprotectAsExecutable(int offset, int size)
{
int endOffs = offset + size;
int regionStart = offset & ~_pageMask;
int regionEnd = (endOffs + _pageMask) & ~_pageMask;
int regionStart = (offset % (int)CacheSize) & ~_pageMask;
int regionEnd = ((endOffs % (int)CacheSize) + _pageMask) & ~_pageMask;
region.Block.MapAsRx((ulong)regionStart, (ulong)(regionEnd - regionStart));
GetRegion(offset).Block.MapAsRx((ulong)regionStart, (ulong)(regionEnd - regionStart));
}
private static int Allocate(int codeSize)
private int Allocate(int codeSize)
{
codeSize = AlignCodeSize(codeSize);
int allocOffset = _cacheAllocator.Allocate(codeSize);
for (int i = _activeRegionIndex; i < _jitRegions.Count; i++)
if (allocOffset >= 0)
{
int allocOffset = _cacheAllocator.Allocate(codeSize);
if (allocOffset >= 0)
{
_jitRegions[i].ExpandIfNeeded((ulong)allocOffset + (ulong)codeSize);
_activeRegionIndex = i;
return allocOffset;
}
GetRegion(allocOffset).ExpandIfNeeded((ulong)(allocOffset % (int)CacheSize) + (ulong)codeSize);
return allocOffset;
}
int exhaustedRegion = _activeRegionIndex;
_cacheAllocator.AddNewBlocks(1);
ReservedRegion newRegion = new(_jitRegions[0].Allocator, CacheSize);
Logger.Warning?.Print(LogClass.Cpu, $"JIT Cache of size {(_jitRegions.Count * CacheSize).Bytes()} exhausted, creating new Cache Region ({((_jitRegions.Count + 1) * CacheSize).Bytes()} Total Allocation).");
_jitRegions.Add(newRegion);
_activeRegionIndex = _jitRegions.Count - 1;
int newRegionNumber = _activeRegionIndex;
Logger.Warning?.Print(LogClass.Cpu, $"JIT Cache Region {exhaustedRegion} exhausted, creating new Cache Region {newRegionNumber} ({((long)(newRegionNumber + 1) * CacheSize).Bytes()} Total Allocation).");
_cacheAllocator = new CacheMemoryAllocator(CacheSize);
int allocOffsetNew = _cacheAllocator.Allocate(codeSize);
if (allocOffsetNew < 0)
allocOffset = _cacheAllocator.Allocate(codeSize);
if (allocOffset < 0)
{
throw new OutOfMemoryException("Failed to allocate in new Cache Region!");
}
newRegion.ExpandIfNeeded((ulong)allocOffsetNew + (ulong)codeSize);
return allocOffsetNew;
GetRegion(allocOffset).ExpandIfNeeded((ulong)(allocOffset % (int)CacheSize) + (ulong)codeSize);
return allocOffset;
}
private nint GetFunctionPtr(int offset)
{
return GetRegion(offset).Pointer + (offset % (int)CacheSize);
}
private ReservedRegion GetRegion(int offset)
{
int index = offset / (int)CacheSize;
return _jitRegions[index];
}
private static int AlignCodeSize(int codeSize)
@@ -194,7 +166,7 @@ namespace Ryujinx.Cpu.LightningJit.Cache
return checked(codeSize + (CodeAlignment - 1)) & ~(CodeAlignment - 1);
}
private static void Add(int offset, int size)
private void Add(int offset, int size)
{
CacheEntry entry = new(offset, size);
@@ -208,7 +180,7 @@ namespace Ryujinx.Cpu.LightningJit.Cache
_cacheEntries.Insert(index, entry);
}
public static bool TryFind(int offset, out CacheEntry entry, out int entryIndex)
public bool TryFind(int offset, out CacheEntry entry, out int entryIndex)
{
lock (_lock)
{
@@ -231,5 +203,13 @@ namespace Ryujinx.Cpu.LightningJit.Cache
entryIndex = 0;
return false;
}
public void Dispose()
{
foreach (ReservedRegion jitRegion in _jitRegions)
{
jitRegion.Dispose();
}
}
}
}
+8 -6
View File
@@ -19,6 +19,7 @@ namespace Ryujinx.Cpu.LightningJit
private static bool IsNoWxPlatform => false;
private readonly ConcurrentQueue<KeyValuePair<ulong, TranslatedFunction>> _oldFuncs;
private readonly JitCache _jitCache;
private readonly NoWxCache _noWxCache;
private bool _disposed;
@@ -39,12 +40,12 @@ namespace Ryujinx.Cpu.LightningJit
}
else
{
JitCache.Initialize(new JitMemoryAllocator(forJit: true));
_jitCache = new(new JitMemoryAllocator(forJit: true));
}
Functions = new TranslatorCache<TranslatedFunction>();
FunctionTable = functionTable;
Stubs = new TranslatorStubs(FunctionTable, _noWxCache);
Stubs = new TranslatorStubs(_jitCache, FunctionTable, _noWxCache);
FunctionTable.Fill = (ulong)Stubs.SlowDispatchStub;
@@ -100,7 +101,7 @@ namespace Ryujinx.Cpu.LightningJit
if (oldFunc != func)
{
JitCache.Unmap(func.FuncPointer);
_jitCache.Unmap(func.FuncPointer);
func = oldFunc;
}
@@ -121,7 +122,7 @@ namespace Ryujinx.Cpu.LightningJit
private TranslatedFunction Translate(ulong address, ExecutionMode mode)
{
CompiledFunction func = Compile(address, mode);
nint funcPointer = JitCache.Map(func.Code);
nint funcPointer = _jitCache.Map(func.Code);
return new TranslatedFunction(funcPointer, (ulong)func.GuestCodeLength);
}
@@ -164,14 +165,14 @@ namespace Ryujinx.Cpu.LightningJit
foreach (TranslatedFunction func in functions)
{
JitCache.Unmap(func.FuncPointer);
_jitCache.Unmap(func.FuncPointer);
}
Functions.Clear();
while (_oldFuncs.TryDequeue(out KeyValuePair<ulong, TranslatedFunction> kv))
{
JitCache.Unmap(kv.Value.FuncPointer);
_jitCache.Unmap(kv.Value.FuncPointer);
}
}
@@ -188,6 +189,7 @@ namespace Ryujinx.Cpu.LightningJit
else
{
ClearJitCache();
_jitCache.Dispose();
}
Stubs.Dispose();
@@ -19,6 +19,8 @@ namespace Ryujinx.Cpu.LightningJit
{
private delegate ulong GetFunctionAddressDelegate(nint framePointer, ulong address);
private readonly JitCache _jitCache;
private readonly Lazy<nint> _slowDispatchStub;
private bool _disposed;
@@ -76,13 +78,16 @@ namespace Ryujinx.Cpu.LightningJit
/// Initializes a new instance of the <see cref="TranslatorStubs"/> class with the specified
/// <see cref="Translator"/> instance.
/// </summary>
/// <param name="jitCache">Jit cache to map functions in</param>
/// <param name="functionTable">Function table used to store pointers to the functions that the guest code will call</param>
/// <param name="noWxCache">Cache used on platforms that enforce W^X, otherwise should be null</param>
/// <exception cref="ArgumentNullException"><paramref name="translator"/> is null</exception>
public TranslatorStubs(IAddressTable<ulong> functionTable, NoWxCache noWxCache)
public TranslatorStubs(JitCache jitCache, IAddressTable<ulong> functionTable, NoWxCache noWxCache)
{
ArgumentNullException.ThrowIfNull(functionTable);
_jitCache = jitCache;
_functionTable = functionTable;
_noWxCache = noWxCache;
_getFunctionAddressRef = NativeInterface.GetFunctionAddress;
@@ -113,12 +118,12 @@ namespace Ryujinx.Cpu.LightningJit
{
if (_dispatchStub.IsValueCreated)
{
JitCache.Unmap(_dispatchStub.Value);
_jitCache.Unmap(_dispatchStub.Value);
}
if (_dispatchLoop.IsValueCreated)
{
JitCache.Unmap(Marshal.GetFunctionPointerForDelegate(_dispatchLoop.Value));
_jitCache.Unmap(Marshal.GetFunctionPointerForDelegate(_dispatchLoop.Value));
}
}
@@ -363,7 +368,7 @@ namespace Ryujinx.Cpu.LightningJit
}
else
{
return JitCache.Map(code);
return _jitCache.Map(code);
}
}
@@ -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,
@@ -11,7 +11,11 @@ namespace Ryujinx.HLE.HOS.Services.Notification
private readonly KEvent _getNotificationSendingNotifierEvent;
private int _getNotificationSendingNotifierEventHandle;
public INotificationSystemEventAccessor(ServiceCtx context) { }
public INotificationSystemEventAccessor(ServiceCtx context)
{
_getNotificationSendingNotifierEvent = new KEvent(context.Device.System.KernelContext);
}
[CommandCmif(0)] // 9.0.0+
// GetNotificationSendingNotifier() -> nn::notification::server::INotificationSystemEventAccessor
@@ -103,8 +103,12 @@ namespace Ryujinx.HLE.HOS.Services.Sdb.Pl
loadedCount++;
}
// The first return value is a boolean "fonts_are_loaded" flag (u8), not a count.
// Some titles (e.g. newer SDK builds) validate it strictly against 1, so writing
// the font count here (e.g. 6) makes them reject the result and spin re-opening pl:u.
context.ResponseData.Write(1);
context.ResponseData.Write(loadedCount);
context.ResponseData.Write((int)SharedFontType.Count);
// context.ResponseData.Write((int)SharedFontType.Count);
return ResultCode.Success;
}
+168 -83
View File
@@ -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
}
}
+1 -1
View File
@@ -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();
+734 -99
View File
@@ -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 = !motionWasDisabled && oldConfig?.Motion?.EnableMotion != newConfig.Motion.EnableMotion;
bool motionBackendChanged = !motionWasDisabled && 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,566 @@ 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)
{
for (int index = 0; index < gamepadDriver.GamepadsIds.Length; index++)
{
string gamepadId = gamepadDriver.GamepadsIds[index];
if (string.Equals(gamepadId, assignedController.Id, StringComparison.Ordinal))
{
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)
{
for (int index = 0; index < gamepadDriver.GamepadsIds.Length; index++)
{
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);
string gamepadId = gamepadDriver.GamepadsIds[index];
if (string.Equals(gamepadId, config.Id, StringComparison.Ordinal))
{
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();
}
}
}
+138 -20
View File
@@ -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,108 @@ 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)
{
List<InputConfig> requestedInputConfig;
List<PlayerInputAssignment> playerInputAssignments;
bool enableKeyboard;
bool enableMouse;
lock (_lock)
{
for (int i = 0; i < _controllers.Length; i++)
{
if (_controllers[i] != null && PlayerHasAssignedControllerId((PlayerIndex)i, id))
{
_controllers[i]?.Dispose();
_controllers[i] = null;
}
}
requestedInputConfig = _requestedInputConfig;
playerInputAssignments = _playerInputAssignments;
enableKeyboard = _enableKeyboard;
enableMouse = _enableMouse;
}
// 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 +204,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 +245,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 +319,8 @@ namespace Ryujinx.Input.HLE
inputConfig.ControllerType,
inputConfig.PlayerIndex);
fallbackConfig.EnableDynamicGamepadSwap = inputConfig.EnableDynamicGamepadSwap;
return true;
}
@@ -257,11 +368,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 +402,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 +503,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);
}
}
+1 -1
View File
@@ -140,7 +140,7 @@ namespace Ryujinx.Input
StrongRumble = 1f,
WeakRumble = 1f,
EnableRumble = false,
UseHDRumble = true,
UseHDRumble = false
},
};
}
+2
View File
@@ -10,6 +10,8 @@ using MemoryPermission = Ryujinx.Tests.Unicorn.MemoryPermission;
namespace Ryujinx.Tests.Cpu
{
[TestFixture]
[Parallelizable(ParallelScope.All)]
[FixtureLifeCycle(LifeCycle.InstancePerTestCase)]
public class CpuTest
{
protected static readonly ulong Size = MemoryBlock.GetPageSize();
+2
View File
@@ -10,6 +10,8 @@ using MemoryPermission = Ryujinx.Tests.Unicorn.MemoryPermission;
namespace Ryujinx.Tests.Cpu
{
[TestFixture]
[Parallelizable(ParallelScope.All)]
[FixtureLifeCycle(LifeCycle.InstancePerTestCase)]
public class CpuTest32
{
protected static readonly uint Size = (uint)MemoryBlock.GetPageSize();
+1 -1
View File
@@ -51,7 +51,7 @@ namespace Ryujinx.Tests.Cpu
bool methodCalled = false;
bool isFz = false;
TranslatorTestMethods.FpFlagsPInvokeTest method = TranslatorTestMethods.GenerateFpFlagsPInvokeTest();
TranslatorTestMethods.FpFlagsPInvokeTest method = TranslatorTestMethods.GenerateFpFlagsPInvokeTest(_translator.JitCache);
// This method sets flush-to-zero and then calls the managed method.
// Before and after setting the flags, it ensures subnormal addition works as expected.
+2 -2
View File
@@ -245,7 +245,7 @@ namespace Ryujinx.Tests.Memory
// Create a large mapping.
mainMemory.MapView(backing, 0, 0, vaSize);
TestMethods.DebugNativeWriteLoop writeFunc = TestMethods.GenerateDebugNativeWriteLoop();
TestMethods.DebugNativeWriteLoop writeFunc = TestMethods.GenerateDebugNativeWriteLoop(_translator.JitCache);
nint writePtr = mainMemory.GetPointer(vaSize - 0x1000, 4);
Thread testThread = new(() =>
@@ -339,7 +339,7 @@ namespace Ryujinx.Tests.Memory
fixed (void* localMap = &state.LocalCounts)
{
TestMethods.DebugThreadLocalMapGetOrReserve getOrReserve = TestMethods.GenerateDebugThreadLocalMapGetOrReserve((nint)localMap);
TestMethods.DebugThreadLocalMapGetOrReserve getOrReserve = TestMethods.GenerateDebugThreadLocalMapGetOrReserve(_translator.JitCache, (nint)localMap);
for (int i = 0; i < ThreadLocalMap<int>.MapSize; i++)
{
+216
View 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"
}
}
}
+208
View File
@@ -0,0 +1,208 @@
{
"Locales": {
"ar_SA": [],
"de_DE": [
"Ryubing ist mein zweiter Vorname.",
"Hier schlechten Witz einfügen.",
"ES IST RYUBING-ZEIT!",
"Fantastisch!",
"Jetzt mit 100% mehr Humor!",
"Jetzt geht es erst richtig los...",
"War wohl nur der Wind...",
"Kann Spuren von Nüssen enthalten!",
"Aus 100% biologischem Anbau!",
"Schönen Tag noch! : )",
"Der Weltraum... unendliche Weiten!",
"Falls du es noch nicht bemerkt hast: Ich liebe es schlechte Witze zu machen : )",
"In C# programmiert!",
"Forgejo hat Gitlab entthront!",
"Irgendwelche Ideen, was hier stehen könnte?",
"Guten Morgen!",
"Guten Tag!",
"Guten Abend!",
"Ich hoffe, du hast einen tollen Tag!",
"ICH... BIN RYUBING!",
"Nein, Lossless Scaling wird NICHT unterstützt.",
"Trans-Rechte sind Menschenrechte!",
":3",
"Bitte einen Controller anschließen!",
"Never gonna give you up!",
"Ganon beobachtet dich!",
"Unzählige Stunden voller Spaß!",
"Yahaha! Du hast mich endeckt!",
"Finde uns auf BlueSky"
],
"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!",
"Nifty",
"Find us on BlueSky",
"We are the knights who say... Ni."
],
"es_ES": [],
"fr_FR": [
"Ryubing est mon deuxième prénom.",
"Toujours à 110% !",
"Je ne pense pas donc je ne suis pas !",
"Tout gloire à Egg.",
"Insérer une blague nulle ici.",
"C'EST L'HEURE DE RYUBING !",
"Je déteste les lundis...",
"Fantastique !",
"Maintenant avec 100% d'humour en plus !",
"'Non approuvé par S&P' a été approuvé par S&P.",
"N'ÊTES-VOUS PAS DIVERTI ?",
"C'est un émulateur !",
"Maintenant le vrai jeu commence...",
"Cuit frais depuis 2018 !",
"Ça devait être le vent...",
"J'étais un aventurier comme toi avant de recevoir une flèche dans le genou.",
"Ryubing !",
"Peut contenir des fruits à coque !",
"Peut inclure des références occasionnelles à la pop culture !",
"100% issue d'agriculture durable !",
"Passez une bonne journée : )",
"Texte du bas",
"Je suis désolé Dave. Je crains que je ne puisse pas faire ça.",
"Ce n'est pas une lune...",
"Monsieur, en train de terminer ce combat.",
"Je vois comment c'est...",
"L'espace ! La frontière finale !",
"Si vous ne l'aviez pas déjà deviné, j'adore faire de mauvaises blagues : )",
"ceci.",
"cela",
"Probablement ne contient pas de haricots cuits.",
"Vous êtes prêts ?",
"Herobrine supprimé.",
"Droit à la réparation !",
"Programmé en C#!",
"Forgejo a détrôné Gitlab !",
"Des idées quoi mettre ici ?",
"Bonjour !",
"Bon après-midi !",
"Bonne soirée !",
"J'espère que vous passez une excellente journée !",
"Veuillez insérer le disque deux !",
"Je... SUIS RYUBING !",
"Ryubing toujours à fond",
"bing bing wahoo.",
"œuf",
"Non, le scaling sans perte n'est PAS supporté.",
"Comment le faites-vous ?",
"Un euro.",
"Deux euros",
"Quelqu'un m'a dit un jour !",
"C'est encore cette période de l'année !",
"Infusé uniquement avec les meilleures blagues.",
"La compilation de shaders asynchrone détruirait mon âme : (",
"Les droits trans sont des droits humains !",
":3",
":3 prohibé",
"Bug de réplication du splash ':3' corrigé.",
"Veuillez connecter une manette !",
"Never gonna give you up !",
"Le jeu était donc truqué depuis le début...",
"Ganon vous observe !",
"Maintenant avec 100% de JSON en plus dans le code des splash !",
"D'innombrables heures de plaisir !",
"Désolé, Link. Je ne peux pas donner de crédit. Revenez quand vous serez un peu... mmmmmm... plus riche !",
"Vous avez rencontré un terrible destin, n'est-ce pas ?",
"Yahaha ! Tu m'as trouvé !",
"J'aurais été dans de beaux draps si tu n'étais pas intervenu à ce moment-là, goro.",
"Restez au frais !",
"Jaune, noir. Jaune, noir. Jaune, noir. Jaune, noir. Oh, noir et jaune ! Secouons un peu ça.",
"Quoi ? Tu es venu me revoir ? Ça rend Beedle TROP HEUREUX !",
"Ne vous faites pas cuire, restez hors du crochet !",
"Maintenant avec 100% de bonnes vibes en plus dans le code des splash !",
"Eh oui jamy !",
"Allez Marcel !",
"Retrouvez nous sur BlueSky"
],
"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
View File
@@ -0,0 +1,64 @@
using System.Collections.Generic;
using Ryujinx.Common.Logging;
using Gommon;
using Ryujinx.Ava.Systems.Configuration;
using System.Text.Json;
namespace Ryujinx.Common
{
public class SplashTextHelper
{
public static void PrintSplash()
{
string splash = GetSplash();
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, " /___/ /___/ ");
if (splash is null)
{
Logger.Error?.Print(LogClass.Application, "Failed to fetch Splash Text! Splash JSON is invalid!");
return;
}
if (!splash.IsNullOrEmpty())
{
Logger.Notice.Print(LogClass.Application, "");
Logger.Notice.Print(LogClass.Application, splash);
Logger.Notice.Print(LogClass.Application, "");
}
}
private static string _finalSplash;
public static string GetSplash()
{
if (_finalSplash is null)
{
try
{
string data;
data = EmbeddedResources.ReadAllText("Ryujinx/Assets/Splashes.json");
SplashLocales splashJson = JsonSerializer.Deserialize<SplashLocales>(data);
_finalSplash = splashJson.Locales[ConfigurationState.Instance.UI.LanguageCode.Value].GetRandomElement() ?? "";
}
catch
{
return null;
}
}
return _finalSplash;
}
private struct SplashLocales
{
public Dictionary<string, List<string>> Locales { get; set; }
}
}
}
+3 -7
View File
@@ -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();
+2
View File
@@ -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" />
+2 -2
View File
@@ -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;
@@ -9,7 +9,9 @@ using Ryujinx.Common.Logging;
using Ryujinx.HLE;
using Ryujinx.HLE.Loaders.Processes;
using Ryujinx.Horizon;
using System.Collections.Generic;
using System.Text;
using System.Text.Json;
namespace Ryujinx.Ava.Systems
{
@@ -135,20 +137,48 @@ namespace Ryujinx.Ava.Systems
if (!formattedValue.Handled)
return;
_discordPresencePlaying.Details = TruncateToByteLength(
formattedValue.Reset
? $"Playing {_currentApp.Title}"
: formattedValue.FormattedString
);
try // New format that attempts to deserialize json, and if it fails (using old method)...
{
Dictionary<string, string> outDictionary = JsonSerializer.Deserialize<Dictionary<string, string>>(formattedValue.FormattedString);
_discordPresencePlaying.Details = TruncateToByteLength(
outDictionary["Details"].IsNullOrEmpty()
? $"Playing {_currentApp.Title}"
: outDictionary["Details"]
);
_discordPresencePlaying.State = TruncateToByteLength(
outDictionary["State"].IsNullOrEmpty()
? $"Total play time: {ValueFormatUtils.FormatTimeSpan(_currentApp.TimePlayed)}"
: outDictionary["State"]
);
if (_discordClient.CurrentPresence.Details.Equals(_discordPresencePlaying.Details))
}
catch // Utilize original code
{
_discordPresencePlaying.Details = TruncateToByteLength(
formattedValue.Reset
? $"Playing {_currentApp.Title}"
: formattedValue.FormattedString
);
}
if (_discordClient.CurrentPresence.Details.Equals(_discordPresencePlaying.Details) && _discordClient.CurrentPresence.State.Equals(_discordPresencePlaying.State))
return; //don't trigger an update if the set presence Details are identical to current
_discordClient.SetPresence(_discordPresencePlaying);
Logger.Info?.Print(LogClass.UI, "Updated Discord RPC based on a supported play report.");
}
public static string PrepareMultilineRpcString(string line1 = "", string line2 = "")
{
Dictionary<string, string> rpcdict = new() { { "Details", line1 }, {"State", line2} };
return JsonSerializer.Serialize(rpcdict);
}
private static string TruncateToByteLength(string input)
{
if (Encoding.UTF8.GetByteCount(input) <= ApplicationByteLimit)
@@ -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
{
@@ -1103,7 +1105,7 @@ namespace Ryujinx.Ava.Systems.PlayReport
return $"At the main menu";
static string LocFinal(string? location) => location switch
static string LocFinal(string location) => location switch
{
"0" => "Somewhere in Miitopia",
"1" => "Wandering around Greenhorne",
@@ -1116,5 +1118,86 @@ 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";
}
return "";
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
}
}
}
@@ -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)
@@ -108,7 +108,7 @@ namespace Ryujinx.Ava.UI.Helpers
try
{
string labelsJson = File.ReadAllText(labelsPath);
Dictionary<string, string>? labels = JsonSerializer.Deserialize<Dictionary<string, string>>(labelsJson, _serializerOptions);
Dictionary<string, string> labels = JsonSerializer.Deserialize<Dictionary<string, string>>(labelsJson, _serializerOptions);
if (labels != null)
{
@@ -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,13 @@ namespace Ryujinx.Ava.UI.ViewModels
break;
case ShaderCacheLoadingState shaderCacheState:
CacheLoadStatus = $"{current} / {total}";
string splash = SplashTextHelper.GetSplash();
if (!splash.IsNullOrEmpty())
{
Splash = $"\"{splash}\"";
}
switch (shaderCacheState)
{
case ShaderCacheLoadingState.Start:
@@ -1756,7 +1767,7 @@ namespace Ryujinx.Ava.UI.ViewModels
}
}
private async Task<IReadOnlyList<string>?> PickFolders(LocaleKeys titleKey)
private async Task<IReadOnlyList<string>> PickFolders(LocaleKeys titleKey)
{
return (await StorageProvider.OpenMultiFolderPickerAsync(new FolderPickerOpenOptions
{
@@ -1932,13 +1943,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;
@@ -2150,7 +2161,7 @@ namespace Ryujinx.Ava.UI.ViewModels
Window.Position.X + (int)(Window.Bounds.Width / 2),
Window.Position.Y + (int)(Window.Bounds.Height / 2));
Avalonia.Platform.Screen? screen =
Avalonia.Platform.Screen screen =
Window.Screens.ScreenFromVisual(Window) ??
Window.Screens.ScreenFromPoint(windowCenter) ??
Window.Screens.Primary;
@@ -2314,7 +2325,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 +2351,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 +2372,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;
@@ -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,6 +45,9 @@ 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)
{
@@ -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();
});
}
})
@@ -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>
@@ -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].((viewModels:XciTrimmerViewModel)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].((viewModels:XciTrimmerViewModel)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>
@@ -18,6 +18,12 @@ namespace Ryujinx.Ava.UI.Views.Dialog
InitializeComponent();
}
private void ToggleSelect(object sender, RoutedEventArgs e)
{
if (DataContext is XciTrimmerViewModel vm)
vm.ToggleSelect();
}
public static async Task Show()
{
ContentDialog contentDialog = new()
@@ -29,7 +35,7 @@ namespace Ryujinx.Ava.UI.Views.Dialog
{
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>());
@@ -52,7 +58,7 @@ namespace Ryujinx.Ava.UI.Views.Dialog
private void Close(object sender, RoutedEventArgs e)
{
((ContentDialog)Parent).Hide();
((ContentDialog)Parent!).Hide();
}
private void Cancel(Object sender, RoutedEventArgs e)
@@ -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>
@@ -0,0 +1,104 @@
<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.Styles>
<Style Selector="ContentPresenter">
<Setter Property="HorizontalAlignment" Value="Stretch" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
</Style>
</ItemsControl.Styles>
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel HorizontalAlignment="Stretch" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate DataType="models:PlayerInputDeviceAssignmentItem">
<Border
Margin="0,3"
Padding="10,8"
CornerRadius="4"
HorizontalAlignment="Stretch"
Background="{DynamicResource ControlFillColorTertiary}"
BorderBrush="{DynamicResource ThemeControlBorderColor}"
BorderThickness="1"
PointerPressed="DeviceRow_OnPointerPressed"
Cursor="Hand"
IsEnabled="{Binding !IsDisabledByOtherPlayer}">
<DockPanel
HorizontalAlignment="Stretch"
LastChildFill="True">
<CheckBox
DockPanel.Dock="Left"
Margin="0,0,8,0"
Padding="0"
VerticalAlignment="Center"
ClipToBounds="False"
IsChecked="{Binding IsAssigned}"
Checked="AssignedDeviceCheckBox_OnCheckedChanged"
Unchecked="AssignedDeviceCheckBox_OnCheckedChanged" />
<TextBlock
DockPanel.Dock="Right"
Margin="10,0,0,0"
Opacity="0.6"
FontSize="12"
MaxWidth="200"
VerticalAlignment="Center"
HorizontalAlignment="Right"
TextWrapping="Wrap"
Text="{Binding AssignedToPlayers}"
IsVisible="{Binding HasAssignedToPlayers}" />
<StackPanel
HorizontalAlignment="Left"
VerticalAlignment="Center">
<TextBlock
HorizontalAlignment="Left"
Text="{Binding Name}" />
<TextBlock
HorizontalAlignment="Left"
Opacity="0.6"
FontSize="12"
TextTrimming="CharacterEllipsis"
TextWrapping="NoWrap"
MaxLines="1"
IsVisible="{Binding HasBoundProfileName}">
<Run Text="{ext:Locale ControllerSettingsProfile}" />
<Run Text=": " />
<Run Text="{Binding BoundProfileName}" />
</TextBlock>
</StackPanel>
</DockPanel>
</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>
@@ -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;
}
}
+66 -9
View File
@@ -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>
+77 -1
View File
@@ -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}"
@@ -44,7 +44,7 @@ namespace Ryujinx.Ava.UI.Views.Settings
return;
}
MainWindow? mainWindow = RyujinxApp.MainWindow;
MainWindow mainWindow = RyujinxApp.MainWindow;
if (mainWindow?.ViewModel?.AppHost?.NpadManager is not { } npadManager)
{
return;
@@ -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"
+11 -1
View File
@@ -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>