Compare commits

...

20 Commits

Author SHA1 Message Date
LotP b19e2ac65d mono-jit (#167)
- fix alignment issue with jitcache that could cause a crash.
- Reimplement sparse-jit to only use 1 layer.
    - the old 2 layer approach basically only ever used 1 path due to the bottom-layer size, so doing a double look-up seemed wasteful.
    - changed memory block allocation stategy to allocate based on requested code range instead of the possible address-space.

Co-authored-by: LotP1 <68976644+LotP1@users.noreply.github.com>
Reviewed-on: https://git.ryujinx.app/projects/Ryubing/pulls/167
2026-07-10 11:16:55 +00:00
der_zelda_player 6953128ff2 Added a lot of German translations (#166)
Reviewed-on: https://git.ryujinx.app/projects/Ryubing/pulls/166
2026-07-06 21:56:53 +00:00
_Neo_ b72990bf53 UI: Actions Menu (Part 3 of 4) → Amiibo & Skylanders (#158)
Ayyyy, welcome to the UI: Actions Menu → Amiibo & Skylanders PR!

WOOOOO!!!!

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

This PR introduces visual and functionality improvements to the two `Scan Amiibo` options, as well as improving `Scan Skylanders`.

### GENERAL
* **Renamed:** Open Mii Applet → Open Mii Editor in `MainMenuBarView.axaml.cs`

### LOCALISATION
* **Fractured:** More locales
    * `Amiibo.json` - all UI-related locales to the `Scan Amiibo` window
    * `Common_Buttons.json` - all locales related to buttons
    * `Common_FilePicker.json` - all locales related to the file picker that are implemented in many places
    * `Dialog_Amiibo.json` - all Amiibo dialogs
    * `Dialog_Skylanders.json` - all Skylander dialogs
* **Removed:** Some weird duplicated locales (they appeared after some sort of rebase, but they were few)

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

### SCAN AMIIBO (WINDOW)
* **Improved:** The UI
    * The UI now more cleanly utilises both Normal and Flush Controls.
    * All buttons have been moved to the top row and features a whole lot more updates (read below).
* **Reworded:** `Hack: Random UUID Tag` → `Use Random UUID`
    * The option is inherently already a hack/workaround, therefore, having "Hack" is not necessary. It is also implied that it's a tag.
* **Implemented:** [Feature Request #271](https://github.com/Ryubing/Issues/issues/271)
    * The feature request is for the ability to pause emulation while navigating the `Scan Amiibo` window (so as to reduce/remove timeouts) and for an Amiibo "name" sorting feature. You asked, we deliver!
    * **Pause Emulation:**
        * The top row now features a "setting" button dropdown, which houses `Use Random UUID` and `Pause Emulation While Scanning`
            * Note: Both options were moved to a button dropdown so as to not have to increase window size by a 1000x
        * `Pause Emulation While Scanning` is a dynamic, real-time implementation.
            * By default, the checkbox is not checked.
            * When it's checked, emulation _instantly_ pauses for the duration while the window is open. Pressing "Scan Amiibo" or "Cancel" resumes emulation. Reopening the window pauses the emulation instantly as well.
            * This setting persists both while in-game and upon restarting Ryujinx, so users don't have to constantly go back and check the option again.
            * Additionally, this feature has been propagated to `Scan Amiibo (BIN)`. Perhaps some users would also want emulation to pause while scanning via BIN, so if the checkbox is checked, it'll pause emulation when the file picker is open as well.
    * **Sorting & Search:**
        * Additionally, implemented a simple "sort by name", in ascending and descending order.
        * Added a search bar (related to the change below) to help with searching through the long Amiibo list.
        * Search is semi-alphabetical, semi-series based. If I want to search for Samus Amiibo, then if I type "Samus", it'll first show Dark Samus, as it appears in a series earlier in the list. This behaviour can be modified to just be alphabetical, but it is also more categorisation.
* **Modified & Renamed:** `Show Incompatible Amiibo` → `Show All Amiibo`
    * Pressing `Show Incompatible Amiibo` would display all Amiibo available, so the option was renamed.
    * The option now forces all Amiibo to appear in a list, without a preselected series. Unchecked the option restores original behaviour by breaking down Amiibo by their series (and only those directly compatible with the game).
* **Removed:** Series label
    * It's clear that it's an Amiibo series.
* **Fixed:** Amiibo Usage visual structure
    * When an Amiibo had multiple in-game usages (multiple purposes), they would all be squished together with no spaces in between them, reducing readability.
    * Now, they are all separated and given an extra line between each usage.
    * Additionally, fixed "Unknown" text position as well by giving it another extra empty line.
* **Renamed:** Writeable to Usage (Can Save Data)
    * Usage and Usage (Can Save Data) are separate strings because some languages must add space before or after the colon
* **Fixed:** Sometimes incorrect Amiibo images could be shown when pressing `Show Incompatible Amiibo`/`Show All Amiibo` in certain instances.
    * Sometimes = pressing the checkbox 10 or 20...maybe 30 times in a row (I pressed it a lot!)
* **Reworded:** Cabinet Dialog strings
    * Cabinet Dialog is the dialog that appears when you want to rename your Amiibo. Previously, the options didn't reflect the actual window and what they were asking the user, but now clearly tell the user that they are renaming their Amiibo, and when the dialog prompts the user to scan their amiibo again.

### SCAN AMIIBO (BIN)
* **Renamed:** `Scan Amiibo (.BIN)` to `Scan Amiibo (BIN)` to be similar to `Install Keys` > `KEYS` and similar menu items
* **Adjusted:** File picker title when loading Amiibo from BIN to...literally tell the user what to select and what will happen (similar to `Install Firmware` and `Install Keys` file picker titles)

### SCAN/REMOVE SKYLANDER
* **Merged:** `Scan Skylander` and `Remove Skylander`
    * Instead of having two separate buttons, let's have one button that actively updates state. Instead of using two shortcuts, it now uses one, and the label updates based on whether the game is asking to `Scan Skylander` or whether you can `Remove Skylander`.
* **Fixed:** `Scan Skylander`/`Remove Skylander` were not visible properly
    * Initial implementation only displayed the menu options/buttons when a particular Skylander Imaginators game was being played (i.e. specific ID)
    * While there is only one Skylander game on the Switch, the game has been distributed in multiple regions under different game IDs and has different languages. People who owned Skylander Imaginators with a 0100ccc0002e6000 ID could use this feature and _nobody else._
    * This has now been fixed by allowing those options to be visible at all times, regardless of the game
        * For Skylander games, this is not an issue because the backend implementation for scanning/removing a skylander is the same (tested)
        * For other games, then it doesn't matter since the feature is not used, but it will let users know the feature exists, in case they wished to previously play the game, but couldn't due to the missing backend.
* **Renamed:** Skylander functions and methods
    * You don't open a window (like Scan Amiibo) but you open a file picker.
* **Fixed:** Inconsistent button state and shortcut update
    * This was a bug in the previous implementation as well - you couldn't scan/remove skylander with the keyboard shortcut unless you opened the `Actions` menu (and only then would the shortcut register). This has been fixed.

As such...Skylanders Imaginators are now **Playable**! Updated the entry and added another game ID.

_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/158
2026-07-06 14:36:00 +00:00
Max 732e882524 Included more Discord title IDs (#165)
- Skylanders: Imaginators (US + EU)
- Dragon Quest VII Reimagined Demo
- Rhythm Heaven Groove: Starter Demo
- MARVEL Cosmic Invasion Demo

Reviewed-on: https://git.ryujinx.app/projects/Ryubing/pulls/165
2026-07-06 14:29:05 +00:00
MrKev 68432d210b VP9-improvements (#132)
- Improves MMNV request handling by tracking active clock requests per session and returning the effective requested rate for each module.
- Treats sentinel clock requests (`0` / negative values) as request clears.
- Preserves VP9 segment maps across inter frames, resetting them for key/intra/size-change cases.
- Avoids stale VP9 motion-vector state by disabling previous MV lookup on key frames and clearing output MVs for intra-only frames.

This fixes VP9 playback corruption seen in several Just Dance videos where stale segmentation/MV state could leak across frames and produce blocky/discolored output until a later refresh frame.
The MMNV changes also make the service behavior closer to what games expect when requesting and clearing NvDec clocks, removing the stub spam for this service.

Co-authored-by: MrKev312 <34964788+MrKev312@users.noreply.github.com>
Reviewed-on: https://git.ryujinx.app/projects/Ryubing/pulls/132
Reviewed-by: sh0inx <sh0inx@noreply.git.ryujinx.app>
2026-07-06 14:23:35 +00:00
Max e47417a161 fixed rumble not being sent to the controller (#163)
woopsies

Reviewed-on: https://git.ryujinx.app/projects/Ryubing/pulls/163
2026-07-05 21:28:59 +00:00
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
121 changed files with 6645 additions and 3402 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" />
+229
View File
@@ -0,0 +1,229 @@
{
"Locales": [
{
"ID": "WindowTitle",
"Translations": {
"ar_SA": "أميبو",
"de_DE": "",
"el_GR": "",
"en_US": "Amiibo",
"es_ES": null,
"fr_FR": null,
"he_IL": "אמיבו",
"it_IT": "",
"ja_JP": "",
"ko_KR": null,
"no_NO": "",
"pl_PL": "",
"pt_BR": null,
"ru_RU": null,
"sv_SE": null,
"th_TH": "อมิโบ้",
"tr_TR": "",
"uk_UA": "",
"zh_CN": null,
"zh_TW": null
}
},
{
"ID": "SeriesLabel",
"Translations": {
"ar_SA": "سلسلة",
"de_DE": "Serie",
"el_GR": "Σειρά",
"en_US": "Series",
"es_ES": "Serie",
"fr_FR": "Séries",
"he_IL": "סדרה",
"it_IT": "Serie",
"ja_JP": "シリーズ",
"ko_KR": "시리즈",
"no_NO": "Serie",
"pl_PL": "Seria",
"pt_BR": "Série",
"ru_RU": "Серия",
"sv_SE": "Serie",
"th_TH": "ซีรีส์",
"tr_TR": "Seri",
"uk_UA": "Серія",
"zh_CN": "系列",
"zh_TW": "系列"
}
},
{
"ID": "ShowAllAmiiboLabel",
"Translations": {
"ar_SA": "إظهار الكل Amiibo",
"de_DE": "Alle Amiibo anzeigen",
"el_GR": "Εμφάνιση Όλων των Amiibo",
"en_US": "Show All Amiibo",
"es_ES": "Mostrar Todas las Amiibo",
"fr_FR": "Afficher Tous les Amiibo",
"he_IL": "הצג הכל Amiibo",
"it_IT": "Mostra tutti gli Amiibo",
"ja_JP": "すべてのAmiiboを表示",
"ko_KR": "모든 Amiibo 표시",
"no_NO": "Vis alle Amiibo",
"pl_PL": "Pokaż Wszystkie Amiibo",
"pt_BR": "Mostrar Todos os Amiibo",
"ru_RU": "Показывать все Amiibo",
"sv_SE": "Visa alla Amiibo",
"th_TH": "แสดง Amiibo ทั้งหมด",
"tr_TR": "Tüm Amiibo'ları Göster",
"uk_UA": "Показати всі Amiibo",
"zh_CN": "显示所有 Amiibo",
"zh_TW": "顯示所有 Amiibo"
}
},
{
"ID": "UseRandomUuidLabel",
"Translations": {
"ar_SA": "استخدام UUID عشوائي",
"de_DE": "Zufällige UUID verwenden",
"el_GR": "Χρήση τυχαίου UUID",
"en_US": "Use Random UUID",
"es_ES": "Usar UUID aleatorio",
"fr_FR": "Utiliser un UUID aléatoire",
"he_IL": "השתמש ב-UUID אקראי",
"it_IT": "Usa UUID casuale",
"ja_JP": "ランダム UUID を使用",
"ko_KR": "무작위 UUID 사용",
"no_NO": "Bruk tilfeldig UUID",
"pl_PL": "Użyj losowego UUID",
"pt_BR": "Usar UUID aleatório",
"ru_RU": "Использовать случайный UUID",
"sv_SE": "Använd slumpmässig UUID",
"th_TH": "ใช้ UUID แบบสุ่ม",
"tr_TR": "Rastgele UUID kullan",
"uk_UA": "Використовувати випадковий UUID",
"zh_CN": "使用随机 UUID",
"zh_TW": "使用隨機 UUID"
}
},
{
"ID": "PauseEmulationWhileScanningAmiibo",
"Translations": {
"ar_SA": "",
"de_DE": "Emulation während des Scannens pausieren",
"el_GR": "",
"en_US": "Pause Emulation While Scanning",
"es_ES": "",
"fr_FR": "",
"he_IL": "",
"it_IT": "",
"ja_JP": "",
"ko_KR": "",
"no_NO": "",
"pl_PL": "",
"pt_BR": "",
"ru_RU": "Приостанавливать эмуляцию во время сканирования",
"sv_SE": "",
"th_TH": "",
"tr_TR": "",
"uk_UA": "",
"zh_CN": "",
"zh_TW": ""
}
},
{
"ID": "UsageLabel",
"Translations": {
"ar_SA": "الاستخدام:",
"de_DE": "Nutzung:",
"el_GR": "Χρήση:",
"en_US": "Usage:",
"es_ES": "Uso:",
"fr_FR": "Utilisation :",
"he_IL": "שימוש:",
"it_IT": "Utilizzo:",
"ja_JP": "使用法:",
"ko_KR": "사용법:",
"no_NO": "Forbruk:",
"pl_PL": "Użycie:",
"pt_BR": "Uso:",
"ru_RU": "Применение:",
"sv_SE": "Användning:",
"th_TH": "การใช้งาน:",
"tr_TR": "Kullanım:",
"uk_UA": "Використання:",
"zh_CN": "用法:",
"zh_TW": "用途:"
}
},
{
"ID": "WritableLabel",
"Translations": {
"ar_SA": "الاستخدام (يمكنه حفظ البيانات):",
"de_DE": "Nutzung (Kann Daten speichern):",
"el_GR": "Χρήση (Μπορεί να αποθηκεύσει δεδομένα):",
"en_US": "Usage (Can Save Data):",
"es_ES": "Uso (Puede guardar datos):",
"fr_FR": "Utilisation (Peut enregistrer des données) :",
"he_IL": "שימוש (יכול לשמור נתונים):",
"it_IT": "Utilizzo (Può salvare dati):",
"ja_JP": "使用法(データを保存できます):",
"ko_KR": "사용법 (데이터 저장 가능):",
"no_NO": "Bruk (Kan lagre data):",
"pl_PL": "Użycie (Może zapisywać dane):",
"pt_BR": "Uso (Pode salvar dados):",
"ru_RU": "Применение (Может сохранять данные):",
"sv_SE": "Användning (Kan spara data):",
"th_TH": "การใช้งาน (สามารถบันทึกข้อมูลได้):",
"tr_TR": "Kullanım (Veri kaydedebilir):",
"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": "Nieznane",
"pt_BR": "Desconhecido",
"ru_RU": "Неизвестно",
"sv_SE": "Okänt",
"th_TH": "ไม่รู้จัก",
"tr_TR": "Bilinmeyen",
"uk_UA": "Невідомо",
"zh_CN": "未知",
"zh_TW": "未知"
}
},
{
"ID": "ScanButton",
"Translations": {
"ar_SA": "فحص Amiibo",
"de_DE": "Amiibo einscannen",
"el_GR": "Σαρώστε το Amiibo",
"en_US": "Scan Amiibo",
"es_ES": "Escanear Amiibo",
"fr_FR": "Scanner l'Amiibo",
"he_IL": "סרוק את ה-Amiibo",
"it_IT": "Scansiona l'Amiibo",
"ja_JP": "Amiiboをスキャン",
"ko_KR": "Amiibo 스캔하기",
"no_NO": "Skanne Amiibo",
"pl_PL": "Zeskanuj Amiibo",
"pt_BR": "Escanear Amiibo",
"ru_RU": "Сканировать Amiibo",
"sv_SE": "Skanna Amiibo",
"th_TH": "สแกน Amiibo",
"tr_TR": "Amiibo'yu tara",
"uk_UA": "Сканувати Amiibo",
"zh_CN": "扫描 Amiibo",
"zh_TW": "掃描 Amiibo"
}
}
]
}
+29
View File
@@ -0,0 +1,29 @@
{
"Locales": [
{
"ID": "Cancel",
"Translations": {
"ar_SA": "إلغاء",
"de_DE": "Abbrechen",
"el_GR": "Ακύρωση",
"en_US": "Cancel",
"es_ES": "Cancelar",
"fr_FR": "Annuler",
"he_IL": "ביטול",
"it_IT": "Annulla",
"ja_JP": "キャンセル",
"ko_KR": "취소",
"no_NO": "Avbryt",
"pl_PL": "Anuluj",
"pt_BR": "Cancelar",
"ru_RU": "Отмена",
"sv_SE": "Avbryt",
"th_TH": "ยกเลิก",
"tr_TR": "İptal",
"uk_UA": "Скасувати",
"zh_CN": "取消",
"zh_TW": "取消"
}
}
]
}
+29
View File
@@ -0,0 +1,29 @@
{
"Locales": [
{
"ID": "AllSupportedFormats",
"Translations": {
"ar_SA": "كل التنسيقات المدعومة",
"de_DE": "Alle unterstützten Formate",
"el_GR": "Όλες Οι Υποστηριζόμενες Μορφές",
"en_US": "All Supported Formats",
"es_ES": "Todos los Formatos Soportados",
"fr_FR": "Tous les Formats Supportés",
"he_IL": "כל הפורמטים הנתמכים",
"it_IT": "Tutti i formati supportati",
"ja_JP": "すべての対応フォーマット",
"ko_KR": "지원되는 모든 형식",
"no_NO": "Alle støttede formater",
"pl_PL": "Wszystkie Obsługiwane Formaty",
"pt_BR": "Todos os Formatos Suportados",
"ru_RU": "Все поддерживаемые форматы",
"sv_SE": "Alla format som stöds",
"th_TH": "รูปแบบที่รองรับทั้งหมด",
"tr_TR": "Tüm Desteklenen Formatlar",
"uk_UA": "Усі підтримувані формати",
"zh_CN": "所有支持的格式",
"zh_TW": "所有支援的格式"
}
}
]
}
+54
View File
@@ -0,0 +1,54 @@
{
"Locales": [
{
"ID": "SearchAmiiboWatermark",
"Translations": {
"ar_SA": "بحث Amiibo...",
"de_DE": "Amiibo suchen...",
"el_GR": "Αναζήτηση Amiibo...",
"en_US": "Search Amiibo...",
"es_ES": "Buscar Amiibo...",
"fr_FR": "Rechercher Amiibo...",
"he_IL": "חפש Amiibo...",
"it_IT": "Cerca Amiibo...",
"ja_JP": "Amiiboを検索...",
"ko_KR": "Amiibo 검색...",
"no_NO": "Søk Amiibo...",
"pl_PL": "Wyszukaj Amiibo...",
"pt_BR": "Buscar Amiibo...",
"ru_RU": "Поиск Amiibo...",
"sv_SE": "Sök Amiibo...",
"th_TH": "ค้นหา Amiibo...",
"tr_TR": "Amiibo Ara...",
"uk_UA": "Пошук Amiibo...",
"zh_CN": "搜索 Amiibo...",
"zh_TW": "搜尋 Amiibo..."
}
},
{
"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": "Trimmstatus",
"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": "從大到小"
}
}
]
}
+179
View File
@@ -0,0 +1,179 @@
{
"Locales": [
{
"ID": "ScanAmiiboFromBinFilePickerTitle",
"Translations": {
"ar_SA": "",
"de_DE": "Wähle eine BIN-Datei aus, um ein Amiibo zu scannen.",
"el_GR": "",
"en_US": "Select a BIN file to scan an Amiibo from",
"es_ES": "Selecciona un archivo BIN para escanear un Amiibo",
"fr_FR": "Sélectionnez un fichier BIN pour scanner un Amiibo",
"he_IL": "",
"it_IT": "Seleziona un file BIN da cui scansionare un Amiibo",
"ja_JP": "",
"ko_KR": "",
"no_NO": "",
"pl_PL": "",
"pt_BR": "Selecione um arquivo BIN para escanear um Amiibo",
"ru_RU": "Выберите BIN-файл для сканирования Amiibo",
"sv_SE": "",
"th_TH": "",
"tr_TR": "",
"uk_UA": "Виберіть BIN-файл для сканування Amiibo",
"zh_CN": "",
"zh_TW": ""
}
},
{
"ID": "APITitle",
"Translations": {
"ar_SA": "أميبو API",
"de_DE": "Amiibo-API",
"el_GR": "API για Amiibo.",
"en_US": "Amiibo API",
"es_ES": "API Amiibo",
"fr_FR": "API Amiibo",
"he_IL": "ממשק תכנות אמיבו",
"it_IT": "API Amiibo",
"ja_JP": "",
"ko_KR": null,
"no_NO": "",
"pl_PL": "API Amiibo",
"pt_BR": "API Amiibo",
"ru_RU": "API Amiibo",
"sv_SE": "Amiibo-API",
"th_TH": "อามิโบ้ API",
"tr_TR": "",
"uk_UA": "API Amiibo",
"zh_CN": null,
"zh_TW": null
}
},
{
"ID": "APIFailFetchMessage",
"Translations": {
"ar_SA": "حدث خطأ أثناء جلب المعلومات من API.",
"de_DE": "Beim Abrufen von Informationen aus der API ist ein Fehler aufgetreten.",
"el_GR": "Παρουσιάστηκε σφάλμα κατά την ανάκτηση πληροφοριών από το API.",
"en_US": "An error occured while fetching information from the API.",
"es_ES": "Ocurrió un error al recibir información de la API.",
"fr_FR": "Une erreur est survenue lors de la récupération des informations de l'API.",
"he_IL": "אירעה שגיאה בעת שליפת מידע מהממשק.",
"it_IT": "Si è verificato un errore durante il recupero delle informazioni dall'API.",
"ja_JP": "API からの情報取得中にエラーが発生しました.",
"ko_KR": "API에서 정보를 가져오는 중에 오류가 발생했습니다.",
"no_NO": "En feil oppstod under henting av informasjon fra API.",
"pl_PL": "Wystąpił błąd podczas pobierania informacji z API.",
"pt_BR": "Um erro ocorreu ao tentar obter informações da API.",
"ru_RU": "Произошла ошибка при получении информации из API.",
"sv_SE": "Ett fel inträffade vid hämtning av information från API.",
"th_TH": "เกิดข้อผิดพลาดขณะเรียกข้อมูลจาก API",
"tr_TR": "API'dan bilgi alırken bir hata oluştu.",
"uk_UA": "Під час отримання інформації з API сталася помилка.",
"zh_CN": "从 API 获取信息时出错。",
"zh_TW": "從 API 取得資訊時出現錯誤。"
}
},
{
"ID": "APIConnectErrorMessage",
"Translations": {
"ar_SA": "غير قادر على الاتصال بخادم API أميبو. قد تكون الخدمة معطلة أو قد تحتاج إلى التحقق من اتصالك بالإنترنت.",
"de_DE": "Verbindung zum Amiibo API Server kann nicht hergestellt werden. Der Dienst ist möglicherweise nicht verfügbar oder es existiert keine Internetverbindung.",
"el_GR": "Δεν είναι δυνατή η σύνδεση με τον διακομιστή Amiibo API. Η υπηρεσία μπορεί να είναι εκτός λειτουργίας ή μπορεί να χρειαστεί να επαληθεύσετε ότι έχετε ενεργή σύνδεσή στο Διαδίκτυο.",
"en_US": "Unable to connect to Amiibo API server. The service may be down or you may need to verify your internet connection is online.",
"es_ES": "No se pudo conectar al servidor de la API Amiibo. El servicio puede estar caído o tu conexión a internet puede haberse desconectado.",
"fr_FR": "Impossible de se connecter au serveur API Amiibo. Le service est peut-être hors service ou vous devriez peut-être vérifier que votre connexion internet est connectée.",
"he_IL": "לא ניתן להתחבר לממשק שרת האמיבו. ייתכן שהשירות מושבת או שתצטרך לוודא שהחיבור לאינטרנט שלך מקוון.",
"it_IT": "Impossibile connettersi al server dell'API Amiibo. Il servizio potrebbe non essere disponibile o potresti non essere connesso a Internet.",
"ja_JP": "Amiibo API サーバに接続できませんでした. サーバがダウンしているか, インターネット接続に問題があるかもしれません.",
"ko_KR": "Amiibo API 서버에 연결할 수 없습니다. 서비스가 다운되었거나 인터넷 연결이 온라인 상태인지 확인이 필요합니다.",
"no_NO": "Kan ikke koble til Amiibo API server. Tjenesten kan være nede, eller du må kanskje verifisere at din internettforbindelse er tilkoblet.",
"pl_PL": "Nie można połączyć się z serwerem API Amiibo. Usługa może nie działać lub może być konieczne sprawdzenie, czy połączenie internetowe jest online.",
"pt_BR": "Não foi possível conectar ao servidor da API Amiibo. O serviço pode estar fora do ar ou você precisa verificar sua conexão com a Internet.",
"ru_RU": "Не удалось подключиться к серверу Amiibo API. Служба может быть недоступна или вам может потребоваться проверить ваше интернет-соединение.",
"sv_SE": "Kunde inte ansluta till Amiibo API-server. Tjänsten kanske är nere eller så behöver du kontrollera att din internetanslutning fungerar.",
"th_TH": "ไม่สามารถเชื่อมต่อกับเซิร์ฟเวอร์ Amiibo API บางบริการอาจหยุดทำงาน หรือไม่คุณต้องทำการตรวจสอบว่าอินเทอร์เน็ตของคุณอยู่ในสถานะเชื่อมต่ออยู่หรือไม่",
"tr_TR": "Amiibo API sunucusuna bağlanılamadı. Sunucu çevrimdışı olabilir veya uygun bir internet bağlantınızın olduğunu kontrol etmeniz gerekebilir.",
"uk_UA": "Неможливо підключитися до сервера Amiibo API. Можливо, служба не працює або вам потрібно перевірити, чи є підключення до Інтернету.",
"zh_CN": "无法连接到 Amiibo API 服务器,服务可能已关闭,或者没有连接互联网。",
"zh_TW": "無法連接 Amiibo API 伺服器。服務可能已停機,或者您可能需要確認網際網路連線是否在線上。"
}
},
{
"ID": "RenameAmiiboTitle",
"Translations": {
"ar_SA": "",
"de_DE": "Amiibo umbenennen",
"el_GR": "",
"en_US": "Rename Amiibo",
"es_ES": "Renombrar Amiibo",
"fr_FR": "Renommer l'Amiibo",
"he_IL": "",
"it_IT": "Rinomina Amiibo",
"ja_JP": "",
"ko_KR": "",
"no_NO": "",
"pl_PL": "",
"pt_BR": "Renomear Amiibo",
"ru_RU": "Переименовать Amiibo",
"sv_SE": "",
"th_TH": "",
"tr_TR": "",
"uk_UA": "Перейменувати Amiibo",
"zh_CN": "",
"zh_TW": ""
}
},
{
"ID": "ScanAmiiboTitle",
"Translations": {
"ar_SA": "فحص Amiibo",
"de_DE": "Amiibo einscannen",
"el_GR": "Σαρώστε το Amiibo",
"en_US": "Scan Amiibo",
"es_ES": "Escanear Amiibo",
"fr_FR": "Scanner l'Amiibo",
"he_IL": "סרוק את ה-Amiibo",
"it_IT": "Scansiona l'Amiibo",
"ja_JP": "Amiiboをスキャン",
"ko_KR": "Amiibo 스캔하기",
"no_NO": "Skanne Amiibo",
"pl_PL": "Zeskanuj Amiibo",
"pt_BR": "Escanear Amiibo",
"ru_RU": "Сканировать Amiibo",
"sv_SE": "Skanna Amiibo",
"th_TH": "สแกน Amiibo",
"tr_TR": "Amiibo'yu tara",
"uk_UA": "Сканувати Amiibo",
"zh_CN": "扫描 Amiibo",
"zh_TW": "掃描 Amiibo"
}
},
{
"ID": "ScanAmiiboMessage",
"Translations": {
"ar_SA": "",
"de_DE": "Bitte scanne jetzt dein Amiibo.",
"el_GR": "",
"en_US": "Please scan your Amiibo now.",
"es_ES": "Escanea tu Amiibo ahora.",
"fr_FR": "Veuillez scannez votre Amiibo.",
"he_IL": "",
"it_IT": "Scansiona ora il tuo Amiibo.",
"ja_JP": "",
"ko_KR": "지금 Amiibo를 스캔하세요.",
"no_NO": "Vennligst skann Amiiboene dine nå.",
"pl_PL": "",
"pt_BR": "Por favor, escaneie seu Amiibo agora.",
"ru_RU": "Пожалуйста, сканируйте ваш Amiibo.",
"sv_SE": "Skanna din Amiibo nu.",
"th_TH": "กรุณาสแกน Amiibo ของคุณตอนนี้",
"tr_TR": "",
"uk_UA": "Будь ласка, проскануйте Ваш Amiibo.",
"zh_CN": "请现在扫描你的 Amiibo",
"zh_TW": "請掃描你的 Amiibo。"
}
}
]
}
+4 -4
View File
@@ -4,7 +4,7 @@
"ID": "UpdatesAddedMessage",
"Translations": {
"ar_SA": "",
"de_DE": "",
"de_DE": "Updates hinzugefügt: {0}",
"el_GR": "",
"en_US": "Updates Added: {0}",
"es_ES": "Actualizaciones Añadidas: {0}",
@@ -29,7 +29,7 @@
"ID": "UpdatesRemovedMessage",
"Translations": {
"ar_SA": "",
"de_DE": "",
"de_DE": "Updates entfernt: {0}",
"el_GR": "",
"en_US": "Updates Removed: {0}",
"es_ES": "Actualizaciones Eliminadas: {0}",
@@ -54,7 +54,7 @@
"ID": "DLCAddedMessage",
"Translations": {
"ar_SA": "",
"de_DE": "",
"de_DE": "DLC hinzugefügt: {0}",
"el_GR": "",
"en_US": "DLC Added: {0}",
"es_ES": "DLC Añadidos: {0}",
@@ -79,7 +79,7 @@
"ID": "DLCRemovedMessage",
"Translations": {
"ar_SA": "",
"de_DE": "",
"de_DE": "DLC entfernt: {0}",
"el_GR": "",
"en_US": "DLC Removed: {0}",
"es_ES": "DLC Eliminados: {0}",
+3 -3
View File
@@ -4,7 +4,7 @@
"ID": "LoadApplicationFromFileFilePickerTitle",
"Translations": {
"ar_SA": "",
"de_DE": "",
"de_DE": "Wähle eine Switch-Anwendungsdatei zum Laden aus",
"el_GR": "",
"en_US": "Select a Switch application file to load",
"es_ES": "Selecciona un archivo de aplicación de Switch para cargar",
@@ -29,7 +29,7 @@
"ID": "LoadUnpackedApplicationFromFolderFilePickerTitle",
"Translations": {
"ar_SA": "",
"de_DE": "",
"de_DE": "Wähle einen Ordner mit einer entpackten Switch-Anwendung zum Laden",
"el_GR": "",
"en_US": "Select a folder containing an unpacked Switch application to load",
"es_ES": "Selecciona una carpeta que contenga una aplicación de Switch descomprimida para cargar",
@@ -54,7 +54,7 @@
"ID": "LoadUpdatesAndDLCFromFolderFilePickerTitle",
"Translations": {
"ar_SA": "",
"de_DE": "",
"de_DE": "Wähle einen oder mehrere Ordner zum Laden von Updates und DLC aus",
"el_GR": "",
"en_US": "Select one or more folders to bulk load updates and DLC from",
"es_ES": "Selecciona una o más carpetas para cargar de forma masiva actualizaciones y DLC",
+29
View File
@@ -0,0 +1,29 @@
{
"Locales": [
{
"ID": "ScanSkylanderFilePickerTitle",
"Translations": {
"ar_SA": "",
"de_DE": "Wähle eine BIN-, SKY-, DMP- oder DUMP-Datei aus, um einen Skylander zu scannen.",
"el_GR": "",
"en_US": "Select a BIN, SKY, DMP, or DUMP file to scan a Skylander from",
"es_ES": "Selecciona un archivo BIN, SKY, DMP o DUMP para escanear un Skylander",
"fr_FR": "Sélectionnez un fichier BIN, SKY, DMP ou DUMP pour scanner un Skylander",
"he_IL": "",
"it_IT": "Seleziona un file BIN, SKY, DMP o DUMP da cui scansionare uno Skylander",
"ja_JP": "",
"ko_KR": "",
"no_NO": "",
"pl_PL": "",
"pt_BR": "Selecione um arquivo BIN, SKY, DMP ou DUMP para escanear um Skylander",
"ru_RU": "Выберите файл BIN, SKY, DMP или DUMP для сканирования Skylander",
"sv_SE": "",
"th_TH": "",
"tr_TR": "",
"uk_UA": "Виберіть файл BIN, SKY, DMP або DUMP для сканування Skylander",
"zh_CN": "",
"zh_TW": ""
}
}
]
}
+304
View File
@@ -0,0 +1,304 @@
{
"Locales": [
{
"ID": "PrimaryMessage",
"Translations": {
"ar_SA": "",
"de_DE": "Entfernt ungenutzten Speicherplatz aus der XCI-Datei, um ihre Dateigröße zu verringern.",
"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": "Datei: {0:n} MB • Spiel: {1:n} MB\n\nErsparnis: {2:n} MB",
"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": "XCI muss nicht getrimmt werden. Prüfen Sie die Logs für Details.",
"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": "Das Trimmen der XCI kann nicht rückgängig gemacht werden. Prüfen Sie die Logs für Details.",
"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": "XCI ist schreibgeschützt und konnte nicht schreibbar gemacht werden. Prüfen Sie die Logs für Details.",
"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": "XCI-Größe hat sich seit dem letzten Scan geändert. Stellen Sie sicher, dass die Datei nicht beschrieben wird, und versuchen Sie es erneut.",
"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": "XCI enthält Daten im freien Speicherbereich. Es ist unsicher zu trimmen.",
"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": "XCI enthält ungültige Daten. Prüfen Sie die Logs für Details.",
"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": "XCI konnte nicht zum Schreiben geöffnet werden. Prüfen Sie die Logs für Details.",
"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": "Trimmen der XCI fehlgeschlagen.",
"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": "Der Vorgang wurde abgebrochen.",
"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": "Es wurde keine Aktion durchgeführt.",
"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": "沒有執行任何操作。"
}
}
]
}
+1 -1
View File
@@ -29,7 +29,7 @@
"ID": "NoUnpackedApplicationFoundInFolder",
"Translations": {
"ar_SA": "",
"de_DE": "",
"de_DE": "Bitte wähle einen entpackten Anwendungsordner mit gültigem ExeFS oder NSO/NRO aus.",
"el_GR": "",
"en_US": "Please select an unpacked application folder with a valid ExeFS or NSO/NRO.",
"es_ES": "",
+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"
}
}
]
}
+59 -34
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": ""
}
},
{
@@ -378,26 +378,26 @@
{
"ID": "ScanAmiiboFromBinButton",
"Translations": {
"ar_SA": "مسح Amiibo (.BIN)",
"de_DE": "Amiibo scannen (.BIN)",
"el_GR": "Σάρωση Amiibo (.BIN)",
"en_US": "Scan Amiibo (.BIN)",
"es_ES": "Escanear un Amiibo (.BIN)",
"fr_FR": "Scanner un Amiibo (.BIN)",
"he_IL": "סרוק Amiibo (.BIN)",
"it_IT": "Scansiona un Amiibo (.BIN)",
"ja_JP": "Amiibo をスキャン (.BIN)",
"ko_KR": "Amiibo 스캔 (.BIN)",
"no_NO": "Skann en Amiibo (.BIN)",
"pl_PL": "Skanuj Amiibo (.BIN)",
"pt_BR": "Escaneie um Amiibo (.BIN)",
"ru_RU": "Сканировать Amiibo (.BIN)",
"sv_SE": "Skanna en Amiibo (.BIN)",
"th_TH": "สแกนอามีโบ (.BIN)",
"tr_TR": "Amiibo Tara (.BIN)",
"uk_UA": "Сканувати Amiibo (.BIN)",
"zh_CN": "扫描 Amiibo (.BIN)",
"zh_TW": "掃瞄 Amiibo (.BIN)"
"ar_SA": "مسح Amiibo (BIN)",
"de_DE": "Amiibo scannen (BIN)",
"el_GR": "Σάρωση Amiibo (BIN)",
"en_US": "Scan Amiibo (BIN)",
"es_ES": "Escanear un Amiibo (BIN)",
"fr_FR": "Scanner un Amiibo (BIN)",
"he_IL": "סרוק Amiibo (BIN)",
"it_IT": "Scansiona un Amiibo (BIN)",
"ja_JP": "Amiibo をスキャン (BIN)",
"ko_KR": "Amiibo 스캔 (BIN)",
"no_NO": "Skann en Amiibo (BIN)",
"pl_PL": "Skanuj Amiibo (BIN)",
"pt_BR": "Escaneie um Amiibo (BIN)",
"ru_RU": "Сканировать Amiibo (BIN)",
"sv_SE": "Skanna en Amiibo (BIN)",
"th_TH": "สแกนอามีโบ (BIN)",
"tr_TR": "Amiibo Tara (BIN)",
"uk_UA": "Сканувати Amiibo (BIN)",
"zh_CN": "扫描 Amiibo (BIN)",
"zh_TW": "掃瞄 Amiibo (BIN)"
}
},
{
@@ -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": {
+4 -4
View File
@@ -154,7 +154,7 @@
"ID": "OpenScreenshotsFolderButton",
"Translations": {
"ar_SA": "",
"de_DE": "Screenshots-Ordner öffnen",
"de_DE": "Bildschirmfoto-Ordner öffnen",
"el_GR": "",
"en_US": "Open Screenshots Folder",
"es_ES": "Abrir Carpeta de Capturas de Pantalla",
@@ -179,7 +179,7 @@
"ID": "AssociateFileTypesButton",
"Translations": {
"ar_SA": "",
"de_DE": "",
"de_DE": "Dateitypen zuordnen",
"el_GR": "",
"en_US": "Associate File Types",
"es_ES": "",
@@ -204,7 +204,7 @@
"ID": "RemoveFileTypeAssociationsButton",
"Translations": {
"ar_SA": "",
"de_DE": "",
"de_DE": "Dateityp-Zuordnungen entfernen",
"el_GR": "",
"en_US": "Remove File Type Associations",
"es_ES": "",
@@ -229,7 +229,7 @@
"ID": "FileTypeAssociationsToolTip",
"Translations": {
"ar_SA": "",
"de_DE": "",
"de_DE": "Ordnet unterstützte Dateitypen (NSP, XCI, NCA, NRO, NSO) Ryujinx zu, sodass sie beim Doppelklick im Dateimanager automatisch geöffnet werden. Das Entfernen der Dateityp-Zuordnungen deaktiviert dieses Verhalten.",
"el_GR": "",
"en_US": "Associates supported file types (NSP, XCI, NCA, NRO, NSO) with Ryujinx so they open automatically when double-clicked in your file manager. Removing file type associations stops this behavior.",
"es_ES": "",
+382 -1857
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -54,7 +54,7 @@
"ID": "StartGamesWithoutUICheckboxLabelToolTip",
"Translations": {
"ar_SA": "",
"de_DE": "",
"de_DE": "Blendet die Benutzeroberfläche von Ryujinx automatisch aus, sobald ein Spiel gestartet wird. Während des Spiels kann die Oberfläche mit F4 eingeblendet werden.",
"el_GR": "",
"en_US": "Automatically hides Ryujinx's UI whenever a game launches. While in-game, press F4 to show the UI.",
"es_ES": "",
+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": "XCI ausgewählt: {0}/{1}",
"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": "XCI ausgewählt: {0}/{1} (Sichtbar: {2})",
"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": "Trimmen: {0}/{1}...",
"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": "Trimmung von {0} wieder aufheben...",
"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": "Getrimmt",
"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": "Ungetrimmt",
"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": "Teilweise",
"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": "Fehlgeschlagen",
"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": "Gespeichert:",
"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": "Trimmen",
"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": "Trimmen aufheben",
"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": "反修剪"
}
}
]
}
+2 -1
View File
@@ -2667,7 +2667,8 @@
0100C52011460000,"Sky: Children of the Light",cpu;online-broken,nothing,2023-02-23 10:57:10
010041C01014E000,"Skybolt Zack",,playable,2021-04-12 18:28:00
0100A0A00D1AA000,"SKYHILL",,playable,2021-03-05 15:19:11
0100CCC0002E6000,"Skylanders Imaginators",crash;services,boots,2020-05-30 18:49:18
010048D000BFC000,"Skylanders Imaginators",,playable,2026-07-02 22:43:30
0100CCC0002E6000,"Skylanders Imaginators",,playable,2026-07-02 22:43:30
010021A00ABEE000,"SKYPEACE",,playable,2020-05-29 14:14:30
0100EA400BF44000,"SkyScrappers",,playable,2020-05-28 22:11:25
0100F3C00C400000,"SkyTime",slow,ingame,2020-05-30 09:24:51
1 title_id game_name labels status last_updated
2667 0100C52011460000 Sky: Children of the Light cpu;online-broken nothing 2023-02-23 10:57:10
2668 010041C01014E000 Skybolt Zack playable 2021-04-12 18:28:00
2669 0100A0A00D1AA000 SKYHILL playable 2021-03-05 15:19:11
2670 0100CCC0002E6000 010048D000BFC000 Skylanders Imaginators crash;services boots playable 2020-05-30 18:49:18 2026-07-02 22:43:30
2671 0100CCC0002E6000 Skylanders Imaginators playable 2026-07-02 22:43:30
2672 010021A00ABEE000 SKYPEACE playable 2020-05-29 14:14:30
2673 0100EA400BF44000 SkyScrappers playable 2020-05-28 22:11:25
2674 0100F3C00C400000 SkyTime slow ingame 2020-05-30 09:24:51
+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; }
+8 -32
View File
@@ -19,46 +19,22 @@ namespace ARMeilleure.Common
new( 7, 8),
new( 1, 6)
];
private static readonly AddressTableLevel[] _levels64BitSparseTiny =
private static readonly AddressTableLevel[] _monoSparse64Bit =
[
new( 11, 28),
new( 2, 9)
new( 2, 37)
];
private static readonly AddressTableLevel[] _levels32BitSparseTiny =
private static readonly AddressTableLevel[] _monoSparse32Bit =
[
new( 10, 22),
new( 1, 9)
new( 1, 31)
];
private static readonly AddressTableLevel[] _levels64BitSparseGiant =
[
new( 38, 1),
new( 2, 36)
];
private static readonly AddressTableLevel[] _levels32BitSparseGiant =
[
new( 31, 1),
new( 1, 30)
];
//high power will run worse on DDR3 systems and some DDR4 systems due to the higher ram utilization
//low power will never run worse than non-sparse, but for most systems it won't be necessary
//high power is always used, but I've left low power in here for future reference
public static AddressTableLevel[] GetArmPreset(bool for64Bits, bool sparse, bool lowPower = false)
public static AddressTableLevel[] GetArmPreset(bool for64Bits, bool sparse)
{
if (sparse)
{
if (lowPower)
{
return for64Bits ? _levels64BitSparseTiny : _levels32BitSparseTiny;
}
else
{
return for64Bits ? _levels64BitSparseGiant : _levels32BitSparseGiant;
}
return for64Bits ? _monoSparse64Bit : _monoSparse32Bit;
}
else
{
@@ -5,7 +5,7 @@ using ARMeilleure.IntermediateRepresentation;
using ARMeilleure.State;
using ARMeilleure.Translation;
using ARMeilleure.Translation.PTC;
using System.Linq;
using static ARMeilleure.Instructions.InstEmitHelper;
using static ARMeilleure.IntermediateRepresentation.Operand.Factory;
@@ -238,7 +238,7 @@ namespace ARMeilleure.Instructions
}
else if (table.Sparse)
{
// Inline table lookup. Only enabled when the sparse function table is enabled with 2 levels.
// Inline table lookup. Only enabled when the sparse function table is enabled with 1 level.
// Deliberately attempts to avoid branches.
Operand tableBase = !context.HasPtc ?
@@ -247,18 +247,15 @@ namespace ARMeilleure.Instructions
hostAddress = tableBase;
for (int i = 0; i < table.Levels.Length; i++)
{
AddressTableLevel level = table.Levels[i];
int clearBits = 64 - (level.Index + level.Length);
AddressTableLevel level = table.Levels.Last();
int clearBits = 64 - (level.Index + level.Length);
Operand index = context.ShiftLeft(
context.ShiftRightUI(context.ShiftLeft(guestAddress, Const(clearBits)), Const(clearBits + level.Index)),
Const(3)
);
Operand index = context.ShiftLeft(
context.ShiftRightUI(context.ShiftLeft(guestAddress, Const(clearBits)), Const(clearBits + level.Index)),
Const(3)
);
hostAddress = context.Load(OperandType.I64, context.Add(hostAddress, index));
}
hostAddress = context.Load(OperandType.I64, context.Add(hostAddress, index));
}
else
{
+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];
+84 -91
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,67 @@ 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;
int regionStart = (offset % (int)CacheSize) & ~_pageMask;
int regionEnd = endOffs % (int)CacheSize == 0
? (((int)CacheSize) + _pageMask) & ~_pageMask
: ((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 == 0
? (((int)CacheSize) + _pageMask) & ~_pageMask
: ((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 +193,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 +207,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 +230,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 = 7018; //! 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();
+13 -8
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;
@@ -156,7 +161,7 @@ namespace ARMeilleure.Translation
context.BranchIfTrue(lblFallback, masked);
Operand index = default;
Operand page = Const((long)_functionTable.Base);
Operand page = Const(_functionTable.Base);
for (int i = 0; i < _functionTable.Levels.Length; i++)
{
@@ -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,
};
}
}
}
+9 -7
View File
@@ -17,6 +17,7 @@ namespace Ryujinx.Common
//Dragon Quest Franchise
"010008900705c000", // Dragon Quest Builders
"010042000a986000", // Dragon Quest Builders 2
"010021701c45e000", // Dragon Quest VII Reimagined Demo
//Fire Emblem Franchise
"0100a6301214e000", // Fire Emblem Engage
@@ -33,7 +34,6 @@ namespace Ryujinx.Common
"01007e3006dda000", // Kirby Star Allies
"01003fb00c5a8000", // Super Kirby Clash
//The Zelda Franchise
"01000b900d8b0000", // Cadence of Hyrule
"0100ae00096ea000", // Hyrule Warriors: Definitive Edition
@@ -43,8 +43,7 @@ namespace Ryujinx.Common
"01002da013484000", // The Legend of Zelda: Skyward Sword HD
"0100f2c0115b6000", // The Legend of Zelda: Tears of the Kingdom
"01008cf01baac000", // The Legend of Zelda: Echoes of Wisdom
//Luigi Franchise
"010048701995e000", // Luigi's Mansion 2 HD
"0100dca0064a6000", // Luigi's Mansion 3
@@ -75,16 +74,13 @@ namespace Ryujinx.Common
"010049900f546002", // Super Mario 3D All-Stars | Super Mario Sunshine
"010049900f546003", // Super Mario 3D All-Stars | Super Mario Galaxy
"010028600ebda000", // Super Mario 3D World + Bowser's Fury
"010049900F546001", // Super Mario 64
"0100ea80032ea000", // Super Mario Bros. U Deluxe
"010015100b514000", // Super Mario Bros. Wonder
"010049900F546003", // Super Mario Galaxy
"01009b90006dc000", // Super Mario Maker 2
"0100000000010000", // SUPER MARIO ODYSSEY
"010036b0034e4000", // Super Mario Party
"0100965017338000", // Super Mario Party Jamboree
"0100bc0018138000", // Super Mario RPG
"010049900F546002", // Super Mario Sunshine
"0100a3900c3e2000", // Paper Mario: The Origami King
"0100ecd018ebe000", // Paper Mario: The Thousand-Year Door
"01009cC022b96000", // Super Mario Galaxy
@@ -149,6 +145,7 @@ namespace Ryujinx.Common
"010067b017588000", // Endless Ocean Luminous
"01003da010e8a000", // Miitopia
"01006b5012b32000", // Part Time UFO
"0100107027310000", // Rhythm Heave Groove: Starter Demo
"0100704000B3A000", // Snipperclips
"01006a800016e000", // Super Smash Bros. Ultimate
"0100a9400c9c2000", // Tokyo Mirage Sessions #FE Encore
@@ -176,7 +173,7 @@ namespace Ryujinx.Common
"01004ad014bf0000", // Sonic Frontiers
"01009aa000faa000", // Sonic Mania
"01005ea01c0fc000", // SONIC X SHADOW GENERATIONS
"01005ea01c0fc001", // ^
"01005ea01c0fc001", // ^ TODO: (Needs to be differentiated like 3D All Stars)
"01006e001823c000", // Sonic Racing: CrossWorlds
"010064b0242be000", // Sonic Racing: CrossWorlds - Demo
@@ -199,6 +196,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 +209,8 @@ namespace Ryujinx.Common
"010013c00e930000", // Hollow Knight: Silksong
"010085500130a000", // Lego City: Undercover
"010073c01af34000", // LEGO Horizon Adventures
"0100653026dde000", // MARVEL Cosmic Invasion Demo
"0100419014456000", // Mary Skelter Finale
"0100d71004694000", // Minecraft
"01007430037f6000", // Monopoly
"0100853015e86000", // No Man's Sky
@@ -231,6 +231,8 @@ namespace Ryujinx.Common
"01008d100d43e000", // Saints Row IV
"0100de600beee000", // Saints Row: The Third - The Full Package
"01001180021fa000", // Shovel Knight: Specter of Torment
"0100ccc0002e6000", // Skylanders Imaginators (US)
"010048d000bfc000", // Skylanders Imaginators (EU)
"010079f00671c000", // Sparkle 2: Evo
"010077b00e046000", // Spyro: Reignited Trilogy
"0100e1D01eb2e000", // Squeakross: Home Squeak Home
+46 -26
View File
@@ -81,12 +81,13 @@ namespace ARMeilleure.Common
private bool _disposed;
private TEntry** _table;
private TEntry* _sparseTable;
private readonly List<AddressTablePage> _pages;
private TEntry _fill;
private readonly MemoryBlock _sparseFill;
private readonly SparseMemoryBlock _fillBottomLevel;
private readonly TEntry* _fillBottomLevelPtr;
private MemoryBlock _sparseFill;
private SparseMemoryBlock _fillBottomLevel;
private TEntry* _fillBottomLevelPtr;
private readonly List<TableSparseBlock> _sparseReserved;
private readonly ReaderWriterLockSlim _sparseLock;
@@ -122,16 +123,23 @@ namespace ARMeilleure.Common
{
ObjectDisposedException.ThrowIf(_disposed, this);
lock (_pages)
if (Sparse)
{
return (nint)GetRootPage();
return (nint)_sparseTable;
}
else
{
lock (_pages)
{
return (nint)GetRootPage();
}
}
}
}
/// <summary>
/// Constructs a new instance of the <see cref="AddressTable{TEntry}"/> class with the specified list of
/// <see cref="Level"/>.
/// <see cref="AddressTableLevel"/>.
/// </summary>
/// <param name="levels">Levels for the address table</param>
/// <param name="sparse">True if the bottom page should be sparsely mapped</param>
@@ -156,18 +164,8 @@ namespace ARMeilleure.Common
if (sparse)
{
// If the address table is sparse, allocate a fill block
_sparseFill = new MemoryBlock(268435456ul, MemoryAllocationFlags.Mirrorable); //low Power TC uses size: 65536ul
ulong bottomLevelSize = (1ul << levels.Last().Length) * (ulong)sizeof(TEntry);
_fillBottomLevel = new SparseMemoryBlock(bottomLevelSize, null, _sparseFill);
_fillBottomLevelPtr = (TEntry*)_fillBottomLevel.Block.Pointer;
_sparseReserved = [];
_sparseLock = new ReaderWriterLockSlim();
_sparseBlockSize = bottomLevelSize;
}
}
@@ -209,13 +207,24 @@ namespace ARMeilleure.Common
public void SignalCodeRange(ulong address, ulong size)
{
AddressTableLevel bottom = Levels.Last();
ulong bottomLevelEntries = 1ul << bottom.Length;
ulong entryIndex = address >> bottom.Index;
ulong entries = size >> bottom.Index;
entries += entryIndex - BitUtils.AlignDown(entryIndex, bottomLevelEntries);
if (Sparse)
{
ulong bottomLevelSize = (ulong)BitUtils.Pow2RoundUp((int)entries) * (ulong)sizeof(TEntry);
_sparseFill = new MemoryBlock(bottomLevelSize, MemoryAllocationFlags.Mirrorable);
_sparseBlockSize = Math.Max(_sparseBlockSize, BitUtils.AlignUp(entries, bottomLevelEntries) * (ulong)sizeof(TEntry));
_fillBottomLevel = new SparseMemoryBlock(bottomLevelSize, null, _sparseFill);
_fillBottomLevelPtr = (TEntry*)_fillBottomLevel.Block.Pointer;
_sparseBlockSize = bottomLevelSize;
_sparseTable = (TEntry*)Allocate((int)entries, Fill, leaf: true);
_sparseTable -= Levels.Last().GetValue(address);
}
}
/// <inheritdoc/>
@@ -234,15 +243,26 @@ namespace ARMeilleure.Common
throw new ArgumentException($"Address 0x{address:X} is not mapped onto the table.", nameof(address));
}
lock (_pages)
if (Sparse)
{
TEntry* page = GetPage(address);
long index = Levels.Last().GetValue(address);
EnsureMapped((nint)(_sparseTable + index));
return ref _sparseTable[index];
}
else
{
lock (_pages)
{
TEntry* page = GetPage(address);
long index = Levels[^1].GetValue(address);
long index = Levels.Last().GetValue(address);
EnsureMapped((nint)(page + index));
EnsureMapped((nint)(page + index));
return ref page[index];
return ref page[index];
}
}
}
@@ -3,6 +3,7 @@ using Ryujinx.Cpu.LightningJit.CodeGen;
using Ryujinx.Cpu.LightningJit.CodeGen.Arm64;
using System;
using System.Diagnostics;
using System.Linq;
using System.Numerics;
using System.Runtime.CompilerServices;
@@ -189,7 +190,7 @@ namespace Ryujinx.Cpu.LightningJit.Arm32.Target.Arm64
}
else if (inlineLookup)
{
// Inline table lookup. Only enabled when the sparse function table is enabled with 2 levels.
// Inline table lookup. Only enabled when the sparse function table is enabled with 1 level.
Operand indexReg = Register(NextFreeRegister(tempRegister + 1, tempGuestAddress));
@@ -203,18 +204,15 @@ namespace Ryujinx.Cpu.LightningJit.Arm32.Target.Arm64
// Index into the table.
asm.Mov(rn, tableBase);
for (int i = 0; i < funcTable.Levels.Length; i++)
{
AddressTableLevel level = funcTable.Levels[i];
asm.Ubfx(indexReg, guestAddress, level.Index, level.Length);
asm.Lsl(indexReg, indexReg, Const(3));
AddressTableLevel level = funcTable.Levels.Last();
asm.Ubfx(indexReg, guestAddress, level.Index, level.Length);
asm.Lsl(indexReg, indexReg, Const(3));
// Index into the page.
asm.Add(rn, rn, indexReg);
// Index into the page.
asm.Add(rn, rn, indexReg);
// Load the page address.
asm.LdrRiUn(rn, rn, 0);
}
// Load the page address.
asm.LdrRiUn(rn, rn, 0);
if (tempGuestAddress != -1)
{
@@ -3,6 +3,7 @@ using Ryujinx.Cpu.LightningJit.CodeGen;
using Ryujinx.Cpu.LightningJit.CodeGen.Arm64;
using System;
using System.Diagnostics;
using System.Linq;
using System.Numerics;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
@@ -355,7 +356,7 @@ namespace Ryujinx.Cpu.LightningJit.Arm64.Target.Arm64
}
else if (inlineLookup)
{
// Inline table lookup. Only enabled when the sparse function table is enabled with 2 levels.
// Inline table lookup. Only enabled when the sparse function table is enabled with 1 level.
Operand indexReg = Register(NextFreeRegister(tempRegister + 1, tempGuestAddress));
@@ -369,18 +370,15 @@ namespace Ryujinx.Cpu.LightningJit.Arm64.Target.Arm64
// Index into the table.
asm.Mov(rn, tableBase);
for (int i = 0; i < funcTable.Levels.Length; i++)
{
AddressTableLevel level = funcTable.Levels[i];
asm.Ubfx(indexReg, guestAddress, level.Index, level.Length);
asm.Lsl(indexReg, indexReg, Const(3));
AddressTableLevel level = funcTable.Levels.Last();
asm.Ubfx(indexReg, guestAddress, level.Index, level.Length);
asm.Lsl(indexReg, indexReg, Const(3));
// Index into the page.
asm.Add(rn, rn, indexReg);
// Index into the page.
asm.Add(rn, rn, indexReg);
// Load the page address.
asm.LdrRiUn(rn, rn, 0);
}
// Load the page address.
asm.LdrRiUn(rn, rn, 0);
if (tempGuestAddress != -1)
{
@@ -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);
}
}
+52
View File
@@ -11,6 +11,9 @@ namespace Ryujinx.Graphics.Nvdec.Vp9
public bool IsHardwareAccelerated => false;
private readonly MemoryAllocator _allocator = new();
private byte[] _lastSegmentMap = [];
private int _lastSegmentMapMiRows;
private int _lastSegmentMapMiCols;
public ISurface CreateSurface(int width, int height)
{
@@ -106,7 +109,9 @@ namespace Ryujinx.Graphics.Nvdec.Vp9
cm.AllocTileWorkerData(_allocator, tileCols, tileRows, maxThreads);
cm.AllocContextBuffers(_allocator, output.Width, output.Height);
cm.InitContextBuffers();
LoadSegmentMap(ref cm, ref pictureInfo);
cm.SetupSegmentationDequant();
cm.SetupScaleFactors();
@@ -153,6 +158,7 @@ namespace Ryujinx.Graphics.Nvdec.Vp9
}
}
SaveSegmentMap(ref cm);
cm.GetMvs(mvsOut);
cm.FreeTileWorkerData(_allocator);
@@ -161,6 +167,52 @@ namespace Ryujinx.Graphics.Nvdec.Vp9
return true;
}
private void LoadSegmentMap(ref Vp9Common cm, ref Vp9PictureInfo pictureInfo)
{
int mapLength = cm.MiRows * cm.MiCols;
if (mapLength == 0 || cm.LastFrameSegMap.IsNull)
{
return;
}
bool reset =
pictureInfo.IsKeyFrame ||
pictureInfo.IntraOnly ||
_lastSegmentMapMiRows != cm.MiRows ||
_lastSegmentMapMiCols != cm.MiCols;
if (_lastSegmentMap.Length != mapLength)
{
_lastSegmentMap = new byte[mapLength];
}
else if (reset)
{
Array.Clear(_lastSegmentMap);
}
_lastSegmentMapMiRows = cm.MiRows;
_lastSegmentMapMiCols = cm.MiCols;
_lastSegmentMap.CopyTo(cm.LastFrameSegMap.AsSpan());
}
private void SaveSegmentMap(ref Vp9Common cm)
{
int mapLength = cm.MiRows * cm.MiCols;
if (!cm.Seg.Enabled || mapLength == 0 || cm.CurrentFrameSegMap.IsNull)
{
return;
}
if (_lastSegmentMap.Length != mapLength)
{
_lastSegmentMap = new byte[mapLength];
}
cm.CurrentFrameSegMap.AsSpan()[..mapLength].CopyTo(_lastSegmentMap);
_lastSegmentMapMiRows = cm.MiRows;
_lastSegmentMapMiCols = cm.MiCols;
}
public void Dispose()
{
_allocator.Dispose();
@@ -1088,6 +1088,12 @@ namespace Ryujinx.Graphics.Nvdec.Vp9.Types
public void GetMvs(Span<Vp9MvRef> mvs)
{
if (FrameIsIntraOnly())
{
mvs.Clear();
return;
}
if (mvs.Length > CurFrameMvs.Length)
{
throw new ArgumentException(
@@ -53,6 +53,7 @@ namespace Ryujinx.Graphics.Nvdec.Types.Vp9
IsKeyFrame = Flags.HasFlag(FrameFlags.IsKeyFrame),
IntraOnly = Flags.HasFlag(FrameFlags.IntraOnly),
UsePrevInFindMvRefs =
!Flags.HasFlag(FrameFlags.IsKeyFrame) &&
!Flags.HasFlag(FrameFlags.ErrorResilientMode) &&
!Flags.HasFlag(FrameFlags.FrameSizeChanged) &&
!Flags.HasFlag(FrameFlags.IntraOnly) &&
@@ -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;
}
+97 -27
View File
@@ -16,10 +16,9 @@ namespace Ryujinx.Horizon.MmNv.Ipc
public Result InitializeOld(Module module, uint fgmPriority, uint autoClearEvent)
{
bool isAutoClearEvent = autoClearEvent != 0;
uint requestId = Register(module, fgmPriority, isAutoClearEvent);
Logger.Stub?.PrintStub(LogClass.ServiceMm, new { module, fgmPriority, isAutoClearEvent });
Register(module, fgmPriority, isAutoClearEvent);
Logger.Info?.PrintMsg(LogClass.ServiceMm, $"Initialized module={module} requestId={requestId} autoClearEvent={isAutoClearEvent}");
return Result.Success;
}
@@ -27,41 +26,48 @@ namespace Ryujinx.Horizon.MmNv.Ipc
[CmifCommand(1)]
public Result FinalizeOld(Module module)
{
Logger.Stub?.PrintStub(LogClass.ServiceMm, new { module });
lock (_sessionList)
{
_sessionList.Remove(GetSessionByModule(module));
Session session = GetSessionByModule(module);
if (session != null)
{
_sessionList.Remove(session);
}
}
Logger.Info?.PrintMsg(LogClass.ServiceMm, $"Finalized module={module}");
return Result.Success;
}
[CmifCommand(2)]
public Result SetAndWaitOld(Module module, uint clockRateMin, int clockRateMax)
public Result SetAndWaitOld(Module module, uint clockRateMin, uint timeout)
{
Logger.Stub?.PrintStub(LogClass.ServiceMm, new { module, clockRateMin, clockRateMax });
uint actualClockRate;
lock (_sessionList)
{
GetSessionByModule(module)?.SetAndWait(clockRateMin, clockRateMax);
Session session = GetSessionByModule(module) ?? RegisterSession(module, false);
ApplyClockRequest(session, clockRateMin, timeout);
actualClockRate = GetEffectiveClockRate(module);
}
Logger.Trace?.PrintMsg(LogClass.ServiceMm, $"SetAndWait module={module} requested={clockRateMin} actual={actualClockRate} timeout={timeout}");
return Result.Success;
}
[CmifCommand(3)]
public Result GetOld(out uint clockRateActual, Module module)
{
Logger.Stub?.PrintStub(LogClass.ServiceMm, new { module });
lock (_sessionList)
{
Session session = GetSessionByModule(module);
clockRateActual = session == null ? 0 : session.ClockRateMin;
clockRateActual = GetEffectiveClockRate(module);
}
Logger.Trace?.PrintMsg(LogClass.ServiceMm, $"Get module={module} actual={clockRateActual}");
return Result.Success;
}
@@ -70,51 +76,76 @@ namespace Ryujinx.Horizon.MmNv.Ipc
{
bool isAutoClearEvent = autoClearEvent != 0;
Logger.Stub?.PrintStub(LogClass.ServiceMm, new { module, fgmPriority, isAutoClearEvent });
requestId = Register(module, fgmPriority, isAutoClearEvent);
Logger.Info?.PrintMsg(LogClass.ServiceMm, $"Initialized module={module} requestId={requestId} autoClearEvent={isAutoClearEvent}");
return Result.Success;
}
[CmifCommand(5)]
public Result Finalize(uint requestId)
{
Logger.Stub?.PrintStub(LogClass.ServiceMm, new { requestId });
lock (_sessionList)
{
_sessionList.Remove(GetSessionById(requestId));
Session session = GetSessionById(requestId);
if (session != null)
{
_sessionList.Remove(session);
}
}
Logger.Info?.PrintMsg(LogClass.ServiceMm, $"Finalized requestId={requestId}");
return Result.Success;
}
[CmifCommand(6)]
public Result SetAndWait(uint requestId, uint clockRateMin, int clockRateMax)
public Result SetAndWait(uint requestId, uint clockRateMin, uint timeout)
{
Logger.Stub?.PrintStub(LogClass.ServiceMm, new { requestId, clockRateMin, clockRateMax });
uint actualClockRate = 0;
Module? module = null;
lock (_sessionList)
{
GetSessionById(requestId)?.SetAndWait(clockRateMin, clockRateMax);
Session session = GetSessionById(requestId);
if (session != null)
{
module = session.Module;
ApplyClockRequest(session, clockRateMin, timeout);
actualClockRate = GetEffectiveClockRate(session.Module);
}
}
Logger.Trace?.PrintMsg(LogClass.ServiceMm, $"SetAndWait requestId={requestId} module={module?.ToString() ?? "<missing>"} requested={clockRateMin} actual={actualClockRate} timeout={timeout}");
return Result.Success;
}
[CmifCommand(7)]
public Result Get(out uint clockRateActual, uint requestId)
{
Logger.Stub?.PrintStub(LogClass.ServiceMm, new { requestId });
Module? module = null;
lock (_sessionList)
{
Session session = GetSessionById(requestId);
clockRateActual = session == null ? 0 : session.ClockRateMin;
if (session == null)
{
clockRateActual = 0;
}
else
{
module = session.Module;
clockRateActual = GetEffectiveClockRate(session.Module);
}
}
Logger.Trace?.PrintMsg(LogClass.ServiceMm, $"Get requestId={requestId} module={module?.ToString() ?? "<missing>"} actual={clockRateActual}");
return Result.Success;
}
@@ -149,12 +180,51 @@ namespace Ryujinx.Horizon.MmNv.Ipc
lock (_sessionList)
{
// Nintendo ignores the fgm priority as the other services were deprecated.
Session session = new(_uniqueId++, module, isAutoClearEvent);
_sessionList.Add(session);
Session session = RegisterSession(module, isAutoClearEvent);
return session.Id;
}
}
private Session RegisterSession(Module module, bool isAutoClearEvent)
{
Session session = new(_uniqueId++, module, isAutoClearEvent);
_sessionList.Add(session);
return session;
}
private void ApplyClockRequest(Session session, uint requestedClockRate, uint timeout)
{
if (IsClockRateSentinel(requestedClockRate))
{
session.ClearRequest(timeout);
}
else
{
session.SetAndWait(requestedClockRate, timeout);
}
}
private uint GetEffectiveClockRate(Module module)
{
uint clockRate = 0;
foreach (Session session in _sessionList)
{
if (session.Module == module && session.HasActiveRequest && session.RequestedClockRate > clockRate)
{
clockRate = session.RequestedClockRate;
}
}
return clockRate;
}
private static bool IsClockRateSentinel(uint clockRate)
{
return clockRate == 0 || unchecked((int)clockRate) < 0;
}
}
}
+2 -2
View File
@@ -7,11 +7,11 @@ namespace Ryujinx.Horizon.Sdk.MmNv
{
Result InitializeOld(Module module, uint fgmPriority, uint autoClearEvent);
Result FinalizeOld(Module module);
Result SetAndWaitOld(Module module, uint clockRateMin, int clockRateMax);
Result SetAndWaitOld(Module module, uint clockRateMin, uint timeout);
Result GetOld(out uint clockRateActual, Module module);
Result Initialize(out uint requestId, Module module, uint fgmPriority, uint autoClearEvent);
Result Finalize(uint requestId);
Result SetAndWait(uint requestId, uint clockRateMin, int clockRateMax);
Result SetAndWait(uint requestId, uint clockRateMin, uint timeout);
Result Get(out uint clockRateActual, uint requestId);
}
}
+17 -7
View File
@@ -5,22 +5,32 @@ namespace Ryujinx.Horizon.Sdk.MmNv
public Module Module { get; }
public uint Id { get; }
public bool IsAutoClearEvent { get; }
public uint ClockRateMin { get; private set; }
public int ClockRateMax { get; private set; }
public uint RequestedClockRate { get; private set; }
public uint LastTimeout { get; private set; }
public bool HasActiveRequest { get; private set; }
public Session(uint id, Module module, bool isAutoClearEvent)
{
Module = module;
Id = id;
IsAutoClearEvent = isAutoClearEvent;
ClockRateMin = 0;
ClockRateMax = -1;
RequestedClockRate = 0;
LastTimeout = uint.MaxValue;
HasActiveRequest = false;
}
public void SetAndWait(uint clockRateMin, int clockRateMax)
public void SetAndWait(uint clockRate, uint timeout)
{
ClockRateMin = clockRateMin;
ClockRateMax = clockRateMax;
RequestedClockRate = clockRate;
LastTimeout = timeout;
HasActiveRequest = true;
}
public void ClearRequest(uint timeout)
{
RequestedClockRate = 0;
LastTimeout = timeout;
HasActiveRequest = false;
}
}
}
+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();
+735 -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,567 @@ 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;
float low = Math.Min(1f, (float)((rightVibrationValue.AmplitudeLow * 0.85 + rightVibrationValue.AmplitudeHigh * 0.15) * controllerConfig.Rumble.StrongRumble));
float high = Math.Min(1f, (float)((leftVibrationValue.AmplitudeLow * 0.15 + leftVibrationValue.AmplitudeHigh * 0.85) * controllerConfig.Rumble.WeakRumble));
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))
{
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++)
{
+113 -3
View File
@@ -1,7 +1,37 @@
{
"Locales": {
"ar_SA": [],
"de_DE": [],
"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.",
@@ -76,10 +106,90 @@
"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!"
"It is Wednesday my dudes!",
"Nifty",
"Find us on BlueSky",
"We are the knights who say... Ni."
],
"es_ES": [],
"fr_FR": [],
"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": [],
@@ -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();
}
}
}
+28 -28
View File
@@ -2,7 +2,6 @@ using System.Collections.Generic;
using Ryujinx.Common.Logging;
using Gommon;
using Ryujinx.Ava.Systems.Configuration;
using System;
using System.Text.Json;
namespace Ryujinx.Common
@@ -11,47 +10,48 @@ namespace Ryujinx.Common
{
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, " /___/ /___/ ");
Logger.Notice.Print(LogClass.Application, "");
Logger.Notice.Print(LogClass.Application, GetSplash());
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 s_finalSplash = "";
private static string _finalSplash;
public static string GetSplash()
{
if (string.IsNullOrEmpty(s_finalSplash))
if (_finalSplash is null)
{
s_finalSplash = GetLangJson();
if (string.IsNullOrEmpty(s_finalSplash))
try
{
s_finalSplash = "Splash Text";
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 $"{s_finalSplash}";
}
private static SplashLocales s_splashJson;
private static string GetLangJson()
{
try
{
string data;
data = EmbeddedResources.ReadAllText("Ryujinx/Assets/Splashes.json");
s_splashJson = JsonSerializer.Deserialize<SplashLocales>(data);
return s_splashJson.Locales[ConfigurationState.Instance.UI.LanguageCode.Value].GetRandomElement();
}
catch
{
return "";
}
return _finalSplash;
}
private struct SplashLocales
+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>
@@ -492,15 +502,20 @@ namespace Ryujinx.Ava.Systems.Configuration
public bool DebuggerSuspendOnStart { get; set; }
/// <summary>
/// Show toggles for dirty hacks in the UI.
/// Show toggles for dirty hacks in the UI.
/// </summary>
public bool ShowDirtyHacks { get; set; }
/// <summary>
/// The packed values of the enabled dirty hacks.
/// The packed values of the enabled dirty hacks.
/// </summary>
public ulong[] DirtyHacks { get; set; }
/// <summary>
/// Pause emulation while scanning Amiibo
/// </summary>
public bool PauseEmulationWhileScanningAmiibo { get; set; }
/// <summary>
/// Loads a configuration file from disk
/// </summary>
@@ -115,7 +115,7 @@ namespace Ryujinx.Ava.Systems.Configuration
System.SkipUserProfilesManager.Value = cff.SkipUserProfiles;
System.UseHypervisor.Value = cff.UseHypervisor;
System.GCLowLatency.Value = cff.GCLowLatency;
UI.GuiColumns.FavColumn.Value = shouldLoadFromFile ? cff.GuiColumns.FavColumn : UI.GuiColumns.FavColumn.Value;
UI.GuiColumns.IconColumn.Value = shouldLoadFromFile ? cff.GuiColumns.IconColumn : UI.GuiColumns.IconColumn.Value;
UI.GuiColumns.AppColumn.Value = shouldLoadFromFile ? cff.GuiColumns.AppColumn : UI.GuiColumns.AppColumn.Value;
@@ -151,12 +151,15 @@ namespace Ryujinx.Ava.Systems.Configuration
UI.WindowStartup.WindowPositionX.Value = shouldLoadFromFile ? cff.WindowStartup.WindowPositionX : UI.WindowStartup.WindowPositionX.Value;
UI.WindowStartup.WindowPositionY.Value = shouldLoadFromFile ? cff.WindowStartup.WindowPositionY : UI.WindowStartup.WindowPositionY.Value;
UI.WindowStartup.WindowMaximized.Value = shouldLoadFromFile ? cff.WindowStartup.WindowMaximized : UI.WindowStartup.WindowMaximized.Value;
UI.PauseEmulationWhileScanningAmiibo.Value = cff.PauseEmulationWhileScanningAmiibo;
Hid.EnableKeyboard.Value = cff.EnableKeyboard;
Hid.EnableMouse.Value = cff.EnableMouse;
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 +339,7 @@ namespace Ryujinx.Ava.Systems.Configuration
EnableRumble = false,
StrongRumble = 1f,
WeakRumble = 1f,
UseHDRumble = true
UseHDRumble = false
};
}
}
@@ -191,6 +191,16 @@ 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; }
/// <summary>
/// Pause emulation while scanning Amiibo
/// </summary>
public ReactiveObject<bool> PauseEmulationWhileScanningAmiibo { get; private set; }
public UISection()
{
GuiColumns = new Columns();
@@ -210,6 +220,10 @@ 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;
PauseEmulationWhileScanningAmiibo = new ReactiveObject<bool>();
PauseEmulationWhileScanningAmiibo.Value = true;
}
}
@@ -513,6 +527,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 +552,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,
@@ -156,6 +158,7 @@ namespace Ryujinx.Ava.Systems.Configuration
DebuggerSuspendOnStart = Debug.DebuggerSuspendOnStart,
ShowDirtyHacks = Hacks.ShowDirtyHacks,
DirtyHacks = Hacks.EnabledHacks.Select(it => it.Pack()).ToArray(),
PauseEmulationWhileScanningAmiibo = UI.PauseEmulationWhileScanningAmiibo,
};
return configurationFile;
@@ -260,6 +263,7 @@ namespace Ryujinx.Ava.Systems.Configuration
UI.WindowStartup.WindowPositionX.Value = 0;
UI.WindowStartup.WindowPositionY.Value = 0;
UI.WindowStartup.WindowMaximized.Value = false;
UI.PauseEmulationWhileScanningAmiibo.Value = true;
Hid.EnableKeyboard.Value = false;
Hid.EnableMouse.Value = false;
Hid.DisableInputWhenOutOfFocus.Value = false;
@@ -332,6 +336,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)
@@ -1105,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",
@@ -1151,8 +1151,10 @@ namespace Ryujinx.Ava.Systems.PlayReport
{
return "At the main menu";
}
static string MarioOrLuigiGamemode(string? gamemode) => gamemode switch
return "";
static string MarioOrLuigiGamemode(string gamemode) => gamemode switch
{
"0" => "mario",
"1" => "luigi",
@@ -1161,14 +1163,14 @@ namespace Ryujinx.Ava.Systems.PlayReport
_ => gamemode
};
static string OtherGameMode(string? gamemode) => gamemode switch
{
"2" => "Boost Rush",
"3" => "Challenges",
"4" => "Coin Battle",
"5" => "Coin Battle Editor",
_ => ""
};
// 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
{
@@ -1196,9 +1198,6 @@ namespace Ryujinx.Ava.Systems.PlayReport
// Glacier seals = 16, water leaf = 15
// desert ice = 14, acorn squid = 13
// all other course numbers are to be considered a hazard
return "";
}
}
}
+3 -3
View File
@@ -187,7 +187,7 @@ namespace Ryujinx.Ava.UI.Applet
StringLengthMax = 25
};
(UserResult result, string userInput) =
await SwkbdAppletDialog.ShowInputDialog(LocaleManager.Instance[LocaleKeys.CabinetDialog], args);
await SwkbdAppletDialog.ShowInputDialog(LocaleManager.Instance[LocaleKeys.Dialog_Amiibo_RenameAmiiboTitle], args);
if (result == UserResult.Ok)
{
inputText = userInput;
@@ -212,11 +212,11 @@ namespace Ryujinx.Ava.UI.Applet
{
dialogCloseEvent.Set();
await ContentDialogHelper.CreateInfoDialog(
LocaleManager.Instance[LocaleKeys.CabinetScanDialog],
LocaleManager.Instance[LocaleKeys.Dialog_Amiibo_ScanAmiiboMessage],
string.Empty,
LocaleManager.Instance[LocaleKeys.InputDialogOk],
string.Empty,
LocaleManager.Instance[LocaleKeys.CabinetTitle]
LocaleManager.Instance[LocaleKeys.Dialog_Amiibo_ScanAmiiboTitle]
);
});
dialogCloseEvent.WaitOne();
@@ -74,10 +74,14 @@ namespace Ryujinx.Ava.UI.Controls
content._host = contentDialog;
contentDialog.Title = title;
contentDialog.PrimaryButtonText = args.SubmitText;
contentDialog.PrimaryButtonText = string.IsNullOrWhiteSpace(args.SubmitText)
? LocaleManager.Instance[LocaleKeys.InputDialogOk]
: args.SubmitText;
contentDialog.IsPrimaryButtonEnabled = content._checkLength(content.Message.Length);
contentDialog.SecondaryButtonText = string.Empty;
contentDialog.CloseButtonText = LocaleManager.Instance[LocaleKeys.InputDialogCancel];
contentDialog.CloseButtonText = LocaleManager.Instance[LocaleKeys.Common_Buttons_Cancel];
contentDialog.Content = content;
void Handler(ContentDialog sender, ContentDialogClosedEventArgs eventArgs)
@@ -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();
}
}
}
}
+177 -118
View File
@@ -3,8 +3,10 @@ using Avalonia.Collections;
using Avalonia.Media.Imaging;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using Path = System.IO.Path;
using Ryujinx.Ava.Common.Locale;
using Ryujinx.Ava.Common.Models.Amiibo;
using Ryujinx.Ava.Systems.Configuration;
using Ryujinx.Ava.UI.Helpers;
using Ryujinx.Ava.UI.Windows;
using Ryujinx.Ava.Utilities;
@@ -20,31 +22,39 @@ using System.Linq;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace Ryujinx.Ava.UI.ViewModels
{
public partial class AmiiboWindowViewModel : BaseModel, IDisposable
{
// ReSharper disable once InconsistentNaming
private static bool _cachedUseRandomUuid;
private const string DefaultJson = "{ \"amiibo\": [] }";
private const float AmiiboImageSize = 350f;
private readonly string _amiiboJsonPath;
private readonly byte[] _amiiboLogoBytes;
private readonly HttpClient _httpClient;
private readonly AmiiboWindow _owner;
private List<AmiiboApi> _amiiboList;
private AvaloniaList<AmiiboApi> _amiibos;
private ObservableCollection<string> _amiiboSeries;
public enum AmiiboSortField
{
Name,
}
private int _amiiboSelectedIndex;
private int _seriesSelectedIndex;
private bool _showAllAmiibo;
// ReSharper disable once InconsistentNaming
private static bool _cachedUseRandomUuid;
public bool IsSortedByName => _sortField == AmiiboSortField.Name;
private const string DefaultJson = "{ \"amiibo\": [] }";
private const float AmiiboImageSize = 350f;
public string TitleId { get; set; }
public string LastScannedAmiiboId { get; set; }
public string SortingFieldName => LocaleManager.Instance[LocaleKeys.Common_Sort_NameLabel];
private readonly string _amiiboJsonPath;
private readonly byte[] _amiiboLogoBytes;
private readonly HttpClient _httpClient;
private readonly AmiiboWindow _owner;
private List<AmiiboApi> _amiiboList;
private AvaloniaList<AmiiboApi> _amiibos;
private ObservableCollection<string> _amiiboSeries;
private CancellationTokenSource _imageCts = new();
private static readonly AmiiboJsonSerializerContext _serializerContext = new(JsonHelper.GetDefaultSerializerOptions());
public AmiiboWindowViewModel(AmiiboWindow owner, string lastScannedAmiiboId, string titleId)
@@ -73,9 +83,6 @@ namespace Ryujinx.Ava.UI.ViewModels
public AmiiboWindowViewModel() { }
public string TitleId { get; set; }
public string LastScannedAmiiboId { get; set; }
public UserResult Response { get; private set; }
public bool UseRandomUuid
@@ -151,6 +158,42 @@ namespace Ryujinx.Ava.UI.ViewModels
}
}
public bool PauseEmulationWhileScanningAmiibo
{
get => ConfigurationState.Instance.UI.PauseEmulationWhileScanningAmiibo.Value;
set
{
ConfigurationState.Instance.UI.PauseEmulationWhileScanningAmiibo.Value = value;
ConfigurationState.Instance.ToFileFormat().SaveConfig(Program.ConfigurationPath);
OnPropertyChanged();
}
}
private string _searchText = string.Empty;
private bool _sortingAscending = true;
public string SearchText
{
get => _searchText;
set
{
_searchText = value;
FilterAmiibo();
OnPropertyChanged();
}
}
public bool SortingAscending
{
get => _sortingAscending;
set
{
_sortingAscending = value;
FilterAmiibo();
OnPropertyChanged();
}
}
[ObservableProperty]
public partial Bitmap AmiiboImage { get; set; }
@@ -166,14 +209,14 @@ namespace Ryujinx.Ava.UI.ViewModels
{
_owner.ScannedAmiibo = AmiiboList[AmiiboSelectedIndex];
_owner.IsScanned = true;
_owner.Close();
}
}
public void Cancel()
{
_owner.IsScanned = false;
_owner.Close();
}
public void Dispose()
@@ -315,42 +358,46 @@ namespace Ryujinx.Ava.UI.ViewModels
private void ParseAmiiboData()
{
_amiiboSeries.Clear();
_amiibos.Clear();
for (int i = 0; i < _amiiboList.Count; i++)
foreach (AmiiboApi amiibo in _amiiboList)
{
if (!_amiiboSeries.Contains(_amiiboList[i].AmiiboSeries))
if (!_amiiboSeries.Contains(amiibo.AmiiboSeries))
{
if (!ShowAllAmiibo)
if (_showAllAmiibo)
{
foreach (AmiiboApiGamesSwitch game in _amiiboList[i].GamesSwitch)
{
if (game != null)
{
if (game.GameId.Contains(TitleId))
{
AmiiboSeries.Add(_amiiboList[i].AmiiboSeries);
break;
}
}
}
_amiiboSeries.Add(amiibo.AmiiboSeries);
}
else
{
AmiiboSeries.Add(_amiiboList[i].AmiiboSeries);
bool hasCompatible = amiibo.GamesSwitch.Any(game =>
game != null && game.GameId.Contains(TitleId));
if (hasCompatible)
{
_amiiboSeries.Add(amiibo.AmiiboSeries);
}
}
}
}
if (LastScannedAmiiboId != string.Empty)
if (_showAllAmiibo)
{
SeriesSelectedIndex = -1;
}
else if (LastScannedAmiiboId != string.Empty)
{
SelectLastScannedAmiibo();
}
else
else if (_amiiboSeries.Count > 0)
{
SeriesSelectedIndex = 0;
}
else
{
SeriesSelectedIndex = -1;
}
FilterAmiibo();
}
private void SelectLastScannedAmiibo()
@@ -360,104 +407,109 @@ namespace Ryujinx.Ava.UI.ViewModels
SeriesSelectedIndex = AmiiboSeries.IndexOf(scanned.AmiiboSeries);
AmiiboSelectedIndex = AmiiboList.IndexOf(scanned);
}
private void FilterAmiibo()
{
_amiibos.Clear();
List<AmiiboApi> amiiboSortedList;
IEnumerable<AmiiboApi> query = _amiiboList.AsEnumerable();
// If no series selected, just display all available amiibos
if (_seriesSelectedIndex < 0)
if (_seriesSelectedIndex >= 0 && _seriesSelectedIndex < _amiiboSeries.Count)
{
amiiboSortedList = _amiiboList
.OrderBy(amiibo => amiibo.AmiiboSeries)
.ThenBy(x => x.Name)
.ToList();
}
else
{
amiiboSortedList = _amiiboList
.Where(amiibo => amiibo.AmiiboSeries == _amiiboSeries[SeriesSelectedIndex])
.OrderBy(amiibo => amiibo.Name).ToList();
string selectedSeries = _amiiboSeries[_seriesSelectedIndex];
query = query.Where(amiibo => amiibo.AmiiboSeries == selectedSeries);
}
for (int i = 0; i < amiiboSortedList.Count; i++)
if (!string.IsNullOrWhiteSpace(_searchText))
{
if (!_amiibos.Contains(amiiboSortedList[i]))
query = query.Where(amiibo =>
amiibo.Name.Contains(_searchText, StringComparison.OrdinalIgnoreCase));
}
if (!_showAllAmiibo)
{
query = query.Where(amiibo =>
amiibo.GamesSwitch.Any(game => game != null && game.GameId.Contains(TitleId)));
}
query = _sortingAscending
? query.OrderBy(amiibo => amiibo.Name)
: query.OrderByDescending(amiibo => amiibo.Name);
_amiibos.AddRange(query);
int restoredIndex = -1;
for (int i = 0; i < _amiibos.Count; i++)
{
if (_amiibos[i].GetId() == LastScannedAmiiboId)
{
if (!_showAllAmiibo)
{
foreach (AmiiboApiGamesSwitch game in amiiboSortedList[i].GamesSwitch)
{
if (game != null)
{
if (game.GameId.Contains(TitleId))
{
_amiibos.Add(amiiboSortedList[i]);
break;
}
}
}
}
else
{
_amiibos.Add(amiiboSortedList[i]);
}
restoredIndex = i;
break;
}
}
AmiiboSelectedIndex = 0;
AmiiboSelectedIndex = restoredIndex != -1
? restoredIndex
: (_amiibos.Count > 0 ? 0 : -1);
SetAmiiboDetails();
}
private AmiiboSortField _sortField = AmiiboSortField.Name;
public AmiiboSortField SortingField
{
get => _sortField;
set
{
_sortField = value;
FilterAmiibo();
OnPropertyChanged(nameof(SortingFieldName));
OnPropertyChanged(nameof(IsSortedByName));
}
}
private void SetAmiiboDetails()
{
ResetAmiiboPreview();
Usage = string.Empty;
if (_amiiboSelectedIndex < 0 || _amiibos.Count < 1)
{
return;
}
AmiiboApi selected = _amiibos[_amiiboSelectedIndex];
string imageUrl = _amiiboList.FirstOrDefault(amiibo => amiibo.Equals(selected)).Image;
string imageUrl = selected.Image;
StringBuilder usageStringBuilder = new();
bool writable = false;
for (int i = 0; i < _amiiboList.Count; i++)
foreach (AmiiboApiGamesSwitch game in selected.GamesSwitch)
{
if (_amiiboList[i].Equals(selected))
if (game != null && game.GameId.Contains(TitleId))
{
bool writable = false;
foreach (AmiiboApiGamesSwitch item in _amiiboList[i].GamesSwitch)
foreach (AmiiboApiUsage usageItem in game.AmiiboUsage)
{
if (item.GameId.Contains(TitleId))
{
foreach (AmiiboApiUsage usageItem in item.AmiiboUsage)
{
usageStringBuilder.Append($"{Environment.NewLine}- {usageItem.Usage.Replace("/", Environment.NewLine + "-")}");
writable = usageItem.Write;
}
}
usageStringBuilder.Append($"{Environment.NewLine}- {usageItem.Usage.Replace("/", Environment.NewLine + "-")}");
if (usageItem.Write)
writable = true;
}
if (usageStringBuilder.Length == 0)
{
usageStringBuilder.Append($"{LocaleManager.Instance[LocaleKeys.Unknown]}.");
}
Usage = $"{LocaleManager.Instance[LocaleKeys.Usage]} {(writable ? $" ({LocaleManager.Instance[LocaleKeys.Writable]})" : string.Empty)} : {usageStringBuilder}";
}
}
string usageLabel = writable
? LocaleManager.Instance[LocaleKeys.Amiibo_WritableLabel]
: LocaleManager.Instance[LocaleKeys.Amiibo_UsageLabel];
if (usageStringBuilder.Length == 0)
{
usageStringBuilder.Append(Environment.NewLine + Environment.NewLine + LocaleManager.Instance[LocaleKeys.Amiibo_UnknownLabel]);
}
else
{
usageStringBuilder.Replace(Environment.NewLine + "-", Environment.NewLine + Environment.NewLine + "-");
}
Usage = usageLabel + usageStringBuilder.ToString();
_ = UpdateAmiiboPreview(imageUrl);
}
@@ -510,8 +562,8 @@ namespace Ryujinx.Ava.UI.ViewModels
Logger.Error?.Print(LogClass.Application, $"Failed to request amiibo data: {exception}");
}
await ContentDialogHelper.CreateInfoDialog(LocaleManager.Instance[LocaleKeys.DialogAmiiboApiTitle],
LocaleManager.Instance[LocaleKeys.DialogAmiiboApiFailFetchMessage],
await ContentDialogHelper.CreateInfoDialog(LocaleManager.Instance[LocaleKeys.Dialog_Amiibo_APITitle],
LocaleManager.Instance[LocaleKeys.Dialog_Amiibo_APIFailFetchMessage],
LocaleManager.Instance[LocaleKeys.InputDialogOk],
string.Empty,
LocaleManager.Instance[LocaleKeys.RyujinxInfo]);
@@ -526,26 +578,33 @@ namespace Ryujinx.Ava.UI.ViewModels
private async Task UpdateAmiiboPreview(string imageUrl)
{
HttpResponseMessage response = await _httpClient.GetAsync(imageUrl);
_imageCts.Cancel();
_imageCts = new CancellationTokenSource();
if (response.IsSuccessStatusCode)
try
{
byte[] amiiboPreviewBytes = await response.Content.ReadAsByteArrayAsync();
using MemoryStream memoryStream = new(amiiboPreviewBytes);
using CancellationTokenSource linkedCts = CancellationTokenSource.CreateLinkedTokenSource(_imageCts.Token);
Bitmap bitmap = new(memoryStream);
HttpResponseMessage response = await _httpClient.GetAsync(imageUrl, linkedCts.Token);
double ratio = Math.Min(AmiiboImageSize / bitmap.Size.Width,
AmiiboImageSize / bitmap.Size.Height);
if (response.IsSuccessStatusCode)
{
byte[] bytes = await response.Content.ReadAsByteArrayAsync(linkedCts.Token);
using MemoryStream ms = new(bytes);
int resizeHeight = (int)(bitmap.Size.Height * ratio);
int resizeWidth = (int)(bitmap.Size.Width * ratio);
Bitmap bitmap = new(ms);
AmiiboImage = bitmap.CreateScaledBitmap(new PixelSize(resizeWidth, resizeHeight));
double ratio = Math.Min(AmiiboImageSize / bitmap.Size.Width, AmiiboImageSize / bitmap.Size.Height);
int newWidth = (int)(bitmap.Size.Width * ratio);
int newHeight = (int)(bitmap.Size.Height * ratio);
AmiiboImage = bitmap.CreateScaledBitmap(new PixelSize(newWidth, newHeight));
}
}
else
catch (OperationCanceledException) { }
catch (Exception ex)
{
Logger.Error?.Print(LogClass.Application, $"Failed to get amiibo preview. Response status code: {response.StatusCode}");
Logger.Error?.Print(LogClass.Application, $"Failed to load amiibo preview: {ex}");
}
}
@@ -560,8 +619,8 @@ namespace Ryujinx.Ava.UI.ViewModels
private static async Task ShowInfoDialog()
{
await ContentDialogHelper.CreateInfoDialog(LocaleManager.Instance[LocaleKeys.DialogAmiiboApiTitle],
LocaleManager.Instance[LocaleKeys.DialogAmiiboApiConnectErrorMessage],
await ContentDialogHelper.CreateInfoDialog(LocaleManager.Instance[LocaleKeys.Dialog_Amiibo_APITitle],
LocaleManager.Instance[LocaleKeys.Dialog_Amiibo_APIConnectErrorMessage],
LocaleManager.Instance[LocaleKeys.InputDialogOk],
string.Empty,
LocaleManager.Instance[LocaleKeys.RyujinxInfo]);
File diff suppressed because it is too large Load Diff
+120 -48
View File
@@ -160,6 +160,10 @@ namespace Ryujinx.Ava.UI.ViewModels
[ObservableProperty] public partial bool UpdateAvailable { get; set; }
[ObservableProperty] public partial string SkylanderButtonHeader { get; set; } = string.Empty;
[ObservableProperty] public partial bool SkylanderButtonEnabled { get; set; }
public static AsyncRelayCommand UpdateCommand { get; } = Commands.Create(async () =>
{
if (Updater.CanUpdate(true))
@@ -204,6 +208,8 @@ namespace Ryujinx.Ava.UI.ViewModels
internal AppHost AppHost { get; set; }
public RelayCommand ToggleSkylanderCommand { get; }
public MainWindowViewModel()
{
Applications = [];
@@ -233,6 +239,8 @@ namespace Ryujinx.Ava.UI.ViewModels
Volume = ConfigurationState.Instance.System.AudioVolume;
CustomVSyncInterval = ConfigurationState.Instance.Graphics.CustomVSyncInterval.Value;
}
ToggleSkylanderCommand = new RelayCommand(ExecuteToggleSkylander);
}
~MainWindowViewModel()
@@ -376,6 +384,50 @@ namespace Ryujinx.Ava.UI.ViewModels
public bool CanScanAmiiboBinaries => AmiiboBinReader.HasAmiiboKeyFile;
[RelayCommand]
private void UpdateSkylanderButton()
{
SkylanderButtonHeader = HasSkylander
? LocaleManager.Instance[LocaleKeys.MenuBar_Actions_RemoveSkylanderButton]
: LocaleManager.Instance[LocaleKeys.MenuBar_Actions_ScanSkylanderButton];
SkylanderButtonEnabled = HasSkylander || IsSkylanderRequested;
OnPropertyChanged(nameof(SkylanderButtonHeader));
OnPropertyChanged(nameof(SkylanderButtonEnabled));
Dispatcher.UIThread.Post(() =>
{
OnPropertyChanged(nameof(SkylanderButtonHeader));
}, DispatcherPriority.Render);
}
private void UpdateSkylanderState()
{
if (AppHost?.Device?.System == null) return;
IsSkylanderRequested = AppHost.Device.System.SearchingForSkylander(out _);
HasSkylander = AppHost.Device.System.HasSkylander(out _);
UpdateSkylanderButton();
}
private async void ExecuteToggleSkylander()
{
if (!IsGameRunning) return;
if (HasSkylander)
{
await RemoveSkylander();
}
else
{
await ScanSkylander();
}
UpdateSkylanderState();
}
public bool IsSkylanderRequested
{
get => field && _isGameRunning;
@@ -384,6 +436,7 @@ namespace Ryujinx.Ava.UI.ViewModels
field = value;
OnPropertyChanged();
UpdateSkylanderButton();
}
}
@@ -395,17 +448,7 @@ namespace Ryujinx.Ava.UI.ViewModels
field = value;
OnPropertyChanged();
}
}
public bool ShowSkylanderActions
{
get => field && _isGameRunning;
set
{
field = value;
OnPropertyChanged();
UpdateSkylanderButton();
}
}
@@ -496,6 +539,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 &&
@@ -790,7 +835,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],
@@ -1258,7 +1303,13 @@ namespace Ryujinx.Ava.UI.ViewModels
break;
case ShaderCacheLoadingState shaderCacheState:
CacheLoadStatus = $"{current} / {total}";
Splash = $"\"{SplashTextHelper.GetSplash()}\"";
string splash = SplashTextHelper.GetSplash();
if (!splash.IsNullOrEmpty())
{
Splash = $"\"{splash}\"";
}
switch (shaderCacheState)
{
case ShaderCacheLoadingState.Start:
@@ -1488,7 +1539,7 @@ namespace Ryujinx.Ava.UI.ViewModels
Title = LocaleManager.Instance[LocaleKeys.Dialog_Firmware_InstallFromFileFilePickerTitle],
FileTypeFilter = new List<FilePickerFileType>
{
new(LocaleManager.Instance[LocaleKeys.AllSupportedFormats])
new(LocaleManager.Instance[LocaleKeys.Common_FilePicker_AllSupportedFormats])
{
Patterns = ["*.xci", "*.zip"],
AppleUniformTypeIdentifiers = ["com.ryujinx.xci", "public.zip-archive"],
@@ -1672,7 +1723,7 @@ namespace Ryujinx.Ava.UI.ViewModels
Title = LocaleManager.Instance[LocaleKeys.Dialog_FileMenu_LoadApplicationFromFileFilePickerTitle],
FileTypeFilter = new List<FilePickerFileType>
{
new(LocaleManager.Instance[LocaleKeys.AllSupportedFormats])
new(LocaleManager.Instance[LocaleKeys.Common_FilePicker_AllSupportedFormats])
{
Patterns = ["*.nsp", "*.xci", "*.nca", "*.nro", "*.nso"],
AppleUniformTypeIdentifiers =
@@ -1759,7 +1810,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
{
@@ -1935,13 +1986,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;
@@ -2002,40 +2053,58 @@ namespace Ryujinx.Ava.UI.ViewModels
}
}
public async Task OpenBinFile()
public async Task ScanAmiiboFromBin()
{
if (AppHost.Device.System.SearchingForAmiibo(out _) && IsGameRunning)
{
Optional<IStorageFile> result = await StorageProvider.OpenSingleFilePickerAsync(
new FilePickerOpenOptions
{
Title = LocaleManager.Instance[LocaleKeys.OpenFileDialogTitle],
FileTypeFilter = new List<FilePickerFileType>
{
new(LocaleManager.Instance[LocaleKeys.AllSupportedFormats])
{
Patterns = ["*.bin"],
}
}
});
bool shouldPause = ConfigurationState.Instance.UI.PauseEmulationWhileScanningAmiibo.Value && IsGameRunning;
if (result.HasValue)
if (shouldPause && AppHost?.Device?.System != null)
{
AppHost?.Pause();
}
try
{
if (AppHost.Device.System.SearchingForAmiibo(out _) && IsGameRunning)
{
AppHost.Device.System.ScanAmiiboFromBin(result.Value.Path.LocalPath);
Optional<IStorageFile> result = await StorageProvider.OpenSingleFilePickerAsync(
new FilePickerOpenOptions
{
Title = LocaleManager.Instance[LocaleKeys.Dialog_Amiibo_ScanAmiiboFromBinFilePickerTitle],
FileTypeFilter = new List<FilePickerFileType>
{
new(LocaleManager.Instance[LocaleKeys.Common_FilePicker_AllSupportedFormats])
{
Patterns = ["*.bin"],
}
}
});
if (result.HasValue)
{
AppHost.Device.System.ScanAmiiboFromBin(result.Value.Path.LocalPath);
}
}
}
finally
{
if (shouldPause && AppHost?.Device?.System != null)
{
AppHost?.Resume();
}
}
}
public async Task OpenSkylanderWindow()
[RelayCommand]
public async Task ScanSkylander()
{
if (AppHost.Device.System.SearchingForSkylander(out int deviceId))
{
Optional<IStorageFile> result = await StorageProvider.OpenSingleFilePickerAsync(
new FilePickerOpenOptions
{
Title = LocaleManager.Instance[LocaleKeys.OpenFileDialogTitle],
Title = LocaleManager.Instance[LocaleKeys.Dialog_Skylanders_ScanSkylanderFilePickerTitle],
FileTypeFilter = new List<FilePickerFileType>
{
new(LocaleManager.Instance[LocaleKeys.AllSupportedFormats])
new(LocaleManager.Instance[LocaleKeys.Common_FilePicker_AllSupportedFormats])
{
Patterns = ["*.sky", "*.bin", "*.dmp", "*.dump"],
},
@@ -2058,12 +2127,15 @@ namespace Ryujinx.Ava.UI.ViewModels
AppHost.Device.System.ScanSkylander(deviceId, data);
}
}
UpdateSkylanderButton();
}
}
[RelayCommand]
public async Task RemoveSkylander()
{
AppHost.Device.System.RemoveSkylander();
UpdateSkylanderButton();
}
public void ReloadRenderDocApi()
@@ -2153,7 +2225,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;
@@ -2317,7 +2389,7 @@ namespace Ryujinx.Ava.UI.ViewModels
if (notifyUser != null)
{
await ContentDialogHelper.CreateWarningDialog(
LocaleManager.Instance[LocaleKeys.TrimXCIFileFailedPrimaryText],
LocaleManager.Instance[LocaleKeys.Dialog_XCITrimmer_TrimFailedMessage],
notifyUser
);
}
@@ -2343,18 +2415,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)
@@ -2364,8 +2436,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;
@@ -154,7 +154,7 @@ namespace Ryujinx.Ava.UI.ViewModels
AllowMultiple = true,
FileTypeFilter = new List<FilePickerFileType>
{
new(LocaleManager.Instance[LocaleKeys.AllSupportedFormats])
new(LocaleManager.Instance[LocaleKeys.Common_FilePicker_AllSupportedFormats])
{
Patterns = ["*.nsp"],
AppleUniformTypeIdentifiers = ["com.ryujinx.nsp"],
@@ -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>
@@ -190,7 +190,7 @@
MinWidth="90"
Margin="5"
Click="Close">
<TextBlock Text="{ext:Locale InputDialogCancel}" />
<TextBlock Text="{ext:Locale Common_Buttons_Cancel}" />
</Button>
</StackPanel>
</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>
@@ -158,7 +158,7 @@
MinWidth="90"
Margin="5"
Click="Close">
<TextBlock Text="{ext:Locale InputDialogCancel}" />
<TextBlock Text="{ext:Locale Common_Buttons_Cancel}" />
</Button>
</StackPanel>
</Panel>

Some files were not shown because too many files have changed in this diff Show More