diff --git a/Core/GameEngine/CMakeLists.txt b/Core/GameEngine/CMakeLists.txt index e6aee6da85f..8f5892206e0 100644 --- a/Core/GameEngine/CMakeLists.txt +++ b/Core/GameEngine/CMakeLists.txt @@ -15,6 +15,7 @@ set(GAMEENGINE_SRC # Include/Common/BattleHonors.h # Include/Common/BezFwdIterator.h # Include/Common/BezierSegment.h + Include/Common/BIGFileSystem.h # Include/Common/BitFlags.h # Include/Common/BitFlagsIO.h # Include/Common/BorderColors.h @@ -644,6 +645,7 @@ set(GAMEENGINE_SRC Source/Common/System/ArchiveFile.cpp Source/Common/System/ArchiveFileSystem.cpp Source/Common/System/AsciiString.cpp + Source/Common/System/BIGFileSystem.cpp # Source/Common/System/BuildAssistant.cpp # Source/Common/System/CriticalSection.cpp # Source/Common/System/DataChunk.cpp diff --git a/Core/GameEngine/Include/Common/ArchiveFile.h b/Core/GameEngine/Include/Common/ArchiveFile.h index 63987f9c9cb..61a43117017 100644 --- a/Core/GameEngine/Include/Common/ArchiveFile.h +++ b/Core/GameEngine/Include/Common/ArchiveFile.h @@ -47,26 +47,35 @@ class File; class ArchiveFile { public: - ArchiveFile(); - virtual ~ArchiveFile(); + ArchiveFile(AsciiString name, AsciiString path); + ~ArchiveFile(); - virtual Bool getFileInfo( const AsciiString& filename, FileInfo *fileInfo) const = 0; ///< fill in the fileInfo struct with info about the file requested. - virtual File* openFile( const Char *filename, Int access = 0) = 0; ///< Open the specified file within the archive file - virtual void closeAllFiles() = 0; ///< Close all file opened in this archive file - virtual AsciiString getName() = 0; ///< Returns the name of the archive file - virtual AsciiString getPath() = 0; ///< Returns full path and name of archive file - virtual void setSearchPriority( Int new_priority ) = 0; ///< Set this archive file's search priority - virtual void close() = 0; ///< Close this archive file + Bool getFileInfo( const AsciiString& filename, FileInfo *fileInfo) const; ///< fill in the fileInfo struct with info about the file requested. + File* openFile( const Char *filename, Int access = 0); ///< Open the specified file within the archive file + void closeAllFiles(); ///< Close all file opened in this archive file + AsciiString getName(); ///< Returns the name of the archive file + AsciiString getPath(); ///< Returns full path and name of archive file + void setSearchPriority( Int new_priority ); ///< Set this archive file's search priority + void close(); ///< Close this archive file void attachFile(File *file); + void setPriority(Int priority) { m_priority = priority; } + Int getPriority() const { return m_priority; } void getFileListInDirectory(const AsciiString& currentDirectory, const AsciiString& originalDirectory, const AsciiString& searchName, FilenameList &filenameList, Bool searchSubdirectories) const; - void getFileListInDirectory(const DetailedArchivedDirectoryInfo *dirInfo, const AsciiString& currentDirectory, const AsciiString& searchName, FilenameList &filenameList, Bool searchSubdirectories) const; void addFile(const AsciiString& path, const ArchivedFileInfo *fileInfo); ///< add this file to our directory tree. protected: + void getFileListInDirectory(const DetailedArchivedDirectoryInfo *dirInfo, const AsciiString& currentDirectory, const AsciiString& searchName, FilenameList &filenameList, Bool searchSubdirectories) const; const ArchivedFileInfo * getArchivedFileInfo(const AsciiString& filename) const; ///< return the ArchivedFileInfo from the directory tree. +protected: + Int m_priority = ARCHIVE_PRIORITY_RETAIL; ///< lookup priority in the archive file system, lower wins File *m_file; ///< file pointer to the archive file on disk. Kept open so we don't have to continuously open and close the file all the time. + FileInfo m_fileInfo; ///< file info of the archive file on disk. + AsciiString m_name; ///< archive file name + AsciiString m_path; ///< archive file path + +private: DetailedArchivedDirectoryInfo m_rootDirectory; }; diff --git a/Core/GameEngine/Include/Common/ArchiveFileSystem.h b/Core/GameEngine/Include/Common/ArchiveFileSystem.h index 48d144484c6..381765359ac 100644 --- a/Core/GameEngine/Include/Common/ArchiveFileSystem.h +++ b/Core/GameEngine/Include/Common/ArchiveFileSystem.h @@ -53,6 +53,8 @@ #include "Common/AsciiString.h" #include "Common/FileSystem.h" // for typedefs, etc. #include "Common/STLTypedefs.h" +#include +#include //---------------------------------------------------------------------------- // Forward References @@ -120,6 +122,31 @@ class ArchivedFileInfo }; +// Lookup priority of an archive, lower wins. The archive mounted first wins on equal priority. +enum ArchivePriority +{ + ARCHIVE_PRIORITY_EXCLUDED = -1, ///< not mounted: a BIG of a game folder that matches user.exclude_paths + ARCHIVE_PRIORITY_MOD = 0, ///< -mod and -modDir, 0 to 999, a later one is lower and wins + ARCHIVE_PRIORITY_GAME_FOLDER = 1000, ///< BIGs of the Zero Hour folder that are not retail, in name order + ARCHIVE_PRIORITY_GAME_FOLDER_GENERALS = 1100, ///< BIGs of the Generals data (ZH_Generals or the Generals install) that are not retail + ARCHIVE_PRIORITY_USER = 100000, ///< data_packs.user of settings.json, 100000 plus the position in its include_paths + ARCHIVE_PRIORITY_GENERALS_ONLINE = 1000000, ///< data_packs.generals_online, 1000000 plus the position in its include_paths + ARCHIVE_PRIORITY_COMMUNITY_PATCH = 2000000, ///< data_packs.community_patch, 2000000 plus the position in its include_paths + ARCHIVE_PRIORITY_RETAIL = 3000000, ///< Zero Hour retail and official patch BIGs + ARCHIVE_PRIORITY_RETAIL_GENERALS = 3000100 ///< Generals retail and official patch BIGs +}; + +// One entry of the include_paths of a data pack layer in settings.json, see ArchiveFileSystem::parseMountEntry +struct ArchiveMountEntry +{ + enum Kind { KIND_BIG, KIND_BIG_GLOB }; + enum Base { BASE_GAME, BASE_USER, BASE_ABSOLUTE }; + + Kind m_kind; + Base m_base; + AsciiString m_path; ///< with backslashes, relative to the base, the BASE_ABSOLUTE path is complete +}; + class ArchiveFileSystem : public SubsystemInterface { public: @@ -141,12 +168,22 @@ class ArchiveFileSystem : public SubsystemInterface void getFileListInDirectory(const AsciiString& currentDirectory, const AsciiString& originalDirectory, const AsciiString& searchName, FilenameList &filenameList, Bool searchSubdirectories) const; ///< search the given directory for files matching the searchName (egs. *.ini, *.rep). Possibly search subdirectories. Scans each Archive file. Bool getFileInfo(const AsciiString& filename, FileInfo *fileInfo, FileInstance instance = 0) const; ///< see FileSystem.h - virtual Bool loadBigFilesFromDirectory(AsciiString dir, AsciiString fileMask, Bool overwrite = FALSE) = 0; + virtual Bool loadArchiveFilesFromDirectory(AsciiString dir, AsciiString fileMask, Bool overwrite = FALSE) = 0; - // Unprotected this for copy-protection routines - ArchiveFile* getArchiveFile(const AsciiString& filename, FileInstance instance = 0) const; + virtual void loadMods() = 0; + + /// Parses an entry of the include_paths of a data pack layer: "game:", "user:" or an absolute path. A path ending in .big is one BIG, + /// a last component with * or ? and ending in .big is a set of BIGs, a path ending in a slash is every BIG in that folder. + /// Returns FALSE and a reason for an entry that is not valid. + static Bool parseMountEntry(const AsciiString& text, ArchiveMountEntry* entry, AsciiString* reason); + + /// TRUE for the retail archives and the official patches of Zero Hour and Generals, in every language. + static Bool isGeneralsArchivePath(const AsciiString& path, Bool generalsFolder); ///< TRUE for the Generals archives: the Generals install folder, or a ZH_Generals folder + + static Bool isRetailArchive(const AsciiString& path, Bool generalsFolder, Bool zeroHour); - void loadMods(); + /// BIGs of the game folders that are not retail and not excluded by user.exclude_paths, which are mounted above the data pack layers + const std::vector& getGameFolderArchives() const { return m_gameFolderArchives; } ArchivedDirectoryInfo* friend_getArchivedDirectoryInfo(const Char* directory); @@ -160,11 +197,21 @@ class ArchiveFileSystem : public SubsystemInterface AsciiString lastToken; ///< Synonymous for file name if the search directory was a file path }; + ArchiveFile* getArchiveFile(const AsciiString& filename, FileInstance instance = 0) const; ArchivedDirectoryInfoResult getArchivedDirectoryInfo(const Char* directory); - virtual void loadIntoDirectoryTree(ArchiveFile *archiveFile, Bool overwrite = FALSE, Bool sortedByName = FALSE); ///< load the archive file's header information and apply it to the global archive directory tree. sortedByName inserts by archive filename order (addon number convention), ignoring overwrite. + virtual void loadIntoDirectoryTree(ArchiveFile *archiveFile, Int priority = ARCHIVE_PRIORITY_RETAIL); ///< load the archive file's header information and apply it to the global archive directory tree. The lowest priority number wins, the first mounted wins on equal priority. + + Int getNextModPriority() { return m_nextModPriority > ARCHIVE_PRIORITY_MOD ? m_nextModPriority-- : ARCHIVE_PRIORITY_MOD; } ///< -mod and -modDir: a later one wins + Int getGameFolderArchivePriority(const AsciiString& path, Bool generalsFolder, Bool zeroHour); ///< retail archives are the bottom, any other BIG of the game folders ranks above the data pack layers unless user.exclude_paths matches it + + void mountListedArchive(const ArchiveMountEntry& entry, Int priority, const PathExcludeList* excludes); + void mountDataPackLayer(const char* layer, Bool enabled, const std::vector& includePaths, const PathExcludeList* excludes, Int priority); ///< mounts the include_paths of one layer in list order, the first one wins, minus what excludes matches ArchiveFileMap m_archiveFileMap; + std::vector m_gameFolderArchives; + AsciiString m_generalsBaseSource; ///< where the Generals data came from, for the log + Int m_nextModPriority = ARCHIVE_PRIORITY_MOD + 999; ArchivedDirectoryInfo m_rootDirectory; }; diff --git a/Core/GameEngineDevice/Include/Win32Device/Common/Win32BIGFileSystem.h b/Core/GameEngine/Include/Common/BIGFileSystem.h similarity index 85% rename from Core/GameEngineDevice/Include/Win32Device/Common/Win32BIGFileSystem.h rename to Core/GameEngine/Include/Common/BIGFileSystem.h index 6819ba90577..95e20c0ea6c 100644 --- a/Core/GameEngineDevice/Include/Win32Device/Common/Win32BIGFileSystem.h +++ b/Core/GameEngine/Include/Common/BIGFileSystem.h @@ -22,7 +22,7 @@ // // //////////////////////////////////////////////////////////////////////////////// -//////// Win32BIGFileSystem.h /////////////////////////// +//////// BIGFileSystem.h /////////////////////////// // Bryan Cleveland, August 2002 ///////////////////////////////////////////////////////////// @@ -30,11 +30,11 @@ #include "Common/ArchiveFileSystem.h" -class Win32BIGFileSystem : public ArchiveFileSystem +class BIGFileSystem : public ArchiveFileSystem { public: - Win32BIGFileSystem(); - virtual ~Win32BIGFileSystem() override; + BIGFileSystem(); + virtual ~BIGFileSystem() override; virtual void init() override; virtual void update() override; @@ -49,7 +49,9 @@ class Win32BIGFileSystem : public ArchiveFileSystem virtual void closeArchiveFile(const Char *filename) override; virtual void closeAllFiles() override; ///< Close all files associated with ArchiveFiles - virtual Bool loadBigFilesFromDirectory(AsciiString dir, AsciiString fileMask, Bool overwrite = FALSE) override; + virtual Bool loadArchiveFilesFromDirectory(AsciiString dir, AsciiString fileMask, Bool overwrite = FALSE) override; + + virtual void loadMods() override; protected: }; diff --git a/Core/GameEngine/Include/Common/FileSystem.h b/Core/GameEngine/Include/Common/FileSystem.h index 8a8b99a9f8c..d4a518434d6 100644 --- a/Core/GameEngine/Include/Common/FileSystem.h +++ b/Core/GameEngine/Include/Common/FileSystem.h @@ -53,6 +53,9 @@ #include +#include +#include + #include "WWLib/mutex.h" //---------------------------------------------------------------------------- @@ -109,6 +112,14 @@ typedef UnsignedByte FileInstance; struct FileInfo { + FileInfo() + : sizeHigh(0) + , sizeLow(0) + , timestampHigh(0) + , timestampLow(0) + { + } + Int64 size() const { return (Int64)sizeHigh << 32 | sizeLow; } Int64 timestamp() const { return (Int64)timestampHigh << 32 | timestampLow; } @@ -118,6 +129,30 @@ struct FileInfo { Int timestampLow; }; +//=============================== +// PathExcludeList +//=============================== +/** + * The exclude_paths of settings.json. An entry is game:, user: or an absolute pattern, where * matches any text + * including folders and ? one character. A pattern ending in a slash is a folder and everything in it. Matching ignores case and slash direction. + * A path is tested as a key: game:, user: or the absolute path. + */ +class PathExcludeList +{ +public: + static Bool parse(const std::string& text, std::string* pattern, AsciiString* reason); ///< FALSE and a reason for an entry that is not valid + static std::string makeKey(const Char* path); ///< key of a path that is relative to the game folder or absolute + static std::string makeKey(const Char* path, const Char* userDataPath); ///< same, a path inside the user data folder becomes user: + + void set(const std::vector& entries); ///< entries that are not valid are dropped + Bool isEmpty() const { return m_patterns.empty(); } + Bool matches(const std::string& key) const; + Bool matchesPath(const Char* path) const { return !m_patterns.empty() && matches(makeKey(path)); } + +private: + std::vector m_patterns; +}; + //=============================== // FileSystem //=============================== @@ -157,6 +192,10 @@ class FileSystem : public SubsystemInterface Bool createDirectory(AsciiString directory); ///< create a directory of the given name. + /// Loose files of the game folder that match are treated as if they did not exist: not opened, not found, not listed. Files that are written and files outside the game folder are not affected. + void setExcludedPaths(const std::vector& entries) { m_excludedPaths.set(entries); } + const PathExcludeList& getExcludedPaths() const { return m_excludedPaths; } + static AsciiString normalizePath(const AsciiString& path); ///< normalizes a file path. The path can refer to a directory. File path must be absolute, but does not need to exist. Returns an empty string on failure. static Bool isPathInDirectory(const AsciiString& testPath, const AsciiString& basePath); ///< determines if a file path is within a base path. Both paths must be absolute, but do not need to exist. @@ -164,6 +203,10 @@ class FileSystem : public SubsystemInterface static bool removeExtension(UnicodeString& path); protected: + Bool isLooseFileExcluded(const Char *filename) const { return m_excludedPaths.matchesPath(filename); } + + PathExcludeList m_excludedPaths; + #if ENABLE_FILESYSTEM_EXISTENCE_CACHE struct FileExistData { diff --git a/Core/GameEngine/Include/Common/UserPreferences.h b/Core/GameEngine/Include/Common/UserPreferences.h index f1b9f3ea786..dd7ce6b100d 100644 --- a/Core/GameEngine/Include/Common/UserPreferences.h +++ b/Core/GameEngine/Include/Common/UserPreferences.h @@ -60,11 +60,13 @@ class UserPreferences : public PreferenceMap Real getReal(AsciiString key, Real defaultValue) const; Int getInt(AsciiString key, Int defaultValue) const; AsciiString getAsciiString(AsciiString key, AsciiString defaultValue) const; + UnicodeString getUnicodeString(AsciiString key, UnicodeString defaultValue) const; void setBool(AsciiString key, Bool val); void setReal(AsciiString key, Real val); void setInt(AsciiString key, Int val); void setAsciiString(AsciiString key, AsciiString val); + void setUnicodeString(AsciiString key, UnicodeString val); protected: AsciiString m_filename; diff --git a/Core/GameEngine/Include/GameNetwork/ConnectionManager.h b/Core/GameEngine/Include/GameNetwork/ConnectionManager.h index 4e1693455b5..1b1d3e2731b 100644 --- a/Core/GameEngine/Include/GameNetwork/ConnectionManager.h +++ b/Core/GameEngine/Include/GameNetwork/ConnectionManager.h @@ -37,6 +37,9 @@ #include "GameNetwork/FrameMetrics.h" #include "GameNetwork/NetworkDefs.h" #include "GameNetwork/DisconnectManager.h" +#if defined(GENERALS_ONLINE) +#include "GameNetwork/NetworkSettings.h" +#endif class GameInfo; class NetCommandWrapperList; @@ -56,6 +59,7 @@ class ConnectionManager virtual void update(Bool isInGame); ///< Service the Connections being managed by this instance. #if defined(GENERALS_ONLINE) + const NetworkSettings &getNetworkSettings() const { return m_networkSettings; } void SeedLatencyData(int highestLatency) { for (int i = 0; i < MAX_SLOTS; ++i) { @@ -165,6 +169,10 @@ class ConnectionManager Int getPingsReceived(); private: +#if defined(GENERALS_ONLINE) + NetworkSettings m_networkSettings; +#endif + void doRelay(); void doKeepAlive(); void sendRemoteCommand(NetCommandRef *msg); diff --git a/Core/GameEngine/Include/GameNetwork/NetworkDefs.h b/Core/GameEngine/Include/GameNetwork/NetworkDefs.h index 6295493ea7d..330477fdcc5 100644 --- a/Core/GameEngine/Include/GameNetwork/NetworkDefs.h +++ b/Core/GameEngine/Include/GameNetwork/NetworkDefs.h @@ -38,7 +38,7 @@ static constexpr const Int MIN_LOGIC_FRAMES = 5; static constexpr const Int MAX_FRAMES_AHEAD = 128; #if defined(GENERALS_ONLINE) -static Int MIN_RUNAHEAD = 6; +extern Int MIN_RUNAHEAD; #else static constexpr const Int MIN_RUNAHEAD = 4; #endif diff --git a/Core/GameEngine/Include/GameNetwork/NetworkSettings.h b/Core/GameEngine/Include/GameNetwork/NetworkSettings.h new file mode 100644 index 00000000000..f09f5c1d644 --- /dev/null +++ b/Core/GameEngine/Include/GameNetwork/NetworkSettings.h @@ -0,0 +1,35 @@ +#pragma once + +#if defined(GENERALS_ONLINE) + +#include + +// Per-match game networking policy, independent of online-service lifetime. +struct NetworkSettings +{ + struct Send + { + int frames_per_packet = 2; // send interval in frames + bool flush_every_frame = true; // flush GNS after each frame + int flags = -1; // GNS send flags tried after a failed reliable send, -1 = none + } send; + struct InputDelay + { + int min_frames = 4; + int decision_interval_ms = 5000; + int fps_history_samples = 30; // per-second FPS samples averaged + int slack_percent = 30; // percent added to the measured round trip, also sets the start run ahead and the rating + + struct Formula + { + int latency_history_samples = 200; // in-band ping samples averaged + } formula; + } input_delay; + + static NetworkSettings LocalDefaults() + { + return NetworkSettings(); + } +}; + +#endif diff --git a/Core/GameEngine/Source/Common/System/ArchiveFile.cpp b/Core/GameEngine/Source/Common/System/ArchiveFile.cpp index 1f982b65a1d..9e0db3f0ee2 100644 --- a/Core/GameEngine/Source/Common/System/ArchiveFile.cpp +++ b/Core/GameEngine/Source/Common/System/ArchiveFile.cpp @@ -30,8 +30,12 @@ #include "Common/ArchiveFile.h" #include "Common/ArchiveFileSystem.h" +#include "Common/LocalFileSystem.h" #include "Common/file.h" +#include "Common/LocalFile.h" #include "Common/PerfTimer.h" +#include "Common/RAMFile.h" +#include "Common/StreamingArchiveFile.h" // checks to see if str matches searchString. Search string is done in the @@ -89,8 +93,10 @@ ArchiveFile::~ArchiveFile() } } -ArchiveFile::ArchiveFile() +ArchiveFile::ArchiveFile(AsciiString name, AsciiString path) : m_file(nullptr) + , m_name(name) + , m_path(path) { } @@ -106,13 +112,11 @@ void ArchiveFile::addFile(const AsciiString& path, const ArchivedFileInfo *fileI while (!token.isEmpty()) { DetailedArchivedDirectoryInfoMap::iterator tempiter = dirInfo->m_directories.find(token); - if (tempiter == dirInfo->m_directories.end()) - { + if (tempiter == dirInfo->m_directories.end()) { dirInfo = &(dirInfo->m_directories[token]); dirInfo->m_directoryName = token; } - else - { + else { dirInfo = &tempiter->second; } @@ -132,18 +136,12 @@ void ArchiveFile::getFileListInDirectory(const AsciiString& currentDirectory, co tokenizer.nextToken(&token, "\\/"); while (!token.isEmpty()) { - DetailedArchivedDirectoryInfoMap::const_iterator it = dirInfo->m_directories.find(token); - if (it != dirInfo->m_directories.end()) - { - dirInfo = &it->second; - } - else - { - // if the directory doesn't exist, then there aren't any files to be had. + // if the directory doesn't exist, then there aren't any files to be had. + if (it == dirInfo->m_directories.end()) return; - } + dirInfo = &it->second; tokenizer.nextToken(&token, "\\/"); } @@ -153,33 +151,31 @@ void ArchiveFile::getFileListInDirectory(const AsciiString& currentDirectory, co void ArchiveFile::getFileListInDirectory(const DetailedArchivedDirectoryInfo *dirInfo, const AsciiString& currentDirectory, const AsciiString& searchName, FilenameList &filenameList, Bool searchSubdirectories) const { DetailedArchivedDirectoryInfoMap::const_iterator diriter = dirInfo->m_directories.begin(); - while (diriter != dirInfo->m_directories.end()) { + for (; diriter != dirInfo->m_directories.end(); ++diriter) { const DetailedArchivedDirectoryInfo *tempDirInfo = &(diriter->second); - AsciiString tempdirname; - tempdirname = currentDirectory; - if ((!tempdirname.isEmpty()) && (!tempdirname.endsWith("\\"))) { - tempdirname.concat('\\'); + AsciiString tempDirName = currentDirectory; + if (!tempDirName.isEmpty() && !tempDirName.endsWith("\\")) { + tempDirName.concat('\\'); } - tempdirname.concat(tempDirInfo->m_directoryName); - getFileListInDirectory(tempDirInfo, tempdirname, searchName, filenameList, searchSubdirectories); - diriter++; + tempDirName.concat(tempDirInfo->m_directoryName); + getFileListInDirectory(tempDirInfo, tempDirName, searchName, filenameList, searchSubdirectories); } ArchivedFileInfoMap::const_iterator fileiter = dirInfo->m_files.begin(); - while (fileiter != dirInfo->m_files.end()) { - if (SearchStringMatches(fileiter->second.m_filename, searchName)) { + for (; fileiter != dirInfo->m_files.end(); ++fileiter) { + const ArchivedFileInfo &fileInfo = fileiter->second; + if (SearchStringMatches(fileInfo.m_filename, searchName)) { AsciiString tempfilename; tempfilename = currentDirectory; - if ((!tempfilename.isEmpty()) && (!tempfilename.endsWith("\\"))) { + if (!tempfilename.isEmpty() && !tempfilename.endsWith("\\")) { tempfilename.concat('\\'); } - tempfilename.concat(fileiter->second.m_filename); + tempfilename.concat(fileInfo.m_filename); if (filenameList.find(tempfilename) == filenameList.end()) { // only insert into the list if its not already in there. filenameList.insert(tempfilename); } } - fileiter++; } } @@ -189,7 +185,18 @@ void ArchiveFile::attachFile(File *file) m_file->close(); m_file = nullptr; } + m_file = file; + + if (m_file != nullptr) { + if (!TheLocalFileSystem->getFileInfo(m_file->getName(), &m_fileInfo)) { + DEBUG_CRASH(("ArchiveFile::attachFile - Cannot query file info for '%s' unexpectedly", m_file->getName())); + m_fileInfo = FileInfo(); + } + } + else { + m_fileInfo = FileInfo(); + } } const ArchivedFileInfo * ArchiveFile::getArchivedFileInfo(const AsciiString& filename) const @@ -201,7 +208,7 @@ const ArchivedFileInfo * ArchiveFile::getArchivedFileInfo(const AsciiString& fil tokenizer.toLower(); tokenizer.nextToken(&token, "\\/"); - while (!token.find('.') || tokenizer.find('.')) + while (!tokenizer.isEmpty()) { DetailedArchivedDirectoryInfoMap::const_iterator it = dirInfo->m_directories.find(token); if (it != dirInfo->m_directories.end()) @@ -227,3 +234,116 @@ const ArchivedFileInfo * ArchiveFile::getArchivedFileInfo(const AsciiString& fil } } + +//============================================================================ +// ArchiveFile::openFile +//============================================================================ + +File* ArchiveFile::openFile( const Char *filename, Int access ) +{ + const ArchivedFileInfo *fileInfo = getArchivedFileInfo(AsciiString(filename)); + + if (fileInfo == nullptr) { + return nullptr; + } + + RAMFile *ramFile = nullptr; + + if (BitIsSet(access, File::STREAMING)) + ramFile = newInstance( StreamingArchiveFile ); + else + ramFile = newInstance( RAMFile ); + + ramFile->deleteOnClose(); + if (ramFile->openFromArchive(m_file, fileInfo->m_filename, fileInfo->m_offset, fileInfo->m_size) == FALSE) { + ramFile->close(); + ramFile = nullptr; + return nullptr; + } + + if ((access & File::WRITE) == 0) { + // requesting read only access. Just return the RAM file. + return ramFile; + } + + // whoever is opening this file wants write access, so copy the file to the local disk + // and return that file pointer. + + constexpr size_t bufferSize = 0; + File *localFile = TheLocalFileSystem->openFile(filename, access, bufferSize); + if (localFile != nullptr) { + ramFile->copyDataToFile(localFile); + } + + ramFile->close(); + ramFile = nullptr; + + return localFile; +} + +//============================================================================ +// ArchiveFile::closeAllFiles +//============================================================================ + +void ArchiveFile::closeAllFiles() +{ + +} + +//============================================================================ +// ArchiveFile::getName +//============================================================================ + +AsciiString ArchiveFile::getName() +{ + return m_name; +} + +//============================================================================ +// ArchiveFile::getPath +//============================================================================ + +AsciiString ArchiveFile::getPath() +{ + return m_path; +} + +//============================================================================ +// ArchiveFile::setSearchPriority +//============================================================================ + +void ArchiveFile::setSearchPriority( Int new_priority ) +{ + +} + +//============================================================================ +// ArchiveFile::close +//============================================================================ + +void ArchiveFile::close() +{ + +} + +//============================================================================ +// ArchiveFile::getFileInfo +//============================================================================ + +Bool ArchiveFile::getFileInfo(const AsciiString& filename, FileInfo *fileInfo) const +{ + const ArchivedFileInfo *archivedFileInfo = getArchivedFileInfo(filename); + + if (archivedFileInfo == nullptr) { + return FALSE; + } + + // fill in the size info. Since the size can't be bigger than a JUNK file, the high int will always be 0. + fileInfo->sizeHigh = 0; + fileInfo->sizeLow = archivedFileInfo->m_size; + // the timestamp is taken from the archive file. + fileInfo->timestampHigh = m_fileInfo.timestampHigh; + fileInfo->timestampLow = m_fileInfo.timestampLow; + + return TRUE; +} diff --git a/Core/GameEngine/Source/Common/System/ArchiveFileSystem.cpp b/Core/GameEngine/Source/Common/System/ArchiveFileSystem.cpp index 5720c5b9417..6171f26fabe 100644 --- a/Core/GameEngine/Source/Common/System/ArchiveFileSystem.cpp +++ b/Core/GameEngine/Source/Common/System/ArchiveFileSystem.cpp @@ -46,11 +46,16 @@ //---------------------------------------------------------------------------- #include "PreRTS.h" +#include +#include +#include +#include #include "Common/ArchiveFile.h" #include "Common/ArchiveFileSystem.h" #include "Common/AsciiString.h" #include "Common/LocalFileSystem.h" #include "Common/PerfTimer.h" +#include "Common/Registry.h" #include "../NGMP_include.h" #include "../OnlineServices_Init.h" @@ -127,8 +132,9 @@ ArchiveFileSystem::~ArchiveFileSystem() } } -void ArchiveFileSystem::loadIntoDirectoryTree(ArchiveFile *archiveFile, Bool overwrite, Bool sortedByName) +void ArchiveFileSystem::loadIntoDirectoryTree(ArchiveFile *archiveFile, Int priority) { + archiveFile->setPriority(priority); FilenameList filenameList; @@ -144,9 +150,9 @@ void ArchiveFileSystem::loadIntoDirectoryTree(ArchiveFile *archiveFile, Bool ove AsciiString token; AsciiString tokenizer = *it; tokenizer.toLower(); - Bool infoInPath = tokenizer.nextToken(&token, "\\/"); + tokenizer.nextToken(&token, "\\/"); - while (infoInPath && (!token.find('.') || tokenizer.find('.'))) + while (!tokenizer.isEmpty()) { path.concat(token); path.concat('\\'); @@ -163,119 +169,311 @@ void ArchiveFileSystem::loadIntoDirectoryTree(ArchiveFile *archiveFile, Bool ove dirInfo = &tempiter->second; } - infoInPath = tokenizer.nextToken(&token, "\\/"); + tokenizer.nextToken(&token, "\\/"); } - ArchivedFileLocationMap::iterator fileIt; - if (sortedByName) - { - // Insert by case-insensitive archive filename, matching game folder load order where the alphabetically first archive wins. - const AsciiString baseName = getBaseFilename(archiveFile->getName()); - std::pair range = dirInfo->m_files.equal_range(token); - fileIt = range.first; - while (fileIt != range.second && getBaseFilename(fileIt->second->getName()).compareNoCase(baseName) <= 0) - ++fileIt; - } - else if (overwrite) + // Insert behind every archive of the same or a lower priority number, so the first one mounted wins on equal priority. + // A plain insert goes to the end of the range of equal keys, the range is rebuilt when the new archive belongs in front of others. + std::pair range = dirInfo->m_files.equal_range(token); + std::vector ordered; + for (ArchivedFileLocationMap::iterator rangeIt = range.first; rangeIt != range.second; ++rangeIt) + ordered.push_back(rangeIt->second); + + size_t position = 0; + while (position < ordered.size() && ordered[position]->getPriority() <= priority) + ++position; + + if (position == ordered.size()) { - // When overwriting, try place the new value at the beginning of the key list. - fileIt = dirInfo->m_files.find(token); + dirInfo->m_files.insert(std::make_pair(token, archiveFile)); } else { - // Append to the end of the key list. - fileIt = dirInfo->m_files.end(); + ordered.insert(ordered.begin() + position, archiveFile); + dirInfo->m_files.erase(range.first, range.second); + for (size_t k = 0; k < ordered.size(); ++k) + dirInfo->m_files.insert(std::make_pair(token, ordered[k])); } - dirInfo->m_files.insert(fileIt, std::make_pair(token, archiveFile)); - -#if defined(DEBUG_LOGGING) && ENABLE_FILESYSTEM_LOGGING - { - const stl::const_range range = stl::get_range(dirInfo->m_files, token, 0); - if (range.distance() >= 2) - { - ArchivedFileLocationMap::const_iterator rangeIt0; - ArchivedFileLocationMap::const_iterator rangeIt1; - - if (overwrite) - { - rangeIt0 = range.begin; - rangeIt1 = std::next(rangeIt0); - - DEBUG_LOG(("ArchiveFileSystem::loadIntoDirectoryTree - adding file %s, archived in %s, overwriting same file in %s", - it->str(), - rangeIt0->second->getName().str(), - rangeIt1->second->getName().str() - )); - } - else - { - rangeIt1 = std::prev(range.end); - rangeIt0 = std::prev(rangeIt1); - - DEBUG_LOG(("ArchiveFileSystem::loadIntoDirectoryTree - adding file %s, archived in %s, overwritten by same file in %s", - it->str(), - rangeIt1->second->getName().str(), - rangeIt0->second->getName().str() - )); - } - } - else - { - DEBUG_LOG(("ArchiveFileSystem::loadIntoDirectoryTree - adding file %s, archived in %s", it->str(), archiveFile->getName().str())); - } - } -#endif + DEBUG_LOG(("ArchiveFileSystem::loadIntoDirectoryTree - adding file %s, archived in %s, priority %d", it->str(), archiveFile->getName().str(), priority)); it++; } } -void ArchiveFileSystem::loadMods() +// Retail archive names, without the extension and in lower case. Only the language of the game counts as retail. +static void addRetailNames(std::set& names, Bool zeroHour) +{ + auto add = [&names](const std::string& name) + { + std::string lower = name; + for (char& c : lower) + c = static_cast(tolower(static_cast(c))); + names.insert(lower); + }; + + const std::string language = GetRegistryLanguage().str(); + + if (zeroHour) + { + add(language + "ZH"); + add("Audio" + language + "ZH"); + add("Speech" + language + "ZH"); + add("W3D" + language + "ZH"); + for (const char* name : { "AudioZH", "INIZH", "MapsZH", "Music", "MusicZH", "ShadersZH", "SpeechZH", "TerrainZH", "TexturesZH", "W3DZH", "WindowZH", "gensecZH", + "PatchData", "PatchINI", "PatchWindow", "PatchZH" }) + add(name); + } + else + { + add(language); + add("Audio" + language); + add("Speech" + language); + for (const char* name : { "Audio", "INI", "Music", "Patch", "PatchData", "PatchINI", "PatchWindow", "Speech", "Terrain", "Textures", "W3D", "Window", "gensec", "maps", "shaders" }) + add(name); + } +} + +Bool ArchiveFileSystem::isGeneralsArchivePath(const AsciiString& path, Bool generalsFolder) +{ + if (generalsFolder) + return TRUE; + + AsciiString lowerPath = path; + lowerPath.toLower(); + return strstr(lowerPath.str(), "zh_generals\\") != nullptr || strstr(lowerPath.str(), "zh_generals/") != nullptr; +} + +Bool ArchiveFileSystem::isRetailArchive(const AsciiString& path, Bool generalsFolder, Bool zeroHour) +{ + static std::set zeroHourNames; + static std::set generalsNames; + if (zeroHourNames.empty()) + { + addRetailNames(zeroHourNames, TRUE); + addRetailNames(generalsNames, FALSE); + } + + AsciiString name = getBaseFilename(path); + if (!name.endsWithNoCase(".big")) + return FALSE; + name.truncateBy(4); + std::string lower = name.str(); + for (char& c : lower) + c = static_cast(tolower(static_cast(c))); + + // The Generals archives are in the Generals install folder, or in a ZH_Generals folder inside the game folder + const Bool inGeneralsFolder = isGeneralsArchivePath(path, generalsFolder); + + if (zeroHour && !inGeneralsFolder) + return zeroHourNames.count(lower) != 0; + return generalsNames.count(lower) != 0; +} + +Int ArchiveFileSystem::getGameFolderArchivePriority(const AsciiString& path, Bool generalsFolder, Bool zeroHour) +{ + // The Zero Hour archives rank above the Generals ones in their tier, whatever the order of the scan + const Bool generals = zeroHour && isGeneralsArchivePath(path, generalsFolder); + if (isRetailArchive(path, generalsFolder, zeroHour)) + return generals ? ARCHIVE_PRIORITY_RETAIL_GENERALS : ARCHIVE_PRIORITY_RETAIL; + + if (TheFileSystem != nullptr && TheFileSystem->getExcludedPaths().matchesPath(path.str())) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "Archive %s is excluded by user.exclude_paths", path.str()); + return ARCHIVE_PRIORITY_EXCLUDED; + } + + m_gameFolderArchives.push_back(path); + return generals ? ARCHIVE_PRIORITY_GAME_FOLDER_GENERALS : ARCHIVE_PRIORITY_GAME_FOLDER; +} + +Bool ArchiveFileSystem::parseMountEntry(const AsciiString& text, ArchiveMountEntry* entry, AsciiString* reason) +{ + auto fail = [reason](const char* why) + { + if (reason != nullptr) + *reason = why; + return FALSE; + }; + + std::string value = text.str(); + // trim + while (!value.empty() && isspace(static_cast(value.back()))) + value.pop_back(); + size_t start = 0; + while (start < value.size() && isspace(static_cast(value[start]))) + ++start; + value = value.substr(start); + if (value.empty()) + return fail("empty"); + + for (char& c : value) + { + if (c == '/') + c = '\\'; + if (static_cast(c) < 32 || c == '<' || c == '>' || c == '"' || c == '|') + return fail("invalid character"); + } + + ArchiveMountEntry::Base base; + std::string path; + std::string lowerPrefix = value.substr(0, 5); + for (char& c : lowerPrefix) + c = static_cast(tolower(static_cast(c))); + if (lowerPrefix == "game:") + { + base = ArchiveMountEntry::BASE_GAME; + path = value.substr(5); + } + else if (lowerPrefix == "user:") + { + base = ArchiveMountEntry::BASE_USER; + path = value.substr(5); + } + else if (value.size() > 3 && isalpha(static_cast(value[0])) && value[1] == ':' && value[2] == '\\') + { + base = ArchiveMountEntry::BASE_ABSOLUTE; + path = value; + } + else if (value.size() > 2 && value[0] == '\\' && value[1] == '\\') + { + base = ArchiveMountEntry::BASE_ABSOLUTE; + path = value; + } + else + { + return fail("needs the prefix game: or user:, or an absolute path"); + } + + if (base != ArchiveMountEntry::BASE_ABSOLUTE) + { + if (path.empty() || path[0] == '\\' || path.find(':') != std::string::npos) + return fail("invalid path"); + } + + // no parent folders + size_t pos = 0; + while (pos <= path.size()) + { + size_t next = path.find('\\', pos); + if (next == std::string::npos) + next = path.size(); + if (path.substr(pos, next - pos) == "..") + return fail("parent folder"); + pos = next + 1; + } + + const size_t lastSeparator = path.find_last_of('\\'); + const std::string lastComponent = lastSeparator == std::string::npos ? path : path.substr(lastSeparator + 1); + const std::string directory = lastSeparator == std::string::npos ? std::string() : path.substr(0, lastSeparator + 1); + if (directory.find_first_of("*?") != std::string::npos) + return fail("wildcards are only allowed in the file name"); + + std::string lowerLast = lastComponent; + for (char& c : lowerLast) + c = static_cast(tolower(static_cast(c))); + + ArchiveMountEntry::Kind kind; + if (lastComponent.empty()) + { + // a folder: every BIG in it + if (directory.empty() || directory == "\\\\") + return fail("invalid path"); + kind = ArchiveMountEntry::KIND_BIG_GLOB; + path += "*.big"; + } + else if (lowerLast.size() < 5 || lowerLast.compare(lowerLast.size() - 4, 4, ".big") != 0) + { + return fail("not a BIG file, a BIG folder or a *.big pattern"); + } + else if (lastComponent.find_first_of("*?") != std::string::npos) + { + kind = ArchiveMountEntry::KIND_BIG_GLOB; + } + else + { + kind = ArchiveMountEntry::KIND_BIG; + } + + if (entry != nullptr) + { + entry->m_kind = kind; + entry->m_base = base; + entry->m_path = path.c_str(); + } + return TRUE; +} + +void ArchiveFileSystem::mountListedArchive(const ArchiveMountEntry& entry, Int priority, const PathExcludeList* excludes) { -#if defined(GENERALS_ONLINE) && defined(GENERALS_ONLINE_COMMUNITY_PATCH_CHANGES) - // load community data patch BIG - if (NGMP_OnlineServicesManager::Settings.DataPacks_UseCommunityPatch() - && !TheGlobalData->m_commandLineData.isCommunityDataPatchDisabled()) - { - std::string strBigPath = std::format("{}GeneralsOnlineGameData/500_900_CommunityPatch_CoreINI.big", TheGlobalData->getPath_UserData().str()); - bool bLoaded = false; - if (TheLocalFileSystem->doesFileExist(strBigPath.c_str())) - { - ArchiveFile* archiveFile = openArchiveFile(strBigPath.c_str()); - if (archiveFile != nullptr) - { - // Sorted by filename so the patch respects the addon number order of BIGs in the game folder. - loadIntoDirectoryTree(archiveFile, FALSE, TRUE); - m_archiveFileMap[AsciiString(strBigPath.c_str())] = archiveFile; - bLoaded = true; - } - } - NetworkLog(ELogVerbosity::LOG_RELEASE, "Loaded community patch (%s): %d", strBigPath.c_str(), bLoaded); - } -#endif - - if (TheGlobalData->m_modBIG.isNotEmpty()) + std::string fullPath = entry.m_path.str(); + if (entry.m_base == ArchiveMountEntry::BASE_USER) + fullPath = std::string(TheGlobalData->getPath_UserData().str()) + fullPath; + + // the files to mount, in name order + std::vector bigPaths; + if (entry.m_kind == ArchiveMountEntry::KIND_BIG) { - ArchiveFile *archiveFile = openArchiveFile(TheGlobalData->m_modBIG.str()); + bigPaths.push_back(fullPath); + } + else + { + const size_t lastSeparator = fullPath.find_last_of('\\'); + const std::string directory = lastSeparator == std::string::npos ? std::string() : fullPath.substr(0, lastSeparator + 1); + const std::string mask = lastSeparator == std::string::npos ? fullPath : fullPath.substr(lastSeparator + 1); + FilenameList files; + TheLocalFileSystem->getFileListInDirectory(AsciiString(directory.c_str()), "", AsciiString(mask.c_str()), files, FALSE); + for (FilenameListIter it = files.begin(); it != files.end(); ++it) + bigPaths.push_back(it->str()); + } - if (archiveFile != nullptr) { - DEBUG_LOG(("ArchiveFileSystem::loadMods - loading %s into the directory tree.", TheGlobalData->m_modBIG.str())); - loadIntoDirectoryTree(archiveFile, TRUE); - m_archiveFileMap[TheGlobalData->m_modBIG] = archiveFile; - DEBUG_LOG(("ArchiveFileSystem::loadMods - %s inserted into the archive file map.", TheGlobalData->m_modBIG.str())); + Int mounted = 0; + for (const std::string& bigPath : bigPaths) + { + if (!TheLocalFileSystem->doesFileExist(bigPath.c_str())) + continue; + if (excludes != nullptr && !excludes->isEmpty() && excludes->matches(PathExcludeList::makeKey(bigPath.c_str(), TheGlobalData->getPath_UserData().str()))) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "Archive %s is excluded", bigPath.c_str()); + continue; } - else + if (m_archiveFileMap.find(AsciiString(bigPath.c_str())) != m_archiveFileMap.end()) { - DEBUG_LOG(("ArchiveFileSystem::loadMods - could not openArchiveFile(%s)", TheGlobalData->m_modBIG.str())); + NetworkLog(ELogVerbosity::LOG_RELEASE, "Archive %s is already mounted", bigPath.c_str()); + continue; } + ArchiveFile* archiveFile = openArchiveFile(bigPath.c_str()); + if (archiveFile == nullptr) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "Archive %s could not be opened", bigPath.c_str()); + continue; + } + loadIntoDirectoryTree(archiveFile, priority); + m_archiveFileMap[AsciiString(bigPath.c_str())] = archiveFile; + ++mounted; + } + NetworkLog(ELogVerbosity::LOG_RELEASE, "Archive %s at priority %d: %d archives", fullPath.c_str(), priority, mounted); +} + +void ArchiveFileSystem::mountDataPackLayer(const char* layer, Bool enabled, const std::vector& includePaths, const PathExcludeList* excludes, Int priority) +{ + if (!enabled) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "Data pack layer %s is disabled", layer); + return; } - if (TheGlobalData->m_modDir.isNotEmpty()) + NetworkLog(ELogVerbosity::LOG_RELEASE, "Data pack layer %s: %d include_paths", layer, static_cast(includePaths.size())); + for (size_t i = 0; i < includePaths.size(); ++i) { - MAYBE_UNUSED Bool ret = loadBigFilesFromDirectory(TheGlobalData->m_modDir, "*.big", TRUE); - (void)ret; - DEBUG_ASSERTLOG(ret, ("loadBigFilesFromDirectory(%s) returned FALSE!", TheGlobalData->m_modDir.str())); + ArchiveMountEntry entry; + AsciiString reason; + if (!parseMountEntry(AsciiString(includePaths[i].c_str()), &entry, &reason)) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "Data pack layer %s: path ignored (%s): %s", layer, reason.str(), includePaths[i].c_str()); + continue; + } + mountListedArchive(entry, priority + static_cast(i), excludes); } } @@ -306,15 +504,15 @@ ArchiveFileSystem::ArchivedDirectoryInfoResult ArchiveFileSystem::getArchivedDir AsciiString token; AsciiString tokenizer = directory; tokenizer.toLower(); - Bool infoInPath = tokenizer.nextToken(&token, "\\/"); + tokenizer.nextToken(&token, "\\/"); - while (infoInPath && (!token.find('.') || tokenizer.find('.'))) + while (!tokenizer.isEmpty()) { ArchivedDirectoryInfoMap::iterator tempiter = dirInfo->m_directories.find(token); if (tempiter != dirInfo->m_directories.end()) { dirInfo = &tempiter->second; - infoInPath = tokenizer.nextToken(&token, "\\/"); + tokenizer.nextToken(&token, "\\/"); } else { diff --git a/Core/GameEngineDevice/Source/Win32Device/Common/Win32BIGFileSystem.cpp b/Core/GameEngine/Source/Common/System/BIGFileSystem.cpp similarity index 54% rename from Core/GameEngineDevice/Source/Win32Device/Common/Win32BIGFileSystem.cpp rename to Core/GameEngine/Source/Common/System/BIGFileSystem.cpp index b824dcaed57..6919947f36a 100644 --- a/Core/GameEngineDevice/Source/Win32Device/Common/Win32BIGFileSystem.cpp +++ b/Core/GameEngine/Source/Common/System/BIGFileSystem.cpp @@ -22,13 +22,16 @@ // // //////////////////////////////////////////////////////////////////////////////// -//////// Win32BIGFileSystem.h /////////////////////////// +//////// BIGFileSystem.cpp /////////////////////////// // Bryan Cleveland, August 2002 ///////////////////////////////////////////////////////////// +#include "PreRTS.h" + +#include "Common/BIGFileSystem.h" + #include "Common/AudioAffect.h" #include "Common/ArchiveFile.h" -#include "Common/ArchiveFileSystem.h" #include "Common/file.h" #include "Common/GameAudio.h" #include "Common/GameMemory.h" @@ -38,48 +41,56 @@ #include "Common/Registry.h" #endif -#include "Win32Device/Common/Win32BIGFile.h" -#include "Win32Device/Common/Win32BIGFileSystem.h" #include "Utility/endian_compat.h" +#include "../NGMP_include.h" +#include "../OnlineServices_Init.h" static const char *BIGFileIdentifier = "BIGF"; -Win32BIGFileSystem::Win32BIGFileSystem() : ArchiveFileSystem() { +BIGFileSystem::BIGFileSystem() : ArchiveFileSystem() { } -Win32BIGFileSystem::~Win32BIGFileSystem() { +BIGFileSystem::~BIGFileSystem() { } -void Win32BIGFileSystem::init() { +void BIGFileSystem::init() { DEBUG_ASSERTCRASH(TheLocalFileSystem != nullptr, ("TheLocalFileSystem must be initialized before TheArchiveFileSystem.")); if (TheLocalFileSystem == nullptr) { return; } - loadBigFilesFromDirectory("", "*.big"); + loadArchiveFilesFromDirectory("", "*.big"); #if RTS_ZEROHOUR // load original Generals assets + // The Steam install ships them in ZH_Generals, which the scan above already mounted. Mounting the Generals install as well would only add its mods. + if (TheLocalFileSystem->doesFileExist("ZH_Generals\\INI.big")) { + m_generalsBaseSource = "ZH_Generals folder"; + return; + } + AsciiString installPath; GetStringFromGeneralsRegistry("", "InstallPath", installPath ); //@todo this will need to be ramped up to a crash for release DEBUG_ASSERTCRASH(!installPath.isEmpty(), ("Be 1337! Go install Generals!")); - if (!installPath.isEmpty()) - loadBigFilesFromDirectory(installPath, "*.big"); + if (!installPath.isEmpty()) { + m_generalsBaseSource.format("Generals install folder %s", installPath.str()); + loadArchiveFilesFromDirectory(installPath, "*.big"); + } #endif } -void Win32BIGFileSystem::reset() { +void BIGFileSystem::reset() { } -void Win32BIGFileSystem::update() { +void BIGFileSystem::update() { } -void Win32BIGFileSystem::postProcessLoad() { +void BIGFileSystem::postProcessLoad() { } -ArchiveFile * Win32BIGFileSystem::openArchiveFile(const Char *filename) { +ArchiveFile * BIGFileSystem::openArchiveFile(const Char *filename) { File *fp = TheLocalFileSystem->openFile(filename, File::READ | File::BINARY); AsciiString archiveFileName; archiveFileName = filename; @@ -87,7 +98,7 @@ ArchiveFile * Win32BIGFileSystem::openArchiveFile(const Char *filename) { Int archiveFileSize = 0; Int numLittleFiles = 0; - DEBUG_LOG(("Win32BIGFileSystem::openArchiveFile - opening BIG file %s", filename)); + DEBUG_LOG(("BIGFileSystem::openArchiveFile - opening BIG file %s", filename)); if (fp == nullptr) { DEBUG_CRASH(("Could not open archive file %s for parsing", filename)); @@ -113,7 +124,7 @@ ArchiveFile * Win32BIGFileSystem::openArchiveFile(const Char *filename) { return nullptr; } - DEBUG_LOG(("Win32BIGFileSystem::openArchiveFile - size of archive file is %d bytes", archiveFileSize)); + DEBUG_LOG(("BIGFileSystem::openArchiveFile - size of archive file is %d bytes", archiveFileSize)); // char t; @@ -127,7 +138,7 @@ ArchiveFile * Win32BIGFileSystem::openArchiveFile(const Char *filename) { } numLittleFiles = betoh(numLittleFiles); - DEBUG_LOG(("Win32BIGFileSystem::openArchiveFile - %d are contained in archive", numLittleFiles)); + DEBUG_LOG(("BIGFileSystem::openArchiveFile - %d are contained in archive", numLittleFiles)); // for (Int i = 0; i < 2; ++i) { // t = buffer[i]; // buffer[i] = buffer[(4-i)-1]; @@ -137,9 +148,8 @@ ArchiveFile * Win32BIGFileSystem::openArchiveFile(const Char *filename) { // seek to the beginning of the directory listing. fp->seek(0x10, File::START); // read in each directory listing. - ArchivedFileInfo *fileInfo = NEW ArchivedFileInfo; - // TheSuperHackers @fix Mauller 23/04/2025 Create new file handle when necessary to prevent memory leak - ArchiveFile *archiveFile = NEW Win32BIGFile(filename, AsciiString::TheEmptyString); + ArchivedFileInfo fileInfo; + ArchiveFile *archiveFile = NEW ArchiveFile(filename, AsciiString::TheEmptyString); Bool corrupt = FALSE; for (Int i = 0; i < numLittleFiles; ++i) { @@ -153,9 +163,9 @@ ArchiveFile * Win32BIGFileSystem::openArchiveFile(const Char *filename) { filesize = betoh(filesize); fileOffset = betoh(fileOffset); - fileInfo->m_archiveFilename = archiveFileName; - fileInfo->m_offset = fileOffset; - fileInfo->m_size = filesize; + fileInfo.m_archiveFilename = archiveFileName; + fileInfo.m_offset = fileOffset; + fileInfo.m_size = filesize; // read in the path name of the file. // the name must terminate within the buffer @@ -179,8 +189,8 @@ ArchiveFile * Win32BIGFileSystem::openArchiveFile(const Char *filename) { --filenameIndex; } - fileInfo->m_filename = (char *)(buffer + filenameIndex + 1); - fileInfo->m_filename.toLower(); + fileInfo.m_filename = (char *)(buffer + filenameIndex + 1); + fileInfo.m_filename.toLower(); buffer[filenameIndex + 1] = 0; AsciiString path; @@ -188,16 +198,15 @@ ArchiveFile * Win32BIGFileSystem::openArchiveFile(const Char *filename) { AsciiString debugpath; debugpath = path; - debugpath.concat(fileInfo->m_filename); -// DEBUG_LOG(("Win32BIGFileSystem::openArchiveFile - adding file %s to archive file %s, file number %d", debugpath.str(), fileInfo->m_archiveFilename.str(), i)); + debugpath.concat(fileInfo.m_filename); +// DEBUG_LOG(("BIGFileSystem::openArchiveFile - adding file %s to archive file %s, file number %d", debugpath.str(), fileInfo->m_archiveFilename.str(), i)); - archiveFile->addFile(path, fileInfo); + archiveFile->addFile(path, &fileInfo); } if (corrupt) { DEBUG_CRASH(("Truncated or corrupt directory in BIG file %s", filename)); delete archiveFile; - delete fileInfo; fp->close(); fp = nullptr; return nullptr; @@ -205,15 +214,12 @@ ArchiveFile * Win32BIGFileSystem::openArchiveFile(const Char *filename) { archiveFile->attachFile(fp); - delete fileInfo; - fileInfo = nullptr; - // leave fp open as the archive file will be using it. return archiveFile; } -void Win32BIGFileSystem::closeArchiveFile(const Char *filename) { +void BIGFileSystem::closeArchiveFile(const Char *filename) { // Need to close the specified big file ArchiveFileMap::iterator it = m_archiveFileMap.find(filename); if (it == m_archiveFileMap.end()) { @@ -234,13 +240,13 @@ void Win32BIGFileSystem::closeArchiveFile(const Char *filename) { m_archiveFileMap.erase(it); } -void Win32BIGFileSystem::closeAllArchiveFiles() { +void BIGFileSystem::closeAllArchiveFiles() { } -void Win32BIGFileSystem::closeAllFiles() { +void BIGFileSystem::closeAllFiles() { } -Bool Win32BIGFileSystem::loadBigFilesFromDirectory(AsciiString dir, AsciiString fileMask, Bool overwrite) { +Bool BIGFileSystem::loadArchiveFilesFromDirectory(AsciiString dir, AsciiString fileMask, Bool overwrite) { FilenameList filenameList; TheLocalFileSystem->getFileListInDirectory(dir, "", fileMask, filenameList, TRUE); @@ -258,13 +264,25 @@ Bool Win32BIGFileSystem::loadBigFilesFromDirectory(AsciiString dir, AsciiString } #endif +#if RTS_ZEROHOUR + const Bool zeroHour = TRUE; +#else + const Bool zeroHour = FALSE; +#endif + // The first scan is the game folder, the second one the Generals install: the retail archives are the bottom of the lookup + const Int priority = overwrite ? getNextModPriority() : getGameFolderArchivePriority((*it), dir.isNotEmpty(), zeroHour); + if (priority == ARCHIVE_PRIORITY_EXCLUDED) { + it++; + continue; + } + ArchiveFile *archiveFile = openArchiveFile((*it).str()); if (archiveFile != nullptr) { - DEBUG_LOG(("Win32BIGFileSystem::loadBigFilesFromDirectory - loading %s into the directory tree.", (*it).str())); - loadIntoDirectoryTree(archiveFile, overwrite); + DEBUG_LOG(("BIGFileSystem::loadArchiveFilesFromDirectory - loading %s into the directory tree.", (*it).str())); + loadIntoDirectoryTree(archiveFile, priority); m_archiveFileMap[(*it)] = archiveFile; - DEBUG_LOG(("Win32BIGFileSystem::loadBigFilesFromDirectory - %s inserted into the archive file map.", (*it).str())); + DEBUG_LOG(("BIGFileSystem::loadArchiveFilesFromDirectory - %s inserted into the archive file map.", (*it).str())); actuallyAdded = TRUE; } @@ -273,3 +291,46 @@ Bool Win32BIGFileSystem::loadBigFilesFromDirectory(AsciiString dir, AsciiString return actuallyAdded; } + +void BIGFileSystem::loadMods() +{ +#if defined(GENERALS_ONLINE) + // Order, highest first: loose files of the game folder, -mod and -modDir, the BIGs of the game folders that are not retail (user.exclude_paths removes some), + // the data pack layers of settings.json (user, generals_online, community_patch), the retail archives. The BIGs of the game folders were mounted by init(). + NetworkLog(ELogVerbosity::LOG_RELEASE, "Generals data: %s", m_generalsBaseSource.isEmpty() ? "none" : m_generalsBaseSource.str()); + NetworkLog(ELogVerbosity::LOG_RELEASE, "Game folder archives above the data pack layers: %d", static_cast(m_gameFolderArchives.size())); + for (const AsciiString& gameFolderArchive : m_gameFolderArchives) + NetworkLog(ELogVerbosity::LOG_RELEASE, " %s", gameFolderArchive.str()); + + const GenOnlineSettings& settings = NGMP_OnlineServicesManager::Settings; + mountDataPackLayer("user", settings.DataPacks_User().m_enabled, settings.DataPacks_User().m_includePaths, &TheFileSystem->getExcludedPaths(), ARCHIVE_PRIORITY_USER); + mountDataPackLayer("generals_online", settings.DataPacks_GeneralsOnline().m_enabled, settings.DataPacks_GeneralsOnline().m_includePaths, nullptr, ARCHIVE_PRIORITY_GENERALS_ONLINE); + + // -disableCommunityDataPatch skips the community_patch layer for this launch only, the setting is not changed + const Bool communityPatchEnabled = settings.DataPacks_CommunityPatch().m_enabled && !TheGlobalData->m_commandLineData.isCommunityDataPatchDisabled(); + mountDataPackLayer("community_patch", communityPatchEnabled, settings.DataPacks_CommunityPatch().m_includePaths, nullptr, ARCHIVE_PRIORITY_COMMUNITY_PATCH); +#endif + + if (TheGlobalData->m_modBIG.isNotEmpty()) + { + ArchiveFile *archiveFile = openArchiveFile(TheGlobalData->m_modBIG.str()); + + if (archiveFile != nullptr) { + DEBUG_LOG(("BIGFileSystem::loadMods - loading %s into the directory tree.", TheGlobalData->m_modBIG.str())); + loadIntoDirectoryTree(archiveFile, getNextModPriority()); + m_archiveFileMap[TheGlobalData->m_modBIG] = archiveFile; + DEBUG_LOG(("BIGFileSystem::loadMods - %s inserted into the archive file map.", TheGlobalData->m_modBIG.str())); + } + else + { + DEBUG_LOG(("BIGFileSystem::loadMods - could not openArchiveFile(%s)", TheGlobalData->m_modBIG.str())); + } + } + + if (TheGlobalData->m_modDir.isNotEmpty()) + { + MAYBE_UNUSED Bool ret = loadArchiveFilesFromDirectory(TheGlobalData->m_modDir, "*.big", TRUE); + (void)ret; + DEBUG_ASSERTLOG(ret, ("loadArchiveFilesFromDirectory(%s) returned FALSE!", TheGlobalData->m_modDir.str())); + } +} diff --git a/Core/GameEngine/Source/Common/System/FileSystem.cpp b/Core/GameEngine/Source/Common/System/FileSystem.cpp index b8e4c4695b6..e4df60933a4 100644 --- a/Core/GameEngine/Source/Common/System/FileSystem.cpp +++ b/Core/GameEngine/Source/Common/System/FileSystem.cpp @@ -56,6 +56,8 @@ #include "Lib/PathUtil.h" +#include + DECLARE_PERF_TIMER(FileSystem) @@ -118,6 +120,184 @@ FileSystem *TheFileSystem = nullptr; //---------------------------------------------------------------------------- +//============================================================================ +// PathExcludeList +//============================================================================ + +static std::string normalizeExcludeText(const std::string& text) +{ + std::string value = text; + for (size_t i = 0; i < value.size(); ++i) + { + if (value[i] == '/') + value[i] = '\\'; + value[i] = static_cast(tolower(static_cast(value[i]))); + } + return value; +} + +static Bool isAbsoluteExcludePath(const std::string& value) +{ + return (value.size() > 2 && isalpha(static_cast(value[0])) && value[1] == ':' && value[2] == '\\') + || (value.size() > 2 && value[0] == '\\' && value[1] == '\\'); +} + +static std::string stripCurrentFolder(const std::string& value) +{ + size_t start = 0; + while (start + 2 <= value.size() && value.compare(start, 2, ".\\") == 0) + start += 2; + return value.substr(start); +} + +Bool PathExcludeList::parse(const std::string& text, std::string* pattern, AsciiString* reason) +{ + std::string value = text; + while (!value.empty() && isspace(static_cast(value[value.size() - 1]))) + value.erase(value.size() - 1); + size_t start = 0; + while (start < value.size() && isspace(static_cast(value[start]))) + ++start; + value = normalizeExcludeText(value.substr(start)); + + if (value.empty()) + { + if (reason != nullptr) + *reason = "empty"; + return FALSE; + } + for (size_t i = 0; i < value.size(); ++i) + { + const unsigned char c = static_cast(value[i]); + if (c < 32 || c == '<' || c == '>' || c == '"' || c == '|') + { + if (reason != nullptr) + *reason = "invalid character"; + return FALSE; + } + } + + std::string rest; + std::string prefix; + if (value.compare(0, 5, "game:") == 0 || value.compare(0, 5, "user:") == 0) + { + prefix = value.substr(0, 5); + rest = stripCurrentFolder(value.substr(5)); + if (rest.empty() || rest[0] == '\\' || rest.find(':') != std::string::npos) + { + if (reason != nullptr) + *reason = "invalid path"; + return FALSE; + } + } + else if (isAbsoluteExcludePath(value)) + { + rest = value; + } + else + { + if (reason != nullptr) + *reason = "needs the prefix game: or user:, or an absolute path"; + return FALSE; + } + + // no parent folders + size_t pos = 0; + while (pos <= rest.size()) + { + size_t next = rest.find('\\', pos); + if (next == std::string::npos) + next = rest.size(); + if (next - pos == 2 && rest.compare(pos, 2, "..") == 0) + { + if (reason != nullptr) + *reason = "parent folder"; + return FALSE; + } + pos = next + 1; + } + + // a folder: everything in it + if (rest[rest.size() - 1] == '\\') + rest += "*"; + + if (pattern != nullptr) + *pattern = prefix + rest; + return TRUE; +} + +std::string PathExcludeList::makeKey(const Char* path) +{ + std::string value = stripCurrentFolder(normalizeExcludeText(path)); + if (isAbsoluteExcludePath(value)) + return value; + return "game:" + value; +} + +std::string PathExcludeList::makeKey(const Char* path, const Char* userDataPath) +{ + std::string value = normalizeExcludeText(path); + std::string userData = normalizeExcludeText(userDataPath); + if (!userData.empty() && userData[userData.size() - 1] != '\\') + userData += "\\"; + if (!userData.empty() && value.size() > userData.size() && value.compare(0, userData.size(), userData) == 0) + return "user:" + value.substr(userData.size()); + return makeKey(path); +} + +void PathExcludeList::set(const std::vector& entries) +{ + m_patterns.clear(); + for (size_t i = 0; i < entries.size(); ++i) + { + std::string pattern; + if (parse(entries[i], &pattern, nullptr)) + m_patterns.push_back(pattern); + } +} + +// * matches any text, ? one character +static Bool matchesExcludePattern(const char* pattern, const char* text) +{ + const char* starPattern = nullptr; + const char* starText = nullptr; + while (*text != '\0') + { + if (*pattern == '*') + { + starPattern = ++pattern; + starText = text; + } + else if (*pattern == '?' || *pattern == *text) + { + ++pattern; + ++text; + } + else if (starPattern != nullptr) + { + pattern = starPattern; + text = ++starText; + } + else + { + return FALSE; + } + } + while (*pattern == '*') + ++pattern; + return *pattern == '\0'; +} + +Bool PathExcludeList::matches(const std::string& key) const +{ + for (size_t i = 0; i < m_patterns.size(); ++i) + { + if (matchesExcludePattern(m_patterns[i].c_str(), key.c_str())) + return TRUE; + } + return FALSE; +} + //============================================================================ // FileSystem::FileSystem //============================================================================ @@ -179,14 +359,17 @@ File* FileSystem::openFile( const Char *filename, Int access, size_t bufferSize if ( TheLocalFileSystem != nullptr ) { + // an excluded loose file does not exist, unless the file is written + const Bool excluded = (access & (File::WRITE | File::APPEND | File::CREATE | File::TRUNCATE)) == 0 && isLooseFileExcluded(filename); + if (instance != 0) { - if (TheLocalFileSystem->doesFileExist(filename)) + if (!excluded && TheLocalFileSystem->doesFileExist(filename)) { --instance; } } - else + else if (!excluded) { file = TheLocalFileSystem->openFile( filename, access, bufferSize ); @@ -242,7 +425,7 @@ Bool FileSystem::doesFileExist(const Char *filename, FileInstance instance) cons } #endif - if (TheLocalFileSystem->doesFileExist(filename)) + if (!isLooseFileExcluded(filename) && TheLocalFileSystem->doesFileExist(filename)) { if (instance == 0) { @@ -286,7 +469,20 @@ Bool FileSystem::doesFileExist(const Char *filename, FileInstance instance) cons void FileSystem::getFileListInDirectory(const AsciiString& directory, const AsciiString& searchName, FilenameList &filenameList, Bool searchSubdirectories) const { USE_PERF_TIMER(FileSystem) - TheLocalFileSystem->getFileListInDirectory(AsciiString::TheEmptyString, directory, searchName, filenameList, searchSubdirectories); + if (m_excludedPaths.isEmpty()) + { + TheLocalFileSystem->getFileListInDirectory(AsciiString::TheEmptyString, directory, searchName, filenameList, searchSubdirectories); + } + else + { + FilenameList localFiles; + TheLocalFileSystem->getFileListInDirectory(AsciiString::TheEmptyString, directory, searchName, localFiles, searchSubdirectories); + for (FilenameListIter it = localFiles.begin(); it != localFiles.end(); ++it) + { + if (!isLooseFileExcluded(it->str())) + filenameList.insert(*it); + } + } TheArchiveFileSystem->getFileListInDirectory(AsciiString::TheEmptyString, directory, searchName, filenameList, searchSubdirectories); } @@ -304,7 +500,7 @@ Bool FileSystem::getFileInfo(const AsciiString& filename, FileInfo *fileInfo, Fi } memset(fileInfo, 0, sizeof(*fileInfo)); - if (TheLocalFileSystem->getFileInfo(filename, fileInfo)) { + if (!isLooseFileExcluded(filename.str()) && TheLocalFileSystem->getFileInfo(filename, fileInfo)) { if (instance == 0) { return TRUE; } diff --git a/Core/GameEngine/Source/Common/UserPreferences.cpp b/Core/GameEngine/Source/Common/UserPreferences.cpp index ae9f7636b7b..c90c795ae02 100644 --- a/Core/GameEngine/Source/Common/UserPreferences.cpp +++ b/Core/GameEngine/Source/Common/UserPreferences.cpp @@ -260,6 +260,19 @@ AsciiString UserPreferences::getAsciiString(AsciiString key, AsciiString default return it->second; } +UnicodeString UserPreferences::getUnicodeString(AsciiString key, UnicodeString defaultValue) const +{ + UserPreferences::const_iterator it = find(key); + if (it == end()) + { + return defaultValue; + } + + UnicodeString ret; + ret.translate(it->second); + return ret; +} + void UserPreferences::setBool(AsciiString key, Bool val) { (*this)[key] = boolAsStr(val); @@ -280,6 +293,13 @@ void UserPreferences::setAsciiString(AsciiString key, AsciiString val) (*this)[key] = val; } +void UserPreferences::setUnicodeString(AsciiString key, UnicodeString val) +{ + AsciiString str; + str.translate(val); + (*this)[key] = str; +} + //----------------------------------------------------------------------------- // QuickMatchPreferences base class //----------------------------------------------------------------------------- @@ -515,22 +535,16 @@ void CustomMatchPreferences::setLastLadder(const AsciiString& addr, UnsignedShor (*this)["LastLadderPort"] = strVal; } -AsciiString CustomMatchPreferences::getLastLobbyName() const +UnicodeString CustomMatchPreferences::getLastLobbyName() const { - CustomMatchPreferences::const_iterator it = find("LastLobbyName"); - if (it == end()) - { - return AsciiString::TheEmptyString; - } - - AsciiString ret = it->second; + UnicodeString ret = getUnicodeString("LastLobbyName", UnicodeString::TheEmptyString); ret.trim(); return ret; } -void CustomMatchPreferences::setLastLobbyName(const AsciiString& name) +void CustomMatchPreferences::setLastLobbyName(const UnicodeString& name) { - (*this)["LastLobbyName"] = name; + setUnicodeString("LastLobbyName", name); } AsciiString CustomMatchPreferences::getLastLadderAddr( void ) diff --git a/Core/GameEngine/Source/GameNetwork/Connection.cpp b/Core/GameEngine/Source/GameNetwork/Connection.cpp index 8ba8a3e2dcd..d81ede4a504 100644 --- a/Core/GameEngine/Source/GameNetwork/Connection.cpp +++ b/Core/GameEngine/Source/GameNetwork/Connection.cpp @@ -271,7 +271,7 @@ UnsignedInt Connection::doSend() { if ((curtime - m_lastTimeSent) < m_frameGrouping) { // DEBUG_LOG(("not sending packet, time = %d, m_lastFrameSent = %d, m_frameGrouping = %d", curtime, m_lastTimeSent, m_frameGrouping)); - NetworkLog(ELogVerbosity::LOG_DEBUG, "not sending packet, time = %d, m_lastFrameSent = %d, m_frameGrouping = %d", curtime, m_lastTimeSent, m_frameGrouping); + NetworkLog(ELogVerbosity::LOG_DEBUG, "not sending packet, time = %I64d, m_lastFrameSent = %I64d, m_frameGrouping = %I64d", (Int64)curtime, (Int64)m_lastTimeSent, (Int64)m_frameGrouping); return 0; } diff --git a/Core/GameEngine/Source/GameNetwork/ConnectionManager.cpp b/Core/GameEngine/Source/GameNetwork/ConnectionManager.cpp index f347db5a83f..504fa1362c3 100644 --- a/Core/GameEngine/Source/GameNetwork/ConnectionManager.cpp +++ b/Core/GameEngine/Source/GameNetwork/ConnectionManager.cpp @@ -59,6 +59,10 @@ #include "../NetworkMesh.h" #include "../ngmp_interfaces.h" +#if defined(GENERALS_ONLINE) +Int MIN_RUNAHEAD = 4; +#endif + static Bool hasValidTransferFileExtension(const AsciiString& filePath) { static const char* const validExtensions[] = { @@ -272,6 +276,23 @@ ConnectionManager::ConnectionManager() */ void ConnectionManager::init() { +#if defined(GENERALS_ONLINE) + m_networkSettings = NetworkSettings::LocalDefaults(); + NGMP_OnlineServicesManager *services = NGMP_OnlineServicesManager::GetInstance(); + if (TheLAN == nullptr && TheNGMPGame != nullptr && services != nullptr) { + const ServiceConfig &overrides = services->GetServiceConfig(); + m_networkSettings.send = overrides.send; + m_networkSettings.input_delay = overrides.input_delay; + } + const NetworkSettings::InputDelay &runAhead = m_networkSettings.input_delay; + TheWritableGlobalData->m_networkFPSHistoryLength = (std::max)(1, runAhead.fps_history_samples); + TheWritableGlobalData->m_networkLatencyHistoryLength = (std::max)(1, runAhead.formula.latency_history_samples); + TheWritableGlobalData->m_networkRunAheadSlack = runAhead.slack_percent; + TheWritableGlobalData->m_networkRunAheadMetricsTime = runAhead.decision_interval_ms; + MIN_RUNAHEAD = runAhead.min_frames; + FRAME_GROUPING_CAP = (int)(m_networkSettings.send.frames_per_packet * (float)(1000.f / GENERALS_ONLINE_HIGH_FPS_LIMIT)); +#endif + // if (m_transport == nullptr) { // m_transport = new Transport; // } @@ -389,10 +410,10 @@ void ConnectionManager::reset() #endif m_packetRouterSlot = -1; - for (i = 0; i < TheGlobalData->m_networkFPSHistoryLength; ++i) { + for (i = 0; i < (UnsignedInt)MAX_SLOTS; ++i) { m_fpsAverages[i] = -1; } - for (i = 0; i < TheGlobalData->m_networkLatencyHistoryLength; ++i) { + for (i = 0; i < (UnsignedInt)MAX_SLOTS; ++i) { m_latencyAverages[i] = 0.0; } @@ -1995,7 +2016,7 @@ void ConnectionManager::setFrameGrouping(time_t frameGrouping) { // This is probably ok since the packet router should have the fastest connection of all // the players in the game. - NetworkLog(ELogVerbosity::LOG_DEBUG, "ConnectionManager::setFrameGrouping Frame grouping is %d, is packet router %d", frameGrouping, m_localSlot == m_packetRouterSlot); + NetworkLog(ELogVerbosity::LOG_DEBUG, "ConnectionManager::setFrameGrouping Frame grouping is %I64d, is packet router %d", (Int64)frameGrouping, m_localSlot == m_packetRouterSlot); if (m_localSlot == m_packetRouterSlot) { frameGrouping = frameGrouping / 2; @@ -2785,7 +2806,7 @@ void ConnectionManager::sendFrameDataToPlayer(UnsignedInt playerID, UnsignedInt } void ConnectionManager::sendSingleFrameToPlayer(UnsignedInt playerID, UnsignedInt frame) { - if ((TheGameLogic->getFrame() - FRAMES_TO_KEEP) > frame) { + if (frame + FRAMES_TO_KEEP < TheGameLogic->getFrame()) { DEBUG_LOG_LEVEL(DEBUG_LEVEL_NET, ("ConnectionManager::sendSingleFrameToPlayer - player %d requested frame %d when we are on frame %d, this is too far in the past.", playerID, frame, TheGameLogic->getFrame())); return; } diff --git a/Core/GameEngine/Source/GameNetwork/DisconnectManager.cpp b/Core/GameEngine/Source/GameNetwork/DisconnectManager.cpp index 9d2f1b58574..a59a6fcbc7c 100644 --- a/Core/GameEngine/Source/GameNetwork/DisconnectManager.cpp +++ b/Core/GameEngine/Source/GameNetwork/DisconnectManager.cpp @@ -214,7 +214,7 @@ void DisconnectManager::updateDisconnectStatus(ConnectionManager *conMgr) { m_haveNotifiedOtherPlayersOfCurrentFrame = TRUE; } - DEBUG_LOG(("DisconnectManager::updateDisconnectStatus - curTime = %d, m_timeOfDisconnectScreenOn = %d, curTime - m_timeOfDisconnectScreenOn = %d", curTime, m_timeOfDisconnectScreenOn, curTime - m_timeOfDisconnectScreenOn)); + DEBUG_LOG(("DisconnectManager::updateDisconnectStatus - curTime = %I64d, m_timeOfDisconnectScreenOn = %I64d, curTime - m_timeOfDisconnectScreenOn = %I64d", (Int64)curTime, (Int64)m_timeOfDisconnectScreenOn, (Int64)(curTime - m_timeOfDisconnectScreenOn))); if (m_timeOfDisconnectScreenOn != 0) { if ((curTime - m_timeOfDisconnectScreenOn) > TheGlobalData->m_networkDisconnectScreenNotifyTime) { diff --git a/Core/GameEngine/Source/GameNetwork/FrameMetrics.cpp b/Core/GameEngine/Source/GameNetwork/FrameMetrics.cpp index 0de0dd2096c..f6739612bdc 100644 --- a/Core/GameEngine/Source/GameNetwork/FrameMetrics.cpp +++ b/Core/GameEngine/Source/GameNetwork/FrameMetrics.cpp @@ -64,11 +64,11 @@ FrameMetrics::FrameMetrics() } FrameMetrics::~FrameMetrics() { - delete m_fpsList; + delete[] m_fpsList; m_fpsList = NULL; #if !defined(USE_NEW_FRAMEMETRIC_LOGIC) - delete m_latencyList; + delete[] m_latencyList; m_latencyList = NULL; delete[] m_pendingLatencies; @@ -228,8 +228,8 @@ void FrameMetrics::processLatencyResponse(UnsignedInt frame) { m_averageLatency += kvPaAir.second; } - NetworkLog(ELogVerbosity::LOG_DEBUG, "Avg latency is: %f / %d = %f", m_averageLatency, m_mapLatenciesLookup.size(), m_averageLatency /= m_mapLatenciesLookup.size()); m_averageLatency /= m_mapLatenciesLookup.size(); + NetworkLog(ELogVerbosity::LOG_DEBUG, "Avg latency is: %f over %d samples", m_averageLatency, (int)m_mapLatenciesLookup.size()); if (timeDiff > 1000) @@ -280,4 +280,16 @@ void FrameMetrics::SeedLatencyData(int latency) { m_averageFps = GENERALS_ONLINE_HIGH_FPS_LIMIT; m_averageLatency = latency / 1000.f; + +#if defined(USE_NEW_FRAMEMETRIC_LOGIC) + // the next response recomputes the average from these maps, so they must carry the seed too + for (std::unordered_map::iterator it = m_mapLatenciesLookup.begin(); it != m_mapLatenciesLookup.end(); ++it) + { + it->second = m_averageLatency; + } + for (std::map::iterator it = m_mapLatenciesSorted.begin(); it != m_mapLatenciesSorted.end(); ++it) + { + it->second = m_averageLatency; + } +#endif } diff --git a/Core/GameEngineDevice/CMakeLists.txt b/Core/GameEngineDevice/CMakeLists.txt index 6added5403b..71933e7c69f 100644 --- a/Core/GameEngineDevice/CMakeLists.txt +++ b/Core/GameEngineDevice/CMakeLists.txt @@ -83,8 +83,6 @@ set(GAMEENGINEDEVICE_SRC # Include/W3DDevice/GameLogic/W3DGameLogic.h # Include/W3DDevice/GameLogic/W3DGhostObject.h # Include/W3DDevice/GameLogic/W3DTerrainLogic.h - Include/Win32Device/Common/Win32BIGFile.h - Include/Win32Device/Common/Win32BIGFileSystem.h # Include/Win32Device/Common/Win32GameEngine.h Include/Win32Device/Common/Win32LocalFile.h Include/Win32Device/Common/Win32LocalFileSystem.h @@ -186,8 +184,6 @@ set(GAMEENGINEDEVICE_SRC # Source/W3DDevice/GameLogic/W3DGameLogic.cpp # Source/W3DDevice/GameLogic/W3DGhostObject.cpp # Source/W3DDevice/GameLogic/W3DTerrainLogic.cpp - Source/Win32Device/Common/Win32BIGFile.cpp - Source/Win32Device/Common/Win32BIGFileSystem.cpp # Source/Win32Device/Common/Win32GameEngine.cpp Source/Win32Device/Common/Win32LocalFile.cpp Source/Win32Device/Common/Win32LocalFileSystem.cpp @@ -200,12 +196,8 @@ set(GAMEENGINEDEVICE_SRC # Add C++ 17 FileSystem implementation for non-VS6 builds if(NOT IS_VS6_BUILD) list(APPEND GAMEENGINEDEVICE_SRC - Include/StdDevice/Common/StdBIGFile.h - Include/StdDevice/Common/StdBIGFileSystem.h Include/StdDevice/Common/StdLocalFile.h Include/StdDevice/Common/StdLocalFileSystem.h - Source/StdDevice/Common/StdBIGFile.cpp - Source/StdDevice/Common/StdBIGFileSystem.cpp Source/StdDevice/Common/StdLocalFile.cpp Source/StdDevice/Common/StdLocalFileSystem.cpp ) diff --git a/Core/GameEngineDevice/Include/StdDevice/Common/StdBIGFile.h b/Core/GameEngineDevice/Include/StdDevice/Common/StdBIGFile.h deleted file mode 100644 index 36538853352..00000000000 --- a/Core/GameEngineDevice/Include/StdDevice/Common/StdBIGFile.h +++ /dev/null @@ -1,53 +0,0 @@ -/* -** Command & Conquer Generals Zero Hour(tm) -** Copyright 2025 Electronic Arts Inc. -** -** This program is free software: you can redistribute it and/or modify -** it under the terms of the GNU General Public License as published by -** the Free Software Foundation, either version 3 of the License, or -** (at your option) any later version. -** -** This program is distributed in the hope that it will be useful, -** but WITHOUT ANY WARRANTY; without even the implied warranty of -** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -** GNU General Public License for more details. -** -** You should have received a copy of the GNU General Public License -** along with this program. If not, see . -*/ - -//////////////////////////////////////////////////////////////////////////////// -// // -// (c) 2001-2003 Electronic Arts Inc. // -// // -//////////////////////////////////////////////////////////////////////////////// - -/////// StdBIGFile.h //////////////////////////////////// -// Stephan Vedder, April 2025 -/////////////////////////////////////////////////////////// - -#pragma once - -#include "Common/ArchiveFile.h" -#include "Common/AsciiString.h" -#include "Common/List.h" - -class StdBIGFile : public ArchiveFile -{ - public: - StdBIGFile(AsciiString name, AsciiString path); - virtual ~StdBIGFile() override; - - virtual Bool getFileInfo(const AsciiString& filename, FileInfo *fileInfo) const override; ///< fill in the fileInfo struct with info about the requested file. - virtual File* openFile( const Char *filename, Int access = 0 ) override;///< Open the specified file within the BIG file - virtual void closeAllFiles() override; ///< Close all file opened in this BIG file - virtual AsciiString getName() override; ///< Returns the name of the BIG file - virtual AsciiString getPath() override; ///< Returns full path and name of BIG file - virtual void setSearchPriority( Int new_priority ) override; ///< Set this BIG file's search priority - virtual void close() override; ///< Close this BIG file - - protected: - - AsciiString m_name; ///< BIG file name - AsciiString m_path; ///< BIG file path -}; diff --git a/Core/GameEngineDevice/Include/StdDevice/Common/StdBIGFileSystem.h b/Core/GameEngineDevice/Include/StdDevice/Common/StdBIGFileSystem.h deleted file mode 100644 index b8cb316ab7c..00000000000 --- a/Core/GameEngineDevice/Include/StdDevice/Common/StdBIGFileSystem.h +++ /dev/null @@ -1,55 +0,0 @@ -/* -** Command & Conquer Generals Zero Hour(tm) -** Copyright 2025 Electronic Arts Inc. -** -** This program is free software: you can redistribute it and/or modify -** it under the terms of the GNU General Public License as published by -** the Free Software Foundation, either version 3 of the License, or -** (at your option) any later version. -** -** This program is distributed in the hope that it will be useful, -** but WITHOUT ANY WARRANTY; without even the implied warranty of -** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -** GNU General Public License for more details. -** -** You should have received a copy of the GNU General Public License -** along with this program. If not, see . -*/ - -//////////////////////////////////////////////////////////////////////////////// -// // -// (c) 2001-2003 Electronic Arts Inc. // -// // -//////////////////////////////////////////////////////////////////////////////// - -//////// StdBIGFileSystem.h /////////////////////////// -// Stephan Vedder, April 2025 -///////////////////////////////////////////////////////////// - -#pragma once - -#include "Common/ArchiveFileSystem.h" - -class StdBIGFileSystem : public ArchiveFileSystem -{ -public: - StdBIGFileSystem(); - virtual ~StdBIGFileSystem() override; - - virtual void init() override; - virtual void update() override; - virtual void reset() override; - virtual void postProcessLoad() override; - - // ArchiveFile operations - virtual void closeAllArchiveFiles() override; ///< Close all Archivefiles currently open - - // File operations - virtual ArchiveFile * openArchiveFile(const Char *filename) override; - virtual void closeArchiveFile(const Char *filename) override; - virtual void closeAllFiles() override; ///< Close all files associated with ArchiveFiles - - virtual Bool loadBigFilesFromDirectory(AsciiString dir, AsciiString fileMask, Bool overwrite = FALSE) override; -protected: - -}; diff --git a/Core/GameEngineDevice/Include/Win32Device/Common/Win32BIGFile.h b/Core/GameEngineDevice/Include/Win32Device/Common/Win32BIGFile.h deleted file mode 100644 index 093aadbcf4c..00000000000 --- a/Core/GameEngineDevice/Include/Win32Device/Common/Win32BIGFile.h +++ /dev/null @@ -1,53 +0,0 @@ -/* -** Command & Conquer Generals Zero Hour(tm) -** Copyright 2025 Electronic Arts Inc. -** -** This program is free software: you can redistribute it and/or modify -** it under the terms of the GNU General Public License as published by -** the Free Software Foundation, either version 3 of the License, or -** (at your option) any later version. -** -** This program is distributed in the hope that it will be useful, -** but WITHOUT ANY WARRANTY; without even the implied warranty of -** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -** GNU General Public License for more details. -** -** You should have received a copy of the GNU General Public License -** along with this program. If not, see . -*/ - -//////////////////////////////////////////////////////////////////////////////// -// // -// (c) 2001-2003 Electronic Arts Inc. // -// // -//////////////////////////////////////////////////////////////////////////////// - -/////// Win32BIGFile.h //////////////////////////////////// -// Bryan Cleveland, August 2002 -/////////////////////////////////////////////////////////// - -#pragma once - -#include "Common/ArchiveFile.h" -#include "Common/AsciiString.h" -#include "Common/List.h" - -class Win32BIGFile : public ArchiveFile -{ - public: - Win32BIGFile(AsciiString name, AsciiString path); - virtual ~Win32BIGFile() override; - - virtual Bool getFileInfo(const AsciiString& filename, FileInfo *fileInfo) const override; ///< fill in the fileInfo struct with info about the requested file. - virtual File* openFile( const Char *filename, Int access = 0 ) override;///< Open the specified file within the BIG file - virtual void closeAllFiles() override; ///< Close all file opened in this BIG file - virtual AsciiString getName() override; ///< Returns the name of the BIG file - virtual AsciiString getPath() override; ///< Returns full path and name of BIG file - virtual void setSearchPriority( Int new_priority ) override; ///< Set this BIG file's search priority - virtual void close() override; ///< Close this BIG file - - protected: - - AsciiString m_name; ///< BIG file name - AsciiString m_path; ///< BIG file path -}; diff --git a/Core/GameEngineDevice/Source/StdDevice/Common/StdBIGFile.cpp b/Core/GameEngineDevice/Source/StdDevice/Common/StdBIGFile.cpp deleted file mode 100644 index c873b733524..00000000000 --- a/Core/GameEngineDevice/Source/StdDevice/Common/StdBIGFile.cpp +++ /dev/null @@ -1,168 +0,0 @@ -/* -** Command & Conquer Generals Zero Hour(tm) -** Copyright 2025 Electronic Arts Inc. -** -** This program is free software: you can redistribute it and/or modify -** it under the terms of the GNU General Public License as published by -** the Free Software Foundation, either version 3 of the License, or -** (at your option) any later version. -** -** This program is distributed in the hope that it will be useful, -** but WITHOUT ANY WARRANTY; without even the implied warranty of -** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -** GNU General Public License for more details. -** -** You should have received a copy of the GNU General Public License -** along with this program. If not, see . -*/ - -//////////////////////////////////////////////////////////////////////////////// -// // -// (c) 2001-2003 Electronic Arts Inc. // -// // -//////////////////////////////////////////////////////////////////////////////// - -////// StdBIGFile.cpp ///////////////////////// -// Stephan Vedder, April 2025 -///////////////////////////////////////////////////// - -#include "Common/LocalFile.h" -#include "Common/LocalFileSystem.h" -#include "Common/RAMFile.h" -#include "Common/StreamingArchiveFile.h" -#include "Common/GameMemory.h" -#include "Common/PerfTimer.h" -#include "StdDevice/Common/StdBIGFile.h" - -//============================================================================ -// StdBIGFile::StdBIGFile -//============================================================================ - -StdBIGFile::StdBIGFile(AsciiString name, AsciiString path) - : m_name(name) - , m_path(path) -{ - -} - -//============================================================================ -// StdBIGFile::~StdBIGFile -//============================================================================ - -StdBIGFile::~StdBIGFile() -{ - -} - -//============================================================================ -// StdBIGFile::openFile -//============================================================================ - -File* StdBIGFile::openFile( const Char *filename, Int access ) -{ - const ArchivedFileInfo *fileInfo = getArchivedFileInfo(AsciiString(filename)); - - if (fileInfo == nullptr) { - return nullptr; - } - - RAMFile *ramFile = nullptr; - - if (BitIsSet(access, File::STREAMING)) - ramFile = newInstance( StreamingArchiveFile ); - else - ramFile = newInstance( RAMFile ); - - ramFile->deleteOnClose(); - if (ramFile->openFromArchive(m_file, fileInfo->m_filename, fileInfo->m_offset, fileInfo->m_size) == FALSE) { - ramFile->close(); - ramFile = nullptr; - return nullptr; - } - - if ((access & File::WRITE) == 0) { - // requesting read only access. Just return the RAM file. - return ramFile; - } - - // whoever is opening this file wants write access, so copy the file to the local disk - // and return that file pointer. - - constexpr size_t bufferSize = 0; - File *localFile = TheLocalFileSystem->openFile(filename, access, bufferSize); - if (localFile != nullptr) { - ramFile->copyDataToFile(localFile); - } - - ramFile->close(); - ramFile = nullptr; - - return localFile; -} - -//============================================================================ -// StdBIGFile::closeAllFiles -//============================================================================ - -void StdBIGFile::closeAllFiles() -{ - -} - -//============================================================================ -// StdBIGFile::getName -//============================================================================ - -AsciiString StdBIGFile::getName() -{ - return m_name; -} - -//============================================================================ -// StdBIGFile::getPath -//============================================================================ - -AsciiString StdBIGFile::getPath() -{ - return m_path; -} - -//============================================================================ -// StdBIGFile::setSearchPriority -//============================================================================ - -void StdBIGFile::setSearchPriority( Int new_priority ) -{ - -} - -//============================================================================ -// StdBIGFile::close -//============================================================================ - -void StdBIGFile::close() -{ - -} - -//============================================================================ -// StdBIGFile::getFileInfo -//============================================================================ - -Bool StdBIGFile::getFileInfo(const AsciiString& filename, FileInfo *fileInfo) const -{ - const ArchivedFileInfo *tempFileInfo = getArchivedFileInfo(filename); - - if (tempFileInfo == nullptr) { - return FALSE; - } - - TheLocalFileSystem->getFileInfo(AsciiString(m_file->getName()), fileInfo); - - // fill in the size info. Since the size can't be bigger than a JUNK file, the high Int will always be 0. - fileInfo->sizeHigh = 0; - fileInfo->sizeLow = tempFileInfo->m_size; - - return TRUE; -} - diff --git a/Core/GameEngineDevice/Source/StdDevice/Common/StdBIGFileSystem.cpp b/Core/GameEngineDevice/Source/StdDevice/Common/StdBIGFileSystem.cpp deleted file mode 100644 index ffd0150f4c5..00000000000 --- a/Core/GameEngineDevice/Source/StdDevice/Common/StdBIGFileSystem.cpp +++ /dev/null @@ -1,242 +0,0 @@ -/* -** Command & Conquer Generals Zero Hour(tm) -** Copyright 2025 Electronic Arts Inc. -** -** This program is free software: you can redistribute it and/or modify -** it under the terms of the GNU General Public License as published by -** the Free Software Foundation, either version 3 of the License, or -** (at your option) any later version. -** -** This program is distributed in the hope that it will be useful, -** but WITHOUT ANY WARRANTY; without even the implied warranty of -** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -** GNU General Public License for more details. -** -** You should have received a copy of the GNU General Public License -** along with this program. If not, see . -*/ - -//////////////////////////////////////////////////////////////////////////////// -// // -// (c) 2001-2003 Electronic Arts Inc. // -// // -//////////////////////////////////////////////////////////////////////////////// - -//////// StdBIGFileSystem.h /////////////////////////// -// Stephan Vedder, April 2025 -///////////////////////////////////////////////////////////// - -#include "Common/AudioAffect.h" -#include "Common/ArchiveFile.h" -#include "Common/ArchiveFileSystem.h" -#include "Common/file.h" -#include "Common/GameAudio.h" -#include "Common/GameMemory.h" -#include "Common/LocalFileSystem.h" - -#if RTS_ZEROHOUR -#include "Common/Registry.h" -#endif - -#include "StdDevice/Common/StdBIGFile.h" -#include "StdDevice/Common/StdBIGFileSystem.h" -#include "Utility/endian_compat.h" - -static const char *BIGFileIdentifier = "BIGF"; - -StdBIGFileSystem::StdBIGFileSystem() : ArchiveFileSystem() { -} - -StdBIGFileSystem::~StdBIGFileSystem() { -} - -void StdBIGFileSystem::init() { - DEBUG_ASSERTCRASH(TheLocalFileSystem != nullptr, ("TheLocalFileSystem must be initialized before TheArchiveFileSystem.")); - if (TheLocalFileSystem == nullptr) { - return; - } - - loadBigFilesFromDirectory("", "*.big"); - -#if RTS_ZEROHOUR - // load original Generals assets - AsciiString installPath; - GetStringFromGeneralsRegistry("", "InstallPath", installPath ); - //@todo this will need to be ramped up to a crash for release - DEBUG_ASSERTCRASH(!installPath.isEmpty(), ("Be 1337! Go install Generals!")); - if (!installPath.isEmpty()) - loadBigFilesFromDirectory(installPath, "*.big"); -#endif -} - -void StdBIGFileSystem::reset() { -} - -void StdBIGFileSystem::update() { -} - -void StdBIGFileSystem::postProcessLoad() { -} - -ArchiveFile * StdBIGFileSystem::openArchiveFile(const Char *filename) { - File *fp = TheLocalFileSystem->openFile(filename, File::READ | File::BINARY); - AsciiString archiveFileName; - archiveFileName = filename; - archiveFileName.toLower(); - Int archiveFileSize = 0; - Int numLittleFiles = 0; - - ArchiveFile *archiveFile = NEW StdBIGFile(filename, AsciiString::TheEmptyString); - - DEBUG_LOG(("StdBIGFileSystem::openArchiveFile - opening BIG file %s", filename)); - - if (fp == nullptr) { - DEBUG_CRASH(("Could not open archive file %s for parsing", filename)); - return nullptr; - } - - AsciiString asciibuf; - char buffer[_MAX_PATH]; - fp->read(buffer, 4); // read the "BIG" at the beginning of the file. - buffer[4] = 0; - if (strcmp(buffer, BIGFileIdentifier) != 0) { - DEBUG_CRASH(("Error reading BIG file identifier in file %s", filename)); - fp->close(); - fp = nullptr; - return nullptr; - } - - // read in the file size. - fp->read(&archiveFileSize, 4); - - DEBUG_LOG(("StdBIGFileSystem::openArchiveFile - size of archive file is %d bytes", archiveFileSize)); - -// char t; - - // read in the number of files contained in this BIG file. - // change the order of the bytes cause the file size is in reverse byte order for some reason. - fp->read(&numLittleFiles, 4); - numLittleFiles = betoh(numLittleFiles); - - DEBUG_LOG(("StdBIGFileSystem::openArchiveFile - %d are contained in archive", numLittleFiles)); -// for (Int i = 0; i < 2; ++i) { -// t = buffer[i]; -// buffer[i] = buffer[(4-i)-1]; -// buffer[(4-i)-1] = t; -// } - - // seek to the beginning of the directory listing. - fp->seek(0x10, File::START); - // read in each directory listing. - ArchivedFileInfo *fileInfo = NEW ArchivedFileInfo; - - for (Int i = 0; i < numLittleFiles; ++i) { - Int filesize = 0; - Int fileOffset = 0; - fp->read(&fileOffset, 4); - fp->read(&filesize, 4); - - filesize = betoh(filesize); - fileOffset = betoh(fileOffset); - - fileInfo->m_archiveFilename = archiveFileName; - fileInfo->m_offset = fileOffset; - fileInfo->m_size = filesize; - - // read in the path name of the file. - Int pathIndex = -1; - do { - ++pathIndex; - fp->read(buffer + pathIndex, 1); - } while (buffer[pathIndex] != 0); - - Int filenameIndex = pathIndex; - while ((filenameIndex >= 0) && (buffer[filenameIndex] != '\\') && (buffer[filenameIndex] != '/')) { - --filenameIndex; - } - - fileInfo->m_filename = (char *)(buffer + filenameIndex + 1); - fileInfo->m_filename.toLower(); - buffer[filenameIndex + 1] = 0; - - AsciiString path; - path = buffer; - - AsciiString debugpath; - debugpath = path; - debugpath.concat(fileInfo->m_filename); -// DEBUG_LOG(("StdBIGFileSystem::openArchiveFile - adding file %s to archive file %s, file number %d", debugpath.str(), fileInfo->m_archiveFilename.str(), i)); - - archiveFile->addFile(path, fileInfo); - } - - archiveFile->attachFile(fp); - - delete fileInfo; - fileInfo = nullptr; - - // leave fp open as the archive file will be using it. - - return archiveFile; -} - -void StdBIGFileSystem::closeArchiveFile(const Char *filename) { - // Need to close the specified big file - ArchiveFileMap::iterator it = m_archiveFileMap.find(filename); - if (it == m_archiveFileMap.end()) { - return; - } - - if (stricmp(filename, MUSIC_BIG) == 0) { - // Stop the current audio - TheAudio->stopAudio(AudioAffect_Music); - - // No need to turn off other audio, as the lookups will just fail. - } - DEBUG_ASSERTCRASH(stricmp(filename, MUSIC_BIG) == 0, ("Attempting to close Archive file '%s', need to add code to handle its shutdown correctly.", filename)); - - // may need to do some other processing here first. - - delete (it->second); - m_archiveFileMap.erase(it); -} - -void StdBIGFileSystem::closeAllArchiveFiles() { -} - -void StdBIGFileSystem::closeAllFiles() { -} - -Bool StdBIGFileSystem::loadBigFilesFromDirectory(AsciiString dir, AsciiString fileMask, Bool overwrite) { - - FilenameList filenameList; - TheLocalFileSystem->getFileListInDirectory(dir, "", fileMask, filenameList, TRUE); - - Bool actuallyAdded = FALSE; - FilenameListIter it = filenameList.begin(); - while (it != filenameList.end()) { -#if RTS_ZEROHOUR - // TheSuperHackers @bugfix bobtista 18/11/2025 Skip duplicate INIZH.big in Data\INI to prevent CRC mismatches. - // English, Chinese, and Korean SKUs shipped with two INIZH.big files (one in Run directory, one in Run\Data\INI). - // The DeleteFile cleanup doesn't work on EA App/Origin installs because the folder is not writable, so we skip loading it instead. - if (it->endsWithNoCase("Data\\INI\\INIZH.big") || it->endsWithNoCase("Data/INI/INIZH.big")) { - it++; - continue; - } -#endif - - ArchiveFile *archiveFile = openArchiveFile((*it).str()); - - if (archiveFile != nullptr) { - DEBUG_LOG(("StdBIGFileSystem::loadBigFilesFromDirectory - loading %s into the directory tree.", (*it).str())); - loadIntoDirectoryTree(archiveFile, overwrite); - m_archiveFileMap[(*it)] = archiveFile; - DEBUG_LOG(("StdBIGFileSystem::loadBigFilesFromDirectory - %s inserted into the archive file map.", (*it).str())); - actuallyAdded = TRUE; - } - - it++; - } - - return actuallyAdded; -} diff --git a/Core/GameEngineDevice/Source/StdDevice/Common/StdLocalFileSystem.cpp b/Core/GameEngineDevice/Source/StdDevice/Common/StdLocalFileSystem.cpp index d47e473f4d2..ee53db20549 100644 --- a/Core/GameEngineDevice/Source/StdDevice/Common/StdLocalFileSystem.cpp +++ b/Core/GameEngineDevice/Source/StdDevice/Common/StdLocalFileSystem.cpp @@ -304,10 +304,10 @@ Bool StdLocalFileSystem::getFileInfo(const AsciiString& filename, FileInfo *file // TODO: fix this to be win compatible (time since 1601) auto time = write_time.time_since_epoch().count(); + fileInfo->sizeHigh = file_size >> 32; + fileInfo->sizeLow = file_size & UINT32_MAX; fileInfo->timestampHigh = time >> 32; fileInfo->timestampLow = time & UINT32_MAX; - fileInfo->sizeHigh = file_size >> 32; - fileInfo->sizeLow = file_size & UINT32_MAX; return TRUE; } diff --git a/Core/GameEngineDevice/Source/Win32Device/Common/Win32BIGFile.cpp b/Core/GameEngineDevice/Source/Win32Device/Common/Win32BIGFile.cpp deleted file mode 100644 index 3deee01fd82..00000000000 --- a/Core/GameEngineDevice/Source/Win32Device/Common/Win32BIGFile.cpp +++ /dev/null @@ -1,168 +0,0 @@ -/* -** Command & Conquer Generals Zero Hour(tm) -** Copyright 2025 Electronic Arts Inc. -** -** This program is free software: you can redistribute it and/or modify -** it under the terms of the GNU General Public License as published by -** the Free Software Foundation, either version 3 of the License, or -** (at your option) any later version. -** -** This program is distributed in the hope that it will be useful, -** but WITHOUT ANY WARRANTY; without even the implied warranty of -** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -** GNU General Public License for more details. -** -** You should have received a copy of the GNU General Public License -** along with this program. If not, see . -*/ - -//////////////////////////////////////////////////////////////////////////////// -// // -// (c) 2001-2003 Electronic Arts Inc. // -// // -//////////////////////////////////////////////////////////////////////////////// - -////// Win32BIGFile.cpp ///////////////////////// -// Bryan Cleveland, August 2002 -///////////////////////////////////////////////////// - -#include "Common/LocalFile.h" -#include "Common/LocalFileSystem.h" -#include "Common/RAMFile.h" -#include "Common/StreamingArchiveFile.h" -#include "Common/GameMemory.h" -#include "Common/PerfTimer.h" -#include "Win32Device/Common/Win32BIGFile.h" - -//============================================================================ -// Win32BIGFile::Win32BIGFile -//============================================================================ - -Win32BIGFile::Win32BIGFile(AsciiString name, AsciiString path) - : m_name(name) - , m_path(path) -{ - -} - -//============================================================================ -// Win32BIGFile::~Win32BIGFile -//============================================================================ - -Win32BIGFile::~Win32BIGFile() -{ - -} - -//============================================================================ -// Win32BIGFile::openFile -//============================================================================ - -File* Win32BIGFile::openFile( const Char *filename, Int access ) -{ - const ArchivedFileInfo *fileInfo = getArchivedFileInfo(AsciiString(filename)); - - if (fileInfo == nullptr) { - return nullptr; - } - - RAMFile *ramFile = nullptr; - - if (BitIsSet(access, File::STREAMING)) - ramFile = newInstance( StreamingArchiveFile ); - else - ramFile = newInstance( RAMFile ); - - ramFile->deleteOnClose(); - if (ramFile->openFromArchive(m_file, fileInfo->m_filename, fileInfo->m_offset, fileInfo->m_size) == FALSE) { - ramFile->close(); - ramFile = nullptr; - return nullptr; - } - - if ((access & File::WRITE) == 0) { - // requesting read only access. Just return the RAM file. - return ramFile; - } - - // whoever is opening this file wants write access, so copy the file to the local disk - // and return that file pointer. - - constexpr size_t bufferSize = 0; - File *localFile = TheLocalFileSystem->openFile(filename, access, bufferSize); - if (localFile != nullptr) { - ramFile->copyDataToFile(localFile); - } - - ramFile->close(); - ramFile = nullptr; - - return localFile; -} - -//============================================================================ -// Win32BIGFile::closeAllFiles -//============================================================================ - -void Win32BIGFile::closeAllFiles() -{ - -} - -//============================================================================ -// Win32BIGFile::getName -//============================================================================ - -AsciiString Win32BIGFile::getName() -{ - return m_name; -} - -//============================================================================ -// Win32BIGFile::getPath -//============================================================================ - -AsciiString Win32BIGFile::getPath() -{ - return m_path; -} - -//============================================================================ -// Win32BIGFile::setSearchPriority -//============================================================================ - -void Win32BIGFile::setSearchPriority( Int new_priority ) -{ - -} - -//============================================================================ -// Win32BIGFile::close -//============================================================================ - -void Win32BIGFile::close() -{ - -} - -//============================================================================ -// Win32BIGFile::getFileInfo -//============================================================================ - -Bool Win32BIGFile::getFileInfo(const AsciiString& filename, FileInfo *fileInfo) const -{ - const ArchivedFileInfo *tempFileInfo = getArchivedFileInfo(filename); - - if (tempFileInfo == nullptr) { - return FALSE; - } - - TheLocalFileSystem->getFileInfo(AsciiString(m_file->getName()), fileInfo); - - // fill in the size info. Since the size can't be bigger than a JUNK file, the high Int will always be 0. - fileInfo->sizeHigh = 0; - fileInfo->sizeLow = tempFileInfo->m_size; - - return TRUE; -} - diff --git a/Core/GameEngineDevice/Source/Win32Device/Common/Win32LocalFileSystem.cpp b/Core/GameEngineDevice/Source/Win32Device/Common/Win32LocalFileSystem.cpp index 43a6ca5cbdd..5ec6fd35680 100644 --- a/Core/GameEngineDevice/Source/Win32Device/Common/Win32LocalFileSystem.cpp +++ b/Core/GameEngineDevice/Source/Win32Device/Common/Win32LocalFileSystem.cpp @@ -60,7 +60,7 @@ File * Win32LocalFileSystem::openFile(const Char *filename, Int access, size_t b AsciiString dirName; string.nextToken(&token, "\\/"); dirName = token; - while ((token.find('.') == nullptr) || (string.find('.') != nullptr)) { + while (!string.isEmpty()) { createDirectory(dirName); string.nextToken(&token, "\\/"); dirName.concat('\\'); @@ -194,10 +194,10 @@ Bool Win32LocalFileSystem::getFileInfo(const AsciiString& filename, FileInfo *fi return FALSE; } - fileInfo->timestampHigh = findData.ftLastWriteTime.dwHighDateTime; - fileInfo->timestampLow = findData.ftLastWriteTime.dwLowDateTime; fileInfo->sizeHigh = findData.nFileSizeHigh; fileInfo->sizeLow = findData.nFileSizeLow; + fileInfo->timestampHigh = findData.ftLastWriteTime.dwHighDateTime; + fileInfo->timestampLow = findData.ftLastWriteTime.dwLowDateTime; FindClose(findHandle); diff --git a/Core/Tools/Autorun/autorun.cpp b/Core/Tools/Autorun/autorun.cpp index 2e5a428c2b3..4f3618dcf2a 100644 --- a/Core/Tools/Autorun/autorun.cpp +++ b/Core/Tools/Autorun/autorun.cpp @@ -119,7 +119,7 @@ #include "GameClient/GameText.h" #include "Common/UnicodeString.h" #include "Win32Device/Common/Win32LocalFileSystem.h" -#include "Win32Device/Common/Win32BIGFileSystem.h" +#include "Common/BIGFileSystem.h" #endif //----------------------------------------------------------------------------- @@ -449,7 +449,7 @@ int PASCAL WinMain( HINSTANCE hInstance, HINSTANCE hPrevInstance, LPTSTR lpszCmd // create singletons TheFileSystem->init(); TheLocalFileSystem = new Win32LocalFileSystem; - TheArchiveFileSystem = new Win32BIGFileSystem; + TheArchiveFileSystem = new BIGFileSystem; TheGameText = CreateGameTextInterface(); TheGameText->init(); diff --git a/Core/Tools/MapCacheBuilder/Source/WinMain.cpp b/Core/Tools/MapCacheBuilder/Source/WinMain.cpp index ab545a07892..fe7fcf1bc5d 100644 --- a/Core/Tools/MapCacheBuilder/Source/WinMain.cpp +++ b/Core/Tools/MapCacheBuilder/Source/WinMain.cpp @@ -51,8 +51,8 @@ #include "Common/ThingFactory.h" #include "Common/FileSystem.h" +#include "Common/BIGFileSystem.h" #include "Win32Device/Common/Win32LocalFileSystem.h" -#include "Win32Device/Common/Win32BIGFileSystem.h" #include "Common/SubsystemInterface.h" #include "GameClient/MapUtil.h" #include "W3DDevice/Common/W3DModuleFactory.h" @@ -101,7 +101,6 @@ #include #include "Win32Device/GameClient/Win32Mouse.h" #include "Win32Device/Common/Win32LocalFileSystem.h" -#include "Win32Device/Common/Win32BIGFileSystem.h" #include "WWLib/trim.h" @@ -249,7 +248,7 @@ Int APIENTRY WinMain( HINSTANCE hInstance, HINSTANCE hPrevInstance, TheFileSystem = new FileSystem; initSubsystem(TheLocalFileSystem, (LocalFileSystem*)new Win32LocalFileSystem); - initSubsystem(TheArchiveFileSystem, (ArchiveFileSystem*)new Win32BIGFileSystem); + initSubsystem(TheArchiveFileSystem, (ArchiveFileSystem*)new BIGFileSystem); INI ini; initSubsystem(TheWritableGlobalData, new GlobalData(), "Data\\INI\\Default\\GameData.ini", "Data\\INI\\GameData.ini"); initSubsystem(TheGameText, CreateGameTextInterface()); diff --git a/Generals/Code/GameEngineDevice/CMakeLists.txt b/Generals/Code/GameEngineDevice/CMakeLists.txt index dea3ff6c9ae..dbb41d0f7b4 100644 --- a/Generals/Code/GameEngineDevice/CMakeLists.txt +++ b/Generals/Code/GameEngineDevice/CMakeLists.txt @@ -70,8 +70,6 @@ set(GAMEENGINEDEVICE_SRC Include/W3DDevice/GameLogic/W3DGameLogic.h Include/W3DDevice/GameLogic/W3DGhostObject.h Include/W3DDevice/GameLogic/W3DTerrainLogic.h -# Include/Win32Device/Common/Win32BIGFile.h -# Include/Win32Device/Common/Win32BIGFileSystem.h Include/Win32Device/Common/Win32GameEngine.h # Include/Win32Device/Common/Win32LocalFile.h # Include/Win32Device/Common/Win32LocalFileSystem.h @@ -168,8 +166,6 @@ set(GAMEENGINEDEVICE_SRC Source/W3DDevice/GameLogic/W3DGameLogic.cpp Source/W3DDevice/GameLogic/W3DGhostObject.cpp Source/W3DDevice/GameLogic/W3DTerrainLogic.cpp -# Source/Win32Device/Common/Win32BIGFile.cpp -# Source/Win32Device/Common/Win32BIGFileSystem.cpp Source/Win32Device/Common/Win32GameEngine.cpp # Source/Win32Device/Common/Win32LocalFile.cpp # Source/Win32Device/Common/Win32LocalFileSystem.cpp diff --git a/Generals/Code/GameEngineDevice/Include/Win32Device/Common/Win32GameEngine.h b/Generals/Code/GameEngineDevice/Include/Win32Device/Common/Win32GameEngine.h index b17369dc844..01f5b0af14f 100644 --- a/Generals/Code/GameEngineDevice/Include/Win32Device/Common/Win32GameEngine.h +++ b/Generals/Code/GameEngineDevice/Include/Win32Device/Common/Win32GameEngine.h @@ -37,7 +37,7 @@ #include "GameLogic/GameLogic.h" #include "GameNetwork/NetworkInterface.h" #include "MilesAudioDevice/MilesAudioManager.h" -#include "Win32Device/Common/Win32BIGFileSystem.h" +#include "Common/BIGFileSystem.h" #include "Win32Device/Common/Win32LocalFileSystem.h" #include "W3DDevice/Common/W3DModuleFactory.h" #include "W3DDevice/GameLogic/W3DGameLogic.h" @@ -93,7 +93,7 @@ inline ModuleFactory *Win32GameEngine::createModuleFactory() { return NEW W3DMod inline ThingFactory *Win32GameEngine::createThingFactory() { return NEW W3DThingFactory; } inline FunctionLexicon *Win32GameEngine::createFunctionLexicon() { return NEW W3DFunctionLexicon; } inline LocalFileSystem *Win32GameEngine::createLocalFileSystem() { return NEW Win32LocalFileSystem; } -inline ArchiveFileSystem *Win32GameEngine::createArchiveFileSystem() { return NEW Win32BIGFileSystem; } +inline ArchiveFileSystem *Win32GameEngine::createArchiveFileSystem() { return NEW BIGFileSystem; } inline ParticleSystemManager* Win32GameEngine::createParticleSystemManager(Bool dummy) { if (dummy) diff --git a/Generals/Code/Tools/GUIEdit/Source/GUIEdit.cpp b/Generals/Code/Tools/GUIEdit/Source/GUIEdit.cpp index 4d18b320cd8..d5c928ff52a 100644 --- a/Generals/Code/Tools/GUIEdit/Source/GUIEdit.cpp +++ b/Generals/Code/Tools/GUIEdit/Source/GUIEdit.cpp @@ -85,7 +85,7 @@ #include "Win32Device/GameClient/Win32Mouse.h" #include "Win32Device/GameClient/Win32DIKeyboard.h" #include "Win32Device/Common/Win32LocalFileSystem.h" -#include "Win32Device/Common/Win32BIGFileSystem.h" +#include "Common/BIGFileSystem.h" #include "resource.h" #include "WinMain.h" @@ -485,7 +485,7 @@ void GUIEdit::init() // create file system TheFileSystem = new FileSystem; TheLocalFileSystem = new Win32LocalFileSystem; - TheArchiveFileSystem = new Win32BIGFileSystem; + TheArchiveFileSystem = new BIGFileSystem; TheFileSystem->init(); //--------------------------------------------------------------------------- diff --git a/Generals/Code/Tools/WorldBuilder/src/WorldBuilder.cpp b/Generals/Code/Tools/WorldBuilder/src/WorldBuilder.cpp index b3287235361..9c5421c320d 100644 --- a/Generals/Code/Tools/WorldBuilder/src/WorldBuilder.cpp +++ b/Generals/Code/Tools/WorldBuilder/src/WorldBuilder.cpp @@ -79,7 +79,7 @@ #include #include "Win32Device/GameClient/Win32Mouse.h" #include "Win32Device/Common/Win32LocalFileSystem.h" -#include "Win32Device/Common/Win32BIGFileSystem.h" +#include "Common/BIGFileSystem.h" static SubsystemInterfaceList TheSubsystemListRecord; @@ -316,7 +316,7 @@ BOOL CWorldBuilderApp::InitInstance() TheFileSystem = new FileSystem; initSubsystem(TheLocalFileSystem, (LocalFileSystem*)new Win32LocalFileSystem); - initSubsystem(TheArchiveFileSystem, (ArchiveFileSystem*)new Win32BIGFileSystem); + initSubsystem(TheArchiveFileSystem, (ArchiveFileSystem*)new BIGFileSystem); // Just for kicks, get the HINSTANCE that WOL would need // if we were going to use it, which we aren't. diff --git a/GeneralsMD/Code/GameEngine/CMakeLists.txt b/GeneralsMD/Code/GameEngine/CMakeLists.txt index a84c0935e98..05bea1c62ca 100644 --- a/GeneralsMD/Code/GameEngine/CMakeLists.txt +++ b/GeneralsMD/Code/GameEngine/CMakeLists.txt @@ -1160,9 +1160,13 @@ set(GAMEENGINE_SRC Include/GameNetwork/GeneralsOnline/HTTP/HTTPRequest.h Include/GameNetwork/GeneralsOnline/NextGenTransport.h Include/GameNetwork/GeneralsOnline/PluginInterfaces.h + Source/GameNetwork/GeneralsOnline/CredentialStore.cpp Source/GameNetwork/GeneralsOnline/DiscordRichPresence.cpp Source/GameNetwork/GeneralsOnline/NGMP_Helpers.cpp Source/GameNetwork/GeneralsOnline/NGMPGame.cpp + Source/GameNetwork/GeneralsOnline/ProxyLogin.cpp + Source/GameNetwork/GeneralsOnline/ServiceConnectionPolicy.cpp + Source/GameNetwork/GeneralsOnline/ServiceEndpointPolicy.cpp Source/GameNetwork/GeneralsOnline/GeneralsOnline_Settings.cpp Source/GameNetwork/GeneralsOnline/HTTP/HTTPManager.cpp Source/GameNetwork/GeneralsOnline/HTTP/HTTPRequest.cpp diff --git a/GeneralsMD/Code/GameEngine/Include/Common/CustomMatchPreferences.h b/GeneralsMD/Code/GameEngine/Include/Common/CustomMatchPreferences.h index 228a7e46b4d..5aea5cee8a4 100644 --- a/GeneralsMD/Code/GameEngine/Include/Common/CustomMatchPreferences.h +++ b/GeneralsMD/Code/GameEngine/Include/Common/CustomMatchPreferences.h @@ -44,8 +44,8 @@ class CustomMatchPreferences : public UserPreferences CustomMatchPreferences(); virtual ~CustomMatchPreferences() override; - AsciiString getLastLobbyName() const; - void setLastLobbyName(const AsciiString& name); + UnicodeString getLastLobbyName() const; + void setLastLobbyName(const UnicodeString& name); void setLastLadder(const AsciiString& addr, UnsignedShort port); AsciiString getLastLadderAddr(); diff --git a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/CredentialStore.h b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/CredentialStore.h new file mode 100644 index 00000000000..ab3f7be5fae --- /dev/null +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/CredentialStore.h @@ -0,0 +1,25 @@ +#pragma once + +#include + +// One DPAPI-protected credentials.json holds the GO login token of every server and the proxy logins. +// Every save reads the whole file, changes its own entry and writes the file back, so no entry wipes another. +// When the file cannot be read, decrypted or written, one log line with the error code is written (no secrets), the value +// stays in memory for this session and nothing blocks. +namespace ServiceConnection +{ +// the file path is asked for on every use, so it follows the instance of a multi-instance debug build +void ConfigureCredentialStore(std::string (*pathProvider)()); +void SetCredentialLogSink(void (*sink)(const std::string&)); +// test hook: encryption and decryption fail with this error code, 0 turns it off +void SetCredentialCryptFailureForTest(unsigned long errorCode); + +// returns false when the value could not be saved to the file; it is then kept for this session +bool SaveProxyLogin(const std::string& proxyKey, const std::string& user, const std::string& password); +bool LoadProxyLogin(const std::string& proxyKey, std::string& user, std::string& password); +bool DeleteProxyLogin(const std::string& proxyKey); + +bool SaveRefreshToken(const std::string& serverKey, const std::string& token); +bool LoadRefreshToken(const std::string& serverKey, std::string& token); +bool DeleteRefreshToken(const std::string& serverKey); +} diff --git a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/GeneralsOnline_Settings.h b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/GeneralsOnline_Settings.h index 0bcc1a2c3e3..006308bab90 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/GeneralsOnline_Settings.h +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/GeneralsOnline_Settings.h @@ -1,13 +1,14 @@ #pragma once -#include +#include +#include +#include +#include "GameNetwork/GeneralsOnline/ServiceEndpointPolicy.h" -enum EHTTPVersion +// One layer of data_packs in settings.json: BIG paths in priority order, the first one wins +struct GenOnlineDataPackLayer { - HTTP_VERSION_AUTO, - HTTP_VERSION_1_0, - HTTP_VERSION_1_1, - HTTP_VERSION_2_0, - HTTP_VERSION_3_0 + bool m_enabled = true; + std::vector m_includePaths; }; class GenOnlineSettings @@ -70,46 +71,20 @@ class GenOnlineSettings Load(); } - bool DataPacks_UseCommunityPatch() const { return m_DataPacks_UseCommunityPatch; } + const GenOnlineDataPackLayer& DataPacks_User() const { return m_DataPacks_User; } + const GenOnlineDataPackLayer& DataPacks_GeneralsOnline() const { return m_DataPacks_GeneralsOnline; } + const GenOnlineDataPackLayer& DataPacks_CommunityPatch() const { return m_DataPacks_CommunityPatch; } + const std::vector& DataPacks_UserExcludePaths() const { return m_DataPacks_UserExcludePaths; } - bool Network_UseAlternativeEndpoint() const { return m_Network_UseAlternativeEndpoint; } - EHTTPVersion Network_GetHTTPVersion() const { return m_Network_HTTPVersion; } - int Network_GetHTTPVersionForCurl() const - { - switch (m_Network_HTTPVersion) - { - case HTTP_VERSION_AUTO: - { - return CURL_HTTP_VERSION_NONE; - } - - case HTTP_VERSION_1_0: - { - return CURL_HTTP_VERSION_1_0; - } - - case HTTP_VERSION_1_1: - { - return CURL_HTTP_VERSION_1_1; - } - - case HTTP_VERSION_2_0: - { - return CURL_HTTP_VERSION_2_0; - } - - case HTTP_VERSION_3_0: - { - return CURL_HTTP_VERSION_3; - } - } - - return CURL_HTTP_VERSION_NONE; - } + ServiceConnectionSettings Network_GetConnectionSettings() const { std::lock_guard lock(m_NetworkMutex); return m_ConnectionSettings; } + std::string Network_GetLastRoute() const { std::lock_guard lock(m_NetworkMutex); return m_LastRoute; } + bool Network_SaveLastRoute(const std::string& host); + bool Network_SaveWindowsLogin(bool windowsLogin); + bool Network_SaveConnectionSettings(const ServiceConnectionSettings& settings, std::string& error); private: void Load(void); - void Save(); + bool Save(); private: // NOTE: This also works as the default creation (since we just call Save) @@ -139,10 +114,19 @@ class GenOnlineSettings bool m_Social_Notification_PlayerSendsRequest_Menus = true; bool m_Social_Notification_PlayerSendsRequest_Gameplay = true; - bool m_DataPacks_UseCommunityPatch = true; + // BIG archives above the retail ones. The layers load in this order, highest priority first. A missing or invalid field keeps its default. + GenOnlineDataPackLayer m_DataPacks_User; + std::vector m_DataPacks_UserExcludePaths; // loose files of the game folder, BIGs of the game folders and BIGs of the user layer that are left out + GenOnlineDataPackLayer m_DataPacks_GeneralsOnline = { true, { "user:GeneralsOnlineGameData/GeneralsOnline_Core.big" } }; + GenOnlineDataPackLayer m_DataPacks_CommunityPatch = { true, { "user:GeneralsOnlineGameData/500_900_CommunityPatch_CoreINI.big" } }; std::string m_Plugins_Anticheat = std::string(); - EHTTPVersion m_Network_HTTPVersion = EHTTPVersion::HTTP_VERSION_AUTO; - bool m_Network_UseAlternativeEndpoint = false; + mutable std::mutex m_NetworkMutex; + ServiceConnectionSettings m_ConnectionSettings; + // host of the service route that worked last + std::string m_LastRoute; + // keeps keys this build does not know when a setting is saved mid-session + std::string m_PreservedJSON = "{}"; + bool m_CanSave = true; }; diff --git a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/HTTP/HTTPManager.h b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/HTTP/HTTPManager.h index 57a4c498928..4db75fc2a17 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/HTTP/HTTPManager.h +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/HTTP/HTTPManager.h @@ -47,7 +47,7 @@ class HTTPManager return m_sProtocolInUse.load(); } - void AddHandleToMulti(CURL* pNewHandle); + bool AddHandleToMulti(CURL* pNewHandle); void RemoveHandleFromMulti(CURL* pHandleToRemove); void SendGETRequest(const char* szURI, EIPProtocolVersion protover, std::map& inHeaders, std::function completionCallback, std::function progressCallback = nullptr, int timeoutMS = -1); @@ -59,11 +59,6 @@ class HTTPManager void Shutdown(); - bool IsProxyEnabled() const { return m_bProxyEnabled; } - - bool DeterminePlatformProxySettings(); - std::string& GetProxyAddress() { return m_strProxyAddr; } - uint16_t GetProxyPort() const { return m_proxyPort; } private: HTTPRequest* PlatformCreateRequest(EHTTPVerb htpVerb, EIPProtocolVersion protover, const char* szURI, std::map& inHeaders, std::function completionCallback, @@ -74,9 +69,6 @@ class HTTPManager std::atomic m_sProtocolInUse = EIPProtocolVersion::DONT_CARE; - bool m_bProxyEnabled = false; - std::string m_strProxyAddr; - uint16_t m_proxyPort; std::atomic m_bShuttingDown = false; diff --git a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/HTTP/HTTPRequest.h b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/HTTP/HTTPRequest.h index f5b901aade5..6ca544b4007 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/HTTP/HTTPRequest.h +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/HTTP/HTTPRequest.h @@ -4,6 +4,8 @@ #include #include #include +#include +#include "../ServiceConnectionPolicy.h" enum class EHTTPVerb; enum class EIPProtocolVersion; @@ -30,6 +32,7 @@ class HTTPRequest void SetPostData(const char* szPostData); void SetPostDataBuffer(std::vector vecBuffer); void StartRequest(); + void PollConnectionSetup(); void DisableServiceAuth() { @@ -65,13 +68,23 @@ class HTTPRequest std::string GetURI() { return m_strURI; } + // proxy the finished request went through, empty when direct + std::string GetProxyURL() const { return m_proxyRoute < m_proxyPolicy.routes.size() && !m_proxyPolicy.routes[m_proxyRoute].direct ? m_proxyPolicy.routes[m_proxyRoute].url : std::string(); } + private: void PlatformStartRequest(); private: CURL* m_pCURL = nullptr; - - int m_responseCode = -1; + bool m_handleAdded = false; + std::shared_ptr m_proxyResolution; + ServiceConnection::ResolvedProxyPolicy m_proxyPolicy; + std::string m_setupFailurePhase; + size_t m_proxyRoute = 0; + std::chrono::steady_clock::time_point m_setupStarted; + + long m_responseCode = 0; + CURLcode m_result = CURLE_OK; EHTTPVerb m_httpVerb; diff --git a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/JsonHelpers.h b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/JsonHelpers.h new file mode 100644 index 00000000000..7de223155b2 --- /dev/null +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/JsonHelpers.h @@ -0,0 +1,21 @@ +#pragma once + +#include "GameNetwork/GeneralsOnline/json.hpp" + +// Optional field: dest keeps its default when the key is missing or null +template +inline void JsonGetOptional(const nlohmann::json& j, const char* szKey, T& dest) +{ + auto it = j.find(szKey); + if (it != j.end() && !it->is_null()) + { + it->get_to(dest); + } +} + +// Required field: throws when the key is missing or the wrong type +template +inline void JsonGetRequired(const nlohmann::json& j, const char* szKey, T& dest) +{ + j.at(szKey).get_to(dest); +} diff --git a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/LobbyConnectionPolicy.h b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/LobbyConnectionPolicy.h new file mode 100644 index 00000000000..1032ef89f2a --- /dev/null +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/LobbyConnectionPolicy.h @@ -0,0 +1,17 @@ +#pragma once +#include +#include + +namespace LobbyConnectionPolicy +{ +inline bool IsCurrentAttempt(int64_t expectedLobby, uint64_t expectedGeneration, const std::vector& expectedMembers, + int64_t lobby, uint64_t generation, const std::vector& members) +{ + return expectedLobby >= 0 && expectedLobby == lobby && expectedGeneration == generation && expectedMembers == members; +} + +inline bool CanRetry(bool manualLobby, bool inMatch, bool member, bool local, bool connected, bool connecting, int64_t sinceLastAttemptMs) +{ + return manualLobby && !inMatch && member && !local && !connected && !connecting && sinceLastAttemptMs >= 1000; +} +} diff --git a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/MeshConnectionTimeoutPolicy.h b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/MeshConnectionTimeoutPolicy.h new file mode 100644 index 00000000000..06bd6898750 --- /dev/null +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/MeshConnectionTimeoutPolicy.h @@ -0,0 +1,15 @@ +#pragma once +#include + +namespace MeshConnectionTimeoutPolicy +{ +constexpr int InitialTimeoutMs = 10000; +constexpr int ReconnectTimeoutMs = 30000; +inline int ClampReconnectTimeout(int value) { return std::clamp(value, InitialTimeoutMs, 60000); } +struct Timeouts { int initialMs; int connectedMs; }; +inline Timeouts ForAttempt(bool retry, int reconnectTimeoutMs) +{ + const int extended = ClampReconnectTimeout(reconnectTimeoutMs); + return {retry ? extended : InitialTimeoutMs, extended}; +} +} diff --git a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NGMPGame.h b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NGMPGame.h index 0d11c6a51b3..624c9840d84 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NGMPGame.h +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NGMPGame.h @@ -65,6 +65,7 @@ class NGMPGame : public GameInfo Int m_reportedNumObservers; bool m_bHasCommittedOutcome = false; + uint64_t m_startAttempt = 0; std::chrono::system_clock::time_point matchStartTime; @@ -79,6 +80,8 @@ class NGMPGame : public GameInfo virtual ~NGMPGame(); virtual void reset(void); + uint64_t BeginStartAttempt() { return ++m_startAttempt; } + uint64_t GetStartAttempt() const { return m_startAttempt; } bool HasCommittedOutcome() const { return m_bHasCommittedOutcome; } void SetHasCommittedOutcome() { m_bHasCommittedOutcome = true; } @@ -87,6 +90,7 @@ class NGMPGame : public GameInfo void StopCountdown() { + ++m_startAttempt; m_bCountdownStarted = false; m_countdownStartTime = -1; m_countdownLastCheckTime = -1; diff --git a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NetworkMesh.h b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NetworkMesh.h index fe3c7ac9c6c..b6c83f4efb6 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NetworkMesh.h +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NetworkMesh.h @@ -6,6 +6,7 @@ #include #include #include "PluginInterfaces.h" +#include "GameNetwork/GeneralsOnline/MeshConnectionTimeoutPolicy.h" class NetRoom_ChatMessagePacket; @@ -66,6 +67,9 @@ class PlayerConnection m_State != EConnectionState::CONNECTION_DISCONNECTED; } + // a closed connection has nothing to receive + bool CanRecv() const { return m_hSteamConnection != k_HSteamNetConnection_Invalid; } + int Recv(SteamNetworkingMessage_t** pMsg); int GetHighestHistoricalLatency() @@ -99,6 +103,7 @@ class PlayerConnection int64_t pingSent = -1; int m_SignallingAttempts = 0; + bool m_retryTimeout = false; // keeps the extended timeouts when signalling restarts // when signalling for this connection started, for connect-time logging int64_t m_connectStartedMs = std::chrono::duration_cast(std::chrono::steady_clock::now().time_since_epoch()).count(); @@ -217,6 +222,7 @@ class NetworkMesh void SendACPacket(uint32_t userID, const void* pData, uint32_t dataLen); + void ApplyConnectionTimeouts(HSteamNetConnection connection, int64_t userID); void StartConnectionSignalling(const char* szMiddlewareID, int64_t remoteUserID, uint16_t preferredPort); // A joiner gets its TURN credentials after its mesh exists; signalling waits for them @@ -245,6 +251,9 @@ class NetworkMesh m_vecPendingStateUpdates.emplace_back(userID, newState); } + // drops a peer that left for good so nothing keeps polling it + void RemoveConnection(int64_t userID); + PlayerConnection* GetConnectionForUser(int64_t user_id) { if (m_mapConnections.contains(user_id)) @@ -289,4 +298,5 @@ class NetworkMesh // k_nSteamNetworkingConfig_P2P_Transport_ICE_Enable_* for this lobby's connections. int m_iceEnable = 0; int m_iceImplementation = 2; + int m_reconnectTimeoutMs = MeshConnectionTimeoutPolicy::ReconnectTimeoutMs; }; diff --git a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NextGenMP_defines.h b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NextGenMP_defines.h index b78109bc4c4..d7ac3365899 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NextGenMP_defines.h +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NextGenMP_defines.h @@ -89,11 +89,7 @@ void showNotificationBox(AsciiString nick, UnicodeString message, bool bPlaySoun #define GENERALS_ONLINE_HIGH_FPS_RENDER 1 // This is optional on 30fps, but will boost/unlock the framerate, similar to gentool #endif -#if defined(GENERALS_ONLINE_HIGH_FPS_SERVER) -static int FRAME_GROUPING_CAP = 32; -#else -static int FRAME_GROUPING_CAP = 64; -#endif +extern int FRAME_GROUPING_CAP; //#define GENERALS_ONLINE_ENABLE_CONTROVERSIAL_NON_RETAIL_CHANGES 1 diff --git a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Auth.h b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Auth.h index d7c524879e7..9deb4599bf2 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Auth.h +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Auth.h @@ -23,6 +23,7 @@ class NGMP_OnlineServices_AuthInterface void RefreshToken(); void BeginLogin(); + void BeginLoginOnSelectedRoute(); void DoFullLoginFlow(); void Tick(); @@ -48,6 +49,8 @@ class NGMP_OnlineServices_AuthInterface void LogoutOfMyAccount(); + static std::string GetCredentialsFilePath(); + private: void LoginAsSecondaryDevAccount(); @@ -56,7 +59,7 @@ class NGMP_OnlineServices_AuthInterface void OnRefreshTokenFailed(const char* szReason, const std::string& strBody = std::string()); - std::string GetCredentialsFilePath(); + static std::string CredentialServerKey(); private: bool m_bWaitingLogin = false; diff --git a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h index 7d1419d2ee1..195476af13f 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h @@ -1,7 +1,12 @@ #pragma once +#include "ServiceConnectionPolicy.h" +#include "GameNetwork/NetworkSettings.h" #include "NGMP_include.h" +#include "MeshConnectionTimeoutPolicy.h" +#include +#include #include #include @@ -185,12 +190,16 @@ class WebSocket // lobby ID the check was started for; a stale reply (different lobby) is ignored int64_t m_connectivityCheckLobbyID = -1; + // when the check was sent; Tick() gives up on it after a client-side timeout + int64_t m_connectivityCheckStartMs = -1; + void SendData_StartFullMeshConnectivityCheck(std::function>, std::string)> cbOnConnectivityCheckComplete); void ClearConnectivityCheckCallback() { m_cbOnConnectivityCheckComplete = nullptr; m_connectivityCheckLobbyID = -1; + m_connectivityCheckStartMs = -1; } void Tick(); @@ -200,6 +209,9 @@ class WebSocket // Queues the message; Tick() flushes it on the main thread. Thread-safe. void Send(const char* message); + // Continues the initial connect after the proxy login prompt closed. + void ResumeAfterProxyLogin(bool loggedIn); + // Thread-safe queue of inbound P2P signal payloads; drained by CSignalingClient::Poll(). void PushPendingSignal(std::vector payload) { @@ -215,6 +227,13 @@ class WebSocket m_pendingSignals.push(std::move(payload)); } + // signals queued for a lobby we've since left must not reach the next one + void ClearPendingSignals() + { + std::scoped_lock lock(m_pendingSignalsMutex); + m_pendingSignals = std::queue>(); + } + std::queue> DrainPendingSignals() { std::scoped_lock lock(m_pendingSignalsMutex); @@ -224,6 +243,16 @@ class WebSocket } private: + CURLcode ReceiveOneFrame(); + void PollConnectionSetup(); + void StartResolvedConnection(); + void FailConnectionSetup(); + bool m_proxyPrompted = false; + std::shared_ptr m_proxyResolution; + ServiceConnection::ResolvedProxyPolicy m_proxyPolicy; + size_t m_proxyRoute = 0; + int64_t m_proxySetupStarted = -1; + bool m_connectAsReconnect = false; CURL* m_pCurlWS = nullptr; CURLM* m_pMulti = nullptr; struct curl_slist* m_pHeaders = nullptr; @@ -259,7 +288,19 @@ class WebSocket // Outbound messages queued for Tick() to flush. std::mutex m_outboundQueueMutex; - std::vector m_vecQueuedOutboungMsgs; + struct OutboundMessage + { + std::string payload; + bool heartbeat; + }; + std::vector m_vecQueuedOutboungMsgs; + size_t m_queuedOutboundBytes = 0; + size_t m_outboundOffset = 0; + bool m_heartbeatQueued = false; + bool m_acceptOutbound = true; + void QueueOutbound(std::string payload, bool heartbeat); + void ClearOutbound(); + CURLcode FlushOutbound(); std::mutex m_pendingSignalsMutex; std::queue> m_pendingSignals; @@ -308,51 +349,167 @@ struct RegionResponse struct ServiceConfig { - bool retry_signalling = false; - bool use_mapped_port = true; - int min_run_ahead_frames = 4; - int ra_update_frequency_frames = 10; - bool relay_all_traffic = false; - int ra_slack_percent = 20; - int frame_grouping_frames = 2; - bool enable_host_migration = true; - - bool network_do_immediate_flush_per_frame = true; - int network_send_flags = -1; - - int network_latency_logic_model = 0; - - bool use_default_config = false; - int ra_slack_override_percent_in_default = 10; - bool do_probes = true; - bool do_replay_upload = true; - - int network_mesh_histogram_duration = 20000; - + // Peer connections: signalling, NAT traversal, and the per-connection ping history + struct Connection + { + bool relay_only = false; + int ice_engine = 2; // ICE library: 0 = library default, 1 = native, 2 = WebRTC; the key also takes "default", "native" or "webrtc" + bool use_mapped_port = true; + bool retry_signalling = true; + int reconnect_timeout_ms = MeshConnectionTimeoutPolicy::ReconnectTimeoutMs; // first negotiation keeps 10 s + bool anticheat_transport = false; // game traffic through the anticheat plugin's transport + int latency_history_ms = 20000; + } connection; + + // Not read from the service: the live values + int network_latency_logic_model = 2; bool ibra_ra_tweaks = false; float ibra_minslack_default = 0.25f; float ibra_maxslack_default = 1.f; - float ibra_minslack_greaterthan300ms = 0.35f; float ibra_maxslack_greaterthan300ms = 1.f; - float ibra_minslack_greaterthan200ms = 0.3f; float ibra_maxslack_greaterthan200ms = 1.f; + using Send = NetworkSettings::Send; + using InputDelay = NetworkSettings::InputDelay; + Send send; + InputDelay input_delay; - int screenshot_width = 557; - int screenshot_height = 333; + struct Lobby + { + bool host_migration = false; + } lobby; - // ICE client used for P2P NAT traversal: 0 = library default, 1 = native, 2 = WebRTC. - int ice_implementation = 2; + struct Telemetry + { + bool screenshot_probes = true; + bool replay_upload = true; + int screenshot_width = 960; + int screenshot_height = 600; + } telemetry; +}; - bool use_new_networking = false; +// Reads key into value when present; a missing key keeps the default +template +inline void ReadServiceConfigValue(const nlohmann::json& j, const char* key, T& value) +{ + if (!j.is_object()) + { + throw std::invalid_argument("Service config sections must be objects"); + } + const auto it = j.find(key); + if (it != j.end() && !it->is_null()) + { + value = it->get(); + } +} - NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(ServiceConfig, retry_signalling, use_mapped_port, min_run_ahead_frames, ra_update_frequency_frames, relay_all_traffic, - ra_slack_percent, frame_grouping_frames, enable_host_migration, network_do_immediate_flush_per_frame, network_send_flags, network_latency_logic_model, - use_default_config, ra_slack_override_percent_in_default, do_probes, do_replay_upload, network_mesh_histogram_duration, - ibra_ra_tweaks, ibra_minslack_default, ibra_maxslack_default, ibra_minslack_greaterthan300ms, ibra_maxslack_greaterthan300ms, ibra_minslack_greaterthan200ms, ibra_maxslack_greaterthan200ms, - screenshot_width, screenshot_height, ice_implementation, use_new_networking) -}; +// The ICE library by name or by number +inline void ReadIceEngine(const nlohmann::json& j, int& value) +{ + if (!j.is_object()) + { + throw std::invalid_argument("Service config sections must be objects"); + } + const auto it = j.find("ice_engine"); + if (it == j.end() || it->is_null()) + { + return; + } + if (it->is_string()) + { + const std::string name = it->get(); + if (name == "default") + { + value = 0; + } + else if (name == "native") + { + value = 1; + } + else if (name == "webrtc") + { + value = 2; + } + return; + } + value = it->get(); +} + +// A key the service sets replaces the compiled default, a missing key keeps it +inline void ReadServiceConfig(const nlohmann::json& j, ServiceConfig& c) +{ + if (j.contains("connection")) + { + const nlohmann::json& s = j["connection"]; + ReadServiceConfigValue(s, "relay_only", c.connection.relay_only); + ReadIceEngine(s, c.connection.ice_engine); + ReadServiceConfigValue(s, "use_mapped_port", c.connection.use_mapped_port); + ReadServiceConfigValue(s, "retry_signalling", c.connection.retry_signalling); + ReadServiceConfigValue(s, "reconnect_timeout_ms", c.connection.reconnect_timeout_ms); + ReadServiceConfigValue(s, "anticheat_transport", c.connection.anticheat_transport); + ReadServiceConfigValue(s, "latency_history_ms", c.connection.latency_history_ms); + } + if (j.contains("send")) + { + const nlohmann::json& s = j["send"]; + ReadServiceConfigValue(s, "frames_per_packet", c.send.frames_per_packet); + ReadServiceConfigValue(s, "flush_every_frame", c.send.flush_every_frame); + ReadServiceConfigValue(s, "flags", c.send.flags); + } + if (j.contains("input_delay")) + { + const nlohmann::json& s = j["input_delay"]; + ReadServiceConfigValue(s, "min_frames", c.input_delay.min_frames); + ReadServiceConfigValue(s, "decision_interval_ms", c.input_delay.decision_interval_ms); + ReadServiceConfigValue(s, "fps_history_samples", c.input_delay.fps_history_samples); + ReadServiceConfigValue(s, "slack_percent", c.input_delay.slack_percent); + if (s.contains("formula")) + { + const nlohmann::json& f = s["formula"]; + ReadServiceConfigValue(f, "latency_history_samples", c.input_delay.formula.latency_history_samples); + } + } + if (j.contains("lobby")) + { + ReadServiceConfigValue(j["lobby"], "host_migration", c.lobby.host_migration); + } + if (j.contains("telemetry")) + { + const nlohmann::json& s = j["telemetry"]; + ReadServiceConfigValue(s, "screenshot_probes", c.telemetry.screenshot_probes); + ReadServiceConfigValue(s, "replay_upload", c.telemetry.replay_upload); + ReadServiceConfigValue(s, "screenshot_width", c.telemetry.screenshot_width); + ReadServiceConfigValue(s, "screenshot_height", c.telemetry.screenshot_height); + } +} + +// Keeps values from the service inside the ranges the game can run with +inline void ClampServiceConfig(ServiceConfig& c) +{ + c.connection.latency_history_ms = std::clamp(c.connection.latency_history_ms, 1000, 120000); + c.connection.reconnect_timeout_ms = MeshConnectionTimeoutPolicy::ClampReconnectTimeout(c.connection.reconnect_timeout_ms); + + c.input_delay.min_frames = std::clamp(c.input_delay.min_frames, 1, 64); + c.input_delay.slack_percent = std::clamp(c.input_delay.slack_percent, 0, 200); + c.input_delay.decision_interval_ms = std::clamp(c.input_delay.decision_interval_ms, 100, 60000); + c.input_delay.fps_history_samples = std::clamp(c.input_delay.fps_history_samples, 1, 128); + c.input_delay.formula.latency_history_samples = std::clamp(c.input_delay.formula.latency_history_samples, 128, 3600); // FrameMetrics drops pending acks when the window is smaller than the frames in flight + c.send.frames_per_packet = std::clamp(c.send.frames_per_packet, 1, 16); + + c.telemetry.screenshot_width = std::clamp(c.telemetry.screenshot_width, 16, 4096); + c.telemetry.screenshot_height = std::clamp(c.telemetry.screenshot_height, 16, 4096); +} + +inline void from_json(const nlohmann::json& j, ServiceConfig& c) +{ + if (!j.is_object()) + { + throw std::invalid_argument("Service config must be an object"); + } + ReadServiceConfig(j, c); + ClampServiceConfig(c); +} class NGMP_OnlineServicesManager { @@ -522,6 +679,12 @@ class NGMP_OnlineServicesManager } void StartVersionCheck(std::function fnCallback); + void StartVersionCheckOnSelectedRoute(std::function fnCallback); + void EnsureServiceRoute(std::function callback); + // true when the last failed route check was a proxy login the player did not complete + bool RouteFailedProxyAuth() const { return m_routeProxyAuthFailed; } + static bool IsServiceURLTrusted(const std::string& url); + static bool IsWebSocketURLTrusted(const std::string& url); std::shared_ptr Internal_GetWebSocket() const { return m_pWebSocket; } HTTPManager* GetHTTPManager() const { return m_pHTTPManager; } @@ -547,6 +710,12 @@ class NGMP_OnlineServicesManager void ProcessMOTD(const char* szMOTD) { m_strMOTD = std::string(szMOTD); + // the welcome screen shows which custom server this is, ahead of anything the server says + const ServiceConnectionSettings settings = Settings.Network_GetConnectionSettings(); + if (settings.serverMode == "custom") + { + m_strMOTD = "Custom server: " + settings.serverAddress.substr(settings.serverAddress.find("://") + 3) + "\n \n" + m_strMOTD; + } } std::string& GetMOTD() { return m_strMOTD; } @@ -636,6 +805,17 @@ class NGMP_OnlineServicesManager std::vector m_vecScreenshotThreads; std::mutex m_mutexScreenshotThreads; + void ProbeServiceRoute(); + void FinishServiceRoute(bool success); + std::string m_serviceBase; + std::vector m_bootstrapRoutes; + size_t m_bootstrapIndex = 0; + std::vector> m_routeCallbacks; + std::chrono::steady_clock::time_point m_routeValidatedAt{}; + std::string m_routeSettingsKey; + bool m_routeProxyPrompted = false; + bool m_routeProxyAuthFailed = false; + ServiceConfig m_ServiceConfig; HTTPManager* m_pHTTPManager = nullptr; diff --git a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.h b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.h index 5e89ed8d313..4972b959d37 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.h +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.h @@ -25,13 +25,20 @@ struct LobbyMemberEntry : public NetworkMemberBase int startpos = -1; bool has_map = false; - uint16_t m_SlotIndex = 999999; + // marks a member the service sent without a usable slot + static constexpr uint16_t NO_SLOT_INDEX = UINT16_MAX; + uint16_t m_SlotIndex = NO_SLOT_INDEX; uint16_t m_SlotState = SlotState::SLOT_OPEN; std::string region; std::string middlewareUserID; int latency = 0; + bool HasValidSlot() const + { + return m_SlotIndex < MAX_SLOTS; + } + bool IsHuman() const { return user_id != -1 && m_SlotState == SlotState::SLOT_PLAYER; @@ -273,6 +280,14 @@ class NGMP_OnlineServices_LobbyInterface } + bool AreLocalLobbyPeersConnected(); + bool HasFailedLobbyPeers(); + int RetryFailedLobbyPeers(); + std::vector GetHumanLobbyMembers() const; + std::string GetLobbyStartSettings() const; + uint64_t GetLobbyGeneration() const { return m_LobbyJoinGeneration.load(); } + bool IsCurrentLobbyAttempt(int64_t lobbyID, uint64_t generation, const std::vector& members) const; + LobbyEntry& GetCurrentLobby() { return m_CurrentLobby; @@ -384,6 +399,10 @@ class NGMP_OnlineServices_LobbyInterface void UpdateRoomDataCache(std::function fnCallback = nullptr); + // re-fetches the lobby when the roster could not be trusted, throttled + void RequestRosterRefresh(); + int64_t m_lastRosterRefreshRequestMs = 0; + std::function m_cbPlayerDoesntHaveMap = nullptr; void RegisterForPlayerDoesntHaveMapCallback(std::function cb) { @@ -472,7 +491,8 @@ class NGMP_OnlineServices_LobbyInterface NetworkMesh* GetNetworkMeshForLobby() { return m_pLobbyMesh; } - void JoinLobby(LobbyEntry lobby, std::string strPassword); + // false when a join is already in progress and this request was dropped + bool JoinLobby(LobbyEntry lobby, std::string strPassword); void LeaveCurrentLobby(); void ResetForMatchmakingRequeue(); @@ -551,6 +571,10 @@ class NGMP_OnlineServices_LobbyInterface #endif bool m_bAttemptingToJoinLobby = false; + + // GET Lobby/{id} responses can arrive out of order; only apply ones newer than the last applied + std::atomic m_LobbyUpdateRequestSeq = 0; + std::atomic m_LobbyUpdateAppliedSeq = 0; // Invalidates asynchronous callbacks left behind by an abandoned lobby join. std::atomic m_LobbyJoinGeneration = 0; LobbyEntry m_LobbyTryingToJoin; diff --git a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/ProxyLogin.h b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/ProxyLogin.h new file mode 100644 index 00000000000..22b414dfe33 --- /dev/null +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/ProxyLogin.h @@ -0,0 +1,22 @@ +#pragma once + +#include +#include +#include "GameNetwork/GeneralsOnline/ServiceEndpointPolicy.h" + +namespace ServiceConnection +{ +bool IsProxyLoginOpen(); +// Worker threads hand work back to the main thread here; it runs once per frame from the game engine update +void RunOnMainThread(std::function task); +void PumpMainThreadTasks(); + +// Main thread only. One dialog does both jobs: it asks for the login of a proxy that answered 407 and checks it in place. +// Login path: done(true) once the proxy accepted the login (or the Windows login was chosen), done(false) on cancel. +// windowsLoginFailed: the Windows login was tried and the proxy did not accept it +void RequestProxyLogin(const std::string& proxyURL, bool windowsLoginFailed, std::function done); +// Accept in Options with changed connection settings: tests them first. +// Save: they passed, or the player chose to save anyway. KeepEditing: back to the options. Discard: leave the connection settings as they were. +enum class AcceptChoice { Save, KeepEditing, Discard }; +void ShowAcceptTest(const ServiceConnectionSettings& settings, std::function decided); +} diff --git a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/ServiceConnectionPolicy.h b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/ServiceConnectionPolicy.h new file mode 100644 index 00000000000..824eee696c2 --- /dev/null +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/ServiceConnectionPolicy.h @@ -0,0 +1,73 @@ +#pragma once + +#include +#include +#include +#include +#include +#include "GameNetwork/GeneralsOnline/ServiceEndpointPolicy.h" + +namespace ServiceConnection +{ +struct ProxyRoute +{ + std::string url; + bool direct = true; + bool allowIntegratedAuth = false; +}; + +struct ResolvedProxyPolicy +{ + std::vector routes; + std::string error; + unsigned long nativeError = 0; + std::string failurePhase; + std::string fallbackReason; +}; + +struct ProxyResolution +{ + std::atomic ready{false}; + ResolvedProxyPolicy result; +}; +// Successful system policies expire after 60 seconds; settings saves invalidate immediately. +void InvalidateProxyPolicyCache(); +bool ShouldLogProxyPolicy(bool websocket, const ResolvedProxyPolicy& result); +std::shared_ptr StartProxyResolution(ServiceConnectionSettings settings, std::string destinationURL); + +// Worker-only: PAC/WPAD resolution can take up to five seconds. Only absent optional WPAD permits system fallback. +ResolvedProxyPolicy ResolveProxyPolicy(const ServiceConnectionSettings& settings, const std::string& destinationURL); +bool ValidateProxyURL(const std::string& proxyURL, std::string& error); +// Configures policy only. The caller owns URL, transfer deadlines and ordered route retries. +bool ConfigureCurl(CURL* curl, const std::string& destinationURL, const ProxyRoute& route, std::string& error); +bool SaveProxyCredentials(const std::string& proxyURL, const std::string& username, + const std::string& password, std::string& error); +bool DeleteProxyCredentials(const std::string& proxyURL, std::string& error); +bool HasProxyCredentials(const std::string& proxyURL); +// Login kept in memory for this session only; never written to disk +void SetSessionProxyCredentials(const std::string& proxyURL, const std::string& username, const std::string& password); +void ClearSessionProxyCredentials(const std::string& proxyURL); +// Logins are bound to one proxy: nothing typed for another proxy is ever offered to this one +void ClearSessionProxyCredentialsExcept(const std::string& keepProxyURL); +// The key a proxy's login is stored under: scheme://host:port in lower case with the default port filled in, empty for an invalid address +std::string CanonicalProxyKey(const std::string& proxyURL); +// User name of the session or saved login, empty when there is none +std::string ProxyUsername(const std::string& proxyURL); +// host:port of a proxy address for display +std::string ProxyDisplayName(const std::string& proxyURL); + +struct ConnectionTestResult +{ + enum class Outcome { Connected, ProxyAuthRequired, ServiceRejected, Unreachable, Failed }; + Outcome outcome = Outcome::Failed; + std::string route; + std::string proxyURL; + std::string detail; + bool proxyAnswered = false; +}; +// Worker-only: probes the anonymous VersionManifest request with the given settings +ConnectionTestResult TestServiceConnection(const ServiceConnectionSettings& settings, const std::string& environment, int maxSeconds = 15); +std::string DescribeConnectionTest(const ConnectionTestResult& result); +const char* ProtocolName(long version); +const char* FailurePhase(CURLcode code, long status, long proxyStatus); +} \ No newline at end of file diff --git a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/ServiceEndpointPolicy.h b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/ServiceEndpointPolicy.h new file mode 100644 index 00000000000..9089162abfb --- /dev/null +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/ServiceEndpointPolicy.h @@ -0,0 +1,35 @@ +#pragma once +#include +#include +#include + +struct ServiceConnectionSettings +{ + // "automatic" picks a working route, "custom" uses only serverAddress + std::string serverMode = "automatic"; + // https origin of the custom server, empty unless serverMode is custom + std::string serverAddress; + // "automatic" follows the Windows proxy settings, "disabled" connects directly, "custom" uses proxyURL + std::string proxyMode = "automatic"; + std::string proxyURL; + // try the current Windows account when the proxy asks for a login + bool windowsLogin = true; +}; + +namespace ServiceConnection +{ +// host[:port] or https://host[:port] to its origin (https, lower case host, default port dropped); false with a reason for anything else +bool NormalizeCustomEndpoint(const std::string& typed, std::string& origin, std::string& error); +// The key the login token is stored under: one for every GO route, one per custom origin, empty when a custom server has no valid address +std::string CredentialServerKey(const ServiceConnectionSettings& settings); +// remembered is the host of the route that worked last, ignored unless it is one of the automatic routes +std::vector BootstrapRoutes(const std::string& environment, + const ServiceConnectionSettings& settings, const std::string& remembered); +// host name of a route url +std::string RouteHost(const std::string& route); +bool IsTrustedServiceURL(const std::string& url, const std::string& base, bool websocket); +std::string RequestURLForLog(const std::string& url, const std::string& serviceBase); +bool IsExpectedVersionManifest(const std::string& body); +// Verified TLS (not in _DEBUG builds), no redirects, no embedded credentials, direct connection; HTTPS or WSS only. +bool ConfigureServiceTransport(CURL* curl, const std::string& url, std::string& error); +} diff --git a/GeneralsMD/Code/GameEngine/Source/Common/GameEngine.cpp b/GeneralsMD/Code/GameEngine/Source/Common/GameEngine.cpp index 0c497745de0..e9d3dfe39d6 100644 --- a/GeneralsMD/Code/GameEngine/Source/Common/GameEngine.cpp +++ b/GeneralsMD/Code/GameEngine/Source/Common/GameEngine.cpp @@ -112,6 +112,7 @@ // GENERALS ONLINE #include "../OnlineServices_Init.h" +#include "GameNetwork/GeneralsOnline/ProxyLogin.h" #include "GameNetwork/GeneralsOnline/DiscordRichPresence.h" #include "GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.h" #include "GameNetwork/GameSpyOverlay.h" @@ -504,6 +505,10 @@ void GameEngine::init() #endif///////////////////////////////////////////////////////////////////////////////////////////// + // NGMP_CHANGE: Init our settings before the archives are mounted, the data_packs layers decide which ones. Needs TheGlobalData for the user data path. + NGMP_OnlineServicesManager::Settings.Initialize(); + TheFileSystem->setExcludedPaths(NGMP_OnlineServicesManager::Settings.DataPacks_UserExcludePaths()); + initSubsystem(TheArchiveFileSystem, "TheArchiveFileSystem", createArchiveFileSystem(), nullptr); // this MUST come after TheLocalFileSystem creation #ifdef DUMP_PERF_STATS/////////////////////////////////////////////////////////////////////////// @@ -538,9 +543,6 @@ void GameEngine::init() // special-case: parse command-line parameters after loading global data CommandLine::parseCommandLineForEngineInit(); - // NGMP_CHANGE: Init our settings before loadMods, which reads DataPacks_UseCommunityPatch. Needs TheGlobalData for the user data path. - NGMP_OnlineServicesManager::Settings.Initialize(); - TheArchiveFileSystem->loadMods(); // doesn't require resets so just create a single instance here. @@ -1011,6 +1013,9 @@ void GameEngine::update() } + // work handed back by worker threads, also while no online services instance exists + ServiceConnection::PumpMainThreadTasks(); + if (NGMP_OnlineServicesManager::GetInstance() != nullptr) { NGMP_OnlineServicesManager::GetInstance()->Tick(); diff --git a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/PopupHostGame.cpp b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/PopupHostGame.cpp index 074b472fa6d..b5f98aed513 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/PopupHostGame.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/PopupHostGame.cpp @@ -336,12 +336,8 @@ void PopupHostGameInit( WindowLayout *layout, void *userData ) #if defined(GENERALS_ONLINE) { CustomMatchPreferences pref; - AsciiString lastLobbyName = pref.getLastLobbyName(); - if (!lastLobbyName.isEmpty()) - { - name.translate(lastLobbyName.str()); - } - else + name = pref.getLastLobbyName(); + if (name.isEmpty()) { name.translate("Generals Online Lobby"); } @@ -605,19 +601,8 @@ WindowMsgHandledType PopupHostGameSystem( GameWindow *window, UnsignedInt msg, W #if defined(GENERALS_ONLINE) // save last used lobby name to CustomPref.ini { - char buffer[256]; - const WideChar* w = name.str(); - int i = 0; - for (; w[i] != 0 && i < 255; ++i) - { - buffer[i] = (char)(w[i] & 0xFF); - } - buffer[i] = 0; - - AsciiString lobbyNameAscii = buffer; - CustomMatchPreferences pref; - pref.setLastLobbyName(lobbyNameAscii); + pref.setLastLobbyName(name); pref.write(); } #endif diff --git a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/WOLGameSetupMenu.cpp b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/WOLGameSetupMenu.cpp index d8c0b2c2eba..1ad7838bd50 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/WOLGameSetupMenu.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/WOLGameSetupMenu.cpp @@ -977,6 +977,215 @@ static void WOLLockSettings() } } +// lobby humans (the service's view) the game has no human slot for +static Int CountLobbyHumansMissingFromGame(NGMPGame* game) +{ + NGMP_OnlineServices_LobbyInterface* pLobbyInterface = NGMP_OnlineServicesManager::GetInterface(); + if (pLobbyInterface == nullptr || game == nullptr) + { + return 0; + } + + Int missing = 0; + for (const LobbyMemberEntry& member : pLobbyInterface->GetCurrentLobby().members) + { + if (!member.IsHuman()) + { + continue; + } + + bool bInGame = false; + for (Int i = 0; i < MAX_SLOTS; ++i) + { + NGMPGameSlot* slot = static_cast(game->getSlot(i)); + if (slot != nullptr && slot->isHuman() && slot->m_userID == member.user_id) + { + bInGame = true; + break; + } + } + + if (!bInGame) + { + ++missing; + } + } + + return missing; +} + +// every lobby human has a slot and a connected link to us +static Bool AreAllHumansSlottedAndConnected(NGMP_OnlineServices_LobbyInterface* lobby) +{ + if (lobby == nullptr || lobby->GetCurrentGame() == nullptr) + { + return FALSE; + } + + return CountLobbyHumansMissingFromGame(lobby->GetCurrentGame()) == 0 && lobby->AreLocalLobbyPeersConnected(); +} + +static const Int MAX_ROSTER_REFRESHES_BEFORE_START = 3; +static bool s_startWaitingForRoster = false; + +// the game must have a slot for every human the lobby lists, otherwise the match would start without them +static void StartMatchWhenRosterMatches(Int refreshesDone) +{ + NGMP_OnlineServices_LobbyInterface* pLobbyInterface = NGMP_OnlineServicesManager::GetInterface(); + NGMPGame* myGame = pLobbyInterface == nullptr ? nullptr : pLobbyInterface->GetCurrentGame(); + + if (pLobbyInterface == nullptr || !myGame || !myGame->isInGame()) + { + s_startWaitingForRoster = false; + return; + } + + if (!TheNGMPGame) + { + s_startWaitingForRoster = false; + return; + } + + const Int missingHumans = CountLobbyHumansMissingFromGame(myGame); + if (missingHumans > 0) + { + if (refreshesDone < MAX_ROSTER_REFRESHES_BEFORE_START) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[NGMP] Start: the game is missing %d lobby player(s), refreshing the lobby (%d/%d)", missingHumans, refreshesDone + 1, MAX_ROSTER_REFRESHES_BEFORE_START); + pLobbyInterface->UpdateRoomDataCache([refreshesDone](bool) + { + StartMatchWhenRosterMatches(refreshesDone + 1); + }); + return; + } + + NetworkLog(ELogVerbosity::LOG_RELEASE, "[NGMP] Start: the game is still missing %d lobby player(s), not starting", missingHumans); + s_startWaitingForRoster = false; + GSMessageBoxOk(TheGameText->fetch("GUI:Error"), UnicodeString(L"The player list was incomplete, so the match was not started. Please rejoin the lobby.")); + PopBackToLobby(); + return; + } + + s_startWaitingForRoster = false; + + // TODO_NGMP + //SendStatsToOtherPlayers(TheNGMPGame); + + GameWindow* buttonBuddy = TheWindowManager->winGetWindowFromId(NULL, NAMEKEY("GameSpyGameOptionsMenu.wnd:ButtonCommunicator")); + if (buttonBuddy) + buttonBuddy->winEnable(FALSE); + GameSpyCloseOverlay(GSOVERLAY_BUDDY); + GameSpyCloseOverlay(GSOVERLAY_PLAYERINFO); + + *TheNGMPGame = *myGame; + TheNGMPGame->startGame(0); +} + +static void RestoreLobbyControlsAfterConnectionFailure() +{ + auto* lobby = NGMP_OnlineServicesManager::GetInterface(); + if (lobby == nullptr || TheNGMPGame == nullptr || TheNGMPGame->isGameInProgress()) + { + return; + } + + if (buttonBack != nullptr) + { + buttonBack->winEnable(TRUE); + } + if (buttonStart != nullptr) + { + buttonStart->winEnable(TRUE); + } + if (lobby->IsHost()) + { + if (buttonSelectMap != nullptr) + { + buttonSelectMap->winEnable(TRUE); + } + if (comboBoxStartingCash != nullptr) + { + comboBoxStartingCash->winEnable(TRUE); + } + if (checkBoxLimitSuperweapons != nullptr) + { + checkBoxLimitSuperweapons->winEnable(TRUE); + } + } + WOLDisplaySlotList(); +} + +// Drops a check result whose lobby, settings or start attempt changed since it was requested +static Bool IsCurrentStartCheck(NGMP_OnlineServices_LobbyInterface* lobby, int64_t lobbyID, uint64_t generation, + const std::vector& members, const std::string& settings, uint64_t attempt) +{ + if (lobby == nullptr || lobby->GetCurrentLobby().lobbyID != lobbyID || lobby->GetLobbyGeneration() != generation + || !lobby->IsHost() || TheNGMPGame == nullptr || TheNGMPGame->isGameInProgress() + || TheNGMPGame->GetStartAttempt() != attempt) + { + return FALSE; + } + + if (!lobby->IsCurrentLobbyAttempt(lobbyID, generation, members) || lobby->GetLobbyStartSettings() != settings) + { + TheNGMPGame->StopCountdown(); + RestoreLobbyControlsAfterConnectionFailure(); + return FALSE; + } + return TRUE; +} + +static void RequestFinalLobbyStart() +{ + auto* lobby = NGMP_OnlineServicesManager::GetInterface(); + auto ws = NGMP_OnlineServicesManager::GetWebSocket(); + if (lobby == nullptr || ws == nullptr || !lobby->IsHost() || !AreAllHumansSlottedAndConnected(lobby)) + { + return; + } + + const auto lobbyID = lobby->GetCurrentLobby().lobbyID; + const auto generation = lobby->GetLobbyGeneration(); + const auto members = lobby->GetHumanLobbyMembers(); + const auto settings = lobby->GetLobbyStartSettings(); + const auto attempt = TheNGMPGame->BeginStartAttempt(); + if (buttonStart != nullptr) + { + buttonStart->winEnable(FALSE); + } + + ws->SendData_StartFullMeshConnectivityCheck([lobbyID, generation, members, settings, attempt](bool complete, + std::list> missing, std::string reason) + { + auto* current = NGMP_OnlineServicesManager::GetInterface(); + if (!IsCurrentStartCheck(current, lobbyID, generation, members, settings, attempt)) + { + return; + } + + if (complete && AreAllHumansSlottedAndConnected(current)) + { + auto socket = NGMP_OnlineServicesManager::GetWebSocket(); + if (socket != nullptr) + { + socket->SendData_StartGame(); + } + return; + } + + GadgetListBoxAddEntryText(listboxGameSetupChat, UnicodeString(L"Start blocked: not all players are connected. Retry failed connections."), GameMakeColor(255, 194, 15, 255), -1, -1); + for (const auto& pair : missing) + { + const auto source = current->GetRoomMemberFromID(pair.first); + const auto target = current->GetRoomMemberFromID(pair.second); + UnicodeString text; + text.format(L"%ls is not connected to %ls.", from_utf8(source.display_name).c_str(), from_utf8(target.display_name).c_str()); + GadgetListBoxAddEntryText(listboxGameSetupChat, text, GameMakeColor(255, 194, 15, 255), -1, -1); + } + RestoreLobbyControlsAfterConnectionFailure(); + }); +} + static void StartPressed() { Bool isReady = TRUE; @@ -991,6 +1200,20 @@ static void StartPressed() if (pLobbyInterface == nullptr || !myGame || pAuthInterface == nullptr) return; + if (pLobbyInterface->GetCurrentLobby().lobby_type == ELobbyType::CustomGame && !pLobbyInterface->AreLocalLobbyPeersConnected()) + { + pLobbyInterface->RetryFailedLobbyPeers(); + GadgetListBoxAddEntryText(listboxGameSetupChat, UnicodeString(L"Start blocked: waiting for all player connections."), GameMakeColor(255, 194, 15, 255), -1, -1); + return; + } + + if (pLobbyInterface->GetCurrentLobby().lobby_type == ELobbyType::CustomGame && !AreAllHumansSlottedAndConnected(pLobbyInterface)) + { + pLobbyInterface->UpdateRoomDataCache(); + GadgetListBoxAddEntryText(listboxGameSetupChat, UnicodeString(L"Start blocked: waiting for the player list."), GameMakeColor(255, 194, 15, 255), -1, -1); + return; + } + NetworkMesh* pMesh = NGMP_OnlineServicesManager::GetNetworkMesh(); if (pMesh == nullptr) { @@ -1162,8 +1385,26 @@ static void StartPressed() buttonStart->winEnable(FALSE); } + const auto checkLobbyID = pLobbyInterface->GetCurrentLobby().lobbyID; + const auto checkGeneration = pLobbyInterface->GetLobbyGeneration(); + const auto checkMembers = pLobbyInterface->GetHumanLobbyMembers(); + const auto checkSettings = pLobbyInterface->GetLobbyStartSettings(); + const auto checkAttempt = TheNGMPGame->BeginStartAttempt(); pWS->SendData_StartFullMeshConnectivityCheck([=](bool bMeshFullyConnected, std::list> missingConnections, std::string strFailureReason) { + auto* current = NGMP_OnlineServicesManager::GetInterface(); + if (!IsCurrentStartCheck(current, checkLobbyID, checkGeneration, checkMembers, checkSettings, checkAttempt)) + { + return; + } + + // the service can report a complete mesh while we hold no link or slot for players the lobby lists + if (bMeshFullyConnected && !AreAllHumansSlottedAndConnected(current)) + { + bMeshFullyConnected = false; + strFailureReason = "no_link_to_lobby_players"; + } + if (bMeshFullyConnected) { UnicodeString strInform = UnicodeString(L"Connections: All players are connected."); @@ -1223,7 +1464,10 @@ static void StartPressed() // who is missing who? //std::list> missingConnections - GadgetListBoxAddEntryText(listboxGameSetupChat, UnicodeString(L"Connections: Missing links:"), GameMakeColor(255, 194, 15, 255), -1, -1); + if (!missingConnections.empty()) + { + GadgetListBoxAddEntryText(listboxGameSetupChat, UnicodeString(L"Connections: Missing links:"), GameMakeColor(255, 194, 15, 255), -1, -1); + } for (auto& missingPair : missingConnections) { bool bFoundPlayer = false; @@ -1893,6 +2137,13 @@ void WOLGameSetupMenuInit( WindowLayout *layout, void *userData ) // cannot connect to the lobby we joined pLobbyInterface->RegisterForCannotConnectToLobbyCallback([](void) { + auto* lobby = NGMP_OnlineServicesManager::GetInterface(); + if (lobby != nullptr && lobby->GetCurrentLobby().lobby_type == ELobbyType::CustomGame) + { + GadgetListBoxAddEntryText(listboxGameSetupChat, UnicodeString(L"Connection failed. Stay in the lobby and use Retry connections."), GameMakeColor(255, 0, 0, 255), -1, -1); + return; + } + if (TheNetwork != NULL) { delete TheNetwork; TheNetwork = NULL; @@ -1954,7 +2205,7 @@ void WOLGameSetupMenuInit( WindowLayout *layout, void *userData ) if (connState == EConnectionState::CONNECTION_FAILED) { #endif - strConnectionMessage.format(L"Connection: %s is now %hs.", strDisplayName.c_str(), strState.c_str()); + strConnectionMessage.format(L"Connection: %ls is now %hs.%ls", strDisplayName.c_str(), strState.c_str(), connState == EConnectionState::CONNECTION_FAILED ? L" Use Retry connections." : L""); const Color connectionColor = connState == EConnectionState::CONNECTION_FAILED ? GameMakeColor(255, 0, 0, 255) : GameMakeColor(192, 192, 192, 255); @@ -1965,6 +2216,21 @@ void WOLGameSetupMenuInit( WindowLayout *layout, void *userData ) #endif } + auto* lobby = NGMP_OnlineServicesManager::GetInterface(); + if (lobby != nullptr && lobby->GetCurrentLobby().lobby_type == ELobbyType::CustomGame + && TheNGMPGame != nullptr && !TheNGMPGame->isGameInProgress()) + { + if (connState != EConnectionState::CONNECTED_DIRECT && TheNGMPGame->IsCountdownStarted()) + { + TheNGMPGame->StopCountdown(); + RestoreLobbyControlsAfterConnectionFailure(); + } + if (connState == EConnectionState::CONNECTED_DIRECT && AreAllHumansSlottedAndConnected(lobby) + && buttonStart != nullptr && !TheNGMPGame->IsCountdownStarted()) + { + buttonStart->winEnable(TRUE); + } + } // update UI WOLDisplaySlotList(); }); @@ -2009,26 +2275,13 @@ void WOLGameSetupMenuInit( WindowLayout *layout, void *userData ) pLobbyInterface->RegisterForGameStartPacket([]() { - NGMP_OnlineServices_LobbyInterface* pLobbyInterface = NGMP_OnlineServicesManager::GetInterface(); - NGMPGame* myGame = pLobbyInterface == nullptr ? nullptr : pLobbyInterface->GetCurrentGame(); - - if (pLobbyInterface == nullptr || !myGame || !myGame->isInGame()) - return; - - if (!TheNGMPGame) + if (s_startWaitingForRoster) + { return; + } - // TODO_NGMP - //SendStatsToOtherPlayers(TheNGMPGame); - - GameWindow* buttonBuddy = TheWindowManager->winGetWindowFromId(NULL, NAMEKEY("GameSpyGameOptionsMenu.wnd:ButtonCommunicator")); - if (buttonBuddy) - buttonBuddy->winEnable(FALSE); - GameSpyCloseOverlay(GSOVERLAY_BUDDY); - GameSpyCloseOverlay(GSOVERLAY_PLAYERINFO); - - *TheNGMPGame = *myGame; - TheNGMPGame->startGame(0); + s_startWaitingForRoster = true; + StartMatchWhenRosterMatches(0); }); @@ -2510,7 +2763,7 @@ void WOLGameSetupMenuUpdate( WindowLayout * layout, void *userData) if (pLobbyInterface != nullptr) { ServiceConfig& serviceConf = NGMP_OnlineServicesManager::GetInstance()->GetServiceConfig(); - bool bHostMigrationEnabledOnService = serviceConf.enable_host_migration; + bool bHostMigrationEnabledOnService = serviceConf.lobby.host_migration; if (bHostMigrationEnabledOnService) { @@ -2601,6 +2854,47 @@ void WOLGameSetupMenuUpdate( WindowLayout * layout, void *userData) } } + auto* connectionLobby = NGMP_OnlineServicesManager::GetInterface(); + if (connectionLobby != nullptr && connectionLobby->GetCurrentLobby().lobby_type == ELobbyType::CustomGame + && TheNGMPGame != nullptr && !TheNGMPGame->isGameInProgress()) + { + const bool peersConnected = connectionLobby->AreLocalLobbyPeersConnected(); + const bool connected = AreAllHumansSlottedAndConnected(connectionLobby); + const bool failed = connectionLobby->HasFailedLobbyPeers(); + static UnsignedInt s_lastSlotRefreshTime = 0; + if (peersConnected && !connected && timeGetTime() > s_lastSlotRefreshTime + 3000) + { + // peers are linked but the game lacks a slot for one, ask the service for the roster + s_lastSlotRefreshTime = timeGetTime(); + connectionLobby->UpdateRoomDataCache(); + } + if (!connected && TheNGMPGame->IsCountdownStarted()) + { + TheNGMPGame->StopCountdown(); + RestoreLobbyControlsAfterConnectionFailure(); + } + + if (buttonStart != nullptr) + { + buttonStart->winSetText(failed ? UnicodeString(L"Retry connections") + : !peersConnected ? UnicodeString(L"Connecting...") + : !connected ? UnicodeString(L"Updating players...") : TheGameText->fetch(connectionLobby->IsHost() ? "GUI:Start" : "GUI:Accept")); + if (!connected) + { + buttonStart->winEnable(failed); + } + else + { + auto socket = NGMP_OnlineServicesManager::GetWebSocket(); + if (!TheNGMPGame->IsCountdownStarted() && socket != nullptr && socket->m_cbOnConnectivityCheckComplete == nullptr + && (buttonStart->winGetStatus() & WIN_STATUS_ENABLED) == 0) + { + RestoreLobbyControlsAfterConnectionFailure(); + } + } + } + } + #if defined(GENERALS_ONLINE_ENABLE_MATCH_START_COUNTDOWN) // is there a countdown in progress? if (TheNGMPGame != nullptr) @@ -2646,12 +2940,7 @@ void WOLGameSetupMenuUpdate( WindowLayout * layout, void *userData) // stop countdown TheNGMPGame->StopCountdown(); - // send start game packet - std::shared_ptr pWS = NGMP_OnlineServicesManager::GetWebSocket(); - if (pWS != nullptr) - { - pWS->SendData_StartGame(); - } + RequestFinalLobbyStart(); } } } @@ -4139,6 +4428,13 @@ WindowMsgHandledType WOLGameSetupMenuSystem( GameWindow *window, UnsignedInt msg } else if ( controlID == buttonStartID ) { + auto* retryLobby = NGMP_OnlineServicesManager::GetInterface(); + if (retryLobby != nullptr && retryLobby->GetCurrentLobby().lobby_type == ELobbyType::CustomGame + && !retryLobby->AreLocalLobbyPeersConnected()) + { + retryLobby->RetryFailedLobbyPeers(); + break; + } savePlayerInfo(); NGMP_OnlineServices_LobbyInterface* pLobbyInterface = NGMP_OnlineServicesManager::GetInterface(); diff --git a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/WOLQuickMatchMenu.cpp b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/WOLQuickMatchMenu.cpp index ad271978319..474f1a16921 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/WOLQuickMatchMenu.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/WOLQuickMatchMenu.cpp @@ -1345,6 +1345,14 @@ void WOLQuickMatchMenuInit( WindowLayout *layout, void *userData ) if (!pLobbyInterface->IsInLobby()) { + Int index = GadgetListBoxAddEntryText(quickmatchTextWindow, UnicodeString(L"Could not join the match lobby, waiting for the matchmaker..."), GameSpyColor[GSCOLOR_DEFAULT], -1, -1); + GadgetListBoxSetItemData(quickmatchTextWindow, (void*)-1, index); + + matchFoundTimeoutStart = 0; + matchStartCountdownLastSecond = 0; + + buttonBack->winEnable(TRUE); + buttonStop->winEnable(TRUE); return; } diff --git a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GameWindowManagerScript.cpp b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GameWindowManagerScript.cpp index d588e90cc31..d1d2da5bea0 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GameWindowManagerScript.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GameWindowManagerScript.cpp @@ -2726,26 +2726,6 @@ GameWindow *GameWindowManager::winCreateFromScript( AsciiString filenameString, else strlcpy(filepath, filename, ARRAY_SIZE(filepath)); -#if defined(GENERALS_ONLINE) - // check Generals Online first - char gofilepath[_MAX_PATH] = "GeneralsOnlineGameData\\"; - if (strchr(filename, '\\') == NULL) - sprintf(gofilepath, "GeneralsOnlineGameData\\%s", filename); - else - strcpy(gofilepath, filename); - - inFile = TheFileSystem->openFile(gofilepath, File::READ); - if (inFile == NULL) - { - // fall back to game archive - inFile = TheFileSystem->openFile(filepath, File::READ); - if (inFile == NULL) - { - DEBUG_LOG(("WinCreateFromScript: Cannot access file '%s'.\n", filename)); - return NULL; - } - } -#else // Open the input file inFile = TheFileSystem->openFile(filepath, File::READ); if (inFile == nullptr) @@ -2753,7 +2733,6 @@ GameWindow *GameWindowManager::winCreateFromScript( AsciiString filenameString, DEBUG_LOG(( "WinCreateFromScript: Cannot access file '%s'.", filename )); return nullptr; } -#endif // read into memory inFile=inFile->convertToRAMFile(); diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/CredentialStore.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/CredentialStore.cpp new file mode 100644 index 00000000000..b06e4384558 --- /dev/null +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/CredentialStore.cpp @@ -0,0 +1,301 @@ +#include "GameNetwork/GeneralsOnline/CredentialStore.h" +#include "GameNetwork/GeneralsOnline/json.hpp" + +#define WIN32_LEAN_AND_MEAN +#include +#include +#include +#include +#include +#include + +#pragma comment(lib, "Crypt32.lib") + +namespace ServiceConnection +{ +namespace +{ +using Json = nlohmann::json; + +std::mutex storeMutex; +std::string (*pathProvider)() = nullptr; +void (*logSink)(const std::string&) = nullptr; +std::atomic forcedCryptError{0}; +// entries that could not be written; they win over the file and use the same layout +Json overlay = Json::object(); +bool fileUnreadable = false; + +void Log(const std::string& line) +{ + static std::string previous; + if (previous == line) + return; + previous = line; + if (logSink) + logSink(line); +} + +std::string Describe(const char* operation, unsigned long code) +{ + return std::string("[Connection] Credentials ") + operation + " failed (" + std::to_string(code) + "), kept for this session only"; +} + +bool Protect(const std::string& plain, std::string& blob, unsigned long& error) +{ + if (const unsigned long forced = forcedCryptError.load()) + { + error = forced; + return false; + } + DATA_BLOB input{ (DWORD)plain.size(), (BYTE*)plain.data() }; + DATA_BLOB output{}; + if (!CryptProtectData(&input, L"GO Credentials", nullptr, nullptr, nullptr, 0, &output)) + { + error = GetLastError(); + return false; + } + blob.assign((const char*)output.pbData, output.cbData); + LocalFree(output.pbData); + return true; +} + +bool Unprotect(const std::string& blob, std::string& plain, unsigned long& error) +{ + if (const unsigned long forced = forcedCryptError.load()) + { + error = forced; + return false; + } + DATA_BLOB input{ (DWORD)blob.size(), (BYTE*)blob.data() }; + DATA_BLOB output{}; + if (!CryptUnprotectData(&input, nullptr, nullptr, nullptr, nullptr, 0, &output)) + { + error = GetLastError(); + return false; + } + plain.assign((const char*)output.pbData, output.cbData); + SecureZeroMemory(output.pbData, output.cbData); + LocalFree(output.pbData); + return true; +} + +// the file of current builds holds {"refresh_token": ...}; it becomes the token of the default GO route +void Normalize(Json& doc) +{ + if (!doc.is_object()) + doc = Json::object(); + if (!doc.contains("tokens") || !doc["tokens"].is_object()) + doc["tokens"] = Json::object(); + if (!doc.contains("proxies") || !doc["proxies"].is_object()) + doc["proxies"] = Json::object(); + if (doc.contains("refresh_token")) + { + if (doc["refresh_token"].is_string() && !doc["refresh_token"].get().empty() && !doc["tokens"].contains("go")) + doc["tokens"]["go"] = doc["refresh_token"]; + doc.erase("refresh_token"); + } +} + +// the stored document; a file that cannot be read counts as empty and is never overwritten +Json ReadFile() +{ + fileUnreadable = false; + Json doc = Json::object(); + if (!pathProvider) + return doc; + const std::string path = pathProvider(); + std::string bytes; + if (FILE* file = fopen(path.c_str(), "rb")) + { + fseek(file, 0, SEEK_END); + const long size = ftell(file); + fseek(file, 0, SEEK_SET); + if (size > 0) + { + bytes.resize(size); + if (fread(bytes.data(), 1, size, file) != (size_t)size) + bytes.clear(); + } + fclose(file); + } + if (bytes.empty()) + return doc; + // a file that was never encrypted still loads + Json parsed = Json::parse(bytes, nullptr, false); + if (!parsed.is_discarded() && parsed.is_object()) + return parsed; + std::string plain; + unsigned long error = 0; + if (Unprotect(bytes, plain, error)) + { + parsed = Json::parse(plain, nullptr, false); + if (!plain.empty()) + SecureZeroMemory(plain.data(), plain.size()); + if (!parsed.is_discarded() && parsed.is_object()) + return parsed; + error = ERROR_INVALID_DATA; + } + fileUnreadable = true; + Log(Describe("read", error)); + return doc; +} + +Json Merged() +{ + Json doc = ReadFile(); + Normalize(doc); + Json over = overlay; + Normalize(over); + for (const char* section : { "tokens", "proxies" }) + { + for (auto it = over[section].begin(); it != over[section].end(); ++it) + doc[section][it.key()] = it.value(); + } + return doc; +} + +bool WriteFile(const Json& doc) +{ + if (!pathProvider || fileUnreadable) + return false; + std::string plain; + try + { + plain = doc.dump(); + } + catch (...) + { + Log(Describe("write", ERROR_INVALID_DATA)); + return false; + } + std::string blob; + unsigned long error = 0; + const bool encrypted = Protect(plain, blob, error); + if (!plain.empty()) + SecureZeroMemory(plain.data(), plain.size()); + if (!encrypted) + { + Log(Describe("encrypt", error)); + return false; + } + const std::string path = pathProvider(); + const std::string temp = path + ".tmp"; + bool saved = false; + if (FILE* file = fopen(temp.c_str(), "wb")) + { + saved = fwrite(blob.data(), 1, blob.size(), file) == blob.size(); + if (fclose(file) != 0) + saved = false; + } + if (saved) + saved = MoveFileExA(temp.c_str(), path.c_str(), MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH) != 0; + if (!saved) + { + Log(Describe("write", GetLastError())); + DeleteFileA(temp.c_str()); + } + return saved; +} + +// changes one entry and rewrites the whole file; when that fails the entry stays in the overlay for this session +bool Change(const char* section, const std::string& key, const Json* value) +{ + std::lock_guard lock(storeMutex); + Json doc = Merged(); + if (value) + doc[section][key] = *value; + else + doc[section].erase(key); + if (WriteFile(doc)) + { + overlay = Json::object(); + return true; + } + Normalize(overlay); + if (value) + overlay[section][key] = *value; + else + overlay[section].erase(key); + return false; +} + +bool Find(const char* section, const std::string& key, Json& value) +{ + std::lock_guard lock(storeMutex); + const Json doc = Merged(); + const auto it = doc[section].find(key); + if (it == doc[section].end()) + return false; + value = *it; + return true; +} +} + +void ConfigureCredentialStore(std::string (*provider)()) +{ + std::lock_guard lock(storeMutex); + pathProvider = provider; +} + +void SetCredentialLogSink(void (*sink)(const std::string&)) +{ + std::lock_guard lock(storeMutex); + logSink = sink; +} + +void SetCredentialCryptFailureForTest(unsigned long errorCode) +{ + forcedCryptError.store(errorCode); +} + +bool SaveProxyLogin(const std::string& proxyKey, const std::string& user, const std::string& password) +{ + if (proxyKey.empty()) + return false; + const Json value = { { "user", user }, { "password", password } }; + return Change("proxies", proxyKey, &value); +} + +bool LoadProxyLogin(const std::string& proxyKey, std::string& user, std::string& password) +{ + Json value; + if (proxyKey.empty() || !Find("proxies", proxyKey, value) || !value.is_object() || !value.contains("user") || !value["user"].is_string()) + return false; + user = value["user"].get(); + password = value.contains("password") && value["password"].is_string() ? value["password"].get() : std::string(); + return !user.empty(); +} + +bool DeleteProxyLogin(const std::string& proxyKey) +{ + Json value; + if (proxyKey.empty() || !Find("proxies", proxyKey, value)) + return true; + return Change("proxies", proxyKey, nullptr); +} + +bool SaveRefreshToken(const std::string& serverKey, const std::string& token) +{ + if (serverKey.empty() || token.empty()) + return false; + const Json value = token; + return Change("tokens", serverKey, &value); +} + +bool LoadRefreshToken(const std::string& serverKey, std::string& token) +{ + Json value; + if (serverKey.empty() || !Find("tokens", serverKey, value) || !value.is_string()) + return false; + token = value.get(); + return !token.empty(); +} + +bool DeleteRefreshToken(const std::string& serverKey) +{ + Json value; + if (serverKey.empty() || !Find("tokens", serverKey, value)) + return true; + return Change("tokens", serverKey, nullptr); +} +} diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/GeneralsOnline_Settings.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/GeneralsOnline_Settings.cpp index 347c65de981..c8c6162a3bd 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/GeneralsOnline_Settings.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/GeneralsOnline_Settings.cpp @@ -1,7 +1,11 @@ #include "GameNetwork/GeneralsOnline/GeneralsOnline_Settings.h" +#include "Common/ArchiveFileSystem.h" #include "../json.hpp" #include "../OnlineServices_LobbyInterface.h" #include "../OnlineServices_Init.h" +#include +#include "GameNetwork/GeneralsOnline/CredentialStore.h" +#include "GameNetwork/GeneralsOnline/OnlineServices_Auth.h" #define SETTINGS_KEY_CAMERA "camera" #define SETTINGS_KEY_CAMERA_MIN_HEIGHT "min_height" @@ -30,14 +34,29 @@ #define SETTINGS_KEY_DEBUG_VERBOSE_LOGGING "verbose_logging" #define SETTINGS_KEY_NETWORK "network" -#define SETTINGS_KEY_NETWORK_HTTP_VERSION "http_version" +#define SETTINGS_KEY_NETWORK_LEGACY_HTTP_VERSION "http_version" #define SETTINGS_KEY_NETWORK_USE_ALTERNATIVE_ENDPOINT "use_alternative_endpoint" +#define SETTINGS_KEY_NETWORK_SERVER "server" +#define SETTINGS_KEY_NETWORK_SERVER_MODE "mode" +#define SETTINGS_KEY_NETWORK_SERVER_ADDRESS "address" +#define SETTINGS_KEY_NETWORK_PROXY "proxy" +#define SETTINGS_KEY_NETWORK_PROXY_MODE "mode" +#define SETTINGS_KEY_NETWORK_PROXY_ADDRESS "address" +#define SETTINGS_KEY_NETWORK_PROXY_WINDOWS_LOGIN "windows_login" +#define SETTINGS_KEY_NETWORK_LAST_ROUTE "last_route" +#define LEGACY_ALTERNATIVE_ROUTE_HOST "api-ru.playgenerals.online" #define SETTINGS_KEY_PLUGINS "plugins" #define SETTINGS_KEY_PLUGINS_ANTICHEAT "anticheat" #define SETTINGS_KEY_DATAPACKS "data_packs" -#define SETTINGS_KEY_DATAPACKS_COMMUNITY_PATCH "use_community_data_patch" +#define SETTINGS_KEY_DATAPACKS_USER "user" +#define SETTINGS_KEY_DATAPACKS_GENERALS_ONLINE "generals_online" +#define SETTINGS_KEY_DATAPACKS_COMMUNITY_PATCH "community_patch" +#define SETTINGS_KEY_DATAPACKS_ENABLED "enabled" +#define SETTINGS_KEY_DATAPACKS_INCLUDE_PATHS "include_paths" +#define SETTINGS_KEY_DATAPACKS_EXCLUDE_PATHS "exclude_paths" +#define SETTINGS_KEY_DATAPACKS_COMMUNITY_PATCH_LEGACY "use_community_data_patch" // before the layers, read once and dropped on save #define SETTINGS_FILENAME_LEGACY "GeneralsOnline_settings.json" #define SETTINGS_FILENAME "settings.json" @@ -94,8 +113,93 @@ std::string GenOnlineSettings::GetAnticheatPlugin() const return m_Plugins_Anticheat; } +// Reads a list of paths of data_packs, an entry that is not valid is dropped. Returns FALSE and keeps the list when the field is missing or not a list. +static bool LoadDataPackPaths(const nlohmann::json& jsonLayer, const char* layerName, const char* key, bool excludes, std::vector& paths) +{ + if (!jsonLayer.contains(key)) + { + return false; + } + + if (!jsonLayer[key].is_array()) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "Data pack layer %s: %s is not a list, using the default", layerName, key); + return false; + } + + paths.clear(); + for (const auto& path : jsonLayer[key]) + { + AsciiString reason; + bool bValid = false; + if (path.is_string()) + { + bValid = excludes ? PathExcludeList::parse(path.get(), nullptr, &reason) : ArchiveFileSystem::parseMountEntry(AsciiString(path.get().c_str()), nullptr, &reason); + } + + if (bValid) + { + paths.push_back(path.get()); + } + else + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "Data pack layer %s: %s entry dropped from the settings (%s)", layerName, key, path.is_string() ? reason.str() : "not a string"); + } + } + return true; +} + +// Reads one layer of data_packs. A missing or invalid field keeps what the layer holds, its default. excludePaths is only read for the user layer. +static void LoadDataPackLayer(const nlohmann::json& dataPacks, const char* name, GenOnlineDataPackLayer& layer, std::vector* excludePaths) +{ + if (!dataPacks.contains(name)) + { + return; + } + + const nlohmann::json& jsonLayer = dataPacks[name]; + if (!jsonLayer.is_object()) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "Data pack layer %s in the settings is not an object, using the default", name); + return; + } + + if (jsonLayer.contains(SETTINGS_KEY_DATAPACKS_ENABLED)) + { + if (jsonLayer[SETTINGS_KEY_DATAPACKS_ENABLED].is_boolean()) + { + layer.m_enabled = jsonLayer[SETTINGS_KEY_DATAPACKS_ENABLED].get(); + } + else + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "Data pack layer %s: enabled is not a boolean, using the default", name); + } + } + + LoadDataPackPaths(jsonLayer, name, SETTINGS_KEY_DATAPACKS_INCLUDE_PATHS, false, layer.m_includePaths); + + if (excludePaths != nullptr) + { + LoadDataPackPaths(jsonLayer, name, SETTINGS_KEY_DATAPACKS_EXCLUDE_PATHS, true, *excludePaths); + } +} + +static nlohmann::json DataPackLayerToJson(const GenOnlineDataPackLayer& layer, const std::vector* excludePaths) +{ + nlohmann::json jsonLayer = nlohmann::json::object(); + jsonLayer[SETTINGS_KEY_DATAPACKS_ENABLED] = layer.m_enabled; + jsonLayer[SETTINGS_KEY_DATAPACKS_INCLUDE_PATHS] = layer.m_includePaths; + if (excludePaths != nullptr) + { + jsonLayer[SETTINGS_KEY_DATAPACKS_EXCLUDE_PATHS] = *excludePaths; + } + return jsonLayer; +} + void GenOnlineSettings::Load(void) { + ServiceConnection::ConfigureCredentialStore(&NGMP_OnlineServices_AuthInterface::GetCredentialsFilePath); + ServiceConnection::SetCredentialLogSink([](const std::string& line) { NetworkLog(ELogVerbosity::LOG_RELEASE, "%s", line.c_str()); }); char GameDir[MAX_PATH + 1] = {}; ::GetCurrentDirectoryA(MAX_PATH + 1u, GameDir); std::string strSettingsFileDir = std::format("{}/GeneralsOnlineData/", TheGlobalData->getPath_UserData().str()); @@ -163,6 +267,9 @@ void GenOnlineSettings::Load(void) try { jsonSettings = nlohmann::json::parse(strJSON); + if (!jsonSettings.is_object()) + throw std::runtime_error("Settings must be an object"); + m_PreservedJSON = jsonSettings.dump(); } catch (...) @@ -217,26 +324,122 @@ void GenOnlineSettings::Load(void) if (jsonSettings.contains(SETTINGS_KEY_NETWORK)) { - auto networkSettings = jsonSettings[SETTINGS_KEY_NETWORK]; - - if (networkSettings.contains(SETTINGS_KEY_NETWORK_HTTP_VERSION)) + // Any network value that is unknown, malformed or of the wrong type goes back to its default and is logged, one line per field. + // The load ends with a save, so the file never keeps the invalid value. Addresses can hold a secret, so their old value is not logged. + auto resetField = [](const std::string& path, const nlohmann::json& old, bool secret) { - int httpVersion = networkSettings[SETTINGS_KEY_NETWORK_HTTP_VERSION]; - - // clamp - if (httpVersion < 0 || httpVersion > HTTP_VERSION_3_0) - { - m_Network_HTTPVersion = EHTTPVersion::HTTP_VERSION_AUTO; - } - else - { - m_Network_HTTPVersion = (EHTTPVersion)httpVersion; - } + const std::string oldText = secret ? std::string("hidden") : old.is_string() ? old.get().substr(0, 64) : std::string(old.type_name()); + NetworkLog(ELogVerbosity::LOG_RELEASE, "[Settings] %s is not valid (%s), reset to its default", path.c_str(), oldText.c_str()); + }; + auto choice = [&](const nlohmann::json& section, const std::string& path, const char* key, std::string& value, std::initializer_list allowed) + { + if (!section.contains(key)) + { + return; + } + const auto& found = section[key]; + if (found.is_string() && std::find_if(allowed.begin(), allowed.end(), [&](const char* name) { return found.get() == name; }) != allowed.end()) + { + value = found.get(); + return; + } + resetField(path + "." + key, found, false); + }; + // a section that is present but not an object goes back to its defaults + auto section = [&](const nlohmann::json& parent, const std::string& path, const char* key) -> const nlohmann::json* + { + if (!parent.contains(key)) + { + return nullptr; + } + if (!parent[key].is_object()) + { + resetField(path + "." + key, parent[key], false); + return nullptr; + } + return &parent[key]; + }; + + if (!jsonSettings[SETTINGS_KEY_NETWORK].is_object()) + { + resetField(SETTINGS_KEY_NETWORK, jsonSettings[SETTINGS_KEY_NETWORK], false); } - - if (networkSettings.contains(SETTINGS_KEY_NETWORK_USE_ALTERNATIVE_ENDPOINT)) + else { - m_Network_UseAlternativeEndpoint = networkSettings[SETTINGS_KEY_NETWORK_USE_ALTERNATIVE_ENDPOINT]; + const auto& networkSettings = jsonSettings[SETTINGS_KEY_NETWORK]; + + if (const auto* serverSettings = section(networkSettings, SETTINGS_KEY_NETWORK, SETTINGS_KEY_NETWORK_SERVER)) + { + const std::string path = std::string(SETTINGS_KEY_NETWORK) + "." + SETTINGS_KEY_NETWORK_SERVER; + choice(*serverSettings, path, SETTINGS_KEY_NETWORK_SERVER_MODE, m_ConnectionSettings.serverMode, { "automatic", "custom" }); + if (serverSettings->contains(SETTINGS_KEY_NETWORK_SERVER_ADDRESS)) + { + const auto& found = (*serverSettings)[SETTINGS_KEY_NETWORK_SERVER_ADDRESS]; + std::string endpointError; + if (found.is_string() && (found.get().empty() || + ServiceConnection::NormalizeCustomEndpoint(found.get(), m_ConnectionSettings.serverAddress, endpointError))) + { + // an empty address stays empty + } + else + { + resetField(path + "." + SETTINGS_KEY_NETWORK_SERVER_ADDRESS, found, true); + m_ConnectionSettings.serverAddress.clear(); + } + } + } + + if (const auto* proxySettings = section(networkSettings, SETTINGS_KEY_NETWORK, SETTINGS_KEY_NETWORK_PROXY)) + { + const std::string path = std::string(SETTINGS_KEY_NETWORK) + "." + SETTINGS_KEY_NETWORK_PROXY; + choice(*proxySettings, path, SETTINGS_KEY_NETWORK_PROXY_MODE, m_ConnectionSettings.proxyMode, { "automatic", "disabled", "custom" }); + if (proxySettings->contains(SETTINGS_KEY_NETWORK_PROXY_ADDRESS)) + { + const auto& found = (*proxySettings)[SETTINGS_KEY_NETWORK_PROXY_ADDRESS]; + std::string proxyError; + if (found.is_string() && (found.get().empty() || ServiceConnection::ValidateProxyURL(found.get(), proxyError))) + { + m_ConnectionSettings.proxyURL = found.get(); + } + else + { + resetField(path + "." + SETTINGS_KEY_NETWORK_PROXY_ADDRESS, found, true); + } + } + if (proxySettings->contains(SETTINGS_KEY_NETWORK_PROXY_WINDOWS_LOGIN)) + { + const auto& found = (*proxySettings)[SETTINGS_KEY_NETWORK_PROXY_WINDOWS_LOGIN]; + if (found.is_boolean()) + { + m_ConnectionSettings.windowsLogin = found.get(); + } + else + { + resetField(path + "." + SETTINGS_KEY_NETWORK_PROXY_WINDOWS_LOGIN, found, false); + } + } + } + + if (networkSettings.contains(SETTINGS_KEY_NETWORK_LAST_ROUTE)) + { + const auto& found = networkSettings[SETTINGS_KEY_NETWORK_LAST_ROUTE]; + if (found.is_string() && found.get().size() <= 253 && + found.get().find_first_not_of("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.-") == std::string::npos) + { + m_LastRoute = found.get(); + } + else + { + resetField(std::string(SETTINGS_KEY_NETWORK) + "." + SETTINGS_KEY_NETWORK_LAST_ROUTE, found, false); + } + } + else if (networkSettings.contains(SETTINGS_KEY_NETWORK_USE_ALTERNATIVE_ENDPOINT) && + networkSettings[SETTINGS_KEY_NETWORK_USE_ALTERNATIVE_ENDPOINT].is_boolean() && + networkSettings[SETTINGS_KEY_NETWORK_USE_ALTERNATIVE_ENDPOINT].get()) + { + // the old switch of the released client now only prefers that route + m_LastRoute = LEGACY_ALTERNATIVE_ROUTE_HOST; + } } } @@ -317,11 +520,18 @@ void GenOnlineSettings::Load(void) if (jsonSettings.contains(SETTINGS_KEY_DATAPACKS)) { - auto datapackSettings = jsonSettings[SETTINGS_KEY_DATAPACKS]; + const nlohmann::json& datapackSettings = jsonSettings[SETTINGS_KEY_DATAPACKS]; + + LoadDataPackLayer(datapackSettings, SETTINGS_KEY_DATAPACKS_USER, m_DataPacks_User, &m_DataPacks_UserExcludePaths); + LoadDataPackLayer(datapackSettings, SETTINGS_KEY_DATAPACKS_GENERALS_ONLINE, m_DataPacks_GeneralsOnline, nullptr); + LoadDataPackLayer(datapackSettings, SETTINGS_KEY_DATAPACKS_COMMUNITY_PATCH, m_DataPacks_CommunityPatch, nullptr); - if (datapackSettings.contains(SETTINGS_KEY_DATAPACKS_COMMUNITY_PATCH)) + // a file from before the layers: the old community patch switch becomes community_patch.enabled + if (!datapackSettings.contains(SETTINGS_KEY_DATAPACKS_COMMUNITY_PATCH) + && datapackSettings.contains(SETTINGS_KEY_DATAPACKS_COMMUNITY_PATCH_LEGACY) + && datapackSettings[SETTINGS_KEY_DATAPACKS_COMMUNITY_PATCH_LEGACY].is_boolean()) { - m_DataPacks_UseCommunityPatch = datapackSettings[SETTINGS_KEY_DATAPACKS_COMMUNITY_PATCH]; + m_DataPacks_CommunityPatch.m_enabled = datapackSettings[SETTINGS_KEY_DATAPACKS_COMMUNITY_PATCH_LEGACY].get(); } } } @@ -356,6 +566,7 @@ void GenOnlineSettings::Load(void) if (bReadFailed) { + m_CanSave = false; // keep the file on disk, the defaults above only apply to this session NetworkLog(ELogVerbosity::LOG_RELEASE, "[NGMP] Could not read settings file, keeping it untouched"); return; @@ -365,19 +576,30 @@ void GenOnlineSettings::Load(void) { // keep the unparseable file for recovery before the defaults replace it std::filesystem::rename(strSettingsFilePath, strSettingsFilePath + ".bad", ec); + if (ec) + { + m_CanSave = false; + return; + } + m_PreservedJSON = "{}"; } // Always save so we re-serialize anything new or missing Save(); } -void GenOnlineSettings::Save() +bool GenOnlineSettings::Save() { if (!m_bInitialized) { Initialize(); } + if (!m_CanSave) + { + return false; + } + nlohmann::json root = { { SETTINGS_KEY_CAMERA, @@ -400,8 +622,14 @@ void GenOnlineSettings::Save() { SETTINGS_KEY_NETWORK, { - {SETTINGS_KEY_NETWORK_HTTP_VERSION, m_Network_HTTPVersion}, - {SETTINGS_KEY_NETWORK_USE_ALTERNATIVE_ENDPOINT, m_Network_UseAlternativeEndpoint} + {SETTINGS_KEY_NETWORK_SERVER, { + {SETTINGS_KEY_NETWORK_SERVER_MODE, m_ConnectionSettings.serverMode}, + {SETTINGS_KEY_NETWORK_SERVER_ADDRESS, m_ConnectionSettings.serverAddress}}}, + {SETTINGS_KEY_NETWORK_PROXY, { + {SETTINGS_KEY_NETWORK_PROXY_MODE, m_ConnectionSettings.proxyMode}, + {SETTINGS_KEY_NETWORK_PROXY_ADDRESS, m_ConnectionSettings.proxyURL}, + {SETTINGS_KEY_NETWORK_PROXY_WINDOWS_LOGIN, m_ConnectionSettings.windowsLogin}}}, + {SETTINGS_KEY_NETWORK_LAST_ROUTE, m_LastRoute} } }, @@ -443,12 +671,19 @@ void GenOnlineSettings::Save() { SETTINGS_KEY_DATAPACKS, { - {SETTINGS_KEY_DATAPACKS_COMMUNITY_PATCH, m_DataPacks_UseCommunityPatch} + {SETTINGS_KEY_DATAPACKS_USER, DataPackLayerToJson(m_DataPacks_User, &m_DataPacks_UserExcludePaths)}, + {SETTINGS_KEY_DATAPACKS_GENERALS_ONLINE, DataPackLayerToJson(m_DataPacks_GeneralsOnline, nullptr)}, + {SETTINGS_KEY_DATAPACKS_COMMUNITY_PATCH, DataPackLayerToJson(m_DataPacks_CommunityPatch, nullptr)} } } }; - std::string strData = root.dump(1); + auto preserved = nlohmann::json::parse(m_PreservedJSON); + preserved.merge_patch(root); + preserved[SETTINGS_KEY_DATAPACKS] = root[SETTINGS_KEY_DATAPACKS]; // the client owns data_packs completely, stale keys go + preserved[SETTINGS_KEY_NETWORK].erase(SETTINGS_KEY_NETWORK_USE_ALTERNATIVE_ENDPOINT); + preserved[SETTINGS_KEY_NETWORK].erase(SETTINGS_KEY_NETWORK_LEGACY_HTTP_VERSION); + std::string strData = preserved.dump(1); std::string strSettingsFilePath = std::format("{}/GeneralsOnlineData/{}", TheGlobalData->getPath_UserData().str(), SETTINGS_FILENAME); @@ -472,4 +707,102 @@ void GenOnlineSettings::Save() std::error_code ec; std::filesystem::remove(strTempPath, ec); } + else + { + m_PreservedJSON = strData; + } + return bSaved; +} + +bool GenOnlineSettings::Network_SaveConnectionSettings(const ServiceConnectionSettings& requested, std::string& error) +{ + error.clear(); + ServiceConnectionSettings settings = requested; + if ((settings.serverMode != "automatic" && settings.serverMode != "custom") || + (settings.proxyMode != "automatic" && settings.proxyMode != "disabled" && settings.proxyMode != "custom")) + { + error = "Choose a valid service and proxy mode."; + return false; + } + if (settings.serverMode == "custom") + { + if (!ServiceConnection::NormalizeCustomEndpoint(settings.serverAddress, settings.serverAddress, error)) + { + return false; + } + } + else + { + settings.serverAddress.clear(); + } + if ((settings.proxyMode == "custom" || !settings.proxyURL.empty()) && !ServiceConnection::ValidateProxyURL(settings.proxyURL, error)) + { + return false; + } + if (!m_bInitialized) + { + Initialize(); + } + std::lock_guard lock(m_NetworkMutex); + const auto previous = m_ConnectionSettings; + m_ConnectionSettings = settings; + if (Save()) + { + // a changed proxy starts without anything typed for the old one + const std::string previousProxy = previous.proxyMode == "custom" ? ServiceConnection::CanonicalProxyKey(previous.proxyURL) : std::string(); + const std::string proxy = settings.proxyMode == "custom" ? ServiceConnection::CanonicalProxyKey(settings.proxyURL) : std::string(); + if (previousProxy != proxy) + ServiceConnection::ClearSessionProxyCredentialsExcept(proxy); + ServiceConnection::InvalidateProxyPolicyCache(); + return true; + } + m_ConnectionSettings = previous; + error = "Could not save connection settings. Check the game user-data folder permissions."; + return false; +} + +bool GenOnlineSettings::Network_SaveWindowsLogin(bool windowsLogin) +{ + if (!m_bInitialized) + { + Initialize(); + } + std::lock_guard lock(m_NetworkMutex); + if (m_ConnectionSettings.windowsLogin == windowsLogin) + { + return true; + } + m_ConnectionSettings.windowsLogin = windowsLogin; + if (!Save()) + { + m_ConnectionSettings.windowsLogin = !windowsLogin; + return false; + } + ServiceConnection::InvalidateProxyPolicyCache(); + return true; +} + +bool GenOnlineSettings::Network_SaveLastRoute(const std::string& host) +{ + if (host.empty() || host.size() > 253 || host.find_first_not_of("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.-") != std::string::npos) + { + return false; + } + if (!m_bInitialized) + { + Initialize(); + } + std::lock_guard lock(m_NetworkMutex); + if (m_LastRoute == host) + { + return true; + } + const auto previous = m_LastRoute; + m_LastRoute = host; + if (Save()) + { + return true; + } + m_LastRoute = previous; + return false; } diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/HTTP/HTTPManager.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/HTTP/HTTPManager.cpp index a420a04cda6..5e506f70c2b 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/HTTP/HTTPManager.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/HTTP/HTTPManager.cpp @@ -83,119 +83,22 @@ void HTTPManager::Shutdown() } m_vecRequestsPendingStart.clear(); - NetworkLog(ELogVerbosity::LOG_RELEASE, "[HTTPManager] Waiting for %d in-flight requests to complete...", (int)m_vecRequestsInFlight.size()); + NetworkLog(ELogVerbosity::LOG_RELEASE, "[HTTPManager] Canceling %d in-flight requests...", (int)m_vecRequestsInFlight.size()); - // Wait for all in-flight requests to complete + // removing handles cancels transfers; resolver workers hold value snapshots, so abandoned results never block shutdown + for (HTTPRequest* request : m_vecRequestsInFlight) + delete request; + m_vecRequestsInFlight.clear(); if (m_pCurl != nullptr) { - int numRunning = 0; - do - { - // Perform any pending operations - curl_multi_perform(m_pCurl, &numRunning); - - // Check for completed requests - int msgq = 0; - CURLMsg* m = nullptr; - while ((m = curl_multi_info_read(m_pCurl, &msgq)) != nullptr) - { - if (m->msg == CURLMSG_DONE) - { - CURL* pCurlHandle = m->easy_handle; - - // Find and remove the associated request - for (auto it = m_vecRequestsInFlight.begin(); it != m_vecRequestsInFlight.end(); ++it) - { - HTTPRequest* pRequest = *it; - if (pRequest != nullptr && pRequest->EasyHandleMatches(pCurlHandle)) - { - // During shutdown, skip invoking the completion callback. The callback - // may reference objects (e.g. NGMP_OnlineServices_StatsInterface via - // captured 'this') that have already been or are being destroyed, - // leading to use-after-free memory corruption and crashes in unrelated - // destructors such as GameSpyMiscPreferences::~GameSpyMiscPreferences(). - // HTTPRequest::~HTTPRequest() handles all necessary curl handle cleanup. - delete pRequest; - m_vecRequestsInFlight.erase(it); - break; - } - } - } - } - - // Small sleep to avoid busy-waiting if there are still operations pending - if (numRunning > 0) - { - std::this_thread::sleep_for(std::chrono::milliseconds(10)); - } - - } while (numRunning > 0 || !m_vecRequestsInFlight.empty()); - - NetworkLog(ELogVerbosity::LOG_RELEASE, "[HTTPManager] All in-flight requests completed"); - - // Delete any remaining in-flight requests without invoking their callbacks. - // These are requests that completed via curl but were not matched above, or - // requests that are still pending completion. Invoking callbacks here is unsafe - // as the objects they reference may already be destroyed. - for (HTTPRequest* pRequest : m_vecRequestsInFlight) - { - if (pRequest != nullptr) - { - delete pRequest; - } - } - m_vecRequestsInFlight.clear(); - - // Now safe to cleanup curl_multi_cleanup(m_pCurl); m_pCurl = nullptr; - - // Cleanup libcurl global state - curl_global_cleanup(); } NetworkLog(ELogVerbosity::LOG_RELEASE, "[HTTPManager] Shutdown complete"); } -bool HTTPManager::DeterminePlatformProxySettings() -{ - CHECK_MAIN_THREAD; - - WINHTTP_CURRENT_USER_IE_PROXY_CONFIG pProxyConfig; - WinHttpGetIEProxyConfigForCurrentUser(&pProxyConfig); - - if (pProxyConfig.lpszProxy != nullptr) - { - LPWSTR ws = pProxyConfig.lpszProxy; - std::string strFullProxy; - strFullProxy.reserve(wcslen(ws)); - for (; *ws; ws++) - strFullProxy += (char)*ws; - - size_t ipStart = strFullProxy.find("="); - ipStart = (ipStart != std::string::npos) ? ipStart + 1 : 0; - size_t ipEnd = strFullProxy.find(":", ipStart); - if (ipEnd == std::string::npos) ipEnd = strFullProxy.size(); - - m_strProxyAddr = strFullProxy.substr(ipStart, ipEnd - ipStart); - - size_t portStart = ipEnd + 1; - size_t portEnd = strFullProxy.find(";", portStart); - std::string strPort = strFullProxy.substr(portStart, portEnd != std::string::npos ? portEnd - portStart : std::string::npos); - - m_proxyPort = (uint16_t)atoi(strPort.c_str()); - } - - m_bProxyEnabled = pProxyConfig.lpszProxy != nullptr; - - if (pProxyConfig.lpszProxy) GlobalFree(pProxyConfig.lpszProxy); - if (pProxyConfig.lpszAutoConfigUrl) GlobalFree(pProxyConfig.lpszAutoConfigUrl); - if (pProxyConfig.lpszProxyBypass) GlobalFree(pProxyConfig.lpszProxyBypass); - - return m_bProxyEnabled; -} - HTTPRequest* HTTPManager::PlatformCreateRequest(EHTTPVerb httpVerb, EIPProtocolVersion protover, const char* szURI, std::map& inHeaders, std::function completionCallback, std::function progressCallback /*= nullptr*/, int timeoutMS /* = -1 */) noexcept { CHECK_MAIN_THREAD; @@ -216,7 +119,6 @@ void HTTPManager::Initialize() CHECK_MAIN_THREAD; m_pCurl = curl_multi_init(); - m_bProxyEnabled = DeterminePlatformProxySettings(); } void HTTPManager::Tick() @@ -225,13 +127,20 @@ void HTTPManager::Tick() std::vector vecItemsToRemove = std::vector(); - // start anything needing starting - for (HTTPRequest* pRequest : m_vecRequestsPendingStart) + // Callbacks may enqueue new requests; do not iterate the live pending vector. + auto pending = std::move(m_vecRequestsPendingStart); + m_vecRequestsPendingStart.clear(); + for (HTTPRequest* request : pending) { - pRequest->StartRequest(); - m_vecRequestsInFlight.push_back(pRequest); + request->StartRequest(); + m_vecRequestsInFlight.push_back(request); + } + for (HTTPRequest* request : m_vecRequestsInFlight) + { + request->PollConnectionSetup(); + if (request->IsComplete()) + vecItemsToRemove.push_back(request); } - m_vecRequestsPendingStart.clear(); // perform and poll if (m_pCurl == nullptr) @@ -248,7 +157,7 @@ void HTTPManager::Tick() if (pRequest->WaitingDelayAction()) { bool bDone = pRequest->InvokeDelayAction(); - if (bDone) + if (bDone && pRequest->IsComplete()) { vecItemsToRemove.push_back(pRequest); } @@ -277,7 +186,8 @@ void HTTPManager::Tick() pRequest->SetWaitingDelay(m->data.result); #else pRequest->Threaded_SetComplete(m->data.result); - vecItemsToRemove.push_back(pRequest); + if (pRequest->IsComplete()) + vecItemsToRemove.push_back(pRequest); #endif } } @@ -288,16 +198,16 @@ void HTTPManager::Tick() // remove any completed for (HTTPRequest* pRequestToDestroy : vecItemsToRemove) { - m_vecRequestsInFlight.erase(std::remove(m_vecRequestsInFlight.begin(), m_vecRequestsInFlight.end(), pRequestToDestroy)); + m_vecRequestsInFlight.erase(std::remove(m_vecRequestsInFlight.begin(), m_vecRequestsInFlight.end(), pRequestToDestroy), m_vecRequestsInFlight.end()); delete pRequestToDestroy; } } -void HTTPManager::AddHandleToMulti(CURL* pNewHandle) +bool HTTPManager::AddHandleToMulti(CURL* pNewHandle) { CHECK_MAIN_THREAD; - curl_multi_add_handle(m_pCurl, pNewHandle); + return m_pCurl != nullptr && curl_multi_add_handle(m_pCurl, pNewHandle) == CURLM_OK; } void HTTPManager::RemoveHandleFromMulti(CURL* pHandleToRemove) diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/HTTP/HTTPRequest.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/HTTP/HTTPRequest.cpp index 02786d7f3d9..1d56d3311fe 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/HTTP/HTTPRequest.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/HTTP/HTTPRequest.cpp @@ -1,4 +1,5 @@ #include "GameNetwork/GeneralsOnline/HTTP/HTTPRequest.h" +#include "GameNetwork/GeneralsOnline/ServiceEndpointPolicy.h" #include "GameNetwork/GeneralsOnline/OnlineServices_Init.h" #include "GameNetwork/GeneralsOnline/HTTP/HTTPManager.h" #include "GameNetwork/GeneralsOnline/NGMP_interfaces.h" @@ -35,22 +36,14 @@ HTTPRequest::HTTPRequest(EHTTPVerb httpVerb, EIPProtocolVersion protover, const HTTPRequest::~HTTPRequest() { - NGMP_OnlineServicesManager* pMgr = NGMP_OnlineServicesManager::GetInstance(); - if (pMgr == nullptr) - return; - - HTTPManager* pHTTPManager = pMgr->GetHTTPManager(); - pHTTPManager->RemoveHandleFromMulti(m_pCURL); - - curl_easy_cleanup(m_pCURL); - - m_vecBuffer.clear(); - - if (headers) + m_proxyResolution.reset(); + NGMP_OnlineServicesManager* manager = NGMP_OnlineServicesManager::GetInstance(); + if (m_handleAdded && manager != nullptr && manager->GetHTTPManager() != nullptr) { - curl_slist_free_all(headers); - headers = nullptr; - } + manager->GetHTTPManager()->RemoveHandleFromMulti(m_pCURL); + } + curl_easy_cleanup(m_pCURL); + curl_slist_free_all(headers); } @@ -59,7 +52,10 @@ void HTTPRequest::SetPostData(const char* szPostData) // TODO_HTTP: Error if verb isnt post m_strPostData = std::string(szPostData); - NetworkLog(ELogVerbosity::LOG_DEBUG, "[%p|%s|Verb %d] Transfer is created: Body is %s", this, m_strURI.c_str(), m_httpVerb, szPostData); + if (NGMP_OnlineServicesManager::Settings.Debug_VerboseLogging()) + { + NetworkLog(ELogVerbosity::LOG_DEBUG, "[%p|%s|Verb %d] Transfer is created: Body is %s", this, m_strURI.c_str(), m_httpVerb, szPostData); + } } void HTTPRequest::SetPostDataBuffer(std::vector vecBuffer) @@ -76,7 +72,48 @@ void HTTPRequest::StartRequest() m_currentBufSize_Used = 0; - NetworkLog(ELogVerbosity::LOG_DEBUG, "[%p|%s|Verb %d] Transfer is starting: Body is %s", this, m_strURI.c_str(), m_httpVerb, m_strPostData.c_str()); + if (NGMP_OnlineServicesManager::Settings.Debug_VerboseLogging()) + { + NetworkLog(ELogVerbosity::LOG_DEBUG, "[%p|%s|Verb %d] Transfer is starting: Body is %s", this, m_strURI.c_str(), m_httpVerb, m_strPostData.c_str()); + } + m_setupStarted = std::chrono::steady_clock::now(); + m_proxyResolution = ServiceConnection::StartProxyResolution( + NGMP_OnlineServicesManager::Settings.Network_GetConnectionSettings(), m_strURI); +} + +void HTTPRequest::PollConnectionSetup() +{ + if (!m_proxyResolution || m_bIsComplete) + return; + if (std::chrono::steady_clock::now() - m_setupStarted >= std::chrono::milliseconds(m_timeoutMS)) + { + m_proxyResolution.reset(); + m_setupFailurePhase = "proxy-timeout"; + Threaded_SetComplete(CURLE_OPERATION_TIMEDOUT); + return; + } + if (!m_proxyResolution->ready.load(std::memory_order_acquire)) + return; + m_proxyPolicy = std::move(m_proxyResolution->result); + m_proxyResolution.reset(); + if (!m_proxyPolicy.error.empty() || m_proxyPolicy.routes.empty()) + { + m_setupFailurePhase = m_proxyPolicy.failurePhase.empty() ? "proxy-policy" : m_proxyPolicy.failurePhase; + NetworkLog(ELogVerbosity::LOG_RELEASE, "[HTTP] Proxy policy failed: phase=%s native_code=%lu detail=%s", + m_setupFailurePhase.c_str(), m_proxyPolicy.nativeError, m_proxyPolicy.error.c_str()); + Threaded_SetComplete(CURLE_FAILED_INIT); + return; + } + if (ServiceConnection::ShouldLogProxyPolicy(false, m_proxyPolicy)) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[HTTP] Proxy route policy: %zu route(s), first=%s", + m_proxyPolicy.routes.size(), m_proxyPolicy.routes.front().direct ? "direct" : m_proxyPolicy.routes.front().url.c_str()); + if (!m_proxyPolicy.fallbackReason.empty()) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[HTTP] Proxy policy fallback: native_code=%lu detail=%s", + m_proxyPolicy.nativeError, m_proxyPolicy.fallbackReason.c_str()); + } + } PlatformStartRequest(); } @@ -119,7 +156,7 @@ void HTTPRequest::InvokeCallbackIfComplete() { strResponse.clear(); } - m_completionCallback(true, m_responseCode, strResponse, this); + m_completionCallback(m_result == CURLE_OK, static_cast(m_responseCode), strResponse, this); } } } @@ -138,6 +175,7 @@ bool HTTPRequest::InvokeDelayAction() int64_t currTime = std::chrono::duration_cast(std::chrono::utc_clock::now().time_since_epoch()).count(); if (currTime - m_timeRequestComplete > 2000) { + m_timeRequestComplete = -1; Threaded_SetComplete(m_pendingCURLCode); return true; } @@ -150,10 +188,31 @@ bool HTTPRequest::InvokeDelayAction() void HTTPRequest::Threaded_SetComplete(CURLcode result) { - // store response code + long proxyStatus = 0; + curl_easy_getinfo(m_pCURL, CURLINFO_HTTP_CONNECTCODE, &proxyStatus); curl_easy_getinfo(m_pCURL, CURLINFO_RESPONSE_CODE, &m_responseCode); + // CONNECT authentication failures do not populate the origin response code. + if (proxyStatus == 407) + m_responseCode = 407; + if (proxyStatus != 407 && m_responseCode != 407 && (result == CURLE_COULDNT_CONNECT || result == CURLE_COULDNT_RESOLVE_PROXY || result == CURLE_COULDNT_RESOLVE_HOST) + && m_proxyRoute + 1 < m_proxyPolicy.routes.size() + && std::chrono::steady_clock::now() - m_setupStarted < std::chrono::milliseconds(m_timeoutMS)) + { + NGMP_OnlineServicesManager::GetInstance()->GetHTTPManager()->RemoveHandleFromMulti(m_pCURL); + m_handleAdded = false; + ++m_proxyRoute; + curl_easy_reset(m_pCURL); + curl_slist_free_all(headers); + headers = nullptr; + m_currentBufSize_Used = 0; + m_vecBuffer.resize(g_initialBufSize); + NetworkLog(ELogVerbosity::LOG_RELEASE, "[HTTP] Trying next policy route after pre-connection failure (%d)", result); + PlatformStartRequest(); + return; + } + m_result = result; - if (result == CURLE_OK) + if (result == CURLE_OK && m_proxyPolicy.routes[m_proxyRoute].direct && NGMP_OnlineServicesManager::IsServiceURLTrusted(m_strURI)) { HTTPManager* pHTTPManager = static_cast(NGMP_OnlineServicesManager::GetInstance()->GetHTTPManager()); if (pHTTPManager != nullptr) @@ -186,46 +245,63 @@ void HTTPRequest::Threaded_SetComplete(CURLcode result) // finalize the size, so we can use .size etc m_vecBuffer.resize(m_currentBufSize_Used); - std::string strURIRedacted = m_strURI; - -#if !_DEBUG - size_t tokenpos = strURIRedacted.find("token:"); - if (tokenpos != -1) + long protocol = 0; + curl_easy_getinfo(m_pCURL, CURLINFO_HTTP_VERSION, &protocol); + // successful requests are only logged in verbose mode + const bool success = m_setupFailurePhase.empty() && result == CURLE_OK && m_responseCode >= 200 && m_responseCode < 300; + if (!success || g_LogVerbosity == ELogVerbosity::LOG_DEBUG || NGMP_OnlineServicesManager::Settings.Debug_VerboseLogging()) { - std::string strReplace = ""; - const size_t tokenLen = 32; - strURIRedacted = strURIRedacted.replace(tokenpos + 6, tokenLen, strReplace); - } -#endif + const char* method = "UNKNOWN"; + switch (m_httpVerb) + { + case EHTTPVerb::HTTP_VERB_GET: method = "GET"; break; + case EHTTPVerb::HTTP_VERB_POST: method = "POST"; break; + case EHTTPVerb::HTTP_VERB_PUT: method = "PUT"; break; + case EHTTPVerb::HTTP_VERB_DELETE: method = "DELETE"; break; + } + const auto url = ServiceConnection::RequestURLForLog(m_strURI, NGMP_OnlineServicesManager::GetAPIEndpoint("")); + if (!m_setupFailurePhase.empty()) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[HTTP] Completed: method=%s url=%s source=proxy-policy phase=%s curl=not-started native_code=%lu", + method, url.c_str(), m_setupFailurePhase.c_str(), m_proxyPolicy.nativeError); + } + else + { + NetworkLog(success ? ELogVerbosity::LOG_DEBUG : ELogVerbosity::LOG_RELEASE, "[HTTP] Completed: method=%s url=%s phase=%s curl=%d status=%ld proxy_status=%ld protocol=%s bytes=%zu", + method, url.c_str(), ServiceConnection::FailurePhase(result, m_responseCode, proxyStatus), result, m_responseCode, proxyStatus, + ServiceConnection::ProtocolName(protocol), m_currentBufSize_Used); + } - std::string strResponse = std::string(reinterpret_cast(m_vecBuffer.data()), m_currentBufSize_Used); - NetworkLog(ELogVerbosity::LOG_RELEASE, "[%p|%s|Verb %d] Transfer is complete: %d bytes total! Curl result is %d", this, strURIRedacted.c_str(), m_httpVerb, m_currentBufSize_Used, result); + if (NGMP_OnlineServicesManager::Settings.Debug_VerboseLogging()) + { + std::string strResponse(reinterpret_cast(m_vecBuffer.data()), m_currentBufSize_Used); - // if we got an error, set the response code to 0 + #if !_DEBUG + // release builds keep hiding the RNG seed and anything token related even when verbose + static const std::string strSeedKey = "\"RNGSeed\":"; + for (size_t seedPos = strResponse.find(strSeedKey); seedPos != std::string::npos; seedPos = strResponse.find(strSeedKey, seedPos)) + { + size_t valueStart = seedPos + strSeedKey.length(); + size_t valueEnd = strResponse.find_first_of(",}", valueStart); + if (valueEnd == std::string::npos) + break; -#if !_DEBUG - static const std::string strSeedKey = "\"RNGSeed\":"; - for (size_t seedPos = strResponse.find(strSeedKey); seedPos != std::string::npos; seedPos = strResponse.find(strSeedKey, seedPos)) - { - size_t valueStart = seedPos + strSeedKey.length(); - size_t valueEnd = strResponse.find_first_of(",}", valueStart); - if (valueEnd == std::string::npos) - break; + strResponse.replace(valueStart, valueEnd - valueStart, ""); + seedPos = valueStart; + } - strResponse.replace(valueStart, valueEnd - valueStart, ""); - seedPos = valueStart; - } + std::string strResponseLower = strResponse; + std::transform(strResponseLower.begin(), strResponseLower.end(), strResponseLower.begin(), + [](unsigned char c) { return std::tolower(c); }); + if (strResponseLower.find("token") != std::string::npos) + { + strResponse = ""; + } + #endif - std::string strResponseLower = strResponse; - std::transform(strResponseLower.begin(), strResponseLower.end(), strResponseLower.begin(), - [](unsigned char c) { return std::tolower(c); }); - if (strResponseLower.find("token") != std::string::npos) - { - strResponse = ""; + NetworkLog(ELogVerbosity::LOG_RELEASE, "[HTTP] Response body for %s: %s", url.c_str(), strResponse.c_str()); + } } -#endif - - NetworkLog(ELogVerbosity::LOG_RELEASE, "[%p|%s|Verb %d] Response was %d - %s!", this, strURIRedacted.c_str(), m_httpVerb, m_responseCode, strResponse.c_str()); // trigger callback InvokeCallbackIfComplete(); @@ -233,99 +309,107 @@ void HTTPRequest::Threaded_SetComplete(CURLcode result) void HTTPRequest::PlatformStartRequest() { - if (m_pCURL) + if (m_pCURL == nullptr) { - HTTPManager* pHTTPManager = static_cast(NGMP_OnlineServicesManager::GetInstance()->GetHTTPManager()); - - curl_easy_setopt(m_pCURL, CURLOPT_URL, m_strURI.c_str()); - curl_easy_setopt(m_pCURL, CURLOPT_FOLLOWLOCATION, 1L); - curl_easy_setopt(m_pCURL, CURLOPT_WRITEDATA, (void*)this); - curl_easy_setopt(m_pCURL, CURLOPT_WRITEFUNCTION, WriteMemoryCallback); - curl_easy_setopt(m_pCURL, CURLOPT_USERAGENT, "GeneralsOnline Client"); - - - curl_easy_setopt(m_pCURL, CURLOPT_CONNECTTIMEOUT_MS, m_timeoutMS); - curl_easy_setopt(m_pCURL, CURLOPT_TIMEOUT_MS, m_timeoutMS); - - curl_easy_setopt(m_pCURL, CURLOPT_HTTP_VERSION, NGMP_OnlineServicesManager::Settings.Network_GetHTTPVersionForCurl()); + Threaded_SetComplete(CURLE_FAILED_INIT); + return; + } + if (m_proxyRoute > 0) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[HTTP] Route %zu: %s", m_proxyRoute + 1, m_proxyPolicy.routes[m_proxyRoute].direct ? "direct" : m_proxyPolicy.routes[m_proxyRoute].url.c_str()); + } + std::string error; + if (!ServiceConnection::ConfigureCurl(m_pCURL, m_strURI, m_proxyPolicy.routes[m_proxyRoute], error)) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[HTTP] Connection policy: %s", error.c_str()); + Threaded_SetComplete(CURLE_FAILED_INIT); + return; + } - if (m_protover == EIPProtocolVersion::DONT_CARE) - { - curl_easy_setopt(m_pCURL, CURLOPT_IPRESOLVE, CURL_IPRESOLVE_WHATEVER); - } - else if (m_protover == EIPProtocolVersion::FORCE_IPV4) - { - curl_easy_setopt(m_pCURL, CURLOPT_IPRESOLVE, CURL_IPRESOLVE_V4); - } - else if (m_protover == EIPProtocolVersion::FORCE_IPV6) + // credentials only ever go to the selected service route + const bool trusted = NGMP_OnlineServicesManager::IsServiceURLTrusted(m_strURI); + for (const auto& kvPair : m_mapHeaders) + { + if (_stricmp(kvPair.first.c_str(), "Authorization") == 0 && !trusted) { - curl_easy_setopt(m_pCURL, CURLOPT_IPRESOLVE, CURL_IPRESOLVE_V6); - } - - - // Are we authenticated? attach our auth header - NGMP_OnlineServices_AuthInterface* pAuthInterface = NGMP_OnlineServicesManager::GetInterface(); - if (pAuthInterface != nullptr && pAuthInterface->IsLoggedIn()) - { - if (m_bAppendAuthIfPresent) - { - m_mapHeaders["Authorization"] = "Bearer " + pAuthInterface->GetAuthToken(); - } + NetworkLog(ELogVerbosity::LOG_RELEASE, "[HTTP] Refused credentials outside selected service route"); + Threaded_SetComplete(CURLE_URL_MALFORMAT); + return; } + } - for (auto& kvPair : m_mapHeaders) - { - std::string strHeader = kvPair.first + ": " + kvPair.second; - headers = curl_slist_append(headers, strHeader.c_str()); - } - curl_easy_setopt(m_pCURL, CURLOPT_HTTPHEADER, headers); + // Are we authenticated? attach our auth header + NGMP_OnlineServices_AuthInterface* pAuthInterface = NGMP_OnlineServicesManager::GetInterface(); + if (trusted && m_bAppendAuthIfPresent && pAuthInterface != nullptr && pAuthInterface->IsLoggedIn()) + { + m_mapHeaders["Authorization"] = "Bearer " + pAuthInterface->GetAuthToken(); + } - if (m_httpVerb == EHTTPVerb::HTTP_VERB_POST || m_httpVerb == EHTTPVerb::HTTP_VERB_PUT || m_httpVerb == EHTTPVerb::HTTP_VERB_DELETE) + for (const auto& kvPair : m_mapHeaders) + { + const std::string strHeader = kvPair.first + ": " + kvPair.second; + curl_slist* updated = curl_slist_append(headers, strHeader.c_str()); + if (updated == nullptr) { - //if (m_strPostData.length() > 0) - { - //char* pEscaped = curl_easy_escape(m_pCURL, m_strPostData.c_str(), m_strPostData.length()); - - if (!m_vecPostDataBuffer.empty()) - { - curl_easy_setopt(m_pCURL, CURLOPT_POSTFIELDS, m_vecPostDataBuffer.data()); - curl_easy_setopt(m_pCURL, CURLOPT_POSTFIELDSIZE, m_vecPostDataBuffer.size()); - } - else - { - curl_easy_setopt(m_pCURL, CURLOPT_POSTFIELDS, m_strPostData.c_str()); - } - } + Threaded_SetComplete(CURLE_OUT_OF_MEMORY); + return; } + headers = updated; + } - // needed for PUT etc - if (m_httpVerb == EHTTPVerb::HTTP_VERB_PUT) + CURLcode result = CURLE_OK; + auto option = [&](CURLoption key, auto value) { + if (result == CURLE_OK) { - curl_easy_setopt(m_pCURL, CURLOPT_CUSTOMREQUEST, "PUT"); + result = curl_easy_setopt(m_pCURL, key, value); } - else if (m_httpVerb == EHTTPVerb::HTTP_VERB_DELETE) + }; + option(CURLOPT_URL, m_strURI.c_str()); + option(CURLOPT_WRITEDATA, static_cast(this)); + option(CURLOPT_WRITEFUNCTION, WriteMemoryCallback); + option(CURLOPT_USERAGENT, "GeneralsOnline Client"); + // proxy lookup and earlier routes share the request deadline + const auto elapsed = std::chrono::duration_cast(std::chrono::steady_clock::now() - m_setupStarted).count(); + const long remaining = static_cast(std::max(1, m_timeoutMS - elapsed)); + option(CURLOPT_CONNECTTIMEOUT_MS, remaining); + option(CURLOPT_TIMEOUT_MS, remaining); + option(CURLOPT_IPRESOLVE, static_cast(m_protover == EIPProtocolVersion::FORCE_IPV4 ? CURL_IPRESOLVE_V4 : + m_protover == EIPProtocolVersion::FORCE_IPV6 ? CURL_IPRESOLVE_V6 : CURL_IPRESOLVE_WHATEVER)); + option(CURLOPT_HTTPHEADER, headers); + + if (m_httpVerb == EHTTPVerb::HTTP_VERB_POST || m_httpVerb == EHTTPVerb::HTTP_VERB_PUT || m_httpVerb == EHTTPVerb::HTTP_VERB_DELETE) + { + if (!m_vecPostDataBuffer.empty()) { - curl_easy_setopt(m_pCURL, CURLOPT_CUSTOMREQUEST, "DELETE"); + option(CURLOPT_POSTFIELDS, m_vecPostDataBuffer.data()); + option(CURLOPT_POSTFIELDSIZE_LARGE, static_cast(m_vecPostDataBuffer.size())); } - -#if _DEBUG - if (pHTTPManager->IsProxyEnabled()) + else { - curl_easy_setopt(m_pCURL, CURLOPT_PROXY, pHTTPManager->GetProxyAddress().c_str()); - curl_easy_setopt(m_pCURL, CURLOPT_PROXYPORT, pHTTPManager->GetProxyPort()); + option(CURLOPT_POSTFIELDS, m_strPostData.c_str()); + option(CURLOPT_POSTFIELDSIZE_LARGE, static_cast(m_strPostData.size())); } + } - curl_easy_setopt(m_pCURL, CURLOPT_SSL_VERIFYPEER, 0); - curl_easy_setopt(m_pCURL, CURLOPT_SSL_VERIFYHOST, 0); - curl_easy_setopt(m_pCURL, CURLOPT_VERBOSE, 1); -#else + // needed for PUT etc + if (m_httpVerb == EHTTPVerb::HTTP_VERB_PUT) + { + option(CURLOPT_CUSTOMREQUEST, "PUT"); + } + else if (m_httpVerb == EHTTPVerb::HTTP_VERB_DELETE) + { + option(CURLOPT_CUSTOMREQUEST, "DELETE"); + } - // Use the OS cert store (needed since we build curl against OpenSSL, not schannel). - curl_easy_setopt(m_pCURL, CURLOPT_SSL_OPTIONS, CURLSSLOPT_NATIVE_CA); - curl_easy_setopt(m_pCURL, CURLOPT_SSL_VERIFYPEER, 1L); - curl_easy_setopt(m_pCURL, CURLOPT_SSL_VERIFYHOST, 2L); -#endif + if (result != CURLE_OK) + { + Threaded_SetComplete(result); + return; + } - pHTTPManager->AddHandleToMulti(m_pCURL); + m_handleAdded = NGMP_OnlineServicesManager::GetInstance()->GetHTTPManager()->AddHandleToMulti(m_pCURL); + if (!m_handleAdded) + { + Threaded_SetComplete(CURLE_FAILED_INIT); } } diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NGMPGame.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NGMPGame.cpp index 62f7b703cba..56846e74a93 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NGMPGame.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NGMPGame.cpp @@ -13,6 +13,12 @@ #include "GameClient/View.h" #include "../NextGenMP_defines.h" +#if defined(GENERALS_ONLINE_HIGH_FPS_SERVER) +int FRAME_GROUPING_CAP = 32; +#else +int FRAME_GROUPING_CAP = 64; +#endif + NGMPGameSlot::NGMPGameSlot() { GameSlot(); @@ -149,9 +155,38 @@ void NGMPGame::UpdateSlotsFromCurrentLobby() return; } - for (Int i = 0; i < MAX_SLOTS; ++i) + const std::vector& members = pLobbyInterface->GetCurrentLobby().members; + + // a real member without a usable slot means the roster is incomplete, keep the previous slots and ask for a fresh one + for (const LobbyMemberEntry& lobbyMember : members) { - LobbyMemberEntry pLobbyMember = pLobbyInterface->GetRoomMemberFromIndex(i); + if (lobbyMember.user_id != -1 && !lobbyMember.HasValidSlot()) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[NGMP] UpdateSlotsFromCurrentLobby: user %lld has no valid slot index (%u), keeping the previous roster", lobbyMember.user_id, lobbyMember.m_SlotIndex); + pLobbyInterface->RequestRosterRefresh(); + return; + } + } + + bool claimedSlots[MAX_SLOTS] = {}; + + for (const LobbyMemberEntry& pLobbyMember : members) + { + // empty placeholder without a slot, nothing to place + if (!pLobbyMember.HasValidSlot()) + { + continue; + } + + NGMPGameSlot* slot = (NGMPGameSlot*)getSlot(pLobbyMember.m_SlotIndex); + + if (slot == nullptr) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[NGMP] UpdateSlotsFromCurrentLobby: getSlot(%u) returned null, skipping", pLobbyMember.m_SlotIndex); + continue; + } + + claimedSlots[pLobbyMember.m_SlotIndex] = true; // TODO_NGMP: Support spectators int playerTemplate = -1; @@ -169,20 +204,6 @@ void NGMPGame::UpdateSlotsFromCurrentLobby() { bool bIsAI = (pLobbyMember.m_SlotState == SlotState::SLOT_EASY_AI || pLobbyMember.m_SlotState == SlotState::SLOT_MED_AI|| pLobbyMember.m_SlotState == SlotState::SLOT_BRUTAL_AI); - if (pLobbyMember.m_SlotIndex >= MAX_SLOTS) - { - NetworkLog(ELogVerbosity::LOG_RELEASE, "[NGMP] UpdateSlotsFromCurrentLobby: bad slot index %u for user %lld, skipping", pLobbyMember.m_SlotIndex, pLobbyMember.user_id); - continue; - } - - NGMPGameSlot* slot = (NGMPGameSlot*)getSlot(pLobbyMember.m_SlotIndex); - - if (slot == nullptr) - { - NetworkLog(ELogVerbosity::LOG_RELEASE, "[NGMP] UpdateSlotsFromCurrentLobby: getSlot(%u) returned null, skipping", pLobbyMember.m_SlotIndex); - continue; - } - // NOTE: Internally generals uses 'local ip' to detect which user is local... we dont have an IP, so just use player index for ip slot->setState((SlotState)pLobbyMember.m_SlotState, UnicodeString(from_utf8(pLobbyMember.display_name).c_str()), pLobbyMember.m_SlotIndex); @@ -219,24 +240,21 @@ void NGMPGame::UpdateSlotsFromCurrentLobby() else { // handle open/closed - if (pLobbyMember.m_SlotIndex >= MAX_SLOTS) - { - NetworkLog(ELogVerbosity::LOG_RELEASE, "[NGMP] UpdateSlotsFromCurrentLobby: bad slot index %u for user %lld, skipping", pLobbyMember.m_SlotIndex, pLobbyMember.user_id); - continue; - } - - NGMPGameSlot* slot = (NGMPGameSlot*)getSlot(pLobbyMember.m_SlotIndex); + slot->setState((SlotState)pLobbyMember.m_SlotState); + } + } - if (slot == nullptr) + // slots no member claims are open + for (Int i = 0; i < MAX_SLOTS; ++i) + { + if (!claimedSlots[i]) + { + GameSlot* slot = getSlot(i); + if (slot != nullptr) { - NetworkLog(ELogVerbosity::LOG_RELEASE, "[NGMP] UpdateSlotsFromCurrentLobby: getSlot(%u) returned null, skipping", pLobbyMember.m_SlotIndex); - continue; + slot->setState(SLOT_OPEN); } - - slot->setState((SlotState)pLobbyMember.m_SlotState); } - - // dont need to handle else here, we set it up upon lobby creation } } @@ -411,34 +429,6 @@ void NGMPGame::launchGame(void) //TheWritableGlobalData->m_networkPlayerTimeoutTime = 60000; //TheWritableGlobalData->m_networkDisconnectScreenNotifyTime = 2500; - // process service config - NGMP_OnlineServicesManager* pOnlineServicesMgr = NGMP_OnlineServicesManager::GetInstance(); - if (pOnlineServicesMgr != nullptr) - { - ServiceConfig& serviceConf = pOnlineServicesMgr->GetServiceConfig(); - - if (serviceConf.use_default_config) - { - TheWritableGlobalData->m_networkFPSHistoryLength = 30; - TheWritableGlobalData->m_networkLatencyHistoryLength = 200; - TheWritableGlobalData->m_networkRunAheadSlack = serviceConf.ra_slack_override_percent_in_default; // normally 10 - TheWritableGlobalData->m_networkRunAheadMetricsTime = 5000; - } - else - { - TheWritableGlobalData->m_networkFPSHistoryLength = 10; - TheWritableGlobalData->m_networkLatencyHistoryLength = 10; - - MIN_RUNAHEAD = serviceConf.min_run_ahead_frames; - - TheWritableGlobalData->m_networkRunAheadSlack = serviceConf.ra_slack_percent; - TheWritableGlobalData->m_networkRunAheadMetricsTime = serviceConf.ra_update_frequency_frames * (float)(1000.f / GENERALS_ONLINE_HIGH_FPS_LIMIT); - - FRAME_GROUPING_CAP = serviceConf.frame_grouping_frames * (float)(1000.f / GENERALS_ONLINE_HIGH_FPS_LIMIT); - } - } - - #if defined(GENERALS_ONLINE_HIGH_FPS_RENDER) TheWritableGlobalData->m_horizontalScrollSpeedFactor = NGMP_OnlineServicesManager::Settings.Camera_MoveSpeedRatio(); TheWritableGlobalData->m_verticalScrollSpeedFactor = NGMP_OnlineServicesManager::Settings.Camera_MoveSpeedRatio(); diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NetworkMesh.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NetworkMesh.cpp index 5957924e4c2..958ac132780 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NetworkMesh.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NetworkMesh.cpp @@ -97,6 +97,7 @@ void OnSteamNetConnectionStatusChanged(SteamNetConnectionStatusChangedCallback_t // Capture before SetDisconnected(), which can erase this entry via UpdateState(). const int64_t userID = plrConnection.m_userID; const int signallingAttemptsBeforeDisconnect = plrConnection.m_SignallingAttempts; + plrConnection.m_retryTimeout = true; if (TheNetwork != nullptr) { @@ -114,7 +115,9 @@ void OnSteamNetConnectionStatusChanged(SteamNetConnectionStatusChangedCallback_t const bool bPeerLeft = pJoinOrderLobby != nullptr && pJoinOrderLobby->IsJoinOrderKnown() && !pJoinOrderLobby->IsLobbyMember(userID); // a match can't leave its lobby, so keep repairing the link until the service drops the player const bool bInMatch = TheGameLogic != nullptr && TheGameLogic->isInInternetGame(); - bool bShouldRetry = serviceConf.retry_signalling && ((bInMatch && !bPeerLeft) || (!bWeJoinedLater && !bPeerLeft) || signallingAttemptsBeforeDisconnect < numSignallingAttempts); + const bool bManualLobby = pJoinOrderLobby != nullptr && pJoinOrderLobby->GetCurrentLobby().lobby_type == ELobbyType::CustomGame; + bool bShouldRetry = serviceConf.connection.retry_signalling && !bPeerLeft + && (bInMatch || (!bManualLobby && !bWeJoinedLater) || signallingAttemptsBeforeDisconnect < numSignallingAttempts); bool bWasError = pInfo->m_info.m_eState == k_ESteamNetworkingConnectionState_ProblemDetectedLocally || pInfo->m_info.m_eEndReason != k_ESteamNetConnectionEnd_App_Generic; plrConnection.SetDisconnected(bWasError, pMesh, bShouldRetry && bWasError); @@ -182,15 +185,28 @@ void OnSteamNetConnectionStatusChanged(SteamNetConnectionStatusChangedCallback_t } else if (pLobbyInterface != nullptr) { - NetworkLog(ELogVerbosity::LOG_RELEASE, "[STEAM NETWORKING][DISCONNECT HANDLER] Performing local removal for user %lld from lobby due to failure to connect\n", userID); + NetworkLog(ELogVerbosity::LOG_RELEASE, "[STEAM NETWORKING][DISCONNECT HANDLER] Connection attempts exhausted for user %lld\n", userID); - // deferred: the handler leaves the lobby, which deletes this mesh while we're still inside its RunCallbacks - pLobbyInterface->QueueCannotConnectToLobby(); + if (pLobbyInterface->GetCurrentLobby().lobby_type == ELobbyType::QuickMatch) + { + // deferred: the handler leaves the lobby, which deletes this mesh while we're still inside its RunCallbacks + pLobbyInterface->QueueCannotConnectToLobby(); + } + else + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[LOBBY] Staying in lobby after connection failure peer=%lld; manual retry available", userID); + } } } } + // a clean close is a leave, the peer is gone for good; the game's own disconnect already ran above + if (!bWasError) + { + pMesh->RemoveConnection(userID); + } + // In this example, we will bail the test whenever this happens. // Was this a normal termination? NetworkLog(ELogVerbosity::LOG_RELEASE, "[STEAM NETWORKING]DISCONNECTED OR PROBLEM DETECTED %d\n", pInfo->m_info.m_eEndReason); @@ -266,23 +282,17 @@ void OnSteamNetConnectionStatusChanged(SteamNetConnectionStatusChangedCallback_t } } + // the server started signalling with this peer, so accept it even if our roster is stale if (bPlayerIsInLobby) { NetworkLog(ELogVerbosity::LOG_RELEASE, "[STEAM NETWORKING][%s] Accepting - Player is in lobby\n", pInfo->m_info.m_szConnectionDescription); - SteamNetworkingSockets()->AcceptConnection(pInfo->m_hConn); } else { - NetworkLog(ELogVerbosity::LOG_RELEASE, "[STEAM NETWORKING][%s] Rejecting - Player is not in lobby\n", pInfo->m_info.m_szConnectionDescription); - - NetworkLog(ELogVerbosity::LOG_RELEASE, "[DC] Closing connection not in lobby %lld", plrConnection.m_userID); - SteamNetworkingSockets()->CloseConnection(pInfo->m_hConn, 1000, "Player is not in lobby (Rejected)", false); - - if (TheNetwork != nullptr) - { - TheNetwork->GetConnectionManager()->disconnectPlayer(plrConnection.m_userID); - } + NetworkLog(ELogVerbosity::LOG_RELEASE, "[STEAM NETWORKING][%s] Accepting - Server started signalling, roster not refreshed yet\n", pInfo->m_info.m_szConnectionDescription); } + pMesh->ApplyConnectionTimeouts(pInfo->m_hConn, plrConnection.m_userID); + SteamNetworkingSockets()->AcceptConnection(pInfo->m_hConn); } } @@ -716,12 +726,13 @@ NetworkMesh::NetworkMesh() m_strTurnTokenString = std::format("{},{}", m_strTurnToken.c_str(), m_strTurnToken.c_str()); ServiceConfig& serviceConf = pOnlineServicesMgr->GetServiceConfig(); - m_iceEnable = (g_bForceRelay || serviceConf.relay_all_traffic) + m_reconnectTimeoutMs = MeshConnectionTimeoutPolicy::ClampReconnectTimeout(serviceConf.connection.reconnect_timeout_ms); + m_iceEnable = (g_bForceRelay || serviceConf.connection.relay_only) ? k_nSteamNetworkingConfig_P2P_Transport_ICE_Enable_Relay : k_nSteamNetworkingConfig_P2P_Transport_ICE_Enable_All; // 0 = library default, 1 = native, 2 = WebRTC - m_iceImplementation = (serviceConf.ice_implementation >= 0 && serviceConf.ice_implementation <= 2) ? serviceConf.ice_implementation : 2; + m_iceImplementation = (serviceConf.connection.ice_engine >= 0 && serviceConf.connection.ice_engine <= 2) ? serviceConf.connection.ice_engine : 2; NetworkLog(ELogVerbosity::LOG_RELEASE, "NetworkMesh: using ICE implementation %d (0=default, 1=native, 2=WebRTC)", m_iceImplementation); m_pSignaling = new CSignalingClient(SteamNetworkingSockets()); @@ -743,6 +754,11 @@ NetworkMesh::NetworkMesh() std::vector vecListenOpts; SteamNetworkingConfigValue_t opt; + const auto listenTimeouts = MeshConnectionTimeoutPolicy::ForAttempt(false, m_reconnectTimeoutMs); + opt.SetInt32(k_ESteamNetworkingConfig_TimeoutInitial, listenTimeouts.initialMs); + vecListenOpts.push_back(opt); + opt.SetInt32(k_ESteamNetworkingConfig_TimeoutConnected, listenTimeouts.connectedMs); + vecListenOpts.push_back(opt); opt.SetInt32(k_ESteamNetworkingConfig_SymmetricConnect, 1); vecListenOpts.push_back(opt); opt.SetInt32(k_ESteamNetworkingConfig_P2P_Transport_ICE_Enable, m_iceEnable); @@ -772,7 +788,7 @@ NetworkMesh::NetworkMesh() void NetworkMesh::Flush() { ServiceConfig& serviceConf = NGMP_OnlineServicesManager::GetInstance()->GetServiceConfig(); - bool bDoImmediateFlushPerFrame = serviceConf.network_do_immediate_flush_per_frame; + bool bDoImmediateFlushPerFrame = serviceConf.send.flush_every_frame; if (bDoImmediateFlushPerFrame) { @@ -865,6 +881,17 @@ void NetworkMesh::SendACPacket(uint32_t userID, const void* pData, uint32_t data } } +void NetworkMesh::ApplyConnectionTimeouts(HSteamNetConnection connection, int64_t userID) +{ + auto it = m_mapConnections.find(userID); + const bool retry = (it != m_mapConnections.end() && it->second.m_retryTimeout) + || (TheGameLogic != nullptr && TheGameLogic->isInInternetGame()); + const auto timeouts = MeshConnectionTimeoutPolicy::ForAttempt(retry, m_reconnectTimeoutMs); + const bool initialSet = SteamNetworkingUtils()->SetConnectionConfigValueInt32(connection, k_ESteamNetworkingConfig_TimeoutInitial, timeouts.initialMs); + const bool connectedSet = SteamNetworkingUtils()->SetConnectionConfigValueInt32(connection, k_ESteamNetworkingConfig_TimeoutConnected, timeouts.connectedMs); + NetworkLog(ELogVerbosity::LOG_RELEASE, "[MESH] Incoming timeouts peer=%lld retry=%d initial=%dms connected=%dms applied=%d", userID, retry ? 1 : 0, timeouts.initialMs, timeouts.connectedMs, initialSet && connectedSet ? 1 : 0); +} + void NetworkMesh::StartConnectionSignalling(const char* szMiddlewareID, int64_t remoteUserID, uint16_t preferredPort) { // Thread safety: Lock connection map during access @@ -898,10 +925,12 @@ void NetworkMesh::StartConnectionSignalling(const char* szMiddlewareID, int64_t // if we already have a connection to this use, drop it, having a single-direction connection will break signalling int previousAttempts = 0; + bool retryTimeout = TheGameLogic != nullptr && TheGameLogic->isInInternetGame(); auto it = m_mapConnections.find(remoteUserID); if (it != m_mapConnections.end()) { previousAttempts = it->second.m_SignallingAttempts; + retryTimeout = true; if (it->second.m_hSteamConnection != k_HSteamNetConnection_Invalid) { @@ -952,7 +981,7 @@ void NetworkMesh::StartConnectionSignalling(const char* szMiddlewareID, int64_t int g_nLocalPort = 0; - int g_nVirtualPortRemote = serviceConf.use_mapped_port ? preferredPort : 0; + int g_nVirtualPortRemote = serviceConf.connection.use_mapped_port ? preferredPort : 0; // Our remote and local port don't match, so we need to set it explicitly if (g_nVirtualPortRemote != g_nLocalPort) @@ -987,6 +1016,13 @@ void NetworkMesh::StartConnectionSignalling(const char* szMiddlewareID, int64_t return; } + const auto timeouts = MeshConnectionTimeoutPolicy::ForAttempt(retryTimeout, m_reconnectTimeoutMs); + opt.SetInt32(k_ESteamNetworkingConfig_TimeoutInitial, timeouts.initialMs); + vecOpts.push_back(opt); + opt.SetInt32(k_ESteamNetworkingConfig_TimeoutConnected, timeouts.connectedMs); + vecOpts.push_back(opt); + NetworkLog(ELogVerbosity::LOG_RELEASE, "[MESH] Connection timeouts peer=%lld retry=%d initial=%dms connected=%dms", remoteUserID, retryTimeout ? 1 : 0, timeouts.initialMs, timeouts.connectedMs); + // create a signaling object for this connection SteamNetworkingErrMsg errMsg; ISteamNetworkingConnectionSignaling* pConnSignaling = m_pSignaling->CreateSignalingForConnection(identityRemote, errMsg); @@ -1015,6 +1051,7 @@ void NetworkMesh::StartConnectionSignalling(const char* szMiddlewareID, int64_t // add attempt; carried over so the retry cap holds across re-signals m_mapConnections[remoteUserID].m_SignallingAttempts = previousAttempts + 1; + m_mapConnections[remoteUserID].m_retryTimeout = retryTimeout; } } @@ -1058,6 +1095,18 @@ void NetworkMesh::SetTurnCredentials(const std::string& strUsername, const std:: } +void NetworkMesh::RemoveConnection(int64_t userID) +{ + std::lock_guard lock(m_mapConnectionsMutex); + + auto it = m_mapConnections.find(userID); + if (it != m_mapConnections.end()) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[ERASE 4] Removing user %lld", it->second.m_userID); + m_mapConnections.erase(it); + } +} + void NetworkMesh::DisconnectUser(int64_t remoteUserID) { std::lock_guard lock(m_mapConnectionsMutex); @@ -1196,6 +1245,11 @@ void PlayerConnection::LiteUpdateForAC() } else { + if (!CanRecv()) + { + return; + } + SteamNetworkingMessage_t* pMsg[255] = { nullptr }; int numPackets = Recv(pMsg); @@ -1336,7 +1390,7 @@ int PlayerConnection::SendGamePacket(void* pBuffer, uint32_t totalDataSize) int sendFlags = k_nSteamNetworkingSend_Reliable | k_nSteamNetworkingSend_AutoRestartBrokenSession; // default from last patch ServiceConfig& serviceConf = NGMP_OnlineServicesManager::GetInstance()->GetServiceConfig(); - int netSendFlags = serviceConf.network_send_flags; + int netSendFlags = serviceConf.send.flags; NetworkLog(ELogVerbosity::LOG_DEBUG, "[GAME PACKET] Sending msg of size %ld to user %lld\n", totalDataSize, m_userID); EResult r = SteamNetworkingSockets()->SendMessageToConnection( @@ -1424,7 +1478,7 @@ void PlayerConnection::UpdateLatencyHistogram() if (NGMP_OnlineServicesManager::GetInstance() != nullptr) { ServiceConfig& serviceConf = NGMP_OnlineServicesManager::GetInstance()->GetServiceConfig(); - histogram_duration = serviceConf.network_mesh_histogram_duration; + histogram_duration = serviceConf.connection.latency_history_ms; } diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NextGenTransport.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NextGenTransport.cpp index d6032342218..1a6f634ebbd 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NextGenTransport.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NextGenTransport.cpp @@ -332,6 +332,11 @@ Bool NextGenTransport::doRecv(void) std::map& connections = pMesh->GetAllConnections(); for (auto& kvPair : connections) { + if (!kvPair.second.CanRecv()) + { + continue; + } + SteamNetworkingMessage_t* pMsg[255] = { nullptr }; int numPackets = kvPair.second.Recv(pMsg); diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Auth.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Auth.cpp index 4bdfb583a4b..52d9d32efce 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Auth.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Auth.cpp @@ -9,7 +9,7 @@ #include #include #include -#include +#include "GameNetwork/GeneralsOnline/CredentialStore.h" #include "GameNetwork/GameSpyOverlay.h" #include "../json.hpp" @@ -173,12 +173,15 @@ void NGMP_OnlineServices_AuthInterface::SendMiddlewareToken(std::string strMWTok void NGMP_OnlineServices_AuthInterface::OnRefreshTokenFailed(const char* szReason, const std::string& strBody) { - // the body can hold a rotated refresh token, so release builds log only its size -#if _DEBUG - NetworkLog(ELogVerbosity::LOG_RELEASE, "[AUTH]: Refresh response body: %s", strBody.c_str()); -#else - NetworkLog(ELogVerbosity::LOG_RELEASE, "[AUTH]: Refresh response body was %zu bytes", strBody.size()); -#endif + // the body can hold a rotated refresh token, so only its size is logged unless verbose + if (NGMP_OnlineServicesManager::Settings.Debug_VerboseLogging()) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[AUTH]: Refresh response body: %s", strBody.c_str()); + } + else + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[AUTH]: Refresh response body was %zu bytes", strBody.size()); + } if (m_currentRefreshAttempt < m_maxRefreshAttempts) { @@ -302,6 +305,33 @@ void NGMP_OnlineServices_AuthInterface::RefreshToken() } void NGMP_OnlineServices_AuthInterface::BeginLogin() +{ + auto* manager = NGMP_OnlineServicesManager::GetInstance(); + if (!manager) + { + return; + } + manager->EnsureServiceRoute([](bool success) + { + auto* auth = NGMP_OnlineServicesManager::GetInterface(); + if (!auth) + { + return; + } + if (success) + { + auth->BeginLoginOnSelectedRoute(); + } + else + { + ClearGSMessageBoxes(); + const bool proxyAuth = NGMP_OnlineServicesManager::GetInstance() != nullptr && NGMP_OnlineServicesManager::GetInstance()->RouteFailedProxyAuth(); + GSMessageBoxOk(UnicodeString(L"Connection failed"), UnicodeString(proxyAuth ? L"The proxy did not accept the login. Try again, or check the proxy settings in Options." : L"No trusted service route could be reached. Check Connection settings in Options, then try again."), []() { TheShell->pop(); }); + } + }); +} + +void NGMP_OnlineServices_AuthInterface::BeginLoginOnSelectedRoute() { m_tokenCreationTime = -1; @@ -431,7 +461,10 @@ void NGMP_OnlineServices_AuthInterface::DoFullLoginFlow() m_lastCheckCode = std::chrono::duration_cast(std::chrono::utc_clock::now().time_since_epoch()).count(); m_strCode = authResp.login_code; - NetworkLog(ELogVerbosity::LOG_DEBUG, "Login Code is %s", m_strCode.c_str()); + if (NGMP_OnlineServicesManager::Settings.Debug_VerboseLogging()) + { + NetworkLog(ELogVerbosity::LOG_DEBUG, "Login Code is %s", m_strCode.c_str()); + } #if defined(USE_TEST_ENV) std::string strURI = std::format("https://www.playgenerals.online/login/?gamecode={}&env=test", m_strCode.c_str()); @@ -536,9 +569,10 @@ void NGMP_OnlineServices_AuthInterface::Tick() nlohmann::json jsonObject = nlohmann::json::parse(strBody); AuthResponse authResp = jsonObject.get(); -#if _DEBUG - NetworkLog(ELogVerbosity::LOG_RELEASE, "PageBody: %s", strBody.c_str()); -#endif + if (NGMP_OnlineServicesManager::Settings.Debug_VerboseLogging()) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "PageBody: %s", strBody.c_str()); + } if (authResp.result == EAuthResponseResult::CODE_INVALID) { NetworkLog(ELogVerbosity::LOG_RELEASE, "LOGIN: Code didnt exist, trying again soon"); @@ -613,14 +647,8 @@ void NGMP_OnlineServices_AuthInterface::LogoutOfMyAccount() std::map mapHeaders; NGMP_OnlineServicesManager::GetInstance()->GetHTTPManager()->SendDELETERequest(strURI.c_str(), EIPProtocolVersion::DONT_CARE, mapHeaders, "", nullptr); - // delete local credentials cache - std::string strCredentialsCachePath = GetCredentialsFilePath(); - - std::error_code ec; - if (std::filesystem::exists(strCredentialsCachePath, ec)) - { - std::filesystem::remove(strCredentialsCachePath, ec); - } + // delete the stored login token + ServiceConnection::DeleteRefreshToken(CredentialServerKey()); } void NGMP_OnlineServices_AuthInterface::LoginAsSecondaryDevAccount() @@ -633,55 +661,8 @@ void NGMP_OnlineServices_AuthInterface::SaveCredentials(const char* szRefreshTok m_strRefreshToken = std::string(szRefreshToken); // store the new refresh token, we'll need it for the next refresh m_tokenCreationTime = std::chrono::duration_cast(std::chrono::utc_clock::now().time_since_epoch()).count(); - // store in data dir - nlohmann::json root = { {"refresh_token", szRefreshToken} }; - - std::string strData = root.dump(1); - - // encrypt before touching the file so a failure doesn't truncate the credentials already stored - std::string strFileData; -#if defined(GENERALS_ONLINE_ENCRYPT_CREDENTIALS) - DATA_BLOB inputBlob; - DATA_BLOB outputBlob; - - inputBlob.pbData = (BYTE*)strData.c_str(); - inputBlob.cbData = static_cast(strData.size()); - - if (CryptProtectData(&inputBlob, L"GO Credentials", nullptr, nullptr, nullptr, 0, &outputBlob)) - { - strFileData.assign((const char*)outputBlob.pbData, outputBlob.cbData); - LocalFree(outputBlob.pbData); - } - else - { - NetworkLog(ELogVerbosity::LOG_RELEASE, "[NGMP] Failed to encrypt credentials, keeping the stored ones"); - return; - } -#else - strFileData = strData; -#endif - - // write to a temp file and swap it in so a failed write never leaves truncated credentials - std::string strCredentialsPath = GetCredentialsFilePath(); - std::string strTempPath = strCredentialsPath + ".tmp"; - bool bSaved = false; - FILE* file = fopen(strTempPath.c_str(), "wb"); - if (file) - { - bSaved = fwrite(strFileData.data(), 1, strFileData.size(), file) == strFileData.size(); - if (fclose(file) != 0) - bSaved = false; - } - - if (bSaved) - bSaved = MoveFileExA(strTempPath.c_str(), strCredentialsPath.c_str(), MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH) != 0; - - if (!bSaved) - { - NetworkLog(ELogVerbosity::LOG_RELEASE, "[NGMP] Failed to save credentials"); - std::error_code ec; - std::filesystem::remove(strTempPath, ec); - } + // a failed save keeps the token for this session + ServiceConnection::SaveRefreshToken(CredentialServerKey(), m_strRefreshToken); } bool NGMP_OnlineServices_AuthInterface::GetCredentials() @@ -689,75 +670,18 @@ bool NGMP_OnlineServices_AuthInterface::GetCredentials() #if defined(_DEBUG) && !defined(USE_TEST_ENV) && !defined(USE_DEBUG_ON_LIVE_SERVER) return false; #endif - std::vector vecBytes; - FILE* file = fopen(GetCredentialsFilePath().c_str(), "rb"); - if (file) - { - fseek(file, 0, SEEK_END); - long fileSize = ftell(file); - fseek(file, 0, SEEK_SET); - if (fileSize > 0) - { - vecBytes.resize(fileSize); - fread(vecBytes.data(), 1, fileSize, file); - } - fclose(file); - } - - - if (!vecBytes.empty()) + std::string strToken; + if (!ServiceConnection::LoadRefreshToken(CredentialServerKey(), strToken)) { - // needs decrypt first -#if defined(GENERALS_ONLINE_ENCRYPT_CREDENTIALS) - DATA_BLOB encryptedBlob; - encryptedBlob.pbData = const_cast(vecBytes.data()); - encryptedBlob.cbData = static_cast(vecBytes.size()); - std::string strJSON; - - DATA_BLOB decryptedBlob = { 0 }; - if (CryptUnprotectData(&encryptedBlob, nullptr, nullptr, nullptr, nullptr, 0, &decryptedBlob)) - { - strJSON = std::string((char*)decryptedBlob.pbData, decryptedBlob.cbData); - LocalFree(decryptedBlob.pbData); // Free memory allocated by CryptUnprotectData - } - else - { - // TODO_JWT: Handle failure - } -#else - std::string strJSON = std::string((char*)vecBytes.data(), vecBytes.size()); -#endif - - - nlohmann::json jsonCredentials = nullptr; - - try - { - jsonCredentials = nlohmann::json::parse(strJSON); - - if (jsonCredentials != nullptr) - { - if (jsonCredentials.contains("refresh_token")) - { - m_strRefreshToken = jsonCredentials["refresh_token"]; - - if (m_strRefreshToken.empty()) - { - return false; - } - - return true; - } - } - - } - catch (...) - { - return false; - } + return false; } + m_strRefreshToken = strToken; + return true; +} - return false; +std::string NGMP_OnlineServices_AuthInterface::CredentialServerKey() +{ + return ServiceConnection::CredentialServerKey(NGMP_OnlineServicesManager::Settings.Network_GetConnectionSettings()); } std::string NGMP_OnlineServices_AuthInterface::GetCredentialsFilePath() diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Init.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Init.cpp index cfe0835efd4..6f3f24d9493 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Init.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Init.cpp @@ -17,6 +17,8 @@ #include "WW3D2/dx8wrapper.h" #include #include +#include "GameNetwork/GeneralsOnline/ServiceEndpointPolicy.h" +#include "GameNetwork/GeneralsOnline/ProxyLogin.h" #define STB_IMAGE_WRITE_IMPLEMENTATION #define STB_IMAGE_RESIZE_IMPLEMENTATION @@ -106,7 +108,7 @@ void NGMP_OnlineServicesManager::CaptureScreenshotForProbe(EScreenshotType scree { ServiceConfig& serviceConf = pOnlineServicesMgr->GetServiceConfig(); - if (serviceConf.do_probes) + if (serviceConf.telemetry.screenshot_probes) { CHECK_MAIN_THREAD; @@ -173,31 +175,144 @@ GenOnlineSettings NGMP_OnlineServicesManager::Settings; NGMP_OnlineServicesManager::NGMP_OnlineServicesManager() { NetworkLog(ELogVerbosity::LOG_RELEASE, "[NGMP] Init"); +#if _DEBUG + NetworkLog(ELogVerbosity::LOG_RELEASE, "[NGMP] WARNING: TLS verification is disabled in debug builds"); +#endif m_pOnlineServicesManager = this; } +static const char* ServiceEnvironment() +{ + switch (NGMP_OnlineServicesManager::g_Environment) + { + case NGMP_OnlineServicesManager::EEnvironment::DEV: return "dev"; + case NGMP_OnlineServicesManager::EEnvironment::TEST: return "test"; + default: return "prod"; + } +} + std::string NGMP_OnlineServicesManager::GetAPIEndpoint(const char* szEndpoint) { - if (g_Environment == EEnvironment::DEV) + auto* manager = GetInstance(); + if (manager && !manager->m_serviceBase.empty()) { - return std::format("https://localhost:9000/env/dev/contract/1/{}", szEndpoint); + return manager->m_serviceBase + szEndpoint; } - else if (g_Environment == EEnvironment::TEST) + const auto routes = ServiceConnection::BootstrapRoutes(ServiceEnvironment(), Settings.Network_GetConnectionSettings(), Settings.Network_GetLastRoute()); + return routes.empty() ? std::string() : routes.front() + szEndpoint; +} + +bool NGMP_OnlineServicesManager::IsServiceURLTrusted(const std::string& url) +{ + return ServiceConnection::IsTrustedServiceURL(url, GetAPIEndpoint(""), false); +} + +bool NGMP_OnlineServicesManager::IsWebSocketURLTrusted(const std::string& url) +{ + return ServiceConnection::IsTrustedServiceURL(url, GetAPIEndpoint(""), true); +} + +void NGMP_OnlineServicesManager::EnsureServiceRoute(std::function callback) +{ + // never change the route of an authenticated session behind its caller + if (m_pAuthInterface && m_pAuthInterface->IsLoggedIn()) { - return std::format("https://api.playgenerals.online:2087/env/test/contract/1/{}", szEndpoint); + callback(!m_serviceBase.empty()); + return; } - else // PROD + const auto settings = Settings.Network_GetConnectionSettings(); + const std::string key = settings.serverMode + "|" + settings.serverAddress + "|" + settings.proxyMode + "|" + settings.proxyURL + (settings.windowsLogin ? "|1" : "|0"); + if (key == m_routeSettingsKey && !m_serviceBase.empty() && std::chrono::steady_clock::now() - m_routeValidatedAt < std::chrono::seconds(30)) { - if (NGMP_OnlineServicesManager::Settings.Network_UseAlternativeEndpoint()) - { - return std::format("https://api-ru.playgenerals.online/env/prod/contract/1/{}", szEndpoint); - } - else - { - return std::format("https://api.playgenerals.online/env/prod/contract/1/{}", szEndpoint); - } + callback(true); + return; } + m_routeCallbacks.push_back(std::move(callback)); + if (m_routeCallbacks.size() > 1) + { + return; + } + m_routeSettingsKey = key; + m_routeProxyPrompted = false; + m_routeProxyAuthFailed = false; + m_bootstrapRoutes = ServiceConnection::BootstrapRoutes(ServiceEnvironment(), settings, Settings.Network_GetLastRoute()); + m_bootstrapIndex = 0; + ProbeServiceRoute(); +} + +void NGMP_OnlineServicesManager::FinishServiceRoute(bool success) +{ + auto callbacks = std::move(m_routeCallbacks); + m_routeCallbacks.clear(); + for (auto& callback : callbacks) + { + callback(success); + } +} + +void NGMP_OnlineServicesManager::ProbeServiceRoute() +{ + if (m_bootstrapIndex >= m_bootstrapRoutes.size()) + { + m_serviceBase.clear(); + FinishServiceRoute(false); + return; + } + + // anonymous request: no credentials are sent until a route is validated + m_serviceBase = m_bootstrapRoutes[m_bootstrapIndex]; + const std::string url = m_serviceBase + "VersionManifest"; + std::map headers; + m_pHTTPManager->SendGETRequest(url.c_str(), EIPProtocolVersion::DONT_CARE, headers, + [](bool success, int status, std::string body, HTTPRequest* request) + { + auto* manager = GetInstance(); + if (!manager) + { + return; + } + // the proxy wants a login: ask once per attempt, then probe the same route again + const std::string proxyURL = status == 407 && request != nullptr ? request->GetProxyURL() : std::string(); + if (!proxyURL.empty()) + { + manager->m_routeProxyAuthFailed = true; + if (!manager->m_routeProxyPrompted) + { + manager->m_routeProxyPrompted = true; + ServiceConnection::RequestProxyLogin(proxyURL, Settings.Network_GetConnectionSettings().windowsLogin, [](bool loggedIn) + { + auto* manager = GetInstance(); + if (!manager) + { + return; + } + if (!loggedIn) + { + manager->m_bootstrapIndex = manager->m_bootstrapRoutes.size(); + } + manager->ProbeServiceRoute(); + }); + return; + } + } + if (success && status == 200 && ServiceConnection::IsExpectedVersionManifest(body)) + { + manager->m_routeProxyAuthFailed = false; + manager->m_routeValidatedAt = std::chrono::steady_clock::now(); + if (g_Environment == EEnvironment::PROD && Settings.Network_GetConnectionSettings().serverMode != "custom") + { + Settings.Network_SaveLastRoute(ServiceConnection::RouteHost(manager->m_serviceBase)); + } + NetworkLog(ELogVerbosity::LOG_RELEASE, "[Connection] Trusted service route validated: %s", manager->m_serviceBase.c_str()); + manager->FinishServiceRoute(true); + return; + } + NetworkLog(ELogVerbosity::LOG_RELEASE, "[Connection] Route validation failed (HTTP %d); credentials were not sent", status); + // an access or proxy rejection is not evidence that another route would work + manager->m_bootstrapIndex = (status == 401 || status == 403 || status == 407) ? manager->m_bootstrapRoutes.size() : manager->m_bootstrapIndex + 1; + manager->ProbeServiceRoute(); + }, nullptr, 6000); } void NGMP_OnlineServicesManager::AttemptLoadSteam() @@ -242,7 +357,7 @@ void NGMP_OnlineServicesManager::CommitReplay(AsciiString absoluteReplayPath) { ServiceConfig& serviceConf = pOnlineServicesMgr->GetServiceConfig(); - if (serviceConf.do_replay_upload) + if (serviceConf.telemetry.replay_upload) { NGMP_OnlineServices_LobbyInterface* pLobbyInterface = NGMP_OnlineServicesManager::GetInterface(); const uint64_t matchID = pLobbyInterface == nullptr ? 0 : pLobbyInterface->GetCurrentMatchID(); @@ -349,7 +464,7 @@ void NGMP_OnlineServicesManager::Shutdown() // Shutdown and completely destroy WebSocket BEFORE cleaning up HTTPManager // This is critical because WebSocket has curl handles that must be freed - // before curl_global_cleanup() is called by HTTPManager + // before the HTTP multi handle is destroyed. if (m_pWebSocket) { NetworkLog(ELogVerbosity::LOG_RELEASE, "[NGMP] Shutting down WebSocket..."); @@ -361,7 +476,7 @@ void NGMP_OnlineServicesManager::Shutdown() NetworkLog(ELogVerbosity::LOG_RELEASE, "[NGMP] WebSocket shutdown complete"); } - // Now safe to shutdown HTTP manager which calls curl_global_cleanup() + // cancel remaining HTTP requests if (m_pHTTPManager != nullptr) { NetworkLog(ELogVerbosity::LOG_RELEASE, "[NGMP] Shutting down HTTPManager..."); @@ -375,6 +490,22 @@ void NGMP_OnlineServicesManager::Shutdown() } void NGMP_OnlineServicesManager::StartVersionCheck(std::function fnCallback) +{ + EnsureServiceRoute([fnCallback](bool success) + { + auto* manager = GetInstance(); + if (!manager) + return; + if (!success) + { + fnCallback(false, false); + return; + } + manager->StartVersionCheckOnSelectedRoute(fnCallback); + }); +} + +void NGMP_OnlineServicesManager::StartVersionCheckOnSelectedRoute(std::function fnCallback) { std::string strURI = NGMP_OnlineServicesManager::GetAPIEndpoint("VersionCheck"); @@ -653,8 +784,8 @@ void NGMP_OnlineServicesManager::CaptureScreenshot(bool bResizeForTransmit, std: if (bResizeForTransmit) { ServiceConfig& serviceConf = NGMP_OnlineServicesManager::GetInstance()->GetServiceConfig(); - int new_width = serviceConf.screenshot_width; - int new_height = serviceConf.screenshot_height; + int new_width = serviceConf.telemetry.screenshot_width; + int new_height = serviceConf.telemetry.screenshot_height; int channels = 3; unsigned char* resized = new unsigned char[new_width * new_height * channels]; @@ -865,8 +996,17 @@ void NGMP_OnlineServicesManager::OnLogin(ELoginResult loginResult, const char* s // connect to WS m_pWebSocket = std::make_shared(); - // TODO_NGMP: This should come from the service, if the service was russia-aware - std::string strWebsocketAddr = NGMP_OnlineServicesManager::Settings.Network_UseAlternativeEndpoint() ? "wss://api-ru.playgenerals.online/ws" : std::string(szWSAddr); + const std::string strWebsocketAddr = szWSAddr ? szWSAddr : ""; + if (!IsWebSocketURLTrusted(strWebsocketAddr)) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[Connection] Rejected untrusted service-issued WebSocket route; reauthentication required"); + m_pWebSocket.reset(); + SetPendingFullTeardown(EGOTearDownReason::UNKNOWN); + ClearGSMessageBoxes(); + GSMessageBoxOk(UnicodeString(L"Connection failed"), UnicodeString(L"The service returned an unexpected WebSocket address. Please reconnect; no token was sent to that address."), []() {}); + return; + } + m_pWebSocket->Connect(strWebsocketAddr.c_str(), false, [=]() { @@ -1074,7 +1214,12 @@ void NGMP_OnlineServicesManager::InitSentry() // Initialize libcurl global state here, before any plugins (e.g. EasyAntiCheat) are loaded. // This ensures libcurl's internal mutexes are fully initialized before the EAC plugin // attempts to use them, preventing an access violation in mtx_do_lock on null mutex state. - curl_global_init(CURL_GLOBAL_DEFAULT); + static const CURLcode curlInitialization = curl_global_init(CURL_GLOBAL_DEFAULT); + if (curlInitialization != CURLE_OK) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[Connection] curl initialization failed (%d)", curlInitialization); + return; + } #if !_DEBUG // game paths are in the process code page, sentry's char* API wants UTF-8, so hand it the wide path @@ -1176,6 +1321,7 @@ void WebSocket::Shutdown() // Signal that we're shutting down m_bShuttingDown = true; + m_proxyResolution.reset(); // Disconnect from the websocket (handles the connected case) Disconnect(); @@ -1346,6 +1492,7 @@ void WebSocket::SendData_StartFullMeshConnectivityCheck(std::function(); m_connectivityCheckLobbyID = pLobbyInterface != nullptr ? pLobbyInterface->GetCurrentLobby().lobbyID : -1; + m_connectivityCheckStartMs = std::chrono::duration_cast(std::chrono::system_clock::now().time_since_epoch()).count(); nlohmann::json j; j["msg_id"] = EWebSocketMessageID::FULL_MESH_CONNECTIVITY_CHECK_HOST_REQUESTS_BEGIN; diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.cpp index 4b5b9da9cbc..1fe90774a7b 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.cpp @@ -1,9 +1,11 @@ #include "GameNetwork/GeneralsOnline/NGMP_interfaces.h" #include "GameNetwork/GeneralsOnline/json.hpp" +#include "GameNetwork/GeneralsOnline/JsonHelpers.h" #include "GameNetwork/GeneralsOnline/HTTP/HTTPManager.h" #include "GameNetwork/GeneralsOnline/OnlineServices_Init.h" #include "GameClient/MapUtil.h" #include "GameLogic/GameLogic.h" +#include "GameNetwork/GeneralsOnline/LobbyConnectionPolicy.h" extern void OnKickedFromLobby(); @@ -84,7 +86,7 @@ void NGMP_OnlineServices_LobbyInterface::UpdateCurrentLobby_Map(UnicodeString st ClearAutoReadyCountdown(); #endif - if (TheNGMPGame && TheNGMPGame->IsCountdownStarted()) + if (TheNGMPGame) TheNGMPGame->StopCountdown(); std::string strURI = std::format("{}/{}", NGMP_OnlineServicesManager::GetAPIEndpoint("Lobby"), m_CurrentLobby.lobbyID); @@ -120,7 +122,7 @@ void NGMP_OnlineServices_LobbyInterface::UpdateCurrentLobby_LimitSuperweapons(bo #if !defined(GENERALS_ONLINE_DISABLE_AUTO_ACCEPT) ClearAutoReadyCountdown(); #endif - if (TheNGMPGame && TheNGMPGame->IsCountdownStarted()) + if (TheNGMPGame) TheNGMPGame->StopCountdown(); std::string strURI = std::format("{}/{}", NGMP_OnlineServicesManager::GetAPIEndpoint("Lobby"), m_CurrentLobby.lobbyID); @@ -144,7 +146,7 @@ void NGMP_OnlineServices_LobbyInterface::UpdateCurrentLobby_StartingCash(Unsigne #if !defined(GENERALS_ONLINE_DISABLE_AUTO_ACCEPT) ClearAutoReadyCountdown(); #endif - if (TheNGMPGame && TheNGMPGame->IsCountdownStarted()) + if (TheNGMPGame) TheNGMPGame->StopCountdown(); std::string strURI = std::format("{}/{}", NGMP_OnlineServicesManager::GetAPIEndpoint("Lobby"), m_CurrentLobby.lobbyID); @@ -189,7 +191,7 @@ void NGMP_OnlineServices_LobbyInterface::UpdateCurrentLobby_AISide(int slot, int #if !defined(GENERALS_ONLINE_DISABLE_AUTO_ACCEPT) ClearAutoReadyCountdown(); #endif - if (TheNGMPGame && TheNGMPGame->IsCountdownStarted()) + if (TheNGMPGame) TheNGMPGame->StopCountdown(); std::string strURI = std::format("{}/{}", NGMP_OnlineServicesManager::GetAPIEndpoint("Lobby"), m_CurrentLobby.lobbyID); @@ -215,7 +217,7 @@ void NGMP_OnlineServices_LobbyInterface::UpdateCurrentLobby_AITeam(int slot, int #if !defined(GENERALS_ONLINE_DISABLE_AUTO_ACCEPT) ClearAutoReadyCountdown(); #endif - if (TheNGMPGame && TheNGMPGame->IsCountdownStarted()) + if (TheNGMPGame) TheNGMPGame->StopCountdown(); std::string strURI = std::format("{}/{}", NGMP_OnlineServicesManager::GetAPIEndpoint("Lobby"), m_CurrentLobby.lobbyID); @@ -240,7 +242,7 @@ void NGMP_OnlineServices_LobbyInterface::UpdateCurrentLobby_AIStartPos(int slot, #if !defined(GENERALS_ONLINE_DISABLE_AUTO_ACCEPT) ClearAutoReadyCountdown(); #endif - if (TheNGMPGame && TheNGMPGame->IsCountdownStarted()) + if (TheNGMPGame) TheNGMPGame->StopCountdown(); std::string strURI = std::format("{}/{}", NGMP_OnlineServicesManager::GetAPIEndpoint("Lobby"), m_CurrentLobby.lobbyID); @@ -272,7 +274,7 @@ void NGMP_OnlineServices_LobbyInterface::UpdateCurrentLobbyMaxCameraHeight(uint1 #if !defined(GENERALS_ONLINE_DISABLE_AUTO_ACCEPT) ClearAutoReadyCountdown(); #endif - if (TheNGMPGame && TheNGMPGame->IsCountdownStarted()) + if (TheNGMPGame) TheNGMPGame->StopCountdown(); std::string strURI = std::format("{}/{}", NGMP_OnlineServicesManager::GetAPIEndpoint("Lobby"), m_CurrentLobby.lobbyID); @@ -322,7 +324,7 @@ void NGMP_OnlineServices_LobbyInterface::UpdateCurrentLobby_AIColor(int slot, in #if !defined(GENERALS_ONLINE_DISABLE_AUTO_ACCEPT) ClearAutoReadyCountdown(); #endif - if (TheNGMPGame && TheNGMPGame->IsCountdownStarted()) + if (TheNGMPGame) TheNGMPGame->StopCountdown(); std::string strURI = std::format("{}/{}", NGMP_OnlineServicesManager::GetAPIEndpoint("Lobby"), m_CurrentLobby.lobbyID); @@ -348,7 +350,7 @@ void NGMP_OnlineServices_LobbyInterface::UpdateCurrentLobby_MySide(int side, int #if !defined(GENERALS_ONLINE_DISABLE_AUTO_ACCEPT) ClearAutoReadyCountdown(); #endif - if (TheNGMPGame && TheNGMPGame->IsCountdownStarted()) + if (TheNGMPGame) TheNGMPGame->StopCountdown(); std::string strURI = std::format("{}/{}", NGMP_OnlineServicesManager::GetAPIEndpoint("Lobby"), m_CurrentLobby.lobbyID); @@ -373,7 +375,7 @@ void NGMP_OnlineServices_LobbyInterface::UpdateCurrentLobby_MyColor(int color) #if !defined(GENERALS_ONLINE_DISABLE_AUTO_ACCEPT) ClearAutoReadyCountdown(); #endif - if (TheNGMPGame && TheNGMPGame->IsCountdownStarted()) + if (TheNGMPGame) TheNGMPGame->StopCountdown(); std::string strURI = std::format("{}/{}", NGMP_OnlineServicesManager::GetAPIEndpoint("Lobby"), m_CurrentLobby.lobbyID); @@ -397,7 +399,7 @@ void NGMP_OnlineServices_LobbyInterface::UpdateCurrentLobby_MyStartPos(int start #if !defined(GENERALS_ONLINE_DISABLE_AUTO_ACCEPT) ClearAutoReadyCountdown(); #endif - if (TheNGMPGame && TheNGMPGame->IsCountdownStarted()) + if (TheNGMPGame) TheNGMPGame->StopCountdown(); std::string strURI = std::format("{}/{}", NGMP_OnlineServicesManager::GetAPIEndpoint("Lobby"), m_CurrentLobby.lobbyID); @@ -421,7 +423,7 @@ void NGMP_OnlineServices_LobbyInterface::UpdateCurrentLobby_MyTeam(int team) #if !defined(GENERALS_ONLINE_DISABLE_AUTO_ACCEPT) ClearAutoReadyCountdown(); #endif - if (TheNGMPGame && TheNGMPGame->IsCountdownStarted()) + if (TheNGMPGame) TheNGMPGame->StopCountdown(); std::string strURI = std::format("{}/{}", NGMP_OnlineServicesManager::GetAPIEndpoint("Lobby"), m_CurrentLobby.lobbyID); @@ -517,6 +519,21 @@ NGMP_OnlineServices_LobbyInterface::NGMP_OnlineServices_LobbyInterface() } +// a missing or out of range slot index leaves the member at NO_SLOT_INDEX +static void ParseMemberSlotIndex(const nlohmann::json& memberJson, LobbyMemberEntry& memberEntry) +{ + if (!memberJson.contains("SlotIndex") || !memberJson["SlotIndex"].is_number_integer()) + { + return; + } + + const int64_t slotIndex = memberJson["SlotIndex"].get(); + if (slotIndex >= 0 && slotIndex < MAX_SLOTS) + { + memberEntry.m_SlotIndex = static_cast(slotIndex); + } +} + void NGMP_OnlineServices_LobbyInterface::SearchForLobbies(std::function onStartCallback, std::function)> onCompleteCallback) { const bool lobbyListWasDirty = m_bLobbyListDirty.exchange(false); @@ -552,7 +569,7 @@ void NGMP_OnlineServices_LobbyInterface::SearchForLobbies(std::function std::vector vecLatencies; std::map mapPlayerLatencies; - jsonObject["latencies"].get_to(vecLatencies); + JsonGetOptional(jsonObject, "latencies", vecLatencies); // player latencies for (const auto& playerLatencyEntryIter : jsonObject["playerlatencies"]) @@ -560,8 +577,8 @@ void NGMP_OnlineServices_LobbyInterface::SearchForLobbies(std::function int64_t user_id = -1; int latency = -1; - playerLatencyEntryIter["user_id"].get_to(user_id); - playerLatencyEntryIter["latency"].get_to(latency); + JsonGetOptional(playerLatencyEntryIter, "user_id", user_id); + JsonGetOptional(playerLatencyEntryIter, "latency", latency); if (user_id != -1 && latency != -1) { @@ -572,41 +589,43 @@ void NGMP_OnlineServices_LobbyInterface::SearchForLobbies(std::function int latencyIndex = 0; for (const auto& lobbyEntryIter : jsonObject["lobbies"]) { + // latencies are indexed by the server's lobby order, so count skipped entries too + const size_t entryLatencyIndex = latencyIndex++; + try + { LobbyEntry lobbyEntry; - lobbyEntryIter["LobbyID"].get_to(lobbyEntry.lobbyID); - lobbyEntryIter["Owner"].get_to(lobbyEntry.owner); - lobbyEntryIter["Name"].get_to(lobbyEntry.name); - lobbyEntryIter["MapName"].get_to(lobbyEntry.map_name); - lobbyEntryIter["MapPath"].get_to(lobbyEntry.map_path); + JsonGetRequired(lobbyEntryIter, "LobbyID", lobbyEntry.lobbyID); + JsonGetRequired(lobbyEntryIter, "Owner", lobbyEntry.owner); + JsonGetOptional(lobbyEntryIter, "Name", lobbyEntry.name); + JsonGetOptional(lobbyEntryIter, "MapName", lobbyEntry.map_name); + JsonGetOptional(lobbyEntryIter, "MapPath", lobbyEntry.map_path); lobbyEntry.map_path = utf8_to_local(lobbyEntry.map_path); // local file path from here on - lobbyEntryIter["IsMapOfficial"].get_to(lobbyEntry.map_official); - lobbyEntryIter["NumCurrentPlayers"].get_to(lobbyEntry.current_players); - lobbyEntryIter["MaxPlayers"].get_to(lobbyEntry.max_players); - lobbyEntryIter["IsVanillaTeamsOnly"].get_to(lobbyEntry.vanilla_teams); - lobbyEntryIter["StartingCash"].get_to(lobbyEntry.starting_cash); - lobbyEntryIter["IsLimitSuperweapons"].get_to(lobbyEntry.limit_superweapons); - lobbyEntryIter["IsTrackingStats"].get_to(lobbyEntry.track_stats); - lobbyEntryIter["IsPassworded"].get_to(lobbyEntry.passworded); - lobbyEntryIter["AllowObservers"].get_to(lobbyEntry.allow_observers); - lobbyEntryIter["MaximumCameraHeight"].get_to(lobbyEntry.max_cam_height); - lobbyEntryIter["ExeCRC"].get_to(lobbyEntry.exe_crc); - lobbyEntryIter["IniCRC"].get_to(lobbyEntry.ini_crc); - lobbyEntryIter["MatchID"].get_to(lobbyEntry.match_id); - lobbyEntryIter["LobbyType"].get_to(lobbyEntry.lobby_type); - lobbyEntryIter["Region"].get_to(lobbyEntry.region); + JsonGetOptional(lobbyEntryIter, "IsMapOfficial", lobbyEntry.map_official); + JsonGetOptional(lobbyEntryIter, "NumCurrentPlayers", lobbyEntry.current_players); + JsonGetOptional(lobbyEntryIter, "MaxPlayers", lobbyEntry.max_players); + JsonGetOptional(lobbyEntryIter, "IsVanillaTeamsOnly", lobbyEntry.vanilla_teams); + JsonGetOptional(lobbyEntryIter, "StartingCash", lobbyEntry.starting_cash); + JsonGetOptional(lobbyEntryIter, "IsLimitSuperweapons", lobbyEntry.limit_superweapons); + JsonGetOptional(lobbyEntryIter, "IsTrackingStats", lobbyEntry.track_stats); + JsonGetOptional(lobbyEntryIter, "IsPassworded", lobbyEntry.passworded); + JsonGetOptional(lobbyEntryIter, "AllowObservers", lobbyEntry.allow_observers); + JsonGetOptional(lobbyEntryIter, "MaximumCameraHeight", lobbyEntry.max_cam_height); + JsonGetOptional(lobbyEntryIter, "ExeCRC", lobbyEntry.exe_crc); + JsonGetOptional(lobbyEntryIter, "IniCRC", lobbyEntry.ini_crc); + JsonGetOptional(lobbyEntryIter, "MatchID", lobbyEntry.match_id); + JsonGetOptional(lobbyEntryIter, "LobbyType", lobbyEntry.lobby_type); + JsonGetOptional(lobbyEntryIter, "Region", lobbyEntry.region); // attach latency - if (latencyIndex < vecLatencies.size()) + if (entryLatencyIndex < vecLatencies.size()) { - lobbyEntry.latency = vecLatencies[latencyIndex]; + lobbyEntry.latency = vecLatencies[entryLatencyIndex]; } else { // dummy value lobbyEntry.latency = 9001; } - ++latencyIndex; - // correct map path if (lobbyEntry.map_official) { @@ -620,16 +639,16 @@ void NGMP_OnlineServices_LobbyInterface::SearchForLobbies(std::function // NOTE: These fields won't be present becauase they're private properties //memberEntryIter["enc_key"].get_to(strEncKey); - for (const auto& memberEntryIter : lobbyEntryIter["Members"]) + for (const auto& memberEntryIter : lobbyEntryIter.at("Members")) { LobbyMemberEntry memberEntry; - memberEntryIter["UserID"].get_to(memberEntry.user_id); - memberEntryIter["DisplayName"].get_to(memberEntry.display_name); - memberEntryIter["IsReady"].get_to(memberEntry.m_bIsReady); - memberEntryIter["SlotIndex"].get_to(memberEntry.m_SlotIndex); - memberEntryIter["SlotState"].get_to(memberEntry.m_SlotState); - memberEntryIter["Region"].get_to(memberEntry.region); + JsonGetRequired(memberEntryIter, "UserID", memberEntry.user_id); + JsonGetOptional(memberEntryIter, "DisplayName", memberEntry.display_name); + JsonGetOptional(memberEntryIter, "IsReady", memberEntry.m_bIsReady); + ParseMemberSlotIndex(memberEntryIter, memberEntry); + JsonGetOptional(memberEntryIter, "SlotState", memberEntry.m_SlotState); + JsonGetOptional(memberEntryIter, "Region", memberEntry.region); // store latency if (mapPlayerLatencies.contains(memberEntry.user_id)) @@ -645,6 +664,11 @@ void NGMP_OnlineServices_LobbyInterface::SearchForLobbies(std::function } parsedLobbies.push_back(std::move(lobbyEntry)); + } + catch (...) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[NGMP] Skipping malformed lobby list entry"); + } } m_vecLobbies = std::move(parsedLobbies); bSearchSucceeded = true; @@ -670,6 +694,18 @@ void NGMP_OnlineServices_LobbyInterface::SearchForLobbies(std::function }); } +void NGMP_OnlineServices_LobbyInterface::RequestRosterRefresh() +{ + const int64_t nowMs = std::chrono::duration_cast(std::chrono::steady_clock::now().time_since_epoch()).count(); + if (nowMs - m_lastRosterRefreshRequestMs < 2000) + { + return; + } + + m_lastRosterRefreshRequestMs = nowMs; + UpdateRoomDataCache(); +} + void NGMP_OnlineServices_LobbyInterface::ResetJoinOrder() { m_setMembersBeforeUs.clear(); @@ -772,13 +808,13 @@ void NGMP_OnlineServices_LobbyInterface::Tick() m_pLobbyMesh->Tick(); } + // stays pending until a menu has registered to handle it if (m_bCannotConnectToLobbyPending) { - m_bCannotConnectToLobbyPending = false; - auto callbackCopy = m_OnCannotConnectToLobbyCallback; if (callbackCopy != nullptr) { + m_bCannotConnectToLobbyPending = false; callbackCopy(); } } @@ -804,7 +840,7 @@ void NGMP_OnlineServices_LobbyInterface::Tick() // TODO_NGMP: Don't do this clientside... UpdateCurrentLobby_ForceReady(); ClearAutoReadyCountdown(); - if (TheNGMPGame && TheNGMPGame->IsCountdownStarted()) + if (TheNGMPGame) TheNGMPGame->StopCountdown(); } } @@ -869,6 +905,7 @@ void NGMP_OnlineServices_LobbyInterface::UpdateRoomDataCache(std::function mapHeaders; @@ -880,11 +917,20 @@ void NGMP_OnlineServices_LobbyInterface::UpdateRoomDataCache(std::functionisQMGame() || m_CurrentLobby.lobby_type == ELobbyType::QuickMatch) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[NGMP] Quick match lobby lookup returned 404, tearing down locally only"); + ResetForMatchmakingRequeue(); + return; + } + LeaveCurrentLobby(); + + // after leaving, which clears it, so the setup menu backs out + m_bPendingHostHasLeft = true; return; } @@ -910,28 +967,28 @@ void NGMP_OnlineServices_LobbyInterface::UpdateRoomDataCache(std::functionStopCountdown(); } + // a player who joined after the connectivity check passed hasn't been checked, so the host must re-run it + if (IsHost() + && TheNGMPGame != nullptr + && TheNGMPGame->IsCountdownStarted() + && lobbyEntry.lobby_type != ELobbyType::QuickMatch + && !TheNGMPGame->isQMGame()) + { + bool bPlayerJoined = false; + for (const LobbyMemberEntry& newMember : lobbyEntry.members) + { + if (!newMember.IsHuman()) + { + continue; + } + + bool bWasInLobby = false; + for (const LobbyMemberEntry& oldMember : m_CurrentLobby.members) + { + if (oldMember.IsHuman() && oldMember.user_id == newMember.user_id) + { + bWasInLobby = true; + break; + } + } + + if (!bWasInLobby) + { + bPlayerJoined = true; + break; + } + } + + if (bPlayerJoined) + { + TheNGMPGame->StopCountdown(); + + UnicodeString strInform = UnicodeString(L"Countdown stopped: a player joined, start again to re-check connections."); + SendAnnouncementMessageToCurrentLobby(strInform, true); + } + } + // store m_CurrentLobby = lobbyEntry; RecordJoinOrder(lobbyEntry.members); @@ -1133,12 +1231,12 @@ void NGMP_OnlineServices_LobbyInterface::UpdateRoomDataCache(std::functionGetAndParseServiceConfig([=]() { @@ -1357,6 +1456,141 @@ void NGMP_OnlineServices_LobbyInterface::JoinLobby(LobbyEntry lobbyInfo, std::st }); }); + + return true; +} + +std::vector NGMP_OnlineServices_LobbyInterface::GetHumanLobbyMembers() const +{ + std::vector members; + for (const auto& member : m_CurrentLobby.members) + { + if (member.IsHuman()) + { + members.push_back(member.user_id); + } + } + std::sort(members.begin(), members.end()); + return members; +} + +std::string NGMP_OnlineServices_LobbyInterface::GetLobbyStartSettings() const +{ + nlohmann::json settings = {m_CurrentLobby.owner, m_CurrentLobby.map_path, m_CurrentLobby.map_official, + m_CurrentLobby.starting_cash, m_CurrentLobby.limit_superweapons, m_CurrentLobby.max_cam_height, + m_CurrentLobby.vanilla_teams, m_CurrentLobby.track_stats, m_CurrentLobby.allow_observers, m_CurrentLobby.rng_seed}; + auto members = m_CurrentLobby.members; + std::sort(members.begin(), members.end(), [](const auto& a, const auto& b) { return a.m_SlotIndex < b.m_SlotIndex; }); + for (const auto& member : members) + { + if (member.m_SlotState == SlotState::SLOT_OPEN || member.m_SlotState == SlotState::SLOT_CLOSED) + { + continue; + } + settings.push_back({member.user_id, member.m_SlotIndex, member.m_SlotState, member.m_bIsReady, + member.side, member.color, member.team, member.startpos, member.has_map}); + } + return settings.dump(); +} + +bool NGMP_OnlineServices_LobbyInterface::IsCurrentLobbyAttempt(int64_t lobbyID, uint64_t generation, const std::vector& members) const +{ + return LobbyConnectionPolicy::IsCurrentAttempt(lobbyID, generation, members, m_CurrentLobby.lobbyID, + GetLobbyGeneration(), GetHumanLobbyMembers()); +} + +bool NGMP_OnlineServices_LobbyInterface::AreLocalLobbyPeersConnected() +{ + auto* auth = NGMP_OnlineServicesManager::GetInterface(); + if (!IsInLobby() || auth == nullptr || m_pLobbyMesh == nullptr) + { + return false; + } + + bool foundSelf = false; + for (const auto& member : m_CurrentLobby.members) + { + if (!member.IsHuman()) + { + continue; + } + if (member.user_id == auth->GetUserID()) + { + foundSelf = true; + continue; + } + auto* connection = m_pLobbyMesh->GetConnectionForUser(member.user_id); + if (connection == nullptr || connection->GetState() != EConnectionState::CONNECTED_DIRECT) + { + return false; + } + } + return foundSelf; +} + +bool NGMP_OnlineServices_LobbyInterface::HasFailedLobbyPeers() +{ + auto* auth = NGMP_OnlineServicesManager::GetInterface(); + if (m_CurrentLobby.lobby_type != ELobbyType::CustomGame || auth == nullptr || !IsInLobby()) + { + return false; + } + + for (const auto& member : m_CurrentLobby.members) + { + if (!member.IsHuman() || member.user_id == auth->GetUserID()) + { + continue; + } + auto* connection = m_pLobbyMesh != nullptr ? m_pLobbyMesh->GetConnectionForUser(member.user_id) : nullptr; + if (connection == nullptr || connection->GetState() == EConnectionState::NOT_CONNECTED + || connection->GetState() == EConnectionState::CONNECTION_FAILED + || connection->GetState() == EConnectionState::CONNECTION_DISCONNECTED) + { + return true; + } + } + return false; +} + +int NGMP_OnlineServices_LobbyInterface::RetryFailedLobbyPeers() +{ + auto* auth = NGMP_OnlineServicesManager::GetInterface(); + auto ws = NGMP_OnlineServicesManager::GetWebSocket(); + if (auth == nullptr || ws == nullptr || !ws->IsConnected() || m_pLobbyMesh == nullptr || !IsInLobby()) + { + return 0; + } + + const bool inMatch = TheNGMPGame != nullptr && TheNGMPGame->isGameInProgress(); + const int64_t now = std::chrono::duration_cast(std::chrono::steady_clock::now().time_since_epoch()).count(); + int retries = 0; + for (const auto& member : m_CurrentLobby.members) + { + if (!member.IsHuman()) + { + continue; + } + auto* connection = m_pLobbyMesh->GetConnectionForUser(member.user_id); + const auto state = connection != nullptr ? connection->GetState() : EConnectionState::NOT_CONNECTED; + if (!LobbyConnectionPolicy::CanRetry(m_CurrentLobby.lobby_type == ELobbyType::CustomGame, inMatch, true, + member.user_id == auth->GetUserID(), state == EConnectionState::CONNECTED_DIRECT, + state == EConnectionState::CONNECTING_DIRECT || state == EConnectionState::FINDING_ROUTE, + connection != nullptr ? now - connection->m_connectStartedMs : 1000)) + { + continue; + } + + auto& target = m_pLobbyMesh->GetAllConnections()[member.user_id]; + target.m_userID = member.user_id; + target.m_SignallingAttempts = 0; + target.m_retryTimeout = true; + target.m_connectStartedMs = now; + ws->SendData_RequestSignalling(member.user_id); + NetworkLog(ELogVerbosity::LOG_RELEASE, "[LOBBY] Manual retry requested lobby=%lld peer=%lld", m_CurrentLobby.lobbyID, member.user_id); + ++retries; + } + return retries; } void NGMP_OnlineServices_LobbyInterface::LeaveCurrentLobby() @@ -1375,6 +1609,7 @@ void NGMP_OnlineServices_LobbyInterface::LeaveCurrentLobby() if (pWS != nullptr) { pWS->ClearConnectivityCheckCallback(); + pWS->ClearPendingSignals(); } // reset host migration flags @@ -1425,6 +1660,12 @@ void NGMP_OnlineServices_LobbyInterface::ResetForMatchmakingRequeue() ResetHostMigrationFlags(); AnticheatPlugInterface::EndSession(); + std::shared_ptr pWS = NGMP_OnlineServicesManager::GetWebSocket(); + if (pWS != nullptr) + { + pWS->ClearPendingSignals(); + } + if (m_pLobbyMesh != nullptr) { m_pLobbyMesh->Disconnect(); @@ -1491,6 +1732,7 @@ void NGMP_OnlineServices_LobbyInterface::CreateLobby(UnicodeString strLobbyName, { m_CurrentLobby = LobbyEntry(); ResetJoinOrder(); + ResetHostMigrationFlags(); m_bJoinOrderKnown = true; std::string strURI = NGMP_OnlineServicesManager::GetAPIEndpoint("Lobbies"); std::map mapHeaders; @@ -1613,6 +1855,10 @@ void NGMP_OnlineServices_LobbyInterface::CreateLobby(UnicodeString strLobbyName, me.display_name = pAuthInterface->GetDisplayName(); me.m_bIsReady = true; // host is always ready + // the host always takes the first slot + me.m_SlotIndex = 0; + me.m_SlotState = SlotState::SLOT_PLAYER; + // TODO_NGMP: Remove this and just hardcode it or provide from service me.preferredPort = 0; diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_MatchmakingInterface.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_MatchmakingInterface.cpp index 374120d9a46..bf4605a1947 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_MatchmakingInterface.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_MatchmakingInterface.cpp @@ -1,5 +1,6 @@ #include "GameNetwork/GeneralsOnline/NGMP_interfaces.h" #include "GameNetwork/GeneralsOnline/json.hpp" +#include "GameNetwork/GeneralsOnline/JsonHelpers.h" #include "GameNetwork/GeneralsOnline/HTTP/HTTPManager.h" #include "GameNetwork/GeneralsOnline/OnlineServices_Init.h" @@ -23,31 +24,38 @@ void NGMP_OnlineServices_MatchmakingInterface::RetrievePlaylists(std::function vecPlaylists; for (const auto& playlistEntryIter : jsonObject["playlists"]) { + try + { PlaylistEntry playlistEntry; - playlistEntryIter["PlaylistID"].get_to(playlistEntry.PlaylistID); - playlistEntryIter["Name"].get_to(playlistEntry.Name); - playlistEntryIter["MinPlayers"].get_to(playlistEntry.MinPlayers); - playlistEntryIter["DesiredPlayers"].get_to(playlistEntry.DesiredPlayers); - playlistEntryIter["MinSelectedMaps"].get_to(playlistEntry.MinSelectedMaps); - playlistEntryIter["AllowTeams"].get_to(playlistEntry.AllowTeams); - playlistEntryIter["TeamSize"].get_to(playlistEntry.TeamSize); - playlistEntryIter["AllowArmySelection"].get_to(playlistEntry.AllowArmySelection); - playlistEntryIter["GracePeriodAtMinPlayersMSec"].get_to(playlistEntry.GracePeriodAtMinPlayersMSec); + JsonGetRequired(playlistEntryIter, "PlaylistID", playlistEntry.PlaylistID); + JsonGetOptional(playlistEntryIter, "Name", playlistEntry.Name); + JsonGetOptional(playlistEntryIter, "MinPlayers", playlistEntry.MinPlayers); + JsonGetOptional(playlistEntryIter, "DesiredPlayers", playlistEntry.DesiredPlayers); + JsonGetOptional(playlistEntryIter, "MinSelectedMaps", playlistEntry.MinSelectedMaps); + JsonGetOptional(playlistEntryIter, "AllowTeams", playlistEntry.AllowTeams); + JsonGetOptional(playlistEntryIter, "TeamSize", playlistEntry.TeamSize); + JsonGetOptional(playlistEntryIter, "AllowArmySelection", playlistEntry.AllowArmySelection); + JsonGetOptional(playlistEntryIter, "GracePeriodAtMinPlayersMSec", playlistEntry.GracePeriodAtMinPlayersMSec); // maps - for (const auto& mapEntryIter : playlistEntryIter["Maps"]) + for (const auto& mapEntryIter : playlistEntryIter.at("Maps")) { PlaylistMapEntry mapEntry; - mapEntryIter["Name"].get_to(mapEntry.Name); - mapEntryIter["Path"].get_to(mapEntry.Path); + JsonGetOptional(mapEntryIter, "Name", mapEntry.Name); + JsonGetRequired(mapEntryIter, "Path", mapEntry.Path); mapEntry.Path = utf8_to_local(mapEntry.Path); // used as a local file path - mapEntryIter["Custom"].get_to(mapEntry.Custom); + JsonGetOptional(mapEntryIter, "Custom", mapEntry.Custom); playlistEntry.Maps.push_back(mapEntry); } vecPlaylists.push_back(playlistEntry); + } + catch (...) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[NGMP] Skipping malformed playlist entry"); + } } // cache diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_RoomsInterface.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_RoomsInterface.cpp index 07e13867223..3973fd02f3f 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_RoomsInterface.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_RoomsInterface.cpp @@ -1,4 +1,6 @@ +#include "GameNetwork/GeneralsOnline/ProxyLogin.h" #include +#include "GameNetwork/GeneralsOnline/ServiceEndpointPolicy.h" #include "GameNetwork/GeneralsOnline/NGMP_interfaces.h" #include "GameNetwork/GeneralsOnline/NGMP_include.h" #include "GameNetwork/GeneralsOnline/NetworkPacket.h" @@ -9,10 +11,10 @@ #include "../HTTP/HTTPManager.h" #include "GameNetwork/GameSpy/PeerDefs.h" -// one clock for every websocket timestamp; utc_clock also loads the time zone database on MSVC +// steady clock, so deadlines do not move with system clock changes static int64_t NowMs() { - return std::chrono::duration_cast(std::chrono::system_clock::now().time_since_epoch()).count(); + return std::chrono::duration_cast(std::chrono::steady_clock::now().time_since_epoch()).count(); } // ----------------------------- @@ -138,103 +140,169 @@ WebSocket::~WebSocket() int WebSocket::Ping() { - size_t sent; - CURLcode result = curl_ws_send(m_pCurlWS, "wsping", strlen("wsping"), &sent, 0, - CURLWS_PING); + if (!m_bConnected) + { + return CURLE_AGAIN; + } + // keep the app heartbeat for older services, no raw PING, at most one queued under backpressure nlohmann::json j; j["msg_id"] = EWebSocketMessageID::PING; - std::string strBody = j.dump(); - - Send(strBody.c_str()); - - return (int)result; + QueueOutbound(j.dump(), true); + return CURLE_OK; } - void WebSocket::Connect(const char* url, bool bIsReconnect, std::function fnWebsocketConnectedCallback) { - if (m_bConnected) - { + if (m_bConnected || m_bShuttingDown) return; - } - + // Copy before replacing the stored address: reconnect passes its c_str(). + const std::string destination(url); + m_strWebsocketAddr = destination; + m_fnWebsocketConnectedCallback = fnWebsocketConnectedCallback; + m_connectAsReconnect = bIsReconnect; m_lastPong = NowMs(); - - // TODO_CACHE: Cleanup multi too + m_proxySetupStarted = NowMs(); + m_proxyResolution.reset(); + m_proxyPolicy = {}; + m_proxyRoute = 0; + m_proxyPrompted = false; if (m_pCurlWS != nullptr) { - // remove from multi before cleanup (required by libcurl) - if (m_pMulti != nullptr) - { - curl_multi_remove_handle(m_pMulti, m_pCurlWS); - } - // cleanup - curl_easy_cleanup(m_pCurlWS); - m_pCurlWS = nullptr; + curl_multi_remove_handle(m_pMulti, m_pCurlWS); + curl_easy_cleanup(m_pCurlWS); + m_pCurlWS = nullptr; } - - // Free old headers before creating new ones - if (m_pHeaders != nullptr) + curl_slist_free_all(m_pHeaders); + m_pHeaders = nullptr; + if (!NGMP_OnlineServicesManager::IsWebSocketURLTrusted(destination)) { - curl_slist_free_all(m_pHeaders); - m_pHeaders = nullptr; + NetworkLog(ELogVerbosity::LOG_RELEASE, "[WebSocket] Refused untrusted service destination"); + FailConnectionSetup(); + return; } + m_proxyResolution = ServiceConnection::StartProxyResolution( + NGMP_OnlineServicesManager::Settings.Network_GetConnectionSettings(), destination); +} - m_pCurlWS = curl_easy_init(); - - if (m_pCurlWS != nullptr) +// the lobby connection is a visible flow: a proxy that wants a login gets one prompt, then the handshake runs again +void WebSocket::ResumeAfterProxyLogin(bool loggedIn) +{ + if (m_bShuttingDown || m_bConnected) { - m_fnWebsocketConnectedCallback = fnWebsocketConnectedCallback; - - int httpResponseCode = -1; - m_strWebsocketAddr = std::string(url); - curl_easy_setopt(m_pCurlWS, CURLOPT_URL, url); - - curl_easy_getinfo(m_pCurlWS, CURLINFO_RESPONSE_CODE, &httpResponseCode); - - curl_easy_setopt(m_pCurlWS, CURLOPT_CONNECT_ONLY, 2L); /* websocket style */ - - // HTTP v1 seems to have a higher success rate of bypassing DPI - curl_easy_setopt(m_pCurlWS, CURLOPT_HTTP_VERSION, NGMP_OnlineServicesManager::Settings.Network_GetHTTPVersionForCurl()); - -#if _DEBUG - curl_easy_setopt(m_pCurlWS, CURLOPT_SSL_VERIFYPEER, 0); - curl_easy_setopt(m_pCurlWS, CURLOPT_SSL_VERIFYHOST, 0); - - curl_easy_setopt(m_pCurlWS, CURLOPT_VERBOSE, 1L); -#else - // Use the OS cert store (needed since we build curl against OpenSSL, not schannel). - curl_easy_setopt(m_pCurlWS, CURLOPT_SSL_OPTIONS, CURLSSLOPT_NATIVE_CA); - curl_easy_setopt(m_pCurlWS, CURLOPT_SSL_VERIFYPEER, 1L); - curl_easy_setopt(m_pCurlWS, CURLOPT_SSL_VERIFYHOST, 2L); -#endif + return; + } + if (!loggedIn) + { + FailConnectionSetup(); + return; + } + m_proxySetupStarted = NowMs(); + StartResolvedConnection(); +} +void WebSocket::FailConnectionSetup() +{ + m_proxyResolution.reset(); + m_bConnected = false; + m_vecWSPartialBuffer.clear(); + if (m_bReconnecting) + { + m_reconnectAttemptStarted = -1; + m_nextReconnectAttempt = NowMs() + ComputeReconnectDelay(); + } + else + { + NGMP_OnlineServicesManager::GetInstance()->SetPendingFullTeardown(EGOTearDownReason::LOST_CONNECTION); + } +} - // ws needs auth - NGMP_OnlineServices_AuthInterface* pAuthInterface = NGMP_OnlineServicesManager::GetInterface(); - if (pAuthInterface == nullptr) +void WebSocket::PollConnectionSetup() +{ + if (!m_proxyResolution) + return; + if (NowMs() - m_proxySetupStarted >= m_reconnectAttemptTimeout) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[WebSocket] Proxy policy failed: phase=proxy-timeout native_code=0"); + FailConnectionSetup(); + return; + } + if (!m_proxyResolution->ready.load(std::memory_order_acquire)) + return; + m_proxyPolicy = std::move(m_proxyResolution->result); + m_proxyResolution.reset(); + if (!m_proxyPolicy.error.empty() || m_proxyPolicy.routes.empty()) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[WebSocket] Proxy policy failed: phase=%s native_code=%lu detail=%s", + m_proxyPolicy.failurePhase.empty() ? "proxy-policy" : m_proxyPolicy.failurePhase.c_str(), + m_proxyPolicy.nativeError, m_proxyPolicy.error.c_str()); + FailConnectionSetup(); + return; + } + if (ServiceConnection::ShouldLogProxyPolicy(true, m_proxyPolicy)) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[WebSocket] Proxy route policy: %zu route(s), first=%s", + m_proxyPolicy.routes.size(), m_proxyPolicy.routes.front().direct ? "direct" : m_proxyPolicy.routes.front().url.c_str()); + if (!m_proxyPolicy.fallbackReason.empty()) { - curl_easy_cleanup(m_pCurlWS); - m_pCurlWS = nullptr; - return; + NetworkLog(ELogVerbosity::LOG_RELEASE, "[WebSocket] Proxy policy fallback: native_code=%lu detail=%s", + m_proxyPolicy.nativeError, m_proxyPolicy.fallbackReason.c_str()); } + } + StartResolvedConnection(); +} - char szHeaderBuffer[8192] = { 0 }; - sprintf_s(szHeaderBuffer, "Authorization: Bearer %s", pAuthInterface->GetAuthToken().c_str()); - m_pHeaders = curl_slist_append(m_pHeaders, szHeaderBuffer); - - sprintf_s(szHeaderBuffer, "is-reconnect: %s", bIsReconnect ? "true": "false"); - m_pHeaders = curl_slist_append(m_pHeaders, szHeaderBuffer); - - curl_easy_setopt(m_pCurlWS, CURLOPT_HTTPHEADER, m_pHeaders); - - //curl_easy_setopt(m_pCurl, CURLOPT_TIMEOUT_MS, 1000); - - /* Perform the request, res gets the return code */ - //CURLcode res = curl_easy_perform(m_pCurl); - curl_multi_add_handle(m_pMulti, m_pCurlWS); +void WebSocket::StartResolvedConnection() +{ + if (m_pCurlWS != nullptr) + { + curl_multi_remove_handle(m_pMulti, m_pCurlWS); + curl_easy_cleanup(m_pCurlWS); } + curl_slist_free_all(m_pHeaders); + m_pHeaders = nullptr; + m_pCurlWS = curl_easy_init(); + if (m_proxyRoute > 0) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[WebSocket] Route %zu: %s", m_proxyRoute + 1, m_proxyPolicy.routes[m_proxyRoute].direct ? "direct" : m_proxyPolicy.routes[m_proxyRoute].url.c_str()); + } + std::string error; + auto* auth = NGMP_OnlineServicesManager::GetInterface(); + if (m_pCurlWS == nullptr || auth == nullptr || !auth->IsLoggedIn() || !NGMP_OnlineServicesManager::IsWebSocketURLTrusted(m_strWebsocketAddr) + || !ServiceConnection::ConfigureCurl(m_pCurlWS, m_strWebsocketAddr, m_proxyPolicy.routes[m_proxyRoute], error)) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[WebSocket] Connection policy: %s", error.c_str()); + FailConnectionSetup(); + return; + } + const std::string authorization = "Authorization: Bearer " + auth->GetAuthToken(); + m_pHeaders = curl_slist_append(nullptr, authorization.c_str()); + if (m_pHeaders == nullptr) + { + FailConnectionSetup(); + return; + } + curl_slist* updated = curl_slist_append(m_pHeaders, m_connectAsReconnect ? "is-reconnect: true" : "is-reconnect: false"); + if (updated == nullptr) + { + FailConnectionSetup(); + return; + } + m_pHeaders = updated; + CURLcode result = CURLE_OK; + auto option = [&](CURLoption key, auto value) { + if (result == CURLE_OK) + result = curl_easy_setopt(m_pCurlWS, key, value); + }; + option(CURLOPT_URL, m_strWebsocketAddr.c_str()); + option(CURLOPT_CONNECT_ONLY, 2L); + option(CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_1_1); + const long remaining = static_cast(std::max(1, m_reconnectAttemptTimeout - (NowMs() - m_proxySetupStarted))); + option(CURLOPT_CONNECTTIMEOUT_MS, remaining); + option(CURLOPT_TIMEOUT_MS, remaining); + option(CURLOPT_HTTPHEADER, m_pHeaders); + if (result != CURLE_OK || curl_multi_add_handle(m_pMulti, m_pCurlWS) != CURLM_OK) + FailConnectionSetup(); } void WebSocket::SendData_RoomChatMessage(UnicodeString& msg, bool bIsAction) @@ -275,34 +343,24 @@ void WebSocket::SendData_JoinNetworkRoom(int roomID, uint64_t requestID) void WebSocket::Disconnect() { + m_proxyResolution.reset(); + EndReconnect(); if (!m_bConnected) { + ClearOutbound(); return; } if (m_pCurlWS != nullptr) { - // best-effort flush of anything queued since the last Tick() before closing - std::vector outboundBatch; - { - std::scoped_lock lock(m_outboundQueueMutex); - outboundBatch.swap(m_vecQueuedOutboungMsgs); - } - - for (std::string& strPayload : outboundBatch) + // best effort, never insert CLOSE into a partially sent frame + const CURLcode result = FlushOutbound(); + if (result == CURLE_OK) { - size_t sentPayload; - CURLcode sendResult = curl_ws_send(m_pCurlWS, strPayload.c_str(), strPayload.length(), &sentPayload, 0, CURLWS_BINARY); - if (sendResult != CURLE_OK) - { - NetworkLog(ELogVerbosity::LOG_RELEASE, "[WebSocket] Disconnect: failed to flush queued message: %s", curl_easy_strerror(sendResult)); - } + size_t sent = 0; + (void)curl_ws_send(m_pCurlWS, "", 0, &sent, 0, CURLWS_CLOSE); } - // send close - size_t sent; - (void)curl_ws_send(m_pCurlWS, "", 0, &sent, 0, CURLWS_CLOSE); - // release headers if (m_pHeaders != nullptr) { @@ -323,23 +381,104 @@ void WebSocket::Disconnect() m_pCurlWS = nullptr; } + ClearOutbound(); m_vecWSPartialBuffer.clear(); m_bConnected = false; } void WebSocket::Send(const char* send_payload) { - // Thread-safe; always queues. Tick() flushes on the main thread. + QueueOutbound(std::string(send_payload), false); +} + +void WebSocket::QueueOutbound(std::string payload, bool heartbeat) +{ std::scoped_lock lock(m_outboundQueueMutex); + if (m_bShuttingDown || !m_acceptOutbound) + { + if (!heartbeat && !m_bShuttingDown) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[WebSocket] Rejecting message while disconnected; actions are not replayed after reconnect"); + } + return; + } + if (heartbeat && m_heartbeatQueued) + { + return; + } static constexpr size_t kMaxQueuedOutboundMsgs = 256; - if (m_vecQueuedOutboungMsgs.size() >= kMaxQueuedOutboundMsgs) + static constexpr size_t kMaxQueuedOutboundBytes = 2 * 1024 * 1024; + if (m_vecQueuedOutboungMsgs.size() >= kMaxQueuedOutboundMsgs || + payload.size() > kMaxQueuedOutboundBytes - m_queuedOutboundBytes) { - NetworkLog(ELogVerbosity::LOG_RELEASE, "[WebSocket] Outbound queue full (%zu), discarding oldest message", m_vecQueuedOutboungMsgs.size()); - m_vecQueuedOutboungMsgs.erase(m_vecQueuedOutboungMsgs.begin()); + // the front may be partially transmitted, do not evict it + NetworkLog(ELogVerbosity::LOG_RELEASE, "[WebSocket] Outbound queue full (%zu messages, %zu bytes), rejecting new message", m_vecQueuedOutboungMsgs.size(), m_queuedOutboundBytes); + return; } - m_vecQueuedOutboungMsgs.push_back(std::string(send_payload)); + m_queuedOutboundBytes += payload.size(); + m_vecQueuedOutboungMsgs.push_back({ std::move(payload), heartbeat }); + m_heartbeatQueued |= heartbeat; +} + +void WebSocket::ClearOutbound() +{ + std::scoped_lock lock(m_outboundQueueMutex); + // delivery is ambiguous after a socket failure, so old actions are not replayed on a new socket + if (!m_vecQueuedOutboungMsgs.empty()) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[WebSocket] Discarding %zu messages from closed connection (%zu bytes, %zu bytes consumed from current message)", m_vecQueuedOutboungMsgs.size(), m_queuedOutboundBytes, m_outboundOffset); + } + m_vecQueuedOutboungMsgs.clear(); + m_queuedOutboundBytes = 0; + m_outboundOffset = 0; + m_heartbeatQueued = false; + m_acceptOutbound = false; +} + +CURLcode WebSocket::FlushOutbound() +{ + // bounded work on the game thread, each call passes the remaining payload of the same frame (curl_ws_send partial writes) + size_t bytesSent = 0; + for (int attempt = 0; attempt < 16; ++attempt) + { + std::scoped_lock lock(m_outboundQueueMutex); + if (m_vecQueuedOutboungMsgs.empty()) + { + return CURLE_OK; + } + const OutboundMessage& message = m_vecQueuedOutboungMsgs.front(); + size_t sent = 0; + const size_t remaining = message.payload.size() - m_outboundOffset; + CURLcode result = curl_ws_send(m_pCurlWS, message.payload.data() + m_outboundOffset, + remaining, &sent, 0, CURLWS_BINARY); + if (sent > remaining) + { + return CURLE_SEND_ERROR; + } + m_outboundOffset += sent; + bytesSent += sent; + if (result != CURLE_OK && result != CURLE_AGAIN) + { + return result; + } + if (m_outboundOffset == message.payload.size()) + { + m_queuedOutboundBytes -= message.payload.size(); + if (message.heartbeat) + { + m_heartbeatQueued = false; + } + m_vecQueuedOutboungMsgs.erase(m_vecQueuedOutboungMsgs.begin()); + m_outboundOffset = 0; + } + if (result == CURLE_AGAIN || sent == 0 || bytesSent >= 256 * 1024) + { + return CURLE_AGAIN; + } + } + return CURLE_AGAIN; } class WebSocketMessageBase @@ -542,12 +681,26 @@ static bool JSONDeserialize(const char* szBuffer, nlohmann::json* jsonObject) } catch (nlohmann::json::exception& jsonException) { - NetworkLog(ELogVerbosity::LOG_RELEASE, "JSONDeserialize: Unparsable JSON: %s (%s)", szBuffer, jsonException.what()); + if (NGMP_OnlineServicesManager::Settings.Debug_VerboseLogging()) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "JSONDeserialize: Unparsable JSON: %s (%s)", szBuffer, jsonException.what()); + } + else + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "JSONDeserialize: Invalid message JSON"); + } return false; } catch (...) { - NetworkLog(ELogVerbosity::LOG_RELEASE, "JSONDeserialize: Unparsable JSON: %s", szBuffer); + if (NGMP_OnlineServicesManager::Settings.Debug_VerboseLogging()) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "JSONDeserialize: Unparsable JSON: %s", szBuffer); + } + else + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "JSONDeserialize: Invalid message JSON"); + } return false; } @@ -566,7 +719,14 @@ static bool JSONGetAsObject(nlohmann::json& jsonObject, T* outMsg) catch (nlohmann::json::exception& jsonException) { std::string targetTypeName = typeid(T).name(); - NetworkLog(ELogVerbosity::LOG_RELEASE, "JSONGetAsObject: Unparsable JSON: Target Type is %s (%s)", targetTypeName.c_str(), jsonException.what()); + if (NGMP_OnlineServicesManager::Settings.Debug_VerboseLogging()) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "JSONGetAsObject: Unparsable JSON: Target Type is %s (%s)", targetTypeName.c_str(), jsonException.what()); + } + else + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "JSONGetAsObject: Invalid message type %s", targetTypeName.c_str()); + } return false; } catch (...) @@ -589,6 +749,7 @@ void WebSocket::BeginReconnect() return; } + ClearOutbound(); m_bReconnecting = true; m_numReconnectAttempts = 0; m_bFreshSession = false; @@ -748,10 +909,23 @@ void WebSocket::UpdateReconnect() void WebSocket::Tick() { + if (m_bShuttingDown || m_pMulti == nullptr) + { + return; + } // Main thread only; m_pCurlWS/m_vecWSPartialBuffer/m_bConnected are unlocked here. UpdateReconnect(); + PollConnectionSetup(); - + // longer than the server's 20s backstop; a late reply then finds no callback and is ignored + static constexpr int64_t CONNECTIVITY_CHECK_TIMEOUT_MS = 30000; + if (m_cbOnConnectivityCheckComplete != nullptr && m_connectivityCheckStartMs != -1 && (NowMs() - m_connectivityCheckStartMs) > CONNECTIVITY_CHECK_TIMEOUT_MS) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[FULL_MESH_CONNECTIVITY_CHECK] No reply from the server, timing out"); + auto cbTimedOut = std::move(m_cbOnConnectivityCheckComplete); + ClearConnectivityCheckCallback(); + cbTimedOut(false, {}, "timeout"); + } /* if (strSignal.length() == 6) @@ -773,13 +947,7 @@ void WebSocket::Tick() pWS->SendData_Signalling(strSignal); */ - // ping? int64_t currTime = NowMs(); - if ((currTime - m_lastPing) > m_timeBetweenUserPings) - { - m_lastPing = currTime; - Ping(); - }; int numReqs = 0; curl_multi_perform(m_pMulti, &numReqs); @@ -797,15 +965,43 @@ void WebSocket::Tick() if (pCurlHandle == m_pCurlWS) // shouldnt hear about anything else { - int httpResponseCode = -1; + long httpResponseCode = 0; + long proxyStatus = 0; curl_easy_getinfo(pCurlHandle, CURLINFO_RESPONSE_CODE, &httpResponseCode); + curl_easy_getinfo(pCurlHandle, CURLINFO_HTTP_CONNECTCODE, &proxyStatus); + if (proxyStatus == 407) + httpResponseCode = 407; /* Check for errors */ if (m->data.result != CURLE_OK) { + const CURLcode failure = m->data.result; + if (httpResponseCode != 407 && (failure == CURLE_COULDNT_CONNECT || failure == CURLE_COULDNT_RESOLVE_PROXY || failure == CURLE_COULDNT_RESOLVE_HOST) + && m_proxyRoute + 1 < m_proxyPolicy.routes.size() && NowMs() - m_proxySetupStarted < m_reconnectAttemptTimeout) + { + ++m_proxyRoute; + NetworkLog(ELogVerbosity::LOG_RELEASE, "[WebSocket] Trying next policy route after pre-connection failure (%d)", failure); + StartResolvedConnection(); + continue; + } m_bConnected = false; m_vecWSPartialBuffer.clear(); - NetworkLog(ELogVerbosity::LOG_RELEASE, "[WebSocket] Failed to connect (%d - %s)", m->data.result, curl_easy_strerror(m->data.result)); + NetworkLog(ELogVerbosity::LOG_RELEASE, "[WebSocket] Handshake failed: phase=%s curl=%d status=%ld proxy_status=%ld", + ServiceConnection::FailurePhase(m->data.result, httpResponseCode, proxyStatus), m->data.result, httpResponseCode, proxyStatus); + if (httpResponseCode == 407 && !m_bReconnecting && !m_proxyPrompted && m_proxyRoute < m_proxyPolicy.routes.size() && !m_proxyPolicy.routes[m_proxyRoute].direct) + { + m_proxyPrompted = true; + ServiceConnection::RequestProxyLogin(m_proxyPolicy.routes[m_proxyRoute].url, m_proxyPolicy.routes[m_proxyRoute].allowIntegratedAuth, [](bool loggedIn) + { + auto* manager = NGMP_OnlineServicesManager::GetInstance(); + auto socket = manager ? manager->Internal_GetWebSocket() : nullptr; + if (socket) + { + socket->ResumeAfterProxyLogin(loggedIn); + } + }); + continue; + } if (m_bReconnecting) { @@ -843,8 +1039,16 @@ void WebSocket::Tick() NetworkLog(ELogVerbosity::LOG_RELEASE, "[WebSocket] Connected"); } + long protocol = 0; + curl_easy_getinfo(pCurlHandle, CURLINFO_HTTP_VERSION, &protocol); + NetworkLog(ELogVerbosity::LOG_RELEASE, "[WebSocket] Handshake status=%ld protocol=%s", httpResponseCode, ServiceConnection::ProtocolName(protocol)); + /* connected and ready */ m_bConnected = true; + { + std::scoped_lock lock(m_outboundQueueMutex); + m_acceptOutbound = true; + } m_vecWSPartialBuffer.clear(); EndReconnect(); @@ -867,42 +1071,81 @@ void WebSocket::Tick() return; } - // send anything we have queued (things sent while not connected, or from any other thread) - std::vector outboundBatch; + if ((currTime - m_lastPing) > m_timeBetweenUserPings) + { + m_lastPing = currTime; + Ping(); + } + + const CURLcode sendResult = FlushOutbound(); + if (sendResult != CURLE_OK && sendResult != CURLE_AGAIN) { - std::scoped_lock lock(m_outboundQueueMutex); - outboundBatch.swap(m_vecQueuedOutboungMsgs); + NetworkLog(ELogVerbosity::LOG_RELEASE, "[WebSocket] Send failed: %s, attempting reconnect", curl_easy_strerror(sendResult)); + m_bConnected = false; + m_vecWSPartialBuffer.clear(); + BeginReconnect(); + return; } - for (std::string& strPayload : outboundBatch) + // drain what the server queued, a signalling burst shouldn't take one frame per tick + static constexpr int MAX_WS_FRAMES_PER_TICK = 64; + CURLcode ret = CURL_LAST; + for (int numFrames = 0; numFrames < MAX_WS_FRAMES_PER_TICK; ++numFrames) { - size_t sent; - CURLcode result = curl_ws_send(m_pCurlWS, strPayload.c_str(), strPayload.length(), &sent, 0, CURLWS_BINARY); + ret = ReceiveOneFrame(); + if (ret != CURLE_OK || !m_bConnected) + { + break; + } + } - if (result != CURLE_OK) + // time since last pong? + if (m_bConnected && m_lastPong != -1 && (currTime - m_lastPong) >= m_timeForWSTimeout) + { + // send event to sentry +#if defined(GENERALS_ONLINE_USE_SENTRY) + if (TheNGMPGame != nullptr) { - NetworkLog(ELogVerbosity::LOG_RELEASE, "curl_ws_send() failed: %s\n", curl_easy_strerror(result)); + AsciiString sentryMsg; + sentryMsg.format("Got websocket disconnect (Timeout: %s), timeout is %lld, last pong was at %lld, current time is %lld, attempting reconnect", curl_easy_strerror(ret), currTime - m_lastPong, m_lastPong, currTime); + sentry_capture_event(sentry_value_new_message_event(SENTRY_LEVEL_ERROR, "WEBSOCKET_DISCONNECT_TIMEOUT", sentryMsg.str())); } - } +#endif + + NetworkLog(ELogVerbosity::LOG_RELEASE, "Got websocket disconnect (Timeout: %s), timeout is %lld, last pong was at %lld, current time is %lld, attempting reconnect", curl_easy_strerror(ret), currTime - m_lastPong, m_lastPong, currTime); + m_bConnected = false; + BeginReconnect(); + m_vecWSPartialBuffer.clear(); + }; +} +// Reads and handles at most one frame, returns the curl_ws_recv result (CURL_LAST if nothing was read) +CURLcode WebSocket::ReceiveOneFrame() +{ // do recv size_t rlen = 0; const struct curl_ws_frame* meta = nullptr; char bufferThisRecv[8196 * 4] = { 0 }; - CURLcode ret = CURL_LAST; - ret = curl_ws_recv(m_pCurlWS, bufferThisRecv, sizeof(bufferThisRecv), &rlen, &meta); + CURLcode ret = curl_ws_recv(m_pCurlWS, bufferThisRecv, sizeof(bufferThisRecv) - 1, &rlen, &meta); // SECURITY FIX: Validate rlen is within buffer bounds - if (rlen > sizeof(bufferThisRecv)) + if (rlen >= sizeof(bufferThisRecv)) { NetworkLog(ELogVerbosity::LOG_RELEASE, "[WebSocket] Received data size %zu exceeds buffer size %zu, discarding", rlen, sizeof(bufferThisRecv)); - return; + return CURL_LAST; } - if (ret != CURLE_RECV_ERROR && ret != CURL_LAST && ret != CURLE_AGAIN && ret != CURLE_GOT_NOTHING) + if (ret == CURLE_OK) { - NetworkLog(ELogVerbosity::LOG_DEBUG, "Got websocket msg: %s", bufferThisRecv); + if (NGMP_OnlineServicesManager::Settings.Debug_VerboseLogging()) + { + NetworkLog(ELogVerbosity::LOG_DEBUG, "Got websocket msg: %s", bufferThisRecv); + } + else + { + NetworkLog(ELogVerbosity::LOG_DEBUG, "[WebSocket] Received %zu bytes", rlen); + } NetworkLog(ELogVerbosity::LOG_DEBUG, "Got websocket len: %d", rlen); // any server frame proves liveness, not just a JSON PONG @@ -925,7 +1168,7 @@ void WebSocket::Tick() { NetworkLog(ELogVerbosity::LOG_RELEASE, "[WebSocket] Partial buffer overflow, discarding message"); m_vecWSPartialBuffer.clear(); - return; + return CURL_LAST; } // SECURITY FIX: Store old size BEFORE resize to avoid off-by-one error in memcpy @@ -1148,6 +1391,14 @@ void WebSocket::Tick() case EWebSocketMessageID::START_GAME: { + auto* startLobby = NGMP_OnlineServicesManager::GetInterface(); + if (startLobby != nullptr && startLobby->GetCurrentLobby().lobby_type == ELobbyType::CustomGame + && !startLobby->AreLocalLobbyPeersConnected()) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[LOBBY] Rejected start: required peer connection is missing"); + startLobby->QueueCannotConnectToLobby(); + break; + } WebSocketMessage_StartGameResponse startGameData; bool bParsed = JSONGetAsObject(jsonObject, &startGameData); @@ -1294,22 +1545,30 @@ void WebSocket::Tick() WebSocketMessage_NetworkStartSignalling startSignallingData; bool bParsed = JSONGetAsObject(jsonObject, &startSignallingData); - // TODO_NGMP: Better location for this - // When we find a new player, get their latest stats. Tooltip and loading screen need it, so we'll grab it now and then use cached data later since it cannot possibly change while in a lobby - NGMP_OnlineServices_StatsInterface* pStatsInterface = NGMP_OnlineServicesManager::GetInterface(); - if (pStatsInterface != nullptr) - { - pStatsInterface->findPlayerStatsByID(startSignallingData.user_id, [=](bool bSuccess, PSPlayerStats stats) - { - - }, EStatsRequestPolicy::BYPASS_CACHE_FORCE_REQUEST); - } - if (bParsed) { NGMP_OnlineServices_LobbyInterface* pLobbyInterface = NGMP_OnlineServicesManager::GetInterface(); if (pLobbyInterface != nullptr) { + // -1 while a join is still in flight, when this can legitimately arrive first + const int64_t currentLobbyID = pLobbyInterface->GetCurrentLobby().lobbyID; + if (currentLobbyID != -1 && currentLobbyID != startSignallingData.lobby_id) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[NETWORK_CONNECTION_START_SIGNALLING] Ignoring signalling for lobby %lld, we are in lobby %lld", startSignallingData.lobby_id, currentLobbyID); + break; + } + + // TODO_NGMP: Better location for this + // When we find a new player, get their latest stats. Tooltip and loading screen need it, so we'll grab it now and then use cached data later since it cannot possibly change while in a lobby + NGMP_OnlineServices_StatsInterface* pStatsInterface = NGMP_OnlineServicesManager::GetInterface(); + if (pStatsInterface != nullptr) + { + pStatsInterface->findPlayerStatsByID(startSignallingData.user_id, [=](bool bSuccess, PSPlayerStats stats) + { + + }, EStatsRequestPolicy::BYPASS_CACHE_FORCE_REQUEST); + } + NetworkMesh* pMesh = pLobbyInterface->GetNetworkMeshForLobby(); if (pMesh != nullptr) @@ -1339,7 +1598,7 @@ void WebSocket::Tick() if (bParsed) { - NetworkLog(ELogVerbosity::LOG_RELEASE, "[AC] Websocket AC_REGISTER_PLAYER for %lld and %s", acData.user_id, acData.mwid); + NetworkLog(ELogVerbosity::LOG_RELEASE, "[AC] Websocket AC_REGISTER_PLAYER for %lld and %s", acData.user_id, acData.mwid.c_str()); if (!AnticheatPlugInterface::RegisterPlayer(acData.mwid, acData.user_id)) { NetworkLog(ELogVerbosity::LOG_RELEASE, "[AC] AnticheatPlugInterface::RegisterPlayer failed"); @@ -1355,7 +1614,7 @@ void WebSocket::Tick() if (bParsed) { - NetworkLog(ELogVerbosity::LOG_RELEASE, "[AC] Websocket AC_DEREGISTER_PLAYER for %lld and %s", acData.user_id, acData.mwid); + NetworkLog(ELogVerbosity::LOG_RELEASE, "[AC] Websocket AC_DEREGISTER_PLAYER for %lld and %s", acData.user_id, acData.mwid.c_str()); if (!AnticheatPlugInterface::DeregisterPlayer(acData.mwid, acData.user_id)) { NetworkLog(ELogVerbosity::LOG_RELEASE, "[AC] AnticheatPlugInterface::DeregisterPlayer failed"); @@ -1535,7 +1794,14 @@ void WebSocket::Tick() if (bParsed) { - NetworkLog(ELogVerbosity::LOG_RELEASE, "[PROBE] GOT PROBE REQUEST: %s!", probe.url.c_str()); + if (NGMP_OnlineServicesManager::Settings.Debug_VerboseLogging()) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[PROBE] GOT PROBE REQUEST: %s!", probe.url.c_str()); + } + else + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[PROBE] Received request"); + } NGMP_OnlineServicesManager::GetInstance()->CaptureScreenshotForProbe(EScreenshotType::SCREENSHOT_TYPE_GAMEPLAY, probe.url); @@ -1580,9 +1846,14 @@ void WebSocket::Tick() lobbyEntry.lobbyID = mmEvent.lobby_id; lobbyEntry.map_path = "Maps\\Alpine Assault\\Alpine Assault.map"; - pLobbyInterface->JoinLobby(lobbyEntry, std::string()); - - pLobbyInterface->InvokeMatchmakingMessageCallback("Joining QuickMatch Lobby"); + if (pLobbyInterface->JoinLobby(lobbyEntry, std::string())) + { + pLobbyInterface->InvokeMatchmakingMessageCallback("Joining QuickMatch Lobby"); + } + else + { + pLobbyInterface->InvokeMatchmakingMessageCallback("Could not join the QuickMatch lobby, another join is still in progress"); + } } else { @@ -1724,21 +1995,41 @@ void WebSocket::Tick() } catch (nlohmann::json::exception& jsonException) { - - NetworkLog(ELogVerbosity::LOG_RELEASE, "Unparsable WebSocketMessage 101: %s (JSON: %s)", bufferThisRecv, jsonException.what()); - NetworkLog(ELogVerbosity::LOG_RELEASE, "Buildup buffer is: %s", m_vecWSPartialBuffer.data()); + if (NGMP_OnlineServicesManager::Settings.Debug_VerboseLogging()) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "Unparsable WebSocketMessage 101: %s (JSON: %s)", bufferThisRecv, jsonException.what()); + NetworkLog(ELogVerbosity::LOG_RELEASE, "Buildup buffer is: %s", m_vecWSPartialBuffer.data()); + } + else + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[WebSocket] Invalid JSON message"); + } m_vecWSPartialBuffer.clear(); } catch (std::exception& e) { - NetworkLog(ELogVerbosity::LOG_RELEASE, "Unparsable WebSocketMessage 100: %s (%s)", bufferThisRecv, e.what()); + if (NGMP_OnlineServicesManager::Settings.Debug_VerboseLogging()) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "Unparsable WebSocketMessage 100: %s (%s)", bufferThisRecv, e.what()); + } + else + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[WebSocket] Message processing failed"); + } m_vecWSPartialBuffer.clear(); } catch (...) { - NetworkLog(ELogVerbosity::LOG_RELEASE, "Unparsable WebSocketMessage 102: %s", bufferThisRecv); + if (NGMP_OnlineServicesManager::Settings.Debug_VerboseLogging()) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "Unparsable WebSocketMessage 102: %s", bufferThisRecv); + } + else + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[WebSocket] Message processing failed"); + } m_vecWSPartialBuffer.clear(); } @@ -1774,7 +2065,7 @@ void WebSocket::Tick() NetworkLog(ELogVerbosity::LOG_DEBUG, "websocket meta was null"); } } - else if (ret == CURLE_RECV_ERROR) + else if (ret != CURLE_AGAIN) { NetworkLog(ELogVerbosity::LOG_RELEASE, "Got websocket disconnect (ERROR: %s), Attempting reconnect", curl_easy_strerror(ret)); @@ -1795,24 +2086,7 @@ void WebSocket::Tick() #endif } - // time since last pong? - if (m_lastPong != -1 && (currTime - m_lastPong) >= m_timeForWSTimeout) - { - // send event to sentry -#if defined(GENERALS_ONLINE_USE_SENTRY) - if (TheNGMPGame != nullptr) - { - AsciiString sentryMsg; - sentryMsg.format("Got websocket disconnect (Timeout: %s), timeout is %lld, last pong was at %lld, current time is %lld, attempting reconnect", curl_easy_strerror(ret), currTime - m_lastPong, m_lastPong, currTime); - sentry_capture_event(sentry_value_new_message_event(SENTRY_LEVEL_ERROR, "WEBSOCKET_DISCONNECT_TIMEOUT", sentryMsg.str())); - } -#endif - - NetworkLog(ELogVerbosity::LOG_RELEASE, "Got websocket disconnect (Timeout: %s), timeout is %lld, last pong was at %lld, current time is %lld, attempting reconnect", curl_easy_strerror(ret), currTime - m_lastPong, m_lastPong, currTime); - m_bConnected = false; - BeginReconnect(); - m_vecWSPartialBuffer.clear(); - }; + return ret; } NGMP_OnlineServices_RoomsInterface::NGMP_OnlineServices_RoomsInterface() diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/PluginInterfaces.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/PluginInterfaces.cpp index 7d60712e8cc..c62f5f7fcf9 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/PluginInterfaces.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/PluginInterfaces.cpp @@ -507,7 +507,7 @@ bool AnticheatPlugInterface::DoesACPluginProvideSecureGameTransport() { #if defined(AC_ENABLED) ServiceConfig& serviceConf = NGMP_OnlineServicesManager::GetInstance()->GetServiceConfig(); - const bool bEnabledByConfig = serviceConf.use_new_networking; + const bool bEnabledByConfig = serviceConf.connection.anticheat_transport; // The plugin makes this same decision internally in places the game cannot // reach - notably inside the EOS anti-cheat OnMessageToPeer callback, which diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/ProxyLogin.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/ProxyLogin.cpp new file mode 100644 index 00000000000..30be4a4c0ff --- /dev/null +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/ProxyLogin.cpp @@ -0,0 +1,876 @@ +#include "PreRTS.h" + +#include "GameNetwork/GeneralsOnline/ProxyLogin.h" +#include "GameNetwork/GeneralsOnline/ServiceConnectionPolicy.h" +#include "GameNetwork/GeneralsOnline/OnlineServices_Init.h" +#include "Common/NameKeyGenerator.h" +#include "GameClient/GameText.h" +#include "GameClient/GameWindowManager.h" +#include "GameClient/WindowLayout.h" +#include "GameClient/Gadget.h" +#include "GameClient/GadgetCheckBox.h" +#include "GameClient/GadgetPushButton.h" +#include "GameClient/GadgetStaticText.h" +#include "GameClient/GadgetTextEntry.h" +#include "GameClient/DisplayString.h" +#include "GameClient/DisplayStringManager.h" +#include "GameClient/KeyDefs.h" +#include +#include +#include +#include +#include + +// One dialog for the login of a proxy and the connection test. It reuses the retail GameSpy login layout, which Generals Online +// does not use otherwise, resized into the frame of the game's message box. States: testing, form, checking, result. +// Fonts and looks come from the retail controls of that layout; distances are one padding and one row gap in 800x600 units. +// The Windows login is tried silently before this form is ever shown, so the form is only for typed credentials. +namespace ServiceConnection +{ +namespace +{ +std::mutex mainThreadMutex; +std::deque> mainThreadTasks; + +enum class State { Testing, Form, Checking, Result }; + +struct Dialog +{ + WindowLayout *layout = nullptr; + GameWindow *parent = nullptr; + GameWindow *panel = nullptr; + GameWindow *inner = nullptr; + GameWindow *header = nullptr; + GameWindow *title = nullptr; + GameWindow *info = nullptr; + GameWindow *error = nullptr; + GameWindow *userLabel = nullptr; + GameWindow *userName = nullptr; + GameWindow *passwordLabel = nullptr; + GameWindow *password = nullptr; + GameWindow *windows = nullptr; + GameWindow *remember = nullptr; + GameWindow *ok = nullptr; + GameWindow *cancel = nullptr; + GameWindow *middle = nullptr; + GameWindow *previousFocus = nullptr; + std::vector coveredBoxes; + State state = State::Testing; + bool testPath = false; + bool acceptMode = false; + bool failed = false; + unsigned generation = 0; + float scaleX = 1.0f; + float scaleY = 1.0f; + std::string proxyURL; + ServiceConnectionSettings settings; + std::string pendingUser; + std::string pendingPassword; + bool pendingRemember = false; + std::function done; + std::function decided; +}; + +Dialog dialog; + +const char *environmentName() +{ + return NGMP_OnlineServicesManager::g_Environment == NGMP_OnlineServicesManager::EEnvironment::DEV ? "dev" : + NGMP_OnlineServicesManager::g_Environment == NGMP_OnlineServicesManager::EEnvironment::TEST ? "test" : "prod"; +} + +GameWindow *control(const char *name) +{ + return TheWindowManager->winGetWindowFromId(nullptr, TheNameKeyGenerator->nameToKey(name)); +} + +void hideRetail(const char *name) +{ + GameWindow *window = control((std::string("GameSpyLoginProfile.wnd:") + name).c_str()); + if (window != nullptr) + { + window->winHide(TRUE); + } +} + +UnicodeString wideText(const std::string &text) +{ + UnicodeString result; + result.translate(text.c_str()); + return result; +} + +std::string narrowText(const UnicodeString &text) +{ + AsciiString ascii; + ascii.translate(text); + return ascii.str(); +} + +void wipeString(std::string &value) +{ + if (!value.empty()) + { + SecureZeroMemory(value.data(), value.size()); + } + value.clear(); +} + +void copyLook(GameWindow *to, GameWindow *from) +{ + WinInstanceData *target = to->winGetInstanceData(); + WinInstanceData *source = from->winGetInstanceData(); + for (Int i = 0; i < MAX_DRAW_DATA; ++i) + { + target->m_enabledDrawData[i] = source->m_enabledDrawData[i]; + target->m_disabledDrawData[i] = source->m_disabledDrawData[i]; + target->m_hiliteDrawData[i] = source->m_hiliteDrawData[i]; + } + target->m_enabledText = source->m_enabledText; + target->m_disabledText = source->m_disabledText; + target->m_hiliteText = source->m_hiliteText; + target->m_imeCompositeText = source->m_imeCompositeText; + target->m_imageOffset = source->m_imageOffset; + to->winSetFont(from->winGetFont()); +} + +GameWindow *createEntry(GameWindow *like, const char *name) +{ + WinInstanceData instance; + instance.m_style = like->winGetStyle(); + EntryData data; + memset(&data, 0, sizeof(data)); + data.aSCIIOnly = TRUE; + data.maxTextLen = 64; + GameWindow *entry = TheWindowManager->gogoGadgetTextEntry(like->winGetParent(), like->winGetStatus(), 0, 0, 10, 10, &instance, &data, like->winGetFont(), FALSE); + if (entry != nullptr) + { + copyLook(entry, like); + entry->winSetWindowId(TheNameKeyGenerator->nameToKey(name)); + } + return entry; +} + +GameWindow *createCheckBox(GameWindow *like, const char *name) +{ + WinInstanceData instance; + instance.m_style = like->winGetStyle(); + GameWindow *box = TheWindowManager->gogoGadgetCheckbox(like->winGetParent(), like->winGetStatus(), 0, 0, 10, 10, &instance, like->winGetFont(), FALSE); + if (box != nullptr) + { + copyLook(box, like); + box->winSetWindowId(TheNameKeyGenerator->nameToKey(name)); + } + return box; +} + +GameWindow *createButton(GameWindow *like, const char *name) +{ + WinInstanceData instance; + instance.m_style = like->winGetStyle(); + GameWindow *button = TheWindowManager->gogoGadgetPushButton(like->winGetParent(), like->winGetStatus(), 0, 0, 10, 10, &instance, like->winGetFont(), FALSE); + if (button != nullptr) + { + copyLook(button, like); + button->winSetWindowId(TheNameKeyGenerator->nameToKey(name)); + } + return button; +} + +GameWindow *createLabel(GameWindow *like, const char *name) +{ + WinInstanceData instance; + instance.m_style = like->winGetStyle(); + TextData data; + memset(&data, 0, sizeof(data)); + GameWindow *label = TheWindowManager->gogoGadgetStaticText(like->winGetParent(), like->winGetStatus(), 0, 0, 10, 10, &instance, &data, like->winGetFont(), FALSE); + if (label != nullptr) + { + copyLook(label, like); + label->winSetWindowId(TheNameKeyGenerator->nameToKey(name)); + } + return label; +} + +void place(GameWindow *window, Int x, Int y, Int width, Int height) +{ + window->winSetPosition(x, y); + window->winSetSize(width, height); +} + +// the panel and the dimmed screen draw as a flat color with a border; the inner layout windows draw nothing +void flatLook(GameWindow *window, bool visible, Color color, Color border) +{ + WinInstanceData *data = window->winGetInstanceData(); + for (Int i = 0; i < MAX_DRAW_DATA; ++i) + { + data->m_enabledDrawData[i].image = nullptr; + data->m_enabledDrawData[i].color = i == 0 && visible ? color : WIN_COLOR_UNDEFINED; + data->m_enabledDrawData[i].borderColor = i == 0 && visible ? border : WIN_COLOR_UNDEFINED; + data->m_disabledDrawData[i] = data->m_enabledDrawData[i]; + data->m_hiliteDrawData[i] = data->m_enabledDrawData[i]; + } + window->winClearStatus(WIN_STATUS_IMAGE | WIN_STATUS_BORDER); +} + +void setCenteredVertically(GameWindow *label, bool centered) +{ + if (TextData *text = static_cast(label->winGetUserData())) + { + text->centeredVertically = centered; + text->leftMargin = 0; + text->topMargin = 0; + } +} + +// ---- layout ---------------------------------------------------------------------------------------------------------------------- + +// Same frame as the game's message box (Menus/MessageBox.wnd): fill 2 2 2, border 47 55 168, a header, a dimmed screen behind it, +// 100 from the top at 800x600. All distances come from the pixel height and width of the retail fonts of the login layout +// (already scaled for the resolution), never below the message box's own size. The top left corner stays put across states. +struct Metrics +{ + Int fontHeight = 0; + Int padding = 0; + Int gap = 0; + Int rowHeight = 0; + Int lineHeight = 0; + Int checkHeight = 0; + Int headerHeight = 0; + Int labelWidth = 0; + Int buttonWidth = 0; + Int buttonHeight = 0; + Int edge = 0; + Int bottomMargin = 0; + Int panelWidth = 0; + Int panelX = 0; + Int panelY = 0; +}; + +Metrics metrics; + +Int px(Int value) +{ + return (Int)(value * dialog.scaleX + 0.5f); +} + +Int py(Int value) +{ + return (Int)(value * dialog.scaleY + 0.5f); +} + +std::string upperText(std::string text) +{ + std::transform(text.begin(), text.end(), text.begin(), [](unsigned char c) { return (char)toupper(c); }); + return text; +} + +Int measure(GameFont *font, const UnicodeString &text) +{ + DisplayString *string = TheDisplayStringManager->newDisplayString(); + string->setFont(font); + string->setText(text); + const Int width = string->getWidth(); + TheDisplayStringManager->freeDisplayString(string); + return width; +} + +void computeMetrics(Int screenWidth, Int screenHeight, const UnicodeString &formMessage) +{ + GameFont *textFont = dialog.password->winGetFont(); + Metrics m; + m.fontHeight = TheWindowManager->winFontHeight(textFont); + const Int titleHeight = TheWindowManager->winFontHeight(dialog.title->winGetFont()); + m.padding = std::max(px(14), m.fontHeight); + m.gap = std::max(py(6), m.fontHeight * 6 / 10); + m.rowHeight = std::max(py(24), m.fontHeight * 18 / 10); + m.lineHeight = std::max(py(16), m.fontHeight * 13 / 10); + m.checkHeight = std::max(py(22), m.fontHeight * 16 / 10); + m.headerHeight = std::max(py(28), titleHeight + py(8)); + m.labelWidth = std::max(measure(textFont, UnicodeString(L"Username")), measure(textFont, TheGameText->fetch("GUI:Password"))) + m.fontHeight; + // the buttons keep the size and the margins of the message box (90x24, 8 from the edges, 27 from the bottom) and are never stretched + m.buttonWidth = px(90); + m.buttonHeight = py(24); + m.edge = px(8); + m.bottomMargin = py(27); + // the checkbox box and its label must fit the field column + const Int rememberWidth = std::max(measure(textFont, TheGameText->fetch("GUI:RememberPassword")), measure(textFont, UnicodeString(L"Use Windows login"))) + m.checkHeight + m.fontHeight; + m.panelWidth = std::max({ px(260), 2 * m.padding + m.labelWidth + std::max(px(150), rememberWidth), 2 * m.padding + rememberWidth }); + m.panelWidth = std::min(m.panelWidth, screenWidth * 62 / 100); + m.panelX = (screenWidth - m.panelWidth) / 2; + m.panelY = py(100) + std::max(0, (screenHeight - py(600)) / 2); + metrics = m; +} + +// lines a text takes in the message area, counting wrapped and explicit lines +Int linesFor(const UnicodeString &text) +{ + GameFont *font = dialog.password->winGetFont(); + const Int available = metrics.panelWidth - 2 * metrics.padding; + const std::wstring all = text.str(); + Int lines = 0; + size_t start = 0; + for (;;) + { + const size_t end = all.find(L'\n', start); + const std::wstring part = all.substr(start, end == std::wstring::npos ? std::wstring::npos : end - start); + lines += std::max(1, (measure(font, UnicodeString(part.c_str())) + available - 1) / available); + if (end == std::wstring::npos) + { + break; + } + start = end + 1; + } + return lines; +} + +void showControls(std::initializer_list windows, bool show) +{ + for (GameWindow *window : windows) + { + window->winHide(!show); + } +} + +void updateFormEnabled() +{ + const bool editable = dialog.state == State::Form; + // with the Windows login the typed credentials are not used, so their fields are greyed + const bool typed = editable && GadgetCheckBoxIsChecked(dialog.windows) == FALSE; + dialog.windows->winEnable(editable); + dialog.userName->winEnable(typed); + dialog.password->winEnable(typed); + dialog.remember->winEnable(typed); + dialog.ok->winEnable(editable); + dialog.cancel->winEnable(TRUE); +} + +// a label that fits the fixed button size, otherwise the short one +std::string fittingLabel(GameWindow *button, const std::string &label, const std::string &shortLabel) +{ + return measure(button->winGetFont(), wideText(label)) + metrics.fontHeight <= metrics.buttonWidth ? label : shortLabel; +} + +void setState(State state, const std::string &title, const UnicodeString &message, const UnicodeString &errorText) +{ + dialog.state = state; + const Metrics &m = metrics; + const bool form = state == State::Form || state == State::Checking; + const Int messageY = m.headerHeight + py(8); + const Int messageHeight = linesFor(message) * m.lineHeight; + const Int errorY = messageY + messageHeight; + const Int windowsY = errorY + m.lineHeight * 2 + m.gap; + const Int userY = windowsY + m.checkHeight + m.gap; + const Int passwordY = userY + m.rowHeight + m.gap; + const Int rememberY = passwordY + m.rowHeight + m.gap; + const Int buttonY = form ? rememberY + m.checkHeight + m.gap * 2 : messageY + messageHeight + m.gap * 2; + const Int panelHeight = buttonY + m.buttonHeight + m.bottomMargin; + const Int fieldX = m.padding + m.labelWidth; + const Int fieldWidth = m.panelWidth - fieldX - m.padding; + const Int contentWidth = m.panelWidth - 2 * m.padding; + + place(dialog.panel, m.panelX, m.panelY, m.panelWidth, panelHeight); + place(dialog.inner, 0, 0, m.panelWidth, panelHeight); + place(dialog.header, 0, 0, m.panelWidth, m.headerHeight); + place(dialog.title, m.padding, 0, contentWidth, m.headerHeight); + place(dialog.info, m.padding, messageY, contentWidth, messageHeight); + setCenteredVertically(dialog.info, form); + place(dialog.error, m.padding, errorY, contentWidth, m.lineHeight * 2); + place(dialog.windows, fieldX, windowsY, fieldWidth, m.checkHeight); + place(dialog.userLabel, m.padding, userY, m.labelWidth, m.rowHeight); + place(dialog.userName, fieldX, userY, fieldWidth, m.rowHeight); + place(dialog.passwordLabel, m.padding, passwordY, m.labelWidth, m.rowHeight); + place(dialog.password, fieldX, passwordY, fieldWidth, m.rowHeight); + place(dialog.remember, fieldX, rememberY, fieldWidth, m.checkHeight); + // button slots of the message box: primary bottom left, optional middle, cancel bottom right + place(dialog.ok, m.edge, buttonY, m.buttonWidth, m.buttonHeight); + place(dialog.middle, (m.panelWidth - m.buttonWidth) / 2, buttonY, m.buttonWidth, m.buttonHeight); + place(dialog.cancel, m.panelWidth - m.edge - m.buttonWidth, buttonY, m.buttonWidth, m.buttonHeight); + const bool failedAccept = state == State::Result && dialog.acceptMode && dialog.failed; + GadgetButtonSetText(dialog.ok, TheGameText->fetch(failedAccept ? "GUI:Back" : "GUI:Ok")); + GadgetButtonSetText(dialog.middle, wideText(fittingLabel(dialog.middle, "SAVE ANYWAY", "SAVE"))); + + GadgetStaticTextSetText(dialog.title, wideText(upperText(title))); + GadgetStaticTextSetText(dialog.info, message); + GadgetStaticTextSetText(dialog.error, errorText); + showControls({ dialog.error, dialog.windows, dialog.userLabel, dialog.userName, dialog.passwordLabel, dialog.password, dialog.remember }, form); + dialog.ok->winHide(state == State::Testing || state == State::Checking); + dialog.middle->winHide(!failedAccept); + dialog.cancel->winHide(state == State::Result && !failedAccept); + updateFormEnabled(); +} + +// A modal message box (such as the update check) would stay above the dialog, so those boxes are hidden while it is open +void hideMessageBoxes() +{ + const NameKeyType boxKey = TheNameKeyGenerator->nameToKey("MessageBox.wnd:MessageBoxParent"); + for (GameWindow *top = TheWindowManager->winGetWindowList(); top != nullptr; top = top->winGetNext()) + { + if (top != dialog.parent && !top->winIsHidden() && TheWindowManager->winGetWindowFromId(top, boxKey) != nullptr) + { + dialog.coveredBoxes.push_back(top); + top->winHide(TRUE); + } + } +} + +// only boxes that are still open get shown again +void showMessageBoxes(const std::vector &boxes) +{ + for (GameWindow *top = TheWindowManager->winGetWindowList(); top != nullptr; top = top->winGetNext()) + { + if (std::find(boxes.begin(), boxes.end(), top) != boxes.end()) + { + top->winHide(FALSE); + } + } +} + +// ---- results --------------------------------------------------------------------------------------------------------------------- + +void finishDialog(bool loggedIn, AcceptChoice choice = AcceptChoice::KeepEditing) +{ + std::function done = std::move(dialog.done); + std::function decided = std::move(dialog.decided); + GameWindow *previousFocus = dialog.previousFocus; + std::vector coveredBoxes = std::move(dialog.coveredBoxes); + TheWindowManager->winUnsetModal(dialog.parent); + GadgetTextEntrySetText(dialog.password, UnicodeString::TheEmptyString); + wipeString(dialog.pendingPassword); + dialog.layout->destroyWindows(); + deleteInstance(dialog.layout); + const unsigned generation = dialog.generation + 1; + dialog = Dialog(); + dialog.generation = generation; + showMessageBoxes(coveredBoxes); + if (previousFocus != nullptr) + { + TheWindowManager->winSetFocus(previousFocus); + } + if (done) + { + done(loggedIn); + } + if (decided) + { + decided(choice); + } +} + +// the proxy accepted the login: keep it as the player chose +void commitLogin() +{ + std::string error; + if (dialog.pendingRemember) + { + ClearSessionProxyCredentials(dialog.proxyURL); + if (!SaveProxyCredentials(dialog.proxyURL, dialog.pendingUser, dialog.pendingPassword, error)) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[Connection] Unable to save the proxy login: %s", error.c_str()); + SetSessionProxyCredentials(dialog.proxyURL, dialog.pendingUser, dialog.pendingPassword); + } + } + wipeString(dialog.pendingPassword); +} + +void showForm(const UnicodeString &errorText, bool clearPassword) +{ + setState(State::Form, "Proxy login", wideText("The proxy " + ProxyDisplayName(dialog.proxyURL) + " needs a login."), errorText); + if (clearPassword) + { + GadgetTextEntrySetText(dialog.password, UnicodeString::TheEmptyString); + } + TheWindowManager->winSetFocus(GadgetCheckBoxIsChecked(dialog.windows) ? dialog.ok : narrowText(GadgetTextEntryGetText(dialog.userName)).empty() ? dialog.userName : dialog.password); +} + +void startProbe(ServiceConnectionSettings settings) +{ + const unsigned generation = ++dialog.generation; + const std::string environment = environmentName(); + std::thread([generation, settings, environment]() + { + ConnectionTestResult result; + try + { + result = TestServiceConnection(settings, environment, 12); + } + catch (...) + { + } + RunOnMainThread([generation, result]() + { + if (dialog.layout == nullptr || dialog.generation != generation) + { + return; + } + using Outcome = ConnectionTestResult::Outcome; + if (dialog.state == State::Testing) + { + if (result.outcome == Outcome::ProxyAuthRequired && !result.proxyURL.empty()) + { + dialog.proxyURL = result.proxyURL; + const std::string savedUser = ProxyUsername(result.proxyURL); + GadgetTextEntrySetText(dialog.userName, wideText(savedUser)); + GadgetCheckBoxSetChecked(dialog.remember, HasProxyCredentials(result.proxyURL)); + showForm(result.detail == "refused" ? TheGameText->fetch("GUI:GSDisconReason8") : UnicodeString::TheEmptyString, false); + } + else if (dialog.acceptMode && result.outcome == Outcome::Connected) + { + finishDialog(true, AcceptChoice::Save); + } + else + { + dialog.failed = result.outcome != Outcome::Connected; + setState(State::Result, "Connection test", wideText(DescribeConnectionTest(result)), UnicodeString::TheEmptyString); + } + return; + } + // checking a login + if (result.outcome == Outcome::ProxyAuthRequired) + { + ClearSessionProxyCredentials(dialog.proxyURL); + wipeString(dialog.pendingPassword); + showForm(GadgetCheckBoxIsChecked(dialog.windows) ? wideText("The Windows login was not accepted.") : TheGameText->fetch("GUI:GSDisconReason8"), true); + return; + } + if (result.outcome == Outcome::Unreachable && !result.proxyAnswered) + { + ClearSessionProxyCredentials(dialog.proxyURL); + wipeString(dialog.pendingPassword); + showForm(TheGameText->fetch("GUI:ConnectionFailed"), false); + return; + } + commitLogin(); + if (!dialog.testPath) + { + finishDialog(true); + return; + } + if (dialog.acceptMode && result.outcome == Outcome::Connected) + { + finishDialog(true, AcceptChoice::Save); + return; + } + dialog.failed = result.outcome != Outcome::Connected; + setState(State::Result, "Connection test", + wideText(result.outcome == Outcome::Connected ? DescribeConnectionTest(result) : + result.outcome == Outcome::Unreachable ? "Logged in to the proxy, but could not reach the service." : + "Logged in to the proxy. " + DescribeConnectionTest(result)), UnicodeString::TheEmptyString); + }); + }).detach(); +} + +// switches to the checking state; stays on the form when the input is incomplete +void acceptForm() +{ + const bool windowsLogin = GadgetCheckBoxIsChecked(dialog.windows) != FALSE; + // the choice is kept, and the check below uses it + NGMP_OnlineServicesManager::Settings.Network_SaveWindowsLogin(windowsLogin); + dialog.settings.windowsLogin = windowsLogin; + if (windowsLogin) + { + ClearSessionProxyCredentials(dialog.proxyURL); + dialog.pendingUser.clear(); + wipeString(dialog.pendingPassword); + dialog.pendingRemember = false; + setState(State::Checking, "Proxy login", TheGameText->fetch("WOL:Connecting"), UnicodeString::TheEmptyString); + startProbe(dialog.testPath ? dialog.settings : NGMP_OnlineServicesManager::Settings.Network_GetConnectionSettings()); + return; + } + std::string user = narrowText(GadgetTextEntryGetText(dialog.userName)); + std::string password = narrowText(GadgetTextEntryGetText(dialog.password)); + if (user.empty()) + { + showForm(wideText("Enter the user name."), false); + wipeString(password); + return; + } + dialog.pendingUser = user; + dialog.pendingPassword = password; + dialog.pendingRemember = GadgetCheckBoxIsChecked(dialog.remember) != FALSE; + wipeString(password); + GadgetTextEntrySetText(dialog.password, UnicodeString::TheEmptyString); + + // the login is used for the check first; it is kept once the proxy accepted it + SetSessionProxyCredentials(dialog.proxyURL, dialog.pendingUser, dialog.pendingPassword); + setState(State::Checking, "Proxy login", TheGameText->fetch("WOL:Connecting"), UnicodeString::TheEmptyString); + startProbe(dialog.testPath ? dialog.settings : NGMP_OnlineServicesManager::Settings.Network_GetConnectionSettings()); +} + +void cancelDialog() +{ + if (dialog.state == State::Checking) + { + ClearSessionProxyCredentials(dialog.proxyURL); + } + // Cancel after a failed check drops the connection changes, earlier it only stops the check + finishDialog(false, dialog.acceptMode && dialog.state == State::Result ? AcceptChoice::Discard : AcceptChoice::KeepEditing); +} + +WindowMsgHandledType dialogSystem(GameWindow *window, UnsignedInt msg, WindowMsgData mData1, WindowMsgData mData2) +{ + if (dialog.layout == nullptr) + { + return MSG_IGNORED; + } + switch (msg) + { + case GWM_INPUT_FOCUS: + { + if (mData1 == TRUE) + { + *(Bool *)mData2 = TRUE; + } + return MSG_HANDLED; + } + case GBM_SELECTED: + { + GameWindow *source = (GameWindow *)mData1; + if (source == dialog.ok) + { + if (dialog.state == State::Form) + { + acceptForm(); + } + else if (dialog.state == State::Result) + { + // with a failed check before Accept this button is "Back" + finishDialog(!dialog.acceptMode, AcceptChoice::KeepEditing); + } + } + else if (source == dialog.windows) + { + updateFormEnabled(); + } + else if (source == dialog.middle) + { + finishDialog(true, AcceptChoice::Save); + } + else if (source == dialog.cancel) + { + cancelDialog(); + } + return MSG_HANDLED; + } + case GEM_EDIT_DONE: + { + GameWindow *source = (GameWindow *)mData1; + if (dialog.state != State::Form) + { + return MSG_HANDLED; + } + if (source == dialog.userName) + { + TheWindowManager->winSetFocus(dialog.password); + } + else if (source == dialog.password) + { + acceptForm(); + } + return MSG_HANDLED; + } + } + return MSG_IGNORED; +} + +WindowMsgHandledType dialogInput(GameWindow *window, UnsignedInt msg, WindowMsgData mData1, WindowMsgData mData2) +{ + if (dialog.layout == nullptr) + { + return MSG_IGNORED; + } + if (msg == GWM_CHAR) + { + if ((UnsignedByte)mData1 == KEY_ESC) + { + if (BitIsSet((UnsignedByte)mData2, KEY_STATE_UP)) + { + cancelDialog(); + } + return MSG_HANDLED; + } + } + return MSG_IGNORED; +} + +// builds the dialog; returns false when the layout could not be built +bool openDialog(bool testPath, const UnicodeString &formMessage) +{ + if (dialog.layout != nullptr || TheWindowManager == nullptr) + { + return false; + } + WindowLayout *layout = TheWindowManager->winCreateLayout(AsciiString("Menus/GameSpyLoginProfile.wnd")); + Dialog d; + d.generation = dialog.generation; + d.layout = layout; + d.parent = control("GameSpyLoginProfile.wnd:WOLLoginMenuParent"); + d.panel = control("GameSpyLoginProfile.wnd:GadgetParent"); + d.header = control("GameSpyLoginProfile.wnd:Line"); + d.userLabel = control("GameSpyLoginProfile.wnd:StaticTextLoginName"); + d.info = control("GameSpyLoginProfile.wnd:StaticTextEmail"); + d.title = control("GameSpyLoginProfile.wnd:StaticTextTitle"); + d.passwordLabel = control("GameSpyLoginProfile.wnd:StaticTextPassword"); + d.password = control("GameSpyLoginProfile.wnd:TextEntryPassword"); + d.remember = control("GameSpyLoginProfile.wnd:CheckBoxRememberInfo"); + d.ok = control("GameSpyLoginProfile.wnd:ButtonLogin"); + d.cancel = control("GameSpyLoginProfile.wnd:ButtonBack"); + d.inner = d.password != nullptr ? d.password->winGetParent() : nullptr; + if (layout == nullptr || d.parent == nullptr || d.panel == nullptr || d.header == nullptr || d.inner == nullptr || d.userLabel == nullptr || d.info == nullptr + || d.title == nullptr || d.passwordLabel == nullptr || d.password == nullptr || d.remember == nullptr || d.ok == nullptr || d.cancel == nullptr) + { + if (layout != nullptr) + { + layout->destroyWindows(); + deleteInstance(layout); + } + return false; + } + for (const char *name : { "ParentESRB", "ComboBoxLoginName", "ComboBoxEmail", "WinAge", "ButtonCreateAccount", "ButtonTOS", "ParentTOS" }) + { + hideRetail(name); + } + d.userName = createEntry(d.password, "GameSpyLoginProfile.wnd:TextEntryProxyUser"); + d.error = createLabel(d.info, "GameSpyLoginProfile.wnd:StaticTextProxyError"); + d.middle = createButton(d.ok, "GameSpyLoginProfile.wnd:ButtonProxyMiddle"); + d.windows = createCheckBox(d.remember, "GameSpyLoginProfile.wnd:CheckBoxProxyWindows"); + if (d.userName == nullptr || d.error == nullptr || d.middle == nullptr || d.windows == nullptr) + { + layout->destroyWindows(); + deleteInstance(layout); + return false; + } + d.testPath = testPath; + d.previousFocus = TheWindowManager->winGetFocus(); + dialog = std::move(d); + + if (EntryData *entry = static_cast(dialog.password->winGetUserData())) + { + entry->secretText = TRUE; + entry->aSCIIOnly = TRUE; + entry->maxTextLen = 64; + } + Int screenWidth = 0; + Int screenHeight = 0; + dialog.parent->winGetSize(&screenWidth, &screenHeight); + // the scale of the layout comes from a retail control (the login button is 180x32 at 800x600), so it follows however the layout was scaled + Int buttonWidthNow = 0; + Int buttonHeightNow = 0; + dialog.ok->winGetSize(&buttonWidthNow, &buttonHeightNow); + dialog.scaleX = buttonWidthNow > 0 ? buttonWidthNow / 180.0f : screenWidth / 800.0f; + dialog.scaleY = buttonHeightNow > 0 ? buttonHeightNow / 32.0f : screenHeight / 600.0f; + + flatLook(dialog.parent, true, GameMakeColor(2, 2, 2, 115), WIN_COLOR_UNDEFINED); + dialog.parent->winClearStatus(WIN_STATUS_NO_INPUT | WIN_STATUS_BORDER); + const Color border = GameMakeColor(47, 55, 168, 255); + flatLook(dialog.panel, true, GameMakeColor(2, 2, 2, 255), border); + flatLook(dialog.inner, false, 0, 0); + flatLook(dialog.header, true, GameMakeColor(0, 0, 0, 0), border); + dialog.header->winHide(FALSE); + dialog.error->winSetEnabledTextColors(GameMakeColor(255, 96, 96, 255), GameMakeColor(0, 0, 0, 255)); + // labels use the font of the entry fields, the title uses the font of the buttons + dialog.title->winSetFont(dialog.ok->winGetFont()); + for (GameWindow *label : { dialog.userLabel, dialog.passwordLabel, dialog.error, dialog.info }) + { + label->winSetFont(dialog.password->winGetFont()); + } + for (GameWindow *label : { dialog.userLabel, dialog.passwordLabel, dialog.error, dialog.title }) + { + setCenteredVertically(label, true); + } + // the status and error messages are centred like the ones of the retail message box, the labels stay left + for (GameWindow *label : { dialog.info, dialog.error }) + { + if (TextData *text = static_cast(label->winGetUserData())) + { + text->centered = TRUE; + } + } + computeMetrics(screenWidth, screenHeight, formMessage); + GadgetStaticTextSetText(dialog.userLabel, UnicodeString(L"Username")); + GadgetStaticTextSetText(dialog.passwordLabel, TheGameText->fetch("GUI:Password")); + GadgetCheckBoxSetText(dialog.remember, TheGameText->fetch("GUI:RememberPassword")); + GadgetCheckBoxSetText(dialog.windows, UnicodeString(L"Use Windows login")); + dialog.windows->winSetTooltip(UnicodeString(L"Log in to the proxy with the Windows account of this PC. Untick it to type a user name and password instead.")); + GadgetButtonSetText(dialog.ok, TheGameText->fetch("GUI:Ok")); + GadgetButtonSetText(dialog.cancel, TheGameText->fetch("GUI:Cancel")); + dialog.middle->winHide(TRUE); + dialog.userName->winSetTooltip(UnicodeString(L"The proxy user name, often DOMAIN\\name.")); + dialog.password->winSetTooltip(UnicodeString(L"The proxy password.")); + dialog.remember->winSetTooltip(UnicodeString(L"Save this login for next time on this PC. Otherwise it is only used until the game closes.")); + GadgetCheckBoxSetChecked(dialog.remember, FALSE); + GadgetTextEntrySetText(dialog.password, UnicodeString::TheEmptyString); + + dialog.parent->winSetSystemFunc(dialogSystem); + dialog.parent->winSetInputFunc(dialogInput); + return true; +} + +void showDialog() +{ + hideMessageBoxes(); + dialog.layout->hide(FALSE); + dialog.layout->bringForward(); + dialog.parent->winBringToTop(); + TheWindowManager->winSetModal(dialog.parent); + TheWindowManager->winSetFocus(dialog.state == State::Form ? dialog.userName : dialog.cancel); +} +} + +void RunOnMainThread(std::function task) +{ + std::lock_guard lock(mainThreadMutex); + mainThreadTasks.push_back(std::move(task)); +} + +void PumpMainThreadTasks() +{ + std::deque> tasks; + { + std::lock_guard lock(mainThreadMutex); + tasks.swap(mainThreadTasks); + } + for (auto &task : tasks) + { + task(); + } +} + +bool IsProxyLoginOpen() +{ + return dialog.layout != nullptr; +} + +void RequestProxyLogin(const std::string &proxyURL, bool windowsLoginFailed, std::function done) +{ + if (!openDialog(false, wideText("The proxy " + ProxyDisplayName(proxyURL) + " needs a login."))) + { + done(false); + return; + } + dialog.proxyURL = proxyURL; + dialog.done = std::move(done); + GadgetTextEntrySetText(dialog.userName, wideText(ProxyUsername(proxyURL))); + GadgetCheckBoxSetChecked(dialog.remember, HasProxyCredentials(proxyURL)); + GadgetCheckBoxSetChecked(dialog.windows, windowsLoginFailed || NGMP_OnlineServicesManager::Settings.Network_GetConnectionSettings().windowsLogin); + showForm(windowsLoginFailed ? wideText("The Windows login was not accepted.") : UnicodeString::TheEmptyString, true); + showDialog(); +} + +// Accept in Options: tests the new settings first and reports what to do with them +void ShowAcceptTest(const ServiceConnectionSettings &settings, std::function decided) +{ + if (!openDialog(true, wideText("The proxy needs a login."))) + { + decided(AcceptChoice::Save); + return; + } + dialog.acceptMode = true; + dialog.settings = settings; + GadgetCheckBoxSetChecked(dialog.windows, settings.windowsLogin); + dialog.decided = std::move(decided); + setState(State::Testing, "Connection test", TheGameText->fetch("WOL:Connecting"), UnicodeString::TheEmptyString); + showDialog(); + startProbe(settings); +} +} diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/ServiceConnectionPolicy.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/ServiceConnectionPolicy.cpp new file mode 100644 index 00000000000..65280791205 --- /dev/null +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/ServiceConnectionPolicy.cpp @@ -0,0 +1,1183 @@ +#include "GameNetwork/GeneralsOnline/ServiceConnectionPolicy.h" +#include "GameNetwork/GeneralsOnline/CredentialStore.h" + +#define WIN32_LEAN_AND_MEAN +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#pragma comment(lib, "winhttp.lib") + +namespace ServiceConnection +{ +namespace +{ +// WinHTTP's proxy schemes; the precompiled header includes wininet.h, whose INTERNET_SCHEME_* values differ +constexpr INTERNET_SCHEME WinHttpSchemeHttp = (INTERNET_SCHEME)1; +constexpr INTERNET_SCHEME WinHttpSchemeHttps = (INTERNET_SCHEME)2; + +std::string Lower(std::string value) +{ + std::transform(value.begin(), value.end(), value.begin(), [](unsigned char c) { return (char)std::tolower(c); }); + return value; +} + +std::wstring Wide(const std::string& value) +{ + if (value.empty()) + return {}; + int size = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, value.data(), (int)value.size(), nullptr, 0); + if (!size) + return {}; + std::wstring result(size, L'\0'); + MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, value.data(), (int)value.size(), &result[0], size); + return result; +} + +std::string UTF8(const std::wstring& value) +{ + if (value.empty()) + return {}; + int size = WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, value.data(), (int)value.size(), nullptr, 0, nullptr, nullptr); + if (!size) + return {}; + std::string result(size, '\0'); + WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, value.data(), (int)value.size(), &result[0], size, nullptr, nullptr); + return result; +} + +std::string Part(CURLU* url, CURLUPart part, unsigned flags = 0) +{ + char* value = nullptr; + if (curl_url_get(url, part, &value, flags) != CURLUE_OK) + return {}; + std::string result(value); + curl_free(value); + return result; +} + +bool CanonicalProxy(const std::string& typed, std::string& canonical, std::string& error) +{ + error.clear(); + // host:port without a scheme means an http proxy, so it maps to the same key as http://host:port + const std::string input = typed.find("://") == std::string::npos && !typed.empty() ? "http://" + typed : typed; + CURLU* url = curl_url(); + if (!url) + { + error = "Unable to allocate proxy URL parser."; + return false; + } + bool valid = input.find_first_of("\r\n\t ") == std::string::npos && + curl_url_set(url, CURLUPART_URL, input.c_str(), CURLU_NON_SUPPORT_SCHEME) == CURLUE_OK; + const auto scheme = Lower(Part(url, CURLUPART_SCHEME)); + const auto host = Lower(Part(url, CURLUPART_HOST)); + auto port = Part(url, CURLUPART_PORT); + const auto path = Part(url, CURLUPART_PATH); + const auto zone = Part(url, CURLUPART_ZONEID); + valid = valid && (scheme == "http" || scheme == "https" || scheme == "socks5h") && !host.empty() && zone.empty() && port != "0" && + input.find('@') == std::string::npos && Part(url, CURLUPART_USER).empty() && Part(url, CURLUPART_PASSWORD).empty() && + input.find('?') == std::string::npos && input.find('#') == std::string::npos && (path.empty() || path == "/"); + if (valid) + { + if (port.empty()) + port = scheme == "https" ? "443" : scheme == "http" ? "80" : "1080"; + canonical = scheme + "://" + host + ":" + port; + } + else + error = "Enter an http://, https:// or socks5h:// proxy address without credentials, path or query."; + curl_url_cleanup(url); + return valid; +} + + +struct SessionLogin +{ + std::string user; + std::string secret; +}; + +std::mutex& SessionLoginMutex() +{ + static std::mutex mutex; + return mutex; +} + +std::map& SessionLogins() +{ + static std::map logins; + return logins; +} + +void WipeSessionLogin(SessionLogin& login) +{ + if (!login.secret.empty()) + SecureZeroMemory(&login.secret[0], login.secret.size()); + login.secret.clear(); +} + +bool FindSessionLogin(const std::string& canonical, SessionLogin& login) +{ + std::lock_guard lock(SessionLoginMutex()); + const auto found = SessionLogins().find(canonical); + if (found == SessionLogins().end()) + return false; + login = found->second; + return true; +} + +bool Wildcard(const std::string& pattern, const std::string& value) +{ + size_t p = 0, v = 0, star = std::string::npos, retry = 0; + while (v < value.size()) + { + if (p < pattern.size() && (pattern[p] == '?' || pattern[p] == value[v])) + { + ++p; + ++v; + } + else if (p < pattern.size() && pattern[p] == '*') + { + star = p++; + retry = v; + } + else if (star != std::string::npos) + { + p = star + 1; + v = ++retry; + } + else + return false; + } + while (p < pattern.size() && pattern[p] == '*') + ++p; + return p == pattern.size(); +} + +std::vector Tokens(std::string input) +{ + std::replace(input.begin(), input.end(), ';', ' '); + std::istringstream stream(input); + std::vector result; + for (std::string token; stream >> token;) + result.push_back(token); + return result; +} + +bool StaticProxy(const std::wstring& proxy, const std::wstring& bypass, const std::string& destination, + ResolvedProxyPolicy& result) +{ + CURLU* url = curl_url(); + if (!url || curl_url_set(url, CURLUPART_URL, destination.c_str(), 0) != CURLUE_OK) + { + if (url) + curl_url_cleanup(url); + result.error = "Invalid proxy lookup destination."; + return false; + } + const auto host = Lower(Part(url, CURLUPART_HOST)); + const auto scheme = Lower(Part(url, CURLUPART_SCHEME)); + const auto port = Part(url, CURLUPART_PORT, CURLU_DEFAULT_PORT); + curl_url_cleanup(url); + for (auto token : Tokens(Lower(UTF8(bypass)))) + { + if ((token == "" && host.find('.') == std::string::npos && host.find(':') == std::string::npos) || + Wildcard(token, host) || Wildcard(token, host + ":" + port)) + { + result.routes.push_back({}); + return true; + } + } + bool specific = false; + bool socksFallback = false; + const auto entries = Tokens(UTF8(proxy)); + if (entries.empty()) + { + result.error = "Windows has an empty static proxy list."; + return false; + } + for (const auto& entry : entries) + { + const auto equal = entry.find('='); + if (equal == std::string::npos) + continue; + const auto protocol = Lower(entry.substr(0, equal)); + if (protocol == scheme) + specific = true; + if (protocol == "socks") + socksFallback = true; + } + for (auto entry : entries) + { + const auto equal = entry.find('='); + if (equal != std::string::npos) + { + if (Lower(entry.substr(0, equal)) != scheme) + continue; + entry.erase(0, equal + 1); + } + else if (specific) + continue; + if (entry.find("://") == std::string::npos) + entry = "http://" + entry; + std::string canonical; + if (!CanonicalProxy(entry, canonical, result.error)) + { + result.routes.clear(); + return false; + } + result.routes.push_back({canonical, false, canonical.find("socks5h://") != 0}); + } + // A protocol-specific system configuration without this protocol explicitly leaves it direct. + if (result.routes.empty()) + { + if (socksFallback) + { + result.error = "Windows SOCKS fallback is unsupported. Select an explicit supported proxy."; + return false; + } + result.routes.push_back({}); + } + return true; +} + +struct ResolverContext +{ + std::atomic references{1}; + std::atomic error{ERROR_IO_PENDING}; + HANDLE completed = CreateEventW(nullptr, TRUE, FALSE, nullptr); + HANDLE closing = CreateEventW(nullptr, TRUE, FALSE, nullptr); + void Release() + { + if (references.fetch_sub(1) == 1) + { + if (completed) + CloseHandle(completed); + if (closing) + CloseHandle(closing); + delete this; + } + } +}; + +void CALLBACK ProxyCallback(HINTERNET, DWORD_PTR value, DWORD status, void* information, DWORD) +{ + auto* context = reinterpret_cast(value); + if (!context) + return; + if (status == WINHTTP_CALLBACK_STATUS_HANDLE_CLOSING) + { + // signal before releasing: this reference keeps the event alive + if (context->closing) + SetEvent(context->closing); + context->Release(); + return; + } + if (status == WINHTTP_CALLBACK_STATUS_GETPROXYFORURL_COMPLETE || status == WINHTTP_CALLBACK_STATUS_REQUEST_ERROR) + { + DWORD error = status == WINHTTP_CALLBACK_STATUS_REQUEST_ERROR ? + static_cast(information)->dwError : ERROR_SUCCESS; + context->error.store(error, std::memory_order_release); + SetEvent(context->completed); + } +} + +// websocket lookups use the matching http scheme +std::string ProxyDestination(std::string destination) +{ + if (destination.compare(0, 6, "wss://") == 0) + destination.replace(0, 6, "https://"); + else if (destination.compare(0, 5, "ws://") == 0) + destination.replace(0, 5, "http://"); + return destination; +} + +ResolvedProxyPolicy AutoProxy(const std::wstring& destination, WINHTTP_AUTOPROXY_OPTIONS& options) +{ + ResolvedProxyPolicy result; + HINTERNET session = WinHttpOpen(L"GeneralsOnline", WINHTTP_ACCESS_TYPE_NO_PROXY, nullptr, nullptr, WINHTTP_FLAG_ASYNC); + if (!session) + { + result.nativeError = GetLastError(); + result.failurePhase = "proxy-discovery"; + result.error = "Unable to initialize Windows proxy resolution."; + return result; + } + WinHttpSetTimeouts(session, 5000, 5000, 5000, 5000); + HINTERNET resolver = nullptr; + DWORD status = ERROR_SUCCESS; + if (WinHttpSetStatusCallback(session, ProxyCallback, WINHTTP_CALLBACK_FLAG_REQUEST_ERROR | + WINHTTP_CALLBACK_FLAG_GETPROXYFORURL_COMPLETE | WINHTTP_CALLBACK_FLAG_HANDLES, 0) == WINHTTP_INVALID_STATUS_CALLBACK) + status = GetLastError(); + if (!status) + status = WinHttpCreateProxyResolver(session, &resolver); + auto* context = new ResolverContext; + bool callbackOwnsReference = false; + if (!context->completed || !context->closing) + status = GetLastError(); + if (!status) + { + DWORD_PTR value = reinterpret_cast(context); + if (!WinHttpSetOption(resolver, WINHTTP_OPTION_CONTEXT_VALUE, &value, sizeof(value))) + status = GetLastError(); + else + { + ++context->references; + callbackOwnsReference = true; + } + } + if (!status) + { + status = WinHttpGetProxyForUrlEx(resolver, destination.c_str(), &options, reinterpret_cast(context)); + if (status == ERROR_IO_PENDING) + { + const DWORD wait = WaitForSingleObject(context->completed, 5000); + status = wait == WAIT_OBJECT_0 ? context->error.load(std::memory_order_acquire) : ERROR_TIMEOUT; + } + } + if (!status) + { + WINHTTP_PROXY_RESULT proxies{}; + status = WinHttpGetProxyResult(resolver, &proxies); + if (!status) + { + for (DWORD i = 0; i < proxies.cEntries; ++i) + { + const auto& entry = proxies.pEntries[i]; + if (!entry.fProxy) + { + result.routes.push_back({}); + continue; + } + std::string scheme; + if (entry.ProxyScheme == WinHttpSchemeHttp) + scheme = "http"; + else if (entry.ProxyScheme == WinHttpSchemeHttps) + scheme = "https"; + else + { + result.error = "Windows returned an unsupported proxy protocol."; + break; + } + std::string host = entry.pwszProxy ? UTF8(entry.pwszProxy) : std::string(); + if (host.find(':') != std::string::npos && host.front() != '[') + host = "[" + host + "]"; + std::string canonical; + if (!CanonicalProxy(scheme + "://" + host + ":" + std::to_string(entry.ProxyPort), canonical, result.error)) + break; + result.routes.push_back({canonical, false, canonical.find("socks5h://") != 0}); + } + WinHttpFreeProxyResult(&proxies); + } + } + if (resolver) + WinHttpCloseHandle(resolver); + // the resolver's final callback must run while its session is still open + if (callbackOwnsReference) + WaitForSingleObject(context->closing, 5000); + WinHttpCloseHandle(session); + // the callback owns the second reference, so a late or missed callback never frees a live context + context->Release(); + result.nativeError = status; + if (status) + result.error = "Windows proxy resolution failed (" + std::to_string(status) + "). Check system proxy settings."; + if (result.routes.empty() && result.error.empty()) + result.error = "Windows returned no permitted proxy route."; + if (!result.error.empty()) + { + result.routes.clear(); + result.failurePhase = status == ERROR_TIMEOUT || status == ERROR_WINHTTP_TIMEOUT ? "proxy-timeout" : + (options.dwFlags & WINHTTP_AUTOPROXY_CONFIG_URL) ? "proxy-pac" : "proxy-discovery"; + } + return result; +} + +ResolvedProxyPolicy ApplyOptionalWpadFallback(ResolvedProxyPolicy result, bool autoDetect, bool explicitPac, + const std::wstring& proxy, const std::wstring& bypass, const std::string& destination) +{ + // No discovered WPAD script is different from failing to load or execute a configured script. + if (!autoDetect || explicitPac || result.nativeError != ERROR_WINHTTP_AUTODETECTION_FAILED || result.error.empty()) + return result; + result.routes.clear(); + result.error.clear(); + result.failurePhase.clear(); + if (!proxy.empty()) + { + if (!StaticProxy(proxy, bypass, destination, result)) + { + result.failurePhase = "proxy-configuration"; + return result; + } + result.fallbackReason = "No WPAD configuration found; using the system static proxy policy."; + } + else + { + result.routes.push_back({}); + result.fallbackReason = "No WPAD configuration or static proxy found; using direct access."; + } + return result; +} +} + +bool ValidateProxyURL(const std::string& proxyURL, std::string& error) +{ + std::string canonical; + return CanonicalProxy(proxyURL, canonical, error); +} + +namespace +{ +ResolvedProxyPolicy ResolveProxyRoutes(const ServiceConnectionSettings& settings, const std::string& destinationURL) +{ + ResolvedProxyPolicy result; + if (settings.proxyMode == "disabled") + { + result.routes.push_back({}); + return result; + } + if (settings.proxyMode == "custom") + { + std::string canonical; + if (CanonicalProxy(settings.proxyURL, canonical, result.error)) + result.routes.push_back({canonical, false, settings.windowsLogin && canonical.find("socks5h://") != 0}); + else + result.failurePhase = "proxy-configuration"; + return result; + } + if (settings.proxyMode != "automatic") + { + result.error = "Unknown proxy mode."; + result.failurePhase = "proxy-configuration"; + return result; + } + const std::string destination = ProxyDestination(destinationURL); + WINHTTP_CURRENT_USER_IE_PROXY_CONFIG config{}; + if (!WinHttpGetIEProxyConfigForCurrentUser(&config)) + { + result.nativeError = GetLastError(); + result.failurePhase = "proxy-settings"; + result.error = "Unable to read Windows proxy settings (" + std::to_string(result.nativeError) + ")."; + return result; + } + const std::wstring proxy = config.lpszProxy ? config.lpszProxy : L""; + const std::wstring bypass = config.lpszProxyBypass ? config.lpszProxyBypass : L""; + const std::wstring pac = config.lpszAutoConfigUrl ? config.lpszAutoConfigUrl : L""; + const bool autoDetect = config.fAutoDetect != FALSE; + GlobalFree(config.lpszProxy); GlobalFree(config.lpszProxyBypass); GlobalFree(config.lpszAutoConfigUrl); + if (!pac.empty() || autoDetect) + { + WINHTTP_AUTOPROXY_OPTIONS options{}; + if (!pac.empty()) + { + options.dwFlags |= WINHTTP_AUTOPROXY_CONFIG_URL; + options.lpszAutoConfigUrl = pac.c_str(); + } + if (autoDetect) + { + options.dwFlags |= WINHTTP_AUTOPROXY_AUTO_DETECT; + options.dwAutoDetectFlags = WINHTTP_AUTO_DETECT_TYPE_DHCP | WINHTTP_AUTO_DETECT_TYPE_DNS_A; + } + options.fAutoLogonIfChallenged = FALSE; + return ApplyOptionalWpadFallback(AutoProxy(Wide(destination), options), autoDetect, !pac.empty(), + proxy, bypass, destination); + } + if (!proxy.empty()) + { + if (!StaticProxy(proxy, bypass, destination, result)) + result.failurePhase = "proxy-configuration"; + } + else + result.routes.push_back({}); + return result; +} +} + +ResolvedProxyPolicy ResolveProxyPolicy(const ServiceConnectionSettings& settings, const std::string& destinationURL) +{ + ResolvedProxyPolicy result = ResolveProxyRoutes(settings, destinationURL); + // the Windows login is only tried when the player wants it + if (!settings.windowsLogin) + { + for (auto& route : result.routes) + route.allowIntegratedAuth = false; + } + return result; +} + +namespace +{ +using ProxyClock = std::chrono::steady_clock; +constexpr size_t MaxProxyJobs = 64; +constexpr size_t MaxProxyWaiters = 64; +constexpr size_t MaxProxyCache = 64; +constexpr auto ProxyCacheTTL = std::chrono::seconds(60); +constexpr auto ProxyFailureTTL = std::chrono::seconds(30); + +void KeyPart(std::string& key, const std::string& value) +{ + key += std::to_string(value.size()) + ":" + value; +} + +// Failures are shared per PAC server, not per URL; empty when no script is fetched +std::string PolicySource(const std::string& pacURL, bool autoDetect) +{ + if (pacURL.empty()) + { + return autoDetect ? "wpad" : std::string(); + } + const auto schemeEnd = pacURL.find("://"); + const auto start = schemeEnd == std::string::npos ? 0 : schemeEnd + 3; + return "pac:" + Lower(pacURL.substr(0, pacURL.find_first_of("/?#", start))); +} + +bool IsCacheableProxyFailure(const ResolvedProxyPolicy& result) +{ + return !result.error.empty() && (result.failurePhase == "proxy-timeout" || + result.failurePhase == "proxy-pac" || result.failurePhase == "proxy-discovery"); +} + +// In-memory only: PAC decisions can depend on the full path and query +bool ProxyKey(const ServiceConnectionSettings& settings, const std::string& destination, + std::string& key, ResolvedProxyPolicy& failure, std::string* systemFingerprint = nullptr, + std::string* failureSource = nullptr) +{ + KeyPart(key, ProxyDestination(destination)); + KeyPart(key, settings.proxyMode); + KeyPart(key, settings.proxyURL); + key += settings.windowsLogin ? "1" : "0"; + WINHTTP_CURRENT_USER_IE_PROXY_CONFIG config{}; + if (!WinHttpGetIEProxyConfigForCurrentUser(&config)) + { + failure.nativeError = GetLastError(); + failure.failurePhase = "proxy-settings"; + failure.error = "Unable to read Windows proxy settings (" + std::to_string(failure.nativeError) + ")."; + return false; + } + const auto systemStart = key.size(); + key += config.fAutoDetect ? "1" : "0"; + KeyPart(key, config.lpszProxy ? UTF8(config.lpszProxy) : ""); + KeyPart(key, config.lpszProxyBypass ? UTF8(config.lpszProxyBypass) : ""); + KeyPart(key, config.lpszAutoConfigUrl ? UTF8(config.lpszAutoConfigUrl) : ""); + if (systemFingerprint) + { + *systemFingerprint = key.substr(systemStart); + } + if (failureSource) + { + *failureSource = PolicySource(config.lpszAutoConfigUrl ? UTF8(config.lpszAutoConfigUrl) : "", config.fAutoDetect != FALSE); + } + GlobalFree(config.lpszProxy); + GlobalFree(config.lpszProxyBypass); + GlobalFree(config.lpszAutoConfigUrl); + return true; +} + +struct ProxyWork +{ + ServiceConnectionSettings settings; + std::string destination; + std::string key; + std::string failureKey; + unsigned long long generation = 0; + std::vector> waiters; +}; +struct ProxyCacheEntry +{ + ResolvedProxyPolicy result; + ProxyClock::time_point expires; +}; +struct ProxyQueue +{ + std::mutex mutex; + std::deque> pending; + std::map> active; + std::map cache; + std::map failures; + std::string lastSystemKey; + unsigned long long generation = 0; + unsigned workers = 0; +}; +std::shared_ptr ProxyQueueState() +{ + static auto queue = std::make_shared(); + return queue; +} +void CompleteProxy(ProxyResolution& state, const ResolvedProxyPolicy& result) +{ + state.result = result; + state.ready.store(true, std::memory_order_release); +} +void ProxyQueueFailure(ProxyResolution& state, const char* message) +{ + ResolvedProxyPolicy result; + result.error = message; + result.failurePhase = "proxy-policy"; + CompleteProxy(state, result); +} +void PruneProxyEntries(std::map& entries, ProxyClock::time_point now) +{ + for (auto it = entries.begin(); it != entries.end();) + { + if (it->second.expires <= now) + { + it = entries.erase(it); + } + else + { + ++it; + } + } +} +void PruneProxyCache(ProxyQueue& queue, ProxyClock::time_point now) +{ + PruneProxyEntries(queue.cache, now); + PruneProxyEntries(queue.failures, now); +} +void FinishProxyWork(ProxyQueue& queue, const std::shared_ptr& work, + const ResolvedProxyPolicy& result, ProxyClock::time_point now) +{ + if (work->generation == queue.generation && result.error.empty() && !result.routes.empty()) + { + PruneProxyCache(queue, now); + if (queue.cache.size() >= MaxProxyCache) + { + queue.cache.erase(queue.cache.begin()); + } + queue.cache[work->key] = {result, now + ProxyCacheTTL}; + } + else if (work->generation == queue.generation && !work->failureKey.empty() && IsCacheableProxyFailure(result)) + { + PruneProxyCache(queue, now); + if (queue.failures.size() >= MaxProxyCache) + { + queue.failures.erase(queue.failures.begin()); + } + queue.failures[work->failureKey] = {result, now + ProxyFailureTTL}; + } + for (auto& waiter : work->waiters) + { + if (auto state = waiter.lock()) + { + CompleteProxy(*state, result); + } + } + const auto active = queue.active.find(work->key); + if (active != queue.active.end() && active->second == work) + { + queue.active.erase(active); + } +} +std::shared_ptr QueueProxyWork(ProxyQueue& queue, const ServiceConnectionSettings& settings, + const std::string& destinationURL, const std::string& key, const std::shared_ptr& state, + const std::string& failureKey = std::string(), ProxyClock::time_point now = ProxyClock::now()) +{ + const auto cached = queue.cache.find(key); + if (cached != queue.cache.end()) + { + CompleteProxy(*state, cached->second.result); + return {}; + } + // a dead PAC server is probed once per TTL, not once per URL + const auto failed = failureKey.empty() ? queue.failures.end() : queue.failures.find(failureKey); + if (failed != queue.failures.end() && failed->second.expires > now) + { + CompleteProxy(*state, failed->second.result); + return {}; + } + const auto active = queue.active.find(key); + if (active != queue.active.end()) + { + auto& waiters = active->second->waiters; + waiters.erase(std::remove_if(waiters.begin(), waiters.end(), + [](const std::weak_ptr& waiter) { return waiter.expired(); }), waiters.end()); + if (waiters.size() >= MaxProxyWaiters) + { + ProxyQueueFailure(*state, "Too many pending proxy lookups. Try again shortly."); + } + else + { + waiters.push_back(state); + } + return {}; + } + if (queue.pending.size() + queue.workers >= MaxProxyJobs) + { + ProxyQueueFailure(*state, "Too many pending proxy lookups. Try again shortly."); + return {}; + } + auto work = std::make_shared(); + work->settings = settings; + work->destination = destinationURL; + work->key = key; + work->failureKey = failureKey; + work->generation = queue.generation; + work->waiters.push_back(state); + queue.active[work->key] = work; + queue.pending.push_back(work); + return work; +} + +} + +void InvalidateProxyPolicyCache() +{ + auto queue = ProxyQueueState(); + std::lock_guard lock(queue->mutex); + ++queue->generation; + queue->cache.clear(); + queue->failures.clear(); + // callers already waiting keep their lookup, new callers start a fresh one + queue->active.clear(); +} + +bool ShouldLogProxyPolicy(bool websocket, const ResolvedProxyPolicy& result) +{ + static std::mutex mutex; + static std::string previous[2]; + std::string signature; + KeyPart(signature, result.fallbackReason); + for (const auto& route : result.routes) + { + signature += route.direct ? "D" : "P"; + KeyPart(signature, route.url); + signature += route.allowIntegratedAuth ? "1" : "0"; + } + std::lock_guard lock(mutex); + auto& last = previous[websocket ? 1 : 0]; + if (last == signature) + { + return false; + } + last = std::move(signature); + return true; +} + +std::shared_ptr StartProxyResolution(ServiceConnectionSettings settings, std::string destinationURL) +{ + auto state = std::make_shared(); + if (destinationURL.size() > 16384 || settings.proxyURL.size() > 2048) + { + ProxyQueueFailure(*state, "Proxy lookup configuration is too long."); + return state; + } + // Explicit direct/custom policy needs neither discovery nor a background worker + if (settings.proxyMode != "automatic") + { + CompleteProxy(*state, ResolveProxyPolicy(settings, destinationURL)); + return state; + } + std::string key, systemFingerprint, failureSource; + ResolvedProxyPolicy failure; + if (!ProxyKey(settings, destinationURL, key, failure, &systemFingerprint, &failureSource)) + { + CompleteProxy(*state, failure); + return state; + } + auto queue = ProxyQueueState(); + std::lock_guard lock(queue->mutex); + if (queue->lastSystemKey != systemFingerprint) + { + ++queue->generation; + queue->cache.clear(); + queue->failures.clear(); + queue->active.clear(); + queue->lastSystemKey = std::move(systemFingerprint); + } + const auto now = ProxyClock::now(); + PruneProxyCache(*queue, now); + auto work = QueueProxyWork(*queue, settings, destinationURL, key, state, failureSource, now); + if (!work) + { + return state; + } + if (queue->workers >= 4) + { + return state; + } + ++queue->workers; + try + { + std::thread([queue]() { + for (;;) + { + std::shared_ptr work; + { + std::lock_guard lock(queue->mutex); + if (queue->pending.empty()) + { + --queue->workers; + return; + } + work = std::move(queue->pending.front()); + queue->pending.pop_front(); + const bool canceled = std::all_of(work->waiters.begin(), work->waiters.end(), + [](const std::weak_ptr& waiter) { return waiter.expired(); }); + if (canceled) + { + const auto active = queue->active.find(work->key); + if (active != queue->active.end() && active->second == work) + { + queue->active.erase(active); + } + continue; + } + } + ResolvedProxyPolicy result; + try + { + result = ResolveProxyPolicy(work->settings, work->destination); + std::string currentKey; + ResolvedProxyPolicy changed; + if (!ProxyKey(work->settings, work->destination, currentKey, changed)) + { + result = std::move(changed); + } + else if (currentKey != work->key) + { + result = {}; + result.error = "Windows proxy settings changed during lookup. Reconnect to retry."; + result.failurePhase = "proxy-settings"; + } + } + catch (...) + { + result.error = "Proxy resolution failed."; + result.failurePhase = "proxy-policy"; + } + std::lock_guard lock(queue->mutex); + FinishProxyWork(*queue, work, result, ProxyClock::now()); + } + }).detach(); + } + catch (...) + { + --queue->workers; + queue->pending.pop_back(); + queue->active.erase(work->key); + ProxyQueueFailure(*state, "Unable to start proxy resolution."); + } + return state; +} + +bool SaveProxyCredentials(const std::string& proxyURL, const std::string& username, + const std::string& password, std::string& error) +{ + std::string canonical; + if (!CanonicalProxy(proxyURL, canonical, error)) + return false; + if (username.empty() || username.find('\0') != std::string::npos || password.find('\0') != std::string::npos) + { + error = "Proxy credentials are invalid."; + return false; + } + if (!SaveProxyLogin(canonical, username, password)) + { + error = "Unable to save proxy credentials. They are used until the game closes."; + return false; + } + return true; +} + +void SetSessionProxyCredentials(const std::string& proxyURL, const std::string& username, const std::string& password) +{ + std::string canonical, error; + if (!CanonicalProxy(proxyURL, canonical, error)) + return; + std::lock_guard lock(SessionLoginMutex()); + auto& login = SessionLogins()[canonical]; + WipeSessionLogin(login); + login.user = username; + login.secret = password; +} + +void ClearSessionProxyCredentials(const std::string& proxyURL) +{ + std::string canonical, error; + if (!CanonicalProxy(proxyURL, canonical, error)) + return; + std::lock_guard lock(SessionLoginMutex()); + const auto found = SessionLogins().find(canonical); + if (found == SessionLogins().end()) + return; + WipeSessionLogin(found->second); + SessionLogins().erase(found); +} + +void ClearSessionProxyCredentialsExcept(const std::string& keepProxyURL) +{ + std::string keep, error; + CanonicalProxy(keepProxyURL, keep, error); + std::lock_guard lock(SessionLoginMutex()); + for (auto it = SessionLogins().begin(); it != SessionLogins().end();) + { + if (it->first == keep) + { + ++it; + continue; + } + WipeSessionLogin(it->second); + it = SessionLogins().erase(it); + } +} + +std::string CanonicalProxyKey(const std::string& proxyURL) +{ + std::string canonical, error; + return CanonicalProxy(proxyURL, canonical, error) ? canonical : std::string(); +} + +std::string ProxyUsername(const std::string& proxyURL) +{ + std::string canonical, error; + if (!CanonicalProxy(proxyURL, canonical, error)) + return {}; + SessionLogin session; + if (FindSessionLogin(canonical, session)) + { + WipeSessionLogin(session); + return session.user; + } + std::string user, password; + const bool found = LoadProxyLogin(canonical, user, password); + if (!password.empty()) + SecureZeroMemory(&password[0], password.size()); + return found ? user : std::string(); +} + +std::string ProxyDisplayName(const std::string& proxyURL) +{ + const auto schemeEnd = proxyURL.find("://"); + return schemeEnd == std::string::npos ? proxyURL : proxyURL.substr(schemeEnd + 3); +} + +bool HasProxyCredentials(const std::string& proxyURL) +{ + std::string canonical, error; + std::string user, password; + const bool found = CanonicalProxy(proxyURL, canonical, error) && LoadProxyLogin(canonical, user, password); + if (!password.empty()) + SecureZeroMemory(&password[0], password.size()); + return found; +} + +bool DeleteProxyCredentials(const std::string& proxyURL, std::string& error) +{ + std::string canonical; + if (!CanonicalProxy(proxyURL, canonical, error)) + return false; + if (DeleteProxyLogin(canonical)) + return true; + error = "Unable to remove proxy credentials."; + return false; +} + +bool ConfigureCurl(CURL* curl, const std::string& destinationURL, const ProxyRoute& route, std::string& error) +{ + if (!ConfigureServiceTransport(curl, destinationURL, error)) + return false; + const bool websocket = destinationURL.compare(0, 6, "wss://") == 0; + auto set = [&](CURLoption option, auto value) { + CURLcode code = curl_easy_setopt(curl, option, value); + if (code != CURLE_OK && error.empty()) + error = "Unable to configure service transport: " + std::string(curl_easy_strerror(code)); + return code == CURLE_OK; + }; + const auto* version = curl_version_info(CURLVERSION_NOW); + const long protocol = websocket ? CURL_HTTP_VERSION_1_1 : + route.direct && (version->features & CURL_VERSION_HTTP3) ? CURL_HTTP_VERSION_3 : + (version->features & CURL_VERSION_HTTP2) ? CURL_HTTP_VERSION_2TLS : CURL_HTTP_VERSION_1_1; + if (!set(CURLOPT_HTTP_VERSION, protocol) || !set(CURLOPT_PROXY_SSL_VERIFYPEER, 1L) || !set(CURLOPT_PROXY_SSL_VERIFYHOST, 2L) || + !set(CURLOPT_PROXY_SSL_OPTIONS, (long)CURLSSLOPT_NATIVE_CA) || !set(CURLOPT_SOCKS5_AUTH, (long)CURLAUTH_BASIC) || + !set(CURLOPT_HEADEROPT, (long)CURLHEADER_SEPARATE) || !set(CURLOPT_NOPROXY, "") || !set(CURLOPT_PROXYAUTH, (long)CURLAUTH_NONE)) + return false; + if (route.direct) + return true; + std::string canonical; + if (!CanonicalProxy(route.url, canonical, error)) + return false; + if (!set(CURLOPT_PROXY, canonical.c_str())) + return false; + if (route.allowIntegratedAuth) + { + if (!(version->features & CURL_VERSION_SSPI)) + { + error = "This build does not support Windows proxy authentication."; + return false; + } + return set(CURLOPT_PROXYAUTH, (long)CURLAUTH_NEGOTIATE) && set(CURLOPT_PROXYUSERPWD, ":"); + } + // otherwise a login typed for this session wins over the saved one + SessionLogin session; + if (FindSessionLogin(canonical, session)) + { + const bool success = set(CURLOPT_PROXYUSERNAME, session.user.c_str()) && set(CURLOPT_PROXYPASSWORD, session.secret.c_str()) && + set(CURLOPT_PROXYAUTH, (long)(CURLAUTH_BASIC | CURLAUTH_DIGEST | CURLAUTH_NEGOTIATE)); + WipeSessionLogin(session); + return success; + } + std::string user, secret; + if (LoadProxyLogin(canonical, user, secret)) + { + const bool success = set(CURLOPT_PROXYUSERNAME, user.c_str()) && set(CURLOPT_PROXYPASSWORD, secret.c_str()) && + set(CURLOPT_PROXYAUTH, (long)(CURLAUTH_BASIC | CURLAUTH_DIGEST | CURLAUTH_NEGOTIATE)); + if (!secret.empty()) + SecureZeroMemory(&secret[0], secret.size()); + return success; + } + return true; +} + +const char* ProtocolName(long version) +{ + switch (version) + { + case CURL_HTTP_VERSION_1_0: return "HTTP/1.0"; + case CURL_HTTP_VERSION_1_1: return "HTTP/1.1"; + case CURL_HTTP_VERSION_2_0: return "HTTP/2"; + case CURL_HTTP_VERSION_3: return "HTTP/3"; + default: return "unknown"; + } +} + +const char* FailurePhase(CURLcode code, long status, long proxyStatus) +{ + if (proxyStatus == 407 || status == 407) + return "proxy authentication"; + if (code == CURLE_COULDNT_RESOLVE_PROXY) + return "proxy DNS"; + if (code == CURLE_COULDNT_RESOLVE_HOST) + return "endpoint DNS"; + if (code == CURLE_PEER_FAILED_VERIFICATION || code == CURLE_SSL_CONNECT_ERROR || + code == CURLE_SSL_CACERT_BADFILE || code == CURLE_SSL_CERTPROBLEM) + return "TLS"; + if (code == CURLE_COULDNT_CONNECT) + return "connection"; + if (code == CURLE_OPERATION_TIMEDOUT) + return "timeout"; + if (status == 401 || status == 403) + return "service authentication"; + if (code != CURLE_OK) + return "transport"; + if (status >= 400) + return "service response"; + return "none"; +} + +namespace +{ +size_t ReceiveProbe(void* data, size_t size, size_t count, void* target) +{ + if (size && count > SIZE_MAX / size) + return 0; + const size_t bytes = size * count; + auto& body = *static_cast(target); + if (bytes > 4096 || body.size() > 4096 - bytes) + return 0; + body.append(static_cast(data), bytes); + return bytes; +} +} + +ConnectionTestResult TestServiceConnection(const ServiceConnectionSettings& settings, const std::string& environment, int maxSeconds) +{ + using Outcome = ConnectionTestResult::Outcome; + ConnectionTestResult result; + result.outcome = Outcome::Unreachable; + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(maxSeconds); + for (const auto& base : BootstrapRoutes(environment, settings, "")) + { + const std::string url = base + "VersionManifest"; + const std::string routeName = base.find("://api-ru.") != std::string::npos ? "alternative" : "primary"; + const auto policy = ResolveProxyPolicy(settings, url); + if (!policy.error.empty()) + { + result.outcome = Outcome::Failed; + result.detail = policy.error; + return result; + } + for (const auto& route : policy.routes) + { + if (std::chrono::steady_clock::now() >= deadline) + return result; + std::unique_ptr curl(curl_easy_init(), curl_easy_cleanup); + std::string error, body; + if (!ConfigureCurl(curl.get(), url, route, error)) + { + result.outcome = Outcome::Failed; + result.detail = error; + return result; + } + if (curl_easy_setopt(curl.get(), CURLOPT_URL, url.c_str()) != CURLE_OK || + curl_easy_setopt(curl.get(), CURLOPT_CONNECTTIMEOUT_MS, 4000L) != CURLE_OK || + curl_easy_setopt(curl.get(), CURLOPT_TIMEOUT_MS, 6000L) != CURLE_OK || + curl_easy_setopt(curl.get(), CURLOPT_WRITEFUNCTION, ReceiveProbe) != CURLE_OK || + curl_easy_setopt(curl.get(), CURLOPT_WRITEDATA, &body) != CURLE_OK) + { + result.outcome = Outcome::Failed; + result.detail = "Could not set up the test."; + return result; + } + const auto code = curl_easy_perform(curl.get()); + long status = 0, proxyStatus = 0; + curl_easy_getinfo(curl.get(), CURLINFO_RESPONSE_CODE, &status); + curl_easy_getinfo(curl.get(), CURLINFO_HTTP_CONNECTCODE, &proxyStatus); + result.route = routeName; + result.proxyURL = route.direct ? std::string() : route.url; + result.proxyAnswered = proxyStatus != 0; + if (status == 407 || proxyStatus == 407) + { + result.outcome = Outcome::ProxyAuthRequired; + result.detail = ProxyUsername(route.url).empty() ? "required" : "refused"; + return result; + } + if (status == 401 || status == 403) + { + result.outcome = Outcome::ServiceRejected; + return result; + } + if (code == CURLE_OK && status == 200 && IsExpectedVersionManifest(body)) + { + result.outcome = Outcome::Connected; + return result; + } + if (code == CURLE_PEER_FAILED_VERIFICATION || code == CURLE_SSL_CONNECT_ERROR) + { + result.outcome = Outcome::Failed; + result.detail = "Secure connection failed. Check certificates or TLS inspection."; + } + else if (code == CURLE_OK) + { + result.outcome = Outcome::Failed; + result.detail = "Unexpected reply from the service (HTTP " + std::to_string(status) + ")."; + } + else + result.outcome = Outcome::Unreachable; + } + } + return result; +} + +std::string DescribeConnectionTest(const ConnectionTestResult& result) +{ + using Outcome = ConnectionTestResult::Outcome; + switch (result.outcome) + { + case Outcome::Connected: + return result.proxyURL.empty() ? "Connected through the " + result.route + " route, no proxy." : "Connected through proxy " + ProxyDisplayName(result.proxyURL) + "."; + case Outcome::ProxyAuthRequired: + return result.detail == "required" ? "The proxy needs a login." : "The proxy refused the login."; + case Outcome::ServiceRejected: + return "The service refused the connection."; + case Outcome::Unreachable: + return std::string("Could not reach the service.") + (result.proxyURL.empty() ? "\nCheck your internet connection." : "\nCheck the proxy address."); + default: + return result.detail.empty() ? "Could not test the connection." : result.detail; + } +} +} diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/ServiceEndpointPolicy.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/ServiceEndpointPolicy.cpp new file mode 100644 index 00000000000..b7f3202cb23 --- /dev/null +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/ServiceEndpointPolicy.cpp @@ -0,0 +1,268 @@ +#include "GameNetwork/GeneralsOnline/ServiceEndpointPolicy.h" +#include "GameNetwork/GeneralsOnline/json.hpp" +#include +#include +#include + +namespace ServiceConnection +{ +static std::string Lower(std::string value); +static std::string Part(CURLU* url, CURLUPart part); + +bool NormalizeCustomEndpoint(const std::string& typed, std::string& origin, std::string& error) +{ + const std::string input = typed; + origin.clear(); + error.clear(); + size_t first = input.find_first_not_of(" \t"); + if (first == std::string::npos) + { + error = "Enter the server address."; + return false; + } + std::string value = input.substr(first, input.find_last_not_of(" \t") - first + 1); + if (std::any_of(value.begin(), value.end(), [](unsigned char c) { return c <= 32 || c >= 127 || c == '\\'; })) + { + error = "The server address has characters that are not allowed."; + return false; + } + const std::string lower = Lower(value); + if (lower.compare(0, 7, "http://") == 0) + { + error = "The server must use https. Plain http is not allowed."; + return false; + } + if (lower.compare(0, 8, "https://") == 0) + value.erase(0, 8); + else if (value.find("://") != std::string::npos) + { + error = "The server must use https."; + return false; + } + if (value.find('@') != std::string::npos) + { + error = "The server address can't contain a user name or password."; + return false; + } + if (value.find('?') != std::string::npos) + { + error = "The server address can't contain a query."; + return false; + } + if (value.find('#') != std::string::npos) + { + error = "The server address can't contain a fragment."; + return false; + } + const auto slash = value.find('/'); + if (slash != std::string::npos && slash + 1 != value.size()) + { + error = "Enter only the host and port, without a path."; + return false; + } + if (slash != std::string::npos) + value.erase(slash); + std::unique_ptr url(curl_url(), curl_url_cleanup); + if (!url || curl_url_set(url.get(), CURLUPART_URL, ("https://" + value).c_str(), 0) != CURLUE_OK) + { + error = "Enter a host name with an optional port, like server.example:8443."; + return false; + } + const auto host = Lower(Part(url.get(), CURLUPART_HOST)); + const auto port = Part(url.get(), CURLUPART_PORT); + if (host.empty() || host.find('%') != std::string::npos || !Part(url.get(), CURLUPART_ZONEID).empty() || port == "0") + { + error = "Enter a host name with an optional port, like server.example:8443."; + return false; + } + origin = "https://" + host + (port.empty() || port == "443" ? "" : ":" + port); + return true; +} + +std::string CredentialServerKey(const ServiceConnectionSettings& settings) +{ + if (settings.serverMode != "custom") + return "go"; + std::string origin, error; + return NormalizeCustomEndpoint(settings.serverAddress, origin, error) ? "custom:" + origin : std::string(); +} + +std::vector BootstrapRoutes(const std::string& environment, + const ServiceConnectionSettings& settings, const std::string& remembered) +{ + // a custom server is the only route there is: no other server is tried for it + if (settings.serverMode == "custom") + { + std::string origin, error; + if ((environment != "dev" && environment != "test" && environment != "prod") || !NormalizeCustomEndpoint(settings.serverAddress, origin, error)) + return {}; + return {origin + "/env/" + environment + "/contract/1/"}; + } + if (environment == "dev") + return {"https://localhost:9000/env/dev/contract/1/"}; + if (environment == "test") + return {"https://api.playgenerals.online:2087/env/test/contract/1/"}; + if (environment != "prod") + return {}; + if (settings.serverMode != "automatic") + return {}; + std::vector routes = { + "https://api.playgenerals.online/env/prod/contract/1/", + "https://api-ru.playgenerals.online/env/prod/contract/1/", + }; + const auto remembered_route = std::find_if(routes.begin(), routes.end(), [&](const std::string& route) { return RouteHost(route) == remembered; }); + if (remembered_route != routes.end()) + std::rotate(routes.begin(), remembered_route, remembered_route + 1); + return routes; +} + +std::string RouteHost(const std::string& route) +{ + const auto schemeEnd = route.find("://"); + if (schemeEnd == std::string::npos) + return {}; + const auto start = schemeEnd + 3; + return route.substr(start, route.find_first_of(":/", start) - start); +} + +static std::string Lower(std::string value) +{ + std::transform(value.begin(), value.end(), value.begin(), [](unsigned char c) { return static_cast(tolower(c)); }); + return value; +} + +static bool EndsWith(const std::string& value, const std::string& suffix) +{ + return value.size() >= suffix.size() && value.compare(value.size() - suffix.size(), suffix.size(), suffix) == 0; +} + +static std::string Part(CURLU* url, CURLUPart part) +{ + char* value = nullptr; + if (curl_url_get(url, part, &value, CURLU_DEFAULT_PORT) != CURLUE_OK) + return {}; + std::string result(value); + curl_free(value); + return result; +} + +bool IsTrustedServiceURL(const std::string& value, const std::string& base, bool websocket) +{ + if (value.find_first_of("\\\r\n\t #") != std::string::npos) + return false; + const auto schemeEnd = value.find("://"); + if (schemeEnd == std::string::npos) + return false; + const auto authorityEnd = value.find_first_of("/?", schemeEnd + 3); + const auto authority = value.substr(schemeEnd + 3, + authorityEnd == std::string::npos ? std::string::npos : authorityEnd - schemeEnd - 3); + if (authority.find('@') != std::string::npos || (websocket && value.find('?') != std::string::npos)) + return false; + std::string rawPath; + if (authorityEnd != std::string::npos && value[authorityEnd] == '/') + rawPath = value.substr(authorityEnd, value.find('?', authorityEnd) - authorityEnd); + // query escaping is legitimate; path separators and dot segments must not change after validation + const auto lowerPath = Lower(rawPath); + if ((websocket && rawPath.find('%') != std::string::npos) || lowerPath.find("%2e") != std::string::npos || + lowerPath.find("%2f") != std::string::npos || lowerPath.find("%5c") != std::string::npos || + rawPath.find("/../") != std::string::npos || rawPath.find("/./") != std::string::npos || + EndsWith(rawPath, "/..") || EndsWith(rawPath, "/.")) + return false; + std::unique_ptr url(curl_url(), curl_url_cleanup); + std::unique_ptr expected(curl_url(), curl_url_cleanup); + if (!url || !expected || curl_url_set(url.get(), CURLUPART_URL, value.c_str(), CURLU_NON_SUPPORT_SCHEME) != CURLUE_OK || + curl_url_set(expected.get(), CURLUPART_URL, base.c_str(), 0) != CURLUE_OK) + return false; + if (Part(url.get(), CURLUPART_SCHEME) != (websocket ? "wss" : "https")) + return false; + auto port = Part(url.get(), CURLUPART_PORT); + if (port.empty() && websocket) + port = "443"; + if (Lower(Part(url.get(), CURLUPART_HOST)) != Lower(Part(expected.get(), CURLUPART_HOST)) || + port != Part(expected.get(), CURLUPART_PORT)) + return false; + const auto path = Part(url.get(), CURLUPART_PATH); + const auto expectedPath = Part(expected.get(), CURLUPART_PATH); + if (!websocket) + return path.compare(0, expectedPath.size(), expectedPath) == 0; + return EndsWith(path, expectedPath + "ws"); +} + +std::string RequestURLForLog(const std::string& value, const std::string& serviceBase) +{ + if (value.size() > 8192 || std::any_of(value.begin(), value.end(), [](unsigned char c) { return c <= 32 || c >= 127; })) + return "[invalid-url]"; + const auto schemeEnd = value.find("://"); + if (schemeEnd == std::string::npos) + return "[invalid-url]"; + const auto authority = value.substr(schemeEnd + 3, value.find_first_of("/?#", schemeEnd + 3) - schemeEnd - 3); + if (authority.find('@') != std::string::npos) + return "[redacted-url]"; + std::unique_ptr url(curl_url(), curl_url_cleanup); + if (!url || curl_url_set(url.get(), CURLUPART_URL, value.c_str(), 0) != CURLUE_OK || Part(url.get(), CURLUPART_SCHEME) != "https") + return "[invalid-url]"; + const auto host = Lower(Part(url.get(), CURLUPART_HOST)); + if (host.empty() || host.find('%') != std::string::npos || !Part(url.get(), CURLUPART_ZONEID).empty()) + return "[invalid-url]"; + const auto port = Part(url.get(), CURLUPART_PORT); + const std::string origin = "https://" + host + (port.empty() || port == "443" ? "" : ":" + port); + const auto path = Part(url.get(), CURLUPART_PATH); + // only known service routes are logged; other paths may carry private identifiers + std::unique_ptr base(curl_url(), curl_url_cleanup); + if (path.find('%') != std::string::npos || !IsTrustedServiceURL(origin + path, serviceBase, false) || + !base || curl_url_set(base.get(), CURLUPART_URL, serviceBase.c_str(), 0) != CURLUE_OK) + return origin + "/[redacted]"; + const auto prefix = Part(base.get(), CURLUPART_PATH); + const auto action = path.substr(prefix.size()); + static const char* const routes[] = { + "Matchmaking/Playlists", "Matchmaking/Widen", "PlayerStats/Batch", + "Social/Friends/Requests", "Social/Friends", "Social/Blocked", + "VersionManifest", "VersionCheck", "CheckLogin", "LoginWithToken", "LoginCode", "RefreshToken", + "ProvideMWToken", "ConnectionOutcome", "GlobalStats", "ServiceConfig", "Matchmaking", + "PlayerStats", "Lobbies", "Lobby", "Rooms", "MOTD", "User" + }; + for (const std::string name : routes) + { + if (action == name || action.compare(0, name.size() + 1, name + "/") == 0) + return origin + prefix + name + (action.size() == name.size() ? "" : "/[redacted]"); + } + return origin + prefix + "[redacted]"; +} + +bool IsExpectedVersionManifest(const std::string& body) +{ + const auto json = nlohmann::json::parse(body, nullptr, false); + return json.is_object() && json.contains("execrc_30") && json.contains("execrc_60") && + json["execrc_30"].is_number_unsigned() && json["execrc_60"].is_number_unsigned() && + json["execrc_30"].get() <= UINT32_MAX && json["execrc_60"].get() <= UINT32_MAX; +} + +bool ConfigureServiceTransport(CURL* curl, const std::string& url, std::string& error) +{ + error.clear(); + const bool websocket = url.compare(0, 6, "wss://") == 0; + if (!curl || (!websocket && url.compare(0, 8, "https://") != 0)) + { + error = "Service connections require HTTPS or WSS."; + return false; + } + auto set = [&](CURLoption option, auto value) { + const CURLcode code = curl_easy_setopt(curl, option, value); + if (code != CURLE_OK && error.empty()) + error = "Unable to configure service transport: " + std::string(curl_easy_strerror(code)); + return code == CURLE_OK; + }; +#if _DEBUG + // debug builds skip certificate checks for local service testing + constexpr long verifyPeer = 0L; + constexpr long verifyHost = 0L; +#else + constexpr long verifyPeer = 1L; + constexpr long verifyHost = 2L; +#endif + return set(CURLOPT_SSL_VERIFYPEER, verifyPeer) && set(CURLOPT_SSL_VERIFYHOST, verifyHost) && + set(CURLOPT_SSL_OPTIONS, static_cast(CURLSSLOPT_NATIVE_CA)) && + set(CURLOPT_DISALLOW_USERNAME_IN_URL, 1L) && set(CURLOPT_FOLLOWLOCATION, 0L) && + set(CURLOPT_PROTOCOLS_STR, websocket ? "wss" : "https") && set(CURLOPT_PROXY, ""); +} +} diff --git a/GeneralsMD/Code/GameEngineDevice/CMakeLists.txt b/GeneralsMD/Code/GameEngineDevice/CMakeLists.txt index 56408e821bd..d251e04bb38 100644 --- a/GeneralsMD/Code/GameEngineDevice/CMakeLists.txt +++ b/GeneralsMD/Code/GameEngineDevice/CMakeLists.txt @@ -81,8 +81,6 @@ set(GAMEENGINEDEVICE_SRC Include/W3DDevice/GameLogic/W3DGameLogic.h Include/W3DDevice/GameLogic/W3DGhostObject.h Include/W3DDevice/GameLogic/W3DTerrainLogic.h -# Include/Win32Device/Common/Win32BIGFile.h -# Include/Win32Device/Common/Win32BIGFileSystem.h Include/Win32Device/Common/Win32GameEngine.h # Include/Win32Device/Common/Win32LocalFile.h # Include/Win32Device/Common/Win32LocalFileSystem.h @@ -181,8 +179,6 @@ set(GAMEENGINEDEVICE_SRC Source/W3DDevice/GameLogic/W3DGameLogic.cpp Source/W3DDevice/GameLogic/W3DGhostObject.cpp Source/W3DDevice/GameLogic/W3DTerrainLogic.cpp -# Source/Win32Device/Common/Win32BIGFile.cpp -# Source/Win32Device/Common/Win32BIGFileSystem.cpp Source/Win32Device/Common/Win32GameEngine.cpp # Source/Win32Device/Common/Win32LocalFile.cpp # Source/Win32Device/Common/Win32LocalFileSystem.cpp diff --git a/GeneralsMD/Code/GameEngineDevice/Include/Win32Device/Common/Win32GameEngine.h b/GeneralsMD/Code/GameEngineDevice/Include/Win32Device/Common/Win32GameEngine.h index 2710cdf4555..e27eb8504c9 100644 --- a/GeneralsMD/Code/GameEngineDevice/Include/Win32Device/Common/Win32GameEngine.h +++ b/GeneralsMD/Code/GameEngineDevice/Include/Win32Device/Common/Win32GameEngine.h @@ -32,12 +32,12 @@ #pragma once +#include "Common/BIGFileSystem.h" #include "Common/GameEngine.h" #include "GameClient/ParticleSys.h" #include "GameLogic/GameLogic.h" #include "GameNetwork/NetworkInterface.h" #include "MilesAudioDevice/MilesAudioManager.h" -#include "Win32Device/Common/Win32BIGFileSystem.h" #include "Win32Device/Common/Win32LocalFileSystem.h" #include "W3DDevice/Common/W3DModuleFactory.h" #include "W3DDevice/GameLogic/W3DGameLogic.h" @@ -93,7 +93,7 @@ inline ModuleFactory *Win32GameEngine::createModuleFactory() { return NEW W3DMod inline ThingFactory *Win32GameEngine::createThingFactory() { return NEW W3DThingFactory; } inline FunctionLexicon *Win32GameEngine::createFunctionLexicon() { return NEW W3DFunctionLexicon; } inline LocalFileSystem *Win32GameEngine::createLocalFileSystem() { return NEW Win32LocalFileSystem; } -inline ArchiveFileSystem *Win32GameEngine::createArchiveFileSystem() { return NEW Win32BIGFileSystem; } +inline ArchiveFileSystem *Win32GameEngine::createArchiveFileSystem() { return NEW BIGFileSystem; } inline ParticleSystemManager* Win32GameEngine::createParticleSystemManager(Bool dummy) { if (dummy) diff --git a/GeneralsMD/Code/Tools/GUIEdit/Source/GUIEdit.cpp b/GeneralsMD/Code/Tools/GUIEdit/Source/GUIEdit.cpp index 2cbc8ec3ffb..6ecfad34952 100644 --- a/GeneralsMD/Code/Tools/GUIEdit/Source/GUIEdit.cpp +++ b/GeneralsMD/Code/Tools/GUIEdit/Source/GUIEdit.cpp @@ -85,7 +85,7 @@ #include "Win32Device/GameClient/Win32Mouse.h" #include "Win32Device/GameClient/Win32DIKeyboard.h" #include "Win32Device/Common/Win32LocalFileSystem.h" -#include "Win32Device/Common/Win32BIGFileSystem.h" +#include "Common/BIGFileSystem.h" #include "resource.h" #include "WinMain.h" @@ -485,7 +485,7 @@ void GUIEdit::init() // create file system TheFileSystem = new FileSystem; TheLocalFileSystem = new Win32LocalFileSystem; - TheArchiveFileSystem = new Win32BIGFileSystem; + TheArchiveFileSystem = new BIGFileSystem; TheFileSystem->init(); //--------------------------------------------------------------------------- diff --git a/GeneralsMD/Code/Tools/WorldBuilder/src/WorldBuilder.cpp b/GeneralsMD/Code/Tools/WorldBuilder/src/WorldBuilder.cpp index d49589ee0c8..65d2fb82b8f 100644 --- a/GeneralsMD/Code/Tools/WorldBuilder/src/WorldBuilder.cpp +++ b/GeneralsMD/Code/Tools/WorldBuilder/src/WorldBuilder.cpp @@ -79,7 +79,7 @@ #include #include "Win32Device/GameClient/Win32Mouse.h" #include "Win32Device/Common/Win32LocalFileSystem.h" -#include "Win32Device/Common/Win32BIGFileSystem.h" +#include "Common/BIGFileSystem.h" static SubsystemInterfaceList TheSubsystemListRecord; @@ -326,7 +326,7 @@ BOOL CWorldBuilderApp::InitInstance() TheFileSystem = new FileSystem; initSubsystem(TheLocalFileSystem, (LocalFileSystem*)new Win32LocalFileSystem); - initSubsystem(TheArchiveFileSystem, (ArchiveFileSystem*)new Win32BIGFileSystem); + initSubsystem(TheArchiveFileSystem, (ArchiveFileSystem*)new BIGFileSystem); // Just for kicks, get the HINSTANCE that WOL would need // if we were going to use it, which we aren't. diff --git a/cmake/triplets/x86-windows-static-md.cmake b/cmake/triplets/x86-windows-static-md.cmake new file mode 100644 index 00000000000..824d4e3fd3f --- /dev/null +++ b/cmake/triplets/x86-windows-static-md.cmake @@ -0,0 +1,13 @@ +set(VCPKG_TARGET_ARCHITECTURE x86) +set(VCPKG_CRT_LINKAGE dynamic) +set(VCPKG_LIBRARY_LINKAGE static) + +# The pinned curl port exposes SSPI but leaves NTLM disabled without this option. +if(PORT STREQUAL "curl") + list(APPEND VCPKG_CMAKE_CONFIGURE_OPTIONS "-DCURL_ENABLE_NTLM=ON") +endif() + +# Match the static protobuf package instead of importing protobuf DLL symbols. +if(PORT STREQUAL "gamenetworkingsockets") + list(APPEND VCPKG_CMAKE_CONFIGURE_OPTIONS "-DProtobuf_USE_STATIC_LIBS=ON") +endif() diff --git a/scripts/pack_go_data.py b/scripts/pack_go_data.py new file mode 100644 index 00000000000..a084b052273 --- /dev/null +++ b/scripts/pack_go_data.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +r"""Pack GeneralsOnline/GameData into the Generals Online fix pack BIG. + +Usage: pack_go_data.py [output.big] + +The folder layout inside GameData is the layout inside the BIG (Window\..., Data\INI\..., Art\Textures\...). +The updater ships the result as \GeneralsOnlineGameData\GeneralsOnline_Core.big. + +The client mounts it through the generals_online layer of data_packs in settings.json. From the top, the lookup is: loose files in the +game folder, -mod and -modDir, the BIGs in the game folders that are not retail, then the layers user, generals_online and community_patch, +then the retail archives. Every layer has enabled and include_paths, which are mounted in list order (the first one wins). An entry is +game:, user: or an absolute path: a BIG, a folder of BIGs or a *.big pattern. + + "data_packs": { + "user": { "enabled": true, "include_paths": [], "exclude_paths": [] }, + "generals_online": { "enabled": true, "include_paths": [ "user:GeneralsOnlineGameData/GeneralsOnline_Core.big" ] }, + "community_patch": { "enabled": true, "include_paths": [ "user:GeneralsOnlineGameData/500_900_CommunityPatch_CoreINI.big" ] } + } + +user.exclude_paths leaves things out: loose files in the game folder (game:Data/INI/*), the BIGs in the game folders that are not retail +(game:340_ControlBarPro*.big, game:ZH_Generals/*.big) and the include_paths of the user layer itself. * matches any text including folders, +a trailing slash is a folder. Retail archives are never excluded. user.enabled false only turns off user.include_paths. + +The old data_packs.use_community_data_patch switch is read as community_patch.enabled when community_patch is missing. +-disableCommunityDataPatch skips the community_patch layer for one launch. +""" +import os +import struct +import sys + +ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "GeneralsOnline", "GameData") +OUT = sys.argv[1] if len(sys.argv) > 1 else "GeneralsOnline_Core.big" + + +def main(): + files = [] + for dirpath, _, names in os.walk(ROOT): + for name in names: + full = os.path.join(dirpath, name) + rel = os.path.relpath(full, ROOT).replace("/", "\\") + files.append((rel, full)) + files.sort(key=lambda f: f[0].lower()) + if not files: + sys.exit("nothing to pack in " + ROOT) + + names = [rel.encode("ascii") + b"\0" for rel, _ in files] + header = 16 + sum(8 + len(n) for n in names) + table = b"" + body = b"" + offset = header + for (rel, full), n in zip(files, names): + with open(full, "rb") as f: + data = f.read() + table += struct.pack(">II", offset, len(data)) + n + body += data + offset += len(data) + # BIGF: magic, total size (little endian), file count and data start (big endian) + with open(OUT, "wb") as f: + f.write(b"BIGF" + struct.pack("II", len(files), header) + table + body) + print("packed %d files into %s" % (len(files), OUT)) + + +main() diff --git a/vcpkg.json b/vcpkg.json index 6003937b674..04a542a091d 100644 --- a/vcpkg.json +++ b/vcpkg.json @@ -2,6 +2,7 @@ "$schema": "https://raw.githubusercontent.com/microsoft/vcpkg-tool/main/docs/vcpkg.schema.json", "builtin-baseline": "b61d33065508ba0d5e393206abb3cb2366299699", "vcpkg-configuration": { + "overlay-triplets": [ "cmake/triplets" ], "registries": [ { "kind": "git", @@ -19,7 +20,7 @@ { "name": "curl", "default-features": false, - "features": [ "openssl", "websockets" ] + "features": [ "openssl", "websockets", "http2", "http3", "sspi" ] }, { "name": "gamenetworkingsockets",