You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1005 lines
31 KiB
1005 lines
31 KiB
//====== 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
|
|
|