diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0d828cad1..bee920495 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/CMakeLists.txt b/CMakeLists.txt index 4e060d870..abeff0e90 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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_compile_definitions(mat-live-rtp-tests PRIVATE + $) + target_link_libraries(mat-live-rtp-tests PRIVATE moonlight-common-c) + 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_compile_definitions(audio-codec-selection-tests PRIVATE + $) + target_compile_options(audio-codec-selection-tests PRIVATE + $) + 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) diff --git a/docs/MAT-HANDOFF.md b/docs/MAT-HANDOFF.md new file mode 100644 index 000000000..e18e9deeb --- /dev/null +++ b/docs/MAT-HANDOFF.md @@ -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. diff --git a/src/AudioStream.c b/src/AudioStream.c index 8f7bc4789..e48dc4fed 100644 --- a/src/AudioStream.c +++ b/src/AudioStream.c @@ -1,4 +1,39 @@ #include "Limelight-internal.h" +#include "MatLivePlayback.h" +#include +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; @@ -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; @@ -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; @@ -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); @@ -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)); @@ -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 }; @@ -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; } @@ -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 @@ -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)); } } @@ -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 @@ -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) { @@ -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; } @@ -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; @@ -425,6 +476,7 @@ void stopAudioStream(void) { PltJoinThread(&decoderThread); } + MatLivePlaybackClose(matLive);matLive=NULL; AudioCallbacks.cleanup(); } @@ -432,6 +484,10 @@ 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); @@ -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); @@ -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; } diff --git a/src/Connection.c b/src/Connection.c index 2c1d4baee..a19d57603 100644 --- a/src/Connection.c +++ b/src/Connection.c @@ -27,6 +27,7 @@ int AudioPacketDuration; bool AudioEncryptionEnabled; bool ReferenceFrameInvalidationSupported; int NegotiatedAudioCodec; +LI_MAT_LIVE_PROFILE MatLiveProfile; int NegotiatedAudioBitrate; int NegotiatedDynamicHdrFormat; int NegotiatedDynamicHdrFallback; @@ -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. @@ -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"); diff --git a/src/Limelight-internal.h b/src/Limelight-internal.h index c39f34f74..f2b35a86d 100644 --- a/src/Limelight-internal.h +++ b/src/Limelight-internal.h @@ -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" diff --git a/src/Limelight.h b/src/Limelight.h index bdabb26e5..9d357f3cc 100644 --- a/src/Limelight.h +++ b/src/Limelight.h @@ -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 diff --git a/src/MatDepacketizer.c b/src/MatDepacketizer.c new file mode 100644 index 000000000..788041b2c --- /dev/null +++ b/src/MatDepacketizer.c @@ -0,0 +1,47 @@ +#include "MatDepacketizer.h" +#include "MatWire.h" + +void MatDepacketizerInit(MAT_DEPACKETIZER* s,MAT_WRITE_CYCLE write,void* context) { + memset(s,0,sizeof(*s));s->write=write;s->context=context;s->stream_hash=2166136261u; +} +static int fail(MAT_DEPACKETIZER* s) { s->failed=1;return -1; } +int MatDepacketizerPush(MAT_DEPACKETIZER* s,const uint8_t* p,size_t n) { + if(s->failed) return -1; + if(!p || n!=MAT_WIRE_PACKET || memcmp(p,"SMAT",4) || p[4]!=1 || + p[6]!=MAT_WIRE_HEADER || p[7] || mat_get32(p+8)!=1 || mat_get32(p+12)!=1 || + !mat_get64(p+16) || !mat_get64(p+24) || mat_get32(p+48)!=48000 || + mat_get32(p+76) || mat_get32(p+72)!=mat_packet_hash(p)) return fail(s); + uint64_t frame=mat_get64(p+32); + if(frame>UINT64_MAX/960 || mat_get64(p+40)!=frame*960) return fail(s); + if(s->started && (s->epoch!=mat_get64(p+16) || s->domain!=mat_get64(p+24))) return fail(s); + if(!s->started) { s->started=1;s->epoch=mat_get64(p+16);s->domain=mat_get64(p+24); } + if(p[5]==MAT_WIRE_SYNC || p[5]==MAT_WIRE_END) { + if(mat_get32(p+52) || mat_get32(p+56) || mat_get32(p+60) || mat_get32(p+64)) return fail(s); + for(unsigned i=MAT_WIRE_HEADER;iended || s->next_frame || s->mask || frame || mat_get32(p+68)) return fail(s); + return 0; + } + if(!frame || frame!=s->next_frame || s->mask || mat_get32(p+68)!=s->stream_hash) return fail(s); + s->ended=1;return 2; // Redundant END packets are deliberately idempotent. + } + if(p[5]!=MAT_WIRE_DATA || s->ended || frame!=s->next_frame || + mat_get32(p+52)!=960 || mat_get32(p+56)!=MAT_WIRE_CYCLE || + p[61] || p[62]!=MAT_WIRE_FRAGMENTS || p[63] || p[60]>=MAT_WIRE_FRAGMENTS || + mat_get32(p+64)!=MAT_WIRE_BODY) return fail(s); + unsigned index=p[60];uint64_t bit=UINT64_C(1)<mask && s->frame_hash!=mat_get32(p+68)) return fail(s); + s->frame_hash=mat_get32(p+68); + if(s->mask&bit) { + if(memcmp(s->cycle+index*MAT_WIRE_BODY,p+MAT_WIRE_HEADER,MAT_WIRE_BODY)) return fail(s); + return 0; + } + memcpy(s->cycle+index*MAT_WIRE_BODY,p+MAT_WIRE_HEADER,MAT_WIRE_BODY);s->mask|=bit; + if(s->mask!=((UINT64_C(1)<cycle,sizeof(s->cycle))!=s->frame_hash || + memcmp(s->cycle,"\x72\xf8\x1f\x4e",4) || (s->cycle[4]&31)!=0x16 || (s->cycle[4]&0x80)) return fail(s); + unsigned pd=(unsigned)s->cycle[6]|(unsigned)s->cycle[7]<<8; + if(pd<2 || pd>MAT_WIRE_CYCLE-8 || (pd&1) || !s->write || !s->write(s->context,s->cycle,sizeof(s->cycle))) return fail(s); + s->stream_hash=mat_hash_update(s->stream_hash,s->cycle,sizeof(s->cycle)); + s->mask=0;++s->next_frame;return 1; +} diff --git a/src/MatDepacketizer.h b/src/MatDepacketizer.h new file mode 100644 index 000000000..6d05b3b31 --- /dev/null +++ b/src/MatDepacketizer.h @@ -0,0 +1,25 @@ +#pragma once +#include +#include +#ifdef __cplusplus +extern "C" { +#endif +/* Live carrier reassembly AFTER existing RTP reorder/FEC + authentication. One bounded + * carrier buffer; in-cycle reorder/identical duplicates allowed. Missing data, + * epoch changes, cross-cycle gaps and missing EOS are terminal, never concealed. + * Callback must copy/write synchronously; zero return means consumer failure. */ +typedef int (*MAT_WRITE_CYCLE)(void*,const uint8_t*,size_t); +typedef struct { + uint64_t epoch,domain,next_frame,mask; + uint32_t frame_hash,stream_hash; + unsigned started,ended,failed; + MAT_WRITE_CYCLE write; + void* context; + uint8_t cycle[61440]; +} MAT_DEPACKETIZER; +void MatDepacketizerInit(MAT_DEPACKETIZER*,MAT_WRITE_CYCLE,void*); +/* -1 sticky failure; 0 accepted, 1 whole cycle written, 2 complete EOS. */ +int MatDepacketizerPush(MAT_DEPACKETIZER*,const uint8_t*,size_t); +#ifdef __cplusplus +} +#endif diff --git a/src/MatLiveCrypto.h b/src/MatLiveCrypto.h new file mode 100644 index 000000000..3d5bdbab5 --- /dev/null +++ b/src/MatLiveCrypto.h @@ -0,0 +1,97 @@ +#pragma once +#include "MatWire.h" +#if defined(USE_MBEDTLS) || defined(USE_PSA_CRYPTO) +#include +#else +#include +#include +#endif +#define MAT_LIVE_HEADER 32u +#define MAT_LIVE_TAG 16u +#define MAT_LIVE_PACKET (MAT_LIVE_HEADER+MAT_WIRE_PACKET+MAT_LIVE_TAG) +typedef struct { uint8_t key[16];uint64_t epoch,domain; } MAT_LIVE_CIPHER; +static inline void MatLiveWipe(void* p,size_t n) { volatile uint8_t* b=(volatile uint8_t*)p;while(n--)*b++=0; } +static inline int MatLiveHmac(const uint8_t* key,size_t size,const uint8_t* data,size_t length,uint8_t out[32]) { +#if defined(USE_MBEDTLS) || defined(USE_PSA_CRYPTO) + psa_key_attributes_t a=PSA_KEY_ATTRIBUTES_INIT;psa_key_id_t id=0;size_t written=0; + psa_set_key_type(&a,PSA_KEY_TYPE_HMAC);psa_set_key_bits(&a,size*8); + psa_set_key_usage_flags(&a,PSA_KEY_USAGE_SIGN_MESSAGE);psa_set_key_algorithm(&a,PSA_ALG_HMAC(PSA_ALG_SHA_256)); + if(psa_crypto_init()!=PSA_SUCCESS || psa_import_key(&a,key,size,&id)!=PSA_SUCCESS)return 0; + psa_status_t result=psa_mac_compute(id,PSA_ALG_HMAC(PSA_ALG_SHA_256),data,length,out,32,&written); + psa_destroy_key(id);return result==PSA_SUCCESS && written==32; +#else + unsigned written=0; + return HMAC(EVP_sha256(),key,(int)size,data,length,out,&written)!=NULL && written==32; +#endif +} +/* RFC5869, one expansion block. A separate key per protocol/epoch/domain prevents + * overlap with GameStream control/audio keys; no RTP16 value is used as a nonce. */ +static inline int MatLiveCipherInit(MAT_LIVE_CIPHER* c,const uint8_t* key,size_t length,uint64_t epoch,uint64_t domain) { + static const uint8_t salt[]="Sunshine MAT live v1"; + static const uint8_t label[]="MATL AES128 GCM"; + uint8_t prk[32],expanded[32],info[sizeof(label)-1+17];int okay=0; + memset(c,0,sizeof(*c)); + if(!key||length!=16||!epoch||epoch>INT64_MAX||!domain||domain>INT64_MAX)return 0; + memcpy(info,label,sizeof(label)-1);mat_put64(info+sizeof(label)-1,epoch); + mat_put64(info+sizeof(label)-1+8,domain);info[sizeof(info)-1]=1; + if(MatLiveHmac(salt,sizeof(salt)-1,key,length,prk) && MatLiveHmac(prk,32,info,sizeof(info),expanded)) { + memcpy(c->key,expanded,16);c->epoch=epoch;c->domain=domain;okay=1; + } + MatLiveWipe(prk,sizeof(prk));MatLiveWipe(expanded,sizeof(expanded));return okay; +} +static inline void MatLiveNonce(uint8_t nonce[12],uint64_t packet) { + memcpy(nonce,"MATL",4);mat_put64(nonce+4,packet); +} +static inline int MatLiveSeal(const MAT_LIVE_CIPHER* c,uint64_t sequence,const uint8_t plain[MAT_WIRE_PACKET],uint8_t wire[MAT_LIVE_PACKET]) { + uint8_t nonce[12];MatLiveNonce(nonce,sequence); + if(!c||!c->epoch||sequence>INT64_MAX)return 0; + memset(wire,0,MAT_LIVE_HEADER);memcpy(wire,"MLIV",4);wire[4]=1;wire[5]=MAT_LIVE_HEADER; + mat_put64(wire+8,c->epoch);mat_put64(wire+16,sequence);mat_put32(wire+24,MAT_WIRE_PACKET); +#if defined(USE_MBEDTLS) || defined(USE_PSA_CRYPTO) + psa_key_attributes_t a=PSA_KEY_ATTRIBUTES_INIT;psa_key_id_t id=0;size_t written=0; + psa_set_key_type(&a,PSA_KEY_TYPE_AES);psa_set_key_bits(&a,128);psa_set_key_usage_flags(&a,PSA_KEY_USAGE_ENCRYPT); + psa_set_key_algorithm(&a,PSA_ALG_GCM); + if(psa_crypto_init()!=PSA_SUCCESS||psa_import_key(&a,c->key,16,&id)!=PSA_SUCCESS)return 0; + psa_status_t result=psa_aead_encrypt(id,PSA_ALG_GCM,nonce,12,wire,MAT_LIVE_HEADER,plain,MAT_WIRE_PACKET, + wire+MAT_LIVE_HEADER,MAT_WIRE_PACKET+MAT_LIVE_TAG,&written); + psa_destroy_key(id);return result==PSA_SUCCESS && written==MAT_WIRE_PACKET+MAT_LIVE_TAG; +#else + EVP_CIPHER_CTX* ctx=EVP_CIPHER_CTX_new();int n=0,tail=0,okay=0; + if(ctx && EVP_EncryptInit_ex(ctx,EVP_aes_128_gcm(),NULL,NULL,NULL)==1 && + EVP_CIPHER_CTX_ctrl(ctx,EVP_CTRL_GCM_SET_IVLEN,12,NULL)==1 && + EVP_EncryptInit_ex(ctx,NULL,NULL,c->key,nonce)==1 && + EVP_EncryptUpdate(ctx,NULL,&n,wire,MAT_LIVE_HEADER)==1 && + EVP_EncryptUpdate(ctx,wire+MAT_LIVE_HEADER,&n,plain,MAT_WIRE_PACKET)==1 && n==MAT_WIRE_PACKET && + EVP_EncryptFinal_ex(ctx,wire+MAT_LIVE_HEADER+n,&tail)==1 && tail==0 && + EVP_CIPHER_CTX_ctrl(ctx,EVP_CTRL_GCM_GET_TAG,MAT_LIVE_TAG,wire+MAT_LIVE_HEADER+MAT_WIRE_PACKET)==1)okay=1; + EVP_CIPHER_CTX_free(ctx);return okay; +#endif +} +/* The caller must not expose plain before authentication succeeds. */ +static inline int MatLiveOpenPacket(const MAT_LIVE_CIPHER* c,uint64_t sequence,const uint8_t* wire,size_t length,uint8_t plain[MAT_WIRE_PACKET]) { + uint8_t nonce[12];MatLiveNonce(nonce,sequence); + if(!c||!wire||length!=MAT_LIVE_PACKET||memcmp(wire,"MLIV",4)||wire[4]!=1||wire[5]!=MAT_LIVE_HEADER|| + wire[6]||wire[7]||mat_get64(wire+8)!=c->epoch||mat_get64(wire+16)!=sequence|| + sequence>INT64_MAX||mat_get32(wire+24)!=MAT_WIRE_PACKET||mat_get32(wire+28))return 0; +#if defined(USE_MBEDTLS) || defined(USE_PSA_CRYPTO) + psa_key_attributes_t a=PSA_KEY_ATTRIBUTES_INIT;psa_key_id_t id=0;size_t written=0; + psa_set_key_type(&a,PSA_KEY_TYPE_AES);psa_set_key_bits(&a,128);psa_set_key_usage_flags(&a,PSA_KEY_USAGE_DECRYPT); + psa_set_key_algorithm(&a,PSA_ALG_GCM); + if(psa_crypto_init()!=PSA_SUCCESS||psa_import_key(&a,c->key,16,&id)!=PSA_SUCCESS)return 0; + psa_status_t result=psa_aead_decrypt(id,PSA_ALG_GCM,nonce,12,wire,MAT_LIVE_HEADER,wire+MAT_LIVE_HEADER, + MAT_WIRE_PACKET+MAT_LIVE_TAG,plain,MAT_WIRE_PACKET,&written); + psa_destroy_key(id); + if(result==PSA_SUCCESS&&written==MAT_WIRE_PACKET)return 1; +#else + EVP_CIPHER_CTX* ctx=EVP_CIPHER_CTX_new();int n=0,tail=0,okay=0; + if(ctx && EVP_DecryptInit_ex(ctx,EVP_aes_128_gcm(),NULL,NULL,NULL)==1 && + EVP_CIPHER_CTX_ctrl(ctx,EVP_CTRL_GCM_SET_IVLEN,12,NULL)==1 && + EVP_DecryptInit_ex(ctx,NULL,NULL,c->key,nonce)==1 && + EVP_DecryptUpdate(ctx,NULL,&n,wire,MAT_LIVE_HEADER)==1 && + EVP_DecryptUpdate(ctx,plain,&n,wire+MAT_LIVE_HEADER,MAT_WIRE_PACKET)==1 && n==MAT_WIRE_PACKET && + EVP_CIPHER_CTX_ctrl(ctx,EVP_CTRL_GCM_SET_TAG,MAT_LIVE_TAG,(void*)(wire+MAT_LIVE_HEADER+MAT_WIRE_PACKET))==1 && + EVP_DecryptFinal_ex(ctx,plain+n,&tail)==1 && tail==0)okay=1; + EVP_CIPHER_CTX_free(ctx);if(okay)return 1; +#endif + MatLiveWipe(plain,MAT_WIRE_PACKET);return 0; +} diff --git a/src/MatLivePlayback.c b/src/MatLivePlayback.c new file mode 100644 index 000000000..35640a8d9 --- /dev/null +++ b/src/MatLivePlayback.c @@ -0,0 +1,35 @@ +#include "MatLivePlayback.h" +#include "MatLiveCrypto.h" +#include "MatReceiver.h" +#include +struct MAT_LIVE_PLAYBACK { + MAT_RECEIVER receiver;MAT_LIVE_CIPHER cipher;LI_MAT_PLAYBACK_CALLBACKS callbacks; + uint64_t nextPacket;int failed; +}; +static int cycle(void* context,const MAT_CYCLE_INFO* info,const uint8_t* bytes,size_t n) { + MAT_LIVE_PLAYBACK* s=context; + return s->callbacks.cycle(s->callbacks.context,bytes,(int)n,info->epoch,info->domain,info->index,info->pts48k); +} +static int end(void* context,uint64_t count) { + MAT_LIVE_PLAYBACK* s=context;return s->callbacks.end(s->callbacks.context,count); +} +MAT_LIVE_PLAYBACK* MatLivePlaybackOpen(const LI_MAT_LIVE_PROFILE* p,const uint8_t key[16],const LI_MAT_PLAYBACK_CALLBACKS* callbacks) { + if(!p||!callbacks||!callbacks->cycle||!callbacks->end)return NULL; + MAT_LIVE_PLAYBACK* s=calloc(1,sizeof(*s));if(!s)return NULL; + if(!MatLiveCipherInit(&s->cipher,key,16,p->epoch,p->domain)){free(s);return NULL;} + s->callbacks=*callbacks;s->nextPacket=4; /* four authenticated transport SYNC packets precede RTP data */ + MatReceiverInit(&s->receiver,cycle,end,s);return s; +} +int MatLivePlaybackPush(MAT_LIVE_PLAYBACK* s,const uint8_t* wire,size_t length) { + uint8_t plain[MAT_WIRE_PACKET]; + if(!s||s->failed)return -1; + if(!MatLiveOpenPacket(&s->cipher,s->nextPacket,wire,length,plain) || + mat_get64(plain+16)!=s->cipher.epoch||mat_get64(plain+24)!=s->cipher.domain) { + MatLivePlaybackFail(s);return -1; + } + int result=MatReceiverPush(&s->receiver,plain,sizeof(plain));MatLiveWipe(plain,sizeof(plain)); + if(result<0){MatLivePlaybackFail(s);return -1;} + ++s->nextPacket;return result; +} +void MatLivePlaybackFail(MAT_LIVE_PLAYBACK* s){if(s){s->failed=1;MatReceiverAbort(&s->receiver);}} +void MatLivePlaybackClose(MAT_LIVE_PLAYBACK* s){if(s){MatLiveWipe(s,sizeof(*s));free(s);}} diff --git a/src/MatLivePlayback.h b/src/MatLivePlayback.h new file mode 100644 index 000000000..5b57fa18c --- /dev/null +++ b/src/MatLivePlayback.h @@ -0,0 +1,14 @@ +#pragma once +#include "Limelight.h" +#include "MatLiveProfile.h" +#ifdef __cplusplus +extern "C" { +#endif +typedef struct MAT_LIVE_PLAYBACK MAT_LIVE_PLAYBACK; +MAT_LIVE_PLAYBACK* MatLivePlaybackOpen(const LI_MAT_LIVE_PROFILE*,const uint8_t key[16],const LI_MAT_PLAYBACK_CALLBACKS*); +int MatLivePlaybackPush(MAT_LIVE_PLAYBACK*,const uint8_t*,size_t); +void MatLivePlaybackFail(MAT_LIVE_PLAYBACK*); +void MatLivePlaybackClose(MAT_LIVE_PLAYBACK*); +#ifdef __cplusplus +} +#endif diff --git a/src/MatLiveProfile.h b/src/MatLiveProfile.h new file mode 100644 index 000000000..de541430d --- /dev/null +++ b/src/MatLiveProfile.h @@ -0,0 +1,34 @@ +#pragma once +#include "MatProfile.h" +#define MAT_LIVE_CODEC 7 +#define MAT_LIVE_NAME "mat-live-v1" +#define MAT_LIVE_CRYPTO "aes128-gcm-hkdf-sha256" +typedef struct { uint64_t epoch,domain; } LI_MAT_LIVE_PROFILE; +static inline int MatLiveDecimal(const char* text,uint64_t* value) { + uint64_t n=0; + if(!text || !*text || *text=='0')return 0; + for(const char* p=text;*p;p++) { + if(*p<'0'||*p>'9'||n>((uint64_t)INT64_MAX-(unsigned)(*p-'0'))/10)return 0; + n=n*10+(unsigned)(*p-'0'); + } + *value=n;return n!=0; +} +static inline int MatLiveProfileParse(LI_MAT_LIVE_PROFILE* out,const char* sdp) { + char version[8],carrier[48],clock[16],crypto[48],epoch[24],domain[24]; + memset(out,0,sizeof(*out)); + if(!sdp)return 0; + return MatSdpValue(sdp,"x-ss-audio.mat-live",version,sizeof(version))==1 && !strcmp(version,"1") && + MatSdpValue(sdp,"x-ss-audio.mat-live-carrier",carrier,sizeof(carrier))==1 && !strcmp(carrier,MAT_CARRIER) && + MatSdpValue(sdp,"x-ss-audio.mat-live-clock",clock,sizeof(clock))==1 && !strcmp(clock,MAT_CLOCK) && + MatSdpValue(sdp,"x-ss-audio.mat-live-crypto",crypto,sizeof(crypto))==1 && !strcmp(crypto,MAT_LIVE_CRYPTO) && + MatSdpValue(sdp,"x-ss-audio.mat-live-epoch",epoch,sizeof(epoch))==1 && MatLiveDecimal(epoch,&out->epoch) && + MatSdpValue(sdp,"x-ss-audio.mat-live-domain",domain,sizeof(domain))==1 && MatLiveDecimal(domain,&out->domain); +} +static inline int MatLiveProfileConfirm(const LI_MAT_LIVE_PROFILE* expected,const char* codec, + const char* carrier,const char* clock,const char* crypto,const char* epoch,const char* domain) { + uint64_t e=0,d=0; + return expected && codec && !strcmp(codec,MAT_LIVE_NAME) && + carrier && !strcmp(carrier,MAT_CARRIER) && clock && !strcmp(clock,MAT_CLOCK) && + crypto && !strcmp(crypto,MAT_LIVE_CRYPTO) && MatLiveDecimal(epoch,&e) && MatLiveDecimal(domain,&d) && + e==expected->epoch && d==expected->domain; +} diff --git a/src/MatProfile.h b/src/MatProfile.h new file mode 100644 index 000000000..0769e3480 --- /dev/null +++ b/src/MatProfile.h @@ -0,0 +1,19 @@ +#pragma once +#include +#include +#include +#define MAT_CARRIER "iec61937-192000-8-s16le" +#define MAT_CLOCK "48000" +/* Exact SDP attribute name, no duplicate values or truncation. */ +static inline int MatSdpValue(const char* sdp,const char* name,char* out,size_t cap) { + int found=0;size_t key=strlen(name);out[0]=0; + for(const char* p=sdp;p && *p;) { + const char* end=strchr(p,'\n');if(!end)end=p+strlen(p); + const char* trimmed=end;if(trimmed>p && trimmed[-1]=='\r')--trimmed; + if((size_t)(trimmed-p)>=key+3 && !memcmp(p,"a=",2) && !memcmp(p+2,name,key) && p[key+2]==':') { + size_t n=(size_t)(trimmed-p)-key-3;if(found || n>=cap)return -1; + memcpy(out,p+key+3,n);out[n]=0;found=1; + } + p=*end?end+1:end; + }return found; +} diff --git a/src/MatReceiver.c b/src/MatReceiver.c new file mode 100644 index 000000000..13890e7bd --- /dev/null +++ b/src/MatReceiver.c @@ -0,0 +1,37 @@ +#include "MatReceiver.h" +#include +static int fail(MAT_RECEIVER* s) { + s->failed = 1; s->depacketizer.failed = 1; return -1; +} +int MatCarrierIsValid(const uint8_t* p, size_t n) { + if (!p || n != 61440 || memcmp(p, "\x72\xf8\x1f\x4e", 4) || + (p[4] & 31) != 0x16 || (p[4] & 0x80)) return 0; + unsigned pd = (unsigned)p[6] | (unsigned)p[7] << 8; + return pd >= 2 && pd <= 61432 && !(pd & 1); +} +static int consume(void* context, const uint8_t* p, size_t n) { + MAT_RECEIVER* s = context; + MAT_DEPACKETIZER* d = &s->depacketizer; + MAT_CYCLE_INFO info = {d->epoch, d->domain, d->next_frame, d->next_frame * 960}; + return s->consume && s->consume(s->context, &info, p, n); +} +void MatReceiverInit(MAT_RECEIVER* s, MAT_CONSUME_CYCLE callback, + MAT_COMPLETE_STREAM complete, void* context) { + memset(s, 0, sizeof(*s)); + s->consume = callback; s->complete = complete; s->context = context; + MatDepacketizerInit(&s->depacketizer, consume, s); +} +static int complete(MAT_RECEIVER* s) { + if (!s->completed) { + if (s->complete && !s->complete(s->context, s->depacketizer.next_frame)) return fail(s); + s->completed = 1; + } + return 2; +} +int MatReceiverPush(MAT_RECEIVER* s, const uint8_t* p, size_t n) { + if (s->failed) return fail(s); + int result = MatDepacketizerPush(&s->depacketizer, p, n); + if (result < 0) return fail(s); + return result == 2 ? complete(s) : result; +} +void MatReceiverAbort(MAT_RECEIVER* s) { if (s) fail(s); } diff --git a/src/MatReceiver.h b/src/MatReceiver.h new file mode 100644 index 000000000..eb0643c52 --- /dev/null +++ b/src/MatReceiver.h @@ -0,0 +1,26 @@ +#pragma once +#include "MatDepacketizer.h" +#ifdef __cplusplus +extern "C" { +#endif +typedef struct { + uint64_t epoch, domain, index, pts48k; +} MAT_CYCLE_INFO; +/* Called synchronously. Copy bytes before returning; false is a terminal failure. + * complete means input EOS, NOT that the hardware has finished playing. */ +typedef int (*MAT_CONSUME_CYCLE)(void*, const MAT_CYCLE_INFO*, const uint8_t*, size_t); +typedef int (*MAT_COMPLETE_STREAM)(void*, uint64_t); +typedef struct { + MAT_DEPACKETIZER depacketizer; + MAT_CONSUME_CYCLE consume; + MAT_COMPLETE_STREAM complete; + void* context; + unsigned failed, completed; +} MAT_RECEIVER; +int MatCarrierIsValid(const uint8_t*, size_t); +void MatReceiverInit(MAT_RECEIVER*, MAT_CONSUME_CYCLE, MAT_COMPLETE_STREAM, void*); +int MatReceiverPush(MAT_RECEIVER*, const uint8_t*, size_t); +void MatReceiverAbort(MAT_RECEIVER*); +#ifdef __cplusplus +} +#endif diff --git a/src/MatWire.h b/src/MatWire.h new file mode 100644 index 000000000..621e30400 --- /dev/null +++ b/src/MatWire.h @@ -0,0 +1,47 @@ +#pragma once +/* Shared host/client experimental dump profile. NOT an E-AC3/Opus payload or a claim of + * Android MAT decode support. All multibyte fields are little endian; no packed + * structs, native alignment, pointers or untrusted allocation sizes on wire. */ +#include +#include +#include + +#define MAT_WIRE_HEADER 80u +#define MAT_WIRE_BODY 1024u +#define MAT_WIRE_PACKET (MAT_WIRE_HEADER + MAT_WIRE_BODY) +#define MAT_WIRE_CYCLE 61440u +#define MAT_WIRE_FRAGMENTS 60u +#define MAT_WIRE_DATA 1u +#define MAT_WIRE_SYNC 2u +#define MAT_WIRE_END 3u + +static inline uint32_t mat_get32(const uint8_t* p) { + return (uint32_t)p[0] | (uint32_t)p[1]<<8 | (uint32_t)p[2]<<16 | (uint32_t)p[3]<<24; +} +static inline uint64_t mat_get64(const uint8_t* p) { return mat_get32(p) | (uint64_t)mat_get32(p+4)<<32; } +static inline void mat_put32(uint8_t* p, uint32_t v) { for(unsigned i=0;i<4;i++) p[i]=(uint8_t)(v>>(i*8)); } +static inline void mat_put64(uint8_t* p, uint64_t v) { mat_put32(p,(uint32_t)v);mat_put32(p+4,(uint32_t)(v>>32)); } +static inline uint32_t mat_hash_update(uint32_t h, const uint8_t* p, size_t n) { + while(n--) { h^=*p++;h*=16777619u; } return h; +} +static inline uint32_t mat_hash(const uint8_t* p, size_t n) { return mat_hash_update(2166136261u,p,n); } +/* Accidental-corruption detection only: FNV and legacy AES-CBC are NOT MACs. */ +static inline uint32_t mat_packet_hash(const uint8_t* p) { + uint32_t h=2166136261u; + for(unsigned i=0;i=72 && i<76)?0:p[i];h*=16777619u; } + return h; +} +static inline void mat_wire_make(uint8_t* p, uint8_t kind, uint64_t epoch, + uint64_t domain, uint64_t frame, unsigned fragment, uint32_t frame_hash, + const uint8_t* cycle) { + memset(p,0,MAT_WIRE_PACKET);memcpy(p,"SMAT",4);p[4]=1;p[5]=kind;p[6]=MAT_WIRE_HEADER; + mat_put32(p+8,1);mat_put32(p+12,1); /* MAT IEC carrier, carrier-48000 clock */ + mat_put64(p+16,epoch);mat_put64(p+24,domain);mat_put64(p+32,frame); + mat_put64(p+40,frame*960);mat_put32(p+48,48000); + if(kind==MAT_WIRE_DATA) { + mat_put32(p+52,960);mat_put32(p+56,MAT_WIRE_CYCLE); + p[60]=(uint8_t)fragment;p[62]=MAT_WIRE_FRAGMENTS;mat_put32(p+64,MAT_WIRE_BODY); + memcpy(p+MAT_WIRE_HEADER,cycle+fragment*MAT_WIRE_BODY,MAT_WIRE_BODY); + } + mat_put32(p+68,frame_hash);mat_put32(p+72,mat_packet_hash(p)); +} diff --git a/src/Rtsp.h b/src/Rtsp.h index 267c4bc0d..bd005f9f4 100644 --- a/src/Rtsp.h +++ b/src/Rtsp.h @@ -13,6 +13,7 @@ #define SEQ_INVALID -1 #define FLAG_ALLOCATED_OPTION_FIELDS 0x1 +#define FLAG_DUPLICATE_OPTION 0x10 /* Parsed duplicate, even if legacy last-value-wins coalesces it. */ #define FLAG_ALLOCATED_MESSAGE_BUFFER 0x2 #define FLAG_ALLOCATED_OPTION_ITEMS 0x4 #define FLAG_ALLOCATED_PAYLOAD 0x8 diff --git a/src/RtspConnection.c b/src/RtspConnection.c index a517ae750..6678f7845 100644 --- a/src/RtspConnection.c +++ b/src/RtspConnection.c @@ -2,6 +2,26 @@ #include "Limelight-internal.h" #include "Rtsp.h" +/* MAT playback confirmation is security/format critical: duplicates fail even + * if they repeat the same value. Header names are ASCII case-insensitive. */ +static int matHeaderEqual(const char* a,const char* b) { + while(*a && *b) { + unsigned x=(unsigned char)*a++,y=(unsigned char)*b++; + if(x>='A' && x<='Z')x+=32; + if(y>='A' && y<='Z')y+=32; + if(x!=y)return 0; + } + return !*a && !*b; +} +static char* uniqueMatOption(POPTION_ITEM options,const char* name) { + char* value=NULL;unsigned count=0; + for(;options;options=options->next)if(matHeaderEqual(options->option,name)){ + if(options->flags & FLAG_DUPLICATE_OPTION)return NULL; + value=options->content;++count; + } + return count==1?value:NULL; +} + #define RTSP_CONNECT_TIMEOUT_SEC 10 #define RTSP_RECEIVE_TIMEOUT_SEC 15 #define RTSP_RETRY_DELAY_MS 500 @@ -560,7 +580,8 @@ static bool requestDescribe(PRTSP_MESSAGE response, int* error) { if (addOption(&request, "Accept", "application/sdp") && addOption(&request, "If-Modified-Since", - "Thu, 01 Jan 1970 00:00:00 GMT")) { + "Thu, 01 Jan 1970 00:00:00 GMT") && + (NegotiatedAudioCodec != AUDIO_CODEC_MAT_LIVE || addOption(&request,"X-ML-Mat-Live","1"))) { ret = transactRtspMessage(&request, response, true, error); } else { @@ -1255,6 +1276,15 @@ int performRtspHandshake(PSERVER_INFORMATION serverInfo) { goto Exit; } + if (NegotiatedAudioCodec == AUDIO_CODEC_MAT_LIVE && !MatLiveProfileParse(&MatLiveProfile,response.payload)) { + memset(&MatLiveProfile,0,sizeof(MatLiveProfile)); + if(StreamConfig.audioCodec!=AUDIO_CODEC_MAT_AUTO) { + Limelog("Host has no compatible live MAT source/profile\n"); + ret=-1;freeMessage(&response);goto Exit; + } + NegotiatedAudioCodec=AUDIO_CODEC_OPUS;NegotiatedAudioBitrate=0; + Limelog("Live MAT source unavailable; selecting Opus before ANNOUNCE (no replay fallback)\n"); + } uint32_t pyrowaveProtocolVersion = 0; uint32_t pyrowaveBitstreamVersion = 0; uint32_t pyrowavePayloadVersion = 0; @@ -1658,6 +1688,31 @@ int performRtspHandshake(PSERVER_INFORMATION serverInfo) { // Sunshine dynamic HDR negotiation result. Hosts without the // extension send no X-SS-Dynamic-HDR header; that is the legacy // outcome (DYNAMIC_HDR_FORMAT_NONE) rather than an error. + if (NegotiatedAudioCodec == AUDIO_CODEC_OPUS) { + const char* confirmedCodec = getOptionContent(response.options,"X-SS-Audio-Codec"); + if ((confirmedCodec && (strcmp(confirmedCodec,"opus") || + !uniqueMatOption(response.options,"X-SS-Audio-Codec"))) || + getOptionContent(response.options,"X-SS-Mat-SHA256") || + getOptionContent(response.options,"X-SS-Mat-Cycles") || + getOptionContent(response.options,"X-SS-Mat-Carrier") || + getOptionContent(response.options,"X-SS-Mat-Clock") || + getOptionContent(response.options,"X-SS-Mat-Live-Epoch") || + getOptionContent(response.options,"X-SS-Mat-Live-Domain") || + getOptionContent(response.options,"X-SS-Mat-Live-Crypto")) { + Limelog("Unexpected codec/MAT confirmation after selecting Opus; refusing media\n"); + ret = -1; freeMessage(&response); goto Exit; + } + } + if(NegotiatedAudioCodec==AUDIO_CODEC_MAT_LIVE && + (getOptionContent(response.options,"X-SS-Mat-SHA256") || getOptionContent(response.options,"X-SS-Mat-Cycles") || + !MatLiveProfileConfirm(&MatLiveProfile, + uniqueMatOption(response.options,"X-SS-Audio-Codec"), + uniqueMatOption(response.options,"X-SS-Mat-Carrier"),uniqueMatOption(response.options,"X-SS-Mat-Clock"), + uniqueMatOption(response.options,"X-SS-Mat-Live-Crypto"),uniqueMatOption(response.options,"X-SS-Mat-Live-Epoch"), + uniqueMatOption(response.options,"X-SS-Mat-Live-Domain")))) { + Limelog("Live MAT ANNOUNCE confirmation missing or mismatched\n"); + ret=-1;freeMessage(&response);goto Exit; + } if (!parseDynamicHdrNegotiation(&response)) { ret = 415; freeMessage(&response); diff --git a/src/RtspParser.c b/src/RtspParser.c index 0b8e4c95b..24a25d546 100644 --- a/src/RtspParser.c +++ b/src/RtspParser.c @@ -292,6 +292,15 @@ int parseRtspMessage(PRTSP_MESSAGE msg, char* rtspMessage, int length) { newOpt->option = opt; newOpt->content = val; newOpt->next = NULL; + // Upstream now coalesces names case-insensitively and frees the new + // node itself. Mark the replacement (not the old node whose flags are + // overwritten) so MAT's unique ACK check cannot miss a duplicate. + for (POPTION_ITEM existing = options; existing; existing = existing->next) { + if (!rtspStrCaseCmp(existing->option, newOpt->option)) { + newOpt->flags |= FLAG_DUPLICATE_OPTION; + break; + } + } insertOption(&options, newOpt); ptr = nextLine; diff --git a/src/SdpGenerator.c b/src/SdpGenerator.c index a1bebe1a9..2046da38c 100644 --- a/src/SdpGenerator.c +++ b/src/SdpGenerator.c @@ -587,8 +587,19 @@ static PSDP_OPTION getAttributesList(char*urlSafeAddr) { if (NegotiatedAudioCodec != AUDIO_CODEC_OPUS) { const char* codecStr = (NegotiatedAudioCodec == AUDIO_CODEC_AC3) ? "ac3" : (NegotiatedAudioCodec == AUDIO_CODEC_EAC3) ? "eac3" : - (NegotiatedAudioCodec == AUDIO_CODEC_PCM_S16) ? "pcm" : "opus"; + (NegotiatedAudioCodec == AUDIO_CODEC_PCM_S16) ? "pcm" : + (NegotiatedAudioCodec == AUDIO_CODEC_MAT_LIVE) ? MAT_LIVE_NAME : "opus"; err |= addAttributeString(&optionHead, "x-ml-audio.codec", codecStr); + if (AUDIO_CODEC_IS_MAT(NegotiatedAudioCodec)) { + err |= addAttributeString(&optionHead,"x-ml-audio.mat-live","1"); + err |= addAttributeString(&optionHead,"x-ml-audio.mat-live-carrier",MAT_CARRIER); + err |= addAttributeString(&optionHead,"x-ml-audio.mat-live-clock",MAT_CLOCK); + err |= addAttributeString(&optionHead,"x-ml-audio.mat-live-crypto",MAT_LIVE_CRYPTO); + snprintf(payloadStr,sizeof(payloadStr),"%llu",(unsigned long long)MatLiveProfile.epoch); + err |= addAttributeString(&optionHead,"x-ml-audio.mat-live-epoch",payloadStr); + snprintf(payloadStr,sizeof(payloadStr),"%llu",(unsigned long long)MatLiveProfile.domain); + err |= addAttributeString(&optionHead,"x-ml-audio.mat-live-domain",payloadStr); + } if (NegotiatedAudioBitrate > 0) { snprintf(payloadStr, sizeof(payloadStr), "%d", NegotiatedAudioBitrate); err |= addAttributeString(&optionHead, "x-ml-audio.bitrate", payloadStr); @@ -634,6 +645,9 @@ static PSDP_OPTION getAttributesList(char*urlSafeAddr) { } // PCM passthrough: keep frames as small as possible. 5 ms @ 48 kHz = // 240 samples, 5.1ch = 2880 B which fits the 4 KB receive buffer. + else if (AUDIO_CODEC_IS_MAT(NegotiatedAudioCodec)) { + AudioPacketDuration = 1; // FEC transport tick; carrier PTS is in SMAT. + } else if (NegotiatedAudioCodec == AUDIO_CODEC_PCM_S16) { AudioPacketDuration = 5; } diff --git a/tests/AudioCodecSelectionTest.c b/tests/AudioCodecSelectionTest.c new file mode 100644 index 000000000..41abbe88a --- /dev/null +++ b/tests/AudioCodecSelectionTest.c @@ -0,0 +1,96 @@ +/* Exercise the real audio startup with a sink that stops before hardware/threads. */ +#define PltGetMillis MatTestGetMillis +#include "../src/AudioStream.c" +#undef PltGetMillis +#include "MatWire.h" +#include "MatReceiver.h" +#include "MatLiveCrypto.h" +#include +static int failures,expectedCodec; +static uint64_t testNow; +static bool publishDuringClock; +uint64_t MatTestGetMillis(void) { + if(publishDuringClock)atomic_store_explicit(&matLastAcceptedMs,testNow+1,memory_order_relaxed); + return testNow; +} +#define CHECK(c) do { if(!(c)) { fprintf(stderr,"line %d: %s\n",__LINE__,#c);failures++;} } while(0) +static int received,ended,terminated,rejectCycle,rejectEnd; +static int consumeCycle(void* context,const unsigned char* bytes,int n,uint64_t epoch,uint64_t domain,uint64_t index,uint64_t pts) { + (void)context;CHECK(MatCarrierIsValid(bytes,(size_t)n));CHECK(epoch==1&&domain==1&&index==0&&pts==0); + ++received;return !rejectCycle; +} +static int consumeEnd(void* context,uint64_t count){(void)context;CHECK(count==1);testNow+=5000;CHECK(!matInputTimedOut());++ended;return !rejectEnd;} +static void terminate(int error){(void)error;++terminated;} +static void liveCallbacks(void) { + unsigned char carrier[MAT_WIRE_CYCLE]={0},packet[MAT_WIRE_PACKET],wire[MAT_LIVE_PACKET],key[16]={0}; + memcpy(carrier,"\x72\xf8\x1f\x4e\x16\x00\x02\x00",8); + MAT_LIVE_CIPHER cipher; + LI_MAT_LIVE_PROFILE profile={1,1}; + CHECK(MatLiveCipherInit(&cipher,key,sizeof(key),1,1)); + LI_MAT_PLAYBACK_CALLBACKS callbacks={consumeCycle,consumeEnd,NULL}; + LiSetMatPlaybackCallbacks(&callbacks);ListenerCallbacks.connectionTerminated=terminate; + for(int test=0;test<4;test++) { + received=ended=terminated=0;rejectCycle=test==1;rejectEnd=test==2; + matPlaybackFailedReported=matPlaybackCompleteReported=false;NegotiatedAudioCodec=AUDIO_CODEC_MAT_LIVE; + LI_MAT_PLAYBACK_CALLBACKS guarded=matCallbacks;guarded.end=matEnd; + testNow=1000;atomic_store(&matInputEnded,false);atomic_store(&matLastAcceptedMs,testNow); + matLive=MatLivePlaybackOpen(&profile,key,&guarded);CHECK(matLive!=NULL); + for(unsigned fragment=0;fragmentsampleRate==48000); + CHECK(format->samplesPerFrame==(mat?960:240)); + CHECK(matLive==NULL); + return -123; +} +int main(void) { + CHECK(initializePlatform()==0); + liveCallbacks(); + watchdog(); + const int requests[]={AUDIO_CODEC_MAT_AUTO,AUDIO_CODEC_MAT_AUTO,AUDIO_CODEC_OPUS,AUDIO_CODEC_MAT_LIVE}; + const int selections[]={AUDIO_CODEC_MAT_LIVE,AUDIO_CODEC_OPUS,AUDIO_CODEC_OPUS,AUDIO_CODEC_MAT_LIVE}; + for(unsigned i=0;i +static int failures,cycles,ends,terminated; +#define CHECK(x) do{if(!(x)){fprintf(stderr,"line %d: %s\n",__LINE__,#x);++failures;}}while(0) +static int cycle(void* c,const uint8_t* bytes,int n,uint64_t e,uint64_t d,uint64_t index,uint64_t pts){(void)c;(void)bytes;CHECK(n==61440 && e==123 && d==7 && index==(unsigned)cycles && pts==index*960);++cycles;return 1;} +static int end(void* c,uint64_t n){(void)c;CHECK(n==(unsigned)cycles);++ends;return 1;} +static void terminate(int error){(void)error;++terminated;} +static void packet(uint64_t sequence,const uint8_t* payload,size_t size){ + PQUEUED_AUDIO_PACKET p=calloc(1,sizeof(*p));if(!p){++failures;return;} + p->header.size=sizeof(RTP_PACKET)+(int)size;PRTP_PACKET r=(PRTP_PACKET)p->data; + r->header=0x80;r->packetType=97;r->sequenceNumber=(uint16_t)sequence;r->timestamp=(uint32_t)sequence; + memcpy(r+1,payload,size);int status=RtpaAddPacket(&rtpAudioQueue,r,(uint16_t)p->header.size); + if(RTPQ_HANDLE_NOW(status))decodeInputData(p); + else { + uint16_t length;PQUEUED_AUDIO_PACKET queued; + while((queued=(PQUEUED_AUDIO_PACKET)RtpaGetQueuedPacket(&rtpAudioQueue,sizeof(QUEUED_AUDIO_PACKET_HEADER),&length))!=NULL){queued->header.size=length;decodeInputData(queued);free(queued);} + } + free(p); +} +int main(void){ + CHECK(initializePlatform()==0);AppVersionQuad[0]=7;AppVersionQuad[1]=1;AppVersionQuad[2]=431;AppVersionQuad[3]=-1; + NegotiatedAudioCodec=AUDIO_CODEC_MAT_LIVE;AudioEncryptionEnabled=true;AudioPacketDuration=1;ListenerCallbacks.connectionTerminated=terminate; + RtpaInitializeQueue(&rtpAudioQueue);lastSeq=3; + LI_MAT_LIVE_PROFILE profile={123,7};uint8_t key[16]={};LI_MAT_PLAYBACK_CALLBACKS cb={0};cb.cycle=cycle;cb.end=end; + matLive=MatLivePlaybackOpen(&profile,key,&cb);CHECK(matLive); + MAT_LIVE_CIPHER cipher;CHECK(MatLiveCipherInit(&cipher,key,16,123,7)); + uint8_t plain[MAT_WIRE_PACKET],wire[MAT_LIVE_PACKET],carrier[MAT_WIRE_CYCLE]={0}; + memcpy(carrier,"\x72\xf8\x1f\x4e\x16\x00\x02\x00",8); + mat_wire_make(plain,MAT_WIRE_SYNC,123,7,0,0,0,NULL);uint64_t next=0; + for(unsigned i=0;i<4;i++){CHECK(MatLiveSeal(&cipher,next,plain,wire));packet(next++,wire,sizeof(wire));} + uint32_t hash=2166136261u; + for(unsigned i=0;i<1300;i++){ + carrier[8]=(uint8_t)i;uint32_t h=mat_hash(carrier,sizeof(carrier)); + for(unsigned j=0;j<60;j++){mat_wire_make(plain,MAT_WIRE_DATA,123,7,i,j,h,carrier);CHECK(MatLiveSeal(&cipher,next,plain,wire));packet(next++,wire,sizeof(wire));} + hash=mat_hash_update(hash,carrier,sizeof(carrier)); + } + mat_wire_make(plain,MAT_WIRE_END,123,7,1300,0,hash,NULL); + for(unsigned i=0;i<4;i++){CHECK(MatLiveSeal(&cipher,next,plain,wire));packet(next++,wire,sizeof(wire));} + CHECK(cycles==1300 && ends==1 && !terminated && lastSeq==(uint16_t)(next-1)); + MatLivePlaybackClose(matLive);matLive=NULL;RtpaCleanupQueue(&rtpAudioQueue);cleanupPlatform(); + if(failures)return 1;puts("PASS actual RTP queue / AudioStream: 78008 packets, RTP16 wrap, legacy CBC bypass, exact authenticated cycles and EOS");return 0; +} diff --git a/tests/MatLiveTest.c b/tests/MatLiveTest.c new file mode 100644 index 000000000..c9f79bce1 --- /dev/null +++ b/tests/MatLiveTest.c @@ -0,0 +1,47 @@ +/* Synthetic carrier bytes exercise transport only. No recorded MAT input or Dolby decode claim. */ +#include "MatLivePlayback.h" +#include "MatLiveCrypto.h" +#include +#include +static int failures,cycles,ends,reject; +#define CHECK(x) do{if(!(x)){fprintf(stderr,"line %d: %s\n",__LINE__,#x);++failures;}}while(0) +static int cycle(void* c,const uint8_t* b,int n,uint64_t e,uint64_t d,uint64_t i,uint64_t pts){ + (void)c;CHECK(n==61440 && b[0]==0x72 && e==123 && d==7 && i==(unsigned)cycles && pts==i*960);++cycles;return !reject; +} +static int end(void* c,uint64_t count){(void)c;CHECK(count==(unsigned)cycles);++ends;return !reject;} +static uint8_t key[16],carrier[MAT_WIRE_CYCLE],plain[MAT_WIRE_PACKET],wire[MAT_LIVE_PACKET]; +static LI_MAT_LIVE_PROFILE profile={123,7}; +static MAT_LIVE_CIPHER cipher; +static MAT_LIVE_PLAYBACK* open_receiver(void){LI_MAT_PLAYBACK_CALLBACKS cb={0};cb.cycle=cycle;cb.end=end;cycles=ends=reject=0;return MatLivePlaybackOpen(&profile,key,&cb);} +static int push(MAT_LIVE_PLAYBACK* receiver,uint64_t sequence){CHECK(MatLiveSeal(&cipher,sequence,plain,wire));return MatLivePlaybackPush(receiver,wire,sizeof(wire));} +int main(int argc,char** argv){ + for(unsigned i=0;i<16;i++)key[i]=(uint8_t)i; + CHECK(MatLiveCipherInit(&cipher,key,16,123,7)); + if(argc==2){for(unsigned i=0;i>8);uint32_t h=mat_hash(carrier,sizeof(carrier)); + for(unsigned j=0;j<60;j++){mat_wire_make(plain,MAT_WIRE_DATA,123,7,i,j,h,carrier);CHECK(push(receiver,packet++)==(j==59?1:0));} + hash=mat_hash_update(hash,carrier,sizeof(carrier)); + } + CHECK(packet==78004 && cycles==1300 && !ends); + mat_wire_make(plain,MAT_WIRE_END,123,7,1300,0,hash,NULL); + for(unsigned j=0;j<4;j++)CHECK(push(receiver,packet++)==2); + CHECK(ends==1);MatLivePlaybackClose(receiver); + // Missing EOS/close must never drain the renderer as a successful stream. + receiver=open_receiver();mat_wire_make(plain,MAT_WIRE_DATA,123,7,0,0,mat_hash(carrier,sizeof(carrier)),carrier);CHECK(push(receiver,4)==0);MatLivePlaybackClose(receiver);CHECK(!ends); + // Replay, gaps, ciphertext/tag/header corruption, wrong key/domain and sticky failure. + for(unsigned test=0;test<8;test++){ + receiver=open_receiver();mat_wire_make(plain,MAT_WIRE_DATA,123,7,0,0,mat_hash(carrier,sizeof(carrier)),carrier); + if(test==5){MAT_LIVE_CIPHER wrong=cipher;wrong.key[0]^=1;CHECK(MatLiveSeal(&wrong,4,plain,wire));} + else if(test==6){mat_put64(plain+24,8);mat_put32(plain+72,mat_packet_hash(plain));CHECK(MatLiveSeal(&cipher,4,plain,wire));} + else CHECK(MatLiveSeal(&cipher,test==0?3:test==1?5:4,plain,wire)); + if(test==2)wire[32]^=1;if(test==3)wire[sizeof(wire)-1]^=1;if(test==4)wire[8]^=1; + if(test==7){CHECK(MatLivePlaybackPush(receiver,wire,sizeof(wire))==0);} + CHECK(MatLivePlaybackPush(receiver,wire,sizeof(wire))<0);CHECK(MatLivePlaybackPush(receiver,wire,sizeof(wire))<0);CHECK(!cycles&&!ends);MatLivePlaybackClose(receiver); + } + receiver=open_receiver();reject=1;for(unsigned j=0;j<60;j++){mat_wire_make(plain,MAT_WIRE_DATA,123,7,0,j,mat_hash(carrier,sizeof(carrier)),carrier);CHECK(push(receiver,4+j)==(j==59?-1:0));}CHECK(cycles==1&&!ends);MatLivePlaybackClose(receiver); + uint64_t number;CHECK(!MatLiveDecimal("01",&number));CHECK(!MatLiveDecimal("0",&number));CHECK(!MatLiveDecimal("9223372036854775808",&number));CHECK(MatLiveDecimal("9223372036854775807",&number)); + if(failures)return 1;puts("PASS live MAT: 78000 authenticated data packets, RTP16 wrap, integrity/replay/gap/identity/EOF/callback failures");return 0; +} diff --git a/tests/RtspParserMatTest.c b/tests/RtspParserMatTest.c new file mode 100644 index 000000000..1352cfa4e --- /dev/null +++ b/tests/RtspParserMatTest.c @@ -0,0 +1,45 @@ +#include "Rtsp.h" + +static int failures; +#define CHECK(c) do { if (!(c)) { fprintf(stderr, "%d: %s\n", __LINE__, #c); failures++; } } while (0) + +static void checkResponse(char* text, const char* expected, bool duplicate) { + RTSP_MESSAGE msg = {0}; + int result = parseRtspMessage(&msg, text, (int)strlen(text)); + CHECK(result == RTSP_ERROR_SUCCESS); + if (result != RTSP_ERROR_SUCCESS) return; + char* value = getOptionContent(msg.options, "X-SS-Mat-Clock"); + CHECK(value && !strcmp(value, expected)); + int marked = 0; + int nodes = 0; + for (POPTION_ITEM opt = msg.options; opt; opt = opt->next) { + nodes++; + if (opt->flags & FLAG_DUPLICATE_OPTION) marked++; + } + CHECK(nodes == 2); // CSeq and one coalesced MAT header, including triples. + CHECK(marked == (duplicate ? 1 : 0)); + CHECK(msg.sequenceNumber == 7); + freeMessage(&msg); +} + +int main(void) { + checkResponse("RTSP/1.0 200 OK\r\ncseq:7\r\nX-SS-Mat-Clock:\t48000 \t\r\n\r\n", "48000", false); + checkResponse("RTSP/1.0 200 OK\r\nCSeq:7\r\nX-SS-Mat-Clock: 1\r\nX-SS-Mat-Clock: 48000\r\n\r\n", "48000", true); + checkResponse("RTSP/1.0 200 OK\r\nCSeq:7\r\nX-SS-Mat-Clock: 1\r\nx-ss-mat-clock: 48000\r\n\r\n", "48000", true); + checkResponse("RTSP/1.0 200 OK\r\nCSeq:7\r\nX-SS-Mat-Clock: 1\r\nx-ss-mat-clock: 2\r\nX-SS-MAT-CLOCK: 48000\r\n\r\n", "48000", true); + + // Preserve upstream bare-LF / whitespace / binary payload handling. + char binary[] = "RTSP/1.0\t200 OK\nCSeq:\t7\nContent-Length: 4\n\nA\0BC"; + RTSP_MESSAGE msg = {0}; + int result = parseRtspMessage(&msg, binary, (int)sizeof(binary) - 1); + CHECK(result == RTSP_ERROR_SUCCESS); + if (result == RTSP_ERROR_SUCCESS) { + CHECK(msg.payloadLength == 4); + CHECK(msg.payload && !memcmp(msg.payload, "A\0BC", 4)); + CHECK(msg.sequenceNumber == 7); + freeMessage(&msg); + } + if (failures) return 1; + puts("PASS RTSP upstream whitespace/binary and MAT duplicate ownership/flags"); + return 0; +}