diff options
| author | FluorescentCIAAfricanAmerican <[email protected]> | 2020-04-22 12:56:21 -0400 |
|---|---|---|
| committer | FluorescentCIAAfricanAmerican <[email protected]> | 2020-04-22 12:56:21 -0400 |
| commit | 3bf9df6b2785fa6d951086978a3e66f49427166a (patch) | |
| tree | 2c0f1f0c63c4832882bc93814ebd2c2b1c6224e5 /game/server/tf/workshop/maps_workshop.cpp | |
| download | archived-source-engine-2018-hl2-src-master.tar.xz archived-source-engine-2018-hl2-src-master.zip | |
Diffstat (limited to 'game/server/tf/workshop/maps_workshop.cpp')
| -rw-r--r-- | game/server/tf/workshop/maps_workshop.cpp | 1005 |
1 files changed, 1005 insertions, 0 deletions
diff --git a/game/server/tf/workshop/maps_workshop.cpp b/game/server/tf/workshop/maps_workshop.cpp new file mode 100644 index 0000000..b5ee505 --- /dev/null +++ b/game/server/tf/workshop/maps_workshop.cpp @@ -0,0 +1,1005 @@ +//====== Copyright Valve Corporation, All rights reserved. ================= +// +//============================================================================= +#include "cbase.h" +#include "maps_workshop.h" +#include "workshop/ugc_utils.h" + +#include "tf_gamerules.h" + +#include "rtime.h" +#include "tier2/fileutils.h" +#include "filesystem.h" + +#include "icommandline.h" + +#include "ServerBrowser/IServerBrowser.h" + +#if !defined ( _GAMECONSOLE ) && !defined ( NO_STEAM ) + +CTFMapsWorkshop g_TFMapsWorkshop; + +CTFMapsWorkshop *TFMapsWorkshop() +{ + // Statically initialized right now, but don't assume infallible + return &g_TFMapsWorkshop; +} + +static_assert( sizeof( PublishedFileId_t ) == 8, "Various printfs in this file assuming PublishedFileId_t is a 64bit type (e.g. %llu)" ); + +static CDllDemandLoader g_ServerBrowser( "ServerBrowser" ); +static IServerBrowser *GetServerBrowser() +{ + if ( engine->IsDedicatedServer() ) + { + return NULL; + } + + static IServerBrowser *pServerBrowser = NULL; + if ( pServerBrowser == NULL ) + { + int iReturnCode; + pServerBrowser = (IServerBrowser *)g_ServerBrowser.GetFactory()( SERVERBROWSER_INTERFACE_VERSION, &iReturnCode ); + Assert( pServerBrowser ); + } + return pServerBrowser; +} + +bool PublishedFileId_t_Less( const PublishedFileId_t &a, const PublishedFileId_t &b ) +{ + return a < b; +} + +// Get and possibly init UGC +static ISteamUGC *GetWorkshopUGC() +{ + static bool bInitUGC = false; + ISteamUGC *pUGC = GetSteamUGC(); + + // The first time we successfully get a steam context we should call the init + if ( pUGC && !bInitUGC ) + { + // For the dedicated server API, honor -ugcpath + int i = CommandLine()->FindParm( "-ugcpath" ); + if ( engine->IsDedicatedServer() && i ) + { + + const char *pUGCPath = CommandLine()->GetParm( i + 1 ); + if ( pUGCPath ) + { + g_pFullFileSystem->CreateDirHierarchy( pUGCPath, UGC_PATHID ); + char szFullPath[MAX_PATH] = { 0 }; + g_pFullFileSystem->RelativePathToFullPath( pUGCPath, UGC_PATHID, szFullPath, sizeof( szFullPath ) ); + if ( *szFullPath ) + { + // NOTE we use our own AppID here as the workshop depot id, but this should match the workshopdepotid in our steam config + pUGC->BInitWorkshopForGameServer( engine->GetAppID(), szFullPath ); + } + else + { + TFWorkshopWarning( "Could not resolve -ugcpath to absolute path: %s\n", pUGCPath ); + } + } + else + { + TFWorkshopWarning( "Empty -ugcpath passed, using default\n" ); + } + } + else if ( i ) + { + TFWorkshopWarning( "-ugcpath is ignored for listen servers\n" ); + } + + bInitUGC = true; + } + + return pUGC; +} + +CTFWorkshopMap::CTFWorkshopMap( PublishedFileId_t fileID ) + : m_nFileID( fileID ), + m_rtimeUpdated( 0 ), + m_nFileSize( 0 ), + m_eState( eState_Refreshing ), + m_bHighPriority( false ) +{ + TFWorkshopDebug( "Created TFWorkshopMap for [ %llu ]\n", (uint64)fileID ); + + Refresh(); +} + +void CTFWorkshopMap::Refresh( eRefreshType refreshType ) +{ + ISteamUGC *steamUGC = GetWorkshopUGC(); + + if ( !steamUGC ) + { + TFWorkshopWarning( "Failed to get Steam UGC context, map will not sync [ %llu ]\n", m_nFileID ); + m_eState = eState_Error; + return; + } + + m_eState = eState_Refreshing; + + // Cancel in-flight request + if ( m_callbackQueryUGCDetails.IsActive() ) + { + m_callbackQueryUGCDetails.Cancel(); + } + + UGCQueryHandle_t ugcQuery = steamUGC->CreateQueryUGCDetailsRequest( &m_nFileID, 1 ); + bool setMeta = steamUGC->SetReturnMetadata( ugcQuery, true ); + bool setCache = steamUGC->SetAllowCachedResponse( ugcQuery, 0 ); + if ( ugcQuery == k_UGCQueryHandleInvalid || !setMeta || !setCache ) + { + TFWorkshopWarning( "Failed to create UGC details request for map [ %llu ]\n", m_nFileID ); + return; + } + SteamAPICall_t hSteamAPICall = steamUGC->SendQueryUGCRequest( ugcQuery ); + m_callbackQueryUGCDetails.Set( hSteamAPICall, this, &CTFWorkshopMap::Steam_OnQueryUGCDetails ); + + if ( refreshType == eRefresh_HighPriority ) + { + m_bHighPriority = true; + } +} + +void CTFWorkshopMap::Steam_OnQueryUGCDetails( SteamUGCQueryCompleted_t *pResult, bool bError ) +{ + if ( pResult->m_eResult != k_EResultOK ) + { + bError = true; + } + + ISteamUGC *steamUGC = GetWorkshopUGC(); + + SteamUGCDetails_t details = { 0 }; + if ( !bError && !( steamUGC->GetQueryUGCResult( pResult->m_handle, 0, &details ) && details.m_eResult == k_EResultOK ) ) + { + TFWorkshopWarning( "Error fetching updated information for map id %llu\n", m_nFileID ); + bError = true; + } + + char szMeta[k_cchDeveloperMetadataMax] = { 0 }; + if ( !bError && !steamUGC->GetQueryUGCMetadata( pResult->m_handle, 0, szMeta, sizeof( szMeta ) ) ) + { + bError = true; + TFWorkshopWarning( "Failed to get metadata for UGC file %llu\n", m_nFileID ); + } + + Assert( details.m_nPublishedFileId == m_nFileID ); + + if ( bError ) + { + TFWorkshopWarning( "Info lookup failed for workshop file %llu ( EResult %i )\n", (uint64)m_nFileID, (int)pResult->m_eResult ); + m_eState = eState_Error; + + return; + } + + // Succeeded, re-evalute + m_eState = eState_Error; + m_nFileSize = details.m_nFileSize; + m_rtimeUpdated = details.m_rtimeUpdated; + + // Our workshop maps use the metadata field for the canonical map filename + CUtlString baseName = CUtlString( szMeta ); + m_strMapName = baseName; + + if ( !baseName.Length() ) + { + TFWorkshopWarning( "Tracked map %llu has no filename and will not sync\n", m_nFileID ); + return; + } + + if ( !g_TFMapsWorkshop.CanonicalNameForMap( m_nFileID, baseName, m_strCanonicalName ) ) + { + TFWorkshopWarning( "Failed to make filename for tracked map, map will not be usuable [ baseName: %s ]\n", baseName.Get() ); + return; + } + + if ( g_TFMapsWorkshop.IsSubscribed( m_nFileID ) ) + { + // Tell serverbrowser about new subscription + IServerBrowser *pServerBrowser = GetServerBrowser(); + if ( pServerBrowser ) + { + TFWorkshopDebug( "Informing server browser of map\n" ); + // The server browser lists maps relative to maps/ without extension + if ( baseName.GetExtension() != "bsp" ) + { + TFWorkshopWarning( "Map with bogus extension, declining to track [ %s ]\n", m_strCanonicalName.Get() ); + return; + } + baseName = baseName.StripExtension(); + pServerBrowser->AddWorkshopSubscribedMap( m_strCanonicalName.Get() ); + } + } + + uint32 state = steamUGC->GetItemState( m_nFileID ); + if (( state & k_EItemStateNeedsUpdate ) || + !( state & ( k_EItemStateDownloading | k_EItemStateDownloadPending | k_EItemStateInstalled ) ) ) + { + // Either out of date or not installed, downloading, or queued to download, ask UGC to do so. The latter happens + // for maps added not from subscriptions that have no reason for UGC to initiate downloads on its own. + if ( !steamUGC->DownloadItem( m_nFileID, m_bHighPriority ) ) + { + TFWorkshopWarning( "DownloadItem failed for file, map will not be usable [ %s ]\n", m_strCanonicalName.Get() ); + return; + } + + TFWorkshopMsg( "New version available for map, download queued [ %s ]\n", m_strCanonicalName.Get() ); + m_eState = eState_Downloading; + } + else if ( engine->IsDedicatedServer() && + ( state & k_EItemStateInstalled ) && + !( state & k_EItemStateDownloading ) && + steamUGC->DownloadItem( m_nFileID, m_bHighPriority ) ) + { + // TODO This is working around a ISteamUGC bug, wherein it sends us the result of the query for a newer revision + // of the file, but GetItemState() does not see an update available yet. This only seems to occur using the + // gameserver API. Once that is fixed this is only needed if the first DownloadItem() call wasn't high + // priority. + // NOTE There is another bug where calling DownloadItem() on the *non-gameserver* api on a fully up to date item + // sometimes sets it to DownloadPending but never begins the download, causing us to wait + // forever. (Triggered by being subscribed to the file?) + uint32 newState = steamUGC->GetItemState( m_nFileID ); + DevMsg( "[TF Workshop] UGC state %u\n", newState ); + // It's unclear if DownloadItem() is supposed to be a no-op on downloaded things, or is meant to return + // false, but either way we'll now get a downloaded callback when things are good. + m_eState = eState_Downloading; + } + else + { + TFWorkshopMsg( "Got updated information for map [ %s ]\n", m_strCanonicalName.Get() ); + m_eState = Downloaded() ? eState_Downloaded : eState_Downloading; + } + + // Notify gamerules of the udpate + TFGameRules()->OnWorkshopMapUpdated( m_nFileID ); +} + +bool CTFWorkshopMap::GetLocalFile( /* out */ CUtlString &strLocalFile ) +{ + uint64 nUGCSize = 0; + uint32 nTimestamp = 0; + char szFolder[MAX_PATH] = { 0 }; + if ( !GetWorkshopUGC()->GetItemInstallInfo( m_nFileID, &nUGCSize, szFolder, sizeof( szFolder ), &nTimestamp ) ) + { + TFWorkshopWarning( "GetItemInstallInfo failed for item, map not usable [ %s ]\n", CanonicalName() ? CanonicalName() : "" ); + return false; + } + + char szFullPath[MAX_PATH] = { 0 }; + V_MakeAbsolutePath( szFullPath, sizeof( szFullPath ), m_strMapName, szFolder ); + strLocalFile = szFullPath; + return true; +} + +bool CTFWorkshopMap::Downloaded( float *flProgress ) +{ + uint32 state = GetWorkshopUGC()->GetItemState( m_nFileID ); + if (( state & k_EItemStateInstalled ) && + !( state & ( k_EItemStateNeedsUpdate | + k_EItemStateDownloadPending | + k_EItemStateDownloading ))) + { + if ( flProgress ) + { + *flProgress = 1.f; + } + return true; + } + + if ( !flProgress ) + { + // No need to calculate + return false; + } + + uint64 unDownloaded = 0; + uint64 unTotal = 0; + *flProgress = 0.f; + if ( GetWorkshopUGC()->GetItemDownloadInfo( m_nFileID, &unDownloaded, &unTotal ) && unTotal > 0 ) + { + *flProgress = (float)unDownloaded / (float)unTotal; + } + + return false; +} + +void CTFWorkshopMap::OnUGCDownload( DownloadItemResult_t *pResult ) +{ + if ( m_eState == eState_Refreshing ) + { + // This can happen if we Refresh while downloading. The info callback will check download state when it arrives, + // so it's safe to drop. + TFWorkshopDebug( "Download callback for map in refresh state [ %llu ]\n", m_nFileID ); + return; + } + + if ( m_eState != eState_Downloading ) + { + TFWorkshopWarning( "Got download callback for item in invalid state [ %llu, state %i ]\n", m_nFileID, (int)m_eState ); + return; + } + + if ( pResult->m_eResult == k_EResultOK ) + { + // TODO Due to the bug workaround in Steam_OnQueryUGCDetails (see TODO there) we trigger no-op downloads for + // even fully prepared maps, so don't spam this. + + // TFWorkshopMsg( "Map download completed [ %s ]\n", m_strCanonicalName.Get() ); + m_eState = eState_Downloaded; + } + else + { + TFWorkshopWarning( "Map download failed with result %u [ %s ]\n", pResult->m_eResult, m_strCanonicalName.Get() ); + m_eState = eState_Error; + } +} + +void CTFWorkshopMap::OnUGCItemInstalled( ItemInstalled_t *pResult ) +{ + // It's not clear this should ever happen for a map we already requested download of, but if we add a + // have-metadata-but-didnt-download state in the future this would let us short-circuit to already-downloaded, + // triggered by e.g. a user subscribing outside the game. + TFWorkshopMsg( "Installed subscribed map [ %s ]\n", m_strCanonicalName.Get() ); +} + +//----------------------------------------------------------------------------- +// Purpose: Main maps workshop constructor +//----------------------------------------------------------------------------- +CTFMapsWorkshop::CTFMapsWorkshop() + : m_callbackDownloadItem( NULL, NULL ) + , m_callbackItemInstalled( NULL, NULL ) + , m_callbackDownloadItem_GameServer( NULL, NULL ) + , m_callbackItemInstalled_GameServer( NULL, NULL ) + , m_mapMaps( 0, 0, PublishedFileId_t_Less ) + , m_nPreparingMap( k_PublishedFileIdInvalid ) +{ +} + +//----------------------------------------------------------------------------- +// Purpose: Initialize workshop and start any background tasks +//----------------------------------------------------------------------------- +bool CTFMapsWorkshop::Init( void ) +{ + if ( !engine->IsDedicatedServer() ) + { + IServerBrowser *pServerBrowser = GetServerBrowser(); + if ( pServerBrowser ) + { + pServerBrowser->SetWorkshopEnabled( true ); + } + + // Refresh for dedicated servers will happen in GameServerAPIActivated. + Refresh(); + } + + if ( engine->IsDedicatedServer() ) + { + m_callbackDownloadItem_GameServer.Register( this, &CTFMapsWorkshop::Steam_OnUGCDownload ); + m_callbackItemInstalled_GameServer.Register( this, &CTFMapsWorkshop::Steam_OnUGCItemInstalled ); + } + else + { + m_callbackDownloadItem.Register( this, &CTFMapsWorkshop::Steam_OnUGCDownload ); + m_callbackItemInstalled.Register( this, &CTFMapsWorkshop::Steam_OnUGCItemInstalled ); + } + + return true; +} + +//----------------------------------------------------------------------------- +// Purpose: Stop & cleanup any tasks in progress +//----------------------------------------------------------------------------- +void CTFMapsWorkshop::Shutdown( void ) +{ + m_mapMaps.PurgeAndDeleteElements(); + + if ( engine->IsDedicatedServer() ) + { + m_callbackDownloadItem_GameServer.Unregister(); + m_callbackItemInstalled_GameServer.Unregister(); + } + else + { + m_callbackDownloadItem.Unregister(); + m_callbackItemInstalled.Unregister(); + } +} + +//----------------------------------------------------------------------------- +// Purpose: Callback for a DownloadItem() call by us completing, mark map as finished +//----------------------------------------------------------------------------- +void CTFMapsWorkshop::Steam_OnUGCDownload( DownloadItemResult_t *pResult ) +{ + // This is a generic callback for any downloads happening, we're listening to handle any relevant to us + PublishedFileId_t nFileID = pResult->m_nPublishedFileId; + if ( nFileID == k_PublishedFileIdInvalid ) + { + TFWorkshopWarning( "Got UGCDownload notice for invalid item ID\n" ); + return; + } + + unsigned short nInd = m_mapMaps.Find( nFileID ); + if ( nInd != m_mapMaps.InvalidIndex() ) + { + // This is a map of ours, notify it + TFWorkshopDebug( "Got DownloadItemResult for %llu\n", nFileID ); + m_mapMaps[ nInd ]->OnUGCDownload( pResult ); + } +} + +//----------------------------------------------------------------------------- +// Purpose: Handle steam-initiated item installs +//----------------------------------------------------------------------------- +void CTFMapsWorkshop::Steam_OnUGCItemInstalled( ItemInstalled_t *pResult ) +{ + // This is a generic callback for any downloads happening, we're listening to handle any relevant to us + PublishedFileId_t nFileID = pResult->m_nPublishedFileId; + if ( nFileID == k_PublishedFileIdInvalid ) + { + TFWorkshopWarning( "Got ItemInstalled notice for invalid item ID\n" ); + return; + } + + unsigned short nInd = m_mapMaps.Find( nFileID ); + if ( nInd != m_mapMaps.InvalidIndex() ) + { + // This is a map of ours, notify it + TFWorkshopDebug( "Got ItemInstalled for %llu\n", nFileID ); + m_mapMaps[ nInd ]->OnUGCItemInstalled( pResult ); + } +} + +//----------------------------------------------------------------------------- +// Purpose: Rebuild our subscriptions. +//----------------------------------------------------------------------------- +void CTFMapsWorkshop::Refresh() +{ + TFWorkshopDebug( "Refresh\n" ); + + // Ensure directory for maps exists + g_pFullFileSystem->CreateDirHierarchy( "maps/workshop", UGC_PATHID ); + + ISteamUGC *steamUGC = GetWorkshopUGC(); + + if ( !steamUGC ) + { + TFWorkshopWarning( "Failed to get Steam UGC service, refresh failed\n" ); + return; + } + + // Check existing maps + FOR_EACH_MAP( m_mapMaps, i ) + { + m_mapMaps[i]->Refresh(); + } + + // Servers are on the steamgameserver API without subscriptions + if ( !engine->IsDedicatedServer() ) + { + // Get new subscriptions + m_vecSubscribedMaps.RemoveAll(); + + uint32 maxResults = steamUGC->GetNumSubscribedItems(); + m_vecSubscribedMaps.AddMultipleToTail( maxResults ); + uint32 numResults = steamUGC->GetSubscribedItems( m_vecSubscribedMaps.Base(), maxResults ); + if ( numResults < maxResults ) + { + m_vecSubscribedMaps.RemoveMultipleFromTail( maxResults - numResults ); + } + + // Check new subscriptions for maps we're not tracking, queue info requests + int newMaps = 0; + FOR_EACH_VEC( m_vecSubscribedMaps, i ) + { + // Ignore maps we're already tracking + PublishedFileId_t fileID = m_vecSubscribedMaps[i]; + + if ( m_mapMaps.Find( fileID ) == m_mapMaps.InvalidIndex() ) + { + CTFWorkshopMap *newMap = new CTFWorkshopMap( fileID ); + m_mapMaps.Insert( fileID, newMap ); + + TFWorkshopDebug( "Created workshop map %llu\n", fileID ); + newMaps++; + } + } + + TFWorkshopMsg( "Got %u subscribed maps, %u new\n", m_vecSubscribedMaps.Count(), newMaps ); + } +} + +//----------------------------------------------------------------------------- +// Purpose: Check if a map is in our subscribed list +//----------------------------------------------------------------------------- +bool CTFMapsWorkshop::IsSubscribed( PublishedFileId_t nFileID ) +{ + return ( m_vecSubscribedMaps.Find( nFileID ) != m_vecSubscribedMaps.InvalidIndex() ); +} + +//----------------------------------------------------------------------------- +// Purpose: Hook from BaseClientDLL to allow us to catch and prepare a workshop map load +//----------------------------------------------------------------------------- +IServerGameDLL::ePrepareLevelResourcesResult +CTFMapsWorkshop::AsyncPrepareLevelResources( /* in/out */ char *pMapName, size_t nMaxMapNameLen, + /* in/out */ char *pMapFileToUse, size_t nMaxMapFileLen, + float *flProgress /* = NULL */ ) +{ + // Files from this hook start with maps/ + PublishedFileId_t nMapID = k_PublishedFileIdInvalid; + CUtlString localName( pMapName ); + localName.ToLower(); + nMapID = MapIDFromName( localName ); + + // Doesn't look like a workshop map load + if ( nMapID == k_PublishedFileIdInvalid ) + { + if ( flProgress ) + { + *flProgress = 1.f; + } + m_nPreparingMap = k_PublishedFileIdInvalid; + return IServerGameDLL::ePrepareLevelResources_Prepared; + } + + bool bNewPrepare = ( m_nPreparingMap != nMapID ); + m_nPreparingMap = nMapID; + + TFWorkshopDebug( "OnClientPrepareLevelResources for [ %s ]\n", pMapName ); + + unsigned int nIndex = m_mapMaps.Find( nMapID ); + CTFWorkshopMap *pMap = NULL; + if ( nIndex == m_mapMaps.InvalidIndex() ) + { + TFWorkshopMsg( "Map ID %llu isn't tracked, adding\n", nMapID ); + pMap = new CTFWorkshopMap( nMapID ); + m_mapMaps.Insert( nMapID, pMap ); + } + else + { + pMap = m_mapMaps[ nIndex ]; + } + + if ( bNewPrepare ) + { + // Even if map is up to date, it could be stale, so always start a new prepare with a re-check + pMap->Refresh( CTFWorkshopMap::eRefresh_HighPriority ); + } + + if ( pMap->State() == CTFWorkshopMap::eState_Refreshing ) + { + if ( flProgress ) + { + *flProgress = 0.f; + } + return IServerGameDLL::ePrepareLevelResources_InProgress; + } + + if ( pMap->State() == CTFWorkshopMap::eState_Downloading ) + { + // Get download % + if ( flProgress ) + { + pMap->Downloaded( flProgress ); + } + + return IServerGameDLL::ePrepareLevelResources_InProgress; + } + + if ( pMap->State() == CTFWorkshopMap::eState_Downloaded ) + { + // Get file name & canonical name + CUtlString fileName; + if ( pMap->GetLocalFile( fileName ) ) + { + TFWorkshopMsg( "Successfully prepared client map from workshop [ %s ]\n", pMap->CanonicalName() ); + V_strncpy( pMapFileToUse, fileName.Get(), nMaxMapFileLen ); + V_strncpy( pMapName, pMap->CanonicalName(), nMaxMapNameLen ); + } + else + { + // Tell engine we're done so it can go on and fail. It should be using maps/workshop/foo.ugc1234.bsp as a fallback... + TFWorkshopWarning( "Map synced, but failed to resolve local file [ %s ]\n", pMap->CanonicalName() ? pMap->CanonicalName() : "" ); + } + } + else + { + Assert ( pMap->State() == CTFWorkshopMap::eState_Error ); + TFWorkshopWarning( "Map failed to sync, load will not go well :(\n" ); + // Tell engine we're done so it can go on and fail + } + + if ( flProgress ) + { + *flProgress = 1.f; + } + + // New calls to this map ID are new loads + m_nPreparingMap = k_PublishedFileIdInvalid; + + return IServerGameDLL::ePrepareLevelResources_Prepared; +} + +//----------------------------------------------------------------------------- +// Purpose: Hook from ServerGameDLL to allow us to catch and prepare a workshop map load +//----------------------------------------------------------------------------- +void CTFMapsWorkshop::PrepareLevelResources( /* in/out */ char *pszMapName, size_t nMapNameSize, + /* in/out */ char *pszMapFile, size_t nMapFileSize ) +{ + // Prepare the map if necessary + PublishedFileId_t nWorkshopID = MapIDFromName( pszMapName ); + if ( nWorkshopID == k_PublishedFileIdInvalid ) + { + return; + } + + // If we are a dedicated server, we're using the special steam gameserver UGC context, and need to make sure + // we're logged in first. + if ( engine->IsDedicatedServer() ) + { + if ( !steamgameserverapicontext || !steamgameserverapicontext->SteamGameServer() ) + { + TFWorkshopWarning( "No steam connection in PrepareLevelResources, workshop map loads will fail\n" ); + return; + } + + // Wait for login to finish, which is async and may not be done yet on initial map load + if ( !steamgameserverapicontext->SteamGameServer()->BLoggedOn() ) + { + TFWorkshopMsg( "Waiting for steam connection\n" ); + while ( !steamgameserverapicontext->SteamGameServer()->BLoggedOn() ) + { + ThreadSleep( 10 ); + } + } + } + + TFWorkshopMsg( "Preparing map ID %llu\n", nWorkshopID ); + + while ( AsyncPrepareLevelResources( pszMapName, nMapNameSize, pszMapFile, nMapFileSize ) == \ + IServerGameDLL::ePrepareLevelResources_InProgress ) + { + ThreadSleep( 10 ); + if ( engine->IsDedicatedServer() ) + { + SteamGameServer_RunCallbacks(); + } + else + { + SteamAPI_RunCallbacks(); + } + } +} + +//----------------------------------------------------------------------------- +// Purpose: Hook from ServerGameDLL to ask if we can provide a level as a workshop map +//----------------------------------------------------------------------------- +IServerGameDLL::eCanProvideLevelResult +CTFMapsWorkshop::OnCanProvideLevel( /* in/out */ char *pMapName, int nMapNameMax ) +{ + // Prepare the map if necessary + PublishedFileId_t nWorkshopID = MapIDFromName( pMapName ); + if ( nWorkshopID != k_PublishedFileIdInvalid ) + { + auto index = m_mapMaps.Find( nWorkshopID ); + if ( index == m_mapMaps.InvalidIndex() ) + { + // Looks like a workshop map, but it's not currently available + return IServerGameDLL::eCanProvideLevel_Possibly; + } + + const char *szCanonicalName = m_mapMaps[ index ]->CanonicalName(); + // A workshop map that we know about + // Provide canonical map name if known. + if ( szCanonicalName && szCanonicalName[0] ) + { + V_strncpy( pMapName, szCanonicalName, nMapNameMax ); + } + + if ( m_mapMaps[ index ]->State() != CTFWorkshopMap::eState_Downloaded ) + { + return IServerGameDLL::eCanProvideLevel_Possibly; + } + + AssertMsg( !GetWorkshopUGC() || m_mapMaps[ index ]->Downloaded(), "Map in state Downloaded isn't" ); + + // Should have canonical name if it is downloaded + if ( !szCanonicalName || !szCanonicalName[0] ) + { + TFWorkshopWarning( "Map is marked available but has no proper name configured [ %llu ]\n", nWorkshopID ); + return IServerGameDLL::eCanProvideLevel_Possibly; + } + return IServerGameDLL::eCanProvideLevel_CanProvide; + } + + return IServerGameDLL::eCanProvideLevel_CannotProvide; +} + +//----------------------------------------------------------------------------- +// Purpose: Hook from ServerGameDLL to tell us the steam API is alive +//----------------------------------------------------------------------------- +void CTFMapsWorkshop::GameServerSteamAPIActivated() +{ + if ( engine->IsDedicatedServer() ) + { + Refresh(); + } +} + +//----------------------------------------------------------------------------- +// Purpose: Backend for tf_workshop_map_status +//----------------------------------------------------------------------------- +void CTFMapsWorkshop::PrintStatusToConsole() +{ + // Find longest map name + unsigned int nMapLen = 12; // minimum for column header and padding + FOR_EACH_MAP_FAST( m_mapMaps, idx ) + { + CUtlString mapName; + GetMapName( m_mapMaps[idx]->FileID(), mapName ); + nMapLen = Max( nMapLen, (unsigned int)mapName.Length() ); + } + + char szHeaderFmt[128] = { 0 }; + char szLineFmt[128] = { 0 }; + V_snprintf( szHeaderFmt, sizeof( szHeaderFmt ), "%%20s %%%us %%12s\n", nMapLen ); + V_snprintf( szLineFmt, sizeof( szLineFmt ), "%%20llu %%%us %%12s\n", nMapLen ); + + Msg( szHeaderFmt, "FileID", "Map Name", "Status" ); + Msg( szHeaderFmt, "---", "---", "---" ); + + FOR_EACH_MAP_FAST( m_mapMaps, idx ) + { + const CTFWorkshopMap &map = *m_mapMaps[idx]; + const char *pState = "unknown"; + switch ( map.State() ) + { + case CTFWorkshopMap::eState_Refreshing: + pState = "refreshing"; + break; + case CTFWorkshopMap::eState_Error: + pState = "error"; + break; + case CTFWorkshopMap::eState_Downloading: + pState = "downloading"; + break; + case CTFWorkshopMap::eState_Downloaded: + pState = "ready"; + break; + } + + CUtlString mapName; + GetMapName( map.FileID(), mapName ); + Msg( szLineFmt, map.FileID(), mapName.Get(), pState ); + } + + Msg( "%u tracked maps\n", m_mapMaps.Count() ); +} + +//----------------------------------------------------------------------------- +bool CTFMapsWorkshop::CanonicalNameForMap( PublishedFileId_t fileID, const CUtlString &originalFileName, /* out */ CUtlString &strCanonName ) +{ + if ( !IsValidOriginalFileNameForMap( originalFileName ) ) + { + TFWorkshopWarning( "Invalid workshop map name %llu [ %s ]\n", fileID, originalFileName.Get() ); + return false; + } + + // cp_mymap.bsp -> workshop/cp_mymap.ugc12345 + char szBase[MAX_PATH]; + V_FileBase( originalFileName.Get(), szBase, sizeof( szBase ) ); + + int len = strCanonName.Format( "workshop/%s.ugc%llu", szBase, fileID ); + if ( len >= MAX_PATH ) + { + Assert( len < MAX_PATH ); + // This should be caught by the name validator but + return false; + } + + return true; +} + +//----------------------------------------------------------------------------- +CTFMapsWorkshop::eNameType CTFMapsWorkshop::GetMapName( PublishedFileId_t nMapID, /* out */ CUtlString &mapName ) +{ + auto index = m_mapMaps.Find( nMapID ); + if ( index != m_mapMaps.InvalidIndex() ) + { + const char *pCanonName = m_mapMaps[ index ]->CanonicalName(); + if ( pCanonName ) + { + mapName = pCanonName; + return CTFMapsWorkshop::eName_Canon; + } + } + + // Default stub name + mapName.Format( "workshop/%llu", nMapID ); + return CTFMapsWorkshop::eName_Incomplete; +} + +//----------------------------------------------------------------------------- +CTFWorkshopMap *CTFMapsWorkshop::FindMapByName( const char *pMapName ) +{ + PublishedFileId_t nWorkshopID = MapIDFromName( pMapName ); + if ( nWorkshopID != k_PublishedFileIdInvalid ) + { + auto index = m_mapMaps.Find( nWorkshopID ); + if ( index != m_mapMaps.InvalidIndex() ) + { + return m_mapMaps[ index ]; + } + } + + return NULL; +} + +//----------------------------------------------------------------------------- +CTFWorkshopMap *CTFMapsWorkshop::FindOrCreateMapByName( const char *pMapName ) +{ + PublishedFileId_t nWorkshopID = MapIDFromName( pMapName ); + if ( nWorkshopID != k_PublishedFileIdInvalid ) + { + auto index = m_mapMaps.Find( nWorkshopID ); + if ( index != m_mapMaps.InvalidIndex() ) + { + return m_mapMaps[ index ]; + } + + // Not found, but valid-looking workshop name, create + CTFWorkshopMap *pMap = new CTFWorkshopMap( nWorkshopID ); + m_mapMaps.Insert( nWorkshopID, pMap ); + return pMap; + } + + return NULL; +} + +//----------------------------------------------------------------------------- +// Purpose: Synchronously prepare a map for use, assuming it has been subscribed to +//----------------------------------------------------------------------------- +PublishedFileId_t CTFMapsWorkshop::MapIDFromName( CUtlString localMapName ) +{ + localMapName.ToLower(); + const char szWorkshopPrefix[] = "workshop/"; + + if ( localMapName.Slice( 0, sizeof( szWorkshopPrefix ) - 1 ) != szWorkshopPrefix ) + { + TFWorkshopDebug( "Map '%s' does not appear to be a workshop map -- no workshop/ prefix\n", localMapName.Get() ); + return k_PublishedFileIdInvalid; + } + + // Check canonical format: workshop/cp_anyname.ugc1234 + // Find .ugc, ensure its followed by a number + const char szUGCSuffix[] = ".ugc"; + const size_t nSuffixLen = sizeof( szUGCSuffix ) - 1; + + CUtlString strID; + + char *pszUGCSuffix = V_strstr( localMapName.Get(), szUGCSuffix ); + if ( pszUGCSuffix && strlen( pszUGCSuffix ) >= nSuffixLen + 1 ) + { + // Need at least five for ".ugc1" + strID = pszUGCSuffix + nSuffixLen; + + // Check that the name string is at least a valid workshop map name. It doesn't have to match the real name, + // since IDs can update their display name at arbitrary points, but "workshop/\n\n\x1.ugc5" should not parse as + // a valid alias for workshop/5 + CUtlString baseMapName = localMapName.Slice( sizeof( szWorkshopPrefix ) - 1, + (int32)((intptr_t)pszUGCSuffix - (intptr_t)localMapName.Get()) ); + if ( !IsValidDisplayNameForMap( baseMapName ) ) + { + TFWorkshopDebug( "Map '%s' looks like a workshop map, but '%s' is not a legal workshop map name\n", + localMapName.Get(), baseMapName.Get() ); + return k_PublishedFileIdInvalid; + } + } + else + { + // Assume workshop/12345 shorthand, we'll fail if we hit a non-number parsing it + strID = localMapName.Slice( sizeof( szWorkshopPrefix ) - 1 ); + } + + int i; + for ( i = 0; i < strID.Length(); i ++ ) + { + if ( strID[i] < '0' || strID[i] > '9' ) + { + break; + } + } + + if ( i != strID.Length() ) + { + return k_PublishedFileIdInvalid; + } + + // Found ID and it was all numbers, sscanf it + PublishedFileId_t nMapID = k_PublishedFileIdInvalid; + sscanf( strID.Get(), "%llu", &nMapID ); + return nMapID; +} + +//----------------------------------------------------------------------------- +// Purpose: Add this map to our list for this session, triggering download/etc as if it were subscribed +//----------------------------------------------------------------------------- +bool CTFMapsWorkshop::AddMap( PublishedFileId_t nMapID ) +{ + unsigned int nIndex = m_mapMaps.Find( nMapID ); + if ( nIndex == m_mapMaps.InvalidIndex() ) + { + m_mapMaps.Insert( nMapID, new CTFWorkshopMap( nMapID ) ); + return true; + } + + return false; +} + +//----------------------------------------------------------------------------- +// Purpose: Command to trigger refresh +//----------------------------------------------------------------------------- +CON_COMMAND( tf_workshop_refresh, "tf_workshop_refresh" ) +{ +#ifdef GAME_DLL + if ( !UTIL_IsCommandIssuedByServerAdmin() ) + return; +#endif + + if ( args.ArgC() != 1 ) + { + TFWorkshopMsg( "Usage: tf_workshop_refresh - Trigger a recheck subscriptions and tracked maps\n" ); + return; + } + + TFWorkshopMsg( "Requesting maps refresh\n" ); + g_TFMapsWorkshop.Refresh(); +} + +//----------------------------------------------------------------------------- +// Purpose: Command to sync prepare map +//----------------------------------------------------------------------------- +CON_COMMAND( tf_workshop_map_sync, "Add a map to the workshop auto-sync list" ) +{ +#ifdef GAME_DLL + if ( !UTIL_IsCommandIssuedByServerAdmin() ) + return; +#endif + + PublishedFileId_t nTargetID = 0; + if ( args.ArgC() == 2 ) + { + sscanf( args[1], "%llu", &nTargetID ); + } + + if ( !nTargetID ) + { + TFWorkshopMsg( "Usage: tf_workshop_map_sync <map ugc id> - Add a map to the workshop auto-sync list\n" ); + return; + } + + if ( g_TFMapsWorkshop.AddMap( nTargetID ) ) + { + TFWorkshopMsg( "Added %llu to tracked maps\n", nTargetID ); + } + else + { + TFWorkshopMsg( "Map %llu is already tracked\n", nTargetID ); + } +} + +CON_COMMAND( tf_workshop_map_status, "Print information about workshop maps and their status" ) +{ +#ifdef GAME_DLL + if ( !UTIL_IsCommandIssuedByServerAdmin() ) + return; +#endif + + g_TFMapsWorkshop.PrintStatusToConsole(); +} + +#endif // !_GAMECONSOLE |