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
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -79,3 +79,10 @@ jobs:
mkdir build_release
cmake ${{ matrix.cmake_args }} -DCMAKE_BUILD_TYPE=Release -B build_release -S .
cmake --build build_release --config Release

- name: Test Windows static client
if: startsWith(matrix.os, 'windows')
run: |
cmake ${{ matrix.cmake_args }} -DCMAKE_BUILD_TYPE=Release -DBUILD_SHARED_LIBS=OFF -DBUILD_TESTING=ON -B build_static -S .
cmake --build build_static --config Release
ctest --test-dir build_static -C Release --output-on-failure
45 changes: 45 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -121,8 +121,53 @@ target_include_directories(moonlight-common-c SYSTEM PRIVATE
target_compile_definitions(moonlight-common-c PRIVATE HAS_SOCKLEN_T)

if (BUILD_TESTING)
add_executable(mat-live-tests tests/MatLiveTest.c src/MatLivePlayback.c src/MatReceiver.c src/MatDepacketizer.c)
target_include_directories(mat-live-tests PRIVATE src)
if(USE_MBEDTLS)
target_compile_definitions(mat-live-tests PRIVATE USE_MBEDTLS)
target_include_directories(mat-live-tests SYSTEM PRIVATE ${MBEDTLS_INCLUDE_DIRS})
if(MBEDTLS_FOUND)
target_link_libraries(mat-live-tests PRIVATE ${MBEDCRYPTO_LIBRARY})
else()
target_link_libraries(mat-live-tests PRIVATE mbedcrypto)
endif()
else()
target_include_directories(mat-live-tests SYSTEM PRIVATE ${OPENSSL_INCLUDE_DIR})
target_link_libraries(mat-live-tests PRIVATE ${OPENSSL_CRYPTO_LIBRARY})
endif()
add_test(NAME mat-live-tests COMMAND mat-live-tests)

add_executable(rtsp-parser-mat-tests tests/RtspParserMatTest.c src/RtspParser.c)
target_include_directories(rtsp-parser-mat-tests PRIVATE src enet/include)
target_compile_definitions(rtsp-parser-mat-tests PRIVATE HAS_SOCKLEN_T)
add_test(NAME rtsp-parser-mat-tests COMMAND rtsp-parser-mat-tests)

get_target_property(_common_c_type moonlight-common-c TYPE)
if(_common_c_type STREQUAL "STATIC_LIBRARY")
add_executable(mat-live-rtp-tests tests/MatLiveRtpTest.c)
target_include_directories(mat-live-rtp-tests PRIVATE enet/include
$<TARGET_PROPERTY:moonlight-common-c,INCLUDE_DIRECTORIES>)
target_compile_definitions(mat-live-rtp-tests PRIVATE
$<TARGET_PROPERTY:moonlight-common-c,COMPILE_DEFINITIONS>)
target_link_libraries(mat-live-rtp-tests PRIVATE moonlight-common-c)
Comment thread
qiin2333 marked this conversation as resolved.
Resolved
if(MSVC)
target_compile_options(mat-live-rtp-tests PRIVATE /experimental:c11atomics)
endif()
if(NOT USE_MBEDTLS)
target_include_directories(mat-live-rtp-tests SYSTEM PRIVATE ${OPENSSL_INCLUDE_DIR})
target_link_libraries(mat-live-rtp-tests PRIVATE ${OPENSSL_CRYPTO_LIBRARY})
endif()
add_test(NAME mat-live-rtp-tests COMMAND mat-live-rtp-tests)
add_executable(audio-codec-selection-tests tests/AudioCodecSelectionTest.c)
target_include_directories(audio-codec-selection-tests PRIVATE
enet/include $<TARGET_PROPERTY:moonlight-common-c,INCLUDE_DIRECTORIES>)
target_compile_definitions(audio-codec-selection-tests PRIVATE
$<TARGET_PROPERTY:moonlight-common-c,COMPILE_DEFINITIONS>)
target_compile_options(audio-codec-selection-tests PRIVATE
$<TARGET_PROPERTY:moonlight-common-c,COMPILE_OPTIONS>)
target_link_libraries(audio-codec-selection-tests PRIVATE moonlight-common-c)
add_test(NAME audio-codec-selection-tests COMMAND audio-codec-selection-tests)

# This test includes the sender and links its internal platform/queue helpers.
add_executable(controller-handoff-queue-tests tests/ControllerHandoffQueueTest.c)
target_include_directories(controller-handoff-queue-tests PRIVATE src enet/include)
Expand Down
38 changes: 38 additions & 0 deletions docs/MAT-HANDOFF.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Live MAT transport boundary - 2026-10-23

This PR targets **mic**. It owns negotiation, authenticated live transport,
bounded reassembly and synchronous cycle/EOS callbacks. It does not own files,
offline input, native capture, encoding or hardware rendering.

- Codec 7 / `mat-live-v1` uses HKDF-SHA256, AES-128-GCM with authenticated headers
and a 64-bit packet counter. RTP/FEC operates on ciphertext; authentication
precedes reassembly. Loss, corruption, gaps and consumer failure are terminal.
- Local MAT preference 6 selects live7 or Opus0 before ANNOUNCE. After selection
there is no codec change. Removed finite codec IDs 4/5 fail before networking;
they are reserved and must not be reused. Old finite-only hosts cause AUTO to
select Opus; stale or mismatched MAT acknowledgements fail the handshake.
- Carrier `iec61937-192000-8-s16le` carries 61,440 bytes per 20 ms cycle. The SMAT
clock is 48,000 Hz / 960 ticks per cycle. RTP fragment ticks are not media PTS.
- `LiSetMatPlaybackCallbacks` registers the live consumer during audio init and
is cleared after threads join. Consumers copy borrowed bytes before returning;
zero means failure. The end callback requires authenticated, complete END and
must finish any bounded drain before returning success. Closing or timing out
cannot synthesize successful EOS.
- The two-second watchdog measures accepted live input. It stops only after
verified END, before the consumer drain. Its timestamp is loaded before the
clock to avoid racing a newer publication. Unrelated Opus behavior is retained.
- Finite profile negotiation, SHA/count receiver, recording APIs and finish
polling are removed. Standalone file checks and mock offline input live in the
parent Sunshine `spatial-object-spike/mat-diagnostics` directory.

Static CTest has 13 tests, including live callback failures, missing END timeout,
drain semantics, RTP wrap/FEC, authenticated integrity and strict RTSP parser
uniqueness. Windows CI also builds/runs static tests with MSVC C11 atomics.
Parent software acceptance adds an independent crypto oracle, actual UDP receive
threads, controlled RTSP negotiation, source/pipe contracts and diagnostics.

Rebuild consumers after these unpublished API removals. The historical Android
app remains pinned to its previous shared revision; current clients are mocks.
Generated bytes establish software behavior only. Earlier finite replay/native
reports do not establish current native capture, hardware playback or Atmos
recognition. Historical APIs/results are available in Git history.
86 changes: 78 additions & 8 deletions src/AudioStream.c
Original file line number Diff line number Diff line change
@@ -1,4 +1,39 @@
#include "Limelight-internal.h"
#include "MatLivePlayback.h"
#include <stdatomic.h>
static atomic_bool matInputEnded;
static atomic_ullong matLastAcceptedMs;
static MAT_LIVE_PLAYBACK* matLive;
static LI_MAT_PLAYBACK_CALLBACKS matCallbacks;
static bool matPlaybackFailedReported,matPlaybackCompleteReported;
void LiSetMatPlaybackCallbacks(const LI_MAT_PLAYBACK_CALLBACKS* callbacks) {
if(callbacks) matCallbacks=*callbacks; else memset(&matCallbacks,0,sizeof(matCallbacks));
}
static int matEnd(void* context,uint64_t cycles) {
// A verified END may take time to drain the hardware. Stop the network
// watchdog before that bounded drain, not before authentication.
atomic_store_explicit(&matInputEnded,true,memory_order_relaxed);
return matCallbacks.end(context,cycles);
}
static bool matInputTimedOut(void) {
if(!AUDIO_CODEC_IS_MAT(NegotiatedAudioCodec)||atomic_load_explicit(&matInputEnded,memory_order_relaxed))return false;
// The decoder can publish a newer timestamp while the receive thread runs.
// Load it before reading the clock so unsigned subtraction cannot underflow.
uint64_t last=atomic_load_explicit(&matLastAcceptedMs,memory_order_relaxed);
uint64_t now=PltGetMillis();
return now>=last && now-last>=2000;
}
static void failMatInput(void) { MatLivePlaybackFail(matLive); }
static void consumeAudio(char* data,int size) {
if(NegotiatedAudioCodec==AUDIO_CODEC_MAT_LIVE) {
int result=MatLivePlaybackPush(matLive,(unsigned char*)data,(size_t)(size>0?size:0));
if(result>=0)atomic_store_explicit(&matLastAcceptedMs,PltGetMillis(),memory_order_relaxed);
if(result<0&&!matPlaybackFailedReported){matPlaybackFailedReported=true;Limelog("Live MAT authentication/sequence/source failed\n");ListenerCallbacks.connectionTerminated(-1);}
else if(result==2&&!matPlaybackCompleteReported){matPlaybackCompleteReported=true;Limelog("Live MAT authenticated EOS and renderer drain completed\n");}
return;
}
AudioCallbacks.decodeAndPlaySample(data,size);
}

static SOCKET rtpSocket = INVALID_SOCKET;

Expand Down Expand Up @@ -72,9 +107,12 @@ static void AudioPingThreadProc(void* context) {

// Initialize the audio stream and start
int initializeAudioStream(void) {
// RTSP can still select Opus for a MAT preference. Final receive parameters
// are set in startAudioStream(), before either queue worker starts.
LbqInitializeLinkedBlockingQueue(&packetQueue, 30);
RtpaInitializeQueue(&rtpAudioQueue);
lastSeq = 0;
matPlaybackFailedReported=matPlaybackCompleteReported=false;
receivedDataFromPeer = false;
pingThreadStarted = false;
firstReceiveTime = 0;
Expand Down Expand Up @@ -107,6 +145,9 @@ int notifyAudioPortNegotiationComplete(void) {

// We may receive audio before our threads are started, but that's okay. We'll
// drop the first 1 second of audio packets to catch up with the backlog.
// MAT starts the authentic ping only AFTER its receiver/consumer are ready.
// The host waits for it, so no startup data is buffered or discarded.
if(AUDIO_CODEC_IS_MAT(NegotiatedAudioCodec))return 0;
int err = PltCreateThread("AudioPing", AudioPingThreadProc, NULL, &udpPingThread);
if (err != 0) {
return err;
Expand All @@ -131,6 +172,7 @@ static void freePacketList(PLINKED_BLOCKING_QUEUE_ENTRY entry) {

// Tear down the audio stream once we're done with it
void destroyAudioStream(void) {
MatLivePlaybackClose(matLive);matLive=NULL;
if (rtpSocket != INVALID_SOCKET) {
if (pingThreadStarted) {
PltInterruptThread(&udpPingThread);
Expand All @@ -157,6 +199,10 @@ static bool queuePacketToLbq(PQUEUED_AUDIO_PACKET* packet) {
}
else if (err == LBQ_BOUND_EXCEEDED) {
Limelog("Audio packet queue overflow\n");
if(AUDIO_CODEC_IS_MAT(NegotiatedAudioCodec)){
ListenerCallbacks.connectionTerminated(-1);
return false; // Don't flush a loss-intolerant stream or silently resume.
}

// The audio queue is full, so free all existing items and try again
freePacketList(LbqFlushQueueItems(&packetQueue));
Expand All @@ -171,18 +217,19 @@ static void decodeInputData(PQUEUED_AUDIO_PACKET packet) {
// packet. Trigger packet loss concealment logic in libopus by
// invoking the decoder with a NULL buffer.
if (packet->header.size == 0) {
AudioCallbacks.decodeAndPlaySample(NULL, 0);
consumeAudio(NULL, 0);
return;
}

PRTP_PACKET rtp = (PRTP_PACKET)&packet->data[0];
if (lastSeq != 0 && (unsigned short)(lastSeq + 1) != rtp->sequenceNumber) {
Limelog("Network dropped audio data (expected %d, but received %d)\n", lastSeq + 1, rtp->sequenceNumber);
if(AUDIO_CODEC_IS_MAT(NegotiatedAudioCodec)){failMatInput();consumeAudio(NULL,0);return;}
}

lastSeq = rtp->sequenceNumber;

if (AudioEncryptionEnabled) {
if (AudioEncryptionEnabled && NegotiatedAudioCodec!=AUDIO_CODEC_MAT_LIVE) {
// We must have room for the AES padding which may be written to the buffer
unsigned char decryptedOpusData[ROUND_TO_PKCS7_PADDED_LEN(MAX_PACKET_SIZE)];
unsigned char iv[16] = { 0 };
Expand All @@ -203,6 +250,7 @@ static void decodeInputData(PQUEUED_AUDIO_PACKET packet) {
(unsigned char*)(rtp + 1), dataLength,
decryptedOpusData, &dataLength)) {
Limelog("Failed to decrypt audio packet (sequence number: %u)\n", rtp->sequenceNumber);
if(AUDIO_CODEC_IS_MAT(NegotiatedAudioCodec)){failMatInput();consumeAudio(NULL,0);}
LC_ASSERT_VT(false);
return;
}
Expand All @@ -222,7 +270,7 @@ static void decodeInputData(PQUEUED_AUDIO_PACKET packet) {
}
#endif

AudioCallbacks.decodeAndPlaySample((char*)decryptedOpusData, dataLength);
consumeAudio((char*)decryptedOpusData, dataLength);
}
else {
#ifdef LC_DEBUG
Expand All @@ -239,7 +287,7 @@ static void decodeInputData(PQUEUED_AUDIO_PACKET packet) {
}
#endif

AudioCallbacks.decodeAndPlaySample((char*)(rtp + 1), packet->header.size - sizeof(*rtp));
consumeAudio((char*)(rtp + 1), packet->header.size - sizeof(*rtp));
}
}

Expand All @@ -251,8 +299,9 @@ static void AudioReceiveThreadProc(void* context) {
uint32_t packetsToDrop;
int waitingForAudioMs;


packet = NULL;
packetsToDrop = 500 / AudioPacketDuration;
packetsToDrop = AUDIO_CODEC_IS_MAT(NegotiatedAudioCodec)?0:500 / AudioPacketDuration;

if (setNonFatalRecvTimeoutMs(rtpSocket, UDP_RECV_POLL_TIMEOUT_MS) < 0) {
// SO_RCVTIMEO failed, so use select() to wait
Expand All @@ -265,6 +314,9 @@ static void AudioReceiveThreadProc(void* context) {

waitingForAudioMs = 0;
while (!PltIsThreadInterrupted(&receiveThread)) {
if(matInputTimedOut()) {
Limelog("MAT validated input timed out (no verified EOS)\n");ListenerCallbacks.connectionTerminated(-1);break;
}
if (packet == NULL) {
packet = (PQUEUED_AUDIO_PACKET)malloc(sizeof(*packet));
if (packet == NULL) {
Expand All @@ -282,7 +334,6 @@ static void AudioReceiveThreadProc(void* context) {
}
else if (packet->header.size == 0) {
// Receive timed out; try again

if (!receivedDataFromPeer) {
waitingForAudioMs += UDP_RECV_POLL_TIMEOUT_MS;
}
Expand All @@ -305,7 +356,7 @@ static void AudioReceiveThreadProc(void* context) {
receivedDataFromPeer = true;
Limelog("Received first audio packet after %d ms\n", waitingForAudioMs);

if (firstReceiveTime != 0) {
if (firstReceiveTime != 0 && !AUDIO_CODEC_IS_MAT(NegotiatedAudioCodec)) {
// XXX firstReceiveTime is never set here...
// We're already dropping 500ms of audio so this probably doesn't matter
packetsToDrop += (uint32_t)(PltGetMillis() - firstReceiveTime) / AudioPacketDuration;
Expand Down Expand Up @@ -425,13 +476,18 @@ void stopAudioStream(void) {
PltJoinThread(&decoderThread);
}

MatLivePlaybackClose(matLive);matLive=NULL;
AudioCallbacks.cleanup();
}

int startAudioStream(void* audioContext, int arFlags) {
int err;
OPUS_MULTISTREAM_CONFIGURATION chosenConfig;

LC_ASSERT(packetQueue.currentSize == 0);
packetQueue.sizeBound = AUDIO_CODEC_IS_MAT(NegotiatedAudioCodec) ? 512 : 30;
lastSeq = AUDIO_CODEC_IS_MAT(NegotiatedAudioCodec) ? 3 : 0;

if (HighQualitySurroundEnabled) {
LC_ASSERT(HighQualitySurroundSupported);
LC_ASSERT(HighQualityOpusConfig.channelCount != 0);
Expand All @@ -444,13 +500,22 @@ int startAudioStream(void* audioContext, int arFlags) {
chosenConfig = NormalQualityOpusConfig;
}

chosenConfig.samplesPerFrame = 48 * AudioPacketDuration;
chosenConfig.samplesPerFrame = AUDIO_CODEC_IS_MAT(NegotiatedAudioCodec)?960:48 * AudioPacketDuration;

err = AudioCallbacks.init(StreamConfig.audioConfiguration, &chosenConfig, audioContext, arFlags);
if (err != 0) {
return err;
}

LI_MAT_PLAYBACK_CALLBACKS callbacks=matCallbacks;callbacks.end=matCallbacks.end?matEnd:NULL;
if(AUDIO_CODEC_IS_MAT(NegotiatedAudioCodec)) {
atomic_store_explicit(&matInputEnded,false,memory_order_relaxed);
atomic_store_explicit(&matLastAcceptedMs,PltGetMillis(),memory_order_relaxed);
}
if(NegotiatedAudioCodec==AUDIO_CODEC_MAT_LIVE) {
matLive=MatLivePlaybackOpen(&MatLiveProfile,(const uint8_t*)StreamConfig.remoteInputAesKey,&callbacks);
if(!matLive){AudioCallbacks.cleanup();return -1;}
}
AudioCallbacks.start();

err = PltCreateThread("AudioRecv", AudioReceiveThreadProc, NULL, &receiveThread);
Expand All @@ -473,6 +538,11 @@ int startAudioStream(void* audioContext, int arFlags) {
}
}

if(AUDIO_CODEC_IS_MAT(NegotiatedAudioCodec) && !pingThreadStarted){
err=PltCreateThread("AudioPing",AudioPingThreadProc,NULL,&udpPingThread);
if(err!=0){stopAudioStream();return err;}
pingThreadStarted=true;
}
return 0;
}

Expand Down
20 changes: 20 additions & 0 deletions src/Connection.c
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ int AudioPacketDuration;
bool AudioEncryptionEnabled;
bool ReferenceFrameInvalidationSupported;
int NegotiatedAudioCodec;
LI_MAT_LIVE_PROFILE MatLiveProfile;
int NegotiatedAudioBitrate;
int NegotiatedDynamicHdrFormat;
int NegotiatedDynamicHdrFallback;
Expand Down Expand Up @@ -331,6 +332,7 @@ int LiStartConnection(PSERVER_INFORMATION serverInfo, PSTREAM_CONFIGURATION stre
// LiGetNegotiatedAudioCodec() to check what was actually selected.
NegotiatedAudioCodec = AUDIO_CODEC_OPUS;
NegotiatedAudioBitrate = 0;
memset(&MatLiveProfile,0,sizeof(MatLiveProfile));
switch (StreamConfig.audioCodec) {
case AUDIO_CODEC_OPUS:
// Default; nothing to do.
Expand All @@ -355,6 +357,24 @@ int LiStartConnection(PSERVER_INFORMATION serverInfo, PSTREAM_CONFIGURATION stre
: (StreamConfig.audioCodec == AUDIO_CODEC_AC3 ? 640000 : 384000);
}
break;
case AUDIO_CODEC_MAT_AUTO:
if (!IS_SUNSHINE() || StreamConfig.controlOnly) {
Limelog("MAT preference unavailable for this session; selecting Opus\n");
break;
}
NegotiatedAudioCodec = AUDIO_CODEC_MAT_LIVE;
break;
case AUDIO_CODEC_MAT_LIVE:
if (!IS_SUNSHINE() || StreamConfig.controlOnly) {
Limelog("Live MAT requires a Sunshine audio session\n");
err = -1; goto Cleanup;
}
NegotiatedAudioCodec = AUDIO_CODEC_MAT_LIVE;
break;
case 4: // Retired finite diagnostic profiles; never silently reinterpret.
case 5:
Limelog("Finite MAT codecs are not supported; request live MAT or Opus\n");
err = -1; goto Cleanup;
case AUDIO_CODEC_PCM_S16:
if (!IS_SUNSHINE()) {
Limelog("PCM passthrough is only supported by Sunshine; falling back to Opus\n");
Expand Down
4 changes: 3 additions & 1 deletion src/Limelight-internal.h
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
#pragma once

#include "Platform.h"
#include "MatLiveProfile.h"
extern LI_MAT_LIVE_PROFILE MatLiveProfile;

#include "Limelight.h"
#include "PlatformSockets.h"
#include "PlatformThreads.h"
Expand Down
14 changes: 14 additions & 0 deletions src/Limelight.h
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,20 @@ typedef struct _DECODE_UNIT {
// delay, no AVR decoder); biggest bandwidth (~1.5 Mbps stereo, ~4.6 Mbps 5.1).
// LAN-only in practice.
#define AUDIO_CODEC_PCM_S16 3
// Values 4 and 5 are retired finite diagnostic profiles; they are rejected.
#define AUDIO_CODEC_MAT_AUTO 6 // Local preference: select codec 7 or 0 before ANNOUNCE; never a wire codec.
#define AUDIO_CODEC_MAT_LIVE 7
#define AUDIO_CODEC_IS_MAT(c) ((c)==AUDIO_CODEC_MAT_LIVE)

/* Registered from audio init, cleared after audio threads join. Return 0 to fail
* the loss-intolerant live session. End is authenticated EOS; consumers complete
* any bounded drain before returning success. No PCM/encoded callback. */
typedef struct {
int (*cycle)(void*, const unsigned char*, int, uint64_t, uint64_t, uint64_t, uint64_t);
int (*end)(void*, uint64_t);
void* context;
} LI_MAT_PLAYBACK_CALLBACKS;
void LiSetMatPlaybackCallbacks(const LI_MAT_PLAYBACK_CALLBACKS* callbacks);

// Bitmasks for STREAM_CONFIGURATION.supportedAudioCodecs (and reserved for a
// future server-side capability advertisement). The set bit indicates that the
Expand Down
Loading
Loading