|
|
//========= Copyright (c), Valve Corporation, All rights reserved. ============//
#include "client_pch.h"
#include "demostreamhttp.h"
#include "cl_steamauth.h"
#include "tier1/keyvaluesjson.h"
#include "tier0/memalloc.h"
#include "cl_demo.h"
#include "sv_steamauth.h"
#include "engine_gcmessages.pb.h"
static ISteamHTTP *s_pSteamHTTP = NULL; ConVar demo_debug( "demo_debug", "0", 0, "Demo debug info." ); ConVar tv_playcast_origin_auth( "tv_playcast_origin_auth", "", FCVAR_RELEASE | FCVAR_HIDDEN, "Get request X-Origin-Auth string" ); ConVar tv_playcast_max_rcvage( "tv_playcast_max_rcvage", "15", FCVAR_RELEASE | FCVAR_HIDDEN ); ConVar tv_playcast_max_rtdelay( "tv_playcast_max_rtdelay", "55", FCVAR_RELEASE | FCVAR_HIDDEN ); ConVar tv_playcast_delay_prediction( "tv_playcast_delay_prediction", "1", FCVAR_RELEASE );
CDemoStreamHttp::CDemoStreamHttp() : m_nState( STATE_IDLE ), m_pStreamSignup( NULL ), m_pClient( NULL ), m_bSyncFromGc( false ), m_flBroadcastKeyframeInterval( 3 ) { V_memset( &m_SyncResponse, 0, sizeof( m_SyncResponse ) ); m_dSyncTimeoutEnd = -1; }
void CDemoStreamHttp::StartStreaming( const char *pUrl, SyncParams_t syncParams ) { if ( !PrepareForStreaming( pUrl ) ) return; m_SyncParams = syncParams; SendSync( ); }
bool CDemoStreamHttp::PrepareForStreaming( const char * pUrl ) { StopStreaming();
if ( pUrl[ 0 ] == 'g' && pUrl[ 1 ] == 'c' && pUrl[ 2 ] == '-' ) { m_Url = pUrl + 3; m_bSyncFromGc = true; } else { m_Url = pUrl; m_bSyncFromGc = false; }
m_Url.StripTrailingSlash();
#ifndef DEDICATED
s_pSteamHTTP = Steam3Client().SteamHTTP(); #endif
if ( !s_pSteamHTTP ) s_pSteamHTTP = Steam3Server().SteamHTTP();
if ( !s_pSteamHTTP ) { DevMsg( "Cannot get Steam HTTP interface\n" ); return false ; }
DevMsg( "Broadcast: Synchronizing stream\n" ); m_nState = STATE_SYNC; return true; }
// this is only called in special cases for debugging, to play back stale contents
void CDemoStreamHttp::StartStreamingCached( const char *pUrl, int nFragment) { if ( !PrepareForStreaming( pUrl ) ) return;
m_nState = STATE_START; // guess parameters of the stream
int nStartTick = 1; m_nDemoProtocol = 4; // DEMO_PROTOCOL == 4 is where I started writing this
int nSignupFragment = 0;
m_SyncParams.m_nStartFragment = 0; m_SyncResponse.flTicksPerSecond = 128; m_SyncResponse.flKeyframeInterval = 3; m_SyncResponse.flRealTimeDelay = 0; m_SyncResponse.flReceiveAge = 0; m_SyncResponse.nFragment = nFragment; m_SyncResponse.nSignupFragment = nSignupFragment; m_SyncResponse.nStartTick = nStartTick; m_SyncResponse.dPlatTimeReceived = Plat_FloatTime();
SendGet( CFmtStr( "/%d/start", nSignupFragment ), new CStartRequest( ) ); BeginBuffering( nFragment ); }
void CDemoStreamHttp::SendSync( int nResync ) { Assert( m_nState == STATE_SYNC ); #ifndef DEDICATED
if ( m_bSyncFromGc ) { GotvHttpStreamId_t params = GetStreamId( m_Url ); DevMsg( "Requesting sync from GC, start fragment %d match id %llu instance %d\n", m_SyncParams.m_nStartFragment, params.m_nMatchId, params.m_nInstanceId ); CEngineGotvSyncPacket msg; msg.set_match_id( params.m_nMatchId ); msg.set_instance_id( params.m_nInstanceId ); if ( m_SyncParams.m_nStartFragment > 0 ) { msg.set_currentfragment( m_SyncParams.m_nStartFragment ); } g_ClientDLL->EngineGotvSyncPacket( &msg ); m_dSyncTimeoutEnd = Plat_FloatTime() + 10; } else #endif
{ char request[ 128 ]; m_SyncParams.PrintSyncRequest( request, sizeof( request ) ); DevMsg( "Requesting sync from relay %s\n", request ); SendGet( request, new CSyncRequest( m_SyncParams, nResync ) ); } }
void CDemoStreamHttp::Resync( ) { m_nState = STATE_SYNC; SendSync( 1 ); }
void CDemoStreamHttp::Update() { if ( m_nState == STATE_SYNC && m_bSyncFromGc && m_dSyncTimeoutEnd > 0 && m_dSyncTimeoutEnd < Plat_FloatTime() ) { StopStreaming(); }
if ( m_nState == STATE_RANDOM_WAIT_AND_SYNC && m_bSyncFromGc && m_dSyncTimeoutEnd > 0 && m_dSyncTimeoutEnd < Plat_FloatTime() ) { m_nState = STATE_SYNC; SendSync( 0 ); } }
// result from "/start" request
void CDemoStreamHttp::OnStart( HTTPRequestHandle hRequest ) { m_pStreamSignup = MakeBuffer( hRequest ); m_nStreamSignupFragment = m_SyncResponse.nSignupFragment; if ( !m_pStreamSignup ) { DevMsg( "Broadcast failed to start: cannot retrieve startup packet data\n" ); StopStreaming(); return; }
DevMsg( "Received signup fragment %d\n", m_SyncResponse.nSignupFragment ); if ( m_pClient ) { m_pClient->OnDemoStreamStart( GetStreamStartReference(), 0 ); } }
void CDemoStreamHttp::OnFragmentRequestSuccess( HTTPRequestHandle hRequest, int nFragment, FragmentTypeEnum_t nType ) { Fragment_t &fragment = Fragment( nFragment ); fragment.ClearStreaming( nType ); if ( Buffer_t *pBuf = MakeBuffer( hRequest ) ) { fragment.SetField( nType, pBuf ); } else { DevMsg( "Broadcast playback failed to retrieve %s frame of fragment %d", AsString( nType ), nFragment ); // TODO: implement fault-tolerant recovery; we can request the next fragment's full frame
StopStreaming(); } }
void CDemoStreamHttp::OnFragmentRequestFailure( EHTTPStatusCode nErrorCode, int nFragment, FragmentTypeEnum_t nType ) { Fragment_t &fragment = Fragment( nFragment ); fragment.ClearStreaming( nType ); // TODO: Retry streaming gracefully, implement timeouts, skip fragment and download the next full frame if needed
}
bool CDemoStreamHttp::OnEngineGotvSyncPacket( const CEngineGotvSyncPacket *pPkt ) { GotvHttpStreamId_t streamId = GetStreamId( m_Url );
if ( streamId.m_nMatchId != pPkt->match_id() || streamId.m_nInstanceId != pPkt->instance_id() ) { Warning( "Ignoring unexpected sync from gc, match %llu:%d, expected %llu:%d\n", pPkt->match_id(), pPkt->instance_id(), streamId.m_nMatchId, streamId.m_nInstanceId ); return false; }
if ( m_nState != STATE_SYNC && m_nState != STATE_RANDOM_WAIT_AND_SYNC ) // we should be waiting for a sync in some way. In case of WAIT_AND_SYNC, maybe GC will send us a packet even though we didn't ask for it, while we're waiting to re-ask for a sync..
{ Warning( "Ignoring unexpected sync from gc, match %llu:%d\n", pPkt->match_id(), pPkt->instance_id() ); return false; }
if ( !pPkt->has_tick() ) { // the packet is empty, which means: wait for a few seconds
m_nState = STATE_RANDOM_WAIT_AND_SYNC; float flDelay = pPkt->rtdelay() * RandomFloat( 0.5f, 1.5f ); m_dSyncTimeoutEnd = Plat_FloatTime() + flDelay; DevMsg( "Waiting %.2f seconds\n", flDelay ); return true; // we actually successfully processed the packet
}
m_nDemoProtocol = 4; m_SyncResponse.flTicksPerSecond = pPkt->tickrate(); m_SyncResponse.flKeyframeInterval = pPkt->has_keyframe_interval() ? pPkt->keyframe_interval() : 3.0f; m_SyncResponse.nStartTick = pPkt->tick(); m_SyncResponse.flRealTimeDelay = pPkt->rtdelay(); m_SyncResponse.flReceiveAge = pPkt->rcvage(); m_SyncResponse.nFragment = pPkt->currentfragment(); m_SyncResponse.nSignupFragment = pPkt->signupfragment(); m_SyncResponse.dPlatTimeReceived = Plat_FloatTime();
return OnSync( 0 ); }
// result from "/sync" request arrived
bool CDemoStreamHttp::OnSync( const char *pBuffer, int nBufferSize, int nResync ) { if ( m_nState != STATE_SYNC ) { Warning( "Ignoring unexpected sync, %d bytes, resync %d\n", nBufferSize, nResync ); return false; } KeyValuesJSONParser json( pBuffer, nBufferSize ); if ( KeyValues *pSync = json.ParseFile() ) { m_SyncResponse.flKeyframeInterval = pSync->GetFloat( "keyframe_interval", 3.0f ); m_SyncResponse.nStartTick = pSync->GetInt( "tick", -1 ); m_SyncResponse.flRealTimeDelay = pSync->GetFloat( "rtdelay", 0 ); m_SyncResponse.flReceiveAge = pSync->GetFloat( "rcvage", 0 ); m_SyncResponse.nFragment = pSync->GetInt( "fragment", 1 ); m_SyncResponse.nSignupFragment = pSync->GetInt( "signup_fragment", 0 ); m_SyncResponse.flTicksPerSecond = pSync->GetInt( "tps", 0 ); m_SyncResponse.dPlatTimeReceived = Plat_FloatTime(); m_nDemoProtocol = pSync->GetInt( "protocol", 4 ); // DEMO_PROTOCOL == 4 is where I started writing this
delete pSync; pSync = NULL;
return OnSync( nResync ); } else { DevMsg( "Broadcast sync: malformed response: %s\n", pBuffer ); StopStreaming(); return false; } }
bool CDemoStreamHttp::OnSync( int nResync ) { if ( nResync && !IsDebug() && m_SyncResponse.flReceiveAge > tv_playcast_max_rcvage.GetFloat() && m_SyncResponse.flRealTimeDelay > tv_playcast_max_rtdelay.GetFloat() ) { DevMsg( "Broadcast resync %d: the stream seems to have stopped (rcvage %.1f, rtdelay %.1f)\n", nResync, m_SyncResponse.flReceiveAge, m_SyncResponse.flRealTimeDelay ); StopStreaming(); return false; } else if ( /*nTick < 0 || nEndTick < nTick || flSkip < 0 ||*/ m_SyncResponse.nFragment < m_SyncResponse.nSignupFragment || m_SyncResponse.nSignupFragment < 0 ) { DevMsg( "Broadcast m_SyncResponse: unexpected response. fragment %d must be at/after start fragment %d\n", m_SyncResponse.nFragment, m_SyncResponse.nSignupFragment ); StopStreaming(); return false; } else { DevMsg( "Broadcast: Buffering stream tick %d fragment %d signup fragment %d\n", m_SyncResponse.nStartTick, m_SyncResponse.nSignupFragment, m_SyncResponse.nSignupFragment ); m_nState = STATE_START; m_dSyncTimeoutEnd = -1; m_flBroadcastKeyframeInterval = m_SyncResponse.flKeyframeInterval; if ( nResync ) { if ( !m_pClient ) { DevMsg( "Broadcast resync failed: Client not connected to Stream\n" ); StopStreaming(); return false; } if ( m_SyncResponse.nSignupFragment == m_nStreamSignupFragment ) { Assert( m_pStreamSignup.IsValid() && m_pClient ); m_pClient->OnDemoStreamStart( GetStreamStartReference(), nResync ); } else { if ( !m_pClient->OnDemoStreamRestarting() ) { StopStreaming(); return false; } DevMsg( "Resync %d response requires full stream restart because signup fragment changed from %d to %d\n", nResync, m_nStreamSignupFragment, m_SyncResponse.nSignupFragment ); SendGet( CFmtStr( "/%d/start", m_SyncResponse.nSignupFragment ), new CStartRequest( ) ); } } else { Assert( !m_pStreamSignup ); // when we're restarting, we don't need the start fragment, we already initialized
SendGet( CFmtStr( "/%d/start", m_SyncResponse.nSignupFragment ), new CStartRequest( ) ); } if ( nResync || !tv_playcast_delay_prediction.GetBool() ) { int nFragment = m_SyncResponse.nFragment; BeginBuffering( nFragment ); } return true; } }
void CDemoStreamHttp::BeginBuffering( int nFragment ) { RequestFragment( nFragment, FRAGMENT_FULL ); for ( int i = 0; i <= 4; ++i ) RequestFragment( nFragment + i, FRAGMENT_DELTA ); }
void CDemoStreamHttp::RequestFragment( int nFragment, FragmentTypeEnum_t nType ) { Fragment_t &fragment = Fragment( nFragment ); if ( !fragment.GetField( nType ) && !fragment.IsStreaming(nType) ) { fragment.SetStreaming( nType ); SendGet( CFmtStr( nType == FRAGMENT_FULL ? "/%d/full" : "/%d/delta", nFragment ), new CFragmentRequest( nFragment, nType ) ); } }
void CDemoStreamHttp::ReleaseFragment( int nFragment ) { UtlHashHandle_t it = m_FragmentCache.Find( nFragment ); if ( it != m_FragmentCache.InvalidHandle() ) { m_FragmentCache[ it ].ResetBuffers(); m_FragmentCache.RemoveByHandle( it ); } }
GotvHttpStreamId_t CDemoStreamHttp::GetStreamId( const char *pUrl ) { GotvHttpStreamId_t out; if ( !pUrl || !*pUrl ) return out; const char *p = pUrl + V_strlen( pUrl ) - 1; if ( !V_isdigit( *p ) ) return out; out.m_nInstanceId = *p - '0';
p--; if ( *p != 'i' ) return out;
out.m_nMatchId = 0; uint64 digitPlace = 1; while ( ( --p ) >= pUrl && V_isdigit( *p ) ) { if ( out.m_nMatchId > uint64( -1ll ) / 10 ) break; // the number doesn't fit 64 bit, error out
out.m_nMatchId += ( *p - '0' ) * digitPlace; digitPlace *= 10; } if ( *p != '/' ) { // invalid matchid
out.m_nMatchId = 0; } return out; }
IDemoStreamClient::DemoStreamReference_t CDemoStreamHttp::GetStreamStartReference( bool bLagCompensation /*= false */ ) { IDemoStreamClient::DemoStreamReference_t start; start.nTick = m_SyncResponse.nStartTick; start.nFragment = m_SyncResponse.nFragment;
if ( bLagCompensation ) { float flSkipSeconds = ( Plat_FloatTime() - m_SyncResponse.dPlatTimeReceived + m_SyncResponse.flReceiveAge ); if ( flSkipSeconds >= 0 && flSkipSeconds < 90 ) // if it's not too suspiciously long interval, we can try to compensate for it
{ int nTotalSkipTicks = int( flSkipSeconds * m_SyncResponse.flTicksPerSecond ); int nTicksPerFragment = int( m_SyncResponse.flKeyframeInterval * m_SyncResponse.flTicksPerSecond ); start.nSkipTicks = nTotalSkipTicks % nTicksPerFragment; start.nTick += ( nTotalSkipTicks - start.nSkipTicks ); start.nFragment += nTotalSkipTicks / nTicksPerFragment; } else start.nSkipTicks = 0; } else { start.nSkipTicks = int( m_SyncResponse.flReceiveAge * m_SyncResponse.flTicksPerSecond ); // Maybe GC should send rtdelay - desired_delay instead?
}
return start; }
void CDemoStreamHttp::StopStreaming() { if ( m_nState != STATE_IDLE ) { m_nState = STATE_IDLE; if ( m_pClient ) { m_pClient->OnDemoStreamStop(); } }
m_dSyncTimeoutEnd = -1;
while ( m_PendingRequests.Count() ) m_PendingRequests.Tail()->Cancel(); m_pStreamSignup = NULL; // delete start Buffer_t
FOR_EACH_HASHTABLE( m_FragmentCache, it ) { m_FragmentCache.Element( it ).ResetBuffers(); } m_FragmentCache.Purge(); }
CDemoStreamHttp::Buffer_t * CDemoStreamHttp::GetFragmentBuffer( int nFragment , FragmentTypeEnum_t nFragmentType ) { UtlHashHandle_t hFind = m_FragmentCache.Find( nFragment ); if ( hFind == m_FragmentCache.InvalidHandle() ) return NULL; return m_FragmentCache[ hFind ].GetField( nFragmentType ); }
void CDemoStreamHttp::CSyncRequest::OnSuccess( const HTTPRequestCompleted_t * pResponse ) { uint32 nBodySize; if( !s_pSteamHTTP->GetHTTPResponseBodySize( pResponse->m_hRequest, &nBodySize ) || nBodySize >= 1024 ) { DevMsg( "Broadcast sync: response buffer overflow (%d bytes)\n", nBodySize ); return; };
char *pResponseBuffer = StackAlloc( char, nBodySize + 1 ); if ( !s_pSteamHTTP->GetHTTPResponseBodyData( pResponse->m_hRequest, ( uint8* )pResponseBuffer, nBodySize ) ) { DevMsg( "Broadcast sync: cannot read response body\n" ); return; } pResponseBuffer[ nBodySize ] = '\0'; m_pParent->OnSync( pResponseBuffer, nBodySize, m_nResync ); }
void CDemoStreamHttp::CSyncRequest::OnFailure( const HTTPRequestCompleted_t * pResponse ) { if ( !m_nResync || m_nResync > 5 ) { CPendingRequest::OnFailure( pResponse ); } else { DevMsg( "%d stream resync failed\n", m_nResync ); m_pParent->SendSync( m_nResync + 1 ); // retry a couple times
} }
void CDemoStreamHttp::CPendingRequest::OnFailure( const HTTPRequestCompleted_t * pResponse ) { if ( !pResponse ) { DevMsg( "Broadcast IO error. Please try again later.\n" ); } else { DevMsg( "Broadcast Streaming error %d\n", pResponse->m_eStatusCode ); } m_pParent->StopStreaming(); }
void CDemoStreamHttp::CStartRequest::OnSuccess( const HTTPRequestCompleted_t * pResponse ) { m_pParent->OnStart( pResponse->m_hRequest ); }
CDemoStreamHttp::Buffer_t * CDemoStreamHttp::MakeBuffer( HTTPRequestHandle hRequest ) { uint32 nBodySize; if ( !s_pSteamHTTP->GetHTTPResponseBodySize( hRequest, &nBodySize ) ) { return NULL; }
uint8 *pMemory = new uint8[ sizeof( Buffer_t ) + nBodySize + 1 ]; if ( !s_pSteamHTTP->GetHTTPResponseBodyData( hRequest, pMemory + sizeof( Buffer_t ), nBodySize ) ) { delete[] pMemory; return NULL; } pMemory[ sizeof( Buffer_t ) + nBodySize ] = '\0'; // in case we need to receive and parse some text-only packets in the future
Buffer_t* pBuffer = ( Buffer_t* )pMemory; pBuffer->m_nRefCount = 0; pBuffer->m_nSize = nBodySize; return pBuffer; }
void CDemoStreamHttp::SendGet( const char *pPath, CPendingRequest *pRequest ) { HTTPRequestHandle hRequest = s_pSteamHTTP->CreateHTTPRequest( k_EHTTPMethodGET, m_Url + pPath ); s_pSteamHTTP->SetHTTPRequestNetworkActivityTimeout( hRequest, 30 ); const char *pOriginAuth = tv_playcast_origin_auth.GetString(); if ( pOriginAuth && *pOriginAuth ) { if ( !s_pSteamHTTP->SetHTTPRequestHeaderValue( hRequest, "X-Origin-Auth", pOriginAuth ) ) { Warning( "Cannot set http X-Origin-Auth for %s\n", pPath ); } } SteamAPICall_t hCall; bool bSentOk = s_pSteamHTTP->SendHTTPRequest( hRequest, &hCall ); pRequest->Init( this, hRequest, hCall ); if ( bSentOk && hCall ) { SteamAPI_RegisterCallResult( pRequest, hCall ); } else { s_pSteamHTTP->ReleaseHTTPRequest( hRequest ); DevMsg( "Broadcast streaming: unexpected failure getting %s\n", pPath ); pRequest->OnFailure( NULL ); // IO failure
} }
CDemoStreamHttp::Fragment_t & CDemoStreamHttp::Fragment( int nFragment ) { UtlHashHandle_t it = m_FragmentCache.Insert( nFragment ); return m_FragmentCache[ it ]; }
CDemoStreamHttp::CPendingRequest::CPendingRequest() : m_pParent( NULL ), m_hRequest( INVALID_HTTPREQUEST_HANDLE ), m_hCall( k_uAPICallInvalid ) { m_iCallback = HTTPRequestCompleted_t::k_iCallback; }
void CDemoStreamHttp::CPendingRequest::Init( CDemoStreamHttp *pParent, HTTPRequestHandle hRequest, SteamAPICall_t hCall ) { m_pParent = pParent; m_hRequest = hRequest; m_hCall = hCall; pParent->m_PendingRequests.AddToTail( this ); }
CDemoStreamHttp::CPendingRequest::~CPendingRequest() { }
void CDemoStreamHttp::CPendingRequest::Run( void *pvParam ) { m_pParent->m_PendingRequests.FindAndFastRemove( this ); OnSuccess( ( HTTPRequestCompleted_t * )pvParam ); delete this; }
void CDemoStreamHttp::CPendingRequest::Run( void *pvParam, bool bIOFailure, SteamAPICall_t hSteamAPICall ) { m_pParent->m_PendingRequests.FindAndFastRemove( this ); if ( bIOFailure ) { OnFailure( NULL ); } else { EHTTPStatusCode nStatus = ( ( HTTPRequestCompleted_t * )pvParam )->m_eStatusCode; Assert( ( ( HTTPRequestCompleted_t * )pvParam )->m_hRequest == m_hRequest ); if ( nStatus != k_EHTTPStatusCode200OK ) // we should always get a 200
{ OnFailure( ( HTTPRequestCompleted_t * )pvParam ); } else { OnSuccess( ( HTTPRequestCompleted_t * )pvParam ); } } delete this; }
void CDemoStreamHttp::CPendingRequest::Cancel() { SteamAPI_UnregisterCallResult( this, m_hCall ); s_pSteamHTTP->ReleaseHTTPRequest( m_hRequest ); m_pParent->m_PendingRequests.FindAndFastRemove( this ); OnFailure( NULL ); delete this; }
void CDemoStreamHttp::CFragmentRequest::OnSuccess( const HTTPRequestCompleted_t * pResponse ) { m_pParent->OnFragmentRequestSuccess( pResponse->m_hRequest, m_nFragment, m_nType ); }
static const char *s_pFragmentTypeName[ CDemoStreamHttp::FRAGMENT_COUNT ] = { "delta", "full" };
void CDemoStreamHttp::CFragmentRequest::OnFailure( const HTTPRequestCompleted_t * pResponse ) { if ( demo_debug.GetBool() ) DevMsg( "Failed to retrieve %s fragment %d\n", s_pFragmentTypeName[ m_nType ], m_nFragment ); m_pParent->OnFragmentRequestFailure( pResponse ? pResponse->m_eStatusCode : k_EHTTPStatusCodeInvalid, m_nFragment, m_nType ); // CPendingRequest::OnFailure( pResponse ); <-- the parent OnFail will stop streaming, and we don't want that because we'll retry this fragment or next for a few times before giving up
}
void CDemoStreamHttp::Fragment_t::ResetBuffers() { for ( int i = 0; i < FRAGMENT_COUNT; ++i ) { if ( m_pField[ i ] ) { Buffer_t::Release( m_pField[ i ] ); m_pField[ i ] = NULL; } } }
void CDemoStreamHttp::Fragment_t::SetField( FragmentTypeEnum_t nFragment, Buffer_t *pBuffer ) { if ( pBuffer ) Buffer_t::AddRef( pBuffer ); if ( m_pField[ nFragment ] ) Buffer_t::Release( m_pField[ nFragment ] ); m_pField[ nFragment ] = pBuffer; }
void CDemoStreamHttp::SyncParams_t::PrintSyncRequest(char *buffer, int nBufferSize) const { if ( m_nStartFragment ) V_snprintf(buffer, nBufferSize, "/sync?fragment=%d", m_nStartFragment ); else V_strncpy( buffer, "/sync", nBufferSize ); }
|