From df2ce202c96dce0835c7c3a09735b0393f1c79f4 Mon Sep 17 00:00:00 2001 From: xezon <4720891+xezon@users.noreply.github.com> Date: Sun, 26 Jul 2026 17:19:44 +0200 Subject: [PATCH 01/28] refactor(filesystem): Remove obsolete allocation in Win32BIGFileSystem::openArchiveFile() (#3376) (cherry picked from commit 86f2cab5434fab3de07a33e16c995a0b62a2542b) --- .../StdDevice/Common/StdBIGFileSystem.cpp | 20 ++++++++---------- .../Win32Device/Common/Win32BIGFileSystem.cpp | 21 +++++++------------ 2 files changed, 17 insertions(+), 24 deletions(-) diff --git a/Core/GameEngineDevice/Source/StdDevice/Common/StdBIGFileSystem.cpp b/Core/GameEngineDevice/Source/StdDevice/Common/StdBIGFileSystem.cpp index ffd0150f4c5..0fb035e0c3e 100644 --- a/Core/GameEngineDevice/Source/StdDevice/Common/StdBIGFileSystem.cpp +++ b/Core/GameEngineDevice/Source/StdDevice/Common/StdBIGFileSystem.cpp @@ -128,7 +128,8 @@ ArchiveFile * StdBIGFileSystem::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; + ArchivedFileInfo fileInfo; + ArchiveFile *archiveFile = NEW StdBIGFile(filename, AsciiString::TheEmptyString); for (Int i = 0; i < numLittleFiles; ++i) { Int filesize = 0; @@ -139,9 +140,9 @@ ArchiveFile * StdBIGFileSystem::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. Int pathIndex = -1; @@ -155,8 +156,8 @@ ArchiveFile * StdBIGFileSystem::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; @@ -164,17 +165,14 @@ ArchiveFile * StdBIGFileSystem::openArchiveFile(const Char *filename) { AsciiString debugpath; debugpath = path; - debugpath.concat(fileInfo->m_filename); + 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->addFile(path, &fileInfo); } archiveFile->attachFile(fp); - delete fileInfo; - fileInfo = nullptr; - // leave fp open as the archive file will be using it. return archiveFile; diff --git a/Core/GameEngineDevice/Source/Win32Device/Common/Win32BIGFileSystem.cpp b/Core/GameEngineDevice/Source/Win32Device/Common/Win32BIGFileSystem.cpp index b824dcaed57..004e393ec02 100644 --- a/Core/GameEngineDevice/Source/Win32Device/Common/Win32BIGFileSystem.cpp +++ b/Core/GameEngineDevice/Source/Win32Device/Common/Win32BIGFileSystem.cpp @@ -137,8 +137,7 @@ 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 + ArchivedFileInfo fileInfo; ArchiveFile *archiveFile = NEW Win32BIGFile(filename, AsciiString::TheEmptyString); Bool corrupt = FALSE; @@ -153,9 +152,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 +178,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 +187,15 @@ ArchiveFile * Win32BIGFileSystem::openArchiveFile(const Char *filename) { AsciiString debugpath; debugpath = path; - debugpath.concat(fileInfo->m_filename); + 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)); - 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,9 +203,6 @@ 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; From b6289ec5e962d5d8ed06503de166f23f2a26306e Mon Sep 17 00:00:00 2001 From: xezon <4720891+xezon@users.noreply.github.com> Date: Sun, 26 Jul 2026 15:58:22 +0200 Subject: [PATCH 02/28] refactor(archive): Simplify code in ArchiveFile (#3376) (cherry picked from commit 5db4f7598b13955076fe37991a613b8906ea1a6a) --- Core/GameEngine/Include/Common/ArchiveFile.h | 5 ++- .../Include/Common/ArchiveFileSystem.h | 4 +- .../Source/Common/System/ArchiveFile.cpp | 42 +++++++------------ 3 files changed, 21 insertions(+), 30 deletions(-) diff --git a/Core/GameEngine/Include/Common/ArchiveFile.h b/Core/GameEngine/Include/Common/ArchiveFile.h index 63987f9c9cb..4ea8f0c3214 100644 --- a/Core/GameEngine/Include/Common/ArchiveFile.h +++ b/Core/GameEngine/Include/Common/ArchiveFile.h @@ -60,13 +60,16 @@ class ArchiveFile void attachFile(File *file); 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: 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. + +private: DetailedArchivedDirectoryInfo m_rootDirectory; }; diff --git a/Core/GameEngine/Include/Common/ArchiveFileSystem.h b/Core/GameEngine/Include/Common/ArchiveFileSystem.h index 48d144484c6..5583290bce0 100644 --- a/Core/GameEngine/Include/Common/ArchiveFileSystem.h +++ b/Core/GameEngine/Include/Common/ArchiveFileSystem.h @@ -143,9 +143,6 @@ class ArchiveFileSystem : public SubsystemInterface virtual Bool loadBigFilesFromDirectory(AsciiString dir, AsciiString fileMask, Bool overwrite = FALSE) = 0; - // Unprotected this for copy-protection routines - ArchiveFile* getArchiveFile(const AsciiString& filename, FileInstance instance = 0) const; - void loadMods(); ArchivedDirectoryInfo* friend_getArchivedDirectoryInfo(const Char* directory); @@ -160,6 +157,7 @@ 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. diff --git a/Core/GameEngine/Source/Common/System/ArchiveFile.cpp b/Core/GameEngine/Source/Common/System/ArchiveFile.cpp index 1f982b65a1d..8ca721021d4 100644 --- a/Core/GameEngine/Source/Common/System/ArchiveFile.cpp +++ b/Core/GameEngine/Source/Common/System/ArchiveFile.cpp @@ -106,13 +106,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 +130,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 +145,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++; } } From 4f23667b4bc0527140a68d07b4a9b5717b176e9d Mon Sep 17 00:00:00 2001 From: xezon <4720891+xezon@users.noreply.github.com> Date: Sun, 27 Sep 2026 10:48:26 +0200 Subject: [PATCH 03/28] fix(filesystem): Fix file path tokenization in ArchiveFile, ArchiveFileSystem, Win32LocalFileSystem (#3374) (cherry picked from commit 3f3ed9fc8cf2eeaa722d1533ea069257563ccf79) --- Core/GameEngine/Source/Common/System/ArchiveFile.cpp | 2 +- .../Source/Common/System/ArchiveFileSystem.cpp | 12 ++++++------ .../Win32Device/Common/Win32LocalFileSystem.cpp | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Core/GameEngine/Source/Common/System/ArchiveFile.cpp b/Core/GameEngine/Source/Common/System/ArchiveFile.cpp index 8ca721021d4..798e3ac841e 100644 --- a/Core/GameEngine/Source/Common/System/ArchiveFile.cpp +++ b/Core/GameEngine/Source/Common/System/ArchiveFile.cpp @@ -191,7 +191,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()) diff --git a/Core/GameEngine/Source/Common/System/ArchiveFileSystem.cpp b/Core/GameEngine/Source/Common/System/ArchiveFileSystem.cpp index 5720c5b9417..17d49e8b372 100644 --- a/Core/GameEngine/Source/Common/System/ArchiveFileSystem.cpp +++ b/Core/GameEngine/Source/Common/System/ArchiveFileSystem.cpp @@ -144,9 +144,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,7 +163,7 @@ void ArchiveFileSystem::loadIntoDirectoryTree(ArchiveFile *archiveFile, Bool ove dirInfo = &tempiter->second; } - infoInPath = tokenizer.nextToken(&token, "\\/"); + tokenizer.nextToken(&token, "\\/"); } ArchivedFileLocationMap::iterator fileIt; @@ -306,15 +306,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/Win32LocalFileSystem.cpp b/Core/GameEngineDevice/Source/Win32Device/Common/Win32LocalFileSystem.cpp index 43a6ca5cbdd..a4ddcb6b59f 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('\\'); From 23749a25f0687ecbb17768351005031535aeba3d Mon Sep 17 00:00:00 2001 From: xezon <4720891+xezon@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:32:53 +0200 Subject: [PATCH 04/28] perf(archivefile): Take timestamps from cached file info in Win32BIGFile::getFileInfo(), StdBIGFile::getFileInfo() (#3415) (cherry picked from commit b805c12ee1aedc0a4b241006803b8e04bbf288a6) --- Core/GameEngine/Include/Common/ArchiveFile.h | 1 + Core/GameEngine/Include/Common/FileSystem.h | 8 ++++++++ .../GameEngine/Source/Common/System/ArchiveFile.cpp | 12 ++++++++++++ .../Source/StdDevice/Common/StdBIGFile.cpp | 13 +++++++------ .../Source/StdDevice/Common/StdLocalFileSystem.cpp | 4 ++-- .../Source/Win32Device/Common/Win32BIGFile.cpp | 13 +++++++------ .../Win32Device/Common/Win32LocalFileSystem.cpp | 4 ++-- 7 files changed, 39 insertions(+), 16 deletions(-) diff --git a/Core/GameEngine/Include/Common/ArchiveFile.h b/Core/GameEngine/Include/Common/ArchiveFile.h index 4ea8f0c3214..b9d4e085567 100644 --- a/Core/GameEngine/Include/Common/ArchiveFile.h +++ b/Core/GameEngine/Include/Common/ArchiveFile.h @@ -69,6 +69,7 @@ class ArchiveFile protected: 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. private: DetailedArchivedDirectoryInfo m_rootDirectory; diff --git a/Core/GameEngine/Include/Common/FileSystem.h b/Core/GameEngine/Include/Common/FileSystem.h index 8a8b99a9f8c..75f93caabb5 100644 --- a/Core/GameEngine/Include/Common/FileSystem.h +++ b/Core/GameEngine/Include/Common/FileSystem.h @@ -109,6 +109,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; } diff --git a/Core/GameEngine/Source/Common/System/ArchiveFile.cpp b/Core/GameEngine/Source/Common/System/ArchiveFile.cpp index 798e3ac841e..e5a6e81d329 100644 --- a/Core/GameEngine/Source/Common/System/ArchiveFile.cpp +++ b/Core/GameEngine/Source/Common/System/ArchiveFile.cpp @@ -30,6 +30,7 @@ #include "Common/ArchiveFile.h" #include "Common/ArchiveFileSystem.h" +#include "Common/LocalFileSystem.h" #include "Common/file.h" #include "Common/PerfTimer.h" @@ -179,7 +180,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 diff --git a/Core/GameEngineDevice/Source/StdDevice/Common/StdBIGFile.cpp b/Core/GameEngineDevice/Source/StdDevice/Common/StdBIGFile.cpp index c873b733524..5f063c2869c 100644 --- a/Core/GameEngineDevice/Source/StdDevice/Common/StdBIGFile.cpp +++ b/Core/GameEngineDevice/Source/StdDevice/Common/StdBIGFile.cpp @@ -151,17 +151,18 @@ void StdBIGFile::close() Bool StdBIGFile::getFileInfo(const AsciiString& filename, FileInfo *fileInfo) const { - const ArchivedFileInfo *tempFileInfo = getArchivedFileInfo(filename); + const ArchivedFileInfo *archivedFileInfo = getArchivedFileInfo(filename); - if (tempFileInfo == nullptr) { + if (archivedFileInfo == 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. + // 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; + 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/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 index 3deee01fd82..fa21e09a278 100644 --- a/Core/GameEngineDevice/Source/Win32Device/Common/Win32BIGFile.cpp +++ b/Core/GameEngineDevice/Source/Win32Device/Common/Win32BIGFile.cpp @@ -151,17 +151,18 @@ void Win32BIGFile::close() Bool Win32BIGFile::getFileInfo(const AsciiString& filename, FileInfo *fileInfo) const { - const ArchivedFileInfo *tempFileInfo = getArchivedFileInfo(filename); + const ArchivedFileInfo *archivedFileInfo = getArchivedFileInfo(filename); - if (tempFileInfo == nullptr) { + if (archivedFileInfo == 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. + // 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; + 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/GameEngineDevice/Source/Win32Device/Common/Win32LocalFileSystem.cpp b/Core/GameEngineDevice/Source/Win32Device/Common/Win32LocalFileSystem.cpp index a4ddcb6b59f..5ec6fd35680 100644 --- a/Core/GameEngineDevice/Source/Win32Device/Common/Win32LocalFileSystem.cpp +++ b/Core/GameEngineDevice/Source/Win32Device/Common/Win32LocalFileSystem.cpp @@ -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); From d86bca80f81deb3bf583f476a98d66ba905365e5 Mon Sep 17 00:00:00 2001 From: xezon <4720891+xezon@users.noreply.github.com> Date: Mon, 5 Oct 2026 20:08:58 +0200 Subject: [PATCH 05/28] refactor(archivefile): Merge Win32BIGFile and StdBIGFile into ArchiveFile (#3505) Both classes are identical apart from their name and contain no platform specific code. Nothing in them depends on the BIG format either: they open a file from an offset and a size inside the archive file. ArchiveFile takes over their functions and is no longer an abstract class. (cherry picked from commit 52a777aa0cc736dfd166c841f2a3ff5adbbe0d50) --- Core/GameEngine/Include/Common/ArchiveFile.h | 20 ++- .../Source/Common/System/ArchiveFile.cpp | 120 ++++++++++++- Core/GameEngineDevice/CMakeLists.txt | 4 - .../Include/StdDevice/Common/StdBIGFile.h | 53 ------ .../Include/Win32Device/Common/Win32BIGFile.h | 53 ------ .../Source/StdDevice/Common/StdBIGFile.cpp | 169 ------------------ .../StdDevice/Common/StdBIGFileSystem.cpp | 3 +- .../Win32Device/Common/Win32BIGFile.cpp | 169 ------------------ .../Win32Device/Common/Win32BIGFileSystem.cpp | 3 +- Generals/Code/GameEngineDevice/CMakeLists.txt | 2 - .../Code/GameEngineDevice/CMakeLists.txt | 2 - 11 files changed, 132 insertions(+), 466 deletions(-) delete mode 100644 Core/GameEngineDevice/Include/StdDevice/Common/StdBIGFile.h delete mode 100644 Core/GameEngineDevice/Include/Win32Device/Common/Win32BIGFile.h delete mode 100644 Core/GameEngineDevice/Source/StdDevice/Common/StdBIGFile.cpp delete mode 100644 Core/GameEngineDevice/Source/Win32Device/Common/Win32BIGFile.cpp diff --git a/Core/GameEngine/Include/Common/ArchiveFile.h b/Core/GameEngine/Include/Common/ArchiveFile.h index b9d4e085567..a7e0ed68bfb 100644 --- a/Core/GameEngine/Include/Common/ArchiveFile.h +++ b/Core/GameEngine/Include/Common/ArchiveFile.h @@ -47,16 +47,16 @@ 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 getFileListInDirectory(const AsciiString& currentDirectory, const AsciiString& originalDirectory, const AsciiString& searchName, FilenameList &filenameList, Bool searchSubdirectories) const; @@ -70,6 +70,8 @@ class ArchiveFile protected: 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/Source/Common/System/ArchiveFile.cpp b/Core/GameEngine/Source/Common/System/ArchiveFile.cpp index e5a6e81d329..9e0db3f0ee2 100644 --- a/Core/GameEngine/Source/Common/System/ArchiveFile.cpp +++ b/Core/GameEngine/Source/Common/System/ArchiveFile.cpp @@ -32,7 +32,10 @@ #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 @@ -90,8 +93,10 @@ ArchiveFile::~ArchiveFile() } } -ArchiveFile::ArchiveFile() +ArchiveFile::ArchiveFile(AsciiString name, AsciiString path) : m_file(nullptr) + , m_name(name) + , m_path(path) { } @@ -229,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/GameEngineDevice/CMakeLists.txt b/Core/GameEngineDevice/CMakeLists.txt index 6added5403b..3315487c387 100644 --- a/Core/GameEngineDevice/CMakeLists.txt +++ b/Core/GameEngineDevice/CMakeLists.txt @@ -83,7 +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 @@ -186,7 +185,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 @@ -200,11 +198,9 @@ 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/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 5f063c2869c..00000000000 --- a/Core/GameEngineDevice/Source/StdDevice/Common/StdBIGFile.cpp +++ /dev/null @@ -1,169 +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 *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/GameEngineDevice/Source/StdDevice/Common/StdBIGFileSystem.cpp b/Core/GameEngineDevice/Source/StdDevice/Common/StdBIGFileSystem.cpp index 0fb035e0c3e..529f5b03648 100644 --- a/Core/GameEngineDevice/Source/StdDevice/Common/StdBIGFileSystem.cpp +++ b/Core/GameEngineDevice/Source/StdDevice/Common/StdBIGFileSystem.cpp @@ -38,7 +38,6 @@ #include "Common/Registry.h" #endif -#include "StdDevice/Common/StdBIGFile.h" #include "StdDevice/Common/StdBIGFileSystem.h" #include "Utility/endian_compat.h" @@ -129,7 +128,7 @@ ArchiveFile * StdBIGFileSystem::openArchiveFile(const Char *filename) { fp->seek(0x10, File::START); // read in each directory listing. ArchivedFileInfo fileInfo; - ArchiveFile *archiveFile = NEW StdBIGFile(filename, AsciiString::TheEmptyString); + ArchiveFile *archiveFile = NEW ArchiveFile(filename, AsciiString::TheEmptyString); for (Int i = 0; i < numLittleFiles; ++i) { Int filesize = 0; diff --git a/Core/GameEngineDevice/Source/Win32Device/Common/Win32BIGFile.cpp b/Core/GameEngineDevice/Source/Win32Device/Common/Win32BIGFile.cpp deleted file mode 100644 index fa21e09a278..00000000000 --- a/Core/GameEngineDevice/Source/Win32Device/Common/Win32BIGFile.cpp +++ /dev/null @@ -1,169 +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 *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/GameEngineDevice/Source/Win32Device/Common/Win32BIGFileSystem.cpp b/Core/GameEngineDevice/Source/Win32Device/Common/Win32BIGFileSystem.cpp index 004e393ec02..2159c367728 100644 --- a/Core/GameEngineDevice/Source/Win32Device/Common/Win32BIGFileSystem.cpp +++ b/Core/GameEngineDevice/Source/Win32Device/Common/Win32BIGFileSystem.cpp @@ -38,7 +38,6 @@ #include "Common/Registry.h" #endif -#include "Win32Device/Common/Win32BIGFile.h" #include "Win32Device/Common/Win32BIGFileSystem.h" #include "Utility/endian_compat.h" @@ -138,7 +137,7 @@ ArchiveFile * Win32BIGFileSystem::openArchiveFile(const Char *filename) { fp->seek(0x10, File::START); // read in each directory listing. ArchivedFileInfo fileInfo; - ArchiveFile *archiveFile = NEW Win32BIGFile(filename, AsciiString::TheEmptyString); + ArchiveFile *archiveFile = NEW ArchiveFile(filename, AsciiString::TheEmptyString); Bool corrupt = FALSE; for (Int i = 0; i < numLittleFiles; ++i) { diff --git a/Generals/Code/GameEngineDevice/CMakeLists.txt b/Generals/Code/GameEngineDevice/CMakeLists.txt index dea3ff6c9ae..bedd7c48520 100644 --- a/Generals/Code/GameEngineDevice/CMakeLists.txt +++ b/Generals/Code/GameEngineDevice/CMakeLists.txt @@ -70,7 +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 @@ -168,7 +167,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 diff --git a/GeneralsMD/Code/GameEngineDevice/CMakeLists.txt b/GeneralsMD/Code/GameEngineDevice/CMakeLists.txt index 56408e821bd..e8567307646 100644 --- a/GeneralsMD/Code/GameEngineDevice/CMakeLists.txt +++ b/GeneralsMD/Code/GameEngineDevice/CMakeLists.txt @@ -81,7 +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 @@ -181,7 +180,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 From 5b1282450bbf6556bcde8c32e7079efa5018ed6a Mon Sep 17 00:00:00 2001 From: xezon <4720891+xezon@users.noreply.github.com> Date: Mon, 5 Oct 2026 20:10:57 +0200 Subject: [PATCH 06/28] refactor(archivefilesystem): Merge Win32BIGFileSystem and StdBIGFileSystem into BIGFileSystem (#3505) Both classes are identical apart from their name and go through the local file system for all file access. The one class that remains sits next to ArchiveFileSystem in Core/GameEngine. (cherry picked from commit e4b4d1e9489644ef8d879671c94867ee83dd71ca) --- Core/GameEngine/CMakeLists.txt | 2 + .../Include/Common/BIGFileSystem.h} | 8 +- .../Source/Common/System/BIGFileSystem.cpp} | 42 +-- Core/GameEngineDevice/CMakeLists.txt | 4 - .../StdDevice/Common/StdBIGFileSystem.h | 55 ---- .../StdDevice/Common/StdBIGFileSystem.cpp | 239 ------------------ Core/Tools/Autorun/autorun.cpp | 4 +- Core/Tools/MapCacheBuilder/Source/WinMain.cpp | 5 +- Generals/Code/GameEngineDevice/CMakeLists.txt | 2 - .../Win32Device/Common/Win32GameEngine.h | 4 +- .../Code/Tools/GUIEdit/Source/GUIEdit.cpp | 4 +- .../Tools/WorldBuilder/src/WorldBuilder.cpp | 4 +- .../Code/GameEngineDevice/CMakeLists.txt | 2 - .../Win32Device/Common/Win32GameEngine.h | 4 +- .../Code/Tools/GUIEdit/Source/GUIEdit.cpp | 4 +- .../Tools/WorldBuilder/src/WorldBuilder.cpp | 4 +- 16 files changed, 44 insertions(+), 343 deletions(-) rename Core/{GameEngineDevice/Include/Win32Device/Common/Win32BIGFileSystem.h => GameEngine/Include/Common/BIGFileSystem.h} (91%) rename Core/{GameEngineDevice/Source/Win32Device/Common/Win32BIGFileSystem.cpp => GameEngine/Source/Common/System/BIGFileSystem.cpp} (82%) delete mode 100644 Core/GameEngineDevice/Include/StdDevice/Common/StdBIGFileSystem.h delete mode 100644 Core/GameEngineDevice/Source/StdDevice/Common/StdBIGFileSystem.cpp 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/GameEngineDevice/Include/Win32Device/Common/Win32BIGFileSystem.h b/Core/GameEngine/Include/Common/BIGFileSystem.h similarity index 91% rename from Core/GameEngineDevice/Include/Win32Device/Common/Win32BIGFileSystem.h rename to Core/GameEngine/Include/Common/BIGFileSystem.h index 6819ba90577..602a63e4dd5 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; diff --git a/Core/GameEngineDevice/Source/Win32Device/Common/Win32BIGFileSystem.cpp b/Core/GameEngine/Source/Common/System/BIGFileSystem.cpp similarity index 82% rename from Core/GameEngineDevice/Source/Win32Device/Common/Win32BIGFileSystem.cpp rename to Core/GameEngine/Source/Common/System/BIGFileSystem.cpp index 2159c367728..6750525f15d 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,19 +41,18 @@ #include "Common/Registry.h" #endif -#include "Win32Device/Common/Win32BIGFileSystem.h" #include "Utility/endian_compat.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; @@ -69,16 +71,16 @@ void Win32BIGFileSystem::init() { #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; @@ -86,7 +88,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)); @@ -112,7 +114,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; @@ -126,7 +128,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]; @@ -187,7 +189,7 @@ 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)); +// 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); } @@ -207,7 +209,7 @@ ArchiveFile * Win32BIGFileSystem::openArchiveFile(const Char *filename) { 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()) { @@ -228,13 +230,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::loadBigFilesFromDirectory(AsciiString dir, AsciiString fileMask, Bool overwrite) { FilenameList filenameList; TheLocalFileSystem->getFileListInDirectory(dir, "", fileMask, filenameList, TRUE); @@ -255,10 +257,10 @@ Bool Win32BIGFileSystem::loadBigFilesFromDirectory(AsciiString dir, AsciiString ArchiveFile *archiveFile = openArchiveFile((*it).str()); if (archiveFile != nullptr) { - DEBUG_LOG(("Win32BIGFileSystem::loadBigFilesFromDirectory - loading %s into the directory tree.", (*it).str())); + DEBUG_LOG(("BIGFileSystem::loadBigFilesFromDirectory - loading %s into the directory tree.", (*it).str())); loadIntoDirectoryTree(archiveFile, overwrite); m_archiveFileMap[(*it)] = archiveFile; - DEBUG_LOG(("Win32BIGFileSystem::loadBigFilesFromDirectory - %s inserted into the archive file map.", (*it).str())); + DEBUG_LOG(("BIGFileSystem::loadBigFilesFromDirectory - %s inserted into the archive file map.", (*it).str())); actuallyAdded = TRUE; } diff --git a/Core/GameEngineDevice/CMakeLists.txt b/Core/GameEngineDevice/CMakeLists.txt index 3315487c387..71933e7c69f 100644 --- a/Core/GameEngineDevice/CMakeLists.txt +++ b/Core/GameEngineDevice/CMakeLists.txt @@ -83,7 +83,6 @@ set(GAMEENGINEDEVICE_SRC # Include/W3DDevice/GameLogic/W3DGameLogic.h # Include/W3DDevice/GameLogic/W3DGhostObject.h # Include/W3DDevice/GameLogic/W3DTerrainLogic.h - Include/Win32Device/Common/Win32BIGFileSystem.h # Include/Win32Device/Common/Win32GameEngine.h Include/Win32Device/Common/Win32LocalFile.h Include/Win32Device/Common/Win32LocalFileSystem.h @@ -185,7 +184,6 @@ set(GAMEENGINEDEVICE_SRC # Source/W3DDevice/GameLogic/W3DGameLogic.cpp # Source/W3DDevice/GameLogic/W3DGhostObject.cpp # Source/W3DDevice/GameLogic/W3DTerrainLogic.cpp - Source/Win32Device/Common/Win32BIGFileSystem.cpp # Source/Win32Device/Common/Win32GameEngine.cpp Source/Win32Device/Common/Win32LocalFile.cpp Source/Win32Device/Common/Win32LocalFileSystem.cpp @@ -198,10 +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/StdBIGFileSystem.h Include/StdDevice/Common/StdLocalFile.h Include/StdDevice/Common/StdLocalFileSystem.h - Source/StdDevice/Common/StdBIGFileSystem.cpp Source/StdDevice/Common/StdLocalFile.cpp Source/StdDevice/Common/StdLocalFileSystem.cpp ) 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/Source/StdDevice/Common/StdBIGFileSystem.cpp b/Core/GameEngineDevice/Source/StdDevice/Common/StdBIGFileSystem.cpp deleted file mode 100644 index 529f5b03648..00000000000 --- a/Core/GameEngineDevice/Source/StdDevice/Common/StdBIGFileSystem.cpp +++ /dev/null @@ -1,239 +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/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; - ArchiveFile *archiveFile = NEW ArchiveFile(filename, AsciiString::TheEmptyString); - - 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); - - // 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/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 bedd7c48520..dbb41d0f7b4 100644 --- a/Generals/Code/GameEngineDevice/CMakeLists.txt +++ b/Generals/Code/GameEngineDevice/CMakeLists.txt @@ -70,7 +70,6 @@ set(GAMEENGINEDEVICE_SRC Include/W3DDevice/GameLogic/W3DGameLogic.h Include/W3DDevice/GameLogic/W3DGhostObject.h Include/W3DDevice/GameLogic/W3DTerrainLogic.h -# Include/Win32Device/Common/Win32BIGFileSystem.h Include/Win32Device/Common/Win32GameEngine.h # Include/Win32Device/Common/Win32LocalFile.h # Include/Win32Device/Common/Win32LocalFileSystem.h @@ -167,7 +166,6 @@ set(GAMEENGINEDEVICE_SRC Source/W3DDevice/GameLogic/W3DGameLogic.cpp Source/W3DDevice/GameLogic/W3DGhostObject.cpp Source/W3DDevice/GameLogic/W3DTerrainLogic.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/GameEngineDevice/CMakeLists.txt b/GeneralsMD/Code/GameEngineDevice/CMakeLists.txt index e8567307646..d251e04bb38 100644 --- a/GeneralsMD/Code/GameEngineDevice/CMakeLists.txt +++ b/GeneralsMD/Code/GameEngineDevice/CMakeLists.txt @@ -81,7 +81,6 @@ set(GAMEENGINEDEVICE_SRC Include/W3DDevice/GameLogic/W3DGameLogic.h Include/W3DDevice/GameLogic/W3DGhostObject.h Include/W3DDevice/GameLogic/W3DTerrainLogic.h -# Include/Win32Device/Common/Win32BIGFileSystem.h Include/Win32Device/Common/Win32GameEngine.h # Include/Win32Device/Common/Win32LocalFile.h # Include/Win32Device/Common/Win32LocalFileSystem.h @@ -180,7 +179,6 @@ set(GAMEENGINEDEVICE_SRC Source/W3DDevice/GameLogic/W3DGameLogic.cpp Source/W3DDevice/GameLogic/W3DGhostObject.cpp Source/W3DDevice/GameLogic/W3DTerrainLogic.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. From b5b3e12b99051e4c64140523e79d1a0a3665072d Mon Sep 17 00:00:00 2001 From: xezon <4720891+xezon@users.noreply.github.com> Date: Mon, 5 Oct 2026 20:11:49 +0200 Subject: [PATCH 07/28] refactor(archivefilesystem): Move loadMods to BIGFileSystem and rename loadBigFilesFromDirectory (#3505) loadMods refers to BIG files, which nothing else in ArchiveFileSystem does. It becomes a virtual function that BIGFileSystem implements. loadBigFilesFromDirectory is renamed to loadArchiveFilesFromDirectory, because the base class is not tied to one archive format. (cherry picked from commit f29956197ad5d0e3ebfb44d9a4d2646b4585e5cb) --- .../Include/Common/ArchiveFileSystem.h | 4 +- .../GameEngine/Include/Common/BIGFileSystem.h | 4 +- .../Common/System/ArchiveFileSystem.cpp | 50 ---------------- .../Source/Common/System/BIGFileSystem.cpp | 60 +++++++++++++++++-- 4 files changed, 60 insertions(+), 58 deletions(-) diff --git a/Core/GameEngine/Include/Common/ArchiveFileSystem.h b/Core/GameEngine/Include/Common/ArchiveFileSystem.h index 5583290bce0..ddad61637a6 100644 --- a/Core/GameEngine/Include/Common/ArchiveFileSystem.h +++ b/Core/GameEngine/Include/Common/ArchiveFileSystem.h @@ -141,9 +141,9 @@ 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; - void loadMods(); + virtual void loadMods() = 0; ArchivedDirectoryInfo* friend_getArchivedDirectoryInfo(const Char* directory); diff --git a/Core/GameEngine/Include/Common/BIGFileSystem.h b/Core/GameEngine/Include/Common/BIGFileSystem.h index 602a63e4dd5..95e20c0ea6c 100644 --- a/Core/GameEngine/Include/Common/BIGFileSystem.h +++ b/Core/GameEngine/Include/Common/BIGFileSystem.h @@ -49,7 +49,9 @@ class BIGFileSystem : 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/Source/Common/System/ArchiveFileSystem.cpp b/Core/GameEngine/Source/Common/System/ArchiveFileSystem.cpp index 17d49e8b372..2322827ef2b 100644 --- a/Core/GameEngine/Source/Common/System/ArchiveFileSystem.cpp +++ b/Core/GameEngine/Source/Common/System/ArchiveFileSystem.cpp @@ -51,8 +51,6 @@ #include "Common/AsciiString.h" #include "Common/LocalFileSystem.h" #include "Common/PerfTimer.h" -#include "../NGMP_include.h" -#include "../OnlineServices_Init.h" //---------------------------------------------------------------------------- @@ -231,54 +229,6 @@ void ArchiveFileSystem::loadIntoDirectoryTree(ArchiveFile *archiveFile, Bool ove } } -void ArchiveFileSystem::loadMods() -{ -#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()) - { - ArchiveFile *archiveFile = openArchiveFile(TheGlobalData->m_modBIG.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())); - } - else - { - DEBUG_LOG(("ArchiveFileSystem::loadMods - could not openArchiveFile(%s)", TheGlobalData->m_modBIG.str())); - } - } - - if (TheGlobalData->m_modDir.isNotEmpty()) - { - MAYBE_UNUSED Bool ret = loadBigFilesFromDirectory(TheGlobalData->m_modDir, "*.big", TRUE); - (void)ret; - DEBUG_ASSERTLOG(ret, ("loadBigFilesFromDirectory(%s) returned FALSE!", TheGlobalData->m_modDir.str())); - } -} - Bool ArchiveFileSystem::doesFileExist(const Char *filename, FileInstance instance) const { ArchivedDirectoryInfoResult result = const_cast(this)->getArchivedDirectoryInfo(filename); diff --git a/Core/GameEngine/Source/Common/System/BIGFileSystem.cpp b/Core/GameEngine/Source/Common/System/BIGFileSystem.cpp index 6750525f15d..3bd1a29026c 100644 --- a/Core/GameEngine/Source/Common/System/BIGFileSystem.cpp +++ b/Core/GameEngine/Source/Common/System/BIGFileSystem.cpp @@ -42,6 +42,8 @@ #endif #include "Utility/endian_compat.h" +#include "../NGMP_include.h" +#include "../OnlineServices_Init.h" static const char *BIGFileIdentifier = "BIGF"; @@ -58,7 +60,7 @@ void BIGFileSystem::init() { return; } - loadBigFilesFromDirectory("", "*.big"); + loadArchiveFilesFromDirectory("", "*.big"); #if RTS_ZEROHOUR // load original Generals assets @@ -67,7 +69,7 @@ void BIGFileSystem::init() { //@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"); + loadArchiveFilesFromDirectory(installPath, "*.big"); #endif } @@ -236,7 +238,7 @@ void BIGFileSystem::closeAllArchiveFiles() { void BIGFileSystem::closeAllFiles() { } -Bool BIGFileSystem::loadBigFilesFromDirectory(AsciiString dir, AsciiString fileMask, Bool overwrite) { +Bool BIGFileSystem::loadArchiveFilesFromDirectory(AsciiString dir, AsciiString fileMask, Bool overwrite) { FilenameList filenameList; TheLocalFileSystem->getFileListInDirectory(dir, "", fileMask, filenameList, TRUE); @@ -257,10 +259,10 @@ Bool BIGFileSystem::loadBigFilesFromDirectory(AsciiString dir, AsciiString fileM ArchiveFile *archiveFile = openArchiveFile((*it).str()); if (archiveFile != nullptr) { - DEBUG_LOG(("BIGFileSystem::loadBigFilesFromDirectory - loading %s into the directory tree.", (*it).str())); + DEBUG_LOG(("BIGFileSystem::loadArchiveFilesFromDirectory - loading %s into the directory tree.", (*it).str())); loadIntoDirectoryTree(archiveFile, overwrite); m_archiveFileMap[(*it)] = archiveFile; - DEBUG_LOG(("BIGFileSystem::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; } @@ -269,3 +271,51 @@ Bool BIGFileSystem::loadBigFilesFromDirectory(AsciiString dir, AsciiString fileM return actuallyAdded; } + +void BIGFileSystem::loadMods() +{ +#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()) + { + 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, TRUE); + 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())); + } +} From 0e0f89f2fc26438f537934174acffdfeeba3d015 Mon Sep 17 00:00:00 2001 From: tintinhamans <5984296+tintinhamans@users.noreply.github.com> Date: Sat, 24 Oct 2026 14:21:23 +0200 Subject: [PATCH 08/28] feat(online): Mount archives by priority and read the data pack layers from settings --- Core/GameEngine/Include/Common/ArchiveFile.h | 3 + .../Include/Common/ArchiveFileSystem.h | 46 ++- Core/GameEngine/Include/Common/FileSystem.h | 35 ++ .../Common/System/ArchiveFileSystem.cpp | 344 +++++++++++++++--- .../Source/Common/System/BIGFileSystem.cpp | 50 +-- .../Source/Common/System/FileSystem.cpp | 206 ++++++++++- .../GeneralsOnline/GeneralsOnline_Settings.h | 18 +- .../GameEngine/Source/Common/GameEngine.cpp | 7 +- .../GUI/GameWindowManagerScript.cpp | 21 -- .../GeneralsOnline_Settings.cpp | 109 +++++- scripts/pack_go_data.py | 63 ++++ 11 files changed, 790 insertions(+), 112 deletions(-) create mode 100644 scripts/pack_go_data.py diff --git a/Core/GameEngine/Include/Common/ArchiveFile.h b/Core/GameEngine/Include/Common/ArchiveFile.h index a7e0ed68bfb..61a43117017 100644 --- a/Core/GameEngine/Include/Common/ArchiveFile.h +++ b/Core/GameEngine/Include/Common/ArchiveFile.h @@ -58,6 +58,8 @@ class ArchiveFile 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; @@ -68,6 +70,7 @@ class ArchiveFile 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 diff --git a/Core/GameEngine/Include/Common/ArchiveFileSystem.h b/Core/GameEngine/Include/Common/ArchiveFileSystem.h index ddad61637a6..31278520dc4 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,29 @@ 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 game folders that are not retail: the Zero Hour folder first, then the Generals folder, in name order + 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 ///< 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: @@ -145,6 +170,17 @@ class ArchiveFileSystem : public SubsystemInterface 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 isRetailArchive(const AsciiString& path, Bool generalsFolder, Bool zeroHour); + + /// 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); protected: @@ -160,9 +196,17 @@ class ArchiveFileSystem : public SubsystemInterface 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; + Int m_nextModPriority = ARCHIVE_PRIORITY_MOD + 999; ArchivedDirectoryInfo m_rootDirectory; }; diff --git a/Core/GameEngine/Include/Common/FileSystem.h b/Core/GameEngine/Include/Common/FileSystem.h index 75f93caabb5..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" //---------------------------------------------------------------------------- @@ -126,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 //=============================== @@ -165,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. @@ -172,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/Source/Common/System/ArchiveFileSystem.cpp b/Core/GameEngine/Source/Common/System/ArchiveFileSystem.cpp index 2322827ef2b..17ba375b480 100644 --- a/Core/GameEngine/Source/Common/System/ArchiveFileSystem.cpp +++ b/Core/GameEngine/Source/Common/System/ArchiveFileSystem.cpp @@ -46,11 +46,18 @@ //---------------------------------------------------------------------------- #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" //---------------------------------------------------------------------------- @@ -125,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; @@ -164,68 +172,298 @@ void ArchiveFileSystem::loadIntoDirectoryTree(ArchiveFile *archiveFile, Bool ove 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)); + DEBUG_LOG(("ArchiveFileSystem::loadIntoDirectoryTree - adding file %s, archived in %s, priority %d", it->str(), archiveFile->getName().str(), priority)); + + it++; + } +} + +// 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::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 + AsciiString lowerPath = path; + lowerPath.toLower(); + const Bool inGeneralsFolder = generalsFolder || strstr(lowerPath.str(), "zh_generals\\") != nullptr || strstr(lowerPath.str(), "zh_generals/") != nullptr; + + if (zeroHour && !inGeneralsFolder) + return zeroHourNames.count(lower) != 0; + return generalsNames.count(lower) != 0; +} + +Int ArchiveFileSystem::getGameFolderArchivePriority(const AsciiString& path, Bool generalsFolder, Bool zeroHour) +{ + if (isRetailArchive(path, generalsFolder, zeroHour)) + return 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 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 defined(DEBUG_LOGGING) && ENABLE_FILESYSTEM_LOGGING + 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) +{ + 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) + { + 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()); + } + + 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()))) { - 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())); - } + NetworkLog(ELogVerbosity::LOG_RELEASE, "Archive %s is excluded", bigPath.c_str()); + continue; + } + if (m_archiveFileMap.find(AsciiString(bigPath.c_str())) != m_archiveFileMap.end()) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "Archive %s is already mounted", bigPath.c_str()); + continue; } -#endif + 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); +} - it++; +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; + } + + 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) + { + 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); } } diff --git a/Core/GameEngine/Source/Common/System/BIGFileSystem.cpp b/Core/GameEngine/Source/Common/System/BIGFileSystem.cpp index 3bd1a29026c..2852e4076aa 100644 --- a/Core/GameEngine/Source/Common/System/BIGFileSystem.cpp +++ b/Core/GameEngine/Source/Common/System/BIGFileSystem.cpp @@ -256,11 +256,23 @@ Bool BIGFileSystem::loadArchiveFilesFromDirectory(AsciiString dir, AsciiString f } #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(("BIGFileSystem::loadArchiveFilesFromDirectory - loading %s into the directory tree.", (*it).str())); - loadIntoDirectoryTree(archiveFile, overwrite); + loadIntoDirectoryTree(archiveFile, priority); m_archiveFileMap[(*it)] = archiveFile; DEBUG_LOG(("BIGFileSystem::loadArchiveFilesFromDirectory - %s inserted into the archive file map.", (*it).str())); actuallyAdded = TRUE; @@ -274,26 +286,20 @@ Bool BIGFileSystem::loadArchiveFilesFromDirectory(AsciiString dir, AsciiString f void BIGFileSystem::loadMods() { -#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); - } +#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, "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()) @@ -302,7 +308,7 @@ void BIGFileSystem::loadMods() if (archiveFile != nullptr) { DEBUG_LOG(("BIGFileSystem::loadMods - loading %s into the directory tree.", TheGlobalData->m_modBIG.str())); - loadIntoDirectoryTree(archiveFile, TRUE); + loadIntoDirectoryTree(archiveFile, getNextModPriority()); m_archiveFileMap[TheGlobalData->m_modBIG] = archiveFile; DEBUG_LOG(("BIGFileSystem::loadMods - %s inserted into the archive file map.", TheGlobalData->m_modBIG.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/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/GeneralsOnline_Settings.h b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/GeneralsOnline_Settings.h index 0bcc1a2c3e3..8ac6d4fb329 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/GeneralsOnline_Settings.h +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/GeneralsOnline_Settings.h @@ -10,6 +10,13 @@ enum EHTTPVersion HTTP_VERSION_3_0 }; +// One layer of data_packs in settings.json: BIG paths in priority order, the first one wins +struct GenOnlineDataPackLayer +{ + bool m_enabled = true; + std::vector m_includePaths; +}; + class GenOnlineSettings { public: @@ -70,7 +77,10 @@ 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; } @@ -139,7 +149,11 @@ 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(); diff --git a/GeneralsMD/Code/GameEngine/Source/Common/GameEngine.cpp b/GeneralsMD/Code/GameEngine/Source/Common/GameEngine.cpp index 0c497745de0..63e64f71f76 100644 --- a/GeneralsMD/Code/GameEngine/Source/Common/GameEngine.cpp +++ b/GeneralsMD/Code/GameEngine/Source/Common/GameEngine.cpp @@ -504,6 +504,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 +542,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. 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/GeneralsOnline_Settings.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/GeneralsOnline_Settings.cpp index 347c65de981..b8e3ca9bfbc 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/GeneralsOnline_Settings.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/GeneralsOnline_Settings.cpp @@ -1,4 +1,5 @@ #include "GameNetwork/GeneralsOnline/GeneralsOnline_Settings.h" +#include "Common/ArchiveFileSystem.h" #include "../json.hpp" #include "../OnlineServices_LobbyInterface.h" #include "../OnlineServices_Init.h" @@ -37,7 +38,13 @@ #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,6 +101,89 @@ 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) { char GameDir[MAX_PATH + 1] = {}; @@ -317,11 +407,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(); } } } @@ -443,7 +540,9 @@ 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)} } } }; 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() From 1f2a6089a935af63342fa824b64713eb6effa179 Mon Sep 17 00:00:00 2001 From: tintinhamans <5984296+tintinhamans@users.noreply.github.com> Date: Sat, 24 Oct 2026 19:51:25 +0200 Subject: [PATCH 09/28] bugfix(filesystem): Mount the Generals data once and rank it below the Zero Hour archives (cherry picked from commit aaea4c1ff329f526719edda459a27559247510ba) --- .../Include/Common/ArchiveFileSystem.h | 9 +++++++-- .../Common/System/ArchiveFileSystem.cpp | 20 ++++++++++++++----- .../Source/Common/System/BIGFileSystem.cpp | 11 +++++++++- 3 files changed, 32 insertions(+), 8 deletions(-) diff --git a/Core/GameEngine/Include/Common/ArchiveFileSystem.h b/Core/GameEngine/Include/Common/ArchiveFileSystem.h index 31278520dc4..381765359ac 100644 --- a/Core/GameEngine/Include/Common/ArchiveFileSystem.h +++ b/Core/GameEngine/Include/Common/ArchiveFileSystem.h @@ -127,11 +127,13 @@ 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 game folders that are not retail: the Zero Hour folder first, then the Generals folder, in name order + 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 ///< retail and official patch BIGs + 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 @@ -176,6 +178,8 @@ class ArchiveFileSystem : public SubsystemInterface 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); /// BIGs of the game folders that are not retail and not excluded by user.exclude_paths, which are mounted above the data pack layers @@ -206,6 +210,7 @@ class ArchiveFileSystem : public SubsystemInterface 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/GameEngine/Source/Common/System/ArchiveFileSystem.cpp b/Core/GameEngine/Source/Common/System/ArchiveFileSystem.cpp index 17ba375b480..6171f26fabe 100644 --- a/Core/GameEngine/Source/Common/System/ArchiveFileSystem.cpp +++ b/Core/GameEngine/Source/Common/System/ArchiveFileSystem.cpp @@ -234,6 +234,16 @@ static void addRetailNames(std::set& names, Bool zeroHour) } } +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; @@ -253,9 +263,7 @@ Bool ArchiveFileSystem::isRetailArchive(const AsciiString& path, Bool generalsFo 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 - AsciiString lowerPath = path; - lowerPath.toLower(); - const Bool inGeneralsFolder = generalsFolder || strstr(lowerPath.str(), "zh_generals\\") != nullptr || strstr(lowerPath.str(), "zh_generals/") != nullptr; + const Bool inGeneralsFolder = isGeneralsArchivePath(path, generalsFolder); if (zeroHour && !inGeneralsFolder) return zeroHourNames.count(lower) != 0; @@ -264,8 +272,10 @@ Bool ArchiveFileSystem::isRetailArchive(const AsciiString& path, Bool generalsFo 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 ARCHIVE_PRIORITY_RETAIL; + return generals ? ARCHIVE_PRIORITY_RETAIL_GENERALS : ARCHIVE_PRIORITY_RETAIL; if (TheFileSystem != nullptr && TheFileSystem->getExcludedPaths().matchesPath(path.str())) { @@ -274,7 +284,7 @@ Int ArchiveFileSystem::getGameFolderArchivePriority(const AsciiString& path, Boo } m_gameFolderArchives.push_back(path); - return ARCHIVE_PRIORITY_GAME_FOLDER; + return generals ? ARCHIVE_PRIORITY_GAME_FOLDER_GENERALS : ARCHIVE_PRIORITY_GAME_FOLDER; } Bool ArchiveFileSystem::parseMountEntry(const AsciiString& text, ArchiveMountEntry* entry, AsciiString* reason) diff --git a/Core/GameEngine/Source/Common/System/BIGFileSystem.cpp b/Core/GameEngine/Source/Common/System/BIGFileSystem.cpp index 2852e4076aa..6919947f36a 100644 --- a/Core/GameEngine/Source/Common/System/BIGFileSystem.cpp +++ b/Core/GameEngine/Source/Common/System/BIGFileSystem.cpp @@ -64,12 +64,20 @@ void BIGFileSystem::init() { #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()) + if (!installPath.isEmpty()) { + m_generalsBaseSource.format("Generals install folder %s", installPath.str()); loadArchiveFilesFromDirectory(installPath, "*.big"); + } #endif } @@ -289,6 +297,7 @@ 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()); From d30ce729f443e9491f9501d6fb2b1717328484e7 Mon Sep 17 00:00:00 2001 From: tintinhamans <5984296+tintinhamans@users.noreply.github.com> Date: Sun, 18 Oct 2026 05:37:02 +0200 Subject: [PATCH 10/28] fix(online): Preserve WebSocket sends under backpressure and read every queued frame --- .../GeneralsOnline/OnlineServices_Init.h | 16 +- .../OnlineServices_RoomsInterface.cpp | 236 ++++++++++++------ 2 files changed, 176 insertions(+), 76 deletions(-) diff --git a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h index 7d1419d2ee1..c14a4ae5f5e 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h @@ -224,6 +224,8 @@ class WebSocket } private: + CURLcode ReceiveOneFrame(); + CURL* m_pCurlWS = nullptr; CURLM* m_pMulti = nullptr; struct curl_slist* m_pHeaders = nullptr; @@ -259,7 +261,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; diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_RoomsInterface.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_RoomsInterface.cpp index 07e13867223..bf388eecd86 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_RoomsInterface.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_RoomsInterface.cpp @@ -9,10 +9,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,20 +138,18 @@ 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) @@ -275,34 +273,23 @@ void WebSocket::SendData_JoinNetworkRoom(int roomID, uint64_t requestID) void WebSocket::Disconnect() { + 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; + // best effort, never insert CLOSE into a partially sent frame + const CURLcode result = FlushOutbound(); + if (result == CURLE_OK) { - std::scoped_lock lock(m_outboundQueueMutex); - outboundBatch.swap(m_vecQueuedOutboungMsgs); + size_t sent = 0; + (void)curl_ws_send(m_pCurlWS, "", 0, &sent, 0, CURLWS_CLOSE); } - for (std::string& strPayload : outboundBatch) - { - 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)); - } - } - - // send close - size_t sent; - (void)curl_ws_send(m_pCurlWS, "", 0, &sent, 0, CURLWS_CLOSE); - // release headers if (m_pHeaders != nullptr) { @@ -323,23 +310,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) + { + // 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_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] Outbound queue full (%zu), discarding oldest message", m_vecQueuedOutboungMsgs.size()); - m_vecQueuedOutboungMsgs.erase(m_vecQueuedOutboungMsgs.begin()); + 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; +} - m_vecQueuedOutboungMsgs.push_back(std::string(send_payload)); +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 @@ -589,6 +657,7 @@ void WebSocket::BeginReconnect() return; } + ClearOutbound(); m_bReconnecting = true; m_numReconnectAttempts = 0; m_bFreshSession = false; @@ -748,6 +817,10 @@ 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(); @@ -773,13 +846,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); @@ -845,6 +912,10 @@ void WebSocket::Tick() /* connected and ready */ m_bConnected = true; + { + std::scoped_lock lock(m_outboundQueueMutex); + m_acceptOutbound = true; + } m_vecWSPartialBuffer.clear(); EndReconnect(); @@ -867,40 +938,72 @@ 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) { - std::scoped_lock lock(m_outboundQueueMutex); - outboundBatch.swap(m_vecQueuedOutboungMsgs); + m_lastPing = currTime; + Ping(); + } + + const CURLcode sendResult = FlushOutbound(); + if (sendResult != CURLE_OK && sendResult != CURLE_AGAIN) + { + 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); NetworkLog(ELogVerbosity::LOG_DEBUG, "Got websocket len: %d", rlen); @@ -925,7 +1028,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 @@ -1774,7 +1877,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 +1898,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() From fef2fab18f5be60ed31859ee5fb01b7fa3245b17 Mon Sep 17 00:00:00 2001 From: tintinhamans <5984296+tintinhamans@users.noreply.github.com> Date: Thu, 22 Oct 2026 23:13:08 +0200 Subject: [PATCH 11/28] feat(online): Pick a working service route automatically --- GeneralsMD/Code/GameEngine/CMakeLists.txt | 1 + .../GeneralsOnline/GeneralsOnline_Settings.h | 60 ++---- .../GeneralsOnline/HTTP/HTTPManager.h | 10 +- .../GeneralsOnline/HTTP/HTTPRequest.h | 4 +- .../GeneralsOnline/OnlineServices_Auth.h | 1 + .../GeneralsOnline/OnlineServices_Init.h | 14 +- .../GeneralsOnline/ServiceEndpointPolicy.h | 23 +++ .../GeneralsOnline_Settings.cpp | 105 ++++++++-- .../GeneralsOnline/HTTP/HTTPManager.cpp | 57 ++---- .../GeneralsOnline/HTTP/HTTPRequest.cpp | 181 +++++++++--------- .../GeneralsOnline/OnlineServices_Auth.cpp | 26 +++ .../GeneralsOnline/OnlineServices_Init.cpp | 136 +++++++++++-- .../OnlineServices_RoomsInterface.cpp | 125 ++++++------ .../GeneralsOnline/ServiceEndpointPolicy.cpp | 130 +++++++++++++ 14 files changed, 583 insertions(+), 290 deletions(-) create mode 100644 GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/ServiceEndpointPolicy.h create mode 100644 GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/ServiceEndpointPolicy.cpp diff --git a/GeneralsMD/Code/GameEngine/CMakeLists.txt b/GeneralsMD/Code/GameEngine/CMakeLists.txt index a84c0935e98..b62f340c6fe 100644 --- a/GeneralsMD/Code/GameEngine/CMakeLists.txt +++ b/GeneralsMD/Code/GameEngine/CMakeLists.txt @@ -1163,6 +1163,7 @@ set(GAMEENGINE_SRC Source/GameNetwork/GeneralsOnline/DiscordRichPresence.cpp Source/GameNetwork/GeneralsOnline/NGMP_Helpers.cpp Source/GameNetwork/GeneralsOnline/NGMPGame.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/GameNetwork/GeneralsOnline/GeneralsOnline_Settings.h b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/GeneralsOnline_Settings.h index 8ac6d4fb329..94e68938aa8 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/GeneralsOnline_Settings.h +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/GeneralsOnline_Settings.h @@ -1,14 +1,8 @@ #pragma once #include - -enum EHTTPVersion -{ - HTTP_VERSION_AUTO, - HTTP_VERSION_1_0, - HTTP_VERSION_1_1, - HTTP_VERSION_2_0, - HTTP_VERSION_3_0 -}; +#include +#include +#include "GameNetwork/GeneralsOnline/ServiceEndpointPolicy.h" // One layer of data_packs in settings.json: BIG paths in priority order, the first one wins struct GenOnlineDataPackLayer @@ -82,44 +76,13 @@ class GenOnlineSettings 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); private: void Load(void); - void Save(); + bool Save(); private: // NOTE: This also works as the default creation (since we just call Save) @@ -157,6 +120,11 @@ class GenOnlineSettings 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..81177b83703 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/HTTP/HTTPRequest.h +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/HTTP/HTTPRequest.h @@ -70,8 +70,10 @@ class HTTPRequest private: CURL* m_pCURL = nullptr; + bool m_handleAdded = false; - int m_responseCode = -1; + long m_responseCode = 0; + CURLcode m_result = CURLE_OK; EHTTPVerb m_httpVerb; diff --git a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Auth.h b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Auth.h index d7c524879e7..45e1d652ee1 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(); diff --git a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h index c14a4ae5f5e..a1bd94e33a9 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h @@ -225,7 +225,7 @@ class WebSocket private: CURLcode ReceiveOneFrame(); - + void FailConnectionSetup(); CURL* m_pCurlWS = nullptr; CURLM* m_pMulti = nullptr; struct curl_slist* m_pHeaders = nullptr; @@ -536,6 +536,10 @@ class NGMP_OnlineServicesManager } void StartVersionCheck(std::function fnCallback); + void StartVersionCheckOnSelectedRoute(std::function fnCallback); + void EnsureServiceRoute(std::function callback); + 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; } @@ -650,6 +654,14 @@ 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{}; + ServiceConfig m_ServiceConfig; HTTPManager* m_pHTTPManager = nullptr; 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..84d461e8fe8 --- /dev/null +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/ServiceEndpointPolicy.h @@ -0,0 +1,23 @@ +#pragma once +#include +#include +#include + +struct ServiceConnectionSettings +{ + // "automatic" picks a working route + std::string serverMode = "automatic"; +}; + +namespace ServiceConnection +{ +// 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); +bool IsExpectedVersionManifest(const std::string& body); +// Verified TLS, 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/GameNetwork/GeneralsOnline/GeneralsOnline_Settings.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/GeneralsOnline_Settings.cpp index b8e3ca9bfbc..a3bbea5010c 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/GeneralsOnline_Settings.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/GeneralsOnline_Settings.cpp @@ -31,8 +31,12 @@ #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_LAST_ROUTE "last_route" +#define LEGACY_ALTERNATIVE_ROUTE_HOST "api-ru.playgenerals.online" #define SETTINGS_KEY_PLUGINS "plugins" #define SETTINGS_KEY_PLUGINS_ANTICHEAT "anticheat" @@ -253,6 +257,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 (...) @@ -305,28 +312,37 @@ void GenOnlineSettings::Load(void) } } - if (jsonSettings.contains(SETTINGS_KEY_NETWORK)) + if (jsonSettings.contains(SETTINGS_KEY_NETWORK) && jsonSettings[SETTINGS_KEY_NETWORK].is_object()) { - auto networkSettings = jsonSettings[SETTINGS_KEY_NETWORK]; + const auto& networkSettings = jsonSettings[SETTINGS_KEY_NETWORK]; - if (networkSettings.contains(SETTINGS_KEY_NETWORK_HTTP_VERSION)) + if (networkSettings.contains(SETTINGS_KEY_NETWORK_SERVER) && networkSettings[SETTINGS_KEY_NETWORK_SERVER].is_object()) { - 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 auto& serverSettings = networkSettings[SETTINGS_KEY_NETWORK_SERVER]; + if (serverSettings.contains(SETTINGS_KEY_NETWORK_SERVER_MODE) && serverSettings[SETTINGS_KEY_NETWORK_SERVER_MODE].is_string()) + { + m_ConnectionSettings.serverMode = serverSettings[SETTINGS_KEY_NETWORK_SERVER_MODE].get(); + } + } + if (m_ConnectionSettings.serverMode != "automatic") + { + m_ConnectionSettings.serverMode = "automatic"; } - if (networkSettings.contains(SETTINGS_KEY_NETWORK_USE_ALTERNATIVE_ENDPOINT)) + if (networkSettings.contains(SETTINGS_KEY_NETWORK_LAST_ROUTE) && networkSettings[SETTINGS_KEY_NETWORK_LAST_ROUTE].is_string()) + { + m_LastRoute = networkSettings[SETTINGS_KEY_NETWORK_LAST_ROUTE].get(); + } + 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()) { - m_Network_UseAlternativeEndpoint = networkSettings[SETTINGS_KEY_NETWORK_USE_ALTERNATIVE_ENDPOINT]; + // the old switch from the released client now only prefers that route + m_LastRoute = LEGACY_ALTERNATIVE_ROUTE_HOST; + } + if (m_LastRoute.size() > 253 || m_LastRoute.find_first_not_of("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.-") != std::string::npos) + { + m_LastRoute.clear(); } } @@ -453,6 +469,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; @@ -462,19 +479,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, @@ -497,8 +525,8 @@ 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_LAST_ROUTE, m_LastRoute} } }, @@ -547,7 +575,12 @@ void GenOnlineSettings::Save() } }; - 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); @@ -571,4 +604,34 @@ void GenOnlineSettings::Save() std::error_code ec; std::filesystem::remove(strTempPath, ec); } + else + { + m_PreservedJSON = strData; + } + return bSaved; +} + +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..eff25ba94e4 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/HTTP/HTTPManager.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/HTTP/HTTPManager.cpp @@ -158,44 +158,6 @@ void HTTPManager::Shutdown() } -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 +178,6 @@ void HTTPManager::Initialize() CHECK_MAIN_THREAD; m_pCurl = curl_multi_init(); - m_bProxyEnabled = DeterminePlatformProxySettings(); } void HTTPManager::Tick() @@ -225,13 +186,19 @@ void HTTPManager::Tick() std::vector vecItemsToRemove = std::vector(); - // start anything needing starting - for (HTTPRequest* pRequest : m_vecRequestsPendingStart) + // callbacks may enqueue new requests, so do not iterate the live pending vector + auto pending = std::move(m_vecRequestsPendingStart); + m_vecRequestsPendingStart.clear(); + for (HTTPRequest* pRequest : pending) { pRequest->StartRequest(); m_vecRequestsInFlight.push_back(pRequest); + // a request refused before reaching curl completes synchronously + if (pRequest->IsComplete()) + { + vecItemsToRemove.push_back(pRequest); + } } - m_vecRequestsPendingStart.clear(); // perform and poll if (m_pCurl == nullptr) @@ -288,16 +255,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..b5119570343 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,13 @@ 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) + 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); } @@ -119,7 +111,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); } } } @@ -152,8 +144,9 @@ void HTTPRequest::Threaded_SetComplete(CURLcode result) { // store response code curl_easy_getinfo(m_pCURL, CURLINFO_RESPONSE_CODE, &m_responseCode); + m_result = result; - if (result == CURLE_OK) + if (result == CURLE_OK && NGMP_OnlineServicesManager::IsServiceURLTrusted(m_strURI)) { HTTPManager* pHTTPManager = static_cast(NGMP_OnlineServicesManager::GetInstance()->GetHTTPManager()); if (pHTTPManager != nullptr) @@ -233,99 +226,101 @@ 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()); - - 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) - { - curl_easy_setopt(m_pCURL, CURLOPT_IPRESOLVE, CURL_IPRESOLVE_V6); - } - + Threaded_SetComplete(CURLE_FAILED_INIT); + return; + } + std::string error; + if (!ServiceConnection::ConfigureServiceTransport(m_pCURL, m_strURI, error)) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[HTTP] Connection policy: %s", error.c_str()); + Threaded_SetComplete(CURLE_FAILED_INIT); + return; + } - // Are we authenticated? attach our auth header - NGMP_OnlineServices_AuthInterface* pAuthInterface = NGMP_OnlineServicesManager::GetInterface(); - if (pAuthInterface != nullptr && pAuthInterface->IsLoggedIn()) + // 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) { - 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"); + option(CURLOPT_CONNECTTIMEOUT_MS, static_cast(m_timeoutMS)); + option(CURLOPT_TIMEOUT_MS, static_cast(m_timeoutMS)); + option(CURLOPT_HTTP_VERSION, static_cast(CURL_HTTP_VERSION_NONE)); + 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/OnlineServices_Auth.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Auth.cpp index 4bdfb583a4b..40f85720725 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Auth.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Auth.cpp @@ -302,6 +302,32 @@ 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(); + GSMessageBoxOk(UnicodeString(L"Connection failed"), UnicodeString(L"No trusted service route could be reached. Check your connection and try again."), []() { TheShell->pop(); }); + } + }); +} + +void NGMP_OnlineServices_AuthInterface::BeginLoginOnSelectedRoute() { m_tokenCreationTime = -1; diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Init.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Init.cpp index cfe0835efd4..6be1b8151a8 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Init.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Init.cpp @@ -17,6 +17,7 @@ #include "WW3D2/dx8wrapper.h" #include #include +#include "GameNetwork/GeneralsOnline/ServiceEndpointPolicy.h" #define STB_IMAGE_WRITE_IMPLEMENTATION #define STB_IMAGE_RESIZE_IMPLEMENTATION @@ -177,29 +178,109 @@ NGMP_OnlineServicesManager::NGMP_OnlineServicesManager() 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 manager->m_serviceBase + szEndpoint; + } + 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://localhost:9000/env/dev/contract/1/{}", szEndpoint); + callback(!m_serviceBase.empty()); + return; } - else if (g_Environment == EEnvironment::TEST) + if (!m_serviceBase.empty() && std::chrono::steady_clock::now() - m_routeValidatedAt < std::chrono::seconds(30)) { - return std::format("https://api.playgenerals.online:2087/env/test/contract/1/{}", szEndpoint); + callback(true); + return; } - else // PROD + m_routeCallbacks.push_back(std::move(callback)); + if (m_routeCallbacks.size() > 1) { - 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); - } + return; + } + m_bootstrapRoutes = ServiceConnection::BootstrapRoutes(ServiceEnvironment(), Settings.Network_GetConnectionSettings(), 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*) + { + auto* manager = GetInstance(); + if (!manager) + { + return; + } + if (success && status == 200 && ServiceConnection::IsExpectedVersionManifest(body)) + { + manager->m_routeValidatedAt = std::chrono::steady_clock::now(); + if (g_Environment == EEnvironment::PROD) + { + 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 rejection is not evidence that another route would work + manager->m_bootstrapIndex = (status == 401 || status == 403) ? manager->m_bootstrapRoutes.size() : manager->m_bootstrapIndex + 1; + manager->ProbeServiceRoute(); + }, nullptr, 6000); +} + void NGMP_OnlineServicesManager::AttemptLoadSteam() { // app id for ZH @@ -375,6 +456,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"); @@ -865,8 +962,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, [=]() { diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_RoomsInterface.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_RoomsInterface.cpp index bf388eecd86..b91f9c9df63 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_RoomsInterface.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_RoomsInterface.cpp @@ -1,4 +1,5 @@ #include +#include "GameNetwork/GeneralsOnline/ServiceEndpointPolicy.h" #include "GameNetwork/GeneralsOnline/NGMP_interfaces.h" #include "GameNetwork/GeneralsOnline/NGMP_include.h" #include "GameNetwork/GeneralsOnline/NetworkPacket.h" @@ -152,86 +153,92 @@ int WebSocket::Ping() void WebSocket::Connect(const char* url, bool bIsReconnect, std::function fnWebsocketConnectedCallback) { - if (m_bConnected) + if (m_bConnected || m_bShuttingDown) { return; } m_lastPong = NowMs(); + // copy before replacing the stored address: reconnect passes its c_str() + const std::string destination(url); + m_strWebsocketAddr = destination; + m_fnWebsocketConnectedCallback = fnWebsocketConnectedCallback; + // TODO_CACHE: Cleanup multi too 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; + // remove from multi before cleanup (required by libcurl) + if (m_pMulti != 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) + // Free old headers before creating new ones + curl_slist_free_all(m_pHeaders); + m_pHeaders = nullptr; + + // the token only ever goes to the selected service route + NGMP_OnlineServices_AuthInterface* pAuthInterface = NGMP_OnlineServicesManager::GetInterface(); + if (pAuthInterface == nullptr || !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_pCurlWS = curl_easy_init(); - - if (m_pCurlWS != nullptr) + std::string error; + if (m_pCurlWS == nullptr || !ServiceConnection::ConfigureServiceTransport(m_pCurlWS, destination, error)) { - 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 + NetworkLog(ELogVerbosity::LOG_RELEASE, "[WebSocket] Connection policy: %s", error.c_str()); + FailConnectionSetup(); + return; + } + const std::string authorization = "Authorization: Bearer " + pAuthInterface->GetAuthToken(); + m_pHeaders = curl_slist_append(nullptr, authorization.c_str()); + curl_slist* updated = m_pHeaders ? curl_slist_append(m_pHeaders, bIsReconnect ? "is-reconnect: true" : "is-reconnect: false") : nullptr; + if (updated == nullptr) + { + FailConnectionSetup(); + return; + } + m_pHeaders = updated; - // ws needs auth - NGMP_OnlineServices_AuthInterface* pAuthInterface = NGMP_OnlineServicesManager::GetInterface(); - if (pAuthInterface == nullptr) + CURLcode result = CURLE_OK; + auto option = [&](CURLoption key, auto value) { + if (result == CURLE_OK) { - curl_easy_cleanup(m_pCurlWS); - m_pCurlWS = nullptr; - return; + result = curl_easy_setopt(m_pCurlWS, key, value); } + }; + option(CURLOPT_URL, destination.c_str()); + option(CURLOPT_CONNECT_ONLY, 2L); /* websocket style */ + // HTTP v1 seems to have a higher success rate of bypassing DPI + option(CURLOPT_HTTP_VERSION, static_cast(CURL_HTTP_VERSION_NONE)); + option(CURLOPT_HTTPHEADER, m_pHeaders); + if (result != CURLE_OK || curl_multi_add_handle(m_pMulti, m_pCurlWS) != CURLM_OK) + { + FailConnectionSetup(); + } +} - 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::FailConnectionSetup() +{ + m_bConnected = false; + m_vecWSPartialBuffer.clear(); + if (m_bReconnecting) + { + m_reconnectAttemptStarted = -1; + m_nextReconnectAttempt = NowMs() + ComputeReconnectDelay(); + } + else + { + NGMP_OnlineServicesManager::GetInstance()->SetPendingFullTeardown(EGOTearDownReason::LOST_CONNECTION); } } 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..24f25a316c4 --- /dev/null +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/ServiceEndpointPolicy.cpp @@ -0,0 +1,130 @@ +#include "GameNetwork/GeneralsOnline/ServiceEndpointPolicy.h" +#include "GameNetwork/GeneralsOnline/json.hpp" +#include +#include +#include + +namespace ServiceConnection +{ +std::vector BootstrapRoutes(const std::string& environment, + const ServiceConnectionSettings& settings, const std::string& remembered) +{ + 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"); +} + +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; + }; + return set(CURLOPT_SSL_VERIFYPEER, 1L) && set(CURLOPT_SSL_VERIFYHOST, 2L) && + 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, ""); +} +} From d7e183586dc3400ecc3d91d3a293869f5a81ef72 Mon Sep 17 00:00:00 2001 From: tintinhamans <5984296+tintinhamans@users.noreply.github.com> Date: Thu, 22 Oct 2026 23:46:47 +0200 Subject: [PATCH 12/28] fix(online): Log response bodies, login codes and websocket payloads only with verbose logging --- .../GeneralsOnline/HTTP/HTTPRequest.cpp | 57 ++++++++------ .../GeneralsOnline/OnlineServices_Auth.cpp | 27 ++++--- .../OnlineServices_RoomsInterface.cpp | 75 ++++++++++++++++--- 3 files changed, 115 insertions(+), 44 deletions(-) diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/HTTP/HTTPRequest.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/HTTP/HTTPRequest.cpp index b5119570343..25453fc266e 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/HTTP/HTTPRequest.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/HTTP/HTTPRequest.cpp @@ -51,7 +51,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) @@ -68,7 +71,10 @@ 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()); + } PlatformStartRequest(); } @@ -191,34 +197,37 @@ void HTTPRequest::Threaded_SetComplete(CURLcode result) } #endif - 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 we got an error, set the response code to 0 + // response bodies can carry tokens and codes, so only their size is logged unless verbose + NetworkLog(ELogVerbosity::LOG_RELEASE, "[%p|%s|Verb %d] Completed: curl=%d status=%ld bytes=%zu", this, strURIRedacted.c_str(), m_httpVerb, result, m_responseCode, m_currentBufSize_Used); + if (NGMP_OnlineServicesManager::Settings.Debug_VerboseLogging()) + { + std::string strResponse(reinterpret_cast(m_vecBuffer.data()), m_currentBufSize_Used); #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; + // 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; - 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 = ""; - } + 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 - NetworkLog(ELogVerbosity::LOG_RELEASE, "[%p|%s|Verb %d] Response was %d - %s!", this, strURIRedacted.c_str(), m_httpVerb, m_responseCode, strResponse.c_str()); + NetworkLog(ELogVerbosity::LOG_RELEASE, "[%p|%s|Verb %d] Response was %ld - %s!", this, strURIRedacted.c_str(), m_httpVerb, m_responseCode, strResponse.c_str()); + } // trigger callback InvokeCallbackIfComplete(); diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Auth.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Auth.cpp index 40f85720725..f33fcd93bae 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Auth.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Auth.cpp @@ -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) { @@ -457,7 +460,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()); @@ -562,9 +568,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"); diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_RoomsInterface.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_RoomsInterface.cpp index b91f9c9df63..928175632fd 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_RoomsInterface.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_RoomsInterface.cpp @@ -617,12 +617,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; } @@ -641,7 +655,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 (...) @@ -1012,7 +1033,14 @@ CURLcode WebSocket::ReceiveOneFrame() 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 @@ -1645,7 +1673,14 @@ CURLcode WebSocket::ReceiveOneFrame() 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); @@ -1834,21 +1869,41 @@ CURLcode WebSocket::ReceiveOneFrame() } 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(); } From 20e538532c3ce833000d40a2ef8789a45fdff5a6 Mon Sep 17 00:00:00 2001 From: tintinhamans <5984296+tintinhamans@users.noreply.github.com> Date: Thu, 22 Oct 2026 23:58:19 +0200 Subject: [PATCH 13/28] feat(online): Skip TLS verification for service connections in debug builds --- .../GameNetwork/GeneralsOnline/ServiceEndpointPolicy.h | 2 +- .../GameNetwork/GeneralsOnline/OnlineServices_Init.cpp | 3 +++ .../GeneralsOnline/ServiceEndpointPolicy.cpp | 10 +++++++++- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/ServiceEndpointPolicy.h b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/ServiceEndpointPolicy.h index 84d461e8fe8..acefa3d6730 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/ServiceEndpointPolicy.h +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/ServiceEndpointPolicy.h @@ -18,6 +18,6 @@ std::vector BootstrapRoutes(const std::string& environment, std::string RouteHost(const std::string& route); bool IsTrustedServiceURL(const std::string& url, const std::string& base, bool websocket); bool IsExpectedVersionManifest(const std::string& body); -// Verified TLS, no redirects, no embedded credentials, direct connection; HTTPS or WSS only. +// 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/GameNetwork/GeneralsOnline/OnlineServices_Init.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Init.cpp index 6be1b8151a8..d484b2a28af 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Init.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Init.cpp @@ -174,6 +174,9 @@ 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; } diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/ServiceEndpointPolicy.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/ServiceEndpointPolicy.cpp index 24f25a316c4..e474e78d795 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/ServiceEndpointPolicy.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/ServiceEndpointPolicy.cpp @@ -122,7 +122,15 @@ bool ConfigureServiceTransport(CURL* curl, const std::string& url, std::string& error = "Unable to configure service transport: " + std::string(curl_easy_strerror(code)); return code == CURLE_OK; }; - return set(CURLOPT_SSL_VERIFYPEER, 1L) && set(CURLOPT_SSL_VERIFYHOST, 2L) && +#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, ""); From 19e41e2a0734603cc71986e868a668a9c5e35cf6 Mon Sep 17 00:00:00 2001 From: tintinhamans <5984296+tintinhamans@users.noreply.github.com> Date: Thu, 22 Oct 2026 23:22:50 +0200 Subject: [PATCH 14/28] feat(online): Share proxy settings between HTTP and websocket --- GeneralsMD/Code/GameEngine/CMakeLists.txt | 1 + .../GeneralsOnline/GeneralsOnline_Settings.h | 3 +- .../GeneralsOnline/HTTP/HTTPRequest.h | 8 + .../GeneralsOnline/OnlineServices_Init.h | 9 + .../GeneralsOnline/ServiceConnectionPolicy.h | 48 + .../GeneralsOnline/ServiceEndpointPolicy.h | 6 + .../GeneralsOnline_Settings.cpp | 65 ++ .../GeneralsOnline/HTTP/HTTPManager.cpp | 93 +- .../GeneralsOnline/HTTP/HTTPRequest.cpp | 164 ++- .../GeneralsOnline/OnlineServices_Auth.cpp | 2 +- .../GeneralsOnline/OnlineServices_Init.cpp | 23 +- .../OnlineServices_RoomsInterface.cpp | 163 ++- .../ServiceConnectionPolicy.cpp | 969 ++++++++++++++++++ .../GeneralsOnline/ServiceEndpointPolicy.cpp | 41 + cmake/triplets/x86-windows-static-md.cmake | 13 + vcpkg.json | 3 +- 16 files changed, 1435 insertions(+), 176 deletions(-) create mode 100644 GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/ServiceConnectionPolicy.h create mode 100644 GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/ServiceConnectionPolicy.cpp create mode 100644 cmake/triplets/x86-windows-static-md.cmake diff --git a/GeneralsMD/Code/GameEngine/CMakeLists.txt b/GeneralsMD/Code/GameEngine/CMakeLists.txt index b62f340c6fe..86aff310ba8 100644 --- a/GeneralsMD/Code/GameEngine/CMakeLists.txt +++ b/GeneralsMD/Code/GameEngine/CMakeLists.txt @@ -1163,6 +1163,7 @@ set(GAMEENGINE_SRC Source/GameNetwork/GeneralsOnline/DiscordRichPresence.cpp Source/GameNetwork/GeneralsOnline/NGMP_Helpers.cpp Source/GameNetwork/GeneralsOnline/NGMPGame.cpp + Source/GameNetwork/GeneralsOnline/ServiceConnectionPolicy.cpp Source/GameNetwork/GeneralsOnline/ServiceEndpointPolicy.cpp Source/GameNetwork/GeneralsOnline/GeneralsOnline_Settings.cpp Source/GameNetwork/GeneralsOnline/HTTP/HTTPManager.cpp diff --git a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/GeneralsOnline_Settings.h b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/GeneralsOnline_Settings.h index 94e68938aa8..fada211ba99 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/GeneralsOnline_Settings.h +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/GeneralsOnline_Settings.h @@ -1,7 +1,7 @@ #pragma once -#include #include #include +#include #include "GameNetwork/GeneralsOnline/ServiceEndpointPolicy.h" // One layer of data_packs in settings.json: BIG paths in priority order, the first one wins @@ -79,6 +79,7 @@ class GenOnlineSettings 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_SaveConnectionSettings(const ServiceConnectionSettings& settings, std::string& error); private: void Load(void); diff --git a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/HTTP/HTTPRequest.h b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/HTTP/HTTPRequest.h index 81177b83703..edd1344c5b0 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() { @@ -71,6 +74,11 @@ class HTTPRequest private: CURL* m_pCURL = nullptr; 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; diff --git a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h index a1bd94e33a9..953f8df30cc 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h @@ -1,4 +1,5 @@ #pragma once +#include "ServiceConnectionPolicy.h" #include "NGMP_include.h" @@ -225,7 +226,14 @@ class WebSocket private: CURLcode ReceiveOneFrame(); + void PollConnectionSetup(); + void StartResolvedConnection(); void FailConnectionSetup(); + 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; @@ -661,6 +669,7 @@ class NGMP_OnlineServicesManager size_t m_bootstrapIndex = 0; std::vector> m_routeCallbacks; std::chrono::steady_clock::time_point m_routeValidatedAt{}; + std::string m_routeSettingsKey; ServiceConfig m_ServiceConfig; 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..5cb70da2e09 --- /dev/null +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/ServiceConnectionPolicy.h @@ -0,0 +1,48 @@ +#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); +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 index acefa3d6730..70087002f4d 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/ServiceEndpointPolicy.h +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/ServiceEndpointPolicy.h @@ -7,6 +7,11 @@ struct ServiceConnectionSettings { // "automatic" picks a working route std::string serverMode = "automatic"; + // "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 @@ -17,6 +22,7 @@ std::vector BootstrapRoutes(const std::string& environment, // 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/GameNetwork/GeneralsOnline/GeneralsOnline_Settings.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/GeneralsOnline_Settings.cpp index a3bbea5010c..0cd194702ae 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/GeneralsOnline_Settings.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/GeneralsOnline_Settings.cpp @@ -35,6 +35,10 @@ #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_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" @@ -329,6 +333,33 @@ void GenOnlineSettings::Load(void) m_ConnectionSettings.serverMode = "automatic"; } + if (networkSettings.contains(SETTINGS_KEY_NETWORK_PROXY) && networkSettings[SETTINGS_KEY_NETWORK_PROXY].is_object()) + { + const auto& proxySettings = networkSettings[SETTINGS_KEY_NETWORK_PROXY]; + if (proxySettings.contains(SETTINGS_KEY_NETWORK_PROXY_MODE) && proxySettings[SETTINGS_KEY_NETWORK_PROXY_MODE].is_string()) + { + m_ConnectionSettings.proxyMode = proxySettings[SETTINGS_KEY_NETWORK_PROXY_MODE].get(); + } + if (proxySettings.contains(SETTINGS_KEY_NETWORK_PROXY_ADDRESS) && proxySettings[SETTINGS_KEY_NETWORK_PROXY_ADDRESS].is_string()) + { + m_ConnectionSettings.proxyURL = proxySettings[SETTINGS_KEY_NETWORK_PROXY_ADDRESS].get(); + } + if (proxySettings.contains(SETTINGS_KEY_NETWORK_PROXY_WINDOWS_LOGIN) && proxySettings[SETTINGS_KEY_NETWORK_PROXY_WINDOWS_LOGIN].is_boolean()) + { + m_ConnectionSettings.windowsLogin = proxySettings[SETTINGS_KEY_NETWORK_PROXY_WINDOWS_LOGIN].get(); + } + } + if (m_ConnectionSettings.proxyMode != "automatic" && m_ConnectionSettings.proxyMode != "disabled" && m_ConnectionSettings.proxyMode != "custom") + { + m_ConnectionSettings.proxyMode = "automatic"; + } + // an invalid custom proxy stays custom with no address, so it can never silently escape to direct + std::string proxyError; + if (!m_ConnectionSettings.proxyURL.empty() && !ServiceConnection::ValidateProxyURL(m_ConnectionSettings.proxyURL, proxyError)) + { + m_ConnectionSettings.proxyURL.clear(); + } + if (networkSettings.contains(SETTINGS_KEY_NETWORK_LAST_ROUTE) && networkSettings[SETTINGS_KEY_NETWORK_LAST_ROUTE].is_string()) { m_LastRoute = networkSettings[SETTINGS_KEY_NETWORK_LAST_ROUTE].get(); @@ -526,6 +557,10 @@ bool GenOnlineSettings::Save() SETTINGS_KEY_NETWORK, { {SETTINGS_KEY_NETWORK_SERVER, {{SETTINGS_KEY_NETWORK_SERVER_MODE, m_ConnectionSettings.serverMode}}}, + {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} } }, @@ -611,6 +646,36 @@ bool GenOnlineSettings::Save() return bSaved; } +bool GenOnlineSettings::Network_SaveConnectionSettings(const ServiceConnectionSettings& settings, std::string& error) +{ + error.clear(); + if (settings.serverMode != "automatic" || + (settings.proxyMode != "automatic" && settings.proxyMode != "disabled" && settings.proxyMode != "custom")) + { + error = "Choose a valid service and proxy mode."; + return false; + } + 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()) + { + 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_SaveLastRoute(const std::string& host) { if (host.empty() || host.size() > 253 || host.find_first_not_of("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.-") != std::string::npos) diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/HTTP/HTTPManager.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/HTTP/HTTPManager.cpp index eff25ba94e4..5e506f70c2b 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/HTTP/HTTPManager.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/HTTP/HTTPManager.cpp @@ -83,75 +83,16 @@ 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"); @@ -186,18 +127,19 @@ void HTTPManager::Tick() std::vector vecItemsToRemove = std::vector(); - // callbacks may enqueue new requests, so do not iterate the live pending vector + // Callbacks may enqueue new requests; do not iterate the live pending vector. auto pending = std::move(m_vecRequestsPendingStart); m_vecRequestsPendingStart.clear(); - for (HTTPRequest* pRequest : pending) + for (HTTPRequest* request : pending) { - pRequest->StartRequest(); - m_vecRequestsInFlight.push_back(pRequest); - // a request refused before reaching curl completes synchronously - if (pRequest->IsComplete()) - { - vecItemsToRemove.push_back(pRequest); - } + request->StartRequest(); + m_vecRequestsInFlight.push_back(request); + } + for (HTTPRequest* request : m_vecRequestsInFlight) + { + request->PollConnectionSetup(); + if (request->IsComplete()) + vecItemsToRemove.push_back(request); } // perform and poll @@ -215,7 +157,7 @@ void HTTPManager::Tick() if (pRequest->WaitingDelayAction()) { bool bDone = pRequest->InvokeDelayAction(); - if (bDone) + if (bDone && pRequest->IsComplete()) { vecItemsToRemove.push_back(pRequest); } @@ -244,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 } } diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/HTTP/HTTPRequest.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/HTTP/HTTPRequest.cpp index 25453fc266e..1d56d3311fe 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/HTTP/HTTPRequest.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/HTTP/HTTPRequest.cpp @@ -36,6 +36,7 @@ HTTPRequest::HTTPRequest(EHTTPVerb httpVerb, EIPProtocolVersion protover, const HTTPRequest::~HTTPRequest() { + m_proxyResolution.reset(); NGMP_OnlineServicesManager* manager = NGMP_OnlineServicesManager::GetInstance(); if (m_handleAdded && manager != nullptr && manager->GetHTTPManager() != nullptr) { @@ -75,6 +76,44 @@ void HTTPRequest::StartRequest() { 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(); } @@ -136,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; } @@ -148,11 +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 && NGMP_OnlineServicesManager::IsServiceURLTrusted(m_strURI)) + 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) @@ -185,48 +245,62 @@ 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) - { - std::string strReplace = ""; - const size_t tokenLen = 32; - strURIRedacted = strURIRedacted.replace(tokenpos + 6, tokenLen, strReplace); - } -#endif - - // response bodies can carry tokens and codes, so only their size is logged unless verbose - NetworkLog(ELogVerbosity::LOG_RELEASE, "[%p|%s|Verb %d] Completed: curl=%d status=%ld bytes=%zu", this, strURIRedacted.c_str(), m_httpVerb, result, m_responseCode, m_currentBufSize_Used); - if (NGMP_OnlineServicesManager::Settings.Debug_VerboseLogging()) + 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 strResponse(reinterpret_cast(m_vecBuffer.data()), m_currentBufSize_Used); - -#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)) + const char* method = "UNKNOWN"; + switch (m_httpVerb) { - 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; + 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; } - - 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) + const auto url = ServiceConnection::RequestURLForLog(m_strURI, NGMP_OnlineServicesManager::GetAPIEndpoint("")); + if (!m_setupFailurePhase.empty()) { - strResponse = ""; + 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); } -#endif + 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); + } + + if (NGMP_OnlineServicesManager::Settings.Debug_VerboseLogging()) + { + std::string strResponse(reinterpret_cast(m_vecBuffer.data()), m_currentBufSize_Used); + + #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; - NetworkLog(ELogVerbosity::LOG_RELEASE, "[%p|%s|Verb %d] Response was %ld - %s!", this, strURIRedacted.c_str(), m_httpVerb, m_responseCode, strResponse.c_str()); + 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 + + NetworkLog(ELogVerbosity::LOG_RELEASE, "[HTTP] Response body for %s: %s", url.c_str(), strResponse.c_str()); + } } // trigger callback @@ -240,8 +314,12 @@ void HTTPRequest::PlatformStartRequest() 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::ConfigureServiceTransport(m_pCURL, m_strURI, 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); @@ -290,9 +368,11 @@ void HTTPRequest::PlatformStartRequest() option(CURLOPT_WRITEDATA, static_cast(this)); option(CURLOPT_WRITEFUNCTION, WriteMemoryCallback); option(CURLOPT_USERAGENT, "GeneralsOnline Client"); - option(CURLOPT_CONNECTTIMEOUT_MS, static_cast(m_timeoutMS)); - option(CURLOPT_TIMEOUT_MS, static_cast(m_timeoutMS)); - option(CURLOPT_HTTP_VERSION, static_cast(CURL_HTTP_VERSION_NONE)); + // 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); diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Auth.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Auth.cpp index f33fcd93bae..9cd0ffe28a3 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Auth.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Auth.cpp @@ -325,7 +325,7 @@ void NGMP_OnlineServices_AuthInterface::BeginLogin() else { ClearGSMessageBoxes(); - GSMessageBoxOk(UnicodeString(L"Connection failed"), UnicodeString(L"No trusted service route could be reached. Check your connection and try again."), []() { TheShell->pop(); }); + GSMessageBoxOk(UnicodeString(L"Connection failed"), UnicodeString(L"No trusted service route could be reached. Check Connection settings in Options, then try again."), []() { TheShell->pop(); }); } }); } diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Init.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Init.cpp index d484b2a28af..89170e14218 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Init.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Init.cpp @@ -220,7 +220,9 @@ void NGMP_OnlineServicesManager::EnsureServiceRoute(std::function ca callback(!m_serviceBase.empty()); return; } - if (!m_serviceBase.empty() && std::chrono::steady_clock::now() - m_routeValidatedAt < std::chrono::seconds(30)) + const auto settings = Settings.Network_GetConnectionSettings(); + const std::string key = settings.serverMode + "|" + 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)) { callback(true); return; @@ -230,7 +232,8 @@ void NGMP_OnlineServicesManager::EnsureServiceRoute(std::function ca { return; } - m_bootstrapRoutes = ServiceConnection::BootstrapRoutes(ServiceEnvironment(), Settings.Network_GetConnectionSettings(), Settings.Network_GetLastRoute()); + m_routeSettingsKey = key; + m_bootstrapRoutes = ServiceConnection::BootstrapRoutes(ServiceEnvironment(), settings, Settings.Network_GetLastRoute()); m_bootstrapIndex = 0; ProbeServiceRoute(); } @@ -278,8 +281,8 @@ void NGMP_OnlineServicesManager::ProbeServiceRoute() return; } NetworkLog(ELogVerbosity::LOG_RELEASE, "[Connection] Route validation failed (HTTP %d); credentials were not sent", status); - // an access rejection is not evidence that another route would work - manager->m_bootstrapIndex = (status == 401 || status == 403) ? manager->m_bootstrapRoutes.size() : manager->m_bootstrapIndex + 1; + // 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); } @@ -433,7 +436,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..."); @@ -445,7 +448,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..."); @@ -1183,7 +1186,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 @@ -1285,6 +1293,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(); diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_RoomsInterface.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_RoomsInterface.cpp index 928175632fd..2de6ecdecd1 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_RoomsInterface.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_RoomsInterface.cpp @@ -154,92 +154,137 @@ int WebSocket::Ping() void WebSocket::Connect(const char* url, bool bIsReconnect, std::function fnWebsocketConnectedCallback) { if (m_bConnected || m_bShuttingDown) - { return; - } - - m_lastPong = NowMs(); - - // copy before replacing the stored address: reconnect passes its c_str() + // Copy before replacing the stored address: reconnect passes its c_str(). const std::string destination(url); m_strWebsocketAddr = destination; m_fnWebsocketConnectedCallback = fnWebsocketConnectedCallback; - - // TODO_CACHE: Cleanup multi too + m_connectAsReconnect = bIsReconnect; + m_lastPong = NowMs(); + m_proxySetupStarted = NowMs(); + m_proxyResolution.reset(); + m_proxyPolicy = {}; + m_proxyRoute = 0; if (m_pCurlWS != nullptr) { - // remove from multi before cleanup (required by libcurl) - if (m_pMulti != nullptr) - { - curl_multi_remove_handle(m_pMulti, m_pCurlWS); - } + curl_multi_remove_handle(m_pMulti, m_pCurlWS); curl_easy_cleanup(m_pCurlWS); m_pCurlWS = nullptr; } - - // Free old headers before creating new ones curl_slist_free_all(m_pHeaders); m_pHeaders = nullptr; - - // the token only ever goes to the selected service route - NGMP_OnlineServices_AuthInterface* pAuthInterface = NGMP_OnlineServicesManager::GetInterface(); - if (pAuthInterface == nullptr || !NGMP_OnlineServicesManager::IsWebSocketURLTrusted(destination)) + if (!NGMP_OnlineServicesManager::IsWebSocketURLTrusted(destination)) { NetworkLog(ELogVerbosity::LOG_RELEASE, "[WebSocket] Refused untrusted service destination"); FailConnectionSetup(); return; } + m_proxyResolution = ServiceConnection::StartProxyResolution( + NGMP_OnlineServicesManager::Settings.Network_GetConnectionSettings(), destination); +} + +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); + } +} +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()) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[WebSocket] Proxy policy fallback: native_code=%lu detail=%s", + m_proxyPolicy.nativeError, m_proxyPolicy.fallbackReason.c_str()); + } + } + StartResolvedConnection(); +} + +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; - if (m_pCurlWS == nullptr || !ServiceConnection::ConfigureServiceTransport(m_pCurlWS, destination, 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 " + pAuthInterface->GetAuthToken(); + const std::string authorization = "Authorization: Bearer " + auth->GetAuthToken(); m_pHeaders = curl_slist_append(nullptr, authorization.c_str()); - curl_slist* updated = m_pHeaders ? curl_slist_append(m_pHeaders, bIsReconnect ? "is-reconnect: true" : "is-reconnect: false") : nullptr; + 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, destination.c_str()); - option(CURLOPT_CONNECT_ONLY, 2L); /* websocket style */ - // HTTP v1 seems to have a higher success rate of bypassing DPI - option(CURLOPT_HTTP_VERSION, static_cast(CURL_HTTP_VERSION_NONE)); + 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::FailConnectionSetup() -{ - m_bConnected = false; - m_vecWSPartialBuffer.clear(); - if (m_bReconnecting) - { - m_reconnectAttemptStarted = -1; - m_nextReconnectAttempt = NowMs() + ComputeReconnectDelay(); - } - else - { - NGMP_OnlineServicesManager::GetInstance()->SetPendingFullTeardown(EGOTearDownReason::LOST_CONNECTION); - } } void WebSocket::SendData_RoomChatMessage(UnicodeString& msg, bool bIsAction) @@ -280,6 +325,7 @@ void WebSocket::SendData_JoinNetworkRoom(int roomID, uint64_t requestID) void WebSocket::Disconnect() { + m_proxyResolution.reset(); EndReconnect(); if (!m_bConnected) { @@ -851,6 +897,7 @@ void WebSocket::Tick() } // Main thread only; m_pCurlWS/m_vecWSPartialBuffer/m_bConnected are unlocked here. UpdateReconnect(); + PollConnectionSetup(); @@ -892,15 +939,29 @@ 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 (m_bReconnecting) { @@ -938,6 +999,10 @@ 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; { 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..3f1f89484cc --- /dev/null +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/ServiceConnectionPolicy.cpp @@ -0,0 +1,969 @@ +#include "GameNetwork/GeneralsOnline/ServiceConnectionPolicy.h" + +#define WIN32_LEAN_AND_MEAN +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#pragma comment(lib, "winhttp.lib") +#pragma comment(lib, "advapi32.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& input, std::string& canonical, std::string& error) +{ + error.clear(); + 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; +} + +std::wstring CredentialTarget(const std::string& canonical) +{ + return L"GeneralsOnline/Proxy/" + Wide(canonical); +} + +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, false}); + } + // 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, false}); + } + 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); +} + +ResolvedProxyPolicy ResolveProxyPolicy(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; +} + +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; + auto user = Wide(username); + auto secret = Wide(password); + if (user.empty() || username.find('\0') != std::string::npos || password.find('\0') != std::string::npos || + (!password.empty() && secret.empty()) || secret.size() * sizeof(wchar_t) > CRED_MAX_CREDENTIAL_BLOB_SIZE) + { + if (!secret.empty()) + SecureZeroMemory(&secret[0], secret.size() * sizeof(wchar_t)); + error = "Proxy credentials are invalid or too long."; + return false; + } + auto target = CredentialTarget(canonical); + CREDENTIALW credential{}; + credential.Type = CRED_TYPE_GENERIC; + credential.TargetName = &target[0]; + credential.UserName = &user[0]; + credential.CredentialBlobSize = (DWORD)(secret.size() * sizeof(wchar_t)); + credential.CredentialBlob = reinterpret_cast(secret.empty() ? nullptr : &secret[0]); + credential.Persist = CRED_PERSIST_LOCAL_MACHINE; + bool success = CredWriteW(&credential, 0) != FALSE; + DWORD status = success ? ERROR_SUCCESS : GetLastError(); + if (!secret.empty()) + SecureZeroMemory(&secret[0], secret.size() * sizeof(wchar_t)); + if (!success) + error = "Unable to save proxy credentials (" + std::to_string(status) + ")."; + return success; +} + +bool DeleteProxyCredentials(const std::string& proxyURL, std::string& error) +{ + std::string canonical; + if (!CanonicalProxy(proxyURL, canonical, error)) + return false; + if (CredDeleteW(CredentialTarget(canonical).c_str(), CRED_TYPE_GENERIC, 0)) + return true; + DWORD status = GetLastError(); + if (status == ERROR_NOT_FOUND) + return true; + error = "Unable to remove proxy credentials (" + std::to_string(status) + ")."; + 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; + PCREDENTIALW credential = nullptr; + if (CredReadW(CredentialTarget(canonical).c_str(), CRED_TYPE_GENERIC, 0, &credential)) + { + bool valid = credential->UserName && *credential->UserName && credential->CredentialBlobSize % sizeof(wchar_t) == 0; + std::string user = valid ? UTF8(credential->UserName) : std::string(); + std::wstring wideSecret; + if (valid && credential->CredentialBlobSize) + wideSecret.assign(reinterpret_cast(credential->CredentialBlob), credential->CredentialBlobSize / sizeof(wchar_t)); + std::string secret = UTF8(wideSecret); + valid = valid && !user.empty() && secret.find('\0') == std::string::npos; + bool success = valid && 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()); + if (!wideSecret.empty()) + SecureZeroMemory(&wideSecret[0], wideSecret.size() * sizeof(wchar_t)); + if (credential->CredentialBlobSize) + SecureZeroMemory(credential->CredentialBlob, credential->CredentialBlobSize); + CredFree(credential); + if (!valid) + error = "Stored proxy credentials are invalid. Save them again."; + return success; + } + DWORD status = GetLastError(); + if (status != ERROR_NOT_FOUND) + { + error = "Unable to read saved proxy credentials (" + std::to_string(status) + ")."; + 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, ":"); + } + 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"; +} +} diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/ServiceEndpointPolicy.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/ServiceEndpointPolicy.cpp index e474e78d795..ca208b95875 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/ServiceEndpointPolicy.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/ServiceEndpointPolicy.cpp @@ -99,6 +99,47 @@ bool IsTrustedServiceURL(const std::string& value, const std::string& base, bool 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); 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/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", From 59ec10596e7d03167758e5ccb0fb033cbb78edeb Mon Sep 17 00:00:00 2001 From: tintinhamans <5984296+tintinhamans@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:12:32 +0200 Subject: [PATCH 15/28] feat(online): Ask for the proxy login when the proxy requires it --- GeneralsMD/Code/GameEngine/CMakeLists.txt | 2 + .../GeneralsOnline/CredentialStore.h | 25 + .../GeneralsOnline/GeneralsOnline_Settings.h | 1 + .../GeneralsOnline/HTTP/HTTPRequest.h | 3 + .../GeneralsOnline/OnlineServices_Auth.h | 4 +- .../GeneralsOnline/OnlineServices_Init.h | 8 + .../GameNetwork/GeneralsOnline/ProxyLogin.h | 22 + .../GeneralsOnline/ServiceConnectionPolicy.h | 25 + .../GameEngine/Source/Common/GameEngine.cpp | 4 + .../GeneralsOnline/CredentialStore.cpp | 301 ++++++ .../GeneralsOnline_Settings.cpp | 28 + .../GeneralsOnline/OnlineServices_Auth.cpp | 141 +-- .../GeneralsOnline/OnlineServices_Init.cpp | 30 +- .../OnlineServices_RoomsInterface.cpp | 32 + .../GameNetwork/GeneralsOnline/ProxyLogin.cpp | 876 ++++++++++++++++++ .../ServiceConnectionPolicy.cpp | 342 +++++-- 16 files changed, 1653 insertions(+), 191 deletions(-) create mode 100644 GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/CredentialStore.h create mode 100644 GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/ProxyLogin.h create mode 100644 GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/CredentialStore.cpp create mode 100644 GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/ProxyLogin.cpp diff --git a/GeneralsMD/Code/GameEngine/CMakeLists.txt b/GeneralsMD/Code/GameEngine/CMakeLists.txt index 86aff310ba8..05bea1c62ca 100644 --- a/GeneralsMD/Code/GameEngine/CMakeLists.txt +++ b/GeneralsMD/Code/GameEngine/CMakeLists.txt @@ -1160,9 +1160,11 @@ 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 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 fada211ba99..006308bab90 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/GeneralsOnline_Settings.h +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/GeneralsOnline_Settings.h @@ -79,6 +79,7 @@ class GenOnlineSettings 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: diff --git a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/HTTP/HTTPRequest.h b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/HTTP/HTTPRequest.h index edd1344c5b0..6ca544b4007 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/HTTP/HTTPRequest.h +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/HTTP/HTTPRequest.h @@ -68,6 +68,9 @@ 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(); diff --git a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Auth.h b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Auth.h index 45e1d652ee1..9deb4599bf2 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Auth.h +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Auth.h @@ -49,6 +49,8 @@ class NGMP_OnlineServices_AuthInterface void LogoutOfMyAccount(); + static std::string GetCredentialsFilePath(); + private: void LoginAsSecondaryDevAccount(); @@ -57,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 953f8df30cc..635b1911651 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h @@ -201,6 +201,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) { @@ -229,6 +232,7 @@ class WebSocket void PollConnectionSetup(); void StartResolvedConnection(); void FailConnectionSetup(); + bool m_proxyPrompted = false; std::shared_ptr m_proxyResolution; ServiceConnection::ResolvedProxyPolicy m_proxyPolicy; size_t m_proxyRoute = 0; @@ -546,6 +550,8 @@ 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); @@ -670,6 +676,8 @@ class NGMP_OnlineServicesManager 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; 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 index 5cb70da2e09..824eee696c2 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/ServiceConnectionPolicy.h +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/ServiceConnectionPolicy.h @@ -43,6 +43,31 @@ bool ConfigureCurl(CURL* curl, const std::string& destinationURL, const ProxyRou 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/Source/Common/GameEngine.cpp b/GeneralsMD/Code/GameEngine/Source/Common/GameEngine.cpp index 63e64f71f76..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" @@ -1012,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/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 0cd194702ae..687a2e09de8 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/GeneralsOnline_Settings.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/GeneralsOnline_Settings.cpp @@ -3,6 +3,8 @@ #include "../json.hpp" #include "../OnlineServices_LobbyInterface.h" #include "../OnlineServices_Init.h" +#include "GameNetwork/GeneralsOnline/CredentialStore.h" +#include "GameNetwork/GeneralsOnline/OnlineServices_Auth.h" #define SETTINGS_KEY_CAMERA "camera" #define SETTINGS_KEY_CAMERA_MIN_HEIGHT "min_height" @@ -194,6 +196,8 @@ static nlohmann::json DataPackLayerToJson(const GenOnlineDataPackLayer& layer, c 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()); @@ -668,6 +672,9 @@ bool GenOnlineSettings::Network_SaveConnectionSettings(const ServiceConnectionSe m_ConnectionSettings = settings; if (Save()) { + // a changed proxy starts without anything typed for the old one + if (settings.proxyMode == "custom" && (previous.proxyMode != "custom" || previous.proxyURL != settings.proxyURL)) + ServiceConnection::ClearSessionProxyCredentialsExcept(settings.proxyURL); ServiceConnection::InvalidateProxyPolicyCache(); return true; } @@ -676,6 +683,27 @@ bool GenOnlineSettings::Network_SaveConnectionSettings(const ServiceConnectionSe 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) diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Auth.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Auth.cpp index 9cd0ffe28a3..d61bd98a611 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" @@ -325,7 +325,8 @@ void NGMP_OnlineServices_AuthInterface::BeginLogin() else { ClearGSMessageBoxes(); - GSMessageBoxOk(UnicodeString(L"Connection failed"), UnicodeString(L"No trusted service route could be reached. Check Connection settings in Options, then try again."), []() { TheShell->pop(); }); + 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(); }); } }); } @@ -646,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() @@ -666,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() @@ -722,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 "go"; } 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 89170e14218..b48163bc537 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Init.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Init.cpp @@ -18,6 +18,7 @@ #include #include #include "GameNetwork/GeneralsOnline/ServiceEndpointPolicy.h" +#include "GameNetwork/GeneralsOnline/ProxyLogin.h" #define STB_IMAGE_WRITE_IMPLEMENTATION #define STB_IMAGE_RESIZE_IMPLEMENTATION @@ -233,6 +234,8 @@ void NGMP_OnlineServicesManager::EnsureServiceRoute(std::function ca return; } m_routeSettingsKey = key; + m_routeProxyPrompted = false; + m_routeProxyAuthFailed = false; m_bootstrapRoutes = ServiceConnection::BootstrapRoutes(ServiceEnvironment(), settings, Settings.Network_GetLastRoute()); m_bootstrapIndex = 0; ProbeServiceRoute(); @@ -262,15 +265,40 @@ void NGMP_OnlineServicesManager::ProbeServiceRoute() 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*) + [](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) { diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_RoomsInterface.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_RoomsInterface.cpp index 2de6ecdecd1..b8e9b4d656a 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_RoomsInterface.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_RoomsInterface.cpp @@ -1,3 +1,4 @@ +#include "GameNetwork/GeneralsOnline/ProxyLogin.h" #include #include "GameNetwork/GeneralsOnline/ServiceEndpointPolicy.h" #include "GameNetwork/GeneralsOnline/NGMP_interfaces.h" @@ -165,6 +166,7 @@ void WebSocket::Connect(const char* url, bool bIsReconnect, std::functiondata.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) { 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 index 3f1f89484cc..65280791205 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/ServiceConnectionPolicy.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/ServiceConnectionPolicy.cpp @@ -1,9 +1,9 @@ #include "GameNetwork/GeneralsOnline/ServiceConnectionPolicy.h" +#include "GameNetwork/GeneralsOnline/CredentialStore.h" #define WIN32_LEAN_AND_MEAN #include #include -#include #include #include #include @@ -14,7 +14,6 @@ #include #pragma comment(lib, "winhttp.lib") -#pragma comment(lib, "advapi32.lib") namespace ServiceConnection { @@ -64,9 +63,11 @@ std::string Part(CURLU* url, CURLUPart part, unsigned flags = 0) return result; } -bool CanonicalProxy(const std::string& input, std::string& canonical, std::string& error) +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) { @@ -95,9 +96,40 @@ bool CanonicalProxy(const std::string& input, std::string& canonical, std::strin return valid; } -std::wstring CredentialTarget(const std::string& canonical) + +struct SessionLogin +{ + std::string user; + std::string secret; +}; + +std::mutex& SessionLoginMutex() +{ + static std::mutex mutex; + return mutex; +} + +std::map& SessionLogins() { - return L"GeneralsOnline/Proxy/" + Wide(canonical); + 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) @@ -200,7 +232,7 @@ bool StaticProxy(const std::wstring& proxy, const std::wstring& bypass, const st result.routes.clear(); return false; } - result.routes.push_back({canonical, false, 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()) @@ -339,7 +371,7 @@ ResolvedProxyPolicy AutoProxy(const std::wstring& destination, WINHTTP_AUTOPROXY std::string canonical; if (!CanonicalProxy(scheme + "://" + host + ":" + std::to_string(entry.ProxyPort), canonical, result.error)) break; - result.routes.push_back({canonical, false, false}); + result.routes.push_back({canonical, false, canonical.find("socks5h://") != 0}); } WinHttpFreeProxyResult(&proxies); } @@ -399,7 +431,9 @@ bool ValidateProxyURL(const std::string& proxyURL, std::string& error) return CanonicalProxy(proxyURL, canonical, error); } -ResolvedProxyPolicy ResolveProxyPolicy(const ServiceConnectionSettings& settings, const std::string& destinationURL) +namespace +{ +ResolvedProxyPolicy ResolveProxyRoutes(const ServiceConnectionSettings& settings, const std::string& destinationURL) { ResolvedProxyPolicy result; if (settings.proxyMode == "disabled") @@ -462,6 +496,19 @@ ResolvedProxyPolicy ResolveProxyPolicy(const ServiceConnectionSettings& settings 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 { @@ -817,37 +864,106 @@ std::shared_ptr StartProxyResolution(ServiceConnectionSettings } 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; - auto user = Wide(username); - auto secret = Wide(password); - if (user.empty() || username.find('\0') != std::string::npos || password.find('\0') != std::string::npos || - (!password.empty() && secret.empty()) || secret.size() * sizeof(wchar_t) > CRED_MAX_CREDENTIAL_BLOB_SIZE) + if (username.empty() || username.find('\0') != std::string::npos || password.find('\0') != std::string::npos) { - if (!secret.empty()) - SecureZeroMemory(&secret[0], secret.size() * sizeof(wchar_t)); - error = "Proxy credentials are invalid or too long."; + 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; } - auto target = CredentialTarget(canonical); - CREDENTIALW credential{}; - credential.Type = CRED_TYPE_GENERIC; - credential.TargetName = &target[0]; - credential.UserName = &user[0]; - credential.CredentialBlobSize = (DWORD)(secret.size() * sizeof(wchar_t)); - credential.CredentialBlob = reinterpret_cast(secret.empty() ? nullptr : &secret[0]); - credential.Persist = CRED_PERSIST_LOCAL_MACHINE; - bool success = CredWriteW(&credential, 0) != FALSE; - DWORD status = success ? ERROR_SUCCESS : GetLastError(); - if (!secret.empty()) - SecureZeroMemory(&secret[0], secret.size() * sizeof(wchar_t)); - if (!success) - error = "Unable to save proxy credentials (" + std::to_string(status) + ")."; - return success; + 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) @@ -855,12 +971,9 @@ bool DeleteProxyCredentials(const std::string& proxyURL, std::string& error) std::string canonical; if (!CanonicalProxy(proxyURL, canonical, error)) return false; - if (CredDeleteW(CredentialTarget(canonical).c_str(), CRED_TYPE_GENERIC, 0)) - return true; - DWORD status = GetLastError(); - if (status == ERROR_NOT_FOUND) + if (DeleteProxyLogin(canonical)) return true; - error = "Unable to remove proxy credentials (" + std::to_string(status) + ")."; + error = "Unable to remove proxy credentials."; return false; } @@ -890,35 +1003,6 @@ bool ConfigureCurl(CURL* curl, const std::string& destinationURL, const ProxyRou return false; if (!set(CURLOPT_PROXY, canonical.c_str())) return false; - PCREDENTIALW credential = nullptr; - if (CredReadW(CredentialTarget(canonical).c_str(), CRED_TYPE_GENERIC, 0, &credential)) - { - bool valid = credential->UserName && *credential->UserName && credential->CredentialBlobSize % sizeof(wchar_t) == 0; - std::string user = valid ? UTF8(credential->UserName) : std::string(); - std::wstring wideSecret; - if (valid && credential->CredentialBlobSize) - wideSecret.assign(reinterpret_cast(credential->CredentialBlob), credential->CredentialBlobSize / sizeof(wchar_t)); - std::string secret = UTF8(wideSecret); - valid = valid && !user.empty() && secret.find('\0') == std::string::npos; - bool success = valid && 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()); - if (!wideSecret.empty()) - SecureZeroMemory(&wideSecret[0], wideSecret.size() * sizeof(wchar_t)); - if (credential->CredentialBlobSize) - SecureZeroMemory(credential->CredentialBlob, credential->CredentialBlobSize); - CredFree(credential); - if (!valid) - error = "Stored proxy credentials are invalid. Save them again."; - return success; - } - DWORD status = GetLastError(); - if (status != ERROR_NOT_FOUND) - { - error = "Unable to read saved proxy credentials (" + std::to_string(status) + ")."; - return false; - } if (route.allowIntegratedAuth) { if (!(version->features & CURL_VERSION_SSPI)) @@ -928,6 +1012,24 @@ bool ConfigureCurl(CURL* curl, const std::string& destinationURL, const ProxyRou } 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; } @@ -966,4 +1068,116 @@ const char* FailurePhase(CURLcode code, long status, long proxyStatus) 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; + } +} } From 24e56b06b83fc99d92ec8171ae1950c98e74984b Mon Sep 17 00:00:00 2001 From: tintinhamans <5984296+tintinhamans@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:19:16 +0200 Subject: [PATCH 16/28] feat(online): Add a custom service server with its own login kept per server --- .../GeneralsOnline/OnlineServices_Init.h | 6 + .../GeneralsOnline/ServiceEndpointPolicy.h | 8 +- .../GeneralsOnline_Settings.cpp | 176 +++++++++++++----- .../GeneralsOnline/OnlineServices_Auth.cpp | 2 +- .../GeneralsOnline/OnlineServices_Init.cpp | 4 +- .../GeneralsOnline/ServiceEndpointPolicy.cpp | 89 +++++++++ 6 files changed, 232 insertions(+), 53 deletions(-) diff --git a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h index 635b1911651..1b7a669a0c3 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h @@ -579,6 +579,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; } diff --git a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/ServiceEndpointPolicy.h b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/ServiceEndpointPolicy.h index 70087002f4d..9089162abfb 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/ServiceEndpointPolicy.h +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/ServiceEndpointPolicy.h @@ -5,8 +5,10 @@ struct ServiceConnectionSettings { - // "automatic" picks a working route + // "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; @@ -16,6 +18,10 @@ struct ServiceConnectionSettings 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); diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/GeneralsOnline_Settings.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/GeneralsOnline_Settings.cpp index 687a2e09de8..c8c6162a3bd 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/GeneralsOnline_Settings.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/GeneralsOnline_Settings.cpp @@ -3,6 +3,7 @@ #include "../json.hpp" #include "../OnlineServices_LobbyInterface.h" #include "../OnlineServices_Init.h" +#include #include "GameNetwork/GeneralsOnline/CredentialStore.h" #include "GameNetwork/GeneralsOnline/OnlineServices_Auth.h" @@ -37,6 +38,7 @@ #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" @@ -320,64 +322,124 @@ void GenOnlineSettings::Load(void) } } - if (jsonSettings.contains(SETTINGS_KEY_NETWORK) && jsonSettings[SETTINGS_KEY_NETWORK].is_object()) + if (jsonSettings.contains(SETTINGS_KEY_NETWORK)) { - const auto& networkSettings = jsonSettings[SETTINGS_KEY_NETWORK]; - - if (networkSettings.contains(SETTINGS_KEY_NETWORK_SERVER) && networkSettings[SETTINGS_KEY_NETWORK_SERVER].is_object()) + // 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) + { + 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) { - const auto& serverSettings = networkSettings[SETTINGS_KEY_NETWORK_SERVER]; - if (serverSettings.contains(SETTINGS_KEY_NETWORK_SERVER_MODE) && serverSettings[SETTINGS_KEY_NETWORK_SERVER_MODE].is_string()) + if (!section.contains(key)) { - m_ConnectionSettings.serverMode = serverSettings[SETTINGS_KEY_NETWORK_SERVER_MODE].get(); + return; } - } - if (m_ConnectionSettings.serverMode != "automatic") - { - m_ConnectionSettings.serverMode = "automatic"; - } - - if (networkSettings.contains(SETTINGS_KEY_NETWORK_PROXY) && networkSettings[SETTINGS_KEY_NETWORK_PROXY].is_object()) - { - const auto& proxySettings = networkSettings[SETTINGS_KEY_NETWORK_PROXY]; - if (proxySettings.contains(SETTINGS_KEY_NETWORK_PROXY_MODE) && proxySettings[SETTINGS_KEY_NETWORK_PROXY_MODE].is_string()) + 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()) { - m_ConnectionSettings.proxyMode = proxySettings[SETTINGS_KEY_NETWORK_PROXY_MODE].get(); + value = found.get(); + return; } - if (proxySettings.contains(SETTINGS_KEY_NETWORK_PROXY_ADDRESS) && proxySettings[SETTINGS_KEY_NETWORK_PROXY_ADDRESS].is_string()) + 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)) { - m_ConnectionSettings.proxyURL = proxySettings[SETTINGS_KEY_NETWORK_PROXY_ADDRESS].get(); + return nullptr; } - if (proxySettings.contains(SETTINGS_KEY_NETWORK_PROXY_WINDOWS_LOGIN) && proxySettings[SETTINGS_KEY_NETWORK_PROXY_WINDOWS_LOGIN].is_boolean()) + if (!parent[key].is_object()) { - m_ConnectionSettings.windowsLogin = proxySettings[SETTINGS_KEY_NETWORK_PROXY_WINDOWS_LOGIN].get(); + resetField(path + "." + key, parent[key], false); + return nullptr; } - } - if (m_ConnectionSettings.proxyMode != "automatic" && m_ConnectionSettings.proxyMode != "disabled" && m_ConnectionSettings.proxyMode != "custom") - { - m_ConnectionSettings.proxyMode = "automatic"; - } - // an invalid custom proxy stays custom with no address, so it can never silently escape to direct - std::string proxyError; - if (!m_ConnectionSettings.proxyURL.empty() && !ServiceConnection::ValidateProxyURL(m_ConnectionSettings.proxyURL, proxyError)) - { - m_ConnectionSettings.proxyURL.clear(); - } + return &parent[key]; + }; - if (networkSettings.contains(SETTINGS_KEY_NETWORK_LAST_ROUTE) && networkSettings[SETTINGS_KEY_NETWORK_LAST_ROUTE].is_string()) + if (!jsonSettings[SETTINGS_KEY_NETWORK].is_object()) { - m_LastRoute = networkSettings[SETTINGS_KEY_NETWORK_LAST_ROUTE].get(); + resetField(SETTINGS_KEY_NETWORK, jsonSettings[SETTINGS_KEY_NETWORK], 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()) + else { - // the old switch from the released client now only prefers that route - m_LastRoute = LEGACY_ALTERNATIVE_ROUTE_HOST; - } - if (m_LastRoute.size() > 253 || m_LastRoute.find_first_not_of("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.-") != std::string::npos) - { - m_LastRoute.clear(); + 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; + } } } @@ -560,7 +622,9 @@ bool GenOnlineSettings::Save() { SETTINGS_KEY_NETWORK, { - {SETTINGS_KEY_NETWORK_SERVER, {{SETTINGS_KEY_NETWORK_SERVER_MODE, m_ConnectionSettings.serverMode}}}, + {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}, @@ -650,15 +714,27 @@ bool GenOnlineSettings::Save() return bSaved; } -bool GenOnlineSettings::Network_SaveConnectionSettings(const ServiceConnectionSettings& settings, std::string& error) +bool GenOnlineSettings::Network_SaveConnectionSettings(const ServiceConnectionSettings& requested, std::string& error) { error.clear(); - if (settings.serverMode != "automatic" || + 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; @@ -673,8 +749,10 @@ bool GenOnlineSettings::Network_SaveConnectionSettings(const ServiceConnectionSe if (Save()) { // a changed proxy starts without anything typed for the old one - if (settings.proxyMode == "custom" && (previous.proxyMode != "custom" || previous.proxyURL != settings.proxyURL)) - ServiceConnection::ClearSessionProxyCredentialsExcept(settings.proxyURL); + 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; } diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Auth.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Auth.cpp index d61bd98a611..52d9d32efce 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Auth.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Auth.cpp @@ -681,7 +681,7 @@ bool NGMP_OnlineServices_AuthInterface::GetCredentials() std::string NGMP_OnlineServices_AuthInterface::CredentialServerKey() { - return "go"; + 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 b48163bc537..8cb48ff85cf 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Init.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Init.cpp @@ -222,7 +222,7 @@ void NGMP_OnlineServicesManager::EnsureServiceRoute(std::function ca return; } const auto settings = Settings.Network_GetConnectionSettings(); - const std::string key = settings.serverMode + "|" + settings.proxyMode + "|" + settings.proxyURL + (settings.windowsLogin ? "|1" : "|0"); + 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)) { callback(true); @@ -300,7 +300,7 @@ void NGMP_OnlineServicesManager::ProbeServiceRoute() { manager->m_routeProxyAuthFailed = false; manager->m_routeValidatedAt = std::chrono::steady_clock::now(); - if (g_Environment == EEnvironment::PROD) + if (g_Environment == EEnvironment::PROD && Settings.Network_GetConnectionSettings().serverMode != "custom") { Settings.Network_SaveLastRoute(ServiceConnection::RouteHost(manager->m_serviceBase)); } diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/ServiceEndpointPolicy.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/ServiceEndpointPolicy.cpp index ca208b95875..b7f3202cb23 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/ServiceEndpointPolicy.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/ServiceEndpointPolicy.cpp @@ -6,9 +6,98 @@ 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") From 3aa6a881641c4920aa6e04807f0d33b1c8bf097d Mon Sep 17 00:00:00 2001 From: tintinhamans <5984296+tintinhamans@users.noreply.github.com> Date: Thu, 22 Oct 2026 06:59:06 +0200 Subject: [PATCH 17/28] feat(network): Share the game network settings between LAN and online --- .../Include/GameNetwork/ConnectionManager.h | 8 + .../Include/GameNetwork/NetworkDefs.h | 2 +- .../Include/GameNetwork/NetworkSettings.h | 35 ++++ .../Source/GameNetwork/Connection.cpp | 2 +- .../Source/GameNetwork/ConnectionManager.cpp | 29 ++- .../Source/GameNetwork/DisconnectManager.cpp | 2 +- .../Source/GameNetwork/FrameMetrics.cpp | 18 +- .../GeneralsOnline/NextGenMP_defines.h | 6 +- .../GeneralsOnline/OnlineServices_Init.h | 184 ++++++++++++++---- .../GUICallbacks/Menus/WOLGameSetupMenu.cpp | 2 +- .../GameNetwork/GeneralsOnline/NGMPGame.cpp | 34 +--- .../GeneralsOnline/NetworkMesh.cpp | 14 +- .../GeneralsOnline/OnlineServices_Init.cpp | 8 +- .../GeneralsOnline/PluginInterfaces.cpp | 2 +- 14 files changed, 256 insertions(+), 90 deletions(-) create mode 100644 Core/GameEngine/Include/GameNetwork/NetworkSettings.h 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/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/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_Init.h b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h index 1b7a669a0c3..9e5e79947ca 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h @@ -1,8 +1,11 @@ #pragma once #include "ServiceConnectionPolicy.h" +#include "GameNetwork/NetworkSettings.h" #include "NGMP_include.h" +#include +#include #include #include @@ -334,51 +337,164 @@ 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; + 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, "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.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 { 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..739367b2894 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/WOLGameSetupMenu.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/WOLGameSetupMenu.cpp @@ -2510,7 +2510,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) { diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NGMPGame.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NGMPGame.cpp index 62f7b703cba..490bfcf0e33 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(); @@ -411,34 +417,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..94cbb787e1e 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NetworkMesh.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NetworkMesh.cpp @@ -114,7 +114,7 @@ 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); + bool bShouldRetry = serviceConf.connection.retry_signalling && ((bInMatch && !bPeerLeft) || (!bWeJoinedLater && !bPeerLeft) || 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); @@ -716,12 +716,12 @@ 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_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()); @@ -772,7 +772,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) { @@ -952,7 +952,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) @@ -1336,7 +1336,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 +1424,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/OnlineServices_Init.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Init.cpp index 8cb48ff85cf..226ca328ef9 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Init.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Init.cpp @@ -108,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; @@ -357,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(); @@ -784,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]; 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 From fc648ec8344dcd21be42681eba1b5e553b675298 Mon Sep 17 00:00:00 2001 From: tintinhamans <5984296+tintinhamans@users.noreply.github.com> Date: Thu, 22 Oct 2026 06:59:31 +0200 Subject: [PATCH 18/28] feat(network): Measure connection latency, jitter and loss with packet feedback --- Core/GameEngine/CMakeLists.txt | 2 + .../Include/GameNetwork/Connection.h | 16 +- .../Include/GameNetwork/ConnectionManager.h | 12 + .../Include/GameNetwork/ConnectionStats.h | 162 +++++++++ .../Include/GameNetwork/NetCommandRef.h | 14 + .../Include/GameNetwork/NetworkDefs.h | 8 + .../Include/GameNetwork/NetworkInterface.h | 1 + .../Include/GameNetwork/NetworkSettings.h | 1 - .../Source/GameNetwork/Connection.cpp | 50 +-- .../Source/GameNetwork/ConnectionManager.cpp | 39 +++ .../Source/GameNetwork/ConnectionStats.cpp | 313 ++++++++++++++++++ .../Source/GameNetwork/NetCommandRef.cpp | 1 + .../GameEngine/Source/GameNetwork/Network.cpp | 14 + .../W3DDevice/GameClient/W3DDisplay.cpp | 14 + GeneralsMD/Code/GameEngine/CMakeLists.txt | 3 + .../GameNetwork/GeneralsOnline/NGMP_include.h | 3 +- .../GameNetwork/GeneralsOnline/NetBytes.h | 16 + .../GameNetwork/GeneralsOnline/NetworkMesh.h | 35 +- .../GeneralsOnline/OnlineServices_Init.h | 11 +- .../GeneralsOnline/PacketFeedback.h | 70 ++++ .../GeneralsOnline/NetworkMesh.cpp | 287 +++++++++++++--- .../GeneralsOnline/NextGenTransport.cpp | 42 ++- .../GeneralsOnline/PacketFeedback.cpp | 179 ++++++++++ .../W3DDevice/GameClient/W3DDisplay.cpp | 14 + 24 files changed, 1199 insertions(+), 108 deletions(-) create mode 100644 Core/GameEngine/Include/GameNetwork/ConnectionStats.h create mode 100644 Core/GameEngine/Source/GameNetwork/ConnectionStats.cpp create mode 100644 GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NetBytes.h create mode 100644 GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/PacketFeedback.h create mode 100644 GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/PacketFeedback.cpp diff --git a/Core/GameEngine/CMakeLists.txt b/Core/GameEngine/CMakeLists.txt index 8f5892206e0..6c2ac043a8e 100644 --- a/Core/GameEngine/CMakeLists.txt +++ b/Core/GameEngine/CMakeLists.txt @@ -506,6 +506,7 @@ set(GAMEENGINE_SRC # Include/GameLogic/WeaponStatus.h Include/GameNetwork/Connection.h Include/GameNetwork/ConnectionManager.h + Include/GameNetwork/ConnectionStats.h Include/GameNetwork/DisconnectManager.h Include/GameNetwork/DownloadManager.h Include/GameNetwork/FileTransfer.h @@ -1101,6 +1102,7 @@ set(GAMEENGINE_SRC Source/GameLogic/System/RankInfo.cpp Source/GameNetwork/Connection.cpp Source/GameNetwork/ConnectionManager.cpp + Source/GameNetwork/ConnectionStats.cpp Source/GameNetwork/DisconnectManager.cpp Source/GameNetwork/DownloadManager.cpp Source/GameNetwork/FileTransfer.cpp diff --git a/Core/GameEngine/Include/GameNetwork/Connection.h b/Core/GameEngine/Include/GameNetwork/Connection.h index 9a26a5fee63..0c77332ab1b 100644 --- a/Core/GameEngine/Include/GameNetwork/Connection.h +++ b/Core/GameEngine/Include/GameNetwork/Connection.h @@ -39,12 +39,7 @@ #include "GameNetwork/User.h" #include "GameNetwork/Transport.h" #include "GameNetwork/NetPacket.h" - -#if defined(GENERALS_ONLINE) -#define CONNECTION_LATENCY_HISTORY_LENGTH 10 -#else -#define CONNECTION_LATENCY_HISTORY_LENGTH 200 -#endif +#include "GameNetwork/ConnectionStats.h" class Connection : public MemoryPoolObject { @@ -80,13 +75,13 @@ class Connection : public MemoryPoolObject void setQuitting(); Bool isQuitting() { return m_isQuitting; } + const ConnectionStats& getStats() const { return m_stats; } + #if defined(RTS_DEBUG) void debugPrintCommands(); #endif protected: - void doRetryMetrics(); - Bool m_isQuitting; UnsignedInt m_quitTime; @@ -95,11 +90,8 @@ class Connection : public MemoryPoolObject NetCommandList *m_netCommandList; time_t m_retryTime; ///< The time between sending retry packets for this connection. Time is in milliseconds. - Real m_averageLatency; ///< The average time between sending a command and receiving an ACK. - Real m_latencies[CONNECTION_LATENCY_HISTORY_LENGTH]; ///< List of the last 100 latencies. + ConnectionStats m_stats; ///< Latency, jitter and loss of this connection. time_t m_frameGrouping; ///< The minimum time between packet sends. time_t m_lastTimeSent; ///< The time of the last packet send. - Int m_numRetries; ///< The number of retries for the last second. - time_t m_retryMetricsTime; ///< The start time of the current retry metrics thing. }; diff --git a/Core/GameEngine/Include/GameNetwork/ConnectionManager.h b/Core/GameEngine/Include/GameNetwork/ConnectionManager.h index 1b1d3e2731b..d6df0d5b273 100644 --- a/Core/GameEngine/Include/GameNetwork/ConnectionManager.h +++ b/Core/GameEngine/Include/GameNetwork/ConnectionManager.h @@ -158,6 +158,10 @@ class ConnectionManager Int getAverageFPS(); Int getSlotAverageFPS(Int slot); +#if defined(GENERALS_ONLINE) + void noteStalling(Bool stalling, UnsignedInt frame); ///< Called every network update with whether the game is waiting for commands. +#endif + const ConnectionStats* getConnectionStats(Int slot) const; ///< Returns nullptr when there is no connection for the slot. #if defined(RTS_DEBUG) void debugPrintConnectionCommands(); @@ -234,6 +238,14 @@ class ConnectionManager UnsignedInt m_smallestPacketArrivalCushion; Bool m_didSelfSlug; +#if defined(GENERALS_ONLINE) + ArrivalCushionStats m_cushionStats[MAX_SLOTS]; + StallStats m_stallStats; + Bool m_stallActive; + time_t m_stallStartTime; + UnsignedInt m_stallMissingMask; +#endif + // ----------------------------------------------------------------------------- FileCommandMap s_fileCommandMap; FileMaskMap s_fileRecipientMaskMap; diff --git a/Core/GameEngine/Include/GameNetwork/ConnectionStats.h b/Core/GameEngine/Include/GameNetwork/ConnectionStats.h new file mode 100644 index 00000000000..c4d07c02e43 --- /dev/null +++ b/Core/GameEngine/Include/GameNetwork/ConnectionStats.h @@ -0,0 +1,162 @@ +/* +** 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 . +*/ + +#pragma once + +#include + +// Fixed size ring of items with a time member, oldest first, that drops the items older than a window. +template +class TimedRing +{ +public: + TimedRing() { clear(); } + + void clear() { m_head = 0; m_count = 0; } + Int size() const { return m_count; } + const T& operator[](Int i) const { return m_items[(m_head + i) % N]; } + + void push(const T& item) + { + if (m_count == N) + { + m_head = (m_head + 1) % N; + --m_count; + } + m_items[(m_head + m_count) % N] = item; + ++m_count; + } + + void prune(time_t now, time_t windowMs) + { + while (m_count > 0 && now - m_items[m_head].time > windowMs) + { + m_head = (m_head + 1) % N; + --m_count; + } + } + +private: + T m_items[N]; + Int m_head; + Int m_count; +}; + +// Tracks latency, jitter and loss of one connection over a sliding time window. +class ConnectionStats +{ +public: + ConnectionStats(); + + void reset(); + void noteAcked(Real rttMs, time_t now); ///< A command that was acked on its first send. + void noteRetry(time_t now); ///< A command that was not acked in time and had to be sent again. + void noteLateAck(time_t now); ///< A retried command was acked by its earlier send after all, the retry was not a loss. + void update(time_t now); ///< Drop the samples that have left the window. + + Real getLatency() const { return m_srtt; } + Real getMinLatency() const; + Real getLatencyPercentile(Int percent) const; + Real getJitter() const { return m_jitter; } + Real getLossPercent() const; ///< Share of retries the earlier send was not acked for + Real getLateAckPercent() const; ///< Share of retries whose earlier send was acked after the retry + Real getRetryPercent() const; ///< Share of retries, lost and late acked together + Real getLatePercent() const; ///< Share of acks that took more than twice the smoothed round trip time. + Int getSampleCount() const { return m_samples.size(); } + +private: + enum + { + MAX_SAMPLES = 1024, ///< Covers the window at 60 acked frames per second. + MAX_RETRIES = 1024 ///< As many as acks, under heavy loss a smaller ring undercounts the retries of the window. + }; + + struct Sample + { + time_t time; + Real rtt; + Bool late; + }; + + struct Stamp + { + time_t time; + }; + + TimedRing m_samples; + TimedRing m_retries; + TimedRing m_lateAcks; + + Real m_srtt; + Real m_jitter; + Real m_previousRtt; + Bool m_hasRtt; +}; + +// Tracks how many frames before its execution frame the frame commands of one sender arrive, +// over the same sliding window as ConnectionStats. Zero or less means the frame had to wait. +class ArrivalCushionStats +{ +public: + ArrivalCushionStats(); + + void reset(); + void note(Int cushion, time_t now); + void update(time_t now); + + Int getSampleCount() const { return m_samples.size(); } + Int getPercentile(Int percent) const; ///< Cushion that percent of the samples are at or above the lowest value of. + +private: + enum { MAX_SAMPLES = 1024 }; + + struct Sample + { + time_t time; + Int cushion; + }; + + TimedRing m_samples; +}; + +// Frames the game wanted to advance but had to wait for commands, over the same sliding window. +class StallStats +{ +public: + StallStats(); + + void reset(); + void note(Int durationMs, UnsignedInt missingSlotMask, time_t now); ///< One finished stall and the slots whose commands were missing when it began. + void update(time_t now); + + Int getCount() const { return m_events.size(); } + Int getTotalMs() const; + Int getCountForSlot(Int slot) const; + +private: + enum { MAX_EVENTS = 512 }; + + struct Event + { + time_t time; + Int ms; + UnsignedInt slotMask; + }; + + TimedRing m_events; +}; diff --git a/Core/GameEngine/Include/GameNetwork/NetCommandRef.h b/Core/GameEngine/Include/GameNetwork/NetCommandRef.h index 4a3ae4226ca..86603e10f3e 100644 --- a/Core/GameEngine/Include/GameNetwork/NetCommandRef.h +++ b/Core/GameEngine/Include/GameNetwork/NetCommandRef.h @@ -62,12 +62,16 @@ class NetCommandRef : public MemoryPoolObject time_t getTimeLastSent() const; void setTimeLastSent(time_t timeLastSent); + Bool isRetried() const; + void setRetried(Bool retried); + protected: NetCommandMsg *m_msg; NetCommandRef *m_next; NetCommandRef *m_prev; UnsignedByte m_relay; ///< Need this in the command reference since the relay value will be different depending on where this particular reference is being sent. time_t m_timeLastSent; + Bool m_retried; ///< Set once this command was sent again, so its last send time is not its first. #ifdef DEBUG_NETCOMMANDREF UnsignedInt m_id; @@ -138,6 +142,16 @@ inline void NetCommandRef::setTimeLastSent(time_t timeLastSent) m_timeLastSent = timeLastSent; } +inline Bool NetCommandRef::isRetried() const +{ + return m_retried; +} + +inline void NetCommandRef::setRetried(Bool retried) +{ + m_retried = retried; +} + /** * Set the send relay for this reference of the command. */ diff --git a/Core/GameEngine/Include/GameNetwork/NetworkDefs.h b/Core/GameEngine/Include/GameNetwork/NetworkDefs.h index 330477fdcc5..af159f71874 100644 --- a/Core/GameEngine/Include/GameNetwork/NetworkDefs.h +++ b/Core/GameEngine/Include/GameNetwork/NetworkDefs.h @@ -56,6 +56,9 @@ enum ConnectionNumbers CPP_11(: Int) static constexpr const Int MAX_SLOTS = MAX_PLAYER + 1; +// Sliding window over which connection latency, jitter, loss and cushion are evaluated. +static constexpr const Int NETWORK_STATS_WINDOW_MS = 10000; + #pragma pack(push, 1) struct TransportMessageHeader { @@ -207,7 +210,12 @@ enum PlayerLeaveCode CPP_11(: Int) { }; // Magic number for identifying a Generals packet. +#if defined(GENERALS_ONLINE) +// 0xF00E: packets carry the sequence envelope; builds without it fail on the magic number +static constexpr const UnsignedShort GENERALS_MAGIC_NUMBER = 0xF00E; +#else static constexpr const UnsignedShort GENERALS_MAGIC_NUMBER = 0xF00D; +#endif // The number of fps history entries. //static constexpr const Int NETWORK_FPS_HISTORY_LENGTH = 30; diff --git a/Core/GameEngine/Include/GameNetwork/NetworkInterface.h b/Core/GameEngine/Include/GameNetwork/NetworkInterface.h index 606429450dd..c8ff8f0122f 100644 --- a/Core/GameEngine/Include/GameNetwork/NetworkInterface.h +++ b/Core/GameEngine/Include/GameNetwork/NetworkInterface.h @@ -100,6 +100,7 @@ class NetworkInterface : public SubsystemInterface virtual Int getAverageFPS() = 0; virtual Int getSlotAverageFPS(Int slot) = 0; + virtual const ConnectionStats* getConnectionStats(Int slot) const = 0; ///< Latency, jitter and loss of the connection to a slot, nullptr when there is none. #if defined(GENERALS_ONLINE) virtual void SeedLatencyData(int highestLatency) = 0; diff --git a/Core/GameEngine/Include/GameNetwork/NetworkSettings.h b/Core/GameEngine/Include/GameNetwork/NetworkSettings.h index f09f5c1d644..af102d9109d 100644 --- a/Core/GameEngine/Include/GameNetwork/NetworkSettings.h +++ b/Core/GameEngine/Include/GameNetwork/NetworkSettings.h @@ -11,7 +11,6 @@ struct NetworkSettings { 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 { diff --git a/Core/GameEngine/Source/GameNetwork/Connection.cpp b/Core/GameEngine/Source/GameNetwork/Connection.cpp index d81ede4a504..2831f12c436 100644 --- a/Core/GameEngine/Source/GameNetwork/Connection.cpp +++ b/Core/GameEngine/Source/GameNetwork/Connection.cpp @@ -48,12 +48,6 @@ Connection::Connection() { m_frameGrouping = 1; m_isQuitting = false; m_quitTime = 0; - m_averageLatency = 0.0f; - Int i; - for(i = 0; i < CONNECTION_LATENCY_HISTORY_LENGTH; i++) - { - m_latencies[i] = 0.0f; - } } /** @@ -84,13 +78,7 @@ void Connection::init() { m_lastTimeSent = 0; m_frameGrouping = 1; - m_numRetries = 0; - m_retryMetricsTime = 0; - - for (Int i = 0; i < CONNECTION_LATENCY_HISTORY_LENGTH; ++i) { - m_latencies[i] = 0; - } - m_averageLatency = 0; + m_stats.reset(); m_isQuitting = FALSE; m_quitTime = 0; } @@ -261,6 +249,8 @@ UnsignedInt Connection::doSend() { time_t curtime = timeGetTime(); Bool couldQueue = TRUE; + m_stats.update(curtime); + // Do this check first, since it's an important fail-safe if (m_isQuitting && curtime > m_quitTime + MaxQuitFlushTime) { @@ -297,9 +287,9 @@ UnsignedInt Connection::doSend() { // the msg command was added to the packet. if (CommandRequiresAck(msg->getCommand())) { if (timeLastSent != -1) { - ++m_numRetries; + m_stats.noteRetry(curtime); + msg->setRetried(TRUE); } - doRetryMetrics(); msg->setTimeLastSent(curtime); } else { m_netCommandList->removeMessage(msg); @@ -376,13 +366,16 @@ NetCommandRef * Connection::processAck(UnsignedShort commandID, UnsignedByte ori } #endif - Int index = temp->getCommand()->getID() % CONNECTION_LATENCY_HISTORY_LENGTH; - m_averageLatency -= ((Real)(m_latencies[index])) / CONNECTION_LATENCY_HISTORY_LENGTH; - Real lat = timeGetTime() - temp->getTimeLastSent(); - - NetworkLog(ELogVerbosity::LOG_DEBUG, "Latency calc is %f, avg is %f", lat, m_averageLatency); - m_averageLatency += lat / CONNECTION_LATENCY_HISTORY_LENGTH; - m_latencies[index] = lat; + // A retried command was last sent after its first send, so its round trip time would be too short. + const time_t now = timeGetTime(); + if (temp->isRetried()) { + // an ack sooner after the retry than any round trip so far answers the earlier send: the retry went out for a late ack, not a loss + if ((m_stats.getSampleCount() > 0) && ((now - temp->getTimeLastSent()) < (time_t)(0.9f * m_stats.getMinLatency()))) { + m_stats.noteLateAck(now); + } + } else { + m_stats.noteAcked((Real)(now - temp->getTimeLastSent()), now); + } #if defined(RTS_DEBUG) if (doDebug == TRUE) { @@ -398,19 +391,6 @@ void Connection::setFrameGrouping(time_t frameGrouping) { // m_retryTime = frameGrouping * 4; } -void Connection::doRetryMetrics() { - static Int numSeconds = 0; - time_t curTime = timeGetTime(); - - if ((curTime - m_retryMetricsTime) > 10000) { - m_retryMetricsTime = curTime; - ++numSeconds; -// DEBUG_LOG(("Retries in the last 10 seconds = %d, average latency = %fms", m_numRetries, m_averageLatency)); - m_numRetries = 0; -// m_retryTime = m_averageLatency * 1.5; - } -} - #if defined(RTS_DEBUG) void Connection::debugPrintCommands() { NetCommandRef *ref = m_netCommandList->getFirstMessage(); diff --git a/Core/GameEngine/Source/GameNetwork/ConnectionManager.cpp b/Core/GameEngine/Source/GameNetwork/ConnectionManager.cpp index 504fa1362c3..d569542efdb 100644 --- a/Core/GameEngine/Source/GameNetwork/ConnectionManager.cpp +++ b/Core/GameEngine/Source/GameNetwork/ConnectionManager.cpp @@ -339,6 +339,11 @@ void ConnectionManager::init() m_latencyAverages[i] = 0.0; // using zero since all floating point standards should be able to specify 0.0 accurately. } m_smallestPacketArrivalCushion = -1; +#if defined(GENERALS_ONLINE) + m_stallActive = FALSE; + m_stallStartTime = 0; + m_stallMissingMask = 0; +#endif m_frameMetrics.init(); @@ -1002,6 +1007,11 @@ void ConnectionManager::processFrameInfo(NetFrameCommandMsg *msg) { if (m_frameData[playerID] != nullptr) { // DEBUG_LOG(("ConnectionManager::processFrameInfo - player %d, frame %d, command count %d, received on frame %d", playerID, msg->getExecutionFrame(), msg->getCommandCount(), TheGameLogic->getFrame())); m_frameData[playerID]->setFrameCommandCount(msg->getExecutionFrame(), msg->getCommandCount()); +#if defined(GENERALS_ONLINE) + if ((playerID != (UnsignedInt)m_localSlot) && (TheGameLogic != nullptr)) { + m_cushionStats[playerID].note((Int)msg->getExecutionFrame() - (Int)TheGameLogic->getFrame(), timeGetTime()); + } +#endif } } } @@ -2742,6 +2752,13 @@ Int ConnectionManager::getSlotAverageFPS(Int slot) { return m_fpsAverages[slot]; } +const ConnectionStats* ConnectionManager::getConnectionStats(Int slot) const { + if ((slot < 0) || (slot >= MAX_SLOTS) || (m_connections[slot] == nullptr)) { + return nullptr; + } + return &m_connections[slot]->getStats(); +} + #if defined(RTS_DEBUG) void ConnectionManager::debugPrintConnectionCommands() { DEBUG_LOG_LEVEL(DEBUG_LEVEL_NET, ("ConnectionManager::debugPrintConnectionCommands - begin commands")); @@ -2873,3 +2890,25 @@ void ConnectionManager::requestFrameDataResend(Int playerID, UnsignedInt frame) msg->detach(); } + +#if defined(GENERALS_ONLINE) +void ConnectionManager::noteStalling(Bool stalling, UnsignedInt frame) { + time_t now = timeGetTime(); + + if (stalling && !m_stallActive) { + // list the senders whose commands for this frame are missing + m_stallMissingMask = 0; + for (Int i = 0; i < MAX_SLOTS; ++i) { + if ((m_frameData[i] != nullptr) && (m_frameData[i]->getIsQuitting() == FALSE) + && (m_frameData[i]->getFrameCommandCount(frame) != m_frameData[i]->getCommandCount(frame))) { + m_stallMissingMask |= (1u << i); + } + } + m_stallActive = TRUE; + m_stallStartTime = now; + } else if (!stalling && m_stallActive) { + m_stallActive = FALSE; + m_stallStats.note((Int)(now - m_stallStartTime), m_stallMissingMask, now); + } +} +#endif diff --git a/Core/GameEngine/Source/GameNetwork/ConnectionStats.cpp b/Core/GameEngine/Source/GameNetwork/ConnectionStats.cpp new file mode 100644 index 00000000000..09b3fc387a6 --- /dev/null +++ b/Core/GameEngine/Source/GameNetwork/ConnectionStats.cpp @@ -0,0 +1,313 @@ +/* +** 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 . +*/ + +#include "PreRTS.h" // This must go first in EVERY cpp file in the GameEngine + +#include "GameNetwork/ConnectionStats.h" +#include "GameNetwork/NetworkDefs.h" + +#include + +ConnectionStats::ConnectionStats() +{ + reset(); +} + +void ConnectionStats::reset() +{ + m_samples.clear(); + m_retries.clear(); + m_lateAcks.clear(); + m_srtt = 0.0f; + m_jitter = 0.0f; + m_previousRtt = 0.0f; + m_hasRtt = FALSE; +} + +void ConnectionStats::noteAcked(Real rttMs, time_t now) +{ + if (rttMs < 0.0f) + { + rttMs = 0.0f; + } + + Bool late = FALSE; + if (m_hasRtt) + { + // RFC 3550 interarrival jitter and the TCP smoothed round trip time. + const Real delta = rttMs - m_previousRtt; + const Real deviation = delta < 0.0f ? -delta : delta; + // late = more than 2 x srtt, floored at 20 ms + const Real lateMargin = m_srtt > 20.0f ? m_srtt : 20.0f; + late = (rttMs - m_srtt) > lateMargin; + m_jitter += (deviation - m_jitter) / 16.0f; + m_srtt += (rttMs - m_srtt) / 8.0f; + } + else + { + m_srtt = rttMs; + m_hasRtt = TRUE; + } + m_previousRtt = rttMs; + + Sample sample; + sample.time = now; + sample.rtt = rttMs; + sample.late = late; + m_samples.push(sample); +} + +void ConnectionStats::noteRetry(time_t now) +{ + Stamp stamp; + stamp.time = now; + m_retries.push(stamp); +} + +void ConnectionStats::noteLateAck(time_t now) +{ + Stamp stamp; + stamp.time = now; + m_lateAcks.push(stamp); +} + +void ConnectionStats::update(time_t now) +{ + m_samples.prune(now, NETWORK_STATS_WINDOW_MS); + m_retries.prune(now, NETWORK_STATS_WINDOW_MS); + m_lateAcks.prune(now, NETWORK_STATS_WINDOW_MS); +} + +Real ConnectionStats::getMinLatency() const +{ + if (m_samples.size() == 0) + { + return 0.0f; + } + + Real result = m_samples[0].rtt; + for (Int i = 1; i < m_samples.size(); ++i) + { + if (m_samples[i].rtt < result) + { + result = m_samples[i].rtt; + } + } + return result; +} + +Real ConnectionStats::getLatencyPercentile(Int percent) const +{ + const Int count = m_samples.size(); + if (count == 0) + { + return 0.0f; + } + + Real sorted[MAX_SAMPLES]; + for (Int i = 0; i < count; ++i) + { + sorted[i] = m_samples[i].rtt; + } + std::sort(sorted, sorted + count); + + Int index = (count - 1) * percent / 100; + if (index < 0) + { + index = 0; + } + if (index >= count) + { + index = count - 1; + } + return sorted[index]; +} + +Real ConnectionStats::getRetryPercent() const +{ + const Int total = m_samples.size() + m_retries.size(); + if (total == 0) + { + return 0.0f; + } + + return 100.0f * m_retries.size() / total; +} + +Real ConnectionStats::getLateAckPercent() const +{ + const Int total = m_samples.size() + m_retries.size(); + if (total == 0) + { + return 0.0f; + } + + const Int lateAcks = m_lateAcks.size() < m_retries.size() ? m_lateAcks.size() : m_retries.size(); + return 100.0f * lateAcks / total; +} + +Real ConnectionStats::getLossPercent() const +{ + const Int total = m_samples.size() + m_retries.size(); + if (total == 0) + { + return 0.0f; + } + + const Int lost = m_retries.size() > m_lateAcks.size() ? m_retries.size() - m_lateAcks.size() : 0; + return 100.0f * lost / total; +} + +Real ConnectionStats::getLatePercent() const +{ + if (m_samples.size() == 0) + { + return 0.0f; + } + + Int lateCount = 0; + for (Int i = 0; i < m_samples.size(); ++i) + { + if (m_samples[i].late) + { + ++lateCount; + } + } + return 100.0f * lateCount / m_samples.size(); +} + +ArrivalCushionStats::ArrivalCushionStats() +{ + reset(); +} + +void ArrivalCushionStats::reset() +{ + m_samples.clear(); +} + +void ArrivalCushionStats::note(Int cushion, time_t now) +{ + update(now); + + Sample sample; + sample.time = now; + sample.cushion = cushion; + m_samples.push(sample); +} + +void ArrivalCushionStats::update(time_t now) +{ + m_samples.prune(now, NETWORK_STATS_WINDOW_MS); +} + +Int ArrivalCushionStats::getPercentile(Int percent) const +{ + if (m_samples.size() == 0) + { + return 0; + } + + // counting sort over a clamped range, the values are small frame counts + enum { LOW = -64, RANGE = 256 }; + Int counts[RANGE]; + for (Int r = 0; r < RANGE; ++r) + { + counts[r] = 0; + } + + for (Int i = 0; i < m_samples.size(); ++i) + { + Int cushion = m_samples[i].cushion; + if (cushion < LOW) + { + cushion = LOW; + } + if (cushion > LOW + RANGE - 1) + { + cushion = LOW + RANGE - 1; + } + ++counts[cushion - LOW]; + } + + Int target = (m_samples.size() * percent + 99) / 100; + if (target < 1) + { + target = 1; + } + + Int seen = 0; + for (Int q = 0; q < RANGE; ++q) + { + seen += counts[q]; + if (seen >= target) + { + return q + LOW; + } + } + return LOW + RANGE - 1; +} + +StallStats::StallStats() +{ + reset(); +} + +void StallStats::reset() +{ + m_events.clear(); +} + +void StallStats::note(Int durationMs, UnsignedInt missingSlotMask, time_t now) +{ + update(now); + + Event event; + event.time = now; + event.ms = durationMs; + event.slotMask = missingSlotMask; + m_events.push(event); +} + +void StallStats::update(time_t now) +{ + m_events.prune(now, NETWORK_STATS_WINDOW_MS); +} + +Int StallStats::getTotalMs() const +{ + Int total = 0; + for (Int i = 0; i < m_events.size(); ++i) + { + total += m_events[i].ms; + } + return total; +} + +Int StallStats::getCountForSlot(Int slot) const +{ + Int count = 0; + for (Int i = 0; i < m_events.size(); ++i) + { + if (m_events[i].slotMask & (1u << slot)) + { + ++count; + } + } + return count; +} diff --git a/Core/GameEngine/Source/GameNetwork/NetCommandRef.cpp b/Core/GameEngine/Source/GameNetwork/NetCommandRef.cpp index ba59cab5694..3c470673176 100644 --- a/Core/GameEngine/Source/GameNetwork/NetCommandRef.cpp +++ b/Core/GameEngine/Source/GameNetwork/NetCommandRef.cpp @@ -49,6 +49,7 @@ NetCommandRef::NetCommandRef(NetCommandMsg *msg) m_prev = nullptr; m_relay = 0; m_timeLastSent = -1; + m_retried = FALSE; #ifdef DEBUG_NETCOMMANDREF m_id = ++refNum; diff --git a/Core/GameEngine/Source/GameNetwork/Network.cpp b/Core/GameEngine/Source/GameNetwork/Network.cpp index a149bca7179..9dc70133a97 100644 --- a/Core/GameEngine/Source/GameNetwork/Network.cpp +++ b/Core/GameEngine/Source/GameNetwork/Network.cpp @@ -165,6 +165,7 @@ class Network : public NetworkInterface virtual Int getAverageFPS() override { return m_conMgr->getAverageFPS(); } virtual Int getSlotAverageFPS(Int slot) override; + virtual const ConnectionStats* getConnectionStats(Int slot) const override; virtual void attachTransport(Transport *transport) override; virtual void initTransport() override; @@ -791,6 +792,12 @@ void Network::update() QueryPerformanceCounter((LARGE_INTEGER*)&curTime); m_isStalling = curTime >= m_nextFrameTime; } + +#if defined(GENERALS_ONLINE) + if (m_conMgr != nullptr && m_localStatus == NETLOCALSTATUS_INGAME) { + m_conMgr->noteStalling(m_isStalling, TheGameLogic->getFrame()); + } +#endif } void Network::liteupdate() { @@ -1094,6 +1101,13 @@ Int Network::getSlotAverageFPS(Int slot) { return -1; } +const ConnectionStats* Network::getConnectionStats(Int slot) const { + if (m_conMgr != nullptr) { + return m_conMgr->getConnectionStats(slot); + } + return nullptr; +} + #if defined(RTS_DEBUG) void Network::toggleNetworkOn() { if (m_networkOn == TRUE) { diff --git a/Generals/Code/GameEngineDevice/Source/W3DDevice/GameClient/W3DDisplay.cpp b/Generals/Code/GameEngineDevice/Source/W3DDevice/GameClient/W3DDisplay.cpp index 38595af3fcf..6ef77a53e79 100644 --- a/Generals/Code/GameEngineDevice/Source/W3DDevice/GameClient/W3DDisplay.cpp +++ b/Generals/Code/GameEngineDevice/Source/W3DDevice/GameClient/W3DDisplay.cpp @@ -1446,6 +1446,20 @@ void W3DDisplay::gatherDebugStats() // Network performance stats unibuffer.format(L"Run Ahead: %d, Net FPS: %d, Packet arrival cushion: %d", TheNetwork->getRunAhead(), TheNetwork->getFrameRate(), TheNetwork->getPacketArrivalCushion()); + + // per-connection latency, jitter and loss + for (Int slot = 0; slot < MAX_SLOTS; ++slot) { + const ConnectionStats* stats = TheNetwork->getConnectionStats(slot); + if (stats != nullptr && stats->getSampleCount() > 0) { + UnicodeString statsstr; + if (stats->getLatePercent() > 0.0f) { + statsstr.format(L" P%d %.0fms j%.1f l%.0f/%.0f%%", slot, stats->getLatency(), stats->getJitter(), stats->getLossPercent(), stats->getLatePercent()); + } else { + statsstr.format(L" P%d %.0fms j%.1f l%.0f%%", slot, stats->getLatency(), stats->getJitter(), stats->getLossPercent()); + } + unibuffer.concat(statsstr); + } + } m_displayStrings[NetStats]->setText( unibuffer ); // Client frame rate averages for all players in the game. This only works right for the packet router. diff --git a/GeneralsMD/Code/GameEngine/CMakeLists.txt b/GeneralsMD/Code/GameEngine/CMakeLists.txt index 05bea1c62ca..e739d110a7a 100644 --- a/GeneralsMD/Code/GameEngine/CMakeLists.txt +++ b/GeneralsMD/Code/GameEngine/CMakeLists.txt @@ -1141,6 +1141,8 @@ set(GAMEENGINE_SRC Include/GameNetwork/GeneralsOnline/Vendor/DiscordRPC/discord_rpc.h Include/GameNetwork/GeneralsOnline/NetworkBitstream.h Include/GameNetwork/GeneralsOnline/NetworkMesh.h + Include/GameNetwork/GeneralsOnline/NetBytes.h + Include/GameNetwork/GeneralsOnline/PacketFeedback.h Include/GameNetwork/GeneralsOnline/NetworkPacket.h Include/GameNetwork/GeneralsOnline/NextGenMP_defines.h Include/GameNetwork/GeneralsOnline/NGMPGame.h @@ -1171,6 +1173,7 @@ set(GAMEENGINE_SRC Source/GameNetwork/GeneralsOnline/HTTP/HTTPManager.cpp Source/GameNetwork/GeneralsOnline/HTTP/HTTPRequest.cpp Source/GameNetwork/GeneralsOnline/NetworkMesh.cpp + Source/GameNetwork/GeneralsOnline/PacketFeedback.cpp Source/GameNetwork/GeneralsOnline/OnlineServices_Init.cpp Source/GameNetwork/GeneralsOnline/OnlineServices_Auth.cpp Source/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.cpp diff --git a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NGMP_include.h b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NGMP_include.h index 342de5a1087..b22c0ced24e 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NGMP_include.h +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NGMP_include.h @@ -10,7 +10,8 @@ enum class ELogVerbosity enum class ENetworkChannel : BYTE { NETWORK_CHANNEL_GAME = 0, - NETWORK_CHANNEL_AC = 1 + NETWORK_CHANNEL_AC = 1, + NETWORK_CHANNEL_PING = 2 }; #define CHECK_MAIN_THREAD assert(std::this_thread::get_id() == NGMP_OnlineServicesManager::g_MainThreadID); diff --git a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NetBytes.h b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NetBytes.h new file mode 100644 index 00000000000..c173e617a29 --- /dev/null +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NetBytes.h @@ -0,0 +1,16 @@ +#pragma once + +#include + +// Little endian wire values, the game runs on little endian targets only + +inline void WriteU16(uint8_t* out, uint16_t value) +{ + out[0] = static_cast(value & 0xFF); + out[1] = static_cast(value >> 8); +} + +inline uint16_t ReadU16(const uint8_t* in) +{ + return static_cast(in[0] | (in[1] << 8)); +} diff --git a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NetworkMesh.h b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NetworkMesh.h index fe3c7ac9c6c..7bae0762a64 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NetworkMesh.h +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NetworkMesh.h @@ -6,6 +6,9 @@ #include #include #include "PluginInterfaces.h" +#include "Lib/BaseType.h" +#include "GameNetwork/ConnectionStats.h" +#include "GameNetwork/GeneralsOnline/PacketFeedback.h" class NetRoom_ChatMessagePacket; @@ -50,6 +53,12 @@ class PlayerConnection void UpdateLatencyHistogram(); + void TickProbe(int64_t nowMs); + void HandleProbe(const uint8_t* payload, size_t len, int64_t arrivalMs); + void ResetProbe(); + static int64_t GetArrivalMs(const SteamNetworkingMessage_t* msg); ///< transport receive time in steady_clock ms + ConnectionStats m_probeStats; + void Close(); bool IsIPV4(); @@ -66,7 +75,7 @@ class PlayerConnection m_State != EConnectionState::CONNECTION_DISCONNECTED; } - int Recv(SteamNetworkingMessage_t** pMsg); + int Recv(SteamNetworkingMessage_t** pMsg, int maxMessages = 255); int GetHighestHistoricalLatency() { @@ -96,14 +105,18 @@ class PlayerConnection EConnectionState m_State = EConnectionState::NOT_CONNECTED; - int64_t pingSent = -1; - int m_SignallingAttempts = 0; // 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(); int GetLatency(); + PacketFeedback m_feedback; + size_t ConsumeEnvelope(const uint8_t* data, size_t len); ///< envelope size, 0 if malformed + int64_t m_lastPacketMs = 0; ///< steady_clock ms of the last enveloped packet, 0 before the first + int SendEnveloped(ENetworkChannel channel, const uint8_t* payload, size_t len, int sendFlags); ///< returns an EResult + bool GetOutgoingLoss(int windowMs, PacketFeedback::Summary& summary); ///< our packets to the peer, false without feedback + bool GetIncomingLoss(int windowMs, PacketFeedback::Summary& summary); ///< the peer's packets to us int GetJitter(); float GetConnectionQuality(); int ComputeConnectionScore(); @@ -118,6 +131,22 @@ class PlayerConnection std::string m_strMiddlewareID = std::string("NOT SET"); void LiteUpdateForAC(); + +private: + static constexpr size_t k_probeRingSize = 64; + static constexpr uint16_t k_noProbeSeq = 0xFFFF; + + struct OutstandingProbe + { + uint16_t seq = k_noProbeSeq; // k_noProbeSeq marks a free slot + int64_t sendTimeMs = 0; + }; + + OutstandingProbe m_probeRing[k_probeRingSize]; // indexed by seq % k_probeRingSize + uint16_t m_nextProbeSeq = 0; + uint16_t m_lastPeerSeq = k_noProbeSeq; // last probe received from the peer + int64_t m_lastPeerSeqRecvMs = 0; + int64_t m_nextProbeMs = 0; }; struct LobbyMemberEntry; diff --git a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h index 9e5e79947ca..8012346fc0c 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h @@ -337,7 +337,7 @@ struct RegionResponse struct ServiceConfig { - // Peer connections: signalling, NAT traversal, and the per-connection ping history + // Peer connections: signalling, NAT traversal, latency probes, and the per-connection ping history struct Connection { bool relay_only = false; @@ -345,6 +345,8 @@ struct ServiceConfig bool use_mapped_port = true; bool retry_signalling = true; bool anticheat_transport = false; // game traffic through the anticheat plugin's transport + int probe_lobby_interval_ms = 250; // time between latency probes sent to each peer while not in a match + int probe_match_interval_ms = 1000; // the same in a match, the command acks already measure the round trip int latency_history_ms = 20000; } connection; @@ -434,6 +436,8 @@ inline void ReadServiceConfig(const nlohmann::json& j, ServiceConfig& c) ReadServiceConfigValue(s, "use_mapped_port", c.connection.use_mapped_port); ReadServiceConfigValue(s, "retry_signalling", c.connection.retry_signalling); ReadServiceConfigValue(s, "anticheat_transport", c.connection.anticheat_transport); + ReadServiceConfigValue(s, "probe_lobby_interval_ms", c.connection.probe_lobby_interval_ms); + ReadServiceConfigValue(s, "probe_match_interval_ms", c.connection.probe_match_interval_ms); ReadServiceConfigValue(s, "latency_history_ms", c.connection.latency_history_ms); } if (j.contains("send")) @@ -441,7 +445,6 @@ inline void ReadServiceConfig(const nlohmann::json& j, ServiceConfig& c) 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")) { @@ -473,7 +476,9 @@ inline void ReadServiceConfig(const nlohmann::json& j, ServiceConfig& c) // 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.latency_history_ms = (std::max)(c.connection.latency_history_ms, 1000); + c.connection.probe_lobby_interval_ms = std::clamp(c.connection.probe_lobby_interval_ms, 50, 5000); + c.connection.probe_match_interval_ms = std::clamp(c.connection.probe_match_interval_ms, 50, 10000); 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); diff --git a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/PacketFeedback.h b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/PacketFeedback.h new file mode 100644 index 00000000000..c5fc1ba138e --- /dev/null +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/PacketFeedback.h @@ -0,0 +1,70 @@ +#pragma once + +#include +#include +#include + +// Per-peer packet envelope after the channel byte: u16 sequence, u8 flags (bit 7 always set, bit 0 = feedback record follows), +// optional feedback { u16 highest seen, u8 expected, u8 received, u8 longest lost run } over the last 64 sequence numbers, +// sent at most every 250 ms. +class PacketFeedback +{ +public: + enum + { + ENVELOPE_BASE_SIZE = 3, + FEEDBACK_SIZE = 5, + ENVELOPE_MAX_SIZE = ENVELOPE_BASE_SIZE + FEEDBACK_SIZE + }; + + struct Summary + { + Summary() : samples(0), lossPercent(0.0f), longestRun(0) {} + + int samples; ///< Reports in the window, 0 when there is nothing to say. + float lossPercent; + int longestRun; ///< Longest run of consecutive lost packets in any report. + }; + + PacketFeedback(); + + void Reset(); + + size_t WriteEnvelope(uint8_t* out, int64_t nowMs); + + /// The envelope written last never left this machine, its sequence number is not a lost packet. + void CancelEnvelope(); + + /// Returns the envelope size, 0 if malformed. + size_t ReadEnvelope(const uint8_t* in, size_t len, int64_t nowMs); + + Summary GetOutgoing(int64_t nowMs, int windowMs) const; + + Summary GetIncoming(int64_t nowMs, int windowMs) const; + +private: + struct Report + { + int64_t timeMs; + uint8_t expected; + uint8_t received; + uint8_t longestRun; + }; + + static void Push(std::deque& reports, const Report& report, int64_t nowMs); + static Summary Summarize(const std::deque& reports, int64_t nowMs, int windowMs); + + enum { MAX_WINDOW_MS = 60000, FEEDBACK_INTERVAL_MS = 250 }; + + uint16_t m_nextSequence; + uint32_t m_sentCount; + + bool m_haveHighest; + uint16_t m_highest; + uint64_t m_mask; ///< Bit n is set when highest - n was received. + uint8_t m_span; ///< How many sequence numbers of the window have been seen, at most 64. + int64_t m_lastFeedbackMs; + + std::deque m_outgoing; ///< reports from the peer about our packets + std::deque m_incoming; ///< taken when we send our record +}; diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NetworkMesh.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NetworkMesh.cpp index 94cbb787e1e..25048c38a0f 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NetworkMesh.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NetworkMesh.cpp @@ -1,5 +1,6 @@ #include "GameNetwork/GeneralsOnline/NetworkMesh.h" #include "GameNetwork/GeneralsOnline/NGMP_include.h" +#include "GameNetwork/GeneralsOnline/NetBytes.h" #include "GameNetwork/GeneralsOnline/NGMP_interfaces.h" #include @@ -19,6 +20,11 @@ bool g_bForceRelay = false; UnsignedInt m_exeCRCOriginal = 0; +static int64_t NowSteadyMs() +{ + return std::chrono::duration_cast(std::chrono::steady_clock::now().time_since_epoch()).count(); +} + // Pool for deferred deletion of ConnectionSignaling objects; avoids "delete this" races during // async Steam callbacks. static std::mutex g_pendingDeletionMutex; @@ -1171,10 +1177,13 @@ void NetworkMesh::Tick() m_pSignaling->Poll(); } + const int64_t nowMs = NowSteadyMs(); + // update connection histograms for (auto& kvPair : m_mapConnections) { PlayerConnection& conn = kvPair.second; + conn.TickProbe(nowMs); conn.UpdateLatencyHistogram(); } @@ -1248,6 +1257,12 @@ void PlayerConnection::LiteUpdateForAC() msg->Release(); continue; } + else if (netChannel == ENetworkChannel::NETWORK_CHANNEL_PING) + { + HandleProbe(vecData.data() + sizeof(ENetworkChannel), numBytes - sizeof(ENetworkChannel), GetArrivalMs(msg)); + msg->Release(); + continue; + } } else if (numBytes != -1 && numBytes < sizeof(ENetworkChannel)) { @@ -1264,6 +1279,160 @@ void PlayerConnection::LiteUpdateForAC() } } +// Probe payload after the envelope, little endian: u16 seq, u16 echoSeq, u16 echoHoldMs +static constexpr size_t k_probePayloadSize = 6; + +void PlayerConnection::ResetProbe() +{ + m_probeStats.reset(); + for (OutstandingProbe& probe : m_probeRing) + { + probe = OutstandingProbe(); + } + m_nextProbeSeq = 0; + m_lastPeerSeq = k_noProbeSeq; + m_lastPeerSeqRecvMs = 0; + m_nextProbeMs = 0; +} + +void PlayerConnection::TickProbe(int64_t nowMs) +{ + int pingIntervalMs = 250; + if (NGMP_OnlineServicesManager::GetInstance() != nullptr) + { + const ServiceConfig::Connection& connection = NGMP_OnlineServicesManager::GetInstance()->GetServiceConfig().connection; + pingIntervalMs = (TheNetwork != nullptr) ? connection.probe_match_interval_ms : connection.probe_lobby_interval_ms; + } + + // an echo rides on the next probe, so it is allowed one probe interval late + const int64_t timeoutMs = (std::max)(static_cast(1000), static_cast(m_probeStats.getLatency() * 4.0f)) + pingIntervalMs; + for (OutstandingProbe& probe : m_probeRing) + { + if (probe.seq != k_noProbeSeq && nowMs - probe.sendTimeMs > timeoutMs) + { + m_probeStats.noteRetry(static_cast(nowMs)); + probe.seq = k_noProbeSeq; + } + } + + m_probeStats.update(static_cast(nowMs)); + + if (nowMs < m_nextProbeMs || m_State != EConnectionState::CONNECTED_DIRECT) + { + return; + } + + m_nextProbeMs = nowMs + pingIntervalMs; + + if (AnticheatPlugInterface::DoesACPluginProvideSecureGameTransport()) + { + // the plugin transport has no per-peer receive path for the ping channel, so nothing could be echoed back + return; + } + + if (m_hSteamConnection == k_HSteamNetConnection_Invalid) + { + return; + } + + const uint16_t seq = m_nextProbeSeq; + m_nextProbeSeq = (m_nextProbeSeq == k_noProbeSeq - 1) ? 0 : static_cast(m_nextProbeSeq + 1); + + uint16_t echoHoldMs = 0; + if (m_lastPeerSeq != k_noProbeSeq) + { + echoHoldMs = static_cast((std::min)(nowMs - m_lastPeerSeqRecvMs, static_cast(k_noProbeSeq - 1))); + } + + BYTE packet[k_probePayloadSize]; + WriteU16(packet + 0, seq); + WriteU16(packet + 2, m_lastPeerSeq); + WriteU16(packet + 4, echoHoldMs); + + if (SendEnveloped(ENetworkChannel::NETWORK_CHANNEL_PING, packet, sizeof(packet), k_nSteamNetworkingSend_UnreliableNoNagle) != k_EResultOK) + { + return; + } + + // the slot is still in use: that probe was never echoed in a full ring turn + OutstandingProbe& slot = m_probeRing[seq % k_probeRingSize]; + if (slot.seq != k_noProbeSeq) + { + m_probeStats.noteRetry(static_cast(nowMs)); + } + slot.seq = seq; + slot.sendTimeMs = nowMs; +} + +int64_t PlayerConnection::GetArrivalMs(const SteamNetworkingMessage_t* msg) +{ + const int64_t nowMs = NowSteadyMs(); + if (msg == nullptr || SteamNetworkingUtils() == nullptr || msg->m_usecTimeReceived <= 0) + { + return nowMs; + } + + // how long the message waited in the transport's queue for the main loop + const int64_t queuedMs = (SteamNetworkingUtils()->GetLocalTimestamp() - msg->m_usecTimeReceived) / 1000; + return nowMs - (std::max)(static_cast(0), queuedMs); +} + +void PlayerConnection::HandleProbe(const uint8_t* payload, size_t len, int64_t arrivalMs) +{ + const size_t envelopeSize = (payload != nullptr) ? ConsumeEnvelope(payload, len) : 0; + if (envelopeSize == 0) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[PROBE] Dropping probe without a valid packet envelope from user %lld, size %zu", m_userID, len); + return; + } + payload += envelopeSize; + len -= envelopeSize; + + if (payload == nullptr || len < k_probePayloadSize) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[PROBE] Dropping malformed probe of size %zu from user %lld", len, m_userID); + return; + } + + // the arrival time, a slow frame on either side is no network delay + const int64_t nowMs = arrivalMs; + const uint16_t peerSeq = ReadU16(payload); + const uint16_t echoSeq = ReadU16(payload + 2); + const uint16_t echoHoldMs = ReadU16(payload + 4); + + // a reordered older probe must not move the echo back + if (peerSeq != k_noProbeSeq && (m_lastPeerSeq == k_noProbeSeq || static_cast(peerSeq - m_lastPeerSeq) < 0x8000)) + { + m_lastPeerSeq = peerSeq; + m_lastPeerSeqRecvMs = nowMs; + } + + if (echoSeq == k_noProbeSeq) + { + return; + } + + OutstandingProbe& acked = m_probeRing[echoSeq % k_probeRingSize]; + if (acked.seq != echoSeq) + { + // already resolved or timed out + return; + } + + const int64_t rttMs = (std::max)(static_cast(0), nowMs - acked.sendTimeMs - echoHoldMs); + m_probeStats.noteAcked(static_cast(rttMs), static_cast(nowMs)); + acked.seq = k_noProbeSeq; + + // older un-echoed probes are dropped, not lost: packet feedback measures the loss + for (OutstandingProbe& probe : m_probeRing) + { + if (probe.seq != k_noProbeSeq && static_cast(echoSeq - probe.seq) < 0x8000) + { + probe.seq = k_noProbeSeq; + } + } +} + PlayerConnection::PlayerConnection(int64_t userID, HSteamNetConnection hSteamConnection) { m_userID = userID; @@ -1327,57 +1496,14 @@ int PlayerConnection::SendGamePacket(void* pBuffer, uint32_t totalDataSize) return (int)k_EResultFail; } - ENetworkChannel netChannel = ENetworkChannel::NETWORK_CHANNEL_GAME; - std::vector vecData; - vecData.resize(totalDataSize + sizeof(ENetworkChannel)); - memcpy(vecData.data() + sizeof(ENetworkChannel), pBuffer, totalDataSize); - vecData[0] = (BYTE)netChannel; - int sendFlags = k_nSteamNetworkingSend_Reliable | k_nSteamNetworkingSend_AutoRestartBrokenSession; // default from last patch - ServiceConfig& serviceConf = NGMP_OnlineServicesManager::GetInstance()->GetServiceConfig(); - 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( - m_hSteamConnection, vecData.data(), vecData.size(), sendFlags, nullptr); - + const int r = SendEnveloped(ENetworkChannel::NETWORK_CHANNEL_GAME, static_cast(pBuffer), totalDataSize, sendFlags); if (r != k_EResultOK) { - if (netSendFlags != -1) - { - if (netSendFlags == 0) - { - sendFlags = k_nSteamNetworkingSend_Unreliable; - } - else if (netSendFlags == 1) - { - sendFlags = k_nSteamNetworkingSend_UnreliableNoNagle; - } - else if (netSendFlags == 2) - { - sendFlags = k_nSteamNetworkingSend_UnreliableNoDelay; - } - else if (netSendFlags == 3) - { - sendFlags = k_nSteamNetworkingSend_Reliable; - } - else if (netSendFlags == 4) - { - sendFlags = k_nSteamNetworkingSend_ReliableNoNagle; - } - } - - NetworkLog(ELogVerbosity::LOG_DEBUG, "[GAME PACKET] Sending msg of size %ld to user %lld\n", totalDataSize, m_userID); - EResult r = SteamNetworkingSockets()->SendMessageToConnection( - m_hSteamConnection, pBuffer, (int)totalDataSize, sendFlags, nullptr); - - if (r != k_EResultOK) - { - NetworkLog(ELogVerbosity::LOG_RELEASE, "[GAME PACKET] Failed to send, err code was %d", r); - } - - return (int)r; + NetworkLog(ELogVerbosity::LOG_RELEASE, "[GAME PACKET] Failed to send, err code was %d", r); + return r; } } @@ -1428,8 +1554,8 @@ void PlayerConnection::UpdateLatencyHistogram() } - // update latency history - int currLatency = GetLatency(); + // update latency history, from the mesh probe once it has samples + int currLatency = (m_probeStats.getSampleCount() > 0) ? static_cast(m_probeStats.getLatency()) : GetLatency(); #if defined(GENERALS_ONLINE_HIGH_FPS_SERVER) const int connectionHistoryLength = histogram_duration / 16; // ~20 sec worth of frames at 60fps (default) #else @@ -1509,12 +1635,12 @@ bool PlayerConnection::IsIPV4() return info.m_addrRemote.IsIPv4(); } -int PlayerConnection::Recv(SteamNetworkingMessage_t** pMsg) +int PlayerConnection::Recv(SteamNetworkingMessage_t** pMsg, int maxMessages) { int r = -1; if (m_hSteamConnection != k_HSteamNetConnection_Invalid) { - r = SteamNetworkingSockets()->ReceiveMessagesOnConnection(m_hSteamConnection, pMsg, 255); + r = SteamNetworkingSockets()->ReceiveMessagesOnConnection(m_hSteamConnection, pMsg, maxMessages); NetworkLog(ELogVerbosity::LOG_DEBUG, "[DISC] Recv Result %d from user %lld", r, m_userID); } else @@ -1528,14 +1654,25 @@ int PlayerConnection::Recv(SteamNetworkingMessage_t** pMsg) std::string PlayerConnection::GetStats() { + // lead with the probe numbers, or just the reported ping while there are no samples yet + char szSummary[64] = { 0 }; + if (m_probeStats.getSampleCount() > 0) + { + snprintf(szSummary, sizeof(szSummary), "Lat %d j%.0f L%.0f%%\n", static_cast(m_probeStats.getLatency()), m_probeStats.getJitter(), m_probeStats.getLossPercent()); + } + else + { + snprintf(szSummary, sizeof(szSummary), "Lat %d\n", GetLatency()); + } + if (m_hSteamConnection == k_HSteamNetConnection_Invalid) - return "(disconnected)"; + return std::string(szSummary) + "(disconnected)"; char szBuf[2048] = { 0 }; int ret = SteamNetworkingSockets()->GetDetailedConnectionStatus(m_hSteamConnection, szBuf, 2048); NetworkLog(ELogVerbosity::LOG_RELEASE, "[STEAM] PlayerConnection::GetStats returned %d", ret); - return std::string(szBuf); + return std::string(szSummary) + szBuf; } @@ -1578,6 +1715,7 @@ void PlayerConnection::UpdateState(EConnectionState newState, NetworkMesh* pOwni { m_connectedSinceTime = std::chrono::steady_clock::now(); m_smoothedScore = -1.0f; + ResetProbe(); } m_State = newState; @@ -1688,6 +1826,51 @@ int PlayerConnection::GetLatency() return -1; } +size_t PlayerConnection::ConsumeEnvelope(const uint8_t* data, size_t len) +{ + const int64_t nowMs = NowSteadyMs(); + m_lastPacketMs = nowMs; + return m_feedback.ReadEnvelope(data, len, nowMs); +} + +int PlayerConnection::SendEnveloped(ENetworkChannel channel, const uint8_t* payload, size_t len, int sendFlags) +{ + const int64_t nowMs = NowSteadyMs(); + + std::vector vecData; + vecData.resize(sizeof(ENetworkChannel) + PacketFeedback::ENVELOPE_MAX_SIZE + len); + vecData[0] = static_cast(channel); + const size_t envelopeSize = m_feedback.WriteEnvelope(vecData.data() + sizeof(ENetworkChannel), nowMs); + memcpy(vecData.data() + sizeof(ENetworkChannel) + envelopeSize, payload, len); + vecData.resize(sizeof(ENetworkChannel) + envelopeSize + len); + + EResult r = SteamNetworkingSockets()->SendMessageToConnection(m_hSteamConnection, vecData.data(), static_cast(vecData.size()), sendFlags, nullptr); + if (r != k_EResultOK) + { + // once more with the same packet, it keeps its sequence number + r = SteamNetworkingSockets()->SendMessageToConnection(m_hSteamConnection, vecData.data(), static_cast(vecData.size()), sendFlags, nullptr); + } + if (r != k_EResultOK) + { + m_feedback.CancelEnvelope(); + } + return static_cast(r); +} + +bool PlayerConnection::GetOutgoingLoss(int windowMs, PacketFeedback::Summary& summary) +{ + const int64_t nowMs = NowSteadyMs(); + summary = m_feedback.GetOutgoing(nowMs, windowMs); + return summary.samples > 0; +} + +bool PlayerConnection::GetIncomingLoss(int windowMs, PacketFeedback::Summary& summary) +{ + const int64_t nowMs = NowSteadyMs(); + summary = m_feedback.GetIncoming(nowMs, windowMs); + return summary.samples > 0; +} + int PlayerConnection::GetJitter() { int sumDelta = 0; diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NextGenTransport.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NextGenTransport.cpp index d6032342218..78fac2e4447 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NextGenTransport.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NextGenTransport.cpp @@ -332,8 +332,26 @@ Bool NextGenTransport::doRecv(void) std::map& connections = pMesh->GetAllConnections(); for (auto& kvPair : connections) { + // Only take as many messages as there are free slots, the rest stay queued in GNS + int numFreeSlots = 0; + for (int iSlot = 0; iSlot < MAX_MESSAGES; ++iSlot) + { + if (!m_inBufferOccupied[iSlot] || m_inBuffer[iSlot].length == 0) + { + ++numFreeSlots; + } + } + + if (numFreeSlots == 0) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, + "Game Packet Recv: m_inBuffer is full, leaving packets from user %lld queued", + static_cast(kvPair.second.m_userID)); + continue; + } + SteamNetworkingMessage_t* pMsg[255] = { nullptr }; - int numPackets = kvPair.second.Recv(pMsg); + int numPackets = kvPair.second.Recv(pMsg, std::min(numFreeSlots, static_cast(std::size(pMsg)))); if (numPackets <= 0) continue; @@ -381,12 +399,28 @@ Bool NextGenTransport::doRecv(void) msg->Release(); continue; } + else if (netChannel == ENetworkChannel::NETWORK_CHANNEL_PING) + { + kvPair.second.HandleProbe(reinterpret_cast(vecPacketDataWithoutHeader.data()), vecPacketDataWithoutHeader.size(), PlayerConnection::GetArrivalMs(msg)); + msg->Release(); + continue; + } else if (netChannel == ENetworkChannel::NETWORK_CHANNEL_GAME) { NetworkLog(ELogVerbosity::LOG_DEBUG, "[GAME PACKET] Received message of size %u from user %lld", vecPacketDataWithoutHeader.size(), static_cast(kvPair.second.m_userID)); + // the packet envelope: sequence number and receiver feedback + const size_t envelopeSize = kvPair.second.ConsumeEnvelope(reinterpret_cast(vecPacketDataWithoutHeader.data()), vecPacketDataWithoutHeader.size()); + if (envelopeSize == 0) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "Game Packet Recv: Dropping packet without a valid envelope from user %lld, probably another game version", static_cast(kvPair.second.m_userID)); + msg->Release(); + continue; + } + vecPacketDataWithoutHeader.erase(vecPacketDataWithoutHeader.begin(), vecPacketDataWithoutHeader.begin() + envelopeSize); + // Must at least contain the header if (vecPacketDataWithoutHeader.size() < sizeof(TransportMessageHeader)) { @@ -565,6 +599,12 @@ Bool NextGenTransport::doRecv(void) bRet = TRUE; } } + else + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "Game Packet Recv: Dropping packet with unknown channel %u from user %lld", static_cast(vecDataWithHeader[0]), static_cast(kvPair.second.m_userID)); + msg->Release(); + continue; + } } else if (vecDataWithHeader.size() != -1) { diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/PacketFeedback.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/PacketFeedback.cpp new file mode 100644 index 00000000000..64c659e9799 --- /dev/null +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/PacketFeedback.cpp @@ -0,0 +1,179 @@ +#include "GameNetwork/GeneralsOnline/PacketFeedback.h" +#include "GameNetwork/GeneralsOnline/NetBytes.h" + +#include + +PacketFeedback::PacketFeedback() +{ + Reset(); +} + +void PacketFeedback::Reset() +{ + m_nextSequence = 0; + m_sentCount = 0; + m_haveHighest = false; + m_highest = 0; + m_mask = 0; + m_span = 0; + m_lastFeedbackMs = 0; + m_outgoing.clear(); + m_incoming.clear(); +} + +size_t PacketFeedback::WriteEnvelope(uint8_t* out, int64_t nowMs) +{ + WriteU16(out, m_nextSequence); + ++m_nextSequence; + ++m_sentCount; + out[2] = 0x80; + + if (!m_haveHighest || (m_lastFeedbackMs != 0 && nowMs - m_lastFeedbackMs < FEEDBACK_INTERVAL_MS)) + { + return ENVELOPE_BASE_SIZE; + } + + int received = 0; + int longestRun = 0; + int run = 0; + for (int bit = 0; bit < m_span; ++bit) + { + if (m_mask & (static_cast(1) << bit)) + { + ++received; + run = 0; + } + else + { + ++run; + longestRun = (std::max)(longestRun, run); + } + } + + out[2] |= 0x01; + WriteU16(out + 3, m_highest); + out[5] = m_span; + out[6] = static_cast(received); + out[7] = static_cast(longestRun); + + Report report; + report.timeMs = nowMs; + report.expected = m_span; + report.received = static_cast(received); + report.longestRun = static_cast(longestRun); + Push(m_incoming, report, nowMs); + + m_lastFeedbackMs = nowMs; + return ENVELOPE_MAX_SIZE; +} + +void PacketFeedback::CancelEnvelope() +{ + if (m_sentCount > 0) + { + --m_nextSequence; + --m_sentCount; + } +} + +size_t PacketFeedback::ReadEnvelope(const uint8_t* in, size_t len, int64_t nowMs) +{ + if (in == nullptr || len < ENVELOPE_BASE_SIZE || (in[2] & 0x80) == 0) + { + return 0; + } + + const bool hasFeedback = (in[2] & 0x01) != 0; + if (hasFeedback && len < ENVELOPE_MAX_SIZE) + { + return 0; + } + + const uint16_t sequence = ReadU16(in); + if (!m_haveHighest) + { + m_haveHighest = true; + m_highest = sequence; + m_mask = 1; + m_span = 1; + } + else + { + const int diff = static_cast(static_cast(sequence - m_highest)); + if (diff > 0) + { + m_mask = (diff >= 64) ? 0 : (m_mask << diff); + m_mask |= 1; + m_highest = sequence; + m_span = static_cast((std::min)(64, m_span + diff)); + } + else if (diff < 0 && -diff < 64) + { + m_mask |= static_cast(1) << (-diff); + } + } + + if (!hasFeedback) + { + return ENVELOPE_BASE_SIZE; + } + + Report report; + report.timeMs = nowMs; + report.expected = in[5]; + report.received = in[6]; + report.longestRun = in[7]; + if (report.expected > 64 || report.received > report.expected) + { + return ENVELOPE_MAX_SIZE; // implausible, the packet is still consumed + } + + // the peer cannot have expected more of our packets than we sent + report.expected = static_cast((std::min)(report.expected, m_sentCount)); + report.received = (std::min)(report.received, report.expected); + Push(m_outgoing, report, nowMs); + return ENVELOPE_MAX_SIZE; +} + +void PacketFeedback::Push(std::deque& reports, const Report& report, int64_t nowMs) +{ + reports.push_back(report); + while (!reports.empty() && nowMs - reports.front().timeMs > MAX_WINDOW_MS) + { + reports.pop_front(); + } +} + +PacketFeedback::Summary PacketFeedback::Summarize(const std::deque& reports, int64_t nowMs, int windowMs) +{ + Summary summary; + int expected = 0; + int received = 0; + for (const Report& report : reports) + { + if (nowMs - report.timeMs > windowMs) + { + continue; + } + ++summary.samples; + expected += report.expected; + received += report.received; + summary.longestRun = (std::max)(summary.longestRun, static_cast(report.longestRun)); + } + + if (expected > 0) + { + summary.lossPercent = 100.0f * static_cast(expected - received) / static_cast(expected); + } + return summary; +} + +PacketFeedback::Summary PacketFeedback::GetOutgoing(int64_t nowMs, int windowMs) const +{ + return Summarize(m_outgoing, nowMs, windowMs); +} + +PacketFeedback::Summary PacketFeedback::GetIncoming(int64_t nowMs, int windowMs) const +{ + return Summarize(m_incoming, nowMs, windowMs); +} diff --git a/GeneralsMD/Code/GameEngineDevice/Source/W3DDevice/GameClient/W3DDisplay.cpp b/GeneralsMD/Code/GameEngineDevice/Source/W3DDevice/GameClient/W3DDisplay.cpp index 5eda9635d85..e69e94431ab 100644 --- a/GeneralsMD/Code/GameEngineDevice/Source/W3DDevice/GameClient/W3DDisplay.cpp +++ b/GeneralsMD/Code/GameEngineDevice/Source/W3DDevice/GameClient/W3DDisplay.cpp @@ -1545,6 +1545,20 @@ void W3DDisplay::gatherDebugStats() // Network performance stats unibuffer.format(L"Run Ahead: %d, Net FPS: %d, Packet arrival cushion: %d", TheNetwork->getRunAhead(), TheNetwork->getFrameRate(), TheNetwork->getPacketArrivalCushion()); + + // per-connection latency, jitter and loss + for (Int slot = 0; slot < MAX_SLOTS; ++slot) { + const ConnectionStats* stats = TheNetwork->getConnectionStats(slot); + if (stats != nullptr && stats->getSampleCount() > 0) { + UnicodeString statsstr; + if (stats->getLatePercent() > 0.0f) { + statsstr.format(L" P%d %.0fms j%.1f l%.0f/%.0f%%", slot, stats->getLatency(), stats->getJitter(), stats->getLossPercent(), stats->getLatePercent()); + } else { + statsstr.format(L" P%d %.0fms j%.1f l%.0f%%", slot, stats->getLatency(), stats->getJitter(), stats->getLossPercent()); + } + unibuffer.concat(statsstr); + } + } m_displayStrings[NetStats]->setText( unibuffer ); // Client frame rate averages for all players in the game. This only works right for the packet router. From 96f3c46ef39d7efe9717caaf264d9db84c34ec8d Mon Sep 17 00:00:00 2001 From: tintinhamans <5984296+tintinhamans@users.noreply.github.com> Date: Thu, 22 Oct 2026 07:03:24 +0200 Subject: [PATCH 19/28] feat(online): Rate connections and show the network overlay --- .../Include/GameNetwork/ConnectionManager.h | 5 + .../Include/GameNetwork/ConnectionStats.h | 30 +- .../Include/GameNetwork/NetworkInterface.h | 1 + .../GameClient/MessageStream/CommandXlat.cpp | 3 +- .../Source/GameNetwork/ConnectionManager.cpp | 33 + .../Source/GameNetwork/ConnectionStats.cpp | 17 +- .../GameEngine/Source/GameNetwork/Network.cpp | 12 + GeneralsMD/Code/GameEngine/CMakeLists.txt | 2 + .../GameEngine/Include/GameClient/InGameUI.h | 1 + .../GeneralsOnline/ConnectionRating.h | 77 +++ .../GameNetwork/GeneralsOnline/NGMPGame.h | 13 + .../GameNetwork/GeneralsOnline/NetworkMesh.h | 10 +- .../GeneralsOnline/OnlineServices_Init.h | 7 +- .../GUICallbacks/Menus/WOLGameSetupMenu.cpp | 89 +-- .../GameEngine/Source/GameClient/InGameUI.cpp | 612 ++++++++++++++++-- .../GeneralsOnline/ConnectionRating.cpp | 275 ++++++++ .../GeneralsOnline/NetworkMesh.cpp | 151 ++--- .../GeneralsOnline/OnlineServices_Init.cpp | 2 +- 18 files changed, 1092 insertions(+), 248 deletions(-) create mode 100644 GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/ConnectionRating.h create mode 100644 GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/ConnectionRating.cpp diff --git a/Core/GameEngine/Include/GameNetwork/ConnectionManager.h b/Core/GameEngine/Include/GameNetwork/ConnectionManager.h index d6df0d5b273..5812389f3f0 100644 --- a/Core/GameEngine/Include/GameNetwork/ConnectionManager.h +++ b/Core/GameEngine/Include/GameNetwork/ConnectionManager.h @@ -244,6 +244,11 @@ class ConnectionManager Bool m_stallActive; time_t m_stallStartTime; UnsignedInt m_stallMissingMask; + Int m_lastKnownRunAhead; + Int m_frameGroupingMs; +public: + Bool getOverlayInfo(Int slot, NetworkOverlayInfo &info); ///< Fills what the overlay and the connection rating need about one slot. +protected: #endif // ----------------------------------------------------------------------------- diff --git a/Core/GameEngine/Include/GameNetwork/ConnectionStats.h b/Core/GameEngine/Include/GameNetwork/ConnectionStats.h index c4d07c02e43..33aa75f87c9 100644 --- a/Core/GameEngine/Include/GameNetwork/ConnectionStats.h +++ b/Core/GameEngine/Include/GameNetwork/ConnectionStats.h @@ -134,7 +134,7 @@ class ArrivalCushionStats TimedRing m_samples; }; -// Frames the game wanted to advance but had to wait for commands, over the same sliding window. +// Frames the game wanted to advance but had to wait for commands, kept for a minute and counted over a window. class StallStats { public: @@ -144,12 +144,11 @@ class StallStats void note(Int durationMs, UnsignedInt missingSlotMask, time_t now); ///< One finished stall and the slots whose commands were missing when it began. void update(time_t now); - Int getCount() const { return m_events.size(); } - Int getTotalMs() const; - Int getCountForSlot(Int slot) const; + Int getCount(time_t now, Int windowMs) const; + Int getCountForSlot(Int slot, time_t now, Int windowMs) const; private: - enum { MAX_EVENTS = 512 }; + enum { MAX_EVENTS = 512, HISTORY_MS = 60000 }; struct Event { @@ -160,3 +159,24 @@ class StallStats TimedRing m_events; }; + +// What the network overlay and the connection rating need to know about one slot beyond its ConnectionStats. +struct NetworkOverlayInfo +{ + NetworkOverlayInfo() + : cushionSamples(0), cushionP5(0), cushionP50(0) + , stallsWindow(0), stallsMinute(0), stalledNow(FALSE), waitingOnSlot(FALSE) + , slotInBandMs(-1), runAhead(0), frameGroupingMs(0) {} + + Int cushionSamples; ///< Frames before execution that the frame commands of this slot arrived, over the stats window. + Int cushionP5; + Int cushionP50; + Int stallsWindow; ///< Stalls the local game had to wait on this slot for, over the stats window. + Int stallsMinute; ///< The same over the last minute. + Bool stalledNow; ///< The local game is waiting for commands right now. + Bool waitingOnSlot; ///< ... and this slot is one it is waiting on. + Int slotInBandMs; ///< In-band round trip this slot contributes to the run ahead, -1 if unknown. + + Int runAhead; ///< Frames. + Int frameGroupingMs; ///< The send interval. +}; diff --git a/Core/GameEngine/Include/GameNetwork/NetworkInterface.h b/Core/GameEngine/Include/GameNetwork/NetworkInterface.h index c8ff8f0122f..2e28de9e1d2 100644 --- a/Core/GameEngine/Include/GameNetwork/NetworkInterface.h +++ b/Core/GameEngine/Include/GameNetwork/NetworkInterface.h @@ -103,6 +103,7 @@ class NetworkInterface : public SubsystemInterface virtual const ConnectionStats* getConnectionStats(Int slot) const = 0; ///< Latency, jitter and loss of the connection to a slot, nullptr when there is none. #if defined(GENERALS_ONLINE) + virtual Bool getOverlayInfo(Int slot, NetworkOverlayInfo &info) = 0; virtual void SeedLatencyData(int highestLatency) = 0; virtual bool IsSlugging() = 0; #endif diff --git a/Core/GameEngine/Source/GameClient/MessageStream/CommandXlat.cpp b/Core/GameEngine/Source/GameClient/MessageStream/CommandXlat.cpp index 231a6f355b4..adb5bb72c8d 100644 --- a/Core/GameEngine/Source/GameClient/MessageStream/CommandXlat.cpp +++ b/Core/GameEngine/Source/GameClient/MessageStream/CommandXlat.cpp @@ -54,6 +54,7 @@ #include "Common/OptionPreferences.h" #include "GameClient/InGameUI.h" +#include "GameClient/Keyboard.h" #include "GameClient/CommandXlat.h" #include "GameClient/DebugDisplay.h" #include "GameClient/Drawable.h" @@ -3936,7 +3937,7 @@ GameMessageDisposition CommandTranslator::translateGameMessage(const GameMessage } else if (key == KEY_F10) { - NGMP_OnlineServicesManager::ToggleAdvancedNetworkStats(); + NGMP_OnlineServicesManager::ToggleNetworkOverlay((TheKeyboard != nullptr && TheKeyboard->isShift()) ? 2 : 1); disp = DESTROY_MESSAGE; } diff --git a/Core/GameEngine/Source/GameNetwork/ConnectionManager.cpp b/Core/GameEngine/Source/GameNetwork/ConnectionManager.cpp index d569542efdb..abe73435b7a 100644 --- a/Core/GameEngine/Source/GameNetwork/ConnectionManager.cpp +++ b/Core/GameEngine/Source/GameNetwork/ConnectionManager.cpp @@ -343,6 +343,8 @@ void ConnectionManager::init() m_stallActive = FALSE; m_stallStartTime = 0; m_stallMissingMask = 0; + m_lastKnownRunAhead = 0; + m_frameGroupingMs = 0; #endif m_frameMetrics.init(); @@ -1412,6 +1414,10 @@ void ConnectionManager::updateRunAhead(Int oldRunAhead, Int frameRate, Bool didS static time_t lasttimesent = 0; time_t curTime = timeGetTime(); +#if defined(GENERALS_ONLINE) + m_lastKnownRunAhead = oldRunAhead; +#endif + if ((lasttimesent == 0) || ((curTime - lasttimesent) > TheGlobalData->m_networkRunAheadMetricsTime)) { if (m_localSlot == m_packetRouterSlot) { // We are the packet router, time to compute a new run ahead for this game. @@ -2031,6 +2037,9 @@ void ConnectionManager::setFrameGrouping(time_t frameGrouping) { if (m_localSlot == m_packetRouterSlot) { frameGrouping = frameGrouping / 2; } +#if defined(GENERALS_ONLINE) + m_frameGroupingMs = (Int)frameGrouping; +#endif for (Int i = 0; i < MAX_SLOTS; ++i) { if (m_connections[i] != nullptr) { m_connections[i]->setFrameGrouping(frameGrouping); @@ -2911,4 +2920,28 @@ void ConnectionManager::noteStalling(Bool stalling, UnsignedInt frame) { m_stallStats.note((Int)(now - m_stallStartTime), m_stallMissingMask, now); } } + +Bool ConnectionManager::getOverlayInfo(Int slot, NetworkOverlayInfo &info) { + time_t now = timeGetTime(); + m_stallStats.update(now); + + info = NetworkOverlayInfo(); + info.stalledNow = m_stallActive; + info.runAhead = m_lastKnownRunAhead; + info.frameGroupingMs = m_frameGroupingMs; + + if (slot >= 0 && slot < MAX_SLOTS) { + info.stallsWindow = m_stallStats.getCountForSlot(slot, now, NETWORK_STATS_WINDOW_MS); + info.stallsMinute = m_stallStats.getCountForSlot(slot, now, 60000); + info.waitingOnSlot = m_stallActive && ((m_stallMissingMask & (1u << slot)) != 0); + if (isPlayerConnected(slot)) { + info.slotInBandMs = (Int)(m_latencyAverages[slot] * 1000.0f); + } + m_cushionStats[slot].update(now); + info.cushionSamples = m_cushionStats[slot].getSampleCount(); + info.cushionP5 = m_cushionStats[slot].getPercentile(5); + info.cushionP50 = m_cushionStats[slot].getPercentile(50); + } + return TRUE; +} #endif diff --git a/Core/GameEngine/Source/GameNetwork/ConnectionStats.cpp b/Core/GameEngine/Source/GameNetwork/ConnectionStats.cpp index 09b3fc387a6..4488d43dc9a 100644 --- a/Core/GameEngine/Source/GameNetwork/ConnectionStats.cpp +++ b/Core/GameEngine/Source/GameNetwork/ConnectionStats.cpp @@ -286,25 +286,28 @@ void StallStats::note(Int durationMs, UnsignedInt missingSlotMask, time_t now) void StallStats::update(time_t now) { - m_events.prune(now, NETWORK_STATS_WINDOW_MS); + m_events.prune(now, HISTORY_MS); } -Int StallStats::getTotalMs() const +Int StallStats::getCount(time_t now, Int windowMs) const { - Int total = 0; + Int count = 0; for (Int i = 0; i < m_events.size(); ++i) { - total += m_events[i].ms; + if (now - m_events[i].time <= windowMs) + { + ++count; + } } - return total; + return count; } -Int StallStats::getCountForSlot(Int slot) const +Int StallStats::getCountForSlot(Int slot, time_t now, Int windowMs) const { Int count = 0; for (Int i = 0; i < m_events.size(); ++i) { - if (m_events[i].slotMask & (1u << slot)) + if ((m_events[i].slotMask & (1u << slot)) && now - m_events[i].time <= windowMs) { ++count; } diff --git a/Core/GameEngine/Source/GameNetwork/Network.cpp b/Core/GameEngine/Source/GameNetwork/Network.cpp index 9dc70133a97..d5e6b6ada93 100644 --- a/Core/GameEngine/Source/GameNetwork/Network.cpp +++ b/Core/GameEngine/Source/GameNetwork/Network.cpp @@ -166,6 +166,9 @@ class Network : public NetworkInterface virtual Int getAverageFPS() override { return m_conMgr->getAverageFPS(); } virtual Int getSlotAverageFPS(Int slot) override; virtual const ConnectionStats* getConnectionStats(Int slot) const override; +#if defined(GENERALS_ONLINE) + virtual Bool getOverlayInfo(Int slot, NetworkOverlayInfo &info) override; +#endif virtual void attachTransport(Transport *transport) override; virtual void initTransport() override; @@ -1108,6 +1111,15 @@ const ConnectionStats* Network::getConnectionStats(Int slot) const { return nullptr; } +#if defined(GENERALS_ONLINE) +Bool Network::getOverlayInfo(Int slot, NetworkOverlayInfo &info) { + if (m_conMgr != nullptr) { + return m_conMgr->getOverlayInfo(slot, info); + } + return FALSE; +} +#endif + #if defined(RTS_DEBUG) void Network::toggleNetworkOn() { if (m_networkOn == TRUE) { diff --git a/GeneralsMD/Code/GameEngine/CMakeLists.txt b/GeneralsMD/Code/GameEngine/CMakeLists.txt index e739d110a7a..d79fb756219 100644 --- a/GeneralsMD/Code/GameEngine/CMakeLists.txt +++ b/GeneralsMD/Code/GameEngine/CMakeLists.txt @@ -1140,6 +1140,7 @@ set(GAMEENGINE_SRC Include/GameNetwork/GeneralsOnline/DiscordRichPresence.h Include/GameNetwork/GeneralsOnline/Vendor/DiscordRPC/discord_rpc.h Include/GameNetwork/GeneralsOnline/NetworkBitstream.h + Include/GameNetwork/GeneralsOnline/ConnectionRating.h Include/GameNetwork/GeneralsOnline/NetworkMesh.h Include/GameNetwork/GeneralsOnline/NetBytes.h Include/GameNetwork/GeneralsOnline/PacketFeedback.h @@ -1172,6 +1173,7 @@ set(GAMEENGINE_SRC Source/GameNetwork/GeneralsOnline/GeneralsOnline_Settings.cpp Source/GameNetwork/GeneralsOnline/HTTP/HTTPManager.cpp Source/GameNetwork/GeneralsOnline/HTTP/HTTPRequest.cpp + Source/GameNetwork/GeneralsOnline/ConnectionRating.cpp Source/GameNetwork/GeneralsOnline/NetworkMesh.cpp Source/GameNetwork/GeneralsOnline/PacketFeedback.cpp Source/GameNetwork/GeneralsOnline/OnlineServices_Init.cpp diff --git a/GeneralsMD/Code/GameEngine/Include/GameClient/InGameUI.h b/GeneralsMD/Code/GameEngine/Include/GameClient/InGameUI.h index d5798a48ec7..872a4dab664 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameClient/InGameUI.h +++ b/GeneralsMD/Code/GameEngine/Include/GameClient/InGameUI.h @@ -615,6 +615,7 @@ class InGameUI : public SubsystemInterface, public Snapshot void drawRenderFps(Int& x, Int& y); void drawSystemTime(Int& x, Int& y); void drawGameTime(); + void drawNetworkOverlay(Int mode); ///< 1 = player view, 2 = dev view void drawPlayerInfoList(); void drawObserverStats(Int &x, Int &y); Bool m_observerStatsHidden = false; // hide/show observer overlay diff --git a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/ConnectionRating.h b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/ConnectionRating.h new file mode 100644 index 00000000000..1225735f622 --- /dev/null +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/ConnectionRating.h @@ -0,0 +1,77 @@ +#pragma once + +#include "Lib/BaseType.h" +#include "GameNetwork/ConnectionStats.h" + +// One overall rating of a connection, shared by the lobby slot indicator and the in-game overlay. +// It is the worse of two axes: delay (the input delay the link forces, in ms) and stability (stalls per minute). +// 5 bars: delay <= 150 ms and no stall in the last minute; 1 bar: delay above 500 ms or 6 or more stalls per minute. +// Colors: 4-5 green, 2-3 amber, 1 red. Red needs one of (RedReason): blackout, loss, delay or stalls, see RedReason in the .cpp. +// Stalls a healthy link does not explain wait on the peer's frame rate: slow PC, at most 3 bars and never red for it. +// Too few samples (10 probes, 20 command acks) give no rating. +class ConnectionRating +{ +public: + enum { BAR_COUNT = 5 }; + enum { SEVERITY_OK = 0, SEVERITY_WARN = 1, SEVERITY_BAD = 2 }; + + struct Inputs + { + Inputs(); + + Real delayMs; ///< Input delay the link forces. + Real stallsPerMinute; ///< Measured and predicted, whichever is higher. + Real measuredStallsPerMinute; ///< Stalls of the last minute in a match, negative in the lobby. + Real lossPercent; ///< Packet loss over the window from enough feedback reports, negative if unknown. + Int silentMs; ///< Since the last packet of the peer, negative if unknown. + Int silentLimitMs; ///< Silence that makes the link red, at least 2.5 probe intervals. + Bool stallInWindow; ///< A stall in the last 10 s stats window. + Bool slowPc; ///< The measured stalls are the peer's frame rate, not the link. + }; + + struct State + { + State() : bars(0), delayBars(0), stabilityBars(0), delayMs(0), stallsPerMinute(0.0f), raiseSinceMs(0), redReason(nullptr), slowPc(FALSE) {} + + Int bars; ///< 1..BAR_COUNT, 0 until rated. + Int delayBars; + Int stabilityBars; + Int delayMs; + Real stallsPerMinute; + UnsignedInt raiseSinceMs; ///< Since when the rating asks for more bars than shown, 0 if it does not. + const char *redReason; ///< Why the link is red, nullptr if it is not. + Bool slowPc; ///< The peer's PC is the bottleneck, the link is fine. + }; + + /// lossPercent is the packet loss from the packet feedback, negative to use the loss of the stats. + static Inputs InputsFromProbe(const ConnectionStats &probe, Int slackPercent, Int sendIntervalMs, Real lossPercent); + + static Inputs InputsFromMatch(const ConnectionStats &cmd, const NetworkOverlayInfo &info, Int slackPercent, Int sendIntervalMs, Real lossPercent); + + /// Fewer bars show at once, more only after the rating asked for them for a few seconds. + static void Update(State &state, const Inputs &inputs, UnsignedInt nowMs); + + static Int Severity(Int bars); + + static const char *RedReason(const Inputs &inputs); + + /// Draws the signal bars, level is bars - 1. + static void DrawBars(Int left, Int top, Int size, Int level); + + /// The spinner shown while a connection is not rated yet. + static void DrawSpinner(Int left, Int top, Int size); + + /// The indicator of a rating as ComputeConnectionRating returns it: the spinner below 1 bar, else the bars. + static void DrawIndicator(Int left, Int top, Int size, Int bars); +}; + +// Draws between begin and end of a display batch, unless the caller already batches. +class ScopedDisplayBatch +{ +public: + ScopedDisplayBatch(); + ~ScopedDisplayBatch(); + +private: + Bool m_ownBatch; +}; diff --git a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NGMPGame.h b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NGMPGame.h index 0d11c6a51b3..1275b1bc5c8 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NGMPGame.h +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NGMPGame.h @@ -79,6 +79,19 @@ class NGMPGame : public GameInfo virtual ~NGMPGame(); virtual void reset(void); + Int getSlotOfUser(int64_t userID) const + { + for (Int slotNum = 0; slotNum < MAX_SLOTS; ++slotNum) + { + const NGMPGameSlot* pSlot = (const NGMPGameSlot*)getConstSlot(slotNum); + if (pSlot != nullptr && pSlot->m_userID == userID) + { + return slotNum; + } + } + return -1; + } + bool HasCommittedOutcome() const { return m_bHasCommittedOutcome; } void SetHasCommittedOutcome() { m_bHasCommittedOutcome = true; } diff --git a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NetworkMesh.h b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NetworkMesh.h index 7bae0762a64..e9df3a2e580 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NetworkMesh.h +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NetworkMesh.h @@ -8,6 +8,7 @@ #include "PluginInterfaces.h" #include "Lib/BaseType.h" #include "GameNetwork/ConnectionStats.h" +#include "GameNetwork/GeneralsOnline/ConnectionRating.h" #include "GameNetwork/GeneralsOnline/PacketFeedback.h" class NetRoom_ChatMessagePacket; @@ -92,7 +93,6 @@ class PlayerConnection } std::vector m_vecLatencyHistory; - std::vector m_vecQualityHistory; std::string GetStats(); std::string GetConnectionType(); @@ -118,11 +118,13 @@ class PlayerConnection bool GetOutgoingLoss(int windowMs, PacketFeedback::Summary& summary); ///< our packets to the peer, false without feedback bool GetIncomingLoss(int windowMs, PacketFeedback::Summary& summary); ///< the peer's packets to us int GetJitter(); - float GetConnectionQuality(); - int ComputeConnectionScore(); + int ComputeConnectionRating(); ///< 1..5 bars, -1 while it is not known yet; cached for 250 ms. + int UpdateConnectionRating(); + int64_t m_nextRatingMs = 0; + int m_ratingResult = -1; std::chrono::steady_clock::time_point m_connectedSinceTime = (std::chrono::steady_clock::time_point::min)(); - float m_smoothedScore = -1.0f; + ConnectionRating::State m_rating; // Only set for Steam connections HSteamNetConnection m_hSteamConnection = k_HSteamNetConnection_Invalid; diff --git a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h index 8012346fc0c..84cecfb5d1e 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h @@ -687,9 +687,10 @@ class NGMP_OnlineServicesManager static void CaptureScreenshot(bool bResizeForTransmit, std::function)> cbOnDataAvailable); static void CaptureScreenshotForProbe(EScreenshotType screenshotType, std::string strURI); - static bool g_bAdvancedNetworkStats; - static void ToggleAdvancedNetworkStats() { g_bAdvancedNetworkStats = !g_bAdvancedNetworkStats; } - static bool IsAdvancedNetworkStatsEnabled() { return g_bAdvancedNetworkStats; } + // the network overlay: 0 off, 1 player view (F10), 2 dev view (Shift+F10) + static int g_networkOverlayMode; + static void ToggleNetworkOverlay(int mode) { g_networkOverlayMode = (g_networkOverlayMode == mode) ? 0 : mode; } + static int GetNetworkOverlayMode() { return g_networkOverlayMode; } void OnLogin(ELoginResult loginResult, const char* szWSAddr, std::function fnWebsocketConnectedCallback); 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 739367b2894..28ce3f2803e 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/WOLGameSetupMenu.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/WOLGameSetupMenu.cpp @@ -262,10 +262,6 @@ enum }; static Int connectionIndicatorState[MAX_SLOTS]; -static const Int CONNECTION_LEVEL_COUNT = 5; -static const Int connectionLevelThresholds[CONNECTION_LEVEL_COUNT - 1] = { 40, 60, 75, 90 }; ///< score needed for 2..5 bars -static const Int CONNECTION_LEVEL_HYSTERESIS = 3; - WindowLayout *WOLMapSelectLayout = NULL; void PopBackToLobby() @@ -515,10 +511,10 @@ static void playerTooltip(GameWindow *window, } bool bIsConnected = false; - int connectionScore = -1; + int connectionBars = -1; int connectionLatency = -1; int connectionJitter = -1; - int connectionQualityPct = -1; + int connectionLossPct = -1; std::string strConnectionType = ""; LobbyMemberEntry member = pLobbyInterface->GetRoomMemberFromID(slot->m_userID); @@ -540,11 +536,10 @@ static void playerTooltip(GameWindow *window, { bIsConnected = false; } - connectionScore = pConnection->ComputeConnectionScore(); + connectionBars = pConnection->ComputeConnectionRating(); connectionLatency = pConnection->GetLatency(); connectionJitter = pConnection->GetJitter(); - float rawQuality = pConnection->GetConnectionQuality(); - connectionQualityPct = (rawQuality >= 0.0f) ? static_cast(rawQuality * 100.0f) : -1; + connectionLossPct = (pConnection->m_probeStats.getSampleCount() > 0) ? static_cast(pConnection->m_probeStats.getLossPercent() + 0.5f) : -1; // packet loss } } } @@ -562,19 +557,18 @@ static void playerTooltip(GameWindow *window, } else if (bIsConnected) { - UnicodeString scoreStr, latencyStr, jitterStr, qualityStr; - if (connectionScore >= 0) scoreStr.format(L"%d%%", connectionScore); else scoreStr = L"Unknown"; + UnicodeString latencyStr, jitterStr, qualityStr; if (connectionLatency >= 0) latencyStr.format(L"%d ms", connectionLatency); else latencyStr = L"Unknown"; if (connectionJitter >= 0) jitterStr.format(L"%d ms", connectionJitter); else jitterStr = L"Unknown"; - if (connectionQualityPct >= 0) qualityStr.format(L"%d%%", connectionQualityPct); else qualityStr = L"Unknown"; + if (connectionLossPct >= 0) qualityStr.format(L"%d%%", connectionLossPct); else qualityStr = L"Unknown"; #if defined(WORLD_SERIES_CLIENT) - playerInfo.format(L"\nConnection State: Connected (%hs)\nConnection Score: %s\nLatency: %s\nJitter: %s\nReliability: %s\nRegion: %hs\nOverall Elo Rating: %d (in %d matches)\nWS Elo Rating: %d\nWins: %d\nLosses: %d\nDisconnects: %d\nFavorite Army: %s", - strConnectionType.c_str(), scoreStr.str(), latencyStr.str(), jitterStr.str(), qualityStr.str(), + playerInfo.format(L"\nConnection State: Connected (%hs)\nPing: %s\nLoss: %s\nJitter: %s\nRegion: %hs\nOverall Elo Rating: %d (in %d matches)\nWS Elo Rating: %d\nWins: %d\nLosses: %d\nDisconnects: %d\nFavorite Army: %s", + strConnectionType.c_str(), latencyStr.str(), qualityStr.str(), jitterStr.str(), member.region.c_str(), stats.elo_rating, stats.elo_num_matches, stats.monthly_elo_rating, totalWins, totalLosses, totalDiscons, favoriteSide.str()); #else - playerInfo.format(L"\nConnection State: Connected (%hs)\nConnection Score: %s\nLatency: %s\nJitter: %s\nReliability: %s\nRegion: %hs\nOverall Elo Rating: %d (in %d matches)\nMonthly Elo Rating: %d\nWins: %d\nLosses: %d\nDisconnects: %d\nFavorite Army: %s", - strConnectionType.c_str(), scoreStr.str(), latencyStr.str(), jitterStr.str(), qualityStr.str(), + playerInfo.format(L"\nConnection State: Connected (%hs)\nPing: %s\nLoss: %s\nJitter: %s\nRegion: %hs\nOverall Elo Rating: %d (in %d matches)\nMonthly Elo Rating: %d\nWins: %d\nLosses: %d\nDisconnects: %d\nFavorite Army: %s", + strConnectionType.c_str(), latencyStr.str(), qualityStr.str(), jitterStr.str(), member.region.c_str(), stats.elo_rating, stats.elo_num_matches, stats.monthly_elo_rating, totalWins, totalLosses, totalDiscons, favoriteSide.str()); #endif } @@ -1399,26 +1393,6 @@ void WOLDisplayGameOptions() } -// ----------------------------------------------------------------------------------------- -// The Bad munkee slot list displaying function -//------------------------------------------------------------------------------------------------- -static Int WOLUpdateConnectionLevel(Int level, Int score) -{ - if (level < 0) - { - level = 0; - while (level < CONNECTION_LEVEL_COUNT - 1 && score >= connectionLevelThresholds[level]) - ++level; - return level; - } - - while (level < CONNECTION_LEVEL_COUNT - 1 && score >= connectionLevelThresholds[level] + CONNECTION_LEVEL_HYSTERESIS) - ++level; - while (level > 0 && score < connectionLevelThresholds[level - 1] - CONNECTION_LEVEL_HYSTERESIS) - --level; - return level; -} - static void WOLRefreshConnectionIndicators(void) { NGMP_OnlineServices_LobbyInterface* pLobbyInterface = NGMP_OnlineServicesManager::GetInterface(); @@ -1445,7 +1419,7 @@ static void WOLRefreshConnectionIndicators(void) genericPingWindow[i]->winHide(FALSE); EConnectionState connectionState = EConnectionState::NOT_CONNECTED; - int connectionScore = -1; + int connectionBars = -1; if (pMesh != nullptr) { @@ -1453,17 +1427,17 @@ static void WOLRefreshConnectionIndicators(void) if (pConnection != nullptr) { connectionState = pConnection->GetState(); - connectionScore = pConnection->ComputeConnectionScore(); + connectionBars = pConnection->ComputeConnectionRating(); } } Int& state = connectionIndicatorState[i]; if (connectionState == EConnectionState::CONNECTION_FAILED || connectionState == EConnectionState::CONNECTION_DISCONNECTED) state = CONNECTION_INDICATOR_WARNING; - else if (connectionState != EConnectionState::CONNECTED_DIRECT || connectionScore < 0) + else if (connectionState != EConnectionState::CONNECTED_DIRECT || connectionBars < 0) state = CONNECTION_INDICATOR_CONNECTING; else - state = WOLUpdateConnectionLevel(state, connectionScore); + state = connectionBars - 1; // the rating is 1..5 bars, the state is the level 0..4 } } @@ -1484,6 +1458,8 @@ static void WOLConnectionIndicatorDraw(GameWindow *window, WinInstanceData *inst if (slot < 0) return; + ScopedDisplayBatch batch; + Int x, y, width, height; window->winGetScreenPosition(&x, &y); window->winGetSize(&width, &height); @@ -1492,26 +1468,11 @@ static void WOLConnectionIndicatorDraw(GameWindow *window, WinInstanceData *inst const Int left = x + (width - size) / 2; const Int top = y + (height - size) / 2; const Real centerX = left + size * 0.5f; - const Real centerY = top + size * 0.5f; const Int state = connectionIndicatorState[slot]; if (state == CONNECTION_INDICATOR_CONNECTING) { - // Faint full ring with a solid arc rotating around it, drawn as short segments. - const Int segmentCount = 24; - const Int arcSegments = 7; - const Real radius = size * 0.4f; - const Real rotation = (timeGetTime() % 900) * 2.0f * PI / 900.0f; - const Color ringColor = GameMakeColor(47, 55, 168, 70); // Zero Hour UI blue - const Color arcColor = GameMakeColor(47, 55, 168, 255); - for (Int segment = 0; segment < segmentCount; ++segment) - { - const Real startAngle = rotation + segment * 2.0f * PI / segmentCount; - const Real endAngle = startAngle + 2.0f * PI / segmentCount; - TheDisplay->drawLine((Int)(centerX + std::cos(startAngle) * radius), (Int)(centerY + std::sin(startAngle) * radius), - (Int)(centerX + std::cos(endAngle) * radius), (Int)(centerY + std::sin(endAngle) * radius), - 3.0f, segment < arcSegments ? arcColor : ringColor); - } + ConnectionRating::DrawSpinner(left, top, size); } else if (state == CONNECTION_INDICATOR_WARNING) { @@ -1528,21 +1489,7 @@ static void WOLConnectionIndicatorDraw(GameWindow *window, WinInstanceData *inst } else { - Color levelColor = GameMakeColor(220, 40, 40, 255); - if (state >= 3) - levelColor = GameMakeColor(0, 200, 0, 255); - else if (state == 2) - levelColor = GameMakeColor(255, 190, 0, 255); - const Color emptyColor = GameMakeColor(90, 90, 90, 200); - - const Int gap = 1; - const Int barWidth = max(1, (size - gap * (CONNECTION_LEVEL_COUNT - 1)) / CONNECTION_LEVEL_COUNT); - for (Int bar = 0; bar < CONNECTION_LEVEL_COUNT; ++bar) - { - const Int barHeight = max(1, size * (bar + 1) / CONNECTION_LEVEL_COUNT); - TheDisplay->drawFillRect(left + bar * (barWidth + gap), top + size - barHeight, barWidth, barHeight, - bar <= state ? levelColor : emptyColor); - } + ConnectionRating::DrawBars(left, top, size, state); } } diff --git a/GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp b/GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp index b833f4c59d1..68954303707 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp @@ -101,6 +101,11 @@ #include "GameNetwork/NetworkInterface.h" extern NetworkInterface * TheNetwork; #include + +namespace +{ + void overlayReleaseCache(); +} #endif @@ -2354,6 +2359,9 @@ void InGameUI::freeMessageResources() void InGameUI::freeCustomUiResources() { +#if defined(GENERALS_ONLINE) + overlayReleaseCache(); +#endif TheDisplayStringManager->freeDisplayString(m_networkLatencyString); m_networkLatencyString = nullptr; TheDisplayStringManager->freeDisplayString(m_renderFpsString); @@ -7171,99 +7179,561 @@ void InGameUI::drawSystemTime(Int& x, Int& y) } } -void InGameUI::drawGameTime() +#if defined(GENERALS_ONLINE) +namespace { - // draw connections - if (NGMP_OnlineServicesManager::IsAdvancedNetworkStatsEnabled()) + enum OverlayColumn + { + COL_SLOT, COL_NAME, COL_BARS, COL_PATH, COL_PROBE, COL_CMD, COL_JIT, COL_LOST, COL_LATE, + COL_CUSHION, COL_SAMPLES, COL_STALLS, COL_OUT, COL_IN, + COL_COUNT + }; + + const Int k_playerViewTop = 500; + const Real k_devViewTopFraction = 0.10f; // the dev table sits higher to clear the control bar + const Int k_nameMaxChars = 14; + const Int k_devViewHeaderLines = 2; + const Int k_devViewFooterLines = 1; + + const wchar_t *const s_overlayHeaders[COL_COUNT] = { - if (TheNGMPGame != nullptr) + L"Slot", L"Player", L"Bars", L"Path", L"Probe", L"Cmd", L"Jit", L"Lost%", L"Late%", + L"Cush5/50", L"n", L"Stalls", L"OutL%", L"InL%" + }; + + + struct OverlayRow + { + OverlayRow() : used(FALSE), bars(-1), slowPc(FALSE), direct(FALSE), pingMs(0), lossPercent(0.0f), latePercent(0.0f) { - NetworkMesh* pMesh = NGMP_OnlineServicesManager::GetNetworkMesh(); + for (Int c = 0; c < COL_COUNT; ++c) + { + severity[c] = ConnectionRating::SEVERITY_OK; + } + } - NGMP_OnlineServices_LobbyInterface* pLobbyInterface = NGMP_OnlineServicesManager::GetInterface(); + Bool used; + UnicodeString name; + Int bars; + Bool slowPc; + Bool direct; + Int pingMs; + Real lossPercent; + Real latePercent; + NetworkOverlayInfo info; + UnicodeString cell[COL_COUNT]; + Int severity[COL_COUNT]; + }; - if (pMesh != nullptr && pLobbyInterface != nullptr) + Int overlayPercentSeverity(Real percent) + { + if (percent >= 5.0f) + { + return ConnectionRating::SEVERITY_BAD; + } + if (percent >= 0.5f) + { + return ConnectionRating::SEVERITY_WARN; + } + return ConnectionRating::SEVERITY_OK; + } + + Color overlayColor(Int severity) + { + if (severity >= ConnectionRating::SEVERITY_BAD) + { + return GameMakeColor(255, 90, 80, 255); + } + if (severity == ConnectionRating::SEVERITY_WARN) + { + return GameMakeColor(255, 195, 50, 255); + } + return GameMakeColor(235, 235, 235, 255); + } + + Color overlayDim() + { + return GameMakeColor(165, 165, 165, 255); + } + + // the overlay owns one DisplayString per text slot, so a slot whose text is unchanged keeps its built sentence and + // quads. a shared string would rebuild a sentence surface and texture on every draw, which is very slow on some drivers + const Int k_overlayRefreshMs = 250; + const Int k_strMeasure = 0; + const Int k_strHeaderBase = 1; + const Int k_strHeaderCount = 8; + const Int k_strRowBase = k_strHeaderBase + k_strHeaderCount; + const Int k_strRowCount = 8; + const Int k_strDevHeadBase = k_strRowBase + MAX_SLOTS * k_strRowCount; + const Int k_strDevCellBase = k_strDevHeadBase + COL_COUNT; + const Int k_strFooterBase = k_strDevCellBase + MAX_SLOTS * COL_COUNT; + const Int k_strFooterCount = 8; + const Int k_strTotal = k_strFooterBase + k_strFooterCount; + + struct OverlayCache + { + OverlayCache() : valid(FALSE), mode(0), refreshedMs(0), lineH(12), rowCount(0) + { + for (Int i = 0; i < k_strTotal; ++i) + { + strings[i] = nullptr; + } + for (Int c = 0; c < COL_COUNT; ++c) { - //std::vector& vecMembers = pLobbyInterface->GetMembersListForCurrentRoom(); + headerCell[c] = s_overlayHeaders[c]; + } + } - ISteamNetworkingSockets* pSteamNetSockets = SteamNetworkingSockets(); - if (!pSteamNetSockets) - { - return; - } + Bool valid; + Int mode; + UnsignedInt refreshedMs; + Int lineH; + Int rowCount; + NetworkOverlayInfo header; + UnicodeString waitingOn; + UnicodeString headerCell[COL_COUNT]; + OverlayRow rows[MAX_SLOTS]; + DisplayString *strings[k_strTotal]; + }; + + OverlayCache *s_overlayCache = nullptr; + GameFont *s_overlayFont = nullptr; + + void overlayReleaseCache() + { + if (s_overlayCache == nullptr) + { + return; + } + for (Int i = 0; i < k_strTotal; ++i) + { + if (s_overlayCache->strings[i] != nullptr && TheDisplayStringManager != nullptr) + { + TheDisplayStringManager->freeDisplayString(s_overlayCache->strings[i]); + } + } + delete s_overlayCache; + s_overlayCache = nullptr; + } + + DisplayString *overlayString(Int index) + { + DisplayString *&pString = s_overlayCache->strings[index]; + if (pString == nullptr) + { + pString = TheDisplayStringManager->newDisplayString(); + } + pString->setFont(s_overlayFont); + return pString; + } + + void overlaySetText(DisplayString *pString, const UnicodeString &text) + { + if (!(text == pString->getText())) + { + pString->setText(text); + } + } - int i = 0; - auto& allConnections = pMesh->GetAllConnections(); - if (allConnections.empty()) + Int overlayTextWidth(Int index, const UnicodeString &text) + { + DisplayString *pString = overlayString(index); + overlaySetText(pString, text); + return pString->getWidth(); + } + + Int overlayDrawText(Int index, Int x, Int y, const UnicodeString &text, Color color, Color dropColor) + { + DisplayString *pString = overlayString(index); + overlaySetText(pString, text); + pString->draw(x, y, color, dropColor); + return pString->getWidth(); + } + + Int overlayTableWidth(const Int *colWidth, const Bool *colShown, Int gap) + { + Int total = 0; + Int shown = 0; + for (Int c = 0; c < COL_COUNT; ++c) + { + if (colShown[c]) + { + total += colWidth[c]; + ++shown; + } + } + return total + (shown > 1 ? (shown - 1) * gap : 0); + } +} +#endif + +void InGameUI::drawNetworkOverlay(Int mode) +{ +#if defined(GENERALS_ONLINE) + if (TheNGMPGame == nullptr || TheNetwork == nullptr) + { + return; + } + + NetworkMesh* pMesh = NGMP_OnlineServicesManager::GetNetworkMesh(); + NGMP_OnlineServices_LobbyInterface* pLobbyInterface = NGMP_OnlineServicesManager::GetInterface(); + if (pMesh == nullptr || pLobbyInterface == nullptr) + { + return; + } + + if (s_overlayCache == nullptr) + { + s_overlayCache = new OverlayCache(); + } + OverlayCache& cache = *s_overlayCache; + s_overlayFont = m_gameTimeString->getFont(); + + const Color dropColor = m_gameTimeDropColor; + const Color neutral = overlayColor(ConnectionRating::SEVERITY_OK); + const Color dim = overlayDim(); + + const UnsignedInt nowMs = timeGetTime(); + const Bool refresh = !cache.valid || cache.mode != mode || nowMs - cache.refreshedMs >= (UnsignedInt)k_overlayRefreshMs; + if (refresh) + { + cache.valid = TRUE; + cache.mode = mode; + cache.refreshedMs = nowMs; + + Int lineW, lineH; + DisplayString *pMeasure = overlayString(k_strMeasure); + overlaySetText(pMeasure, UnicodeString(L"Wg")); + pMeasure->getSize(&lineW, &lineH); + cache.lineH = (std::max)(lineH, 12); + TheNetwork->getOverlayInfo(-1, cache.header); + + for (Int slot = 0; slot < MAX_SLOTS; ++slot) + { + cache.rows[slot] = OverlayRow(); + } + cache.rowCount = 0; + cache.waitingOn.clear(); + OverlayRow (&rows)[MAX_SLOTS] = cache.rows; + Int& rowCount = cache.rowCount; + UnicodeString& waitingOn = cache.waitingOn; + + for (auto& connection : pMesh->GetAllConnections()) + { + PlayerConnection& conn = connection.second; + if (!conn.IsValid()) + { + continue; + } + + LobbyMemberEntry member = pLobbyInterface->GetRoomMemberFromID(connection.first); + if (member.display_name.empty() || member.m_SlotIndex < 0 || member.m_SlotIndex >= MAX_SLOTS) + { + continue; + } + + const Int slot = member.m_SlotIndex; + OverlayRow& row = rows[slot]; + row.used = TRUE; + row.name.set(from_utf8(member.display_name).c_str()); + row.bars = conn.ComputeConnectionRating(); + row.slowPc = conn.m_rating.slowPc; + row.direct = conn.IsDirect(); + row.pingMs = conn.GetLatency(); + ++rowCount; + + const ConnectionStats* pCmdStats = TheNetwork->getConnectionStats(slot); + const ConnectionStats& probeStats = conn.m_probeStats; + const Bool hasCmd = (pCmdStats != nullptr && pCmdStats->getSampleCount() > 0); + row.lossPercent = hasCmd ? pCmdStats->getLossPercent() : probeStats.getLossPercent(); + row.latePercent = hasCmd ? pCmdStats->getLatePercent() : 0.0f; + TheNetwork->getOverlayInfo(slot, row.info); + + if (row.info.waitingOnSlot) + { + if (!waitingOn.isEmpty()) { - return; + waitingOn.concat(L", "); } + waitingOn.concat(row.name); + } - for (auto& connection : allConnections) - { - if (!connection.second.IsValid()) - { - continue; - } + if (mode != 2) + { + continue; + } - LobbyMemberEntry lobbyMember = pLobbyInterface->GetRoomMemberFromID(connection.first); + UnicodeString* cell = row.cell; + Int* sev = row.severity; + cell[COL_SLOT].format(L"%d", slot); + cell[COL_NAME] = row.name; + cell[COL_NAME].truncateTo(k_nameMaxChars); + if (row.bars > 0) + { + cell[COL_BARS].format(L"%d", row.bars); + sev[COL_BARS] = ConnectionRating::Severity(row.bars); + } + else + { + cell[COL_BARS] = L"-"; + } + cell[COL_PATH].format(L"%hs %hs", conn.IsIPV4() ? "IPv4" : "IPv6", row.direct ? "direct" : "relay"); - const int k_nLanes = 1; - SteamNetConnectionRealTimeStatus_t status; - SteamNetConnectionRealTimeLaneStatus_t laneStatus[k_nLanes]; - - if (!TheNetwork) - { - continue; - } + if (probeStats.getSampleCount() > 0) + { + cell[COL_PROBE].format(L"%.0f", probeStats.getLatency()); + } + else + { + cell[COL_PROBE] = L"-"; + } - EResult res = pSteamNetSockets->GetConnectionRealTimeStatus(connection.second.m_hSteamConnection, &status, k_nLanes, laneStatus); + if (hasCmd) + { + const Real jitter = pCmdStats->getJitter(); + cell[COL_CMD].format(L"%.0f", pCmdStats->getLatency()); + cell[COL_JIT].format(L"%.1f", jitter); + sev[COL_JIT] = (jitter >= 60.0f) ? ConnectionRating::SEVERITY_BAD : ((jitter >= 30.0f) ? ConnectionRating::SEVERITY_WARN : ConnectionRating::SEVERITY_OK); + cell[COL_LOST].format(L"%.0f", pCmdStats->getLossPercent()); + sev[COL_LOST] = overlayPercentSeverity(pCmdStats->getLossPercent()); + cell[COL_LATE].format(L"%.0f", pCmdStats->getLatePercent()); + sev[COL_LATE] = overlayPercentSeverity(pCmdStats->getLatePercent()); + } + else + { + cell[COL_CMD] = L"-"; + cell[COL_JIT] = L"-"; + cell[COL_LOST] = L"-"; + cell[COL_LATE] = L"-"; + } - if (res == k_EResultNoConnection || lobbyMember.display_name.empty()) - { - continue; - } + if (row.info.cushionSamples > 0) + { + cell[COL_CUSHION].format(L"%d/%d", row.info.cushionP5, row.info.cushionP50); + sev[COL_CUSHION] = (row.info.cushionP5 <= 0) ? ConnectionRating::SEVERITY_BAD : ConnectionRating::SEVERITY_OK; + } + else + { + cell[COL_CUSHION] = L"-"; + } + cell[COL_SAMPLES].format(L"%d", row.info.cushionSamples); + cell[COL_STALLS].format(L"%d", row.info.stallsWindow); + sev[COL_STALLS] = (row.info.stallsWindow >= 3 && !row.slowPc) ? ConnectionRating::SEVERITY_BAD : ((row.info.stallsWindow > 0) ? ConnectionRating::SEVERITY_WARN : ConnectionRating::SEVERITY_OK); + + // out is how our packets fare to the peer, in is the peer's to us + PacketFeedback::Summary feedbackOut; + PacketFeedback::Summary feedbackIn; + if (conn.GetOutgoingLoss(NETWORK_STATS_WINDOW_MS, feedbackOut)) + { + cell[COL_OUT].format(L"%.1f", feedbackOut.lossPercent); + sev[COL_OUT] = overlayPercentSeverity(feedbackOut.lossPercent); + } + else + { + cell[COL_OUT] = L"-"; + } + if (conn.GetIncomingLoss(NETWORK_STATS_WINDOW_MS, feedbackIn)) + { + cell[COL_IN].format(L"%.1f", feedbackIn.lossPercent); + sev[COL_IN] = overlayPercentSeverity(feedbackIn.lossPercent); + } + else + { + cell[COL_IN] = L"-"; + } + } + } - int avgFPS = TheNetwork->getSlotAverageFPS(lobbyMember.m_SlotIndex); - - UnicodeString netString; - netString.format(L"\n[usr %s|%d][%hs %hs][AVGFPS: %d] Lat: %i, QL: %.2f, QR: %.2f OutP/s: %.2f, OutB/s: %.2f, InP/s: %.2f, InB/s: %.2f, SR %i PU: %d, PR: %d, NACK: %d, QT: %I64d", - from_utf8(lobbyMember.display_name).c_str(), - (int)res, - connection.second.IsIPV4() ? "IPv4" : "IPv6", - connection.second.IsDirect() ? "Direct" : "Relay", - avgFPS, - status.m_nPing, - status.m_flConnectionQualityLocal, - status.m_flConnectionQualityRemote, - status.m_flOutPacketsPerSec, - status.m_flOutBytesPerSec, - status.m_flInPacketsPerSec, - status.m_flInBytesPerSec, - status.m_nSendRateBytesPerSecond, - status.m_cbPendingUnreliable, - status.m_cbPendingReliable, - status.m_cbSentUnackedReliable, - status.m_usecQueueTime); - - int w, h; - m_gameTimeString->getSize(&w, &h); - - bool bIsHighQuality = true; - if (avgFPS < (GENERALS_ONLINE_HIGH_FPS_LIMIT - 1) || status.m_cbSentUnackedReliable >= 1000 || (status.m_flConnectionQualityLocal != -1.f && status.m_flConnectionQualityLocal < 1.f) || (status.m_flConnectionQualityRemote != -1.f && status.m_flConnectionQualityRemote < 1.f)) - { - bIsHighQuality = false; - } + const Int lineH = cache.lineH; + const Int segmentGap = (lineH * 3) / 4; + const Int displayWidth = TheDisplay->getWidth(); + const Int displayHeight = TheDisplay->getHeight(); - m_gameTimeString->setText(netString); - m_gameTimeString->draw(0, 500 + (i * h / 2), bIsHighQuality ? m_colorGood : m_colorBad, m_gameTimeDropColor); - ++i; - } + const Int left = 0; + + OverlayRow (&rows)[MAX_SLOTS] = cache.rows; + const Int rowCount = cache.rowCount; + const UnicodeString& waitingOn = cache.waitingOn; + const NetworkOverlayInfo& header = cache.header; + + const Int tableLines = (mode == 2) ? (k_devViewHeaderLines + rowCount + k_devViewFooterLines) : (1 + rowCount); + Int y = (mode == 2) ? (Int)(displayHeight * k_devViewTopFraction) : k_playerViewTop; + y = (std::min)(y, displayHeight - 4 - tableLines * lineH); + y = (std::max)(y, 0); + + const Int delayMs = header.runAhead * 1000 / GENERALS_ONLINE_HIGH_FPS_LIMIT; + { + Int x = left; + UnicodeString text; + text.format(L"Input delay %d ms", delayMs); + x += overlayDrawText(k_strHeaderBase + 0, x, y, text, header.stalledNow ? overlayColor(ConnectionRating::SEVERITY_BAD) : neutral, dropColor) + segmentGap; + + if (header.stalledNow && !waitingOn.isEmpty()) + { + text.format(L"waiting on: %ls", waitingOn.str()); + overlayDrawText(k_strHeaderBase + 3, x, y, text, overlayColor(ConnectionRating::SEVERITY_BAD), dropColor); + } + y += lineH; + } + + if (mode != 2) + { + Int nameWidth = 0; + for (Int slot = 0; slot < MAX_SLOTS; ++slot) + { + if (rows[slot].used) + { + nameWidth = (std::max)(nameWidth, overlayTextWidth(k_strRowBase + slot * k_strRowCount, rows[slot].name)); + } + } + + Int barRowY[MAX_SLOTS]; + Int barRowBars[MAX_SLOTS]; + Int barRowCount = 0; + for (Int slot = 0; slot < MAX_SLOTS; ++slot) + { + const OverlayRow& row = rows[slot]; + if (!row.used) + { + continue; + } + + // the indicators of all rows are drawn in one batch afterwards, text draws must not sit inside it + barRowY[barRowCount] = y; + barRowBars[barRowCount] = row.bars; + ++barRowCount; + + const Int rowStr = k_strRowBase + slot * k_strRowCount; + Int x = left + lineH + 8; + UnicodeString text; + overlayDrawText(rowStr, x, y, row.name, neutral, dropColor); + x += nameWidth + segmentGap; + text.format(L"ping %d ms", row.pingMs); + x += overlayDrawText(rowStr + 1, x, y, text, neutral, dropColor) + segmentGap; + text = row.direct ? L"direct" : L"relay"; + x += overlayDrawText(rowStr + 2, x, y, text, dim, dropColor) + segmentGap; + if (row.lossPercent >= 0.5f) + { + text.format(L"loss %.0f%%", row.lossPercent); + x += overlayDrawText(rowStr + 3, x, y, text, overlayColor(overlayPercentSeverity(row.lossPercent)), dropColor) + segmentGap; + } + if (row.latePercent >= 0.5f) + { + text.format(L"late %.0f%%", row.latePercent); + x += overlayDrawText(rowStr + 4, x, y, text, overlayColor(overlayPercentSeverity(row.latePercent)), dropColor) + segmentGap; + } + if (row.slowPc) + { + text = L"slow PC"; + x += overlayDrawText(rowStr + 5, x, y, text, overlayColor(ConnectionRating::SEVERITY_WARN), dropColor) + segmentGap; + } + else if (row.info.stallsWindow > 0) + { + text.format(L"stalls %d", row.info.stallsWindow); + x += overlayDrawText(rowStr + 5, x, y, text, overlayColor(row.info.stallsWindow >= 3 ? ConnectionRating::SEVERITY_BAD : ConnectionRating::SEVERITY_WARN), dropColor) + segmentGap; + } + y += lineH; + } + + if (barRowCount > 0) + { + ScopedDisplayBatch batch; + for (Int barRow = 0; barRow < barRowCount; ++barRow) + { + ConnectionRating::DrawIndicator(left, barRowY[barRow], lineH, barRowBars[barRow]); } + } + return; + } + // dev view: one row per peer, every column drawn at its own x as the font is proportional + Int colWidth[COL_COUNT]; + Bool colShown[COL_COUNT]; + Int colX[COL_COUNT]; + for (Int c = 0; c < COL_COUNT; ++c) + { + colShown[c] = TRUE; + colWidth[c] = overlayTextWidth(k_strDevHeadBase + c, cache.headerCell[c]); + for (Int slot = 0; slot < MAX_SLOTS; ++slot) + { + if (rows[slot].used) + { + colWidth[c] = (std::max)(colWidth[c], overlayTextWidth(k_strDevCellBase + slot * COL_COUNT + c, rows[slot].cell[c])); + } + } + } + + Int tableGap = segmentGap; + if (overlayTableWidth(colWidth, colShown, tableGap) > displayWidth - 8) + { + tableGap = (std::max)(4, lineH / 3); + } + const Int optionalColumns[3] = { COL_OUT, COL_IN, COL_BARS }; + for (Int o = 0; o < 3 && overlayTableWidth(colWidth, colShown, tableGap) > displayWidth - 8; ++o) + { + colShown[optionalColumns[o]] = FALSE; + } + + { + Int x = left; + for (Int c = 0; c < COL_COUNT; ++c) + { + colX[c] = x; + if (colShown[c]) + { + x += colWidth[c] + tableGap; + } } } + for (Int c = 0; c < COL_COUNT; ++c) + { + if (colShown[c]) + { + overlayDrawText(k_strDevHeadBase + c, colX[c], y, cache.headerCell[c], dim, dropColor); + } + } + y += lineH; + + for (Int slot = 0; slot < MAX_SLOTS; ++slot) + { + const OverlayRow& row = rows[slot]; + if (!row.used) + { + continue; + } + for (Int c = 0; c < COL_COUNT; ++c) + { + if (colShown[c]) + { + overlayDrawText(k_strDevCellBase + slot * COL_COUNT + c, colX[c], y, row.cell[c], overlayColor(row.severity[c]), dropColor); + } + } + y += lineH; + } + y += lineH / 2; + + { + UnicodeString text; + text.format(L"R %d frames (%d ms) send interval %d ms", header.runAhead, delayMs, header.frameGroupingMs); + overlayDrawText(k_strFooterBase + 0, left, y, text, neutral, dropColor); + } +#endif +} + +void InGameUI::drawGameTime() +{ + if (NGMP_OnlineServicesManager::GetNetworkOverlayMode() != 0) + { + drawNetworkOverlay(NGMP_OnlineServicesManager::GetNetworkOverlayMode()); + } +#if defined(GENERALS_ONLINE) + else + { + overlayReleaseCache(); + } +#endif + Int currentFrame = TheGameLogic->getFrame(); Int gameSeconds = (Int)(SECONDS_PER_LOGICFRAME_REAL * currentFrame); Int hours = gameSeconds / 60 / 60; diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/ConnectionRating.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/ConnectionRating.cpp new file mode 100644 index 00000000000..397c129250e --- /dev/null +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/ConnectionRating.cpp @@ -0,0 +1,275 @@ +#include "GameNetwork/GeneralsOnline/ConnectionRating.h" +#include "GameNetwork/NetworkDefs.h" +#include "GameClient/Color.h" +#include "GameClient/Display.h" + +#include +#include + +// upper limit of each band, index 0 is the limit for 5 bars, above the last is 1 bar +static const Real k_delayLimitsMs[ConnectionRating::BAR_COUNT - 1] = { 150.0f, 225.0f, 300.0f, 500.0f }; +static const Real k_stallLimitsPerMinute[ConnectionRating::BAR_COUNT - 1] = { 0.05f, 0.5f, 1.0f, 6.0f }; + +// Red (1 bar) only for a link that breaks the match, every other weakness is amber at worst: +static const Real k_redDelayMs = k_delayLimitsMs[ConnectionRating::BAR_COUNT - 2]; // input delay the run ahead would need +static const Real k_redStallsPerMinute = k_stallLimitsPerMinute[ConnectionRating::BAR_COUNT - 2]; // measured in a match, a prediction alone is never red +static const Real k_redLossPercent = 20.0f; // sustained packet loss, more than the redundancy repairs +static const Int k_redSilentMs = 1000; // no packet at all for this long, a blackout + +// Measured stalls of a link whose loss and tail predict almost none are the peer's slow frame rate +static const Real k_slowPcMinStalls = k_redStallsPerMinute; +static const Real k_slowPcMaxLinkStalls = 1.0f; +static const Int k_slowPcMaxBars = 3; + +static const Real k_hysteresis = 0.1f; // a band is left only when the value is this much past its limit + +static const UnsignedInt k_raiseHoldMs = 3000; // more bars are shown only after the rating asked for them this long + +// predicted stalls/min = 2.1 x loss% x 110 / (rtt + 60) + 0.1 x max(0, tail - max(100 ms, 0.2 x rtt)), tail = p90 round trip - smoothed round trip, up to 500 ms +static Real PredictStallsPerMinute(const ConnectionStats &stats, Real lossPercent) +{ + const Real tail = std::clamp(stats.getLatencyPercentile(90) - stats.getLatency(), 0.0f, 500.0f); + const Real headroom = std::max(100.0f, 0.2f * stats.getLatency()); + const Real excess = std::max(0.0f, tail - headroom); + const Real loss = (lossPercent >= 0.0f) ? lossPercent : stats.getRetryPercent(); + return 2.1f * loss * 110.0f / (stats.getLatency() + 60.0f) + 0.1f * excess; +} + +static Real EstimateDelayMs(Real roundTripMs, Int slackPercent, Int sendIntervalMs) +{ + return roundTripMs * (1.0f + slackPercent / 100.0f) + sendIntervalMs; +} + +ConnectionRating::Inputs::Inputs() + : delayMs(0.0f), stallsPerMinute(0.0f), measuredStallsPerMinute(-1.0f), lossPercent(-1.0f), silentMs(-1), silentLimitMs(k_redSilentMs), stallInWindow(FALSE), slowPc(FALSE) +{ +} + +ConnectionRating::Inputs ConnectionRating::InputsFromProbe(const ConnectionStats &probe, Int slackPercent, Int sendIntervalMs, Real lossPercent) +{ + Inputs inputs; + // the p75 round trip, not the smoothed one a few spikes pull up + inputs.delayMs = EstimateDelayMs(probe.getLatencyPercentile(75), slackPercent, sendIntervalMs); + inputs.stallsPerMinute = PredictStallsPerMinute(probe, lossPercent); + return inputs; +} + +ConnectionRating::Inputs ConnectionRating::InputsFromMatch(const ConnectionStats &cmd, const NetworkOverlayInfo &info, Int slackPercent, Int sendIntervalMs, Real lossPercent) +{ + Inputs inputs; + const Real roundTripMs = (info.slotInBandMs >= 0) ? (Real)info.slotInBandMs : cmd.getLatency(); + inputs.delayMs = EstimateDelayMs(roundTripMs, slackPercent, sendIntervalMs); + + Real stallsPerMinute = PredictStallsPerMinute(cmd, lossPercent); + if ((Real)info.stallsMinute >= k_slowPcMinStalls && stallsPerMinute < k_slowPcMaxLinkStalls) + { + inputs.stallsPerMinute = stallsPerMinute; + inputs.slowPc = TRUE; + return inputs; + } + if ((Real)info.stallsMinute > stallsPerMinute) + { + stallsPerMinute = (Real)info.stallsMinute; // the minute window holds one minute of stalls + } + inputs.stallsPerMinute = stallsPerMinute; + inputs.measuredStallsPerMinute = (Real)info.stallsMinute; + inputs.stallInWindow = info.stallsWindow > 0; + return inputs; +} + +static Int RateBand(Int previousBars, Real value, const Real *limits) +{ + const Int count = ConnectionRating::BAR_COUNT; + + Int raw = 1; + for (Int bars = count; bars >= 2; --bars) + { + if (value <= limits[count - bars]) + { + raw = bars; + break; + } + } + + if (previousBars < 1) + { + return raw; + } + + Int bars = previousBars; + while (bars < count && value <= limits[count - (bars + 1)] * (1.0f - k_hysteresis)) + { + ++bars; + } + while (bars > 1 && value > limits[count - bars] * (1.0f + k_hysteresis)) + { + --bars; + } + return bars; +} + +void ConnectionRating::Update(State &state, const Inputs &inputs, UnsignedInt nowMs) +{ + Real stalls = inputs.stallsPerMinute; + if (inputs.stallInWindow) + { + // a stall in the 10 s window rules out the two top bands + stalls = std::max(stalls, 0.6f); + } + else if (inputs.measuredStallsPerMinute > 0.0f) + { + // a stall in the last minute rules out the top band + stalls = std::max(stalls, 0.3f); + } + + state.delayBars = RateBand(state.delayBars, inputs.delayMs, k_delayLimitsMs); + state.stabilityBars = RateBand(state.stabilityBars, stalls, k_stallLimitsPerMinute); + Int rated = std::min(state.delayBars, state.stabilityBars); + state.redReason = RedReason(inputs); + if (state.redReason != nullptr) + { + rated = 1; + } + else if (rated < 2) + { + rated = 2; + } + state.slowPc = inputs.slowPc; + if (state.slowPc && rated > k_slowPcMaxBars) + { + rated = k_slowPcMaxBars; + } + if (state.bars < 1 || rated < state.bars) + { + state.bars = rated; + state.raiseSinceMs = 0; + } + else if (rated > state.bars) + { + // a noisy input crosses a limit back and forth, it must stay better for the whole hold + if (state.raiseSinceMs == 0) + { + state.raiseSinceMs = nowMs | 1; + } + else if (nowMs - state.raiseSinceMs >= k_raiseHoldMs) + { + state.bars = rated; + state.raiseSinceMs = 0; + } + } + else + { + state.raiseSinceMs = 0; + } + state.delayMs = static_cast(inputs.delayMs); + state.stallsPerMinute = stalls; +} + +const char *ConnectionRating::RedReason(const Inputs &inputs) +{ + if (inputs.silentMs >= inputs.silentLimitMs) + { + return "blackout"; + } + if (inputs.lossPercent >= k_redLossPercent) + { + return "loss"; + } + if (inputs.delayMs > k_redDelayMs) + { + return "delay"; + } + if (inputs.measuredStallsPerMinute >= k_redStallsPerMinute) + { + return "stalls"; + } + return nullptr; +} + +Int ConnectionRating::Severity(Int bars) +{ + if (bars >= 4) + { + return SEVERITY_OK; + } + if (bars >= 2) + { + return SEVERITY_WARN; + } + return SEVERITY_BAD; +} + +void ConnectionRating::DrawSpinner(Int left, Int top, Int size) +{ + // a faint full ring with a solid arc rotating around it, drawn as short segments + const Real twoPi = 6.28318531f; + const Int segmentCount = 24; + const Int arcSegments = 7; + const Real radius = size * 0.4f; + const Real centerX = left + size * 0.5f; + const Real centerY = top + size * 0.5f; + const Real rotation = (timeGetTime() % 900) * twoPi / 900.0f; + const Color ringColor = GameMakeColor(47, 55, 168, 70); // Zero Hour UI blue + const Color arcColor = GameMakeColor(47, 55, 168, 255); + for (Int segment = 0; segment < segmentCount; ++segment) + { + const Real startAngle = rotation + segment * twoPi / segmentCount; + const Real endAngle = startAngle + twoPi / segmentCount; + TheDisplay->drawLine((Int)(centerX + std::cos(startAngle) * radius), (Int)(centerY + std::sin(startAngle) * radius), + (Int)(centerX + std::cos(endAngle) * radius), (Int)(centerY + std::sin(endAngle) * radius), + 3.0f, segment < arcSegments ? arcColor : ringColor); + } +} + +void ConnectionRating::DrawIndicator(Int left, Int top, Int size, Int bars) +{ + if (bars < 1) + { + DrawSpinner(left, top, size); + } + else + { + DrawBars(left, top, size, bars - 1); + } +} + +ScopedDisplayBatch::ScopedDisplayBatch() + : m_ownBatch(!TheDisplay->isBatching()) +{ + if (m_ownBatch) + { + TheDisplay->beginBatch(); + } +} + +ScopedDisplayBatch::~ScopedDisplayBatch() +{ + if (m_ownBatch) + { + TheDisplay->endBatch(); + } +} + +void ConnectionRating::DrawBars(Int left, Int top, Int size, Int level) +{ + Color levelColor = GameMakeColor(220, 40, 40, 255); + const Int severity = Severity(level + 1); + if (severity == SEVERITY_OK) + { + levelColor = GameMakeColor(0, 200, 0, 255); + } + else if (severity == SEVERITY_WARN) + { + levelColor = GameMakeColor(255, 190, 0, 255); + } + const Color emptyColor = GameMakeColor(90, 90, 90, 200); + + const Int gap = 1; + const Int barWidth = std::max(1, (size - gap * (BAR_COUNT - 1)) / BAR_COUNT); + for (Int bar = 0; bar < BAR_COUNT; ++bar) + { + const Int barHeight = std::max(1, size * (bar + 1) / BAR_COUNT); + TheDisplay->drawFillRect(left + bar * (barWidth + gap), top + size - barHeight, barWidth, barHeight, + bar <= level ? levelColor : emptyColor); + } +} diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NetworkMesh.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NetworkMesh.cpp index 25048c38a0f..dbd81c738d8 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NetworkMesh.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NetworkMesh.cpp @@ -6,6 +6,7 @@ #include #include "GameNetwork/NetworkDefs.h" #include "GameNetwork/NetworkInterface.h" +#include "Common/GlobalData.h" #include "GameLogic/GameLogic.h" #include "../OnlineServices_RoomsInterface.h" #include "../json.hpp" @@ -1567,38 +1568,6 @@ void PlayerConnection::UpdateLatencyHistogram() m_vecLatencyHistory.erase(m_vecLatencyHistory.begin()); } m_vecLatencyHistory.push_back(currLatency); - - // Sample connection quality into rolling history. - // Prefer SNS local quality, then remote quality, then fall back to manual in/out packet rate ratio. - if (m_hSteamConnection != k_HSteamNetConnection_Invalid) - { - SteamNetConnectionRealTimeStatus_t status; - if (SteamNetworkingSockets()->GetConnectionRealTimeStatus(m_hSteamConnection, &status, 0, nullptr) == k_EResultOK) - { - float sample = -1.0f; - if (status.m_flConnectionQualityLocal >= 0.0f) - { - sample = status.m_flConnectionQualityLocal; - } - else if (status.m_flConnectionQualityRemote >= 0.0f) - { - sample = status.m_flConnectionQualityRemote; - } - else if (status.m_flOutPacketsPerSec > 0.0f) - { - sample = status.m_flInPacketsPerSec / status.m_flOutPacketsPerSec; - } - - if (sample > 1.0f) sample = 1.0f; - - if (sample >= 0.0f) - { - if (m_vecQualityHistory.size() >= connectionHistoryLength) - m_vecQualityHistory.erase(m_vecQualityHistory.begin()); - m_vecQualityHistory.push_back(sample); - } - } - } } void PlayerConnection::Close() @@ -1714,7 +1683,8 @@ void PlayerConnection::UpdateState(EConnectionState newState, NetworkMesh* pOwni if (newState == EConnectionState::CONNECTED_DIRECT && m_State != EConnectionState::CONNECTED_DIRECT) { m_connectedSinceTime = std::chrono::steady_clock::now(); - m_smoothedScore = -1.0f; + m_rating = ConnectionRating::State(); + m_nextRatingMs = 0; ResetProbe(); } @@ -1899,20 +1869,20 @@ int PlayerConnection::GetJitter() return sumDelta / count; } -float PlayerConnection::GetConnectionQuality() +int PlayerConnection::ComputeConnectionRating() { - if (!m_vecQualityHistory.empty()) + // the overlay and the lobby ask every frame, the percentile sorts inside do not change that fast + const int64_t ratingNowMs = NowSteadyMs(); + if (ratingNowMs < m_nextRatingMs) { - float sum = 0.0f; - for (float r : m_vecQualityHistory) - sum += r; - return sum / static_cast(m_vecQualityHistory.size()); + return m_ratingResult; } - - return -1.0f; + m_nextRatingMs = ratingNowMs + 250; + m_ratingResult = UpdateConnectionRating(); + return m_ratingResult; } -int PlayerConnection::ComputeConnectionScore() +int PlayerConnection::UpdateConnectionRating() { static constexpr std::chrono::milliseconds k_warmupDuration{ 1000 }; if (m_connectedSinceTime == (std::chrono::steady_clock::time_point::min)() || @@ -1921,65 +1891,76 @@ int PlayerConnection::ComputeConnectionScore() return -1; } - // TODO_EOS: need to impl jitter etc again - const int latency = GetLatency(); - const int jitter = GetJitter(); - const float quality = GetConnectionQuality(); // packet delivery ratio [0..1] + if (NGMP_OnlineServicesManager::GetInstance() == nullptr) + { + return -1; + } + const ServiceConfig& serviceConf = NGMP_OnlineServicesManager::GetInstance()->GetServiceConfig(); + const int slackPercent = serviceConf.input_delay.slack_percent; + int sendIntervalMs = serviceConf.send.frames_per_packet * 1000 / GENERALS_ONLINE_HIGH_FPS_LIMIT; - static constexpr float k_subScoreFloor = 0.3f; - static constexpr float k_latencyWeight = 0.30f; - static constexpr float k_jitterWeight = 0.25f; - static constexpr float k_reliabilityWeight = 0.45f; + // the command connections are indexed by game slot, find the slot of this user + int slot = -1; + const ConnectionStats* pCmd = nullptr; + if (TheNetwork != nullptr && TheNGMPGame != nullptr) + { + slot = TheNGMPGame->getSlotOfUser(m_userID); + pCmd = TheNetwork->getConnectionStats(slot); + } - float weightedLogSum = 0.0f; - float activeWeightSum = 0.0f; + ConnectionRating::Inputs inputs; + NetworkOverlayInfo info; - if (latency >= 0) + float feedbackLoss = -1.0f; + static constexpr int k_minCmdSamples = 20; + static constexpr int k_minFeedbackReports = 3; + const bool inMatch = (pCmd != nullptr && pCmd->getSampleCount() >= k_minCmdSamples); + const int feedbackWindowMs = inMatch ? NETWORK_STATS_WINDOW_MS : 60000; + PacketFeedback::Summary outLoss; + PacketFeedback::Summary inLoss; + const bool hasOut = GetOutgoingLoss(feedbackWindowMs, outLoss); + const bool hasIn = GetIncomingLoss(feedbackWindowMs, inLoss); + if (hasOut || hasIn) { - // 10ms and below are treated as full score. Above that, roughly: - // 400ms -> composite 75, 800ms -> composite 50 when other metrics are perfect. - int effectiveLatency = (std::max)(latency - 10, 0); - float latFactor = std::clamp(1.0f - static_cast(effectiveLatency) / 1590.0f, 0.0f, 1.0f); - float latencyScore = (std::max)(std::powf(latFactor, 4.545f), k_subScoreFloor); - weightedLogSum += k_latencyWeight * std::logf(latencyScore); - activeWeightSum += k_latencyWeight; + feedbackLoss = (std::max)(hasOut ? outLoss.lossPercent : 0.0f, hasIn ? inLoss.lossPercent : 0.0f); } - - if (jitter >= 0) + float sustainedLoss = -1.0f; + if (outLoss.samples >= k_minFeedbackReports || inLoss.samples >= k_minFeedbackReports) { - // 50ms -> composite 75, 100ms -> composite 50 when other metrics are perfect. - float jitFactor = std::clamp(1.0f - static_cast(jitter) / 200.0f, 0.0f, 1.0f); - float jitterScore = (std::max)(std::powf(jitFactor, 2.632f), k_subScoreFloor); - weightedLogSum += k_jitterWeight * std::logf(jitterScore); - activeWeightSum += k_jitterWeight; + sustainedLoss = (std::max)((outLoss.samples >= k_minFeedbackReports) ? outLoss.lossPercent : 0.0f, (inLoss.samples >= k_minFeedbackReports) ? inLoss.lossPercent : 0.0f); } - if (quality >= 0.0f) + if (inMatch && TheNetwork->getOverlayInfo(slot, info)) + { + if (info.frameGroupingMs > 0) + { + sendIntervalMs = info.frameGroupingMs; + } + inputs = ConnectionRating::InputsFromMatch(*pCmd, info, slackPercent, sendIntervalMs, feedbackLoss); + } + else if (m_probeStats.getSampleCount() >= 10) { - // 90% -> composite 75, 80% -> composite 50 when other metrics are perfect. - float relFactor = std::clamp(2.5f * quality - 1.5f, 0.0f, 1.0f); - float reliabilityScore = (std::max)(std::powf(relFactor, 2.5f), k_subScoreFloor); - weightedLogSum += k_reliabilityWeight * std::logf(reliabilityScore); - activeWeightSum += k_reliabilityWeight; + inputs = ConnectionRating::InputsFromProbe(m_probeStats, slackPercent, sendIntervalMs, feedbackLoss); } - - if (activeWeightSum <= 0.0f) + else { - return -1; + // a gap in the samples keeps the last rating, the indicator must not blink out + return (m_rating.bars > 0) ? m_rating.bars : -1; } - float composite = std::expf(weightedLogSum / activeWeightSum); - float rawScore = composite * 100.0f; - - static constexpr float k_scoreSmoothingAlpha = 0.15f; - if (m_smoothedScore < 0.0f) + inputs.lossPercent = sustainedLoss; + if (m_lastPacketMs > 0) { - m_smoothedScore = rawScore; + const int64_t nowSteadyMs = std::chrono::duration_cast(std::chrono::steady_clock::now().time_since_epoch()).count(); + inputs.silentMs = static_cast((std::min)(nowSteadyMs - m_lastPacketMs, static_cast(3600000))); } - else { - m_smoothedScore += k_scoreSmoothingAlpha * (rawScore - m_smoothedScore); + // silence must outlast the probe interval of the current phase, probes may be the only traffic + const ServiceConfig::Connection& pingConfig = serviceConf.connection; + const int pingIntervalMs = (TheNetwork != nullptr) ? pingConfig.probe_match_interval_ms : pingConfig.probe_lobby_interval_ms; + inputs.silentLimitMs = (std::max)(inputs.silentLimitMs, pingIntervalMs * 5 / 2); } - return static_cast(std::round(m_smoothedScore)); + ConnectionRating::Update(m_rating, inputs, timeGetTime()); + return m_rating.bars; } diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Init.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Init.cpp index 226ca328ef9..8472dce6a41 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Init.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Init.cpp @@ -41,7 +41,7 @@ std::mutex NGMP_OnlineServicesManager::m_ScreenshotMutex; std::vector NGMP_OnlineServicesManager::m_vecGuardedSSData; -bool NGMP_OnlineServicesManager::g_bAdvancedNetworkStats; +int NGMP_OnlineServicesManager::g_networkOverlayMode; NetworkMesh* NGMP_OnlineServicesManager::GetNetworkMesh() { From 8a4965313f0c2ce1e8e95777feba1a3d14e7cd4d Mon Sep 17 00:00:00 2001 From: tintinhamans <5984296+tintinhamans@users.noreply.github.com> Date: Thu, 22 Oct 2026 07:03:26 +0200 Subject: [PATCH 20/28] feat(network): Repair datagram loss with adaptive redundancy --- .../Include/GameNetwork/Connection.h | 32 ++ .../Include/GameNetwork/ConnectionManager.h | 13 + .../Include/GameNetwork/ConnectionStats.h | 10 +- .../Include/GameNetwork/FrameData.h | 2 +- .../Include/GameNetwork/FrameDataManager.h | 2 +- .../Include/GameNetwork/NetCommandRef.h | 41 +- .../GameNetwork/NetCommandWrapperList.h | 12 + .../Include/GameNetwork/NetworkSettings.h | 8 + .../Source/GameNetwork/Connection.cpp | 355 +++++++++++++++++- .../Source/GameNetwork/ConnectionManager.cpp | 92 ++++- .../Source/GameNetwork/ConnectionStats.cpp | 50 ++- .../Source/GameNetwork/FrameData.cpp | 5 +- .../Source/GameNetwork/FrameDataManager.cpp | 5 +- .../Source/GameNetwork/NetCommandRef.cpp | 3 + .../GameNetwork/NetCommandWrapperList.cpp | 34 ++ .../GeneralsOnline/OnlineServices_Init.h | 9 + .../GameEngine/Source/GameClient/InGameUI.cpp | 7 +- .../GeneralsOnline/NetworkMesh.cpp | 17 +- 18 files changed, 665 insertions(+), 32 deletions(-) diff --git a/Core/GameEngine/Include/GameNetwork/Connection.h b/Core/GameEngine/Include/GameNetwork/Connection.h index 0c77332ab1b..c1f409e3c7e 100644 --- a/Core/GameEngine/Include/GameNetwork/Connection.h +++ b/Core/GameEngine/Include/GameNetwork/Connection.h @@ -40,6 +40,9 @@ #include "GameNetwork/Transport.h" #include "GameNetwork/NetPacket.h" #include "GameNetwork/ConnectionStats.h" +#if defined(GENERALS_ONLINE) +#include "GameNetwork/NetworkSettings.h" +#endif class Connection : public MemoryPoolObject { @@ -61,6 +64,12 @@ class Connection : public MemoryPoolObject void setUser(User *user); User *getUser(); void setFrameGrouping(time_t frameGrouping); +#if defined(GENERALS_ONLINE) + void setNetworkSettings(const NetworkSettings &settings, Bool unreliableTransport) { m_networkSettings = settings; m_unreliableTransport = unreliableTransport; } +#endif + void setSlot(Int slot) { m_slot = slot; } + Int getRedundancyDepth() const { return m_redundancyDepth; } + time_t getFrameGrouping() const { return m_frameGrouping; } ///< The send interval in use, shorter than the configured one while redundancy is on. void sendNetCommandMsg(NetCommandMsg *msg, UnsignedByte relay); @@ -82,6 +91,21 @@ class Connection : public MemoryPoolObject #endif protected: +#if defined(GENERALS_ONLINE) + NetworkSettings m_networkSettings; + Bool m_unreliableTransport; +#endif + + void resetRedundancy(); + void updateRedundancy(time_t curtime, Bool &active, time_t &window, Int &depth); + Bool hasLossEvidence(); ///< A packet was lost in either direction lately, or nothing says otherwise + Bool getOutgoingLoss(float &lossPercent, Int &longestRun); ///< FALSE when the peer sent no packet feedback + void noteLossEvent(time_t now, Bool burst); + void setRedundancyDepth(Int depth, Int cap, time_t now); + Bool isQueueBuilding(time_t now); ///< Round trip at 2x its low while packets are lost: a full queue + void applyFrameGrouping(); + void addRedundantCommands(NetPacket *packet, time_t curtime, time_t window, Int depth); + Bool m_isQuitting; UnsignedInt m_quitTime; @@ -92,6 +116,14 @@ class Connection : public MemoryPoolObject time_t m_retryTime; ///< The time between sending retry packets for this connection. Time is in milliseconds. ConnectionStats m_stats; ///< Latency, jitter and loss of this connection. + Int m_redundancyDepth; ///< How many later packets each un-acked command is repeated in, 0 on a clean connection. + Int m_redundancyCap; ///< The deepest depth, from the longest burst to repair and the send interval. + time_t m_lastLossEventTime; ///< What the decay counts from. + time_t m_lastDepthChangeTime; ///< A burst needs a round trip to show whether the new depth helped. + Bool m_queueSuspected; ///< A full queue on the path, redundancy steps down instead of up. + Int m_slot; + + time_t m_configuredFrameGrouping; time_t m_frameGrouping; ///< The minimum time between packet sends. time_t m_lastTimeSent; ///< The time of the last packet send. }; diff --git a/Core/GameEngine/Include/GameNetwork/ConnectionManager.h b/Core/GameEngine/Include/GameNetwork/ConnectionManager.h index 5812389f3f0..c5bee5502f2 100644 --- a/Core/GameEngine/Include/GameNetwork/ConnectionManager.h +++ b/Core/GameEngine/Include/GameNetwork/ConnectionManager.h @@ -239,6 +239,19 @@ class ConnectionManager Bool m_didSelfSlug; #if defined(GENERALS_ONLINE) + // the last command ids seen per sender, so a redundant copy of a command that is not synchronized is dropped + enum { RECENT_ID_COUNT = 256 }; + struct RecentCommandIds + { + Bool valid; + UnsignedShort highest; + Bool seen[RECENT_ID_COUNT]; + }; + RecentCommandIds m_recentIds[MAX_SLOTS]; + + Bool isDuplicateCommand(NetCommandMsg *msg); + void resetRecentCommandIds(); + ArrivalCushionStats m_cushionStats[MAX_SLOTS]; StallStats m_stallStats; Bool m_stallActive; diff --git a/Core/GameEngine/Include/GameNetwork/ConnectionStats.h b/Core/GameEngine/Include/GameNetwork/ConnectionStats.h index 33aa75f87c9..db57961c0d0 100644 --- a/Core/GameEngine/Include/GameNetwork/ConnectionStats.h +++ b/Core/GameEngine/Include/GameNetwork/ConnectionStats.h @@ -67,17 +67,22 @@ class ConnectionStats void noteAcked(Real rttMs, time_t now); ///< A command that was acked on its first send. void noteRetry(time_t now); ///< A command that was not acked in time and had to be sent again. void noteLateAck(time_t now); ///< A retried command was acked by its earlier send after all, the retry was not a loss. + void setRedundancyDepth(Int depth, Int cap); void update(time_t now); ///< Drop the samples that have left the window. Real getLatency() const { return m_srtt; } Real getMinLatency() const; - Real getLatencyPercentile(Int percent) const; + Real getLatencyPercentile(Int percent, time_t since = 0) const; ///< Round trip that this share of the samples in the window, from since on, stays within + Int getSampleCountSince(time_t since) const; Real getJitter() const { return m_jitter; } Real getLossPercent() const; ///< Share of retries the earlier send was not acked for Real getLateAckPercent() const; ///< Share of retries whose earlier send was acked after the retry Real getRetryPercent() const; ///< Share of retries, lost and late acked together Real getLatePercent() const; ///< Share of acks that took more than twice the smoothed round trip time. Int getSampleCount() const { return m_samples.size(); } + enum { MAX_REDUNDANCY_DEPTH = 16 }; + Int getRedundancyDepth() const { return m_depth; } + Int getRedundancyCap() const { return m_depthCap; } private: enum @@ -106,6 +111,9 @@ class ConnectionStats Real m_jitter; Real m_previousRtt; Bool m_hasRtt; + + Int m_depth; + Int m_depthCap; }; // Tracks how many frames before its execution frame the frame commands of one sender arrive, diff --git a/Core/GameEngine/Include/GameNetwork/FrameData.h b/Core/GameEngine/Include/GameNetwork/FrameData.h index aa0427967e8..c9fec4e1f11 100644 --- a/Core/GameEngine/Include/GameNetwork/FrameData.h +++ b/Core/GameEngine/Include/GameNetwork/FrameData.h @@ -48,7 +48,7 @@ class FrameData { NetCommandList * getCommandList(); void setFrameCommandCount(UnsignedInt totalCommandCount); UnsignedInt getFrameCommandCount(); - void addCommand(NetCommandMsg *msg); + Bool addCommand(NetCommandMsg *msg); ///< Returns FALSE when the command was already in the frame. UnsignedInt getCommandCount(); void zeroFrame(); void destroyGameMessages(); diff --git a/Core/GameEngine/Include/GameNetwork/FrameDataManager.h b/Core/GameEngine/Include/GameNetwork/FrameDataManager.h index 1ad6055da05..97e98ef86f8 100644 --- a/Core/GameEngine/Include/GameNetwork/FrameDataManager.h +++ b/Core/GameEngine/Include/GameNetwork/FrameDataManager.h @@ -38,7 +38,7 @@ class FrameDataManager : public MemoryPoolObject void reset(); void update(); - void addNetCommandMsg(NetCommandMsg *msg); + Bool addNetCommandMsg(NetCommandMsg *msg); ///< Returns FALSE when the command was already in its frame. void setIsLocal(Bool isLocal); FrameDataReturnType allCommandsReady(UnsignedInt frame, Bool debugSpewage); NetCommandList * getFrameCommandList(UnsignedInt frame); diff --git a/Core/GameEngine/Include/GameNetwork/NetCommandRef.h b/Core/GameEngine/Include/GameNetwork/NetCommandRef.h index 86603e10f3e..2aa842461ce 100644 --- a/Core/GameEngine/Include/GameNetwork/NetCommandRef.h +++ b/Core/GameEngine/Include/GameNetwork/NetCommandRef.h @@ -62,6 +62,13 @@ class NetCommandRef : public MemoryPoolObject time_t getTimeLastSent() const; void setTimeLastSent(time_t timeLastSent); + time_t getTimeFirstSent() const; + void setTimeFirstSent(time_t timeFirstSent); + + Int getRedundantCopies() const; + time_t getTimeFirstRedundant() const; + void noteRedundantCopy(time_t now); ///< One more redundant copy went out, the first one is timestamped. + Bool isRetried() const; void setRetried(Bool retried); @@ -71,7 +78,10 @@ class NetCommandRef : public MemoryPoolObject NetCommandRef *m_prev; UnsignedByte m_relay; ///< Need this in the command reference since the relay value will be different depending on where this particular reference is being sent. time_t m_timeLastSent; - Bool m_retried; ///< Set once this command was sent again, so its last send time is not its first. + Int m_redundantCopies; + time_t m_timeFirstRedundant; + time_t m_timeFirstSent; ///< Stays put on resends, round trips are measured from it + Bool m_retried; ///< Sent again after the redundancy window: no round trip sample #ifdef DEBUG_NETCOMMANDREF UnsignedInt m_id; @@ -142,6 +152,35 @@ inline void NetCommandRef::setTimeLastSent(time_t timeLastSent) m_timeLastSent = timeLastSent; } +inline time_t NetCommandRef::getTimeFirstSent() const +{ + return m_timeFirstSent; +} + +inline void NetCommandRef::setTimeFirstSent(time_t timeFirstSent) +{ + m_timeFirstSent = timeFirstSent; +} + +inline Int NetCommandRef::getRedundantCopies() const +{ + return m_redundantCopies; +} + +inline time_t NetCommandRef::getTimeFirstRedundant() const +{ + return m_timeFirstRedundant; +} + +inline void NetCommandRef::noteRedundantCopy(time_t now) +{ + if (m_redundantCopies == 0) + { + m_timeFirstRedundant = now; + } + ++m_redundantCopies; +} + inline Bool NetCommandRef::isRetried() const { return m_retried; diff --git a/Core/GameEngine/Include/GameNetwork/NetCommandWrapperList.h b/Core/GameEngine/Include/GameNetwork/NetCommandWrapperList.h index 5c6fb8979a6..ad4abfcc772 100644 --- a/Core/GameEngine/Include/GameNetwork/NetCommandWrapperList.h +++ b/Core/GameEngine/Include/GameNetwork/NetCommandWrapperList.h @@ -38,6 +38,7 @@ class NetCommandWrapperListNode : public MemoryPoolObject Bool isComplete(); UnsignedShort getCommandID(); + UnsignedByte getPlayerID(); UnsignedInt getRawDataLength(); void copyChunkData(NetWrapperCommandMsg *msg); UnsignedByte * getRawData(); @@ -48,6 +49,7 @@ class NetCommandWrapperListNode : public MemoryPoolObject protected: UnsignedShort m_commandID; + UnsignedByte m_playerID; UnsignedByte *m_data; UnsignedInt m_totalDataLength; Bool *m_chunksPresent; @@ -72,7 +74,17 @@ class NetCommandWrapperList : public MemoryPoolObject Int getPercentComplete(UnsignedShort wrappedCommandID); protected: + enum { RECENT_COMPLETED_COUNT = 16 }; + void removeFromList(NetCommandWrapperListNode *node); + Bool wasRecentlyCompleted(UnsignedByte playerID, UnsignedShort wrappedCommandID) const; + void noteCompleted(UnsignedByte playerID, UnsignedShort wrappedCommandID); NetCommandWrapperListNode *m_list; + + // A late copy of a chunk must not start a second assembly of a command that was already delivered. + UnsignedByte m_recentCompletedPlayers[RECENT_COMPLETED_COUNT]; + UnsignedShort m_recentCompletedIDs[RECENT_COMPLETED_COUNT]; + Int m_recentCompletedCount; + Int m_recentCompletedNext; }; diff --git a/Core/GameEngine/Include/GameNetwork/NetworkSettings.h b/Core/GameEngine/Include/GameNetwork/NetworkSettings.h index af102d9109d..28f10cecbdd 100644 --- a/Core/GameEngine/Include/GameNetwork/NetworkSettings.h +++ b/Core/GameEngine/Include/GameNetwork/NetworkSettings.h @@ -9,8 +9,16 @@ struct NetworkSettings { struct Send { + std::string mode = "datagram"; // "reliable" = GNS retransmits, "datagram" = unreliable datagrams int frames_per_packet = 2; // send interval in frames + int frames_per_packet_lossy = 1; // send interval in frames while the redundancy depth is above 0, 0 = keep the configured one bool flush_every_frame = true; // flush GNS after each frame + bool redundancy = true; // repeat un-acked commands in packets that go out anyway + int redundancy_window_ms = 0; // wait before a command is repeated, 0 = max(2 x smoothed rtt, 100 ms) + int redundancy_burst_ms = 150; // longest burst of lost packets repaired, caps the depth + bool fast_resend = true; // resend after srtt + 2 x jitter (at most 400 ms) instead of 4 x jitter (at most 1 s) + + bool IsDatagram() const { return mode == "datagram"; } } send; struct InputDelay { diff --git a/Core/GameEngine/Source/GameNetwork/Connection.cpp b/Core/GameEngine/Source/GameNetwork/Connection.cpp index 2831f12c436..6df132bad1e 100644 --- a/Core/GameEngine/Source/GameNetwork/Connection.cpp +++ b/Core/GameEngine/Source/GameNetwork/Connection.cpp @@ -29,6 +29,11 @@ #include "GameNetwork/networkutil.h" #include "GameLogic/GameLogic.h" #include "../NGMP_include.h" +#if defined(GENERALS_ONLINE) +#include "../ngmp_interfaces.h" +#include "GameNetwork/GeneralsOnline/NGMPGame.h" +#include "../NetworkMesh.h" +#endif #if defined(GENERALS_ONLINE) enum { MaxQuitFlushTime = 5000 }; // wait this many milliseconds at most to retry things before quitting @@ -36,6 +41,11 @@ enum { MaxQuitFlushTime = 5000 }; // wait this many milliseconds at most to retr enum { MaxQuitFlushTime = 30000 }; // wait this many milliseconds at most to retry things before quitting #endif +#if defined(GENERALS_ONLINE) +enum { MinScaledRetryTime = 50, MinAutoRedundancyWindow = 100, MaxRedundantCandidates = 128, MinLateAckMargin = 50, MaxScaledRetryTime = 1000, MaxFastRetryTime = 400, StartDepth = 2 }; +enum { DataLossWindowMs = 3000, DataLossBurstRun = 2 }; // the peer's reports of the last 3 s, a lost run of 2 packets is a burst +#endif + /** * The constructor. */ @@ -46,8 +56,15 @@ Connection::Connection() { m_retryTime = 2000; // set retry time to 2 seconds. m_lastTimeSent = 0; m_frameGrouping = 1; + m_configuredFrameGrouping = 1; m_isQuitting = false; m_quitTime = 0; + resetRedundancy(); + m_slot = -1; +#if defined(GENERALS_ONLINE) + m_networkSettings = NetworkSettings::LocalDefaults(); + m_unreliableTransport = FALSE; +#endif } /** @@ -78,7 +95,9 @@ void Connection::init() { m_lastTimeSent = 0; m_frameGrouping = 1; + m_configuredFrameGrouping = 1; m_stats.reset(); + resetRedundancy(); m_isQuitting = FALSE; m_quitTime = 0; } @@ -146,6 +165,7 @@ void Connection::sendNetCommandMsg(NetCommandMsg *msg, UnsignedByte relay) { packet->reset(); NetCommandRef *tempref = NEW_NETCOMMANDREF(msg); + tempref->setRelay(relay); // the relay byte takes room in the packet too Bool msgFits = packet->addCommand(tempref); deleteInstance(tempref); // delete the temporary reference. @@ -240,6 +260,274 @@ void Connection::setQuitting() DEBUG_LOG(("Connection::setQuitting() at time %d", m_quitTime)); } +void Connection::resetRedundancy() { + m_redundancyDepth = 0; + m_redundancyCap = 0; + m_lastLossEventTime = 0; + m_lastDepthChangeTime = 0; + m_queueSuspected = FALSE; +} + +#if defined(GENERALS_ONLINE) +// The mesh connection to the peer in a slot, nullptr when there is none. +static PlayerConnection *GetMeshConnection(Int slot) { + if (TheNGMPGame == nullptr || slot < 0) { + return nullptr; + } + const NGMPGameSlot *gameSlot = (const NGMPGameSlot *)TheNGMPGame->getConstSlot(slot); + if (gameSlot == nullptr) { + return nullptr; + } + NetworkMesh *mesh = NGMP_OnlineServicesManager::GetNetworkMesh(); + return (mesh != nullptr) ? mesh->GetConnectionForUser(gameSlot->m_userID) : nullptr; +} +#endif + +// While the redundancy depth is above 0 the connection sends at the lossy frame grouping, otherwise at the configured one. +void Connection::applyFrameGrouping() { + time_t grouping = m_configuredFrameGrouping; +#if defined(GENERALS_ONLINE) + if (m_redundancyDepth > 0) { + const Int lossyFrames = m_networkSettings.send.frames_per_packet_lossy; + if (lossyFrames > 0) { + const time_t lossy = (time_t)(lossyFrames * 1000 / GENERALS_ONLINE_HIGH_FPS_LIMIT); + if (lossy < grouping) { + grouping = lossy; + } + } + } +#endif + m_frameGrouping = grouping; +} + +void Connection::setRedundancyDepth(Int depth, Int cap, time_t now) { +#if defined(GENERALS_ONLINE) + if (depth != m_redundancyDepth) { + m_lastDepthChangeTime = now; + } + m_redundancyDepth = depth; + m_stats.setRedundancyDepth(depth, cap); + applyFrameGrouping(); +#endif +} + +// How our packets fared on their way to the peer, from the peer's own reports; FALSE when there are none, then nothing is gated on them. +Bool Connection::getOutgoingLoss(float &lossPercent, Int &longestRun) { +#if defined(GENERALS_ONLINE) + PlayerConnection *connection = GetMeshConnection(m_slot); + PacketFeedback::Summary summary; + if (connection == nullptr || !connection->GetOutgoingLoss(DataLossWindowMs, summary)) { + return FALSE; + } + lossPercent = summary.lossPercent; + longestRun = summary.longestRun; + return TRUE; +#else + return FALSE; +#endif +} + +// A resend or a late ack on a link that loses nothing is delay, and repeating commands cannot help it. +// TRUE when there is no packet feedback to say otherwise. +Bool Connection::hasLossEvidence() { +#if defined(GENERALS_ONLINE) + PlayerConnection *connection = GetMeshConnection(m_slot); + PacketFeedback::Summary outLoss; + PacketFeedback::Summary inLoss; + if (connection == nullptr) { + return TRUE; + } + const Bool hasOut = connection->GetOutgoingLoss(NETWORK_STATS_WINDOW_MS, outLoss); + const Bool hasIn = connection->GetIncomingLoss(NETWORK_STATS_WINDOW_MS, inLoss); + if (!hasOut && !hasIn) { + return TRUE; + } + return (hasOut && (outLoss.lossPercent > 0.0f || outLoss.longestRun > 0)) || (hasIn && (inLoss.lossPercent > 0.0f || inLoss.longestRun > 0)); +#else + return TRUE; +#endif +} + +// A loss takes the depth to 2, a burst (all copies of a command lost) one deeper. +void Connection::noteLossEvent(time_t now, Bool burst) { +#if defined(GENERALS_ONLINE) + if (!m_networkSettings.send.redundancy) { + return; + } + + if (!hasLossEvidence()) { + return; + } + + // a full queue loses packets because it is full, more copies would only fill it further + if (m_queueSuspected) { + return; + } + + m_lastLossEventTime = now; + + Int target = m_redundancyDepth; + if (m_redundancyDepth < StartDepth) { + target = StartDepth; + } else if (burst) { + // retries of commands sent before the last change say nothing about the new depth, one round trip has to pass + time_t settle = (time_t)(2.0f * m_stats.getLatency()); + if (settle < MinAutoRedundancyWindow) { + settle = MinAutoRedundancyWindow; + } + if ((now - m_lastDepthChangeTime) >= settle) { + // a lost ack also leaves copies unacked: only a lost run of 2+ packets (peer report) counts as a burst + float outLoss = 0.0f; + Int outRun = 0; + if (!getOutgoingLoss(outLoss, outRun) || outRun >= DataLossBurstRun) { + target = m_redundancyDepth + 1; + } + } + } + + if (target > m_redundancyCap) { + target = m_redundancyCap; + } + if (target != m_redundancyDepth) { + setRedundancyDepth(target, m_redundancyCap, now); + } +#endif +} + +Bool Connection::isQueueBuilding(time_t now) { +#if defined(GENERALS_ONLINE) + enum { RECENT_MS = 3000, MIN_SAMPLES = 20, MIN_RISE_MS = 50 }; + const time_t since = now - RECENT_MS; + if (m_stats.getSampleCountSince(since) < MIN_SAMPLES) { + return FALSE; + } + const Real low = m_stats.getMinLatency(); + const Real recent = m_stats.getLatencyPercentile(50, since); + if (low <= 0.0f || recent < 2.0f * low || (recent - low) < (Real)MIN_RISE_MS) { + return FALSE; + } + float outLoss = 0.0f; + Int outRun = 0; + const Bool outLossing = getOutgoingLoss(outLoss, outRun) && outLoss >= 1.0f; + return outLossing || m_stats.getRetryPercent() >= 2.0f; +#else + return FALSE; +#endif +} + +void Connection::updateRedundancy(time_t curtime, Bool &active, time_t &window, Int &depth) { + active = FALSE; + window = 0; + depth = 0; + m_retryTime = 2000; + +#if defined(GENERALS_ONLINE) + const NetworkSettings::Send &sendSettings = m_networkSettings.send; + const Real srtt = m_stats.getLatency(); + const time_t interval = (m_frameGrouping > 1) ? m_frameGrouping : 1; + + if ((sendSettings.redundancy || m_unreliableTransport) && (srtt > 0.0f)) { + const Bool fastResend = sendSettings.fast_resend; + // the peer sends its acks with its next packet, up to one send interval after it got the command + time_t retry = (time_t)(srtt + (fastResend ? 2.0f : 4.0f) * m_stats.getJitter()) + interval; + if (retry < MinScaledRetryTime) { + retry = MinScaledRetryTime; + } + if (retry > (fastResend ? MaxFastRetryTime : MaxScaledRetryTime)) { + retry = fastResend ? MaxFastRetryTime : MaxScaledRetryTime; + } + m_retryTime = retry; + } + + if (!sendSettings.redundancy) { + if (m_redundancyDepth != 0) { + setRedundancyDepth(0, 0, curtime); + } + return; + } + + // the deepest depth covers the longest burst for the current send interval + Int cap = (Int)((sendSettings.redundancy_burst_ms + interval - 1) / interval); + if (cap > ConnectionStats::MAX_REDUNDANCY_DEPTH) { + cap = ConnectionStats::MAX_REDUNDANCY_DEPTH; + } + if (cap < 1) { + cap = 1; + } + + if (cap != m_redundancyCap) { + m_redundancyCap = cap; + if (m_redundancyDepth > cap) { + setRedundancyDepth(cap, cap, curtime); + } + } + + const Bool queue = isQueueBuilding(curtime); + m_queueSuspected = queue; + if (queue && m_redundancyDepth > 0) { + // one step per settle time, the next round trips show whether the queue drains + time_t settle = (time_t)(2.0f * srtt); + if (settle < MinAutoRedundancyWindow) { + settle = MinAutoRedundancyWindow; + } + if ((curtime - m_lastDepthChangeTime) >= settle) { + setRedundancyDepth(m_redundancyDepth - 1, cap, curtime); + } + } + + if ((m_redundancyDepth > 0) && ((curtime - m_lastLossEventTime) >= NETWORK_STATS_WINDOW_MS)) { + setRedundancyDepth(m_redundancyDepth - 1, cap, curtime); + m_lastLossEventTime = curtime; + } + m_stats.setRedundancyDepth(m_redundancyDepth, cap); + + depth = m_redundancyDepth; + active = (depth > 0); + + window = sendSettings.redundancy_window_ms; + if (window <= 0) { + window = (time_t)(2.0f * srtt); + if (window < MinAutoRedundancyWindow) { + window = MinAutoRedundancyWindow; + } + } +#endif +} + +// Fills the rest of the packet with young un-acked commands, so the next packet repairs a loss without a retry. +void Connection::addRedundantCommands(NetPacket *packet, time_t curtime, time_t window, Int depth) { +#if defined(GENERALS_ONLINE) + NetCommandRef *candidates[MaxRedundantCandidates]; + Int count = 0; + const time_t interval = (m_frameGrouping > 1) ? m_frameGrouping : 1; // a command sent in this packet is younger than this + + NetCommandRef *ref = m_netCommandList->getFirstMessage(); + while ((ref != nullptr) && (count < MaxRedundantCandidates)) { + const time_t timeLastSent = ref->getTimeLastSent(); + if ((timeLastSent != -1) && CommandRequiresAck(ref->getCommand()) + && ((curtime - timeLastSent) >= interval) && ((curtime - ref->getTimeFirstSent()) <= window) + && (ref->getRedundantCopies() < depth)) { + Int slot = count; + while ((slot > 0) && (candidates[slot - 1]->getTimeFirstSent() > ref->getTimeFirstSent())) { + candidates[slot] = candidates[slot - 1]; + --slot; + } + candidates[slot] = ref; + ++count; + } + ref = ref->getNext(); + } + + for (Int i = 0; i < count; ++i) { + if (!packet->addCommand(candidates[i])) { + break; + } + candidates[i]->noteRedundantCopy(curtime); + candidates[i]->setTimeLastSent(curtime); // not a retry, so the round trip is still taken from the first send + } +#endif +} + /** * This is the good part. We take all the network commands queued up for this connection, * packetize them and put them on the transport's send queue for actual sending. @@ -265,6 +553,16 @@ UnsignedInt Connection::doSend() { return 0; } + Bool redundancy = FALSE; + time_t redundancyWindow = 0; + Int redundancyDepth = 0; + updateRedundancy(curtime, redundancy, redundancyWindow, redundancyDepth); +#if defined(GENERALS_ONLINE) + const Bool fastResend = m_networkSettings.send.fast_resend; +#else + const Bool fastResend = FALSE; +#endif + // iterate through all the messages and put them into a packet(s). NetCommandRef *msg = m_netCommandList->getFirstMessage(); @@ -281,25 +579,46 @@ UnsignedInt Connection::doSend() { time_t timeLastSent = msg->getTimeLastSent(); - if (((curtime - timeLastSent) > m_retryTime) || (timeLastSent == -1)) { - notDone = packet->addCommand(msg); - if (notDone) { + // a command inside the redundancy window is not retried, it goes out again as a redundant copy + // fast resend: the first resend counts from the first send, not from the last redundant copy, and the window does not hold it back + const time_t sentAt = (fastResend && !msg->isRetried() && (timeLastSent != -1)) ? msg->getTimeFirstSent() : timeLastSent; + Bool due = (timeLastSent == -1) || ((curtime - sentAt) > m_retryTime); + if (due && redundancy && !fastResend && (timeLastSent != -1) && ((curtime - msg->getTimeFirstSent()) <= redundancyWindow)) { + due = FALSE; + } + + if (due) { + if (packet->addCommand(msg)) { // the msg command was added to the packet. if (CommandRequiresAck(msg->getCommand())) { if (timeLastSent != -1) { m_stats.noteRetry(curtime); + if (!msg->isRetried() && !fastResend) { + noteLossEvent(curtime, TRUE); // the command and all its copies were lost, or their acks were + } msg->setRetried(TRUE); + } else { + msg->setTimeFirstSent(curtime); } msg->setTimeLastSent(curtime); } else { m_netCommandList->removeMessage(msg); deleteInstance(msg); } + } else { + notDone = FALSE; + if (packet->getNumCommands() > 0) { + next = msg; // the packet is full, this one starts the next packet + } } } msg = next; } + if (redundancy && notDone && (packet->getNumCommands() > 0)) { + addRedundantCommands(packet, curtime, redundancyWindow, redundancyDepth); + } + if (msg != nullptr) { DEBUG_LOG(("didn't finish sending all commands in connection")); } @@ -367,14 +686,37 @@ NetCommandRef * Connection::processAck(UnsignedShort commandID, UnsignedByte ori #endif // A retried command was last sent after its first send, so its round trip time would be too short. + // Redundant copies are not retries: any copy's ack counts as an ack of the first send. const time_t now = timeGetTime(); if (temp->isRetried()) { - // an ack sooner after the retry than any round trip so far answers the earlier send: the retry went out for a late ack, not a loss + // an ack faster than any round trip so far answers the earlier send: the retry was for a late ack +#if defined(GENERALS_ONLINE) + const Bool fastResend = m_networkSettings.send.fast_resend; +#else + const Bool fastResend = FALSE; +#endif if ((m_stats.getSampleCount() > 0) && ((now - temp->getTimeLastSent()) < (time_t)(0.9f * m_stats.getMinLatency()))) { m_stats.noteLateAck(now); + if (fastResend) { + // unambiguous: it answers the first send, the slow acks that cause fast resends must not be missing from srtt + m_stats.noteAcked((Real)(now - temp->getTimeFirstSent()), now); + } + } else if (fastResend) { + // Karn: no sample from an ambiguous ack; the burst event moves here from the resend, a slow ack is no loss + noteLossEvent(now, TRUE); } } else { - m_stats.noteAcked((Real)(now - temp->getTimeLastSent()), now); + const Real rtt = (Real)(now - temp->getTimeFirstSent()); + const Real srtt = m_stats.getLatency(); + const Real lateMargin = (srtt > MinLateAckMargin) ? srtt : (Real)MinLateAckMargin; + if ((m_stats.getSampleCount() > 0) && ((rtt - srtt) > lateMargin)) { + noteLossEvent(now, FALSE); // an ack later than the smoothed round trip plus its own length, at least 50 ms + } else if ((temp->getTimeFirstRedundant() != -1) && (m_stats.getSampleCount() > 0) + && ((now - temp->getTimeFirstRedundant()) >= (time_t)m_stats.getMinLatency() - m_frameGrouping / 2)) { + // the ack came too late to answer the first copy, a repeat repaired a loss + noteLossEvent(now, FALSE); + } + m_stats.noteAcked((Real)(now - temp->getTimeFirstSent()), now); } #if defined(RTS_DEBUG) @@ -387,7 +729,8 @@ NetCommandRef * Connection::processAck(UnsignedShort commandID, UnsignedByte ori } void Connection::setFrameGrouping(time_t frameGrouping) { - m_frameGrouping = frameGrouping; + m_configuredFrameGrouping = frameGrouping; + applyFrameGrouping(); // m_retryTime = frameGrouping * 4; } diff --git a/Core/GameEngine/Source/GameNetwork/ConnectionManager.cpp b/Core/GameEngine/Source/GameNetwork/ConnectionManager.cpp index abe73435b7a..9d459b074a4 100644 --- a/Core/GameEngine/Source/GameNetwork/ConnectionManager.cpp +++ b/Core/GameEngine/Source/GameNetwork/ConnectionManager.cpp @@ -360,6 +360,9 @@ void ConnectionManager::init() m_netCommandWrapperList = newInstance(NetCommandWrapperList); m_netCommandWrapperList->init(); +#if defined(GENERALS_ONLINE) + resetRecentCommandIds(); +#endif s_fileCommandMap.clear(); s_fileRecipientMaskMap.clear(); @@ -410,6 +413,9 @@ void ConnectionManager::reset() m_netCommandWrapperList->init(); } m_netCommandWrapperList->reset(); +#if defined(GENERALS_ONLINE) + resetRecentCommandIds(); +#endif m_localSlot = -1; #ifdef MEMORYPOOL_DEBUG @@ -484,6 +490,60 @@ void ConnectionManager::destroyGameMessages() { } } +#if defined(GENERALS_ONLINE) +// TRUE if (sender, id) was seen in the last 256 ids, remembers it otherwise. Synchronized commands are deduplicated by the frame data. +Bool ConnectionManager::isDuplicateCommand(NetCommandMsg *msg) { + if (IsCommandSynchronized(msg->getNetCommandType())) { + return FALSE; + } + + const UnsignedInt playerID = msg->getPlayerID(); + if (playerID >= MAX_SLOTS) { + return FALSE; + } + + RecentCommandIds &recent = m_recentIds[playerID]; + const UnsignedShort id = msg->getID(); + + if (!recent.valid) { + recent.valid = TRUE; + recent.highest = id; + for (Int i = 0; i < RECENT_ID_COUNT; ++i) { + recent.seen[i] = FALSE; + } + recent.seen[id % RECENT_ID_COUNT] = TRUE; + return FALSE; + } + + const Int diff = (Int)(Short)(id - recent.highest); + if (diff > 0) { + // the window moves forward, the slots it moves over belong to ids not seen yet + for (Int step = 1; step <= diff && step <= RECENT_ID_COUNT; ++step) { + recent.seen[(recent.highest + step) % RECENT_ID_COUNT] = FALSE; + } + recent.highest = id; + recent.seen[id % RECENT_ID_COUNT] = TRUE; + return FALSE; + } + + if (-diff >= RECENT_ID_COUNT) { + return FALSE; + } + + if (recent.seen[id % RECENT_ID_COUNT]) { + return TRUE; + } + recent.seen[id % RECENT_ID_COUNT] = TRUE; + return FALSE; +} + +void ConnectionManager::resetRecentCommandIds() { + for (Int i = 0; i < MAX_SLOTS; ++i) { + m_recentIds[i].valid = FALSE; + } +} +#endif + /** * ConnectionManager::doRelay() * Queries the transport for commands that need to be relayed to another client. @@ -513,6 +573,11 @@ void ConnectionManager::doRelay() { if (CommandRequiresAck(cmd->getCommand())) { ackCommand(cmd, m_localSlot); +#if defined(GENERALS_ONLINE) + if (isDuplicateCommand(cmd->getCommand())) { + continue; // a redundant copy, acked again in case the first ack was lost + } +#endif } if (!processNetCommand(cmd)) { sendRemoteCommand(cmd); @@ -537,6 +602,11 @@ void ConnectionManager::doRelay() { for (NetCommandRef* cmd = cmdList->getFirstMessage(); cmd; cmd = cmd->getNext()) { if (CommandRequiresAck(cmd->getCommand())) { ackCommand(cmd, m_localSlot); +#if defined(GENERALS_ONLINE) + if (isDuplicateCommand(cmd->getCommand())) { + continue; + } +#endif } if (!processNetCommand(cmd)) { sendRemoteCommand(cmd); @@ -1008,9 +1078,14 @@ void ConnectionManager::processFrameInfo(NetFrameCommandMsg *msg) { if (playerID < MAX_SLOTS) { if (m_frameData[playerID] != nullptr) { // DEBUG_LOG(("ConnectionManager::processFrameInfo - player %d, frame %d, command count %d, received on frame %d", playerID, msg->getExecutionFrame(), msg->getCommandCount(), TheGameLogic->getFrame())); +#if defined(GENERALS_ONLINE) + // a redundant copy of a frame that was already seen says nothing about how early frames arrive + NetCommandList *frameCommands = m_frameData[playerID]->getFrameCommandList(msg->getExecutionFrame()); + const Bool isCopy = (frameCommands != nullptr) && (frameCommands->findMessage(msg) != nullptr); +#endif m_frameData[playerID]->setFrameCommandCount(msg->getExecutionFrame(), msg->getCommandCount()); #if defined(GENERALS_ONLINE) - if ((playerID != (UnsignedInt)m_localSlot) && (TheGameLogic != nullptr)) { + if (!isCopy && (playerID != (UnsignedInt)m_localSlot) && (TheGameLogic != nullptr)) { m_cushionStats[playerID].note((Int)msg->getExecutionFrame() - (Int)TheGameLogic->getFrame(), timeGetTime()); } #endif @@ -1311,13 +1386,20 @@ void ConnectionManager::sendRemoteCommand(NetCommandRef *msg) { const UnsignedByte relay = msg->getRelay(); const UnsignedInt playerID = msg->getCommand()->getPlayerID(); FrameDataManager *frameDataMgr = playerID < MAX_SLOTS ? m_frameData[playerID] : nullptr; + Bool isNew = TRUE; if ((relay & (1 << m_localSlot)) && (frameDataMgr != nullptr)) { if (IsCommandSynchronized(msg->getCommand()->getNetCommandType())) { DEBUG_LOG_LEVEL(DEBUG_LEVEL_NET, ("ConnectionManager::sendRemoteCommand - adding net command of type %s to player %d for frame %d", GetNetCommandTypeAsString(msg->getCommand()->getNetCommandType()), msg->getCommand()->getPlayerID(), msg->getCommand()->getExecutionFrame())); - frameDataMgr->addNetCommandMsg(msg->getCommand()); + isNew = frameDataMgr->addNetCommandMsg(msg->getCommand()); } } +#if defined(GENERALS_ONLINE) + if (!isNew) { + return; // a redundant copy, it was already relayed and counted + } +#endif + for (Int i = 0; i < MAX_SLOTS; ++i) { if ((relay & (1 << i)) && ((m_connections[i] != nullptr) && (m_connections[i]->isQuitting() == FALSE))) { DEBUG_LOG_LEVEL(DEBUG_LEVEL_NET, ("ConnectionManager::sendRemoteCommand - relaying command %d to player %d", msg->getCommand()->getID(), i)); @@ -1335,7 +1417,7 @@ void ConnectionManager::sendRemoteCommand(NetCommandRef *msg) { } // Do some metrics to find the minimum packet arrival cushion. - if (IsCommandSynchronized(msg->getCommand()->getNetCommandType())) { + if (isNew && IsCommandSynchronized(msg->getCommand()->getNetCommandType())) { // DEBUG_LOG(("ConnectionManager::sendRemoteCommand - about to call allCommandsReady")); if (allCommandsReady(msg->getCommand()->getExecutionFrame(), TRUE)) { UnsignedInt cushion = msg->getCommand()->getExecutionFrame() - TheGameLogic->getFrame(); @@ -2323,6 +2405,10 @@ void ConnectionManager::parseUserList(const GameInfo *game) { m_connections[i] = newInstance(Connection)(); m_connections[i]->init(); +#if defined(GENERALS_ONLINE) + m_connections[i]->setNetworkSettings(m_networkSettings, TheLAN != nullptr || m_networkSettings.send.IsDatagram()); +#endif + m_connections[i]->setSlot(i); m_connections[i]->attachTransport(m_transport); // UnsignedShort port = (TheNAT)?TheNAT->getSlotPort(i):8088; UnsignedShort port = slot->getPort(); diff --git a/Core/GameEngine/Source/GameNetwork/ConnectionStats.cpp b/Core/GameEngine/Source/GameNetwork/ConnectionStats.cpp index 4488d43dc9a..129659295a2 100644 --- a/Core/GameEngine/Source/GameNetwork/ConnectionStats.cpp +++ b/Core/GameEngine/Source/GameNetwork/ConnectionStats.cpp @@ -37,6 +37,8 @@ void ConnectionStats::reset() m_jitter = 0.0f; m_previousRtt = 0.0f; m_hasRtt = FALSE; + m_depth = 0; + m_depthCap = 0; } void ConnectionStats::noteAcked(Real rttMs, time_t now) @@ -86,6 +88,21 @@ void ConnectionStats::noteLateAck(time_t now) m_lateAcks.push(stamp); } +void ConnectionStats::setRedundancyDepth(Int depth, Int cap) +{ + if (depth < 0) + { + depth = 0; + } + if (depth > MAX_REDUNDANCY_DEPTH) + { + depth = MAX_REDUNDANCY_DEPTH; + } + + m_depth = depth; + m_depthCap = cap; +} + void ConnectionStats::update(time_t now) { m_samples.prune(now, NETWORK_STATS_WINDOW_MS); @@ -111,18 +128,21 @@ Real ConnectionStats::getMinLatency() const return result; } -Real ConnectionStats::getLatencyPercentile(Int percent) const +Real ConnectionStats::getLatencyPercentile(Int percent, time_t since) const { - const Int count = m_samples.size(); - if (count == 0) + Real sorted[MAX_SAMPLES]; + Int count = 0; + for (Int i = 0; i < m_samples.size(); ++i) { - return 0.0f; + if (m_samples[i].time >= since) + { + sorted[count] = m_samples[i].rtt; + ++count; + } } - - Real sorted[MAX_SAMPLES]; - for (Int i = 0; i < count; ++i) + if (count == 0) { - sorted[i] = m_samples[i].rtt; + return 0.0f; } std::sort(sorted, sorted + count); @@ -138,6 +158,20 @@ Real ConnectionStats::getLatencyPercentile(Int percent) const return sorted[index]; } +Int ConnectionStats::getSampleCountSince(time_t since) const +{ + Int count = 0; + for (Int i = m_samples.size() - 1; i >= 0; --i) + { + if (m_samples[i].time < since) + { + break; + } + ++count; + } + return count; +} + Real ConnectionStats::getRetryPercent() const { const Int total = m_samples.size() + m_retries.size(); diff --git a/Core/GameEngine/Source/GameNetwork/FrameData.cpp b/Core/GameEngine/Source/GameNetwork/FrameData.cpp index 88b980f33f5..3c102507bf1 100644 --- a/Core/GameEngine/Source/GameNetwork/FrameData.cpp +++ b/Core/GameEngine/Source/GameNetwork/FrameData.cpp @@ -155,7 +155,7 @@ UnsignedInt FrameData::getCommandCount() { /** * Add a command to this frame */ -void FrameData::addCommand(NetCommandMsg *msg) { +Bool FrameData::addCommand(NetCommandMsg *msg) { // need to add the message in order of command ID if (m_commandList == nullptr) { init(); @@ -164,12 +164,13 @@ void FrameData::addCommand(NetCommandMsg *msg) { // We don't need to worry about setting the relay since its not getting sent anywhere. if (m_commandList->findMessage(msg) != nullptr) { // We don't want to add the same command twice. - return; + return FALSE; } m_commandList->addMessage(msg); ++m_commandCount; //DEBUG_LOG(("added command %d, type = %d(%s), command count = %d, frame command count = %d", msg->getID(), msg->getNetCommandType(), GetNetCommandTypeAsString(msg->getNetCommandType()), m_commandCount, m_frameCommandCount)); + return TRUE; } /** diff --git a/Core/GameEngine/Source/GameNetwork/FrameDataManager.cpp b/Core/GameEngine/Source/GameNetwork/FrameDataManager.cpp index 1dfc3349306..871275ae4e2 100644 --- a/Core/GameEngine/Source/GameNetwork/FrameDataManager.cpp +++ b/Core/GameEngine/Source/GameNetwork/FrameDataManager.cpp @@ -83,16 +83,17 @@ void FrameDataManager::update() { /** * Add a network command to the appropriate frame. */ -void FrameDataManager::addNetCommandMsg(NetCommandMsg *msg) { +Bool FrameDataManager::addNetCommandMsg(NetCommandMsg *msg) { UnsignedInt frame = msg->getExecutionFrame(); UnsignedInt frameindex = frame % FRAME_DATA_LENGTH; DEBUG_LOG_LEVEL(DEBUG_LEVEL_NET, ("FrameDataManager::addNetCommandMsg - about to add a command of type %s for frame %d, frame index %d", GetNetCommandTypeAsString(msg->getNetCommandType()), frame, frameindex)); - m_frameData[frameindex].addCommand(msg); + const Bool added = m_frameData[frameindex].addCommand(msg); if (m_isLocal) { // If this is the local connection, adjust the frame command count. m_frameData[frameindex].setFrameCommandCount(m_frameData[frameindex].getCommandCount()); } + return added; } /** diff --git a/Core/GameEngine/Source/GameNetwork/NetCommandRef.cpp b/Core/GameEngine/Source/GameNetwork/NetCommandRef.cpp index 3c470673176..6bb92c16b1f 100644 --- a/Core/GameEngine/Source/GameNetwork/NetCommandRef.cpp +++ b/Core/GameEngine/Source/GameNetwork/NetCommandRef.cpp @@ -49,6 +49,9 @@ NetCommandRef::NetCommandRef(NetCommandMsg *msg) m_prev = nullptr; m_relay = 0; m_timeLastSent = -1; + m_timeFirstSent = -1; + m_redundantCopies = 0; + m_timeFirstRedundant = -1; m_retried = FALSE; #ifdef DEBUG_NETCOMMANDREF diff --git a/Core/GameEngine/Source/GameNetwork/NetCommandWrapperList.cpp b/Core/GameEngine/Source/GameNetwork/NetCommandWrapperList.cpp index 1e50c5f53e5..79aa405544d 100644 --- a/Core/GameEngine/Source/GameNetwork/NetCommandWrapperList.cpp +++ b/Core/GameEngine/Source/GameNetwork/NetCommandWrapperList.cpp @@ -49,6 +49,7 @@ NetCommandWrapperListNode::NetCommandWrapperListNode(NetWrapperCommandMsg *msg) m_data = NEW UnsignedByte[m_totalDataLength]; // pool[]ify m_commandID = msg->getWrappedCommandID(); + m_playerID = msg->getPlayerID(); } NetCommandWrapperListNode::~NetCommandWrapperListNode() { @@ -74,6 +75,10 @@ UnsignedShort NetCommandWrapperListNode::getCommandID() { return m_commandID; } +UnsignedByte NetCommandWrapperListNode::getPlayerID() { + return m_playerID; +} + UnsignedInt NetCommandWrapperListNode::getRawDataLength() { return m_totalDataLength; } @@ -133,6 +138,8 @@ UnsignedByte * NetCommandWrapperListNode::getRawData() { NetCommandWrapperList::NetCommandWrapperList() { m_list = nullptr; + m_recentCompletedCount = 0; + m_recentCompletedNext = 0; } NetCommandWrapperList::~NetCommandWrapperList() { @@ -146,6 +153,8 @@ NetCommandWrapperList::~NetCommandWrapperList() { void NetCommandWrapperList::init() { m_list = nullptr; + m_recentCompletedCount = 0; + m_recentCompletedNext = 0; } void NetCommandWrapperList::reset() { @@ -155,6 +164,26 @@ void NetCommandWrapperList::reset() { deleteInstance(m_list); m_list = temp; } + m_recentCompletedCount = 0; + m_recentCompletedNext = 0; +} + +Bool NetCommandWrapperList::wasRecentlyCompleted(UnsignedByte playerID, UnsignedShort wrappedCommandID) const { + for (Int i = 0; i < m_recentCompletedCount; ++i) { + if ((m_recentCompletedPlayers[i] == playerID) && (m_recentCompletedIDs[i] == wrappedCommandID)) { + return TRUE; + } + } + return FALSE; +} + +void NetCommandWrapperList::noteCompleted(UnsignedByte playerID, UnsignedShort wrappedCommandID) { + m_recentCompletedPlayers[m_recentCompletedNext] = playerID; + m_recentCompletedIDs[m_recentCompletedNext] = wrappedCommandID; + m_recentCompletedNext = (m_recentCompletedNext + 1) % RECENT_COMPLETED_COUNT; + if (m_recentCompletedCount < RECENT_COMPLETED_COUNT) { + ++m_recentCompletedCount; + } } Int NetCommandWrapperList::getPercentComplete(UnsignedShort wrappedCommandID) @@ -175,6 +204,10 @@ void NetCommandWrapperList::processWrapper(NetCommandRef *ref) { NetCommandWrapperListNode *temp = m_list; NetWrapperCommandMsg *msg = (NetWrapperCommandMsg *)(ref->getCommand()); + if (wasRecentlyCompleted(msg->getPlayerID(), msg->getWrappedCommandID())) { + return; // a duplicate or late chunk of a command that was already delivered + } + while ((temp != nullptr) && (temp->getCommandID() != msg->getWrappedCommandID())) { temp = temp->m_next; } @@ -206,6 +239,7 @@ NetCommandList * NetCommandWrapperList::getReadyCommands() deleteInstance(msg); msg = nullptr; + noteCompleted(temp->getPlayerID(), temp->getCommandID()); removeFromList(temp); temp = nullptr; } diff --git a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h index 84cecfb5d1e..67c47f4c537 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h @@ -443,8 +443,14 @@ inline void ReadServiceConfig(const nlohmann::json& j, ServiceConfig& c) if (j.contains("send")) { const nlohmann::json& s = j["send"]; + ReadServiceConfigValue(s, "mode", c.send.mode); ReadServiceConfigValue(s, "frames_per_packet", c.send.frames_per_packet); + ReadServiceConfigValue(s, "frames_per_packet_lossy", c.send.frames_per_packet_lossy); ReadServiceConfigValue(s, "flush_every_frame", c.send.flush_every_frame); + ReadServiceConfigValue(s, "redundancy", c.send.redundancy); + ReadServiceConfigValue(s, "redundancy_window_ms", c.send.redundancy_window_ms); + ReadServiceConfigValue(s, "redundancy_burst_ms", c.send.redundancy_burst_ms); + ReadServiceConfigValue(s, "fast_resend", c.send.fast_resend); } if (j.contains("input_delay")) { @@ -479,6 +485,9 @@ inline void ClampServiceConfig(ServiceConfig& c) c.connection.latency_history_ms = (std::max)(c.connection.latency_history_ms, 1000); c.connection.probe_lobby_interval_ms = std::clamp(c.connection.probe_lobby_interval_ms, 50, 5000); c.connection.probe_match_interval_ms = std::clamp(c.connection.probe_match_interval_ms, 50, 10000); + c.send.redundancy_window_ms = std::clamp(c.send.redundancy_window_ms, 0, 5000); + c.send.redundancy_burst_ms = std::clamp(c.send.redundancy_burst_ms, 0, 1000); + c.send.frames_per_packet_lossy = std::clamp(c.send.frames_per_packet_lossy, 0, 8); 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); diff --git a/GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp b/GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp index 68954303707..ea6a22d989b 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp @@ -7185,7 +7185,7 @@ namespace enum OverlayColumn { COL_SLOT, COL_NAME, COL_BARS, COL_PATH, COL_PROBE, COL_CMD, COL_JIT, COL_LOST, COL_LATE, - COL_CUSHION, COL_SAMPLES, COL_STALLS, COL_OUT, COL_IN, + COL_CUSHION, COL_SAMPLES, COL_STALLS, COL_DEPTH, COL_OUT, COL_IN, COL_COUNT }; @@ -7198,7 +7198,7 @@ namespace const wchar_t *const s_overlayHeaders[COL_COUNT] = { L"Slot", L"Player", L"Bars", L"Path", L"Probe", L"Cmd", L"Jit", L"Lost%", L"Late%", - L"Cush5/50", L"n", L"Stalls", L"OutL%", L"InL%" + L"Cush5/50", L"n", L"Stalls", L"Depth", L"OutL%", L"InL%" }; @@ -7498,6 +7498,8 @@ void InGameUI::drawNetworkOverlay(Int mode) sev[COL_LOST] = overlayPercentSeverity(pCmdStats->getLossPercent()); cell[COL_LATE].format(L"%.0f", pCmdStats->getLatePercent()); sev[COL_LATE] = overlayPercentSeverity(pCmdStats->getLatePercent()); + cell[COL_DEPTH].format(L"%d/%d", pCmdStats->getRedundancyDepth(), pCmdStats->getRedundancyCap()); + sev[COL_DEPTH] = (pCmdStats->getRedundancyCap() > 0 && pCmdStats->getRedundancyDepth() >= pCmdStats->getRedundancyCap()) ? ConnectionRating::SEVERITY_WARN : ConnectionRating::SEVERITY_OK; } else { @@ -7505,6 +7507,7 @@ void InGameUI::drawNetworkOverlay(Int mode) cell[COL_JIT] = L"-"; cell[COL_LOST] = L"-"; cell[COL_LATE] = L"-"; + cell[COL_DEPTH] = L"-"; } if (row.info.cushionSamples > 0) diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NetworkMesh.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NetworkMesh.cpp index dbd81c738d8..b3c9dda06dd 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NetworkMesh.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NetworkMesh.cpp @@ -1475,13 +1475,13 @@ int PlayerConnection::SendGamePacket(void* pBuffer, uint32_t totalDataSize) if (totalDataSize == 0) { NetworkLog(ELogVerbosity::LOG_RELEASE, "[GAME PACKET] Cannot send empty game packet to user %lld", m_userID); - return (int)k_EResultFail; + return -(int)k_EResultFail; } if (pBuffer == nullptr) { NetworkLog(ELogVerbosity::LOG_RELEASE, "[GAME PACKET] Cannot send game packet with null buffer to user %lld", m_userID); - return (int)k_EResultFail; + return -(int)k_EResultFail; } if (AnticheatPlugInterface::DoesACPluginProvideSecureGameTransport()) @@ -1494,21 +1494,28 @@ int PlayerConnection::SendGamePacket(void* pBuffer, uint32_t totalDataSize) if (m_hSteamConnection == k_HSteamNetConnection_Invalid) { NetworkLog(ELogVerbosity::LOG_RELEASE, "[GAME PACKET] Cannot send game packet - connection is invalid for user %lld", m_userID); - return (int)k_EResultFail; + return -(int)k_EResultFail; } + ServiceConfig& serviceConf = NGMP_OnlineServicesManager::GetInstance()->GetServiceConfig(); + + // datagram mode leaves loss repair to the game layer; the restart flag only applies to ISteamNetworkingMessages int sendFlags = k_nSteamNetworkingSend_Reliable | k_nSteamNetworkingSend_AutoRestartBrokenSession; // default from last patch + if (serviceConf.send.IsDatagram()) + { + sendFlags = k_nSteamNetworkingSend_UnreliableNoNagle; + } NetworkLog(ELogVerbosity::LOG_DEBUG, "[GAME PACKET] Sending msg of size %ld to user %lld\n", totalDataSize, m_userID); const int r = SendEnveloped(ENetworkChannel::NETWORK_CHANNEL_GAME, static_cast(pBuffer), totalDataSize, sendFlags); if (r != k_EResultOK) { NetworkLog(ELogVerbosity::LOG_RELEASE, "[GAME PACKET] Failed to send, err code was %d", r); - return r; + return -r; } } - return (int)k_EResultFail; + return 0; } From 1e2e88e906ebe047f2706de7ca0e0c432683641b Mon Sep 17 00:00:00 2001 From: tintinhamans <5984296+tintinhamans@users.noreply.github.com> Date: Thu, 22 Oct 2026 07:03:26 +0200 Subject: [PATCH 21/28] feat(network): Validate peer identity and authenticate match startup and forwarded traffic --- .../Include/GameNetwork/ConnectionManager.h | 77 ++ .../Include/GameNetwork/NetCommandMsg.h | 56 ++ .../Include/GameNetwork/NetPacketStructs.h | 88 +++ .../Include/GameNetwork/NetworkDefs.h | 7 + .../Include/GameNetwork/NetworkInterface.h | 5 + .../GameLogic/System/GameLogicDispatch.cpp | 15 + .../Source/GameNetwork/Connection.cpp | 10 + .../Source/GameNetwork/ConnectionManager.cpp | 663 +++++++++++++++++- .../Source/GameNetwork/NetCommandMsg.cpp | 95 +++ .../GameNetwork/NetCommandWrapperList.cpp | 14 + .../Source/GameNetwork/NetPacketStructs.cpp | 130 ++++ .../GameEngine/Source/GameNetwork/Network.cpp | 105 +++ .../Source/GameNetwork/NetworkUtil.cpp | 4 + .../Source/Common/System/MemoryInit.cpp | 2 + GeneralsMD/Code/GameEngine/CMakeLists.txt | 2 + .../GameEngine/Include/GameLogic/GameLogic.h | 3 + .../GameNetwork/GeneralsOnline/MatchKeys.h | 77 ++ .../GameNetwork/GeneralsOnline/NGMPGame.h | 8 +- .../GameNetwork/GeneralsOnline/NGMP_include.h | 3 +- .../GameNetwork/GeneralsOnline/NetBytes.h | 18 + .../GameNetwork/GeneralsOnline/NetworkMesh.h | 31 + .../GeneralsOnline/OnlineServices_Init.h | 21 +- .../GameEngine/Source/Common/GameEngine.cpp | 8 + .../Source/Common/System/MemoryInit.cpp | 2 + .../GUICallbacks/Menus/WOLGameSetupMenu.cpp | 43 +- .../GameNetwork/GeneralsOnline/MatchKeys.cpp | 333 +++++++++ .../GameNetwork/GeneralsOnline/NGMPGame.cpp | 15 +- .../GeneralsOnline/NetworkMesh.cpp | 342 ++++++++- .../GeneralsOnline/NextGenTransport.cpp | 10 + .../OnlineServices_LobbyInterface.cpp | 4 + .../OnlineServices_RoomsInterface.cpp | 3 +- 31 files changed, 2166 insertions(+), 28 deletions(-) create mode 100644 GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/MatchKeys.h create mode 100644 GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/MatchKeys.cpp diff --git a/Core/GameEngine/Include/GameNetwork/ConnectionManager.h b/Core/GameEngine/Include/GameNetwork/ConnectionManager.h index c5bee5502f2..7ec23934856 100644 --- a/Core/GameEngine/Include/GameNetwork/ConnectionManager.h +++ b/Core/GameEngine/Include/GameNetwork/ConnectionManager.h @@ -224,6 +224,9 @@ class ConnectionManager NetCommandList *m_pendingCommands; NetCommandList *m_relayedCommands; +#if !RETAIL_COMPATIBLE_NETWORKING + Int m_currentSenderSlot; ///< The slot of the peer the packet being read came from, -1 when not known. +#endif FrameMetrics m_frameMetrics; @@ -250,8 +253,82 @@ class ConnectionManager RecentCommandIds m_recentIds[MAX_SLOTS]; Bool isDuplicateCommand(NetCommandMsg *msg); + + Int slotForUserID(Int64 userID) const; + UnsignedInt m_formerRouterMask; ///< Slots that were the packet router in this match, their last packets can still be on the way. + Bool isCommandFromSender(NetCommandMsg *msg, Int senderSlot); ///< A command must come from the player it names, unless the packet router relays it + Bool noteSecurityDrop(); + Bool m_loggedSenderUnavailable; + Int m_securityDrops; void resetRecentCommandIds(); + // Frames with commands and chat that a peer passes on for another player need that player's signature. + // Every peer keeps the signatures it gets as long as the frames, so whoever holds a frame holds its proof. + enum { SIGNATURE_BYTES = 64, SIGNATURE_WAIT_MS = 3000, HELD_CHAT_COUNT = 8 }; + struct FrameSignature + { + Bool used; + UnsignedInt frame; + Bool relayed; ///< a command or the frame info arrived from a peer other than the author + Bool haveSignature; + Bool verified; + Int relayedBy; + time_t waitingSince; ///< when the frame was complete without a signature, 0 when not waiting + UnsignedByte signature[SIGNATURE_BYTES]; + }; + struct HeldChat + { + NetChatCommandMsg *msg; + Int relayedBy; + time_t since; + }; + struct ChatSignature + { + Bool used; + UnsignedInt author; + UnsignedShort commandID; + time_t since; + UnsignedByte signature[SIGNATURE_BYTES]; + }; + enum { RELAYED_WRAPPER_COUNT = 8 }; + struct RelayedWrapper + { + Bool used; + UnsignedShort wrappedCommandID; + Int relayedBy; + }; + std::vector m_frameSignatures[MAX_SLOTS]; ///< Indexed like the frame data, by frame modulo FRAME_DATA_LENGTH. + HeldChat m_heldChats[HELD_CHAT_COUNT]; + ChatSignature m_chatSignatures[HELD_CHAT_COUNT]; + RelayedWrapper m_relayedWrappers[MAX_SLOTS][RELAYED_WRAPPER_COUNT]; + Int m_nextRelayedWrapper[MAX_SLOTS]; + Int64 m_signerUserID[MAX_SLOTS]; ///< The user id per slot, kept after the player leaves to verify its last frames. + UnsignedInt m_lastFrameInfoSent; ///< The last frame our frame info went out for, our data up to it is final. + void clearSignatureState(Bool releaseChats); + void requestPassedOnFrameAgain(Int slot, UnsignedInt frame, Int passedOnBy); + FrameSignature *findFrameSignature(Int slot, UnsignedInt frame); + FrameSignature &frameSignatureFor(Int slot, UnsignedInt frame); + Int64 signerUserID(Int slot); + void noteFramePassedOn(Int author, UnsignedInt frame, Int relayedBy, Bool changesFrame); + void noteWrapperPassedOn(NetCommandMsg *wrapper, Int relayedBy); + Int wrapperPassedOnBy(NetCommandMsg *msg); + Bool buildSignedFrame(Int slot, UnsignedInt frame, std::vector &out); + Bool buildSignedCommand(Int slot, NetCommandMsg *msg, std::vector &out); + void signLocalFrame(NetFrameCommandMsg *frameInfo); + void signLocalChat(NetChatCommandMsg *msg); + Bool buildSignedLeave(Int slot, std::vector &out); +public: + Bool signLeaveNotice(NetDisconnectPlayerCommandMsg *msg); ///< The leaving player signs its leave notice. + Bool verifyLeaveNotice(NetDisconnectPlayerCommandMsg *msg); ///< The notice carries the signature of the player it says left. +private: + void attachStoredSignature(Int author, NetFrameCommandMsg *frameInfo); + Bool isPassedOnFrameAuthentic(Int slot, UnsignedInt frame); + void storeFrameSignature(Int author, UnsignedInt frame, const UnsignedByte *signature, Bool fromAuthor); + void processSignatures(NetSignaturesCommandMsg *msg); + Bool holdPassedOnChat(NetChatCommandMsg *msg); + void releaseHeldChat(Int index, const UnsignedByte *signature); + void expireHeldChats(time_t now); + ArrivalCushionStats m_cushionStats[MAX_SLOTS]; StallStats m_stallStats; Bool m_stallActive; diff --git a/Core/GameEngine/Include/GameNetwork/NetCommandMsg.h b/Core/GameEngine/Include/GameNetwork/NetCommandMsg.h index 49cd3bc21c3..e8d297a9ac0 100644 --- a/Core/GameEngine/Include/GameNetwork/NetCommandMsg.h +++ b/Core/GameEngine/Include/GameNetwork/NetCommandMsg.h @@ -269,10 +269,17 @@ class NetFrameCommandMsg : public NetCommandMsgT +{ + MEMORY_POOL_GLUE_WITH_USERLOOKUP_CREATE(NetSignedFrameCommandMsg, "NetSignedFrameCommandMsg") +public: + NetSignedFrameCommandMsg(); + NetSignedFrameCommandMsg(const NetFrameCommandMsg *frameMsg); + + UnsignedShort getCommandCount() const { return m_commandCount; } + void setCommandCount(UnsignedShort commandCount) { m_commandCount = commandCount; } + const UnsignedByte *getSignature() const { return m_signature; } + void setSignature(const UnsignedByte *signature); + + virtual Select getSmallNetPacketSelect() const override; + +protected: + UnsignedShort m_commandCount; + UnsignedByte m_signature[NET_SIGNATURE_SIZE]; +}; + +//----------------------------------------------------------------------------- +// The signatures of chat that the router relays, frame signatures ride inline in the frame info. +class NetSignaturesCommandMsg : public NetCommandMsgT +{ + MEMORY_POOL_GLUE_WITH_USERLOOKUP_CREATE(NetSignaturesCommandMsg, "NetSignaturesCommandMsg") +public: + enum { MAX_ENTRIES = 12 }; + typedef NetPacketSignaturesCommandData::Entry Entry; + + NetSignaturesCommandMsg(); + + Int getEntryCount() const { return m_entryCount; } + const Entry &getEntry(Int index) const { return m_entries[index]; } + Bool addEntry(UnsignedInt frame, UnsignedShort commandID, const UnsignedByte *signature); + + virtual Select getSmallNetPacketSelect() const override; + +protected: + Int m_entryCount; + Entry m_entries[MAX_ENTRIES]; +}; diff --git a/Core/GameEngine/Include/GameNetwork/NetPacketStructs.h b/Core/GameEngine/Include/GameNetwork/NetPacketStructs.h index c96a1e11b13..d2ebb2094a2 100644 --- a/Core/GameEngine/Include/GameNetwork/NetPacketStructs.h +++ b/Core/GameEngine/Include/GameNetwork/NetPacketStructs.h @@ -55,6 +55,8 @@ class NetDisconnectScreenOffCommandMsg; class NetFrameResendRequestCommandMsg; class NetLoadCompleteCommandMsg; class NetTimeOutGameStartCommandMsg; +class NetSignedFrameCommandMsg; +class NetSignaturesCommandMsg; //////////////////////////////////////////////////////////////////////////////// // Helper class to pass buffer pointer and size @@ -644,6 +646,8 @@ struct NetPacketDisconnectKeepAliveCommandBase // NetPacketDisconnectPlayerCommand //////////////////////////////////////////////////////////////////////////////// +enum { NET_SIGNATURE_SIZE = 64 }; + struct NetPacketDisconnectPlayerCommandData { typedef NetDisconnectPlayerCommandMsg CommandMsg; @@ -652,6 +656,10 @@ struct NetPacketDisconnectPlayerCommandData { UnsignedByte disconnectSlot; UnsignedInt disconnectFrame; +#if !RETAIL_COMPATIBLE_NETWORKING + UnsignedByte hasSignature; // the leaving player's signature of its leave, so peers can pass the notice on + UnsignedByte signature[NET_SIGNATURE_SIZE]; +#endif }; static size_t getSize(const NetCommandMsg &msg) { return sizeof(FixedData); } @@ -1177,6 +1185,82 @@ struct NetPacketFrameResendRequestCommandBase static size_t copyBytes(UnsignedByte *buffer, const NetCommandRef &ref); }; +//////////////////////////////////////////////////////////////////////////////// +// NetPacketSignedFrameCommand +//////////////////////////////////////////////////////////////////////////////// + +// A frame info followed by its author's signature. It is read back into a NetFrameCommandMsg. +struct NetPacketSignedFrameCommandData +{ + typedef NetSignedFrameCommandMsg CommandMsg; + + struct FixedData + { + UnsignedShort commandCount; + UnsignedByte signature[NET_SIGNATURE_SIZE]; + }; + + static size_t getSize(const NetCommandMsg &msg) { return sizeof(FixedData); } + static size_t copyBytes(UnsignedByte *buffer, const NetCommandRef &ref); + static size_t readMessage(NetCommandRef &ref, NetPacketBuf buf); + static size_t readIntoFrame(NetFrameCommandMsg *frameMsg, NetPacketBuf buf); +}; + +struct NetPacketSignedFrameCommandBase +{ + typedef NetSignedFrameCommandMsg CommandMsg; + + struct CommandBase + { + NetPacketCommandTypeField commandType; + NetPacketFrameField frame; + NetPacketRelayField relay; + NetPacketPlayerIdField playerId; + NetPacketCommandIdField commandId; + NetPacketDataField dataHeader; + }; + + static size_t getSize() { return sizeof(CommandBase); } + static size_t copyBytes(UnsignedByte *buffer, const NetCommandRef &ref); +}; + +//////////////////////////////////////////////////////////////////////////////// +// NetPacketSignaturesCommand +//////////////////////////////////////////////////////////////////////////////// + +struct NetPacketSignaturesCommandData +{ + typedef NetSignaturesCommandMsg CommandMsg; + + struct Entry + { + UnsignedInt frame; + UnsignedShort commandId; + UnsignedByte signature[NET_SIGNATURE_SIZE]; + }; + + static size_t getSize(const NetCommandMsg &msg); + static size_t copyBytes(UnsignedByte *buffer, const NetCommandRef &ref); + static size_t readMessage(NetCommandRef &ref, NetPacketBuf buf); +}; + +struct NetPacketSignaturesCommandBase +{ + typedef NetSignaturesCommandMsg CommandMsg; + + struct CommandBase + { + NetPacketCommandTypeField commandType; + NetPacketRelayField relay; + NetPacketPlayerIdField playerId; + NetPacketCommandIdField commandId; + NetPacketDataField dataHeader; + }; + + static size_t getSize() { return sizeof(CommandBase); } + static size_t copyBytes(UnsignedByte *buffer, const NetCommandRef &ref); +}; + //////////////////////////////////////////////////////////////////////////////// struct NetPacketAckCommand : public NetPacketCommandTemplate {}; @@ -1204,6 +1288,8 @@ struct NetPacketTimeOutGameStartCommand : public NetPacketCommandTemplat struct NetPacketDisconnectFrameCommand : public NetPacketCommandTemplate {}; struct NetPacketDisconnectScreenOffCommand : public NetPacketCommandTemplate {}; struct NetPacketFrameResendRequestCommand : public NetPacketCommandTemplate {}; +struct NetPacketSignedFrameCommand : public NetPacketCommandTemplate {}; +struct NetPacketSignaturesCommand : public NetPacketCommandTemplate {}; struct SmallNetPacketAckCommand : public SmallNetPacketCommandTemplate {}; struct SmallNetPacketFrameCommand : public SmallNetPacketCommandTemplate {}; @@ -1230,6 +1316,8 @@ struct SmallNetPacketTimeOutGameStartCommand : public SmallNetPacketCommandTe struct SmallNetPacketDisconnectFrameCommand : public SmallNetPacketCommandTemplate {}; struct SmallNetPacketDisconnectScreenOffCommand : public SmallNetPacketCommandTemplate {}; struct SmallNetPacketFrameResendRequestCommand : public SmallNetPacketCommandTemplate {}; +struct SmallNetPacketSignedFrameCommand : public SmallNetPacketCommandTemplate {}; +struct SmallNetPacketSignaturesCommand : public SmallNetPacketCommandTemplate {}; // Restore normal struct packing #pragma pack(pop) diff --git a/Core/GameEngine/Include/GameNetwork/NetworkDefs.h b/Core/GameEngine/Include/GameNetwork/NetworkDefs.h index af159f71874..4510ac4f336 100644 --- a/Core/GameEngine/Include/GameNetwork/NetworkDefs.h +++ b/Core/GameEngine/Include/GameNetwork/NetworkDefs.h @@ -133,6 +133,10 @@ struct TransportMessage Int length; UnsignedInt addr; UnsignedShort port; +#if defined(GENERALS_ONLINE) + Bool senderKnown; ///< The transport knows which user this packet came from, set by the receiving side only. + Int64 senderUserID; +#endif }; #pragma pack(pop) @@ -192,6 +196,9 @@ enum NetCommandType CPP_11(: Int) { NETCOMMANDTYPE_DISCONNECTFRAME, NETCOMMANDTYPE_DISCONNECTSCREENOFF, NETCOMMANDTYPE_DISCONNECTEND, + + NETCOMMANDTYPE_SIGNEDFRAMEINFO = 32, // A frame info with its author's signature, only on the wire. + NETCOMMANDTYPE_SIGNATURES, // Signatures of an author sent apart from its frames: for its chat. }; enum NetLocalStatus CPP_11(: Int) { diff --git a/Core/GameEngine/Include/GameNetwork/NetworkInterface.h b/Core/GameEngine/Include/GameNetwork/NetworkInterface.h index 2e28de9e1d2..a72a3a2c460 100644 --- a/Core/GameEngine/Include/GameNetwork/NetworkInterface.h +++ b/Core/GameEngine/Include/GameNetwork/NetworkInterface.h @@ -60,6 +60,9 @@ class NetworkInterface : public SubsystemInterface virtual void setLocalAddress(UnsignedInt ip, UnsignedInt port) = 0; ///< Tell the network what local ip and port to bind to. virtual Bool isFrameDataReady() = 0; ///< Are the commands for the next frame available? +#if defined(GENERALS_ONLINE) + virtual Bool isGameExitPending() const = 0; +#endif virtual Bool isStalling() = 0; virtual void parseUserList(const GameInfo* game) = 0; ///< Parse a userlist, creating connections virtual void startGame() = 0; ///< Sets the network game frame counter to -1 @@ -105,6 +108,8 @@ class NetworkInterface : public SubsystemInterface #if defined(GENERALS_ONLINE) virtual Bool getOverlayInfo(Int slot, NetworkOverlayInfo &info) = 0; virtual void SeedLatencyData(int highestLatency) = 0; + virtual void requireMatchKeys() {} + virtual Bool matchKeysRequired() const { return FALSE; } virtual bool IsSlugging() = 0; #endif diff --git a/Core/GameEngine/Source/GameLogic/System/GameLogicDispatch.cpp b/Core/GameEngine/Source/GameLogic/System/GameLogicDispatch.cpp index c9c7cbb7602..737e916da43 100644 --- a/Core/GameEngine/Source/GameLogic/System/GameLogicDispatch.cpp +++ b/Core/GameEngine/Source/GameLogic/System/GameLogicDispatch.cpp @@ -896,6 +896,21 @@ bool GameLogic::onNewGame(MAYBE_UNUSED GameMessage *msg) return true; } +#if defined(GENERALS_ONLINE) +void GameLogic::processPendingGameExit() +{ + for (GameMessage* msg = TheCommandList->getFirstMessage(); msg != nullptr; msg = msg->next()) { + if (msg->getType() == GameMessage::MSG_CLEAR_GAME_DATA) { + TheCommandList->removeMessage(msg); + deleteInstance(msg); + AIGroupPtr selectedGroup = nullptr; + onClearGameData(nullptr, selectedGroup); + return; + } + } +} +#endif + bool GameLogic::onClearGameData(MAYBE_UNUSED GameMessage *msg, AIGroupPtr ¤tlySelectedGroup) { #if defined(RTS_DEBUG) diff --git a/Core/GameEngine/Source/GameNetwork/Connection.cpp b/Core/GameEngine/Source/GameNetwork/Connection.cpp index 6df132bad1e..ffab6ac480f 100644 --- a/Core/GameEngine/Source/GameNetwork/Connection.cpp +++ b/Core/GameEngine/Source/GameNetwork/Connection.cpp @@ -157,6 +157,16 @@ void Connection::sendNetCommandMsg(NetCommandMsg *msg, UnsignedByte relay) { if (m_isQuitting) return; +#if defined(GENERALS_ONLINE) + // a signed frame info goes out in its signed form + if (msg->getNetCommandType() == NETCOMMANDTYPE_FRAMEINFO && ((NetFrameCommandMsg *)msg)->hasSignature()) { + NetSignedFrameCommandMsg *signedMsg = newInstance(NetSignedFrameCommandMsg)((NetFrameCommandMsg *)msg); + sendNetCommandMsg(signedMsg, relay); + signedMsg->detach(); + return; + } +#endif + if (m_netCommandList != nullptr) { // check to see if this command will fit in a packet. If not, we need to split it up. // we are splitting up the command here so that the retry logic will not try to diff --git a/Core/GameEngine/Source/GameNetwork/ConnectionManager.cpp b/Core/GameEngine/Source/GameNetwork/ConnectionManager.cpp index 9d459b074a4..5237e9a9c32 100644 --- a/Core/GameEngine/Source/GameNetwork/ConnectionManager.cpp +++ b/Core/GameEngine/Source/GameNetwork/ConnectionManager.cpp @@ -59,6 +59,8 @@ #include "../NetworkMesh.h" #include "../ngmp_interfaces.h" +#include + #if defined(GENERALS_ONLINE) Int MIN_RUNAHEAD = 4; #endif @@ -211,6 +213,10 @@ static Bool hasValidTransferFileContent(const AsciiString& filePath, const Unsig */ ConnectionManager::~ConnectionManager() { +#if defined(GENERALS_ONLINE) + clearSignatureState(TRUE); +#endif + deleteInstance(m_localUser); m_localUser = nullptr; @@ -269,6 +275,9 @@ ConnectionManager::ConnectionManager() m_netCommandWrapperList = nullptr; m_localUser = nullptr; m_localUser = newInstance(User); +#if defined(GENERALS_ONLINE) + clearSignatureState(FALSE); +#endif } /** @@ -339,11 +348,18 @@ void ConnectionManager::init() m_latencyAverages[i] = 0.0; // using zero since all floating point standards should be able to specify 0.0 accurately. } m_smallestPacketArrivalCushion = -1; +#if !RETAIL_COMPATIBLE_NETWORKING + m_currentSenderSlot = -1; +#endif #if defined(GENERALS_ONLINE) m_stallActive = FALSE; m_stallStartTime = 0; m_stallMissingMask = 0; m_lastKnownRunAhead = 0; + m_loggedSenderUnavailable = FALSE; + m_securityDrops = 0; + m_formerRouterMask = 0; + clearSignatureState(TRUE); m_frameGroupingMs = 0; #endif @@ -415,6 +431,7 @@ void ConnectionManager::reset() m_netCommandWrapperList->reset(); #if defined(GENERALS_ONLINE) resetRecentCommandIds(); + clearSignatureState(TRUE); #endif m_localSlot = -1; @@ -537,11 +554,544 @@ Bool ConnectionManager::isDuplicateCommand(NetCommandMsg *msg) { return FALSE; } +Int ConnectionManager::slotForUserID(Int64 userID) const { + return (TheNGMPGame != nullptr) ? TheNGMPGame->getSlotOfUser(userID) : -1; +} + +// Counts a dropped command; TRUE for the drops that are logged (the first 20, then every 100th). +Bool ConnectionManager::noteSecurityDrop() { + ++m_securityDrops; + return m_securityDrops <= 20 || (m_securityDrops % 100) == 0; +} + +// How a command may reach us with another player's name on it: 0 only straight from its author, 1 authenticated by the +// author's signature (frames, chat), also from a former router, 2 only from the current packet router. +static Int relayClass(NetCommandType type) { + switch (type) { + case NETCOMMANDTYPE_FRAMEINFO: + case NETCOMMANDTYPE_SIGNEDFRAMEINFO: + case NETCOMMANDTYPE_GAMECOMMAND: + case NETCOMMANDTYPE_PLAYERLEAVE: + case NETCOMMANDTYPE_RUNAHEAD: + case NETCOMMANDTYPE_DESTROYPLAYER: + case NETCOMMANDTYPE_CHAT: + case NETCOMMANDTYPE_SIGNATURES: + return 1; + case NETCOMMANDTYPE_WRAPPER: + case NETCOMMANDTYPE_RUNAHEADMETRICS: + case NETCOMMANDTYPE_PROGRESS: + case NETCOMMANDTYPE_ACKBOTH: + case NETCOMMANDTYPE_ACKSTAGE1: + case NETCOMMANDTYPE_ACKSTAGE2: + return 2; + default: + return 0; // votes, disconnect frames, keep alives, load complete, file transfer, ... are never passed on + } +} + +// Checks that a command names its sender, or that the packet router or a former one passes it on. +Bool ConnectionManager::isCommandFromSender(NetCommandMsg *msg, Int senderSlot) { + const Int claimedSlot = (Int)msg->getPlayerID(); + if (claimedSlot == senderSlot && claimedSlot >= 0 && claimedSlot < MAX_SLOTS) { + return TRUE; + } + const Int cls = relayClass(msg->getNetCommandType()); + const Bool nameOk = claimedSlot >= 0 && claimedSlot < MAX_SLOTS && claimedSlot != (Int)m_localSlot; + Bool maySendForOthers = FALSE; + if (nameOk && cls == 1) { + maySendForOthers = (senderSlot == m_packetRouterSlot) || (senderSlot >= 0 && (m_formerRouterMask & (1u << senderSlot)) != 0); + } else if (nameOk && cls == 2) { + maySendForOthers = (senderSlot == m_packetRouterSlot); + } + // forwarded frames are checked in isPassedOnFrameAuthentic + if (maySendForOthers) { + return TRUE; + } + if (noteSecurityDrop()) { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[SECURITY] dropped %s claiming slot %d from slot %d (%d dropped so far)", + GetNetCommandTypeAsString(msg->getNetCommandType()), claimedSlot, (Int)senderSlot, m_securityDrops); + } + return FALSE; +} + void ConnectionManager::resetRecentCommandIds() { for (Int i = 0; i < MAX_SLOTS; ++i) { m_recentIds[i].valid = FALSE; } } + +static Int64 userIDOfSlot(Int slot) { + return (TheNGMPGame != nullptr) ? TheNGMPGame->getUserOfSlot(slot) : -1; +} + +static MatchKeys *activeMatchKeys() { + NetworkMesh *mesh = NGMP_OnlineServicesManager::GetNetworkMesh(); + if (mesh == nullptr || !mesh->GetMatchKeys().IsActive()) { + return nullptr; + } + return &mesh->GetMatchKeys(); +} + +static void appendBytes(std::vector &out, UnsignedInt64 value, Int count) { + for (Int i = 0; i < count; ++i) { + out.push_back((UnsignedByte)(value >> (8 * i))); + } +} + +static void appendSignedHeader(std::vector &out, Char kind, UnsignedInt64 matchID, Int64 userID, Int slot) { + out.push_back('G'); + out.push_back('O'); + out.push_back('S'); + out.push_back((UnsignedByte)kind); + out.push_back(1); // version + appendBytes(out, matchID, 8); + appendBytes(out, (UnsignedInt64)userID, 8); + out.push_back((UnsignedByte)slot); +} + +// The command as the full packet format writes it, without the relay mask that differs per hop. +static void appendCommandBytes(std::vector &out, NetCommandMsg *msg) { + NetCommandRef *ref = NEW_NETCOMMANDREF(msg); + ref->setRelay(0); + const size_t start = out.size(); + const size_t size = msg->getSizeForNetPacket(); + appendBytes(out, 0, 4); + out.resize(start + 4 + size); + const size_t written = msg->copyBytesForNetPacket(&out[start + 4], *ref); + out.resize(start + 4 + written); + for (Int i = 0; i < 4; ++i) { + out[start + i] = (UnsignedByte)(written >> (8 * i)); + } + deleteInstance(ref); +} + +static Bool commandSignOrder(const NetCommandMsg *a, const NetCommandMsg *b) { + if (a->getNetCommandType() != b->getNetCommandType()) { + return a->getNetCommandType() < b->getNetCommandType(); + } + return a->getID() < b->getID(); +} + +void ConnectionManager::clearSignatureState(Bool releaseChats) { + for (Int slot = 0; slot < MAX_SLOTS; ++slot) { + m_frameSignatures[slot].assign(FRAME_DATA_LENGTH, FrameSignature()); + m_nextRelayedWrapper[slot] = 0; + m_signerUserID[slot] = -1; + for (Int wrapper = 0; wrapper < RELAYED_WRAPPER_COUNT; ++wrapper) { + m_relayedWrappers[slot][wrapper].used = FALSE; + } + } + for (Int held = 0; held < HELD_CHAT_COUNT; ++held) { + if (releaseChats && m_heldChats[held].msg != nullptr) { + m_heldChats[held].msg->detach(); + } + m_heldChats[held].msg = nullptr; + m_chatSignatures[held].used = FALSE; + } + m_currentSenderSlot = -1; + m_lastFrameInfoSent = 0; +} + +ConnectionManager::FrameSignature *ConnectionManager::findFrameSignature(Int slot, UnsignedInt frame) { + if (slot < 0 || slot >= MAX_SLOTS) { + return nullptr; + } + FrameSignature &entry = m_frameSignatures[slot][frame % FRAME_DATA_LENGTH]; + return (entry.used && entry.frame == frame) ? &entry : nullptr; +} + +ConnectionManager::FrameSignature &ConnectionManager::frameSignatureFor(Int slot, UnsignedInt frame) { + FrameSignature &entry = m_frameSignatures[slot][frame % FRAME_DATA_LENGTH]; + if (!entry.used || entry.frame != frame) { + entry = FrameSignature(); + entry.used = TRUE; + entry.frame = frame; + entry.relayedBy = -1; + } + return entry; +} + +// The user id of a slot, kept once seen so the last frames of a player that left can still be verified. +Int64 ConnectionManager::signerUserID(Int slot) { + if (slot < 0 || slot >= MAX_SLOTS) { + return -1; + } + const Int64 userID = userIDOfSlot(slot); + if (userID >= 0) { + m_signerUserID[slot] = userID; + } + return m_signerUserID[slot]; +} + +void ConnectionManager::noteFramePassedOn(Int author, UnsignedInt frame, Int relayedBy, Bool changesFrame) { + if (author < 0 || author >= MAX_SLOTS || author == (Int)m_localSlot) { + return; + } + if (TheGameLogic != nullptr && frame < TheGameLogic->getFrame()) { + return; + } + FrameSignature &entry = frameSignatureFor(author, frame); + entry.relayed = TRUE; + entry.relayedBy = relayedBy; + if (changesFrame) { + entry.verified = FALSE; + } +} + +void ConnectionManager::noteWrapperPassedOn(NetCommandMsg *wrapper, Int relayedBy) { + const Int author = (Int)wrapper->getPlayerID(); + if (author < 0 || author >= MAX_SLOTS) { + return; + } + const UnsignedShort wrappedID = ((NetWrapperCommandMsg *)wrapper)->getWrappedCommandID(); + for (Int i = 0; i < RELAYED_WRAPPER_COUNT; ++i) { + if (m_relayedWrappers[author][i].used && m_relayedWrappers[author][i].wrappedCommandID == wrappedID) { + m_relayedWrappers[author][i].relayedBy = relayedBy; + return; + } + } + RelayedWrapper &entry = m_relayedWrappers[author][m_nextRelayedWrapper[author]]; + m_nextRelayedWrapper[author] = (m_nextRelayedWrapper[author] + 1) % RELAYED_WRAPPER_COUNT; + entry.used = TRUE; + entry.wrappedCommandID = wrappedID; + entry.relayedBy = relayedBy; +} + +Int ConnectionManager::wrapperPassedOnBy(NetCommandMsg *msg) { + const Int author = (Int)msg->getPlayerID(); + if (author < 0 || author >= MAX_SLOTS) { + return -2; + } + for (Int i = 0; i < RELAYED_WRAPPER_COUNT; ++i) { + if (m_relayedWrappers[author][i].used && m_relayedWrappers[author][i].wrappedCommandID == msg->getID()) { + m_relayedWrappers[author][i].used = FALSE; + return m_relayedWrappers[author][i].relayedBy; + } + } + return author; +} + +/// Signed bytes: header (match, user id, slot), frame, command count, then the frame's commands in full packet form (relay 0) sorted by type and id. +/// The frame info id is not signed, a peer that passes it on renumbers it. +Bool ConnectionManager::buildSignedFrame(Int slot, UnsignedInt frame, std::vector &out) { + MatchKeys *keys = activeMatchKeys(); + const Int64 userID = signerUserID(slot); + if (keys == nullptr || userID < 0 || m_frameData[slot] == nullptr) { + return FALSE; + } + + std::vector commands; + NetCommandList *list = m_frameData[slot]->getFrameCommandList(frame); + for (NetCommandRef *ref = (list != nullptr) ? list->getFirstMessage() : nullptr; ref != nullptr; ref = ref->getNext()) { + commands.push_back(ref->getCommand()); + } + std::sort(commands.begin(), commands.end(), commandSignOrder); + + out.clear(); + appendSignedHeader(out, 'F', keys->GetMatchID(), userID, slot); + appendBytes(out, frame, 4); + appendBytes(out, m_frameData[slot]->getFrameCommandCount(frame), 2); + for (size_t i = 0; i < commands.size(); ++i) { + appendCommandBytes(out, commands[i]); + } + return TRUE; +} + +// What the author signs for its chat, the one command it sends through the packet router outside a frame. +Bool ConnectionManager::buildSignedCommand(Int slot, NetCommandMsg *msg, std::vector &out) { + MatchKeys *keys = activeMatchKeys(); + const Int64 userID = signerUserID(slot); + if (keys == nullptr || userID < 0) { + return FALSE; + } + out.clear(); + appendSignedHeader(out, 'C', keys->GetMatchID(), userID, slot); + appendCommandBytes(out, msg); + return TRUE; +} + +/// Signs a local frame that has commands; the signature rides in the frame info. Empty frames are not signed. +void ConnectionManager::signLocalFrame(NetFrameCommandMsg *frameInfo) { + MatchKeys *keys = activeMatchKeys(); + if (keys == nullptr || frameInfo->getCommandCount() == 0) { + return; + } + + std::vector data; + UnsignedByte signature[SIGNATURE_BYTES]; + if (!buildSignedFrame(m_localSlot, frameInfo->getExecutionFrame(), data) || !keys->Sign(&data[0], data.size(), signature)) { + return; + } + frameInfo->setSignature(signature); +} + +void ConnectionManager::signLocalChat(NetChatCommandMsg *chat) { + MatchKeys *keys = activeMatchKeys(); + if (keys == nullptr || m_packetRouterSlot >= MAX_SLOTS || m_packetRouterSlot == m_localSlot || m_connections[m_packetRouterSlot] == nullptr) { + return; + } + + std::vector data; + UnsignedByte signature[SIGNATURE_BYTES]; + if (!buildSignedCommand(m_localSlot, chat, data) || !keys->Sign(&data[0], data.size(), signature)) { + return; + } + + NetSignaturesCommandMsg *msg = newInstance(NetSignaturesCommandMsg); + msg->addEntry(chat->getExecutionFrame(), chat->getID(), signature); + msg->setPlayerID(m_localSlot); + msg->setID(GenerateNextCommandID()); + sendLocalCommand(msg, (UnsignedByte)(0xff & ~(1 << m_localSlot))); + msg->detach(); +} + +// What the leaver signs: the match, its user id and slot. A leave carries no frame, so a copy a peer passes on keeps its proof. +Bool ConnectionManager::buildSignedLeave(Int slot, std::vector &out) { + MatchKeys *keys = activeMatchKeys(); + const Int64 userID = signerUserID(slot); + if (keys == nullptr || userID < 0) { + return FALSE; + } + out.clear(); + appendSignedHeader(out, 'L', keys->GetMatchID(), userID, slot); + return TRUE; +} + +// The leaving player signs its own leave; without a key yet the notice goes out unsigned. +Bool ConnectionManager::signLeaveNotice(NetDisconnectPlayerCommandMsg *msg) { + MatchKeys *keys = activeMatchKeys(); + if (keys == nullptr || (Int)msg->getDisconnectSlot() != (Int)m_localSlot) { + return FALSE; + } + std::vector data; + UnsignedByte signature[SIGNATURE_BYTES]; + if (!buildSignedLeave(m_localSlot, data) || !keys->Sign(&data[0], data.size(), signature)) { + return FALSE; + } + msg->setSignature(signature); + return TRUE; +} + +// TRUE if the notice carries the signature of the player it says left. +Bool ConnectionManager::verifyLeaveNotice(NetDisconnectPlayerCommandMsg *msg) { + const Int slot = (Int)msg->getDisconnectSlot(); + MatchKeys *keys = activeMatchKeys(); + if (!msg->hasSignature() || slot < 0 || slot >= MAX_SLOTS || keys == nullptr) { + return FALSE; + } + std::vector data; + return buildSignedLeave(slot, data) && keys->Verify(signerUserID(slot), &data[0], data.size(), msg->getSignature()); +} + +void ConnectionManager::attachStoredSignature(Int author, NetFrameCommandMsg *frameInfo) { + FrameSignature *entry = findFrameSignature(author, frameInfo->getExecutionFrame()); + if (entry != nullptr && entry->haveSignature) { + frameInfo->setSignature(entry->signature); + } +} + +// the author's signature replaces a passed on one, never the reverse +void ConnectionManager::storeFrameSignature(Int author, UnsignedInt frame, const UnsignedByte *signature, Bool fromAuthor) { + if (author < 0 || author >= MAX_SLOTS || author == (Int)m_localSlot) { + return; + } + if (TheGameLogic != nullptr && frame + FRAMES_TO_KEEP < TheGameLogic->getFrame()) { + return; + } + FrameSignature &entry = frameSignatureFor(author, frame); + if (entry.haveSignature && !fromAuthor) { + return; + } + if (!entry.haveSignature || memcmp(entry.signature, signature, SIGNATURE_BYTES) != 0) { + memcpy(entry.signature, signature, SIGNATURE_BYTES); + entry.haveSignature = TRUE; + entry.verified = FALSE; + } +} + +// Asks the author for the frame again when we reach it, and the peer that passed it on, which holds it in any case. +void ConnectionManager::requestPassedOnFrameAgain(Int slot, UnsignedInt frame, Int passedOnBy) { + UnsignedByte targets = 0; + if (isPlayerConnected(slot)) { + targets |= (UnsignedByte)(1 << slot); + } + if (passedOnBy >= 0 && passedOnBy < MAX_SLOTS && passedOnBy != (Int)m_localSlot && isPlayerConnected(passedOnBy)) { + targets |= (UnsignedByte)(1 << passedOnBy); + } + if (targets == 0) { + requestFrameDataResend(slot, frame); + return; + } + + NetFrameResendRequestCommandMsg *msg = newInstance(NetFrameResendRequestCommandMsg); + msg->setPlayerID(m_localSlot); + msg->setFrameToResend(frame); + msg->setID(GenerateNextCommandID()); + sendLocalCommandDirect(msg, targets); + msg->detach(); +} + +/// A frame that a peer other than its author passed on runs only with the author's valid signature, else it is cleared and asked for again. +Bool ConnectionManager::isPassedOnFrameAuthentic(Int slot, UnsignedInt frame) { + FrameSignature *entry = findFrameSignature(slot, frame); + if (entry == nullptr || !entry->relayed || entry->verified || !TheNetwork->matchKeysRequired()) { + return TRUE; + } + // empty frames are not signed + if (m_frameData[slot]->getFrameCommandCount(frame) == 0) { + return TRUE; + } + + const char *problem = nullptr; + if (!entry->haveSignature) { + const time_t now = timeGetTime(); + if (entry->waitingSince == 0) { + entry->waitingSince = now; + } + if ((now - entry->waitingSince) < SIGNATURE_WAIT_MS) { + return FALSE; + } + problem = "unsigned"; + } else { + MatchKeys *keys = activeMatchKeys(); + std::vector data; + if (keys != nullptr && buildSignedFrame(slot, frame, data) && keys->Verify(signerUserID(slot), &data[0], data.size(), entry->signature)) { + entry->verified = TRUE; + return TRUE; + } + problem = "forged"; + } + + if (noteSecurityDrop()) { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[SECURITY] dropped %s commands for player %d relayed by %d (frame %u, %d dropped so far), asking for them again", + problem, slot, entry->relayedBy, frame, m_securityDrops); + } + const Int passedOnBy = entry->relayedBy; + m_frameData[slot]->resetFrame(frame, FALSE); + *entry = FrameSignature(); + entry->used = TRUE; + entry->frame = frame; + entry->relayedBy = -1; + requestPassedOnFrameAgain(slot, frame, passedOnBy); + return FALSE; +} + +void ConnectionManager::processSignatures(NetSignaturesCommandMsg *msg) { + const Int author = (Int)msg->getPlayerID(); + if (author < 0 || author >= MAX_SLOTS || author == (Int)m_localSlot) { + return; + } + + const time_t now = timeGetTime(); + for (Int e = 0; e < msg->getEntryCount(); ++e) { + const NetSignaturesCommandMsg::Entry &signedEntry = msg->getEntry(e); + expireHeldChats(now); + Bool released = FALSE; + for (Int held = 0; held < HELD_CHAT_COUNT && !released; ++held) { + NetChatCommandMsg *chat = m_heldChats[held].msg; + if (chat != nullptr && (Int)chat->getPlayerID() == author && chat->getID() == signedEntry.commandId) { + releaseHeldChat(held, signedEntry.signature); + released = TRUE; + } + } + if (released) { + continue; + } + + // the chat is not here yet, keep the signature for it, the oldest one gives way + Int index = 0; + for (Int i = 0; i < HELD_CHAT_COUNT; ++i) { + if (!m_chatSignatures[i].used) { + index = i; + break; + } + if (m_chatSignatures[i].since < m_chatSignatures[index].since) { + index = i; + } + } + ChatSignature &stored = m_chatSignatures[index]; + stored.used = TRUE; + stored.author = (UnsignedInt)author; + stored.commandID = signedEntry.commandId; + stored.since = now; + memcpy(stored.signature, signedEntry.signature, SIGNATURE_BYTES); + } +} + +// Chat the packet router relays is held until its author's signature checks out. +Bool ConnectionManager::holdPassedOnChat(NetChatCommandMsg *msg) { + const Int author = (Int)msg->getPlayerID(); + const Int relayedBy = m_currentSenderSlot; + if (relayedBy < 0 || author == relayedBy || author == (Int)m_localSlot || !TheNetwork->matchKeysRequired()) { + return FALSE; + } + + const time_t now = timeGetTime(); + expireHeldChats(now); + + Int index = 0; + for (Int i = 0; i < HELD_CHAT_COUNT; ++i) { + if (m_heldChats[i].msg == nullptr) { + index = i; + break; + } + if (m_heldChats[i].since < m_heldChats[index].since) { + index = i; + } + } + if (m_heldChats[index].msg != nullptr) { + ++m_securityDrops; + NetworkLog(ELogVerbosity::LOG_RELEASE, "[SECURITY] dropped unsigned chat for player %d relayed by %d, too many chats wait for a signature", + (Int)m_heldChats[index].msg->getPlayerID(), m_heldChats[index].relayedBy); + m_heldChats[index].msg->detach(); + m_heldChats[index].msg = nullptr; + } + msg->attach(); + m_heldChats[index].msg = msg; + m_heldChats[index].relayedBy = relayedBy; + m_heldChats[index].since = now; + + for (Int i = 0; i < HELD_CHAT_COUNT; ++i) { + if (m_chatSignatures[i].used && m_chatSignatures[i].author == (UnsignedInt)author && m_chatSignatures[i].commandID == msg->getID()) { + m_chatSignatures[i].used = FALSE; + releaseHeldChat(index, m_chatSignatures[i].signature); + break; + } + } + return TRUE; +} + +void ConnectionManager::releaseHeldChat(Int index, const UnsignedByte *signature) { + NetChatCommandMsg *chat = m_heldChats[index].msg; + const Int relayedBy = m_heldChats[index].relayedBy; + m_heldChats[index].msg = nullptr; + + const Int author = (Int)chat->getPlayerID(); + MatchKeys *keys = activeMatchKeys(); + std::vector data; + if (keys != nullptr && buildSignedCommand(author, chat, data) && keys->Verify(signerUserID(author), &data[0], data.size(), signature)) { + processChat(chat); + } else { + ++m_securityDrops; + NetworkLog(ELogVerbosity::LOG_RELEASE, "[SECURITY] dropped forged chat for player %d relayed by %d", author, relayedBy); + } + chat->detach(); +} + +void ConnectionManager::expireHeldChats(time_t now) { + for (Int i = 0; i < HELD_CHAT_COUNT; ++i) { + if (m_heldChats[i].msg != nullptr && (now - m_heldChats[i].since) > SIGNATURE_WAIT_MS * 3) { + ++m_securityDrops; + NetworkLog(ELogVerbosity::LOG_RELEASE, "[SECURITY] dropped unsigned chat for player %d relayed by %d", + (Int)m_heldChats[i].msg->getPlayerID(), m_heldChats[i].relayedBy); + m_heldChats[i].msg->detach(); + m_heldChats[i].msg = nullptr; + } + if (m_chatSignatures[i].used && (now - m_chatSignatures[i].since) > SIGNATURE_WAIT_MS * 3) { + m_chatSignatures[i].used = FALSE; + } + } +} #endif /** @@ -566,11 +1116,40 @@ void ConnectionManager::doRelay() { // Get the command list from the packet. NetCommandList *cmdList = packet->getCommandList(); +#if defined(GENERALS_ONLINE) + // -2 when the transport cannot say who sent the packet + Int senderSlot = -2; + if (m_transport->m_inBuffer[i].senderKnown) { + senderSlot = slotForUserID(m_transport->m_inBuffer[i].senderUserID); + if (senderSlot < 0) { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[SECURITY] dropped a packet of %d commands from user %lld, who is not in this game", (Int)packet->getNumCommands(), (long long)m_transport->m_inBuffer[i].senderUserID); + } + } else if (!m_loggedSenderUnavailable) { + m_loggedSenderUnavailable = TRUE; + NetworkLog(ELogVerbosity::LOG_RELEASE, "[SECURITY] the transport does not say who sent a packet (anticheat plugin path), commands are not checked against their sender"); + } +#endif + +#if !RETAIL_COMPATIBLE_NETWORKING +#if defined(GENERALS_ONLINE) + m_currentSenderSlot = (senderSlot >= 0) ? senderSlot : -1; +#endif +#endif + // Iterate through the commands in this packet and send them to the proper connections. for (NetCommandRef* cmd = cmdList->getFirstMessage(); cmd; cmd = cmd->getNext()) { //DEBUG_LOG(("ConnectionManager::doRelay() - Looking at a command of type %s", //GetNetCommandTypeAsString(cmd->getCommand()->getNetCommandType()))); +#if defined(GENERALS_ONLINE) + if (senderSlot == -1 || (senderSlot >= 0 && !isCommandFromSender(cmd->getCommand(), senderSlot))) { + continue; // not acked and not processed + } + if (senderSlot >= 0 && cmd->getCommand()->getNetCommandType() == NETCOMMANDTYPE_WRAPPER && (Int)cmd->getCommand()->getPlayerID() != senderSlot) { + noteWrapperPassedOn(cmd->getCommand(), senderSlot); + } +#endif + if (CommandRequiresAck(cmd->getCommand())) { ackCommand(cmd, m_localSlot); #if defined(GENERALS_ONLINE) @@ -583,6 +1162,9 @@ void ConnectionManager::doRelay() { sendRemoteCommand(cmd); } } +#if defined(GENERALS_ONLINE) + m_currentSenderSlot = -1; +#endif // Delete this packet since we won't be needing it anymore. deleteInstance(packet); @@ -598,8 +1180,20 @@ void ConnectionManager::doRelay() { } } +#if !RETAIL_COMPATIBLE_NETWORKING + m_currentSenderSlot = -1; +#endif + NetCommandList *cmdList = m_netCommandWrapperList->getReadyCommands(); for (NetCommandRef* cmd = cmdList->getFirstMessage(); cmd; cmd = cmd->getNext()) { +#if defined(GENERALS_ONLINE) + // a command put together from pieces some other peer passed on counts as passed on by that peer + m_currentSenderSlot = wrapperPassedOnBy(cmd->getCommand()); + if (m_currentSenderSlot < 0 || !isCommandFromSender(cmd->getCommand(), m_currentSenderSlot)) { + m_currentSenderSlot = -1; + continue; // the command inside the pieces is held to the same rule as one sent plain + } +#endif if (CommandRequiresAck(cmd->getCommand())) { ackCommand(cmd, m_localSlot); #if defined(GENERALS_ONLINE) @@ -612,6 +1206,9 @@ void ConnectionManager::doRelay() { sendRemoteCommand(cmd); } } +#if defined(GENERALS_ONLINE) + m_currentSenderSlot = -1; +#endif // Delete this packet since we won't be needing it anymore. deleteInstance(packet); @@ -721,9 +1318,20 @@ Bool ConnectionManager::processNetCommand(NetCommandRef *ref) { return FALSE; case NETCOMMANDTYPE_CHAT: +#if defined(GENERALS_ONLINE) + if (holdPassedOnChat((NetChatCommandMsg *)msg)) { + return FALSE; // shown once the author's signature checks out + } +#endif processChat((NetChatCommandMsg *)msg); return FALSE; +#if defined(GENERALS_ONLINE) + case NETCOMMANDTYPE_SIGNATURES: + processSignatures((NetSignaturesCommandMsg *)msg); + return FALSE; +#endif + case NETCOMMANDTYPE_FILE: processFile((NetFileCommandMsg *)msg); return FALSE; @@ -1077,6 +1685,14 @@ void ConnectionManager::processFrameInfo(NetFrameCommandMsg *msg) { if (playerID < MAX_SLOTS) { if (m_frameData[playerID] != nullptr) { +#if defined(GENERALS_ONLINE) + if (msg->hasSignature()) { + storeFrameSignature((Int)playerID, msg->getExecutionFrame(), msg->getSignature(), m_currentSenderSlot == (Int)playerID); + } + if (m_currentSenderSlot >= 0 && (Int)playerID != m_currentSenderSlot) { + noteFramePassedOn((Int)playerID, msg->getExecutionFrame(), m_currentSenderSlot, m_frameData[playerID]->getFrameCommandCount(msg->getExecutionFrame()) != msg->getCommandCount()); + } +#endif // DEBUG_LOG(("ConnectionManager::processFrameInfo - player %d, frame %d, command count %d, received on frame %d", playerID, msg->getExecutionFrame(), msg->getCommandCount(), TheGameLogic->getFrame())); #if defined(GENERALS_ONLINE) // a redundant copy of a frame that was already seen says nothing about how early frames arrive @@ -1391,6 +2007,11 @@ void ConnectionManager::sendRemoteCommand(NetCommandRef *msg) { if (IsCommandSynchronized(msg->getCommand()->getNetCommandType())) { DEBUG_LOG_LEVEL(DEBUG_LEVEL_NET, ("ConnectionManager::sendRemoteCommand - adding net command of type %s to player %d for frame %d", GetNetCommandTypeAsString(msg->getCommand()->getNetCommandType()), msg->getCommand()->getPlayerID(), msg->getCommand()->getExecutionFrame())); isNew = frameDataMgr->addNetCommandMsg(msg->getCommand()); +#if defined(GENERALS_ONLINE) + if (isNew && m_currentSenderSlot >= 0 && (Int)playerID != m_currentSenderSlot) { + noteFramePassedOn((Int)playerID, msg->getCommand()->getExecutionFrame(), m_currentSenderSlot, TRUE); + } +#endif } } @@ -1883,7 +2504,14 @@ void ConnectionManager::processFrameTick(UnsignedInt frame) { DEBUG_LOG_LEVEL(DEBUG_LEVEL_NET, ("ConnectionManager::processFrameTick - sending frame info for frame %d, ID %d, command count %d", frame, msg->getID(), commandCount)); +#if defined(GENERALS_ONLINE) + signLocalFrame(msg); +#endif + sendLocalCommand(msg, 0xff & ~(1 << m_localSlot)); +#if defined(GENERALS_ONLINE) + m_lastFrameInfoSent = frame; +#endif msg->detach(); } @@ -2052,6 +2680,11 @@ Bool ConnectionManager::allCommandsReady(UnsignedInt frame, Bool justTesting /* requestFrameDataResend(i, frame); retval = FALSE; } +#if defined(GENERALS_ONLINE) + else if (i != (Int)m_localSlot && !isPassedOnFrameAuthentic(i, frame)) { + retval = FALSE; + } +#endif } } @@ -2060,6 +2693,15 @@ Bool ConnectionManager::allCommandsReady(UnsignedInt frame, Bool justTesting /* for (i = 0; i < MAX_SLOTS; ++i) { if ((m_frameData[i] != nullptr) && (i != m_localSlot)) { m_frameData[i]->resetFrame(frame, FALSE); +#if defined(GENERALS_ONLINE) + // the resent copy is judged by where it comes from, a signature that came already stays + FrameSignature *entry = findFrameSignature(i, frame); + if (entry != nullptr) { + entry->relayed = FALSE; + entry->verified = FALSE; + entry->waitingSince = 0; + } +#endif } } } @@ -2225,13 +2867,12 @@ PlayerLeaveCode ConnectionManager::disconnectPlayer(Int slot) { // } if (slot == m_packetRouterSlot) { - Int index = 0; - while ((index < MAX_SLOTS) && (m_packetRouterFallback[index] != m_packetRouterSlot)) { - ++index; - } - ++index; - if (index < MAX_SLOTS) { - m_packetRouterSlot = m_packetRouterFallback[index]; +#if defined(GENERALS_ONLINE) + m_formerRouterMask |= (1u << m_packetRouterSlot); +#endif + const UnsignedInt nextRouter = getNextPacketRouterSlot(m_packetRouterSlot); + if (nextRouter < (UnsignedInt)MAX_SLOTS) { + m_packetRouterSlot = nextRouter; } else { DEBUG_LOG(("ConnectionManager::disconnectPlayer - packet router had no valid fallback, defaulting to local slot %d", m_localSlot)); m_packetRouterSlot = m_localSlot; @@ -2620,6 +3261,9 @@ void ConnectionManager::sendChat(UnicodeString text, Int playerMask, UnsignedInt DEBUG_LOG_LEVEL(DEBUG_LEVEL_NET, ("Chat message has ID of %d, mask of %8.8X, text of %ls", msg->getID(), msg->getPlayerMask(), msg->getText().str())); sendLocalCommand(msg, 0xff ^ (1 << m_localSlot)); +#if defined(GENERALS_ONLINE) + signLocalChat(msg); +#endif processChat(msg); msg->detach(); @@ -2946,6 +3590,11 @@ void ConnectionManager::sendSingleFrameToPlayer(UnsignedInt playerID, UnsignedIn } msg->setPlayerID(i); DEBUG_LOG_LEVEL(DEBUG_LEVEL_NET, ("ConnectionManager::sendFrameDataToPlayer - sending frame info from player %d to player %d for frame %d with command count %d and ID %d and relay %d", i, playerID, msg->getExecutionFrame(), msg->getCommandCount(), msg->getID(), relay)); +#if defined(GENERALS_ONLINE) + if (i != (Int)m_localSlot) { + attachStoredSignature(i, msg); + } +#endif sendLocalCommandDirect(msg, relay); msg->detach(); } diff --git a/Core/GameEngine/Source/GameNetwork/NetCommandMsg.cpp b/Core/GameEngine/Source/GameNetwork/NetCommandMsg.cpp index a931a33a554..2dead32b164 100644 --- a/Core/GameEngine/Source/GameNetwork/NetCommandMsg.cpp +++ b/Core/GameEngine/Source/GameNetwork/NetCommandMsg.cpp @@ -27,6 +27,7 @@ #include "GameNetwork/NetCommandMsg.h" #include "GameNetwork/NetPacketStructs.h" +#include "GameNetwork/NetCommandRef.h" #include "GameNetwork/GameMessageParser.h" #include "Common/GameState.h" #include "Common/PlayerList.h" @@ -346,6 +347,24 @@ NetAckStage2CommandMsg::~NetAckStage2CommandMsg() { NetFrameCommandMsg::NetFrameCommandMsg() { m_commandCount = 0; m_commandType = NETCOMMANDTYPE_FRAMEINFO; + m_hasSignature = FALSE; + memset(m_signature, 0, sizeof(m_signature)); +} + +void NetFrameCommandMsg::setSignature(const UnsignedByte *signature) { + memcpy(m_signature, signature, sizeof(m_signature)); + m_hasSignature = TRUE; +} + +// A frame info that came in its signed form is read with its signature and is a plain frame info from then on. +size_t NetFrameCommandMsg::readMessageData(NetCommandRef& ref, NetPacketBuf buf) const { + if (m_commandType == NETCOMMANDTYPE_SIGNEDFRAMEINFO) { + NetFrameCommandMsg *frameMsg = static_cast(ref.getCommand()); + const size_t size = NetPacketSignedFrameCommandData::readIntoFrame(frameMsg, buf); + frameMsg->setNetCommandType(NETCOMMANDTYPE_FRAMEINFO); + return size; + } + return SmallNetPacketFrameCommand::CommandData::readMessage(ref, buf); } /** @@ -613,6 +632,9 @@ NetCommandMsg::Select NetDisconnectKeepAliveCommandMsg::getSmallNetPacketSelect( NetDisconnectPlayerCommandMsg::NetDisconnectPlayerCommandMsg() { m_commandType = NETCOMMANDTYPE_DISCONNECTPLAYER; m_disconnectSlot = 0; + m_disconnectFrame = 0; + m_hasSignature = FALSE; + memset(m_signature, 0, sizeof(m_signature)); } /** @@ -635,6 +657,11 @@ void NetDisconnectPlayerCommandMsg::setDisconnectSlot(UnsignedByte slot) { m_disconnectSlot = slot; } +void NetDisconnectPlayerCommandMsg::setSignature(const UnsignedByte *signature) { + memcpy(m_signature, signature, sizeof(m_signature)); + m_hasSignature = TRUE; +} + /** * Sets the disconnect frame */ @@ -1253,3 +1280,71 @@ NetCommandMsg::Select NetTimeOutGameStartCommandMsg::getSmallNetPacketSelect() c select.useCommandId = 1; return select; } + +//------------------------- +// NetSignedFrameCommandMsg +//------------------------- +NetSignedFrameCommandMsg::NetSignedFrameCommandMsg() { + m_commandType = NETCOMMANDTYPE_SIGNEDFRAMEINFO; + m_commandCount = 0; + memset(m_signature, 0, sizeof(m_signature)); +} + +NetSignedFrameCommandMsg::NetSignedFrameCommandMsg(const NetFrameCommandMsg *frameMsg) { + m_commandType = NETCOMMANDTYPE_SIGNEDFRAMEINFO; + m_executionFrame = frameMsg->getExecutionFrame(); + m_playerID = frameMsg->getPlayerID(); + m_id = frameMsg->getID(); + m_commandCount = frameMsg->getCommandCount(); + memcpy(m_signature, frameMsg->getSignature(), sizeof(m_signature)); +} + +NetSignedFrameCommandMsg::~NetSignedFrameCommandMsg() { +} + +void NetSignedFrameCommandMsg::setSignature(const UnsignedByte *signature) { + memcpy(m_signature, signature, sizeof(m_signature)); +} + +NetCommandMsg::Select NetSignedFrameCommandMsg::getSmallNetPacketSelect() const { + Select select; + select.useCommandType = 1; + select.useRelay = 1; + select.useFrame = 1; + select.usePlayerId = 1; + select.useCommandId = 1; + return select; +} + +//------------------------- +// NetSignaturesCommandMsg +//------------------------- +NetSignaturesCommandMsg::NetSignaturesCommandMsg() { + m_commandType = NETCOMMANDTYPE_SIGNATURES; + m_entryCount = 0; + memset(m_entries, 0, sizeof(m_entries)); +} + +NetSignaturesCommandMsg::~NetSignaturesCommandMsg() { +} + +Bool NetSignaturesCommandMsg::addEntry(UnsignedInt frame, UnsignedShort commandID, const UnsignedByte *signature) { + if (m_entryCount >= MAX_ENTRIES) { + return FALSE; + } + Entry &entry = m_entries[m_entryCount++]; + entry.frame = frame; + entry.commandId = commandID; + memcpy(entry.signature, signature, sizeof(entry.signature)); + return TRUE; +} + +NetCommandMsg::Select NetSignaturesCommandMsg::getSmallNetPacketSelect() const { + Select select; + select.useCommandType = 1; + select.useRelay = 1; + select.useFrame = 0; + select.usePlayerId = 1; + select.useCommandId = 1; + return select; +} diff --git a/Core/GameEngine/Source/GameNetwork/NetCommandWrapperList.cpp b/Core/GameEngine/Source/GameNetwork/NetCommandWrapperList.cpp index 79aa405544d..db9422ff5c4 100644 --- a/Core/GameEngine/Source/GameNetwork/NetCommandWrapperList.cpp +++ b/Core/GameEngine/Source/GameNetwork/NetCommandWrapperList.cpp @@ -29,6 +29,7 @@ #include "GameNetwork/NetCommandWrapperList.h" #include "GameNetwork/NetPacket.h" +#include "../NGMP_include.h" //////////////////////////////////////////////////////////////////////////////////////////////////// ////// NetCommandWrapperListNode /////////////////////////////////////////////////////////////////// @@ -233,6 +234,19 @@ NetCommandList * NetCommandWrapperList::getReadyCommands() next = temp->m_next; if (temp->isComplete()) { NetCommandRef *msg = NetPacket::ConstructNetCommandMsgFromRawData(temp->getRawData(), temp->getRawDataLength()); + +#if defined(GENERALS_ONLINE) + // the wrapper was checked against its sender, the command inside has to name the same player + if (msg != nullptr && msg->getCommand() != nullptr && msg->getCommand()->getPlayerID() != temp->getPlayerID()) { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[SECURITY] dropped a wrapped command claiming slot %d inside a wrapper from slot %d", (Int)msg->getCommand()->getPlayerID(), (Int)temp->getPlayerID()); + deleteInstance(msg); + noteCompleted(temp->getPlayerID(), temp->getCommandID()); + removeFromList(temp); + temp = next; + continue; + } +#endif + NetCommandRef *ret = retlist->addMessage(msg->getCommand()); ret->setRelay(msg->getRelay()); diff --git a/Core/GameEngine/Source/GameNetwork/NetPacketStructs.cpp b/Core/GameEngine/Source/GameNetwork/NetPacketStructs.cpp index 500fcdbee88..846d1443738 100644 --- a/Core/GameEngine/Source/GameNetwork/NetPacketStructs.cpp +++ b/Core/GameEngine/Source/GameNetwork/NetPacketStructs.cpp @@ -241,6 +241,14 @@ NetCommandMsg *SmallNetPacketCommandBase::constructNetCommandMsg(const CommandBa case NETCOMMANDTYPE_FRAMERESENDREQUEST: msg = newInstance(NetFrameResendRequestCommandMsg); break; + case NETCOMMANDTYPE_SIGNEDFRAMEINFO: + // read back into a plain frame info that keeps the signature, see NetFrameCommandMsg::readMessageData + msg = newInstance(NetFrameCommandMsg); + msg->setNetCommandType(NETCOMMANDTYPE_SIGNEDFRAMEINFO); + break; + case NETCOMMANDTYPE_SIGNATURES: + msg = newInstance(NetSignaturesCommandMsg); + break; default: DEBUG_CRASH(("SmallNetPacketCommandBase::constructNetCommandMsg: Unexpected command type '%d' encountered.", commandType)); return nullptr; @@ -538,6 +546,10 @@ size_t NetPacketDisconnectPlayerCommandData::copyBytes(UnsignedByte *buffer, con FixedData data; data.disconnectSlot = cmdMsg->getDisconnectSlot(); data.disconnectFrame = cmdMsg->getDisconnectFrame(); +#if !RETAIL_COMPATIBLE_NETWORKING + data.hasSignature = cmdMsg->hasSignature() ? 1 : 0; + memcpy(data.signature, cmdMsg->getSignature(), sizeof(data.signature)); +#endif return network::writeObject(buffer, data); } @@ -548,10 +560,19 @@ size_t NetPacketDisconnectPlayerCommandData::readMessage(NetCommandRef &ref, Net FixedData data; data.disconnectSlot = 0; data.disconnectFrame = 0; +#if !RETAIL_COMPATIBLE_NETWORKING + data.hasSignature = 0; + memset(data.signature, 0, sizeof(data.signature)); +#endif size_t size = network::readObject(data, buf); cmdMsg->setDisconnectSlot(data.disconnectSlot); cmdMsg->setDisconnectFrame(data.disconnectFrame); +#if !RETAIL_COMPATIBLE_NETWORKING + if (data.hasSignature != 0) { + cmdMsg->setSignature(data.signature); + } +#endif return size; } @@ -1428,3 +1449,112 @@ size_t NetPacketFrameResendRequestCommandBase::copyBytes(UnsignedByte *buffer, c return network::writeObject(buffer, base); } + +//////////////////////////////////////////////////////////////////////////////// +// NetPacketSignedFrameCommand +//////////////////////////////////////////////////////////////////////////////// + +size_t NetPacketSignedFrameCommandData::copyBytes(UnsignedByte *buffer, const NetCommandRef &ref) +{ + const CommandMsg *cmdMsg = static_cast(ref.getCommand()); + FixedData data; + data.commandCount = cmdMsg->getCommandCount(); + memcpy(data.signature, cmdMsg->getSignature(), NET_SIGNATURE_SIZE); + + return network::writeObject(buffer, data); +} + +size_t NetPacketSignedFrameCommandData::readMessage(NetCommandRef &ref, NetPacketBuf buf) +{ + CommandMsg *cmdMsg = static_cast(ref.getCommand()); + FixedData data; + memset(&data, 0, sizeof(data)); + + size_t size = network::readObject(data, buf); + cmdMsg->setCommandCount(data.commandCount); + cmdMsg->setSignature(data.signature); + + return size; +} + +size_t NetPacketSignedFrameCommandData::readIntoFrame(NetFrameCommandMsg *frameMsg, NetPacketBuf buf) +{ + FixedData data; + memset(&data, 0, sizeof(data)); + + size_t size = network::readObject(data, buf); + frameMsg->setCommandCount(data.commandCount); + frameMsg->setSignature(data.signature); + + return size; +} + +size_t NetPacketSignedFrameCommandBase::copyBytes(UnsignedByte *buffer, const NetCommandRef &ref) +{ + const NetCommandMsg *msg = ref.getCommand(); + CommandBase base; + base.commandType.commandType = msg->getNetCommandType(); + base.relay.relay = ref.getRelay(); + base.frame.frame = msg->getExecutionFrame(); + base.playerId.playerId = msg->getPlayerID(); + base.commandId.commandId = msg->getID(); + + return network::writeObject(buffer, base); +} + +//////////////////////////////////////////////////////////////////////////////// +// NetPacketSignaturesCommand +//////////////////////////////////////////////////////////////////////////////// + +size_t NetPacketSignaturesCommandData::getSize(const NetCommandMsg &msg) +{ + const CommandMsg *cmdMsg = static_cast(&msg); + return sizeof(UnsignedByte) + cmdMsg->getEntryCount() * sizeof(Entry); +} + +size_t NetPacketSignaturesCommandData::copyBytes(UnsignedByte *buffer, const NetCommandRef &ref) +{ + const CommandMsg *cmdMsg = static_cast(ref.getCommand()); + + size_t size = 0; + size += network::writePrimitive(buffer + size, (UnsignedByte)cmdMsg->getEntryCount()); + for (Int i = 0; i < cmdMsg->getEntryCount(); ++i) + { + size += network::writeObject(buffer + size, cmdMsg->getEntry(i)); + } + return size; +} + +size_t NetPacketSignaturesCommandData::readMessage(NetCommandRef &ref, NetPacketBuf buf) +{ + CommandMsg *cmdMsg = static_cast(ref.getCommand()); + UnsignedByte count = 0; + + size_t size = 0; + size += network::readObject(count, buf.offset(size)); + for (Int i = 0; i < (Int)count; ++i) + { + Entry entry; + memset(&entry, 0, sizeof(entry)); + const size_t read = network::readObject(entry, buf.offset(size)); + if (read < sizeof(entry)) + { + break; + } + size += read; + cmdMsg->addEntry(entry.frame, entry.commandId, entry.signature); + } + return size; +} + +size_t NetPacketSignaturesCommandBase::copyBytes(UnsignedByte *buffer, const NetCommandRef &ref) +{ + const NetCommandMsg *msg = ref.getCommand(); + CommandBase base; + base.commandType.commandType = msg->getNetCommandType(); + base.relay.relay = ref.getRelay(); + base.playerId.playerId = msg->getPlayerID(); + base.commandId.commandId = msg->getID(); + + return network::writeObject(buffer, base); +} diff --git a/Core/GameEngine/Source/GameNetwork/Network.cpp b/Core/GameEngine/Source/GameNetwork/Network.cpp index d5e6b6ada93..ea6d13f9f83 100644 --- a/Core/GameEngine/Source/GameNetwork/Network.cpp +++ b/Core/GameEngine/Source/GameNetwork/Network.cpp @@ -52,6 +52,7 @@ #include "Common/Recorder.h" #include "GameClient/MessageBox.h" #include "../NGMP_include.h" +#include "../ngmp_interfaces.h" #if defined(DEBUG_CRC) && !RETAIL_COMPATIBLE_NETWORKING @@ -114,6 +115,8 @@ class Network : public NetworkInterface #if defined(GENERALS_ONLINE) virtual void SeedLatencyData(int latency); + virtual void requireMatchKeys() override { m_matchKeysRequired = TRUE; } + virtual Bool matchKeysRequired() const override { return m_matchKeysRequired; } #endif void setLocalAddress(UnsignedInt ip, UnsignedInt port); @@ -121,6 +124,9 @@ class Network : public NetworkInterface UnsignedInt getFrameRate(void) { return m_frameRate; } UnsignedInt getPacketArrivalCushion(void); ///< Returns the smallest packet arrival cushion since this was last called. Bool isFrameDataReady(void); +#if defined(GENERALS_ONLINE) + virtual Bool isGameExitPending() const override { return m_localStatus == NETLOCALSTATUS_POSTGAME; } +#endif virtual Bool isStalling(); void parseUserList(const GameInfo* game); void startGame(void); ///< Sets the network game frame counter to -1 @@ -239,6 +245,13 @@ class Network : public NetworkInterface #if defined(RTS_DEBUG) Bool m_networkOn; #endif + +#if defined(GENERALS_ONLINE) + Bool m_matchKeysRequired; + Bool m_matchStartFailed; + Bool startPrerequisitesReady() const; + void failMatchStart(); ///< Leaves rather than starting without authentication. +#endif }; UnsignedInt Network::getPingFrame() @@ -311,6 +324,15 @@ Network::~Network() */ Bool Network::deinit() { +#if defined(GENERALS_ONLINE) + if (m_matchKeysRequired) { + NetworkMesh* mesh = NGMP_OnlineServicesManager::GetNetworkMesh(); + if (mesh != nullptr) { + mesh->EndMatchKeys(); + } + m_matchKeysRequired = FALSE; + } +#endif if (m_conMgr) { m_conMgr->destroyGameMessages(); @@ -357,6 +379,11 @@ void Network::init() m_localStatus = NETLOCALSTATUS_PREGAME; +#if defined(GENERALS_ONLINE) + m_matchKeysRequired = FALSE; + m_matchStartFailed = FALSE; +#endif + QueryPerformanceFrequency((LARGE_INTEGER*)&m_perfCountFreq); m_nextFrameTime = 0; m_sawCRCMismatch = FALSE; @@ -389,6 +416,7 @@ void Network::SeedLatencyData(int highestLatency) m_conMgr->SeedLatencyData(highestLatency); } + #endif #if defined(GENERALS_ONLINE) @@ -545,6 +573,13 @@ Bool Network::processCommand(GameMessage* msg) if (TheGameLogic->getFrame() == 1) { #else if (TheGameLogic->getFrame() == 1) { +#endif +#if defined(GENERALS_ONLINE) + // Pregame updates drive loading; only entering gameplay requires completed setup. + if (!startPrerequisitesReady()) { + failMatchStart(); + return TRUE; + } #endif m_localStatus = NETLOCALSTATUS_INGAME; NetCommandList *netcmdlist = m_conMgr->getFrameCommandList(0); // clear out frame 0 since we skipped it @@ -759,7 +794,20 @@ void Network::update() } #endif +#if defined(GENERALS_ONLINE) + if (m_matchStartFailed) { + // Discard gameplay input, but leave the queued exit command for normal teardown. + GetCommandsFromCommandList(); + return; + } +#endif + GetCommandsFromCommandList(); // Remove commands from TheCommandList and send them to the connection manager. +#if defined(GENERALS_ONLINE) + if (m_matchStartFailed) { + return; + } +#endif if (m_conMgr != NULL) { if (m_localStatus == NETLOCALSTATUS_INGAME) { #if defined(GENERALS_ONLINE_RUN_FAST) @@ -777,10 +825,23 @@ void Network::update() } liteupdate(); +#if defined(GENERALS_ONLINE) + if (m_matchStartFailed) { + return; + } +#endif if (m_localStatus == NETLOCALSTATUS_LEFT) {// || (m_localStatus == NETLOCALSTATUS_LEAVING)) { endOfGameCheck(); +#if defined(GENERALS_ONLINE) + return; +#endif } +#if defined(GENERALS_ONLINE) + if (m_localStatus == NETLOCALSTATUS_POSTGAME) { + return; + } +#endif if (AllCommandsReady(TheGameLogic->getFrame())) { // If all the commands are ready for the next frame... m_conMgr->handleAllCommandsReady(); @@ -804,6 +865,11 @@ void Network::update() } void Network::liteupdate() { +#if defined(GENERALS_ONLINE) + if (m_matchStartFailed) { + return; + } +#endif #if defined(RTS_DEBUG) if (m_networkOn == FALSE) { @@ -887,7 +953,11 @@ Bool Network::timeForNewFrame() { * Returns true if the game commands for the next frame have been put on the command list. */ Bool Network::isFrameDataReady() { +#if defined(GENERALS_ONLINE) + return m_frameDataReady; +#else return (m_frameDataReady || (m_localStatus == NETLOCALSTATUS_LEFT)); +#endif } Bool Network::isStalling() @@ -1055,8 +1125,43 @@ void Network::updateLoadProgress(Int percent) } } +#if defined(GENERALS_ONLINE) +Bool Network::startPrerequisitesReady() const +{ + if (m_matchStartFailed) { + return FALSE; + } + if (m_matchKeysRequired) { + NetworkMesh* mesh = NGMP_OnlineServicesManager::GetNetworkMesh(); + return mesh != nullptr && mesh->GetMatchKeys().IsReady(); + } + return TRUE; +} + +void Network::failMatchStart() +{ + if (m_matchStartFailed) { + return; + } + NetworkMesh* mesh = NGMP_OnlineServicesManager::GetNetworkMesh(); + NetworkLog(ELogVerbosity::LOG_RELEASE, "[START] Missing match keys, leaving (keys=%zu/%zu)", + mesh != nullptr ? mesh->GetMatchKeys().GetPeerKeyCount() : 0, + mesh != nullptr ? mesh->GetMatchKeys().GetExpectedPeers() : 0); + m_matchStartFailed = TRUE; + quitGame(); + if (m_conMgr != nullptr) { + TheGameLogic->processProgressComplete((Int)m_conMgr->getLocalPlayerID()); + } +} +#endif + void Network::loadProgressComplete() { +#if defined(GENERALS_ONLINE) + if (m_matchStartFailed) { + return; + } +#endif if (m_conMgr != nullptr) { m_conMgr->loadProgressComplete(); } diff --git a/Core/GameEngine/Source/GameNetwork/NetworkUtil.cpp b/Core/GameEngine/Source/GameNetwork/NetworkUtil.cpp index 798e2c8055b..770f7072cb4 100644 --- a/Core/GameEngine/Source/GameNetwork/NetworkUtil.cpp +++ b/Core/GameEngine/Source/GameNetwork/NetworkUtil.cpp @@ -133,6 +133,8 @@ Bool DoesCommandRequireACommandID(NetCommandType type) case NETCOMMANDTYPE_DISCONNECTVOTE: case NETCOMMANDTYPE_DISCONNECTFRAME: case NETCOMMANDTYPE_DISCONNECTSCREENOFF: + case NETCOMMANDTYPE_SIGNEDFRAMEINFO: + case NETCOMMANDTYPE_SIGNATURES: return TRUE; default: return FALSE; @@ -222,6 +224,8 @@ const char* GetNetCommandTypeAsString(NetCommandType type) CASE_LABEL(NETCOMMANDTYPE_DISCONNECTFRAME) CASE_LABEL(NETCOMMANDTYPE_DISCONNECTSCREENOFF) CASE_LABEL(NETCOMMANDTYPE_DISCONNECTEND) + CASE_LABEL(NETCOMMANDTYPE_SIGNEDFRAMEINFO) + CASE_LABEL(NETCOMMANDTYPE_SIGNATURES) default: DEBUG_CRASH(("Unhandled NetCommandType in GetNetCommandTypeAsString")); return ""; diff --git a/Generals/Code/GameEngine/Source/Common/System/MemoryInit.cpp b/Generals/Code/GameEngine/Source/Common/System/MemoryInit.cpp index 4c941ae755a..cc47ff54ebc 100644 --- a/Generals/Code/GameEngine/Source/Common/System/MemoryInit.cpp +++ b/Generals/Code/GameEngine/Source/Common/System/MemoryInit.cpp @@ -565,6 +565,8 @@ static PoolSizeRec sizes[] = { "NetProgressCommandMsg", 32, 32 }, { "NetLoadCompleteCommandMsg", 32, 32 }, { "NetTimeOutGameStartCommandMsg", 32, 32 }, + { "NetSignedFrameCommandMsg", 32, 32 }, + { "NetSignaturesCommandMsg", 32, 32 }, { "NetWrapperCommandMsg", 32, 32 }, { "NetFileCommandMsg", 32, 32 }, { "NetFileAnnounceCommandMsg", 32, 32 }, diff --git a/GeneralsMD/Code/GameEngine/CMakeLists.txt b/GeneralsMD/Code/GameEngine/CMakeLists.txt index d79fb756219..3b5f35c3278 100644 --- a/GeneralsMD/Code/GameEngine/CMakeLists.txt +++ b/GeneralsMD/Code/GameEngine/CMakeLists.txt @@ -1141,6 +1141,7 @@ set(GAMEENGINE_SRC Include/GameNetwork/GeneralsOnline/Vendor/DiscordRPC/discord_rpc.h Include/GameNetwork/GeneralsOnline/NetworkBitstream.h Include/GameNetwork/GeneralsOnline/ConnectionRating.h + Include/GameNetwork/GeneralsOnline/MatchKeys.h Include/GameNetwork/GeneralsOnline/NetworkMesh.h Include/GameNetwork/GeneralsOnline/NetBytes.h Include/GameNetwork/GeneralsOnline/PacketFeedback.h @@ -1174,6 +1175,7 @@ set(GAMEENGINE_SRC Source/GameNetwork/GeneralsOnline/HTTP/HTTPManager.cpp Source/GameNetwork/GeneralsOnline/HTTP/HTTPRequest.cpp Source/GameNetwork/GeneralsOnline/ConnectionRating.cpp + Source/GameNetwork/GeneralsOnline/MatchKeys.cpp Source/GameNetwork/GeneralsOnline/NetworkMesh.cpp Source/GameNetwork/GeneralsOnline/PacketFeedback.cpp Source/GameNetwork/GeneralsOnline/OnlineServices_Init.cpp diff --git a/GeneralsMD/Code/GameEngine/Include/GameLogic/GameLogic.h b/GeneralsMD/Code/GameEngine/Include/GameLogic/GameLogic.h index c3a66f6c326..51c3b6c9e30 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameLogic/GameLogic.h +++ b/GeneralsMD/Code/GameEngine/Include/GameLogic/GameLogic.h @@ -223,6 +223,9 @@ class GameLogic : public SubsystemInterface, public Snapshot UnsignedInt getFrameObjectsChangedTriggerAreas() {return m_frameObjectsChangedTriggerAreas;} void exitGame(); +#if defined(GENERALS_ONLINE) + void processPendingGameExit(); +#endif void quit(Bool toDesktop); void clearGameData(Bool showScoreScreen = TRUE); ///< Clear the game data void closeWindows(); diff --git a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/MatchKeys.h b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/MatchKeys.h new file mode 100644 index 00000000000..48666f5cae3 --- /dev/null +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/MatchKeys.h @@ -0,0 +1,77 @@ +#pragma once + +#include +#include +#include +#include +#include + +typedef struct evp_pkey_st EVP_PKEY; + +// Ed25519 keys: each client makes a key pair when it joins or creates a lobby and announces the public key over each peer's own connection, +// a key counts for the user whose connection it came on. A match binds to the keys when it starts: the match id and the roster go into everything +// that is signed, so a signature is good for that match only. See ConnectionManager for what is signed. +// +// Channel messages, reliable: +// announcement: u8 kind (2), i64 user id, 32 bytes public key +// status: u8 kind (3), u8 count, count x i64 user id of the peers whose key the sender holds +class MatchKeys +{ +public: + enum { PUBLIC_KEY_SIZE = 32, SIGNATURE_SIZE = 64, ANNOUNCEMENT_SIZE = 1 + 8 + PUBLIC_KEY_SIZE, MAX_STATUS_USERS = 16 }; + enum { KIND_ANNOUNCEMENT = 2, KIND_STATUS = 3 }; + + MatchKeys(); + ~MatchKeys(); + + bool BeginLobby(int64_t localUserID); + /// Drops the key pair and every peer key, call when the lobby is left. + void End(); + bool HasLocalKey() const { return m_privateKey != nullptr; } + int64_t GetLocalUserID() const { return m_localUserID; } + + bool BeginMatch(uint64_t matchID, const std::set& expectedPeers); + void EndMatch(); + bool IsActive() const { return m_active; } + + std::vector BuildAnnouncement() const; + /// The keys held, the host reads it to see that every pair has exchanged keys. + std::vector BuildStatus() const; + + /// Takes an announcement that arrived on the connection of connectionUserID. False and a reason when rejected, changed when a key was added or replaced. + bool OnAnnouncement(int64_t connectionUserID, const uint8_t* data, size_t len, const char*& reason, bool& changed); + /// Takes a status that arrived on the connection of connectionUserID. + bool OnStatus(int64_t connectionUserID, const uint8_t* data, size_t len, const char*& reason, bool& changed); + + /// Drops the key of a peer that left the lobby, never one of a bound match roster. + bool DropPeer(int64_t userID); + + size_t GetPeerKeyCount() const { return m_peerKeys.size(); } + size_t GetExpectedPeers() const { return m_expectedPeers.size(); } + /// True when bound and a key is held for every peer of the roster. + bool IsReady() const; + bool ExpectsPeer(int64_t userID) const { return m_expectedPeers.count(userID) != 0; } + bool HasPeerKey(int64_t userID) const { return m_peerKeys.find(userID) != m_peerKeys.end(); } + std::vector GetPeerKeyUsers() const; + /// True when peer reported holding the keys of every user in needed, peer itself excepted. + bool PeerHoldsAll(int64_t peer, const std::set& needed) const; + uint64_t GetMatchID() const { return m_matchID; } + + bool Sign(const uint8_t* data, size_t len, uint8_t* signature) const; + /// Checks a signature of userID over data, false without a key of that user or when not bound to a match. + bool Verify(int64_t userID, const uint8_t* data, size_t len, const uint8_t* signature); + +private: + void FreePeerKeyObject(int64_t userID); + void FreePeerKeyObjects(); + + bool m_active; + uint64_t m_matchID; + int64_t m_localUserID; + std::set m_expectedPeers; + EVP_PKEY* m_privateKey; + std::array m_publicKey; + std::map> m_peerKeys; + std::map> m_peerHolds; ///< Per peer, the users whose key it said it holds. + std::map m_peerKeyObjects; ///< The peer keys parsed for OpenSSL, made on first use. +}; diff --git a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NGMPGame.h b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NGMPGame.h index 1275b1bc5c8..11281e7cd92 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NGMPGame.h +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NGMPGame.h @@ -84,7 +84,7 @@ class NGMPGame : public GameInfo for (Int slotNum = 0; slotNum < MAX_SLOTS; ++slotNum) { const NGMPGameSlot* pSlot = (const NGMPGameSlot*)getConstSlot(slotNum); - if (pSlot != nullptr && pSlot->m_userID == userID) + if (pSlot != nullptr && pSlot->isHuman() && pSlot->m_userID == userID) { return slotNum; } @@ -92,6 +92,12 @@ class NGMPGame : public GameInfo return -1; } + int64_t getUserOfSlot(Int slotNum) const + { + const NGMPGameSlot* pSlot = (slotNum >= 0 && slotNum < MAX_SLOTS) ? (const NGMPGameSlot*)getConstSlot(slotNum) : nullptr; + return (pSlot != nullptr && pSlot->isHuman()) ? pSlot->m_userID : -1; + } + bool HasCommittedOutcome() const { return m_bHasCommittedOutcome; } void SetHasCommittedOutcome() { m_bHasCommittedOutcome = true; } diff --git a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NGMP_include.h b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NGMP_include.h index b22c0ced24e..e439210828b 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NGMP_include.h +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NGMP_include.h @@ -11,7 +11,8 @@ enum class ENetworkChannel : BYTE { NETWORK_CHANNEL_GAME = 0, NETWORK_CHANNEL_AC = 1, - NETWORK_CHANNEL_PING = 2 + NETWORK_CHANNEL_PING = 2, + NETWORK_CHANNEL_KEYS = 3 // match signing keys, sent reliably }; #define CHECK_MAIN_THREAD assert(std::this_thread::get_id() == NGMP_OnlineServicesManager::g_MainThreadID); diff --git a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NetBytes.h b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NetBytes.h index c173e617a29..f7e89df57b4 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NetBytes.h +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NetBytes.h @@ -14,3 +14,21 @@ inline uint16_t ReadU16(const uint8_t* in) { return static_cast(in[0] | (in[1] << 8)); } + +inline void WriteU64(uint8_t* out, uint64_t value) +{ + for (int i = 0; i < 8; ++i) + { + out[i] = static_cast(value >> (8 * i)); + } +} + +inline uint64_t ReadU64(const uint8_t* in) +{ + uint64_t value = 0; + for (int i = 0; i < 8; ++i) + { + value |= static_cast(in[i]) << (8 * i); + } + return value; +} diff --git a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NetworkMesh.h b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NetworkMesh.h index e9df3a2e580..d557c4f35c9 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NetworkMesh.h +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NetworkMesh.h @@ -10,6 +10,7 @@ #include "GameNetwork/ConnectionStats.h" #include "GameNetwork/GeneralsOnline/ConnectionRating.h" #include "GameNetwork/GeneralsOnline/PacketFeedback.h" +#include "GameNetwork/GeneralsOnline/MatchKeys.h" class NetRoom_ChatMessagePacket; @@ -117,6 +118,9 @@ class PlayerConnection int SendEnveloped(ENetworkChannel channel, const uint8_t* payload, size_t len, int sendFlags); ///< returns an EResult bool GetOutgoingLoss(int windowMs, PacketFeedback::Summary& summary); ///< our packets to the peer, false without feedback bool GetIncomingLoss(int windowMs, PacketFeedback::Summary& summary); ///< the peer's packets to us + + void HandleKeyMessage(const uint8_t* data, size_t len); ///< A match key announcement arrived on this connection. + int GetJitter(); int ComputeConnectionRating(); ///< 1..5 bars, -1 while it is not known yet; cached for 250 ms. int UpdateConnectionRating(); @@ -200,6 +204,8 @@ class NetworkMesh void RegisterConnectivity(int64_t userID); void UpdateConnectivity(PlayerConnection* connection); + MatchKeys m_matchKeys; + std::function m_cbOnConnected = nullptr; void RegisterForConnectionEvents(std::function cb) { @@ -227,6 +233,23 @@ class NetworkMesh return highestLatency; } + // The signing keys, see MatchKeys.h + bool BeginLobbyKeys(); ///< Makes this client's key, call when it joins or creates a lobby. + void TickLobbyKeys(int64_t nowMs); ///< Announces the key to the peers that lack it and tells them which keys we hold. + bool BeginMatchKeys(); ///< Binds the keys to the match that is starting, false when a key of the roster is missing. + void EndMatchKeys(); ///< Unbinds from the match, the lobby keys stay. + void OnKeyAnnouncement(int64_t connectionUserID, const uint8_t* data, size_t len); + + /// A lobby member the host has to wait for before it starts. + struct LobbyStartBlocker + { + int64_t userID; + bool notConnected; ///< Else the connection is up but the keys are not exchanged with everyone yet. + }; + void GetLobbyStartBlockers(std::vector& blockers); + const MatchKeys& GetMatchKeys() const { return m_matchKeys; } + MatchKeys& GetMatchKeys() { return m_matchKeys; } + Real getMaximumHistoricalLatency() { int highestLatency = 0; @@ -302,6 +325,14 @@ class NetworkMesh bool m_bDisconnected = false; + struct KeySync + { + int64_t nextAnnounceMs = 0; + int64_t nextStatusMs = 0; + std::vector lastStatus; + }; + std::map m_keySync; ///< Per peer, when the key and the held keys were last sent. + std::string m_strTurnUsername; std::string m_strTurnToken; std::string m_strTurnUsernameString; diff --git a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h index 67c47f4c537..b42223ec538 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h @@ -207,8 +207,16 @@ class WebSocket // Continues the initial connect after the proxy login prompt closed. void ResumeAfterProxyLogin(bool loggedIn); + // An inbound P2P signal and the user the service says it came from. The service sets the sender itself + // from the authenticated websocket session, the payload is whatever the peer wrote. + struct PendingSignal + { + int64_t senderUserID = -1; + std::vector payload; + }; + // Thread-safe queue of inbound P2P signal payloads; drained by CSignalingClient::Poll(). - void PushPendingSignal(std::vector payload) + void PushPendingSignal(int64_t senderUserID, std::vector payload) { std::scoped_lock lock(m_pendingSignalsMutex); @@ -219,13 +227,16 @@ class WebSocket m_pendingSignals.pop(); } - m_pendingSignals.push(std::move(payload)); + PendingSignal signal; + signal.senderUserID = senderUserID; + signal.payload = std::move(payload); + m_pendingSignals.push(std::move(signal)); } - std::queue> DrainPendingSignals() + std::queue DrainPendingSignals() { std::scoped_lock lock(m_pendingSignalsMutex); - std::queue> drained; + std::queue drained; drained.swap(m_pendingSignals); return drained; } @@ -291,7 +302,7 @@ class WebSocket CURLcode FlushOutbound(); std::mutex m_pendingSignalsMutex; - std::queue> m_pendingSignals; + std::queue m_pendingSignals; }; enum class ERoomFlags : int diff --git a/GeneralsMD/Code/GameEngine/Source/Common/GameEngine.cpp b/GeneralsMD/Code/GameEngine/Source/Common/GameEngine.cpp index e9d3dfe39d6..38dd3f500f0 100644 --- a/GeneralsMD/Code/GameEngine/Source/Common/GameEngine.cpp +++ b/GeneralsMD/Code/GameEngine/Source/Common/GameEngine.cpp @@ -1028,6 +1028,14 @@ void GameEngine::update() } } +#if defined(GENERALS_ONLINE) + if (TheNetwork != nullptr && TheNetwork->isGameExitPending()) { + // Teardown can delete Network and must not run another gameplay update. + TheGameLogic->processPendingGameExit(); + return; + } +#endif + // TheSuperHackers @info Ignores frozen time because the script engine needs updating in the logic update regardless. if (canUpdateGameLogic(FramePacer::IgnoreFrozenTime)) { diff --git a/GeneralsMD/Code/GameEngine/Source/Common/System/MemoryInit.cpp b/GeneralsMD/Code/GameEngine/Source/Common/System/MemoryInit.cpp index 2881c795981..e082fe22f4d 100644 --- a/GeneralsMD/Code/GameEngine/Source/Common/System/MemoryInit.cpp +++ b/GeneralsMD/Code/GameEngine/Source/Common/System/MemoryInit.cpp @@ -613,6 +613,8 @@ static PoolSizeRec sizes[] = { "NetProgressCommandMsg", 32, 32 }, { "NetLoadCompleteCommandMsg", 32, 32 }, { "NetTimeOutGameStartCommandMsg", 32, 32 }, + { "NetSignedFrameCommandMsg", 32, 32 }, + { "NetSignaturesCommandMsg", 32, 32 }, { "NetWrapperCommandMsg", 32, 32 }, { "NetFileCommandMsg", 32, 32 }, { "NetFileAnnounceCommandMsg", 32, 32 }, 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 28ce3f2803e..4297a990e5d 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/WOLGameSetupMenu.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/WOLGameSetupMenu.cpp @@ -971,6 +971,29 @@ static void WOLLockSettings() } } +// The host does not start while a member is not connected or has not exchanged keys with everyone. +static Bool WOLLobbyReadyToStart(void) +{ + NGMP_OnlineServices_LobbyInterface* pLobbyInterface = NGMP_OnlineServicesManager::GetInterface(); + NetworkMesh* pMesh = NGMP_OnlineServicesManager::GetNetworkMesh(); + if (pLobbyInterface == nullptr || pMesh == nullptr) + { + return TRUE; + } + + std::vector blockers; + pMesh->GetLobbyStartBlockers(blockers); + for (const NetworkMesh::LobbyStartBlocker& blocker : blockers) + { + const LobbyMemberEntry member = pLobbyInterface->GetRoomMemberFromID(blocker.userID); + UnicodeString text; + text.format(blocker.notConnected ? L"Connections: Waiting for %s to connect." : L"Connections: Waiting for %s to exchange keys.", from_utf8(member.display_name).c_str()); + GadgetListBoxAddEntryText(listboxGameSetupChat, text, GameMakeColor(255, 194, 15, 255), -1, -1); + NetworkLog(ELogVerbosity::LOG_RELEASE, "[START] Not starting, waiting for user %lld to %s", static_cast(blocker.userID), blocker.notConnected ? "connect" : "exchange keys"); + } + return blockers.empty(); +} + static void StartPressed() { Bool isReady = TRUE; @@ -1033,6 +1056,11 @@ static void StartPressed() return; } + if (!WOLLobbyReadyToStart()) + { + return; + } + // see if everyone's accepted and count the number of players in the game UnicodeString mapDisplayName; const MapMetaData *mapData = TheMapCache->findMap( myGame->getMap() ); @@ -2589,15 +2617,20 @@ void WOLGameSetupMenuUpdate( WindowLayout * layout, void *userData) // are we done? if (secondsRemaining <= 0) { - s_matchStartCountdownWasRunning = false; // stop countdown TheNGMPGame->StopCountdown(); - // send start game packet - std::shared_ptr pWS = NGMP_OnlineServicesManager::GetWebSocket(); - if (pWS != nullptr) + // a member that joined during the countdown is not ready yet, the buttons come back with the stopped countdown + if (WOLLobbyReadyToStart()) { - pWS->SendData_StartGame(); + s_matchStartCountdownWasRunning = false; + + // send start game packet + std::shared_ptr pWS = NGMP_OnlineServicesManager::GetWebSocket(); + if (pWS != nullptr) + { + pWS->SendData_StartGame(); + } } } } diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/MatchKeys.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/MatchKeys.cpp new file mode 100644 index 00000000000..767a12adac5 --- /dev/null +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/MatchKeys.cpp @@ -0,0 +1,333 @@ +#include "GameNetwork/GeneralsOnline/MatchKeys.h" +#include "GameNetwork/GeneralsOnline/NetBytes.h" + +#include + +#include + +MatchKeys::MatchKeys() + : m_active(false) + , m_matchID(0) + , m_localUserID(-1) + , m_privateKey(nullptr) +{ + m_publicKey.fill(0); +} + +MatchKeys::~MatchKeys() +{ + End(); +} + +bool MatchKeys::BeginLobby(int64_t localUserID) +{ + if (m_privateKey != nullptr && m_localUserID == localUserID) + { + return true; + } + End(); + + EVP_PKEY_CTX* ctx = EVP_PKEY_CTX_new_id(EVP_PKEY_ED25519, nullptr); + if (ctx == nullptr) + { + return false; + } + + EVP_PKEY* key = nullptr; + const bool generated = EVP_PKEY_keygen_init(ctx) == 1 && EVP_PKEY_keygen(ctx, &key) == 1; + EVP_PKEY_CTX_free(ctx); + if (!generated || key == nullptr) + { + return false; + } + + size_t publicKeySize = PUBLIC_KEY_SIZE; + if (EVP_PKEY_get_raw_public_key(key, m_publicKey.data(), &publicKeySize) != 1 || publicKeySize != PUBLIC_KEY_SIZE) + { + EVP_PKEY_free(key); + return false; + } + + m_privateKey = key; + m_localUserID = localUserID; + return true; +} + +void MatchKeys::End() +{ + if (m_privateKey != nullptr) + { + EVP_PKEY_free(m_privateKey); + m_privateKey = nullptr; + } + m_publicKey.fill(0); + FreePeerKeyObjects(); + m_peerKeys.clear(); + m_peerHolds.clear(); + m_active = false; + m_expectedPeers.clear(); + m_matchID = 0; + m_localUserID = -1; +} + +bool MatchKeys::BeginMatch(uint64_t matchID, const std::set& expectedPeers) +{ + if (m_privateKey == nullptr) + { + return false; + } + m_matchID = matchID; + m_expectedPeers = expectedPeers; + m_active = true; + return true; +} + +void MatchKeys::EndMatch() +{ + m_active = false; + m_expectedPeers.clear(); + m_matchID = 0; +} + +bool MatchKeys::IsReady() const +{ + if (!m_active) + { + return false; + } + for (int64_t userID : m_expectedPeers) + { + if (!HasPeerKey(userID)) + { + return false; + } + } + return true; +} + +std::vector MatchKeys::GetPeerKeyUsers() const +{ + std::vector users; + for (const auto& entry : m_peerKeys) + { + users.push_back(entry.first); + } + return users; +} + +bool MatchKeys::PeerHoldsAll(int64_t peer, const std::set& needed) const +{ + auto holds = m_peerHolds.find(peer); + if (holds == m_peerHolds.end()) + { + return false; + } + for (int64_t userID : needed) + { + if (userID != peer && holds->second.count(userID) == 0) + { + return false; + } + } + return true; +} + +bool MatchKeys::DropPeer(int64_t userID) +{ + if (m_active && ExpectsPeer(userID)) + { + return false; + } + FreePeerKeyObject(userID); + m_peerHolds.erase(userID); + return m_peerKeys.erase(userID) != 0; +} + +void MatchKeys::FreePeerKeyObject(int64_t userID) +{ + auto parsed = m_peerKeyObjects.find(userID); + if (parsed != m_peerKeyObjects.end()) + { + EVP_PKEY_free(parsed->second); + m_peerKeyObjects.erase(parsed); + } +} + +void MatchKeys::FreePeerKeyObjects() +{ + for (auto& entry : m_peerKeyObjects) + { + EVP_PKEY_free(entry.second); + } + m_peerKeyObjects.clear(); +} + +bool MatchKeys::Sign(const uint8_t* data, size_t len, uint8_t* signature) const +{ + if (!m_active || m_privateKey == nullptr) + { + return false; + } + + EVP_MD_CTX* ctx = EVP_MD_CTX_new(); + if (ctx == nullptr) + { + return false; + } + + size_t signatureSize = SIGNATURE_SIZE; + const bool signed_ = EVP_DigestSignInit(ctx, nullptr, nullptr, nullptr, m_privateKey) == 1 + && EVP_DigestSign(ctx, signature, &signatureSize, data, len) == 1 && signatureSize == SIGNATURE_SIZE; + EVP_MD_CTX_free(ctx); + return signed_; +} + +bool MatchKeys::Verify(int64_t userID, const uint8_t* data, size_t len, const uint8_t* signature) +{ + if (!m_active) + { + return false; + } + + EVP_PKEY* key = nullptr; + auto parsed = m_peerKeyObjects.find(userID); + if (parsed != m_peerKeyObjects.end()) + { + key = parsed->second; + } + else + { + auto raw = m_peerKeys.find(userID); + if (raw == m_peerKeys.end()) + { + return false; + } + key = EVP_PKEY_new_raw_public_key(EVP_PKEY_ED25519, nullptr, raw->second.data(), PUBLIC_KEY_SIZE); + if (key == nullptr) + { + return false; + } + m_peerKeyObjects[userID] = key; + } + + EVP_MD_CTX* ctx = EVP_MD_CTX_new(); + if (ctx == nullptr) + { + return false; + } + + const bool valid = EVP_DigestVerifyInit(ctx, nullptr, nullptr, nullptr, key) == 1 + && EVP_DigestVerify(ctx, signature, SIGNATURE_SIZE, data, len) == 1; + EVP_MD_CTX_free(ctx); + return valid; +} + +std::vector MatchKeys::BuildAnnouncement() const +{ + std::vector message(ANNOUNCEMENT_SIZE); + message[0] = KIND_ANNOUNCEMENT; + WriteU64(message.data() + 1, static_cast(m_localUserID)); + memcpy(message.data() + 9, m_publicKey.data(), PUBLIC_KEY_SIZE); + return message; +} + +std::vector MatchKeys::BuildStatus() const +{ + std::vector message; + message.push_back(KIND_STATUS); + message.push_back(0); + uint8_t count = 0; + for (const auto& entry : m_peerKeys) + { + if (count >= MAX_STATUS_USERS) + { + break; + } + uint8_t id[8]; + WriteU64(id, static_cast(entry.first)); + message.insert(message.end(), id, id + 8); + ++count; + } + message[1] = count; + return message; +} + +bool MatchKeys::OnAnnouncement(int64_t connectionUserID, const uint8_t* data, size_t len, const char*& reason, bool& changed) +{ + changed = false; + if (data == nullptr || len != ANNOUNCEMENT_SIZE || data[0] != KIND_ANNOUNCEMENT) + { + reason = "malformed or another version"; + return false; + } + + const int64_t announcedUserID = static_cast(ReadU64(data + 1)); + if (announcedUserID != connectionUserID) + { + reason = "it names another user than the connection it arrived on"; + return false; + } + if (m_privateKey == nullptr || connectionUserID == m_localUserID) + { + reason = "this client has no key of its own yet or the key is its own"; + return false; + } + if (m_active && !ExpectsPeer(connectionUserID)) + { + reason = "the user is not in the match roster"; + return false; + } + + std::array key; + memcpy(key.data(), data + 9, PUBLIC_KEY_SIZE); + + auto existing = m_peerKeys.find(connectionUserID); + if (existing != m_peerKeys.end()) + { + if (existing->second == key) + { + reason = nullptr; + return true; // the same key again + } + if (m_active) + { + reason = "this user already announced a different key"; + return false; + } + // a client that came back into the lobby makes a new key, the connection it arrives on vouches for it + FreePeerKeyObject(connectionUserID); + m_peerHolds.erase(connectionUserID); + } + + m_peerKeys[connectionUserID] = key; + changed = true; + reason = nullptr; + return true; +} + +bool MatchKeys::OnStatus(int64_t connectionUserID, const uint8_t* data, size_t len, const char*& reason, bool& changed) +{ + changed = false; + if (data == nullptr || len < 2 || data[0] != KIND_STATUS) + { + reason = "malformed or another version"; + return false; + } + const size_t count = data[1]; + if (count > MAX_STATUS_USERS || len != 2 + count * 8) + { + reason = "malformed status"; + return false; + } + + std::set holds; + for (size_t i = 0; i < count; ++i) + { + holds.insert(static_cast(ReadU64(data + 2 + i * 8))); + } + auto existing = m_peerHolds.find(connectionUserID); + changed = existing == m_peerHolds.end() || existing->second != holds; + m_peerHolds[connectionUserID] = holds; + reason = nullptr; + return true; +} diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NGMPGame.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NGMPGame.cpp index 490bfcf0e33..bf00a650e6c 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NGMPGame.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NGMPGame.cpp @@ -452,12 +452,21 @@ void NGMPGame::launchGame(void) TheNetwork = NetworkInterface::createNetwork(); TheNetwork->init(); + TheNetwork->requireMatchKeys(); NetworkMesh* pMesh = NGMP_OnlineServicesManager::GetNetworkMesh(); - if (pMesh != nullptr) + if (pMesh == nullptr || !pMesh->BeginMatchKeys()) { - - TheNetwork->SeedLatencyData(pMesh->getMaximumHistoricalLatency()); + delete TheNetwork; + TheNetwork = nullptr; + setGameInProgress(FALSE); + UnicodeString error; + error.translate("Could not establish authenticated game connections. This transport may not support player authentication."); + GSMessageBoxOk(TheGameText->fetch("GUI:Error"), error); + void PopBackToLobby(void); + PopBackToLobby(); + return; } + TheNetwork->SeedLatencyData(pMesh->getMaximumHistoricalLatency()); // TODO_NGMP: Do we really care about these values anymore TheNetwork->setLocalAddress(getLocalIP(), 8888); diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NetworkMesh.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NetworkMesh.cpp index b3c9dda06dd..7cb67e58100 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NetworkMesh.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NetworkMesh.cpp @@ -4,6 +4,8 @@ #include "GameNetwork/GeneralsOnline/NGMP_interfaces.h" #include +#include +#include #include "GameNetwork/NetworkDefs.h" #include "GameNetwork/NetworkInterface.h" #include "Common/GlobalData.h" @@ -48,6 +50,27 @@ static void CleanupPendingConnSignalingDeletions() } } +// The user id of a handshake identity, which is plain text chosen by the peer. +static bool ParseUserIdentity(const SteamNetworkingIdentity& identity, int64_t& userId) +{ + const char* szIdentity = identity.GetGenericString(); + if (szIdentity == nullptr) + { + return false; + } + + const size_t identityLen = strlen(szIdentity); + const char* szDigits = (szIdentity[0] == '-') ? szIdentity + 1 : szIdentity; + const size_t numDigits = identityLen - (szDigits - szIdentity); + if (numDigits == 0 || numDigits > 19 || strspn(szDigits, "0123456789") != numDigits) + { + return false; + } + + const std::from_chars_result parseResult = std::from_chars(szIdentity, szIdentity + identityLen, userId); + return parseResult.ec == std::errc() && parseResult.ptr == szIdentity + identityLen; +} + // Called on connection state transitions. Always runs on the main thread via RunCallbacks(), so // m_mapConnections is accessed here without m_mapConnectionsMutex. void OnSteamNetConnectionStatusChanged(SteamNetConnectionStatusChangedCallback_t* pInfo) @@ -234,6 +257,15 @@ void OnSteamNetConnectionStatusChanged(SteamNetConnectionStatusChangedCallback_t assert(plrConnection.m_hSteamConnection == k_HSteamNetConnection_Invalid); // not really a bug in this code, but a bug in the test #endif + // the identity of the peer must be the user this connection is for + int64_t remoteUserID = 0; + if (!ParseUserIdentity(pInfo->m_info.m_identityRemote, remoteUserID) || remoteUserID != plrConnection.m_userID) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[SECURITY] Rejecting incoming connection %s: its identity is not user %lld", pInfo->m_info.m_szConnectionDescription, static_cast(plrConnection.m_userID)); + SteamNetworkingSockets()->CloseConnection(pInfo->m_hConn, 1001, "Identity mismatch (Rejected)", false); + return; + } + if (pInfo != nullptr) { @@ -362,6 +394,15 @@ void OnSteamNetConnectionStatusChanged(SteamNetConnectionStatusChangedCallback_t { PlayerConnection& plrConnection = connections[connectionID]; + // the peer that finished the handshake must be the user this connection was made for + int64_t remoteUserID = 0; + if (!ParseUserIdentity(pInfo->m_info.m_identityRemote, remoteUserID) || remoteUserID != plrConnection.m_userID) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[SECURITY] Closing connection %s: its identity is not user %lld", pInfo->m_info.m_szConnectionDescription, static_cast(plrConnection.m_userID)); + SteamNetworkingSockets()->CloseConnection(pInfo->m_hConn, 1001, "Identity mismatch (Rejected)", false); + break; + } + plrConnection.UpdateState(EConnectionState::CONNECTED_DIRECT, pMesh); } @@ -486,9 +527,17 @@ class CSignalingClient : public ISignalingClient // identity does not have spaces, since our protocol doesn't permit it. NetworkLog(ELogVerbosity::LOG_DEBUG, "Creating signaling session for peer '%s'\n", sIdentityPeer.c_str()); - // Silence warnings - (void)errMsg; - int64_t user_id = std::stoll(identityPeer.GetGenericString()); + // The identity comes from the remote peer, so only accept a plain integer user id + int64_t user_id = 0; + const bool bValidIdentity = ParseUserIdentity(identityPeer, user_id); + + if (!bValidIdentity) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[SIGNAL] Rejecting connection, peer identity '%s' is not a valid user id", sIdentityPeer.c_str()); + snprintf(errMsg, sizeof(errMsg), "Invalid peer identity"); + return nullptr; + } + return new ConnectionSignaling(this, user_id); } @@ -522,7 +571,7 @@ class CSignalingClient : public ISignalingClient sendBatch.pop_front(); } - std::queue> pendingSignals = pWS->DrainPendingSignals(); + std::queue pendingSignals = pWS->DrainPendingSignals(); // Now dispatch any buffered signals if (!pendingSignals.empty()) @@ -533,13 +582,15 @@ class CSignalingClient : public ISignalingClient // NOTE: outbound msg doesnt need sender ID, we only need that to determine target on the server, everything else is included in the payload // // Get the next signal - std::vector signalData = pendingSignals.front(); + const WebSocket::PendingSignal pendingSignal = pendingSignals.front(); + const std::vector& signalData = pendingSignal.payload; pendingSignals.pop(); // Setup a context object that can respond if this signal is a connection request. struct Context : ISteamNetworkingSignalingRecvContext { CSignalingClient* m_pOwner; + int64_t m_serverSender = -1; // the user the service delivered this signal from virtual ISteamNetworkingConnectionSignaling* OnConnectRequest( HSteamNetConnection hConn, @@ -557,6 +608,16 @@ class CSignalingClient : public ISignalingClient // Also, note that if there was routing/session info, it should have been in // our envelope that we know how to parse, and we should save it off in this // context object. + // The identity in the handshake is whatever the peer wrote. The service stamped the signal with + // the user it came from, a request claiming to be somebody else is not answered. + int64_t claimedUserID = 0; + if (ParseUserIdentity(identityPeer, claimedUserID) && claimedUserID != m_serverSender) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[SECURITY] Rejecting connection request: the handshake claims user %lld but the service delivered the signal from user %lld", + static_cast(claimedUserID), static_cast(m_serverSender)); + return nullptr; + } + SteamNetworkingErrMsg ignoreErrMsg; return m_pOwner->CreateSignalingForConnection(identityPeer, ignoreErrMsg); } @@ -581,6 +642,7 @@ class CSignalingClient : public ISignalingClient }; Context context; context.m_pOwner = this; + context.m_serverSender = pendingSignal.senderUserID; // Dispatch. // Remember: From inside this function, our context object might get callbacks. @@ -773,6 +835,9 @@ NetworkMesh::NetworkMesh() } m_bInitialized = true; + + // the key of this client for the lobby, peers get it as they connect + BeginLobbyKeys(); } @@ -1027,6 +1092,252 @@ void NetworkMesh::StartConnectionSignalling(const char* szMiddlewareID, int64_t } +bool NetworkMesh::BeginLobbyKeys() +{ + NGMP_OnlineServices_AuthInterface* auth = NGMP_OnlineServicesManager::GetInterface(); + if (auth == nullptr || AnticheatPlugInterface::DoesACPluginProvideSecureGameTransport()) + { + return false; + } + + std::lock_guard lock(m_mapConnectionsMutex); + if (!m_matchKeys.BeginLobby(auth->GetUserID())) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[KEYS] Could not make the key pair of this client"); + return false; + } + m_keySync.clear(); + return true; +} + +void NetworkMesh::TickLobbyKeys(int64_t nowMs) +{ + // once a match is bound its keys are done, the roster does not change + if (!m_matchKeys.HasLocalKey() || m_matchKeys.IsActive()) + { + return; + } + + std::lock_guard lock(m_mapConnectionsMutex); + + // the key of a peer that is gone is dropped, it announces again when it is back + for (int64_t userID : m_matchKeys.GetPeerKeyUsers()) + { + auto it = m_mapConnections.find(userID); + if (it == m_mapConnections.end() || !it->second.IsValid()) + { + m_matchKeys.DropPeer(userID); + m_keySync.erase(userID); + NetworkLog(ELogVerbosity::LOG_RELEASE, "[KEYS] Dropped the key of user %lld, it is no longer connected", static_cast(userID)); + } + } + + const std::set self = { m_matchKeys.GetLocalUserID() }; + const std::vector announcement = m_matchKeys.BuildAnnouncement(); + const std::vector status = m_matchKeys.BuildStatus(); + for (auto& entry : m_mapConnections) + { + PlayerConnection& connection = entry.second; + if (connection.GetState() != EConnectionState::CONNECTED_DIRECT || connection.m_hSteamConnection == k_HSteamNetConnection_Invalid) + { + m_keySync.erase(entry.first); + continue; + } + + KeySync& sync = m_keySync[entry.first]; + const bool announce = nowMs >= sync.nextAnnounceMs && !m_matchKeys.PeerHoldsAll(entry.first, self); + const bool report = nowMs >= sync.nextStatusMs || status != sync.lastStatus; + if (!announce && !report) + { + continue; + } + + for (int pass = 0; pass < 2; ++pass) + { + if (pass == 0 ? !announce : !report) + { + continue; + } + const std::vector& body = pass == 0 ? announcement : status; + std::vector packet(sizeof(ENetworkChannel) + body.size()); + packet[0] = static_cast(ENetworkChannel::NETWORK_CHANNEL_KEYS); + memcpy(packet.data() + sizeof(ENetworkChannel), body.data(), body.size()); + const EResult result = SteamNetworkingSockets()->SendMessageToConnection(connection.m_hSteamConnection, + packet.data(), static_cast(packet.size()), k_nSteamNetworkingSend_ReliableNoNagle, nullptr); + if (result != k_EResultOK) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[KEYS] Could not send to user %lld, error %d", static_cast(entry.first), static_cast(result)); + } + } + if (announce) + { + sync.nextAnnounceMs = nowMs + 1000; + } + if (report) + { + sync.nextStatusMs = nowMs + 5000; + sync.lastStatus = status; + } + } +} + +bool NetworkMesh::BeginMatchKeys() +{ + NGMP_OnlineServices_AuthInterface* auth = NGMP_OnlineServicesManager::GetInterface(); + NGMP_OnlineServices_LobbyInterface* lobby = NGMP_OnlineServicesManager::GetInterface(); + if (auth == nullptr || lobby == nullptr || TheNGMPGame == nullptr) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[KEYS] Cannot authenticate match without auth, lobby and roster"); + EndMatchKeys(); + return false; + } + + std::set peers; + bool haveLocalPlayer = false; + for (Int i = 0; i < MAX_SLOTS; ++i) + { + const NGMPGameSlot* slot = (const NGMPGameSlot*)TheNGMPGame->getConstSlot(i); + if (slot == nullptr || !slot->isHuman()) + { + continue; + } + if (slot->m_userID < 0 || !peers.insert(slot->m_userID).second) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[KEYS] Invalid or duplicate user identity in match roster"); + EndMatchKeys(); + return false; + } + haveLocalPlayer |= slot->m_userID == auth->GetUserID(); + } + if (!haveLocalPlayer) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[KEYS] Local user is missing from match roster"); + EndMatchKeys(); + return false; + } + peers.erase(auth->GetUserID()); + if (!peers.empty() && AnticheatPlugInterface::DoesACPluginProvideSecureGameTransport()) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[KEYS] Cannot authenticate peers on plugin transport without sender attribution"); + EndMatchKeys(); + return false; + } + + std::lock_guard lock(m_mapConnectionsMutex); + if (!m_matchKeys.HasLocalKey() && !BeginLobbyKeys()) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[KEYS] Could not make the key pair of this client"); + return false; + } + + // the keys came in while the lobby was open, a launch never waits for one + for (int64_t userID : peers) + { + if (!m_matchKeys.HasPeerKey(userID)) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[KEYS] No key of user %lld in the roster of the match", static_cast(userID)); + EndMatchKeys(); + return false; + } + } + + if (!m_matchKeys.BeginMatch(lobby->GetCurrentMatchID(), peers)) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[KEYS] Could not bind the keys to the match"); + return false; + } + NetworkLog(ELogVerbosity::LOG_RELEASE, "[KEYS] Keys bound to match %llu with %zu peers", static_cast(lobby->GetCurrentMatchID()), peers.size()); + return true; +} + +void NetworkMesh::EndMatchKeys() +{ + m_matchKeys.EndMatch(); +} + +void NetworkMesh::GetLobbyStartBlockers(std::vector& blockers) +{ + blockers.clear(); + NGMP_OnlineServices_AuthInterface* auth = NGMP_OnlineServicesManager::GetInterface(); + NGMP_OnlineServices_LobbyInterface* lobby = NGMP_OnlineServicesManager::GetInterface(); + if (auth == nullptr || lobby == nullptr) + { + return; + } + + std::set humans; + for (const LobbyMemberEntry& member : lobby->GetCurrentLobby().members) + { + if (member.IsHuman()) + { + humans.insert(member.user_id); + } + } + + // on a plugin transport there are no keys, the launch says so + const bool keysApply = !AnticheatPlugInterface::DoesACPluginProvideSecureGameTransport(); + std::lock_guard lock(m_mapConnectionsMutex); + for (int64_t userID : humans) + { + if (userID == auth->GetUserID()) + { + continue; + } + const PlayerConnection* connection = GetConnectionForUser(userID); + if (connection == nullptr || connection->GetState() != EConnectionState::CONNECTED_DIRECT) + { + blockers.push_back({ userID, true }); + } + else if (keysApply && (!m_matchKeys.HasPeerKey(userID) || !m_matchKeys.PeerHoldsAll(userID, humans))) + { + blockers.push_back({ userID, false }); + } + } +} + +void NetworkMesh::OnKeyAnnouncement(int64_t connectionUserID, const uint8_t* data, size_t len) +{ + if (data == nullptr || len == 0 || !m_matchKeys.HasLocalKey()) + { + return; + } + + // in the lobby only its members count, a sender whose member entry has not arrived yet announces again + if (!m_matchKeys.IsActive()) + { + NGMP_OnlineServices_LobbyInterface* lobby = NGMP_OnlineServicesManager::GetInterface(); + bool member = false; + if (lobby != nullptr) + { + for (const LobbyMemberEntry& entry : lobby->GetCurrentLobby().members) + { + member |= entry.IsHuman() && entry.user_id == connectionUserID; + } + } + if (!member) + { + return; + } + } + + std::lock_guard lock(m_mapConnectionsMutex); + const char* szReason = nullptr; + bool changed = false; + const bool accepted = data[0] == MatchKeys::KIND_STATUS + ? m_matchKeys.OnStatus(connectionUserID, data, len, szReason, changed) + : m_matchKeys.OnAnnouncement(connectionUserID, data, len, szReason, changed); + if (!accepted) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[KEYS] Rejected a key message from user %lld: %s", static_cast(connectionUserID), szReason != nullptr ? szReason : "unknown"); + return; + } + + if (changed && data[0] == MatchKeys::KIND_ANNOUNCEMENT) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[KEYS] have keys for %zu peers, got one from user %lld", m_matchKeys.GetPeerKeyCount(), static_cast(connectionUserID)); + } +} + void NetworkMesh::AwaitTurnCredentials() { std::lock_guard lock(m_mapConnectionsMutex); @@ -1130,6 +1441,10 @@ void NetworkMesh::Disconnect() m_bDisconnected = true; + EndMatchKeys(); + m_matchKeys.End(); + m_keySync.clear(); + for (auto& connectionData : m_mapConnections) { connectionData.second.Close(); @@ -1188,6 +1503,8 @@ void NetworkMesh::Tick() conn.UpdateLatencyHistogram(); } + TickLobbyKeys(nowMs); + // the game transport isn't created until the game begins, but we want to transfer AC packets in the lobby first, so consider this a liteupdate if (TheNGMPGame != nullptr && !TheNGMPGame->isGameInProgress()) { @@ -1264,6 +1581,12 @@ void PlayerConnection::LiteUpdateForAC() msg->Release(); continue; } + else if (netChannel == ENetworkChannel::NETWORK_CHANNEL_KEYS) + { + HandleKeyMessage(vecData.data() + sizeof(ENetworkChannel), numBytes - sizeof(ENetworkChannel)); + msg->Release(); + continue; + } } else if (numBytes != -1 && numBytes < sizeof(ENetworkChannel)) { @@ -1365,6 +1688,15 @@ void PlayerConnection::TickProbe(int64_t nowMs) slot.sendTimeMs = nowMs; } +void PlayerConnection::HandleKeyMessage(const uint8_t* data, size_t len) +{ + NetworkMesh* pMesh = NGMP_OnlineServicesManager::GetNetworkMesh(); + if (pMesh != nullptr) + { + pMesh->OnKeyAnnouncement(m_userID, data, len); + } +} + int64_t PlayerConnection::GetArrivalMs(const SteamNetworkingMessage_t* msg) { const int64_t nowMs = NowSteadyMs(); diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NextGenTransport.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NextGenTransport.cpp index 78fac2e4447..404094f7c7d 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NextGenTransport.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NextGenTransport.cpp @@ -399,6 +399,12 @@ Bool NextGenTransport::doRecv(void) msg->Release(); continue; } + else if (netChannel == ENetworkChannel::NETWORK_CHANNEL_KEYS) + { + kvPair.second.HandleKeyMessage(reinterpret_cast(vecPacketDataWithoutHeader.data()), vecPacketDataWithoutHeader.size()); + msg->Release(); + continue; + } else if (netChannel == ENetworkChannel::NETWORK_CHANNEL_PING) { kvPair.second.HandleProbe(reinterpret_cast(vecPacketDataWithoutHeader.data()), vecPacketDataWithoutHeader.size(), PlayerConnection::GetArrivalMs(msg)); @@ -548,6 +554,10 @@ Bool NextGenTransport::doRecv(void) // Clear slot std::memset(&m_inBuffer[i], 0, sizeof(m_inBuffer[i])); + // the connection the packet arrived on says which user sent it + m_inBuffer[i].senderKnown = true; + m_inBuffer[i].senderUserID = static_cast(kvPair.second.m_userID); + // Copy header m_inBuffer[i].header = incomingMessage.header; diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.cpp index 4b5b9da9cbc..ec1a32ef2f0 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.cpp @@ -828,6 +828,10 @@ void NGMP_OnlineServices_LobbyInterface::Tick() if (!TheNGMPGame->HasCommittedOutcome()) { TheNGMPGame->SetHasCommittedOutcome(); + if (m_pLobbyMesh != nullptr) + { + m_pLobbyMesh->EndMatchKeys(); + } pStatsInterface->CommitMyOutcome(localPlayer->getScoreKeeper(), TheVictoryConditions->isLocalAlliedVictory()); } } diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_RoomsInterface.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_RoomsInterface.cpp index b8e9b4d656a..6847da91cc6 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_RoomsInterface.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_RoomsInterface.cpp @@ -1649,7 +1649,8 @@ CURLcode WebSocket::ReceiveOneFrame() { NetworkLog(ELogVerbosity::LOG_RELEASE, "[SIGNAL] Signal User: %lld!", signalData.target_user_id); NetworkLog(ELogVerbosity::LOG_RELEASE, "[SIGNAL] Signal Payload Size: %d!", (int)signalData.payload.size()); - PushPendingSignal(std::move(signalData.payload)); + // target_user_id is the sender here, the service fills it in from the authenticated session + PushPendingSignal(signalData.target_user_id, std::move(signalData.payload)); } } break; From d016ea6276115523f8688277b436aa58272ff693 Mon Sep 17 00:00:00 2001 From: tintinhamans <5984296+tintinhamans@users.noreply.github.com> Date: Thu, 22 Oct 2026 07:03:27 +0200 Subject: [PATCH 22/28] feat(network): Send commands directly across the peer mesh --- .../Include/GameNetwork/ConnectionManager.h | 8 +- .../Include/GameNetwork/FrameMetrics.h | 1 + .../Include/GameNetwork/NetworkSettings.h | 1 + .../Source/GameNetwork/ConnectionManager.cpp | 103 +++++++++++++++++- .../Source/GameNetwork/FrameMetrics.cpp | 4 + .../GeneralsOnline/OnlineServices_Init.h | 1 + 6 files changed, 111 insertions(+), 7 deletions(-) diff --git a/Core/GameEngine/Include/GameNetwork/ConnectionManager.h b/Core/GameEngine/Include/GameNetwork/ConnectionManager.h index 7ec23934856..05130873681 100644 --- a/Core/GameEngine/Include/GameNetwork/ConnectionManager.h +++ b/Core/GameEngine/Include/GameNetwork/ConnectionManager.h @@ -255,8 +255,14 @@ class ConnectionManager Bool isDuplicateCommand(NetCommandMsg *msg); Int slotForUserID(Int64 userID) const; + Bool isPeerActive(Int slot) const; + void onDirectFrameAck(UnsignedInt frame, Int peerSlot); + std::map m_frameAckedMasks; ///< Frame -> the peers that acked its frame info so far. + Bool isDirectSendActive(); + Bool m_directSendActive; + time_t m_directSendCheckedAt; UnsignedInt m_formerRouterMask; ///< Slots that were the packet router in this match, their last packets can still be on the way. - Bool isCommandFromSender(NetCommandMsg *msg, Int senderSlot); ///< A command must come from the player it names, unless the packet router relays it + Bool isCommandFromSender(NetCommandMsg *msg, Int senderSlot); Bool noteSecurityDrop(); Bool m_loggedSenderUnavailable; Int m_securityDrops; diff --git a/Core/GameEngine/Include/GameNetwork/FrameMetrics.h b/Core/GameEngine/Include/GameNetwork/FrameMetrics.h index 13967e49164..42956046bdd 100644 --- a/Core/GameEngine/Include/GameNetwork/FrameMetrics.h +++ b/Core/GameEngine/Include/GameNetwork/FrameMetrics.h @@ -50,6 +50,7 @@ class FrameMetrics { Real getAverageLatency(); Int getAverageFPS(); Int getMinimumCushion(); + Bool isPending(UnsignedInt frame); void SeedLatencyData(int latency); diff --git a/Core/GameEngine/Include/GameNetwork/NetworkSettings.h b/Core/GameEngine/Include/GameNetwork/NetworkSettings.h index 28f10cecbdd..eb4cf850d8e 100644 --- a/Core/GameEngine/Include/GameNetwork/NetworkSettings.h +++ b/Core/GameEngine/Include/GameNetwork/NetworkSettings.h @@ -10,6 +10,7 @@ struct NetworkSettings struct Send { std::string mode = "datagram"; // "reliable" = GNS retransmits, "datagram" = unreliable datagrams + bool direct = true; // every player sends commands straight to each peer while the mesh is up int frames_per_packet = 2; // send interval in frames int frames_per_packet_lossy = 1; // send interval in frames while the redundancy depth is above 0, 0 = keep the configured one bool flush_every_frame = true; // flush GNS after each frame diff --git a/Core/GameEngine/Source/GameNetwork/ConnectionManager.cpp b/Core/GameEngine/Source/GameNetwork/ConnectionManager.cpp index 5237e9a9c32..5191bbce8b3 100644 --- a/Core/GameEngine/Source/GameNetwork/ConnectionManager.cpp +++ b/Core/GameEngine/Source/GameNetwork/ConnectionManager.cpp @@ -358,7 +358,10 @@ void ConnectionManager::init() m_lastKnownRunAhead = 0; m_loggedSenderUnavailable = FALSE; m_securityDrops = 0; + m_directSendActive = FALSE; + m_frameAckedMasks.clear(); m_formerRouterMask = 0; + m_directSendCheckedAt = 0; clearSignatureState(TRUE); m_frameGroupingMs = 0; #endif @@ -554,6 +557,39 @@ Bool ConnectionManager::isDuplicateCommand(NetCommandMsg *msg) { return FALSE; } +enum { ACK_PRUNE_FRAMES = 256, DIRECT_RECHECK_MS = 250 }; + +Bool ConnectionManager::isPeerActive(Int slot) const { + return m_connections[slot] != nullptr && !m_connections[slot]->isQuitting(); +} + +// Frame infos sent directly are acked by every peer, the latency of a frame is the slowest peer's round trip. +void ConnectionManager::onDirectFrameAck(UnsignedInt frame, Int peerSlot) { + if (peerSlot < 0 || peerSlot >= MAX_SLOTS || !m_frameMetrics.isPending(frame)) { + return; + } + + UnsignedInt &acked = m_frameAckedMasks[frame]; + acked |= (1u << peerSlot); + + UnsignedInt expected = 0; + for (Int slot = 0; slot < MAX_SLOTS; ++slot) { + if (slot != m_localSlot && isPeerActive(slot)) { + expected |= (1u << slot); + } + } + + if ((acked & expected) == expected) { + m_frameMetrics.processLatencyResponse(frame); + m_frameAckedMasks.erase(frame); + } + + // frames that never completed, a peer's ack was lost for good + while (!m_frameAckedMasks.empty() && m_frameAckedMasks.begin()->first + ACK_PRUNE_FRAMES < frame) { + m_frameAckedMasks.erase(m_frameAckedMasks.begin()); + } +} + Int ConnectionManager::slotForUserID(Int64 userID) const { return (TheNGMPGame != nullptr) ? TheNGMPGame->getSlotOfUser(userID) : -1; } @@ -589,6 +625,38 @@ static Int relayClass(NetCommandType type) { } } +// send.direct is on and every peer has a direct connection; commands then bypass the packet router. +Bool ConnectionManager::isDirectSendActive() { + if (TheNGMPGame == nullptr || !m_networkSettings.send.direct) { + return FALSE; + } + + const time_t now = timeGetTime(); + if (m_directSendCheckedAt != 0 && (now - m_directSendCheckedAt) < DIRECT_RECHECK_MS) { + return m_directSendActive; + } + m_directSendCheckedAt = now; + + Bool meshUp = TRUE; + NetworkMesh *mesh = NGMP_OnlineServicesManager::GetNetworkMesh(); + for (Int slot = 0; slot < MAX_SLOTS && meshUp; ++slot) { + if (slot == m_localSlot || !isPeerActive(slot)) { + continue; + } + const NGMPGameSlot *gameSlot = (const NGMPGameSlot *)TheNGMPGame->getConstSlot(slot); + PlayerConnection *connection = (mesh != nullptr && gameSlot != nullptr) ? mesh->GetConnectionForUser(gameSlot->m_userID) : nullptr; + if (connection == nullptr || connection->GetState() != EConnectionState::CONNECTED_DIRECT) { + meshUp = FALSE; + } + } + + if (meshUp != m_directSendActive) { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[DIRECT] commands are now sent %s", meshUp ? "directly to every peer" : "through the packet router, a peer connection is not up"); + } + m_directSendActive = meshUp; + return m_directSendActive; +} + // Checks that a command names its sender, or that the packet router or a former one passes it on. Bool ConnectionManager::isCommandFromSender(NetCommandMsg *msg, Int senderSlot) { const Int claimedSlot = (Int)msg->getPlayerID(); @@ -827,7 +895,8 @@ void ConnectionManager::signLocalFrame(NetFrameCommandMsg *frameInfo) { void ConnectionManager::signLocalChat(NetChatCommandMsg *chat) { MatchKeys *keys = activeMatchKeys(); - if (keys == nullptr || m_packetRouterSlot >= MAX_SLOTS || m_packetRouterSlot == m_localSlot || m_connections[m_packetRouterSlot] == nullptr) { + if (keys == nullptr || m_packetRouterSlot >= MAX_SLOTS || m_packetRouterSlot == m_localSlot || m_connections[m_packetRouterSlot] == nullptr + || isDirectSendActive()) { return; } @@ -1736,8 +1805,16 @@ void ConnectionManager::processAckStage1(NetCommandMsg *msg) { } if (ref != nullptr) { - if (ref->getCommand()->getNetCommandType() == NETCOMMANDTYPE_FRAMEINFO) { - m_frameMetrics.processLatencyResponse(((NetFrameCommandMsg *)(ref->getCommand()))->getExecutionFrame()); + if (ref->getCommand()->getNetCommandType() == NETCOMMANDTYPE_FRAMEINFO || ref->getCommand()->getNetCommandType() == NETCOMMANDTYPE_SIGNEDFRAMEINFO) { + const UnsignedInt ackedFrame = ref->getCommand()->getExecutionFrame(); +#if defined(GENERALS_ONLINE) + if (isDirectSendActive()) { + onDirectFrameAck(ackedFrame, (Int)playerID); + } else +#endif + { + m_frameMetrics.processLatencyResponse(ackedFrame); + } } deleteInstance(ref); @@ -1950,7 +2027,16 @@ void ConnectionManager::ackCommand(NetCommandRef *ref, UnsignedInt localSlot) { ackmsg->setPlayerID(localSlot); // Tell the player who this ack is coming from. - if (CommandRequiresDirectSend(msg) && CommandRequiresAck(msg)) { + // A command that came straight from its author and needs no relaying is acked straight back to it, one the router + // passed on is acked to the router. The arrival decides, not our own direct send mode: peers can be in different modes. + Bool ackDirectly = CommandRequiresDirectSend(msg) && CommandRequiresAck(msg); +#if defined(GENERALS_ONLINE) && !RETAIL_COMPATIBLE_NETWORKING + if (!ackDirectly && sendRelay == 0 && msg->getPlayerID() < MAX_SLOTS && m_connections[msg->getPlayerID()] != nullptr) { + // an unknown sender (anticheat transport) falls back to our own mode + ackDirectly = (m_currentSenderSlot >= 0) ? (m_currentSenderSlot == (Int)msg->getPlayerID()) : isDirectSendActive(); + } +#endif + if (ackDirectly) { // Send this ack directly back to the sending player, don't go through the packet router. if (msg->getPlayerID() < MAX_SLOTS) { if (m_connections[msg->getPlayerID()] != nullptr) { @@ -2592,8 +2678,13 @@ void ConnectionManager::sendLocalCommand(NetCommandMsg *msg, UnsignedByte relay } // Send the packet to everyone else - if (m_localSlot == m_packetRouterSlot) { - // I am the packet router, I need to send this packet to everyone individually. +#if defined(GENERALS_ONLINE) + const Bool sendToAll = isDirectSendActive() || (m_localSlot == m_packetRouterSlot); +#else + const Bool sendToAll = (m_localSlot == m_packetRouterSlot); +#endif + if (sendToAll) { + // I am the packet router or sending directly, I need to send this packet to everyone individually. for (Int i = 0; i < MAX_SLOTS; ++i) { // Send it to all open connections. if (((m_connections[i] != nullptr) && (m_connections[i]->isQuitting() == FALSE)) && (relay & (1 << i))) { diff --git a/Core/GameEngine/Source/GameNetwork/FrameMetrics.cpp b/Core/GameEngine/Source/GameNetwork/FrameMetrics.cpp index f6739612bdc..9a7c77fa5d5 100644 --- a/Core/GameEngine/Source/GameNetwork/FrameMetrics.cpp +++ b/Core/GameEngine/Source/GameNetwork/FrameMetrics.cpp @@ -273,6 +273,10 @@ Real FrameMetrics::getAverageLatency() { return m_averageLatency; } +Bool FrameMetrics::isPending(UnsignedInt frame) { + return m_mapPendingLatenciesLookup.find(frame) != m_mapPendingLatenciesLookup.end(); +} + Int FrameMetrics::getMinimumCushion() { return m_minimumCushion; } diff --git a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h index b42223ec538..15448217cae 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h @@ -455,6 +455,7 @@ inline void ReadServiceConfig(const nlohmann::json& j, ServiceConfig& c) { const nlohmann::json& s = j["send"]; ReadServiceConfigValue(s, "mode", c.send.mode); + ReadServiceConfigValue(s, "direct", c.send.direct); ReadServiceConfigValue(s, "frames_per_packet", c.send.frames_per_packet); ReadServiceConfigValue(s, "frames_per_packet_lossy", c.send.frames_per_packet_lossy); ReadServiceConfigValue(s, "flush_every_frame", c.send.flush_every_frame); From 05afd00f776aabc0377cc0c69f960419d9f45832 Mon Sep 17 00:00:00 2001 From: tintinhamans <5984296+tintinhamans@users.noreply.github.com> Date: Thu, 22 Oct 2026 07:03:28 +0200 Subject: [PATCH 23/28] fix(network): Synchronize disconnect removal and quit handling --- .../Include/GameNetwork/ConnectionManager.h | 13 +- .../Include/GameNetwork/DisconnectManager.h | 33 +- .../Include/GameNetwork/NetworkInterface.h | 2 +- .../Include/GameNetwork/NetworkSettings.h | 6 + .../Source/GameNetwork/Connection.cpp | 3 +- .../Source/GameNetwork/ConnectionManager.cpp | 151 ++++++- .../Source/GameNetwork/DisconnectManager.cpp | 371 +++++++++++++++++- .../Source/GameNetwork/NetPacket.cpp | 3 + .../GameEngine/Source/GameNetwork/Network.cpp | 7 + .../GameNetwork/GeneralsOnline/NetworkMesh.h | 2 + .../GeneralsOnline/NextGenTransport.h | 3 + .../GeneralsOnline/OnlineServices_Init.h | 11 + .../GeneralsOnline/NetworkMesh.cpp | 33 +- .../GeneralsOnline/NextGenTransport.cpp | 42 +- 14 files changed, 643 insertions(+), 37 deletions(-) diff --git a/Core/GameEngine/Include/GameNetwork/ConnectionManager.h b/Core/GameEngine/Include/GameNetwork/ConnectionManager.h index 05130873681..dd2268297e1 100644 --- a/Core/GameEngine/Include/GameNetwork/ConnectionManager.h +++ b/Core/GameEngine/Include/GameNetwork/ConnectionManager.h @@ -112,6 +112,7 @@ class ConnectionManager UnsignedInt getPacketRouterSlot(); ///< Returns the current packet router's slot. PlayerLeaveCode disconnectPlayer(Int slot); ///< Disconnect this player immediately. This should only be called by the disconnect manager. #if defined(GENERALS_ONLINE) + void playerClosedGracefully(Int64 userID); PlayerLeaveCode disconnectPlayer(int64_t userID); ///< Disconnect this player immediately. This should only be called by the disconnect manager. #endif void disconnectLocalPlayer(); ///< Does whatever is necessary to get TheNetwork to realize that it should be leaving the game now. @@ -151,7 +152,7 @@ class ConnectionManager void notifyOthersOfCurrentFrame(Int frame); void sendFrameDataToPlayer(UnsignedInt playerID, UnsignedInt startingFrame); - void sendSingleFrameToPlayer(UnsignedInt playerID, UnsignedInt frame); + void sendSingleFrameToPlayer(UnsignedInt playerID, UnsignedInt frame, Bool ownOnly = FALSE); void notifyOthersOfNewFrame(UnsignedInt frame); UnsignedInt getNextPacketRouterSlot(UnsignedInt playerID); ///< returns the packet router player that comes after the given player. @@ -204,6 +205,11 @@ class ConnectionManager Real getMaximumLatency(); ///< Returns the average of the two highest average latencies between players. void requestFrameDataResend(Int playerID, UnsignedInt frame); ///< request of this player that he send the specified frame's data. + UnsignedShort generateImpersonatedCommandID(Int playerID, UnsignedInt frame); +#if !RETAIL_COMPATIBLE_NETWORKING + void askForOutPlayerFrame(Int slot, UnsignedInt frame); + Int getSlotOfAddress(UnsignedInt addr, UnsignedShort port); +#endif // The connections are set up like the slot list. The connection corresponding to the local // player's position in the slot list will be null. Connections corresponding to slots that @@ -224,8 +230,13 @@ class ConnectionManager NetCommandList *m_pendingCommands; NetCommandList *m_relayedCommands; + UnsignedShort m_lastSeenCommandID[MAX_SLOTS]; + Bool m_haveLastSeenCommandID[MAX_SLOTS]; #if !RETAIL_COMPATIBLE_NETWORKING Int m_currentSenderSlot; ///< The slot of the peer the packet being read came from, -1 when not known. + Int m_outFrameAskedSlot; + UnsignedInt m_outFrameAskedFrame; + UnsignedInt m_outFrameAskedTime; #endif FrameMetrics m_frameMetrics; diff --git a/Core/GameEngine/Include/GameNetwork/DisconnectManager.h b/Core/GameEngine/Include/GameNetwork/DisconnectManager.h index ccbb183881f..fc6b6a5fa38 100644 --- a/Core/GameEngine/Include/GameNetwork/DisconnectManager.h +++ b/Core/GameEngine/Include/GameNetwork/DisconnectManager.h @@ -58,6 +58,13 @@ class DisconnectManager void voteForPlayerDisconnect(Int slot, ConnectionManager *conMgr); +#if !RETAIL_COMPATIBLE_NETWORKING + void playerClosedGracefully(Int slot, ConnectionManager *conMgr); ///< The next packet router removes the player without waiting out its timeout. + Bool isPlayerOutOfGame(Int slot, ConnectionManager *conMgr); +#endif + + Bool isLocalPlayerNextPacketRouter(ConnectionManager *conMgr); ///< returns true if the local player is next in line to be the packet router with all the players that have timed out being taken out of the picture. + // For disconnect blame assignment UnsignedInt getPingFrame(); Int getPingsSent(); @@ -78,12 +85,23 @@ class DisconnectManager void updateWaitForPacketRouter(ConnectionManager *conMgr); void recalculatePacketRouterIndex(ConnectionManager *conMgr); Bool allOnSameFrame(ConnectionManager *conMgr); ///< returns true if all players are stuck on the same frame. - Bool isLocalPlayerNextPacketRouter(ConnectionManager *conMgr); ///< returns true if the local player is next in line to be the packet router with all the players that have timed out being taken out of the picture. +#if !RETAIL_COMPATIBLE_NETWORKING + Int getActingPacketRouterSlot(ConnectionManager *conMgr); +#endif Bool hasPlayerTimedOut(Int slot); ///< returns true if this player has timed out. void sendPlayerDestruct(Int slot, ConnectionManager *conMgr); ///< send a destruct player network message. +#if RETAIL_COMPATIBLE_NETWORKING + void sendPendingRelays(ConnectionManager *conMgr); +#endif + void removePlayer(Int slot, ConnectionManager *conMgr); + Bool isPlayerSilent(Int slot); ///< The slot is a translated one. + Bool isPlayerGone(Int slot, ConnectionManager *conMgr); ///< A silent player that timed out or was voted out by all others. The slot is a translated one. Bool isPlayerVotedOut(Int slot, ConnectionManager *conMgr); ///< returns true if this player has been voted out. Bool isPlayerInGame(Int slot, ConnectionManager *conMgr); ///< returns true if the player has neither timed out or been voted out. - UnsignedInt getMaxDisconnectFrame(); ///< returns the highest frame that people have got to. + UnsignedInt getMaxDisconnectFrame(ConnectionManager *conMgr); ///< The highest frame that the players that have not left have got to. +#if !RETAIL_COMPATIBLE_NETWORKING + Bool isGonePlayerConnected(ConnectionManager *conMgr); +#endif Int countVotesForPlayer(Int slot); ///< return the number of disconnect votes a player has. void resetPlayersVotes(Int playerID, UnsignedInt frame, ConnectionManager *conMgr); ///< reset the votes for this player. @@ -109,6 +127,10 @@ class DisconnectManager time_t m_lastKeepAliveSendTime; time_t m_playerTimeouts[MAX_SLOTS - 1]; +#if !RETAIL_COMPATIBLE_NETWORKING + Bool m_closedGracefully[MAX_SLOTS - 1]; ///< By translated slot. + Bool m_screenKept; +#endif time_t m_packetRouterTimeout; DisconnectVoteStruct m_playerVotes[MAX_SLOTS][MAX_SLOTS]; @@ -117,6 +139,13 @@ class DisconnectManager UnsignedInt m_disconnectFrames[MAX_SLOTS]; Bool m_disconnectFramesReceived[MAX_SLOTS]; Bool m_haveNotifiedOtherPlayersOfCurrentFrame; +#if RETAIL_COMPATIBLE_NETWORKING + UnsignedInt m_relayFrame[MAX_SLOTS]; + UnsignedByte m_relayTo[MAX_SLOTS]; +#else + Bool m_removedInRound; + UnsignedInt m_catchUpFrame; +#endif time_t m_timeOfDisconnectScreenOn; Int m_pingsSent; diff --git a/Core/GameEngine/Include/GameNetwork/NetworkInterface.h b/Core/GameEngine/Include/GameNetwork/NetworkInterface.h index a72a3a2c460..a63e2f5db19 100644 --- a/Core/GameEngine/Include/GameNetwork/NetworkInterface.h +++ b/Core/GameEngine/Include/GameNetwork/NetworkInterface.h @@ -61,7 +61,7 @@ class NetworkInterface : public SubsystemInterface virtual void setLocalAddress(UnsignedInt ip, UnsignedInt port) = 0; ///< Tell the network what local ip and port to bind to. virtual Bool isFrameDataReady() = 0; ///< Are the commands for the next frame available? #if defined(GENERALS_ONLINE) - virtual Bool isGameExitPending() const = 0; + virtual Bool isGameExitPending() const { return FALSE; } #endif virtual Bool isStalling() = 0; virtual void parseUserList(const GameInfo* game) = 0; ///< Parse a userlist, creating connections diff --git a/Core/GameEngine/Include/GameNetwork/NetworkSettings.h b/Core/GameEngine/Include/GameNetwork/NetworkSettings.h index eb4cf850d8e..32342b7b7ab 100644 --- a/Core/GameEngine/Include/GameNetwork/NetworkSettings.h +++ b/Core/GameEngine/Include/GameNetwork/NetworkSettings.h @@ -34,6 +34,12 @@ struct NetworkSettings } formula; } input_delay; + struct Disconnect + { + int freeze_before_screen_ms = 5000; // stuck on a frame this long, then the disconnect screen comes up + int remove_after_ms = 60000; // a player silent this long on the disconnect screen is removed + } disconnect; + static NetworkSettings LocalDefaults() { return NetworkSettings(); diff --git a/Core/GameEngine/Source/GameNetwork/Connection.cpp b/Core/GameEngine/Source/GameNetwork/Connection.cpp index ffab6ac480f..adf1c22d6bd 100644 --- a/Core/GameEngine/Source/GameNetwork/Connection.cpp +++ b/Core/GameEngine/Source/GameNetwork/Connection.cpp @@ -684,7 +684,8 @@ NetCommandRef * Connection::processAck(UnsignedShort commandID, UnsignedByte ori // Need to check for both the command ID and the player ID. temp = temp->getNext(); } - if (temp == nullptr) { + // an ack for a command not sent yet belongs to another command with the same id + if (temp == nullptr || temp->getTimeLastSent() == -1) { return nullptr; } diff --git a/Core/GameEngine/Source/GameNetwork/ConnectionManager.cpp b/Core/GameEngine/Source/GameNetwork/ConnectionManager.cpp index 5191bbce8b3..98dc13e4c4b 100644 --- a/Core/GameEngine/Source/GameNetwork/ConnectionManager.cpp +++ b/Core/GameEngine/Source/GameNetwork/ConnectionManager.cpp @@ -265,6 +265,8 @@ ConnectionManager::ConnectionManager() { for (Int i = 0; i < MAX_SLOTS; ++i) { m_frameData[i] = nullptr; + m_lastSeenCommandID[i] = 0; + m_haveLastSeenCommandID[i] = FALSE; } m_transport = nullptr; m_disconnectManager = nullptr; @@ -292,6 +294,7 @@ void ConnectionManager::init() const ServiceConfig &overrides = services->GetServiceConfig(); m_networkSettings.send = overrides.send; m_networkSettings.input_delay = overrides.input_delay; + m_networkSettings.disconnect = overrides.disconnect; } const NetworkSettings::InputDelay &runAhead = m_networkSettings.input_delay; TheWritableGlobalData->m_networkFPSHistoryLength = (std::max)(1, runAhead.fps_history_samples); @@ -300,6 +303,8 @@ void ConnectionManager::init() 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)); + TheWritableGlobalData->m_networkDisconnectTime = m_networkSettings.disconnect.freeze_before_screen_ms; + TheWritableGlobalData->m_networkPlayerTimeoutTime = m_networkSettings.disconnect.remove_after_ms; #endif // if (m_transport == nullptr) { @@ -331,6 +336,8 @@ void ConnectionManager::init() m_packetRouterSlot = 0; /// @todo The LAN/WOL interface should be telling us who the packet router is based on machine specs passed around through game options. for (i = 0; i < MAX_SLOTS; ++i) { m_packetRouterFallback[i] = -1; + m_lastSeenCommandID[i] = 0; + m_haveLastSeenCommandID[i] = FALSE; } for (i = 0; i < MAX_SLOTS; ++i) { @@ -350,6 +357,9 @@ void ConnectionManager::init() m_smallestPacketArrivalCushion = -1; #if !RETAIL_COMPATIBLE_NETWORKING m_currentSenderSlot = -1; + m_outFrameAskedSlot = -1; + m_outFrameAskedFrame = 0; + m_outFrameAskedTime = 0; #endif #if defined(GENERALS_ONLINE) m_stallActive = FALSE; @@ -643,6 +653,12 @@ Bool ConnectionManager::isDirectSendActive() { if (slot == m_localSlot || !isPeerActive(slot)) { continue; } +#if !RETAIL_COMPATIBLE_NETWORKING + // a player that is out needs no commands + if (m_disconnectManager != nullptr && m_disconnectManager->isPlayerOutOfGame(slot, this)) { + continue; + } +#endif const NGMPGameSlot *gameSlot = (const NGMPGameSlot *)TheNGMPGame->getConstSlot(slot); PlayerConnection *connection = (mesh != nullptr && gameSlot != nullptr) ? mesh->GetConnectionForUser(gameSlot->m_userID) : nullptr; if (connection == nullptr || connection->GetState() != EConnectionState::CONNECTED_DIRECT) { @@ -657,7 +673,7 @@ Bool ConnectionManager::isDirectSendActive() { return m_directSendActive; } -// Checks that a command names its sender, or that the packet router or a former one passes it on. +// Checks that a command names its sender, or that a router, a former router or a peer holding a removed player's frames passes it on. Bool ConnectionManager::isCommandFromSender(NetCommandMsg *msg, Int senderSlot) { const Int claimedSlot = (Int)msg->getPlayerID(); if (claimedSlot == senderSlot && claimedSlot >= 0 && claimedSlot < MAX_SLOTS) { @@ -671,10 +687,22 @@ Bool ConnectionManager::isCommandFromSender(NetCommandMsg *msg, Int senderSlot) } else if (nameOk && cls == 2) { maySendForOthers = (senderSlot == m_packetRouterSlot); } +#if !RETAIL_COMPATIBLE_NETWORKING + // a player that is out sends nothing more, so its last signed frames may come from any peer that has them + if (nameOk && cls == 1 && !maySendForOthers && m_disconnectManager != nullptr && m_disconnectManager->isPlayerOutOfGame(claimedSlot, this)) { + maySendForOthers = TRUE; + } +#endif // forwarded frames are checked in isPassedOnFrameAuthentic if (maySendForOthers) { return TRUE; } + // late frames of a removed player: acked, then ignored + if (nameOk && cls == 1 && m_connections[claimedSlot] == nullptr + && m_signerUserID[claimedSlot] >= 0 && IsCommandSynchronized(msg->getNetCommandType())) { + return TRUE; + } + if (noteSecurityDrop()) { NetworkLog(ELogVerbosity::LOG_RELEASE, "[SECURITY] dropped %s claiming slot %d from slot %d (%d dropped so far)", GetNetCommandTypeAsString(msg->getNetCommandType()), claimedSlot, (Int)senderSlot, m_securityDrops); @@ -1202,6 +1230,8 @@ void ConnectionManager::doRelay() { #if !RETAIL_COMPATIBLE_NETWORKING #if defined(GENERALS_ONLINE) m_currentSenderSlot = (senderSlot >= 0) ? senderSlot : -1; +#else + m_currentSenderSlot = getSlotOfAddress(m_transport->m_inBuffer[i].addr, m_transport->m_inBuffer[i].port); #endif #endif @@ -1295,6 +1325,16 @@ Bool ConnectionManager::processNetCommand(NetCommandRef *ref) { NetCommandMsg *msg = ref->getCommand(); NetCommandType cmdType = msg->getNetCommandType(); + // Track the command ids of each player, the ids far away from them are free for passed on frame infos. + if (DoesCommandRequireACommandID(cmdType) && msg->getPlayerID() < MAX_SLOTS && msg->getPlayerID() != m_localSlot) { + const UnsignedInt from = msg->getPlayerID(); + const UnsignedShort distance = (UnsignedShort)(msg->getID() - m_lastSeenCommandID[from]); + if (m_haveLastSeenCommandID[from] == FALSE || distance < 0x4000 || distance > 0xC000) { + m_lastSeenCommandID[from] = msg->getID(); + m_haveLastSeenCommandID[from] = TRUE; + } + } + // Handle ACK commands first (before connection validation) if ((cmdType == NETCOMMANDTYPE_ACKSTAGE1) || (cmdType == NETCOMMANDTYPE_ACKSTAGE2) || @@ -2027,6 +2067,15 @@ void ConnectionManager::ackCommand(NetCommandRef *ref, UnsignedInt localSlot) { ackmsg->setPlayerID(localSlot); // Tell the player who this ack is coming from. +#if !RETAIL_COMPATIBLE_NETWORKING + // a peer other than the router that passed on another player's command waits for the ack itself + if ((m_currentSenderSlot >= 0) && (m_currentSenderSlot != (Int)msg->getPlayerID()) && (m_currentSenderSlot != (Int)m_packetRouterSlot) && (m_connections[m_currentSenderSlot] != nullptr)) { + m_connections[m_currentSenderSlot]->sendNetCommandMsg(ackmsg, 1 << m_currentSenderSlot); + ackmsg->detach(); + return; + } +#endif + // A command that came straight from its author and needs no relaying is acked straight back to it, one the router // passed on is acked to the router. The arrival decides, not our own direct send mode: peers can be in different modes. Bool ackDirectly = CommandRequiresDirectSend(msg) && CommandRequiresAck(msg); @@ -2767,6 +2816,11 @@ Bool ConnectionManager::allCommandsReady(UnsignedInt frame, Bool justTesting /* frameRetVal = m_frameData[i]->allCommandsReady(frame, (frame != commandsReadyDebugSpewage) && (justTesting == FALSE)); if (frameRetVal == FRAMEDATA_NOTREADY) { retval = FALSE; +#if !RETAIL_COMPATIBLE_NETWORKING + if ((justTesting == FALSE) && (i != (Int)m_localSlot)) { + askForOutPlayerFrame(i, frame); + } +#endif } else if (frameRetVal == FRAMEDATA_RESEND) { requestFrameDataResend(i, frame); retval = FALSE; @@ -2991,8 +3045,23 @@ PlayerLeaveCode ConnectionManager::disconnectPlayer(Int slot) { } #if defined(GENERALS_ONLINE) +void ConnectionManager::playerClosedGracefully(Int64 userID) +{ + Int slot = slotForUserID(userID); + if (slot >= 0 && slot < MAX_SLOTS && m_disconnectManager != nullptr) + { + m_disconnectManager->playerClosedGracefully(slot, this); + } +} + PlayerLeaveCode ConnectionManager::disconnectPlayer(int64_t userID) { + // a player whose connection ended before the load finished must not hold the others at the load screen, the disconnect handling removes it once the match runs + const Int slot = slotForUserID(userID); + if (slot >= 0 && slot != (Int)m_localSlot && TheGameLogic != nullptr && TheGameLogic->isInInternetGame() && !TheGameLogic->isProgressComplete()) { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[LOAD] slot %d is gone before the load finished, not waiting for it", (Int)slot); + TheGameLogic->processProgressComplete(slot); + } return PLAYERLEAVECODE_UNKNOWN; // if (TheNGMPGame != nullptr) // { @@ -3022,9 +3091,15 @@ void ConnectionManager::quitGame() { if (DoesCommandRequireACommandID(disconnectMsg->getNetCommandType())) { disconnectMsg->setID(GenerateNextCommandID()); } +#if defined(GENERALS_ONLINE) + signLeaveNotice(disconnectMsg); // lets the peers pass the notice on +#endif //DEBUG_LOG(("ConnectionManager::disconnectLocalPlayer - about to send disconnect command")); sendLocalCommandDirect(disconnectMsg, 0xff ^ (1 << m_localSlot)); + // the connections are deleted right after, so send now + setFrameGrouping(0); + //DEBUG_LOG(("ConnectionManager::disconnectLocalPlayer - about to flush connections")); flushConnections(); // need to do this so our packet actually gets sent before the connections are deleted. //DEBUG_LOG(("ConnectionManager::disconnectLocalPlayer - done flushing connections")); @@ -3370,6 +3445,8 @@ void ConnectionManager::sendDisconnectChat(UnicodeString text) { sendLocalCommandDirect(msg, 0xff ^ (1 << m_localSlot)); processDisconnectChat(msg); + + msg->detach(); } UnsignedShort ConnectionManager::sendFileAnnounce(AsciiString path, UnsignedByte playerMask) @@ -3644,15 +3721,26 @@ void ConnectionManager::notifyOthersOfNewFrame(UnsignedInt frame) { msg->detach(); } +// An id half the counter away from the player's own, so a passed on frame info never collides with its commands. +UnsignedShort ConnectionManager::generateImpersonatedCommandID(Int playerID, UnsignedInt frame) { + return (UnsignedShort)(((m_lastSeenCommandID[playerID] + 0x8000) & 0xF000) | (frame & 0x0FFF)); +} + void ConnectionManager::sendFrameDataToPlayer(UnsignedInt playerID, UnsignedInt startingFrame) { DEBUG_LOG_LEVEL(DEBUG_LEVEL_NET, ("ConnectionManager::sendFrameDataToPlayer - sending frame data to player %d starting with frame %d", playerID, startingFrame)); for (UnsignedInt frame = startingFrame; frame < TheGameLogic->getFrame(); ++frame) { sendSingleFrameToPlayer(playerID, frame); } +#if defined(GENERALS_ONLINE) + // a peer that dropped a passed on frame asks for it while we have not run it yet: our own data for it is final + if (startingFrame >= TheGameLogic->getFrame() && startingFrame <= m_lastFrameInfoSent) { + sendSingleFrameToPlayer(playerID, startingFrame, TRUE); + } +#endif DEBUG_LOG_LEVEL(DEBUG_LEVEL_NET, ("ConnectionManager::sendFrameDataToPlayer - done sending commands to player %d", playerID)); } -void ConnectionManager::sendSingleFrameToPlayer(UnsignedInt playerID, UnsignedInt frame) { +void ConnectionManager::sendSingleFrameToPlayer(UnsignedInt playerID, UnsignedInt frame, Bool ownOnly) { 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; @@ -3660,8 +3748,31 @@ void ConnectionManager::sendSingleFrameToPlayer(UnsignedInt playerID, UnsignedIn UnsignedByte relay = 1 << playerID; +#if !RETAIL_COMPATIBLE_NETWORKING + const Bool isActingRouter = m_disconnectManager->isLocalPlayerNextPacketRouter(this); +#endif + DEBUG_LOG_LEVEL(DEBUG_LEVEL_NET, ("ConnectionManager::sendFrameDataToPlayer - sending data for frame %d", frame)); for (Int i = 0; i < MAX_SLOTS; ++i) { +#if !RETAIL_COMPATIBLE_NETWORKING + // Own data and that of a player that is out go from everyone, the rest only from the acting packet router. + if ((isActingRouter == FALSE) && (i != (Int)m_localSlot) && (m_disconnectManager->isPlayerOutOfGame(i, this) == FALSE)) { + continue; + } +#endif +#if defined(GENERALS_ONLINE) + // For a frame we have not run yet only our own data is final, and that of a player that is out once we hold all of it. + if (ownOnly && (i != (Int)m_localSlot)) { + Bool finalOfOutPlayer = FALSE; +#if !RETAIL_COMPATIBLE_NETWORKING + finalOfOutPlayer = (m_frameData[i] != nullptr) && m_disconnectManager->isPlayerOutOfGame(i, this) + && (m_frameData[i]->getFrameCommandCount(frame) == m_frameData[i]->getCommandCount(frame)); +#endif + if (!finalOfOutPlayer) { + continue; + } + } +#endif if ((m_frameData[i] != nullptr) && (i != playerID)) { // no need to send his own commands to him. NetCommandList *list = m_frameData[i]->getFrameCommandList(frame); if (list != nullptr) { @@ -3677,7 +3788,7 @@ void ConnectionManager::sendSingleFrameToPlayer(UnsignedInt playerID, UnsignedIn msg->setExecutionFrame(frame); msg->setCommandCount(frameCommandCount); if (DoesCommandRequireACommandID(msg->getNetCommandType())) { - msg->setID(GenerateNextCommandID()); + msg->setID(i == (Int)m_localSlot ? GenerateNextCommandID() : generateImpersonatedCommandID(i, frame)); } msg->setPlayerID(i); DEBUG_LOG_LEVEL(DEBUG_LEVEL_NET, ("ConnectionManager::sendFrameDataToPlayer - sending frame info from player %d to player %d for frame %d with command count %d and ID %d and relay %d", i, playerID, msg->getExecutionFrame(), msg->getCommandCount(), msg->getID(), relay)); @@ -3712,6 +3823,15 @@ void ConnectionManager::requestFrameDataResend(Int playerID, UnsignedInt frame) msg->setID(GenerateNextCommandID()); } +#if !RETAIL_COMPATIBLE_NETWORKING + // A player that is out answers no more, so the others that may hold its frames are asked instead. + if (m_disconnectManager->isPlayerOutOfGame(playerID, this) == TRUE) { + sendLocalCommandDirect(msg, (UnsignedByte)(0xff & ~(1 << m_localSlot) & ~(1 << playerID))); + msg->detach(); + return; + } +#endif + if (isPlayerConnected(playerID) == FALSE) { playerID = 0; while ((playerID < MAX_SLOTS) && (isPlayerConnected(playerID) == FALSE)) { @@ -3726,6 +3846,31 @@ void ConnectionManager::requestFrameDataResend(Int playerID, UnsignedInt frame) msg->detach(); } +#if !RETAIL_COMPATIBLE_NETWORKING +void ConnectionManager::askForOutPlayerFrame(Int slot, UnsignedInt frame) { + if (m_disconnectManager->isPlayerOutOfGame(slot, this) == FALSE) { + return; + } + const UnsignedInt now = timeGetTime(); + if ((slot == m_outFrameAskedSlot) && (frame == m_outFrameAskedFrame) && ((now - m_outFrameAskedTime) < 1000)) { + return; + } + m_outFrameAskedSlot = slot; + m_outFrameAskedFrame = frame; + m_outFrameAskedTime = now; + requestFrameDataResend(slot, frame); +} + +Int ConnectionManager::getSlotOfAddress(UnsignedInt addr, UnsignedShort port) { + for (Int i = 0; i < MAX_SLOTS; ++i) { + if ((m_connections[i] != nullptr) && (m_connections[i]->getUser()->GetIPAddr() == addr) && (m_connections[i]->getUser()->GetPort() == port)) { + return i; + } + } + return -1; +} +#endif + #if defined(GENERALS_ONLINE) void ConnectionManager::noteStalling(Bool stalling, UnsignedInt frame) { time_t now = timeGetTime(); diff --git a/Core/GameEngine/Source/GameNetwork/DisconnectManager.cpp b/Core/GameEngine/Source/GameNetwork/DisconnectManager.cpp index a59a6fcbc7c..eaab073f2d8 100644 --- a/Core/GameEngine/Source/GameNetwork/DisconnectManager.cpp +++ b/Core/GameEngine/Source/GameNetwork/DisconnectManager.cpp @@ -35,6 +35,18 @@ #include "GameNetwork/GameSpy/PingThread.h" #include "GameNetwork/GameSpy/GSConfig.h" +static const UnsignedInt SILENT_PLAYER_TIME = 2000; + +#if !RETAIL_COMPATIBLE_NETWORKING +static const UnsignedInt SCREEN_NOTIFY_TIME = 1000; + +static const UnsignedInt CATCH_UP_WAIT_TIME = 1000; + +// Frame 0 in a disconnect player command marks a leave notice, never a frame to remove the player on. +static const UnsignedInt LEAVE_NOTICE_FRAME = 0; + +static const UnsignedInt KEPT_SCREEN_WAIT_TIME = 200; +#endif DisconnectManager::DisconnectManager() { @@ -48,12 +60,24 @@ DisconnectManager::DisconnectManager() for( i = 0; i < MAX_SLOTS; ++i) { m_packetRouterFallback[i] = 0; +#if RETAIL_COMPATIBLE_NETWORKING + m_relayFrame[i] = 0; + m_relayTo[i] = 0; +#endif } m_packetRouterTimeout = 0; for( i = 0; i < MAX_SLOTS -1; ++i) { m_playerTimeouts[i] = 0; +#if !RETAIL_COMPATIBLE_NETWORKING + m_closedGracefully[i] = FALSE; +#endif } +#if !RETAIL_COMPATIBLE_NETWORKING + m_screenKept = FALSE; + m_removedInRound = FALSE; + m_catchUpFrame = 0; +#endif for( i = 0; i < MAX_SLOTS; ++i) { for (Int j = 0; j < MAX_SLOTS; ++j) { @@ -86,7 +110,19 @@ void DisconnectManager::init() { for (i = 0; i < MAX_SLOTS; ++i) { m_disconnectFrames[i] = 0; m_disconnectFramesReceived[i] = FALSE; +#if RETAIL_COMPATIBLE_NETWORKING + m_relayFrame[i] = 0; + m_relayTo[i] = 0; +#endif + } +#if !RETAIL_COMPATIBLE_NETWORKING + for (i = 0; i < MAX_SLOTS - 1; ++i) { + m_closedGracefully[i] = FALSE; } + m_screenKept = FALSE; + m_removedInRound = FALSE; + m_catchUpFrame = 0; +#endif m_pingFrame = 0; m_pingsSent = 0; @@ -103,7 +139,17 @@ void DisconnectManager::update(ConnectionManager *conMgr) { // the last one we had the commands ready for. if (TheGameLogic->getFrame() == m_lastFrame) { time_t curTime = timeGetTime(); - if ((curTime - m_lastFrameTime) > TheGlobalData->m_networkDisconnectTime) { +#if RETAIL_COMPATIBLE_NETWORKING + const time_t waitTime = TheGlobalData->m_networkDisconnectTime; +#else + const Bool catchUp = (m_catchUpFrame != 0) && (m_catchUpFrame == TheGameLogic->getFrame()); + time_t waitTime = catchUp ? std::min(TheGlobalData->m_networkDisconnectTime, CATCH_UP_WAIT_TIME) : TheGlobalData->m_networkDisconnectTime; + // screen kept for a leaver: no catch-up wait + if (m_screenKept == TRUE) { + waitTime = std::min(waitTime, (time_t)KEPT_SCREEN_WAIT_TIME); + } +#endif + if ((curTime - m_lastFrameTime) > waitTime) { if (m_disconnectState == DISCONNECTSTATETYPE_SCREENOFF) { turnOnScreen(conMgr); } @@ -113,6 +159,17 @@ void DisconnectManager::update(ConnectionManager *conMgr) { nextFrame(TheGameLogic->getFrame(), conMgr); } +#if !RETAIL_COMPATIBLE_NETWORKING + if ((m_screenKept == TRUE) && (m_disconnectState == DISCONNECTSTATETYPE_SCREENOFF) && (isGonePlayerConnected(conMgr) == FALSE)) { + TheDisconnectMenu->hideScreen(); + m_screenKept = FALSE; + } +#endif + +#if RETAIL_COMPATIBLE_NETWORKING + sendPendingRelays(conMgr); +#endif + if (m_disconnectState != DISCONNECTSTATETYPE_SCREENOFF) { updateDisconnectStatus(conMgr); @@ -198,12 +255,18 @@ Int DisconnectManager::getPingsReceived() void DisconnectManager::updateDisconnectStatus(ConnectionManager *conMgr) { + Bool removedPlayer = FALSE; for (Int i = 0; i < MAX_SLOTS; ++i) { if (conMgr->isPlayerConnected(i)) { Int slot = translatedSlotPosition(i, conMgr->getLocalPlayerID()); if (slot != -1) { time_t curTime = timeGetTime(); time_t newTime = TheGlobalData->m_networkPlayerTimeoutTime - (curTime - m_playerTimeouts[slot]); +#if !RETAIL_COMPATIBLE_NETWORKING + if (m_closedGracefully[slot]) { + newTime = -1; + } +#endif // if someone is more than 2/3 timed out, lets get our frame numbers sync'd up. Also if someone is voted out // lets do the same thing. @@ -217,7 +280,11 @@ void DisconnectManager::updateDisconnectStatus(ConnectionManager *conMgr) { 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 RETAIL_COMPATIBLE_NETWORKING if ((curTime - m_timeOfDisconnectScreenOn) > TheGlobalData->m_networkDisconnectScreenNotifyTime) { +#else + if ((curTime - m_timeOfDisconnectScreenOn) > std::min(TheGlobalData->m_networkDisconnectScreenNotifyTime, SCREEN_NOTIFY_TIME)) { +#endif TheNetwork->notifyOthersOfCurrentFrame(); m_haveNotifiedOtherPlayersOfCurrentFrame = TRUE; } @@ -232,9 +299,8 @@ void DisconnectManager::updateDisconnectStatus(ConnectionManager *conMgr) { if (isLocalPlayerNextPacketRouter(conMgr) == TRUE) { DEBUG_LOG(("DisconnectManager::updateDisconnectStatus - local player is next packet router")); DEBUG_LOG(("DisconnectManager::updateDisconnectStatus - about to do the disconnect procedure for player %d", i)); - sendDisconnectCommand(i, conMgr); - disconnectPlayer(i, conMgr); - sendPlayerDestruct(i, conMgr); + removePlayer(i, conMgr); + removedPlayer = TRUE; } else { DEBUG_LOG(("DisconnectManager::updateDisconnectStatus - local player is not the next packet router")); } @@ -246,6 +312,22 @@ void DisconnectManager::updateDisconnectStatus(ConnectionManager *conMgr) { } } } + + if ((removedPlayer == TRUE) && (allOnSameFrame(conMgr) == TRUE) && (isLocalPlayerNextPacketRouter(conMgr) == TRUE)) { + for (Int i = 0; i < MAX_SLOTS; ++i) { + Int slot = translatedSlotPosition(i, conMgr->getLocalPlayerID()); + if ((slot != -1) && (conMgr->isPlayerConnected(i) == TRUE) && (isPlayerInGame(slot, conMgr) == FALSE)) { + DEBUG_LOG(("DisconnectManager::updateDisconnectStatus - about to do the disconnect procedure for player %d in the same pass", i)); + removePlayer(i, conMgr); + } + } + } +} + +void DisconnectManager::removePlayer(Int slot, ConnectionManager *conMgr) { + sendDisconnectCommand(slot, conMgr); + disconnectPlayer(slot, conMgr); + sendPlayerDestruct(slot, conMgr); } void DisconnectManager::updateWaitForPacketRouter(ConnectionManager *conMgr) { @@ -306,23 +388,143 @@ void DisconnectManager::processDisconnectKeepAlive(NetCommandMsg *msg, Connectio Int slot = translatedSlotPosition(cmdMsg->getPlayerID(), conMgr->getLocalPlayerID()); if (slot != -1) { resetPlayerTimeout(slot); +#if !RETAIL_COMPATIBLE_NETWORKING + m_closedGracefully[slot] = FALSE; +#endif } } +#if !RETAIL_COMPATIBLE_NETWORKING +void DisconnectManager::playerClosedGracefully(Int slot, ConnectionManager *conMgr) { + if (slot < 0 || slot >= MAX_SLOTS) { + return; + } + Int transSlot = translatedSlotPosition(slot, conMgr->getLocalPlayerID()); + if (transSlot == -1 || conMgr->isPlayerConnected(slot) == FALSE) { + return; + } + // The player counts as timed out, so the next packet router removes it with the usual disconnect command. + m_closedGracefully[transSlot] = TRUE; +} +#endif + void DisconnectManager::processDisconnectPlayer(NetCommandMsg *msg, ConnectionManager *conMgr) { NetDisconnectPlayerCommandMsg *cmdMsg = (NetDisconnectPlayerCommandMsg *)msg; DEBUG_LOG(("DisconnectManager::processDisconnectPlayer - Got disconnect player command from player %d. Disconnecting player %d on frame %d", msg->getPlayerID(), cmdMsg->getDisconnectSlot(), cmdMsg->getDisconnectFrame())); - DEBUG_ASSERTCRASH(TheGameLogic->getFrame() == cmdMsg->getDisconnectFrame(), ("disconnecting player on the wrong frame!!!")); - Int slot = cmdMsg->getDisconnectSlot(); + Int slot = cmdMsg->getDisconnectSlot(); + if (slot < 0 || slot >= MAX_SLOTS) { + DEBUG_LOG(("DisconnectManager::processDisconnectPlayer - slot %d is not in the game, ignored", slot)); + return; + } - // if this is a self initiated quit (Quit button on DC menu), - // send a destroy-player command so assets are removed. - if (cmdMsg->getPlayerID() == slot) { - // sendPlayerDestruct assumes we are (or we will become) the router. +#if RETAIL_COMPATIBLE_NETWORKING + // a player that quits from the disconnect screen also destroys its assets + if (cmdMsg->getPlayerID() == (UnsignedInt)slot) { sendPlayerDestruct(slot, conMgr); } +#endif + + if ((slot != (Int)conMgr->getLocalPlayerID()) && (conMgr->isPlayerConnected(slot) == FALSE)) { + DEBUG_LOG(("DisconnectManager::processDisconnectPlayer - player %d is already disconnected", slot)); + return; + } + +#if !RETAIL_COMPATIBLE_NETWORKING + if ((cmdMsg->getPlayerID() == (UnsignedInt)slot) || (cmdMsg->getDisconnectFrame() == LEAVE_NOTICE_FRAME)) { + if (cmdMsg->getPlayerID() != (UnsignedInt)slot) { +#if defined(GENERALS_ONLINE) + if (!conMgr->verifyLeaveNotice(cmdMsg)) { + DEBUG_LOG(("DisconnectManager::processDisconnectPlayer - player %d said that player %d left without its signature, ignored", msg->getPlayerID(), slot)); + return; + } +#else + return; +#endif + } + playerClosedGracefully(slot, conMgr); + + // everyone that got the leave from the leaver tells the others, so all of them pick the same next packet router + if (cmdMsg->getPlayerID() == (UnsignedInt)slot) { + NetDisconnectPlayerCommandMsg *noticeMsg = newInstance(NetDisconnectPlayerCommandMsg); + noticeMsg->setDisconnectSlot(slot); + noticeMsg->setDisconnectFrame(LEAVE_NOTICE_FRAME); + noticeMsg->setPlayerID(conMgr->getLocalPlayerID()); + if (cmdMsg->hasSignature()) { + noticeMsg->setSignature(cmdMsg->getSignature()); // the leaver's proof travels with the notice + } + if (DoesCommandRequireACommandID(noticeMsg->getNetCommandType())) { + noticeMsg->setID(GenerateNextCommandID()); + } + conMgr->sendLocalCommandDirect(noticeMsg, (UnsignedByte)(0xff & ~(1 << conMgr->getLocalPlayerID()) & ~(1 << slot))); + noticeMsg->detach(); + } + return; + } + + // a removal is the call of the acting packet router + if ((Int)cmdMsg->getPlayerID() != getActingPacketRouterSlot(conMgr)) { + DEBUG_LOG(("DisconnectManager::processDisconnectPlayer - player %d is not the acting packet router, removal of player %d ignored", msg->getPlayerID(), slot)); + return; + } +#endif + + DEBUG_ASSERTCRASH(TheGameLogic->getFrame() == cmdMsg->getDisconnectFrame(), ("disconnecting player on the wrong frame!!!")); disconnectPlayer(cmdMsg->getDisconnectSlot(), conMgr); + +#if RETAIL_COMPATIBLE_NETWORKING + if (cmdMsg->getPlayerID() == (UnsignedInt)slot && isLocalPlayerNextPacketRouter(conMgr) == TRUE) { + m_relayFrame[slot] = TheGameLogic->getFrame(); + m_relayTo[slot] = 0; + for (Int i = 0; i < MAX_SLOTS; ++i) { + if ((i != slot) && (i != (Int)conMgr->getLocalPlayerID()) && (conMgr->isPlayerConnected(i) == TRUE)) { + m_relayTo[slot] |= (UnsignedByte)(1 << i); + } + } + } +#endif +} + +#if RETAIL_COMPATIBLE_NETWORKING +void DisconnectManager::sendPendingRelays(ConnectionManager *conMgr) { + for (Int slot = 0; slot < MAX_SLOTS; ++slot) { + if (m_relayTo[slot] == 0) { + continue; + } + if (isLocalPlayerNextPacketRouter(conMgr) == FALSE) { + m_relayTo[slot] = 0; + continue; + } + UnsignedByte sendTo = 0; + for (Int i = 0; i < MAX_SLOTS; ++i) { + const UnsignedByte bit = (UnsignedByte)(1 << i); + if ((m_relayTo[slot] & bit) == 0) { + continue; + } + if (conMgr->isPlayerConnected(i) == FALSE) { + m_relayTo[slot] &= (UnsignedByte)~bit; + } + // pass the leave on to players stalled on the frame it was applied on + else if ((m_disconnectFramesReceived[i] == TRUE) && (m_disconnectFrames[i] == m_relayFrame[slot])) { + sendTo |= bit; + m_relayTo[slot] &= (UnsignedByte)~bit; + } + } + if (sendTo == 0) { + continue; + } + DEBUG_LOG(("DisconnectManager::sendPendingRelays - passing on the leave of player %d on frame %d to mask %X", slot, m_relayFrame[slot], sendTo)); + NetDisconnectPlayerCommandMsg *relayMsg = newInstance(NetDisconnectPlayerCommandMsg); + relayMsg->setDisconnectSlot(slot); + relayMsg->setDisconnectFrame(m_relayFrame[slot]); + relayMsg->setPlayerID(conMgr->getLocalPlayerID()); + if (DoesCommandRequireACommandID(relayMsg->getNetCommandType())) { + relayMsg->setID(GenerateNextCommandID()); + } + conMgr->sendLocalCommand(relayMsg, sendTo); + relayMsg->detach(); + } } +#endif void DisconnectManager::processPacketRouterQuery(NetCommandMsg *msg, ConnectionManager *conMgr) { NetPacketRouterQueryCommandMsg *cmdMsg = (NetPacketRouterQueryCommandMsg *)msg; @@ -462,6 +664,11 @@ void DisconnectManager::nextFrame(UnsignedInt frame, ConnectionManager *conMgr) void DisconnectManager::allCommandsReady(UnsignedInt frame, ConnectionManager *conMgr, Bool waitForPacketRouter) { if (m_disconnectState != DISCONNECTSTATETYPE_SCREENOFF) { DEBUG_LOG(("DisconnectManager::allCommandsReady - setting screen state to off.")); +#if !RETAIL_COMPATIBLE_NETWORKING + // no removal: a stall on the next frame brings the screen back soon + m_catchUpFrame = (m_removedInRound == TRUE) ? 0 : frame + 1; + m_removedInRound = FALSE; +#endif TheDisconnectMenu->hideScreen(); m_disconnectState = DISCONNECTSTATETYPE_SCREENOFF; @@ -474,6 +681,14 @@ void DisconnectManager::allCommandsReady(UnsignedInt frame, ConnectionManager *c DEBUG_LOG(("DisconnectManager::allCommandsReady - resetting m_timeOfDisconnectScreenOn")); m_timeOfDisconnectScreenOn = 0; + +#if !RETAIL_COMPATIBLE_NETWORKING + // the screen stays up until the player that has left is removed + m_screenKept = isGonePlayerConnected(conMgr); + if (m_screenKept == TRUE) { + TheDisconnectMenu->showScreen(); + } +#endif } } @@ -561,7 +776,13 @@ void DisconnectManager::turnOnScreen(ConnectionManager *conMgr) { m_disconnectState = DISCONNECTSTATETYPE_SCREENON; m_lastKeepAliveSendTime = -1; populateDisconnectScreen(conMgr); - resetPlayerTimeouts(conMgr); + // the count-downs run from the stall start + for (Int i = 0; i < MAX_SLOTS; ++i) { + Int slot = translatedSlotPosition(i, conMgr->getLocalPlayerID()); + if ((slot != -1) && (m_playerTimeouts[slot] < m_lastFrameTime)) { + m_playerTimeouts[slot] = m_lastFrameTime; + } + } TheDisconnectMenu->hidePacketRouterTimeout(); m_haveNotifiedOtherPlayersOfCurrentFrame = FALSE; @@ -571,6 +792,9 @@ void DisconnectManager::turnOnScreen(ConnectionManager *conMgr) { } void DisconnectManager::disconnectPlayer(Int slot, ConnectionManager *conMgr) { +#if !RETAIL_COMPATIBLE_NETWORKING + m_removedInRound = TRUE; +#endif DEBUG_LOG(("DisconnectManager::disconnectPlayer - Disconnecting slot number %d on frame %d", slot, TheGameLogic->getFrame())); DEBUG_ASSERTCRASH((slot >= 0) && (slot < MAX_SLOTS), ("Attempting to disconnect an invalid slot number")); if ((slot < 0) || (slot >= (MAX_SLOTS))) { @@ -614,7 +838,7 @@ void DisconnectManager::sendDisconnectCommand(Int slot, ConnectionManager *conMg return; } - UnsignedInt disconnectFrame = getMaxDisconnectFrame(); + UnsignedInt disconnectFrame = getMaxDisconnectFrame(conMgr); // Need to do the NetDisconnectPlayerCommandMsg creation and sending here. NetDisconnectPlayerCommandMsg *msg = newInstance(NetDisconnectPlayerCommandMsg); @@ -691,6 +915,23 @@ Bool DisconnectManager::allOnSameFrame(ConnectionManager *conMgr) { return retval; } +#if !RETAIL_COMPATIBLE_NETWORKING +// The first packet router in line that is still in the game, -1 if there is none. +Int DisconnectManager::getActingPacketRouterSlot(ConnectionManager *conMgr) { + UnsignedInt localSlot = conMgr->getLocalPlayerID(); + UnsignedInt packetRouterSlot = conMgr->getPacketRouterSlot(); + Int transSlot = translatedSlotPosition(packetRouterSlot, localSlot); + while ((transSlot != -1) && (isPlayerInGame(transSlot, conMgr) == FALSE)) { + packetRouterSlot = conMgr->getNextPacketRouterSlot(packetRouterSlot); + if (packetRouterSlot >= MAX_SLOTS) { + return -1; + } + transSlot = translatedSlotPosition(packetRouterSlot, localSlot); + } + return (Int)packetRouterSlot; +} +#endif + Bool DisconnectManager::isLocalPlayerNextPacketRouter(ConnectionManager *conMgr) { UnsignedInt localSlot = conMgr->getLocalPlayerID(); UnsignedInt packetRouterSlot = conMgr->getPacketRouterSlot(); @@ -720,6 +961,12 @@ Bool DisconnectManager::hasPlayerTimedOut(Int slot) { return FALSE; } +#if !RETAIL_COMPATIBLE_NETWORKING + if (m_closedGracefully[slot]) { + return TRUE; + } +#endif + time_t newTime = TheGlobalData->m_networkPlayerTimeoutTime - (timeGetTime() - m_playerTimeouts[slot]); if (newTime <= 0) { return TRUE; @@ -749,6 +996,46 @@ void DisconnectManager::sendPlayerDestruct(Int slot, ConnectionManager *conMgr) netmsg->detach(); } +Bool DisconnectManager::isPlayerSilent(Int slot) { + if ((slot == -1) || (m_disconnectState == DISCONNECTSTATETYPE_SCREENOFF) || (m_timeOfDisconnectScreenOn == 0)) { + return FALSE; + } +#if !RETAIL_COMPATIBLE_NETWORKING + if (m_closedGracefully[slot]) { + return TRUE; + } +#endif + const time_t curTime = timeGetTime(); + // Nobody is silent before the keep alives had time to arrive. + if ((curTime - m_timeOfDisconnectScreenOn) < (time_t)SILENT_PLAYER_TIME) { + return FALSE; + } + return (curTime - m_playerTimeouts[slot]) >= (time_t)SILENT_PLAYER_TIME; +} + +Bool DisconnectManager::isPlayerGone(Int slot, ConnectionManager *conMgr) { + if (isPlayerSilent(slot) == FALSE) { + return FALSE; + } + if (hasPlayerTimedOut(slot) == TRUE) { + return TRUE; + } + const Int localSlot = conMgr->getLocalPlayerID(); + const Int transSlot = untranslatedSlotPosition(slot, localSlot); + for (Int i = 0; i < MAX_SLOTS; ++i) { + if ((i == transSlot) || (conMgr->isPlayerConnected(i) == FALSE)) { + continue; + } + if ((i != localSlot) && (isPlayerSilent(translatedSlotPosition(i, localSlot)) == TRUE)) { + continue; + } + if ((m_playerVotes[transSlot][i].vote == FALSE) || (m_playerVotes[transSlot][i].frame != TheGameLogic->getFrame())) { + return FALSE; + } + } + return TRUE; +} + // the 'slot' variable is supposed to be a translated slot position. (translated slot meaning // that it is the player's position in the disconnect menu) Bool DisconnectManager::isPlayerVotedOut(Int slot, ConnectionManager *conMgr) { @@ -756,17 +1043,41 @@ Bool DisconnectManager::isPlayerVotedOut(Int slot, ConnectionManager *conMgr) { // we can't vote out ourselves. return FALSE; } - Int transSlot = untranslatedSlotPosition(slot, conMgr->getLocalPlayerID()); + const Int localSlot = conMgr->getLocalPlayerID(); + Int transSlot = untranslatedSlotPosition(slot, localSlot); Int numVotes = countVotesForPlayer(transSlot); - if (numVotes >= (conMgr->getNumPlayers() - 1)) { - return TRUE; + Int numVoters = conMgr->getNumPlayers() - 1; + + // a player that is gone cannot vote, neither its vote nor its missing vote counts + for (Int i = 0; i < MAX_SLOTS; ++i) { + if ((i == transSlot) || (i == localSlot) || (conMgr->isPlayerConnected(i) == FALSE)) { + continue; + } + if (isPlayerGone(translatedSlotPosition(i, localSlot), conMgr) == TRUE) { + --numVoters; + if ((m_playerVotes[transSlot][i].vote == TRUE) && (m_playerVotes[transSlot][i].frame == TheGameLogic->getFrame())) { + --numVotes; + } + } } - return FALSE; + + // One vote alone never removes a player of a game with more than two. + if ((numVoters < 2) && (conMgr->getNumPlayers() > 2)) { + numVoters = 2; + } + return numVotes >= numVoters; } -UnsignedInt DisconnectManager::getMaxDisconnectFrame() { +UnsignedInt DisconnectManager::getMaxDisconnectFrame(ConnectionManager *conMgr) { UnsignedInt retval = 0; for (Int i = 0; i < MAX_SLOTS; ++i) { +#if !RETAIL_COMPATIBLE_NETWORKING + // a player that has left reports the frame it stopped on, which the others may never reach + Int transSlot = translatedSlotPosition(i, conMgr->getLocalPlayerID()); + if ((transSlot != -1) && (m_closedGracefully[transSlot] == TRUE)) { + continue; + } +#endif if (m_disconnectFrames[i] > retval) { retval = m_disconnectFrames[i]; } @@ -774,6 +1085,32 @@ UnsignedInt DisconnectManager::getMaxDisconnectFrame() { return retval; } +#if !RETAIL_COMPATIBLE_NETWORKING +Bool DisconnectManager::isGonePlayerConnected(ConnectionManager *conMgr) { + for (Int i = 0; i < MAX_SLOTS; ++i) { + Int transSlot = translatedSlotPosition(i, conMgr->getLocalPlayerID()); + if ((transSlot != -1) && (m_closedGracefully[transSlot] == TRUE) && (conMgr->isPlayerConnected(i) == TRUE)) { + return TRUE; + } + } + return FALSE; +} +#endif + +#if !RETAIL_COMPATIBLE_NETWORKING +Bool DisconnectManager::isPlayerOutOfGame(Int slot, ConnectionManager *conMgr) { + if ((slot < 0) || (slot >= MAX_SLOTS) || (conMgr->isPlayerConnected(slot) == FALSE)) { + return FALSE; + } + Int transSlot = translatedSlotPosition(slot, conMgr->getLocalPlayerID()); + if (transSlot == -1) { + return FALSE; + } + return (isPlayerInGame(transSlot, conMgr) == FALSE); +} + +#endif + Bool DisconnectManager::isPlayerInGame(Int slot, ConnectionManager *conMgr) { Int transSlot = untranslatedSlotPosition(slot, conMgr->getLocalPlayerID()); DEBUG_ASSERTCRASH((transSlot >= 0) && (transSlot < MAX_SLOTS), ("invalid slot number")); diff --git a/Core/GameEngine/Source/GameNetwork/NetPacket.cpp b/Core/GameEngine/Source/GameNetwork/NetPacket.cpp index c04446d7e51..c0513f4684d 100644 --- a/Core/GameEngine/Source/GameNetwork/NetPacket.cpp +++ b/Core/GameEngine/Source/GameNetwork/NetPacket.cpp @@ -323,6 +323,9 @@ Bool NetPacket::isFrameRepeat(NetCommandRef *msg) { } NetFrameCommandMsg *framemsg = (NetFrameCommandMsg *)(msg->getCommand()); NetFrameCommandMsg *lastmsg = (NetFrameCommandMsg *)(m_lastCommand->getCommand()); + if (framemsg->getPlayerID() != lastmsg->getPlayerID()) { + return FALSE; + } if (framemsg->getCommandCount() != 0) { return FALSE; } diff --git a/Core/GameEngine/Source/GameNetwork/Network.cpp b/Core/GameEngine/Source/GameNetwork/Network.cpp index ea6d13f9f83..9f321d53b54 100644 --- a/Core/GameEngine/Source/GameNetwork/Network.cpp +++ b/Core/GameEngine/Source/GameNetwork/Network.cpp @@ -607,6 +607,13 @@ Bool Network::processCommand(GameMessage* msg) m_lastFrame = TheGameLogic->getFrame(); } +#if defined(GENERALS_ONLINE) + if (msg->getType() == GameMessage::MSG_CLEAR_GAME_DATA && + (m_localStatus == NETLOCALSTATUS_LEAVING || m_localStatus == NETLOCALSTATUS_LEFT)) { + return TRUE; // the exit is already under way + } +#endif + // Are we leaving the game? // This has to happen after the check to see if this is the start of a new logic frame. // The reason is that we have to send all the frame info packets necessary to get to the diff --git a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NetworkMesh.h b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NetworkMesh.h index d557c4f35c9..dafbbe5bf57 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NetworkMesh.h +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NetworkMesh.h @@ -50,6 +50,8 @@ class PlayerConnection EConnectionState GetState() const { return m_State; } int SendGamePacket(void* pBuffer, uint32_t totalDataSize); + bool LogInvalidDue(); ///< true about every 5 s while the connection stays invalid, the rest of its lines stay out of the log + int64_t m_nextInvalidLogMs = 0; void SendACPacket(const void* pData, uint32_t dataLen); diff --git a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NextGenTransport.h b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NextGenTransport.h index 71814f94ee7..315d2bd64df 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NextGenTransport.h +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NextGenTransport.h @@ -49,6 +49,9 @@ class NextGenTransport : public Transport //: public MemoryPoolObject } private: + Bool sendFailureLogDue(); ///< true once a second, the other failure lines are counted + UnsignedInt m_nextSendFailLogMs = 0; + Int m_skippedSendFailLogs = 0; OutgoingPacketState m_outPacketState[MAX_MESSAGES]; // Track which incoming buffer slots are occupied (handles zero-length packets) bool m_inBufferOccupied[MAX_MESSAGES]; diff --git a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h index 15448217cae..20944a0723b 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h @@ -372,8 +372,10 @@ struct ServiceConfig float ibra_maxslack_greaterthan200ms = 1.f; using Send = NetworkSettings::Send; using InputDelay = NetworkSettings::InputDelay; + using Disconnect = NetworkSettings::Disconnect; Send send; InputDelay input_delay; + Disconnect disconnect; struct Lobby { @@ -477,6 +479,12 @@ inline void ReadServiceConfig(const nlohmann::json& j, ServiceConfig& c) ReadServiceConfigValue(f, "latency_history_samples", c.input_delay.formula.latency_history_samples); } } + if (j.contains("disconnect")) + { + const nlohmann::json& s = j["disconnect"]; + ReadServiceConfigValue(s, "freeze_before_screen_ms", c.disconnect.freeze_before_screen_ms); + ReadServiceConfigValue(s, "remove_after_ms", c.disconnect.remove_after_ms); + } if (j.contains("lobby")) { ReadServiceConfigValue(j["lobby"], "host_migration", c.lobby.host_migration); @@ -508,6 +516,9 @@ inline void ClampServiceConfig(ServiceConfig& c) 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.disconnect.freeze_before_screen_ms = std::clamp(c.disconnect.freeze_before_screen_ms, 1000, 30000); + c.disconnect.remove_after_ms = std::clamp(c.disconnect.remove_after_ms, 10000, 180000); + c.telemetry.screenshot_width = std::clamp(c.telemetry.screenshot_width, 16, 4096); c.telemetry.screenshot_height = std::clamp(c.telemetry.screenshot_height, 16, 4096); } diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NetworkMesh.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NetworkMesh.cpp index 7cb67e58100..24cb32c723d 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NetworkMesh.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NetworkMesh.cpp @@ -128,9 +128,20 @@ void OnSteamNetConnectionStatusChanged(SteamNetConnectionStatusChangedCallback_t const int64_t userID = plrConnection.m_userID; const int signallingAttemptsBeforeDisconnect = plrConnection.m_SignallingAttempts; + const bool bInMatch = TheGameLogic != nullptr && TheGameLogic->isInInternetGame(); if (TheNetwork != nullptr) { TheNetwork->GetConnectionManager()->disconnectPlayer(userID); + + // a peer that closed on purpose during the match has left; closing to negotiate a new connection is not a leave + const bool bClosedOnPurpose = pInfo->m_info.m_eState == k_ESteamNetworkingConnectionState_ClosedByPeer + && pInfo->m_info.m_eEndReason == k_ESteamNetConnectionEnd_App_Generic + && strstr(pInfo->m_info.m_szEndDebug, "new connection being negotiated") == nullptr + && strstr(pInfo->m_info.m_szEndDebug, "(Rejected)") == nullptr; + if (bClosedOnPurpose && bInMatch) + { + TheNetwork->GetConnectionManager()->playerClosedGracefully(userID); + } } NetworkLog(ELogVerbosity::LOG_RELEASE, "[DC] Closing connection %lld", userID); @@ -143,7 +154,6 @@ void OnSteamNetConnectionStatusChanged(SteamNetConnectionStatusChangedCallback_t const bool bWeJoinedLater = pJoinOrderLobby == nullptr || !pJoinOrderLobby->IsJoinOrderKnown() || pJoinOrderLobby->JoinedAfter(userID); 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.connection.retry_signalling && ((bInMatch && !bPeerLeft) || (!bWeJoinedLater && !bPeerLeft) || signallingAttemptsBeforeDisconnect < numSignallingAttempts); bool bWasError = pInfo->m_info.m_eState == k_ESteamNetworkingConnectionState_ProblemDetectedLocally || pInfo->m_info.m_eEndReason != k_ESteamNetConnectionEnd_App_Generic; @@ -1802,6 +1812,17 @@ PlayerConnection::PlayerConnection(int64_t userID, const char* szMiddlewareID) } } +bool PlayerConnection::LogInvalidDue() +{ + const int64_t nowMs = NowSteadyMs(); + if (nowMs < m_nextInvalidLogMs) + { + return false; + } + m_nextInvalidLogMs = nowMs + 5000; + return true; +} + int PlayerConnection::SendGamePacket(void* pBuffer, uint32_t totalDataSize) { if (totalDataSize == 0) @@ -1825,7 +1846,10 @@ int PlayerConnection::SendGamePacket(void* pBuffer, uint32_t totalDataSize) { if (m_hSteamConnection == k_HSteamNetConnection_Invalid) { - NetworkLog(ELogVerbosity::LOG_RELEASE, "[GAME PACKET] Cannot send game packet - connection is invalid for user %lld", m_userID); + if (LogInvalidDue()) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[GAME PACKET] Cannot send game packet - connection is invalid for user %lld", m_userID); + } return -(int)k_EResultFail; } @@ -1953,7 +1977,10 @@ int PlayerConnection::Recv(SteamNetworkingMessage_t** pMsg, int maxMessages) } else { - NetworkLog(ELogVerbosity::LOG_RELEASE, "[DISC] Recv Failed 1 from user %lld", m_userID); + if (LogInvalidDue()) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[DISC] Recv Failed 1 from user %lld", m_userID); + } } return r; diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NextGenTransport.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NextGenTransport.cpp index 404094f7c7d..8c387770c45 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NextGenTransport.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NextGenTransport.cpp @@ -634,6 +634,18 @@ Bool NextGenTransport::doRecv(void) return bRet; } +Bool NextGenTransport::sendFailureLogDue() +{ + const UnsignedInt nowMs = timeGetTime(); + if (static_cast(nowMs - m_nextSendFailLogMs) < 0) + { + ++m_skippedSendFailLogs; + return FALSE; + } + m_nextSendFailLogMs = nowMs + 1000; + return TRUE; +} + Bool NextGenTransport::doSend(void) { Bool retval = TRUE; @@ -742,19 +754,27 @@ Bool NextGenTransport::doSend(void) if (m_outPacketState[i].retryCount < OutgoingPacketState::MAX_RETRIES) { - NetworkLog(ELogVerbosity::LOG_RELEASE, - "Game Packet Send: SendGamePacket failed (err=%d), retry %d/%d for packet to user %lld", - sendResult, m_outPacketState[i].retryCount, - OutgoingPacketState::MAX_RETRIES, pSlot->m_userID); + if (sendFailureLogDue()) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, + "Game Packet Send: SendGamePacket failed (err=%d), retry %d/%d for packet to user %lld, %d lines skipped", + sendResult, m_outPacketState[i].retryCount, + OutgoingPacketState::MAX_RETRIES, pSlot->m_userID, m_skippedSendFailLogs); + m_skippedSendFailLogs = 0; + } // Keep packet in queue for retry retval = FALSE; } else { // Max retries exceeded - drop packet - NetworkLog(ELogVerbosity::LOG_RELEASE, - "Game Packet Send: Dropping packet after %d failed retries to user %lld", - m_outPacketState[i].retryCount, pSlot->m_userID); + if (sendFailureLogDue()) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, + "Game Packet Send: Dropping packet after %d failed retries to user %lld, %d lines skipped", + m_outPacketState[i].retryCount, pSlot->m_userID, m_skippedSendFailLogs); + m_skippedSendFailLogs = 0; + } m_outBuffer[i].length = 0; m_outPacketState[i].retryCount = 0; retval = FALSE; @@ -850,7 +870,11 @@ Bool NextGenTransport::queueSend(UnsignedInt addr, return TRUE; } - NetworkLog(ELogVerbosity::LOG_RELEASE, - "Game Packet QueueSend: m_outBuffer full, dropping packet"); + if (sendFailureLogDue()) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, + "Game Packet QueueSend: m_outBuffer full, dropping packet, %d lines skipped", m_skippedSendFailLogs); + m_skippedSendFailLogs = 0; + } return FALSE; } From 6ce2e3c34541fd9662c26684bbfebded6dd6ac0f Mon Sep 17 00:00:00 2001 From: tintinhamans <5984296+tintinhamans@users.noreply.github.com> Date: Thu, 22 Oct 2026 07:03:29 +0200 Subject: [PATCH 24/28] feat(network): Revise runahead from measured latency and frame pacing --- .../Include/GameNetwork/Connection.h | 2 + .../Include/GameNetwork/ConnectionManager.h | 14 + .../Include/GameNetwork/ConnectionStats.h | 1 + .../Include/GameNetwork/FrameMetrics.h | 2 +- .../Include/GameNetwork/NetworkInterface.h | 1 + .../Include/GameNetwork/NetworkSettings.h | 11 +- .../Source/GameNetwork/Connection.cpp | 15 + .../Source/GameNetwork/ConnectionManager.cpp | 286 ++++++++---------- .../Source/GameNetwork/ConnectionStats.cpp | 18 ++ .../Source/GameNetwork/FrameMetrics.cpp | 25 +- .../Source/GameNetwork/GameSpy/LobbyUtils.cpp | 2 +- .../GameEngine/Source/GameNetwork/Network.cpp | 46 ++- .../GameEngine/Include/GameClient/InGameUI.h | 1 + .../GameNetwork/GeneralsOnline/NGMP_include.h | 1 - .../GameNetwork/GeneralsOnline/NetworkMesh.h | 42 +-- .../GeneralsOnline/OnlineServices_Init.h | 28 +- .../GameEngine/Source/Common/GlobalData.cpp | 4 + .../GameEngine/Source/GameClient/InGameUI.cpp | 34 +-- .../GameNetwork/GeneralsOnline/NGMPGame.cpp | 2 +- .../GeneralsOnline/NGMP_Helpers.cpp | 5 - .../GeneralsOnline/NetworkMesh.cpp | 54 +--- 21 files changed, 299 insertions(+), 295 deletions(-) diff --git a/Core/GameEngine/Include/GameNetwork/Connection.h b/Core/GameEngine/Include/GameNetwork/Connection.h index c1f409e3c7e..a0425fe0522 100644 --- a/Core/GameEngine/Include/GameNetwork/Connection.h +++ b/Core/GameEngine/Include/GameNetwork/Connection.h @@ -68,6 +68,7 @@ class Connection : public MemoryPoolObject void setNetworkSettings(const NetworkSettings &settings, Bool unreliableTransport) { m_networkSettings = settings; m_unreliableTransport = unreliableTransport; } #endif void setSlot(Int slot) { m_slot = slot; } + void setShortGrouping(Bool shortGrouping); ///< Send at one frame while the run ahead is close to its floor. Int getRedundancyDepth() const { return m_redundancyDepth; } time_t getFrameGrouping() const { return m_frameGrouping; } ///< The send interval in use, shorter than the configured one while redundancy is on. @@ -123,6 +124,7 @@ class Connection : public MemoryPoolObject Bool m_queueSuspected; ///< A full queue on the path, redundancy steps down instead of up. Int m_slot; + Bool m_shortGrouping; time_t m_configuredFrameGrouping; time_t m_frameGrouping; ///< The minimum time between packet sends. time_t m_lastTimeSent; ///< The time of the last packet send. diff --git a/Core/GameEngine/Include/GameNetwork/ConnectionManager.h b/Core/GameEngine/Include/GameNetwork/ConnectionManager.h index dd2268297e1..ddade0251f7 100644 --- a/Core/GameEngine/Include/GameNetwork/ConnectionManager.h +++ b/Core/GameEngine/Include/GameNetwork/ConnectionManager.h @@ -263,6 +263,8 @@ class ConnectionManager }; RecentCommandIds m_recentIds[MAX_SLOTS]; + Int m_runAheadLowerCount; + Int m_runAheadLowerPeak; Bool isDuplicateCommand(NetCommandMsg *msg); Int slotForUserID(Int64 userID) const; @@ -352,6 +354,18 @@ class ConnectionManager time_t m_stallStartTime; UnsignedInt m_stallMissingMask; Int m_lastKnownRunAhead; + Bool m_runAheadStillInitial; ///< No run ahead change has taken effect yet, the first decisions apply without the decrease hold. + Int m_pendingRunAhead; ///< Router: the run ahead and execution frame of the last command it issued. + UnsignedInt m_pendingExecFrame; + Int applyDecreaseHold(Int oldRunAhead, Int askedRunAhead, Int decreaseAfter); +public: + void noteRunAheadApplied(Int oldRunAhead, Int newRunAhead); +private: + + Bool m_shortGrouping; ///< The run ahead is within 2 frames of its floor, connections send at one frame + Int getRunAheadFloor() const; + time_t getEffectiveDecisionIntervalMs(Int runAhead) const; ///< The longer of the configured floor and 2 x the run ahead in ms + void updateShortGrouping(); Int m_frameGroupingMs; public: Bool getOverlayInfo(Int slot, NetworkOverlayInfo &info); ///< Fills what the overlay and the connection rating need about one slot. diff --git a/Core/GameEngine/Include/GameNetwork/ConnectionStats.h b/Core/GameEngine/Include/GameNetwork/ConnectionStats.h index db57961c0d0..a92f3c70f06 100644 --- a/Core/GameEngine/Include/GameNetwork/ConnectionStats.h +++ b/Core/GameEngine/Include/GameNetwork/ConnectionStats.h @@ -72,6 +72,7 @@ class ConnectionStats Real getLatency() const { return m_srtt; } Real getMinLatency() const; + Real getMaxLatency() const; Real getLatencyPercentile(Int percent, time_t since = 0) const; ///< Round trip that this share of the samples in the window, from since on, stays within Int getSampleCountSince(time_t since) const; Real getJitter() const { return m_jitter; } diff --git a/Core/GameEngine/Include/GameNetwork/FrameMetrics.h b/Core/GameEngine/Include/GameNetwork/FrameMetrics.h index 42956046bdd..29120262c43 100644 --- a/Core/GameEngine/Include/GameNetwork/FrameMetrics.h +++ b/Core/GameEngine/Include/GameNetwork/FrameMetrics.h @@ -48,7 +48,7 @@ class FrameMetrics { void addCushion(Int cushion); Real getAverageLatency(); - Int getAverageFPS(); + Int getAverageFPS(); ///< The median of the per-second fps samples. Int getMinimumCushion(); Bool isPending(UnsignedInt frame); diff --git a/Core/GameEngine/Include/GameNetwork/NetworkInterface.h b/Core/GameEngine/Include/GameNetwork/NetworkInterface.h index a63e2f5db19..ae327473c3c 100644 --- a/Core/GameEngine/Include/GameNetwork/NetworkInterface.h +++ b/Core/GameEngine/Include/GameNetwork/NetworkInterface.h @@ -62,6 +62,7 @@ class NetworkInterface : public SubsystemInterface virtual Bool isFrameDataReady() = 0; ///< Are the commands for the next frame available? #if defined(GENERALS_ONLINE) virtual Bool isGameExitPending() const { return FALSE; } + virtual UnsignedInt getMeasuredLogicRate() = 0; ///< Logic frames executed per second, over about a second #endif virtual Bool isStalling() = 0; virtual void parseUserList(const GameInfo* game) = 0; ///< Parse a userlist, creating connections diff --git a/Core/GameEngine/Include/GameNetwork/NetworkSettings.h b/Core/GameEngine/Include/GameNetwork/NetworkSettings.h index 32342b7b7ab..a390070d702 100644 --- a/Core/GameEngine/Include/GameNetwork/NetworkSettings.h +++ b/Core/GameEngine/Include/GameNetwork/NetworkSettings.h @@ -23,14 +23,17 @@ struct NetworkSettings } send; struct InputDelay { - int min_frames = 4; - int decision_interval_ms = 5000; - int fps_history_samples = 30; // per-second FPS samples averaged + int min_frames = 0; // lowest run ahead, 0 = send interval frames + 3 + int decision_interval_ms = 1000; + int fps_history_samples = 30; // median of this many per-second FPS samples + int catch_up_frames = 1; // a frame later than this many frame times resets the clock, retail catches up from 2 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 + int latency_window_ms = 0; // in-band round trip averaging window in ms, 0 = since the last decision + int decrease_after_decisions = 20; // decisions in a row that must ask for less before the run ahead falls; 0 = follow at once (retail) + bool repair_budget = true; // add a send interval while a connection repeats commands } formula; } input_delay; diff --git a/Core/GameEngine/Source/GameNetwork/Connection.cpp b/Core/GameEngine/Source/GameNetwork/Connection.cpp index adf1c22d6bd..6bb74a3e6e6 100644 --- a/Core/GameEngine/Source/GameNetwork/Connection.cpp +++ b/Core/GameEngine/Source/GameNetwork/Connection.cpp @@ -57,6 +57,7 @@ Connection::Connection() { m_lastTimeSent = 0; m_frameGrouping = 1; m_configuredFrameGrouping = 1; + m_shortGrouping = FALSE; m_isQuitting = false; m_quitTime = 0; resetRedundancy(); @@ -96,6 +97,7 @@ void Connection::init() { m_lastTimeSent = 0; m_frameGrouping = 1; m_configuredFrameGrouping = 1; + m_shortGrouping = FALSE; m_stats.reset(); resetRedundancy(); m_isQuitting = FALSE; @@ -306,6 +308,12 @@ void Connection::applyFrameGrouping() { } } } + if (m_shortGrouping) { + const time_t oneFrame = (time_t)(1000 / GENERALS_ONLINE_HIGH_FPS_LIMIT); + if (oneFrame < grouping) { + grouping = oneFrame; + } + } #endif m_frameGrouping = grouping; } @@ -739,6 +747,13 @@ NetCommandRef * Connection::processAck(UnsignedShort commandID, UnsignedByte ori return temp; } +void Connection::setShortGrouping(Bool shortGrouping) { + if (shortGrouping != m_shortGrouping) { + m_shortGrouping = shortGrouping; + applyFrameGrouping(); + } +} + void Connection::setFrameGrouping(time_t frameGrouping) { m_configuredFrameGrouping = frameGrouping; applyFrameGrouping(); diff --git a/Core/GameEngine/Source/GameNetwork/ConnectionManager.cpp b/Core/GameEngine/Source/GameNetwork/ConnectionManager.cpp index 98dc13e4c4b..c6e4a0aadf1 100644 --- a/Core/GameEngine/Source/GameNetwork/ConnectionManager.cpp +++ b/Core/GameEngine/Source/GameNetwork/ConnectionManager.cpp @@ -298,7 +298,7 @@ void ConnectionManager::init() } 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_networkLatencyHistoryLength = (std::max)(30, (runAhead.formula.latency_window_ms > 0 ? runAhead.formula.latency_window_ms : runAhead.decision_interval_ms) * GENERALS_ONLINE_HIGH_FPS_LIMIT / 1000); TheWritableGlobalData->m_networkRunAheadSlack = runAhead.slack_percent; TheWritableGlobalData->m_networkRunAheadMetricsTime = runAhead.decision_interval_ms; MIN_RUNAHEAD = runAhead.min_frames; @@ -366,6 +366,11 @@ void ConnectionManager::init() m_stallStartTime = 0; m_stallMissingMask = 0; m_lastKnownRunAhead = 0; + m_runAheadLowerCount = 0; + m_runAheadLowerPeak = 0; + m_runAheadStillInitial = TRUE; + m_pendingRunAhead = 0; + m_pendingExecFrame = 0; m_loggedSenderUnavailable = FALSE; m_securityDrops = 0; m_directSendActive = FALSE; @@ -373,6 +378,7 @@ void ConnectionManager::init() m_formerRouterMask = 0; m_directSendCheckedAt = 0; clearSignatureState(TRUE); + m_shortGrouping = FALSE; m_frameGroupingMs = 0; #endif @@ -600,6 +606,46 @@ void ConnectionManager::onDirectFrameAck(UnsignedInt frame, Int peerSlot) { } } +Int ConnectionManager::getRunAheadFloor() const { + const NetworkSettings::InputDelay &runAhead = m_networkSettings.input_delay; + if (runAhead.min_frames > 0) { + return runAhead.min_frames; + } + // send interval + receive + execute + margin frame + return (m_shortGrouping ? 1 : m_networkSettings.send.frames_per_packet) + 3; +} + +time_t ConnectionManager::getEffectiveDecisionIntervalMs(Int runAhead) const { + time_t interval = TheGlobalData->m_networkRunAheadMetricsTime; // the floor + const time_t twiceRunAhead = (time_t)(2 * runAhead * 1000 / GENERALS_ONLINE_HIGH_FPS_LIMIT); + if (twiceRunAhead > interval) { + interval = twiceRunAhead; + } + return interval; +} + +// leaving the one frame sends needs 2 frames more than entering them +void ConnectionManager::updateShortGrouping() { + if (TheNetwork == nullptr) { + return; + } + + const Int runAhead = (Int)TheNetwork->getRunAhead(); + const NetworkSettings::InputDelay &config = m_networkSettings.input_delay; + const Int floorOneFrame = (config.min_frames > 0) ? config.min_frames : 4; + if (!m_shortGrouping && runAhead <= floorOneFrame + 2) { + m_shortGrouping = TRUE; + } else if (m_shortGrouping && runAhead > floorOneFrame + 4) { + m_shortGrouping = FALSE; + } + + for (Int i = 0; i < MAX_SLOTS; ++i) { + if (m_connections[i] != nullptr) { + m_connections[i]->setShortGrouping(m_shortGrouping); + } + } +} + Int ConnectionManager::slotForUserID(Int64 userID) const { return (TheNGMPGame != nullptr) ? TheNGMPGame->getSlotOfUser(userID) : -1; } @@ -2245,6 +2291,9 @@ void ConnectionManager::update(Bool isInGame) { } } +#if defined(GENERALS_ONLINE) + updateShortGrouping(); +#endif m_transport->doSend(); } @@ -2256,7 +2305,19 @@ void ConnectionManager::updateRunAhead(Int oldRunAhead, Int frameRate, Bool didS m_lastKnownRunAhead = oldRunAhead; #endif - if ((lasttimesent == 0) || ((curTime - lasttimesent) > TheGlobalData->m_networkRunAheadMetricsTime)) { +#if defined(GENERALS_ONLINE) + const time_t decisionInterval = getEffectiveDecisionIntervalMs(oldRunAhead); +#else + const time_t decisionInterval = TheGlobalData->m_networkRunAheadMetricsTime; +#endif + if ((lasttimesent == 0) || ((curTime - lasttimesent) > decisionInterval)) { +#if defined(GENERALS_ONLINE) + if (m_networkSettings.input_delay.formula.latency_window_ms <= 0) { + // the in-band latency is averaged over the samples since the previous decision + const UnsignedInt samples = (UnsignedInt)(decisionInterval * GENERALS_ONLINE_HIGH_FPS_LIMIT / 1000); + TheWritableGlobalData->m_networkLatencyHistoryLength = (samples > 30) ? samples : 30; + } +#endif if (m_localSlot == m_packetRouterSlot) { // We are the packet router, time to compute a new run ahead for this game. m_latencyAverages[m_localSlot] = m_frameMetrics.getAverageLatency(); @@ -2291,100 +2352,40 @@ void ConnectionManager::updateRunAhead(Int oldRunAhead, Int frameRate, Bool didS // The runahead should always be rounded up to the next integer value to prevent variations in latency from causing stutter // The network slack pushes the runahead up to the next value when the latency is within the slack percentage of the current runahead - Real configuredSlack = (Real)TheGlobalData->m_networkRunAheadSlack / 100.0f; - - Real effectiveSlack = configuredSlack; - -#if defined(GENERALS_ONLINE) - if (TheNGMPGame != nullptr) - { - ServiceConfig& serviceConf = NGMP_OnlineServicesManager::GetInstance()->GetServiceConfig(); - if (serviceConf.ibra_ra_tweaks) - { - // dynamic slack based on jitter - NetworkMesh* pMesh = NGMP_OnlineServicesManager::GetNetworkMesh(); - if (pMesh != nullptr) - { - // 1) Current max latency (RTT) - int maxLatMs = pMesh->getMaximumLatency(); - - // 2) Compute worst-case jitter (max - min) across recent history - int jitterMs = 0; - - auto& connections = pMesh->GetAllConnections(); - for (auto& kvPair : connections) - { - PlayerConnection& conn = kvPair.second; - - if (conn.m_vecLatencyHistory.size() > 1) - { - int minL = INT_MAX; - int maxL = 0; - - for (int l : conn.m_vecLatencyHistory) - { - if (l < minL) minL = l; - if (l > maxL) maxL = l; - } - - int span = maxL - minL; - if (span > jitterMs) - { - jitterMs = span; - } - } - } - - // 3) Convert jitter to a 0?1+ ratio relative to latency - Real jitterRatio = 0.0f; - if (maxLatMs > 0) - { - jitterRatio = (Real)jitterMs / (Real)maxLatMs; - } - - // 4) Map jitter ratio into a slack range based on latency - // Lower latency: tighter slack - // Higher latency: allow more slack to hide jitter - Real minSlack = serviceConf.ibra_minslack_default; - Real maxSlack = serviceConf.ibra_maxslack_default; - - if (maxLatMs > 300) - { - // At high RTT, allow more headroom - minSlack = serviceConf.ibra_minslack_greaterthan300ms; - maxSlack = serviceConf.ibra_maxslack_greaterthan300ms; - } - else if (maxLatMs > 200) - { - // Medium-high latency (200?300 ms) - minSlack = serviceConf.ibra_minslack_greaterthan200ms; - maxSlack = serviceConf.ibra_maxslack_greaterthan200ms; - } - - // Clamp jitterRatio to [0,1] when mapping - if (jitterRatio < 0.0f) jitterRatio = 0.0f; - if (jitterRatio > 1.0f) jitterRatio = 1.0f; - - Real dynamicSlack = minSlack + (maxSlack - minSlack) * jitterRatio; - - // ignore service slack and use our own dynamic slack - effectiveSlack = dynamicSlack; - } - } - } -#endif - - const Real runAheadSlackScale = 1.0f + effectiveSlack; + const Real runAheadSlackScale = 1.0f + (Real)TheGlobalData->m_networkRunAheadSlack / 100.0f; - //NetworkLog(ELogVerbosity::LOG_DEBUG, "RA CALC: %f * %f * %f = %d", getMaximumLatency(), runAheadSlackScale, (Real)minFps, (int)ceilf(getMaximumLatency() * runAheadSlackScale * (Real)minFps)); Int newRunAhead = ceilf(getMaximumLatency() * runAheadSlackScale * (Real)minFps); // TheSuperHackers @info if the runahead goes below 3 logic frames it can start to introduce stutter // We also limit the upper range of the runahead to prevent it getting out of hand Int minRunAheadForClamp = MIN_RUNAHEAD; +#if defined(GENERALS_ONLINE) + minRunAheadForClamp = getRunAheadFloor(); +#endif +#if defined(GENERALS_ONLINE) + // a lost packet is repaired by the copy in the next one, a send interval later: budget for it + Int repairFrames = 0; + if (m_networkSettings.input_delay.formula.repair_budget) { + time_t repairInterval = 0; + for (Int repairSlot = 0; repairSlot < MAX_SLOTS; ++repairSlot) { + if (m_connections[repairSlot] != nullptr && m_connections[repairSlot]->getRedundancyDepth() > 0 && m_connections[repairSlot]->getFrameGrouping() > repairInterval) { + repairInterval = m_connections[repairSlot]->getFrameGrouping(); + } + } + repairFrames = (Int)((repairInterval * GENERALS_ONLINE_HIGH_FPS_LIMIT + 999) / 1000); + } + newRunAhead += repairFrames; +#endif newRunAhead = clamp(minRunAheadForClamp, newRunAhead, MAX_FRAMES_AHEAD / 2); +#if defined(GENERALS_ONLINE) + const Int decreaseAfter = m_networkSettings.input_delay.formula.decrease_after_decisions; + // a command that was issued but has not taken effect yet is the run ahead to decide from, not the one still running + const Int baseRunAhead = (m_pendingExecFrame != 0 && oldRunAhead != m_pendingRunAhead && TheGameLogic->getFrame() <= m_pendingExecFrame + 2) ? m_pendingRunAhead : oldRunAhead; + newRunAhead = applyDecreaseHold(baseRunAhead, newRunAhead, decreaseAfter); +#endif + NetRunAheadCommandMsg* msg = newInstance(NetRunAheadCommandMsg); msg->setPlayerID(m_localSlot); if (DoesCommandRequireACommandID(msg->getNetCommandType())) { @@ -2424,6 +2425,11 @@ void ConnectionManager::updateRunAhead(Int oldRunAhead, Int frameRate, Bool didS msg->setRunAhead(newRunAhead); msg->setFrameRate(minFps); +#if defined(GENERALS_ONLINE) + m_pendingRunAhead = newRunAhead; + m_pendingExecFrame = msg->getExecutionFrame(); +#endif + //DEBUG_LOG(("ConnectionManager::updateRunAhead - new run ahead = %d, new frame rate = %d, execution frame %d", newRunAhead, minFps, msg->getExecutionFrame())); sendLocalCommand(msg, 0xff ^ (1 << minFpsPlayer)); // Send the packet to everyone but the lowest FPS player. @@ -2510,90 +2516,56 @@ void ConnectionManager::updateRunAhead(Int oldRunAhead, Int frameRate, Bool didS } } -Real ConnectionManager::getMaximumLatency() -{ - int latencyLogicModel = 0; - - if (TheNGMPGame != nullptr) - { - ServiceConfig& serviceConf = NGMP_OnlineServicesManager::GetInstance()->GetServiceConfig(); - latencyLogicModel = serviceConf.network_latency_logic_model; - // 0 = original - // 1 = pick highest between original and Valve latency (current) - // 2 = pick highest between original and Valve latency (historic) - // 3 = use Valve latency (current) - // 4 = use Valve latency (historic) +#if defined(GENERALS_ONLINE) +// The run ahead goes up at once and falls only after decreaseAfter asks for less in a row, then to the highest of them. +Int ConnectionManager::applyDecreaseHold(Int oldRunAhead, Int askedRunAhead, Int decreaseAfter) { + if (m_runAheadStillInitial) { + m_runAheadLowerCount = 0; + return askedRunAhead; } - - Real maxLatency = 0.0f; - - for (Int i = 0; i < MAX_SLOTS; ++i) { - if (isPlayerConnected(i) && m_latencyAverages[i] > maxLatency) { - maxLatency = m_latencyAverages[i]; + if (decreaseAfter > 0 && askedRunAhead < oldRunAhead) { + m_runAheadLowerPeak = (m_runAheadLowerCount == 0) ? askedRunAhead : max(m_runAheadLowerPeak, askedRunAhead); + ++m_runAheadLowerCount; + if (m_runAheadLowerCount >= decreaseAfter) { + m_runAheadLowerCount = 0; + return m_runAheadLowerPeak; } + return oldRunAhead; } + m_runAheadLowerCount = 0; + return askedRunAhead; +} - - if (latencyLogicModel == 0) - { - return maxLatency; +void ConnectionManager::noteRunAheadApplied(Int oldRunAhead, Int newRunAhead) { + if (oldRunAhead == newRunAhead) { + return; } - else if (latencyLogicModel == 1) - { - NetworkMesh* pMesh = NGMP_OnlineServicesManager::GetNetworkMesh(); + m_runAheadStillInitial = FALSE; +} +#endif - if (pMesh != nullptr) - { - Real maxGOLatency = (pMesh->getMaximumLatency() / 1000.f); - return maxLatency > maxGOLatency ? maxLatency : maxGOLatency; - } - else - { - return maxLatency; - } - } - else if (latencyLogicModel == 2) - { - NetworkMesh* pMesh = NGMP_OnlineServicesManager::GetNetworkMesh(); - if (pMesh != nullptr) - { - Real maxGOLatency = (pMesh->getMaximumHistoricalLatency() / 1000.f); - return maxLatency > maxGOLatency ? maxLatency : maxGOLatency; - } - else - { - return maxLatency; - } - } - else if (latencyLogicModel == 3) +// The average of the two highest in-band round trips, our own slot carries the one to our slowest peer. +Real ConnectionManager::getMaximumLatency() +{ + Real lat1 = 0.0f; + Real lat2 = 0.0f; + for (Int i = 0; i < MAX_SLOTS; ++i) { - NetworkMesh* pMesh = NGMP_OnlineServicesManager::GetNetworkMesh(); - - if (pMesh != nullptr) - { - Real maxGOLatency = (pMesh->getMaximumLatency() / 1000.f); - return maxGOLatency; - } - else + if (!isPlayerConnected(i)) { - return maxLatency; + continue; } - } - else if (latencyLogicModel == 4) - { - NetworkMesh* pMesh = NGMP_OnlineServicesManager::GetNetworkMesh(); - if (pMesh != nullptr) + if (m_latencyAverages[i] > lat1) { - Real maxGOLatency = (pMesh->getMaximumHistoricalLatency() / 1000.f); - return maxGOLatency; + lat2 = lat1; + lat1 = m_latencyAverages[i]; } - else + else if (m_latencyAverages[i] > lat2) { - return maxLatency; + lat2 = m_latencyAverages[i]; } } - - return maxLatency; + return (lat1 + lat2) / 2.0f; } void ConnectionManager::getMinimumFps(Int &minFps, Int &minFpsPlayer) { diff --git a/Core/GameEngine/Source/GameNetwork/ConnectionStats.cpp b/Core/GameEngine/Source/GameNetwork/ConnectionStats.cpp index 129659295a2..325d879ccf8 100644 --- a/Core/GameEngine/Source/GameNetwork/ConnectionStats.cpp +++ b/Core/GameEngine/Source/GameNetwork/ConnectionStats.cpp @@ -128,6 +128,24 @@ Real ConnectionStats::getMinLatency() const return result; } +Real ConnectionStats::getMaxLatency() const +{ + if (m_samples.size() == 0) + { + return 0.0f; + } + + Real result = m_samples[0].rtt; + for (Int i = 1; i < m_samples.size(); ++i) + { + if (m_samples[i].rtt > result) + { + result = m_samples[i].rtt; + } + } + return result; +} + Real ConnectionStats::getLatencyPercentile(Int percent, time_t since) const { Real sorted[MAX_SAMPLES]; diff --git a/Core/GameEngine/Source/GameNetwork/FrameMetrics.cpp b/Core/GameEngine/Source/GameNetwork/FrameMetrics.cpp index 9a7c77fa5d5..a56c2732c25 100644 --- a/Core/GameEngine/Source/GameNetwork/FrameMetrics.cpp +++ b/Core/GameEngine/Source/GameNetwork/FrameMetrics.cpp @@ -26,6 +26,7 @@ #include "PreRTS.h" // This must go first in EVERY cpp file in the GameEngine +#include #include #include "GameNetwork/FrameMetrics.h" @@ -171,7 +172,9 @@ void FrameMetrics::doPerFrameMetrics(UnsignedInt frame) { // beyond size limit? remove the oldest //NetworkLog(ELogVerbosity::LOG_DEBUG, "SIZE of pending latencies: %ld and %ld", m_mapPendingLatenciesSorted.size(), m_mapPendingLatenciesLookup.size()); - if (m_mapPendingLatenciesSorted.size() > TheGlobalData->m_networkLatencyHistoryLength) + // the table holds the frame infos in the air, which is independent of how many samples are averaged + const UnsignedInt pendingCapacity = (std::max)(TheGlobalData->m_networkLatencyHistoryLength, (UnsignedInt)(MAX_FRAMES_AHEAD * 2)); + if (m_mapPendingLatenciesSorted.size() > pendingCapacity) { // remove oldest frame UnsignedInt oldestFrame = m_mapPendingLatenciesSorted.begin()->first; @@ -209,7 +212,7 @@ void FrameMetrics::processLatencyResponse(UnsignedInt frame) // beyond size limit? remove the oldest //NetworkLog(ELogVerbosity::LOG_DEBUG, "SIZE of actual latencies: %ld and %ld/%ld", m_mapLatenciesLookup.size(), m_mapLatenciesSorted.size(), TheGlobalData->m_networkLatencyHistoryLength); - if (m_mapLatenciesLookup.size() > TheGlobalData->m_networkLatencyHistoryLength) + while (!m_mapLatenciesSorted.empty() && m_mapLatenciesLookup.size() > TheGlobalData->m_networkLatencyHistoryLength) // more than one when the window shrank { // remove oldest frame UnsignedInt oldestFrame = m_mapLatenciesSorted.begin()->first; @@ -266,7 +269,25 @@ void FrameMetrics::addCushion(Int cushion) { } Int FrameMetrics::getAverageFPS() { +#if defined(GENERALS_ONLINE) + enum { MAX_MEDIAN_SAMPLES = 128 }; + Real sorted[MAX_MEDIAN_SAMPLES]; + UnsignedInt count = TheGlobalData->m_networkFPSHistoryLength; + if (count > MAX_MEDIAN_SAMPLES) { + count = MAX_MEDIAN_SAMPLES; + } + if (count == 0) { + return (Int)m_averageFps; + } + + for (UnsignedInt i = 0; i < count; ++i) { + sorted[i] = m_fpsList[i]; + } + std::nth_element(sorted, sorted + count / 2, sorted + count); + return (Int)sorted[count / 2]; +#else return (Int)m_averageFps; +#endif } Real FrameMetrics::getAverageLatency() { diff --git a/Core/GameEngine/Source/GameNetwork/GameSpy/LobbyUtils.cpp b/Core/GameEngine/Source/GameNetwork/GameSpy/LobbyUtils.cpp index 93892fe49f4..82f233ed591 100644 --- a/Core/GameEngine/Source/GameNetwork/GameSpy/LobbyUtils.cpp +++ b/Core/GameEngine/Source/GameNetwork/GameSpy/LobbyUtils.cpp @@ -381,7 +381,7 @@ static void gameTooltip(GameWindow* window, tooltip.concat(region); UnicodeString latency; - latency.format(L"\nLatency: %d (%d frames)\n", lobbyEntry.latency, ConvertMSLatencyToGenToolFrames(lobbyEntry.latency)); + latency.format(L"\nLatency: %d [%dms]\n", ConvertMSLatencyToFrames(lobbyEntry.latency), lobbyEntry.latency); tooltip.concat(latency); #else tooltip.format(TheGameText->fetch("TOOLTIP:GameInfoGameName"), room->getGameName().str()); diff --git a/Core/GameEngine/Source/GameNetwork/Network.cpp b/Core/GameEngine/Source/GameNetwork/Network.cpp index 9f321d53b54..2b22d47510b 100644 --- a/Core/GameEngine/Source/GameNetwork/Network.cpp +++ b/Core/GameEngine/Source/GameNetwork/Network.cpp @@ -126,6 +126,7 @@ class Network : public NetworkInterface Bool isFrameDataReady(void); #if defined(GENERALS_ONLINE) virtual Bool isGameExitPending() const override { return m_localStatus == NETLOCALSTATUS_POSTGAME; } + virtual UnsignedInt getMeasuredLogicRate() override; #endif virtual Bool isStalling(); void parseUserList(const GameInfo* game); @@ -228,6 +229,11 @@ class Network : public NetworkInterface Int m_lastExecutionFrame; ///< The highest frame number that a command could have been executed on. Int m_lastFrameCompleted; Bool m_didSelfSlug; +#if defined(GENERALS_ONLINE) + DWORD m_rateSampleMs; + UnsignedInt m_rateSampleFrame; + UnsignedInt m_measuredLogicRate; +#endif __int64 m_perfCountFreq; ///< The frequency of the performance counter. __int64 m_nextFrameTime; ///< When did we execute the last frame? For slugging the GameLogic... @@ -382,6 +388,9 @@ void Network::init() #if defined(GENERALS_ONLINE) m_matchKeysRequired = FALSE; m_matchStartFailed = FALSE; + m_rateSampleMs = 0; + m_rateSampleFrame = 0; + m_measuredLogicRate = 0; #endif QueryPerformanceFrequency((LARGE_INTEGER*)&m_perfCountFreq); @@ -732,7 +741,13 @@ void Network::processFrameSynchronizedNetCommand(NetCommandRef* msg) { } void Network::processRunAheadCommand(NetRunAheadCommandMsg* msg) { +#if defined(GENERALS_ONLINE) + const Int previousRunAhead = m_runAhead; +#endif m_runAhead = msg->getRunAhead(); +#if defined(GENERALS_ONLINE) + m_conMgr->noteRunAheadApplied(previousRunAhead, m_runAhead); +#endif m_frameRate = msg->getFrameRate(); time_t frameGrouping = (1000 * m_runAhead) / m_frameRate; // number of miliseconds between packet sends frameGrouping = frameGrouping / 2; // since we only want the latency for one way to be a factor. @@ -923,7 +938,12 @@ Bool Network::timeForNewFrame() { */ if (m_conMgr != NULL) { Real cushion = m_conMgr->getMinimumCushion(); +#if defined(GENERALS_ONLINE) + // the controller keeps a margin of one frame, slow down when the commands of the slowest player arrive less than that early + Real runAheadPercentage = 1.0f; +#else Real runAheadPercentage = m_runAhead * (TheGlobalData->m_networkRunAheadSlack / (Real)100.0); // If we are at least 50% into our slack, we need to slow down. +#endif if (cushion < runAheadPercentage) { __int64 oldFrameDelay = frameDelay; frameDelay += oldFrameDelay / 10; // temporarily decrease the frame rate by 20%. @@ -938,9 +958,13 @@ Bool Network::timeForNewFrame() { if (curTime >= m_nextFrameTime) { NetworkLog(ELogVerbosity::LOG_DEBUG, "Allowing a new frame, frameDelay = %I64d, curTime - m_nextFrameTime = %I64d", frameDelay, curTime - m_nextFrameTime); -// if (m_nextFrameTime + frameDelay < curTime) { - if (m_nextFrameTime + frameDelay < curTime) { - //if ((m_nextFrameTime + ((GENERALS_ONLINE_HIGH_FPS_FRAME_MULTIPLIER * 2) * frameDelay)) < curTime) { + __int64 catchUpFrames = 1; +#if defined(GENERALS_ONLINE) + if (m_conMgr != nullptr) { + catchUpFrames = m_conMgr->getNetworkSettings().input_delay.catch_up_frames; + } +#endif + if (m_nextFrameTime + catchUpFrames * frameDelay < curTime) { // If we get too far behind on our framerate we need to reset the nextFrameTime thing. m_nextFrameTime = curTime; NetworkLog(ELogVerbosity::LOG_DEBUG, "Initializing m_nextFrameTime to %I64d", m_nextFrameTime); @@ -1216,6 +1240,22 @@ Int Network::getSlotAverageFPS(Int slot) { return -1; } +#if defined(GENERALS_ONLINE) +UnsignedInt Network::getMeasuredLogicRate() { + const DWORD now = timeGetTime(); + const UnsignedInt frame = TheGameLogic->getFrame(); + if (m_rateSampleMs == 0 || frame < m_rateSampleFrame) { + m_rateSampleMs = now; + m_rateSampleFrame = frame; + } else if (now - m_rateSampleMs >= 1000) { + m_measuredLogicRate = (UnsignedInt)(((frame - m_rateSampleFrame) * 1000.0f) / (Real)(now - m_rateSampleMs) + 0.5f); + m_rateSampleMs = now; + m_rateSampleFrame = frame; + } + return m_measuredLogicRate; +} +#endif + const ConnectionStats* Network::getConnectionStats(Int slot) const { if (m_conMgr != nullptr) { return m_conMgr->getConnectionStats(slot); diff --git a/GeneralsMD/Code/GameEngine/Include/GameClient/InGameUI.h b/GeneralsMD/Code/GameEngine/Include/GameClient/InGameUI.h index 872a4dab664..d5c966ceac8 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameClient/InGameUI.h +++ b/GeneralsMD/Code/GameEngine/Include/GameClient/InGameUI.h @@ -834,6 +834,7 @@ class InGameUI : public SubsystemInterface, public Snapshot Color m_networkLatencyColor; Color m_networkLatencyDropColor; UnsignedInt m_lastNetworkLatencyFrames; + UnsignedInt m_lastNetworkLogicRate; // Render FPS Counter DisplayString* m_renderFpsString; diff --git a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NGMP_include.h b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NGMP_include.h index e439210828b..57da3030398 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NGMP_include.h +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NGMP_include.h @@ -36,7 +36,6 @@ std::wstring WidenFormatSpecifiers(const std::wstring& format); int RoundUpLatencyToFrameInterval(int latency, int frameInterval); int ConvertMSLatencyToFrames(int ms); -int ConvertMSLatencyToGenToolFrames(int ms); // NGMP_NOTE: Plr Templates look like this: /* | NAME | GAME INDEX | SERVICE INDEX | PLAYABLE | diff --git a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NetworkMesh.h b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NetworkMesh.h index dafbbe5bf57..3e386de7e6a 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NetworkMesh.h +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NetworkMesh.h @@ -55,8 +55,6 @@ class PlayerConnection void SendACPacket(const void* pData, uint32_t dataLen); - void UpdateLatencyHistogram(); - void TickProbe(int64_t nowMs); void HandleProbe(const uint8_t* payload, size_t len, int64_t arrivalMs); void ResetProbe(); @@ -81,21 +79,12 @@ class PlayerConnection int Recv(SteamNetworkingMessage_t** pMsg, int maxMessages = 255); - int GetHighestHistoricalLatency() + // the highest probe round trip of the stats window, the current ping while there are no probe samples + int GetPeakLatency() { - int highestLatency = 0; - for (int latencyHistory : m_vecLatencyHistory) - { - if (latencyHistory > highestLatency) - { - highestLatency = latencyHistory; - } - } - - return highestLatency; + return (m_probeStats.getSampleCount() > 0) ? static_cast(m_probeStats.getMaxLatency()) : GetLatency(); } - std::vector m_vecLatencyHistory; std::string GetStats(); std::string GetConnectionType(); @@ -219,22 +208,6 @@ class NetworkMesh m_cbOnConnected = nullptr; } - int getMaximumLatency() - { - int highestLatency = 0; - - for (auto& kvPair : m_mapConnections) - { - PlayerConnection& conn = kvPair.second; - if (conn.GetLatency() > highestLatency) - { - highestLatency = conn.GetLatency(); - } - } - - return highestLatency; - } - // The signing keys, see MatchKeys.h bool BeginLobbyKeys(); ///< Makes this client's key, call when it joins or creates a lobby. void TickLobbyKeys(int64_t nowMs); ///< Announces the key to the peers that lack it and tells them which keys we hold. @@ -252,16 +225,17 @@ class NetworkMesh const MatchKeys& GetMatchKeys() const { return m_matchKeys; } MatchKeys& GetMatchKeys() { return m_matchKeys; } - Real getMaximumHistoricalLatency() + // PEAK HOLD: the highest probe round trip of any connection over the probe stats window + Real getMaximumProbeLatency() { int highestLatency = 0; for (auto& kvPair : m_mapConnections) { - PlayerConnection& conn = kvPair.second; - if (conn.GetHighestHistoricalLatency() > highestLatency) + const int peak = kvPair.second.GetPeakLatency(); + if (peak > highestLatency) { - highestLatency = conn.GetHighestHistoricalLatency(); + highestLatency = peak; } } diff --git a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h index 20944a0723b..a1161e6bf91 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h @@ -348,7 +348,7 @@ struct RegionResponse struct ServiceConfig { - // Peer connections: signalling, NAT traversal, latency probes, and the per-connection ping history + // Peer connections: signalling, NAT traversal and latency probes struct Connection { bool relay_only = false; @@ -358,18 +358,8 @@ struct ServiceConfig bool anticheat_transport = false; // game traffic through the anticheat plugin's transport int probe_lobby_interval_ms = 250; // time between latency probes sent to each peer while not in a match int probe_match_interval_ms = 1000; // the same in a match, the command acks already measure the round trip - 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; using Disconnect = NetworkSettings::Disconnect; @@ -451,7 +441,6 @@ inline void ReadServiceConfig(const nlohmann::json& j, ServiceConfig& c) ReadServiceConfigValue(s, "anticheat_transport", c.connection.anticheat_transport); ReadServiceConfigValue(s, "probe_lobby_interval_ms", c.connection.probe_lobby_interval_ms); ReadServiceConfigValue(s, "probe_match_interval_ms", c.connection.probe_match_interval_ms); - ReadServiceConfigValue(s, "latency_history_ms", c.connection.latency_history_ms); } if (j.contains("send")) { @@ -472,11 +461,14 @@ inline void ReadServiceConfig(const nlohmann::json& j, ServiceConfig& c) 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, "catch_up_frames", c.input_delay.catch_up_frames); 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); + ReadServiceConfigValue(f, "latency_window_ms", c.input_delay.formula.latency_window_ms); + ReadServiceConfigValue(f, "decrease_after_decisions", c.input_delay.formula.decrease_after_decisions); + ReadServiceConfigValue(f, "repair_budget", c.input_delay.formula.repair_budget); } } if (j.contains("disconnect")) @@ -502,18 +494,22 @@ inline void ReadServiceConfig(const nlohmann::json& j, ServiceConfig& c) // Keeps values from the service inside the ranges the game can run with inline void ClampServiceConfig(ServiceConfig& c) { - c.connection.latency_history_ms = (std::max)(c.connection.latency_history_ms, 1000); c.connection.probe_lobby_interval_ms = std::clamp(c.connection.probe_lobby_interval_ms, 50, 5000); c.connection.probe_match_interval_ms = std::clamp(c.connection.probe_match_interval_ms, 50, 10000); c.send.redundancy_window_ms = std::clamp(c.send.redundancy_window_ms, 0, 5000); c.send.redundancy_burst_ms = std::clamp(c.send.redundancy_burst_ms, 0, 1000); c.send.frames_per_packet_lossy = std::clamp(c.send.frames_per_packet_lossy, 0, 8); - c.input_delay.min_frames = std::clamp(c.input_delay.min_frames, 1, 64); + c.input_delay.formula.decrease_after_decisions = std::clamp(c.input_delay.formula.decrease_after_decisions, 0, 20); + c.input_delay.catch_up_frames = std::clamp(c.input_delay.catch_up_frames, 1, 4); + c.input_delay.min_frames = std::clamp(c.input_delay.min_frames, 0, 30); // the start run ahead is at least 30, a higher floor would make configs disagree 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 + if (c.input_delay.formula.latency_window_ms > 0) + { + c.input_delay.formula.latency_window_ms = std::clamp(c.input_delay.formula.latency_window_ms, 30 * 1000 / GENERALS_ONLINE_HIGH_FPS_LIMIT + 1, 60000); + } c.send.frames_per_packet = std::clamp(c.send.frames_per_packet, 1, 16); c.disconnect.freeze_before_screen_ms = std::clamp(c.disconnect.freeze_before_screen_ms, 1000, 30000); diff --git a/GeneralsMD/Code/GameEngine/Source/Common/GlobalData.cpp b/GeneralsMD/Code/GameEngine/Source/Common/GlobalData.cpp index 04063cf38f6..1305b0e9197 100644 --- a/GeneralsMD/Code/GameEngine/Source/Common/GlobalData.cpp +++ b/GeneralsMD/Code/GameEngine/Source/Common/GlobalData.cpp @@ -1031,7 +1031,11 @@ GlobalData::GlobalData() m_networkFPSHistoryLength = 30; m_networkLatencyHistoryLength = 200; m_networkRunAheadMetricsTime = 500; +#if defined(GENERALS_ONLINE) + m_networkCushionHistoryLength = 20; // 333 ms at 60 Hz, what retail's 10 frames at 30 Hz covered +#else m_networkCushionHistoryLength = 10; +#endif m_networkRunAheadSlack = 10; m_networkKeepAliveDelay = 20; m_networkDisconnectTime = 5000; diff --git a/GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp b/GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp index ea6a22d989b..65ab0d87426 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp @@ -1179,6 +1179,7 @@ InGameUI::InGameUI() m_networkLatencyColor = GameMakeColor(173, 216, 255, 255); m_networkLatencyDropColor = GameMakeColor(0, 0, 0, 255); m_lastNetworkLatencyFrames = ~0u; + m_lastNetworkLogicRate = ~0u; m_renderFpsString = nullptr; m_renderFpsLimitString = nullptr; @@ -6893,7 +6894,8 @@ void InGameUI::refreshNetworkLatencyResources() { m_networkLatencyString = TheDisplayStringManager->newDisplayString(); m_lastNetworkLatencyFrames = ~0u; - } + m_lastNetworkLogicRate = ~0u; + } m_networkLatencyPointSize = TheGlobalData->m_networkLatencyFontSize; Int adjustedNetworkLatencyFontSize = TheGlobalLanguageData->adjustFontSize(m_networkLatencyPointSize); @@ -7037,26 +7039,16 @@ void InGameUI::updateRenderFpsString() void InGameUI::drawNetworkLatency(Int& x, Int& y) { #if defined(GENERALS_ONLINE) - const UnsignedInt actualLatencyInMS = TheNetwork->getRunAhead() * (1000 / GENERALS_ONLINE_HIGH_FPS_LIMIT); - const UnsignedInt actualFrames = ConvertMSLatencyToFrames(actualLatencyInMS); - const UnsignedInt gentoolFrames = ConvertMSLatencyToGenToolFrames(actualLatencyInMS); - - //bool bIsSelfSlugged = TheNetwork->IsSlugging(); + const UnsignedInt networkLatencyFrames = TheNetwork->getRunAhead(); + const UnsignedInt logicRate = TheNetwork->getMeasuredLogicRate(); - if (gentoolFrames != m_lastNetworkLatencyFrames) + if (networkLatencyFrames != m_lastNetworkLatencyFrames || logicRate != m_lastNetworkLogicRate) { UnicodeString latencyStr; - - if (actualFrames != gentoolFrames) - { - latencyStr.format(L"[%u] - [%ums - %u]", TheNetwork->getFrameRate(), actualLatencyInMS, actualFrames); - } - else - { - latencyStr.format(L"%u [%ums][L: %u]", gentoolFrames, actualLatencyInMS, TheNetwork->getFrameRate()); - } + latencyStr.format(L"%u [%ums] [L%u]", networkLatencyFrames, (networkLatencyFrames * 1000 + GENERALS_ONLINE_HIGH_FPS_LIMIT / 2) / GENERALS_ONLINE_HIGH_FPS_LIMIT, logicRate); m_networkLatencyString->setText(latencyStr); - m_lastNetworkLatencyFrames = gentoolFrames; + m_lastNetworkLatencyFrames = networkLatencyFrames; + m_lastNetworkLogicRate = logicRate; } #else const UnsignedInt networkLatencyFrames = TheNetwork->getRunAhead(); @@ -7070,8 +7062,6 @@ void InGameUI::drawNetworkLatency(Int& x, Int& y) } #endif - - // TheSuperHackers @info at the HUD anchor this draws inline and advances x otherwise uses configured position if (isAtHudAnchorPos(m_networkLatencyPosition)) { @@ -7272,7 +7262,7 @@ namespace struct OverlayCache { - OverlayCache() : valid(FALSE), mode(0), refreshedMs(0), lineH(12), rowCount(0) + OverlayCache() : valid(FALSE), mode(0), refreshedMs(0), lineH(12), logicRate(0), rowCount(0) { for (Int i = 0; i < k_strTotal; ++i) { @@ -7288,6 +7278,7 @@ namespace Int mode; UnsignedInt refreshedMs; Int lineH; + UnsignedInt logicRate; Int rowCount; NetworkOverlayInfo header; UnicodeString waitingOn; @@ -7406,6 +7397,7 @@ void InGameUI::drawNetworkOverlay(Int mode) overlaySetText(pMeasure, UnicodeString(L"Wg")); pMeasure->getSize(&lineW, &lineH); cache.lineH = (std::max)(lineH, 12); + cache.logicRate = TheNetwork->getMeasuredLogicRate(); TheNetwork->getOverlayInfo(-1, cache.header); for (Int slot = 0; slot < MAX_SLOTS; ++slot) @@ -7568,7 +7560,7 @@ void InGameUI::drawNetworkOverlay(Int mode) { Int x = left; UnicodeString text; - text.format(L"Input delay %d ms", delayMs); + text.format(L"Input delay %d ms, logic rate %u Hz", delayMs, cache.logicRate); x += overlayDrawText(k_strHeaderBase + 0, x, y, text, header.stalledNow ? overlayColor(ConnectionRating::SEVERITY_BAD) : neutral, dropColor) + segmentGap; if (header.stalledNow && !waitingOn.isEmpty()) diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NGMPGame.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NGMPGame.cpp index bf00a650e6c..e98138005db 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NGMPGame.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NGMPGame.cpp @@ -466,7 +466,7 @@ void NGMPGame::launchGame(void) PopBackToLobby(); return; } - TheNetwork->SeedLatencyData(pMesh->getMaximumHistoricalLatency()); + TheNetwork->SeedLatencyData(pMesh->getMaximumProbeLatency()); // TODO_NGMP: Do we really care about these values anymore TheNetwork->setLocalAddress(getLocalIP(), 8888); diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NGMP_Helpers.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NGMP_Helpers.cpp index 77223c06347..c6014600128 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NGMP_Helpers.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NGMP_Helpers.cpp @@ -335,11 +335,6 @@ int ConvertMSLatencyToFrames(int ms) return (int)ceil((ms / 1000.f) * (float)GENERALS_ONLINE_HIGH_FPS_LIMIT); } -int ConvertMSLatencyToGenToolFrames(int ms) -{ - return (int)ceil((float)ConvertMSLatencyToFrames(ms) / (float)GENERALS_ONLINE_HIGH_FPS_FRAME_MULTIPLIER); -} - #include #include #include diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NetworkMesh.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NetworkMesh.cpp index 24cb32c723d..fdcfe4b03a5 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NetworkMesh.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NetworkMesh.cpp @@ -1505,12 +1505,11 @@ void NetworkMesh::Tick() const int64_t nowMs = NowSteadyMs(); - // update connection histograms + // probe every connection for (auto& kvPair : m_mapConnections) { PlayerConnection& conn = kvPair.second; conn.TickProbe(nowMs); - conn.UpdateLatencyHistogram(); } TickLobbyKeys(nowMs); @@ -1907,32 +1906,6 @@ void PlayerConnection::SendACPacket(const void* pData, uint32_t dataLen) } } -void PlayerConnection::UpdateLatencyHistogram() -{ - int histogram_duration = 20000; - - if (NGMP_OnlineServicesManager::GetInstance() != nullptr) - { - ServiceConfig& serviceConf = NGMP_OnlineServicesManager::GetInstance()->GetServiceConfig(); - histogram_duration = serviceConf.connection.latency_history_ms; - } - - - // update latency history, from the mesh probe once it has samples - int currLatency = (m_probeStats.getSampleCount() > 0) ? static_cast(m_probeStats.getLatency()) : GetLatency(); -#if defined(GENERALS_ONLINE_HIGH_FPS_SERVER) - const int connectionHistoryLength = histogram_duration / 16; // ~20 sec worth of frames at 60fps (default) -#else - const int connectionHistoryLength = histogram_duration / 33; // ~20 sec worth of frames at 30fps (default) -#endif - - if (m_vecLatencyHistory.size() >= connectionHistoryLength) - { - m_vecLatencyHistory.erase(m_vecLatencyHistory.begin()); - } - m_vecLatencyHistory.push_back(currLatency); -} - void PlayerConnection::Close() { if (m_ConnectionType == EConnectionType::BuiltIn_ValveSockets) @@ -2209,30 +2182,13 @@ bool PlayerConnection::GetIncomingLoss(int windowMs, PacketFeedback::Summary& su int PlayerConnection::GetJitter() { - int sumDelta = 0; - int count = 0; - int prev = -1; - for (int sample : m_vecLatencyHistory) + // smoothed round trip variation of the probe over its stats window + if (m_probeStats.getSampleCount() < 10) { - if (sample >= 0) - { - if (prev >= 0) - { - sumDelta += std::abs(sample - prev); - ++count; - } - prev = sample; - } - else - { - prev = -1; // gap in valid data - } - } - - if (count < 10) return -1; + } - return sumDelta / count; + return static_cast(m_probeStats.getJitter()); } int PlayerConnection::ComputeConnectionRating() From eb5d0a6bdf96d0da1aed6b38f84d3b63f90d3408 Mon Sep 17 00:00:00 2001 From: tintinhamans <5984296+tintinhamans@users.noreply.github.com> Date: Thu, 22 Oct 2026 07:03:30 +0200 Subject: [PATCH 25/28] feat(network): Control arrival cushion and starting runahead --- .../Include/GameNetwork/ConnectionManager.h | 68 +- .../Include/GameNetwork/ConnectionStats.h | 106 ++- .../Include/GameNetwork/NetCommandMsg.h | 19 + .../Include/GameNetwork/NetPacketStructs.h | 5 + .../Include/GameNetwork/NetworkDefs.h | 5 + .../Include/GameNetwork/NetworkInterface.h | 2 + .../Include/GameNetwork/NetworkSettings.h | 4 + .../Source/GameNetwork/ConnectionManager.cpp | 649 +++++++++++++++- .../Source/GameNetwork/ConnectionStats.cpp | 390 +++++++++- .../Source/GameNetwork/FrameMetrics.cpp | 12 +- .../Source/GameNetwork/NetCommandMsg.cpp | 8 + .../Source/GameNetwork/NetPacketStructs.cpp | 23 + .../GameEngine/Source/GameNetwork/Network.cpp | 139 +++- .../GameEngine/Include/GameLogic/GameLogic.h | 1 + .../GeneralsOnline/ConnectionRating.h | 5 +- .../GameNetwork/GeneralsOnline/NGMP_include.h | 3 +- .../GameNetwork/GeneralsOnline/NetworkMesh.h | 90 ++- .../GeneralsOnline/OnlineServices_Init.h | 6 + .../GUICallbacks/Menus/WOLGameSetupMenu.cpp | 30 +- .../Source/GameLogic/System/GameLogic.cpp | 108 ++- .../GeneralsOnline/ConnectionRating.cpp | 15 +- .../GameNetwork/GeneralsOnline/NGMPGame.cpp | 9 +- .../GeneralsOnline/NetworkMesh.cpp | 731 +++++++++++++++++- .../GeneralsOnline/NextGenTransport.cpp | 10 + .../GeneralsOnline/OnlineServices_Init.cpp | 7 + 25 files changed, 2313 insertions(+), 132 deletions(-) diff --git a/Core/GameEngine/Include/GameNetwork/ConnectionManager.h b/Core/GameEngine/Include/GameNetwork/ConnectionManager.h index ddade0251f7..f1bfd680339 100644 --- a/Core/GameEngine/Include/GameNetwork/ConnectionManager.h +++ b/Core/GameEngine/Include/GameNetwork/ConnectionManager.h @@ -161,6 +161,8 @@ class ConnectionManager Int getSlotAverageFPS(Int slot); #if defined(GENERALS_ONLINE) void noteStalling(Bool stalling, UnsignedInt frame); ///< Called every network update with whether the game is waiting for commands. + void noteScreenshotCapture(); ///< The main thread captures a screenshot, the stalls of the next two reports are not the network's. + Int getRemoteLookAhead(UnsignedInt frame, Int maxFrames, Bool &overdue); ///< Fewest frames after this one a remote player's commands are complete for, -1 without a remote player. #endif const ConnectionStats* getConnectionStats(Int slot) const; ///< Returns nullptr when there is no connection for the slot. @@ -203,6 +205,7 @@ class ConnectionManager // void doPerFrameMetrics(UnsignedInt frame); void getMinimumFps(Int &minFps, Int &minFpsPlayer); ///< Returns the smallest FPS in the m_fpsAverages list. Real getMaximumLatency(); ///< Returns the average of the two highest average latencies between players. + Real getLiveProbeLatency(); ///< Seconds, the highest probe round trip to a player still in the game, 0 if none is known. void requestFrameDataResend(Int playerID, UnsignedInt frame); ///< request of this player that he send the specified frame's data. UnsignedShort generateImpersonatedCommandID(Int playerID, UnsignedInt frame); @@ -250,6 +253,7 @@ class ConnectionManager Int m_minFpsPlayer; Int m_minFps; UnsignedInt m_smallestPacketArrivalCushion; + time_t m_lastRunAheadDecisionTime; ///< The last run ahead decision of this match, 0 before the first. Bool m_didSelfSlug; #if defined(GENERALS_ONLINE) @@ -271,7 +275,6 @@ class ConnectionManager Bool isPeerActive(Int slot) const; void onDirectFrameAck(UnsignedInt frame, Int peerSlot); std::map m_frameAckedMasks; ///< Frame -> the peers that acked its frame info so far. - Bool isDirectSendActive(); Bool m_directSendActive; time_t m_directSendCheckedAt; UnsignedInt m_formerRouterMask; ///< Slots that were the packet router in this match, their last packets can still be on the way. @@ -353,19 +356,74 @@ class ConnectionManager Bool m_stallActive; time_t m_stallStartTime; UnsignedInt m_stallMissingMask; + Bool m_stallWaitedOnRemote; ///< The current stall had a remote player's commands missing Int m_lastKnownRunAhead; + + // cushion controller (input_delay.controller), every player measures per sender, the packet router sums the cushions of each pair + Int m_ctlWindowMin[MAX_SLOTS]; ///< Lowest cushion per sender of the commands issued under the current run ahead since the last report, NO_CUSHION if none. + UnsignedInt m_ctlValidFromExec[MAX_SLOTS]; ///< Commands of a sender for an earlier execution frame were issued under the previous run ahead. + Int m_ctlLateCount[MAX_SLOTS]; ///< Late commands (cushion <= 0) per sender since the last report. + Int m_ctlStallMs; ///< Ms of the waits of at least a frame for a remote player's commands since the last report. + time_t m_ctlStallMark; ///< Stall time before this was reported already. + Int m_ctlScreenshotReports; ///< Reports still to flag as holding a local screenshot capture + time_t m_screenshotStallUntil; ///< A stall that starts before this follows a local screenshot capture + Bool m_ctlScreenshotSeen; ///< Router: a report since the last decision was flagged as holding a screenshot capture. + Bool m_ctlSeeded; + Int m_ctlSeedWait; + Int m_ctlMin[MAX_SLOTS][MAX_SLOTS]; ///< Router: lowest cushion of the reports since the last decision, [reporting player][sender]. + Int m_ctlReportLate[MAX_SLOTS]; ///< Router: late commands per reporting player since the last decision. + Int m_ctlReportStall[MAX_SLOTS]; ///< Router: the same stall ms per reporting player since the last decision, the decision uses the largest of them. + time_t m_ctlIgnoreUntil; ///< Router: reports arriving before this were measured under the previous run ahead, D after a change. + time_t m_ctlChangeTime; ///< Router: when the last run ahead change was decided. + time_t m_ctlReportTime[MAX_SLOTS]; ///< Router: when the last report counted since the change arrived per player, 0 = none yet. + time_t m_lastHeardMs[MAX_SLOTS]; ///< When the last packet of a player arrived, 0 = none yet. + time_t m_paceLastFreshMs[MAX_SLOTS]; ///< When the last new frame info of a player arrived, 0 = none yet. + Real m_paceGapAvgMs[MAX_SLOTS]; ///< Smoothed gap between the arrivals of new frame infos per player. + Real m_paceGapDevMs[MAX_SLOTS]; ///< Smoothed deviation of that gap. + time_t m_ctlOutageSince; ///< Router: since when a connected player is silent, 0 = no outage. + Int m_ctlSlowSlot; ///< Router: the slow producer whose stalls the last decision left out, -1 = none. + void updateControllerOutage(time_t now); ///< Router: starts or ends an outage, the controller holds through it. + time_t m_ctlLastPairTime; ///< Router: when a decision last saw a complete pair, the latency formula takes over without one. + time_t m_ctlLastWindowTime; ///< Router: when the last report window was taken in, its length is the time since. + RunAheadSpikeController m_spikeController; ///< Router: the baseline quantile, spike episodes and the cost rule. Bool m_runAheadStillInitial; ///< No run ahead change has taken effect yet, the first decisions apply without the decrease hold. - Int m_pendingRunAhead; ///< Router: the run ahead and execution frame of the last command it issued. - UnsignedInt m_pendingExecFrame; + Int m_pendingRunAhead; ///< Router: the run ahead of the last command it issued. + UnsignedInt m_pendingExecFrame; ///< Router: the execution frame of that command. + void collectControllerReport(Int *lowest, Int *late, Int &stallMs, Bool &screenshot); ///< The lowest cushion per sender, the late commands and the stall ms since the last report. + void resetControllerWindow(Bool sinceChange); ///< Drops the reports of the window, and with sinceChange which players reported since the change. + Int decideControllerRunAhead(Int oldRunAhead, Int formulaRunAhead, Int floorRunAhead, time_t now, Int decreaseAfter); + Int discardControllerReport(); ///< Takes the own report of the window and drops it, returns its stall ms. + Bool m_fastDropStep; ///< Router: the last decision was a fast drop step, the next one waits the short dead time. Int applyDecreaseHold(Int oldRunAhead, Int askedRunAhead, Int decreaseAfter); public: - void noteRunAheadApplied(Int oldRunAhead, Int newRunAhead); + void noteRunAheadApplied(Int oldRunAhead, Int newRunAhead); ///< The run ahead command took effect on this frame. + Int getRunAheadFloor() const; ///< The configured minimum, or the send interval frames + 3 + Int getRunAheadCap() const; ///< The run ahead cap in frames, input_delay.max_frames. + Bool acceptsRunAhead(Int runAhead) const; ///< An online match takes a run ahead between its floor and cap (0 only while alone). + Int computeStartRunAhead(Real roundTripMs, Bool oneWay) const; ///< The run ahead in frames estimateRunAheadMs asks for, between the floor and the cap. + Bool isDirectSendActive(Bool atStart = FALSE); ///< send.direct is on and every peer connection is up; atStart looks at the links right now and counts every peer in, before the match runs nobody is out + Bool isLinkUp(Int slot); ///< The mesh connection to the slot is up, a pair that fell back to the packet router is not. + Bool usesOneWayRunAhead(Bool atStart = FALSE); ///< The controller is on and direct sending is active right now: a command travels one way, not through the router. + Bool usesOneWayRunAheadForUser(Int64 userID); ///< The same for the link to one user: its connection is up and direct sending is on. + static Real estimateRunAheadMs(Real roundTripMs, const NetworkSettings::Send &sendSettings, const NetworkSettings::InputDelay &inputDelay, Bool oneWay); ///< The input delay a round trip needs, shared by the start run ahead, the controller seed and the connection rating. + void applyStartRunAhead(Int oldRunAhead, Int newRunAhead); ///< Frames 1 to the new run ahead - 1 have no commands, the frames after it wait for theirs. private: Bool m_shortGrouping; ///< The run ahead is within 2 frames of its floor, connections send at one frame - Int getRunAheadFloor() const; time_t getEffectiveDecisionIntervalMs(Int runAhead) const; ///< The longer of the configured floor and 2 x the run ahead in ms void updateShortGrouping(); + Int m_ctlLastReason; ///< Router: NetworkOverlayInfo::ControllerReason of the last controller decision. + Int m_ctlLastMinSum; ///< Router: what that decision saw, for the overlay. + Int m_ctlLastPairA; + Int m_ctlLastPairB; + Int m_ctlLastBase; + Int m_ctlLastCover; + Int m_ctlLastSpikesPerMin10; + Int m_ctlLastSpikeSizeMs; + Bool m_ctlLastInSpike; + Int m_changeDir; ///< Direction of the last run ahead change that took effect, 1 up, -1 down. + Int m_changeReason; ///< NetworkOverlayInfo::ChangeReason of that change. + time_t m_changeAppliedTime; ///< When it took effect on this machine, 0 = none yet. Int m_frameGroupingMs; public: Bool getOverlayInfo(Int slot, NetworkOverlayInfo &info); ///< Fills what the overlay and the connection rating need about one slot. diff --git a/Core/GameEngine/Include/GameNetwork/ConnectionStats.h b/Core/GameEngine/Include/GameNetwork/ConnectionStats.h index a92f3c70f06..e613b18cbe9 100644 --- a/Core/GameEngine/Include/GameNetwork/ConnectionStats.h +++ b/Core/GameEngine/Include/GameNetwork/ConnectionStats.h @@ -72,7 +72,6 @@ class ConnectionStats Real getLatency() const { return m_srtt; } Real getMinLatency() const; - Real getMaxLatency() const; Real getLatencyPercentile(Int percent, time_t since = 0) const; ///< Round trip that this share of the samples in the window, from since on, stays within Int getSampleCountSince(time_t since) const; Real getJitter() const { return m_jitter; } @@ -169,13 +168,98 @@ class StallStats TimedRing m_events; }; +// Run ahead from the per window need: a forgetting quantile of normal windows is the baseline; spike episodes are bought only +// when they recur faster than one per lambda. Rules use window time, so the decision interval does not matter. +class RunAheadSpikeController +{ +public: + enum Reason { REASON_HOLD, REASON_BASELINE, REASON_COVER, REASON_UNCOVER, REASON_CONGESTION }; + + struct Decision + { + Int runAhead; + Int reason; ///< Reason. + Int base; ///< Frames, the q quantile of the normal windows. + Int cover; ///< Frames bought on top of the baseline for spikes. + Int spikesPerMin10; ///< Spike episodes per minute over the horizon, times 10. + Int spikeSizeMs; ///< The median spike height over the baseline in the horizon. + Bool inSpike; + Bool fastDrop; ///< The target is the recent need, the next decision does not wait for the long dead time. + }; + + RunAheadSpikeController(); + + void reset(); + void dropEpisode(); ///< An outage: the open episode is not the network's trend. + Decision onWindow(time_t now, Int windowMs, Int need, Bool stalled, Int stallMs, Real jitterMs, Int runAhead, Int marginFrames, Real frameMs, Int floorFrames, Int capFrames, Bool safeFastDrop, Bool boundRaise); + +private: + enum + { + BUCKETS = 128, // one frame each, the need is 0 to 127 frames + SAMPLE_RING = 16, // need samples of the open episode + RECENT_RING = 8, // the last windows, for the median need of a fast drop + EPISODE_RING = 32 // more than the HORIZON_MS / LAMBDA_MS + 1 the cost rule looks at + }; + + struct Episode + { + time_t start; + Int height; + }; + + Real m_hist[BUCKETS]; ///< The normal windows, forgetting. + Real m_mass; + Real m_timeMs; ///< The time the normal windows cover, forgetting the same way. + Real m_saved[BUCKETS]; ///< The baseline before a level shift was adopted, for the undo. + Real m_savedMass; + Real m_savedTimeMs; + Int m_savedBase; + Int m_undoMs; ///< Window time left in which a return to the saved baseline undoes the adopted level. + Int m_undoCalmMs; + Episode m_pending; ///< The episode an adoption came from, a spike after all if it is undone. + + Bool m_inSpike; + time_t m_spikeStart; + Int m_peakNeed; + Int m_episodeStallMs; + Int m_episodeMs; ///< Window time of the open episode after the window that opened it. + Int m_calmMs; + time_t m_sampleTime[SAMPLE_RING]; + Int m_sampleNeed[SAMPLE_RING]; + Int m_sampleMs[SAMPLE_RING]; + Int m_sampleCount; + + Episode m_episodes[EPISODE_RING]; + Int m_episodeCount; ///< Recorded so far, the ring holds the last EPISODE_RING. + Int m_lastCover; + Int m_recentNeed[RECENT_RING]; + Int m_recentMs[RECENT_RING]; + Int m_recentCount; + Int m_gateCleanMs; ///< Window time in a row the need stayed within the target + 1. + time_t m_gateClusterStart; ///< Start of the current spike cluster. + time_t m_gatePrevClusterStart; ///< Start of the cluster before it. + + static void forget(Real *hist, Real &mass, Real factor); + static void addSample(Real *hist, Real &mass, Int need); + static Int quantile(const Real *hist, Real mass, Real q); + Int baseline(Int runAhead, Int marginFrames) const; + void recordEpisode(const Episode &episode); +}; + // What the network overlay and the connection rating need to know about one slot beyond its ConnectionStats. struct NetworkOverlayInfo { NetworkOverlayInfo() : cushionSamples(0), cushionP5(0), cushionP50(0) , stallsWindow(0), stallsMinute(0), stalledNow(FALSE), waitingOnSlot(FALSE) - , slotInBandMs(-1), runAhead(0), frameGroupingMs(0) {} + , runAhead(0), frameGroupingMs(0) + , isRouter(FALSE), controllerOn(FALSE), marginFrames(1), changeDir(0), changeReason(CHANGE_NONE), changeAgoMs(-1) + , ctlReason(CTL_NONE), ctlMinSum(NO_PAIR_SUM), ctlPairA(-1), ctlPairB(-1), ctlBase(0), ctlCover(0), ctlSpikesPerMin10(0), ctlSpikeSizeMs(0), ctlInSpike(FALSE), ctlSinceChangeMs(0) {} + + enum ChangeReason { CHANGE_NONE, CHANGE_START, CHANGE_LAG_SPIKE, CHANGE_STALL, CHANGE_CLEAN }; + enum { NO_PAIR_SUM = 999 }; ///< ctlMinSum when no pair reported + enum ControllerReason { CTL_NONE, CTL_SEED, CTL_HOLD, CTL_BASELINE, CTL_COVER, CTL_UNCOVER, CTL_CONGESTION, CTL_OUTAGE, CTL_FORMULA }; Int cushionSamples; ///< Frames before execution that the frame commands of this slot arrived, over the stats window. Int cushionP5; @@ -184,8 +268,24 @@ struct NetworkOverlayInfo Int stallsMinute; ///< The same over the last minute. Bool stalledNow; ///< The local game is waiting for commands right now. Bool waitingOnSlot; ///< ... and this slot is one it is waiting on. - Int slotInBandMs; ///< In-band round trip this slot contributes to the run ahead, -1 if unknown. Int runAhead; ///< Frames. Int frameGroupingMs; ///< The send interval. + + Bool isRouter; ///< This machine decides the run ahead, the controller fields are valid only here. + Bool controllerOn; + Int marginFrames; ///< Cushion frames each direction of a pair keeps spare. + Int changeDir; ///< Direction of the last applied run ahead change, 1 up, -1 down, 0 none yet. + Int changeReason; ///< ChangeReason, known on every client. + Int changeAgoMs; ///< Since that change took effect, -1 if none. + Int ctlReason; ///< ControllerReason of the last decision. + Int ctlMinSum; ///< Lowest sum of the two cushion directions of a pair, NO_PAIR_SUM if no pair reported. + Int ctlPairA; + Int ctlPairB; + Int ctlBase; ///< Frames, the baseline quantile of the normal windows. + Int ctlCover; ///< Frames bought for spikes on top of the baseline. + Int ctlSpikesPerMin10; ///< Spike episodes per minute, times 10. + Int ctlSpikeSizeMs; ///< The median spike height over the baseline. + Bool ctlInSpike; ///< A spike episode is open, the run ahead holds. + Int ctlSinceChangeMs; }; diff --git a/Core/GameEngine/Include/GameNetwork/NetCommandMsg.h b/Core/GameEngine/Include/GameNetwork/NetCommandMsg.h index e8d297a9ac0..1959c49c5f5 100644 --- a/Core/GameEngine/Include/GameNetwork/NetCommandMsg.h +++ b/Core/GameEngine/Include/GameNetwork/NetCommandMsg.h @@ -312,11 +312,30 @@ class NetRunAheadMetricsCommandMsg : public NetCommandMsgT= 0 && isLinkUp(slot); +} + +// Input delay for a round trip: through the router round trip x (1 + slack); sent directly with the controller one way x (1 + slack) + send interval + margin. +Real ConnectionManager::estimateRunAheadMs(Real roundTripMs, const NetworkSettings::Send &sendSettings, const NetworkSettings::InputDelay &inputDelay, Bool oneWay) { + const Real slackScale = 1.0f + (Real)inputDelay.slack_percent / 100.0f; + if (!oneWay) { + return roundTripMs * slackScale; + } + const Real frameMs = 1000.0f / (Real)GENERALS_ONLINE_HIGH_FPS_LIMIT; + return roundTripMs * 0.5f * slackScale + (Real)sendSettings.frames_per_packet * frameMs + (Real)inputDelay.margin_frames * frameMs; +} + +Int ConnectionManager::getRunAheadCap() const { + return runAheadCap(m_networkSettings); +} + +Bool ConnectionManager::acceptsRunAhead(Int runAhead) const { + if (TheLAN != nullptr || TheNGMPGame == nullptr) { + return TRUE; + } + if (runAhead == 0) { + // the router runs a match without remote players at no run ahead + for (Int i = 0; i < MAX_SLOTS; ++i) { + if (i != m_localSlot && m_connections[i] != nullptr) { + return FALSE; + } + } + return TRUE; + } + return runAhead >= getRunAheadFloor() && runAhead <= getRunAheadCap(); +} + +Int ConnectionManager::computeStartRunAhead(Real roundTripMs, Bool oneWay) const { + // no repair budget at the start + const Int runAhead = (Int)ceilf(estimateRunAheadMs(roundTripMs, m_networkSettings.send, m_networkSettings.input_delay, oneWay) * (Real)GENERALS_ONLINE_HIGH_FPS_LIMIT / 1000.0f); + return clamp(getRunAheadFloor(), runAhead, getRunAheadCap()); +} + +void ConnectionManager::applyStartRunAhead(Int oldRunAhead, Int newRunAhead) { + if (newRunAhead < oldRunAhead) { + for (Int frame = newRunAhead; frame < oldRunAhead; ++frame) { + for (Int i = 0; i < MAX_SLOTS; ++i) { + if (m_frameData[i] != nullptr) { + m_frameData[i]->resetFrame((UnsignedInt)frame, FALSE); + } + } + } + } else if (newRunAhead > oldRunAhead) { + zeroFrames((UnsignedInt)oldRunAhead, (UnsignedInt)(newRunAhead - oldRunAhead)); + } +} + Int ConnectionManager::getRunAheadFloor() const { const NetworkSettings::InputDelay &runAhead = m_networkSettings.input_delay; if (runAhead.min_frames > 0) { @@ -681,33 +795,41 @@ static Int relayClass(NetCommandType type) { } } +// The mesh connection to a slot is up. +Bool ConnectionManager::isLinkUp(Int slot) { + if (TheNGMPGame == nullptr || slot < 0 || slot >= MAX_SLOTS) { + return FALSE; + } + NetworkMesh *mesh = NGMP_OnlineServicesManager::GetNetworkMesh(); + const NGMPGameSlot *gameSlot = (const NGMPGameSlot *)TheNGMPGame->getConstSlot(slot); + PlayerConnection *connection = (mesh != nullptr && gameSlot != nullptr) ? mesh->GetConnectionForUser(gameSlot->m_userID) : nullptr; + return connection != nullptr && connection->GetState() == EConnectionState::CONNECTED_DIRECT; +} + // send.direct is on and every peer has a direct connection; commands then bypass the packet router. -Bool ConnectionManager::isDirectSendActive() { +Bool ConnectionManager::isDirectSendActive(Bool atStart) { if (TheNGMPGame == nullptr || !m_networkSettings.send.direct) { return FALSE; } const time_t now = timeGetTime(); - if (m_directSendCheckedAt != 0 && (now - m_directSendCheckedAt) < DIRECT_RECHECK_MS) { + if (!atStart && m_directSendCheckedAt != 0 && (now - m_directSendCheckedAt) < DIRECT_RECHECK_MS) { return m_directSendActive; } m_directSendCheckedAt = now; Bool meshUp = TRUE; - NetworkMesh *mesh = NGMP_OnlineServicesManager::GetNetworkMesh(); for (Int slot = 0; slot < MAX_SLOTS && meshUp; ++slot) { if (slot == m_localSlot || !isPeerActive(slot)) { continue; } #if !RETAIL_COMPATIBLE_NETWORKING - // a player that is out needs no commands - if (m_disconnectManager != nullptr && m_disconnectManager->isPlayerOutOfGame(slot, this)) { + // a player that is out needs no commands, before the match runs nobody is out + if (!atStart && m_disconnectManager != nullptr && m_disconnectManager->isPlayerOutOfGame(slot, this)) { continue; } #endif - const NGMPGameSlot *gameSlot = (const NGMPGameSlot *)TheNGMPGame->getConstSlot(slot); - PlayerConnection *connection = (mesh != nullptr && gameSlot != nullptr) ? mesh->GetConnectionForUser(gameSlot->m_userID) : nullptr; - if (connection == nullptr || connection->GetState() != EConnectionState::CONNECTED_DIRECT) { + if (!isLinkUp(slot)) { meshUp = FALSE; } } @@ -1264,6 +1386,9 @@ void ConnectionManager::doRelay() { Int senderSlot = -2; if (m_transport->m_inBuffer[i].senderKnown) { senderSlot = slotForUserID(m_transport->m_inBuffer[i].senderUserID); + if (senderSlot >= 0) { + m_lastHeardMs[senderSlot] = timeGetTime(); + } if (senderSlot < 0) { NetworkLog(ELogVerbosity::LOG_RELEASE, "[SECURITY] dropped a packet of %d commands from user %lld, who is not in this game", (Int)packet->getNumCommands(), (long long)m_transport->m_inBuffer[i].senderUserID); } @@ -1582,6 +1707,20 @@ void ConnectionManager::processRunAheadMetrics(NetRunAheadMetricsCommandMsg *msg if (isPlayerConnected(playerID)) { m_latencyAverages[playerID] = msg->getAverageLatency(); m_fpsAverages[playerID] = msg->getAverageFps(); +#if defined(GENERALS_ONLINE) + if (timeGetTime() >= m_ctlIgnoreUntil) { + for (Int sender = 0; sender < MAX_SLOTS; ++sender) { + // NO_CUSHION is above any cushion, the lowest report of the window wins + m_ctlMin[playerID][sender] = min(m_ctlMin[playerID][sender], msg->getCushionMin(sender)); + m_ctlReportLate[playerID] += msg->getLate(sender); + } + m_ctlReportStall[playerID] += msg->getStallMs(); + m_ctlReportTime[playerID] = timeGetTime(); + if (msg->getScreenshot()) { + m_ctlScreenshotSeen = TRUE; + } + } +#endif //DEBUG_LOG(("ConnectionManager::processRunAheadMetrics - player %d, fps = %d, latency = %f", player, msg->getAverageFps(), msg->getAverageLatency())); // NGMP_CHANGE: Modern machines render games at much higher framerates, 100 is no longer only achievable when a game is in the background... @@ -1857,7 +1996,29 @@ void ConnectionManager::processFrameInfo(NetFrameCommandMsg *msg) { m_frameData[playerID]->setFrameCommandCount(msg->getExecutionFrame(), msg->getCommandCount()); #if defined(GENERALS_ONLINE) if (!isCopy && (playerID != (UnsignedInt)m_localSlot) && (TheGameLogic != nullptr)) { - m_cushionStats[playerID].note((Int)msg->getExecutionFrame() - (Int)TheGameLogic->getFrame(), timeGetTime()); + const Int cushion = (Int)msg->getExecutionFrame() - (Int)TheGameLogic->getFrame(); + const time_t cushionTime = timeGetTime(); + m_cushionStats[playerID].note(cushion, cushionTime); + // frame infos of one packet share the ms + enum { PACE_SAME_BURST_MS = 2, PACE_GAP_SAMPLE_CAP_MS = 100 }; // caps the gap sample + if (m_paceLastFreshMs[playerID] != 0 && (cushionTime - m_paceLastFreshMs[playerID]) >= PACE_SAME_BURST_MS) { + const Real gap = (Real)min(cushionTime - m_paceLastFreshMs[playerID], PACE_GAP_SAMPLE_CAP_MS); + if (m_paceGapAvgMs[playerID] <= 0.0f) { + m_paceGapAvgMs[playerID] = gap; + m_paceGapDevMs[playerID] = gap * 0.5f; + } else { + const Real deviation = (gap > m_paceGapAvgMs[playerID]) ? (gap - m_paceGapAvgMs[playerID]) : (m_paceGapAvgMs[playerID] - gap); + m_paceGapDevMs[playerID] += (deviation - m_paceGapDevMs[playerID]) * 0.25f; + m_paceGapAvgMs[playerID] += (gap - m_paceGapAvgMs[playerID]) * 0.125f; + } + } + m_paceLastFreshMs[playerID] = cushionTime; + if (msg->getExecutionFrame() >= m_ctlValidFromExec[playerID]) { + m_ctlWindowMin[playerID] = min(m_ctlWindowMin[playerID], cushion); + if (cushion <= 0) { + ++m_ctlLateCount[playerID]; + } + } } #endif } @@ -2298,11 +2459,15 @@ void ConnectionManager::update(Bool isInGame) { } void ConnectionManager::updateRunAhead(Int oldRunAhead, Int frameRate, Bool didSelfSlug, Int nextExecutionFrame) { - static time_t lasttimesent = 0; time_t curTime = timeGetTime(); #if defined(GENERALS_ONLINE) m_lastKnownRunAhead = oldRunAhead; + if (m_localSlot == m_packetRouterSlot) { + updateControllerOutage(curTime); + } else { + m_fastDropStep = FALSE; + } #endif #if defined(GENERALS_ONLINE) @@ -2310,7 +2475,13 @@ void ConnectionManager::updateRunAhead(Int oldRunAhead, Int frameRate, Bool didS #else const time_t decisionInterval = TheGlobalData->m_networkRunAheadMetricsTime; #endif - if ((lasttimesent == 0) || ((curTime - lasttimesent) > decisionInterval)) { +#if defined(GENERALS_ONLINE) + // after a fast drop step the next decision only waits the short dead time + const time_t gateInterval = (m_fastDropStep && FAST_DROP_DEAD_MS < decisionInterval) ? (time_t)FAST_DROP_DEAD_MS : decisionInterval; +#else + const time_t gateInterval = decisionInterval; +#endif + if ((m_lastRunAheadDecisionTime == 0) || ((curTime - m_lastRunAheadDecisionTime) > gateInterval)) { #if defined(GENERALS_ONLINE) if (m_networkSettings.input_delay.formula.latency_window_ms <= 0) { // the in-band latency is averaged over the samples since the previous decision @@ -2377,13 +2548,25 @@ void ConnectionManager::updateRunAhead(Int oldRunAhead, Int frameRate, Bool didS } newRunAhead += repairFrames; #endif +#if defined(GENERALS_ONLINE) + newRunAhead = clamp(minRunAheadForClamp, newRunAhead, runAheadCap(m_networkSettings)); +#else newRunAhead = clamp(minRunAheadForClamp, newRunAhead, MAX_FRAMES_AHEAD / 2); +#endif #if defined(GENERALS_ONLINE) + const Bool controllerOn = m_networkSettings.input_delay.controller; const Int decreaseAfter = m_networkSettings.input_delay.formula.decrease_after_decisions; // a command that was issued but has not taken effect yet is the run ahead to decide from, not the one still running const Int baseRunAhead = (m_pendingExecFrame != 0 && oldRunAhead != m_pendingRunAhead && TheGameLogic->getFrame() <= m_pendingExecFrame + 2) ? m_pendingRunAhead : oldRunAhead; - newRunAhead = applyDecreaseHold(baseRunAhead, newRunAhead, decreaseAfter); + if (controllerOn) + { + newRunAhead = decideControllerRunAhead(baseRunAhead, newRunAhead, minRunAheadForClamp, curTime, decreaseAfter); + } + else + { + newRunAhead = applyDecreaseHold(baseRunAhead, newRunAhead, decreaseAfter); + } #endif NetRunAheadCommandMsg* msg = newInstance(NetRunAheadCommandMsg); @@ -2488,6 +2671,21 @@ void ConnectionManager::updateRunAhead(Int oldRunAhead, Int frameRate, Bool didS msg->setID(GenerateNextCommandID()); } msg->setAverageLatency(m_frameMetrics.getAverageLatency()); +#if defined(GENERALS_ONLINE) + { + Int lowest[MAX_SLOTS]; + Int late[MAX_SLOTS]; + Int stallMs; + Bool screenshot; + collectControllerReport(lowest, late, stallMs, screenshot); + for (Int sender = 0; sender < MAX_SLOTS; ++sender) { + msg->setCushionMin(sender, lowest[sender]); + msg->setLate(sender, late[sender]); + } + msg->setStallMs(stallMs); + msg->setScreenshot(screenshot); + } +#endif // see above for explanation. // if (didSelfSlug) { @@ -2512,11 +2710,15 @@ void ConnectionManager::updateRunAhead(Int oldRunAhead, Int frameRate, Bool didS } msg->detach(); } - lasttimesent = curTime; + m_lastRunAheadDecisionTime = curTime; } } #if defined(GENERALS_ONLINE) +static Int framesToMs(Int frames) { + return (frames * 1000 + GENERALS_ONLINE_HIGH_FPS_LIMIT - 1) / GENERALS_ONLINE_HIGH_FPS_LIMIT; +} + // The run ahead goes up at once and falls only after decreaseAfter asks for less in a row, then to the highest of them. Int ConnectionManager::applyDecreaseHold(Int oldRunAhead, Int askedRunAhead, Int decreaseAfter) { if (m_runAheadStillInitial) { @@ -2536,14 +2738,362 @@ Int ConnectionManager::applyDecreaseHold(Int oldRunAhead, Int askedRunAhead, Int return askedRunAhead; } +void ConnectionManager::resetControllerWindow(Bool sinceChange) { + m_ctlScreenshotSeen = FALSE; + for (Int i = 0; i < MAX_SLOTS; ++i) { + m_ctlReportLate[i] = 0; + m_ctlReportStall[i] = 0; + if (sinceChange) { + m_ctlReportTime[i] = 0; + } + for (Int sender = 0; sender < MAX_SLOTS; ++sender) { + m_ctlMin[i][sender] = NetRunAheadMetricsCommandMsg::NO_CUSHION; + } + } +} + void ConnectionManager::noteRunAheadApplied(Int oldRunAhead, Int newRunAhead) { if (oldRunAhead == newRunAhead) { return; } + // what the overlay says about this change: the router knows the controller's reason, the others infer it from the direction and their own stalls + { + const time_t appliedNow = timeGetTime(); + Int changeReason = NetworkOverlayInfo::CHANGE_CLEAN; + if (m_runAheadStillInitial) { + changeReason = NetworkOverlayInfo::CHANGE_START; + } else if (newRunAhead > oldRunAhead) { + m_stallStats.update(appliedNow); + const Bool router = (m_localSlot == m_packetRouterSlot); + const Bool stalled = router ? (m_ctlLastReason == NetworkOverlayInfo::CTL_CONGESTION) : (m_stallStats.getCount(appliedNow, NETWORK_STATS_WINDOW_MS) > 0); + changeReason = stalled ? NetworkOverlayInfo::CHANGE_STALL : NetworkOverlayInfo::CHANGE_LAG_SPIKE; + } + m_changeDir = (newRunAhead > oldRunAhead) ? 1 : -1; + m_changeReason = changeReason; + m_changeAppliedTime = appliedNow; + static const char *const s_changeNames[] = { "none", "start", "lag spike", "stall", "clean" }; + NetworkLog(ELogVerbosity::LOG_RELEASE, "[RUNAHEAD] input delay %d->%d ms (%s) reason=%s", framesToMs(oldRunAhead), framesToMs(newRunAhead), (m_changeDir > 0) ? "rising" : "lowering", s_changeNames[changeReason]); + } m_runAheadStillInitial = FALSE; + if (TheGameLogic == nullptr) { + return; + } + // commands issued from this frame on carry an execution frame of at least this + the new run ahead + const UnsignedInt validFrom = TheGameLogic->getFrame() + newRunAhead; + for (Int sender = 0; sender < MAX_SLOTS; ++sender) { + m_ctlValidFromExec[sender] = validFrom; + m_ctlWindowMin[sender] = NetRunAheadMetricsCommandMsg::NO_CUSHION; + m_ctlLateCount[sender] = 0; + } + m_ctlStallMs = 0; + m_ctlStallMark = timeGetTime(); +} + +void ConnectionManager::noteScreenshotCapture() { + // the capture blocks this frame, and the wait it leaves can end in the next report + m_ctlScreenshotReports = 2; + m_screenshotStallUntil = timeGetTime() + 3000; +} + +void ConnectionManager::collectControllerReport(Int *lowest, Int *late, Int &stallMs, Bool &screenshot) { + const time_t now = timeGetTime(); + for (Int sender = 0; sender < MAX_SLOTS; ++sender) { + lowest[sender] = m_ctlWindowMin[sender]; + m_ctlWindowMin[sender] = NetRunAheadMetricsCommandMsg::NO_CUSHION; + late[sender] = m_ctlLateCount[sender]; + m_ctlLateCount[sender] = 0; + } + if (m_stallActive && m_stallWaitedOnRemote && (now - m_stallStartTime) >= framesToMs(1)) { + m_ctlStallMs += (Int)(now - ((m_stallStartTime > m_ctlStallMark) ? m_stallStartTime : m_ctlStallMark)); + } + stallMs = m_ctlStallMs; + m_ctlStallMs = 0; + m_ctlStallMark = now; + screenshot = m_ctlScreenshotReports > 0; + if (screenshot) { + --m_ctlScreenshotReports; + } +} + +/** Takes the own report of the window and drops it, returns its stall ms. */ +Int ConnectionManager::discardControllerReport() { + Int lowest[MAX_SLOTS]; + Int late[MAX_SLOTS]; + Int stallMs; + Bool screenshot; + collectControllerReport(lowest, late, stallMs, screenshot); + return stallMs; +} + +/** A player silent for 1 s is an outage: the controller holds, drops its stalls and the open episode. */ +void ConnectionManager::updateControllerOutage(time_t now) { + enum { OUTAGE_MS = 1000 }; + Int silentSlot = -1; + time_t silentMs = 0; + for (Int slot = 0; slot < MAX_SLOTS; ++slot) { + if (slot != m_localSlot && isPlayerConnected(slot) && m_lastHeardMs[slot] != 0 && (now - m_lastHeardMs[slot]) >= OUTAGE_MS) { + if ((now - m_lastHeardMs[slot]) > silentMs) { + silentMs = now - m_lastHeardMs[slot]; + silentSlot = slot; + } + } + } + + if (silentSlot >= 0) { + if (m_ctlOutageSince == 0) { + m_ctlOutageSince = now - silentMs; + m_spikeController.dropEpisode(); + NetworkLog(ELogVerbosity::LOG_RELEASE, "[CONTROLLER] outage slot=%d silent for %dms, holding R %d", silentSlot, (Int)silentMs, m_lastKnownRunAhead); + } + return; + } + + if (m_ctlOutageSince != 0) { + const time_t outageMs = now - m_ctlOutageSince; + m_ctlOutageSince = 0; + const Int stallMs = discardControllerReport(); + resetControllerWindow(TRUE); + m_spikeController.dropEpisode(); + // reports still on their way cover the outage + const time_t ignoreUntil = now + getEffectiveDecisionIntervalMs(m_lastKnownRunAhead); + if (ignoreUntil > m_ctlIgnoreUntil) { + m_ctlIgnoreUntil = ignoreUntil; + } + m_ctlLastWindowTime = m_ctlIgnoreUntil; + NetworkLog(ELogVerbosity::LOG_RELEASE, "[CONTROLLER] outage over after %dms, dropped its stalls (own %dms), the controller resumes", (Int)outageMs, stallMs); + } +} + +/** Router: run ahead from the lowest cushion sum of the pairs (plus margin) and the stalls, via RunAheadSpikeController. */ +Int ConnectionManager::decideControllerRunAhead(Int oldRunAhead, Int formulaRunAhead, Int floorRunAhead, time_t now, Int decreaseAfter) { + const NetworkSettings::InputDelay &config = m_networkSettings.input_delay; + const Int cap = runAheadCap(m_networkSettings); + m_fastDropStep = FALSE; + const Int margin = config.margin_frames; + const Int noCushion = NetRunAheadMetricsCommandMsg::NO_CUSHION; + const Real frameMs = 1000.0f / (Real)GENERALS_ONLINE_HIGH_FPS_LIMIT; + const time_t formulaAfterMs = 2 * NETWORK_STATS_WINDOW_MS; // no complete pair for this long, the latency formula takes over + + if (!m_ctlSeeded) { + if (getMaximumLatency() <= 0.0f && ++m_ctlSeedWait < 3) { + return oldRunAhead; + } + m_ctlSeeded = TRUE; + m_ctlLastPairTime = now; + m_spikeController.reset(); + resetControllerWindow(TRUE); + // sent directly the seed is the one way run ahead the controller settles at, the same estimate the start run ahead used + const Bool oneWay = usesOneWayRunAhead(); + // the probes of the players still here: the averaged latencies still hold the samples of a player that just left + const Real liveLatency = getLiveProbeLatency(); + const Real seedLatency = (liveLatency > 0.0f) ? liveLatency : getMaximumLatency(); + const Int seedRunAhead = oneWay ? computeStartRunAhead(seedLatency * 1000.0f, TRUE) : formulaRunAhead; + NetworkLog(ELogVerbosity::LOG_RELEASE, "[CONTROLLER] R %d->%d reason=seed rtt_used=%.0fms one_way=%d", oldRunAhead, seedRunAhead, seedLatency * 1000.0f, (Int)oneWay); + m_ctlLastReason = NetworkOverlayInfo::CTL_SEED; + m_ctlChangeTime = now; + m_ctlIgnoreUntil = now + getEffectiveDecisionIntervalMs(seedRunAhead); + m_ctlLastWindowTime = m_ctlIgnoreUntil; + return seedRunAhead; + } + + // dead time: reports of the last change are still arriving + if (now < m_ctlIgnoreUntil) { + return oldRunAhead; + } + + // an outage is no reason to raise, the waits it causes are not the network's trend + if (m_ctlOutageSince != 0) { + const Int stallMs = discardControllerReport(); + resetControllerWindow(FALSE); + m_spikeController.dropEpisode(); + m_ctlLastWindowTime = now; + m_ctlLastReason = NetworkOverlayInfo::CTL_OUTAGE; + NetworkLog(ELogVerbosity::LOG_RELEASE, "[CONTROLLER] R %d->%d reason=outage silent_for=%dms stallms_dropped=%d", oldRunAhead, oldRunAhead, (Int)(now - m_ctlOutageSince), stallMs); + return oldRunAhead; + } + + // the router's own row + { + Int ownLowest[MAX_SLOTS]; + Int ownLate[MAX_SLOTS]; + Int ownStallMs; + Bool ownScreenshot; + collectControllerReport(ownLowest, ownLate, ownStallMs, ownScreenshot); + if (ownScreenshot) { + m_ctlScreenshotSeen = TRUE; + } + for (Int sender = 0; sender < MAX_SLOTS; ++sender) { + m_ctlMin[m_localSlot][sender] = min(m_ctlMin[m_localSlot][sender], ownLowest[sender]); + m_ctlReportLate[m_localSlot] += ownLate[sender]; + } + m_ctlReportStall[m_localSlot] += ownStallMs; + } + + // wait for every player's first report since the change, 2 D at most + { + Bool allReported = TRUE; + for (Int a = 0; a < MAX_SLOTS; ++a) { + if (a != m_localSlot && isPlayerConnected(a) && m_ctlReportTime[a] == 0) { + allReported = FALSE; + } + } + if (!allReported && (now - m_ctlChangeTime) < 2 * getEffectiveDecisionIntervalMs(oldRunAhead)) { + return oldRunAhead; + } + } + + // the need of the window: the pair with the lowest sum of its two directions sets it + Int minSum = noCushion; + Int pairA = -1; + Int pairB = -1; + Int pairCount = 0; + Int late = 0; + Int stallMs = 0; + for (Int a = 0; a < MAX_SLOTS; ++a) { + if (a != m_localSlot && !isPlayerConnected(a)) { + continue; + } + late += m_ctlReportLate[a]; + stallMs = max(stallMs, m_ctlReportStall[a]); + for (Int b = a + 1; b < MAX_SLOTS; ++b) { + if (b != m_localSlot && !isPlayerConnected(b)) { + continue; + } + if (m_ctlMin[a][b] == noCushion || m_ctlMin[b][a] == noCushion) { + continue; + } + ++pairCount; + const Int sum = m_ctlMin[a][b] + m_ctlMin[b][a]; + if (sum < minSum) { + minSum = sum; + pairA = a; + pairB = b; + } + } + } + // a slow producer feeds every other player late at any run ahead, its own cushion piles up while the others wait for it + Int slowSlot = -1; + Int slowFps = 0; + Int slowCushion = noCushion; + { + const Int lagFrames = max(SLOW_PRODUCER_MIN_LAG_FRAMES, oldRunAhead / 4); + for (Int p = 0; p < MAX_SLOTS && slowSlot < 0; ++p) { + if (p != m_localSlot && !isPlayerConnected(p)) { + continue; + } + Int ownCushion = noCushion; + for (Int sender = 0; sender < MAX_SLOTS; ++sender) { + if (sender != p) { + ownCushion = min(ownCushion, m_ctlMin[p][sender]); + } + } + const Bool lowFps = m_fpsAverages[p] > 0 && m_fpsAverages[p] < SLOW_PRODUCER_FPS; + const Bool piledUp = ownCushion != noCushion && ownCushion > oldRunAhead + lagFrames; + if (lowFps || piledUp) { + slowSlot = p; + slowFps = m_fpsAverages[p]; + slowCushion = ownCushion; + } + } + } + const Bool screenshotInterval = m_ctlScreenshotSeen; + const Int windowMs = (Int)(now - m_ctlLastWindowTime); + m_ctlLastWindowTime = now; + resetControllerWindow(FALSE); + + if (slowSlot != m_ctlSlowSlot) { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[CONTROLLER] slow producer slot=%d (was %d) fps=%d own_cushion=%d R=%d, the stalls waiting for it (%dms) do not raise R", slowSlot, m_ctlSlowSlot, slowFps, (slowCushion == noCushion) ? -1 : slowCushion, oldRunAhead, stallMs); + m_ctlSlowSlot = slowSlot; + } + // no run ahead can buy back the wait for a player that is slower than the game + if (slowSlot >= 0) { + stallMs = 0; + } + + const Bool havePair = pairCount > 0; + const Int stallGateMs = framesToMs(3); // a wait of less than 3 F is not seen and one more frame of run ahead would not fix it + const Bool stalled = !screenshotInterval && stallMs >= stallGateMs; + // 2 R - sum frames are missing over the pair, half of it per direction, plus the margin each direction keeps + const Int twiceShort = 2 * oldRunAhead - minSum; + Int need = havePair ? (((twiceShort >= 0) ? ((twiceShort + 1) / 2) : -((-twiceShort) / 2)) + margin) : 0; + if (stalled) { + need = max(need, oldRunAhead + (Int)ceil(stallMs / frameMs) + margin); + } + if (havePair) { + m_ctlLastPairTime = now; + } + m_ctlLastMinSum = havePair ? minSum : NetworkOverlayInfo::NO_PAIR_SUM; + m_ctlLastPairA = pairA; + m_ctlLastPairB = pairB; + + Int newRunAhead = oldRunAhead; + const char *reason = "hold"; + Int reasonCode = NetworkOverlayInfo::CTL_HOLD; + Bool fastDrop = FALSE; + if (screenshotInterval) { + // every client captures at the same second, the window says nothing about the network + } else if (!havePair && !stalled) { + if ((now - m_ctlLastPairTime) >= formulaAfterMs) { + newRunAhead = applyDecreaseHold(oldRunAhead, formulaRunAhead, decreaseAfter); + reason = "formula"; + reasonCode = NetworkOverlayInfo::CTL_FORMULA; + } + } else { + // the largest round trip jitter of the mesh probes widens what counts as a spike + Real jitterMs = 0.0f; + NetworkMesh *pMesh = NGMP_OnlineServicesManager::GetNetworkMesh(); + if (pMesh != nullptr) { + for (auto &kvPair : pMesh->GetAllConnections()) { + jitterMs = max(jitterMs, kvPair.second.m_probeStats.getJitter()); + } + } + const RunAheadSpikeController::Decision d = m_spikeController.onWindow(now, windowMs, need, stalled, stalled ? stallMs : 0, jitterMs, oldRunAhead, margin, frameMs, floorRunAhead, cap, config.fast_recovery, slowSlot >= 0); + static const char *const s_reasonNames[] = { "hold", "baseline", "cover", "uncover", "congestion" }; + static const Int s_reasonCodes[] = { NetworkOverlayInfo::CTL_HOLD, NetworkOverlayInfo::CTL_BASELINE, NetworkOverlayInfo::CTL_COVER, NetworkOverlayInfo::CTL_UNCOVER, NetworkOverlayInfo::CTL_CONGESTION }; + newRunAhead = d.runAhead; + fastDrop = d.fastDrop; + reason = s_reasonNames[d.reason]; + reasonCode = s_reasonCodes[d.reason]; + m_ctlLastBase = d.base; + m_ctlLastCover = d.cover; + m_ctlLastSpikesPerMin10 = d.spikesPerMin10; + m_ctlLastSpikeSizeMs = d.spikeSizeMs; + m_ctlLastInSpike = d.inSpike; + } + newRunAhead = clamp(floorRunAhead, newRunAhead, cap); + m_ctlLastReason = reasonCode; + NetworkLog(ELogVerbosity::LOG_RELEASE, "[CONTROLLER] R %d->%d reason=%s need=%d minS=%d pair=%d-%d late=%d stallms=%d", + oldRunAhead, newRunAhead, reason, need, havePair ? minSum : (Int)NetworkOverlayInfo::NO_PAIR_SUM, pairA, pairB, late, stallMs); + + if (newRunAhead != oldRunAhead) { + resetControllerWindow(TRUE); + m_ctlChangeTime = now; + // a fast drop waits the shortest dead time before the next step down + m_fastDropStep = fastDrop && newRunAhead < oldRunAhead; + m_ctlIgnoreUntil = now + (m_fastDropStep ? FAST_DROP_DEAD_MS : getEffectiveDecisionIntervalMs(newRunAhead)); + m_ctlLastWindowTime = m_ctlIgnoreUntil; + } + return newRunAhead; } #endif +Real ConnectionManager::getLiveProbeLatency() +{ + NetworkMesh *pMesh = NGMP_OnlineServicesManager::GetNetworkMesh(); + if (pMesh == nullptr) { + return 0.0f; + } + Int highestMs = 0; + for (auto &kvPair : pMesh->GetAllConnections()) { + const Int slot = slotForUserID(kvPair.first); + if (slot < 0 || slot == m_localSlot || !isPlayerConnected(slot)) { + continue; + } + highestMs = max(highestMs, kvPair.second.GetSeedLatency()); + } + return (Real)highestMs / 1000.0f; +} + // The average of the two highest in-band round trips, our own slot carries the one to our slowest peer. Real ConnectionManager::getMaximumLatency() { @@ -3858,10 +4408,59 @@ void ConnectionManager::noteStalling(Bool stalling, UnsignedInt frame) { } m_stallActive = TRUE; m_stallStartTime = now; - } else if (!stalling && m_stallActive) { + m_stallWaitedOnRemote = FALSE; + } + if (stalling) { + // only a remote player's missing commands are a network stall, a missing local slot or the disconnect manager holding the frame is not + for (Int i = 0; i < MAX_SLOTS && !m_stallWaitedOnRemote; ++i) { + if ((i != m_localSlot) && (m_frameData[i] != nullptr) && (m_frameData[i]->getIsQuitting() == FALSE) + && (m_frameData[i]->getFrameCommandCount(frame) != m_frameData[i]->getCommandCount(frame))) { + m_stallWaitedOnRemote = TRUE; + } + } + } else if (m_stallActive) { m_stallActive = FALSE; - m_stallStats.note((Int)(now - m_stallStartTime), m_stallMissingMask, now); + // a wait shorter than a frame is scheduler jitter, a frame is the least a larger run ahead could buy back + if (m_stallWaitedOnRemote && (now - m_stallStartTime) >= framesToMs(1)) { + m_ctlStallMs += (Int)(now - ((m_stallStartTime > m_ctlStallMark) ? m_stallStartTime : m_ctlStallMark)); + } + // the overlay, the rating and the logs count the stalls the controller counts: a wait of a frame or more for a + // remote player's commands, not one a local screenshot capture caused + const Bool screenshotStall = (m_screenshotStallUntil != 0 && m_stallStartTime < m_screenshotStallUntil); + if (m_stallWaitedOnRemote && (now - m_stallStartTime) >= framesToMs(1) && !screenshotStall) { + m_stallStats.note((Int)(now - m_stallStartTime), m_stallMissingMask, now); + } + } +} + +/** Frames after this one that every remote player's commands are complete for (lowest, up to maxFrames); overdue = its next frame info is later than its usual gap. */ +Int ConnectionManager::getRemoteLookAhead(UnsignedInt frame, Int maxFrames, Bool &overdue) { + const time_t now = timeGetTime(); + Int lowest = -1; + overdue = FALSE; + for (Int i = 0; i < MAX_SLOTS; ++i) { + if ((i == m_localSlot) || (m_frameData[i] == nullptr) || m_frameData[i]->getIsQuitting()) { + continue; + } + Int ahead = 0; + while ((ahead < maxFrames) && (m_frameData[i]->getFrameCommandCount(frame + 1 + ahead) == m_frameData[i]->getCommandCount(frame + 1 + ahead))) { + ++ahead; + } + // a usual gap plus twice its deviation, never less than a frame + Bool late = FALSE; + if (m_paceLastFreshMs[i] != 0 && m_paceGapAvgMs[i] > 0.0f) { + Real limitMs = m_paceGapAvgMs[i] + 2.0f * m_paceGapDevMs[i]; + if (limitMs < (Real)framesToMs(1)) { + limitMs = (Real)framesToMs(1); + } + late = (Real)(now - m_paceLastFreshMs[i]) > limitMs; + } + if ((lowest < 0) || (ahead < lowest) || ((ahead == lowest) && late && !overdue)) { + lowest = ahead; + overdue = late; + } } + return lowest; } Bool ConnectionManager::getOverlayInfo(Int slot, NetworkOverlayInfo &info) { @@ -3872,14 +4471,28 @@ Bool ConnectionManager::getOverlayInfo(Int slot, NetworkOverlayInfo &info) { info.stalledNow = m_stallActive; info.runAhead = m_lastKnownRunAhead; info.frameGroupingMs = m_frameGroupingMs; + const NetworkSettings::InputDelay &runAheadConfig = m_networkSettings.input_delay; + info.controllerOn = runAheadConfig.controller; + info.marginFrames = runAheadConfig.margin_frames; + info.isRouter = (m_localSlot == m_packetRouterSlot); + info.changeDir = m_changeDir; + info.changeReason = m_changeReason; + info.changeAgoMs = (m_changeAppliedTime != 0) ? (Int)(now - m_changeAppliedTime) : -1; + info.ctlReason = m_ctlLastReason; + info.ctlMinSum = m_ctlLastMinSum; + info.ctlPairA = m_ctlLastPairA; + info.ctlPairB = m_ctlLastPairB; + info.ctlBase = m_ctlLastBase; + info.ctlCover = m_ctlLastCover; + info.ctlSpikesPerMin10 = m_ctlLastSpikesPerMin10; + info.ctlSpikeSizeMs = m_ctlLastSpikeSizeMs; + info.ctlInSpike = m_ctlLastInSpike; + info.ctlSinceChangeMs = (m_ctlChangeTime != 0) ? (Int)(now - m_ctlChangeTime) : 0; if (slot >= 0 && slot < MAX_SLOTS) { info.stallsWindow = m_stallStats.getCountForSlot(slot, now, NETWORK_STATS_WINDOW_MS); info.stallsMinute = m_stallStats.getCountForSlot(slot, now, 60000); info.waitingOnSlot = m_stallActive && ((m_stallMissingMask & (1u << slot)) != 0); - if (isPlayerConnected(slot)) { - info.slotInBandMs = (Int)(m_latencyAverages[slot] * 1000.0f); - } m_cushionStats[slot].update(now); info.cushionSamples = m_cushionStats[slot].getSampleCount(); info.cushionP5 = m_cushionStats[slot].getPercentile(5); diff --git a/Core/GameEngine/Source/GameNetwork/ConnectionStats.cpp b/Core/GameEngine/Source/GameNetwork/ConnectionStats.cpp index 325d879ccf8..3606167395f 100644 --- a/Core/GameEngine/Source/GameNetwork/ConnectionStats.cpp +++ b/Core/GameEngine/Source/GameNetwork/ConnectionStats.cpp @@ -128,24 +128,6 @@ Real ConnectionStats::getMinLatency() const return result; } -Real ConnectionStats::getMaxLatency() const -{ - if (m_samples.size() == 0) - { - return 0.0f; - } - - Real result = m_samples[0].rtt; - for (Int i = 1; i < m_samples.size(); ++i) - { - if (m_samples[i].rtt > result) - { - result = m_samples[i].rtt; - } - } - return result; -} - Real ConnectionStats::getLatencyPercentile(Int percent, time_t since) const { Real sorted[MAX_SAMPLES]; @@ -366,3 +348,375 @@ Int StallStats::getCountForSlot(Int slot, time_t now, Int windowMs) const } return count; } + +// Rules run on window time, so any decision interval behaves the same. +static const Int SPIKE_LAMBDA_MS = 10000; // a player gives 1 frame of permanent delay for 1/lambda less freeze, the cover pays for spikes recurring faster than this +static const Int SPIKE_FORGET_MS = 500; +static const Real SPIKE_FORGET = 0.983f; // per SPIKE_FORGET_MS of window time, about a 30 s memory: up after 1.5 s of high windows, down after 90 s +static const Int SPIKE_WINDOW_MIN_MS = 100; +static const Int SPIKE_WINDOW_MAX_MS = 5000; // a decision interval is 2 x 128 frames at most, a longer window counts as this much +static const Int SPIKE_HEAD_MIN_FRAMES = 4; // over the baseline that opens an episode, above the normal tail +static const Int SPIKE_HEAD_JITTER_FACTOR = 3; // or 3 x the round trip jitter when that is more +static const Int SPIKE_TAIL_FRAMES = 1; // over the baseline that counts as calm again +static const Int SPIKE_TAIL_MS = 1000; // calm window time in a row that ends an episode +static const Int SPIKE_SUSTAIN_MS = 2000; // episode window time after its first window that makes it a level shift +static const Int SPIKE_UNDO_MS = SPIKE_SUSTAIN_MS; // window time after an adoption in which a return to the old baseline undoes it +static const Int SPIKE_WARMUP_MS = SPIKE_SUSTAIN_MS; // normal window time before the quantile is trusted, the seed stands until then +static const Int SPIKE_HORIZON_MS = 120000; // the spike rate memory, 3 periods of a 34 s spike +static const Int SPIKE_ALLOWED = SPIKE_HORIZON_MS / SPIKE_LAMBDA_MS; // spikes taller than the cover allowed in the horizon, a rate of 1 / lambda +static const Int SPIKE_EXTRA_MAX_FRAMES = 12; // the cover buys 200 ms at most, a taller spike is not affordable +static const Int SAFE_CLEAN_MS = 3000; // window time the need stays within the target + 1 before a fast drop is safe +static const Int SAFE_HISTORY_MS = 120000; // a fast drop needs the current spike to be the only one in this much time before it +static const Int SPIKE_DOWN_STEP_FRAMES = 2; // per decision going down, at least: the engine drains the queued frames without a stall, the step keeps the delay change gradual +static const Int SPIKE_MAX_RAISE_FRAMES = 24; // most a single decision raises while a slow producer is suspected, loss stalls raise without a bound + +// Adds value to an ascending array of count entries. +static void insertSorted(Int *array, Int &count, Int value) +{ + Int pos = count; + while (pos > 0 && array[pos - 1] > value) + { + array[pos] = array[pos - 1]; + --pos; + } + array[pos] = value; + ++count; +} + +RunAheadSpikeController::RunAheadSpikeController() +{ + reset(); +} + +void RunAheadSpikeController::reset() +{ + for (Int bucket = 0; bucket < BUCKETS; ++bucket) + { + m_hist[bucket] = 0.0f; + m_saved[bucket] = 0.0f; + } + m_mass = 0.0f; + m_timeMs = 0.0f; + m_savedMass = 0.0f; + m_savedTimeMs = 0.0f; + m_savedBase = 0; + m_undoMs = 0; + m_undoCalmMs = 0; + m_pending.start = 0; + m_pending.height = 0; + m_inSpike = FALSE; + m_spikeStart = 0; + m_peakNeed = 0; + m_episodeStallMs = 0; + m_episodeMs = 0; + m_calmMs = 0; + m_sampleCount = 0; + m_episodeCount = 0; + m_lastCover = 0; + m_recentCount = 0; + m_gateCleanMs = 0; + m_gateClusterStart = 0; + m_gatePrevClusterStart = 0; +} + +void RunAheadSpikeController::dropEpisode() +{ + m_inSpike = FALSE; + m_episodeMs = 0; + m_calmMs = 0; + m_sampleCount = 0; + m_undoMs = 0; + m_gateCleanMs = 0; +} + +void RunAheadSpikeController::forget(Real *hist, Real &mass, Real factor) +{ + for (Int bucket = 0; bucket < BUCKETS; ++bucket) + { + hist[bucket] *= factor; + } + mass *= factor; +} + +void RunAheadSpikeController::addSample(Real *hist, Real &mass, Int need) +{ + hist[clamp(0, need, BUCKETS - 1)] += 1.0f; + mass += 1.0f; +} + +Int RunAheadSpikeController::quantile(const Real *hist, Real mass, Real q) +{ + const Real wanted = q * mass; + Real sum = 0.0f; + for (Int bucket = 0; bucket < BUCKETS; ++bucket) + { + sum += hist[bucket]; + if (sum >= wanted && sum > 0.0f) + { + return bucket; + } + } + return BUCKETS - 1; +} + +Int RunAheadSpikeController::baseline(Int runAhead, Int marginFrames) const +{ + if (m_timeMs < SPIKE_WARMUP_MS) + { + return runAhead - marginFrames; + } + // q = 1 - W / lambda + const Real q = 1.0f - m_timeMs / (m_mass * (Real)SPIKE_LAMBDA_MS); + return quantile(m_hist, m_mass, q); +} + +void RunAheadSpikeController::recordEpisode(const Episode &episode) +{ + m_episodes[m_episodeCount % EPISODE_RING] = episode; + ++m_episodeCount; +} + +RunAheadSpikeController::Decision RunAheadSpikeController::onWindow(time_t now, Int windowMs, Int need, Bool stalled, Int stallMs, Real jitterMs, Int runAhead, Int marginFrames, Real frameMs, Int floorFrames, Int capFrames, Bool safeFastDrop, Bool boundRaise) +{ + Decision d; + d.runAhead = runAhead; + d.reason = REASON_HOLD; + d.fastDrop = FALSE; + + const Int window = clamp(SPIKE_WINDOW_MIN_MS, windowMs, SPIKE_WINDOW_MAX_MS); + const Real factor = (Real)pow((double)SPIKE_FORGET, (double)window / (double)SPIKE_FORGET_MS); + need = clamp(0, need, BUCKETS - 1); + m_recentNeed[m_recentCount % RECENT_RING] = need; + m_recentMs[m_recentCount % RECENT_RING] = window; + ++m_recentCount; + + // every window ages the histograms by the time it covers, spike windows too + forget(m_hist, m_mass, factor); + m_timeMs *= factor; + if (m_undoMs > 0) + { + forget(m_saved, m_savedMass, factor); + m_savedTimeMs *= factor; + } + + Int base = baseline(runAhead, marginFrames); + const Int head = max(SPIKE_HEAD_MIN_FRAMES, (Int)ceil(SPIKE_HEAD_JITTER_FACTOR * jitterMs / frameMs)); + Bool undone = FALSE; + + // an adopted level the need does not stay at was a spike after all, the old baseline comes back at once + if (m_undoMs > 0) + { + m_undoMs -= window; + if (need <= m_savedBase + SPIKE_TAIL_FRAMES && !stalled) + { + m_undoCalmMs += window; + if (m_undoCalmMs >= SPIKE_TAIL_MS) + { + for (Int restore = 0; restore < BUCKETS; ++restore) + { + m_hist[restore] = m_saved[restore]; + } + m_mass = m_savedMass; + m_timeMs = m_savedTimeMs; + m_undoMs = 0; + undone = TRUE; + d.reason = REASON_BASELINE; + recordEpisode(m_pending); + base = baseline(runAhead, marginFrames); + } + } + else + { + m_undoCalmMs = 0; + } + } + + if (!m_inSpike) + { + // only normal windows feed the baseline + if (need > base + head || stalled) + { + m_inSpike = TRUE; + m_spikeStart = now; + // a spike that opens before SAFE_CLEAN_MS of calm belongs to the same cluster of loss + if (m_gateClusterStart == 0 || m_gateCleanMs >= SAFE_CLEAN_MS) + { + m_gatePrevClusterStart = m_gateClusterStart; + m_gateClusterStart = now; + } + m_peakNeed = need; + m_episodeStallMs = stallMs; + m_episodeMs = 0; + m_calmMs = 0; + m_sampleTime[0] = now; + m_sampleNeed[0] = need; + m_sampleMs[0] = window; + m_sampleCount = 1; + } + else + { + addSample(m_hist, m_mass, need); + m_timeMs += window; + } + } + else + { + m_peakNeed = max(m_peakNeed, need); + m_episodeStallMs += stallMs; + m_episodeMs += window; + m_sampleTime[m_sampleCount % SAMPLE_RING] = now; + m_sampleNeed[m_sampleCount % SAMPLE_RING] = need; + m_sampleMs[m_sampleCount % SAMPLE_RING] = window; + ++m_sampleCount; + m_calmMs = (need <= base + SPIKE_TAIL_FRAMES && !stalled) ? (m_calmMs + window) : 0; + // the height a run ahead would need to cover the whole episode, its stalls split over windows add up + Episode episode; + episode.start = m_spikeStart; + episode.height = max(0, max(m_peakNeed, runAhead + marginFrames + (Int)ceil(m_episodeStallMs / frameMs)) - base); + if (m_calmMs >= SPIKE_TAIL_MS) + { + recordEpisode(episode); + m_inSpike = FALSE; + addSample(m_hist, m_mass, need); + m_timeMs += window; + } + else if (m_episodeMs >= SPIKE_SUSTAIN_MS) + { + // level shift: rebase on the median need of the last SUSTAIN + Int recent[SAMPLE_RING]; + Int recentCount = 0; + Int recentMs = 0; + const Int kept = min(m_sampleCount, SAMPLE_RING); + for (Int k = 0; k < kept; ++k) + { + const Int at = (m_sampleCount - 1 - k) % SAMPLE_RING; + if ((now - m_sampleTime[at]) < (time_t)SPIKE_SUSTAIN_MS) + { + insertSorted(recent, recentCount, m_sampleNeed[at]); + recentMs += m_sampleMs[at]; + } + } + const Int level = (recentCount > 0) ? recent[recentCount / 2] : need; + for (Int save = 0; save < BUCKETS; ++save) + { + m_saved[save] = m_hist[save]; + m_hist[save] = 0.0f; + } + m_savedMass = m_mass; + m_savedTimeMs = m_timeMs; + m_savedBase = base; + m_mass = 0.0f; + m_pending = episode; + for (Int fill = 0; fill < max(recentCount, 1); ++fill) + { + addSample(m_hist, m_mass, level); + } + m_timeMs = (Real)max(recentMs, SPIKE_WARMUP_MS); + m_undoMs = SPIKE_UNDO_MS; + m_undoCalmMs = 0; + m_inSpike = FALSE; + d.reason = REASON_CONGESTION; + } + } + + const Bool warm = m_timeMs >= SPIKE_WARMUP_MS; + base = baseline(runAhead, marginFrames); + + // cover = the tallest spike height that more than horizon / lambda episodes reach + Int heights[EPISODE_RING]; + Int inHorizon = 0; + const Int stored = min(m_episodeCount, EPISODE_RING); + for (Int e = 0; e < stored; ++e) + { + const Episode &episode = m_episodes[e]; + if ((now - episode.start) <= SPIKE_HORIZON_MS) + { + Int pos = inHorizon; + while (pos > 0 && heights[pos - 1] < episode.height) + { + heights[pos] = heights[pos - 1]; + --pos; + } + heights[pos] = episode.height; + ++inHorizon; + } + } + const Int cover = (inHorizon > SPIKE_ALLOWED) ? min(SPIKE_EXTRA_MAX_FRAMES, heights[SPIKE_ALLOWED]) : 0; + const Int target = clamp(floorFrames, base + cover, capFrames); + + m_gateCleanMs = (!m_inSpike && !stalled && need <= target + 1) ? (m_gateCleanMs + window) : 0; + const Bool gateClean = m_gateCleanMs >= SAFE_CLEAN_MS; + const Bool gateIsolated = m_gatePrevClusterStart == 0 || (m_gateClusterStart - m_gatePrevClusterStart) > SAFE_HISTORY_MS; + + // fast drop: target the recent median need + Int goal = target; + Bool fastDrop = FALSE; + // the gate stays open through our own steps down: they do not touch the clean counter and the reseeded baseline only moves the target + const Bool gateOpen = safeFastDrop && gateClean && gateIsolated && !m_inSpike && !undone && warm; + if (gateOpen) + { + Int recent[RECENT_RING]; + Int recentCount = 0; + Int recentMs = 0; + const Int kept = min(m_recentCount, RECENT_RING); + for (Int k = 0; k < kept && recentMs < SAFE_CLEAN_MS; ++k) + { + const Int at = (m_recentCount - 1 - k) % RECENT_RING; + insertSorted(recent, recentCount, m_recentNeed[at]); + recentMs += m_recentMs[at]; + } + const Int level = clamp(floorFrames, recent[recentCount / 2], capFrames); + if (level < target) + { + goal = level; + fastDrop = TRUE; + } + } + + // no change during a spike, a raise decided now arrives after it ended + if (!m_inSpike && warm) + { + if (target > runAhead) + { + d.runAhead = boundRaise ? min(target, runAhead + SPIKE_MAX_RAISE_FRAMES) : target; + if (d.reason != REASON_CONGESTION) + { + d.reason = (cover > m_lastCover) ? REASON_COVER : REASON_BASELINE; + } + } + else if (goal < runAhead) + { + // fast recovery: a third of the excess per step, never below the goal; with the safe gate open the goal is the recent need, + // the baseline is reseeded from it and the next step does not wait the long dead time + const Int step = safeFastDrop ? max(SPIKE_DOWN_STEP_FRAMES, (runAhead - goal + 2) / 3) : SPIKE_DOWN_STEP_FRAMES; + if (gateOpen) + { + d.fastDrop = TRUE; + } + if (fastDrop) + { + for (Int reseed = 0; reseed < BUCKETS; ++reseed) + { + m_hist[reseed] = 0.0f; + } + m_mass = 0.0f; + addSample(m_hist, m_mass, goal); + m_timeMs = (Real)SPIKE_WARMUP_MS; + } + d.runAhead = undone ? target : max(goal, runAhead - step); + if (d.reason != REASON_CONGESTION && !undone) + { + d.reason = (cover < m_lastCover) ? REASON_UNCOVER : REASON_BASELINE; + } + } + } + m_lastCover = cover; + + const Int sizeFrames = (inHorizon > 0) ? heights[inHorizon / 2] : 0; + d.base = base; + d.cover = cover; + d.spikesPerMin10 = inHorizon * 10 * 60000 / SPIKE_HORIZON_MS; + d.spikeSizeMs = (Int)(sizeFrames * frameMs + 0.5f); + d.inSpike = m_inSpike; + return d; +} diff --git a/Core/GameEngine/Source/GameNetwork/FrameMetrics.cpp b/Core/GameEngine/Source/GameNetwork/FrameMetrics.cpp index a56c2732c25..d42fd15f422 100644 --- a/Core/GameEngine/Source/GameNetwork/FrameMetrics.cpp +++ b/Core/GameEngine/Source/GameNetwork/FrameMetrics.cpp @@ -97,15 +97,21 @@ void FrameMetrics::init() { NetworkMesh* pMesh = NGMP_OnlineServicesManager::GetNetworkMesh(); if (TheNGMPGame != nullptr && pMesh != nullptr) { + // average ping of the connections that have one, else the retail 200 ms int totalLatency = 0; + int latencyCount = 0; std::map& connections = pMesh->GetAllConnections(); for (auto& kvPair : connections) { - PlayerConnection& conn = kvPair.second; - totalLatency += conn.GetLatency(); + const int latency = kvPair.second.GetLatency(); + if (latency >= 0) + { + totalLatency += latency; + ++latencyCount; + } } - m_averageLatency = (Real)((Real)totalLatency / 1000.f) / (Real)connections.size(); + m_averageLatency = (latencyCount > 0) ? (Real)totalLatency / 1000.f / (Real)latencyCount : (Real)0.2; } else { diff --git a/Core/GameEngine/Source/GameNetwork/NetCommandMsg.cpp b/Core/GameEngine/Source/GameNetwork/NetCommandMsg.cpp index 2dead32b164..a314b9caa64 100644 --- a/Core/GameEngine/Source/GameNetwork/NetCommandMsg.cpp +++ b/Core/GameEngine/Source/GameNetwork/NetCommandMsg.cpp @@ -447,6 +447,14 @@ NetCommandMsg::Select NetPlayerLeaveCommandMsg::getSmallNetPacketSelect() const NetRunAheadMetricsCommandMsg::NetRunAheadMetricsCommandMsg() { m_averageLatency = 0.0; m_averageFps = 0; +#if defined(GENERALS_ONLINE) + for (Int slot = 0; slot < MAX_SLOTS; ++slot) { + m_cushionMin[slot] = NO_CUSHION; + m_late[slot] = 0; + } + m_stallMs = 0; + m_screenshot = FALSE; +#endif m_commandType = NETCOMMANDTYPE_RUNAHEADMETRICS; } diff --git a/Core/GameEngine/Source/GameNetwork/NetPacketStructs.cpp b/Core/GameEngine/Source/GameNetwork/NetPacketStructs.cpp index 846d1443738..f6a70272371 100644 --- a/Core/GameEngine/Source/GameNetwork/NetPacketStructs.cpp +++ b/Core/GameEngine/Source/GameNetwork/NetPacketStructs.cpp @@ -392,6 +392,14 @@ size_t NetPacketRunAheadMetricsCommandData::copyBytes(UnsignedByte *buffer, cons FixedData data; data.averageLatency = cmdMsg->getAverageLatency(); data.averageFps = cmdMsg->getAverageFps(); +#if defined(GENERALS_ONLINE) + for (Int slot = 0; slot < MAX_SLOTS; ++slot) { + const Int lowest = cmdMsg->getCushionMin(slot); + data.cushionMin[slot] = (lowest == CommandMsg::NO_CUSHION) ? (Byte)-128 : (Byte)clamp(-127, lowest, 127); + data.late[slot] = (UnsignedByte)clamp(0, cmdMsg->getLate(slot), 255); + } + data.stallMs = (UnsignedShort)clamp(0, cmdMsg->getStallMs(), 0x7FFF) | (cmdMsg->getScreenshot() ? 0x8000 : 0); +#endif return network::writeObject(buffer, data); } @@ -402,10 +410,25 @@ size_t NetPacketRunAheadMetricsCommandData::readMessage(NetCommandRef &ref, NetP FixedData data; data.averageLatency = 0.2f; data.averageFps = 30; +#if defined(GENERALS_ONLINE) + for (Int slot = 0; slot < MAX_SLOTS; ++slot) { + data.cushionMin[slot] = (Byte)-128; + data.late[slot] = 0; + } + data.stallMs = 0; +#endif size_t size = network::readObject(data, buf); cmdMsg->setAverageLatency(data.averageLatency); cmdMsg->setAverageFps(data.averageFps); +#if defined(GENERALS_ONLINE) + for (Int slot = 0; slot < MAX_SLOTS; ++slot) { + cmdMsg->setCushionMin(slot, (data.cushionMin[slot] == -128) ? (Int)CommandMsg::NO_CUSHION : (Int)data.cushionMin[slot]); + cmdMsg->setLate(slot, data.late[slot]); + } + cmdMsg->setStallMs(data.stallMs & 0x7FFF); + cmdMsg->setScreenshot((data.stallMs & 0x8000) != 0); +#endif return size; } diff --git a/Core/GameEngine/Source/GameNetwork/Network.cpp b/Core/GameEngine/Source/GameNetwork/Network.cpp index 2b22d47510b..5e07c3f8743 100644 --- a/Core/GameEngine/Source/GameNetwork/Network.cpp +++ b/Core/GameEngine/Source/GameNetwork/Network.cpp @@ -84,6 +84,9 @@ struct ConnectionMessage #pragma pack(pop) static const int CmdMsgLen = 6; //< Minimum size of a command packet (Int + Unsigned Short) +#if defined(GENERALS_ONLINE) +static const time_t START_RUNAHEAD_TIMEOUT_MS = 15000; //< A router that stays connected has this long after the load to send the start run ahead. +#endif // PRIVATE DATA /////////////////////////////////////////////////////////////// @@ -117,6 +120,8 @@ class Network : public NetworkInterface virtual void SeedLatencyData(int latency); virtual void requireMatchKeys() override { m_matchKeysRequired = TRUE; } virtual Bool matchKeysRequired() const override { return m_matchKeysRequired; } + virtual void setStartRunAheadRequired(Bool required) override { m_startRunAheadRequired = required; } + virtual void applyStartRunAhead(Int runAhead, Int maxRttMs, Int pairs) override; #endif void setLocalAddress(UnsignedInt ip, UnsignedInt port); @@ -256,7 +261,15 @@ class Network : public NetworkInterface Bool m_matchKeysRequired; Bool m_matchStartFailed; Bool startPrerequisitesReady() const; - void failMatchStart(); ///< Leaves rather than starting without authentication. + Bool m_startRunAheadRequired; ///< The router sends the start run ahead, the load is not complete until it arrived. + Bool m_startRunAheadApplied; + Bool m_loggedRejectedRunAhead; ///< A run ahead outside the floor and cap was ignored and logged. + Bool m_loadCompletePending; ///< The load finished before all start prerequisites arrived. + time_t m_loadCompletePendingSince; + void failMatchStart(); ///< Leaves rather than starting without authentication or the router decision. + + void updatePace(); ///< Sets the speed of the next frame from how far ahead the remote commands are. + Real m_paceSpeed; ///< Share of the full frame rate the next frame runs at, 1 = full. #endif }; @@ -376,7 +389,11 @@ void Network::init() m_lastFrame = 0; m_runAhead = min(max(30, MIN_RUNAHEAD), MAX_FRAMES_AHEAD / 2); ///< @todo: don't hard-code the run-ahead. +#if defined(GENERALS_ONLINE_HIGH_FPS_SERVER) + m_frameRate = GENERALS_ONLINE_HIGH_FPS_LIMIT; // the logic rate update() sets once the match runs +#else m_frameRate = 30; +#endif m_lastExecutionFrame = m_runAhead - 1; // subtract 1 since we're starting on frame 0 m_lastFrameCompleted = m_runAhead - 1; // subtract 1 since we're starting on frame 0 m_frameDataReady = FALSE; @@ -391,6 +408,12 @@ void Network::init() m_rateSampleMs = 0; m_rateSampleFrame = 0; m_measuredLogicRate = 0; + m_startRunAheadRequired = FALSE; + m_startRunAheadApplied = FALSE; + m_loggedRejectedRunAhead = FALSE; + m_loadCompletePending = FALSE; + m_loadCompletePendingSince = 0; + m_paceSpeed = 1.0f; #endif QueryPerformanceFrequency((LARGE_INTEGER*)&m_perfCountFreq); @@ -421,11 +444,39 @@ void Network::init() #if defined(GENERALS_ONLINE) void Network::SeedLatencyData(int highestLatency) { - NetworkLog(ELogVerbosity::LOG_RELEASE, "[PRESEED] Seeding with highest latency %d", highestLatency); + NetworkLog(ELogVerbosity::LOG_RELEASE, "[PRESEED] Seeding with the lobby round trip %d ms (p75 of the probes)", highestLatency); m_conMgr->SeedLatencyData(highestLatency); } +void Network::applyStartRunAhead(Int runAhead, Int maxRttMs, Int pairs) +{ + if (m_conMgr == nullptr || m_startRunAheadApplied || m_localStatus == NETLOCALSTATUS_POSTGAME) + { + return; + } + + runAhead = clamp(m_conMgr->getRunAheadFloor(), runAhead, m_conMgr->getRunAheadCap()); + const Int previousRunAhead = m_runAhead; + + // no frame of the match has run and no peer sent commands for one, every peer waits for our load complete first + m_conMgr->applyStartRunAhead(previousRunAhead, runAhead); + m_runAhead = runAhead; + m_lastExecutionFrame = m_runAhead - 1; + m_lastFrameCompleted = m_runAhead - 1; + if (maxRttMs > 0) + { + m_conMgr->SeedLatencyData(maxRttMs); + } + m_startRunAheadApplied = TRUE; + + NetworkLog(ELogVerbosity::LOG_RELEASE, "[RUNAHEAD] start R=%d (pairs=%d, max rtt=%dms)", m_runAhead, pairs, maxRttMs); + + if (m_loadCompletePending) + { + loadProgressComplete(); + } +} #endif #if defined(GENERALS_ONLINE) @@ -585,6 +636,11 @@ Bool Network::processCommand(GameMessage* msg) #endif #if defined(GENERALS_ONLINE) // Pregame updates drive loading; only entering gameplay requires completed setup. + // A load that was forced on because the router went silent has no start run ahead, every player that got here starts at the retail one. + if (m_startRunAheadRequired && !m_startRunAheadApplied) { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[RUNAHEAD] the load was forced on without the router's decision, start R=%d as in retail", (Int)m_runAhead); + applyStartRunAhead(m_runAhead, 0, 0); + } if (!startPrerequisitesReady()) { failMatchStart(); return TRUE; @@ -742,6 +798,13 @@ void Network::processFrameSynchronizedNetCommand(NetCommandRef* msg) { void Network::processRunAheadCommand(NetRunAheadCommandMsg* msg) { #if defined(GENERALS_ONLINE) + if (!m_conMgr->acceptsRunAhead(msg->getRunAhead())) { + if (!m_loggedRejectedRunAhead) { + m_loggedRejectedRunAhead = TRUE; + NetworkLog(ELogVerbosity::LOG_RELEASE, "[SECURITY] ignored a run ahead of %d, outside %d..%d", (Int)msg->getRunAhead(), m_conMgr->getRunAheadFloor(), m_conMgr->getRunAheadCap()); + } + return; + } const Int previousRunAhead = m_runAhead; #endif m_runAhead = msg->getRunAhead(); @@ -891,6 +954,14 @@ void Network::liteupdate() { if (m_matchStartFailed) { return; } + if (m_loadCompletePending) { + if (startPrerequisitesReady()) { + loadProgressComplete(); + } else if ((timeGetTime() - m_loadCompletePendingSince) >= START_RUNAHEAD_TIMEOUT_MS) { + failMatchStart(); + return; + } + } #endif #if defined(RTS_DEBUG) @@ -936,14 +1007,16 @@ Bool Network::timeForNewFrame() { * to avoid being frozen by spikes in network lag. This will happen if another user's computer is * running too far behind us, so we need to slow down to let them catch up. */ - if (m_conMgr != NULL) { - Real cushion = m_conMgr->getMinimumCushion(); #if defined(GENERALS_ONLINE) - // the controller keeps a margin of one frame, slow down when the commands of the slowest player arrive less than that early - Real runAheadPercentage = 1.0f; + // stretch the next frame while a remote player's commands are short + if (m_conMgr != NULL && curTime >= m_nextFrameTime) { + updatePace(); + frameDelay = (__int64)((double)frameDelay / (double)m_paceSpeed); + } #else + if (m_conMgr != NULL) { + Real cushion = m_conMgr->getMinimumCushion(); Real runAheadPercentage = m_runAhead * (TheGlobalData->m_networkRunAheadSlack / (Real)100.0); // If we are at least 50% into our slack, we need to slow down. -#endif if (cushion < runAheadPercentage) { __int64 oldFrameDelay = frameDelay; frameDelay += oldFrameDelay / 10; // temporarily decrease the frame rate by 20%. @@ -953,6 +1026,7 @@ Bool Network::timeForNewFrame() { // DEBUG_LOG(("Average cushion = %f, run ahead percentage = %f", cushion, runAheadPercentage)); } } +#endif // Check to see if we can run another frame. if (curTime >= m_nextFrameTime) { @@ -966,7 +1040,12 @@ Bool Network::timeForNewFrame() { #endif if (m_nextFrameTime + catchUpFrames * frameDelay < curTime) { // If we get too far behind on our framerate we need to reset the nextFrameTime thing. +#if defined(GENERALS_ONLINE) + // after a stall the lost time is not caught up + m_nextFrameTime = curTime + frameDelay; +#else m_nextFrameTime = curTime; +#endif NetworkLog(ELogVerbosity::LOG_DEBUG, "Initializing m_nextFrameTime to %I64d", m_nextFrameTime); } else { // Set the soonest possible starting time for the next frame. @@ -980,6 +1059,37 @@ Bool Network::timeForNewFrame() { return FALSE; } +#if defined(GENERALS_ONLINE) +/** Slows the next frame while a remote player's commands run short; 90% floor, ramps down over 4 frames and up over 10. */ +void Network::updatePace() { + static const Real PACE_FLOOR_SPEED = 0.9f; + enum { PACE_SLOW_FRAMES = 4, PACE_RECOVER_FRAMES = 10 }; + + const Int margin = m_conMgr->getNetworkSettings().input_delay.margin_frames; + const Int lowFrames = margin + 1; + + Bool overdue = FALSE; + const Int ahead = m_conMgr->getRemoteLookAhead(TheGameLogic->getFrame(), lowFrames, overdue); + Int shortFrames = 0; + if (ahead >= 0) { + if (overdue) { + shortFrames = lowFrames - ahead; + } else if (ahead == 0) { + shortFrames = 1; + } + } + shortFrames = clamp(0, shortFrames, lowFrames); + + const Real share = (1.0f - PACE_FLOOR_SPEED) / (Real)lowFrames; + const Real target = 1.0f - share * (Real)shortFrames; + if (target < m_paceSpeed) { + m_paceSpeed = max(target, m_paceSpeed - (1.0f - PACE_FLOOR_SPEED) / (Real)PACE_SLOW_FRAMES); + } else { + m_paceSpeed = min(target, m_paceSpeed + (1.0f - PACE_FLOOR_SPEED) / (Real)PACE_RECOVER_FRAMES); + } +} +#endif + /** * Returns true if the game commands for the next frame have been put on the command list. */ @@ -1159,7 +1269,7 @@ void Network::updateLoadProgress(Int percent) #if defined(GENERALS_ONLINE) Bool Network::startPrerequisitesReady() const { - if (m_matchStartFailed) { + if (m_matchStartFailed || (m_startRunAheadRequired && !m_startRunAheadApplied)) { return FALSE; } if (m_matchKeysRequired) { @@ -1175,7 +1285,8 @@ void Network::failMatchStart() return; } NetworkMesh* mesh = NGMP_OnlineServicesManager::GetNetworkMesh(); - NetworkLog(ELogVerbosity::LOG_RELEASE, "[START] Missing match keys, leaving (keys=%zu/%zu)", + NetworkLog(ELogVerbosity::LOG_RELEASE, "[START] Missing start run ahead of a connected router, leaving (router=%d, keys=%zu/%zu)", + !m_startRunAheadRequired || m_startRunAheadApplied, mesh != nullptr ? mesh->GetMatchKeys().GetPeerKeyCount() : 0, mesh != nullptr ? mesh->GetMatchKeys().GetExpectedPeers() : 0); m_matchStartFailed = TRUE; @@ -1192,6 +1303,16 @@ void Network::loadProgressComplete() if (m_matchStartFailed) { return; } + // load complete waits for the peer keys and the start run ahead of the router + if (!startPrerequisitesReady()) { + if (!m_loadCompletePending) { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[START] Load complete, waiting for authenticated peers and router decision"); + m_loadCompletePendingSince = timeGetTime(); + } + m_loadCompletePending = TRUE; + return; + } + m_loadCompletePending = FALSE; #endif if (m_conMgr != nullptr) { m_conMgr->loadProgressComplete(); diff --git a/GeneralsMD/Code/GameEngine/Include/GameLogic/GameLogic.h b/GeneralsMD/Code/GameEngine/Include/GameLogic/GameLogic.h index 51c3b6c9e30..1e6dd6f4b26 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameLogic/GameLogic.h +++ b/GeneralsMD/Code/GameEngine/Include/GameLogic/GameLogic.h @@ -174,6 +174,7 @@ class GameLogic : public SubsystemInterface, public Snapshot void updateLoadProgress( Int progress ); void deleteLoadScreen(); + void revealFromLoadScreen(Bool loadingSaveGame); ///< Fades the load screen out and deletes it. //Kris: Cut setGameLoading() and replaced with setLoadingMap() and setLoadingSave() -- reason: nomenclature //void setGameLoading( Bool loading ) { m_loadingScene = loading; } diff --git a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/ConnectionRating.h b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/ConnectionRating.h index 1225735f622..98da8897293 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/ConnectionRating.h +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/ConnectionRating.h @@ -7,6 +7,7 @@ // It is the worse of two axes: delay (the input delay the link forces, in ms) and stability (stalls per minute). // 5 bars: delay <= 150 ms and no stall in the last minute; 1 bar: delay above 500 ms or 6 or more stalls per minute. // Colors: 4-5 green, 2-3 amber, 1 red. Red needs one of (RedReason): blackout, loss, delay or stalls, see RedReason in the .cpp. +// The delay is ConnectionManager::estimateRunAheadMs of the link's p75 probe round trip, the command round trip in a match without enough probes. // Stalls a healthy link does not explain wait on the peer's frame rate: slow PC, at most 3 bars and never red for it. // Too few samples (10 probes, 20 command acks) give no rating. class ConnectionRating @@ -44,9 +45,9 @@ class ConnectionRating }; /// lossPercent is the packet loss from the packet feedback, negative to use the loss of the stats. - static Inputs InputsFromProbe(const ConnectionStats &probe, Int slackPercent, Int sendIntervalMs, Real lossPercent); + static Inputs InputsFromProbe(const ConnectionStats &probe, Real lossPercent, Real expectedDelayMs); - static Inputs InputsFromMatch(const ConnectionStats &cmd, const NetworkOverlayInfo &info, Int slackPercent, Int sendIntervalMs, Real lossPercent); + static Inputs InputsFromMatch(const ConnectionStats &cmd, const NetworkOverlayInfo &info, Real lossPercent, Real expectedDelayMs); /// Fewer bars show at once, more only after the rating asked for them for a few seconds. static void Update(State &state, const Inputs &inputs, UnsignedInt nowMs); diff --git a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NGMP_include.h b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NGMP_include.h index 57da3030398..9e580f97404 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NGMP_include.h +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NGMP_include.h @@ -12,7 +12,8 @@ enum class ENetworkChannel : BYTE NETWORK_CHANNEL_GAME = 0, NETWORK_CHANNEL_AC = 1, NETWORK_CHANNEL_PING = 2, - NETWORK_CHANNEL_KEYS = 3 // match signing keys, sent reliably + NETWORK_CHANNEL_KEYS = 3, // match signing keys, sent reliably + NETWORK_CHANNEL_START = 4 // lobby round trips to the router and its start run ahead back, sent reliably at match start }; #define CHECK_MAIN_THREAD assert(std::this_thread::get_id() == NGMP_OnlineServicesManager::g_MainThreadID); diff --git a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NetworkMesh.h b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NetworkMesh.h index 3e386de7e6a..466b7468259 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NetworkMesh.h +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/NetworkMesh.h @@ -79,11 +79,19 @@ class PlayerConnection int Recv(SteamNetworkingMessage_t** pMsg, int maxMessages = 255); - // the highest probe round trip of the stats window, the current ping while there are no probe samples - int GetPeakLatency() + // the p75 probe round trip of the stats window, -1 with too few samples: a spike or a lossy spell must not set the start run ahead + int GetSeedLatency(int minSamples = k_seedMinSamples) const { - return (m_probeStats.getSampleCount() > 0) ? static_cast(m_probeStats.getMaxLatency()) : GetLatency(); + if (m_probeStats.getSampleCount() < minSamples) + { + return -1; + } + return static_cast(m_probeStats.getLatencyPercentile(k_seedPercentile) + 0.5f); } + static constexpr int k_seedMinSamples = 10; // the probes a lobby round trip, a jitter or a rating needs, 2.5 s at the lobby interval + int GetMatchRatingMinSamples() const; ///< Half the probes the stats window holds at the match interval, one lost probe must not drop the probe p75. + static constexpr int k_seedPercentile = 75; + float GetSustainedLossPercent(int windowMs); ///< The worse direction of the packet feedback loss from at least 3 reports, -1 if unknown. std::string GetStats(); @@ -225,23 +233,28 @@ class NetworkMesh const MatchKeys& GetMatchKeys() const { return m_matchKeys; } MatchKeys& GetMatchKeys() { return m_matchKeys; } - // PEAK HOLD: the highest probe round trip of any connection over the probe stats window - Real getMaximumProbeLatency() + // the highest p75 probe round trip of any connection over the probe stats window, 0 if none has enough samples + int getSeedProbeLatency() { int highestLatency = 0; for (auto& kvPair : m_mapConnections) { - const int peak = kvPair.second.GetPeakLatency(); - if (peak > highestLatency) + const int seed = kvPair.second.GetSeedLatency(); + if (seed > highestLatency) { - highestLatency = peak; + highestLatency = seed; } } - return Real(highestLatency); + return highestLatency; } + // Start run ahead: players send lobby round trips to the router, which sends frame 1's run ahead to all. + void BeginStartRunAhead(int routerSlot); ///< Call after the network parsed the user list. + void OnStartRunAheadMessage(int64_t connectionUserID, const uint8_t* data, size_t len); + bool BeginSyncedStart(int stage); ///< Call when the local part of the stage is done; false if there is nobody to start with. + bool SyncedStartDone(int stage); ///< Polled while waiting: true once every player is ready and the common leave time came, or on timeout. int SendGamePacket(void* pBuffer, uint32_t totalDataSize, int64_t userID); @@ -309,6 +322,65 @@ class NetworkMesh }; std::map m_keySync; ///< Per peer, when the key and the held keys were last sent. + struct StartRunAheadReport + { + uint64_t matchID = 0; + std::vector> rtts; ///< user and p75 round trip in ms of each connection of the reporter + bool directSend = false; ///< the reporter sends its commands directly to every peer, else through the router + }; + std::map m_startReports; ///< by reporter, kept until a match with another id begins + uint64_t m_startMatchID = 0; + bool m_startActive = false; ///< BeginStartRunAhead ran for m_startMatchID + bool m_startIsRouter = false; + bool m_startDecided = false; ///< the router sent its decision, or a peer applied the router's + int64_t m_startBeginMs = 0; + int64_t m_startRouterUserID = -1; + std::vector m_startPlayers; ///< user ids of the other human players of the match + int64_t m_startLocalUserID = -1; + struct StartDecision + { + uint64_t matchID = 0; + int runAhead = 0; + int maxRtt = 0; + int pairs = 0; + }; + std::map m_startDecisions; ///< by sender, a decision can come before this client began; only the router's counts + bool StartReportsComplete() const; + bool IsStartPlayer(int64_t userID) const; + bool IsStartPlayerLost(int64_t userID, int64_t nowMs); ///< No usable connection to the user for START_LOST_GRACE_MS + std::map m_startLostSinceMs; ///< by user, when its connection was first seen unusable + void PruneLostStartPlayers(int64_t nowMs); ///< The router stops waiting for the reports of players that left + void FallBackToRetailStart(); ///< The router left before it decided, every client that has no decision starts at the retail run ahead + void SanitizeStartReport(int64_t reporter, StartRunAheadReport& report) const; ///< keeps the entries that name a start player, clamps their round trips + void TickStartRunAhead(int64_t nowMs); + void DecideStartRunAhead(); + void ApplyStartDecision(); + void SendStartMessage(int64_t userID, const std::vector& payload); + // the stages of the synced start: 0 on the load screen, 1 when all the local setup is done + enum { SYNC_STAGES = 2 }; + struct StartReady + { + uint64_t matchID = 0; + bool direct = false; ///< the sender's direct path to every peer is up, or it waited long enough for it + }; + struct SyncStage + { + std::map ready; ///< by sender, can come before this client is ready + uint64_t goMatchID = 0; ///< the router's go, valid for m_startMatchID only + int goDelayMs = 0; + int64_t goReceivedMs = 0; + bool hasGo = false; + bool active = false; + bool readySent = false; + int64_t beginMs = 0; + int64_t allInMs = 0; ///< when the last ready came in, 0 while one is missing + int64_t leaveAtMs = 0; ///< the common leave time once known, 0 before + }; + SyncStage m_sync[SYNC_STAGES]; + void SendSyncReady(int stage, int64_t nowMs, bool direct); + int CountSyncMissing(int stage, int64_t nowMs, int& total); ///< start players still to be heard from, players that left do not count + void FinishSyncedStart(int stage, const char* szReason, int64_t nowMs); + std::string m_strTurnUsername; std::string m_strTurnToken; std::string m_strTurnUsernameString; diff --git a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h index a1161e6bf91..3b5d8b55f25 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h @@ -458,7 +458,11 @@ inline void ReadServiceConfig(const nlohmann::json& j, ServiceConfig& c) if (j.contains("input_delay")) { const nlohmann::json& s = j["input_delay"]; + ReadServiceConfigValue(s, "controller", c.input_delay.controller); + ReadServiceConfigValue(s, "margin_frames", c.input_delay.margin_frames); ReadServiceConfigValue(s, "min_frames", c.input_delay.min_frames); + ReadServiceConfigValue(s, "max_frames", c.input_delay.max_frames); + ReadServiceConfigValue(s, "fast_recovery", c.input_delay.fast_recovery); ReadServiceConfigValue(s, "decision_interval_ms", c.input_delay.decision_interval_ms); ReadServiceConfigValue(s, "fps_history_samples", c.input_delay.fps_history_samples); ReadServiceConfigValue(s, "catch_up_frames", c.input_delay.catch_up_frames); @@ -503,6 +507,7 @@ inline void ClampServiceConfig(ServiceConfig& c) c.input_delay.formula.decrease_after_decisions = std::clamp(c.input_delay.formula.decrease_after_decisions, 0, 20); c.input_delay.catch_up_frames = std::clamp(c.input_delay.catch_up_frames, 1, 4); c.input_delay.min_frames = std::clamp(c.input_delay.min_frames, 0, 30); // the start run ahead is at least 30, a higher floor would make configs disagree + c.input_delay.max_frames = std::clamp(c.input_delay.max_frames, (std::max)(30, c.input_delay.min_frames), 128); 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); @@ -511,6 +516,7 @@ inline void ClampServiceConfig(ServiceConfig& c) c.input_delay.formula.latency_window_ms = std::clamp(c.input_delay.formula.latency_window_ms, 30 * 1000 / GENERALS_ONLINE_HIGH_FPS_LIMIT + 1, 60000); } c.send.frames_per_packet = std::clamp(c.send.frames_per_packet, 1, 16); + c.input_delay.margin_frames = std::clamp(c.input_delay.margin_frames, 0, 10); c.disconnect.freeze_before_screen_ms = std::clamp(c.disconnect.freeze_before_screen_ms, 1000, 30000); c.disconnect.remove_after_ms = std::clamp(c.disconnect.remove_after_ms, 10000, 180000); 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 4297a990e5d..fc3f6521f9e 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/WOLGameSetupMenu.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/WOLGameSetupMenu.cpp @@ -515,6 +515,7 @@ static void playerTooltip(GameWindow *window, int connectionLatency = -1; int connectionJitter = -1; int connectionLossPct = -1; + int connectionDelayMs = -1; std::string strConnectionType = ""; LobbyMemberEntry member = pLobbyInterface->GetRoomMemberFromID(slot->m_userID); @@ -537,9 +538,16 @@ static void playerTooltip(GameWindow *window, bIsConnected = false; } connectionBars = pConnection->ComputeConnectionRating(); - connectionLatency = pConnection->GetLatency(); + // the same values the rating uses + connectionLatency = pConnection->GetSeedLatency(); + if (connectionLatency < 0) + { + connectionLatency = pConnection->GetLatency(); + } connectionJitter = pConnection->GetJitter(); - connectionLossPct = (pConnection->m_probeStats.getSampleCount() > 0) ? static_cast(pConnection->m_probeStats.getLossPercent() + 0.5f) : -1; // packet loss + const float sustainedLoss = pConnection->GetSustainedLossPercent(60000); + connectionLossPct = (sustainedLoss >= 0.0f) ? static_cast(sustainedLoss + 0.5f) : -1; + connectionDelayMs = (connectionBars > 0) ? pConnection->m_rating.delayMs : -1; } } } @@ -557,18 +565,26 @@ static void playerTooltip(GameWindow *window, } else if (bIsConnected) { - UnicodeString latencyStr, jitterStr, qualityStr; + UnicodeString latencyStr, jitterStr, qualityStr, delayStr; if (connectionLatency >= 0) latencyStr.format(L"%d ms", connectionLatency); else latencyStr = L"Unknown"; + if (connectionDelayMs >= 0) + { + delayStr.format(L"%d ms", connectionDelayMs); + } + else + { + delayStr = L"Unknown"; + } if (connectionJitter >= 0) jitterStr.format(L"%d ms", connectionJitter); else jitterStr = L"Unknown"; if (connectionLossPct >= 0) qualityStr.format(L"%d%%", connectionLossPct); else qualityStr = L"Unknown"; #if defined(WORLD_SERIES_CLIENT) - playerInfo.format(L"\nConnection State: Connected (%hs)\nPing: %s\nLoss: %s\nJitter: %s\nRegion: %hs\nOverall Elo Rating: %d (in %d matches)\nWS Elo Rating: %d\nWins: %d\nLosses: %d\nDisconnects: %d\nFavorite Army: %s", - strConnectionType.c_str(), latencyStr.str(), qualityStr.str(), jitterStr.str(), + playerInfo.format(L"\nConnection State: Connected (%hs)\nPing (round trip): %s\nPacket loss: %s\nJitter: %s\nExpected input delay: %s\nRegion: %hs\nOverall Elo Rating: %d (in %d matches)\nWS Elo Rating: %d\nWins: %d\nLosses: %d\nDisconnects: %d\nFavorite Army: %s", + strConnectionType.c_str(), latencyStr.str(), qualityStr.str(), jitterStr.str(), delayStr.str(), member.region.c_str(), stats.elo_rating, stats.elo_num_matches, stats.monthly_elo_rating, totalWins, totalLosses, totalDiscons, favoriteSide.str()); #else - playerInfo.format(L"\nConnection State: Connected (%hs)\nPing: %s\nLoss: %s\nJitter: %s\nRegion: %hs\nOverall Elo Rating: %d (in %d matches)\nMonthly Elo Rating: %d\nWins: %d\nLosses: %d\nDisconnects: %d\nFavorite Army: %s", - strConnectionType.c_str(), latencyStr.str(), qualityStr.str(), jitterStr.str(), + playerInfo.format(L"\nConnection State: Connected (%hs)\nPing (round trip): %s\nPacket loss: %s\nJitter: %s\nExpected input delay: %s\nRegion: %hs\nOverall Elo Rating: %d (in %d matches)\nMonthly Elo Rating: %d\nWins: %d\nLosses: %d\nDisconnects: %d\nFavorite Army: %s", + strConnectionType.c_str(), latencyStr.str(), qualityStr.str(), jitterStr.str(), delayStr.str(), member.region.c_str(), stats.elo_rating, stats.elo_num_matches, stats.monthly_elo_rating, totalWins, totalLosses, totalDiscons, favoriteSide.str()); #endif } diff --git a/GeneralsMD/Code/GameEngine/Source/GameLogic/System/GameLogic.cpp b/GeneralsMD/Code/GameEngine/Source/GameLogic/System/GameLogic.cpp index 1fa00d1b2e6..de6aefdebe6 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameLogic/System/GameLogic.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameLogic/System/GameLogic.cpp @@ -1193,6 +1193,43 @@ void GameLogic::updateLoadProgress(Int progress) } } +void GameLogic::revealFromLoadScreen(Bool loadingSaveGame) +{ + // if we're in a load game, don't fade yet + if (loadingSaveGame == FALSE && TheTransitionHandler != NULL && m_loadScreen) + { + TheFramePacer->reset(); + TheTransitionHandler->setGroup("FadeWholeScreen"); + while (!TheTransitionHandler->isFinished()) + { + TheWindowManager->update(); + if (!TheTransitionHandler->isFinished()) + { + TheDisplay->draw(); + setFPMode(); + TheFramePacer->update(); + } + + } + } + + if (m_loadScreen) + { + TheMouse->setVisibility(TRUE); + + /* + // + // delete load screen only when not loading a save game, for save games we still + // have more work to do and the load screen will be deleted elsewhere after + // we're all done with the load game progress + // + if( loadingSaveGame == FALSE ) + */ + deleteLoadScreen(); + + } +} + // ------------------------------------------------------------------------------------------------ /** Delete the load screen */ // ------------------------------------------------------------------------------------------------ @@ -2355,6 +2392,9 @@ void GameLogic::tryStartNewGame( Bool loadingSaveGame ) TheNetwork->loadProgressComplete(); TheNetwork->liteupdate(); } +#if defined(GENERALS_ONLINE) + const UnsignedInt loadCompleteTime = timeGetTime(); +#endif #if defined(GENERALS_ONLINE) // set a minimum display time for the load screen on GO to allow players to read ELO, army, stats, etc. @@ -2371,38 +2411,29 @@ void GameLogic::tryStartNewGame( Bool loadingSaveGame ) Sleep(100); } - // if we're in a load game, don't fade yet - if (loadingSaveGame == FALSE && TheTransitionHandler != NULL && m_loadScreen) + Bool syncedStart = FALSE; +#if defined(GENERALS_ONLINE) + // stay on the load screen until every player is ready to run frames, so nobody runs ahead into a stall + if (isInInternetGame() && !m_forceGameStartByTimeOut) { - TheFramePacer->reset(); - TheTransitionHandler->setGroup("FadeWholeScreen"); - while (!TheTransitionHandler->isFinished()) + NetworkMesh* pStartMesh = NGMP_OnlineServicesManager::GetNetworkMesh(); + if (pStartMesh != nullptr && pStartMesh->BeginSyncedStart(0)) { - TheWindowManager->update(); - if (!TheTransitionHandler->isFinished()) + syncedStart = TRUE; + while (!pStartMesh->SyncedStartDone(0)) { - TheDisplay->draw(); - setFPMode(); - TheFramePacer->update(); + updateLoadProgress(101); + Sleep(5); } - } + NetworkLog(ELogVerbosity::LOG_RELEASE, "[SYNC] left the load screen %u ms after the load was complete", timeGetTime() - loadCompleteTime); } +#endif - if (m_loadScreen) + // with a synced start the load screen stays up until the other players are ready + if (!syncedStart) { - TheMouse->setVisibility(TRUE); - - /* - // - // delete load screen only when not loading a save game, for save games we still - // have more work to do and the load screen will be deleted elsewhere after - // we're all done with the load game progress - // - if( loadingSaveGame == FALSE ) - */ - deleteLoadScreen(); - + revealFromLoadScreen(loadingSaveGame); } #ifdef DUMP_PERF_STATS @@ -2508,7 +2539,7 @@ void GameLogic::tryStartNewGame( Bool loadingSaveGame ) { HideControlBar(); } - else + else if (!syncedStart) { ShowControlBar(FALSE); } @@ -2517,7 +2548,10 @@ void GameLogic::tryStartNewGame( Bool loadingSaveGame ) // Turn off the UI HideControlBar(); #endif - TheWritableGlobalData->m_loadScreenRender = FALSE; ///< mark to resume rendering as normal + if (!syncedStart) + { + TheWritableGlobalData->m_loadScreenRender = FALSE; ///< mark to resume rendering as normal + } // if we're in a gamespy game, mark us as playing if (TheGameSpyBuddyMessageQueue && TheGameSpyGame && isInInternetGame()) @@ -2543,6 +2577,28 @@ void GameLogic::tryStartNewGame( Bool loadingSaveGame ) } } + // the local setup took a different time on every machine, start the first frames together while the load screen is still up +#if defined(GENERALS_ONLINE) + if (syncedStart) + { + NetworkMesh* pStartMesh = NGMP_OnlineServicesManager::GetNetworkMesh(); + if (pStartMesh != nullptr && pStartMesh->BeginSyncedStart(1)) + { + while (!pStartMesh->SyncedStartDone(1)) + { + updateLoadProgress(101); + Sleep(2); + } + } + } +#endif + if (syncedStart) + { + revealFromLoadScreen(loadingSaveGame); + ShowControlBar(FALSE); + TheWritableGlobalData->m_loadScreenRender = FALSE; ///< mark to resume rendering as normal + } + //ReAllows quit menu to work during loading scene //setGameLoading(FALSE); setLoadingMap(FALSE); diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/ConnectionRating.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/ConnectionRating.cpp index 397c129250e..bcdc176f877 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/ConnectionRating.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/ConnectionRating.cpp @@ -35,30 +35,23 @@ static Real PredictStallsPerMinute(const ConnectionStats &stats, Real lossPercen return 2.1f * loss * 110.0f / (stats.getLatency() + 60.0f) + 0.1f * excess; } -static Real EstimateDelayMs(Real roundTripMs, Int slackPercent, Int sendIntervalMs) -{ - return roundTripMs * (1.0f + slackPercent / 100.0f) + sendIntervalMs; -} - ConnectionRating::Inputs::Inputs() : delayMs(0.0f), stallsPerMinute(0.0f), measuredStallsPerMinute(-1.0f), lossPercent(-1.0f), silentMs(-1), silentLimitMs(k_redSilentMs), stallInWindow(FALSE), slowPc(FALSE) { } -ConnectionRating::Inputs ConnectionRating::InputsFromProbe(const ConnectionStats &probe, Int slackPercent, Int sendIntervalMs, Real lossPercent) +ConnectionRating::Inputs ConnectionRating::InputsFromProbe(const ConnectionStats &probe, Real lossPercent, Real expectedDelayMs) { Inputs inputs; - // the p75 round trip, not the smoothed one a few spikes pull up - inputs.delayMs = EstimateDelayMs(probe.getLatencyPercentile(75), slackPercent, sendIntervalMs); + inputs.delayMs = expectedDelayMs; inputs.stallsPerMinute = PredictStallsPerMinute(probe, lossPercent); return inputs; } -ConnectionRating::Inputs ConnectionRating::InputsFromMatch(const ConnectionStats &cmd, const NetworkOverlayInfo &info, Int slackPercent, Int sendIntervalMs, Real lossPercent) +ConnectionRating::Inputs ConnectionRating::InputsFromMatch(const ConnectionStats &cmd, const NetworkOverlayInfo &info, Real lossPercent, Real expectedDelayMs) { Inputs inputs; - const Real roundTripMs = (info.slotInBandMs >= 0) ? (Real)info.slotInBandMs : cmd.getLatency(); - inputs.delayMs = EstimateDelayMs(roundTripMs, slackPercent, sendIntervalMs); + inputs.delayMs = expectedDelayMs; Real stallsPerMinute = PredictStallsPerMinute(cmd, lossPercent); if ((Real)info.stallsMinute >= k_slowPcMinStalls && stallsPerMinute < k_slowPcMaxLinkStalls) diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NGMPGame.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NGMPGame.cpp index e98138005db..b2bf95adfb8 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NGMPGame.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NGMPGame.cpp @@ -466,7 +466,8 @@ void NGMPGame::launchGame(void) PopBackToLobby(); return; } - TheNetwork->SeedLatencyData(pMesh->getMaximumProbeLatency()); + // One spike or lossy spell must not start the match at a huge run ahead. + TheNetwork->SeedLatencyData(pMesh->getSeedProbeLatency()); // TODO_NGMP: Do we really care about these values anymore TheNetwork->setLocalAddress(getLocalIP(), 8888); @@ -475,6 +476,12 @@ void NGMPGame::launchGame(void) TheNetwork->parseUserList(this); + if (pMesh != nullptr && TheNetwork->GetConnectionManager() != nullptr) + { + // the run ahead of frame 1, decided by the packet router from the lobby round trips + pMesh->BeginStartRunAhead((int)TheNetwork->GetConnectionManager()->getPacketRouterSlot()); + } + if (TheGameLogic->isInGame()) { TheGameLogic->clearGameData(); } diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NetworkMesh.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NetworkMesh.cpp index fdcfe4b03a5..f58b696b31e 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NetworkMesh.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NetworkMesh.cpp @@ -4,6 +4,7 @@ #include "GameNetwork/GeneralsOnline/NGMP_interfaces.h" #include +#include #include #include #include "GameNetwork/NetworkDefs.h" @@ -1348,6 +1349,660 @@ void NetworkMesh::OnKeyAnnouncement(int64_t connectionUserID, const uint8_t* dat } } +// Start run ahead payload after the channel byte, little endian: +// report u8 1, u64 match id, u8 count, u8 flags (bit 0: sends directly to every peer), count x (i64 user id, u16 p75 round trip ms) +// decision u8 2, u64 match id, u8 run ahead, u16 highest pair round trip ms, u8 pairs +// ready u8 3, u64 match id, u8 stage, u8 flags (bit 0: the sender sends directly to every peer, or waited long enough for that) +// go u8 4, u64 match id, u8 stage, u16 ms the receiver waits before it starts +static constexpr uint8_t START_MSG_REPORT = 1; +static constexpr uint8_t START_MSG_DECISION = 2; +static constexpr int64_t START_REPORT_WAIT_MS = 3000; // the router decides without the reports still missing after this +static constexpr size_t START_REPORT_MAX = 16; +static constexpr int START_REPORT_MAX_RTT_MS = 2000; // longer lobby round trips are clamped +static constexpr int64_t START_LOST_GRACE_MS = 2000; // a connection that is down this long belongs to a player that left +static constexpr uint8_t START_MSG_READY = 3; +static constexpr uint8_t START_MSG_GO = 4; +static constexpr int64_t SYNC_TIMEOUT_MS = 10000; // the load screen leaves without the readies still missing after this +static constexpr int64_t SYNC_DIRECT_WAIT_MS = 3000; // a client that has no direct path to every peer by now is ready anyway, the router path carries it +static constexpr int64_t SYNC_GO_WAIT_MS = 1500; // the router's go is awaited this long after every ready is in +static constexpr int SYNC_GO_MAX_DELAY_MS = 250; + +static void WriteStartBytes(std::vector& out, const void* pData, size_t len) +{ + const uint8_t* pBytes = static_cast(pData); + out.insert(out.end(), pBytes, pBytes + len); +} + +void NetworkMesh::SendStartMessage(int64_t userID, const std::vector& payload) +{ + PlayerConnection* pConnection = GetConnectionForUser(userID); + if (pConnection == nullptr || pConnection->m_hSteamConnection == k_HSteamNetConnection_Invalid) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[RUNAHEAD] No connection to user %lld for the start run ahead", static_cast(userID)); + return; + } + + std::vector packet; + packet.push_back(static_cast(ENetworkChannel::NETWORK_CHANNEL_START)); + packet.insert(packet.end(), payload.begin(), payload.end()); + const EResult r = SteamNetworkingSockets()->SendMessageToConnection(pConnection->m_hSteamConnection, packet.data(), static_cast(packet.size()), k_nSteamNetworkingSend_ReliableNoNagle, nullptr); + if (r != k_EResultOK) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[RUNAHEAD] Could not send the start run ahead message to user %lld, error %d", static_cast(userID), static_cast(r)); + } +} + +void NetworkMesh::BeginStartRunAhead(int routerSlot) +{ + std::lock_guard lock(m_mapConnectionsMutex); + + NGMP_OnlineServices_AuthInterface* pAuthInterface = NGMP_OnlineServicesManager::GetInterface(); + NGMP_OnlineServices_LobbyInterface* pLobbyInterface = NGMP_OnlineServicesManager::GetInterface(); + if (TheNetwork == nullptr || TheNGMPGame == nullptr || pAuthInterface == nullptr || pLobbyInterface == nullptr) + { + return; + } + + const int64_t localUserID = pAuthInterface->GetUserID(); + m_startLocalUserID = localUserID; + m_startMatchID = pLobbyInterface->GetCurrentMatchID(); + m_startActive = false; + m_startDecided = false; + m_startPlayers.clear(); + m_startRouterUserID = -1; + m_startLostSinceMs.clear(); + for (Int slotNum = 0; slotNum < MAX_SLOTS; ++slotNum) + { + const NGMPGameSlot* pSlot = (const NGMPGameSlot*)TheNGMPGame->getConstSlot(slotNum); + if (pSlot == nullptr || !pSlot->isHuman()) + { + continue; + } + if (slotNum == routerSlot) + { + m_startRouterUserID = pSlot->m_userID; + } + if (pSlot->m_userID != localUserID) + { + m_startPlayers.push_back(pSlot->m_userID); + } + } + + // reports of another match are stale, reports from a user who is no start player are dropped + for (auto it = m_startReports.begin(); it != m_startReports.end(); ) + { + if (it->second.matchID != m_startMatchID || !IsStartPlayer(it->first)) + { + it = m_startReports.erase(it); + } + else + { + SanitizeStartReport(it->first, it->second); + ++it; + } + } + + for (SyncStage& st : m_sync) + { + for (auto it = st.ready.begin(); it != st.ready.end(); ) + { + it = (it->second.matchID != m_startMatchID) ? st.ready.erase(it) : std::next(it); + } + st.hasGo = st.hasGo && st.goMatchID == m_startMatchID; + st.active = false; + } + + // no remote player or anticheat transport: retail start + if (m_startPlayers.empty() || m_startRouterUserID < 0 || AnticheatPlugInterface::DoesACPluginProvideSecureGameTransport()) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[RUNAHEAD] start R=%d, no start run ahead exchange (players=%d, router=%lld)", (int)TheNetwork->getRunAhead(), (int)m_startPlayers.size(), static_cast(m_startRouterUserID)); + return; + } + + m_startIsRouter = (m_startRouterUserID == localUserID); + + TheNetwork->setStartRunAheadRequired(TRUE); + m_startActive = true; + m_startBeginMs = std::chrono::duration_cast(std::chrono::steady_clock::now().time_since_epoch()).count(); + + // our round trip to every other player, the router keeps its own as a report + StartRunAheadReport own; + own.matchID = m_startMatchID; + own.directSend = TheNetwork->GetConnectionManager() != nullptr && TheNetwork->GetConnectionManager()->isDirectSendActive(true); + for (int64_t userID : m_startPlayers) + { + PlayerConnection* pConnection = GetConnectionForUser(userID); + const int rtt = (pConnection != nullptr) ? pConnection->GetSeedLatency() : -1; + if (rtt >= 0) + { + own.rtts.push_back(std::make_pair(userID, rtt)); + } + } + + if (m_startIsRouter) + { + m_startReports[localUserID] = own; + TickStartRunAhead(m_startBeginMs); + return; + } + + std::vector payload; + payload.push_back(START_MSG_REPORT); + WriteStartBytes(payload, &m_startMatchID, sizeof(m_startMatchID)); + const uint8_t count = static_cast((std::min)(own.rtts.size(), START_REPORT_MAX)); + payload.push_back(count); + payload.push_back(own.directSend ? 1 : 0); + for (uint8_t i = 0; i < count; ++i) + { + const int64_t userID = own.rtts[i].first; + const uint16_t rtt = static_cast((std::min)(own.rtts[i].second, 0xFFFE)); + WriteStartBytes(payload, &userID, sizeof(userID)); + WriteStartBytes(payload, &rtt, sizeof(rtt)); + } + SendStartMessage(m_startRouterUserID, payload); + + // the router may have decided before this client began + ApplyStartDecision(); +} + +void NetworkMesh::OnStartRunAheadMessage(int64_t connectionUserID, const uint8_t* data, size_t len) +{ + std::lock_guard lock(m_mapConnectionsMutex); + + if (data == nullptr || len < 1 + sizeof(uint64_t)) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[RUNAHEAD] Ignoring a malformed start run ahead message from user %lld", static_cast(connectionUserID)); + return; + } + + uint64_t matchID = 0; + memcpy(&matchID, data + 1, sizeof(matchID)); + size_t offset = 1 + sizeof(matchID); + + if (data[0] == START_MSG_REPORT) + { + if (len < offset + 1) + { + return; + } + const uint8_t count = data[offset++]; + if (len < offset + 1) + { + return; + } + const uint8_t flags = data[offset++]; + if (len < offset + count * (sizeof(int64_t) + sizeof(uint16_t))) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[RUNAHEAD] Ignoring a truncated round trip report from user %lld", static_cast(connectionUserID)); + return; + } + + StartRunAheadReport report; + report.matchID = matchID; + report.directSend = (flags & 1) != 0; + for (uint8_t i = 0; i < count; ++i) + { + int64_t userID = 0; + uint16_t rtt = 0; + memcpy(&userID, data + offset, sizeof(userID)); + offset += sizeof(userID); + memcpy(&rtt, data + offset, sizeof(rtt)); + offset += sizeof(rtt); + report.rtts.push_back(std::make_pair(userID, static_cast(rtt))); + } + if (m_startActive && matchID == m_startMatchID) + { + // the start players are known: only they report, and only about start players + if (!IsStartPlayer(connectionUserID)) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[SECURITY] Ignoring a start round trip report from user %lld, who is not a start player", static_cast(connectionUserID)); + return; + } + SanitizeStartReport(connectionUserID, report); + } + m_startReports[connectionUserID] = report; + + const int64_t nowMs = std::chrono::duration_cast(std::chrono::steady_clock::now().time_since_epoch()).count(); + TickStartRunAhead(nowMs); + } + else if (data[0] == START_MSG_DECISION) + { + if (len < offset + 1 + sizeof(uint16_t) + 1) + { + return; + } + if (m_startActive && connectionUserID != m_startRouterUserID) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[SECURITY] Ignoring a start run ahead decision from user %lld, who is not the router", static_cast(connectionUserID)); + return; + } + uint16_t maxRtt = 0; + StartDecision decision; + decision.matchID = matchID; + decision.runAhead = data[offset]; + memcpy(&maxRtt, data + offset + 1, sizeof(maxRtt)); + decision.maxRtt = maxRtt; + decision.pairs = data[offset + 1 + sizeof(uint16_t)]; + m_startDecisions[connectionUserID] = decision; + + ApplyStartDecision(); + } + else if (data[0] == START_MSG_READY) + { + if (len < offset + 2 || data[offset] >= SYNC_STAGES) + { + return; + } + const int stage = data[offset]; + if (m_startActive && matchID == m_startMatchID && !IsStartPlayer(connectionUserID)) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[SECURITY] Ignoring a start ready from user %lld, who is not a start player", static_cast(connectionUserID)); + return; + } + StartReady ready; + ready.matchID = matchID; + ready.direct = (data[offset + 1] & 1) != 0; + m_sync[stage].ready[connectionUserID] = ready; + + int total = 0; + const int missing = CountSyncMissing(stage, NowSteadyMs(), total); + NetworkLog(ELogVerbosity::LOG_RELEASE, "[SYNC] stage %d ready from user %lld (direct=%d), %d of %d heard", stage, static_cast(connectionUserID), (int)ready.direct, total - missing, total); + } + else if (data[0] == START_MSG_GO) + { + if (len < offset + 1 + sizeof(uint16_t) || data[offset] >= SYNC_STAGES) + { + return; + } + if (m_startActive && connectionUserID != m_startRouterUserID) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[SECURITY] Ignoring a start go from user %lld, who is not the router", static_cast(connectionUserID)); + return; + } + const int stage = data[offset]; + uint16_t delayMs = 0; + memcpy(&delayMs, data + offset + 1, sizeof(delayMs)); + SyncStage& st = m_sync[stage]; + st.goMatchID = matchID; + st.goDelayMs = (std::min)(static_cast(delayMs), SYNC_GO_MAX_DELAY_MS); + st.goReceivedMs = NowSteadyMs(); + st.hasGo = true; + NetworkLog(ELogVerbosity::LOG_RELEASE, "[SYNC] stage %d go from user %lld, starting in %d ms", stage, static_cast(connectionUserID), st.goDelayMs); + } +} + +void NetworkMesh::ApplyStartDecision() +{ + if (!m_startActive || m_startIsRouter || m_startDecided || TheNetwork == nullptr) + { + return; + } + // decisions of other users are kept until the router is known, only the router's counts + auto it = m_startDecisions.find(m_startRouterUserID); + if (it == m_startDecisions.end()) + { + return; + } + if (it->second.matchID != m_startMatchID) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[RUNAHEAD] Ignoring the start R of match %llu, this is match %llu", static_cast(it->second.matchID), static_cast(m_startMatchID)); + return; + } + + m_startDecided = true; + TheNetwork->applyStartRunAhead(it->second.runAhead, it->second.maxRtt, it->second.pairs); +} + +bool NetworkMesh::StartReportsComplete() const +{ + for (int64_t userID : m_startPlayers) + { + auto it = m_startReports.find(userID); + if (it == m_startReports.end() || it->second.matchID != m_startMatchID) + { + return false; + } + } + return true; +} + +bool NetworkMesh::IsStartPlayer(int64_t userID) const +{ + return std::find(m_startPlayers.begin(), m_startPlayers.end(), userID) != m_startPlayers.end(); +} + +void NetworkMesh::SanitizeStartReport(int64_t reporter, StartRunAheadReport& report) const +{ + // an entry names the user the reporter measured: a start player or the local user, never the reporter itself + for (auto it = report.rtts.begin(); it != report.rtts.end(); ) + { + if (it->first == reporter || (!IsStartPlayer(it->first) && it->first != m_startLocalUserID)) + { + it = report.rtts.erase(it); + } + else + { + it->second = (std::min)(it->second, START_REPORT_MAX_RTT_MS); + ++it; + } + } +} + +bool NetworkMesh::IsStartPlayerLost(int64_t userID, int64_t nowMs) +{ + std::lock_guard lock(m_mapConnectionsMutex); + const PlayerConnection* pConnection = GetConnectionForUser(userID); + if (pConnection != nullptr && pConnection->IsValid()) + { + m_startLostSinceMs.erase(userID); + return false; + } + auto it = m_startLostSinceMs.emplace(userID, nowMs).first; + return nowMs - it->second >= START_LOST_GRACE_MS; +} + +void NetworkMesh::PruneLostStartPlayers(int64_t nowMs) +{ + for (auto it = m_startPlayers.begin(); it != m_startPlayers.end(); ) + { + if (IsStartPlayerLost(*it, nowMs)) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[RUNAHEAD] user %lld left before the start run ahead, not waiting for its report", static_cast(*it)); + m_startReports.erase(*it); + it = m_startPlayers.erase(it); + } + else + { + ++it; + } + } +} + +void NetworkMesh::FallBackToRetailStart() +{ + m_startDecided = true; + if (TheNetwork == nullptr) + { + return; + } + NetworkLog(ELogVerbosity::LOG_RELEASE, "[RUNAHEAD] the router (user %lld) left before it decided, start R=%d as in retail", static_cast(m_startRouterUserID), (int)TheNetwork->getRunAhead()); + TheNetwork->applyStartRunAhead((Int)TheNetwork->getRunAhead(), 0, 0); +} + +void NetworkMesh::TickStartRunAhead(int64_t nowMs) +{ + if (!m_startActive || m_startDecided) + { + return; + } + + if (!m_startIsRouter) + { + if (IsStartPlayerLost(m_startRouterUserID, nowMs)) + { + FallBackToRetailStart(); + } + return; + } + + PruneLostStartPlayers(nowMs); + if (!StartReportsComplete() && nowMs - m_startBeginMs < START_REPORT_WAIT_MS) + { + return; + } + + DecideStartRunAhead(); +} + +void NetworkMesh::DecideStartRunAhead() +{ + m_startDecided = true; + if (TheNetwork == nullptr) + { + return; + } + + // round trips count only if every player reported, otherwise everybody starts at the retail value + const bool allReported = StartReportsComplete(); + + // a pair is the higher round trip of the two its players measured to each other + std::map, int> pairRtts; + int reports = 0; + + // a command travels one way only if every player sends directly right now: the router by its own state, the others by what they reported + ConnectionManager* pConnectionManager = TheNetwork->GetConnectionManager(); + bool oneWay = pConnectionManager != nullptr && pConnectionManager->usesOneWayRunAhead(true); + for (auto& kvReport : m_startReports) + { + // a player that left after it reported does not count + const bool reporterStays = kvReport.first == m_startLocalUserID || IsStartPlayer(kvReport.first); + if (!allReported || kvReport.second.matchID != m_startMatchID || !reporterStays) + { + continue; + } + ++reports; + if (kvReport.first != m_startLocalUserID) + { + oneWay = oneWay && kvReport.second.directSend; + } + for (const auto& entry : kvReport.second.rtts) + { + if (entry.first != m_startLocalUserID && !IsStartPlayer(entry.first)) + { + continue; + } + const int64_t a = (std::min)(kvReport.first, entry.first); + const int64_t b = (std::max)(kvReport.first, entry.first); + int& rtt = pairRtts[std::make_pair(a, b)]; + rtt = (std::max)(rtt, entry.second); + } + } + + int maxRtt = 0; + for (const auto& kvPair : pairRtts) + { + maxRtt = (std::max)(maxRtt, kvPair.second); + } + + const int pairs = static_cast(pairRtts.size()); + int runAhead = static_cast(TheNetwork->getRunAhead()); + if (pairs > 0 && pConnectionManager != nullptr) + { + runAhead = pConnectionManager->computeStartRunAhead(static_cast(maxRtt), oneWay); + } + NetworkLog(ELogVerbosity::LOG_RELEASE, "[RUNAHEAD] router decides the start R=%d from %d reports of %d players (pairs=%d, max rtt=%dms, one_way=%d)%s", runAhead, reports, (int)m_startPlayers.size() + 1, pairs, maxRtt, (int)oneWay, + allReported ? "" : ", a player did not report: retail start"); + + std::vector payload; + payload.push_back(START_MSG_DECISION); + WriteStartBytes(payload, &m_startMatchID, sizeof(m_startMatchID)); + payload.push_back(static_cast(runAhead)); + const uint16_t maxRtt16 = static_cast((std::min)(maxRtt, 0xFFFE)); + WriteStartBytes(payload, &maxRtt16, sizeof(maxRtt16)); + payload.push_back(static_cast((std::min)(pairs, 255))); + for (int64_t userID : m_startPlayers) + { + SendStartMessage(userID, payload); + } + + TheNetwork->applyStartRunAhead(runAhead, maxRtt, pairs); +} + +void NetworkMesh::SendSyncReady(int stage, int64_t nowMs, bool direct) +{ + std::vector payload; + payload.push_back(START_MSG_READY); + WriteStartBytes(payload, &m_startMatchID, sizeof(m_startMatchID)); + payload.push_back(static_cast(stage)); + payload.push_back(direct ? 1 : 0); + for (int64_t userID : m_startPlayers) + { + if (!IsStartPlayerLost(userID, nowMs)) + { + SendStartMessage(userID, payload); + } + } + m_sync[stage].readySent = true; + NetworkLog(ELogVerbosity::LOG_RELEASE, "[SYNC] stage %d ready sent after %lld ms (direct=%d)", stage, static_cast(nowMs - m_sync[stage].beginMs), (int)direct); +} + +int NetworkMesh::CountSyncMissing(int stage, int64_t nowMs, int& total) +{ + int missing = 0; + total = 0; + for (int64_t userID : m_startPlayers) + { + if (IsStartPlayerLost(userID, nowMs)) + { + continue; + } + ++total; + auto it = m_sync[stage].ready.find(userID); + if (it == m_sync[stage].ready.end() || it->second.matchID != m_startMatchID) + { + ++missing; + } + } + return missing; +} + +void NetworkMesh::FinishSyncedStart(int stage, const char* szReason, int64_t nowMs) +{ + NetworkLog(ELogVerbosity::LOG_RELEASE, "[SYNC] stage %d done %lld ms after the local part was done: %s", stage, static_cast(nowMs - m_sync[stage].beginMs), szReason); + m_sync[stage].active = false; +} + +bool NetworkMesh::BeginSyncedStart(int stage) +{ + std::lock_guard lock(m_mapConnectionsMutex); + + if (stage < 0 || stage >= SYNC_STAGES) + { + return false; + } + SyncStage& st = m_sync[stage]; + st.active = m_startActive && !m_startPlayers.empty(); + st.readySent = false; + st.allInMs = 0; + st.leaveAtMs = 0; + st.beginMs = NowSteadyMs(); + NetworkLog(ELogVerbosity::LOG_RELEASE, "[SYNC] stage %d local part done, waiting for %d players (router=%d)", stage, st.active ? (int)m_startPlayers.size() : 0, (int)m_startIsRouter); + return st.active; +} + +bool NetworkMesh::SyncedStartDone(int stage) +{ + std::lock_guard lock(m_mapConnectionsMutex); + + if (stage < 0 || stage >= SYNC_STAGES || !m_sync[stage].active) + { + return true; + } + SyncStage& st = m_sync[stage]; + const int64_t nowMs = NowSteadyMs(); + + if (!st.readySent) + { + // on the load screen: ready once the direct path to every peer is up, or if direct sending is off or does not come up in time + bool directUp = true; + ConnectionManager* pConnectionManager = TheNetwork != nullptr ? TheNetwork->GetConnectionManager() : nullptr; + if (stage == 0 && pConnectionManager != nullptr && pConnectionManager->getNetworkSettings().send.direct) + { + directUp = pConnectionManager->isDirectSendActive(true); + } + if (directUp || nowMs - st.beginMs >= SYNC_DIRECT_WAIT_MS) + { + SendSyncReady(stage, nowMs, directUp); + } + } + + if (st.leaveAtMs != 0) + { + if (nowMs < st.leaveAtMs) + { + return false; + } + FinishSyncedStart(stage, "common start time", nowMs); + return true; + } + + if (nowMs - st.beginMs >= SYNC_TIMEOUT_MS) + { + int total = 0; + const int missing = CountSyncMissing(stage, nowMs, total); + FinishSyncedStart(stage, missing > 0 ? "timeout, a player is not ready" : "timeout", nowMs); + return true; + } + + // the router's go decides, even if a ready of ours is still on the way + if (!m_startIsRouter && st.hasGo && st.goMatchID == m_startMatchID) + { + st.leaveAtMs = st.goReceivedMs + st.goDelayMs; + return false; + } + + if (!st.readySent) + { + return false; + } + int total = 0; + const int missing = CountSyncMissing(stage, nowMs, total); + if (missing > 0) + { + st.allInMs = 0; + return false; + } + if (st.allInMs == 0) + { + st.allInMs = nowMs; + NetworkLog(ELogVerbosity::LOG_RELEASE, "[SYNC] stage %d every player is ready after %lld ms", stage, static_cast(nowMs - st.beginMs)); + } + + if (m_startIsRouter) + { + // every client starts when the farthest go has arrived: the wait is that one way time minus its own + int maxOneWay = 0; + for (int64_t userID : m_startPlayers) + { + const PlayerConnection* pConnection = GetConnectionForUser(userID); + const int rtt = pConnection != nullptr ? pConnection->GetSeedLatency() : -1; + maxOneWay = (std::max)(maxOneWay, (std::min)((std::max)(rtt, 0) / 2, SYNC_GO_MAX_DELAY_MS)); + } + for (int64_t userID : m_startPlayers) + { + if (IsStartPlayerLost(userID, nowMs)) + { + continue; + } + const PlayerConnection* pConnection = GetConnectionForUser(userID); + const int rtt = pConnection != nullptr ? pConnection->GetSeedLatency() : -1; + const uint16_t delayMs = static_cast((std::max)(maxOneWay - (std::min)((std::max)(rtt, 0) / 2, SYNC_GO_MAX_DELAY_MS), 0)); + std::vector payload; + payload.push_back(START_MSG_GO); + WriteStartBytes(payload, &m_startMatchID, sizeof(m_startMatchID)); + payload.push_back(static_cast(stage)); + WriteStartBytes(payload, &delayMs, sizeof(delayMs)); + SendStartMessage(userID, payload); + } + st.leaveAtMs = nowMs + maxOneWay; + NetworkLog(ELogVerbosity::LOG_RELEASE, "[SYNC] stage %d go sent to %d players, starting in %d ms", stage, total, maxOneWay); + if (maxOneWay == 0) + { + FinishSyncedStart(stage, "common start time", nowMs); + return true; + } + return false; + } + + // no go from a router that left or does not answer: everybody has its ready, start as the others do + if (IsStartPlayerLost(m_startRouterUserID, nowMs) || nowMs - st.allInMs >= SYNC_GO_WAIT_MS) + { + FinishSyncedStart(stage, "every ready is in, no go from the router", nowMs); + return true; + } + return false; +} + void NetworkMesh::AwaitTurnCredentials() { std::lock_guard lock(m_mapConnectionsMutex); @@ -1513,6 +2168,7 @@ void NetworkMesh::Tick() } TickLobbyKeys(nowMs); + TickStartRunAhead(nowMs); // the game transport isn't created until the game begins, but we want to transfer AC packets in the lobby first, so consider this a liteupdate if (TheNGMPGame != nullptr && !TheNGMPGame->isGameInProgress()) @@ -1596,6 +2252,16 @@ void PlayerConnection::LiteUpdateForAC() msg->Release(); continue; } + else if (netChannel == ENetworkChannel::NETWORK_CHANNEL_START) + { + NetworkMesh* pMesh = NGMP_OnlineServicesManager::GetNetworkMesh(); + if (pMesh != nullptr) + { + pMesh->OnStartRunAheadMessage(m_userID, vecData.data() + sizeof(ENetworkChannel), numBytes - sizeof(ENetworkChannel)); + } + msg->Release(); + continue; + } } else if (numBytes != -1 && numBytes < sizeof(ENetworkChannel)) { @@ -2180,10 +2846,25 @@ bool PlayerConnection::GetIncomingLoss(int windowMs, PacketFeedback::Summary& su return summary.samples > 0; } +float PlayerConnection::GetSustainedLossPercent(int windowMs) +{ + // one report of a short window is no trend + static constexpr int k_minFeedbackReports = 3; + PacketFeedback::Summary outLoss; + PacketFeedback::Summary inLoss; + GetOutgoingLoss(windowMs, outLoss); + GetIncomingLoss(windowMs, inLoss); + if (outLoss.samples < k_minFeedbackReports && inLoss.samples < k_minFeedbackReports) + { + return -1.0f; + } + return (std::max)((outLoss.samples >= k_minFeedbackReports) ? outLoss.lossPercent : 0.0f, (inLoss.samples >= k_minFeedbackReports) ? inLoss.lossPercent : 0.0f); +} + int PlayerConnection::GetJitter() { - // smoothed round trip variation of the probe over its stats window - if (m_probeStats.getSampleCount() < 10) + // RFC 3550 style: the mean deviation of consecutive probe round trips, smoothed with a gain of 1/16 + if (m_probeStats.getSampleCount() < k_seedMinSamples) { return -1; } @@ -2191,6 +2872,17 @@ int PlayerConnection::GetJitter() return static_cast(m_probeStats.getJitter()); } +int PlayerConnection::GetMatchRatingMinSamples() const +{ + int intervalMs = 1000; + if (NGMP_OnlineServicesManager::GetInstance() != nullptr) + { + intervalMs = (std::max)(1, NGMP_OnlineServicesManager::GetInstance()->GetServiceConfig().connection.probe_match_interval_ms); + } + // the window holds about W / interval probes, 10 at 1 s: the probe value stays until more than half of them are lost + return (std::min)(k_seedMinSamples, (std::max)(3, NETWORK_STATS_WINDOW_MS / (2 * intervalMs))); +} + int PlayerConnection::ComputeConnectionRating() { // the overlay and the lobby ask every frame, the percentile sorts inside do not change that fast @@ -2217,9 +2909,6 @@ int PlayerConnection::UpdateConnectionRating() { return -1; } - const ServiceConfig& serviceConf = NGMP_OnlineServicesManager::GetInstance()->GetServiceConfig(); - const int slackPercent = serviceConf.input_delay.slack_percent; - int sendIntervalMs = serviceConf.send.frames_per_packet * 1000 / GENERALS_ONLINE_HIGH_FPS_LIMIT; // the command connections are indexed by game slot, find the slot of this user int slot = -1; @@ -2235,7 +2924,6 @@ int PlayerConnection::UpdateConnectionRating() float feedbackLoss = -1.0f; static constexpr int k_minCmdSamples = 20; - static constexpr int k_minFeedbackReports = 3; const bool inMatch = (pCmd != nullptr && pCmd->getSampleCount() >= k_minCmdSamples); const int feedbackWindowMs = inMatch ? NETWORK_STATS_WINDOW_MS : 60000; PacketFeedback::Summary outLoss; @@ -2246,23 +2934,28 @@ int PlayerConnection::UpdateConnectionRating() { feedbackLoss = (std::max)(hasOut ? outLoss.lossPercent : 0.0f, hasIn ? inLoss.lossPercent : 0.0f); } - float sustainedLoss = -1.0f; - if (outLoss.samples >= k_minFeedbackReports || inLoss.samples >= k_minFeedbackReports) - { - sustainedLoss = (std::max)((outLoss.samples >= k_minFeedbackReports) ? outLoss.lossPercent : 0.0f, (inLoss.samples >= k_minFeedbackReports) ? inLoss.lossPercent : 0.0f); - } + const float sustainedLoss = GetSustainedLossPercent(feedbackWindowMs); + + ConnectionManager *commandManager = TheNetwork != nullptr ? TheNetwork->GetConnectionManager() : nullptr; + const ServiceConfig &lobbyConfig = NGMP_OnlineServicesManager::GetInstance()->GetServiceConfig(); + const NetworkSettings::Send &ratingSend = commandManager != nullptr + ? commandManager->getNetworkSettings().send : lobbyConfig.send; + const NetworkSettings::InputDelay &ratingInputDelay = commandManager != nullptr + ? commandManager->getNetworkSettings().input_delay : lobbyConfig.input_delay; + // the state of this link, a pair that fell back to the packet router is rated with the longer path + const Bool oneWay = commandManager != nullptr ? commandManager->usesOneWayRunAheadForUser(m_userID) + : lobbyConfig.input_delay.controller && lobbyConfig.send.direct && m_State == EConnectionState::CONNECTED_DIRECT; if (inMatch && TheNetwork->getOverlayInfo(slot, info)) { - if (info.frameGroupingMs > 0) - { - sendIntervalMs = info.frameGroupingMs; - } - inputs = ConnectionRating::InputsFromMatch(*pCmd, info, slackPercent, sendIntervalMs, feedbackLoss); + // probe p75, else the smoothed command round trip + const int seedLatency = GetSeedLatency(GetMatchRatingMinSamples()); + const Real roundTripMs = (seedLatency >= 0) ? (Real)seedLatency : pCmd->getLatency(); + inputs = ConnectionRating::InputsFromMatch(*pCmd, info, feedbackLoss, ConnectionManager::estimateRunAheadMs(roundTripMs, ratingSend, ratingInputDelay, oneWay)); } - else if (m_probeStats.getSampleCount() >= 10) + else if (GetSeedLatency() >= 0) { - inputs = ConnectionRating::InputsFromProbe(m_probeStats, slackPercent, sendIntervalMs, feedbackLoss); + inputs = ConnectionRating::InputsFromProbe(m_probeStats, feedbackLoss, ConnectionManager::estimateRunAheadMs((Real)GetSeedLatency(), ratingSend, ratingInputDelay, oneWay)); } else { @@ -2278,7 +2971,7 @@ int PlayerConnection::UpdateConnectionRating() } { // silence must outlast the probe interval of the current phase, probes may be the only traffic - const ServiceConfig::Connection& pingConfig = serviceConf.connection; + const ServiceConfig::Connection& pingConfig = lobbyConfig.connection; const int pingIntervalMs = (TheNetwork != nullptr) ? pingConfig.probe_match_interval_ms : pingConfig.probe_lobby_interval_ms; inputs.silentLimitMs = (std::max)(inputs.silentLimitMs, pingIntervalMs * 5 / 2); } diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NextGenTransport.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NextGenTransport.cpp index 8c387770c45..4765fb3ccd1 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NextGenTransport.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NextGenTransport.cpp @@ -405,6 +405,16 @@ Bool NextGenTransport::doRecv(void) msg->Release(); continue; } + else if (netChannel == ENetworkChannel::NETWORK_CHANNEL_START) + { + NetworkMesh* pStartMesh = NGMP_OnlineServicesManager::GetNetworkMesh(); + if (pStartMesh != nullptr) + { + pStartMesh->OnStartRunAheadMessage(kvPair.second.m_userID, reinterpret_cast(vecPacketDataWithoutHeader.data()), vecPacketDataWithoutHeader.size()); + } + msg->Release(); + continue; + } else if (netChannel == ENetworkChannel::NETWORK_CHANNEL_PING) { kvPair.second.HandleProbe(reinterpret_cast(vecPacketDataWithoutHeader.data()), vecPacketDataWithoutHeader.size(), PlayerConnection::GetArrivalMs(msg)); diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Init.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Init.cpp index 8472dce6a41..3a6ee29a92f 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Init.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Init.cpp @@ -10,6 +10,7 @@ #include #include "GameClient/DisplayStringManager.h" #include "GameNetwork/NetworkInterface.h" +#include "GameNetwork/ConnectionManager.h" #include "Common/MultiplayerSettings.h" #include "GameNetwork/GameSpyOverlay.h" #include "GameClient/Display.h" @@ -112,6 +113,12 @@ void NGMP_OnlineServicesManager::CaptureScreenshotForProbe(EScreenshotType scree { CHECK_MAIN_THREAD; + // the capture stalls every client at the same time, the run ahead controller must not read that as the network + if (TheNetwork != nullptr && TheNetwork->GetConnectionManager() != nullptr) + { + TheNetwork->GetConnectionManager()->noteScreenshotCapture(); + } + NGMP_OnlineServices_LobbyInterface* pLobbyInterface = NGMP_OnlineServicesManager::GetInterface(); if (pLobbyInterface != nullptr) { From 79d5e22b1e2f79664c2a6a138dace5e97b2a8c4e Mon Sep 17 00:00:00 2001 From: tintinhamans <5984296+tintinhamans@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:20:40 +0200 Subject: [PATCH 26/28] feat(network): Show the run ahead controller in the network overlay --- .../GameEngine/Source/GameClient/InGameUI.cpp | 76 ++++++++++++++++++- 1 file changed, 73 insertions(+), 3 deletions(-) diff --git a/GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp b/GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp index 65ab0d87426..60d101fb948 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp @@ -7181,9 +7181,10 @@ namespace const Int k_playerViewTop = 500; const Real k_devViewTopFraction = 0.10f; // the dev table sits higher to clear the control bar + const Int k_changeShownMs = 10000; const Int k_nameMaxChars = 14; const Int k_devViewHeaderLines = 2; - const Int k_devViewFooterLines = 1; + const Int k_devViewFooterLines = 3; const wchar_t *const s_overlayHeaders[COL_COUNT] = { @@ -7191,6 +7192,8 @@ namespace L"Cush5/50", L"n", L"Stalls", L"Depth", L"OutL%", L"InL%" }; + const wchar_t *const s_overlayChangeNames[] = { L"", L"start", L"lag spike", L"stall", L"clean" }; + const wchar_t *const s_overlayControllerNames[] = { L"n/a", L"seed", L"hold", L"baseline", L"cover", L"uncover", L"congestion", L"outage", L"formula" }; struct OverlayRow { @@ -7505,7 +7508,7 @@ void InGameUI::drawNetworkOverlay(Int mode) if (row.info.cushionSamples > 0) { cell[COL_CUSHION].format(L"%d/%d", row.info.cushionP5, row.info.cushionP50); - sev[COL_CUSHION] = (row.info.cushionP5 <= 0) ? ConnectionRating::SEVERITY_BAD : ConnectionRating::SEVERITY_OK; + sev[COL_CUSHION] = (row.info.cushionP5 <= 0) ? ConnectionRating::SEVERITY_BAD : ((row.info.cushionP5 <= row.info.marginFrames) ? ConnectionRating::SEVERITY_WARN : ConnectionRating::SEVERITY_OK); } else { @@ -7563,6 +7566,28 @@ void InGameUI::drawNetworkOverlay(Int mode) text.format(L"Input delay %d ms, logic rate %u Hz", delayMs, cache.logicRate); x += overlayDrawText(k_strHeaderBase + 0, x, y, text, header.stalledNow ? overlayColor(ConnectionRating::SEVERITY_BAD) : neutral, dropColor) + segmentGap; + const Bool moving = (header.changeAgoMs >= 0 && header.changeAgoMs < k_changeShownMs); + if (moving && header.changeDir > 0) + { + text = L"rising"; + x += overlayDrawText(k_strHeaderBase + 1, x, y, text, overlayColor(ConnectionRating::SEVERITY_WARN), dropColor) + segmentGap / 2; + } + else if (moving && header.changeDir < 0) + { + text = L"lowering"; + x += overlayDrawText(k_strHeaderBase + 1, x, y, text, GameMakeColor(120, 220, 120, 255), dropColor) + segmentGap / 2; + } + else + { + text = L"steady"; + x += overlayDrawText(k_strHeaderBase + 1, x, y, text, dim, dropColor) + segmentGap / 2; + } + if (header.changeReason > NetworkOverlayInfo::CHANGE_NONE) + { + text.format(L"(%ls)", s_overlayChangeNames[header.changeReason]); + x += overlayDrawText(k_strHeaderBase + 2, x, y, text, dim, dropColor) + segmentGap; + } + if (header.stalledNow && !waitingOn.isEmpty()) { text.format(L"waiting on: %ls", waitingOn.str()); @@ -7709,9 +7734,54 @@ void InGameUI::drawNetworkOverlay(Int mode) y += lineH / 2; { + Int x = left; UnicodeString text; text.format(L"R %d frames (%d ms) send interval %d ms", header.runAhead, delayMs, header.frameGroupingMs); - overlayDrawText(k_strFooterBase + 0, left, y, text, neutral, dropColor); + x += overlayDrawText(k_strFooterBase + 0, x, y, text, neutral, dropColor) + segmentGap; + + if (header.isRouter && header.controllerOn) + { + const Int reasonSeverity = (header.ctlReason == NetworkOverlayInfo::CTL_CONGESTION || header.ctlReason == NetworkOverlayInfo::CTL_OUTAGE) ? ConnectionRating::SEVERITY_BAD : ((header.ctlReason == NetworkOverlayInfo::CTL_COVER || header.ctlInSpike) ? ConnectionRating::SEVERITY_WARN : ConnectionRating::SEVERITY_OK); + text.format(L"reason %ls", s_overlayControllerNames[header.ctlReason]); + x += overlayDrawText(k_strFooterBase + 1, x, y, text, overlayColor(reasonSeverity), dropColor) + segmentGap; + + const Int minSumNeeded = 2 * header.marginFrames; + if (header.ctlMinSum < NetworkOverlayInfo::NO_PAIR_SUM) + { + text.format(L"minS %d of %d (pair %d-%d)", header.ctlMinSum, minSumNeeded, header.ctlPairA, header.ctlPairB); + x += overlayDrawText(k_strFooterBase + 2, x, y, text, overlayColor(header.ctlMinSum <= 0 ? ConnectionRating::SEVERITY_BAD : (header.ctlMinSum < minSumNeeded ? ConnectionRating::SEVERITY_WARN : ConnectionRating::SEVERITY_OK)), dropColor) + segmentGap; + } + else + { + text = L"minS - (no pair)"; + x += overlayDrawText(k_strFooterBase + 2, x, y, text, dim, dropColor) + segmentGap; + } + if (header.ctlReason == NetworkOverlayInfo::CTL_NONE) + { + text = L"base n/a cover n/a"; + } + else + { + text.format(L"base %d cover %d%ls", header.ctlBase, header.ctlCover, header.ctlInSpike ? L" in spike" : L""); + } + overlayDrawText(k_strFooterBase + 3, x, y, text, neutral, dropColor); + y += lineH; + + text.format(L"spikes %d.%d/min size %d ms since_change %d ms", header.ctlSpikesPerMin10 / 10, header.ctlSpikesPerMin10 % 10, header.ctlSpikeSizeMs, header.ctlSinceChangeMs); + overlayDrawText(k_strFooterBase + 4, left, y, text, neutral, dropColor); + y += lineH; + } + else + { + if (header.changeReason > NetworkOverlayInfo::CHANGE_NONE) + { + text.format(L"last change %ls %d s ago", s_overlayChangeNames[header.changeReason], header.changeAgoMs / 1000); + x += overlayDrawText(k_strFooterBase + 5, x, y, text, neutral, dropColor) + segmentGap; + } + text = header.isRouter ? L"controller off" : L"host decides"; + overlayDrawText(k_strFooterBase + 6, x, y, text, dim, dropColor); + y += lineH; + } } #endif } From 86d53de70c996b1b36c76ca226edaac27f59718e Mon Sep 17 00:00:00 2001 From: tintinhamans <5984296+tintinhamans@users.noreply.github.com> Date: Mon, 12 Oct 2026 07:33:05 +0200 Subject: [PATCH 27/28] feat(lobby): Add the /net chat command for the relay and connection engine settings --- .../GUICallbacks/Menus/WOLGameSetupMenu.cpp | 13 ++ .../GUI/GUICallbacks/Menus/WOLLobbyMenu.cpp | 213 ++++++++++++++++-- .../GeneralsOnline/NetworkMesh.cpp | 11 +- 3 files changed, 218 insertions(+), 19 deletions(-) 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 fc3f6521f9e..4919b5e5190 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/WOLGameSetupMenu.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/WOLGameSetupMenu.cpp @@ -3642,6 +3642,7 @@ Bool handleGameSetupSlashCommands(UnicodeString uText) GadgetListBoxAddEntryText(listboxGameSetupChat, UnicodeString(L"/maxcameraheight - Set the camera height limit (host only)."), helpColor, -1, -1); // GadgetListBoxAddEntryText(listboxGameSetupChat, UnicodeString(L"/leave - Return to the main lobby."), helpColor, -1, -1); // GadgetListBoxAddEntryText(listboxGameSetupChat, UnicodeString(L"/quit - Exit the game."), helpColor, -1, -1); + GadgetListBoxAddEntryText(listboxGameSetupChat, UnicodeString(L"/net - Show how you reach each player. /net relay and /net engine change your settings."), helpColor, -1, -1); GadgetListBoxAddEntryText(listboxGameSetupChat, UnicodeString(L"/support - Open the GeneralsOnline Discord."), helpColor, -1, -1); GadgetListBoxAddEntryText(listboxGameSetupChat, UnicodeString(L"/help - Show these commands. You can also use /commands."), helpColor, -1, -1); return TRUE; // was a slash command @@ -3651,6 +3652,18 @@ Bool handleGameSetupSlashCommands(UnicodeString uText) ShellExecuteA(NULL, "open", "https://discord.playgenerals.online", NULL, NULL, SW_SHOWNORMAL); return TRUE; } + else if (token == "net") + { + void handleNetSlashCommand(AsciiString subcommand, AsciiString value, GameWindow* listbox); + AsciiString subcommand; + AsciiString value; + remainder.nextToken(&subcommand); + remainder.nextToken(&value); + subcommand.toLower(); + value.toLower(); + handleNetSlashCommand(subcommand, value, listboxGameSetupChat); + return TRUE; // was a slash command + } else if (token == "friendsonly") { NGMP_OnlineServicesManager* pOnlineServicesMgr = NGMP_OnlineServicesManager::GetInstance(); diff --git a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/WOLLobbyMenu.cpp b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/WOLLobbyMenu.cpp index b207a4e7c71..e08c561fff8 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/WOLLobbyMenu.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/WOLLobbyMenu.cpp @@ -176,6 +176,191 @@ int getQR2HostingStatus(); } extern int isThreadHosting; +// /net: the relay and connection engine settings for the next lobby mesh, and the route to each player of the current one +static const wchar_t* netRelaySetting() +{ + extern int g_relayOverride; + if (g_relayOverride == 1) + { + return L"force (relay only)"; + } + NGMP_OnlineServicesManager* manager = NGMP_OnlineServicesManager::GetInstance(); + if (manager == nullptr) + { + return L"auto (service setting unavailable)"; + } + return manager->GetServiceConfig().connection.relay_only + ? L"auto (service requires relay)" : L"auto (service allows direct connections)"; +} + +static const wchar_t* netEngineSetting() +{ + extern int g_iceImplementationOverride; + if (g_iceImplementationOverride == 1) + { + return L"native"; + } + if (g_iceImplementationOverride == 2) + { + return L"webrtc"; + } + NGMP_OnlineServicesManager* manager = NGMP_OnlineServicesManager::GetInstance(); + if (manager == nullptr) + { + return L"auto (service setting unavailable)"; + } + switch (manager->GetServiceConfig().connection.ice_engine) + { + case 0: return L"auto (service selects the library default)"; + case 1: return L"auto (service selects native)"; + default: return L"auto (service selects webrtc)"; + } +} + +static void setNetRelayOverride(int wanted) +{ + extern int g_relayOverride; + extern UnsignedInt m_exeCRCOriginal; + // Keep relay-only lobbies separate without losing the real build CRC. + if (wanted == 1 && g_relayOverride != 1) + { + m_exeCRCOriginal = TheWritableGlobalData->m_exeCRC; + TheWritableGlobalData->m_exeCRC = m_exeCRCOriginal ^ 0x52454C59; + } + else if (wanted != 1 && g_relayOverride == 1) + { + TheWritableGlobalData->m_exeCRC = m_exeCRCOriginal; + m_exeCRCOriginal = 0; + } + g_relayOverride = wanted; +} + +void handleNetSlashCommand(AsciiString subcommand, AsciiString value, GameWindow* listbox) +{ + extern int g_iceImplementationOverride; + + const Color netColor = GameMakeColor(255, 255, 0, 255); + UnicodeString line; + + if (subcommand == "reset" && value.isEmpty()) + { + setNetRelayOverride(-1); + g_iceImplementationOverride = -1; + line.format(L"Next lobby: relay %ls; engine %ls. Existing connections are unchanged.", netRelaySetting(), netEngineSetting()); + GadgetListBoxAddEntryText(listbox, line, netColor, -1, -1); + return; + } + + if (subcommand == "relay") + { + int wanted = -2; + if (value == "force") + { + wanted = 1; + } + else if (value == "auto") + { + wanted = -1; + } + + if (wanted == -2) + { + line.format(L"Next lobby relay: %ls. Usage: /net relay force | auto", netRelaySetting()); + GadgetListBoxAddEntryText(listbox, line, netColor, -1, -1); + return; + } + + setNetRelayOverride(wanted); + line.format(L"Next lobby: relay %ls. Existing connections are unchanged.%ls", netRelaySetting(), + (wanted == 1) ? L" You can only join lobbies of players who also force relay. Use /net relay auto to undo this." : L""); + GadgetListBoxAddEntryText(listbox, line, netColor, -1, -1); + return; + } + + if (subcommand == "engine") + { + int wanted = -2; + if (value == "native") + { + wanted = 1; + } + else if (value == "webrtc") + { + wanted = 2; + } + else if (value == "auto") + { + wanted = -1; + } + + if (wanted == -2) + { + line.format(L"Next lobby engine: %ls. Usage: /net engine native | webrtc | auto", netEngineSetting()); + GadgetListBoxAddEntryText(listbox, line, netColor, -1, -1); + return; + } + + g_iceImplementationOverride = wanted; + + line.format(L"Next lobby engine: %ls. Existing connections are unchanged.%ls", netEngineSetting(), + (wanted == -1) ? L"" : L" Use /net engine auto to undo this."); + GadgetListBoxAddEntryText(listbox, line, netColor, -1, -1); + return; + } + + if (subcommand.isNotEmpty()) + { + GadgetListBoxAddEntryText(listbox, UnicodeString(L"Usage: /net, /net relay force | auto, /net engine native | webrtc | auto, /net reset"), netColor, -1, -1); + return; + } + + line.format(L"Next lobby: relay %ls; engine %ls. Existing connections are unchanged.", netRelaySetting(), netEngineSetting()); + GadgetListBoxAddEntryText(listbox, line, netColor, -1, -1); + + NetworkMesh* pMesh = NGMP_OnlineServicesManager::GetNetworkMesh(); + NGMP_OnlineServices_LobbyInterface* pLobbyInterface = NGMP_OnlineServicesManager::GetInterface(); + if (pMesh == nullptr || pMesh->GetAllConnections().empty()) + { + GadgetListBoxAddEntryText(listbox, UnicodeString(L"You are not connected to any players. Join or host a lobby to see how you reach each player."), netColor, -1, -1); + } + else + { + for (auto& kvPair : pMesh->GetAllConnections()) + { + PlayerConnection& conn = kvPair.second; + + std::wstring strName = L"Unknown player"; + if (pLobbyInterface != nullptr) + { + for (const LobbyMemberEntry& member : pLobbyInterface->GetCurrentLobby().members) + { + if (member.user_id == kvPair.first) + { + strName = from_utf8(member.display_name); + break; + } + } + } + + if (conn.GetState() == EConnectionState::CONNECTED_DIRECT) + { + line.format(L"%ls: %ls, ping %d ms", strName.c_str(), conn.IsDirect() ? L"direct" : L"via relay", conn.GetLatency()); + } + else if (conn.GetState() == EConnectionState::CONNECTING_DIRECT || conn.GetState() == EConnectionState::FINDING_ROUTE) + { + line.format(L"%ls: connecting", strName.c_str()); + } + else + { + line.format(L"%ls: not connected", strName.c_str()); + } + GadgetListBoxAddEntryText(listbox, line, netColor, -1, -1); + } + } + + GadgetListBoxAddEntryText(listbox, UnicodeString(L"Change with /net relay force | auto and /net engine native | webrtc | auto. /net reset clears both overrides."), netColor, -1, -1); +} + Bool handleLobbySlashCommands(UnicodeString uText, Bool *wasRateLimited) { if (wasRateLimited != nullptr) @@ -228,8 +413,7 @@ Bool handleLobbySlashCommands(UnicodeString uText, Bool *wasRateLimited) GadgetListBoxAddEntryText(listboxLobbyChat, UnicodeString(L"/me - Send an emote."), helpColor, -1, -1); GadgetListBoxAddEntryText(listboxLobbyChat, UnicodeString(L"/name - Changes your display name - Example: /name General Granger. You can also use /nick."), helpColor, -1, -1); // GadgetListBoxAddEntryText(listboxLobbyChat, UnicodeString(L"/refresh - Refresh the game and player lists."), helpColor, -1, -1); - // GadgetListBoxAddEntryText(listboxLobbyChat, UnicodeString(L"/forcerelay - Use relay connections only."), helpColor, -1, -1); - // GadgetListBoxAddEntryText(listboxLobbyChat, UnicodeString(L"/allowrelay - Allow direct connections again."), helpColor, -1, -1); + GadgetListBoxAddEntryText(listboxLobbyChat, UnicodeString(L"/net - Show your connection settings. /net relay and /net engine change them."), helpColor, -1, -1); GadgetListBoxAddEntryText(listboxLobbyChat, UnicodeString(L"/support - Open the GeneralsOnline Discord."), helpColor, -1, -1); GadgetListBoxAddEntryText(listboxLobbyChat, UnicodeString(L"/help - Show these commands. You can also use /commands."), helpColor, -1, -1); return TRUE; // was a slash command @@ -259,24 +443,21 @@ Bool handleLobbySlashCommands(UnicodeString uText, Bool *wasRateLimited) return TRUE; // was a slash command } - else if (token == "forcerelay") + else if (token == "net") { - extern bool g_bForceRelay; - extern UnsignedInt m_exeCRCOriginal; - g_bForceRelay = true; - m_exeCRCOriginal = TheWritableGlobalData->m_exeCRC; - TheWritableGlobalData->m_exeCRC = 123456; - GadgetListBoxAddEntryText(listboxLobbyChat, UnicodeString(L"Relays are now forced on. You will only be able to join lobbies where the same option has been set. Use /allowrelay to reset this"), GameMakeColor(255, 0, 0, 255), -1, -1); + AsciiString subcommand; + AsciiString value; + remainder.nextToken(&subcommand); + remainder.nextToken(&value); + subcommand.toLower(); + value.toLower(); + handleNetSlashCommand(subcommand, value, listboxLobbyChat); return TRUE; // was a slash command } - else if (token == "allowrelay") + else if (token == "forcerelay" || token == "allowrelay") { - extern bool g_bForceRelay; - extern UnsignedInt m_exeCRCOriginal; - g_bForceRelay = false; - TheWritableGlobalData->m_exeCRC = m_exeCRCOriginal; - m_exeCRCOriginal = 0; - GadgetListBoxAddEntryText(listboxLobbyChat, UnicodeString(L"Relays are now optional again. You will only be able to join lobbies where the same option has been set. Use /forcerelay to reset this"), GameMakeColor(255, 0, 0, 255), -1, -1); + // the old names of /net relay force and /net relay auto + handleNetSlashCommand("relay", (token == "forcerelay") ? "force" : "auto", listboxLobbyChat); return TRUE; // was a slash command } else if (token == "refresh") diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NetworkMesh.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NetworkMesh.cpp index f58b696b31e..fe5a6b05cbb 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NetworkMesh.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NetworkMesh.cpp @@ -21,9 +21,12 @@ #include #include -bool g_bForceRelay = false; UnsignedInt m_exeCRCOriginal = 0; +// set by the /net relay and /net engine chat commands, -1 follows the service config +int g_relayOverride = -1; // 1 = relay only, -1 = service policy +int g_iceImplementationOverride = -1; // 1 = native, 2 = WebRTC + static int64_t NowSteadyMs() { return std::chrono::duration_cast(std::chrono::steady_clock::now().time_since_epoch()).count(); @@ -796,12 +799,14 @@ NetworkMesh::NetworkMesh() m_strTurnTokenString = std::format("{},{}", m_strTurnToken.c_str(), m_strTurnToken.c_str()); ServiceConfig& serviceConf = pOnlineServicesMgr->GetServiceConfig(); - m_iceEnable = (g_bForceRelay || serviceConf.connection.relay_only) + const bool relayOnly = (g_relayOverride == 1) || serviceConf.connection.relay_only; + m_iceEnable = relayOnly ? k_nSteamNetworkingConfig_P2P_Transport_ICE_Enable_Relay : k_nSteamNetworkingConfig_P2P_Transport_ICE_Enable_All; // 0 = library default, 1 = native, 2 = WebRTC - m_iceImplementation = (serviceConf.connection.ice_engine >= 0 && serviceConf.connection.ice_engine <= 2) ? serviceConf.connection.ice_engine : 2; + const int iceImplementation = (g_iceImplementationOverride >= 0) ? g_iceImplementationOverride : serviceConf.connection.ice_engine; + m_iceImplementation = (iceImplementation >= 0 && iceImplementation <= 2) ? iceImplementation : 2; NetworkLog(ELogVerbosity::LOG_RELEASE, "NetworkMesh: using ICE implementation %d (0=default, 1=native, 2=WebRTC)", m_iceImplementation); m_pSignaling = new CSignalingClient(SteamNetworkingSockets()); From 2f36df5d57ea8cf1a3e52625165de3dbbca4329f Mon Sep 17 00:00:00 2001 From: tintinhamans <5984296+tintinhamans@users.noreply.github.com> Date: Fri, 16 Oct 2026 05:44:28 +0200 Subject: [PATCH 28/28] feat(online): Turn on Wi-Fi media streaming mode while a match runs --- .../GameEngine/Source/GameNetwork/Network.cpp | 7 + GeneralsMD/Code/GameEngine/CMakeLists.txt | 2 + .../GeneralsOnline/OnlineServices_Init.h | 2 + .../GeneralsOnline/WifiLowLatency.h | 9 + .../GameNetwork/GeneralsOnline/NGMPGame.cpp | 7 + .../GeneralsOnline/OnlineServices_Init.cpp | 3 + .../GeneralsOnline/WifiLowLatency.cpp | 478 ++++++++++++++++++ 7 files changed, 508 insertions(+) create mode 100644 GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/WifiLowLatency.h create mode 100644 GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/WifiLowLatency.cpp diff --git a/Core/GameEngine/Source/GameNetwork/Network.cpp b/Core/GameEngine/Source/GameNetwork/Network.cpp index 5e07c3f8743..0ca05ad92c0 100644 --- a/Core/GameEngine/Source/GameNetwork/Network.cpp +++ b/Core/GameEngine/Source/GameNetwork/Network.cpp @@ -53,6 +53,9 @@ #include "GameClient/MessageBox.h" #include "../NGMP_include.h" #include "../ngmp_interfaces.h" +#if defined(GENERALS_ONLINE) +#include "GameNetwork/GeneralsOnline/WifiLowLatency.h" +#endif #if defined(DEBUG_CRC) && !RETAIL_COMPATIBLE_NETWORKING @@ -336,6 +339,10 @@ Network::Network() Network::~Network() { deinit(); +#if defined(GENERALS_ONLINE) + // the match is over, release the Wi-Fi tuning + WifiLowLatency::End(); +#endif } /** diff --git a/GeneralsMD/Code/GameEngine/CMakeLists.txt b/GeneralsMD/Code/GameEngine/CMakeLists.txt index 3b5f35c3278..2e2e27a3b7c 100644 --- a/GeneralsMD/Code/GameEngine/CMakeLists.txt +++ b/GeneralsMD/Code/GameEngine/CMakeLists.txt @@ -1164,6 +1164,7 @@ set(GAMEENGINE_SRC Include/GameNetwork/GeneralsOnline/HTTP/HTTPRequest.h Include/GameNetwork/GeneralsOnline/NextGenTransport.h Include/GameNetwork/GeneralsOnline/PluginInterfaces.h + Include/GameNetwork/GeneralsOnline/WifiLowLatency.h Source/GameNetwork/GeneralsOnline/CredentialStore.cpp Source/GameNetwork/GeneralsOnline/DiscordRichPresence.cpp Source/GameNetwork/GeneralsOnline/NGMP_Helpers.cpp @@ -1189,6 +1190,7 @@ set(GAMEENGINE_SRC Source/GameNetwork/GeneralsOnline/NextGenTransport.cpp Source/GameNetwork/GeneralsOnline/NetworkBitstream.cpp Source/GameNetwork/GeneralsOnline/PluginInterfaces.cpp + Source/GameNetwork/GeneralsOnline/WifiLowLatency.cpp ) if(RTS_GAMEMEMORY_ENABLE) diff --git a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h index 3b5d8b55f25..4df5a212953 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h @@ -358,6 +358,7 @@ struct ServiceConfig bool anticheat_transport = false; // game traffic through the anticheat plugin's transport int probe_lobby_interval_ms = 250; // time between latency probes sent to each peer while not in a match int probe_match_interval_ms = 1000; // the same in a match, the command acks already measure the round trip + bool wifi_low_latency = true; // Wi-Fi media streaming mode while a match runs, stops background scans from stalling the game } connection; using Send = NetworkSettings::Send; @@ -441,6 +442,7 @@ inline void ReadServiceConfig(const nlohmann::json& j, ServiceConfig& c) ReadServiceConfigValue(s, "anticheat_transport", c.connection.anticheat_transport); ReadServiceConfigValue(s, "probe_lobby_interval_ms", c.connection.probe_lobby_interval_ms); ReadServiceConfigValue(s, "probe_match_interval_ms", c.connection.probe_match_interval_ms); + ReadServiceConfigValue(s, "wifi_low_latency", c.connection.wifi_low_latency); } if (j.contains("send")) { diff --git a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/WifiLowLatency.h b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/WifiLowLatency.h new file mode 100644 index 00000000000..5b175f034e3 --- /dev/null +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/WifiLowLatency.h @@ -0,0 +1,9 @@ +#pragma once + +// Turns on Wi-Fi media streaming mode for the adapter that carries the game traffic while a match runs; best effort, a no-op without Wi-Fi. +class WifiLowLatency +{ +public: + static void Begin(); ///< Match start, returns at once. + static void End(); ///< Match end or shutdown, waits 250 ms at most for the worker. +}; diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NGMPGame.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NGMPGame.cpp index b2bf95adfb8..5752ca6a04f 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NGMPGame.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/NGMPGame.cpp @@ -8,6 +8,7 @@ #include "GameNetwork/GameSpyOverlay.h" #include "Common/RandomValue.h" #include "GameNetwork/GeneralsOnline/NGMP_interfaces.h" +#include "GameNetwork/GeneralsOnline/WifiLowLatency.h" #include "GameNetwork/NetworkInterface.h" #include "Common/GlobalData.h" #include "GameClient/View.h" @@ -451,6 +452,12 @@ void NGMPGame::launchGame(void) // Time to initialize TheNetwork for this game. TheNetwork = NetworkInterface::createNetwork(); TheNetwork->init(); + + // Wi-Fi tuning for the match, released when TheNetwork is destroyed + if (NGMP_OnlineServicesManager::GetInstance() != nullptr && NGMP_OnlineServicesManager::GetInstance()->GetServiceConfig().connection.wifi_low_latency) + { + WifiLowLatency::Begin(); + } TheNetwork->requireMatchKeys(); NetworkMesh* pMesh = NGMP_OnlineServicesManager::GetNetworkMesh(); diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Init.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Init.cpp index 3a6ee29a92f..0bb8cea83b7 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Init.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Init.cpp @@ -1,5 +1,6 @@ #include "GameNetwork/GeneralsOnline/NGMP_interfaces.h" #include "GameNetwork/GeneralsOnline/NetworkMesh.h" +#include "GameNetwork/GeneralsOnline/WifiLowLatency.h" #include "GameNetwork/GeneralsOnline/HTTP/HTTPManager.h" #include "../json.hpp" #include "GameClient/MessageBox.h" @@ -467,6 +468,8 @@ void NGMP_OnlineServicesManager::Shutdown() // This prevents race conditions where threads might still be using resources WaitForScreenshotThreads(); + WifiLowLatency::End(); + NetworkMeshLibrary::Shutdown(); // Shutdown and completely destroy WebSocket BEFORE cleaning up HTTPManager diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/WifiLowLatency.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/WifiLowLatency.cpp new file mode 100644 index 00000000000..f911be6cf37 --- /dev/null +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/WifiLowLatency.cpp @@ -0,0 +1,478 @@ +#include "GameNetwork/GeneralsOnline/WifiLowLatency.h" +#include "GameNetwork/GeneralsOnline/NGMP_include.h" + +#include +#include +#include +#include +#include + +#pragma comment(lib, "iphlpapi.lib") + +// The few wlanapi.h declarations used, so no SDK header or import library is needed +enum +{ + WIFI_OPCODE_MEDIA_STREAMING = 3, // wlan_intf_opcode_media_streaming_mode + WIFI_STATE_CONNECTED = 1, // wlan_interface_state_connected + WIFI_CLIENT_VERSION = 2, // the Vista and later API + WIFI_SOURCE_NONE = 0, // WLAN_NOTIFICATION_SOURCE_NONE + WIFI_SOURCE_ACM = 8, // WLAN_NOTIFICATION_SOURCE_ACM + WIFI_ACM_CONNECTION_COMPLETE = 10, + WIFI_ACM_DISCONNECTED = 21, + WIFI_POLL_MS = 60000, // slow fallback check, it only queries + WIFI_SETTLE_MS = 1000, // a connection change needs a moment before the route is final + WIFI_BACKOFF_MIN_MS = 2000, + WIFI_BACKOFF_MAX_MS = 60000, + WIFI_END_WAIT_MS = 250, // End does not wait longer for a slow WLAN service, the worker then ends on its own + WIFI_MAX_TARGETS = 2, // the IPv4 and the IPv6 route + WIFI_LOG_GENERAL = WIFI_MAX_TARGETS, + WIFI_LOG_NOTIFY = WIFI_MAX_TARGETS + 1, + WIFI_LOG_SLOTS = WIFI_MAX_TARGETS + 2 +}; + +struct WifiInterfaceInfo +{ + GUID guid; + WCHAR description[256]; + DWORD state; +}; +static_assert(sizeof(WifiInterfaceInfo) == 532, "must match WLAN_INTERFACE_INFO"); + +struct WifiInterfaceList +{ + DWORD count; + DWORD index; + WifiInterfaceInfo items[1]; +}; + +struct WifiNotificationData +{ + DWORD source; + DWORD code; + GUID interfaceGuid; + DWORD dataSize; + PVOID data; +}; + +typedef VOID(WINAPI* WifiNotifyFn)(WifiNotificationData*, PVOID); +typedef DWORD(WINAPI* WifiOpenHandleFn)(DWORD, PVOID, PDWORD, PHANDLE); +typedef DWORD(WINAPI* WifiCloseHandleFn)(HANDLE, PVOID); +typedef DWORD(WINAPI* WifiEnumInterfacesFn)(HANDLE, PVOID, WifiInterfaceList**); +typedef DWORD(WINAPI* WifiSetInterfaceFn)(HANDLE, const GUID*, DWORD, DWORD, const PVOID, PVOID); +typedef DWORD(WINAPI* WifiQueryInterfaceFn)(HANDLE, const GUID*, DWORD, PVOID, PDWORD, PVOID*, PDWORD); +typedef DWORD(WINAPI* WifiRegisterNotificationFn)(HANDLE, DWORD, BOOL, WifiNotifyFn, PVOID, PVOID, PDWORD); +typedef VOID(WINAPI* WifiFreeMemoryFn)(PVOID); + +struct WifiApi +{ + WifiOpenHandleFn open; + WifiCloseHandleFn close; + WifiEnumInterfacesFn enumInterfaces; + WifiSetInterfaceFn set; + WifiQueryInterfaceFn query; + WifiRegisterNotificationFn registerNotification; // optional, without it the poll does the work + WifiFreeMemoryFn freeMemory; +}; + +// Shared by End and the worker, freed by whoever lets go last; the worker owns the WLAN handle, the library and its log +struct WifiState +{ + HANDLE stop; + HANDLE wake; + volatile LONG refs; + volatile LONG detached; +}; + +// A line is logged only when it differs from the last one of its slot, so a failure shows once and so does its recovery +struct WifiLog +{ + WifiState* state; + char last[WIFI_LOG_SLOTS][192]; +}; + +static WifiState* s_state = NULL; +static HANDLE s_thread = NULL; +static volatile LONG s_workers = 0; + +static void WifiRelease(WifiState* state) +{ + if (InterlockedDecrement(&state->refs) == 0) + { + CloseHandle(state->stop); + CloseHandle(state->wake); + delete state; + } +} + +static void WifiLogLine(WifiLog& log, int slot, const char* format, ...) +{ + char line[192]; + va_list args; + va_start(args, format); + _vsnprintf(line, sizeof(line), format, args); + va_end(args); + line[sizeof(line) - 1] = '\0'; + if (strcmp(line, log.last[slot]) == 0 || log.state->detached) + { + return; + } + strcpy(log.last[slot], line); + NetworkLog(ELogVerbosity::LOG_RELEASE, "[WIFI] %s", line); +} + +// Runs on a WLAN thread, so it only wakes the worker +static VOID WINAPI WifiNotify(WifiNotificationData* data, PVOID context) +{ + if (data != NULL && data->source == WIFI_SOURCE_ACM && (data->code == WIFI_ACM_CONNECTION_COMPLETE || data->code == WIFI_ACM_DISCONNECTED)) + { + SetEvent(((WifiState*)context)->wake); + } +} + +// The handle is gone or the service restarted, so the handle has to be opened again +static bool WifiNeedsReopen(DWORD result) +{ + return result == ERROR_INVALID_HANDLE || result == ERROR_SERVICE_NOT_ACTIVE || (result >= 1700 && result <= 1899); // 1700..1899 are the RPC_S_ errors +} + +static bool WifiParseGuid(const char* text, GUID& guid) +{ + unsigned int d1, d2, d3, b[8]; + if (sscanf(text, "{%8x-%4x-%4x-%2x%2x-%2x%2x%2x%2x%2x%2x}", &d1, &d2, &d3, &b[0], &b[1], &b[2], &b[3], &b[4], &b[5], &b[6], &b[7]) != 11) + { + return false; + } + guid.Data1 = d1; + guid.Data2 = (unsigned short)d2; + guid.Data3 = (unsigned short)d3; + for (int i = 0; i < 8; ++i) + { + guid.Data4[i] = (unsigned char)b[i]; + } + return true; +} + +// The Wi-Fi adapters on the route to the internet, none when the game traffic goes over Ethernet or a tunnel +static int WifiGameAdapters(GUID* targets) +{ + sockaddr_in peer4 = {}; + peer4.sin_family = AF_INET; + peer4.sin_addr.S_un.S_addr = 0x01010101; // 1.1.1.1, only used for the route lookup, nothing is sent + sockaddr_in6 peer6 = {}; + peer6.sin6_family = AF_INET6; + peer6.sin6_addr.u.Word[0] = htons(0x2606); + peer6.sin6_addr.u.Word[1] = htons(0x4700); + peer6.sin6_addr.u.Word[2] = htons(0x4700); + peer6.sin6_addr.u.Word[7] = htons(0x1111); + + DWORD index4 = 0; + DWORD index6 = 0; + const bool have4 = GetBestInterfaceEx((sockaddr*)&peer4, &index4) == NO_ERROR; + const bool have6 = GetBestInterfaceEx((sockaddr*)&peer6, &index6) == NO_ERROR; + if (!have4 && !have6) + { + return 0; + } + + ULONG size = 16 * 1024; + IP_ADAPTER_ADDRESSES* adapters = NULL; + ULONG result = ERROR_BUFFER_OVERFLOW; + for (int attempt = 0; attempt < 3 && result == ERROR_BUFFER_OVERFLOW; ++attempt) + { + free(adapters); + adapters = (IP_ADAPTER_ADDRESSES*)malloc(size); + if (adapters == NULL) + { + return 0; + } + result = GetAdaptersAddresses(AF_UNSPEC, GAA_FLAG_SKIP_ANYCAST | GAA_FLAG_SKIP_MULTICAST | GAA_FLAG_SKIP_DNS_SERVER, NULL, adapters, &size); + } + + int count = 0; + if (result == NO_ERROR) + { + for (IP_ADAPTER_ADDRESSES* adapter = adapters; adapter != NULL && count < WIFI_MAX_TARGETS; adapter = adapter->Next) + { + const bool onRoute = (have4 && adapter->IfIndex == index4) || (have6 && adapter->Ipv6IfIndex == index6); + if (adapter->IfType == IF_TYPE_IEEE80211 && onRoute && WifiParseGuid(adapter->AdapterName, targets[count])) + { + ++count; + } + } + } + free(adapters); + return count; +} + +// Turns media streaming mode on where it is not on yet; returns an error that needs the handle reopened, else success +static DWORD WifiTune(const WifiApi& api, HANDLE client, const GUID* targets, int targetCount, WifiLog& log) +{ + WifiInterfaceList* list = NULL; + const DWORD enumResult = api.enumInterfaces(client, NULL, &list); + if (enumResult != ERROR_SUCCESS) + { + WifiLogLine(log, WIFI_LOG_GENERAL, "listing the interfaces failed, result=%lu", enumResult); + return enumResult; + } + if (list == NULL) + { + return ERROR_SUCCESS; + } + + DWORD fatal = ERROR_SUCCESS; + int tuned = 0; + for (DWORD i = 0; i < list->count && fatal == ERROR_SUCCESS; ++i) + { + const WifiInterfaceInfo& info = list->items[i]; + int slot = -1; + for (int k = 0; k < targetCount; ++k) + { + if (IsEqualGUID(info.guid, targets[k])) + { + slot = k; + } + } + if (slot < 0 || info.state != WIFI_STATE_CONNECTED) + { + continue; + } + ++tuned; + + char description[256]; + if (WideCharToMultiByte(CP_UTF8, 0, info.description, -1, description, sizeof(description), NULL, NULL) == 0) + { + description[0] = '\0'; + } + + BOOL* current = NULL; + DWORD size = 0; + DWORD type = 0; + const DWORD queryResult = api.query(client, &info.guid, WIFI_OPCODE_MEDIA_STREAMING, NULL, &size, (PVOID*)¤t, &type); + const bool alreadyOn = queryResult == ERROR_SUCCESS && current != NULL && size >= sizeof(BOOL) && *current; + if (current != NULL) + { + api.freeMemory(current); + } + if (alreadyOn) + { + WifiLogLine(log, slot, "interface \"%s\" streaming=on (already)", description); + continue; + } + if (WifiNeedsReopen(queryResult)) + { + WifiLogLine(log, slot, "interface \"%s\" query failed, result=%lu", description, queryResult); + fatal = queryResult; + break; + } + + BOOL on = TRUE; + const DWORD setResult = api.set(client, &info.guid, WIFI_OPCODE_MEDIA_STREAMING, sizeof(on), &on, NULL); + if (setResult == ERROR_SUCCESS) + { + WifiLogLine(log, slot, "interface \"%s\" streaming=on", description); + } + else + { + WifiLogLine(log, slot, "interface \"%s\" streaming failed, result=%lu", description, setResult); + if (WifiNeedsReopen(setResult)) + { + fatal = setResult; + } + } + } + if (tuned == 0) + { + WifiLogLine(log, WIFI_LOG_GENERAL, "no connected Wi-Fi interface on the game route, nothing to do"); + } + api.freeMemory(list); + return fatal; +} + +static void WifiCloseClient(const WifiApi& api, HANDLE client, bool registered) +{ + if (registered) + { + api.registerNotification(client, WIFI_SOURCE_NONE, TRUE, NULL, NULL, NULL, NULL); + } + api.close(client, NULL); +} + +static DWORD WINAPI WifiWorker(LPVOID param) +{ + WifiState* state = (WifiState*)param; + WifiLog* log = new WifiLog(); + memset(log, 0, sizeof(*log)); + log->state = state; + + HMODULE dll = LoadLibraryExW(L"wlanapi.dll", NULL, LOAD_LIBRARY_SEARCH_SYSTEM32); + WifiApi api = {}; + if (dll != NULL) + { + api.open = (WifiOpenHandleFn)GetProcAddress(dll, "WlanOpenHandle"); + api.close = (WifiCloseHandleFn)GetProcAddress(dll, "WlanCloseHandle"); + api.enumInterfaces = (WifiEnumInterfacesFn)GetProcAddress(dll, "WlanEnumInterfaces"); + api.set = (WifiSetInterfaceFn)GetProcAddress(dll, "WlanSetInterface"); + api.query = (WifiQueryInterfaceFn)GetProcAddress(dll, "WlanQueryInterface"); + api.registerNotification = (WifiRegisterNotificationFn)GetProcAddress(dll, "WlanRegisterNotification"); + api.freeMemory = (WifiFreeMemoryFn)GetProcAddress(dll, "WlanFreeMemory"); + } + if (api.open == NULL || api.close == NULL || api.enumInterfaces == NULL || api.set == NULL || api.query == NULL || api.freeMemory == NULL) + { + WifiLogLine(*log, WIFI_LOG_GENERAL, "the WLAN API is not available (%lu), no Wi-Fi tuning", GetLastError()); + if (dll != NULL) + { + FreeLibrary(dll); + } + delete log; + WifiRelease(state); + InterlockedDecrement(&s_workers); + return 0; + } + + HANDLE client = NULL; + bool registered = false; + DWORD backoff = WIFI_BACKOFF_MIN_MS; + const HANDLE waits[2] = { state->stop, state->wake }; + for (;;) + { + bool retry = false; + GUID targets[WIFI_MAX_TARGETS]; + const int targetCount = WifiGameAdapters(targets); + if (targetCount == 0) + { + WifiLogLine(*log, WIFI_LOG_GENERAL, "the game traffic is not on Wi-Fi, nothing to do"); + } + else + { + if (client == NULL) + { + DWORD version = 0; + const DWORD openResult = api.open(WIFI_CLIENT_VERSION, NULL, &version, &client); + if (openResult != ERROR_SUCCESS) + { + client = NULL; + retry = true; + WifiLogLine(*log, WIFI_LOG_GENERAL, "opening the WLAN client failed, result=%lu", openResult); + } + else if (api.registerNotification != NULL) + { + const DWORD registerResult = api.registerNotification(client, WIFI_SOURCE_ACM, TRUE, WifiNotify, state, NULL, NULL); + registered = registerResult == ERROR_SUCCESS; + if (!registered) + { + WifiLogLine(*log, WIFI_LOG_NOTIFY, "notifications failed, result=%lu, polling only", registerResult); + } + } + } + if (client != NULL) + { + const DWORD tuneResult = WifiTune(api, client, targets, targetCount, *log); + if (WifiNeedsReopen(tuneResult)) + { + WifiCloseClient(api, client, registered); + client = NULL; + registered = false; + retry = true; + } + } + } + + if (retry) + { + if (WaitForSingleObject(state->stop, backoff) == WAIT_OBJECT_0) + { + break; + } + backoff = (backoff * 2 < WIFI_BACKOFF_MAX_MS) ? backoff * 2 : WIFI_BACKOFF_MAX_MS; + continue; + } + backoff = WIFI_BACKOFF_MIN_MS; + + const DWORD waitResult = WaitForMultipleObjects(2, waits, FALSE, WIFI_POLL_MS); + if (waitResult == WAIT_OBJECT_0) + { + break; + } + if (waitResult == WAIT_OBJECT_0 + 1 && WaitForSingleObject(state->stop, WIFI_SETTLE_MS) == WAIT_OBJECT_0) + { + break; + } + } + + // closing the handle withdraws our request, other clients keep theirs + if (client != NULL) + { + WifiCloseClient(api, client, registered); + } + FreeLibrary(dll); + WifiLogLine(*log, WIFI_LOG_GENERAL, "released the Wi-Fi tuning"); + delete log; + WifiRelease(state); + InterlockedDecrement(&s_workers); + return 0; +} + +void WifiLowLatency::Begin() +{ + if (s_state != NULL) + { + return; + } + if (s_workers != 0) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[WIFI] the previous worker is still ending, no Wi-Fi tuning for this match"); + return; + } + WifiState* state = new WifiState(); + state->stop = CreateEventA(NULL, TRUE, FALSE, NULL); + state->wake = CreateEventA(NULL, FALSE, FALSE, NULL); + state->refs = 2; + state->detached = 0; + if (state->stop == NULL || state->wake == NULL) + { + if (state->stop != NULL) + { + CloseHandle(state->stop); + } + if (state->wake != NULL) + { + CloseHandle(state->wake); + } + delete state; + return; + } + + InterlockedIncrement(&s_workers); + s_thread = CreateThread(NULL, 0, WifiWorker, state, 0, NULL); + if (s_thread == NULL) + { + InterlockedDecrement(&s_workers); + CloseHandle(state->stop); + CloseHandle(state->wake); + delete state; + NetworkLog(ELogVerbosity::LOG_RELEASE, "[WIFI] starting the worker failed (%lu)", GetLastError()); + return; + } + s_state = state; + // nothing here is time critical, the game threads come first + SetThreadPriority(s_thread, THREAD_PRIORITY_LOWEST); +} + +void WifiLowLatency::End() +{ + if (s_state == NULL) + { + return; + } + SetEvent(s_state->stop); + if (WaitForSingleObject(s_thread, WIFI_END_WAIT_MS) != WAIT_OBJECT_0) + { + // the worker is stuck in the WLAN service, it cleans up after itself once the call returns + InterlockedExchange(&s_state->detached, 1); + NetworkLog(ELogVerbosity::LOG_RELEASE, "[WIFI] the worker did not stop within %dms, it ends on its own", (int)WIFI_END_WAIT_MS); + } + CloseHandle(s_thread); + WifiRelease(s_state); + s_thread = NULL; + s_state = NULL; +}