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
12 changes: 12 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,18 @@ if (BUILD_TESTING)
endif()
add_test(NAME ds5-haptics-ir-protocol-golden-tests COMMAND ds5-haptics-ir-protocol-golden-tests)

if(_common_c_type STREQUAL "STATIC_LIBRARY")
add_executable(ds5-haptics-negotiation-tests tests/Ds5HapticsNegotiationTest.c)
target_include_directories(ds5-haptics-negotiation-tests PRIVATE
enet/include $<TARGET_PROPERTY:moonlight-common-c,INCLUDE_DIRECTORIES>)
target_compile_definitions(ds5-haptics-negotiation-tests PRIVATE
$<TARGET_PROPERTY:moonlight-common-c,COMPILE_DEFINITIONS>)
target_compile_options(ds5-haptics-negotiation-tests PRIVATE
$<TARGET_PROPERTY:moonlight-common-c,COMPILE_OPTIONS>)
target_link_libraries(ds5-haptics-negotiation-tests PRIVATE moonlight-common-c)
add_test(NAME ds5-haptics-negotiation-tests COMMAND ds5-haptics-negotiation-tests)
endif()

add_executable(pyrowave-protocol-golden-tests
tests/PyrowaveProtocolGoldenTest.c
src/PyrowaveProtocol.c
Expand Down
113 changes: 113 additions & 0 deletions docs/DS5-HAPTICS-IR.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
# DualSense authored IR: v2 base with optional enhancement

The protocol library owns capability advertisement, message dispatch, decoding,
validation and synchronous callbacks. The SDK owns PCM analysis. Sunshine owns
controller association, encoding and sending. Clients own playback clocks,
deduplication, expiry, calibration and actuator allocation.

## Compatibility and negotiation

The original v2 capability `ML_FF_DS5_HAPTICS_IR_V2 = 0x08`, control message
`0x550B`, 72-byte payload and feature semantics remain unchanged. Existing v2
clients continue receiving exactly that path. No reserved v2 field is reused.

A client can additionally register `ds5HapticsIrExtended` alongside its required
`ds5HapticsIrV2` callback to advertise `ML_FF_DS5_HAPTICS_IR_EXTENDED = 0x80`.
This accepts an enhanced payload on the existing message (`0x550B`) consisting of the original 72-byte
v2 base followed by an optional continuous/event extension. The host sends one
representation per connection: enhanced when both capabilities are present,
v2 for v2-only clients, ordinary rumble when neither is present. An old host
sees the base capability and sends v2. Raw authored PCM and IR remain exclusive.
The enhanced callback alone is not a complete v2 receiver and is rejected.

The control receiver uses the existing v2 message route: a 72-byte payload
goes to the original callback; a longer payload is decoded only when the
enhancement callback was negotiated. No extra message type or channel is added.

The enhanced decoder reuses the existing v2 decoder on the first 72 bytes.
It delivers the base and enhancement together in one callback, without also
issuing a legacy callback or sending a duplicate base message. This avoids
joining separately lost/reordered base and enhancement packets.

Both callbacks execute on the control receive thread and borrow their frame
until return. Copy before deferring work; do not block this thread. C callback
structs are native values, not packed wire layouts. As with the v2 source API,
rebuild callers, headers and library together when the callback struct grows.
Old applications cannot replace only their shared library across that change;
wire fallback and source recompilation compatibility are separate from binary ABI.

## Original v2 base

All integers and IEEE-754 binary32 floats are little-endian. The v2 base always
has its own version 2 and length 72, even inside an enhanced message.

| Base offset | Type | Meaning |
| --- | --- | --- |
| 0 | u8 | Version, 2 |
| 1 | u8 | Discontinuity 1, partial 2, END 4, SILENT 8 |
| 2 | u16 | Base length, 72 |
| 4 | u16 | Client controller number |
| 6 | u16 | Reserved, zero |
| 8 | u32 | Source sequence, shared by base and enhancement |
| 12 | u64 | End of the represented v2 source window, microseconds |
| 20 | u32 | Number of stereo source frames represented |
| 24, 44 | 5 x f32 | Each lane: tactile RMS, peak, relative attack, low-band ratio, sign-transition rate |
| 64 | f32 | Lane correlation |
| 68 | u32 | Reserved, zero |

RMS/peak describe the 50-400 Hz tactile band, with `0 <= rms <= peak <= 1`.
Attack and low-band energy ratio are in [0, 1]; the split is approximately
160 Hz. Sign transitions are a trailing estimate in [0, 48000], not a pitch.
Correlation is in [-1, 1]. All values must be finite. SILENT describes the
current tactile input. These features retain their original v2 meaning and are
not repurposed as continuous envelope values, raw PCM statistics or event IDs.

## Optional enhancement version 1

The outer control message length is `104 + 20 * eventCount`, maximum 264 bytes.
The extension begins immediately after the base. Its length is `32 + 20*N`;
its version is independent of the unchanged base version.

| Extension offset | Type | Meaning |
| --- | --- | --- |
| 0 | u8 | Extension version, 1 |
| 1 | u8 | Event count, 0-8 |
| 2 | u16 | Complete extension length |
| 4 | u32 | Enhancement epoch |
| 8 | u64 | End of the continuous/event analysis state, microseconds |
| 16, 24 | 2 x f32 | Each lane: continuous low, continuous high |
| 32 onward | 20-byte records | Identified source-time events |

Extension version 1 defines 50-180 Hz / 180-400 Hz continuous levels in [0, 1], with
release history. The enhancement window end can follow the last completed v2
hop, but cannot precede the base timestamp. It shares the same 48 kHz source
clock. The source sequence, controller identity, lifecycle flags, source count
and original signal features are reused from the base rather than duplicated.
A source count is metadata, not a payload length or an allocation request;
positive u32 counts are independent of the SDK's current 240-frame input limit.
A zero count is accepted in an enhanced message only with END.

Each event contains `eventId:u32` at offset 0, `sourceTimeUs:u64` at offset 4,
then `leftStrength:f32` and `rightStrength:f32` at offsets 12 and 16, in [0, 1].
Wire records have no native alignment padding. Event time must not exceed the
enhancement window end. Decoder validation covers the complete v2 base,
extension sizes/version and finite ranges. Analysis profile, confidence and
sharpness are not transported; the extension version fixes the field semantics.
No device model, preset, gain or output-rate policy is carried here.

## Playback semantics

Newer valid frames replace continuous state. Events use
`(base.controllerNumber, epoch, eventId)` identity and immutable published values.
A producer can repeat recent events as bounded transport redundancy. Clients
can recover an unseen event from a reordered packet without replacing newer
continuous state. The base source sequence and epoch use unsigned 32-bit serial
ordering with rollover; zero is valid. Restart/discontinuity advances the epoch.

Base END terminates its epoch and clears pending playback; old-epoch END must
not stop a new source. Base SILENT must not erase an enhanced continuous release
tail. When enhancement is absent, the client retains its v2 renderer. When it
is present, it consumes the supplied continuous state and source events instead
of also estimating and playing the base attack a second time. Queues, freshness
limits, missed-event cleanup and phone/controller routing belong to the client.
Source time is not audio/video PTS and does not promise cross-media synchronization.
11 changes: 10 additions & 1 deletion src/Connection.c
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,16 @@ int LiStartConnection(PSERVER_INFORMATION serverInfo, PSTREAM_CONFIGURATION stre
goto Cleanup;
}

if (clCallbacks != NULL && clCallbacks->ds5HapticsPcm != NULL && clCallbacks->ds5HapticsIrV2 != NULL) {
if (clCallbacks != NULL && clCallbacks->ds5HapticsIrExtended != NULL &&
clCallbacks->ds5HapticsIrV2 == NULL) {
Limelog("Authored IR enhancement requires the v2 base callback\n");
LC_ASSERT(false);
err = -1;
goto Cleanup;
}

if (clCallbacks != NULL && clCallbacks->ds5HapticsPcm != NULL &&
(clCallbacks->ds5HapticsIrV2 != NULL || clCallbacks->ds5HapticsIrExtended != NULL)) {
Limelog("Physical and simulated DualSense haptics modes are mutually exclusive\n");
LC_ASSERT(false);
err = -1;
Expand Down
21 changes: 15 additions & 6 deletions src/ControlStream.c
Original file line number Diff line number Diff line change
Expand Up @@ -1488,6 +1488,11 @@ static void dispatchDs5HapticsIrV2(const LI_DS5_HAPTICS_IR_FRAME_V2* frame, void
ListenerCallbacks.ds5HapticsIrV2(frame);
}

static void dispatchDs5HapticsIrExtended(const LI_DS5_HAPTICS_IR_EXTENDED_FRAME* frame, void* context) {
(void)context;
ListenerCallbacks.ds5HapticsIrExtended(frame);
}

static void processRemoteTextContext(const uint8_t* payload, int payloadLength) {
LI_REMOTE_TEXT_CONTEXT context;

Expand Down Expand Up @@ -1758,12 +1763,16 @@ static void controlReceiveThreadFunc(void* context) {
}
}
else if (ctlHdr->type == packetTypes[IDX_DS5_HAPTICS_IR_V2]) {
if (ListenerCallbacks.ds5HapticsIrV2 != NULL &&
packetLength > (int)sizeof(*ctlHdr)) {
processDs5HapticsIrStreamPacket((const uint8_t*)(ctlHdr + 1),
packetLength - (int)sizeof(*ctlHdr),
dispatchDs5HapticsIrV2,
NULL);
if (ListenerCallbacks.ds5HapticsIrV2 != NULL && packetLength > (int)sizeof(*ctlHdr)) {
const uint8_t* irPayload = (const uint8_t*)(ctlHdr + 1);
const int irLength = packetLength - (int)sizeof(*ctlHdr);
if (irLength == DS5_HAPTICS_IR_STREAM_WIRE_SIZE) {
processDs5HapticsIrStreamPacket(irPayload, irLength, dispatchDs5HapticsIrV2, NULL);
}
else if (ListenerCallbacks.ds5HapticsIrExtended != NULL) {
processDs5HapticsIrExtendedStreamPacket(irPayload, irLength,
dispatchDs5HapticsIrExtended, NULL);
}
}
}
else if (ctlHdr->type == packetTypes[IDX_REMOTE_TEXT_CONTEXT]) {
Expand Down
52 changes: 52 additions & 0 deletions src/Ds5HapticsIrStream.c
Original file line number Diff line number Diff line change
Expand Up @@ -85,3 +85,55 @@ bool processDs5HapticsIrStreamPacket(const uint8_t* payload,
callback(&frame, context);
return true;
}

static void captureBaseFrame(const LI_DS5_HAPTICS_IR_FRAME_V2* base, void* context) {
*(LI_DS5_HAPTICS_IR_FRAME_V2*)context = *base;
}

bool processDs5HapticsIrExtendedStreamPacket(const uint8_t* payload,
int payloadLength,
Ds5HapticsIrExtendedStreamCallback callback,
void* context) {
LI_DS5_HAPTICS_IR_EXTENDED_FRAME frame;
const uint8_t* extension;
unsigned int i;
if (payload == NULL || callback == NULL ||
payloadLength < DS5_HAPTICS_IR_EXTENDED_STREAM_BASE_SIZE ||
payloadLength > DS5_HAPTICS_IR_EXTENDED_STREAM_MAX_SIZE) return false;
extension = payload + DS5_HAPTICS_IR_STREAM_WIRE_SIZE;
if (extension[0] != DS5_HAPTICS_IR_EXTENSION_VERSION ||
extension[1] > LI_DS5_HAPTICS_IR_EXTENDED_MAX_EVENTS ||
readLe16(extension + 2) != payloadLength - DS5_HAPTICS_IR_STREAM_WIRE_SIZE ||
payloadLength != DS5_HAPTICS_IR_EXTENDED_STREAM_BASE_SIZE +
extension[1] * DS5_HAPTICS_IR_EXTENDED_STREAM_EVENT_SIZE) return false;

memset(&frame, 0, sizeof(frame));
// Reuse the production v2 decoder, including its unchanged units and guards.
if (!processDs5HapticsIrStreamPacket(payload, DS5_HAPTICS_IR_STREAM_WIRE_SIZE,
captureBaseFrame, &frame.base)) return false;
if (frame.base.sourceFrameCount == 0 &&
!(frame.base.flags & LI_DS5_HAPTICS_IR_FLAG_STREAM_END)) return false;
frame.epoch = readLe32(extension + 4);
frame.windowEndUs = readLe64(extension + 8);
frame.eventCount = extension[1];
if (frame.windowEndUs < frame.base.timestampUs) return false;
for (i = 0; i < 2; i++) {
frame.lanes[i].continuousLow = readLeFloat(extension + 16 + i * 8);
frame.lanes[i].continuousHigh = readLeFloat(extension + 20 + i * 8);
if (!isNormalized(frame.lanes[i].continuousLow) ||
!isNormalized(frame.lanes[i].continuousHigh)) return false;
}
for (i = 0; i < frame.eventCount; i++) {
const uint8_t* event = payload + DS5_HAPTICS_IR_EXTENDED_STREAM_BASE_SIZE +
i * DS5_HAPTICS_IR_EXTENDED_STREAM_EVENT_SIZE;
LI_DS5_HAPTICS_IR_EVENT* out = &frame.events[i];
out->eventId = readLe32(event);
out->sourceTimeUs = readLe64(event + 4);
out->leftStrength = readLeFloat(event + 12);
out->rightStrength = readLeFloat(event + 16);
if (out->sourceTimeUs > frame.windowEndUs ||
!isNormalized(out->leftStrength) || !isNormalized(out->rightStrength)) return false;
}
callback(&frame, context);
return true;
}
13 changes: 13 additions & 0 deletions src/Ds5HapticsIrStream.h
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@

#define DS5_HAPTICS_IR_STREAM_PROTOCOL_VERSION 2
#define DS5_HAPTICS_IR_STREAM_WIRE_SIZE 72
#define DS5_HAPTICS_IR_EXTENSION_VERSION 1
#define DS5_HAPTICS_IR_EXTENSION_BASE_SIZE 32
#define DS5_HAPTICS_IR_EXTENDED_STREAM_BASE_SIZE (DS5_HAPTICS_IR_STREAM_WIRE_SIZE + DS5_HAPTICS_IR_EXTENSION_BASE_SIZE)
#define DS5_HAPTICS_IR_EXTENDED_STREAM_EVENT_SIZE 20
#define DS5_HAPTICS_IR_EXTENDED_STREAM_MAX_SIZE (DS5_HAPTICS_IR_EXTENDED_STREAM_BASE_SIZE + LI_DS5_HAPTICS_IR_EXTENDED_MAX_EVENTS * DS5_HAPTICS_IR_EXTENDED_STREAM_EVENT_SIZE)

typedef void(*Ds5HapticsIrStreamCallback)(const LI_DS5_HAPTICS_IR_FRAME_V2* frame,
void* context);
Expand All @@ -12,3 +17,11 @@ bool processDs5HapticsIrStreamPacket(const uint8_t* payload,
int payloadLength,
Ds5HapticsIrStreamCallback callback,
void* context);

typedef void(*Ds5HapticsIrExtendedStreamCallback)(const LI_DS5_HAPTICS_IR_EXTENDED_FRAME* frame,
void* context);

bool processDs5HapticsIrExtendedStreamPacket(const uint8_t* payload,
int payloadLength,
Ds5HapticsIrExtendedStreamCallback callback,
void* context);
1 change: 1 addition & 0 deletions src/Limelight-internal.h
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ extern uint32_t EncryptionFeaturesEnabled;
#define ML_FF_SESSION_ID_V1 0x02 // Client supports X-SS-Ping-Payload and X-SS-Connect-Data
#define ML_FF_DS5_HAPTICS_PCM 0x04 // Client accepts authored DualSense PCM on the control stream
#define ML_FF_DS5_HAPTICS_IR_V2 0x08 // Client selected simulated DualSense and accepts authored IR v2
#define ML_FF_DS5_HAPTICS_IR_EXTENDED 0x80 // Client accepts continuous-state/source-event v2 IR enhancement (0x550B)
#define ML_FF_REMOTE_TEXT_CONTEXT 0x10 // Client accepts remote text context updates
#define ML_FF_PYROWAVE 0x20 // Client accepts the experimental PyroWave video contract
#define ML_FF_DS5_HAPTICS_STATE 0x40 // Client accepts absolute DS5 actuator controls (0x550D)
Expand Down
30 changes: 30 additions & 0 deletions src/Limelight.h
Original file line number Diff line number Diff line change
Expand Up @@ -676,6 +676,31 @@ typedef struct _LI_DS5_HAPTICS_IR_FRAME_V2 {
} LI_DS5_HAPTICS_IR_FRAME_V2, *PLI_DS5_HAPTICS_IR_FRAME_V2;
typedef void(*ConnListenerDs5HapticsIrV2)(const LI_DS5_HAPTICS_IR_FRAME_V2* frame);

// Optional continuous/event enhancement of an unchanged v2 base frame.
// The base retains its original fields, units and callback interpretation.
// See docs/DS5-HAPTICS-IR.md. This callback borrows the complete frame on the
// control receive thread; copy it before deferring work and do not block.
#define LI_DS5_HAPTICS_IR_EXTENDED_MAX_EVENTS 8
typedef struct _LI_DS5_HAPTICS_IR_CONTINUOUS_LANE {
float continuousLow; // 50-180 Hz, linear full scale [0, 1].
float continuousHigh; // 180-400 Hz, linear full scale [0, 1].
} LI_DS5_HAPTICS_IR_CONTINUOUS_LANE, *PLI_DS5_HAPTICS_IR_CONTINUOUS_LANE;
typedef struct _LI_DS5_HAPTICS_IR_EVENT {
uint32_t eventId;
uint64_t sourceTimeUs;
float leftStrength;
float rightStrength;
} LI_DS5_HAPTICS_IR_EVENT, *PLI_DS5_HAPTICS_IR_EVENT;
typedef struct _LI_DS5_HAPTICS_IR_EXTENDED_FRAME {
LI_DS5_HAPTICS_IR_FRAME_V2 base;
uint32_t epoch;
uint64_t windowEndUs; // Enhancement state end; may follow the base hop.
uint8_t eventCount;
LI_DS5_HAPTICS_IR_CONTINUOUS_LANE lanes[2];
LI_DS5_HAPTICS_IR_EVENT events[LI_DS5_HAPTICS_IR_EXTENDED_MAX_EVENTS];
} LI_DS5_HAPTICS_IR_EXTENDED_FRAME, *PLI_DS5_HAPTICS_IR_EXTENDED_FRAME;
typedef void(*ConnListenerDs5HapticsIrExtended)(const LI_DS5_HAPTICS_IR_EXTENDED_FRAME* frame);

// A host-side text focus observation correlated with input from this client.
// Coordinates use the capture coordinate space described by captureWidth/Height.
#define LI_TEXT_CONTEXT_FLAG_ACTIVE 0x0001
Expand Down Expand Up @@ -714,6 +739,9 @@ typedef struct _LI_REMOTE_TEXT_CONTEXT {
} LI_REMOTE_TEXT_CONTEXT, *PLI_REMOTE_TEXT_CONTEXT;
typedef void(*ConnListenerRemoteTextContext)(const LI_REMOTE_TEXT_CONTEXT* context);

// Source API: rebuild callers and the library together when this struct grows.
// Appending the optional enhancement callback follows the existing v2 integration model;
// it preserves v2 member offsets and wire behavior, not a drop-in binary ABI.
typedef struct _CONNECTION_LISTENER_CALLBACKS {
ConnListenerStageStarting stageStarting;
ConnListenerStageComplete stageComplete;
Expand All @@ -733,6 +761,8 @@ typedef struct _CONNECTION_LISTENER_CALLBACKS {
ConnListenerCursorUpdate cursorUpdate;
ConnListenerDs5HapticsPcm ds5HapticsPcm;
ConnListenerDs5HapticsIrV2 ds5HapticsIrV2;
// Register both IR callbacks for enhancement preference with older-host v2 fallback.
ConnListenerDs5HapticsIrExtended ds5HapticsIrExtended;
} CONNECTION_LISTENER_CALLBACKS, *PCONNECTION_LISTENER_CALLBACKS;

// Use this function to zero the connection callbacks when allocated on the stack or heap
Expand Down
4 changes: 4 additions & 0 deletions src/SdpGenerator.c
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,10 @@ static PSDP_OPTION getAttributesList(char*urlSafeAddr) {
if (ListenerCallbacks.ds5HapticsIrV2 != NULL) {
moonlightFeatureFlags |= ML_FF_DS5_HAPTICS_IR_V2;
}
if (ListenerCallbacks.ds5HapticsIrV2 != NULL && ListenerCallbacks.ds5HapticsIrExtended != NULL) {
// Enhancement is optional and requires the existing v2 base capability.
moonlightFeatureFlags |= ML_FF_DS5_HAPTICS_IR_EXTENDED;
}
if (RemoteTextContextCallback != NULL) {
moonlightFeatureFlags |= ML_FF_REMOTE_TEXT_CONTEXT;
}
Expand Down
Loading
Loading