Compare commits

..

8 Commits

Author SHA1 Message Date
_Neo_
905a41a643 UI: Actions Menu (Part 2 of 4) → XCI Trimmer (#146)
Ayyyy, welcome to the UI: Actions Menu → XCI Trimmer PR!

Let's keep it up!

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

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

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

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

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

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

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

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

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

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

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

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

### Nerd Zone

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

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

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

New configl types include:

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

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

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

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

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

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

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

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

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

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

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

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

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

---

### Release Notes

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

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

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

##### What's Changed

##### Enhancements

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

##### Fixes

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

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

</details>

---

### Configuration

📅 **Schedule**: (UTC)

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

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

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

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

---

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

---

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

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

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

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

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

Tracked things:

Main menu
Last played course

Examples:

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

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

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

Co-authored-by: Awesomeangotti <143439211+Awesomeangotti@users.noreply.github.com>
Reviewed-on: https://git.ryujinx.app/projects/Ryubing/pulls/130
2026-06-25 20:59:56 +00:00
_Neo_
8b1b015572 macOS: Fix Hypervisor BadArgument Launch Crash (#89)
Ayyyy, welcome to the **macOS: Fix Hypervisor BadArgument Launch Crash PR!**

We are so so back my friends.

This PR fixes the macOS Hypervisor `BadArgument` launch crash, while also sneaking in some improvements to help _potentially_ mitigate those lovely 0 FPS deadlock on macOS in certain games with Vulkan + Hypervisor enabled (there will be a separate PR in the future to address this).

The PR is slightly different in terms of the PR messages that I write, but I hope that it'll provide the necessary context to those unaware of the reasoning/causes of the issue.

Also, this is a bit of a read, so grab some snacks, pour a drink, get comfortable, and enjoy some…

### STORY TIME!!!

_Before we proceed forward, we must properly understand what Hypervisor is._

_Hypervisor refers to using Apple's built-in virtualization technology, the Hypervisor Framework, to accelerate CPU emulation._

_There are two main ways to do this:_

_1. **Interpreter** – executes instructions one by one (very slow)._
_2. **JIT (Just-In-Time)** recompilation – translates Switch CPU instructions into native code (much faster)._

_On Apple Silicon Macs (M1, M2, M3, etc.), the guest CPU and host CPU are both ARM64-based. As the Nintendo Switch itself is ARM64, The Hypervisor Framework can allow Ryujinx to execute large amounts of guest code more directly, reducing emulation overhead. The architectural similarity makes hardware-assisted execution practical. On Intel Macs, there isn't the same direct ARM-to-ARM advantage, so the benefits are much more limited or unavailable._

_When Hypervisor mode is available and working correctly, it can provide higher frames, reduced CPU bottlenecks, faster shader compilation in certain instances, smoother gameplay, and lower CPU usage._

_The improvement depends heavily on the game. CPU-heavy titles tend to benefit the most._

_There are downsides though. Sometimes, certain games may have compatibilities issues, certain feature may not work exactly the same, bugs in the actual Hypervisor implementation can cause crashes or issues, and performance gain vary significantly on a game-to-game basis._

_In terms of a simple analogy:_

_**Without Hypervisor:** Ryujinx acts like a translator who rewrites every sentence before speaking it._
_**With Hypervisor:** Ryujinx can let the Mac's CPU understand and execute much more of the Switch's code directly, so less translation work is needed._

_Anyway, now that we have the background, onto the story!_

Once upon a time, around ~December 2025 / January 2026, users — and yes, even devs (though let’s be honest, macOS devs are basically cryptids at this point)

_(Authors note: Cryptids - animals or other beings whose present existence is disputed or unsubstantiated by science)_

Started noticing inconsistent behaviour with the macOS Hypervisor. In other words, Hypervisor began showing inconsistent launch behaviour for users who've upgraded to the then current macOS versions (15.7.3 / 26.2). This behaviour continued as macOS got updated, and is still present on the latest version (Tahoe 26.5.1).

Initially, Hypervisor was mostly crashing with an `Unexpected result "Denied"`. The likely cause is two fold:
1. macOS security permissions changed during that update.
2. Hypervisor framework got updated, but nothing what communicated to the devs.

(It is likely to be the former, not the later. This is the main theory, at least.)

However, this crash later evolved into (because of course it did) into `Unexpected result "BadArgument"`.

We'll come back to this in a bit.

For users who wished to run games with Hypervisor enabled, they had a simple solution - build Ryujinx locally. Yes, this is an effective solution in its own right, as that does bypass certain macOS signing and certain .app bundle-related constraints, as well as certain security permissions.

However, this doesn't explain why:
* Official server builds were the only builds that were affected
* Users on _older macOS_ (below 15.7.3 / 26.2) could run games with Hypervisor enabled as if nothing had ever gone wrong in their lives.
* Users on _newer macOS_ versions _could still run certain older_ Ryujinx builds as intended — specifically, the last or close-to-last version they had lying around in their Downloads folder.
* Users on macOS above 15.7.3 / 26.2 could still run games with Hypervisor enabled, on builds downloaded from the server, for whatever reason.

So now we’re here:
* Same macOS versions → Different outcomes
* Different Ryujinx builds → Different behavior
* `Denied` → Evolved into `BadArgument`
* No consistent reproduction steps

And so, after attempting to fix this for quite some time, I've determined it to be unfixable. Ryujinx should drop macOS support because development has been held back far too long, and there is nothing we can possibly do. Every possible solution was tested and the results were null.

So, it is with great sadness that, here and now, I proclaim that Ryujinx will terminate further macOS support.

Bye-bye, Ryujinx on macOS! You had a great run, and you'll always be remembered. Thank you for bringing so much great memories to us, macOS users!

...

...

...

...

...

HAHA - NOT SO FAST!

(_Please tell me I got you there_)

Not all hope is lost - there is light at the end of the tunnel!

**The key insight is this:** `BadArgument` is not just “`Denied` but newer and worse”. It’s something else entirely.

* `Denied` → macOS refuses the request at the security boundary. Nothing even starts.
* `BadArgument` → macOS accepts the request, but the data being passed is now invalid under newer constraints.

So instead of:

_“You shall not pass.”_

We now have:

_“You may pass… but what is THAT?”_

And this brings us to this PR!

### The PR

Users on macOS were experiencing two major issues when Hypervisor was enabled:

**1. Hard Crash – "Unexpected result `BadArgument`"**

The most frequent stack trace pointed to:

```
System.Exception: Unexpected result "BadArgument".
   at Ryujinx.Cpu.AppleHv.HvResultExtensions.ThrowOnError(...)
   at Ryujinx.Cpu.AppleHv.HvExecutionContextVcpu.GetX(Int32 index)
   at Ryujinx.HLE.HOS.Kernel.Threading.KThread.GetCurrentContext()
   at Ryujinx.HLE.HOS.Kernel.Threading.KThread.GetThreadContext3(...)
```
This crash occurred very early during game initialization, most often inside supervisor calls such as `GetThreadContext3`.

**2. Intermittent Permanent 0 FPS Freezes with Vulkan (unrelated to `BadArgument`, but somewhat addressed in this PR)**
The game would run normally for a while, then drop to 0 FPS permanently. Disabling Hypervisor eliminated the freezes but performance was at times inconsistent.

### The Explanation

`HvResult.BadArgument` is returned by Apple's Hypervisor framework (`hv_vcpu_get_reg`, `hv_vcpu_get_sys_reg`, `hv_vcpu_get_simd_fp_reg`, etc.) when it determines that the requested operation cannot be performed in the current vCPU state.

**Common reasons why this happens:**
* Timing races - the guest tries to read registers before the vCPU has fully synchronized its internal state after context switches, exceptions, or vCPU pool reuse.
* Stricter validation in newer macOS versions.
* vCPU state inconsistencies during early boot or heavy syscall activity.

Because the original implementation called `.ThrowOnError()` unconditionally on every Hypervisor call, even a single transient `BadArgument` would immediately terminate execution. This was the direct cause of the crash.

The 0 FPS issue was a separate but related symptom: when Hypervisor is enabled, the guest CPU runs significantly faster than MoltenVK's presentation queue expects, leading to command buffer starvation and queue deadlock.

### The Changes

* **Implemented: Complete Shadow Register Cache (`HvExecutionContextVcpu.cs`)**
    * Added full in-memory shadow copies of every relevant register:
        * General-purpose registers: `private readonly ulong[] _x = new ulong[32]`;
        * Vector/SIMD registers: private readonly `V128[] _v = new V128[32]`;
        * System/special registers: `_pc`, `_elrEl1`, `_esrEl1`, `_tpidrEl0`, `_tpidrroEl0`, `_fpcr`, `_fpsr`, and `_pstateRaw` (stored as `ulong` internally to avoid type conversion issues).
    * **Reasoning:** Returning zero or garbage values on failure could corrupt game state. When the Hypervisor returns `BadArgument`, we now have a safe, previously-valid value to return instead of crashing. A shadow cache gives us that last known good value. The cache is updated on every successful read, so correctness is preserved in normal operation. This is the central fix for the `BadArgument` crash.

* **Defensive BadArgument Handling on Every Register Access Path**
    * Updated every read operation (`GetX`, `GetV`, `Pstate`, cached getters, etc.) to explicitly check the return value, with the same pattern applied across all accessors.
    * **Reasoning:** This directly eliminates the crash while preserving the original error-throwing behavior for genuine failures. Returning cached data is far safer than injecting zero or garbage values that could corrupt guest state:

```
HvResult res = HvApi.hv_vcpu_get_...(..., out value);
if (res == HvResult.BadArgument)
{
    _fallbackCount++;
    LogHvWarning("...");           // rate-limited
    return cachedValue;            // safe fallback
}
res.ThrowOnError();                // only throw on real errors
return cachedValue = value;        // success path + cache update
```

* **AggressiveMode Global Option** (NOT IMPLEMENTED IN THIS PR, BUT A FUTURE PR WILL ADD THIS FULLY)
    * Added:
`public static bool AggressiveMode { get; set; } = false;`
    * When true/enabled, all warning logs in LogHvWarning() are suppressed.
    * **Reasoning:** Diagnostic logging has overhead. In the future, users will be able to disable/enable this when required. This will also be useful for any dev working on macOS Hypevisor (aka - me).

* **Hot-Path Optimizations**
    * Changed index checks from `if (index < 0 || index > 30)` to `if ((uint)index > 30)` in `GetX`, `SetX`, `GetV`, and `SetV`.
    * Separated cache initialization into a dedicated `InitializeCacheDefaults()` method called by constructor and `Reset()`.
    * **Reasoning:** `GetX` is called millions of times per second. Reducing branches improves CPU branch prediction and overall throughput. The clean initialization pattern ensures reliable behavior when vCPUs are reused from the pool.

* **Improved Logging, Diagnostics & Monitoring**
    * Rate-limited warnings with a cooldown timer (`WarningCooldownTicks = 1_000_000_000 ≈ 1 second`).
    * Clear, descriptive messages (e.g. "PAC failure on SP_EL0", "PAC failure on X15").
    * Added `public long GetFallbackCount()` for runtime monitoring.
    * **Reasoning:** We need visibility into fallback frequency during development and testing, but we must not spam the log for end users during normal gameplay.

_Quick side note:_

_The logging mentions "PAC failure" rather than HV `BadArgument`, and this is a shorthand I've added right now for myself.
Technically, we are catching HvResult.BadArgument. It's called "PAC failure" in logs because PAC is a frequent trigger, especially with games like TOTK that use heavy mods (AKA TOTK Optimiser)._

_Right now, I'm calling it a PAC failure, but will later rename to `BadArgument` as I progress further on a certain issue that's been sitting in our GitHub for some time._

_Back to the main stuff:_

* **GPU Synchronization & _Potential_ 0 FPS Mitigation (`HvExecutionContext.cs`) (PR FOLLOW UP IN THE FUTURE)**
    * Added adaptive `TryGpuSync()` called periodically in the main `Execute()` loop (every 10–12 execution steps).
    * Added strategic `Thread.Yield()` after `SvcAarch64` handling and in sync points.
    * Tuned counters based on real gameplay testing (% 10 for light sync, % 6 for stronger sync).
    * **Reasoning:** Hypervisor accelerates the guest CPU significantly. Without periodic yielding/flushing, MoltenVK's presentation queue can starve, causing permanent 0 FPS. The adaptive approach gives aims to fix this by performing a "resync". This has been tested and is showing positive progress, but, as mentioned earlier, will continue in a future PR.

These are all of the changes in this PR.

It's been quite a ride getting Hypervisor back up and running, but I loved every second of it.

If there is anything that is unclear in this PR, please let me know and I'll provide more details and update the description above.

Here is a small FAQ section to answer some more questions. These answers come from my own personal testing and behaviour that I observed.

### FAQ
**Q:** Why can some users play games with Hypervisor enabled and others can't on newer macOS versions?
**A:** This needs a bit of clarification.
You _can_ still run games with Hypervisor enabled on latest macOS - just not all games. Games such as Pokémon Legends: Z-A, The Legend of Zelda: Tears of the Kingdom, The Legend of Zelda: Breath of the Wild, can all _run_ and will _boot_ with Hypervisor enabled.

However, some other games, like Beyblade X Xone, Beyblade X Evobattle, Tales of Xillia Remastered, Tales of Berserk Remastered, etc, will _crash_ on boot with `BadArgument` error.

So it's not that Hypervisor works for one user and doesn't work for the other.

_It's that the game doesn't cause Hypervisor to crash._

Games differ in how aggressively they read registers early in boot and how much they rely on modded/JIT code. Games with heavy early supervisor calls and/or mods that manipulate pointers/PAC (pointer authentication errors) are far more likely to hit BadArgument.

(PAC is the reason why TOTK crashes with UltraCam when Hypervisor is disabled on macOS)

Lightweight or less modded titles often avoid the problematic code paths.
––––––
**Q:** How stable is this fix?
**A:** Updating macOS versions (Tahoe -> Tahoe) didn't break this implementation. Current data and testing all provide positive results (i.e. - no crash, hooray!)
––––––
**Q:** What about the `Denied` error?
**A:** This has more so to do with macOS permissions. Currently, Ryujinx is not being shipped in a DMG (which, by Apple conventions, is a more proper way of handling macOS app installations outside of the App Store). There is a theory that packaging Ryujinx into a DMG file will alleviate any permission issues that we currently have, and may help fix this type of error, so this PR may not even be needed (but we'll have to see how it goes).

That said, this error has not appeared in any of the more recent crash logs that I've analysed or acquired myself (from testing), leading to a possible hypothesis that the permission issues have been resolved, but the Hypervisor implementation broke on server builds due to an update to the framework. This is, however, unlikely, because, again, only builds after a particular version (i.e. - the last working version on a users system) broke.

However, DMG packaging is still on a table and something that we want to implement as soon as possible.
––––––
**Q:** So why do local builds run then?
**A:** A lot of different factors - build environment, folder location, lack of permissions associated with running .exe files, different ways of handling pure .exe files and .app bundles, notarisation, and more. There was no "scientific testing" done on each of the factors and seeing which one affects what exactly. Pushing this PR and implementing the DMG will tell.

Reviewed-on: https://git.ryujinx.app/projects/Ryubing/pulls/89
2026-06-25 20:48:05 +00:00
63 changed files with 5369 additions and 1955 deletions

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" />

View File

@@ -0,0 +1,29 @@
{
"Locales": [
{
"ID": "SearchWatermark",
"Translations": {
"ar_SA": "بحث",
"de_DE": "Suche",
"el_GR": "Αναζήτηση",
"en_US": "Search",
"es_ES": "Buscar",
"fr_FR": "Rechercher",
"he_IL": "חפש",
"it_IT": "Cerca",
"ja_JP": "検索",
"ko_KR": "찾기",
"no_NO": "Søk",
"pl_PL": "Wyszukaj",
"pt_BR": "Buscar",
"ru_RU": "Поиск",
"sv_SE": "Sök",
"th_TH": "ค้นหา",
"tr_TR": "Ara",
"uk_UA": "Пошук",
"zh_CN": "搜索",
"zh_TW": "搜尋"
}
}
]
}

View File

@@ -0,0 +1,129 @@
{
"Locales": [
{
"ID": "NameLabel",
"Translations": {
"ar_SA": "الاسم",
"de_DE": "",
"el_GR": "Όνομα",
"en_US": "Name",
"es_ES": "Nombre",
"fr_FR": "Nom",
"he_IL": "שם",
"it_IT": "Nome",
"ja_JP": "名称",
"ko_KR": "이름",
"no_NO": "Navn",
"pl_PL": "Nazwa",
"pt_BR": "Nome",
"ru_RU": "Название",
"sv_SE": "Namn",
"th_TH": "ชื่อ",
"tr_TR": "İsim",
"uk_UA": "Назва",
"zh_CN": "名称",
"zh_TW": "名稱"
}
},
{
"ID": "SavingsLabel",
"Translations": {
"ar_SA": "التوفير",
"de_DE": "Einsparung",
"el_GR": "Εξοικονόμηση",
"en_US": "Savings",
"es_ES": "Ahorro",
"fr_FR": "Économies",
"he_IL": "חיסכון",
"it_IT": "Risparmio",
"ja_JP": "節約",
"ko_KR": "절약",
"no_NO": "Besparelse",
"pl_PL": "Oszczędność",
"pt_BR": "Economia",
"ru_RU": "Экономия",
"sv_SE": "Besparing",
"th_TH": "การประหยัด",
"tr_TR": "Tasarruf",
"uk_UA": "Економія",
"zh_CN": "节省",
"zh_TW": "節省"
}
},
{
"ID": "TrimStatusLabel",
"Translations": {
"ar_SA": "",
"de_DE": "",
"el_GR": "",
"en_US": "Trim Status",
"es_ES": "Estado del recorte",
"fr_FR": "État de réduction",
"he_IL": "",
"it_IT": "Stato della riduzione",
"ja_JP": "",
"ko_KR": "",
"no_NO": "",
"pl_PL": "",
"pt_BR": "",
"ru_RU": "Статус обрезки",
"sv_SE": "",
"th_TH": "",
"tr_TR": "",
"uk_UA": "Статус обрізки",
"zh_CN": "",
"zh_TW": ""
}
},
{
"ID": "OrderAscending",
"Translations": {
"ar_SA": "تصاعدي",
"de_DE": "Aufsteigend",
"el_GR": "Αύξουσα",
"en_US": "Ascending",
"es_ES": "Ascendente",
"fr_FR": "Croissant",
"he_IL": "סדר עולה",
"it_IT": "Crescente",
"ja_JP": "昇順",
"ko_KR": "오름차순",
"no_NO": "Stigende",
"pl_PL": "Rosnąco",
"pt_BR": "Ascendente",
"ru_RU": "По Возрастанию",
"sv_SE": "Stigande",
"th_TH": "จากน้อยไปมาก",
"tr_TR": "Artan",
"uk_UA": "За зростанням",
"zh_CN": "升序",
"zh_TW": "從小到大"
}
},
{
"ID": "OrderDescending",
"Translations": {
"ar_SA": "تنازلي",
"de_DE": "Absteigend",
"el_GR": "Φθίνουσα",
"en_US": "Descending",
"es_ES": "Descendente",
"fr_FR": "Décroissant",
"he_IL": "סדר יורד",
"it_IT": "Decrescente",
"ja_JP": "降順",
"ko_KR": "내림차순",
"no_NO": "Synkende",
"pl_PL": "Malejąco",
"pt_BR": "Descendente",
"ru_RU": "По Убыванию",
"sv_SE": "Fallande",
"th_TH": "จากมากไปน้อย",
"tr_TR": "Azalan",
"uk_UA": "За спаданням",
"zh_CN": "降序",
"zh_TW": "從大到小"
}
}
]
}

View File

@@ -0,0 +1,304 @@
{
"Locales": [
{
"ID": "PrimaryMessage",
"Translations": {
"ar_SA": "",
"de_DE": "",
"el_GR": "",
"en_US": "Removes unused space from the XCI to reduce its file size.",
"es_ES": "Elimina el espacio no utilizado del XCI para reducir su tamaño.",
"fr_FR": "Supprime lespace inutilisé du XCI afin de réduire sa taille.",
"he_IL": "",
"it_IT": "Rimuove lo spazio inutilizzato dall'XCI per ridurne le dimensioni.",
"ja_JP": "",
"ko_KR": "XCI에서 사용되지 않는 공간을 제거하여 파일 크기를 줄입니다.",
"no_NO": "Fjerner ubrukt plass fra XCI-filen for å redusere filstørrelsen.",
"pl_PL": "Usuwa nieużywane miejsce z pliku XCI, aby zmniejszyć jego rozmiar.",
"pt_BR": "Remove o espaço não utilizado do XCI para reduzir seu tamanho.",
"ru_RU": "Удаляет неиспользуемое пространство из XCI, уменьшая размер файла.",
"sv_SE": "Tar bort oanvänt utrymme från XCI-filen för att minska filstorleken.",
"th_TH": "ลบพื้นที่ที่ไม่ได้ใช้งานออกจาก XCI เพื่อลดขนาดไฟล์",
"tr_TR": "",
"uk_UA": "Видаляє невикористаний простір із XCI, зменшуючи розмір файлу.",
"zh_CN": "移除 XCI 中未使用的空间以减小文件大小。",
"zh_TW": "移除 XCI 中未使用的空間以減少檔案大小。"
}
},
{
"ID": "SecondaryMessage",
"Translations": {
"ar_SA": "",
"de_DE": "",
"el_GR": "",
"en_US": "File: {0:n} MB • Game: {1:n} MB\n\nSavings: {2:n} MB",
"es_ES": "Archivo: {0:n} MB • Juego: {1:n} MB\n\nAhorro: {2:n} MB",
"fr_FR": "Fichier: {0:n} Mo • Jeu: {1:n} Mo\n\nÉconomies: {2:n} Mo",
"he_IL": "",
"it_IT": "File: {0:n} MB • Gioco: {1:n} MB\n\nRisparmio: {2:n} MB",
"ja_JP": "",
"ko_KR": "파일: {0:n} MB • 게임: {1:n} MB\n\n절약: {2:n} MB",
"no_NO": "Fil: {0:n} MB • Spill: {1:n} MB\n\nBesparelse: {2:n} MB",
"pl_PL": "Plik: {0:n} MB • Gra: {1:n} MB\n\nOszczędności: {2:n} MB",
"pt_BR": "Arquivo: {0:n} MB • Jogo: {1:n} MB\n\nEconomia: {2:n} MB",
"ru_RU": "Файл: {0:n} МБ • Игра: {1:n} МБ\n\nЭкономия: {2:n} МБ",
"sv_SE": "Fil: {0:n} MB • Spel: {1:n} MB\n\nBesparing: {2:n} MB",
"th_TH": "ไฟล์: {0:n} MB • เกม: {1:n} MB\n\nการประหยัด: {2:n} MB",
"tr_TR": "",
"uk_UA": "Файл: {0:n} МБ • Гра: {1:n} МБ\n\nЕкономія: {2:n} МБ",
"zh_CN": "文件: {0:n} MB • 游戏: {1:n} MB\n\n节省: {2:n} MB",
"zh_TW": "檔案: {0:n} MB • 遊戲: {1:n} MB\n\n節省: {2:n} MB"
}
},
{
"ID": "NoTrimNecessaryMessage",
"Translations": {
"ar_SA": "",
"de_DE": "",
"el_GR": "",
"en_US": "XCI does not require trimming. Check logs for details.",
"es_ES": "El XCI no necesita ser recortado. Verifica los logs para detalles.",
"fr_FR": "Le XCI na pas besoin dêtre réduit. Référez-vous aux journaux pour détails.",
"he_IL": "",
"it_IT": "Non è necessario ridurre la dimensione del XCI. Controlla i log per dettagli.",
"ja_JP": "",
"ko_KR": "XCI는 트리밍할 필요가 없습니다. 자세한 내용은 로그를 확인.",
"no_NO": "XCI trenger ikke å trimmes. Sjekk loggene for detaljer.",
"pl_PL": "XCI nie wymaga przycinania. Sprawdź dzienniki, aby uzyskać szczegóły.",
"pt_BR": "O XCI não precisa ser reduzido. Verifique os logs para detalhes.",
"ru_RU": "XCI не требует обрезки. Проверьте логи для подробностей.",
"sv_SE": "XCI behöver inte optimeras. Kontrollera loggen för detaljer.",
"th_TH": "XCI ไม่จำเป็นต้องถูกตัดแต่ง โปรดตรวจสอบบันทึกสำหรับรายละเอียด",
"tr_TR": "",
"uk_UA": "XCI не потребує обрізання. Перевірте журнали для отримання деталей.",
"zh_CN": "XCI 不需要被瘦身。查看日志以获得更多细节。",
"zh_TW": "XCI 不需要修剪。檢查日誌以取得更多資訊。"
}
},
{
"ID": "NoUntrimPossibleMessage",
"Translations": {
"ar_SA": "",
"de_DE": "",
"el_GR": "",
"en_US": "XCI cannot be untrimmed. Check logs for details.",
"es_ES": "El recorte del XCI no puede ser deshecho. Verifica los registros para detalles.",
"fr_FR": "Le XCI ne peut être restauré. Référez-vous aux journaux pour détails.",
"he_IL": "",
"it_IT": "XCI non può essere ripristinato. Controlla i log per dettagli.",
"ja_JP": "",
"ko_KR": "XCI는 복원할 수 없습니다. 자세한 내용은 로그를 확인.",
"no_NO": "XCI kan ikke gjenopprettes. Sjekk loggene for detaljer.",
"pl_PL": "XCI nie może zostać przywrócone. Sprawdź dzienniki, aby uzyskać szczegóły.",
"pt_BR": "XCI não pode ser desfeito. Verifique os logs para detalhes.",
"ru_RU": "XCI не может быть восстановлен. Проверьте журналы для подробностей.",
"sv_SE": "XCI kan inte återställas. Kontrollera loggen för detaljer.",
"th_TH": "ไม่สามารถคืนค่า XCI ได้ โปรดตรวจสอบบันทึกสำหรับรายละเอียด",
"tr_TR": "",
"uk_UA": "XCI не можна відновити. Перевірте журнали для деталей.",
"zh_CN": "XCI 不能恢复。查看日志以获取详情。",
"zh_TW": "XCI 無法恢復。檢查日誌以取得詳情。"
}
},
{
"ID": "ReadOnlyFileCannotFixMessage",
"Translations": {
"ar_SA": "",
"de_DE": "",
"el_GR": "",
"en_US": "XCI is read-only and could not be made writable. Check logs for details.",
"es_ES": "XCI es solo lectura y no se puede escribir. Verifica los registros para detalles.",
"fr_FR": "XCI en lecture seule et n'a pas pu être rendu écrivable. Référez-vous aux journaux pour détails.",
"he_IL": "",
"it_IT": "XCI è solo lettura e non può essere scritto. Controlla i log per dettagli.",
"ja_JP": "",
"ko_KR": "XCI 파일은 읽기 전용이며 쓰기 불가. 로그를 확인하십시오.",
"no_NO": "XCI er skrivebeskyttet og kunne ikke gjøres skrivbar. Sjekk loggene for detaljer.",
"pl_PL": "XCI jest tylko do odczytu i nie można zapisać. Sprawdź logi dla szczegółów.",
"pt_BR": "XCI é somente leitura e não pode ser gravado. Verifique os logs para detalhes.",
"ru_RU": "XCI только для чтения и не стал доступен для записи. Проверьте журналы для подробностей.",
"sv_SE": "XCI-filen är skrivskyddad och kunde inte göras skrivbar. Kontrollera loggen för mer information",
"th_TH": "XCI เป็นอ่านอย่างเดียวและไม่สามารถเขียนได้ ตรวจสอบบันทึกสำหรับรายละเอียด",
"tr_TR": "",
"uk_UA": "XCI тільки для читання і не можна записати. Перевірте логи для деталей.",
"zh_CN": "XCI 只读,无法写入。查看日志以获取详情。",
"zh_TW": "XCI 檔案唯讀,無法寫入。檢查日誌以取得詳情。"
}
},
{
"ID": "SizeChangedMessage",
"Translations": {
"ar_SA": "",
"de_DE": "",
"el_GR": "",
"en_US": "XCI size changed since last scan. Ensure the file is not being written to and try again.",
"es_ES": "El tamaño de XCI ha cambiado desde que fue escaneado. Verifica que no se esté escribiendo al archivo y vuelve a intentarlo.",
"fr_FR": "La taille de XCI a changé depuis son analyse. Vérifiez que le fichier nest pas en cours décriture, puis réessayez.",
"he_IL": "",
"it_IT": "La dimensione di XCI è cambiata da quando è stato scansionato. Controlla che il file non sia scritto e riprova.",
"ja_JP": "",
"ko_KR": "XCI 크기가 스캔 후 변경되었습니다. 파일이 쓰여지고 있지 않은지 확인하고 다시 시도하세요.",
"no_NO": "XCI har endret størrelse siden den ble skannet. Kontroller at det ikke skrives til filen, og prøv på nytt.",
"pl_PL": "Rozmiar XCI zmienił się od momentu zeskanowania. Sprawdź, czy plik nie jest zapisywany, a następnie spróbuj ponownie.",
"pt_BR": "O tamanho de XCI mudou desde que foi escaneado. Verifique se o arquivo não está sendo gravado e tente novamente.",
"ru_RU": "Размер XCI изменился после сканирования. Проверьте, не записывается ли файл, и попробуйте снова.",
"sv_SE": "XCI har ändrats i storlek sedan den lästes av. Kontrollera att filen inte skrivs till och försök igen.",
"th_TH": "ขนาด XCI เปลี่ยนไปตั้งแต่การสแกนครั้งล่าสุด ตรวจสอบว่าไฟล์ไม่ได้ถูกเขียน และลองใหม่",
"tr_TR": "",
"uk_UA": "Розмір XCI змінився з моменту сканування. Перевірте, чи не записується файл, та спробуйте знову.",
"zh_CN": "XCI 在扫描后大小发生了变化。请检查文件是否未被写入,然后重试。",
"zh_TW": "XCI 檔案大小自上次掃描以來已經改變。請檢查檔案是否未被寫入,然後再嘗試。"
}
},
{
"ID": "FreeSpaceCheckFailedMessage",
"Translations": {
"ar_SA": "",
"de_DE": "",
"el_GR": "",
"en_US": "XCI has data in the free space area. It is not safe to trim.",
"es_ES": "XCI tiene datos en el área de espacio libre. No es seguro recortarlo.",
"fr_FR": "XCI contient des données dans la zone d'espace libre. Il n'est pas sûr de le réduire.",
"he_IL": "",
"it_IT": "XCI contiene dati nell'area di spazio libero. Non è sicuro ridurre la sua dimensione.",
"ja_JP": "",
"ko_KR": "XCI 파일에 여유 공간 영역에 데이터가 있으므로 트리밍하는 것이 안전하지 않습니다.",
"no_NO": "XCI har data i den ledige plassen. Det er ikke trygt å trimme den.",
"pl_PL": "XCI zawiera dane w obszarze wolnego miejsca. Nie jest bezpieczne go przycinać.",
"pt_BR": "XCI tem dados na área de espaço livre. Não é seguro reduzi-lo.",
"ru_RU": "XCI содержит данные в свободной области. Его обрезка небезопасна.",
"sv_SE": "XCI har data i det lediga utrymmet. Det är inte säkert att optimera.",
"th_TH": "XCI มีข้อมูลในพื้นที่ว่าง จึงไม่ปลอดภัยที่จะทำการตัดแต่ง",
"tr_TR": "",
"uk_UA": "XCI містить дані в зоні вільного простору. Тому обрізка небезпечна.",
"zh_CN": "XCI 文件的空闲区域内有数据。不能安全瘦身。",
"zh_TW": "XCI 檔案有數據儲存於空閒區域。修剪不安全。"
}
},
{
"ID": "InvalidDataMessage",
"Translations": {
"ar_SA": "",
"de_DE": "",
"el_GR": "",
"en_US": "XCI contains invalid data. Check logs for details.",
"es_ES": "XCI contiene datos inválidos. Lee el registro para detalles.",
"fr_FR": "XCI contient des données invalides. Référez-vous aux journaux pour détails.",
"he_IL": "",
"it_IT": "XCI contiene dati non validi. Controlla i log per dettagli.",
"ja_JP": "",
"ko_KR": "XCI 파일에 유효하지 않은 데이터가 포함되어 있습니다. 로그를 확인하세요.",
"no_NO": "XCI-filen inneholder ugyldige data. Sjekk loggene for detaljer.",
"pl_PL": "XCI zawiera nieprawidłowe dane. Sprawdź dzienniki, aby uzyskać szczegóły.",
"pt_BR": "XCI contém dados inválidos. Verifique os logs para detalhes.",
"ru_RU": "XCI содержит недопустимые данные. Проверьте журналы для подробностей.",
"sv_SE": "XCI-filen innehåller ogiltig data. Kontrollera loggen för detaljer.",
"th_TH": "XCI มีข้อมูลที่ไม่ถูกต้อง โปรดตรวจสอบบันทึกสำหรับรายละเอียด",
"tr_TR": "",
"uk_UA": "XCI містить недійсні дані. Перевірте журнали для деталей.",
"zh_CN": "XCI 文件含有无效数据。查看日志以获得更多细节。",
"zh_TW": "XCI 檔案帶有無效的數據。檢查日誌以取得更多資訊"
}
},
{
"ID": "WriteErrorMessage",
"Translations": {
"ar_SA": "",
"de_DE": "",
"el_GR": "",
"en_US": "XCI could not be opened for writing. Check logs for details.",
"es_ES": "XCI no se puede abrir para escribir. Lee el registro para detalles.",
"fr_FR": "XCI n'a pas pu être ouvert pour écriture. Consultez les journaux pour détails.",
"he_IL": "",
"it_IT": "XCI non può essere aperto in scrittura. Controlla i log per dettagli.",
"ja_JP": "",
"ko_KR": "XCI를 쓰기 위해 열 수 없습니다. 로그를 확인하세요.",
"no_NO": "XCI kunne ikke åpnes for skriving. Sjekk loggene for detaljer.",
"pl_PL": "Nie można otworzyć XCI do zapisu. Sprawdź dzienniki, aby uzyskać szczegóły.",
"pt_BR": "XCI não pôde ser aberto para gravação. Verifique os logs para detalhes.",
"ru_RU": "Не удалось открыть XCI для записи. Проверьте журналы для подробностей.",
"sv_SE": "XCI kunde inte öppnas för skrivning. Kontrollera loggen för detaljer.",
"th_TH": "ไม่สามารถเปิด XCI เพื่อเขียนข้อมูลได้ โปรดตรวจสอบบันทึกสำหรับรายละเอียด",
"tr_TR": "",
"uk_UA": "Не вдалося відкрити XCI для запису. Перевірте журнали для деталей.",
"zh_CN": "XCI 不能写入。查看日志以获得更多细节。",
"zh_TW": "XCI 無法開啟以進行寫入。請檢查日誌以取得更多資訊。"
}
},
{
"ID": "TrimFailedMessage",
"Translations": {
"ar_SA": "",
"de_DE": "",
"el_GR": "",
"en_US": "Failed to trim XCI.",
"es_ES": "El recorte del XCI falló.",
"fr_FR": "La réduction du XCI a échoué.",
"he_IL": "",
"it_IT": "Riduzione del XCI fallita.",
"ja_JP": "",
"ko_KR": "XCI 트리밍에 실패했습니다.",
"no_NO": "Trimming av XCI mislyktes.",
"pl_PL": "Nie udało się przyciąć XCI.",
"pt_BR": "A redução do XCI falhou.",
"ru_RU": "Обрезка XCI не удалась.",
"sv_SE": "Optimering av XCI misslyckades.",
"th_TH": "การตัดแต่ง XCI ล้มเหลว",
"tr_TR": "",
"uk_UA": "Не вдалося обрізати XCI.",
"zh_CN": "XCI 瘦身失败。",
"zh_TW": "修剪 XCI 失敗。"
}
},
{
"ID": "TrimCancelledMessage",
"Translations": {
"ar_SA": "",
"de_DE": "",
"el_GR": "",
"en_US": "The operation was cancelled.",
"es_ES": "La operación fue cancelada.",
"fr_FR": "L'opération a été annulée.",
"he_IL": "",
"it_IT": "L'operazione è stata annullata.",
"ja_JP": "",
"ko_KR": "작업이 취소되었습니다.",
"no_NO": "Operasjonen ble avbrutt.",
"pl_PL": "",
"pt_BR": "A operação foi cancelada.",
"ru_RU": "Операция была отменена.",
"sv_SE": "Åtgärden avbröts.",
"th_TH": "การดำเนินการถูกยกเลิกแล้ว.",
"tr_TR": "",
"uk_UA": "Операцію перервано.",
"zh_CN": "操作已取消。",
"zh_TW": "操作已取消。"
}
},
{
"ID": "NoOperationPerformedMessage",
"Translations": {
"ar_SA": "",
"de_DE": "",
"el_GR": "",
"en_US": "No operation was performed.",
"es_ES": "No se realizó ninguna operación.",
"fr_FR": "Aucune opération n'a été effectuée.",
"he_IL": "",
"it_IT": "Non è stata effettuata alcuna operazione.",
"ja_JP": "",
"ko_KR": "작업이 수행되지 않았습니다.",
"no_NO": "Ingen operasjon ble utført.",
"pl_PL": "",
"pt_BR": "Nenhuma operação foi realizada.",
"ru_RU": "Операция не была выполнена.",
"sv_SE": "Ingen åtgärd genomfördes.",
"th_TH": "ไม่มีการดำเนินการใด ๆ",
"tr_TR": "",
"uk_UA": "Операцію не було виконано.",
"zh_CN": "未执行任何操作。",
"zh_TW": "沒有執行任何操作。"
}
}
]
}

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"
}
}
]
}

View File

@@ -228,26 +228,26 @@
{
"ID": "XCITrimmerButton",
"Translations": {
"ar_SA": "",
"de_DE": "XCI-Dateien trimmen",
"ar_SA": "",
"de_DE": "XCI-Trimmer",
"el_GR": "",
"en_US": "Trim XCI Files",
"es_ES": "Recortar Archivos XCI",
"fr_FR": "Réduire les Fichiers XCI",
"en_US": "XCI Trimmer",
"es_ES": "Recortador de XCI",
"fr_FR": "Réducteur de XCI",
"he_IL": "",
"it_IT": "Riduci dimensioni dei file XCI",
"it_IT": "Trimmer XCI",
"ja_JP": "",
"ko_KR": "XCI 파일 트리머",
"no_NO": "Trim XCI-filer",
"pl_PL": "",
"pt_BR": "Reduzir Arquivos XCI",
"ru_RU": "Обрезать XCI файлы",
"sv_SE": "Optimera XCI-filer",
"th_TH": "ตัดแต่งไฟล์ XCI",
"tr_TR": "",
"uk_UA": "Обрізати XCI файли",
"zh_CN": "瘦身 XCI 文件",
"zh_TW": "修剪 XCI 檔案"
"pl_PL": "Przycinacz XCI",
"pt_BR": "Cortador de XCI",
"ru_RU": "Триммер XCI",
"sv_SE": "XCI-trimmer",
"th_TH": "",
"tr_TR": "XCI Kesici",
"uk_UA": "Тример XCI",
"zh_CN": "",
"zh_TW": ""
}
},
{
@@ -450,6 +450,31 @@
"zh_TW": "移除 Skylander"
}
},
{
"ID": "SimulateWakeUpMessageButton",
"Translations": {
"ar_SA": "محاكاة رسالة الاستيقاظ",
"de_DE": "Aufwachnachricht simulieren",
"el_GR": "Προσομοίωση Μηνύματος Αφύπνισης",
"en_US": "Simulate Wake-Up Message",
"es_ES": "Simular Mensaje de Reactivación",
"fr_FR": "Simuler un Message de Réveil",
"he_IL": "דמה הודעת השכמה",
"it_IT": "Simula messaggio di risveglio",
"ja_JP": "スリープ復帰メッセージをシミュレート",
"ko_KR": "절전 모드 해제 메시지 시뮬레이션",
"no_NO": "Simuler oppvåknings-melding",
"pl_PL": "Symuluj wiadomość wybudzania",
"pt_BR": "Simular Mensagem de Acordar o Console",
"ru_RU": "Имитировать сообщение пробуждения",
"sv_SE": "Simulera uppvakningsmeddelande",
"th_TH": "จำลองการปลุกอุปกรณ์ให้ทำงาน",
"tr_TR": "Uyandırma Mesajı Simüle Et",
"uk_UA": "Симулювати повідомлення про пробудження",
"zh_CN": "模拟唤醒消息",
"zh_TW": "模擬喚醒訊息"
}
},
{
"ID": "TakeScreenshotButton",
"Translations": {

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,32 @@
{
"Locales": [
{
"ID": "TrimmingXCILabel",
"Translations": {
"ar_SA": "جاري تقليم: {0}",
"de_DE": "Schneide: {0}",
"el_GR": "Κόβει το: {0}",
"en_US": "Trimming: {0}",
"es_ES": "Recortando: {0}",
"fr_FR": "Réduction de: {0}",
"he_IL": "חיתוך: {0}",
"it_IT": "Riduzione di: {0}",
"ja_JP": "{0} をトリミング中:",
"ko_KR": "{0} 트리밍:",
"no_NO": "Trimming av: {0}",
"pl_PL": "Przycinanie: {0}",
"pt_BR": "Reduzindo: {0}",
"ru_RU": "Обрезка: {0}",
"sv_SE": "Trimmar: {0}",
"th_TH": "กำลังตัด: {0}",
"tr_TR": "{0} Kısaltılıyor:",
"uk_UA": "Обрізка: {0}",
"zh_CN": "正在修剪: {0}",
"zh_TW": "正在修剪: {0}"
}
},
{
"ID": "FirmwareVersion",
"ID": "FirmwareVersionLabel",
"Translations": {
"ar_SA": "",
"de_DE": "",

View File

@@ -0,0 +1,429 @@
{
"Locales": [
{
"ID": "StatusCountLabel",
"Translations": {
"ar_SA": "",
"de_DE": "",
"el_GR": "",
"en_US": "XCI Selected: {0}/{1}",
"es_ES": "XCI Seleccionados: {0}/{1}",
"fr_FR": "XCI Sélectionnés : {0}/{1}",
"he_IL": "",
"it_IT": "XCI Selezionati: {0}/{1}",
"ja_JP": "",
"ko_KR": "XCI 선택됨: {0}/{1}",
"no_NO": "XCI Valgt: {0}/{1}",
"pl_PL": "XCI Wybrane: {0}/{1}",
"pt_BR": "XCI Selecionados: {0}/{1}",
"ru_RU": "Выбрано XCI: {0}/{1}",
"sv_SE": "XCI Valda: {0}/{1}",
"th_TH": "XCI เลือกแล้ว: {0}/{1}",
"tr_TR": "",
"uk_UA": "Вибрано XCI: {0}/{1}",
"zh_CN": "XCI 已选: {0}/{1}",
"zh_TW": "XCI 已選擇: {0}/{1}"
}
},
{
"ID": "StatusCountWithFilterLabel",
"Translations": {
"ar_SA": "",
"de_DE": "",
"el_GR": "",
"en_US": "XCI Selected: {0}/{1} (Displayed: {2})",
"es_ES": "XCI Seleccionados: {0}/{1} (Mostrados: {2})",
"fr_FR": "XCI Sélectionnés : {0}/{1} (Affichés : {2})",
"he_IL": "",
"it_IT": "XCI Selezionati: {0}/{1} (Visualizzati: {2})",
"ja_JP": "",
"ko_KR": "XCI 선택됨: {0}/{1} (표시됨: {2})",
"no_NO": "XCI Valgt: {0}/{1} (Vises: {2})",
"pl_PL": "XCI Wybrane: {0}/{1} (Wyświetlone: {2})",
"pt_BR": "XCI Selecionados: {0}/{1} (Exibidos: {2})",
"ru_RU": "Выбрано XCI: {0}/{1} (Отображается: {2})",
"sv_SE": "XCI Valda: {0}/{1} (Visas: {2})",
"th_TH": "XCI เลือกแล้ว: {0}/{1} (แสดง: {2})",
"tr_TR": "",
"uk_UA": "Вибрано XCI: {0}/{1} (Відображається: {2})",
"zh_CN": "XCI 已选: {0}/{1}(显示: {2}",
"zh_TW": "XCI 已選擇: {0}/{1}(顯示: {2}"
}
},
{
"ID": "StatusTrimmingLabel",
"Translations": {
"ar_SA": "",
"de_DE": "",
"el_GR": "",
"en_US": "Trimming: {0}/{1}...",
"es_ES": "Recortando: {0}/{1}...",
"fr_FR": "Réduction : {0}/{1}...",
"he_IL": "",
"it_IT": "Riduzione: {0}/{1}...",
"ja_JP": "",
"ko_KR": "트리밍: {0}/{1}...",
"no_NO": "Trimmer: {0}/{1}...",
"pl_PL": "",
"pt_BR": "Recortando: {0}/{1}...",
"ru_RU": "Обрезка: {0}/{1}...",
"sv_SE": "Trimmar: {0}/{1}...",
"th_TH": "กำลังตัดแต่ง: {0}/{1}...",
"tr_TR": "",
"uk_UA": "Обрізання: {0}/{1}...",
"zh_CN": "正在修剪:{0}/{1}...",
"zh_TW": "正在修剪:{0}/{1}..."
}
},
{
"ID": "StatusUntrimmingLabel",
"Translations": {
"ar_SA": "",
"de_DE": "",
"el_GR": "",
"en_US": "Untrimming {0}...",
"es_ES": "Deshaciendo el recorte {0}...",
"fr_FR": "Restauration de {0}...",
"he_IL": "",
"it_IT": "Ripristino di {0}...",
"ja_JP": "",
"ko_KR": "{0} 복원 중...",
"no_NO": "Gjenoppretter {0}...",
"pl_PL": "",
"pt_BR": "Restaurando {0}...",
"ru_RU": "Восстановление {0}...",
"sv_SE": "Återställer {0}...",
"th_TH": "กำลังกู้คืน {0}...",
"tr_TR": "",
"uk_UA": "Відновлення {0}...",
"zh_CN": "正在恢复 {0}...",
"zh_TW": "正在還原 {0}..."
}
},
{
"ID": "SelectAllButton",
"Translations": {
"ar_SA": "اختر الكل",
"de_DE": "Alles auswählen",
"el_GR": "Επιλογή όλων",
"en_US": "Select All",
"es_ES": "Seleccionar Todo",
"fr_FR": "Sélectionner Tout",
"he_IL": "בחר הכל",
"it_IT": "Seleziona tutto",
"ja_JP": "すべて選択",
"ko_KR": "모두 선택",
"no_NO": "Velg alle",
"pl_PL": "Zaznacz wszystko",
"pt_BR": "Selecionar tudo",
"ru_RU": "Выбрать все",
"sv_SE": "Markera alla",
"th_TH": "เลือกทั้งหมด",
"tr_TR": "Hepsini seç",
"uk_UA": "Вибрати все",
"zh_CN": "选择全部",
"zh_TW": "選擇全部"
}
},
{
"ID": "ClearSelectionButton",
"Translations": {
"ar_SA": "مسح التحديد",
"de_DE": "Auswahl aufheben",
"el_GR": "Εκκαθάριση επιλογής",
"en_US": "Clear Selection",
"es_ES": "Borrar selección",
"fr_FR": "Effacer la sélection",
"he_IL": "נקה בחירה",
"it_IT": "Cancella selezione",
"ja_JP": "選択をクリア",
"ko_KR": "선택 해제",
"no_NO": "Fjern utvalg",
"pl_PL": "Wyczyść zaznaczenie",
"pt_BR": "Limpar seleção",
"ru_RU": "Очистить выделение",
"sv_SE": "Rensa markering",
"th_TH": "ล้างการเลือก",
"tr_TR": "Seçimi temizle",
"uk_UA": "Очистити вибір",
"zh_CN": "清除选择",
"zh_TW": "清除選擇"
}
},
{
"ID": "TrimmedLabel",
"Translations": {
"ar_SA": "",
"de_DE": "",
"el_GR": "",
"en_US": "Trimmed",
"es_ES": "Recortado",
"fr_FR": "Réduit",
"he_IL": "",
"it_IT": "Dim. ridotta",
"ja_JP": "",
"ko_KR": "트리밍됨",
"no_NO": "Trimmet",
"pl_PL": "",
"pt_BR": "Reduzido",
"ru_RU": "Обрезан",
"sv_SE": "Optimerad",
"th_TH": "ตัดแต่งแล้ว",
"tr_TR": "",
"uk_UA": "Обрізані",
"zh_CN": "经过瘦身的",
"zh_TW": "已修剪"
}
},
{
"ID": "UntrimmedLabel",
"Translations": {
"ar_SA": "",
"de_DE": "",
"el_GR": "",
"en_US": "Untrimmed",
"es_ES": "Sin Recortar",
"fr_FR": "Non Réduit",
"he_IL": "",
"it_IT": "Dim. originale",
"ja_JP": "",
"ko_KR": "트리밍되지 않음",
"no_NO": "Ikke trimmet",
"pl_PL": "",
"pt_BR": "Não Reduzido",
"ru_RU": "Не обрезан",
"sv_SE": "Orörd",
"th_TH": "ยังไม่ได้ตัดแต่ง",
"tr_TR": "",
"uk_UA": "Необрізані",
"zh_CN": "没有瘦身的",
"zh_TW": "未修剪"
}
},
{
"ID": "PartialLabel",
"Translations": {
"ar_SA": "",
"de_DE": "",
"el_GR": "",
"en_US": "Partial",
"es_ES": "Parcial",
"fr_FR": "Partiel",
"he_IL": "",
"it_IT": "Parziale",
"ja_JP": "",
"ko_KR": "일부",
"no_NO": "Delvis",
"pl_PL": "",
"pt_BR": "Parcial",
"ru_RU": "Частично",
"sv_SE": "Delvis",
"th_TH": "ยังไม่สมบูรณ์",
"tr_TR": "",
"uk_UA": "Часткові",
"zh_CN": "分区",
"zh_TW": "部分"
}
},
{
"ID": "FailedLabel",
"Translations": {
"ar_SA": "",
"de_DE": "",
"el_GR": "",
"en_US": "Failed",
"es_ES": "Fallido",
"fr_FR": "Échoué",
"he_IL": "",
"it_IT": "Fallito",
"ja_JP": "",
"ko_KR": "실패",
"no_NO": "Mislyktes",
"pl_PL": "",
"pt_BR": "Falhou",
"ru_RU": "Ошибка",
"sv_SE": "Misslyckades",
"th_TH": "ล้มเหลว",
"tr_TR": "",
"uk_UA": "Невдача",
"zh_CN": "失败",
"zh_TW": "失敗"
}
},
{
"ID": "UnknownLabel",
"Translations": {
"ar_SA": "مجهول",
"de_DE": "Unbekannt",
"el_GR": "Άγνωστο",
"en_US": "Unknown",
"es_ES": "Desconocido",
"fr_FR": "Inconnu",
"he_IL": "לא ידוע",
"it_IT": "Sconosciuto",
"ja_JP": "不明",
"ko_KR": "알 수 없음",
"no_NO": "Ukjent",
"pl_PL": "Nieznany",
"pt_BR": "Desconhecido",
"ru_RU": "Неизвестно",
"sv_SE": "Okänd",
"th_TH": "ไม่รู้จัก",
"tr_TR": "Bilinmeyen",
"uk_UA": "Невідомо",
"zh_CN": "未知",
"zh_TW": "未知"
}
},
{
"ID": "CalculatedSavingsLabel",
"Translations": {
"ar_SA": null,
"de_DE": null,
"el_GR": null,
"en_US": "{0} MB ({1}%)",
"es_ES": null,
"fr_FR": "{0} Mo ({1} %)",
"he_IL": null,
"it_IT": null,
"ja_JP": "{0}MB{1}%",
"ko_KR": "{0}MB ({1}%)",
"no_NO": null,
"pl_PL": null,
"pt_BR": null,
"ru_RU": "{0} МБ ({1}%)",
"sv_SE": null,
"th_TH": null,
"tr_TR": null,
"uk_UA": "{0} МБ ({1}%)",
"zh_CN": "{0} MB{1}%",
"zh_TW": "{0} MB{1}%"
}
},
{
"ID": "SavedLabel",
"Translations": {
"ar_SA": "",
"de_DE": "",
"el_GR": "",
"en_US": "Saved:",
"es_ES": "Ahorrado:",
"fr_FR": "Économies :",
"he_IL": "",
"it_IT": "Risparmiato:",
"ja_JP": "",
"ko_KR": "",
"no_NO": "",
"pl_PL": "Zaoszczędzone:",
"pt_BR": "Economizado:",
"ru_RU": "Сохранено:",
"sv_SE": "",
"th_TH": "",
"tr_TR": "",
"uk_UA": "Збережено:",
"zh_CN": "",
"zh_TW": ""
}
},
{
"ID": "RemainingLabel",
"Translations": {
"ar_SA": "المتبقية:",
"de_DE": "Verbleibend:",
"el_GR": "Απομένουν:",
"en_US": "Remaining:",
"es_ES": "Restante:",
"fr_FR": "Restant :",
"he_IL": "נותרו:",
"it_IT": "Rimanenti:",
"ja_JP": "残り:",
"ko_KR": "남은:",
"no_NO": "Gjenstående:",
"pl_PL": "Pozostało:",
"pt_BR": "Restante:",
"ru_RU": "Осталось:",
"sv_SE": "Kvar:",
"th_TH": "เหลือ:",
"tr_TR": "Kalan:",
"uk_UA": "Залишилося:",
"zh_CN": "剩余:",
"zh_TW": "剩餘:"
}
},
{
"ID": "MBLabel",
"Translations": {
"ar_SA": null,
"de_DE": null,
"el_GR": null,
"en_US": "{0} MB",
"es_ES": null,
"fr_FR": "{0} Mo",
"he_IL": null,
"it_IT": null,
"ja_JP": "{0}MB",
"ko_KR": "{0}MB",
"no_NO": null,
"pl_PL": null,
"pt_BR": null,
"ru_RU": "{0} МБ",
"sv_SE": null,
"th_TH": null,
"tr_TR": null,
"uk_UA": "{0} МБ",
"zh_CN": null,
"zh_TW": null
}
},
{
"ID": "TrimButton",
"Translations": {
"ar_SA": "",
"de_DE": "",
"el_GR": "",
"en_US": "Trim",
"es_ES": "Recortar",
"fr_FR": "Réduire",
"he_IL": "",
"it_IT": "Riduci",
"ja_JP": "",
"ko_KR": "트림",
"no_NO": "",
"pl_PL": "Przytnij",
"pt_BR": "Reduzir",
"ru_RU": "Обрезать",
"sv_SE": "Trimma",
"th_TH": "ตัด",
"tr_TR": "Kırp",
"uk_UA": "Обрізати",
"zh_CN": "瘦身",
"zh_TW": "修剪"
}
},
{
"ID": "UntrimButton",
"Translations": {
"ar_SA": "",
"de_DE": "",
"el_GR": "",
"en_US": "Untrim",
"es_ES": "Desrecortar",
"fr_FR": "Dé-Réduire",
"he_IL": "",
"it_IT": "Ripristina",
"ja_JP": "",
"ko_KR": "언트림",
"no_NO": "Utrim",
"pl_PL": "",
"pt_BR": "Restaurar",
"ru_RU": "Восстановить",
"sv_SE": "Avoptimera",
"th_TH": "กู้คืน",
"tr_TR": "",
"uk_UA": "Відновити",
"zh_CN": "取消精简",
"zh_TW": "反修剪"
}
}
]
}

View File

@@ -0,0 +1,11 @@
namespace Ryujinx.Common.Configuration.Hid
{
public class AssignedInputDevice
{
public AssignedInputDeviceType Type { get; set; }
public string Id { get; set; }
public string ProfileName { get; set; }
}
}

View File

@@ -0,0 +1,11 @@
using System.Text.Json.Serialization;
namespace Ryujinx.Common.Configuration.Hid
{
[JsonConverter(typeof(JsonStringEnumConverter<AssignedInputDeviceType>))]
public enum AssignedInputDeviceType
{
Keyboard,
Controller,
}
}

View File

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

View File

@@ -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; } = [];
}
}

View File

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

View File

@@ -1,4 +1,5 @@
using ARMeilleure.State;
using Ryujinx.Common.Logging;
using Ryujinx.Cpu.AppleHv.Arm;
using Ryujinx.Memory.Tracking;
using System;
@@ -17,9 +18,7 @@ namespace Ryujinx.Cpu.AppleHv
{
uint currentEl = Pstate & ~((uint)ExceptionLevel.PstateMask);
if (currentEl == (uint)ExceptionLevel.EL1h)
{
return _impl.ElrEl1;
}
return _impl.Pc;
}
}
@@ -69,9 +68,7 @@ namespace Ryujinx.Cpu.AppleHv
set
{
if (value)
{
throw new NotSupportedException();
}
}
}
@@ -82,11 +79,13 @@ namespace Ryujinx.Cpu.AppleHv
private readonly IHvExecutionContext _shadowContext;
private IHvExecutionContext _impl;
private int _shouldStep;
private readonly ExceptionCallbacks _exceptionCallbacks;
private int _interruptRequested;
// GPU Sync control
private int _syncCounter;
private int _strongSyncCounter;
public HvExecutionContext(ICounter counter, ExceptionCallbacks exceptionCallbacks)
{
_counter = counter;
@@ -108,38 +107,17 @@ namespace Ryujinx.Cpu.AppleHv
/// <inheritdoc/>
public void SetV(int index, V128 value) => _impl.SetV(index, value);
private void InterruptHandler()
{
_exceptionCallbacks.InterruptCallback?.Invoke(this);
}
private void BreakHandler(ulong address, int imm)
{
_exceptionCallbacks.BreakCallback?.Invoke(this, address, imm);
}
private void StepHandler()
{
_exceptionCallbacks.StepCallback?.Invoke(this);
}
private void SupervisorCallHandler(ulong address, int imm)
{
_exceptionCallbacks.SupervisorCallback?.Invoke(this, address, imm);
}
private void UndefinedHandler(ulong address, int opCode)
{
_exceptionCallbacks.UndefinedCallback?.Invoke(this, address, opCode);
}
private void InterruptHandler() => _exceptionCallbacks.InterruptCallback?.Invoke(this);
private void BreakHandler(ulong address, int imm) => _exceptionCallbacks.BreakCallback?.Invoke(this, address, imm);
private void StepHandler() => _exceptionCallbacks.StepCallback?.Invoke(this);
private void SupervisorCallHandler(ulong address, int imm) => _exceptionCallbacks.SupervisorCallback?.Invoke(this, address, imm);
private void UndefinedHandler(ulong address, int opCode) => _exceptionCallbacks.UndefinedCallback?.Invoke(this, address, opCode);
/// <inheritdoc/>
public void RequestInterrupt()
{
if (Interlocked.Exchange(ref _interruptRequested, 1) == 0 && _impl is HvExecutionContextVcpu impl)
{
impl.RequestInterrupt();
}
}
private bool GetAndClearInterruptRequested()
@@ -161,13 +139,9 @@ namespace Ryujinx.Cpu.AppleHv
{
uint currentEl = Pstate & ~((uint)ExceptionLevel.PstateMask);
if (currentEl == (uint)ExceptionLevel.EL1h)
{
_impl.ElrEl1 = value;
}
else
{
_impl.Pc = value;
}
}
}
@@ -181,9 +155,11 @@ namespace Ryujinx.Cpu.AppleHv
public unsafe void Execute(HvMemoryManager memoryManager, ulong address)
{
HvVcpu vcpu = HvVcpuPool.Instance.Create(memoryManager.AddressSpace, _shadowContext, SwapContext);
HvApi.hv_vcpu_set_reg(vcpu.Handle, HvReg.PC, address).ThrowOnError();
_syncCounter = 0;
_strongSyncCounter = 0;
while (Running)
{
if (Interlocked.CompareExchange(ref _shouldStep, 0, 1) == 1)
@@ -192,16 +168,23 @@ namespace Ryujinx.Cpu.AppleHv
if (currentEl == (uint)ExceptionLevel.EL1h)
{
HvApi.hv_vcpu_get_sys_reg(vcpu.Handle, HvSysReg.SPSR_EL1, out ulong spsr).ThrowOnError();
spsr |= (1 << 21);
spsr |= (1U << 21);
HvApi.hv_vcpu_set_sys_reg(vcpu.Handle, HvSysReg.SPSR_EL1, spsr);
}
else
{
Pstate |= (1 << 21);
Pstate |= (1U << 21);
}
HvApi.hv_vcpu_set_sys_reg(vcpu.Handle, HvSysReg.MDSCR_EL1, 1);
}
// Adaptive GPU synchronization to prevent 0 FPS
if (++_syncCounter % 12 == 0)
{
TryGpuSync();
_syncCounter = 0;
}
HvApi.hv_vcpu_run(vcpu.Handle).ThrowOnError();
HvExitReason reason = vcpu.ExitInfo->Reason;
@@ -212,9 +195,7 @@ namespace Ryujinx.Cpu.AppleHv
ExceptionClass hvEc = (ExceptionClass)(hvEsr >> 26);
if (hvEc != ExceptionClass.HvcAarch64)
{
throw new Exception($"Unhandled exception from guest kernel with ESR 0x{hvEsr:X} ({hvEc}).");
}
address = SynchronousException(memoryManager, ref vcpu);
HvApi.hv_vcpu_set_reg(vcpu.Handle, HvReg.PC, address).ThrowOnError();
@@ -245,10 +226,31 @@ namespace Ryujinx.Cpu.AppleHv
HvVcpuPool.Instance.Destroy(vcpu, SwapContext);
}
// TryGpuSync() is called periodically in the main Execute() loop. The "syncing" value can be tuned based on gameplay results.
// This feature it to be followed-up and further completed in a future PR.
private void TryGpuSync()
{
try
{
Thread.Yield();
if (++_strongSyncCounter % 6 == 0)
{
Thread.Yield();
}
}
catch (Exception ex)
{
if (_strongSyncCounter % 100 == 0)
{
Logger.Warning?.Print(LogClass.Gpu, $"[AppleHv] GPU sync issue: {ex.Message}");
}
}
}
private ulong SynchronousException(HvMemoryManager memoryManager, ref HvVcpu vcpu)
{
ulong vcpuHandle = vcpu.Handle;
HvApi.hv_vcpu_get_sys_reg(vcpuHandle, HvSysReg.ELR_EL1, out ulong elr).ThrowOnError();
HvApi.hv_vcpu_get_sys_reg(vcpuHandle, HvSysReg.ESR_EL1, out ulong esr).ThrowOnError();
@@ -259,16 +261,20 @@ namespace Ryujinx.Cpu.AppleHv
case ExceptionClass.DataAbortLowerEl:
DataAbort(memoryManager.Tracking, vcpuHandle, (uint)esr);
break;
case ExceptionClass.TrappedMsrMrsSystem:
InstructionTrap((uint)esr);
HvApi.hv_vcpu_set_sys_reg(vcpuHandle, HvSysReg.ELR_EL1, elr + 4UL).ThrowOnError();
break;
case ExceptionClass.SvcAarch64:
ReturnToPool(vcpu);
ushort id = (ushort)esr;
SupervisorCallHandler(elr - 4UL, id);
Thread.Yield(); // MoltenVK causes extremely frequent SVC exits, and HVF handles them in a busy loop. Hypervisor.Framework accelerates the guest CPU, and without periodic yielding/flushing, MoltenVK's presentation queue can starve, causing permanent 0 FPS deadlock.
vcpu = RentFromPool(memoryManager.AddressSpace, vcpu);
break;
case ExceptionClass.SoftwareStepLowerEl:
HvApi.hv_vcpu_get_sys_reg(vcpuHandle, HvSysReg.SPSR_EL1, out ulong spsr).ThrowOnError();
spsr &= ~((ulong)(1 << 21));
@@ -278,21 +284,23 @@ namespace Ryujinx.Cpu.AppleHv
StepHandler();
vcpu = RentFromPool(memoryManager.AddressSpace, vcpu);
break;
case ExceptionClass.BrkAarch64:
ReturnToPool(vcpu);
BreakHandler(elr, (ushort)esr);
vcpu = RentFromPool(memoryManager.AddressSpace, vcpu);
break;
default:
throw new Exception($"Unhandled guest exception {ec}.");
}
// Make sure we will continue running at EL0.
if (memoryManager.AddressSpace.GetAndClearUserTlbInvalidationPending())
{
// TODO: Invalidate only the range that was modified?
return HvAddressSpace.KernelRegionTlbiEretAddress;
}
return HvAddressSpace.KernelRegionEretAddress;
}
@@ -305,7 +313,6 @@ namespace Ryujinx.Cpu.AppleHv
if (farValid)
{
HvApi.hv_vcpu_get_sys_reg(vcpu, HvSysReg.FAR_EL1, out ulong far).ThrowOnError();
ulong size = 1UL << accessSizeLog2;
if (!tracking.VirtualMemoryEvent(far, size, write))
@@ -349,9 +356,7 @@ namespace Ryujinx.Cpu.AppleHv
private void WriteRt(uint rt, ulong value)
{
if (rt < 31)
{
SetX((int)rt, value);
}
}
private void ReturnToPool(HvVcpu vcpu)
@@ -369,8 +374,6 @@ namespace Ryujinx.Cpu.AppleHv
_impl = newContext;
}
public void Dispose()
{
}
public void Dispose() { }
}
}

View File

@@ -1,5 +1,7 @@
using ARMeilleure.State;
using Ryujinx.Common.Logging;
using Ryujinx.Memory;
using System;
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using System.Threading;
@@ -14,8 +16,31 @@ namespace Ryujinx.Cpu.AppleHv
private static readonly SetSimdFpReg _setSimdFpReg;
private static readonly nint _setSimdFpRegNativePtr;
public static bool AggressiveMode { get; set; } = false;
private bool _earlyBootPhase = true;
public ulong ThreadUid { get; set; }
private readonly ulong[] _x = new ulong[32];
private readonly V128[] _v = new V128[32];
private ulong _pc;
private ulong _elrEl1;
private ulong _esrEl1;
private ulong _tpidrEl0;
private ulong _tpidrroEl0;
private ulong _fpcr;
private ulong _fpsr;
private ulong _pstateRaw;
private long _fallbackCount;
private long _lastWarningTicks;
private const long WarningCooldownTicks = 500_000_000; // 0.5 seconds
private readonly ulong _vcpu;
private int _interruptRequested;
private readonly object _registerLock = new object();
static HvExecutionContextVcpu()
{
// .NET does not support passing vectors by value, so we need to pass a pointer and use a native
@@ -33,155 +58,293 @@ namespace Ryujinx.Cpu.AppleHv
}
}
public HvExecutionContextVcpu(ulong vcpu)
{
_vcpu = vcpu;
Reset();
}
public void Reset()
{
lock (_registerLock)
{
_pstateRaw = 0x80000000UL;
_pc = 0;
_elrEl1 = 0;
_esrEl1 = 0;
_tpidrEl0 = 0;
_tpidrroEl0 = 0;
_fpcr = 0;
_fpsr = 0;
Array.Clear(_x, 0, _x.Length);
Array.Clear(_v, 0, _v.Length);
_fallbackCount = 0;
_lastWarningTicks = 0;
_interruptRequested = 0;
_earlyBootPhase = true;
}
}
private void LogHvWarning(string operation, string regName, string extra = "")
{
if (AggressiveMode) return;
long now = DateTime.UtcNow.Ticks;
if (now - _lastWarningTicks <= WarningCooldownTicks) return;
string msg = $"[AppleHv] BadArgument on {operation} {regName} | PC=0x{_pc:X16}";
if (!string.IsNullOrEmpty(extra)) msg += $" | {extra}";
msg += $" | Total: {Interlocked.Read(ref _fallbackCount)}";
Logger.Warning?.Print(LogClass.Cpu, msg);
_lastWarningTicks = now;
}
public ulong Pc
{
get
{
HvApi.hv_vcpu_get_reg(_vcpu, HvReg.PC, out ulong pc).ThrowOnError();
return pc;
}
set
{
HvApi.hv_vcpu_set_reg(_vcpu, HvReg.PC, value).ThrowOnError();
}
get { lock (_registerLock) return GetRegCached(HvReg.PC, ref _pc, "PC"); }
set { lock (_registerLock) SetRegCached(HvReg.PC, value, ref _pc, "PC"); }
}
public ulong ElrEl1
{
get
{
HvApi.hv_vcpu_get_sys_reg(_vcpu, HvSysReg.ELR_EL1, out ulong elr).ThrowOnError();
return elr;
}
set
{
HvApi.hv_vcpu_set_sys_reg(_vcpu, HvSysReg.ELR_EL1, value).ThrowOnError();
}
get { lock (_registerLock) return GetSysRegCached(HvSysReg.ELR_EL1, ref _elrEl1, "ELR_EL1"); }
set { lock (_registerLock) SetSysRegCached(HvSysReg.ELR_EL1, value, ref _elrEl1, "ELR_EL1"); }
}
public ulong EsrEl1
{
get
{
HvApi.hv_vcpu_get_sys_reg(_vcpu, HvSysReg.ESR_EL1, out ulong esr).ThrowOnError();
return esr;
}
set
{
HvApi.hv_vcpu_set_sys_reg(_vcpu, HvSysReg.ESR_EL1, value).ThrowOnError();
}
get { lock (_registerLock) return GetSysRegCached(HvSysReg.ESR_EL1, ref _esrEl1, "ESR_EL1"); }
set { lock (_registerLock) SetSysRegCached(HvSysReg.ESR_EL1, value, ref _esrEl1, "ESR_EL1"); }
}
public long TpidrEl0
{
get
{
HvApi.hv_vcpu_get_sys_reg(_vcpu, HvSysReg.TPIDR_EL0, out ulong tpidrEl0).ThrowOnError();
return (long)tpidrEl0;
}
set
{
HvApi.hv_vcpu_set_sys_reg(_vcpu, HvSysReg.TPIDR_EL0, (ulong)value).ThrowOnError();
}
get { lock (_registerLock) return (long)GetSysRegCached(HvSysReg.TPIDR_EL0, ref _tpidrEl0, "TPIDR_EL0"); }
set { lock (_registerLock) SetSysRegCached(HvSysReg.TPIDR_EL0, (ulong)value, ref _tpidrEl0, "TPIDR_EL0"); }
}
public long TpidrroEl0
{
get
{
HvApi.hv_vcpu_get_sys_reg(_vcpu, HvSysReg.TPIDRRO_EL0, out ulong tpidrroEl0).ThrowOnError();
return (long)tpidrroEl0;
}
set
{
HvApi.hv_vcpu_set_sys_reg(_vcpu, HvSysReg.TPIDRRO_EL0, (ulong)value).ThrowOnError();
}
get { lock (_registerLock) return (long)GetSysRegCached(HvSysReg.TPIDRRO_EL0, ref _tpidrroEl0, "TPIDRRO_EL0"); }
set { lock (_registerLock) SetSysRegCached(HvSysReg.TPIDRRO_EL0, (ulong)value, ref _tpidrroEl0, "TPIDRRO_EL0"); }
}
public uint Pstate
{
get
{
HvApi.hv_vcpu_get_reg(_vcpu, HvReg.CPSR, out ulong cpsr).ThrowOnError();
return (uint)cpsr;
lock (_registerLock)
{
HvResult res = HvApi.hv_vcpu_get_reg(_vcpu, HvReg.CPSR, out ulong val);
if (res == HvResult.BadArgument)
{
Interlocked.Increment(ref _fallbackCount);
LogHvWarning("Get", "CPSR (Pstate)");
return (uint)_pstateRaw;
}
res.ThrowOnError();
_pstateRaw = val;
return (uint)val;
}
}
set
{
HvApi.hv_vcpu_set_reg(_vcpu, HvReg.CPSR, (ulong)value).ThrowOnError();
lock (_registerLock)
{
HvResult res = HvApi.hv_vcpu_set_reg(_vcpu, HvReg.CPSR, value);
if (res == HvResult.BadArgument)
{
Interlocked.Increment(ref _fallbackCount);
LogHvWarning("Set", "CPSR (Pstate)", $"value=0x{value:X}");
}
else res.ThrowOnError();
_pstateRaw = value;
}
}
}
public uint Fpcr
{
get
{
HvApi.hv_vcpu_get_reg(_vcpu, HvReg.FPCR, out ulong fpcr).ThrowOnError();
return (uint)fpcr;
}
set
{
HvApi.hv_vcpu_set_reg(_vcpu, HvReg.FPCR, (ulong)value).ThrowOnError();
}
get { lock (_registerLock) return (uint)GetRegCached(HvReg.FPCR, ref _fpcr, "FPCR"); }
set { lock (_registerLock) SetRegCached(HvReg.FPCR, value, ref _fpcr, "FPCR"); }
}
public uint Fpsr
{
get
{
HvApi.hv_vcpu_get_reg(_vcpu, HvReg.FPSR, out ulong fpsr).ThrowOnError();
return (uint)fpsr;
}
set
{
HvApi.hv_vcpu_set_reg(_vcpu, HvReg.FPSR, (ulong)value).ThrowOnError();
}
}
private readonly ulong _vcpu;
private int _interruptRequested;
public HvExecutionContextVcpu(ulong vcpu)
{
_vcpu = vcpu;
get { lock (_registerLock) return (uint)GetRegCached(HvReg.FPSR, ref _fpsr, "FPSR"); }
set { lock (_registerLock) SetRegCached(HvReg.FPSR, value, ref _fpsr, "FPSR"); }
}
public ulong GetX(int index)
{
if (index == 31)
lock (_registerLock)
{
HvApi.hv_vcpu_get_sys_reg(_vcpu, HvSysReg.SP_EL0, out ulong value).ThrowOnError();
return value;
}
else
{
HvApi.hv_vcpu_get_reg(_vcpu, HvReg.X0 + (uint)index, out ulong value).ThrowOnError();
return value;
ulong value;
string regName = index == 31 ? "SP_EL0" : $"X{index}";
if (index == 31)
{
HvResult res = HvApi.hv_vcpu_get_sys_reg(_vcpu, HvSysReg.SP_EL0, out value);
if (res == HvResult.BadArgument)
{
Interlocked.Increment(ref _fallbackCount);
LogHvWarning("GetX", regName);
return _x[31];
}
res.ThrowOnError();
return _x[31] = value;
}
if ((uint)index > 30) return 0;
if (index == 0 && _earlyBootPhase && _pc == 0)
{
return _x[0];
}
HvResult resX = HvApi.hv_vcpu_get_reg(_vcpu, HvReg.X0 + (uint)index, out value);
if (resX == HvResult.BadArgument)
{
Interlocked.Increment(ref _fallbackCount);
LogHvWarning("GetX", regName);
return _x[index];
}
resX.ThrowOnError();
return _x[index] = value;
}
}
public void SetX(int index, ulong value)
{
if (index == 31)
lock (_registerLock)
{
HvApi.hv_vcpu_set_sys_reg(_vcpu, HvSysReg.SP_EL0, value).ThrowOnError();
}
else
{
HvApi.hv_vcpu_set_reg(_vcpu, HvReg.X0 + (uint)index, value).ThrowOnError();
string regName = index == 31 ? "SP_EL0" : $"X{index}";
if (index == 31)
{
HvResult res = HvApi.hv_vcpu_set_sys_reg(_vcpu, HvSysReg.SP_EL0, value);
if (res == HvResult.BadArgument)
{
Interlocked.Increment(ref _fallbackCount);
LogHvWarning("SetX", regName, $"value=0x{value:X16}");
_x[31] = value;
return;
}
res.ThrowOnError();
_x[31] = value;
}
else if ((uint)index <= 30)
{
HvResult res = HvApi.hv_vcpu_set_reg(_vcpu, HvReg.X0 + (uint)index, value);
if (res == HvResult.BadArgument)
{
Interlocked.Increment(ref _fallbackCount);
LogHvWarning("SetX", regName, $"value=0x{value:X16}");
_x[index] = value;
return;
}
res.ThrowOnError();
_x[index] = value;
}
}
}
public V128 GetV(int index)
{
HvApi.hv_vcpu_get_simd_fp_reg(_vcpu, HvSimdFPReg.Q0 + (uint)index, out HvSimdFPUchar16 value).ThrowOnError();
return new V128(value.Low, value.High);
lock (_registerLock)
{
if ((uint)index > 31) return default;
HvResult res = HvApi.hv_vcpu_get_simd_fp_reg(_vcpu, HvSimdFPReg.Q0 + (uint)index, out HvSimdFPUchar16 val);
if (res == HvResult.BadArgument)
{
Interlocked.Increment(ref _fallbackCount);
LogHvWarning("GetV", $"Q{index}");
return _v[index];
}
res.ThrowOnError();
return _v[index] = new V128(val.Low, val.High);
}
}
public void SetV(int index, V128 value)
{
_setSimdFpReg(_vcpu, HvSimdFPReg.Q0 + (uint)index, value, _setSimdFpRegNativePtr).ThrowOnError();
lock (_registerLock)
{
if ((uint)index > 31) return;
HvResult res = _setSimdFpReg(_vcpu, HvSimdFPReg.Q0 + (uint)index, value, _setSimdFpRegNativePtr);
if (res == HvResult.BadArgument)
{
Interlocked.Increment(ref _fallbackCount);
LogHvWarning("SetV", $"Q{index}");
_v[index] = value;
return;
}
res.ThrowOnError();
_v[index] = value;
}
}
private ulong GetRegCached(HvReg reg, ref ulong cached, string name)
{
HvResult res = HvApi.hv_vcpu_get_reg(_vcpu, reg, out ulong val);
if (res == HvResult.BadArgument)
{
Interlocked.Increment(ref _fallbackCount);
LogHvWarning("GetReg", name);
return cached;
}
res.ThrowOnError();
return cached = val;
}
private void SetRegCached(HvReg reg, ulong value, ref ulong cached, string name)
{
HvResult res = HvApi.hv_vcpu_set_reg(_vcpu, reg, value);
if (res == HvResult.BadArgument)
{
Interlocked.Increment(ref _fallbackCount);
LogHvWarning("SetReg", name, $"value=0x{value:X16}");
cached = value;
return;
}
res.ThrowOnError();
cached = value;
}
private ulong GetSysRegCached(HvSysReg reg, ref ulong cached, string name)
{
HvResult res = HvApi.hv_vcpu_get_sys_reg(_vcpu, reg, out ulong val);
if (res == HvResult.BadArgument)
{
Interlocked.Increment(ref _fallbackCount);
LogHvWarning("GetSysReg", name);
return cached;
}
res.ThrowOnError();
return cached = val;
}
private void SetSysRegCached(HvSysReg reg, ulong value, ref ulong cached, string name)
{
HvResult res = HvApi.hv_vcpu_set_sys_reg(_vcpu, reg, value);
if (res == HvResult.BadArgument)
{
Interlocked.Increment(ref _fallbackCount);
LogHvWarning("SetSysReg", name, $"value=0x{value:X16}");
cached = value;
return;
}
res.ThrowOnError();
cached = value;
}
public long GetFallbackCount() => Interlocked.Read(ref _fallbackCount);
public void RequestInterrupt()
{
if (Interlocked.Exchange(ref _interruptRequested, 1) == 0)

View File

@@ -689,13 +689,26 @@ namespace Ryujinx.HLE.HOS.Kernel.Threading
return context.Pstate & 0xFF0FFE20;
}
private const long ContextCacheDurationTicks = 800_000; // ~0.8ms cache lifetime
private ThreadContext _cachedContext;
private long _contextCacheTimestamp;
private bool _hasValidContextCache;
private ThreadContext GetCurrentContext()
{
var now = DateTime.UtcNow.Ticks;
// Cache hit
if (_hasValidContextCache && (now - _contextCacheTimestamp) < ContextCacheDurationTicks)
{
return _cachedContext;
}
const int MaxRegistersAArch32 = 15;
const int MaxFpuRegistersAArch32 = 16;
ThreadContext context = new();
Span<ulong> registersSpan = context.Registers.AsSpan();
Span<V128> fpuRegistersSpan = context.FpuRegisters.AsSpan();
@@ -705,12 +718,10 @@ namespace Ryujinx.HLE.HOS.Kernel.Threading
{
registersSpan[i] = Context.GetX(i);
}
for (int i = 0; i < fpuRegistersSpan.Length; i++)
{
fpuRegistersSpan[i] = Context.GetV(i);
}
context.Fp = Context.GetX(29);
context.Lr = Context.GetX(30);
context.Sp = Context.GetX(31);
@@ -724,12 +735,10 @@ namespace Ryujinx.HLE.HOS.Kernel.Threading
{
registersSpan[i] = (uint)Context.GetX(i);
}
for (int i = 0; i < MaxFpuRegistersAArch32; i++)
{
fpuRegistersSpan[i] = Context.GetV(i);
}
context.Pc = (uint)Context.Pc;
context.Pstate = GetPsr(Context);
context.Tpidr = (uint)Context.TpidrroEl0;
@@ -738,6 +747,11 @@ namespace Ryujinx.HLE.HOS.Kernel.Threading
context.Fpcr = (uint)Context.Fpcr;
context.Fpsr = (uint)Context.Fpsr;
// Update cache
_cachedContext = context;
_contextCacheTimestamp = now;
_hasValidContextCache = true;
return context;
}

View File

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

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

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

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 = oldConfig.Motion.EnableMotion != newConfig.Motion.EnableMotion;
bool motionBackendChanged = oldConfig.Motion.MotionBackend != newConfig.Motion.MotionBackend;
return motionWasDisabled ||
leftMotionMissing ||
isJoyconPairNeedingRightMotion ||
motionEnabledChanged ||
motionBackendChanged;
}
public void Update()
{
if (_playerInputAssignment?.EnableDynamicInputSwap == true)
{
UpdateDynamic();
return;
}
// _gamepad may be altered by other threads
IGamepad gamepad = _gamepad;
if (gamepad != null && GamepadDriver != null)
{
State = gamepad.GetMappedStateSnapshot();
_activeConfig = _config;
if (_config is StandardControllerInputConfig controllerConfig && controllerConfig.Motion.EnableMotion)
if (_activeConfig is StandardControllerInputConfig controllerConfig && controllerConfig.Motion?.EnableMotion == true)
{
if (controllerConfig.Motion.MotionBackend == MotionInputBackendType.GamepadDriver)
{
if ((gamepad.Features & GamepadFeaturesFlag.Motion) != 0)
{
Vector3 accelerometer = gamepad.GetMotionData(MotionInputId.Accelerometer);
Vector3 gyroscope = gamepad.GetMotionData(MotionInputId.Gyroscope);
accelerometer = new Vector3(accelerometer.X, -accelerometer.Z, accelerometer.Y);
gyroscope = new Vector3(gyroscope.X, -gyroscope.Z, gyroscope.Y);
_leftMotionInput.Update(accelerometer, gyroscope, (ulong)PerformanceCounter.ElapsedNanoseconds / 1000, controllerConfig.Motion.Sensitivity, (float)controllerConfig.Motion.GyroDeadzone);
if (controllerConfig.ControllerType == ConfigControllerType.JoyconPair)
{
if (gamepad.Id == "JoyConPair")
{
Vector3 rightAccelerometer = gamepad.GetMotionData(MotionInputId.SecondAccelerometer);
Vector3 rightGyroscope = gamepad.GetMotionData(MotionInputId.SecondGyroscope);
rightAccelerometer = new Vector3(rightAccelerometer.X, -rightAccelerometer.Z, rightAccelerometer.Y);
rightGyroscope = new Vector3(rightGyroscope.X, -rightGyroscope.Z, rightGyroscope.Y);
_rightMotionInput.Update(rightAccelerometer, rightGyroscope, (ulong)PerformanceCounter.ElapsedNanoseconds / 1000, controllerConfig.Motion.Sensitivity, (float)controllerConfig.Motion.GyroDeadzone);
}
else
{
_rightMotionInput = _leftMotionInput;
}
}
}
}
else if (controllerConfig.Motion.MotionBackend == MotionInputBackendType.CemuHook && controllerConfig.Motion is CemuHookMotionConfigController cemuControllerConfig)
{
int clientId = (int)controllerConfig.PlayerIndex;
// First of all ensure we are registered
_cemuHookClient.RegisterClient(clientId, cemuControllerConfig.DsuServerHost, cemuControllerConfig.DsuServerPort);
// Then request and retrieve the data
_cemuHookClient.RequestData(clientId, cemuControllerConfig.Slot);
_cemuHookClient.TryGetData(clientId, cemuControllerConfig.Slot, out _leftMotionInput);
if (controllerConfig.ControllerType == ConfigControllerType.JoyconPair)
{
if (!cemuControllerConfig.MirrorInput)
{
_cemuHookClient.RequestData(clientId, cemuControllerConfig.AltSlot);
_cemuHookClient.TryGetData(clientId, cemuControllerConfig.AltSlot, out _rightMotionInput);
}
else
{
_rightMotionInput = _leftMotionInput;
}
}
}
UpdateControllerMotion(gamepad, controllerConfig);
}
else
{
_leftMotionInput = null;
_rightMotionInput = null;
}
}
else
@@ -371,7 +475,7 @@ namespace Ryujinx.Input.HLE
}
}
if (_gamepad is IKeyboard)
if (_activeConfig is StandardKeyboardInputConfig)
{
(float leftAxisX, float leftAxisY) = State.GetStick(StickInputId.Left);
(float rightAxisX, float rightAxisY) = State.GetStick(StickInputId.Right);
@@ -388,7 +492,7 @@ namespace Ryujinx.Input.HLE
Dy = ClampAxis(rightAxisY),
};
}
else if (_config is StandardControllerInputConfig controllerConfig)
else if (_activeConfig is StandardControllerInputConfig controllerConfig)
{
(float leftAxisX, float leftAxisY) = State.GetStick(StickInputId.Left);
(float rightAxisX, float rightAxisY) = State.GetStick(StickInputId.Right);
@@ -509,9 +613,12 @@ namespace Ryujinx.Input.HLE
return value;
}
public static KeyboardInput GetHLEKeyboardInput(IGamepadDriver KeyboardDriver)
public static KeyboardInput GetHLEKeyboardInput(IGamepadDriver keyboardDriver)
{
IKeyboard keyboard = KeyboardDriver.GetGamepad("0") as IKeyboard;
if (keyboardDriver.GetGamepad("0") is not IKeyboard keyboard)
{
return default;
}
KeyboardStateSnapshot keyboardState = keyboard.GetKeyboardStateSnapshot();
@@ -543,7 +650,7 @@ namespace Ryujinx.Input.HLE
{
if (disposing)
{
_gamepad?.Dispose();
DisposeOpenedGamepads();
}
}
@@ -557,38 +664,565 @@ namespace Ryujinx.Input.HLE
{
if (queue.TryDequeue(out (VibrationValue, VibrationValue) dualVibrationValue))
{
if (_config is not StandardControllerInputConfig controllerConfig ||
!controllerConfig.Rumble.EnableRumble)
if (_controllerConfig is StandardControllerInputConfig dynamicControllerConfig &&
_playerInputAssignment?.EnableDynamicInputSwap == true &&
dynamicControllerConfig.Rumble?.EnableRumble == true)
{
return;
ApplyRumble(_controllerGamepad ?? _assignedControllerGamepads.FirstOrDefault(), dynamicControllerConfig, dualVibrationValue);
}
else if (_config is StandardControllerInputConfig controllerConfig && controllerConfig.Rumble?.EnableRumble == true)
{
ApplyRumble(_gamepad, controllerConfig, dualVibrationValue);
}
}
}
public bool HasAssignedControllerId(string id)
{
if (string.IsNullOrEmpty(id))
{
return false;
}
if (_playerInputAssignment?.EnableDynamicInputSwap == true)
{
return _assignedControllerGamepads.Any(gamepad => gamepad?.Id == id);
}
return Id == id;
}
private void ApplyRumble(IGamepad gamepad, StandardControllerInputConfig controllerConfig, (VibrationValue, VibrationValue) dualVibrationValue)
{
if (gamepad == null)
{
return;
}
VibrationValue leftVibrationValue = dualVibrationValue.Item1;
VibrationValue rightVibrationValue = dualVibrationValue.Item2;
leftVibrationValue.AmplitudeLow *= controllerConfig.Rumble.WeakRumble;
leftVibrationValue.AmplitudeHigh *= controllerConfig.Rumble.StrongRumble;
rightVibrationValue.AmplitudeLow *= controllerConfig.Rumble.WeakRumble;
rightVibrationValue.AmplitudeHigh *= controllerConfig.Rumble.StrongRumble;
if (!controllerConfig.Rumble.UseHDRumble || gamepad.HDRumble(leftVibrationValue, rightVibrationValue) == false)
{
float low = Math.Min(1f, (float)(rightVibrationValue.AmplitudeLow * 0.85 + rightVibrationValue.AmplitudeHigh * 0.15));
float high = Math.Min(1f, (float)(leftVibrationValue.AmplitudeLow * 0.15 + leftVibrationValue.AmplitudeHigh * 0.85));
gamepad.Rumble(low, high, 0xFFFFFFFF);
}
Logger.Debug?.Print(LogClass.Hid, $"Effect for {controllerConfig.PlayerIndex} " +
// Value=value/multiplier * multiplier (result)
$"L.low.amp={leftVibrationValue.AmplitudeLow / controllerConfig.Rumble.WeakRumble} * {controllerConfig.Rumble.WeakRumble} ({leftVibrationValue.AmplitudeLow}), " +
$"L.high.amp={leftVibrationValue.AmplitudeHigh / controllerConfig.Rumble.WeakRumble} * {controllerConfig.Rumble.WeakRumble} ({leftVibrationValue.AmplitudeHigh}), " +
$"L.low.freq={leftVibrationValue.FrequencyLow / controllerConfig.Rumble.WeakRumble} * {controllerConfig.Rumble.WeakRumble} ({leftVibrationValue.FrequencyLow}), " +
$"L.high.freq={leftVibrationValue.FrequencyHigh / controllerConfig.Rumble.WeakRumble} * {controllerConfig.Rumble.WeakRumble} ({leftVibrationValue.FrequencyHigh}), " +
$"R.low.amp={rightVibrationValue.AmplitudeLow / controllerConfig.Rumble.StrongRumble} * {controllerConfig.Rumble.StrongRumble} ({rightVibrationValue.AmplitudeLow}), " +
$"R.high.amp={rightVibrationValue.AmplitudeHigh / controllerConfig.Rumble.StrongRumble} * {controllerConfig.Rumble.StrongRumble} ({rightVibrationValue.AmplitudeHigh}), " +
$"R.low.freq={rightVibrationValue.FrequencyLow / controllerConfig.Rumble.StrongRumble} * {controllerConfig.Rumble.StrongRumble} ({rightVibrationValue.FrequencyLow}), " +
$"R.high.freq={rightVibrationValue.FrequencyHigh / controllerConfig.Rumble.StrongRumble} * {controllerConfig.Rumble.StrongRumble} ({rightVibrationValue.FrequencyHigh})");
}
private void ConfigureDynamicGamepads(IGamepadDriver keyboardDriver, IGamepadDriver gamepadDriver, InputConfig config)
{
AssignedInputDevice assignedKeyboard = _playerInputAssignment?.Devices.FirstOrDefault(device => device.Type == AssignedInputDeviceType.Keyboard);
if (!string.IsNullOrEmpty(assignedKeyboard?.Id))
{
_keyboardGamepad = OpenSingleGamepad(keyboardDriver, assignedKeyboard.Id, true);
}
foreach (AssignedInputDevice assignedController in ResolveDynamicControllerAssignments(gamepadDriver, config))
{
IGamepad controllerGamepad = OpenSingleGamepad(gamepadDriver, assignedController.Id, false);
if (controllerGamepad != null)
{
_assignedControllerGamepads.Add(controllerGamepad);
_assignedControllerConfigs.Add(null);
_previousControllerStates.Add(default);
}
}
_controllerGamepad = _assignedControllerGamepads.FirstOrDefault();
GamepadDriver = null;
Id = _assignedControllerGamepads.FirstOrDefault()?.Id ?? config.Id;
}
private IEnumerable<AssignedInputDevice> ResolveDynamicControllerAssignments(IGamepadDriver gamepadDriver, InputConfig config)
{
if (gamepadDriver == null)
{
yield break;
}
List<AssignedInputDevice> assignedControllers = _playerInputAssignment?.Devices
.Where(device => device.Type == AssignedInputDeviceType.Controller)
.ToList() ?? [];
if (_playerInputAssignment?.EnableDynamicInputSwap == true)
{
foreach (AssignedInputDevice assignedController in assignedControllers)
{
foreach (string gamepadId in gamepadDriver.GamepadsIds)
{
if (gamepadId == assignedController.Id)
{
yield return assignedController;
break;
}
}
}
VibrationValue leftVibrationValue = dualVibrationValue.Item1;
VibrationValue rightVibrationValue = dualVibrationValue.Item2;
yield break;
}
leftVibrationValue.AmplitudeLow *= controllerConfig.Rumble.WeakRumble;
leftVibrationValue.AmplitudeHigh *= controllerConfig.Rumble.StrongRumble;
rightVibrationValue.AmplitudeLow *= controllerConfig.Rumble.WeakRumble;
rightVibrationValue.AmplitudeHigh *= controllerConfig.Rumble.StrongRumble;
if (!controllerConfig.Rumble.UseHDRumble || _gamepad?.HDRumble(leftVibrationValue, rightVibrationValue) == false)
if (config is StandardControllerInputConfig)
{
foreach (string gamepadId in gamepadDriver.GamepadsIds)
{
float low = Math.Min(1f, (float)((rightVibrationValue.AmplitudeLow * 0.85 + rightVibrationValue.AmplitudeHigh * 0.15)));
float high = Math.Min(1f, (float)((leftVibrationValue.AmplitudeLow * 0.15 + leftVibrationValue.AmplitudeHigh * 0.85)));
_gamepad?.Rumble(low, high, 0xFFFFFFFF);
if (gamepadId == config.Id)
{
yield return new AssignedInputDevice
{
Type = AssignedInputDeviceType.Controller,
Id = gamepadId,
};
yield break;
}
}
}
if (!gamepadDriver.GamepadsIds.IsEmpty)
{
yield return new AssignedInputDevice
{
Type = AssignedInputDeviceType.Controller,
Id = gamepadDriver.GamepadsIds[0],
};
}
}
private static IGamepad OpenSingleGamepad(IGamepadDriver driver, string id, bool keyboard)
{
if (driver == null || string.IsNullOrEmpty(id))
{
return null;
}
if (keyboard && driver is IKeyboardModeDriver keyboardModeDriver)
{
return keyboardModeDriver.GetKeyboard(id, KeyboardInputMode.Physical);
}
return driver.GetGamepad(id);
}
private void UpdateDynamicConfigurations(InputConfig config)
{
if (config is StandardKeyboardInputConfig keyboardConfig)
{
AssignedInputDevice assignedKeyboard = _playerInputAssignment?.Devices.FirstOrDefault(device => device.Type == AssignedInputDeviceType.Keyboard);
_keyboardConfig = ResolveKeyboardConfiguration(assignedKeyboard, keyboardConfig, _keyboardGamepad);
_assignedControllerConfigs.Clear();
foreach (IGamepad controllerGamepad in _assignedControllerGamepads)
{
AssignedInputDevice assignedController = _playerInputAssignment?.Devices.FirstOrDefault(device =>
device.Type == AssignedInputDeviceType.Controller &&
device.Id == controllerGamepad.Id);
_assignedControllerConfigs.Add(ResolveControllerConfiguration(assignedController, keyboardConfig, controllerGamepad));
}
Logger.Debug?.Print(LogClass.Hid, $"Effect for {controllerConfig.PlayerIndex} " +
// Value=value/multiplier * multiplier (result)
$"L.low.amp={leftVibrationValue.AmplitudeLow / controllerConfig.Rumble.WeakRumble} * {controllerConfig.Rumble.WeakRumble} ({leftVibrationValue.AmplitudeLow}), " +
$"L.high.amp={leftVibrationValue.AmplitudeHigh / controllerConfig.Rumble.WeakRumble} * {controllerConfig.Rumble.WeakRumble} ({leftVibrationValue.AmplitudeHigh}), " +
$"L.low.freq={leftVibrationValue.FrequencyLow / controllerConfig.Rumble.WeakRumble} * {controllerConfig.Rumble.WeakRumble} ({leftVibrationValue.FrequencyLow}), " +
$"L.high.freq={leftVibrationValue.FrequencyHigh / controllerConfig.Rumble.WeakRumble} * {controllerConfig.Rumble.WeakRumble} ({leftVibrationValue.FrequencyHigh}), " +
$"R.low.amp={rightVibrationValue.AmplitudeLow / controllerConfig.Rumble.StrongRumble} * {controllerConfig.Rumble.StrongRumble} ({rightVibrationValue.AmplitudeLow}), " +
$"R.high.amp={rightVibrationValue.AmplitudeHigh / controllerConfig.Rumble.StrongRumble} * {controllerConfig.Rumble.StrongRumble} ({rightVibrationValue.AmplitudeHigh}), " +
$"R.low.freq={rightVibrationValue.FrequencyLow / controllerConfig.Rumble.StrongRumble} * {controllerConfig.Rumble.StrongRumble} ({rightVibrationValue.FrequencyLow}), " +
$"R.high.freq={rightVibrationValue.FrequencyHigh / controllerConfig.Rumble.StrongRumble} * {controllerConfig.Rumble.StrongRumble} ({rightVibrationValue.FrequencyHigh})");
_controllerConfig = _assignedControllerConfigs.FirstOrDefault();
}
else if (config is StandardControllerInputConfig controllerConfig)
{
_assignedControllerConfigs.Clear();
foreach (IGamepad controllerGamepad in _assignedControllerGamepads)
{
AssignedInputDevice assignedController = _playerInputAssignment?.Devices.FirstOrDefault(device =>
device.Type == AssignedInputDeviceType.Controller &&
device.Id == controllerGamepad.Id);
_assignedControllerConfigs.Add(ResolveControllerConfiguration(assignedController, controllerConfig, controllerGamepad));
}
_controllerConfig = _assignedControllerConfigs.FirstOrDefault() ?? controllerConfig;
if (_keyboardGamepad != null)
{
AssignedInputDevice assignedKeyboard = _playerInputAssignment?.Devices.FirstOrDefault(device => device.Type == AssignedInputDeviceType.Keyboard);
_keyboardConfig = ResolveKeyboardConfiguration(assignedKeyboard, controllerConfig, _keyboardGamepad);
}
else
{
_keyboardConfig = null;
}
}
}
private StandardKeyboardInputConfig ResolveKeyboardConfiguration(AssignedInputDevice assignedKeyboard, InputConfig baseConfig, IGamepad keyboardGamepad)
{
if (keyboardGamepad == null)
{
return null;
}
if (TryLoadAssignedProfile<StandardKeyboardInputConfig>(assignedKeyboard, KeyboardString, keyboardGamepad, baseConfig, out StandardKeyboardInputConfig profileConfig))
{
return profileConfig;
}
if (baseConfig is StandardKeyboardInputConfig keyboardBaseConfig)
{
StandardKeyboardInputConfig clonedConfig = CloneConfig(keyboardBaseConfig);
if (clonedConfig != null)
{
clonedConfig.Id = keyboardGamepad.Id;
clonedConfig.Name = keyboardGamepad.Name;
clonedConfig.PlayerIndex = baseConfig.PlayerIndex;
clonedConfig.EnableDynamicGamepadSwap = true;
return clonedConfig;
}
}
StandardKeyboardInputConfig defaultConfig = InputConfigDefaults.CreateDefaultKeyboardConfiguration(
keyboardGamepad.Id,
keyboardGamepad.Name,
baseConfig.ControllerType,
baseConfig.PlayerIndex);
defaultConfig.EnableDynamicGamepadSwap = true;
return defaultConfig;
}
private StandardControllerInputConfig ResolveControllerConfiguration(AssignedInputDevice assignedController, InputConfig baseConfig, IGamepad controllerGamepad)
{
if (controllerGamepad == null)
{
return null;
}
if (TryLoadAssignedProfile<StandardControllerInputConfig>(assignedController, ControllerString, controllerGamepad, baseConfig, out StandardControllerInputConfig profileConfig))
{
return profileConfig;
}
if (baseConfig is StandardControllerInputConfig controllerBaseConfig)
{
StandardControllerInputConfig clonedConfig = CloneConfig(controllerBaseConfig);
if (clonedConfig != null)
{
clonedConfig.Id = controllerGamepad.Id;
clonedConfig.Name = controllerGamepad.Name;
clonedConfig.PlayerIndex = baseConfig.PlayerIndex;
clonedConfig.EnableDynamicGamepadSwap = true;
return clonedConfig;
}
}
StandardControllerInputConfig defaultConfig = InputConfigDefaults.CreateDefaultControllerConfiguration(
controllerGamepad.Id,
controllerGamepad.Name,
baseConfig.ControllerType,
baseConfig.PlayerIndex,
controllerGamepad.Name?.Contains("Nintendo") == true);
defaultConfig.EnableDynamicGamepadSwap = true;
return defaultConfig;
}
private static T CloneConfig<T>(T config) where T : InputConfig
{
return JsonHelper.Deserialize(
JsonHelper.Serialize(config, _serializerContext.InputConfig),
_serializerContext.InputConfig) as T;
}
private static bool TryLoadAssignedProfile<T>(AssignedInputDevice assignedDevice, string profileDirectory, IGamepad gamepad, InputConfig baseConfig, out T config)
where T : InputConfig
{
config = null;
if (string.IsNullOrWhiteSpace(assignedDevice?.ProfileName))
{
return false;
}
string path = Path.Combine(AppDataManager.ProfilesDirPath, profileDirectory, assignedDevice.ProfileName + ".json");
if (!File.Exists(path))
{
return false;
}
try
{
config = JsonHelper.DeserializeFromFile(path, _serializerContext.InputConfig) as T;
}
catch (JsonException)
{
return false;
}
catch (InvalidOperationException)
{
return false;
}
if (config == null)
{
return false;
}
config.Id = gamepad.Id;
config.Name = gamepad.Name;
config.PlayerIndex = baseConfig.PlayerIndex;
config.EnableDynamicGamepadSwap = true;
return true;
}
private void UpdateDynamic()
{
GamepadStateSnapshot keyboardState = _keyboardGamepad?.GetMappedStateSnapshot() ?? default;
bool keyboardHasInput = _keyboardGamepad != null && HasInput(keyboardState);
bool keyboardNewInput = _keyboardGamepad != null && HasNewInput(keyboardState, _previousKeyboardState);
int controllerWithNewInput = -1;
int controllerWithHeldInput = -1;
// Note: dynamic swap is "last input wins", so we scan every assigned controller
// and promote whichever one most recently produced a meaningful state change.
for (int i = 0; i < _assignedControllerGamepads.Count; i++)
{
IGamepad controllerGamepad = _assignedControllerGamepads[i];
GamepadStateSnapshot controllerState = controllerGamepad?.GetMappedStateSnapshot() ?? default;
if (HasNewInput(controllerState, _previousControllerStates[i]))
{
controllerWithNewInput = i;
}
if (controllerWithHeldInput == -1 && HasInput(controllerState))
{
controllerWithHeldInput = i;
}
_previousControllerStates[i] = controllerState;
}
if (keyboardNewInput && controllerWithNewInput == -1)
{
_activeInputSource = DynamicInputSource.Keyboard;
}
else if (controllerWithNewInput != -1 && !keyboardNewInput)
{
_activeInputSource = DynamicInputSource.Controller;
_activeControllerIndex = controllerWithNewInput;
}
else if (_activeInputSource == DynamicInputSource.Keyboard && !keyboardHasInput && controllerWithHeldInput != -1)
{
_activeInputSource = DynamicInputSource.Controller;
_activeControllerIndex = controllerWithHeldInput;
}
else if (_activeInputSource == DynamicInputSource.Controller && controllerWithHeldInput == -1 && keyboardHasInput)
{
_activeInputSource = DynamicInputSource.Keyboard;
}
else if (_activeInputSource == DynamicInputSource.None)
{
_activeInputSource = _config switch
{
StandardKeyboardInputConfig when _keyboardGamepad != null => DynamicInputSource.Keyboard,
StandardControllerInputConfig when _assignedControllerGamepads.Count > 0 => DynamicInputSource.Controller,
_ when keyboardHasInput => DynamicInputSource.Keyboard,
_ when controllerWithHeldInput != -1 => DynamicInputSource.Controller,
_ when _keyboardGamepad != null => DynamicInputSource.Keyboard,
_ when _assignedControllerGamepads.Count > 0 => DynamicInputSource.Controller,
_ => DynamicInputSource.None,
};
if (_activeInputSource == DynamicInputSource.Controller)
{
_activeControllerIndex = controllerWithHeldInput != -1 ? controllerWithHeldInput : 0;
}
}
UpdateActiveGamepad();
State = _activeInputSource switch
{
DynamicInputSource.Keyboard => keyboardState,
DynamicInputSource.Controller when _activeControllerIndex >= 0 && _activeControllerIndex < _previousControllerStates.Count => _previousControllerStates[_activeControllerIndex],
_ => default,
};
if (_activeConfig is StandardControllerInputConfig controllerConfig && _controllerGamepad != null && _activeInputSource == DynamicInputSource.Controller)
{
UpdateControllerMotion(_controllerGamepad, controllerConfig);
}
else
{
_leftMotionInput = null;
_rightMotionInput = null;
}
_previousKeyboardState = keyboardState;
}
private void UpdateActiveGamepad()
{
(_gamepad, _activeConfig, GamepadDriver) = _activeInputSource switch
{
DynamicInputSource.Keyboard => (_keyboardGamepad, _keyboardConfig, _keyboardDriver),
DynamicInputSource.Controller =>
(
_activeControllerIndex >= 0 && _activeControllerIndex < _assignedControllerGamepads.Count
? _assignedControllerGamepads[_activeControllerIndex]
: _assignedControllerGamepads.FirstOrDefault(),
_activeControllerIndex >= 0 && _activeControllerIndex < _assignedControllerConfigs.Count
? _assignedControllerConfigs[_activeControllerIndex]
: _assignedControllerConfigs.FirstOrDefault(),
_controllerDriver
),
_ => ((IGamepad?)null, (InputConfig?)null, (IGamepadDriver?)null)
};
_controllerGamepad = _gamepad;
}
private void UpdateControllerMotion(IGamepad gamepad, StandardControllerInputConfig controllerConfig)
{
if (gamepad == null || controllerConfig?.Motion == null || !controllerConfig.Motion.EnableMotion)
{
_leftMotionInput = null;
_rightMotionInput = null;
return;
}
if (controllerConfig.Motion.MotionBackend == MotionInputBackendType.GamepadDriver)
{
if ((gamepad.Features & GamepadFeaturesFlag.Motion) != 0)
{
_leftMotionInput ??= new MotionInput();
_rightMotionInput ??= new MotionInput();
Vector3 accelerometer = gamepad.GetMotionData(MotionInputId.Accelerometer);
Vector3 gyroscope = gamepad.GetMotionData(MotionInputId.Gyroscope);
accelerometer = new Vector3(accelerometer.X, -accelerometer.Z, accelerometer.Y);
gyroscope = new Vector3(gyroscope.X, -gyroscope.Z, gyroscope.Y);
_leftMotionInput.Update(accelerometer, gyroscope, (ulong)PerformanceCounter.ElapsedNanoseconds / 1000, controllerConfig.Motion.Sensitivity, (float)controllerConfig.Motion.GyroDeadzone);
if (controllerConfig.ControllerType == ConfigControllerType.JoyconPair)
{
if (gamepad.Id == "JoyConPair")
{
Vector3 rightAccelerometer = gamepad.GetMotionData(MotionInputId.SecondAccelerometer);
Vector3 rightGyroscope = gamepad.GetMotionData(MotionInputId.SecondGyroscope);
rightAccelerometer = new Vector3(rightAccelerometer.X, -rightAccelerometer.Z, rightAccelerometer.Y);
rightGyroscope = new Vector3(rightGyroscope.X, -rightGyroscope.Z, rightGyroscope.Y);
_rightMotionInput.Update(rightAccelerometer, rightGyroscope, (ulong)PerformanceCounter.ElapsedNanoseconds / 1000, controllerConfig.Motion.Sensitivity, (float)controllerConfig.Motion.GyroDeadzone);
}
else
{
_rightMotionInput = _leftMotionInput;
}
}
}
else
{
_leftMotionInput = null;
_rightMotionInput = null;
}
}
else if (controllerConfig.Motion.MotionBackend == MotionInputBackendType.CemuHook && controllerConfig.Motion is CemuHookMotionConfigController cemuControllerConfig)
{
int clientId = (int)controllerConfig.PlayerIndex;
_cemuHookClient.RegisterClient(clientId, cemuControllerConfig.DsuServerHost, cemuControllerConfig.DsuServerPort);
_cemuHookClient.RequestData(clientId, cemuControllerConfig.Slot);
_cemuHookClient.TryGetData(clientId, cemuControllerConfig.Slot, out _leftMotionInput);
if (controllerConfig.ControllerType == ConfigControllerType.JoyconPair)
{
if (!cemuControllerConfig.MirrorInput)
{
_cemuHookClient.RequestData(clientId, cemuControllerConfig.AltSlot);
_cemuHookClient.TryGetData(clientId, cemuControllerConfig.AltSlot, out _rightMotionInput);
}
else
{
_rightMotionInput = _leftMotionInput;
}
}
}
}
private static bool HasInput(GamepadStateSnapshot state)
{
for (GamepadButtonInputId inputId = GamepadButtonInputId.A; inputId < GamepadButtonInputId.Count; inputId++)
{
if (state.IsPressed(inputId))
{
return true;
}
}
return StickIsActive(state.GetStick(StickInputId.Left)) || StickIsActive(state.GetStick(StickInputId.Right));
}
private static bool HasNewInput(GamepadStateSnapshot current, GamepadStateSnapshot previous)
{
for (GamepadButtonInputId inputId = GamepadButtonInputId.A; inputId < GamepadButtonInputId.Count; inputId++)
{
if (current.IsPressed(inputId) && !previous.IsPressed(inputId))
{
return true;
}
}
return StickBecameActive(current.GetStick(StickInputId.Left), previous.GetStick(StickInputId.Left)) ||
StickBecameActive(current.GetStick(StickInputId.Right), previous.GetStick(StickInputId.Right));
}
private static bool StickIsActive((float X, float Y) stick)
{
const float Threshold = 0.2f;
return MathF.Abs(stick.X) > Threshold || MathF.Abs(stick.Y) > Threshold;
}
private static bool StickBecameActive((float X, float Y) current, (float X, float Y) previous)
{
bool currentActive = StickIsActive(current);
bool previousActive = StickIsActive(previous);
return currentActive && (!previousActive || MathF.Abs(current.X - previous.X) > 0.1f || MathF.Abs(current.Y - previous.Y) > 0.1f);
}
private void DisposeOpenedGamepads()
{
if (!ReferenceEquals(_gamepad, _keyboardGamepad) && !_assignedControllerGamepads.Contains(_gamepad))
{
_gamepad?.Dispose();
}
_keyboardGamepad?.Dispose();
foreach (IGamepad controllerGamepad in _assignedControllerGamepads.Distinct())
{
controllerGamepad?.Dispose();
}
}
}

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,98 @@ namespace Ryujinx.Input.HLE
private void HandleOnGamepadDisconnected(string obj)
{
// Force input reload
List<InputConfig> requestedInputConfig;
List<PlayerInputAssignment> playerInputAssignments;
bool enableKeyboard;
bool enableMouse;
lock (_lock)
{
// Forcibly disconnect any controllers with this ID.
for (int i = 0; i < _controllers.Length; i++)
{
if (_controllers[i]?.Id == obj)
if (_controllers[i]?.HasAssignedControllerId(obj) == true)
{
_controllers[i]?.Dispose();
_controllers[i] = null;
}
}
ReloadConfiguration(_requestedInputConfig, _enableKeyboard, _enableMouse);
requestedInputConfig = _requestedInputConfig;
playerInputAssignments = _playerInputAssignments;
enableKeyboard = _enableKeyboard;
enableMouse = _enableMouse;
}
// Force input reload.
ReloadConfiguration(requestedInputConfig, playerInputAssignments, enableKeyboard, enableMouse);
}
private void HandleOnGamepadConnected(string _)
private void HandleOnGamepadConnected(string id)
{
lock (_lock)
{
for (int i = 0; i < _controllers.Length; i++)
{
if (_controllers[i] != null && PlayerHasAssignedControllerId((PlayerIndex)i, id))
{
_controllers[i]?.Dispose();
_controllers[i] = null;
}
}
}
// Force input reload
ReloadConfiguration(_requestedInputConfig, _enableKeyboard, _enableMouse);
ReloadConfiguration(_requestedInputConfig, _playerInputAssignments, _enableKeyboard, _enableMouse);
}
private bool PlayerHasAssignedControllerId(PlayerIndex playerIndex, string id)
{
if (string.IsNullOrEmpty(id))
{
return false;
}
InputConfig inputConfig = _requestedInputConfig.FirstOrDefault(config => (int)config.PlayerIndex == (int)playerIndex);
if (inputConfig == null)
{
return false;
}
PlayerInputAssignment playerInputAssignment = GetPlayerInputAssignment(inputConfig);
return playerInputAssignment.EnableDynamicInputSwap &&
playerInputAssignment.Devices.Any(device =>
device.Type == AssignedInputDeviceType.Controller &&
string.Equals(device.Id, id, StringComparison.Ordinal));
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private bool DriverConfigurationUpdate(ref NpadController controller, InputConfig config)
private bool DriverConfigurationUpdate(ref NpadController controller, InputConfig config, PlayerInputAssignment playerInputAssignment)
{
IGamepadDriver targetDriver =
config is StandardKeyboardInputConfig
? _keyboardDriver
: _gamepadDriver;
Debug.Assert(_keyboardDriver != null, "Keyboard driver is not initialized!");
Debug.Assert(_gamepadDriver != null, "Gamepad driver is not initialized!");
Debug.Assert(targetDriver != null, "Unknown input configuration!");
if (controller.GamepadDriver != targetDriver || controller.Id != config.Id)
if (!controller.MatchesDriverConfiguration(config, playerInputAssignment))
{
return controller.UpdateDriverConfiguration(targetDriver, config);
return controller.UpdateDriverConfiguration(_keyboardDriver, _gamepadDriver, config, playerInputAssignment);
}
return controller.GamepadDriver != null;
return controller.IsAvailable;
}
public void ReloadConfiguration(List<InputConfig> inputConfig, bool enableKeyboard, bool enableMouse)
{
ReloadConfiguration(inputConfig, [], enableKeyboard, enableMouse);
}
public void ReloadConfiguration(List<InputConfig> inputConfig, List<PlayerInputAssignment> playerInputAssignments, bool enableKeyboard, bool enableMouse)
{
lock (_lock)
{
_requestedInputConfig = inputConfig?.ToList() ?? [];
_playerInputAssignments = playerInputAssignments?.ToList() ?? [];
NpadController[] oldControllers = _controllers.ToArray();
@@ -146,14 +194,17 @@ namespace Ryujinx.Input.HLE
}
InputConfig activeConfig = inputConfigEntry;
bool isValid = DriverConfigurationUpdate(ref controller, activeConfig);
PlayerInputAssignment playerInputAssignment = GetPlayerInputAssignment(inputConfigEntry);
bool isValid = DriverConfigurationUpdate(ref controller, activeConfig, playerInputAssignment);
if (!isValid &&
!playerInputAssignment.EnableDynamicInputSwap &&
inputConfigEntry is StandardControllerInputConfig &&
TryGetKeyboardFallback(inputConfigEntry, out StandardKeyboardInputConfig fallbackConfig))
{
activeConfig = fallbackConfig;
isValid = DriverConfigurationUpdate(ref controller, activeConfig);
isValid = DriverConfigurationUpdate(ref controller, activeConfig, playerInputAssignment);
}
if (!isValid)
@@ -184,6 +235,54 @@ namespace Ryujinx.Input.HLE
}
}
private PlayerInputAssignment GetPlayerInputAssignment(InputConfig inputConfig)
{
PlayerInputAssignment playerInputAssignment = _playerInputAssignments.FirstOrDefault(assignment => assignment.PlayerIndex == inputConfig.PlayerIndex);
if (playerInputAssignment != null)
{
PlayerInputAssignment normalizedAssignment = PlayerInputAssignmentHelper.Normalize(
playerInputAssignment,
PlayerInputAssignmentHelper.CreatePrimaryDevice(inputConfig));
if (normalizedAssignment.EnableDynamicInputSwap || normalizedAssignment.Devices.Count > 0)
{
return normalizedAssignment;
}
}
// Note: older configs only know about a single saved device per player,
// so we synthesize a routing entry here until the user saves explicit assignments.
playerInputAssignment = new PlayerInputAssignment
{
PlayerIndex = inputConfig.PlayerIndex,
EnableDynamicInputSwap = inputConfig.EnableDynamicGamepadSwap,
};
AssignedInputDevice primaryDevice = PlayerInputAssignmentHelper.CreatePrimaryDevice(inputConfig);
if (primaryDevice != null)
{
playerInputAssignment.Devices.Add(primaryDevice);
}
if (playerInputAssignment.EnableDynamicInputSwap && inputConfig is StandardControllerInputConfig)
{
string keyboardId = _keyboardDriver.GamepadsIds.IsEmpty ? null : _keyboardDriver.GamepadsIds[0];
if (!string.IsNullOrWhiteSpace(keyboardId))
{
playerInputAssignment.Devices.Add(new AssignedInputDevice
{
Type = AssignedInputDeviceType.Keyboard,
Id = keyboardId,
});
}
}
return playerInputAssignment;
}
private bool TryGetKeyboardFallback(InputConfig inputConfig, out StandardKeyboardInputConfig fallbackConfig)
{
fallbackConfig = null;
@@ -210,6 +309,8 @@ namespace Ryujinx.Input.HLE
inputConfig.ControllerType,
inputConfig.PlayerIndex);
fallbackConfig.EnableDynamicGamepadSwap = inputConfig.EnableDynamicGamepadSwap;
return true;
}
@@ -257,11 +358,16 @@ namespace Ryujinx.Input.HLE
}
public void Initialize(Switch device, List<InputConfig> inputConfig, bool enableKeyboard, bool enableMouse)
{
Initialize(device, inputConfig, [], enableKeyboard, enableMouse);
}
public void Initialize(Switch device, List<InputConfig> inputConfig, List<PlayerInputAssignment> playerInputAssignments, bool enableKeyboard, bool enableMouse)
{
_device = device;
_device.Configuration.RefreshInputConfig = RefreshInputConfigForHLE;
ReloadConfiguration(inputConfig, enableKeyboard, enableMouse);
ReloadConfiguration(inputConfig, playerInputAssignments, enableKeyboard, enableMouse);
}
public void Update(float aspectRatio = 1)
@@ -286,7 +392,7 @@ namespace Ryujinx.Input.HLE
// Do we allow input updates and is a controller connected?
if (_inputUpdateBlockCount == 0 && controller != null)
{
DriverConfigurationUpdate(ref controller, inputConfig);
DriverConfigurationUpdate(ref controller, inputConfig, GetPlayerInputAssignment(inputConfig));
controller.UpdateUserConfiguration(inputConfig);
controller.Update();
@@ -387,7 +493,9 @@ namespace Ryujinx.Input.HLE
{
lock (_lock)
{
return _inputConfig.FirstOrDefault(x => x.PlayerIndex == (Common.Configuration.Hid.PlayerIndex)index);
NpadController controller = _controllers[index];
return controller?.ActiveConfig ?? _inputConfig.FirstOrDefault(x => x.PlayerIndex == (Common.Configuration.Hid.PlayerIndex)index);
}
}

View File

@@ -140,7 +140,7 @@ namespace Ryujinx.Input
StrongRumble = 1f,
WeakRumble = 1f,
EnableRumble = false,
UseHDRumble = true,
UseHDRumble = false
},
};
}

View File

@@ -0,0 +1,216 @@
{
"mario": {
"1": {
"1": "Acorn Plains Way",
"2": "Tilted Tunnel",
"21": "Crushing-Cogs Tower",
"3": "Yoshi Hill",
"4": "Mushroom Heights",
"5": "Rise of the Piranha Plants",
"23": "Lemmy's Swingback Castle",
"13": "Blooper's Secret Lair"
},
"2": {
"1": "Stone-Eye Zone",
"2": "Perilous Pokey Cave",
"3": "Fire Snake Cavern",
"21": "Stoneslide Tower",
"4": "Spike's Spouting Sands",
"5": "Dry Desert Mushrooms",
"6": "Blooming Lakitus",
"23": "Morton's Compactor Castle",
"14": "Piranha Plants on Ice"
},
"3": {
"1": "Waterspout Beach",
"2": "Tropical Refresher",
"21": "Giant Skewer Tower",
"20": "Haunted Shipwreck",
"3": "Above the Cheep Cheep Seas",
"4": "Urchin Shoals",
"5": "Dragoneel's Undersea Grotto",
"23": "Larry's Torpedo Castle",
"15": "Skyward Stalk"
},
"4": {
"1": "Spinning-Star Sky",
"2": "Cooligan Fields",
"21": "Freezing-Rain Tower",
"3": "Prickly Goombas!",
"4": "Scaling the Mountainside",
"5": "Icicle Caverns",
"20": "Swaying Ghost House",
"23": "Wendy's Shifting Castle",
"16": "Fliprus Lake"
},
"5": {
"37": "The Mighty Cannonship",
"1": "Jungle of the Giants",
"2": "Bridge over Poisoned Waters",
"3": "Bramball Woods",
"21": "Snake Block Tower",
"20": "Which-Way Labyrinth",
"4": "Painted Swampland",
"5": "Deepsea Ruins",
"6": "Seesaw Bridge",
"7": "Wiggler Stampede",
"23": "Iggy's Volcanic Castle",
"17": "Flight of the Para-Beetles"
},
"6": {
"1": "Fuzzy Clifftop",
"2": "Porcupuffer Falls",
"21": "Grinding-Stone Tower",
"3": "Wadlewing's Nest",
"4": "Light Blocks, Dark Tower",
"5": "Walking Piranha Plants!",
"6": "Thrilling Spine Coaster",
"22": "Screwtop Tower",
"7": "Shifting-Floor Cave",
"23": "Roy's Conveyor Castle"
},
"7": {
"1": "Land of Flying Blocks",
"2": "Seesaw Shrooms",
"3": "Switchback Hill",
"21": "Slide Lift Tower",
"20": "Spinning Spirit House",
"4": "Bouncy Cloud Boomerangs",
"5": "A Quick Dip in the Sky",
"6": "Snaking above Mist Valley",
"23": "Ludwig's Clockwork Castle",
"37": "Boarding the Airship"
},
"8": {
"1": "Meteor Moat",
"2": "Magma-River Cruise",
"3": "Rising Tides of Lava",
"4": "firefall Cliffs",
"42": "Red-Hot Elevator Ride",
"43": "The Final Battle"
},
"9": {
"1": "Spine-Tingling Spine Coaster",
"2": "Run for It",
"3": "Swim for Your Life!",
"4": "Hammerswing Caverns",
"5": "Spinning Platforms of Doom",
"6": "Fire Bar Cliffs",
"7": "Lakitu! Lakitu! Lakitu!",
"8": "Pendulum Castle",
"9": "Follow That Shell!"
},
"11": {
"1": "",
"2": "",
"3": "",
"4": "",
"5": "",
"6": "",
"7": "",
"8": ""
}
},
"luigi": {
"1": {
"1": "Waddlewing Warning!",
"2": "Crooked Cavern",
"21": "Flame-Gear Tower",
"3": "Rolling Yoshi Hills",
"4": "Piranha Heights",
"5": "Piranha Gardens",
"23": "Lemmy's Lights-Out Castle",
"13": "Cheep Chomp Chase"
},
"2": {
"1": "Spike's Tumbling Desert",
"2": "Underground Grrrols",
"3": "Piranhas in the Dark",
"21": "Wind-Up Tower",
"4": "The Walls Have Eyes",
"5": "Stone Spike Conveyors",
"6": "Spinning Sandstones",
"23": "Morton's Lava-Block Castle",
"14": "Slippery Rope Ladders"
},
"3": {
"1": "Huckit Beach Resort",
"2": "Urchin Reef Romp",
"21": "Shish-Kebab Tower",
"20": "Haunted Cargo Hold",
"3": "Waterspout Sprint",
"4": "The Great Geysers",
"5": "Dragoneel Depths",
"23": "Larry's Trigger-Happy Castle",
"15": "Beanstalk Jungle"
},
"4": {
"1": "Broozers and Barrels",
"2": "Cooligan Shrooms",
"21": "Icicle Tower",
"3": "Fire and Ice",
"4": "Weighty Waddlewings",
"5": "Ice-Slide Expressway",
"20": "Peek-a-Boo Ghost House",
"23": "Wendy's Thwomp Castle",
"16": "Fliprus Floes"
},
"5": {
"1": "Giant Swing-Along",
"2": "Dancing Blocks, Poison Swamp",
"3": "Heart of Bramball Woods",
"21": "Stone-Snake Tower",
"20": "Boo's Favorite Haunt",
"4": "Painted Pipeworks",
"5": "Deepsea Stone-Eyes",
"6": "Sumo Bro Bridge",
"7": "Wiggler Floodlands",
"23": "Iggy's Swinging-Chains Castle",
"17": "Para-Beetle Parade"
},
"6": {
"1": "Mount Fuzzy",
"2": "Porcupuffer Cavern",
"21": "Smashing-Stone Tower",
"3": "Spike's Seesaws",
"4": "Light-Up-Lift Tower",
"5": "Rising Piranhas",
"6": "Spine Coaster Stowaways",
"22": "Sumo Bro's Spinning Tower",
"7": "Switch-Lift Express",
"23": "Roy's Ironclad Castle"
},
"7": {
"1": "Frozen Fuzzies",
"2": "Wiggler Rodeo",
"3": "Rainbow Skywalk",
"21": "Stonecrush Tower",
"20": "Vanishing Ghost House",
"4": "Above The Bouncy Clouds",
"5": "Flame Chomp Ferris Wheel",
"6": "Three-Headed Snake Block",
"23": "Ludwig's Block-Press Castle",
"37": "Bowser Jr. Showdown"
},
"8": {
"1": "Magma Moat",
"2": "Magmaw River Cruise",
"3": "Hot Cogs",
"4": "Firefall Rising",
"42": "Current Event",
"43": "The Final Battle"
},
"9": {
"1": "Spine Coaster Connections",
"2": "P Switch Peril",
"3": "Star Coin Deep Dive",
"4": "Hammerswing Hideout",
"5": "Under Construction",
"6": "Fire Bar Sprint",
"7": "Cloudy Capers",
"8": "Impossible Pendulums",
"9": "Flying Squirrel Ovation"
}
}
}

View File

@@ -0,0 +1,98 @@
{
"Locales": {
"ar_SA": [],
"de_DE": [],
"el_GR": [],
"en_US": [
"Ryubing is my middle name.",
"Giving it 110 percent!",
"I don't think therefore I don't am!",
"All hail Egg.",
"Insert cringy joke here.",
"ITS RYUBINGING TIME!",
"I hate Mondays...",
"Fantastical!",
"Now with 100% more humor!",
"'Not S&P approved' has been approved by S&P.",
"ARE YOU NOT ENTERTAINED?",
"It's an emulator!",
"Now the real game begins...",
"Cooked fresh since 2018!",
"Must've been the wind...",
"I used to be an adventurer like you before I took an arrow to the knee.",
"Ryubing!",
"May contain nuts!",
"May include occasional pop culture references!",
"100% organically grown!",
"Have a nice day : )",
"Spoats car!",
"Bottom text",
"Im sorry Dave. I'm afraid I can't do that.",
"That's no moon...",
"Sir, finishing this fight.",
"I see how it is...",
"Space! The final frontier!",
"If you could not tell already, I love making bad jokes : )",
"this.",
"Probably contains no baked beans.",
"Y'all ready for this?",
"Removed Herobrine.",
"Right to repair!",
"Programmed in C#!",
"Forgejo has dethroned Gitlab!",
"Any ideas what to put here?",
"Good morning!",
"Good afternoon!",
"Good evening!",
"I hope you are having a great day!",
"Please insert disc two!",
"I... AM RYUBING!",
"Ryubingin' it up",
"bing bing wahoo.",
"egg",
"No, lossless scaling is NOT supported.",
"How do you people do anything?",
"One dollar.",
"Somebody once told me!",
"Its that time of the year again!",
"Brewed from only the finest memes.",
"Async shader compilation would destroy my soul : (",
"Trans rights are human rights!",
":3",
"Patched ':3' splash replication glitch.",
"Please connect a controller!",
"Never gonna give you up!",
"The game was rigged from the start.",
"Ganon is watching you!",
"Now with 100% more JSON in the splash code!",
"Countless hours of fun!",
"Sorry, Link. I can't give credit. Come back when you're a little... mmmmmm... richer!",
"Do a barrel roll!",
"You've met with a terrible fate, haven't you?",
"Yahaha! You found me!",
"I would've been in real trouble if you hadn't shown up when you did, goro.",
"Stay fresh!",
"Yellow, black. Yellow, black. Yellow, black. Yellow, black. Ooh, black and yellow! Let's shake it up a little.",
"Whaaa? You came to see me again? That makes Beedle SO HAPPY!",
"Don't get cooked, stay off the hook!",
"Now with 100% more good vibes in the splash code!",
"It is Wednesday my dudes!"
],
"es_ES": [],
"fr_FR": [],
"he_IL": [],
"it_IT": [],
"ja_JP": [],
"ko_KR": [],
"no_NO": [],
"pl_PL": [],
"pt_BR": [],
"ru_RU": [],
"sv_SE": [],
"th_TH": [],
"tr_TR": [],
"uk_UA": [],
"zh_CN": [],
"zh_TW": []
}
}

View File

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

View File

@@ -0,0 +1,64 @@
using System.Collections.Generic;
using Ryujinx.Common.Logging;
using Gommon;
using Ryujinx.Ava.Systems.Configuration;
using System;
using System.Text.Json;
namespace Ryujinx.Common
{
public class SplashTextHelper
{
public static void PrintSplash()
{
Logger.Notice.Print(LogClass.Application, " ___ __ _ ");
Logger.Notice.Print(LogClass.Application, @" / _ \ __ __ __ __ / / (_) ___ ___ _");
Logger.Notice.Print(LogClass.Application, @" / , _/ / // // // / / _ \ / / / _ \ / _ `/");
Logger.Notice.Print(LogClass.Application, @"/_/|_| \_, / \_,_/ /_.__//_/ /_//_/ \_, / ");
Logger.Notice.Print(LogClass.Application, " /___/ /___/ ");
Logger.Notice.Print(LogClass.Application, "");
Logger.Notice.Print(LogClass.Application, GetSplash());
Logger.Notice.Print(LogClass.Application, "");
}
private static string s_finalSplash = "";
public static string GetSplash()
{
if (string.IsNullOrEmpty(s_finalSplash))
{
s_finalSplash = GetLangJson();
if (string.IsNullOrEmpty(s_finalSplash))
{
s_finalSplash = "Splash Text";
}
}
return $"{s_finalSplash}";
}
private static SplashLocales s_splashJson;
private static string GetLangJson()
{
try
{
string data;
data = EmbeddedResources.ReadAllText("Ryujinx/Assets/Splashes.json");
s_splashJson = JsonSerializer.Deserialize<SplashLocales>(data);
return s_splashJson.Locales[ConfigurationState.Instance.UI.LanguageCode.Value].GetRandomElement();
}
catch
{
return "";
}
}
private struct SplashLocales
{
public Dictionary<string, List<string>> Locales { get; set; }
}
}
}

View File

@@ -26,9 +26,9 @@ namespace Ryujinx.Ava.Common
internal class TrimmerWindow : Ryujinx.Common.Logging.XCIFileTrimmerLog
{
private readonly XciTrimmerViewModel _viewModel;
private readonly XCITrimmerViewModel _viewModel;
public TrimmerWindow(XciTrimmerViewModel viewModel)
public TrimmerWindow(XCITrimmerViewModel viewModel)
{
_viewModel = viewModel;
}

View File

@@ -437,13 +437,9 @@ namespace Ryujinx.Ava
internal static void PrintSystemInfo()
{
Logger.Notice.Print(LogClass.Application, " ___ __ _ ");
Logger.Notice.Print(LogClass.Application, @" / _ \ __ __ __ __ / / (_) ___ ___ _");
Logger.Notice.Print(LogClass.Application, @" / , _/ / // // // / / _ \ / / / _ \ / _ `/");
Logger.Notice.Print(LogClass.Application, @"/_/|_| \_, / \_,_/ /_.__//_/ /_//_/ \_, / ");
Logger.Notice.Print(LogClass.Application, " /___/ /___/ ");
// Print the ryubing logo + joke splash
SplashTextHelper.PrintSplash();
Logger.Notice.Print(LogClass.Application, $"{RyujinxApp.FullAppName} Version: {Version}");
Logger.Notice.Print(LogClass.Application, $".NET Runtime: {RuntimeInformation.FrameworkDescription}");
SystemInfo.Gather().Print();

View File

@@ -175,6 +175,8 @@
<EmbeddedResource Include="Assets\UIImages\Logo_Ryujinx.png" />
<EmbeddedResource Include="Assets\UIImages\Logo_Forgejo.png" />
<EmbeddedResource Include="Assets\UIImages\Logo_Ryujinx_AntiAlias.png" />
<EmbeddedResource Include="Assets\PlayReports\*.json" />
<EmbeddedResource Include="Assets\Splashes.json" />
</ItemGroup>
<ItemGroup>
<AdditionalFiles Include="..\..\assets\Locales\*.json" />

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

View File

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

View File

@@ -157,6 +157,8 @@ namespace Ryujinx.Ava.Systems.Configuration
Hid.DisableInputWhenOutOfFocus.Value = shouldLoadFromFile ? cff.DisableInputWhenOutOfFocus : Hid.DisableInputWhenOutOfFocus.Value; // Get from global config only
Hid.Hotkeys.Value = shouldLoadFromFile ? cff.Hotkeys : Hid.Hotkeys.Value; // Get from global config only
Hid.InputConfig.Value = cff.InputConfig ?? [] ;
Hid.PlayerInputAssignments.Value = cff.PlayerInputAssignments ?? [];
Hid.AllowDuplicateDeviceAssignment.Value = cff.AllowDuplicateDeviceAssignment;
Hid.RainbowSpeed.Value = cff.RainbowSpeed;
Multiplayer.LanInterfaceId.Value = cff.MultiplayerLanInterfaceId;
@@ -336,7 +338,7 @@ namespace Ryujinx.Ava.Systems.Configuration
EnableRumble = false,
StrongRumble = 1f,
WeakRumble = 1f,
UseHDRumble = true
UseHDRumble = false
};
}
}

View File

@@ -191,6 +191,11 @@ namespace Ryujinx.Ava.Systems.Configuration
/// </summary>
public ReactiveObject<bool> IsAscendingOrder { get; private set; }
/// <summary>
/// Show Dynamic Input Swap first-use warning
/// </summary>
public ReactiveObject<bool> ShowDynamicInputSwapWarning { get; private set; }
public UISection()
{
GuiColumns = new Columns();
@@ -210,6 +215,8 @@ namespace Ryujinx.Ava.Systems.Configuration
LanguageCode = new ReactiveObject<string>();
ShowConsole = new ReactiveObject<bool>();
ShowConsole.Event += static (_, e) => ConsoleHelper.SetConsoleWindowState(e.NewValue);
ShowDynamicInputSwapWarning = new ReactiveObject<bool>();
ShowDynamicInputSwapWarning.Value = true;
}
}
@@ -513,6 +520,19 @@ namespace Ryujinx.Ava.Systems.Configuration
/// </summary>
public ReactiveObject<List<InputConfig>> InputConfig { get; private set; }
/// <summary>
/// Player-level input routing assignments.
/// NOTE: This keeps dynamic input swap and multi-device ownership attached to the player,
/// not to the currently edited keyboard/controller profile.
/// </summary>
public ReactiveObject<List<PlayerInputAssignment>> PlayerInputAssignments { get; private set; }
/// <summary>
/// Whether to allow mapping the same input device to multiple players.
/// This is a global setting shared across all players.
/// </summary>
public ReactiveObject<bool> AllowDuplicateDeviceAssignment { get; private set; }
/// <summary>
/// The speed of spectrum cycling for the Rainbow LED feature.
/// </summary>
@@ -525,6 +545,8 @@ namespace Ryujinx.Ava.Systems.Configuration
DisableInputWhenOutOfFocus = new ReactiveObject<bool>();
Hotkeys = new ReactiveObject<KeyboardHotkeys>();
InputConfig = new ReactiveObject<List<InputConfig>>();
PlayerInputAssignments = new ReactiveObject<List<PlayerInputAssignment>>();
AllowDuplicateDeviceAssignment = new ReactiveObject<bool>();
RainbowSpeed = new ReactiveObject<float>();
RainbowSpeed.Event += (_, args) => Rainbow.Speed = args.NewValue;
}

View File

@@ -143,6 +143,8 @@ namespace Ryujinx.Ava.Systems.Configuration
DisableInputWhenOutOfFocus = Hid.DisableInputWhenOutOfFocus,
Hotkeys = Hid.Hotkeys,
InputConfig = Hid.InputConfig,
PlayerInputAssignments = Hid.PlayerInputAssignments,
AllowDuplicateDeviceAssignment = Hid.AllowDuplicateDeviceAssignment,
RainbowSpeed = Hid.RainbowSpeed,
GraphicsBackend = Graphics.GraphicsBackend,
PreferredGpu = Graphics.PreferredGpu,
@@ -332,6 +334,22 @@ namespace Ryujinx.Ava.Systems.Configuration
},
}
];
Hid.PlayerInputAssignments.Value =
[
new PlayerInputAssignment
{
PlayerIndex = PlayerIndex.Player1,
EnableDynamicInputSwap = false,
Devices =
[
new AssignedInputDevice
{
Type = AssignedInputDeviceType.Keyboard,
Id = "0",
},
],
},
];
Debug.EnableGdbStub.Value = false;
Debug.GdbStubPort.Value = 55555;
Debug.DebuggerSuspendOnStart.Value = false;

View File

@@ -1,10 +1,12 @@
using Gommon;
using Humanizer;
using MsgPack;
using Ryujinx.Common;
using System;
using System.Buffers.Binary;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
namespace Ryujinx.Ava.Systems.PlayReport
{
@@ -1116,5 +1118,87 @@ namespace Ryujinx.Ava.Systems.PlayReport
_ => "Wandering"
};
}
private static FormattedValue NsmbudRpc(SparseMultiValue values)
{
if (values.Matched.TryGetValue("WorldNo", out Value world) && values.Matched.TryGetValue("CourseNo", out Value course) | values.Matched.TryGetValue("GameModeType", out Value gamemode))
{
string worldstr = world.ToString();
string coursestr = course.ToString();
int courseint = Int32.Parse(coursestr);
string gamemodestr = gamemode.ToString();
try
{
Dictionary<string, Dictionary<string, Dictionary<string, string>>> output;
string data;
data = EmbeddedResources.ReadAllText("Ryujinx/Assets/PlayReports/nsmbud.json");
output = JsonSerializer.Deserialize<Dictionary<string, Dictionary<string, Dictionary<string, string>>>>(data);
if (SpecialMapNames(courseint) == "Hazard")
{
return $"Last Played: Course {worldstr}-Hazard";
}
string outputloc = output[MarioOrLuigiGamemode(gamemodestr)][worldstr][coursestr];
return $"Last Played: Course {worldstr}-{SpecialMapNames(courseint)} | {outputloc}";
}
catch
{
return FormattedValue.ForceReset;
}
}
if (values.Matched.TryGetValue("RlId", out Value RlId) | values.Matched.TryGetValue("TotalPlayTime", out Value TotalPlayTime))
{
return "At the main menu";
}
static string MarioOrLuigiGamemode(string? gamemode) => gamemode switch
{
"0" => "mario",
"1" => "luigi",
"4" => "mario",
"5" => "mario",
_ => gamemode
};
static string OtherGameMode(string? gamemode) => gamemode switch
{
"2" => "Boost Rush",
"3" => "Challenges",
"4" => "Coin Battle",
"5" => "Coin Battle Editor",
_ => ""
};
static string SpecialMapNames(int? course) => course switch
{
>= 1 and <= 9 => course.ToString(),
13 => "Shortcut",
14 => "Shortcut",
15 => "Shortcut",
16 => "Shortcut",
17 => "Shortcut",
20 => "Ghost",
21 => "Tower",
22 => "Tower",
23 => "Castle",
37 => "Airship",
42 => "Castle",
43 => "Castle",
_ => "Hazard"
};
// For future reference
// Tower course = 21, Castle course = 23,Haunted Mansion/ship = 20
// Tower course 2 (rock candy) = 22
// Peach castle 1 = 42, Peach final battle = 43
// airship = 37, jungle beetles = 17
// Glacier seals = 16, water leaf = 15
// desert ice = 14, acorn squid = 13
// all other course numbers are to be considered a hazard
return "";
}
}
}

View File

@@ -138,6 +138,12 @@ namespace Ryujinx.Ava.Systems.PlayReport
.WithDescription("based on gold count, report info only in the mii selector, and gamestage (progression)")
.AddSparseMultiValueFormatter(["gold", "secret", "stage"], MiitopiaRPC)
)
.AddSpec(
"0100ea80032ea000", // New Super Mario Bros U Deluxe
spec => spec
.WithDescription("based on world map return info.")
.AddSparseMultiValueFormatter(["WorldNo", "CourseNo", "RlId", "TotalPlayTime", "GameModeType"], NsmbudRpc)
)
);
private static string Playing(string game) => $"Playing {game}";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,72 @@
using Ryujinx.Ava.UI.ViewModels;
using Ryujinx.Common.Configuration.Hid;
using System;
namespace Ryujinx.Ava.UI.Models.Input
{
public class PlayerInputDeviceAssignmentItem : BaseModel
{
public DeviceType DeviceType { get; init; }
public string Id { get; init; }
public string Name { get; init; }
public AssignedInputDeviceType AssignedType =>
DeviceType == DeviceType.Keyboard ? AssignedInputDeviceType.Keyboard : AssignedInputDeviceType.Controller;
public bool IsAssigned
{
get;
set
{
field = value;
OnPropertyChanged();
}
}
public bool HasBoundProfileName => !string.IsNullOrWhiteSpace(BoundProfileName);
public string BoundProfileName
{
get;
set
{
field = value;
OnPropertyChanged();
OnPropertyChanged(nameof(HasBoundProfileName));
}
}
public bool HasAssignedToPlayers => !string.IsNullOrWhiteSpace(AssignedToPlayers);
/// <summary>
/// Comma-separated list of player names (e.g. "Player 1, Player 3")
/// that have this device assigned. Empty if no other player uses it.
/// </summary>
public string AssignedToPlayers
{
get;
set
{
field = value;
OnPropertyChanged();
OnPropertyChanged(nameof(HasAssignedToPlayers));
}
}
/// <summary>
/// True when this device is assigned to another player and
/// AllowDuplicateDeviceAssignment is disabled, making it unclickable.
/// </summary>
public bool IsDisabledByOtherPlayer
{
get;
set
{
field = value;
OnPropertyChanged();
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -76,6 +76,8 @@ namespace Ryujinx.Ava.UI.ViewModels
[ObservableProperty] public partial string LoadHeading { get; set; }
[ObservableProperty] public partial string CacheLoadStatus { get; set; }
[ObservableProperty] public partial string Splash { get; set; }
[ObservableProperty] public partial string DockedStatusText { get; set; }
@@ -494,6 +496,8 @@ namespace Ryujinx.Ava.UI.ViewModels
public bool HasCompatibilityEntry => SelectedApplication.HasPlayabilityInfo;
public bool IsXCIFile => Path.GetExtension(SelectedApplication.Path)?.ToLower() == ".xci";
public bool HasDlc => ApplicationLibrary.HasDlcs(SelectedApplication.Id);
public bool OpenUserSaveDirectoryEnabled => SelectedApplication.HasControlHolder &&
@@ -788,7 +792,7 @@ namespace Ryujinx.Ava.UI.ViewModels
{
ApplicationSort.Favorite => LocaleManager.Instance[LocaleKeys.CommonFavorite],
ApplicationSort.TitleId => LocaleManager.Instance[LocaleKeys.DlcManagerTableHeadingTitleIdLabel],
ApplicationSort.Title => LocaleManager.Instance[LocaleKeys.GameListHeaderApplication],
ApplicationSort.Title => LocaleManager.Instance[LocaleKeys.Common_Sort_NameLabel],
ApplicationSort.Developer => LocaleManager.Instance[LocaleKeys.GameListSortDeveloper],
ApplicationSort.LastPlayed => LocaleManager.Instance[LocaleKeys.GameListSortLastPlayed],
ApplicationSort.TotalTimePlayed => LocaleManager.Instance[LocaleKeys.GameListSortTimePlayed],
@@ -1256,6 +1260,7 @@ namespace Ryujinx.Ava.UI.ViewModels
break;
case ShaderCacheLoadingState shaderCacheState:
CacheLoadStatus = $"{current} / {total}";
Splash = $"\"{SplashTextHelper.GetSplash()}\"";
switch (shaderCacheState)
{
case ShaderCacheLoadingState.Start:
@@ -1932,13 +1937,13 @@ namespace Ryujinx.Ava.UI.ViewModels
if (version != null)
{
LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.StatusBar_FirmwareVersion, version.VersionString);
LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.StatusBar_FirmwareVersionLabel, version.VersionString);
hasApplet = version.Major > 3;
}
else
{
LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.StatusBar_FirmwareVersion, "NaN");
LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.StatusBar_FirmwareVersionLabel, "NaN");
}
IsAppletMenuActive = hasApplet;
@@ -2314,7 +2319,7 @@ namespace Ryujinx.Ava.UI.ViewModels
if (notifyUser != null)
{
await ContentDialogHelper.CreateWarningDialog(
LocaleManager.Instance[LocaleKeys.TrimXCIFileFailedPrimaryText],
LocaleManager.Instance[LocaleKeys.Dialog_XCITrimmer_TrimFailedMessage],
notifyUser
);
}
@@ -2340,18 +2345,18 @@ namespace Ryujinx.Ava.UI.ViewModels
if (trimmer.CanBeTrimmed)
{
double savings = (double)trimmer.DiskSpaceSavingsB / 1024.0 / 1024.0;
double currentFileSize = (double)trimmer.FileSizeB / 1024.0 / 1024.0;
double cartDataSize = (double)trimmer.DataSizeB / 1024.0 / 1024.0;
int savings = (int)Math.Round((double)trimmer.DiskSpaceSavingsB / 1024.0 / 1024.0);
int currentFileSize = (int)Math.Round((double)trimmer.FileSizeB / 1024.0 / 1024.0);
int cartDataSize = (int)Math.Round((double)trimmer.DataSizeB / 1024.0 / 1024.0);
string secondaryText = LocaleManager.Instance.UpdateAndGetDynamicValue(
LocaleKeys.TrimXCIFileDialogSecondaryText, currentFileSize, cartDataSize, savings);
LocaleKeys.Dialog_XCITrimmer_SecondaryMessage, currentFileSize.ToString("0"), cartDataSize.ToString("0"), savings.ToString("0"));
UserResult result = await ContentDialogHelper.CreateConfirmationDialog(
LocaleManager.Instance[LocaleKeys.TrimXCIFileDialogPrimaryText],
LocaleManager.Instance[LocaleKeys.Dialog_XCITrimmer_PrimaryMessage],
secondaryText,
LocaleManager.Instance[LocaleKeys.Continue],
LocaleManager.Instance[LocaleKeys.Cancel],
LocaleManager.Instance[LocaleKeys.GameListContextMenuTrimXCI]
LocaleManager.Instance[LocaleKeys.GameListContextMenu_TrimXCIButton]
);
if (result == UserResult.Yes)
@@ -2361,8 +2366,8 @@ namespace Ryujinx.Ava.UI.ViewModels
Dispatcher.UIThread.Post(() =>
{
StatusBarProgressStatusText =
LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.StatusBarXCIFileTrimming,
Path.GetFileName(filename));
LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.StatusBar_TrimmingXCILabel,
Path.GetFileNameWithoutExtension(filename));
StatusBarProgressStatusVisible = true;
StatusBarProgressMaximum = 1;
StatusBarProgressValue = 0;

View File

@@ -16,7 +16,7 @@ using static Ryujinx.Common.Utilities.XCIFileTrimmer;
namespace Ryujinx.Ava.UI.ViewModels
{
public class XciTrimmerViewModel : BaseModel
public class XCITrimmerViewModel : BaseModel
{
private const long BytesPerMb = 1024 * 1024;
@@ -29,11 +29,11 @@ namespace Ryujinx.Ava.UI.ViewModels
public enum SortField
{
Name,
Saved
Savings,
Status
}
private const string _FileExtXCI = "XCI";
private const string _fileExtXCI = "XCI";
private readonly Ryujinx.Common.Logging.XCIFileTrimmerLog _logger;
private ApplicationLibrary ApplicationLibrary => _mainWindowViewModel.ApplicationLibrary;
private Optional<XCITrimmerFileModel> _processingApplication = null;
@@ -45,8 +45,11 @@ namespace Ryujinx.Ava.UI.ViewModels
private string _search;
private ProcessingMode _processingMode;
private SortField _sortField = SortField.Name;
private int _processingCurrent;
private int _processingTotal;
public XciTrimmerViewModel(MainWindowViewModel mainWindowViewModel)
public XCITrimmerViewModel(MainWindowViewModel mainWindowViewModel)
{
_logger = new XCITrimmerLog.TrimmerWindow(this);
_mainWindowViewModel = mainWindowViewModel;
@@ -56,7 +59,7 @@ namespace Ryujinx.Ava.UI.ViewModels
private void LoadXCIApplications()
{
IEnumerable<ApplicationData> apps = ApplicationLibrary.Applications.Items
.Where(app => app.FileExtension == _FileExtXCI);
.Where(app => app.FileExtension == _fileExtXCI);
foreach (ApplicationData xciApp in apps)
AddOrUpdateXCITrimmerFile(CreateXCITrimmerFile(xciApp.Path));
@@ -64,11 +67,9 @@ namespace Ryujinx.Ava.UI.ViewModels
ApplicationsChanged();
}
private XCITrimmerFileModel CreateXCITrimmerFile(
string path,
OperationOutcome operationOutcome = OperationOutcome.Undetermined)
private XCITrimmerFileModel CreateXCITrimmerFile(string path, OperationOutcome operationOutcome = OperationOutcome.Undetermined)
{
ApplicationData xciApp = ApplicationLibrary.Applications.Items.First(app => app.FileExtension == _FileExtXCI && app.Path == path);
ApplicationData xciApp = ApplicationLibrary.Applications.Items.First(app => app.FileExtension == _fileExtXCI && app.Path == path);
return XCITrimmerFileModel.FromApplicationData(xciApp, _logger) with { ProcessingOutcome = operationOutcome };
}
@@ -90,11 +91,15 @@ namespace Ryujinx.Ava.UI.ViewModels
SortAndFilter();
}
public bool AnySelected =>
_selectedXCIFiles.Count > 0;
private void SortingChanged()
{
OnPropertiesChanged(
nameof(IsSortedByName),
nameof(IsSortedBySaved),
nameof(IsSortedBySavings),
nameof(IsSortedByStatus),
nameof(SortingAscending),
nameof(SortingField),
nameof(SortingFieldName));
@@ -114,6 +119,7 @@ namespace Ryujinx.Ava.UI.ViewModels
nameof(Status),
nameof(PotentialSavings),
nameof(ActualSavings),
nameof(SavingsDifference),
nameof(CanTrim),
nameof(CanUntrim));
@@ -123,16 +129,30 @@ namespace Ryujinx.Ava.UI.ViewModels
private void SelectionChanged(bool displayedChanged = true)
{
OnPropertiesChanged(
nameof(Status),
nameof(CanTrim),
nameof(CanUntrim),
nameof(SelectedXCIFiles));
OnPropertyChanged(nameof(Status));
OnPropertyChanged(nameof(CanTrim));
OnPropertyChanged(nameof(CanUntrim));
OnPropertyChanged(nameof(SelectedXCIFiles));
OnPropertyChanged(nameof(AnySelected));
OnPropertyChanged(nameof(SelectToggleText));
if (displayedChanged)
OnPropertyChanged(nameof(SelectedDisplayedXCIFiles));
}
public void ToggleSelect()
{
if (AnySelected)
DeselectAll();
else
SelectAll();
}
public string SelectToggleText =>
AnySelected
? LocaleManager.Instance[LocaleKeys.XCITrimmer_ClearSelectionButton]
: LocaleManager.Instance[LocaleKeys.XCITrimmer_SelectAllButton];
private void ProcessingChanged()
{
OnPropertiesChanged(
@@ -167,6 +187,14 @@ namespace Ryujinx.Ava.UI.ViewModels
(processingMode == ProcessingMode.Trimming && xci.Trimmable)
)).ToList();
_processingTotal = toProcess.Count;
_processingCurrent = 0;
Dispatcher.UIThread.Post(() =>
{
OnPropertyChanged(nameof(Status));
});
List<XCITrimmerFileModel> viewsSaved = DisplayedXCIFiles.ToList();
Dispatcher.UIThread.Post(() =>
@@ -219,6 +247,12 @@ namespace Ryujinx.Ava.UI.ViewModels
ProcessingApplication = null;
});
}
_processingCurrent++;
Dispatcher.UIThread.Post(() =>
{
OnPropertyChanged(nameof(Status));
});
}
}
finally
@@ -226,9 +260,20 @@ namespace Ryujinx.Ava.UI.ViewModels
Dispatcher.UIThread.Post(() =>
{
_displayedXCIFiles.AddOrReplaceMatching(_allXCIFiles, viewsSaved);
_selectedXCIFiles.AddOrReplaceMatching(_allXCIFiles, toProcess);
Processing = false;
ApplicationsChanged();
_selectedXCIFiles.Clear();
foreach (var processed in toProcess)
{
var updated = _allXCIFiles.FirstOrDefault(x => x.Path == processed.Path);
if (updated != null)
_selectedXCIFiles.Add(updated);
}
SelectionChanged();
});
}
})
@@ -254,9 +299,9 @@ namespace Ryujinx.Ava.UI.ViewModels
private class CompareXCITrimmerFiles : IComparer<XCITrimmerFileModel>
{
private readonly XciTrimmerViewModel _viewModel;
private readonly XCITrimmerViewModel _viewModel;
public CompareXCITrimmerFiles(XciTrimmerViewModel ViewModel)
public CompareXCITrimmerFiles(XCITrimmerViewModel ViewModel)
{
_viewModel = ViewModel;
}
@@ -270,9 +315,13 @@ namespace Ryujinx.Ava.UI.ViewModels
case SortField.Name:
result = x.Name.CompareTo(y.Name);
break;
case SortField.Saved:
case SortField.Savings:
result = x.PotentialSavingsB.CompareTo(y.PotentialSavingsB);
break;
case SortField.Status:
result = x.CurrentSavingsB.CompareTo(y.CurrentSavingsB);
break;
}
if (!_viewModel.SortingAscending)
@@ -312,15 +361,16 @@ namespace Ryujinx.Ava.UI.ViewModels
}
}
public void SelectDisplayed()
public void SelectAll()
{
SelectedXCIFiles.Clear();
SelectedXCIFiles.AddRange(DisplayedXCIFiles);
SelectionChanged();
}
public void DeselectDisplayed()
public void DeselectAll()
{
SelectedXCIFiles.RemoveMany(DisplayedXCIFiles);
SelectedXCIFiles.Clear();
SelectionChanged();
}
@@ -426,16 +476,23 @@ namespace Ryujinx.Ava.UI.ViewModels
{
return _processingMode switch
{
ProcessingMode.Trimming => string.Format(LocaleManager.Instance[LocaleKeys.XCITrimmerTitleStatusTrimming], DisplayedXCIFiles.Count),
ProcessingMode.Untrimming => string.Format(LocaleManager.Instance[LocaleKeys.XCITrimmerTitleStatusUntrimming], DisplayedXCIFiles.Count),
ProcessingMode.Trimming => string.Format(
LocaleManager.Instance[LocaleKeys.XCITrimmer_StatusTrimmingLabel],
_processingCurrent,
_processingTotal),
ProcessingMode.Untrimming => string.Format(
LocaleManager.Instance[LocaleKeys.XCITrimmer_StatusUntrimmingLabel],
_processingCurrent,
_processingTotal),
_ => string.Empty
};
}
else
{
return string.IsNullOrEmpty(Search) ?
string.Format(LocaleManager.Instance[LocaleKeys.XCITrimmerTitleStatusCount], SelectedXCIFiles.Count, AllXCIFiles.Count) :
string.Format(LocaleManager.Instance[LocaleKeys.XCITrimmerTitleStatusCountWithFilter], SelectedXCIFiles.Count, AllXCIFiles.Count, DisplayedXCIFiles.Count);
string.Format(LocaleManager.Instance[LocaleKeys.XCITrimmer_StatusCountLabel], SelectedXCIFiles.Count, AllXCIFiles.Count) :
string.Format(LocaleManager.Instance[LocaleKeys.XCITrimmer_StatusCountWithFilterLabel], SelectedXCIFiles.Count, AllXCIFiles.Count, DisplayedXCIFiles.Count);
}
}
}
@@ -466,8 +523,9 @@ namespace Ryujinx.Ava.UI.ViewModels
{
return SortingField switch
{
SortField.Name => LocaleManager.Instance[LocaleKeys.XCITrimmerSortName],
SortField.Saved => LocaleManager.Instance[LocaleKeys.XCITrimmerSortSaved],
SortField.Name => LocaleManager.Instance[LocaleKeys.Common_Sort_NameLabel],
SortField.Savings => LocaleManager.Instance[LocaleKeys.Common_Sort_SavingsLabel],
SortField.Status => LocaleManager.Instance[LocaleKeys.Common_Sort_TrimStatusLabel],
_ => string.Empty,
};
}
@@ -488,11 +546,13 @@ namespace Ryujinx.Ava.UI.ViewModels
get => _sortField == SortField.Name;
}
public bool IsSortedBySaved
public bool IsSortedBySavings
{
get => _sortField == SortField.Saved;
get => _sortField == SortField.Savings;
}
public bool IsSortedByStatus => _sortField == SortField.Status;
public AvaloniaList<XCITrimmerFileModel> SelectedXCIFiles
{
get => _selectedXCIFiles;
@@ -517,7 +577,7 @@ namespace Ryujinx.Ava.UI.ViewModels
{
get
{
return string.Format(LocaleManager.Instance[LocaleKeys.XCITrimmerSavingsMb], AllXCIFiles.Sum(xci => xci.PotentialSavingsB / BytesPerMb));
return string.Format(LocaleManager.Instance[LocaleKeys.XCITrimmer_MBLabel], AllXCIFiles.Sum(xci => xci.PotentialSavingsB / BytesPerMb));
}
}
@@ -525,7 +585,19 @@ namespace Ryujinx.Ava.UI.ViewModels
{
get
{
return string.Format(LocaleManager.Instance[LocaleKeys.XCITrimmerSavingsMb], AllXCIFiles.Sum(xci => xci.CurrentSavingsB / BytesPerMb));
return string.Format(LocaleManager.Instance[LocaleKeys.XCITrimmer_MBLabel], AllXCIFiles.Sum(xci => xci.CurrentSavingsB / BytesPerMb));
}
}
public string SavingsDifference
{
get
{
long potentialSavings = AllXCIFiles.Sum(xci => xci.PotentialSavingsB);
long actualSavings = AllXCIFiles.Sum(xci => xci.CurrentSavingsB);
long differenceMb = (potentialSavings - actualSavings) / BytesPerMb;
return string.Format(LocaleManager.Instance[LocaleKeys.XCITrimmer_MBLabel], differenceMb);
}
}

View File

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

View File

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

View File

@@ -0,0 +1,185 @@
<UserControl
x:Class="Ryujinx.Ava.UI.Views.Dialog.XCITrimmerView"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:ui="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia"
xmlns:ext="clr-namespace:Ryujinx.Ava.Common.Markup"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:viewModels="clr-namespace:Ryujinx.Ava.UI.ViewModels"
xmlns:helpers="clr-namespace:Ryujinx.Ava.UI.Helpers"
xmlns:models="clr-namespace:Ryujinx.Ava.Common.Models"
Width="700"
Height="600"
x:DataType="viewModels:XCITrimmerViewModel"
Focusable="True"
mc:Ignorable="d">
<Grid Margin="25,10,25,0" RowDefinitions="Auto,Auto,*,Auto,Auto">
<Panel Margin="0,0,0,10" Grid.Row="0">
<TextBlock Text="{Binding Status}" />
</Panel>
<Grid Margin="0,0,0,10" Grid.Row="1" IsVisible="{Binding !Processing}" ColumnDefinitions="Auto,*">
<StackPanel Grid.Column="0" Orientation="Horizontal" HorizontalAlignment="Left" VerticalAlignment="Center">
<Button Margin="0,0,10,0" MinWidth="90" Click="ToggleSelect">
<TextBlock Text="{Binding SelectToggleText}" />
</Button>
</StackPanel>
<StackPanel Orientation="Horizontal" Grid.Column="1" HorizontalAlignment="Right">
<DropDownButton Width="170" HorizontalAlignment="Right" VerticalAlignment="Center" Content="{Binding SortingFieldName}">
<DropDownButton.Flyout>
<Flyout Placement="Bottom">
<StackPanel Margin="0" HorizontalAlignment="Stretch" Orientation="Vertical">
<StackPanel>
<RadioButton Checked="Sort_Checked" Content="{ext:Locale Common_Sort_NameLabel}" GroupName="Sort" IsChecked="{Binding IsSortedByName, Mode=OneTime}" Tag="Name" />
<RadioButton Checked="Sort_Checked" Content="{ext:Locale Common_Sort_SavingsLabel}" GroupName="Sort" IsChecked="{Binding IsSortedBySavings, Mode=OneTime}" Tag="Savings" />
<RadioButton Checked="Sort_Checked" Content="{ext:Locale Common_Sort_TrimStatusLabel}" GroupName="Sort" IsChecked="{Binding IsSortedByStatus, Mode=OneTime}" Tag="Status" />
</StackPanel>
<Border Width="60" Height="2" Margin="5" HorizontalAlignment="Stretch" BorderBrush="White" BorderThickness="0,1,0,0">
<Separator Height="0" HorizontalAlignment="Stretch" />
</Border>
<RadioButton Checked="Order_Checked" Content="{ext:Locale Common_Sort_OrderAscending}" GroupName="Order" IsChecked="{Binding SortingAscending, Mode=OneTime}" Tag="Ascending" />
<RadioButton Checked="Order_Checked" Content="{ext:Locale Common_Sort_OrderDescending}" GroupName="Order" IsChecked="{Binding !SortingAscending, Mode=OneTime}" Tag="Descending" />
</StackPanel>
</Flyout>
</DropDownButton.Flyout>
</DropDownButton>
<TextBox
Width="200"
MaxWidth="200"
Margin="5,0,0,0"
VerticalAlignment="Center"
HorizontalAlignment="Right"
Watermark="{ext:Locale Common_Search_SearchWatermark}"
Text="{Binding Search}" />
</StackPanel>
</Grid>
<Border
Grid.Row="2"
Margin="0,0,0,20"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
BorderBrush="{DynamicResource AppListHoverBackgroundColor}"
BorderThickness="1"
CornerRadius="5"
Padding="2.5">
<ListBox
AutoScrollToSelectedItem="{Binding Processing}"
SelectedItem="{Binding NullableProcessingApplication}"
SelectionMode="Multiple, Toggle"
Background="Transparent"
SelectionChanged="OnSelectionChanged"
SelectedItems="{Binding SelectedDisplayedXCIFiles, Mode=OneWay}"
ItemsSource="{Binding DisplayedXCIFiles}"
IsEnabled="{Binding !Processing}">
<ListBox.Styles>
<Style Selector="ListBoxItem">
<Setter Property="Margin" Value="0" />
<Setter Property="Background" Value="Transparent" />
</Style>
</ListBox.Styles>
<ListBox.DataTemplates>
<DataTemplate DataType="models:XCITrimmerFileModel">
<Grid ColumnDefinitions="Auto,*,Auto">
<ui:SymbolIcon
Grid.Column="0"
Margin="10,0,20,0"
Width="15"
Height="15"
FontSize="15"
FlowDirection="LeftToRight"
HorizontalAlignment="Left"
VerticalAlignment="Center"
Symbol="{Binding Converter={x:Static helpers:XCITrimmerFileStatusConverter.Instance}}">
<ToolTip.Tip>
<StackPanel>
<TextBlock
Text="{Binding StatusText}" />
<TextBlock
MaxLines="5"
MaxWidth="200"
TextWrapping="Wrap"
IsVisible="{Binding HasStatusDetail}"
Text="{Binding ., Converter={x:Static helpers:XCITrimmerFileStatusDetailConverter.Instance}}" />
</StackPanel>
</ToolTip.Tip>
</ui:SymbolIcon>
<TextBlock
Grid.Column="1"
Margin="0,0,10,0"
VerticalAlignment="Center"
MaxLines="2"
TextWrapping="Wrap"
TextTrimming="CharacterEllipsis"
Text="{Binding Name}" />
<Grid
Grid.Column="2"
MinWidth="120"
ColumnDefinitions="*">
<ProgressBar
Height="10"
Margin="10,0,10,0"
HorizontalAlignment="Stretch"
VerticalAlignment="Center"
CornerRadius="5"
IsVisible="{Binding $parent[UserControl].DataContext.Processing}"
Maximum="100"
Minimum="0"
Value="{Binding PercentageProgress}" />
<TextBlock
Margin="10,0,10,0"
FlowDirection="LeftToRight"
HorizontalAlignment="Right"
VerticalAlignment="Center"
MaxLines="1"
IsVisible="{Binding !$parent[UserControl].DataContext.Processing}"
Text="{Binding ., Converter={x:Static helpers:XCITrimmerFileSpaceSavingsConverter.Instance}}" />
</Grid>
</Grid>
</DataTemplate>
</ListBox.DataTemplates>
</ListBox>
</Border>
<StackPanel Grid.Row="3" Margin="0,0,0,20" Spacing="5" FlowDirection="LeftToRight" HorizontalAlignment="Center" VerticalAlignment="Center" Orientation="Horizontal">
<TextBlock Classes="h1" HorizontalAlignment="Center" VerticalAlignment="Center" MaxLines="1" Text="{ext:Locale XCITrimmer_SavedLabel}" />
<TextBlock Classes="h1" HorizontalAlignment="Center" VerticalAlignment="Center" FontWeight="Regular" MaxLines="1" Text="{Binding ActualSavings}" />
<TextBlock Classes="h1" HorizontalAlignment="Center" VerticalAlignment="Center" FontWeight="Regular" MaxLines="1" Text="•" />
<TextBlock Classes="h1" HorizontalAlignment="Center" VerticalAlignment="Center" MaxLines="1" Text="{ext:Locale XCITrimmer_RemainingLabel}" />
<TextBlock Classes="h1" HorizontalAlignment="Center" VerticalAlignment="Center" FontWeight="Regular" MaxLines="1" Text="{Binding SavingsDifference}" />
</StackPanel>
<Panel Grid.Row="4" Margin="0,10,0,0" HorizontalAlignment="Stretch">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="10" HorizontalAlignment="Left">
<Button Name="TrimButton" MinWidth="90" Click="Trim" IsEnabled="{Binding CanTrim}">
<TextBlock Text="{ext:Locale XCITrimmer_TrimButton}" />
</Button>
<Button Name="UntrimButton" MinWidth="90" Click="Untrim" IsEnabled="{Binding CanUntrim}">
<TextBlock Text="{ext:Locale XCITrimmer_UntrimButton}" />
</Button>
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="10" HorizontalAlignment="Right">
<Button Name="CancellingButton" MinWidth="90" Click="Cancel" IsEnabled="False">
<Button.IsVisible>
<MultiBinding Converter="{x:Static BoolConverters.And}">
<Binding Path="Processing" />
<Binding Path="Cancel" />
</MultiBinding>
</Button.IsVisible>
<TextBlock Text="{ext:Locale InputDialogCancelling}" />
</Button>
<Button Name="CancelButton" MinWidth="90" Click="Cancel">
<Button.IsVisible>
<MultiBinding Converter="{x:Static BoolConverters.And}">
<Binding Path="Processing" />
<Binding Path="!Cancel" />
</MultiBinding>
</Button.IsVisible>
<TextBlock Text="{ext:Locale InputDialogCancel}" />
</Button>
<Button Name="CloseButton" MinWidth="90" Click="Close" IsVisible="{Binding !Processing}">
<TextBlock Text="{ext:Locale SettingsButtonClose}" />
</Button>
</StackPanel>
</Grid>
</Panel>
</Grid>
</UserControl>

View File

@@ -11,13 +11,19 @@ using System.Threading.Tasks;
namespace Ryujinx.Ava.UI.Views.Dialog
{
public partial class XciTrimmerView : RyujinxControl<XciTrimmerViewModel>
public partial class XCITrimmerView : RyujinxControl<XCITrimmerViewModel>
{
public XciTrimmerView()
public XCITrimmerView()
{
InitializeComponent();
}
private void ToggleSelect(object sender, RoutedEventArgs e)
{
if (DataContext is XCITrimmerViewModel vm)
vm.ToggleSelect();
}
public static async Task Show()
{
ContentDialog contentDialog = new()
@@ -25,11 +31,11 @@ namespace Ryujinx.Ava.UI.Views.Dialog
PrimaryButtonText = string.Empty,
SecondaryButtonText = string.Empty,
CloseButtonText = string.Empty,
Content = new XciTrimmerView
Content = new XCITrimmerView
{
ViewModel = new XciTrimmerViewModel(RyujinxApp.MainWindow.ViewModel)
ViewModel = new XCITrimmerViewModel(RyujinxApp.MainWindow.ViewModel)
},
Title = LocaleManager.Instance[LocaleKeys.XCITrimmerWindowTitle]
Title = LocaleManager.Instance[LocaleKeys.MenuBar_Actions_XCITrimmerButton]
};
Style bottomBorder = new(x => x.OfType<Grid>().Name("DialogSpace").Child().OfType<Border>());
@@ -63,7 +69,7 @@ namespace Ryujinx.Ava.UI.Views.Dialog
public void Sort_Checked(object sender, RoutedEventArgs args)
{
if (sender is RadioButton { Tag: string sortField })
ViewModel.SortingField = Enum.Parse<XciTrimmerViewModel.SortField>(sortField);
ViewModel.SortingField = Enum.Parse<XCITrimmerViewModel.SortField>(sortField);
}
public void Order_Checked(object sender, RoutedEventArgs args)

View File

@@ -1,311 +0,0 @@
<UserControl
x:Class="Ryujinx.Ava.UI.Views.Dialog.XciTrimmerView"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:ext="clr-namespace:Ryujinx.Ava.Common.Markup"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:viewModels="clr-namespace:Ryujinx.Ava.UI.ViewModels"
xmlns:helpers="clr-namespace:Ryujinx.Ava.UI.Helpers"
xmlns:models="clr-namespace:Ryujinx.Ava.Common.Models"
Width="700"
Height="600"
x:DataType="viewModels:XciTrimmerViewModel"
Focusable="True"
mc:Ignorable="d">
<Grid Margin="20 0 20 0" RowDefinitions="Auto,Auto,*,Auto,Auto">
<Panel
Margin="10 10 10 10"
Grid.Row="0">
<TextBlock Text="{Binding Status}" />
</Panel>
<Panel
Margin="0 0 10 10"
IsVisible="{Binding !Processing}"
Grid.Row="1">
<Grid ColumnDefinitions="Auto,*,Auto">
<StackPanel
Grid.Column="0"
Orientation="Horizontal">
<DropDownButton
Width="150"
HorizontalAlignment="Left"
VerticalAlignment="Center"
Content="{Binding SortingFieldName}">
<DropDownButton.Flyout>
<Flyout Placement="Bottom">
<StackPanel
Margin="0"
HorizontalAlignment="Stretch"
Orientation="Vertical">
<StackPanel>
<RadioButton
Checked="Sort_Checked"
Content="{ext:Locale XCITrimmerSortName}"
GroupName="Sort"
IsChecked="{Binding IsSortedByName, Mode=OneTime}"
Tag="Name" />
<RadioButton
Checked="Sort_Checked"
Content="{ext:Locale XCITrimmerSortSaved}"
GroupName="Sort"
IsChecked="{Binding IsSortedBySaved, Mode=OneTime}"
Tag="Saved" />
</StackPanel>
<Border
Width="60"
Height="2"
Margin="5"
HorizontalAlignment="Stretch"
BorderBrush="White"
BorderThickness="0,1,0,0">
<Separator Height="0" HorizontalAlignment="Stretch" />
</Border>
<RadioButton
Checked="Order_Checked"
Content="{ext:Locale OrderAscending}"
GroupName="Order"
IsChecked="{Binding SortingAscending, Mode=OneTime}"
Tag="Ascending" />
<RadioButton
Checked="Order_Checked"
Content="{ext:Locale OrderDescending}"
GroupName="Order"
IsChecked="{Binding !SortingAscending, Mode=OneTime}"
Tag="Descending" />
</StackPanel>
</Flyout>
</DropDownButton.Flyout>
</DropDownButton>
</StackPanel>
<TextBox
Grid.Column="1"
MinHeight="29"
MaxHeight="29"
Margin="5 0 5 0"
HorizontalAlignment="Stretch"
Watermark="{ext:Locale Search}"
Text="{Binding Search}" />
<StackPanel
Grid.Column="2"
Orientation="Horizontal">
<Button
Name="SelectDisplayedButton"
MinWidth="90"
Margin="5"
Command="{Binding SelectDisplayed}">
<TextBlock Text="{ext:Locale XCITrimmerSelectDisplayed}" />
</Button>
<Button
Name="DeselectDisplayedButton"
MinWidth="90"
Margin="5"
Command="{Binding DeselectDisplayed}">
<TextBlock Text="{ext:Locale XCITrimmerDeselectDisplayed}" />
</Button>
</StackPanel>
</Grid>
</Panel>
<Border
Grid.Row="2"
Margin="0 0 0 10"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
BorderBrush="{DynamicResource AppListHoverBackgroundColor}"
BorderThickness="1"
CornerRadius="5"
Padding="2.5">
<ListBox
AutoScrollToSelectedItem="{Binding Processing}"
SelectedItem="{Binding NullableProcessingApplication}"
SelectionMode="Multiple, Toggle"
Background="Transparent"
SelectionChanged="OnSelectionChanged"
SelectedItems="{Binding SelectedDisplayedXCIFiles, Mode=OneWay}"
ItemsSource="{Binding DisplayedXCIFiles}"
IsEnabled="{Binding !Processing}">
<ListBox.DataTemplates>
<DataTemplate
DataType="models:XCITrimmerFileModel">
<Panel Margin="10">
<Grid ColumnDefinitions="65*,35*">
<TextBlock
Grid.Column="0"
Margin="10 0 10 0"
HorizontalAlignment="Left"
VerticalAlignment="Center"
MaxLines="2"
TextWrapping="Wrap"
TextTrimming="CharacterEllipsis"
Text="{Binding Name}">
</TextBlock>
<Grid Grid.Column="1" ColumnDefinitions="45*,55*">
<ProgressBar
Height="10"
Margin="10 0 10 0"
HorizontalAlignment="Stretch"
VerticalAlignment="Center"
CornerRadius="5"
IsVisible="{Binding $parent[UserControl].((viewModels:XciTrimmerViewModel)DataContext).Processing}"
Maximum="100"
Minimum="0"
Value="{Binding PercentageProgress}" />
<TextBlock
Grid.Column="0"
Margin="10 0 10 0"
HorizontalAlignment="Left"
VerticalAlignment="Center"
MaxLines="1"
Text="{Binding ., Converter={x:Static helpers:XCITrimmerFileStatusConverter.Instance}}">
<ToolTip.Tip>
<StackPanel
IsVisible="{Binding IsFailed}">
<TextBlock
Classes="h1"
Text="{ext:Locale XCITrimmerTitleStatusFailed}" />
<TextBlock
Text="{Binding ., Converter={x:Static helpers:XCITrimmerFileStatusDetailConverter.Instance}}"
MaxLines="5"
MaxWidth="200"
MaxHeight="100"
TextTrimming="None"
TextWrapping="Wrap"/>
</StackPanel>
</ToolTip.Tip>
</TextBlock>
<TextBlock
Grid.Column="1"
Margin="10 0 10 0"
HorizontalAlignment="Left"
VerticalAlignment="Center"
MaxLines="1"
Text="{Binding ., Converter={x:Static helpers:XCITrimmerFileSpaceSavingsConverter.Instance}}">>
</TextBlock>
</Grid>
</Grid>
</Panel>
</DataTemplate>
</ListBox.DataTemplates>
<ListBox.Styles>
<Style Selector="ListBoxItem">
<Setter Property="Background" Value="Transparent" />
</Style>
</ListBox.Styles>
</ListBox>
</Border>
<Border
Grid.Row="3"
Margin="0 0 0 10"
HorizontalAlignment="Stretch"
BorderBrush="{DynamicResource AppListHoverBackgroundColor}"
BorderThickness="1"
CornerRadius="5"
Padding="2.5">
<Grid ColumnDefinitions="Auto,*" RowDefinitions="Auto,Auto">
<TextBlock
Grid.Column="0"
Grid.Row="0"
Classes="h1"
Margin="5"
HorizontalAlignment="Right"
VerticalAlignment="Center"
MaxLines="1"
Text="{ext:Locale XCITrimmerPotentialSavings}" />
<TextBlock
Grid.Column="0"
Grid.Row="1"
Classes="h1"
Margin="5"
HorizontalAlignment="Right"
VerticalAlignment="Center"
MaxLines="1"
Text="{ext:Locale XCITrimmerActualSavings}" />
<TextBlock
Grid.Column="1"
Grid.Row="0"
Margin="5"
HorizontalAlignment="Left"
VerticalAlignment="Center"
MaxLines="1"
Text="{Binding PotentialSavings}" />
<TextBlock
Grid.Column="1"
Grid.Row="1"
Margin="5"
HorizontalAlignment="Left"
VerticalAlignment="Center"
MaxLines="1"
Text="{Binding ActualSavings}" />
</Grid>
</Border>
<Panel
Grid.Row="4"
HorizontalAlignment="Stretch">
<Grid ColumnDefinitions="*,Auto">
<StackPanel
Grid.Column="0"
Orientation="Horizontal"
Spacing="10"
HorizontalAlignment="Left">
<Button
Name="TrimButton"
MinWidth="90"
Margin="5"
Click="Trim"
IsEnabled="{Binding CanTrim}">
<TextBlock Text="{ext:Locale XCITrimmerTrim}" />
</Button>
<Button
Name="UntrimButton"
MinWidth="90"
Margin="5"
Click="Untrim"
IsEnabled="{Binding CanUntrim}">
<TextBlock Text="{ext:Locale XCITrimmerUntrim}" />
</Button>
</StackPanel>
<StackPanel
Grid.Column="1"
Orientation="Horizontal"
Spacing="10"
HorizontalAlignment="Right">
<Button
Name="CancellingButton"
MinWidth="90"
Margin="5"
Click="Cancel"
IsEnabled="False">
<Button.IsVisible>
<MultiBinding Converter="{x:Static BoolConverters.And}">
<Binding Path="Processing" />
<Binding Path="Cancel" />
</MultiBinding>
</Button.IsVisible>
<TextBlock Text="{ext:Locale InputDialogCancelling}" />
</Button>
<Button
Name="CancelButton"
MinWidth="90"
Margin="5"
Click="Cancel">
<Button.IsVisible>
<MultiBinding Converter="{x:Static BoolConverters.And}">
<Binding Path="Processing" />
<Binding Path="!Cancel" />
</MultiBinding>
</Button.IsVisible>
<TextBlock Text="{ext:Locale InputDialogCancel}" />
</Button>
<Button
Name="CloseButton"
MinWidth="90"
Margin="5"
Click="Close"
IsVisible="{Binding !Processing}">
<TextBlock Text="{ext:Locale InputDialogClose}" />
</Button>
</StackPanel>
</Grid>
</Panel>
</Grid>
</UserControl>

View File

@@ -0,0 +1,89 @@
<UserControl
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ui="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia"
xmlns:ext="clr-namespace:Ryujinx.Ava.Common.Markup"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:models="clr-namespace:Ryujinx.Ava.UI.Models.Input"
xmlns:viewModels="clr-namespace:Ryujinx.Ava.UI.ViewModels.Input"
x:Class="Ryujinx.Ava.UI.Views.Input.AssignedDevicesInputView"
x:DataType="viewModels:InputViewModel"
x:CompileBindings="True"
mc:Ignorable="d">
<Grid Margin="10" RowDefinitions="Auto,Auto">
<Border
Grid.Row="0"
Padding="10,8"
BorderBrush="{DynamicResource ThemeControlBorderColor}"
BorderThickness="1"
CornerRadius="5"
HorizontalAlignment="Stretch">
<ItemsControl
ItemsSource="{Binding PlayerInputDevices}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="models:PlayerInputDeviceAssignmentItem">
<Border
Margin="0,3"
Padding="10,8"
CornerRadius="4"
Background="{DynamicResource ControlFillColorTertiary}"
BorderBrush="{DynamicResource ThemeControlBorderColor}"
BorderThickness="1"
PointerPressed="DeviceRow_OnPointerPressed"
Cursor="Hand"
IsEnabled="{Binding !IsDisabledByOtherPlayer}">
<Grid ColumnDefinitions="Auto,*,200">
<CheckBox
Grid.Column="0"
IsChecked="{Binding IsAssigned}"
Checked="AssignedDeviceCheckBox_OnCheckedChanged"
Unchecked="AssignedDeviceCheckBox_OnCheckedChanged"
VerticalAlignment="Center"
Margin="0"
Padding="0" />
<StackPanel
Grid.Column="1"
Margin="8,0,0,0"
VerticalAlignment="Center">
<TextBlock Text="{Binding Name}" />
<TextBlock
Opacity="0.6"
FontSize="12"
MaxWidth="160"
TextTrimming="CharacterEllipsis"
TextWrapping="NoWrap"
MaxLines="1"
IsVisible="{Binding HasBoundProfileName}">
<Run Text="{ext:Locale ControllerSettingsProfile}" />
<Run Text=":" />
<Run Text="{Binding BoundProfileName}" />
</TextBlock>
</StackPanel>
<TextBlock
Grid.Column="2"
VerticalAlignment="Center"
HorizontalAlignment="Center"
Margin="10,0,0,0"
Opacity="0.6"
FontSize="12"
Text="{Binding AssignedToPlayers}"
TextWrapping="Wrap"
MaxWidth="200"
IsVisible="{Binding HasAssignedToPlayers}" />
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Border>
<CheckBox
Grid.Row="1"
Margin="0,8,0,0"
IsChecked="{Binding AllowDuplicateDeviceAssignment}">
<TextBlock
VerticalAlignment="Center"
Text="{ext:Locale ControllerSettingsAllowDuplicateDeviceAssignment}" />
</CheckBox>
</Grid>
</UserControl>

View File

@@ -0,0 +1,97 @@
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.VisualTree;
using FluentAvalonia.UI.Controls;
using Ryujinx.Ava.Common.Locale;
using Ryujinx.Ava.UI.Models.Input;
using Ryujinx.Ava.UI.ViewModels.Input;
using System.Linq;
using System.Threading.Tasks;
namespace Ryujinx.Ava.UI.Views.Input
{
public partial class AssignedDevicesInputView : UserControl
{
public AssignedDevicesInputView()
{
InitializeComponent();
}
public AssignedDevicesInputView(InputViewModel viewModel)
{
DataContext = viewModel;
InitializeComponent();
}
public static async Task Show(InputViewModel viewModel)
{
// Store original state to allow discarding changes
var originalAssignments = viewModel.PlayerInputDevices
.Select(item => new { item.Id, item.DeviceType, item.IsAssigned })
.ToList();
var originalAllowDuplicate = viewModel.AllowDuplicateDeviceAssignment;
AssignedDevicesInputView content = new(viewModel);
ContentDialog contentDialog = new()
{
Title = LocaleManager.Instance[LocaleKeys.ControllerSettingsAssignedInputDevices],
PrimaryButtonText = LocaleManager.Instance[LocaleKeys.ControllerSettingsSave],
SecondaryButtonText = string.Empty,
CloseButtonText = LocaleManager.Instance[LocaleKeys.ControllerSettingsClose],
Content = content,
};
ContentDialogResult result = await contentDialog.ShowAsync();
if (result == ContentDialogResult.Primary)
{
viewModel.Save();
}
else
{
// Discard changes by reverting to original state
foreach (var original in originalAssignments)
{
var item = viewModel.PlayerInputDevices.FirstOrDefault(d =>
d.Id == original.Id && d.DeviceType == original.DeviceType);
if (item != null && item.IsAssigned != original.IsAssigned)
{
// Use Toggle to revert, which will properly refresh state
viewModel.ToggleAssignedPlayerInputDevice(item, original.IsAssigned);
}
}
// Revert AllowDuplicateDeviceAssignment to original state
if (viewModel.AllowDuplicateDeviceAssignment != originalAllowDuplicate)
{
viewModel.AllowDuplicateDeviceAssignment = originalAllowDuplicate;
}
viewModel.RefreshModifiedState();
}
}
private void AssignedDeviceCheckBox_OnCheckedChanged(object sender, RoutedEventArgs e)
{
if (sender is CheckBox { DataContext: PlayerInputDeviceAssignmentItem item } checkBox)
{
_viewModel?.ToggleAssignedPlayerInputDevice(item, checkBox.IsChecked == true);
}
}
private void DeviceRow_OnPointerPressed(object sender, PointerPressedEventArgs e)
{
if (e.Source is Control control && control.FindAncestorOfType<CheckBox>() != null)
{
return;
}
if (sender is Border { DataContext: PlayerInputDeviceAssignmentItem item })
{
_viewModel?.ToggleAssignedPlayerInputDevice(item, !item.IsAssigned);
}
}
private InputViewModel _viewModel => DataContext as InputViewModel;
}
}

View File

@@ -88,7 +88,7 @@
Grid.Column="2"
Margin="2"
HorizontalAlignment="Stretch"
VerticalAlignment="Center" ColumnDefinitions="Auto,*,Auto,Auto,Auto">
VerticalAlignment="Center" ColumnDefinitions="Auto,*,Auto,Auto,Auto,Auto">
<TextBlock
Margin="5,0,10,0"
Width="90"
@@ -101,19 +101,46 @@
Name="ProfileBox"
HorizontalAlignment="Stretch"
VerticalAlignment="Center"
MaxHeight="32"
Padding="8,0,44,0"
IsEnabled="{Binding ShowSettings}"
SelectedItem="{Binding ChosenProfile, Mode=TwoWay}"
SelectionChanged="ComboBox_SelectionChanged"
ItemsSource="{Binding ProfilesList}"
Text="{Binding ProfileName, Mode=TwoWay}" />
Text="{Binding ProfileName, Mode=TwoWay}">
<ui:FAComboBox.Styles>
<Style Selector="TextBlock">
<Setter Property="TextTrimming" Value="CharacterEllipsis" />
<Setter Property="TextWrapping" Value="NoWrap" />
<Setter Property="MaxLines" Value="1" />
<Setter Property="MaxWidth" Value="170" />
</Style>
<Style Selector="TextBox">
<Setter Property="TextWrapping" Value="NoWrap" />
<Setter Property="MaxLines" Value="1" />
<Setter Property="Padding" Value="0,0,36,0" />
</Style>
</ui:FAComboBox.Styles>
</ui:FAComboBox>
<ui:SymbolIcon
Grid.Column="1"
Symbol="Link"
FontSize="12"
Opacity="0.6"
IsVisible="{Binding IsProfileLinked}"
HorizontalAlignment="Right"
VerticalAlignment="Center"
Margin="0,0,37,0" />
<Button
Grid.Column="2"
MinWidth="0"
Margin="5,0,0,0"
VerticalAlignment="Center"
ToolTip.Tip="{ext:Locale ControllerSettingsLoadProfileToolTip}"
Command="{Binding LoadProfileButton}">
ToolTip.Tip="{ext:Locale ControllerSettingsBindProfileToolTip}"
IsEnabled="{Binding CanBindSelectedProfile}"
Click="LinkProfileButton_OnClick">
<ui:SymbolIcon
Symbol="View"
Symbol="Link"
FontSize="15"
Height="20" />
</Button>
@@ -122,19 +149,34 @@
MinWidth="0"
Margin="5,0,0,0"
VerticalAlignment="Center"
ToolTip.Tip="{ext:Locale ControllerSettingsSaveProfileToolTip}"
Command="{Binding SaveProfile}">
ToolTip.Tip="{ext:Locale ControllerSettingsLoadProfileToolTip}"
IsEnabled="{Binding ShowSettings}"
Click="LoadProfileButton_OnClick">
<ui:SymbolIcon
Symbol="Save"
Symbol="OpenFolder"
FontSize="15"
Height="20" />
</Button>
<Button
Grid.Column="4"
MinWidth="0"
Margin="5,0,0,0"
VerticalAlignment="Center"
ToolTip.Tip="{ext:Locale ControllerSettingsSaveProfileToolTip}"
IsEnabled="{Binding CanDeleteOrSaveProfile}"
Command="{Binding SaveProfile}">
<ui:SymbolIcon
Symbol="Save"
FontSize="15"
Height="20" />
</Button>
<Button
Grid.Column="5"
MinWidth="0"
Margin="5,0,0,0"
VerticalAlignment="Center"
ToolTip.Tip="{ext:Locale ControllerSettingsRemoveProfileToolTip}"
IsEnabled="{Binding CanDeleteOrSaveProfile}"
Command="{Binding RemoveProfile}">
<ui:SymbolIcon
Symbol="Delete"
@@ -149,7 +191,7 @@
<Grid
Grid.Column="0"
Margin="2"
HorizontalAlignment="Stretch" ColumnDefinitions="Auto,*,Auto,Auto">
HorizontalAlignment="Stretch" ColumnDefinitions="Auto,*,Auto,Auto,Auto">
<TextBlock
Grid.Column="0"
Margin="5,0,10,0"
@@ -187,7 +229,21 @@
MinWidth="0"
Margin="5,0,0,0"
VerticalAlignment="Center"
ToolTip.Tip="{ext:Locale ControllerSettingsAssignedInputDevicesTooltip}"
IsEnabled="{Binding CanOpenAssignedDevices}"
Click="AssignedDevicesButton_OnClick">
<ui:SymbolIcon
Symbol="Settings"
FontSize="15"
Height="20"/>
</Button>
<Button
Grid.Column="4"
MinWidth="0"
Margin="5,0,0,0"
VerticalAlignment="Center"
ToolTip.Tip="{ext:Locale ControllerSettingsResetKeybindsToDefault}"
IsEnabled="{Binding ShowSettings}"
Click="ResetCurrentDeviceToDefaultsButton_OnClick">
<ui:SymbolIcon
Symbol="Undo"
@@ -210,6 +266,7 @@
<ComboBox
Grid.Column="1"
HorizontalAlignment="Stretch"
IsEnabled="{Binding ShowSettings}"
ItemsSource="{Binding Controllers}"
SelectedIndex="{Binding Controller}">
<ComboBox.ItemTemplate>

View File

@@ -1,4 +1,5 @@
using Avalonia.Controls;
using Avalonia.Controls.Templates;
using Avalonia.Interactivity;
using Avalonia;
using Avalonia.Layout;
@@ -45,12 +46,69 @@ namespace Ryujinx.Ava.UI.Views.Input
ViewModel = new InputViewModel(this, useGlobalConfig); // Create new Input Page with the selected input config scope.
InitializeComponent();
SetupProfileBoxItemTemplate();
if (VisualRoot is not null)
{
ViewModel.RetargetKeyboardDriver(this);
}
}
private void SetupProfileBoxItemTemplate()
{
var dataTemplate = new FuncDataTemplate<string>((profileName, scope) =>
{
var grid = new Grid
{
ColumnDefinitions = new ColumnDefinitions("*,Auto")
};
var textBlock = new TextBlock
{
Text = profileName,
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center,
TextTrimming = TextTrimming.CharacterEllipsis,
TextWrapping = TextWrapping.NoWrap,
MaxWidth = 170
};
Grid.SetColumn(textBlock, 0);
var linkIcon = new FluentAvalonia.UI.Controls.SymbolIcon
{
Symbol = FluentAvalonia.UI.Controls.Symbol.Link,
FontSize = 12,
Opacity = 0.6,
Margin = new Thickness(10, 0, 0, 0),
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center
};
Grid.SetColumn(linkIcon, 1);
// Bind visibility to whether the profile is linked
linkIcon.Bind(
FluentAvalonia.UI.Controls.SymbolIcon.IsVisibleProperty,
new Avalonia.Data.Binding(".")
{
Converter = ProfileNameLinkedConverter.Instance,
ConverterParameter = ViewModel
});
grid.Children.Add(textBlock);
grid.Children.Add(linkIcon);
return grid;
});
ProfileBox.ItemTemplate = dataTemplate;
}
public void RefreshProfileBoxItemTemplate()
{
// Force the ComboBox to re-render its items
var itemsSource = ProfileBox.ItemsSource;
ProfileBox.ItemsSource = null;
ProfileBox.ItemsSource = itemsSource;
}
private async void PlayerIndexBox_OnSelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (PlayerIndexBox != null)
@@ -102,8 +160,15 @@ namespace Ryujinx.Ava.UI.Views.Input
if (sender is FAComboBox faComboBox)
{
faComboBox.IsDropDownOpen = false;
ViewModel.RefreshModifiedState();
}
ViewModel.RefreshModifiedState();
}
private async void AssignedDevicesButton_OnClick(object sender, RoutedEventArgs e)
{
await AssignedDevicesInputView.Show(ViewModel);
ViewModel.RefreshModifiedState();
}
private async void ResetCurrentDeviceToDefaultsButton_OnClick(object sender, RoutedEventArgs e)
@@ -155,6 +220,17 @@ namespace Ryujinx.Ava.UI.Views.Input
}
}
private void LinkProfileButton_OnClick(object sender, RoutedEventArgs e)
{
ViewModel?.LinkCurrentProfileToCurrentDevice();
RefreshProfileBoxItemTemplate();
}
private void LoadProfileButton_OnClick(object sender, RoutedEventArgs e)
{
ViewModel?.LoadProfile();
}
public void Dispose()
{
ViewModel.Dispose();

View File

@@ -163,7 +163,7 @@
IsEnabled="{Binding HasSkylander}" />
<MenuItem
Command="{Binding SimulateWakeUpMessage}"
Header="{ext:Locale MenuBarOptionsSimulateWakeUpMessage}"
Header="{ext:Locale MenuBar_Actions_SimulateWakeUpMessageButton}"
Icon="{ext:Icon fa-solid fa-sun}"
InputGesture="Ctrl + M" />
<Separator />
@@ -216,7 +216,7 @@
<MenuItem Header="{ext:Locale MenuBar_Actions_ToolsLabel}" Icon="{ext:Icon fa-solid fa-toolbox}">
<MenuItem
Name="MiiAppletMenuItem" Header="{ext:Locale MenuBar_Actions_MiiEditorButton}" Icon="{ext:Icon fa-solid fa-face-grin-wide}" />
<MenuItem Name="XciTrimmerMenuItem" Header="{ext:Locale MenuBar_Actions_XCITrimmerButton}" Icon="{ext:Icon fa-solid fa-scissors}" />
<MenuItem Name="XCITrimmerMenuItem" Header="{ext:Locale MenuBar_Actions_XCITrimmerButton}" Icon="{ext:Icon fa-solid fa-scissors}" />
</MenuItem>
</MenuItem>
<MenuItem VerticalAlignment="Center" Header="{ext:Locale MenuBarView}">

View File

@@ -43,7 +43,7 @@ namespace Ryujinx.Ava.UI.Views.Main
ResumeEmulationMenuItem.Command = Commands.Create(() => ViewModel.AppHost?.Resume());
StopEmulationMenuItem.Command = Commands.Create(() => ViewModel.AppHost?.ShowExitPrompt().OrCompleted());
RestartEmulationMenuItem.Command = Commands.Create(() => ViewModel.RestartEmulation());
XciTrimmerMenuItem.Command = Commands.Create(XciTrimmerView.Show);
XCITrimmerMenuItem.Command = Commands.Create(XCITrimmerView.Show);
AboutWindowMenuItem.Command = Commands.Create(AboutView.Show);
CompatibilityListMenuItem.Command = Commands.Create(() => CompatibilityListWindow.Show());
LdnGameListMenuItem.Command = Commands.Create(() => LdnGamesListWindow.Show());

View File

@@ -50,33 +50,37 @@
VerticalAlignment="Center"
IsVisible="{Binding EnableNonGameRunningControls}"
Text="{ext:Locale StatusBarGamesLoaded}" />
<TextBlock
Name="StatusBarProgressStatus"
Grid.Column="2"
MinWidth="200"
Margin="10,0,5,0"
VerticalAlignment="Center"
IsVisible="{Binding StatusBarProgressStatusVisible}"
Text="{Binding StatusBarProgressStatusText}" />
<ProgressBar
Name="LoadProgressBar"
Grid.Column="3"
MinWidth="200"
Height="6"
VerticalAlignment="Center"
Margin="0, 0, 5, 0"
Foreground="{DynamicResource SystemAccentColorLight2}"
IsVisible="{Binding StatusBarVisible}"
Maximum="{Binding StatusBarProgressMaximum}"
Value="{Binding StatusBarProgressValue}" />
<controls:MiniVerticalSeparator Grid.Column="4" IsVisible="{Binding ShowTotalTimePlayed}" />
<controls:MiniVerticalSeparator Grid.Column="2" IsVisible="{Binding ShowTotalTimePlayed}" />
<TextBlock
Grid.Column="5"
Grid.Column="3"
Margin="5,0,5,0"
VerticalAlignment="Center"
IsVisible="{Binding ShowTotalTimePlayed}"
Text="{ext:Locale GameListLabelTotalTimePlayed}">
</TextBlock>
<controls:MiniVerticalSeparator Grid.Column="4" IsVisible="{Binding StatusBarVisible}" />
<controls:MiniVerticalSeparator Grid.Column="4" IsVisible="{Binding StatusBarProgressStatusVisible}" />
<TextBlock
Name="StatusBarProgressStatus"
Grid.Column="5"
Margin="5,0,0,0"
VerticalAlignment="Center"
IsVisible="{Binding StatusBarProgressStatusVisible}"
Text="{Binding StatusBarProgressStatusText}"
TextTrimming="CharacterEllipsis"
TextWrapping="NoWrap"
MaxWidth="1000" />
<ProgressBar
Name="LoadProgressBar"
Grid.Column="6"
MinWidth="100"
Height="6"
VerticalAlignment="Center"
Margin="5,0,5,0"
Foreground="{DynamicResource SystemAccentColorLight2}"
IsVisible="{Binding StatusBarVisible}"
Maximum="{Binding StatusBarProgressMaximum}"
Value="{Binding StatusBarProgressValue}" />
</Grid>
</StackPanel>
<StackPanel
@@ -329,7 +333,7 @@
Margin="5, 0, 0, 0"
HorizontalAlignment="Right"
VerticalAlignment="Center"
Text="{ext:Locale StatusBar_FirmwareVersion}" />
Text="{ext:Locale StatusBar_FirmwareVersionLabel}" />
</StackPanel>
</Grid>
</UserControl>

View File

@@ -95,7 +95,7 @@
Tag="Favorite" />
<RadioButton
Checked="Sort_Checked"
Content="{ext:Locale GameListHeaderApplication}"
Content="{ext:Locale Common_Sort_NameLabel}"
GroupName="Sort"
IsChecked="{Binding IsSortedByTitle, Mode=OneTime}"
Tag="Title" />
@@ -153,13 +153,13 @@
</Border>
<RadioButton
Checked="Order_Checked"
Content="{ext:Locale OrderAscending}"
Content="{ext:Locale Common_Sort_OrderAscending}"
GroupName="Order"
IsChecked="{Binding IsAscending, Mode=OneTime}"
Tag="Ascending" />
<RadioButton
Checked="Order_Checked"
Content="{ext:Locale OrderDescending}"
Content="{ext:Locale Common_Sort_OrderDescending}"
GroupName="Order"
IsChecked="{Binding !IsAscending, Mode=OneTime}"
Tag="Descending" />

View File

@@ -41,6 +41,13 @@
<StackPanel
Orientation="Horizontal"
Spacing="10">
<CheckBox
ToolTip.Tip="{ext:Locale DockModeToggleTooltip}"
MinWidth="0"
IsChecked="{Binding EnableDockedMode}">
<TextBlock
Text="{ext:Locale SettingsTabInputEnableDockedMode}" />
</CheckBox>
<CheckBox
ToolTip.Tip="{ext:Locale UseGlobalInputTooltip}"
MinWidth="0"
@@ -49,11 +56,11 @@
Text="{ext:Locale SettingsTabInputUseGlobalInput}" />
</CheckBox>
<CheckBox
ToolTip.Tip="{ext:Locale DockModeToggleTooltip}"
MinWidth="0"
IsChecked="{Binding EnableDockedMode}">
ToolTip.Tip="{ext:Locale ControllerSettingsDynamicInputSwapTooltip}"
IsChecked="{Binding ElementName=InputView, Path=ViewModel.EnableDynamicGamepadSwap, Mode=TwoWay}"
IsEnabled="{Binding ElementName=InputView, Path=ViewModel.ShowSettings}">
<TextBlock
Text="{ext:Locale SettingsTabInputEnableDockedMode}" />
Text="{ext:Locale ControllerSettingsDynamicInputSwap}" />
</CheckBox>
<CheckBox
ToolTip.Tip="{ext:Locale DirectKeyboardTooltip}"

View File

@@ -33,7 +33,7 @@
HorizontalContentAlignment="Left"
MinWidth="100">
<ComboBoxItem
Content="{ext:Locale Name}" />
Content="{ext:Locale Common_Sort_NameLabel}" />
<ComboBoxItem
Content="{ext:Locale Size}" />
<ComboBox.Styles>
@@ -46,9 +46,9 @@
HorizontalContentAlignment="Left"
MinWidth="150">
<ComboBoxItem
Content="{ext:Locale OrderAscending}" />
Content="{ext:Locale Common_Sort_OrderAscending}" />
<ComboBoxItem
Content="{ext:Locale OrderDescending}" />
Content="{ext:Locale Common_Sort_OrderDescending}" />
<ComboBox.Styles>
<Style Selector="ContentControl#ContentPresenter">
<Setter Property="HorizontalAlignment" Value="Left" />
@@ -60,7 +60,7 @@
Grid.Column="1"
HorizontalAlignment="Stretch"
Margin="10,0, 0, 0" ColumnDefinitions="Auto,*">
<TextBlock Text="{ext:Locale Search}" VerticalAlignment="Center" />
<TextBlock Text="{ext:Locale Common_Search_SearchWatermark}" VerticalAlignment="Center" />
<TextBox
Margin="10,0,0,0"
Grid.Column="1"

View File

@@ -135,7 +135,7 @@
Grid.Column="1"
HorizontalAlignment="Stretch"
VerticalAlignment="Center"
IsVisible="{Binding ShowLoadProgress}" RowDefinitions="Auto,Auto,Auto">
IsVisible="{Binding ShowLoadProgress}" RowDefinitions="Auto,Auto,Auto,Auto">
<TextBlock
Grid.Row="0"
Margin="10"
@@ -179,6 +179,16 @@
Text="{Binding CacheLoadStatus}"
TextAlignment="Start"
MaxWidth="500" />
<TextBlock
Grid.Row="3"
Margin="10"
FontSize="14"
FontStyle="Oblique"
IsVisible="{Binding ShowLoadProgress}"
Text="{Binding Splash}"
Foreground="LightGray"
TextAlignment="Start"
MaxWidth="500" />
</Grid>
</Grid>
</Grid>