Compare commits

...

10 commits

Author SHA1 Message Date
52e0c23d06 Add MIDI input backend and remapping support 2026-03-16 20:39:14 +01:00
sh0inx
4e81a4c2f4 [HLE] Added "null" check for isAtRest (ryubing/ryujinx!287)
See merge request ryubing/ryujinx!287
2026-03-15 09:46:36 -05:00
Coxxs
9cae62096a HLE: Implement CreateContextForSystem (ryubing/ryujinx!285)
See merge request ryubing/ryujinx!285
2026-03-14 13:57:49 -05:00
BowedCascade
648b609ebb Add restart emulation command (ryubing/ryujinx!276)
See merge request ryubing/ryujinx!276
2026-03-14 13:56:20 -05:00
BowedCascade
5ae86fc493 Fix keys file overwrite on installation and method name typo (ryubing/ryujinx!268)
See merge request ryubing/ryujinx!268
2026-03-14 13:52:58 -05:00
KeatonTheBot
6f90e47a73 UI: Restore FluentAvaloniaUI package, disable animations on app initialization (ryubing/ryujinx!256)
See merge request ryubing/ryujinx!256
2026-03-14 13:48:59 -05:00
sh0inx
ac5f9857e2 HLE: Implement IHidServer IsSixAxisSensorAtRest (ryubing/ryujinx!228)
See merge request ryubing/ryujinx!228
2026-03-14 13:25:55 -05:00
KeatonTheBot
4b42087bd4 Linux: Fix file picker not launching from disabling core dumps (ryubing/ryujinx!249)
See merge request ryubing/ryujinx!249
2026-03-06 19:04:42 -06:00
EscoDev
80cbf5d1fc Fix incorrect save button locale in user editor (ryubing/ryujinx!280)
See merge request ryubing/ryujinx!280
2026-03-01 15:48:29 -06:00
LotP
cc6d2dc162 fix nacp language buffer (ryubing/ryujinx!281)
See merge request ryubing/ryujinx!281
2026-02-25 13:58:31 -06:00
57 changed files with 1917 additions and 283 deletions

View file

@ -3,13 +3,13 @@
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageVersion Include="Avalonia" Version="11.3.6" /> <PackageVersion Include="Avalonia" Version="11.3.12" />
<PackageVersion Include="Avalonia.Controls.DataGrid" Version="11.3.6" /> <PackageVersion Include="Avalonia.Controls.DataGrid" Version="11.3.12" />
<PackageVersion Include="Avalonia.Desktop" Version="11.3.6" /> <PackageVersion Include="Avalonia.Desktop" Version="11.3.12" />
<PackageVersion Include="Avalonia.Diagnostics" Version="11.3.6" /> <PackageVersion Include="Avalonia.Diagnostics" Version="11.3.12" />
<PackageVersion Include="Avalonia.Markup.Xaml.Loader" Version="11.3.6" /> <PackageVersion Include="Avalonia.Markup.Xaml.Loader" Version="11.3.12" />
<PackageVersion Include="Svg.Controls.Avalonia" Version="11.3.6.2" /> <PackageVersion Include="Svg.Controls.Avalonia" Version="11.3.9.4" />
<PackageVersion Include="Svg.Controls.Skia.Avalonia" Version="11.3.6.2" /> <PackageVersion Include="Svg.Controls.Skia.Avalonia" Version="11.3.9.4" />
<PackageVersion Include="Microsoft.Build.Framework" Version="17.11.4" /> <PackageVersion Include="Microsoft.Build.Framework" Version="17.11.4" />
<PackageVersion Include="Microsoft.Build.Utilities.Core" Version="17.12.6" /> <PackageVersion Include="Microsoft.Build.Utilities.Core" Version="17.12.6" />
<PackageVersion Include="Newtonsoft.Json" Version="13.0.3" /> <PackageVersion Include="Newtonsoft.Json" Version="13.0.3" />
@ -22,7 +22,7 @@
<PackageVersion Include="Concentus" Version="2.2.2" /> <PackageVersion Include="Concentus" Version="2.2.2" />
<PackageVersion Include="DiscordRichPresence" Version="1.6.1.70" /> <PackageVersion Include="DiscordRichPresence" Version="1.6.1.70" />
<PackageVersion Include="DynamicData" Version="9.4.1" /> <PackageVersion Include="DynamicData" Version="9.4.1" />
<PackageVersion Include="FluentAvaloniaUI.NoAnim" Version="2.4.0-build3" /> <PackageVersion Include="FluentAvaloniaUI" Version="2.5.0" />
<PackageVersion Include="Humanizer" Version="2.14.1" /> <PackageVersion Include="Humanizer" Version="2.14.1" />
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.4" /> <PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.4" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="4.9.2" /> <PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="4.9.2" />
@ -30,6 +30,7 @@
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.9.0" /> <PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.9.0" />
<PackageVersion Include="Microsoft.IO.RecyclableMemoryStream" Version="3.0.1" /> <PackageVersion Include="Microsoft.IO.RecyclableMemoryStream" Version="3.0.1" />
<PackageVersion Include="MsgPack.Cli" Version="1.0.1" /> <PackageVersion Include="MsgPack.Cli" Version="1.0.1" />
<PackageVersion Include="Melanchall.DryWetMIDI" Version="8.0.1" />
<PackageVersion Include="NetCoreServer" Version="8.0.7" /> <PackageVersion Include="NetCoreServer" Version="8.0.7" />
<PackageVersion Include="NUnit" Version="3.13.3" /> <PackageVersion Include="NUnit" Version="3.13.3" />
<PackageVersion Include="NUnit3TestAdapter" Version="4.1.0" /> <PackageVersion Include="NUnit3TestAdapter" Version="4.1.0" />
@ -41,7 +42,7 @@
<PackageVersion Include="Ryujinx.Audio.OpenAL.Dependencies" Version="1.21.0.1" /> <PackageVersion Include="Ryujinx.Audio.OpenAL.Dependencies" Version="1.21.0.1" />
<PackageVersion Include="Ryujinx.Graphics.Nvdec.Dependencies.AllArch" Version="6.1.2-build3" /> <PackageVersion Include="Ryujinx.Graphics.Nvdec.Dependencies.AllArch" Version="6.1.2-build3" />
<PackageVersion Include="Ryujinx.Graphics.Vulkan.Dependencies.MoltenVK" Version="1.2.0" /> <PackageVersion Include="Ryujinx.Graphics.Vulkan.Dependencies.MoltenVK" Version="1.2.0" />
<PackageVersion Include="Ryujinx.LibHac" Version="0.21.0-alpha.126" /> <PackageVersion Include="Ryujinx.LibHac" Version="0.21.0-alpha.129" />
<PackageVersion Include="Ryujinx.UpdateClient" Version="1.0.44" /> <PackageVersion Include="Ryujinx.UpdateClient" Version="1.0.44" />
<PackageVersion Include="Ryujinx.Systems.Update.Common" Version="1.0.44" /> <PackageVersion Include="Ryujinx.Systems.Update.Common" Version="1.0.44" />
<PackageVersion Include="Gommon" Version="2.8.0.1" /> <PackageVersion Include="Gommon" Version="2.8.0.1" />

View file

@ -59,6 +59,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ryujinx.Input", "src\Ryujin
EndProject EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ryujinx.Input.SDL3", "src\Ryujinx.Input.SDL3\Ryujinx.Input.SDL3.csproj", "{D728444C-3D1F-4A0E-B4C9-5C9375D47EA3}" Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ryujinx.Input.SDL3", "src\Ryujinx.Input.SDL3\Ryujinx.Input.SDL3.csproj", "{D728444C-3D1F-4A0E-B4C9-5C9375D47EA3}"
EndProject EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ryujinx.Input.Midi", "src\Ryujinx.Input.Midi\Ryujinx.Input.Midi.csproj", "{2E49F4FB-4ABA-49A5-9292-8C969F22F44E}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ryujinx.Graphics.Nvdec.FFmpeg", "src\Ryujinx.Graphics.Nvdec.FFmpeg\Ryujinx.Graphics.Nvdec.FFmpeg.csproj", "{BEE1C184-C9A4-410B-8DFC-FB74D5C93AEB}" Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ryujinx.Graphics.Nvdec.FFmpeg", "src\Ryujinx.Graphics.Nvdec.FFmpeg\Ryujinx.Graphics.Nvdec.FFmpeg.csproj", "{BEE1C184-C9A4-410B-8DFC-FB74D5C93AEB}"
EndProject EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ryujinx", "src\Ryujinx\Ryujinx.csproj", "{7C1B2721-13DA-4B62-B046-C626605ECCE6}" Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ryujinx", "src\Ryujinx\Ryujinx.csproj", "{7C1B2721-13DA-4B62-B046-C626605ECCE6}"
@ -392,6 +394,18 @@ Global
{C16F112F-38C3-40BC-9F5F-4791112063D6}.Release|x64.Build.0 = Release|Any CPU {C16F112F-38C3-40BC-9F5F-4791112063D6}.Release|x64.Build.0 = Release|Any CPU
{C16F112F-38C3-40BC-9F5F-4791112063D6}.Release|x86.ActiveCfg = Release|Any CPU {C16F112F-38C3-40BC-9F5F-4791112063D6}.Release|x86.ActiveCfg = Release|Any CPU
{C16F112F-38C3-40BC-9F5F-4791112063D6}.Release|x86.Build.0 = Release|Any CPU {C16F112F-38C3-40BC-9F5F-4791112063D6}.Release|x86.Build.0 = Release|Any CPU
{2E49F4FB-4ABA-49A5-9292-8C969F22F44E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{2E49F4FB-4ABA-49A5-9292-8C969F22F44E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2E49F4FB-4ABA-49A5-9292-8C969F22F44E}.Debug|x64.ActiveCfg = Debug|Any CPU
{2E49F4FB-4ABA-49A5-9292-8C969F22F44E}.Debug|x64.Build.0 = Debug|Any CPU
{2E49F4FB-4ABA-49A5-9292-8C969F22F44E}.Debug|x86.ActiveCfg = Debug|Any CPU
{2E49F4FB-4ABA-49A5-9292-8C969F22F44E}.Debug|x86.Build.0 = Debug|Any CPU
{2E49F4FB-4ABA-49A5-9292-8C969F22F44E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2E49F4FB-4ABA-49A5-9292-8C969F22F44E}.Release|Any CPU.Build.0 = Release|Any CPU
{2E49F4FB-4ABA-49A5-9292-8C969F22F44E}.Release|x64.ActiveCfg = Release|Any CPU
{2E49F4FB-4ABA-49A5-9292-8C969F22F44E}.Release|x64.Build.0 = Release|Any CPU
{2E49F4FB-4ABA-49A5-9292-8C969F22F44E}.Release|x86.ActiveCfg = Release|Any CPU
{2E49F4FB-4ABA-49A5-9292-8C969F22F44E}.Release|x86.Build.0 = Release|Any CPU
{BEE1C184-C9A4-410B-8DFC-FB74D5C93AEB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {BEE1C184-C9A4-410B-8DFC-FB74D5C93AEB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{BEE1C184-C9A4-410B-8DFC-FB74D5C93AEB}.Debug|Any CPU.Build.0 = Debug|Any CPU {BEE1C184-C9A4-410B-8DFC-FB74D5C93AEB}.Debug|Any CPU.Build.0 = Debug|Any CPU
{BEE1C184-C9A4-410B-8DFC-FB74D5C93AEB}.Debug|x64.ActiveCfg = Debug|Any CPU {BEE1C184-C9A4-410B-8DFC-FB74D5C93AEB}.Debug|x64.ActiveCfg = Debug|Any CPU

View file

@ -575,6 +575,31 @@
"zh_TW": "停止模擬" "zh_TW": "停止模擬"
} }
}, },
{
"ID": "MenuBarOptionsRestartEmulation",
"Translations": {
"ar_SA": "",
"de_DE": "",
"el_GR": "",
"en_US": "Restart Emulation",
"es_ES": "",
"fr_FR": "",
"he_IL": "",
"it_IT": "",
"ja_JP": "",
"ko_KR": "",
"no_NO": "",
"pl_PL": "",
"pt_BR": "",
"ru_RU": "",
"sv_SE": "",
"th_TH": "",
"tr_TR": "",
"uk_UA": "",
"zh_CN": "",
"zh_TW": ""
}
},
{ {
"ID": "MenuBarOptionsSettings", "ID": "MenuBarOptionsSettings",
"Translations": { "Translations": {
@ -11300,6 +11325,31 @@
"zh_TW": "刪除" "zh_TW": "刪除"
} }
}, },
{
"ID": "UserProfilesSave",
"Translations": {
"ar_SA": "",
"de_DE": "Speichern",
"el_GR": "",
"en_US": "Save",
"es_ES": "",
"fr_FR": "",
"he_IL": "",
"it_IT": "",
"ja_JP": "",
"ko_KR": "",
"no_NO": "",
"pl_PL": "",
"pt_BR": "",
"ru_RU": "",
"sv_SE": "",
"th_TH": "",
"tr_TR": "",
"uk_UA": "",
"zh_CN": "",
"zh_TW": ""
}
},
{ {
"ID": "UserProfilesClose", "ID": "UserProfilesClose",
"Translations": { "Translations": {

View file

@ -7,6 +7,7 @@ namespace Ryujinx.Common.Configuration.Hid
{ {
Invalid, Invalid,
WindowKeyboard, WindowKeyboard,
Midi,
GamepadSDL2, //backcompat GamepadSDL2, //backcompat
GamepadSDL3, GamepadSDL3,
} }

View file

@ -1,5 +1,6 @@
using Ryujinx.Common.Configuration.Hid.Controller; using Ryujinx.Common.Configuration.Hid.Controller;
using Ryujinx.Common.Configuration.Hid.Keyboard; using Ryujinx.Common.Configuration.Hid.Keyboard;
using Ryujinx.Common.Configuration.Hid.Midi;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
namespace Ryujinx.Common.Configuration.Hid namespace Ryujinx.Common.Configuration.Hid
@ -7,6 +8,7 @@ namespace Ryujinx.Common.Configuration.Hid
[JsonSourceGenerationOptions(WriteIndented = true)] [JsonSourceGenerationOptions(WriteIndented = true)]
[JsonSerializable(typeof(InputConfig))] [JsonSerializable(typeof(InputConfig))]
[JsonSerializable(typeof(StandardKeyboardInputConfig))] [JsonSerializable(typeof(StandardKeyboardInputConfig))]
[JsonSerializable(typeof(StandardMidiInputConfig))]
[JsonSerializable(typeof(StandardControllerInputConfig))] [JsonSerializable(typeof(StandardControllerInputConfig))]
public partial class InputConfigJsonSerializerContext : JsonSerializerContext public partial class InputConfigJsonSerializerContext : JsonSerializerContext
{ {

View file

@ -1,5 +1,6 @@
using Ryujinx.Common.Configuration.Hid.Controller; using Ryujinx.Common.Configuration.Hid.Controller;
using Ryujinx.Common.Configuration.Hid.Keyboard; using Ryujinx.Common.Configuration.Hid.Keyboard;
using Ryujinx.Common.Configuration.Hid.Midi;
using Ryujinx.Common.Utilities; using Ryujinx.Common.Utilities;
using System; using System;
using System.Text.Json; using System.Text.Json;
@ -58,6 +59,7 @@ namespace Ryujinx.Common.Configuration.Hid
return backendType switch return backendType switch
{ {
InputBackendType.WindowKeyboard => JsonSerializer.Deserialize(ref reader, _serializerContext.StandardKeyboardInputConfig), InputBackendType.WindowKeyboard => JsonSerializer.Deserialize(ref reader, _serializerContext.StandardKeyboardInputConfig),
InputBackendType.Midi => JsonSerializer.Deserialize(ref reader, _serializerContext.StandardMidiInputConfig),
InputBackendType.GamepadSDL2 or InputBackendType.GamepadSDL3 => JsonSerializer.Deserialize(ref reader, _serializerContext.StandardControllerInputConfig), InputBackendType.GamepadSDL2 or InputBackendType.GamepadSDL3 => JsonSerializer.Deserialize(ref reader, _serializerContext.StandardControllerInputConfig),
_ => throw new InvalidOperationException($"Unknown backend type {backendType}"), _ => throw new InvalidOperationException($"Unknown backend type {backendType}"),
}; };
@ -70,6 +72,9 @@ namespace Ryujinx.Common.Configuration.Hid
case InputBackendType.WindowKeyboard: case InputBackendType.WindowKeyboard:
JsonSerializer.Serialize(writer, value as StandardKeyboardInputConfig, _serializerContext.StandardKeyboardInputConfig); JsonSerializer.Serialize(writer, value as StandardKeyboardInputConfig, _serializerContext.StandardKeyboardInputConfig);
break; break;
case InputBackendType.Midi:
JsonSerializer.Serialize(writer, value as StandardMidiInputConfig, _serializerContext.StandardMidiInputConfig);
break;
case InputBackendType.GamepadSDL2 or InputBackendType.GamepadSDL3: case InputBackendType.GamepadSDL2 or InputBackendType.GamepadSDL3:
JsonSerializer.Serialize(writer, value as StandardControllerInputConfig, _serializerContext.StandardControllerInputConfig); JsonSerializer.Serialize(writer, value as StandardControllerInputConfig, _serializerContext.StandardControllerInputConfig);
break; break;

View file

@ -0,0 +1,42 @@
using System;
namespace Ryujinx.Common.Configuration.Hid.Midi
{
public struct MidiBinding : IEquatable<MidiBinding>
{
public MidiBindingKind Kind { get; set; }
public byte Number { get; set; }
public byte Channel { get; set; }
public byte Threshold { get; set; }
public readonly bool IsUnbound => Kind == MidiBindingKind.Unbound;
public readonly bool Equals(MidiBinding other)
{
return Kind == other.Kind &&
Number == other.Number &&
Channel == other.Channel &&
Threshold == other.Threshold;
}
public override readonly bool Equals(object obj)
{
return obj is MidiBinding other && Equals(other);
}
public override readonly int GetHashCode()
{
return HashCode.Combine((byte)Kind, Number, Channel, Threshold);
}
public static bool operator ==(MidiBinding left, MidiBinding right)
{
return left.Equals(right);
}
public static bool operator !=(MidiBinding left, MidiBinding right)
{
return !left.Equals(right);
}
}
}

View file

@ -0,0 +1,12 @@
using System.Text.Json.Serialization;
namespace Ryujinx.Common.Configuration.Hid.Midi
{
[JsonConverter(typeof(JsonStringEnumConverter<MidiBindingKind>))]
public enum MidiBindingKind : byte
{
Unbound,
Note,
ControlChange,
}
}

View file

@ -0,0 +1,6 @@
using Ryujinx.Common.Configuration.Hid.Keyboard;
namespace Ryujinx.Common.Configuration.Hid.Midi
{
public class StandardMidiInputConfig : GenericKeyboardInputConfig<MidiBinding> { }
}

View file

@ -22,10 +22,11 @@ namespace Ryujinx.Common.Utilities
} }
// "dumpable" attribute of the calling process // "dumpable" attribute of the calling process
private const int PR_GET_DUMPABLE = 3;
private const int PR_SET_DUMPABLE = 4; private const int PR_SET_DUMPABLE = 4;
[DllImport("libc", SetLastError = true)] [LibraryImport("libc", SetLastError = true)]
private static extern int prctl(int option, int arg2); private static partial int prctl(int option, int arg2);
public static void SetCoreDumpable(bool dumpable) public static void SetCoreDumpable(bool dumpable)
{ {
@ -36,5 +37,13 @@ namespace Ryujinx.Common.Utilities
Debug.Assert(result == 0); Debug.Assert(result == 0);
} }
} }
// Use the below line to display dumpable status in the console:
// Console.WriteLine($"{OsUtils.IsCoreDumpable()}");
public static bool IsCoreDumpable()
{
int result = prctl(PR_GET_DUMPABLE, 0);
return result == 1;
}
} }
} }

View file

@ -488,6 +488,8 @@ namespace Ryujinx.HLE.FileSystem
if (keyPaths.Length is 0) if (keyPaths.Length is 0)
throw new FileNotFoundException($"Directory '{keysSource}' contained no '.keys' files."); throw new FileNotFoundException($"Directory '{keysSource}' contained no '.keys' files.");
List<string> failedFiles = new();
foreach (string filePath in keyPaths) foreach (string filePath in keyPaths)
{ {
try try
@ -497,17 +499,20 @@ namespace Ryujinx.HLE.FileSystem
catch (Exception e) catch (Exception e)
{ {
Logger.Error?.Print(LogClass.Application, e.Message); Logger.Error?.Print(LogClass.Application, e.Message);
failedFiles.Add(Path.GetFileName(filePath));
continue; continue;
} }
string destPath = Path.Combine(installDirectory, Path.GetFileName(filePath)); string destPath = Path.Combine(installDirectory, Path.GetFileName(filePath));
if (File.Exists(destPath))
File.Delete(destPath);
File.Copy(filePath, destPath, true); File.Copy(filePath, destPath, true);
} }
if (failedFiles.Count > 0)
{
throw new InvalidOperationException($"Failed to install the following key files: {string.Join(", ", failedFiles)}");
}
return; return;
} }
@ -518,8 +523,6 @@ namespace Ryujinx.HLE.FileSystem
FileInfo info = new(keysSource); FileInfo info = new(keysSource);
using FileStream file = File.OpenRead(keysSource);
if (info.Extension is not ".keys") if (info.Extension is not ".keys")
throw new InvalidFirmwarePackageException("Input file extension is not .keys"); throw new InvalidFirmwarePackageException("Input file extension is not .keys");
@ -534,10 +537,6 @@ namespace Ryujinx.HLE.FileSystem
string dest = Path.Combine(installDirectory, info.Name); string dest = Path.Combine(installDirectory, info.Name);
if (File.Exists(dest))
File.Delete(dest);
// overwrite: true seems to not work on its own? https://github.com/Ryubing/Issues/issues/189
File.Copy(keysSource, dest, true); File.Copy(keysSource, dest, true);
} }
@ -1059,7 +1058,7 @@ namespace Ryujinx.HLE.FileSystem
} }
} }
public static bool AreKeysAlredyPresent(string pathToCheck) public static bool AreKeysAlreadyPresent(string pathToCheck)
{ {
string[] fileNames = ["prod.keys", "title.keys", "console.keys", "dev.keys"]; string[] fileNames = ["prod.keys", "title.keys", "console.keys", "dev.keys"];
foreach (string file in fileNames) foreach (string file in fileNames)

View file

@ -56,6 +56,7 @@ namespace Ryujinx.HLE.HOS.Services.Hid
_activeCount = 0; _activeCount = 0;
JoyHold = NpadJoyHoldType.Vertical; JoyHold = NpadJoyHoldType.Vertical;
SixAxisActive = false;
} }
internal ref KEvent GetStyleSetUpdateEvent(PlayerIndex player) internal ref KEvent GetStyleSetUpdateEvent(PlayerIndex player)
@ -581,6 +582,29 @@ namespace Ryujinx.HLE.HOS.Services.Hid
return needUpdateRight; return needUpdateRight;
} }
public bool isAtRest(int playerNumber)
{
ref NpadInternalState currentNpad = ref _device.Hid.SharedMemory.Npads[playerNumber].InternalState;
if (currentNpad.StyleSet == NpadStyleTag.None)
{
return true; // it will always be at rest because it cannot move.
}
ref SixAxisSensorState storage = ref GetSixAxisSensorLifo(ref currentNpad, false).GetCurrentEntryRef();
float acceleration = Math.Abs(storage.Acceleration.X)
+ Math.Abs(storage.Acceleration.Y)
+ Math.Abs(storage.Acceleration.Z);
float angularVelocity = Math.Abs(storage.AngularVelocity.X)
+ Math.Abs(storage.AngularVelocity.Y)
+ Math.Abs(storage.AngularVelocity.Z);
// TODO: check against config deadzone and add sensitivity setting
return ((acceleration <= 1.0F) && (angularVelocity <= 1.0F));
}
private void UpdateDisconnectedInputSixAxis(PlayerIndex index) private void UpdateDisconnectedInputSixAxis(PlayerIndex index)
{ {
ref NpadInternalState currentNpad = ref _device.Hid.SharedMemory.Npads[(int)index].InternalState; ref NpadInternalState currentNpad = ref _device.Hid.SharedMemory.Npads[(int)index].InternalState;

View file

@ -602,19 +602,33 @@ namespace Ryujinx.HLE.HOS.Services.Hid
} }
[CommandCmif(82)] [CommandCmif(82)]
// IsSixAxisSensorAtRest(nn::hid::SixAxisSensorHandle, nn::applet::AppletResourceUserId) -> bool IsAsRest // IsSixAxisSensorAtRest(nn::hid::SixAxisSensorHandle, nn::applet::AppletResourceUserId) -> bool IsAtRest
public ResultCode IsSixAxisSensorAtRest(ServiceCtx context) public ResultCode IsSixAxisSensorAtRest(ServiceCtx context)
{ {
int sixAxisSensorHandle = context.RequestData.ReadInt32(); int sixAxisSensorHandle = context.RequestData.ReadInt32();
// 4 byte struct w/ 4-byte alignment
// uint typeValue = (uint) sixAxisSensorHandle; // 0x0 0x4 TypeValue
// uint npadStyleIndex = (uint) sixAxisSensorHandle & 0xff; // 0x0 0x1 NpadStyleIndex
int playerNumber = (sixAxisSensorHandle << 8) & 0xff; // 0x1 0x1 PlayerNumber
// uint deviceIdx= ((uint) sixAxisSensorHandle << 16) & 0xff; // 0x2 0x1 DeviceIdx
// uint unknown = ((uint) sixAxisSensorHandle << 24) & 0xff;
// 32bit sign extension padding -> if = 0, + offset, else - offset
// npadStyleIndex = ((npadStyleIndex & 0x8000) == 0) ? npadStyleIndex | 0xFFFF0000 : npadStyleIndex & 0xFFFF0000;
// playerNumber = ((playerNumber & 0x8000) == 0) ? playerNumber | 0xFFFF0000 : playerNumber & 0xFFFF0000;
// deviceIdx = ((deviceIdx & 0x8000) == 0) ? deviceIdx | 0xFFFF0000 : deviceIdx & 0xFFFF0000;
// unknown = ((unknown & 0x8000) == 0) ? unknown | 0xFFFF0000 : unknown & 0xFFFF0000;
context.RequestData.BaseStream.Position += 4; // Padding context.RequestData.BaseStream.Position += 4; // Padding
long appletResourceUserId = context.RequestData.ReadInt64(); long appletResourceUserId = context.RequestData.ReadInt64();
bool isAtRest = true; // TODO: link to context.Device.Hid.Npads.SixAxisActive when properly implemented
// We currently do not support stopping or starting SixAxisTracking.
context.ResponseData.Write(isAtRest);
Logger.Stub?.PrintStub(LogClass.ServiceHid, new { appletResourceUserId, sixAxisSensorHandle, isAtRest });
context.ResponseData.Write(context.Device.Hid.Npads.isAtRest(playerNumber));
return ResultCode.Success; return ResultCode.Success;
} }

View file

@ -17,7 +17,7 @@ namespace Ryujinx.HLE.HOS.Services.Ssl
public ISslService(ServiceCtx context) { } public ISslService(ServiceCtx context) { }
[CommandCmif(0)] [CommandCmif(0)]
// CreateContext(nn::ssl::sf::SslVersion, u64, pid) -> object<nn::ssl::sf::ISslContext> // CreateContext(nn::ssl::sf::SslVersion, u64 pid_placeholder, pid) -> object<nn::ssl::sf::ISslContext>
public ResultCode CreateContext(ServiceCtx context) public ResultCode CreateContext(ServiceCtx context)
{ {
SslVersion sslVersion = (SslVersion)context.RequestData.ReadUInt32(); SslVersion sslVersion = (SslVersion)context.RequestData.ReadUInt32();
@ -126,14 +126,18 @@ namespace Ryujinx.HLE.HOS.Services.Ssl
} }
[CommandCmif(100)] [CommandCmif(100)]
// CreateContextForSystem(u64 pid, nn::ssl::sf::SslVersion, u64) // CreateContextForSystem(nn::ssl::sf::SslVersion, u64 pid_placeholder, pid) -> object<nn::ssl::sf::ISslContextForSystem>
public ResultCode CreateContextForSystem(ServiceCtx context) public ResultCode CreateContextForSystem(ServiceCtx context)
{ {
ulong pid = context.RequestData.ReadUInt64();
SslVersion sslVersion = (SslVersion)context.RequestData.ReadUInt32(); SslVersion sslVersion = (SslVersion)context.RequestData.ReadUInt32();
#pragma warning disable IDE0059 // Remove unnecessary value assignment
ulong pidPlaceholder = context.RequestData.ReadUInt64(); ulong pidPlaceholder = context.RequestData.ReadUInt64();
#pragma warning restore IDE0059
Logger.Stub?.PrintStub(LogClass.ServiceSsl, new { pid, sslVersion, pidPlaceholder }); // Note: We use ISslContext here instead of ISslContextForSystem class because Ryujinx implements both in one class.
MakeObject(context, new ISslContext(context.Request.HandleDesc.PId, sslVersion));
Logger.Stub?.PrintStub(LogClass.ServiceSsl, new { sslVersion });
return ResultCode.Success; return ResultCode.Success;
} }

View file

@ -20,5 +20,7 @@ namespace Ryujinx.HLE.HOS.SystemState
SimplifiedChinese, SimplifiedChinese,
TraditionalChinese, TraditionalChinese,
BrazilianPortuguese, BrazilianPortuguese,
Polish,
Thai,
} }
} }

View file

@ -23,7 +23,9 @@ namespace Ryujinx.HLE.HOS.SystemState
"es-419", "es-419",
"zh-Hans", "zh-Hans",
"zh-Hant", "zh-Hant",
"pt-BR" "pt-BR",
"pl",
"th"
]; ];
internal long DesiredKeyboardLayout { get; private set; } internal long DesiredKeyboardLayout { get; private set; }

View file

@ -18,5 +18,7 @@ namespace Ryujinx.HLE.HOS.SystemState
TraditionalChinese, TraditionalChinese,
SimplifiedChinese, SimplifiedChinese,
BrazilianPortuguese, BrazilianPortuguese,
Polish,
Thai,
} }
} }

View file

@ -1,12 +1,19 @@
using Ryujinx.Common.Memory; using Ryujinx.Common.Memory;
using System; using System;
using System.Buffers.Binary;
using System.IO;
using System.IO.Compression;
using System.Runtime.InteropServices;
using System.Text; using System.Text;
namespace Ryujinx.Horizon.Sdk.Ns namespace Ryujinx.Horizon.Sdk.Ns
{ {
public struct ApplicationControlProperty public struct ApplicationControlProperty
{ {
public Array16<ApplicationTitle> Title; /// <summary>
/// Use <see cref="Title"/> to access titles instead of accessing them directly.
/// </summary>
public Array16<ApplicationTitle> TitleBlock;
public Array37<byte> Isbn; public Array37<byte> Isbn;
public StartupUserAccountValue StartupUserAccount; public StartupUserAccountValue StartupUserAccount;
public UserAccountSwitchLockValue UserAccountSwitchLock; public UserAccountSwitchLockValue UserAccountSwitchLock;
@ -58,7 +65,10 @@ namespace Ryujinx.Horizon.Sdk.Ns
public RepairFlagValue RepairFlag; public RepairFlagValue RepairFlag;
public byte ProgramIndex; public byte ProgramIndex;
public RequiredNetworkServiceLicenseOnLaunchValue RequiredNetworkServiceLicenseOnLaunchFlag; public RequiredNetworkServiceLicenseOnLaunchValue RequiredNetworkServiceLicenseOnLaunchFlag;
public Array4<byte> Reserved3214; public byte ApplicationErrorCodePrefix;
public TitleCompressionValue TitleCompression;
public byte AcdIndex;
public byte ApparentPlatform;
public ApplicationNeighborDetectionClientConfiguration NeighborDetectionClientConfiguration; public ApplicationNeighborDetectionClientConfiguration NeighborDetectionClientConfiguration;
public ApplicationJitConfiguration JitConfiguration; public ApplicationJitConfiguration JitConfiguration;
public RequiredAddOnContentsSetBinaryDescriptor RequiredAddOnContentsSetBinaryDescriptors; public RequiredAddOnContentsSetBinaryDescriptor RequiredAddOnContentsSetBinaryDescriptors;
@ -75,6 +85,47 @@ namespace Ryujinx.Horizon.Sdk.Ns
public readonly string ApplicationErrorCodeCategoryString => Encoding.UTF8.GetString(ApplicationErrorCodeCategory.AsSpan()).TrimEnd('\0'); public readonly string ApplicationErrorCodeCategoryString => Encoding.UTF8.GetString(ApplicationErrorCodeCategory.AsSpan()).TrimEnd('\0');
public readonly string BcatPassphraseString => Encoding.UTF8.GetString(BcatPassphrase.AsSpan()).TrimEnd('\0'); public readonly string BcatPassphraseString => Encoding.UTF8.GetString(BcatPassphrase.AsSpan()).TrimEnd('\0');
private const int TitleCount = 32;
private const int TitleEntrySize = 0x300;
/// <summary>
/// Returns the resolved title entries. When <see cref="TitleCompression"/> is
/// <see cref="TitleCompressionValue.Enable"/>, the raw <see cref="TitleBlock"/> bytes are
/// decompressed (raw deflate) from 0x3000 into 0x6000 bytes yielding up to 32 entries.
/// Otherwise the 16 uncompressed entries from <see cref="TitleBlock"/> are returned directly.
/// </summary>
public readonly ApplicationTitle[] Title
{
get
{
var titles = new ApplicationTitle[TitleCount];
if (TitleCompression != TitleCompressionValue.Enable)
{
TitleBlock.AsSpan().CopyTo(titles);
return titles;
}
ReadOnlySpan<byte> titleBytes = MemoryMarshal.AsBytes(TitleBlock.AsSpan());
ushort compressedBlobSize = BinaryPrimitives.ReadUInt16LittleEndian(titleBytes);
ReadOnlySpan<byte> compressedBlob = titleBytes.Slice(2, compressedBlobSize);
byte[] decompressed = new byte[TitleCount * TitleEntrySize];
using (var compressedStream = new MemoryStream(compressedBlob.ToArray()))
using (var deflateStream = new DeflateStream(compressedStream, CompressionMode.Decompress))
{
deflateStream.ReadExactly(decompressed, 0, decompressed.Length);
}
MemoryMarshal.Cast<byte, ApplicationTitle>(decompressed).CopyTo(titles);
return titles;
}
}
public struct ApplicationTitle public struct ApplicationTitle
{ {
public ByteArray512 Name; public ByteArray512 Name;
@ -130,6 +181,8 @@ namespace Ryujinx.Horizon.Sdk.Ns
TraditionalChinese = 13, TraditionalChinese = 13,
SimplifiedChinese = 14, SimplifiedChinese = 14,
BrazilianPortuguese = 15, BrazilianPortuguese = 15,
Polish = 16,
Thai = 17,
} }
public enum Organization public enum Organization
@ -302,5 +355,11 @@ namespace Ryujinx.Horizon.Sdk.Ns
Deny = 0, Deny = 0,
Allow = 1, Allow = 1,
} }
public enum TitleCompressionValue : byte
{
Disable = 0,
Enable = 1,
}
} }
} }

View file

@ -0,0 +1,58 @@
using Ryujinx.Common.Configuration.Hid.Midi;
namespace Ryujinx.Input.Midi.Assigner
{
public sealed class MidiBindingAssigner
{
private readonly IMidiGamepad _gamepad;
private MidiCapturedInput? _capturedInput;
public MidiBindingAssigner(IMidiGamepad gamepad)
{
_gamepad = gamepad;
}
public void Initialize()
{
_gamepad?.ResetCapturedInputs();
_capturedInput = null;
}
public void ReadInput()
{
if (_gamepad != null && _gamepad.TryDequeueCapturedInput(out MidiCapturedInput input))
{
_capturedInput = input;
}
}
public bool IsAnyButtonPressed()
{
return _capturedInput.HasValue;
}
public bool ShouldCancel()
{
return _gamepad == null || !_gamepad.IsConnected;
}
public MidiBinding? GetPressedBinding(bool captureAnyChannel, byte threshold)
{
if (!_capturedInput.HasValue)
{
return null;
}
MidiCapturedInput input = _capturedInput.Value;
_capturedInput = null;
return new MidiBinding
{
Kind = input.Kind,
Number = input.Number,
Channel = captureAnyChannel ? (byte)0 : input.Channel,
Threshold = threshold,
};
}
}
}

View file

@ -0,0 +1,8 @@
namespace Ryujinx.Input.Midi
{
public interface IMidiGamepad : IGamepad
{
bool TryDequeueCapturedInput(out MidiCapturedInput input);
void ResetCapturedInputs();
}
}

View file

@ -0,0 +1,20 @@
using Ryujinx.Common.Configuration.Hid.Midi;
namespace Ryujinx.Input.Midi
{
public readonly struct MidiCapturedInput
{
public MidiBindingKind Kind { get; }
public byte Number { get; }
public byte Channel { get; }
public byte Value { get; }
public MidiCapturedInput(MidiBindingKind kind, byte number, byte channel, byte value)
{
Kind = kind;
Number = number;
Channel = channel;
Value = value;
}
}
}

View file

@ -0,0 +1,58 @@
using Melanchall.DryWetMidi.Core;
using Melanchall.DryWetMidi.Multimedia;
using System;
namespace Ryujinx.Input.Midi
{
internal sealed class MidiDeviceConnection : IDisposable
{
private readonly InputDevice _device;
public MidiDeviceState State { get; } = new();
public bool IsConnected { get; private set; }
public MidiDeviceConnection(InputDevice device)
{
_device = device;
_device.EventReceived += HandleEventReceived;
_device.StartEventsListening();
IsConnected = true;
}
private void HandleEventReceived(object sender, MidiEventReceivedEventArgs e)
{
switch (e.Event)
{
case NoteOnEvent noteOn:
{
byte channel = (byte)noteOn.Channel;
byte noteNumber = (byte)noteOn.NoteNumber;
byte velocity = (byte)noteOn.Velocity;
State.SetNoteValue(channel, noteNumber, velocity);
break;
}
case NoteOffEvent noteOff:
State.SetNoteValue((byte)noteOff.Channel, (byte)noteOff.NoteNumber, 0);
break;
case ControlChangeEvent controlChange:
State.SetControlValue((byte)controlChange.Channel, (byte)controlChange.ControlNumber, (byte)controlChange.ControlValue);
break;
}
}
public void Dispose()
{
if (!IsConnected)
{
return;
}
IsConnected = false;
_device.EventReceived -= HandleEventReceived;
_device.StopEventsListening();
_device.Dispose();
}
}
}

View file

@ -0,0 +1,94 @@
using Ryujinx.Common.Configuration.Hid.Midi;
using System.Collections.Generic;
using System.Threading;
namespace Ryujinx.Input.Midi
{
internal class MidiDeviceState
{
private readonly byte[,] _noteVelocities = new byte[16, 128];
private readonly byte[,] _controlValues = new byte[16, 128];
private readonly Queue<MidiCapturedInput> _capturedInputs = [];
private readonly Lock _lock = new();
public void SetNoteValue(byte channel, byte note, byte velocity)
{
lock (_lock)
{
_noteVelocities[channel, note] = velocity;
if (velocity > 0)
{
_capturedInputs.Enqueue(new MidiCapturedInput(MidiBindingKind.Note, note, (byte)(channel + 1), velocity));
}
}
}
public void SetControlValue(byte channel, byte control, byte value)
{
lock (_lock)
{
_controlValues[channel, control] = value;
_capturedInputs.Enqueue(new MidiCapturedInput(MidiBindingKind.ControlChange, control, (byte)(channel + 1), value));
}
}
public bool IsBindingActive(MidiBinding binding)
{
if (binding.IsUnbound)
{
return false;
}
lock (_lock)
{
int threshold = binding.Threshold == 0 ? 1 : binding.Threshold;
for (int channelIndex = 0; channelIndex < 16; channelIndex++)
{
if (binding.Channel != 0 && binding.Channel != channelIndex + 1)
{
continue;
}
int currentValue = binding.Kind switch
{
MidiBindingKind.Note => _noteVelocities[channelIndex, binding.Number],
MidiBindingKind.ControlChange => _controlValues[channelIndex, binding.Number],
_ => 0,
};
if (currentValue >= threshold)
{
return true;
}
}
return false;
}
}
public bool TryDequeueCapturedInput(out MidiCapturedInput input)
{
lock (_lock)
{
if (_capturedInputs.Count > 0)
{
input = _capturedInputs.Dequeue();
return true;
}
}
input = default;
return false;
}
public void ResetCapturedInputs()
{
lock (_lock)
{
_capturedInputs.Clear();
}
}
}
}

View file

@ -0,0 +1,185 @@
using Ryujinx.Common.Configuration.Hid;
using Ryujinx.Common.Configuration.Hid.Midi;
using System;
using System.Collections.Generic;
using System.Numerics;
using System.Threading;
namespace Ryujinx.Input.Midi
{
public sealed class MidiGamepad : IMidiGamepad
{
private readonly record struct ButtonMappingEntry(GamepadButtonInputId To, MidiBinding From)
{
public bool IsValid => To is not GamepadButtonInputId.Unbound && !From.IsUnbound;
}
private readonly MidiDeviceConnection _connection;
private readonly List<ButtonMappingEntry> _buttonsUserMapping = [];
private readonly Lock _userMappingLock = new();
private StandardMidiInputConfig _configuration;
public GamepadFeaturesFlag Features => GamepadFeaturesFlag.None;
public string Id { get; }
public string Name { get; }
public bool IsConnected => _connection.IsConnected;
internal MidiGamepad(string id, string name, MidiDeviceConnection connection)
{
Id = id;
Name = name;
_connection = connection;
}
public bool IsPressed(GamepadButtonInputId inputId)
{
return false;
}
public (float, float) GetStick(StickInputId inputId)
{
return (0, 0);
}
public Vector3 GetMotionData(MotionInputId inputId)
{
return Vector3.Zero;
}
public void SetTriggerThreshold(float triggerThreshold)
{
}
public void SetConfiguration(InputConfig configuration)
{
lock (_userMappingLock)
{
_configuration = (StandardMidiInputConfig)configuration;
_buttonsUserMapping.Clear();
_buttonsUserMapping.Add(new ButtonMappingEntry(GamepadButtonInputId.LeftStick, _configuration.LeftJoyconStick.StickButton));
_buttonsUserMapping.Add(new ButtonMappingEntry(GamepadButtonInputId.DpadUp, _configuration.LeftJoycon.DpadUp));
_buttonsUserMapping.Add(new ButtonMappingEntry(GamepadButtonInputId.DpadDown, _configuration.LeftJoycon.DpadDown));
_buttonsUserMapping.Add(new ButtonMappingEntry(GamepadButtonInputId.DpadLeft, _configuration.LeftJoycon.DpadLeft));
_buttonsUserMapping.Add(new ButtonMappingEntry(GamepadButtonInputId.DpadRight, _configuration.LeftJoycon.DpadRight));
_buttonsUserMapping.Add(new ButtonMappingEntry(GamepadButtonInputId.Minus, _configuration.LeftJoycon.ButtonMinus));
_buttonsUserMapping.Add(new ButtonMappingEntry(GamepadButtonInputId.LeftShoulder, _configuration.LeftJoycon.ButtonL));
_buttonsUserMapping.Add(new ButtonMappingEntry(GamepadButtonInputId.LeftTrigger, _configuration.LeftJoycon.ButtonZl));
_buttonsUserMapping.Add(new ButtonMappingEntry(GamepadButtonInputId.SingleRightTrigger0, _configuration.LeftJoycon.ButtonSr));
_buttonsUserMapping.Add(new ButtonMappingEntry(GamepadButtonInputId.SingleLeftTrigger0, _configuration.LeftJoycon.ButtonSl));
_buttonsUserMapping.Add(new ButtonMappingEntry(GamepadButtonInputId.RightStick, _configuration.RightJoyconStick.StickButton));
_buttonsUserMapping.Add(new ButtonMappingEntry(GamepadButtonInputId.A, _configuration.RightJoycon.ButtonA));
_buttonsUserMapping.Add(new ButtonMappingEntry(GamepadButtonInputId.B, _configuration.RightJoycon.ButtonB));
_buttonsUserMapping.Add(new ButtonMappingEntry(GamepadButtonInputId.X, _configuration.RightJoycon.ButtonX));
_buttonsUserMapping.Add(new ButtonMappingEntry(GamepadButtonInputId.Y, _configuration.RightJoycon.ButtonY));
_buttonsUserMapping.Add(new ButtonMappingEntry(GamepadButtonInputId.Plus, _configuration.RightJoycon.ButtonPlus));
_buttonsUserMapping.Add(new ButtonMappingEntry(GamepadButtonInputId.RightShoulder, _configuration.RightJoycon.ButtonR));
_buttonsUserMapping.Add(new ButtonMappingEntry(GamepadButtonInputId.RightTrigger, _configuration.RightJoycon.ButtonZr));
_buttonsUserMapping.Add(new ButtonMappingEntry(GamepadButtonInputId.SingleRightTrigger1, _configuration.RightJoycon.ButtonSr));
_buttonsUserMapping.Add(new ButtonMappingEntry(GamepadButtonInputId.SingleLeftTrigger1, _configuration.RightJoycon.ButtonSl));
}
}
public void SetLed(uint packedRgb)
{
}
public void Rumble(float lowFrequency, float highFrequency, uint durationMs)
{
}
public GamepadStateSnapshot GetMappedStateSnapshot()
{
GamepadStateSnapshot result = default;
lock (_userMappingLock)
{
if (_configuration == null)
{
return result;
}
foreach (ButtonMappingEntry entry in _buttonsUserMapping)
{
if (!entry.IsValid || result.IsPressed(entry.To))
{
continue;
}
result.SetPressed(entry.To, _connection.State.IsBindingActive(entry.From));
}
(short leftStickX, short leftStickY) = GetStickValues(_configuration.LeftJoyconStick);
(short rightStickX, short rightStickY) = GetStickValues(_configuration.RightJoyconStick);
result.SetStick(StickInputId.Left, ConvertRawStickValue(leftStickX), ConvertRawStickValue(leftStickY));
result.SetStick(StickInputId.Right, ConvertRawStickValue(rightStickX), ConvertRawStickValue(rightStickY));
}
return result;
}
public GamepadStateSnapshot GetStateSnapshot()
{
return GetMappedStateSnapshot();
}
public bool TryDequeueCapturedInput(out MidiCapturedInput input)
{
return _connection.State.TryDequeueCapturedInput(out input);
}
public void ResetCapturedInputs()
{
_connection.State.ResetCapturedInputs();
}
public void Dispose()
{
_connection.Dispose();
}
private static float ConvertRawStickValue(short value)
{
const float ConvertRate = 1.0f / (short.MaxValue + 0.5f);
return value * ConvertRate;
}
private (short, short) GetStickValues(Ryujinx.Common.Configuration.Hid.Keyboard.JoyconConfigKeyboardStick<MidiBinding> stickConfig)
{
short stickX = 0;
short stickY = 0;
if (_connection.State.IsBindingActive(stickConfig.StickUp))
{
stickY += 1;
}
if (_connection.State.IsBindingActive(stickConfig.StickDown))
{
stickY -= 1;
}
if (_connection.State.IsBindingActive(stickConfig.StickRight))
{
stickX += 1;
}
if (_connection.State.IsBindingActive(stickConfig.StickLeft))
{
stickX -= 1;
}
Vector2 stick = new(stickX, stickY);
if (stick != Vector2.Zero)
{
stick = Vector2.Normalize(stick);
}
return ((short)(stick.X * short.MaxValue), (short)(stick.Y * short.MaxValue));
}
}
}

View file

@ -0,0 +1,101 @@
using Melanchall.DryWetMidi.Multimedia;
using System;
using System.Collections.Generic;
using System.Linq;
namespace Ryujinx.Input.Midi
{
public sealed class MidiGamepadDriver : IGamepadDriver
{
public string DriverName => "MIDI";
public ReadOnlySpan<string> GamepadsIds => EnumerateDeviceIds().ToArray();
public event Action<string> OnGamepadConnected
{
add { }
remove { }
}
public event Action<string> OnGamepadDisconnected
{
add { }
remove { }
}
public bool IsSupported => OperatingSystem.IsWindows() || OperatingSystem.IsMacOS();
public string GetDeviceName(string id)
{
return TryGetDeviceInfo(id, out _, out string name) ? name : null;
}
public IGamepad GetGamepad(string id)
{
if (!TryGetDeviceInfo(id, out int index, out string name))
{
return null;
}
InputDevice device = InputDevice.GetByIndex(index);
if (device == null)
{
return null;
}
return new MidiGamepad(id, name, new MidiDeviceConnection(device));
}
public IEnumerable<IGamepad> GetGamepads()
{
foreach (string id in EnumerateDeviceIds())
{
IGamepad gamepad = GetGamepad(id);
if (gamepad != null)
{
yield return gamepad;
}
}
}
public void Dispose()
{
}
private IEnumerable<string> EnumerateDeviceIds()
{
if (!IsSupported)
{
yield break;
}
for (int index = 0; index < InputDevice.GetDevicesCount(); index++)
{
yield return index.ToString();
}
}
private bool TryGetDeviceInfo(string id, out int index, out string name)
{
index = -1;
name = null;
if (!IsSupported || !int.TryParse(id, out index))
{
return false;
}
if (index < 0 || index >= InputDevice.GetDevicesCount())
{
return false;
}
using InputDevice device = InputDevice.GetByIndex(index);
name = device?.Name;
return device != null;
}
}
}

View file

@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<DefaultItemExcludes>$(DefaultItemExcludes);._*</DefaultItemExcludes>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Melanchall.DryWetMIDI" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Ryujinx.Input\Ryujinx.Input.csproj" />
</ItemGroup>
</Project>

View file

@ -1,14 +1,32 @@
using Ryujinx.Common.Configuration.Hid;
using System; using System;
using System.Collections.Generic;
namespace Ryujinx.Input.HLE namespace Ryujinx.Input.HLE
{ {
public class InputManager(IGamepadDriver keyboardDriver, IGamepadDriver gamepadDriver) public class InputManager(IGamepadDriver keyboardDriver, IGamepadDriver gamepadDriver, IGamepadDriver midiDriver = null)
: IDisposable : IDisposable
{ {
private readonly Dictionary<InputBackendType, IGamepadDriver> _drivers = new()
{
{ InputBackendType.WindowKeyboard, keyboardDriver },
{ InputBackendType.Midi, midiDriver },
{ InputBackendType.GamepadSDL2, gamepadDriver },
{ InputBackendType.GamepadSDL3, gamepadDriver },
};
public IGamepadDriver KeyboardDriver { get; } = keyboardDriver; public IGamepadDriver KeyboardDriver { get; } = keyboardDriver;
public IGamepadDriver GamepadDriver { get; } = gamepadDriver; public IGamepadDriver GamepadDriver { get; } = gamepadDriver;
public IGamepadDriver MidiDriver { get; } = midiDriver;
public IGamepadDriver MouseDriver { get; private set; } public IGamepadDriver MouseDriver { get; private set; }
public IGamepadDriver GetDriver(InputBackendType backend)
{
_drivers.TryGetValue(backend, out IGamepadDriver driver);
return driver;
}
public void SetMouseDriver(IGamepadDriver mouseDriver) public void SetMouseDriver(IGamepadDriver mouseDriver)
{ {
MouseDriver?.Dispose(); MouseDriver?.Dispose();
@ -18,7 +36,7 @@ namespace Ryujinx.Input.HLE
public NpadManager CreateNpadManager() public NpadManager CreateNpadManager()
{ {
return new NpadManager(KeyboardDriver, GamepadDriver, MouseDriver); return new NpadManager(this, MouseDriver);
} }
public TouchScreenManager CreateTouchScreenManager() public TouchScreenManager CreateTouchScreenManager()
@ -37,6 +55,7 @@ namespace Ryujinx.Input.HLE
{ {
KeyboardDriver?.Dispose(); KeyboardDriver?.Dispose();
GamepadDriver?.Dispose(); GamepadDriver?.Dispose();
MidiDriver?.Dispose();
MouseDriver?.Dispose(); MouseDriver?.Dispose();
} }
} }

View file

@ -1,7 +1,5 @@
using Ryujinx.Common; using Ryujinx.Common;
using Ryujinx.Common.Configuration.Hid; using Ryujinx.Common.Configuration.Hid;
using Ryujinx.Common.Configuration.Hid.Controller;
using Ryujinx.Common.Configuration.Hid.Keyboard;
using Ryujinx.HLE.HOS.Services.Hid; using Ryujinx.HLE.HOS.Services.Hid;
using System; using System;
using System.Buffers; using System.Buffers;
@ -30,6 +28,7 @@ namespace Ryujinx.Input.HLE
private readonly NpadController[] _controllers; private readonly NpadController[] _controllers;
private readonly InputManager _inputManager;
private readonly IGamepadDriver _keyboardDriver; private readonly IGamepadDriver _keyboardDriver;
private readonly IGamepadDriver _gamepadDriver; private readonly IGamepadDriver _gamepadDriver;
private readonly IGamepadDriver _mouseDriver; private readonly IGamepadDriver _mouseDriver;
@ -43,13 +42,14 @@ namespace Ryujinx.Input.HLE
private readonly List<GamepadInput> _hleInputStates = []; private readonly List<GamepadInput> _hleInputStates = [];
private readonly List<SixAxisInput> _hleMotionStates = new(NpadDevices.MaxControllers); private readonly List<SixAxisInput> _hleMotionStates = new(NpadDevices.MaxControllers);
public NpadManager(IGamepadDriver keyboardDriver, IGamepadDriver gamepadDriver, IGamepadDriver mouseDriver) public NpadManager(InputManager inputManager, IGamepadDriver mouseDriver)
{ {
_controllers = new NpadController[MaxControllers]; _controllers = new NpadController[MaxControllers];
_cemuHookClient = new CemuHookClient(this); _cemuHookClient = new CemuHookClient(this);
_keyboardDriver = keyboardDriver; _inputManager = inputManager;
_gamepadDriver = gamepadDriver; _keyboardDriver = inputManager.KeyboardDriver;
_gamepadDriver = inputManager.GamepadDriver;
_mouseDriver = mouseDriver; _mouseDriver = mouseDriver;
_inputConfig = []; _inputConfig = [];
@ -102,16 +102,7 @@ namespace Ryujinx.Input.HLE
[MethodImpl(MethodImplOptions.AggressiveInlining)] [MethodImpl(MethodImplOptions.AggressiveInlining)]
private bool DriverConfigurationUpdate(ref NpadController controller, InputConfig config) private bool DriverConfigurationUpdate(ref NpadController controller, InputConfig config)
{ {
IGamepadDriver targetDriver = _gamepadDriver; IGamepadDriver targetDriver = _inputManager.GetDriver(config.Backend);
if (config is StandardControllerInputConfig)
{
targetDriver = _gamepadDriver;
}
else if (config is StandardKeyboardInputConfig)
{
targetDriver = _keyboardDriver;
}
Debug.Assert(targetDriver != null, "Unknown input configuration!"); Debug.Assert(targetDriver != null, "Unknown input configuration!");

View file

@ -0,0 +1,112 @@
using NUnit.Framework;
using Ryujinx.Common.Configuration.Hid;
using Ryujinx.Common.Configuration.Hid.Keyboard;
using Ryujinx.Common.Configuration.Hid.Midi;
using Ryujinx.Common.Utilities;
using Ryujinx.Input;
using Ryujinx.Input.HLE;
using System;
using System.Collections.Generic;
namespace Ryujinx.Tests.HLE
{
public class MidiInputConfigTests
{
private static readonly InputConfigJsonSerializerContext SerializerContext = new(JsonHelper.GetDefaultSerializerOptions());
[Test]
public void MidiInputConfigRoundTripsThroughInputConfigConverter()
{
StandardMidiInputConfig config = new()
{
Version = InputConfig.CurrentVersion,
Backend = InputBackendType.Midi,
Id = "0",
Name = "Test MIDI Device",
ControllerType = ControllerType.ProController,
PlayerIndex = PlayerIndex.Player1,
LeftJoycon = new LeftJoyconCommonConfig<MidiBinding>
{
DpadUp = new MidiBinding { Kind = MidiBindingKind.Note, Number = 60, Channel = 1, Threshold = 10 },
DpadDown = new MidiBinding { Kind = MidiBindingKind.ControlChange, Number = 1, Channel = 0, Threshold = 64 },
},
LeftJoyconStick = new JoyconConfigKeyboardStick<MidiBinding>
{
StickUp = new MidiBinding { Kind = MidiBindingKind.Note, Number = 61, Channel = 0, Threshold = 1 },
StickButton = new MidiBinding { Kind = MidiBindingKind.ControlChange, Number = 64, Channel = 2, Threshold = 127 },
},
RightJoycon = new RightJoyconCommonConfig<MidiBinding>
{
ButtonA = new MidiBinding { Kind = MidiBindingKind.Note, Number = 72, Channel = 3, Threshold = 20 },
},
RightJoyconStick = new JoyconConfigKeyboardStick<MidiBinding>
{
StickLeft = new MidiBinding { Kind = MidiBindingKind.ControlChange, Number = 10, Channel = 0, Threshold = 90 },
},
};
string json = JsonHelper.Serialize<InputConfig>(config, SerializerContext.InputConfig);
InputConfig roundTripped = JsonHelper.Deserialize<InputConfig>(json, SerializerContext.InputConfig);
Assert.That(roundTripped, Is.TypeOf<StandardMidiInputConfig>());
StandardMidiInputConfig midiConfig = (StandardMidiInputConfig)roundTripped;
Assert.Multiple(() =>
{
Assert.That(midiConfig.Backend, Is.EqualTo(InputBackendType.Midi));
Assert.That(midiConfig.LeftJoycon.DpadUp, Is.EqualTo(config.LeftJoycon.DpadUp));
Assert.That(midiConfig.LeftJoycon.DpadDown, Is.EqualTo(config.LeftJoycon.DpadDown));
Assert.That(midiConfig.LeftJoyconStick.StickUp, Is.EqualTo(config.LeftJoyconStick.StickUp));
Assert.That(midiConfig.LeftJoyconStick.StickButton, Is.EqualTo(config.LeftJoyconStick.StickButton));
Assert.That(midiConfig.RightJoycon.ButtonA, Is.EqualTo(config.RightJoycon.ButtonA));
Assert.That(midiConfig.RightJoyconStick.StickLeft, Is.EqualTo(config.RightJoyconStick.StickLeft));
});
}
[Test]
public void InputManagerReturnsMidiDriverForMidiBackend()
{
using StubGamepadDriver keyboard = new("Keyboard");
using StubGamepadDriver gamepad = new("Gamepad");
using StubGamepadDriver midi = new("MIDI");
using InputManager inputManager = new(keyboard, gamepad, midi);
Assert.That(inputManager.GetDriver(InputBackendType.WindowKeyboard), Is.SameAs(keyboard));
Assert.That(inputManager.GetDriver(InputBackendType.GamepadSDL3), Is.SameAs(gamepad));
Assert.That(inputManager.GetDriver(InputBackendType.Midi), Is.SameAs(midi));
}
private sealed class StubGamepadDriver(string driverName) : IGamepadDriver
{
public string DriverName { get; } = driverName;
public ReadOnlySpan<string> GamepadsIds => [];
public event Action<string> OnGamepadConnected
{
add { }
remove { }
}
public event Action<string> OnGamepadDisconnected
{
add { }
remove { }
}
public IGamepad GetGamepad(string id)
{
return null;
}
public IEnumerable<IGamepad> GetGamepads()
{
yield break;
}
public void Dispose()
{
}
}
}
}

View file

@ -27,6 +27,7 @@
<ProjectReference Include="..\Ryujinx.Audio\Ryujinx.Audio.csproj" /> <ProjectReference Include="..\Ryujinx.Audio\Ryujinx.Audio.csproj" />
<ProjectReference Include="..\Ryujinx.Cpu\Ryujinx.Cpu.csproj" /> <ProjectReference Include="..\Ryujinx.Cpu\Ryujinx.Cpu.csproj" />
<ProjectReference Include="..\Ryujinx.HLE\Ryujinx.HLE.csproj" /> <ProjectReference Include="..\Ryujinx.HLE\Ryujinx.HLE.csproj" />
<ProjectReference Include="..\Ryujinx.Input\Ryujinx.Input.csproj" />
<ProjectReference Include="..\Ryujinx.Tests.Memory\Ryujinx.Tests.Memory.csproj" /> <ProjectReference Include="..\Ryujinx.Tests.Memory\Ryujinx.Tests.Memory.csproj" />
<ProjectReference Include="..\Ryujinx.Memory\Ryujinx.Memory.csproj" /> <ProjectReference Include="..\Ryujinx.Memory\Ryujinx.Memory.csproj" />
<ProjectReference Include="..\Ryujinx.Tests.Unicorn\Ryujinx.Tests.Unicorn.csproj" /> <ProjectReference Include="..\Ryujinx.Tests.Unicorn\Ryujinx.Tests.Unicorn.csproj" />

View file

@ -9,6 +9,7 @@ using Ryujinx.Common.Configuration.Hid;
using Ryujinx.Common.Configuration.Hid.Controller; using Ryujinx.Common.Configuration.Hid.Controller;
using Ryujinx.Common.Configuration.Hid.Controller.Motion; using Ryujinx.Common.Configuration.Hid.Controller.Motion;
using Ryujinx.Common.Configuration.Hid.Keyboard; using Ryujinx.Common.Configuration.Hid.Keyboard;
using Ryujinx.Common.Configuration.Hid.Midi;
using Ryujinx.Common.Logging; using Ryujinx.Common.Logging;
using Ryujinx.Common.Utilities; using Ryujinx.Common.Utilities;
using Ryujinx.Cpu; using Ryujinx.Cpu;
@ -74,12 +75,17 @@ namespace Ryujinx.Headless
IGamepad gamepad = _inputManager.KeyboardDriver.GetGamepad(inputId); IGamepad gamepad = _inputManager.KeyboardDriver.GetGamepad(inputId);
bool isKeyboard = true; InputBackendType inputBackend = InputBackendType.WindowKeyboard;
if (gamepad == null) if (gamepad == null)
{ {
gamepad = _inputManager.GamepadDriver.GetGamepad(inputId); gamepad = _inputManager.GamepadDriver.GetGamepad(inputId);
isKeyboard = false; inputBackend = InputBackendType.GamepadSDL3;
if (gamepad == null)
{
gamepad = _inputManager.MidiDriver?.GetGamepad(inputId);
inputBackend = InputBackendType.Midi;
if (gamepad == null) if (gamepad == null)
{ {
@ -88,6 +94,7 @@ namespace Ryujinx.Headless
return null; return null;
} }
} }
}
string gamepadName = gamepad.Name; string gamepadName = gamepad.Name;
@ -97,7 +104,7 @@ namespace Ryujinx.Headless
if (inputProfileName == null || inputProfileName.Equals("default")) if (inputProfileName == null || inputProfileName.Equals("default"))
{ {
if (isKeyboard) if (inputBackend == InputBackendType.WindowKeyboard)
{ {
config = new StandardKeyboardInputConfig config = new StandardKeyboardInputConfig
{ {
@ -150,6 +157,58 @@ namespace Ryujinx.Headless
}, },
}; };
} }
else if (inputBackend == InputBackendType.Midi)
{
static MidiBinding UnboundBinding() => new() { Kind = MidiBindingKind.Unbound, Threshold = 1 };
config = new StandardMidiInputConfig
{
Version = InputConfig.CurrentVersion,
Backend = InputBackendType.Midi,
Id = null,
ControllerType = ControllerType.JoyconPair,
LeftJoycon = new LeftJoyconCommonConfig<MidiBinding>
{
DpadUp = UnboundBinding(),
DpadDown = UnboundBinding(),
DpadLeft = UnboundBinding(),
DpadRight = UnboundBinding(),
ButtonMinus = UnboundBinding(),
ButtonL = UnboundBinding(),
ButtonZl = UnboundBinding(),
ButtonSl = UnboundBinding(),
ButtonSr = UnboundBinding(),
},
LeftJoyconStick = new JoyconConfigKeyboardStick<MidiBinding>
{
StickUp = UnboundBinding(),
StickDown = UnboundBinding(),
StickLeft = UnboundBinding(),
StickRight = UnboundBinding(),
StickButton = UnboundBinding(),
},
RightJoycon = new RightJoyconCommonConfig<MidiBinding>
{
ButtonA = UnboundBinding(),
ButtonB = UnboundBinding(),
ButtonX = UnboundBinding(),
ButtonY = UnboundBinding(),
ButtonPlus = UnboundBinding(),
ButtonR = UnboundBinding(),
ButtonZr = UnboundBinding(),
ButtonSl = UnboundBinding(),
ButtonSr = UnboundBinding(),
},
RightJoyconStick = new JoyconConfigKeyboardStick<MidiBinding>
{
StickUp = UnboundBinding(),
StickDown = UnboundBinding(),
StickLeft = UnboundBinding(),
StickRight = UnboundBinding(),
StickButton = UnboundBinding(),
},
};
}
else else
{ {
bool isNintendoStyle = gamepadName.Contains("Nintendo"); bool isNintendoStyle = gamepadName.Contains("Nintendo");
@ -229,10 +288,14 @@ namespace Ryujinx.Headless
{ {
string profileBasePath; string profileBasePath;
if (isKeyboard) if (inputBackend == InputBackendType.WindowKeyboard)
{ {
profileBasePath = Path.Combine(AppDataManager.ProfilesDirPath, "keyboard"); profileBasePath = Path.Combine(AppDataManager.ProfilesDirPath, "keyboard");
} }
else if (inputBackend == InputBackendType.Midi)
{
profileBasePath = Path.Combine(AppDataManager.ProfilesDirPath, "midi");
}
else else
{ {
profileBasePath = Path.Combine(AppDataManager.ProfilesDirPath, "controller"); profileBasePath = Path.Combine(AppDataManager.ProfilesDirPath, "controller");
@ -262,7 +325,12 @@ namespace Ryujinx.Headless
config.Id = inputId; config.Id = inputId;
config.PlayerIndex = index; config.PlayerIndex = index;
string inputTypeName = isKeyboard ? "Keyboard" : "Gamepad"; string inputTypeName = inputBackend switch
{
InputBackendType.WindowKeyboard => "Keyboard",
InputBackendType.Midi => "MIDI",
_ => "Gamepad",
};
Logger.Info?.Print(LogClass.Application, $"{config.PlayerIndex} configured with {inputTypeName} \"{config.Id}\""); Logger.Info?.Print(LogClass.Application, $"{config.PlayerIndex} configured with {inputTypeName} \"{config.Id}\"");

View file

@ -22,6 +22,7 @@ using Ryujinx.HLE.HOS;
using Ryujinx.HLE.HOS.Services.Account.Acc; using Ryujinx.HLE.HOS.Services.Account.Acc;
using Ryujinx.Input; using Ryujinx.Input;
using Ryujinx.Input.HLE; using Ryujinx.Input.HLE;
using Ryujinx.Input.Midi;
using Ryujinx.Input.SDL3; using Ryujinx.Input.SDL3;
using Ryujinx.SDL3.Common; using Ryujinx.SDL3.Common;
using System; using System;
@ -181,7 +182,7 @@ namespace Ryujinx.Headless
_accountManager = new AccountManager(_libHacHorizonManager.RyujinxClient, option.UserProfile); _accountManager = new AccountManager(_libHacHorizonManager.RyujinxClient, option.UserProfile);
_userChannelPersistence = new UserChannelPersistence(); _userChannelPersistence = new UserChannelPersistence();
_inputManager = new InputManager(new SDL3KeyboardDriver(), new SDL3GamepadDriver()); _inputManager = new InputManager(new SDL3KeyboardDriver(), new SDL3GamepadDriver(), new MidiGamepadDriver());
GraphicsConfig.EnableShaderCache = !option.DisableShaderCache; GraphicsConfig.EnableShaderCache = !option.DisableShaderCache;
@ -216,6 +217,18 @@ namespace Ryujinx.Headless
gamepad.Dispose(); gamepad.Dispose();
} }
if (_inputManager.MidiDriver != null)
{
foreach (string id in _inputManager.MidiDriver.GamepadsIds)
{
IGamepad gamepad = _inputManager.MidiDriver.GetGamepad(id);
Logger.Info?.Print(LogClass.Application, $"- {id} (\"{gamepad.Name}\")");
gamepad.Dispose();
}
}
return; return;
} }

View file

@ -42,6 +42,7 @@ namespace Ryujinx.Ava
public static bool PreviewerDetached { get; private set; } public static bool PreviewerDetached { get; private set; }
public static bool UseHardwareAcceleration { get; private set; } public static bool UseHardwareAcceleration { get; private set; }
public static string BackendThreadingArg { get; private set; } public static string BackendThreadingArg { get; private set; }
public static bool CoreDumpArg { get; private set; }
private const uint MbIconwarning = 0x30; private const uint MbIconwarning = 0x30;
@ -81,6 +82,8 @@ namespace Ryujinx.Ava
bool noGuiArg = ConsumeCommandLineArgument(ref args, "--no-gui") || ConsumeCommandLineArgument(ref args, "nogui"); bool noGuiArg = ConsumeCommandLineArgument(ref args, "--no-gui") || ConsumeCommandLineArgument(ref args, "nogui");
bool coreDumpArg = ConsumeCommandLineArgument(ref args, "--core-dumps"); bool coreDumpArg = ConsumeCommandLineArgument(ref args, "--core-dumps");
CoreDumpArg = coreDumpArg;
// TODO: Ryujinx causes core dumps on Linux when it exits "uncleanly", eg. through an unhandled exception. // TODO: Ryujinx causes core dumps on Linux when it exits "uncleanly", eg. through an unhandled exception.
// This is undesirable and causes very odd behavior during development (the process stops responding, // This is undesirable and causes very odd behavior during development (the process stops responding,
// the .NET debugger freezes or suddenly detaches, /tmp/ gets filled etc.), unless explicitly requested by the user. // the .NET debugger freezes or suddenly detaches, /tmp/ gets filled etc.), unless explicitly requested by the user.

View file

@ -49,7 +49,7 @@
<PackageReference Include="Svg.Controls.Avalonia" /> <PackageReference Include="Svg.Controls.Avalonia" />
<PackageReference Include="Svg.Controls.Skia.Avalonia" /> <PackageReference Include="Svg.Controls.Skia.Avalonia" />
<PackageReference Include="DynamicData" /> <PackageReference Include="DynamicData" />
<PackageReference Include="FluentAvaloniaUI.NoAnim" /> <PackageReference Include="FluentAvaloniaUI" />
<PackageReference Include="CommandLineParser" /> <PackageReference Include="CommandLineParser" />
<PackageReference Include="CommunityToolkit.Mvvm" /> <PackageReference Include="CommunityToolkit.Mvvm" />
<PackageReference Include="DiscordRichPresence" /> <PackageReference Include="DiscordRichPresence" />
@ -76,6 +76,7 @@
<ProjectReference Include="..\Ryujinx.Graphics.Vulkan/Ryujinx.Graphics.Vulkan.csproj" /> <ProjectReference Include="..\Ryujinx.Graphics.Vulkan/Ryujinx.Graphics.Vulkan.csproj" />
<ProjectReference Include="..\Ryujinx.Graphics.OpenGL/Ryujinx.Graphics.OpenGL.csproj" /> <ProjectReference Include="..\Ryujinx.Graphics.OpenGL/Ryujinx.Graphics.OpenGL.csproj" />
<ProjectReference Include="..\Ryujinx.Input\Ryujinx.Input.csproj" /> <ProjectReference Include="..\Ryujinx.Input\Ryujinx.Input.csproj" />
<ProjectReference Include="..\Ryujinx.Input.Midi\Ryujinx.Input.Midi.csproj" />
<ProjectReference Include="..\Ryujinx.Input.SDL3\Ryujinx.Input.SDL3.csproj" /> <ProjectReference Include="..\Ryujinx.Input.SDL3\Ryujinx.Input.SDL3.csproj" />
<ProjectReference Include="..\Ryujinx.Audio.Backends.Apple\Ryujinx.Audio.Backends.Apple.csproj" /> <ProjectReference Include="..\Ryujinx.Audio.Backends.Apple\Ryujinx.Audio.Backends.Apple.csproj" />
<ProjectReference Include="..\Ryujinx.Audio.Backends.SDL3\Ryujinx.Audio.Backends.SDL3.csproj" /> <ProjectReference Include="..\Ryujinx.Audio.Backends.SDL3\Ryujinx.Audio.Backends.SDL3.csproj" />

View file

@ -1404,7 +1404,7 @@ namespace Ryujinx.Ava.Systems.AppLibrary
if (string.IsNullOrWhiteSpace(data.Name)) if (string.IsNullOrWhiteSpace(data.Name))
{ {
foreach (ref readonly ApplicationControlProperty.ApplicationTitle controlTitle in controlData.Title) foreach (ApplicationControlProperty.ApplicationTitle controlTitle in controlData.Title)
{ {
if (!controlTitle.NameString.IsEmpty()) if (!controlTitle.NameString.IsEmpty())
{ {
@ -1417,7 +1417,7 @@ namespace Ryujinx.Ava.Systems.AppLibrary
if (string.IsNullOrWhiteSpace(data.Developer)) if (string.IsNullOrWhiteSpace(data.Developer))
{ {
foreach (ref readonly ApplicationControlProperty.ApplicationTitle controlTitle in controlData.Title) foreach (ApplicationControlProperty.ApplicationTitle controlTitle in controlData.Title)
{ {
if (!controlTitle.PublisherString.IsEmpty()) if (!controlTitle.PublisherString.IsEmpty())
{ {

View file

@ -24,6 +24,8 @@ namespace Ryujinx.Ava.Systems.Configuration.System
SimplifiedChinese, SimplifiedChinese,
TraditionalChinese, TraditionalChinese,
BrazilianPortuguese, BrazilianPortuguese,
Polish,
Thai,
} }
public static class LanguageEnumHelper public static class LanguageEnumHelper

View file

@ -2,6 +2,7 @@ using Avalonia.Data.Converters;
using Ryujinx.Ava.Common.Locale; using Ryujinx.Ava.Common.Locale;
using Ryujinx.Common.Configuration.Hid; using Ryujinx.Common.Configuration.Hid;
using Ryujinx.Common.Configuration.Hid.Controller; using Ryujinx.Common.Configuration.Hid.Controller;
using Ryujinx.Common.Configuration.Hid.Midi;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization; using System.Globalization;
@ -173,6 +174,15 @@ namespace Ryujinx.Ava.UI.Helpers
keyString = stickInputId.ToString(); keyString = stickInputId.ToString();
} }
break;
case MidiBinding midiBinding:
keyString = midiBinding.Kind switch
{
MidiBindingKind.Unbound => LocaleManager.Instance[LocaleKeys.KeyUnbound],
MidiBindingKind.Note => $"Note {midiBinding.Number} Ch {(midiBinding.Channel == 0 ? "Any" : midiBinding.Channel)} T{midiBinding.Threshold}",
MidiBindingKind.ControlChange => $"CC {midiBinding.Number} Ch {(midiBinding.Channel == 0 ? "Any" : midiBinding.Channel)} T{midiBinding.Threshold}",
_ => midiBinding.ToString(),
};
break; break;
} }

View file

@ -4,6 +4,7 @@ namespace Ryujinx.Ava.UI.Models
{ {
None, None,
Keyboard, Keyboard,
Midi,
Controller, Controller,
} }
} }

View file

@ -0,0 +1,202 @@
using CommunityToolkit.Mvvm.ComponentModel;
using Ryujinx.Ava.UI.ViewModels;
using Ryujinx.Common.Configuration.Hid;
using Ryujinx.Common.Configuration.Hid.Midi;
namespace Ryujinx.Ava.UI.Models.Input
{
public partial class MidiInputConfig : BaseModel
{
public string Id { get; set; }
public string Name { get; set; }
public ControllerType ControllerType { get; set; }
public PlayerIndex PlayerIndex { get; set; }
[ObservableProperty]
public partial MidiBinding LeftStickUp { get; set; }
[ObservableProperty]
public partial MidiBinding LeftStickDown { get; set; }
[ObservableProperty]
public partial MidiBinding LeftStickLeft { get; set; }
[ObservableProperty]
public partial MidiBinding LeftStickRight { get; set; }
[ObservableProperty]
public partial MidiBinding LeftStickButton { get; set; }
[ObservableProperty]
public partial MidiBinding RightStickUp { get; set; }
[ObservableProperty]
public partial MidiBinding RightStickDown { get; set; }
[ObservableProperty]
public partial MidiBinding RightStickLeft { get; set; }
[ObservableProperty]
public partial MidiBinding RightStickRight { get; set; }
[ObservableProperty]
public partial MidiBinding RightStickButton { get; set; }
[ObservableProperty]
public partial MidiBinding DpadUp { get; set; }
[ObservableProperty]
public partial MidiBinding DpadDown { get; set; }
[ObservableProperty]
public partial MidiBinding DpadLeft { get; set; }
[ObservableProperty]
public partial MidiBinding DpadRight { get; set; }
[ObservableProperty]
public partial MidiBinding ButtonMinus { get; set; }
[ObservableProperty]
public partial MidiBinding ButtonPlus { get; set; }
[ObservableProperty]
public partial MidiBinding ButtonA { get; set; }
[ObservableProperty]
public partial MidiBinding ButtonB { get; set; }
[ObservableProperty]
public partial MidiBinding ButtonX { get; set; }
[ObservableProperty]
public partial MidiBinding ButtonY { get; set; }
[ObservableProperty]
public partial MidiBinding ButtonL { get; set; }
[ObservableProperty]
public partial MidiBinding ButtonR { get; set; }
[ObservableProperty]
public partial MidiBinding ButtonZl { get; set; }
[ObservableProperty]
public partial MidiBinding ButtonZr { get; set; }
[ObservableProperty]
public partial MidiBinding LeftButtonSl { get; set; }
[ObservableProperty]
public partial MidiBinding LeftButtonSr { get; set; }
[ObservableProperty]
public partial MidiBinding RightButtonSl { get; set; }
[ObservableProperty]
public partial MidiBinding RightButtonSr { get; set; }
public MidiInputConfig(InputConfig config)
{
if (config == null)
{
return;
}
Id = config.Id;
Name = config.Name;
ControllerType = config.ControllerType;
PlayerIndex = config.PlayerIndex;
if (config is not StandardMidiInputConfig midiConfig)
{
return;
}
LeftStickUp = midiConfig.LeftJoyconStick.StickUp;
LeftStickDown = midiConfig.LeftJoyconStick.StickDown;
LeftStickLeft = midiConfig.LeftJoyconStick.StickLeft;
LeftStickRight = midiConfig.LeftJoyconStick.StickRight;
LeftStickButton = midiConfig.LeftJoyconStick.StickButton;
RightStickUp = midiConfig.RightJoyconStick.StickUp;
RightStickDown = midiConfig.RightJoyconStick.StickDown;
RightStickLeft = midiConfig.RightJoyconStick.StickLeft;
RightStickRight = midiConfig.RightJoyconStick.StickRight;
RightStickButton = midiConfig.RightJoyconStick.StickButton;
DpadUp = midiConfig.LeftJoycon.DpadUp;
DpadDown = midiConfig.LeftJoycon.DpadDown;
DpadLeft = midiConfig.LeftJoycon.DpadLeft;
DpadRight = midiConfig.LeftJoycon.DpadRight;
ButtonL = midiConfig.LeftJoycon.ButtonL;
ButtonMinus = midiConfig.LeftJoycon.ButtonMinus;
LeftButtonSl = midiConfig.LeftJoycon.ButtonSl;
LeftButtonSr = midiConfig.LeftJoycon.ButtonSr;
ButtonZl = midiConfig.LeftJoycon.ButtonZl;
ButtonA = midiConfig.RightJoycon.ButtonA;
ButtonB = midiConfig.RightJoycon.ButtonB;
ButtonX = midiConfig.RightJoycon.ButtonX;
ButtonY = midiConfig.RightJoycon.ButtonY;
ButtonR = midiConfig.RightJoycon.ButtonR;
ButtonPlus = midiConfig.RightJoycon.ButtonPlus;
RightButtonSl = midiConfig.RightJoycon.ButtonSl;
RightButtonSr = midiConfig.RightJoycon.ButtonSr;
ButtonZr = midiConfig.RightJoycon.ButtonZr;
}
public InputConfig GetConfig()
{
return new StandardMidiInputConfig
{
Id = Id,
Name = Name,
Backend = InputBackendType.Midi,
PlayerIndex = PlayerIndex,
ControllerType = ControllerType,
LeftJoycon = new LeftJoyconCommonConfig<MidiBinding>
{
DpadUp = DpadUp,
DpadDown = DpadDown,
DpadLeft = DpadLeft,
DpadRight = DpadRight,
ButtonL = ButtonL,
ButtonMinus = ButtonMinus,
ButtonZl = ButtonZl,
ButtonSl = LeftButtonSl,
ButtonSr = LeftButtonSr,
},
RightJoycon = new RightJoyconCommonConfig<MidiBinding>
{
ButtonA = ButtonA,
ButtonB = ButtonB,
ButtonX = ButtonX,
ButtonY = ButtonY,
ButtonPlus = ButtonPlus,
ButtonSl = RightButtonSl,
ButtonSr = RightButtonSr,
ButtonR = ButtonR,
ButtonZr = ButtonZr,
},
LeftJoyconStick = new Ryujinx.Common.Configuration.Hid.Keyboard.JoyconConfigKeyboardStick<MidiBinding>
{
StickUp = LeftStickUp,
StickDown = LeftStickDown,
StickRight = LeftStickRight,
StickLeft = LeftStickLeft,
StickButton = LeftStickButton,
},
RightJoyconStick = new Ryujinx.Common.Configuration.Hid.Keyboard.JoyconConfigKeyboardStick<MidiBinding>
{
StickUp = RightStickUp,
StickDown = RightStickDown,
StickLeft = RightStickLeft,
StickRight = RightStickRight,
StickButton = RightStickButton,
},
Version = InputConfig.CurrentVersion,
};
}
}
}

View file

@ -61,6 +61,18 @@ namespace Ryujinx.Ava.UI.Models.Input
} }
} }
private MidiInputConfig _midiConfig;
public MidiInputConfig MidiConfig
{
get => _midiConfig;
set
{
_midiConfig = value;
OnPropertyChanged();
}
}
private (float, float) _uiStickLeft; private (float, float) _uiStickLeft;
public (float, float) UiStickLeft public (float, float) UiStickLeft
{ {
@ -131,6 +143,13 @@ namespace Ryujinx.Ava.UI.Models.Input
return; return;
} }
else if (config is MidiInputViewModel midiConfig)
{
MidiConfig = midiConfig.Config;
Type = DeviceType.Midi;
return;
}
Type = DeviceType.None; Type = DeviceType.None;
} }
@ -209,6 +228,17 @@ namespace Ryujinx.Ava.UI.Models.Input
rightBuffer = controller.GetStick((StickInputId)GamepadConfig.RightJoystick); rightBuffer = controller.GetStick((StickInputId)GamepadConfig.RightJoystick);
} }
break;
case DeviceType.Midi:
IGamepad midi = Parent.SelectedGamepad;
if (midi != null)
{
GamepadStateSnapshot snapshot = midi.GetMappedStateSnapshot();
leftBuffer = snapshot.GetStick(StickInputId.Left);
rightBuffer = snapshot.GetStick(StickInputId.Right);
}
break; break;
case DeviceType.None: case DeviceType.None:
@ -252,6 +282,7 @@ namespace Ryujinx.Ava.UI.Models.Input
} }
KeyboardConfig = null; KeyboardConfig = null;
MidiConfig = null;
GamepadConfig = null; GamepadConfig = null;
Parent = null; Parent = null;

View file

@ -5,6 +5,7 @@ using Avalonia.Markup.Xaml;
using Avalonia.Platform; using Avalonia.Platform;
using Avalonia.Styling; using Avalonia.Styling;
using Avalonia.Threading; using Avalonia.Threading;
using FluentAvalonia.Core;
using FluentAvalonia.UI.Windowing; using FluentAvalonia.UI.Windowing;
using Gommon; using Gommon;
using Ryujinx.Ava.Common.Locale; using Ryujinx.Ava.Common.Locale;
@ -53,6 +54,9 @@ namespace Ryujinx.Ava
{ {
Name = FormatTitle(); Name = FormatTitle();
// Disable menu animations
FAUISettings.SetAnimationsEnabledAtAppLevel(false);
AvaloniaXamlLoader.Load(this); AvaloniaXamlLoader.Load(this);
if (OperatingSystem.IsMacOS()) if (OperatingSystem.IsMacOS())

View file

@ -16,9 +16,11 @@ using Ryujinx.Common.Configuration.Hid;
using Ryujinx.Common.Configuration.Hid.Controller; using Ryujinx.Common.Configuration.Hid.Controller;
using Ryujinx.Common.Configuration.Hid.Controller.Motion; using Ryujinx.Common.Configuration.Hid.Controller.Motion;
using Ryujinx.Common.Configuration.Hid.Keyboard; using Ryujinx.Common.Configuration.Hid.Keyboard;
using Ryujinx.Common.Configuration.Hid.Midi;
using Ryujinx.Common.Logging; using Ryujinx.Common.Logging;
using Ryujinx.Common.Utilities; using Ryujinx.Common.Utilities;
using Ryujinx.Input; using Ryujinx.Input;
using Ryujinx.Input.Midi;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
@ -40,6 +42,7 @@ namespace Ryujinx.Ava.UI.ViewModels.Input
private const string JoyConLeftResource = "Ryujinx/Assets/Icons/Controller_JoyConLeft.svg"; private const string JoyConLeftResource = "Ryujinx/Assets/Icons/Controller_JoyConLeft.svg";
private const string JoyConRightResource = "Ryujinx/Assets/Icons/Controller_JoyConRight.svg"; private const string JoyConRightResource = "Ryujinx/Assets/Icons/Controller_JoyConRight.svg";
private const string KeyboardString = "keyboard"; private const string KeyboardString = "keyboard";
private const string MidiString = "midi";
private const string ControllerString = "controller"; private const string ControllerString = "controller";
private readonly MainWindow _mainWindow; private readonly MainWindow _mainWindow;
@ -95,13 +98,15 @@ namespace Ryujinx.Ava.UI.ViewModels.Input
// XAML Flags // XAML Flags
public bool ShowSettings => _device > 0; public bool ShowSettings => _device > 0;
public bool IsController => _device > 1; public DeviceType CurrentDeviceType => _device >= 0 && _device < Devices.Count ? Devices[_device].Type : DeviceType.None;
public bool IsKeyboard => !IsController; public bool IsController => CurrentDeviceType == DeviceType.Controller;
public bool IsKeyboard => CurrentDeviceType == DeviceType.Keyboard;
public bool IsMidi => CurrentDeviceType == DeviceType.Midi;
public bool IsRight { get; set; } public bool IsRight { get; set; }
public bool IsLeft { get; set; } public bool IsLeft { get; set; }
public string RevertDeviceId { get; set; } public string RevertDeviceId { get; set; }
public bool HasLed => (SelectedGamepad.Features & GamepadFeaturesFlag.Led) != 0; public bool HasLed => SelectedGamepad != null && (SelectedGamepad.Features & GamepadFeaturesFlag.Led) != 0;
public bool CanClearLed => SelectedGamepad.Name.ContainsIgnoreCase("DualSense"); public bool CanClearLed => SelectedGamepad?.Name.ContainsIgnoreCase("DualSense") == true;
public event Action NotifyChangesEvent; public event Action NotifyChangesEvent;
@ -349,6 +354,11 @@ namespace Ryujinx.Ava.UI.ViewModels.Input
ConfigViewModel = new KeyboardInputViewModel(this, new KeyboardInputConfig(keyboardInputConfig), VisualStick); ConfigViewModel = new KeyboardInputViewModel(this, new KeyboardInputConfig(keyboardInputConfig), VisualStick);
} }
if (Config is StandardMidiInputConfig midiInputConfig)
{
ConfigViewModel = new MidiInputViewModel(this, new MidiInputConfig(midiInputConfig), VisualStick);
}
if (Config is StandardControllerInputConfig controllerInputConfig) if (Config is StandardControllerInputConfig controllerInputConfig)
{ {
ConfigViewModel = new ControllerInputViewModel(this, new GamepadInputConfig(controllerInputConfig), VisualStick); ConfigViewModel = new ControllerInputViewModel(this, new GamepadInputConfig(controllerInputConfig), VisualStick);
@ -407,6 +417,10 @@ namespace Ryujinx.Ava.UI.ViewModels.Input
{ {
type = DeviceType.Keyboard; type = DeviceType.Keyboard;
} }
else if (Config is StandardMidiInputConfig)
{
type = DeviceType.Midi;
}
if (Config is StandardControllerInputConfig) if (Config is StandardControllerInputConfig)
{ {
@ -452,6 +466,10 @@ namespace Ryujinx.Ava.UI.ViewModels.Input
SelectedGamepad = _mainWindow.InputManager.KeyboardDriver.GetGamepad(id); SelectedGamepad = _mainWindow.InputManager.KeyboardDriver.GetGamepad(id);
} }
} }
else if (type == DeviceType.Midi)
{
SelectedGamepad = _mainWindow.InputManager.MidiDriver?.GetGamepad(id);
}
else else
{ {
SelectedGamepad = _mainWindow.InputManager.GamepadDriver.GetGamepad(id); SelectedGamepad = _mainWindow.InputManager.GamepadDriver.GetGamepad(id);
@ -609,6 +627,19 @@ namespace Ryujinx.Ava.UI.ViewModels.Input
} }
} }
if (_mainWindow.InputManager.MidiDriver is MidiGamepadDriver midiDriver)
{
foreach (string id in midiDriver.GamepadsIds)
{
string name = midiDriver.GetDeviceName(id);
if (!string.IsNullOrWhiteSpace(name))
{
Devices.Add((DeviceType.Midi, id, GetShortGamepadName(name)));
}
}
}
DeviceList.AddRange(Devices.Select(x => x.Name)); DeviceList.AddRange(Devices.Select(x => x.Name));
Device = Math.Min(Device, DeviceList.Count); Device = Math.Min(Device, DeviceList.Count);
} }
@ -623,6 +654,10 @@ namespace Ryujinx.Ava.UI.ViewModels.Input
{ {
path = Path.Combine(path, KeyboardString); path = Path.Combine(path, KeyboardString);
} }
else if (type == DeviceType.Midi)
{
path = Path.Combine(path, MidiString);
}
else if (type == DeviceType.Controller) else if (type == DeviceType.Controller)
{ {
path = Path.Combine(path, ControllerString); path = Path.Combine(path, ControllerString);
@ -720,6 +755,62 @@ namespace Ryujinx.Ava.UI.ViewModels.Input
}, },
}; };
} }
else if (activeDevice.Type == DeviceType.Midi)
{
string id = activeDevice.Id;
string name = activeDevice.Name;
static MidiBinding UnboundBinding() => new() { Kind = MidiBindingKind.Unbound, Threshold = 1 };
config = new StandardMidiInputConfig
{
Version = InputConfig.CurrentVersion,
Backend = InputBackendType.Midi,
Id = id,
Name = name,
ControllerType = ControllerType.ProController,
LeftJoycon = new LeftJoyconCommonConfig<MidiBinding>
{
DpadUp = UnboundBinding(),
DpadDown = UnboundBinding(),
DpadLeft = UnboundBinding(),
DpadRight = UnboundBinding(),
ButtonMinus = UnboundBinding(),
ButtonL = UnboundBinding(),
ButtonZl = UnboundBinding(),
ButtonSl = UnboundBinding(),
ButtonSr = UnboundBinding(),
},
LeftJoyconStick = new JoyconConfigKeyboardStick<MidiBinding>
{
StickUp = UnboundBinding(),
StickDown = UnboundBinding(),
StickLeft = UnboundBinding(),
StickRight = UnboundBinding(),
StickButton = UnboundBinding(),
},
RightJoycon = new RightJoyconCommonConfig<MidiBinding>
{
ButtonA = UnboundBinding(),
ButtonB = UnboundBinding(),
ButtonX = UnboundBinding(),
ButtonY = UnboundBinding(),
ButtonPlus = UnboundBinding(),
ButtonR = UnboundBinding(),
ButtonZr = UnboundBinding(),
ButtonSl = UnboundBinding(),
ButtonSr = UnboundBinding(),
},
RightJoyconStick = new JoyconConfigKeyboardStick<MidiBinding>
{
StickUp = UnboundBinding(),
StickDown = UnboundBinding(),
StickLeft = UnboundBinding(),
StickRight = UnboundBinding(),
StickButton = UnboundBinding(),
},
};
}
else if (activeDevice.Type == DeviceType.Controller) else if (activeDevice.Type == DeviceType.Controller)
{ {
bool isNintendoStyle = Devices.ToList().FirstOrDefault(x => x.Id == activeDevice.Id).Name.Contains("Nintendo"); bool isNintendoStyle = Devices.ToList().FirstOrDefault(x => x.Id == activeDevice.Id).Name.Contains("Nintendo");
@ -905,6 +996,10 @@ namespace Ryujinx.Ava.UI.ViewModels.Input
{ {
config = (ConfigViewModel as KeyboardInputViewModel).Config.GetConfig(); config = (ConfigViewModel as KeyboardInputViewModel).Config.GetConfig();
} }
else if (IsMidi)
{
config = (ConfigViewModel as MidiInputViewModel).Config.GetConfig();
}
else if (IsController) else if (IsController)
{ {
config = (ConfigViewModel as ControllerInputViewModel).Config.GetConfig(); config = (ConfigViewModel as ControllerInputViewModel).Config.GetConfig();
@ -1008,15 +1103,18 @@ namespace Ryujinx.Ava.UI.ViewModels.Input
KeyboardInputConfig inputConfig = (ConfigViewModel as KeyboardInputViewModel).Config; KeyboardInputConfig inputConfig = (ConfigViewModel as KeyboardInputViewModel).Config;
inputConfig.Id = device.Id; inputConfig.Id = device.Id;
} }
else if (device.Type == DeviceType.Midi)
{
MidiInputConfig inputConfig = (ConfigViewModel as MidiInputViewModel).Config;
inputConfig.Id = device.Id;
}
else else
{ {
GamepadInputConfig inputConfig = (ConfigViewModel as ControllerInputViewModel).Config; GamepadInputConfig inputConfig = (ConfigViewModel as ControllerInputViewModel).Config;
inputConfig.Id = device.Id.Split(" ")[0]; inputConfig.Id = device.Id.Split(" ")[0];
} }
InputConfig config = !IsController InputConfig config = GetCurrentConfigFromViewModel();
? (ConfigViewModel as KeyboardInputViewModel).Config.GetConfig()
: (ConfigViewModel as ControllerInputViewModel).Config.GetConfig();
config.ControllerType = Controllers[_controller].Type; config.ControllerType = Controllers[_controller].Type;
config.PlayerIndex = _playerId; config.PlayerIndex = _playerId;
config.Name = device.Name; config.Name = device.Name;
@ -1055,6 +1153,7 @@ namespace Ryujinx.Ava.UI.ViewModels.Input
OnPropertyChanged(nameof(IsController)); OnPropertyChanged(nameof(IsController));
OnPropertyChanged(nameof(ShowSettings)); OnPropertyChanged(nameof(ShowSettings));
OnPropertyChanged(nameof(IsKeyboard)); OnPropertyChanged(nameof(IsKeyboard));
OnPropertyChanged(nameof(IsMidi));
OnPropertyChanged(nameof(IsRight)); OnPropertyChanged(nameof(IsRight));
OnPropertyChanged(nameof(IsLeft)); OnPropertyChanged(nameof(IsLeft));
NotifyChangesEvent?.Invoke(); NotifyChangesEvent?.Invoke();
@ -1075,5 +1174,16 @@ namespace Ryujinx.Ava.UI.ViewModels.Input
AvaloniaKeyboardDriver.Dispose(); AvaloniaKeyboardDriver.Dispose();
} }
private InputConfig GetCurrentConfigFromViewModel()
{
return ConfigViewModel switch
{
KeyboardInputViewModel keyboardViewModel => keyboardViewModel.Config.GetConfig(),
MidiInputViewModel midiViewModel => midiViewModel.Config.GetConfig(),
ControllerInputViewModel controllerViewModel => controllerViewModel.Config.GetConfig(),
_ => null,
};
}
} }
} }

View file

@ -6,6 +6,20 @@ namespace Ryujinx.Ava.UI.ViewModels.Input
{ {
public partial class KeyboardInputViewModel : BaseModel public partial class KeyboardInputViewModel : BaseModel
{ {
public bool ShowMidiCaptureOptions => false;
public bool CaptureAnyChannel
{
get;
set;
} = true;
public int CaptureThreshold
{
get;
set;
} = 1;
public KeyboardInputConfig Config public KeyboardInputConfig Config
{ {
get; get;

View file

@ -0,0 +1,77 @@
using CommunityToolkit.Mvvm.ComponentModel;
using Ryujinx.Ava.UI.Models.Input;
namespace Ryujinx.Ava.UI.ViewModels.Input
{
public partial class MidiInputViewModel : BaseModel
{
public bool ShowMidiCaptureOptions => true;
public MidiInputConfig Config
{
get;
set
{
field = value;
OnPropertyChanged();
}
}
public StickVisualizer Visualizer
{
get;
set
{
field = value;
OnPropertyChanged();
}
}
public bool IsLeft
{
get;
set
{
field = value;
OnPropertyChanged();
OnPropertyChanged(nameof(HasSides));
}
}
public bool IsRight
{
get;
set
{
field = value;
OnPropertyChanged();
OnPropertyChanged(nameof(HasSides));
}
}
public bool HasSides => IsLeft ^ IsRight;
[ObservableProperty]
public partial int CaptureThreshold { get; set; } = 1;
[ObservableProperty]
public partial bool CaptureAnyChannel { get; set; } = true;
public readonly InputViewModel ParentModel;
public MidiInputViewModel(InputViewModel model, MidiInputConfig config, StickVisualizer visualizer)
{
ParentModel = model;
Visualizer = visualizer;
model.NotifyChangesEvent += OnParentModelChanged;
OnParentModelChanged();
Config = config;
}
public void OnParentModelChanged()
{
IsLeft = ParentModel.IsLeft;
IsRight = ParentModel.IsRight;
}
}
}

View file

@ -174,6 +174,7 @@ namespace Ryujinx.Ava.UI.ViewModels
private string _screenshotKey = "F8"; private string _screenshotKey = "F8";
private float _volume; private float _volume;
private ApplicationData _currentApplicationData; private ApplicationData _currentApplicationData;
private bool _pendingRestart;
private readonly AutoResetEvent _rendererWaitEvent; private readonly AutoResetEvent _rendererWaitEvent;
private int _customVSyncInterval; private int _customVSyncInterval;
private int _customVSyncIntervalPercentageProxy; private int _customVSyncIntervalPercentageProxy;
@ -1062,7 +1063,7 @@ namespace Ryujinx.Ava.UI.ViewModels
string dialogMessage = string dialogMessage =
LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.DialogKeysInstallerKeysInstallMessage); LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.DialogKeysInstallerKeysInstallMessage);
if (ContentManager.AreKeysAlredyPresent(systemDirectory)) if (ContentManager.AreKeysAlreadyPresent(systemDirectory))
{ {
dialogMessage += dialogMessage +=
LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys
@ -1250,6 +1251,14 @@ namespace Ryujinx.Ava.UI.ViewModels
await LoadApplication(_currentApplicationData); await LoadApplication(_currentApplicationData);
} }
else if (_pendingRestart)
{
_pendingRestart = false;
Logger.Info?.Print(LogClass.Application, $"Restarting emulation for '{_currentApplicationData.Name}'");
await LoadApplication(_currentApplicationData);
}
else else
{ {
// Otherwise, clear state. // Otherwise, clear state.
@ -1258,6 +1267,21 @@ namespace Ryujinx.Ava.UI.ViewModels
} }
} }
public void RestartEmulation()
{
if (AppHost is null || _currentApplicationData is null)
{
Logger.Warning?.Print(LogClass.Application, "RestartEmulation called but no application is running.");
return;
}
Logger.Info?.Print(LogClass.Application, $"Restart requested for '{_currentApplicationData.Name}'");
_pendingRestart = true;
AppHost.Stop();
}
private void Update_StatusBar(object sender, StatusUpdatedEventArgs args) private void Update_StatusBar(object sender, StatusUpdatedEventArgs args)
{ {
if (ShowMenuAndStatusBar && !ShowLoadProgress) if (ShowMenuAndStatusBar && !ShowLoadProgress)

View file

@ -230,6 +230,9 @@
<DataTemplate DataType="viewModels:KeyboardInputViewModel"> <DataTemplate DataType="viewModels:KeyboardInputViewModel">
<views:KeyboardInputView /> <views:KeyboardInputView />
</DataTemplate> </DataTemplate>
<DataTemplate DataType="viewModels:MidiInputViewModel">
<views:KeyboardInputView />
</DataTemplate>
</ContentControl.DataTemplates> </ContentControl.DataTemplates>
</ContentControl> </ContentControl>
</StackPanel> </StackPanel>

View file

@ -2,6 +2,7 @@
xmlns="https://github.com/avaloniaui" xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ext="clr-namespace:Ryujinx.Ava.Common.Markup" xmlns:ext="clr-namespace:Ryujinx.Ava.Common.Markup"
xmlns:controls="clr-namespace:Ryujinx.Ava.UI.Controls"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:viewModels="clr-namespace:Ryujinx.Ava.UI.ViewModels.Input" xmlns:viewModels="clr-namespace:Ryujinx.Ava.UI.ViewModels.Input"
@ -11,8 +12,7 @@
d:DesignHeight="800" d:DesignHeight="800"
d:DesignWidth="800" d:DesignWidth="800"
x:Class="Ryujinx.Ava.UI.Views.Input.KeyboardInputView" x:Class="Ryujinx.Ava.UI.Views.Input.KeyboardInputView"
x:DataType="viewModels:KeyboardInputViewModel" x:CompileBindings="False"
x:CompileBindings="True"
mc:Ignorable="d" mc:Ignorable="d"
Focusable="True"> Focusable="True">
<Design.DataContext> <Design.DataContext>
@ -29,6 +29,37 @@
HorizontalAlignment="Stretch" HorizontalAlignment="Stretch"
VerticalAlignment="Stretch" VerticalAlignment="Stretch"
Orientation="Vertical"> Orientation="Vertical">
<Border
BorderBrush="{DynamicResource ThemeControlBorderColor}"
BorderThickness="1"
Margin="0,0,0,8"
Padding="10"
CornerRadius="5"
IsVisible="{Binding ShowMidiCaptureOptions}">
<StackPanel
Orientation="Horizontal"
Spacing="10"
VerticalAlignment="Center">
<CheckBox IsChecked="{Binding CaptureAnyChannel}">
<TextBlock Text="Any Channel" />
</CheckBox>
<TextBlock
VerticalAlignment="Center"
Text="Threshold" />
<controls:SliderScroll
Width="130"
Maximum="127"
TickFrequency="1"
IsSnapToTickEnabled="True"
SmallChange="1"
Minimum="1"
Value="{Binding CaptureThreshold, Mode=TwoWay}" />
<TextBlock
Width="30"
VerticalAlignment="Center"
Text="{Binding CaptureThreshold}" />
</StackPanel>
</Border>
<!-- Button / JoyStick Settings --> <!-- Button / JoyStick Settings -->
<Grid <Grid
Name="SettingButtons" Name="SettingButtons"

View file

@ -4,21 +4,32 @@ using Avalonia.Controls.Primitives;
using Avalonia.Input; using Avalonia.Input;
using Avalonia.Interactivity; using Avalonia.Interactivity;
using Avalonia.LogicalTree; using Avalonia.LogicalTree;
using Avalonia.Threading;
using Ryujinx.Ava.UI.Controls; using Ryujinx.Ava.UI.Controls;
using Ryujinx.Ava.UI.Helpers; using Ryujinx.Ava.UI.Helpers;
using Ryujinx.Ava.UI.ViewModels;
using Ryujinx.Ava.UI.ViewModels.Input; using Ryujinx.Ava.UI.ViewModels.Input;
using Ryujinx.Common.Configuration.Hid;
using Ryujinx.Common.Configuration.Hid.Midi;
using Ryujinx.Input; using Ryujinx.Input;
using Ryujinx.Input.Assigner; using Ryujinx.Input.Assigner;
using Ryujinx.Input.Midi;
using Ryujinx.Input.Midi.Assigner;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Button = Ryujinx.Input.Button; using Button = Ryujinx.Input.Button;
using Key = Ryujinx.Common.Configuration.Hid.Key; using Key = Ryujinx.Common.Configuration.Hid.Key;
namespace Ryujinx.Ava.UI.Views.Input namespace Ryujinx.Ava.UI.Views.Input
{ {
public partial class KeyboardInputView : RyujinxControl<KeyboardInputViewModel> public partial class KeyboardInputView : RyujinxControl<BaseModel>
{ {
private ButtonKeyAssigner _currentAssigner; private ButtonKeyAssigner _keyboardAssigner;
private ToggleButton _midiToggleButton;
private CancellationTokenSource _midiCancellationTokenSource;
private bool _shouldUnbindMidi;
public KeyboardInputView() public KeyboardInputView()
{ {
@ -37,157 +48,148 @@ namespace Ryujinx.Ava.UI.Views.Input
{ {
base.OnPointerReleased(e); base.OnPointerReleased(e);
if (_currentAssigner is { ToggledButton.IsPointerOver: false }) ToggleButton currentButton = GetCurrentToggleButton();
if (currentButton is { IsPointerOver: false })
{ {
_currentAssigner.Cancel(); CancelCurrentAssignment();
} }
} }
private void Button_IsCheckedChanged(object sender, RoutedEventArgs e) private async void Button_IsCheckedChanged(object sender, RoutedEventArgs e)
{ {
if (sender is not ToggleButton button) if (sender is not ToggleButton button)
return;
if (button.IsChecked is true)
{
if (_currentAssigner != null && button == _currentAssigner.ToggledButton)
{ {
return; return;
} }
if (_currentAssigner == null) if (button.IsChecked is not true)
{ {
_currentAssigner = new ButtonKeyAssigner(button); if (GetCurrentToggleButton() == button)
{
CancelCurrentAssignment();
}
return;
}
if (GetCurrentToggleButton() == button)
{
return;
}
if (GetCurrentToggleButton() != null)
{
CancelCurrentAssignment();
button.IsChecked = false;
return;
}
Focus(NavigationMethod.Pointer); Focus(NavigationMethod.Pointer);
PointerPressed += MouseClick; PointerPressed += MouseClick;
IKeyboard keyboard = if (DataContext is MidiInputViewModel midiViewModel)
(IKeyboard)ViewModel.ParentModel.AvaloniaKeyboardDriver.GetGamepad("0"); // Open Avalonia keyboard for cancel operations.
IButtonAssigner assigner =
new KeyboardKeyAssigner((IKeyboard)ViewModel.ParentModel.SelectedGamepad);
_currentAssigner.ButtonAssigned += (_, be) =>
{ {
await StartMidiAssignmentAsync(button, midiViewModel);
}
else
{
StartKeyboardAssignment(button);
}
}
private void StartKeyboardAssignment(ToggleButton button)
{
_keyboardAssigner = new ButtonKeyAssigner(button);
IKeyboard keyboard = (IKeyboard)GetCancelKeyboard();
IButtonAssigner assigner = new KeyboardKeyAssigner((IKeyboard)GetSelectedGamepad());
_keyboardAssigner.ButtonAssigned += (_, be) =>
{
_keyboardAssigner = null;
if (be.ButtonValue.HasValue) if (be.ButtonValue.HasValue)
{ {
Button buttonValue = be.ButtonValue.Value; Button buttonValue = be.ButtonValue.Value;
ViewModel.ParentModel.IsModified = true; AssignKeyboardBinding(button.Name, buttonValue.AsHidType<Key>());
switch (button.Name)
{
case "ButtonZl":
ViewModel.Config.ButtonZl = buttonValue.AsHidType<Key>();
break;
case "ButtonL":
ViewModel.Config.ButtonL = buttonValue.AsHidType<Key>();
break;
case "ButtonMinus":
ViewModel.Config.ButtonMinus = buttonValue.AsHidType<Key>();
break;
case "LeftStickButton":
ViewModel.Config.LeftStickButton = buttonValue.AsHidType<Key>();
break;
case "LeftStickUp":
ViewModel.Config.LeftStickUp = buttonValue.AsHidType<Key>();
break;
case "LeftStickDown":
ViewModel.Config.LeftStickDown = buttonValue.AsHidType<Key>();
break;
case "LeftStickRight":
ViewModel.Config.LeftStickRight = buttonValue.AsHidType<Key>();
break;
case "LeftStickLeft":
ViewModel.Config.LeftStickLeft = buttonValue.AsHidType<Key>();
break;
case "DpadUp":
ViewModel.Config.DpadUp = buttonValue.AsHidType<Key>();
break;
case "DpadDown":
ViewModel.Config.DpadDown = buttonValue.AsHidType<Key>();
break;
case "DpadLeft":
ViewModel.Config.DpadLeft = buttonValue.AsHidType<Key>();
break;
case "DpadRight":
ViewModel.Config.DpadRight = buttonValue.AsHidType<Key>();
break;
case "LeftButtonSr":
ViewModel.Config.LeftButtonSr = buttonValue.AsHidType<Key>();
break;
case "LeftButtonSl":
ViewModel.Config.LeftButtonSl = buttonValue.AsHidType<Key>();
break;
case "RightButtonSr":
ViewModel.Config.RightButtonSr = buttonValue.AsHidType<Key>();
break;
case "RightButtonSl":
ViewModel.Config.RightButtonSl = buttonValue.AsHidType<Key>();
break;
case "ButtonZr":
ViewModel.Config.ButtonZr = buttonValue.AsHidType<Key>();
break;
case "ButtonR":
ViewModel.Config.ButtonR = buttonValue.AsHidType<Key>();
break;
case "ButtonPlus":
ViewModel.Config.ButtonPlus = buttonValue.AsHidType<Key>();
break;
case "ButtonA":
ViewModel.Config.ButtonA = buttonValue.AsHidType<Key>();
break;
case "ButtonB":
ViewModel.Config.ButtonB = buttonValue.AsHidType<Key>();
break;
case "ButtonX":
ViewModel.Config.ButtonX = buttonValue.AsHidType<Key>();
break;
case "ButtonY":
ViewModel.Config.ButtonY = buttonValue.AsHidType<Key>();
break;
case "RightStickButton":
ViewModel.Config.RightStickButton = buttonValue.AsHidType<Key>();
break;
case "RightStickUp":
ViewModel.Config.RightStickUp = buttonValue.AsHidType<Key>();
break;
case "RightStickDown":
ViewModel.Config.RightStickDown = buttonValue.AsHidType<Key>();
break;
case "RightStickRight":
ViewModel.Config.RightStickRight = buttonValue.AsHidType<Key>();
break;
case "RightStickLeft":
ViewModel.Config.RightStickLeft = buttonValue.AsHidType<Key>();
break;
}
} }
}; };
_currentAssigner.GetInputAndAssign(assigner, keyboard); _keyboardAssigner.GetInputAndAssign(assigner, keyboard);
} }
else
private async Task StartMidiAssignmentAsync(ToggleButton button, MidiInputViewModel viewModel)
{ {
if (_currentAssigner != null) if (GetSelectedGamepad() is not IMidiGamepad midiGamepad)
{ {
_currentAssigner.Cancel();
_currentAssigner = null;
button.IsChecked = false; button.IsChecked = false;
PointerPressed -= MouseClick;
return;
} }
}
} _midiToggleButton = button;
else _shouldUnbindMidi = false;
_midiCancellationTokenSource = new CancellationTokenSource();
MidiBindingAssigner assigner = new(midiGamepad);
IKeyboard keyboard = (IKeyboard)GetCancelKeyboard();
assigner.Initialize();
MidiBinding? binding = null;
try
{ {
_currentAssigner?.Cancel(); binding = await Task.Run(async () =>
_currentAssigner = null; {
while (!_midiCancellationTokenSource.IsCancellationRequested)
{
await Task.Delay(10, _midiCancellationTokenSource.Token);
assigner.ReadInput();
if (assigner.IsAnyButtonPressed() || assigner.ShouldCancel() || (keyboard != null && keyboard.IsPressed(Ryujinx.Input.Key.Escape)))
{
break;
} }
} }
if (_shouldUnbindMidi)
{
return CreateUnboundMidiBinding();
}
return assigner.GetPressedBinding(viewModel.CaptureAnyChannel, (byte)viewModel.CaptureThreshold);
}, _midiCancellationTokenSource.Token);
}
catch (OperationCanceledException)
{
}
await Dispatcher.UIThread.InvokeAsync(() =>
{
PointerPressed -= MouseClick;
if (_midiToggleButton != null)
{
_midiToggleButton.IsChecked = false;
}
_midiToggleButton = null;
_midiCancellationTokenSource?.Dispose();
_midiCancellationTokenSource = null;
if (binding.HasValue)
{
AssignMidiBinding(button.Name, binding.Value);
}
});
}
private void MouseClick(object sender, PointerPressedEventArgs e) private void MouseClick(object sender, PointerPressedEventArgs e)
{ {
bool shouldUnbind = e.GetCurrentPoint(this).Properties.IsMiddleButtonPressed; bool shouldUnbind = e.GetCurrentPoint(this).Properties.IsMiddleButtonPressed;
bool shouldRemoveBinding = e.GetCurrentPoint(this).Properties.IsRightButtonPressed; bool shouldRemoveBinding = e.GetCurrentPoint(this).Properties.IsRightButtonPressed;
if (shouldRemoveBinding) if (shouldRemoveBinding)
@ -195,61 +197,177 @@ namespace Ryujinx.Ava.UI.Views.Input
DeleteBind(); DeleteBind();
} }
_currentAssigner?.Cancel(shouldUnbind); CancelCurrentAssignment(shouldUnbind);
PointerPressed -= MouseClick; PointerPressed -= MouseClick;
} }
private void DeleteBind() private void DeleteBind()
{ {
ToggleButton button = GetCurrentToggleButton();
if (_currentAssigner != null) if (button == null)
{ {
return;
}
if (DataContext is MidiInputViewModel)
{
AssignMidiBinding(button.Name, CreateUnboundMidiBinding());
}
else
{
AssignKeyboardBinding(button.Name, Key.Unbound);
}
}
private void CancelCurrentAssignment(bool shouldUnbind = false)
{
if (_keyboardAssigner != null)
{
_keyboardAssigner.Cancel(shouldUnbind);
_keyboardAssigner = null;
}
if (_midiToggleButton != null)
{
_shouldUnbindMidi = shouldUnbind;
_midiCancellationTokenSource?.Cancel();
}
}
private ToggleButton GetCurrentToggleButton()
{
return _keyboardAssigner?.ToggledButton ?? _midiToggleButton;
}
private IGamepad GetSelectedGamepad()
{
return DataContext switch
{
KeyboardInputViewModel keyboardViewModel => keyboardViewModel.ParentModel.SelectedGamepad,
MidiInputViewModel midiViewModel => midiViewModel.ParentModel.SelectedGamepad,
_ => null,
};
}
private IGamepad GetCancelKeyboard()
{
return DataContext switch
{
KeyboardInputViewModel keyboardViewModel => keyboardViewModel.ParentModel.AvaloniaKeyboardDriver.GetGamepad("0"),
MidiInputViewModel midiViewModel => midiViewModel.ParentModel.AvaloniaKeyboardDriver.GetGamepad("0"),
_ => null,
};
}
private void AssignKeyboardBinding(string controlName, Key value)
{
if (DataContext is not KeyboardInputViewModel viewModel)
{
return;
}
viewModel.ParentModel.IsModified = true;
Dictionary<string, Action> buttonActions = new() Dictionary<string, Action> buttonActions = new()
{ {
{ "ButtonZl", () => ViewModel.Config.ButtonZl = Key.Unbound }, { "ButtonZl", () => viewModel.Config.ButtonZl = value },
{ "ButtonL", () => ViewModel.Config.ButtonL = Key.Unbound }, { "ButtonL", () => viewModel.Config.ButtonL = value },
{ "ButtonMinus", () => ViewModel.Config.ButtonMinus = Key.Unbound }, { "ButtonMinus", () => viewModel.Config.ButtonMinus = value },
{ "LeftStickButton", () => ViewModel.Config.LeftStickButton = Key.Unbound }, { "LeftStickButton", () => viewModel.Config.LeftStickButton = value },
{ "LeftStickUp", () => ViewModel.Config.LeftStickUp = Key.Unbound }, { "LeftStickUp", () => viewModel.Config.LeftStickUp = value },
{ "LeftStickDown", () => ViewModel.Config.LeftStickDown = Key.Unbound }, { "LeftStickDown", () => viewModel.Config.LeftStickDown = value },
{ "LeftStickRight", () => ViewModel.Config.LeftStickRight = Key.Unbound }, { "LeftStickRight", () => viewModel.Config.LeftStickRight = value },
{ "LeftStickLeft", () => ViewModel.Config.LeftStickLeft = Key.Unbound }, { "LeftStickLeft", () => viewModel.Config.LeftStickLeft = value },
{ "DpadUp", () => ViewModel.Config.DpadUp = Key.Unbound }, { "DpadUp", () => viewModel.Config.DpadUp = value },
{ "DpadDown", () => ViewModel.Config.DpadDown = Key.Unbound }, { "DpadDown", () => viewModel.Config.DpadDown = value },
{ "DpadLeft", () => ViewModel.Config.DpadLeft = Key.Unbound }, { "DpadLeft", () => viewModel.Config.DpadLeft = value },
{ "DpadRight", () => ViewModel.Config.DpadRight = Key.Unbound }, { "DpadRight", () => viewModel.Config.DpadRight = value },
{ "LeftButtonSr", () => ViewModel.Config.LeftButtonSr = Key.Unbound }, { "LeftButtonSr", () => viewModel.Config.LeftButtonSr = value },
{ "LeftButtonSl", () => ViewModel.Config.LeftButtonSl = Key.Unbound }, { "LeftButtonSl", () => viewModel.Config.LeftButtonSl = value },
{ "RightButtonSr", () => ViewModel.Config.RightButtonSr = Key.Unbound }, { "RightButtonSr", () => viewModel.Config.RightButtonSr = value },
{ "RightButtonSl", () => ViewModel.Config.RightButtonSl = Key.Unbound }, { "RightButtonSl", () => viewModel.Config.RightButtonSl = value },
{ "ButtonZr", () => ViewModel.Config.ButtonZr = Key.Unbound }, { "ButtonZr", () => viewModel.Config.ButtonZr = value },
{ "ButtonR", () => ViewModel.Config.ButtonR = Key.Unbound }, { "ButtonR", () => viewModel.Config.ButtonR = value },
{ "ButtonPlus", () => ViewModel.Config.ButtonPlus = Key.Unbound }, { "ButtonPlus", () => viewModel.Config.ButtonPlus = value },
{ "ButtonA", () => ViewModel.Config.ButtonA = Key.Unbound }, { "ButtonA", () => viewModel.Config.ButtonA = value },
{ "ButtonB", () => ViewModel.Config.ButtonB = Key.Unbound }, { "ButtonB", () => viewModel.Config.ButtonB = value },
{ "ButtonX", () => ViewModel.Config.ButtonX = Key.Unbound }, { "ButtonX", () => viewModel.Config.ButtonX = value },
{ "ButtonY", () => ViewModel.Config.ButtonY = Key.Unbound }, { "ButtonY", () => viewModel.Config.ButtonY = value },
{ "RightStickButton", () => ViewModel.Config.RightStickButton = Key.Unbound }, { "RightStickButton", () => viewModel.Config.RightStickButton = value },
{ "RightStickUp", () => ViewModel.Config.RightStickUp = Key.Unbound }, { "RightStickUp", () => viewModel.Config.RightStickUp = value },
{ "RightStickDown", () => ViewModel.Config.RightStickDown = Key.Unbound }, { "RightStickDown", () => viewModel.Config.RightStickDown = value },
{ "RightStickRight", () => ViewModel.Config.RightStickRight = Key.Unbound }, { "RightStickRight", () => viewModel.Config.RightStickRight = value },
{ "RightStickLeft", () => ViewModel.Config.RightStickLeft = Key.Unbound } { "RightStickLeft", () => viewModel.Config.RightStickLeft = value },
}; };
if (buttonActions.TryGetValue(_currentAssigner.ToggledButton.Name, out Action action)) if (buttonActions.TryGetValue(controlName, out Action action))
{ {
action(); action();
ViewModel.ParentModel.IsModified = true;
} }
} }
private void AssignMidiBinding(string controlName, MidiBinding value)
{
if (DataContext is not MidiInputViewModel viewModel)
{
return;
}
viewModel.ParentModel.IsModified = true;
Dictionary<string, Action> buttonActions = new()
{
{ "ButtonZl", () => viewModel.Config.ButtonZl = value },
{ "ButtonL", () => viewModel.Config.ButtonL = value },
{ "ButtonMinus", () => viewModel.Config.ButtonMinus = value },
{ "LeftStickButton", () => viewModel.Config.LeftStickButton = value },
{ "LeftStickUp", () => viewModel.Config.LeftStickUp = value },
{ "LeftStickDown", () => viewModel.Config.LeftStickDown = value },
{ "LeftStickRight", () => viewModel.Config.LeftStickRight = value },
{ "LeftStickLeft", () => viewModel.Config.LeftStickLeft = value },
{ "DpadUp", () => viewModel.Config.DpadUp = value },
{ "DpadDown", () => viewModel.Config.DpadDown = value },
{ "DpadLeft", () => viewModel.Config.DpadLeft = value },
{ "DpadRight", () => viewModel.Config.DpadRight = value },
{ "LeftButtonSr", () => viewModel.Config.LeftButtonSr = value },
{ "LeftButtonSl", () => viewModel.Config.LeftButtonSl = value },
{ "RightButtonSr", () => viewModel.Config.RightButtonSr = value },
{ "RightButtonSl", () => viewModel.Config.RightButtonSl = value },
{ "ButtonZr", () => viewModel.Config.ButtonZr = value },
{ "ButtonR", () => viewModel.Config.ButtonR = value },
{ "ButtonPlus", () => viewModel.Config.ButtonPlus = value },
{ "ButtonA", () => viewModel.Config.ButtonA = value },
{ "ButtonB", () => viewModel.Config.ButtonB = value },
{ "ButtonX", () => viewModel.Config.ButtonX = value },
{ "ButtonY", () => viewModel.Config.ButtonY = value },
{ "RightStickButton", () => viewModel.Config.RightStickButton = value },
{ "RightStickUp", () => viewModel.Config.RightStickUp = value },
{ "RightStickDown", () => viewModel.Config.RightStickDown = value },
{ "RightStickRight", () => viewModel.Config.RightStickRight = value },
{ "RightStickLeft", () => viewModel.Config.RightStickLeft = value },
};
if (buttonActions.TryGetValue(controlName, out Action action))
{
action();
}
}
private static MidiBinding CreateUnboundMidiBinding()
{
return new MidiBinding
{
Kind = MidiBindingKind.Unbound,
Threshold = 1,
};
} }
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
{ {
base.OnDetachedFromVisualTree(e); base.OnDetachedFromVisualTree(e);
_currentAssigner?.Cancel();
_currentAssigner = null; CancelCurrentAssignment();
} }
} }
} }

View file

@ -47,18 +47,13 @@
</StackPanel> </StackPanel>
<StackPanel Orientation="Horizontal" IsEnabled="{Binding ShowLedColorPicker}"> <StackPanel Orientation="Horizontal" IsEnabled="{Binding ShowLedColorPicker}">
<TextBlock MinWidth="75" MaxWidth="200" Text="{ext:Locale ControllerSettingsLedColor}" /> <TextBlock MinWidth="75" MaxWidth="200" Text="{ext:Locale ControllerSettingsLedColor}" />
<ui:ColorPickerButton <ColorPicker
Margin="5" Margin="5"
IsMoreButtonVisible="False"
UseColorPalette="False"
UseColorTriangle="False"
UseColorWheel="False"
ShowAcceptDismissButtons="False"
IsAlphaEnabled="False" IsAlphaEnabled="False"
AttachedToVisualTree="ColorPickerButton_OnAttachedToVisualTree" AttachedToVisualTree="ColorPicker_OnAttachedToVisualTree"
ColorChanged="ColorPickerButton_OnColorChanged" ColorChanged="ColorPicker_OnColorChanged"
Color="{Binding LedColor, Mode=TwoWay}"> Color="{Binding LedColor, Mode=TwoWay}">
</ui:ColorPickerButton> </ColorPicker>
</StackPanel> </StackPanel>
</StackPanel> </StackPanel>
</UserControl> </UserControl>

View file

@ -1,4 +1,5 @@
using Avalonia; using Avalonia;
using Avalonia.Controls;
using FluentAvalonia.UI.Controls; using FluentAvalonia.UI.Controls;
using Ryujinx.Ava.Common.Locale; using Ryujinx.Ava.Common.Locale;
using Ryujinx.Ava.UI.Controls; using Ryujinx.Ava.UI.Controls;
@ -30,19 +31,17 @@ namespace Ryujinx.UI.Views.Input
InitializeComponent(); InitializeComponent();
} }
private void ColorPickerButton_OnColorChanged(ColorPickerButton sender, ColorButtonColorChangedEventArgs args) private void ColorPicker_OnColorChanged(object sender, ColorChangedEventArgs args)
{ {
if (!args.NewColor.HasValue)
return;
if (!ViewModel.EnableLedChanging) if (!ViewModel.EnableLedChanging)
return; return;
if (ViewModel.TurnOffLed) if (ViewModel.TurnOffLed)
return; return;
ViewModel.ParentModel.SelectedGamepad.SetLed(args.NewColor.Value.ToUInt32()); ViewModel.ParentModel.SelectedGamepad.SetLed(args.NewColor.ToUInt32());
} }
private void ColorPickerButton_OnAttachedToVisualTree(object sender, VisualTreeAttachmentEventArgs e) private void ColorPicker_OnAttachedToVisualTree(object sender, VisualTreeAttachmentEventArgs e)
{ {
if (!ViewModel.EnableLedChanging) if (!ViewModel.EnableLedChanging)
return; return;

View file

@ -167,6 +167,12 @@
Icon="{ext:Icon fa-solid fa-stop}" Icon="{ext:Icon fa-solid fa-stop}"
InputGesture="Escape" InputGesture="Escape"
IsEnabled="{Binding IsGameRunning}" /> IsEnabled="{Binding IsGameRunning}" />
<MenuItem
Name="RestartEmulationMenuItem"
Header="{ext:Locale MenuBarOptionsRestartEmulation}"
Icon="{ext:Icon fa-solid fa-rotate-right}"
InputGesture="Ctrl + R"
IsEnabled="{Binding IsGameRunning}" />
<MenuItem Command="{Binding SimulateWakeUpMessage}" Header="{ext:Locale MenuBarOptionsSimulateWakeUpMessage}" Icon="{ext:Icon fa-solid fa-sun}" /> <MenuItem Command="{Binding SimulateWakeUpMessage}" Header="{ext:Locale MenuBarOptionsSimulateWakeUpMessage}" Icon="{ext:Icon fa-solid fa-sun}" />
<Separator /> <Separator />
<MenuItem <MenuItem

View file

@ -43,6 +43,7 @@ namespace Ryujinx.Ava.UI.Views.Main
PauseEmulationMenuItem.Command = Commands.Create(() => ViewModel.AppHost?.Pause()); PauseEmulationMenuItem.Command = Commands.Create(() => ViewModel.AppHost?.Pause());
ResumeEmulationMenuItem.Command = Commands.Create(() => ViewModel.AppHost?.Resume()); ResumeEmulationMenuItem.Command = Commands.Create(() => ViewModel.AppHost?.Resume());
StopEmulationMenuItem.Command = Commands.Create(() => ViewModel.AppHost?.ShowExitPrompt().OrCompleted()); StopEmulationMenuItem.Command = Commands.Create(() => ViewModel.AppHost?.ShowExitPrompt().OrCompleted());
RestartEmulationMenuItem.Command = Commands.Create(() => ViewModel.RestartEmulation());
CheatManagerMenuItem.Command = Commands.CreateSilentFail(OpenCheatManagerForCurrentApp); CheatManagerMenuItem.Command = Commands.CreateSilentFail(OpenCheatManagerForCurrentApp);
InstallFileTypesMenuItem.Command = Commands.Create(InstallFileTypes); InstallFileTypesMenuItem.Command = Commands.Create(InstallFileTypes);
UninstallFileTypesMenuItem.Command = Commands.Create(UninstallFileTypes); UninstallFileTypesMenuItem.Command = Commands.Create(UninstallFileTypes);

View file

@ -108,7 +108,7 @@
<Button <Button
Name="SaveButton" Name="SaveButton"
Click="SaveButton_Click"> Click="SaveButton_Click">
<TextBlock Text="{ext:Locale UserProfilesSetProfileImage}" /> <TextBlock Text="{ext:Locale UserProfilesSave}" />
</Button> </Button>
</StackPanel> </StackPanel>
</Grid> </Grid>

View file

@ -78,22 +78,16 @@
Spacing="10" Spacing="10"
Margin="0 24 0 0" Margin="0 24 0 0"
HorizontalAlignment="Right"> HorizontalAlignment="Right">
<ui:ColorPickerButton <ColorPicker
FlyoutPlacement="Top"
IsMoreButtonVisible="False"
UseColorPalette="False"
UseColorTriangle="False"
UseColorWheel="False"
ShowAcceptDismissButtons="False"
IsAlphaEnabled="False" IsAlphaEnabled="False"
Color="{Binding BackgroundColor, Mode=TwoWay}" Color="{Binding BackgroundColor, Mode=TwoWay}"
Name="ColorButton"> Name="ColorButton">
<ui:ColorPickerButton.Styles> <ColorPicker.Styles>
<Style Selector="Grid#Root > DockPanel > Grid"> <Style Selector="Grid#Root > DockPanel > Grid">
<Setter Property="IsVisible" Value="False" /> <Setter Property="IsVisible" Value="False" />
</Style> </Style>
</ui:ColorPickerButton.Styles> </ColorPicker.Styles>
</ui:ColorPickerButton> </ColorPicker>
<Button <Button
Content="{ext:Locale AvatarChoose}" Content="{ext:Locale AvatarChoose}"
Height="35" Height="35"

View file

@ -41,6 +41,7 @@
<KeyBinding Gesture="Escape" Command="{Binding ExitCurrentState}" /> <KeyBinding Gesture="Escape" Command="{Binding ExitCurrentState}" />
<KeyBinding Gesture="Ctrl+A" Command="{Binding OpenAmiiboWindow}" /> <KeyBinding Gesture="Ctrl+A" Command="{Binding OpenAmiiboWindow}" />
<KeyBinding Gesture="Ctrl+B" Command="{Binding OpenBinFile}" /> <KeyBinding Gesture="Ctrl+B" Command="{Binding OpenBinFile}" />
<KeyBinding Gesture="Ctrl+R" Command="{Binding RestartEmulation}" />
<KeyBinding Gesture="Ctrl+Shift+R" Command="{Binding ReloadRenderDocApi}" /> <KeyBinding Gesture="Ctrl+Shift+R" Command="{Binding ReloadRenderDocApi}" />
<KeyBinding Gesture="Ctrl+Shift+C" Command="{Binding ToggleCapture}" /> <KeyBinding Gesture="Ctrl+Shift+C" Command="{Binding ToggleCapture}" />
</Window.KeyBindings> </Window.KeyBindings>

View file

@ -29,6 +29,7 @@ using Ryujinx.HLE.FileSystem;
using Ryujinx.HLE.HOS; using Ryujinx.HLE.HOS;
using Ryujinx.HLE.HOS.Services.Account.Acc; using Ryujinx.HLE.HOS.Services.Account.Acc;
using Ryujinx.Input.HLE; using Ryujinx.Input.HLE;
using Ryujinx.Input.Midi;
using Ryujinx.Input.SDL3; using Ryujinx.Input.SDL3;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
@ -105,7 +106,7 @@ namespace Ryujinx.Ava.UI.Windows
if (Program.PreviewerDetached) if (Program.PreviewerDetached)
{ {
InputManager = new InputManager(new AvaloniaKeyboardDriver(this), new SDL3GamepadDriver()); InputManager = new InputManager(new AvaloniaKeyboardDriver(this), new SDL3GamepadDriver(), new MidiGamepadDriver());
_ = this.GetObservable(IsActiveProperty).Subscribe(it => ViewModel.IsActive = it); _ = this.GetObservable(IsActiveProperty).Subscribe(it => ViewModel.IsActive = it);
this.ScalingChanged += OnScalingChanged; this.ScalingChanged += OnScalingChanged;

View file

@ -1,5 +1,7 @@
using Avalonia.Platform.Storage; using Avalonia.Platform.Storage;
using Gommon; using Gommon;
using Ryujinx.Common.Utilities;
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
@ -11,29 +13,42 @@ namespace Ryujinx.Ava.Utilities
extension(IStorageProvider storageProvider) extension(IStorageProvider storageProvider)
{ {
public Task<Optional<IStorageFolder>> OpenSingleFolderPickerAsync(FolderPickerOpenOptions openOptions = null) => public Task<Optional<IStorageFolder>> OpenSingleFolderPickerAsync(FolderPickerOpenOptions openOptions = null) =>
storageProvider.OpenFolderPickerAsync(FixOpenOptions(openOptions, false)) CoreDumpable(() => storageProvider.OpenFolderPickerAsync(FixOpenOptions(openOptions, false)))
.Then(folders => folders.FindFirst()); .Then(folders => folders.FindFirst());
public Task<Optional<IStorageFile>> OpenSingleFilePickerAsync(FilePickerOpenOptions openOptions = null) => public Task<Optional<IStorageFile>> OpenSingleFilePickerAsync(FilePickerOpenOptions openOptions = null) =>
storageProvider.OpenFilePickerAsync(FixOpenOptions(openOptions, false)) CoreDumpable(() => storageProvider.OpenFilePickerAsync(FixOpenOptions(openOptions, false)))
.Then(files => files.FindFirst()); .Then(files => files.FindFirst());
public Task<Optional<IReadOnlyList<IStorageFolder>>> OpenMultiFolderPickerAsync(FolderPickerOpenOptions openOptions = null) => public Task<Optional<IReadOnlyList<IStorageFolder>>> OpenMultiFolderPickerAsync(FolderPickerOpenOptions openOptions = null) =>
storageProvider.OpenFolderPickerAsync(FixOpenOptions(openOptions, true)) CoreDumpable(() => storageProvider.OpenFolderPickerAsync(FixOpenOptions(openOptions, true)))
.Then(folders => folders.Count > 0 ? Optional.Of(folders) : default); .Then(folders => folders.Count > 0 ? Optional.Of(folders) : default);
public Task<Optional<IReadOnlyList<IStorageFile>>> OpenMultiFilePickerAsync(FilePickerOpenOptions openOptions = null) => public Task<Optional<IReadOnlyList<IStorageFile>>> OpenMultiFilePickerAsync(FilePickerOpenOptions openOptions = null) =>
storageProvider.OpenFilePickerAsync(FixOpenOptions(openOptions, true)) CoreDumpable(() => storageProvider.OpenFilePickerAsync(FixOpenOptions(openOptions, true)))
.Then(files => files.Count > 0 ? Optional.Of(files) : default); .Then(files => files.Count > 0 ? Optional.Of(files) : default);
} }
private static async Task<T> CoreDumpable<T>(Func<Task<T>> picker)
{
OsUtils.SetCoreDumpable(true);
try
{
return await picker();
}
finally
{
if (!Program.CoreDumpArg)
OsUtils.SetCoreDumpable(false);
}
}
private static FilePickerOpenOptions FixOpenOptions(this FilePickerOpenOptions openOptions, bool allowMultiple) private static FilePickerOpenOptions FixOpenOptions(this FilePickerOpenOptions openOptions, bool allowMultiple)
{ {
if (openOptions is null) if (openOptions is null)
return new FilePickerOpenOptions { AllowMultiple = allowMultiple }; return new FilePickerOpenOptions { AllowMultiple = allowMultiple };
openOptions.AllowMultiple = allowMultiple; openOptions.AllowMultiple = allowMultiple;
return openOptions; return openOptions;
} }
@ -43,7 +58,6 @@ namespace Ryujinx.Ava.Utilities
return new FolderPickerOpenOptions { AllowMultiple = allowMultiple }; return new FolderPickerOpenOptions { AllowMultiple = allowMultiple };
openOptions.AllowMultiple = allowMultiple; openOptions.AllowMultiple = allowMultiple;
return openOptions; return openOptions;
} }
} }