Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions tools/sunshine-ds5-sidecar/AdaptiveTriggerState.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
namespace Sunshine.Ds5Sidecar;

internal sealed class AdaptiveTriggerState
{
internal const byte RightFlag = 0x04;
internal const byte LeftFlag = 0x08;
internal const int EffectSize = 11;

private readonly object _lock = new();
private readonly byte[] _left = new byte[EffectSize];
private readonly byte[] _right = new byte[EffectSize];

internal bool TryUpdate(IReadOnlyDictionary<string, object> fields,
byte deviceId,
byte controllerNumber,
out Protocol.Message message)
{
lock (_lock)
{
byte flags = 0;
if (TryGetEffect(fields, "leftTriggerEffect", out var left) &&
!_left.AsSpan().SequenceEqual(left))
{
left.CopyTo(_left);
flags |= LeftFlag;
}

if (TryGetEffect(fields, "rightTriggerEffect", out var right) &&
!_right.AsSpan().SequenceEqual(right))
{
right.CopyTo(_right);
flags |= RightFlag;
}

if (flags == 0)
{
message = default;
return false;
}

message = BuildMessage(deviceId, controllerNumber, flags, _left, _right);
return true;
}
}

internal bool TryReset(byte deviceId, byte controllerNumber, out Protocol.Message message)
{
lock (_lock)
{
if (!_left.AsSpan().ContainsAnyExcept((byte)0) &&
!_right.AsSpan().ContainsAnyExcept((byte)0))
{
message = default;
return false;
}

Array.Clear(_left);
Array.Clear(_right);
message = BuildMessage(deviceId, controllerNumber, LeftFlag | RightFlag, _left, _right);
return true;
}
}

private static bool TryGetEffect(IReadOnlyDictionary<string, object> fields,
string name,
out ReadOnlySpan<byte> effect)
{
if (fields.TryGetValue(name, out var item) &&
item is byte[] bytes &&
bytes.Length >= EffectSize)
{
effect = bytes.AsSpan(0, EffectSize);
return true;
}

effect = default;
return false;
}

private static Protocol.Message BuildMessage(byte deviceId,
byte controllerNumber,
byte flags,
ReadOnlySpan<byte> left,
ReadOnlySpan<byte> right)
{
// id:u8, controller:u8, flags:u8, left/right type:u8, reserved:u8,
// left/right effect payload:10 bytes = 26 bytes.
var payload = new byte[26];
payload[0] = deviceId;
payload[1] = controllerNumber;
payload[2] = flags;
payload[3] = left[0];
payload[4] = right[0];
left.Slice(1, 10).CopyTo(payload.AsSpan(6, 10));
right.Slice(1, 10).CopyTo(payload.AsSpan(16, 10));
return new Protocol.Message(Protocol.MessageType.AdaptiveTriggers, 0, payload);
}
}
70 changes: 30 additions & 40 deletions tools/sunshine-ds5-sidecar/ControllerSession.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,11 @@ internal sealed class ControllerSession : IDisposable
private const int HapticsFramesPerPacket = 240;

private readonly object _stateLock = new();
private readonly object _outputLock = new();
private readonly HMController _controller;
private readonly HMProfile _profile;
private readonly Action<Protocol.Message> _emit;
private readonly AdaptiveTriggerState _adaptiveTriggers = new();
private HMGamepadState _state;
private readonly Dictionary<uint, int> _touchSlots = new();
private readonly Stopwatch _clock = Stopwatch.StartNew();
Expand Down Expand Up @@ -191,38 +193,32 @@ private void OnOutputDecoded(object? sender, HMOutputDecodedEventArgs output)
if (!output.CrcValid)
return;

if (TryByte(output.Fields, "leftMotor", out var left) &&
TryByte(output.Fields, "rightMotor", out var right))
lock (_outputLock)
{
var payload = new byte[6];
payload[0] = DeviceId;
payload[1] = ClientControllerNumber;
BinaryPrimitives.WriteUInt16LittleEndian(payload.AsSpan(2, 2), (ushort)(left * 257));
BinaryPrimitives.WriteUInt16LittleEndian(payload.AsSpan(4, 2), (ushort)(right * 257));
_emit(new Protocol.Message(Protocol.MessageType.Rumble, 0, payload));
}
if (Volatile.Read(ref _disposed) != 0)
return;

if (TryBytes(output.Fields, "leftTriggerEffect", out var leftEffect) &&
TryBytes(output.Fields, "rightTriggerEffect", out var rightEffect) &&
leftEffect.Length >= 11 && rightEffect.Length >= 11)
{
var payload = new byte[26];
payload[0] = DeviceId;
payload[1] = ClientControllerNumber;
payload[2] = 0x0C;
payload[3] = leftEffect[0];
payload[4] = rightEffect[0];
leftEffect.AsSpan(1, 10).CopyTo(payload.AsSpan(6, 10));
rightEffect.AsSpan(1, 10).CopyTo(payload.AsSpan(16, 10));
_emit(new Protocol.Message(Protocol.MessageType.AdaptiveTriggers, 0, payload));
}
if (TryByte(output.Fields, "leftMotor", out var left) &&
TryByte(output.Fields, "rightMotor", out var right))
{
var payload = new byte[6];
payload[0] = DeviceId;
payload[1] = ClientControllerNumber;
BinaryPrimitives.WriteUInt16LittleEndian(payload.AsSpan(2, 2), (ushort)(left * 257));
BinaryPrimitives.WriteUInt16LittleEndian(payload.AsSpan(4, 2), (ushort)(right * 257));
_emit(new Protocol.Message(Protocol.MessageType.Rumble, 0, payload));
}

if (output.Fields.TryGetValue("lightbar", out var value) && value is byte[] rgb && rgb.Length >= 3)
{
_emit(new Protocol.Message(
Protocol.MessageType.Led,
0,
new[] { DeviceId, ClientControllerNumber, rgb[0], rgb[1], rgb[2] }));
if (_adaptiveTriggers.TryUpdate(output.Fields, DeviceId, ClientControllerNumber, out var adaptiveTriggers))
_emit(adaptiveTriggers);

if (output.Fields.TryGetValue("lightbar", out var value) && value is byte[] rgb && rgb.Length >= 3)
{
_emit(new Protocol.Message(
Protocol.MessageType.Led,
0,
new[] { DeviceId, ClientControllerNumber, rgb[0], rgb[1], rgb[2] }));
}
}
}

Expand Down Expand Up @@ -367,22 +363,16 @@ private static bool TryByte(IReadOnlyDictionary<string, object> fields, string n
return false;
}

private static bool TryBytes(IReadOnlyDictionary<string, object> fields, string name, out byte[] value)
{
if (fields.TryGetValue(name, out var item) && item is byte[] bytes)
{
value = bytes;
return true;
}
value = Array.Empty<byte>();
return false;
}

public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) != 0)
return;
_controller.OutputDecoded -= OnOutputDecoded;
lock (_outputLock)
{
if (_adaptiveTriggers.TryReset(DeviceId, ClientControllerNumber, out var adaptiveTriggers))
_emit(adaptiveTriggers);
}
if (_controller.UsbAudio is not null)
{
_controller.UsbAudio.Output.FramesReceived -= OnAudioFrames;
Expand Down
1 change: 1 addition & 0 deletions tools/sunshine-ds5-sidecar/Protocol.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ internal enum Capability : uint
Touchpad = 1u << 4,
Motion = 1u << 5,
Battery = 1u << 6,
AdaptiveTriggers = 1u << 7,
}

internal enum MessageType : ushort
Expand Down
41 changes: 41 additions & 0 deletions tools/sunshine-ds5-sidecar/ProtocolSelfTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ internal static class ProtocolSelfTest
{
internal static async Task<int> RunAsync(bool composite, string? resultPath, string? audioWriterPath)
{
VerifyAdaptiveTriggerEncoding();
var pipeName = $"sunshine-ds5-self-test-{Environment.ProcessId}-{Guid.NewGuid():N}";
using var stopping = new CancellationTokenSource(TimeSpan.FromSeconds(30));
await using var server = new SidecarServer(pipeName);
Expand All @@ -21,6 +22,9 @@ internal static async Task<int> RunAsync(bool composite, string? resultPath, str
var hello = await ReceiveAsync(client, stopping.Token);
Require(hello.Type == Protocol.MessageType.HelloReply && hello.RequestId == 1 && hello.Payload.Length == 4,
"hello reply");
var helloCapabilities = (Protocol.Capability)BinaryPrimitives.ReadUInt32LittleEndian(hello.Payload);
Require(helloCapabilities.HasFlag(Protocol.Capability.Hid), "hello HID capability");
Require(helloCapabilities.HasFlag(Protocol.Capability.AdaptiveTriggers), "hello adaptive trigger capability");

await SendAsync(client, new Protocol.Message(
Protocol.MessageType.Attach, 2, new byte[] { 0, 0, composite ? (byte)1 : (byte)0, 0 }), stopping.Token);
Expand All @@ -31,6 +35,7 @@ internal static async Task<int> RunAsync(bool composite, string? resultPath, str
"attach reply");
var capabilities = (Protocol.Capability)BinaryPrimitives.ReadUInt32LittleEndian(attach.Payload.AsSpan(4));
Require(capabilities.HasFlag(Protocol.Capability.Hid), "HID capability");
Require(capabilities.HasFlag(Protocol.Capability.AdaptiveTriggers), "adaptive trigger capability");
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Require(!composite || capabilities.HasFlag(Protocol.Capability.AudioFourChannel),
"composite four-channel audio capability");

Expand Down Expand Up @@ -126,6 +131,7 @@ internal static async Task<int> RunAsync(bool composite, string? resultPath, str
touch = true,
motion = true,
battery = true,
adaptive_triggers = true,
haptics_pcm = capturedHapticsBytes != 0,
haptics_bytes = capturedHapticsBytes,
detached = true,
Expand Down Expand Up @@ -192,6 +198,41 @@ private static string DecodeError(ReadOnlySpan<byte> payload)
private static void WriteFloat(Span<byte> destination, float value) =>
BinaryPrimitives.WriteInt32LittleEndian(destination, BitConverter.SingleToInt32Bits(value));

private static void VerifyAdaptiveTriggerEncoding()
{
var state = new AdaptiveTriggerState();
var left = Enumerable.Range(0x20, AdaptiveTriggerState.EffectSize).Select(value => (byte)value).ToArray();
var right = Enumerable.Range(0x40, AdaptiveTriggerState.EffectSize).Select(value => (byte)value).ToArray();

Require(state.TryUpdate(new Dictionary<string, object> { ["leftTriggerEffect"] = left },
3, 2, out var leftMessage),
"left adaptive trigger update");
Require(leftMessage.Type == Protocol.MessageType.AdaptiveTriggers &&
leftMessage.Payload.Length == 26 &&
leftMessage.Payload[0] == 3 && leftMessage.Payload[1] == 2 &&
leftMessage.Payload[2] == AdaptiveTriggerState.LeftFlag &&
leftMessage.Payload[3] == left[0] && leftMessage.Payload[4] == 0 &&
leftMessage.Payload.AsSpan(6, 10).SequenceEqual(left.AsSpan(1, 10)),
"left adaptive trigger encoding");

Require(!state.TryUpdate(new Dictionary<string, object> { ["leftTriggerEffect"] = left },
3, 2, out _),
"adaptive trigger duplicate suppression");

Require(state.TryUpdate(new Dictionary<string, object> { ["rightTriggerEffect"] = right },
3, 2, out var rightMessage) &&
rightMessage.Payload[2] == AdaptiveTriggerState.RightFlag &&
rightMessage.Payload[3] == left[0] && rightMessage.Payload[4] == right[0] &&
rightMessage.Payload.AsSpan(16, 10).SequenceEqual(right.AsSpan(1, 10)),
"right adaptive trigger encoding");

Require(state.TryReset(3, 2, out var resetMessage) &&
resetMessage.Payload[2] == (AdaptiveTriggerState.LeftFlag | AdaptiveTriggerState.RightFlag) &&
resetMessage.Payload.AsSpan(3).IndexOfAnyExcept((byte)0) == -1,
"adaptive trigger reset");
Require(!state.TryReset(3, 2, out _), "adaptive trigger reset duplicate suppression");
}

private static void Require(bool condition, string operation)
{
if (!condition) throw new InvalidOperationException($"Protocol self-test failed at {operation}");
Expand Down
4 changes: 3 additions & 1 deletion tools/sunshine-ds5-sidecar/SidecarServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,8 @@ private async Task ReadLoopAsync(Stream pipe, CancellationToken cancellationToke
Protocol.Capability.AuthoredHapticsPcm |
Protocol.Capability.Touchpad |
Protocol.Capability.Motion |
Protocol.Capability.Battery))));
Protocol.Capability.Battery |
Protocol.Capability.AdaptiveTriggers))));
break;
case Protocol.MessageType.Attach:
Attach(header.RequestId, payload);
Expand Down Expand Up @@ -203,6 +204,7 @@ private void Attach(uint requestId, ReadOnlySpan<byte> payload)
Protocol.Capability.Touchpad |
Protocol.Capability.Motion |
Protocol.Capability.Battery |
Protocol.Capability.AdaptiveTriggers |
(session.HasAudio
? Protocol.Capability.AudioFourChannel | Protocol.Capability.AuthoredHapticsPcm
: 0)));
Expand Down
Loading