diff --git a/CMakeLists.txt b/CMakeLists.txt index b3e53406f..b7df6cf21 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -540,6 +540,7 @@ set(${PROJECT_NAME}_SRCS objects/dialogs/filedialog.cpp objects/dialogs/filedialog.h objects/dialogs/dialogcostyle.cpp objects/dialogs/dialogcostyle.h objects/dialogs/dialogmessagebox.cpp objects/dialogs/dialogmessagebox.h + objects/dialogs/dialogmodsyncprogress.cpp objects/dialogs/dialogmodsyncprogress.h objects/dialogs/dialogtextinput.cpp objects/dialogs/dialogtextinput.h objects/dialogs/folderdialog.cpp objects/dialogs/folderdialog.h objects/dialogs/dialogvaluecounter.cpp objects/dialogs/dialogvaluecounter.h diff --git a/coreengine/commandlineparser.cpp b/coreengine/commandlineparser.cpp index efbec0dbc..4a3c4d3a4 100644 --- a/coreengine/commandlineparser.cpp +++ b/coreengine/commandlineparser.cpp @@ -32,6 +32,7 @@ const char* const CommandLineParser::ARG_AISLAVE = "aiSlave"; const char* const CommandLineParser::ARG_USERPATH = "userPath"; const char* const CommandLineParser::ARG_DEBUGLEVEL = "debugLevel"; const char* const CommandLineParser::ARG_SLAVETRAINING = "slaveTraining"; +const char* const CommandLineParser::ARG_REJOINPASSWORD = "rejoin-password"; // options required for hosting a dedicated server const char* const CommandLineParser::ARG_SERVER = "server"; @@ -88,7 +89,8 @@ CommandLineParser::CommandLineParser() m_mailServerSendAddress(ARG_MAILSERVERSENDADDRESS, tr("E-Mail address used on the mail server for the server for sending mails to accounts."), tr("address"), ""), m_mailServerAuthMethod(ARG_MAILSERVERAUTHMETHOD, tr("Mail server authentication type (Plain, Login) for the server for sending mails to accounts."), tr("method"), ""), m_serverSaveFile(ARG_SERVERSAVEFILE, tr("Path to the server game save file"), tr("path"), ""), - m_slaveTraining(ARG_SLAVETRAINING, tr("mode for starting an ai training session.")) + m_slaveTraining(ARG_SLAVETRAINING, tr("mode for starting an ai training session.")), + m_rejoinPassword(ARG_REJOINPASSWORD, tr("Password used by the auto-rejoin path after a mod-sync restart. Internal; not for manual use."), tr("password"), "") { Interpreter::setCppOwnerShip(this); m_parser.setApplicationDescription("Commander Wars game"); @@ -130,6 +132,7 @@ CommandLineParser::CommandLineParser() m_parser.addOption(m_mailServerAuthMethod); m_parser.addOption(m_serverSaveFile); m_parser.addOption(m_slaveTraining); + m_parser.addOption(m_rejoinPassword); } void CommandLineParser::parseArgsPhaseOne(QCoreApplication & app) @@ -174,6 +177,10 @@ void CommandLineParser::parseArgsPhaseOne(QCoreApplication & app) QString value = m_parser.value(m_update); Settings::getInstance()->setUpdateStep(value); } + if (m_parser.isSet(m_rejoinPassword)) + { + Mainapp::setRejoinPassword(m_parser.value(m_rejoinPassword)); + } } bool CommandLineParser::getUserPath(QString & path) diff --git a/coreengine/commandlineparser.h b/coreengine/commandlineparser.h index 1d010a945..606aa8d15 100644 --- a/coreengine/commandlineparser.h +++ b/coreengine/commandlineparser.h @@ -26,6 +26,7 @@ class CommandLineParser final : public QObject static const char* const ARG_USERPATH; static const char* const ARG_DEBUGLEVEL; static const char* const ARG_SLAVETRAINING; + static const char* const ARG_REJOINPASSWORD; static const char* const ARG_SERVER; static const char* const ARG_SERVERSLAVEHOSTOPTIONS; @@ -93,6 +94,7 @@ class CommandLineParser final : public QObject QCommandLineOption m_mailServerAuthMethod; QCommandLineOption m_serverSaveFile; QCommandLineOption m_slaveTraining; + QCommandLineOption m_rejoinPassword; QCommandLineParser m_parser; }; diff --git a/coreengine/filesupport.cpp b/coreengine/filesupport.cpp index 46da59349..c4048a286 100644 --- a/coreengine/filesupport.cpp +++ b/coreengine/filesupport.cpp @@ -4,6 +4,15 @@ #include #include +#include +#include +#include +#include +#include +#include +#include +#include +#include const char* const Filesupport::LIST_FILENAME_ENDING = ".bl"; @@ -54,7 +63,7 @@ void Filesupport::addHash(QCryptographicHash & hash, const QString & folder, con } } -QByteArray Filesupport::getRuntimeHash(const QStringList & mods) +QByteArray Filesupport::getLegacyRuntimeHash(const QStringList & mods) { QStringList folders = mods; folders.append("resources/scripts"); @@ -63,6 +72,97 @@ QByteArray Filesupport::getRuntimeHash(const QStringList & mods) return getHash(filter, folders); } +QByteArray Filesupport::hashSingleFolder(const QString & folder, const QStringList & filter) +{ + return getHash(filter, QStringList{folder}); +} + +QMap Filesupport::getPerModHashes(const QStringList & mods) +{ + const QStringList filter = {"*.js", "*.csv"}; + QMap result; + for (const auto & mod : std::as_const(mods)) + { + result.insert(mod, hashSingleFolder(mod, filter)); + } + return result; +} + +QMap Filesupport::getResourceFolderHashes() +{ + const QStringList filter = {"*.js", "*.csv"}; + QMap result; + result.insert(QStringLiteral("resources/scripts"), hashSingleFolder(QStringLiteral("resources/scripts"), filter)); + result.insert(QStringLiteral("resources/aidata"), hashSingleFolder(QStringLiteral("resources/aidata"), filter)); + return result; +} + +bool Filesupport::validateModPath(const QString & modPath, qint32 maxLen) +{ + if (modPath.isEmpty() || modPath.size() > maxLen) + { + return false; + } + if (!modPath.startsWith(QStringLiteral("mods/"))) + { + return false; + } + if (modPath.contains(QChar('\\'))) + { + return false; + } + const QStringList segments = modPath.split(QChar('/')); + if (segments.size() != 2) + { + return false; + } + static const QSet kInvalidChars = { + QChar('<'), QChar('>'), QChar(':'), QChar('"'), + QChar('|'), QChar('?'), QChar('*'), + }; + for (const auto & segment : segments) + { + if (segment.isEmpty() || segment == QStringLiteral(".") || segment == QStringLiteral("..")) + { + return false; + } + // Windows strips trailing dots and spaces at create time; rejecting them avoids name collisions. + if (segment.endsWith(QChar('.')) || segment.endsWith(QChar(' '))) + { + return false; + } + for (const QChar c : segment) + { + if (c.unicode() < 0x20 || c.unicode() == 0x7F) + { + return false; + } + if (kInvalidChars.contains(c)) + { + return false; + } + } + } + static const QSet kReservedNames = { + QStringLiteral("CON"), QStringLiteral("PRN"), QStringLiteral("AUX"), QStringLiteral("NUL"), + QStringLiteral("CONIN$"), QStringLiteral("CONOUT$"), + QStringLiteral("COM0"), QStringLiteral("COM1"), QStringLiteral("COM2"), QStringLiteral("COM3"), + QStringLiteral("COM4"), QStringLiteral("COM5"), QStringLiteral("COM6"), QStringLiteral("COM7"), + QStringLiteral("COM8"), QStringLiteral("COM9"), + QStringLiteral("LPT0"), QStringLiteral("LPT1"), QStringLiteral("LPT2"), QStringLiteral("LPT3"), + QStringLiteral("LPT4"), QStringLiteral("LPT5"), QStringLiteral("LPT6"), QStringLiteral("LPT7"), + QStringLiteral("LPT8"), QStringLiteral("LPT9"), + }; + const QString & nameSegment = segments[1]; + const qint32 dotIdx = nameSegment.indexOf(QChar('.')); + const QString basename = (dotIdx >= 0) ? nameSegment.left(dotIdx) : nameSegment; + if (kReservedNames.contains(basename.toUpper())) + { + return false; + } + return true; +} + void Filesupport::writeByteArray(QDataStream& stream, const QByteArray& array) { stream << static_cast(array.size()); @@ -140,3 +240,764 @@ Filesupport::StringList Filesupport::readList(const QString & file) } return ret; } + +namespace +{ + constexpr qint32 kModSyncDisabled = 1; + constexpr qint32 kModSyncUnknownMod = 2; + constexpr qint32 kModSyncSizeCapExceeded = 3; + constexpr qint32 kModSyncFileCountCapExceeded = 4; + constexpr qint32 kModSyncInvalidPath = 5; + constexpr qint32 kModSyncInternalError = 6; + + bool segmentClean(const QString & seg) + { + static const QSet kInvalid = { + QChar('<'), QChar('>'), QChar(':'), QChar('"'), + QChar('|'), QChar('?'), QChar('*'), + }; + if (seg.isEmpty() || seg == QStringLiteral(".") || seg == QStringLiteral("..")) + { + return false; + } + if (seg.endsWith(QChar('.')) || seg.endsWith(QChar(' '))) + { + return false; + } + for (const QChar c : seg) + { + if (c.unicode() < 0x20 || c.unicode() == 0x7F) + { + return false; + } + if (kInvalid.contains(c)) + { + return false; + } + } + static const QSet kReserved = { + QStringLiteral("CON"), QStringLiteral("PRN"), QStringLiteral("AUX"), QStringLiteral("NUL"), + QStringLiteral("CONIN$"), QStringLiteral("CONOUT$"), + QStringLiteral("COM0"), QStringLiteral("COM1"), QStringLiteral("COM2"), QStringLiteral("COM3"), + QStringLiteral("COM4"), QStringLiteral("COM5"), QStringLiteral("COM6"), QStringLiteral("COM7"), + QStringLiteral("COM8"), QStringLiteral("COM9"), + QStringLiteral("LPT0"), QStringLiteral("LPT1"), QStringLiteral("LPT2"), QStringLiteral("LPT3"), + QStringLiteral("LPT4"), QStringLiteral("LPT5"), QStringLiteral("LPT6"), QStringLiteral("LPT7"), + QStringLiteral("LPT8"), QStringLiteral("LPT9"), + }; + const qint32 dotIdx = seg.indexOf(QChar('.')); + const QString basename = (dotIdx >= 0) ? seg.left(dotIdx) : seg; + if (kReserved.contains(basename.toUpper())) + { + return false; + } + return true; + } + + QString joinPath(const QString & a, const QString & b) + { + if (a.isEmpty()) + { + return b; + } + if (a.endsWith(QChar('/'))) + { + return a + b; + } + return a + QChar('/') + b; + } +} + +bool Filesupport::validateRelativeFilePath(const QString & relPath, qint32 maxLen) +{ + if (relPath.isEmpty() || relPath.size() > maxLen) + { + return false; + } + if (relPath.startsWith(QChar('/')) || relPath.contains(QChar('\\'))) + { + return false; + } + if (relPath.size() >= 2 && relPath[1] == QChar(':')) + { + return false; + } + const QStringList segments = relPath.split(QChar('/')); + for (const auto & seg : segments) + { + if (!segmentClean(seg)) + { + return false; + } + } + return true; +} + +Filesupport::ModSyncPackage Filesupport::buildModSyncPackage(const QString & installRoot, const QString & modPath, const ModSyncCaps & caps) +{ + ModSyncPackage pkg; + if (!validateModPath(modPath)) + { + pkg.rejectReason = kModSyncInvalidPath; + return pkg; + } + const QString modRoot = joinPath(installRoot, modPath); + QDir modDir(modRoot); + if (!modDir.exists()) + { + pkg.rejectReason = kModSyncUnknownMod; + return pkg; + } + // Resolve to an absolute root once; QDir::relativeFilePath short-circuits to verbatim when either operand is relative. + const QDir absModDir(modDir.absolutePath()); + QMap files; + qint64 uncompressedTotal = 0; + qint32 fileCount = 0; + QDirIterator it(modRoot, QDir::Files | QDir::NoSymLinks, QDirIterator::Subdirectories); + while (it.hasNext()) + { + const QString absolute = QFileInfo(it.next()).absoluteFilePath(); + const QString rel = absModDir.relativeFilePath(absolute); + const QStringList relSegs = rel.split(QChar('/')); + const QString basename = relSegs.isEmpty() ? rel : relSegs.last(); + // Segment-based filter; substrings would false-positive legit filenames containing .bak- or .sync-staging-. + bool cruft = false; + for (qint32 si = 0; si < relSegs.size(); ++si) + { + const QString & seg = relSegs[si]; + if (seg == QStringLiteral(".git") || seg == QStringLiteral(".svn") || seg == QStringLiteral("__pycache__")) + { + cruft = true; + break; + } + const bool isDir = (si + 1 < relSegs.size()); + if (isDir && (seg.startsWith(QStringLiteral(".sync-staging-")) || seg.startsWith(QStringLiteral(".bak-")))) + { + cruft = true; + break; + } + } + if (cruft) + { + continue; + } + if (basename == QStringLiteral(".DS_Store") || basename == QStringLiteral("Thumbs.db") || basename == QStringLiteral("desktop.ini")) + { + continue; + } + if (!validateRelativeFilePath(rel, caps.relPathMaxLen)) + { + pkg.rejectReason = kModSyncInvalidPath; + return pkg; + } + QFile f(absolute); + if (!f.open(QIODevice::ReadOnly)) + { + pkg.rejectReason = kModSyncInternalError; + return pkg; + } + const qint64 size = f.size(); + if (size > caps.perModBytes) + { + pkg.rejectReason = kModSyncSizeCapExceeded; + return pkg; + } + uncompressedTotal += size; + if (uncompressedTotal > caps.perModBytes) + { + pkg.rejectReason = kModSyncSizeCapExceeded; + return pkg; + } + ++fileCount; + if (fileCount > caps.fileCountMax) + { + pkg.rejectReason = kModSyncFileCountCapExceeded; + return pkg; + } + files.insert(rel, f.readAll()); + f.close(); + } + QByteArray serialized; + { + QDataStream stream(&serialized, QIODevice::WriteOnly); + stream.setVersion(QDataStream::Version::Qt_6_5); + // Custom format: keys as UTF-8 QByteArrays so the receiver can run a length-bounded reader. + stream << static_cast(files.size()); + for (auto iter = files.constBegin(); iter != files.constEnd(); ++iter) + { + stream << iter.key().toUtf8(); + stream << iter.value(); + } + } + if (serialized.size() > caps.perModBytes || serialized.size() > std::numeric_limits::max()) + { + pkg.rejectReason = kModSyncSizeCapExceeded; + return pkg; + } + pkg.declaredUncompressedSize = static_cast(serialized.size()); + // Level 1 = zlib best-speed; ~few percent worse ratio on text-heavy mods, big drop in host CPU stall. + pkg.compressedBlob = qCompress(serialized, 1); + pkg.fileCount = fileCount; + if (pkg.compressedBlob.size() > caps.perModBytes) + { + pkg.rejectReason = kModSyncSizeCapExceeded; + pkg.compressedBlob.clear(); + return pkg; + } + return pkg; +} + +QMap Filesupport::extractModSyncPackage(const QByteArray & compressedBlob, qint32 declaredUncompressedSize, const ModSyncCaps & caps, qint32 & rejectReason) +{ + rejectReason = 0; + QMap files; + if (caps.perModBytes < 0 || caps.fileCountMax < 0 || caps.relPathMaxLen <= 0) + { + rejectReason = kModSyncInternalError; + return files; + } + if (compressedBlob.size() > caps.perModBytes) + { + rejectReason = kModSyncSizeCapExceeded; + return files; + } + if (declaredUncompressedSize <= 0 || declaredUncompressedSize > caps.perModBytes) + { + rejectReason = kModSyncSizeCapExceeded; + return files; + } + // qCompress prefixes a 4-byte big-endian uncompressed size used for pre-allocation; gate it before qUncompress to bound memory. + if (compressedBlob.size() < 4) + { + rejectReason = kModSyncInternalError; + return files; + } + const quint32 framingSize = qFromBigEndian(reinterpret_cast(compressedBlob.constData())); + if (framingSize > static_cast(caps.perModBytes) || static_cast(framingSize) != declaredUncompressedSize) + { + rejectReason = kModSyncSizeCapExceeded; + return files; + } + const QByteArray serialized = qUncompress(compressedBlob); + if (serialized.isEmpty() || serialized.size() != declaredUncompressedSize) + { + rejectReason = kModSyncInternalError; + return files; + } + QDataStream stream(serialized); + stream.setVersion(QDataStream::Version::Qt_6_5); + qint32 mapSize = 0; + stream >> mapSize; + if (stream.status() != QDataStream::Ok || mapSize < 0 || mapSize > caps.fileCountMax) + { + rejectReason = kModSyncFileCountCapExceeded; + return QMap(); + } + // Validate length header before allocation; defends against decompression-bomb pre-allocation in operator>>. + auto readBoundedBytes = [&stream](QByteArray & out, qint64 maxBytes) -> bool + { + quint32 declared = 0; + stream >> declared; + if (stream.status() != QDataStream::Ok) + { + return false; + } + if (declared == 0xFFFFFFFFu) + { + out.clear(); + return true; + } + if (declared > static_cast(maxBytes)) + { + return false; + } + out.resize(static_cast(declared)); + if (out.size() > 0 && stream.readRawData(out.data(), out.size()) != out.size()) + { + return false; + } + return true; + }; + qint64 uncompressedTotal = 0; + for (qint32 i = 0; i < mapSize; ++i) + { + QByteArray keyUtf8; + QByteArray value; + // UTF-8 max 4 bytes per code point; cap key bytes at relPathMaxLen * 4. + if (!readBoundedBytes(keyUtf8, static_cast(caps.relPathMaxLen) * 4)) + { + rejectReason = kModSyncInvalidPath; + return QMap(); + } + if (!readBoundedBytes(value, caps.perModBytes)) + { + rejectReason = kModSyncSizeCapExceeded; + return QMap(); + } + const QString key = QString::fromUtf8(keyUtf8); + if (!validateRelativeFilePath(key, caps.relPathMaxLen)) + { + rejectReason = kModSyncInvalidPath; + return QMap(); + } + uncompressedTotal += value.size(); + if (uncompressedTotal > caps.perModBytes) + { + rejectReason = kModSyncSizeCapExceeded; + return QMap(); + } + if (files.contains(key)) + { + rejectReason = kModSyncInvalidPath; + return QMap(); + } + files.insert(key, value); + } + return files; +} + +QString Filesupport::stageModSync(const QString & installRoot, const QString & modPath, const QMap & files, const ModSyncCaps & caps, qint32 & rejectReason) +{ + rejectReason = 0; + if (!validateModPath(modPath)) + { + rejectReason = kModSyncInvalidPath; + return QString(); + } + if (files.size() > caps.fileCountMax) + { + rejectReason = kModSyncFileCountCapExceeded; + return QString(); + } + qint64 totalBytes = 0; + for (auto iter = files.constBegin(); iter != files.constEnd(); ++iter) + { + if (!validateRelativeFilePath(iter.key(), caps.relPathMaxLen)) + { + rejectReason = kModSyncInvalidPath; + return QString(); + } + if (iter.value().size() > caps.perModBytes) + { + rejectReason = kModSyncSizeCapExceeded; + return QString(); + } + totalBytes += iter.value().size(); + if (totalBytes > caps.perModBytes) + { + rejectReason = kModSyncSizeCapExceeded; + return QString(); + } + } + const qint64 pid = QCoreApplication::applicationPid(); + const QString stagingRel = modPath + QStringLiteral(".sync-staging-") + QString::number(pid); + const QString stagingAbs = joinPath(installRoot, stagingRel); + QDir stagingDir(stagingAbs); + if (stagingDir.exists() && !stagingDir.removeRecursively()) + { + rejectReason = kModSyncInternalError; + return QString(); + } + if (!QDir().mkpath(stagingAbs)) + { + rejectReason = kModSyncInternalError; + return QString(); + } + for (auto iter = files.constBegin(); iter != files.constEnd(); ++iter) + { + const QString full = joinPath(stagingAbs, iter.key()); + const QFileInfo fi(full); + if (!QDir().mkpath(fi.absolutePath())) + { + QDir(stagingAbs).removeRecursively(); + rejectReason = kModSyncInternalError; + return QString(); + } + QSaveFile f(full); + if (!f.open(QIODevice::WriteOnly)) + { + QDir(stagingAbs).removeRecursively(); + rejectReason = kModSyncInternalError; + return QString(); + } + f.write(iter.value()); + if (!f.commit()) + { + QDir(stagingAbs).removeRecursively(); + rejectReason = kModSyncInternalError; + return QString(); + } + } + return stagingRel; +} + +void Filesupport::reapModSyncFolders(const QString & installRoot, qint32 backupKeep) +{ + const QString modsRoot = joinPath(installRoot, QStringLiteral("mods")); + QDir modsDir(modsRoot); + if (!modsDir.exists()) + { + return; + } + const auto entries = modsDir.entryInfoList(QDir::Dirs | QDir::NoDotAndDotDot); + QMap> backupsByMod; + const QDateTime cutoff = QDateTime::currentDateTime().addSecs(-3600); + // Match only exact slice-2-generated shapes; substring matching would catch legitimate mod folder names. + auto matchStagingShape = [](const QString & name, QString & outPrefix) -> bool + { + const qint32 idx = name.lastIndexOf(QStringLiteral(".sync-staging-")); + if (idx <= 0) + { + return false; + } + const QString prefix = name.left(idx); + const QString suffix = name.mid(idx + QStringLiteral(".sync-staging-").size()); + if (suffix.isEmpty() || !validateModPath(QStringLiteral("mods/") + prefix)) + { + return false; + } + for (const QChar c : suffix) + { + if (c < QChar('0') || c > QChar('9')) + { + return false; + } + } + outPrefix = prefix; + return true; + }; + auto matchBackupShape = [](const QString & name, QString & outPrefix) -> bool + { + const qint32 idx = name.lastIndexOf(QStringLiteral(".bak-")); + if (idx <= 0) + { + return false; + } + const QString prefix = name.left(idx); + const QString suffix = name.mid(idx + QStringLiteral(".bak-").size()); + if (!validateModPath(QStringLiteral("mods/") + prefix)) + { + return false; + } + // Generated suffix is exactly yyyyMMdd-HHmmsszzzZ (19 chars) with optional - collision counter. + if (suffix.size() < 19) + { + return false; + } + auto isDigit = [](QChar c) { return c >= QChar('0') && c <= QChar('9'); }; + for (qint32 i = 0; i < 8; ++i) + { + if (!isDigit(suffix[i])) return false; + } + if (suffix[8] != QChar('-')) return false; + for (qint32 i = 9; i < 18; ++i) + { + if (!isDigit(suffix[i])) return false; + } + if (suffix[18] != QChar('Z')) return false; + if (suffix.size() > 19) + { + // Collision-counter form requires `-` after the timestamp; bare `-` is invalid. + if (suffix[19] != QChar('-') || suffix.size() < 21) return false; + for (qint32 i = 20; i < suffix.size(); ++i) + { + if (!isDigit(suffix[i])) return false; + } + } + outPrefix = prefix; + return true; + }; + for (const auto & entry : entries) + { + const QString name = entry.fileName(); + QString prefix; + if (matchStagingShape(name, prefix)) + { + // Mtime fallback heuristic; cheap, no platform-specific PID liveness check. + if (entry.lastModified() < cutoff) + { + CONSOLE_PRINT("Reaping stale staging dir: " + entry.absoluteFilePath(), GameConsole::eINFO); + QDir(entry.absoluteFilePath()).removeRecursively(); + } + continue; + } + if (matchBackupShape(name, prefix)) + { + backupsByMod[prefix].append(entry); + } + } + for (auto iter = backupsByMod.begin(); iter != backupsByMod.end(); ++iter) + { + auto & list = iter.value(); + if (list.size() <= backupKeep) + { + continue; + } + std::sort(list.begin(), list.end(), [](const QFileInfo & a, const QFileInfo & b) + { + return a.lastModified() > b.lastModified(); + }); + for (qint32 i = backupKeep; i < list.size(); ++i) + { + CONSOLE_PRINT("Pruning old mod-sync backup: " + list[i].absoluteFilePath(), GameConsole::eINFO); + QDir(list[i].absoluteFilePath()).removeRecursively(); + } + } +} + +QString Filesupport::pendingModSyncManifestPath(const QString & userDataPath) +{ + return joinPath(userDataPath, QStringLiteral(".pending-mod-sync.json")); +} + +bool Filesupport::writePendingModSyncManifest(const QString & userDataPath, const QList> & swaps) +{ + QJsonArray jsonSwaps; + for (const auto & pair : swaps) + { + QJsonObject entry; + entry.insert(QStringLiteral("staging"), pair.first); + entry.insert(QStringLiteral("final"), pair.second); + jsonSwaps.append(entry); + } + QJsonObject root; + root.insert(QStringLiteral("version"), 1); + root.insert(QStringLiteral("swaps"), jsonSwaps); + const QString path = pendingModSyncManifestPath(userDataPath); + QSaveFile f(path); + if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate)) + { + CONSOLE_PRINT("Failed to open pending mod-sync manifest for write: " + path, GameConsole::eERROR); + return false; + } + f.write(QJsonDocument(root).toJson(QJsonDocument::Compact)); + if (!f.commit()) + { + CONSOLE_PRINT("Failed to commit pending mod-sync manifest: " + path, GameConsole::eERROR); + return false; + } + return true; +} + +QStringList Filesupport::executePendingModSyncManifest(const QString & installRoot, const QString & userDataPath) +{ + QStringList applied; + const QString path = pendingModSyncManifestPath(userDataPath); + QFile f(path); + if (!f.exists()) + { + return applied; + } + // Oversize manifest is treated as corrupt or tampered: discard rather than try to parse partial state. + constexpr qint64 kMaxManifestBytes = 1 * 1024 * 1024; + if (f.size() > kMaxManifestBytes) + { + CONSOLE_PRINT("Pending mod-sync manifest oversize, discarding: " + path, GameConsole::eERROR); + QFile::remove(path); + return applied; + } + if (!f.open(QIODevice::ReadOnly)) + { + CONSOLE_PRINT("Pending mod-sync manifest exists but cannot be read: " + path, GameConsole::eERROR); + return applied; + } + const QByteArray data = f.readAll(); + f.close(); + QJsonParseError parseErr; + const QJsonDocument doc = QJsonDocument::fromJson(data, &parseErr); + if (parseErr.error != QJsonParseError::NoError || !doc.isObject()) + { + CONSOLE_PRINT("Pending mod-sync manifest is invalid JSON: " + parseErr.errorString(), GameConsole::eERROR); + QFile::remove(path); + return applied; + } + const QJsonObject root = doc.object(); + if (root.value(QStringLiteral("version")).toInt(0) != 1) + { + CONSOLE_PRINT("Pending mod-sync manifest has unknown version, discarding", GameConsole::eERROR); + QFile::remove(path); + return applied; + } + const QJsonArray swaps = root.value(QStringLiteral("swaps")).toArray(); + // UTC + ms keeps backup names lexically sorted across DST and avoids same-second collision on rapid retries. + const QString isoStamp = QDateTime::currentDateTimeUtc().toString(QStringLiteral("yyyyMMdd-HHmmsszzzZ")); + // Read the flag once before the swap loop so settings cannot drift mid-batch. Must be called after Settings::loadSettings has run its binding loop; today the boot order at settings.cpp:1557 satisfies that. + const bool keepBackups = Settings::getInstance()->getModSyncKeepBackups(); + for (const auto & v : swaps) + { + const QJsonObject entry = v.toObject(); + const QString stagingRel = entry.value(QStringLiteral("staging")).toString(); + const QString finalRel = entry.value(QStringLiteral("final")).toString(); + if (!validateModPath(finalRel)) + { + CONSOLE_PRINT("Manifest entry has invalid final path, skipping: " + finalRel, GameConsole::eERROR); + continue; + } + // Pin staging to the exact shape stageModSync writes: .sync-staging-. + const QString stagingPrefix = finalRel + QStringLiteral(".sync-staging-"); + if (!stagingRel.startsWith(stagingPrefix)) + { + CONSOLE_PRINT("Manifest staging path shape mismatch, skipping: " + stagingRel, GameConsole::eERROR); + continue; + } + const QString suffix = stagingRel.mid(stagingPrefix.size()); + bool suffixDigits = !suffix.isEmpty(); + for (const QChar c : suffix) + { + if (c < QChar('0') || c > QChar('9')) + { + suffixDigits = false; + break; + } + } + if (!suffixDigits) + { + CONSOLE_PRINT("Manifest staging suffix not numeric pid, skipping: " + stagingRel, GameConsole::eERROR); + continue; + } + const QString stagingAbs = joinPath(installRoot, stagingRel); + const QString finalAbs = joinPath(installRoot, finalRel); + const QFileInfo stagingInfo(stagingAbs); + if (!stagingInfo.exists()) + { + CONSOLE_PRINT("Manifest staging missing, skipping: " + stagingAbs, GameConsole::eERROR); + continue; + } + // Defends against a tampered manifest pointing the staging slot at a regular file. + if (!stagingInfo.isDir()) + { + CONSOLE_PRINT("Manifest staging path is not a directory, skipping: " + stagingAbs, GameConsole::eERROR); + continue; + } + QString backupAbs; + const QFileInfo finalInfo(finalAbs); + const bool finalExisted = finalInfo.exists(); + if (finalExisted) + { + // Symmetric guard with stagingInfo.isDir(); skip the swap if a regular file occupies the mod slot. + if (!finalInfo.isDir()) + { + CONSOLE_PRINT("Manifest final path is not a directory, skipping: " + finalAbs, GameConsole::eERROR); + continue; + } + // Loop suffix defends against duplicate manifest entries for the same finalRel sharing one isoStamp. + backupAbs = finalAbs + QStringLiteral(".bak-") + isoStamp; + for (qint32 n = 1; QFileInfo::exists(backupAbs); ++n) + { + backupAbs = finalAbs + QStringLiteral(".bak-") + isoStamp + QStringLiteral("-") + QString::number(n); + } + if (!QDir().rename(finalAbs, backupAbs)) + { + CONSOLE_PRINT("Failed to back up existing mod folder: " + finalAbs + " -> " + backupAbs, GameConsole::eERROR); + continue; + } + } + if (!QDir().rename(stagingAbs, finalAbs)) + { + CONSOLE_PRINT("Failed to swap staging into place: " + stagingAbs + " -> " + finalAbs, GameConsole::eERROR); + // Roll the backup back so the active mod folder is not left missing. + if (finalExisted && !QDir().rename(backupAbs, finalAbs)) + { + CONSOLE_PRINT("CRITICAL: rollback also failed; mod folder is at " + backupAbs, GameConsole::eERROR); + } + continue; + } + CONSOLE_PRINT("Mod sync applied: " + finalRel, GameConsole::eINFO); + applied.append(finalRel); + // Backup retention is opt-in; remove the .bak directory now if disabled. Failure to remove just leaves the dir for the next reapModSyncFolders pass. + if (finalExisted && !keepBackups && !backupAbs.isEmpty()) + { + if (!QDir(backupAbs).removeRecursively()) + { + CONSOLE_PRINT("Failed to remove .bak after successful sync; reaper will clean up: " + backupAbs, GameConsole::eWARNING); + } + } + } + QFile::remove(path); + return applied; +} + +QString Filesupport::rejoinManifestPath(const QString & userDataPath) +{ + return joinPath(userDataPath, QStringLiteral(".rejoin.json")); +} + +bool Filesupport::writeRejoinManifest(const QString & userDataPath, const QString & host, quint16 port) +{ + if (host.isEmpty() || port == 0) + { + return false; + } + QJsonObject root; + root.insert(QStringLiteral("version"), 1); + root.insert(QStringLiteral("host"), host); + root.insert(QStringLiteral("port"), static_cast(port)); + root.insert(QStringLiteral("timestamp"), QDateTime::currentSecsSinceEpoch()); + const QString path = rejoinManifestPath(userDataPath); + QSaveFile f(path); + if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate)) + { + CONSOLE_PRINT("Failed to open rejoin manifest for write: " + path, GameConsole::eERROR); + return false; + } + f.write(QJsonDocument(root).toJson(QJsonDocument::Compact)); + if (!f.commit()) + { + CONSOLE_PRINT("Failed to commit rejoin manifest: " + path, GameConsole::eERROR); + return false; + } + return true; +} + +Filesupport::RejoinManifest Filesupport::consumeRejoinManifest(const QString & userDataPath) +{ + RejoinManifest result; + const QString path = rejoinManifestPath(userDataPath); + QFile f(path); + if (!f.exists()) + { + return result; + } + constexpr qint64 kMaxRejoinManifestBytes = 4 * 1024; + if (f.size() > kMaxRejoinManifestBytes) + { + CONSOLE_PRINT("Rejoin manifest oversize, discarding: " + path, GameConsole::eERROR); + QFile::remove(path); + return result; + } + if (!f.open(QIODevice::ReadOnly)) + { + CONSOLE_PRINT("Rejoin manifest exists but cannot be read: " + path, GameConsole::eERROR); + QFile::remove(path); + return result; + } + const QByteArray data = f.readAll(); + f.close(); + QFile::remove(path); + QJsonParseError parseErr; + const QJsonDocument doc = QJsonDocument::fromJson(data, &parseErr); + if (parseErr.error != QJsonParseError::NoError || !doc.isObject()) + { + CONSOLE_PRINT("Rejoin manifest invalid JSON: " + parseErr.errorString(), GameConsole::eERROR); + return result; + } + const QJsonObject root = doc.object(); + if (root.value(QStringLiteral("version")).toInt(0) != 1) + { + CONSOLE_PRINT("Rejoin manifest unknown version, discarding", GameConsole::eERROR); + return result; + } + const QString host = root.value(QStringLiteral("host")).toString(); + const qint32 port = root.value(QStringLiteral("port")).toInt(0); + const qint64 timestamp = root.value(QStringLiteral("timestamp")).toVariant().toLongLong(); + if (host.isEmpty() || port <= 0 || port > 0xFFFF) + { + CONSOLE_PRINT("Rejoin manifest has invalid host or port, discarding", GameConsole::eERROR); + return result; + } + result.valid = true; + result.host = host; + result.port = static_cast(port); + result.timestamp = timestamp; + return result; +} diff --git a/coreengine/filesupport.h b/coreengine/filesupport.h index eef65ad72..21b71b7da 100644 --- a/coreengine/filesupport.h +++ b/coreengine/filesupport.h @@ -5,6 +5,8 @@ #include #include #include +#include +#include class Filesupport final { @@ -14,28 +16,65 @@ class Filesupport final QString name; QStringList items; }; + struct ModSyncCaps + { + qint32 perModBytes{64 * 1024 * 1024}; + qint32 fileCountMax{5000}; + qint32 relPathMaxLen{260}; + }; + // rejectReason matches NetworkCommands::ModSyncRejectReason; qint32 keeps coreengine decoupled from multiplayer/. + struct ModSyncPackage + { + QByteArray compressedBlob; + qint32 declaredUncompressedSize{0}; + qint32 fileCount{0}; + qint32 rejectReason{0}; + }; static const char* const LIST_FILENAME_ENDING; + static constexpr qint32 LegacyRuntimeHashSize = 64; + // Old clients read this field as a QByteArray length, so versions must stay small and never collide with 64. + static constexpr qint32 LegacyHashPayloadVersion = 1; + static constexpr qint32 CurrentHashPayloadVersion = 2; + static_assert(CurrentHashPayloadVersion != LegacyRuntimeHashSize, "sentinel collision with legacy hash size"); + static_assert(CurrentHashPayloadVersion != LegacyHashPayloadVersion, "sentinel collision with legacy payload version"); + + // Bit 0 = slice-1 mod-sync wire format; future schema breakages claim new bits. + static constexpr quint32 CapabilityModSync = 0x00000001u; + + static constexpr qint32 ModPathDefaultMaxLen = 260; + Filesupport() = delete; ~Filesupport() = delete; - /** - * @brief getRuntimeHash - * @return - */ - static QByteArray getRuntimeHash(const QStringList & mods); - /** - * @brief getHash - * @param filter - * @param folders - * @return - */ + static QByteArray getLegacyRuntimeHash(const QStringList & mods); + static QMap getPerModHashes(const QStringList & mods); + static QMap getResourceFolderHashes(); + static QByteArray hashSingleFolder(const QString & folder, const QStringList & filter); static QByteArray getHash(const QStringList & filter, const QStringList & folders); - /** - * @brief addHash - * @param hash - * @param folder - * @param filter - */ static void addHash(QCryptographicHash & hash, const QString & folder, const QStringList & filter); + static bool validateModPath(const QString & modPath, qint32 maxLen = ModPathDefaultMaxLen); + static bool validateRelativeFilePath(const QString & relPath, qint32 maxLen); + static ModSyncPackage buildModSyncPackage(const QString & installRoot, const QString & modPath, const ModSyncCaps & caps); + static QMap extractModSyncPackage(const QByteArray & compressedBlob, qint32 declaredUncompressedSize, const ModSyncCaps & caps, qint32 & rejectReason); + // Returns staging path RELATIVE to installRoot on success (e.g. "mods/foo.sync-staging-12345"); the caller writes that into the manifest. + // `caps` re-validates relpath, file count, per-file and total bytes here as defense in depth; callers should still run extractModSyncPackage first. + static QString stageModSync(const QString & installRoot, const QString & modPath, const QMap & files, const ModSyncCaps & caps, qint32 & rejectReason); + static void reapModSyncFolders(const QString & installRoot, qint32 backupKeep = 3); + static QString pendingModSyncManifestPath(const QString & userDataPath); + static bool writePendingModSyncManifest(const QString & userDataPath, const QList> & swaps); + // Returns the list of `final` paths that were successfully swapped in; slice 3 uses this to drive settings mutation. + static QStringList executePendingModSyncManifest(const QString & installRoot, const QString & userDataPath); + + struct RejoinManifest + { + bool valid{false}; + QString host; + quint16 port{0}; + qint64 timestamp{0}; + }; + static QString rejoinManifestPath(const QString & userDataPath); + static bool writeRejoinManifest(const QString & userDataPath, const QString & host, quint16 port); + // Reads .rejoin.json, deletes the file, returns parsed contents. Caller decides freshness based on RejoinManifest::timestamp. + static RejoinManifest consumeRejoinManifest(const QString & userDataPath); /** * @brief writeByteArray * @param stream diff --git a/coreengine/mainapp.cpp b/coreengine/mainapp.cpp index 8f6bf2403..4daea4f57 100644 --- a/coreengine/mainapp.cpp +++ b/coreengine/mainapp.cpp @@ -60,6 +60,8 @@ Mainapp* Mainapp::m_pMainapp{nullptr}; bool Mainapp::m_slave{false}; bool Mainapp::m_trainingSession{false}; +QStringList Mainapp::m_restartArgv; +QString Mainapp::m_rejoinPassword; const char* const Mainapp::GAME_CONTEXT = "GAME"; Mainapp::Mainapp() @@ -741,6 +743,26 @@ void Mainapp::setSlave(bool slave) m_slave = slave; } +QStringList Mainapp::getRestartArgv() +{ + return m_restartArgv; +} + +void Mainapp::setRestartArgv(const QStringList & argv) +{ + m_restartArgv = argv; +} + +QString Mainapp::getRejoinPassword() +{ + return m_rejoinPassword; +} + +void Mainapp::setRejoinPassword(const QString & password) +{ + m_rejoinPassword = password; +} + void Mainapp::showCrashReport(const QString & log) { static qint32 counter = 0; diff --git a/coreengine/mainapp.h b/coreengine/mainapp.h index f58588ea8..10903b8a8 100644 --- a/coreengine/mainapp.h +++ b/coreengine/mainapp.h @@ -135,6 +135,11 @@ class Mainapp final : public oxygine::GameWindow * @param slave */ static void setSlave(bool slave); + // Argv passed to QProcess::startDetached when main() restarts after exit(1). Set this before exit(1) to thread args (e.g. --rejoin-password=...) into the child without persisting them to disk. + static QStringList getRestartArgv(); + static void setRestartArgv(const QStringList & argv); + static QString getRejoinPassword(); + static void setRejoinPassword(const QString & password); /** * @brief qsTr * @param text @@ -311,6 +316,8 @@ public slots: static Mainapp* m_pMainapp; static bool m_slave; static bool m_trainingSession; + static QStringList m_restartArgv; + static QString m_rejoinPassword; QMutex m_crashMutex; spQThread m_Workerthread; spQThread m_Networkthread; diff --git a/coreengine/settings.cpp b/coreengine/settings.cpp index a03eb226e..c655e476c 100644 --- a/coreengine/settings.cpp +++ b/coreengine/settings.cpp @@ -19,6 +19,7 @@ #include #include "coreengine/settings.h" +#include "coreengine/filesupport.h" #include "coreengine/mainapp.h" #include "coreengine/globalutils.h" #include "coreengine/userdata.h" @@ -260,6 +261,66 @@ void Settings::setServerPassword(const QString newServerPassword) m_serverPassword = newServerPassword; } +bool Settings::getModSyncEnabled() const +{ + return m_modSyncEnabled; +} + +void Settings::setModSyncEnabled(bool newModSyncEnabled) +{ + m_modSyncEnabled = newModSyncEnabled; +} + +qint32 Settings::getModSyncMaxPerModBytes() const +{ + return m_modSyncMaxPerModBytes; +} + +void Settings::setModSyncMaxPerModBytes(qint32 newValue) +{ + m_modSyncMaxPerModBytes = newValue; +} + +qint32 Settings::getModSyncMaxTotalBytes() const +{ + return m_modSyncMaxTotalBytes; +} + +void Settings::setModSyncMaxTotalBytes(qint32 newValue) +{ + m_modSyncMaxTotalBytes = newValue; +} + +qint32 Settings::getModSyncMaxFiles() const +{ + return m_modSyncMaxFiles; +} + +void Settings::setModSyncMaxFiles(qint32 newValue) +{ + m_modSyncMaxFiles = newValue; +} + +qint32 Settings::getModSyncMaxRelativePathLength() const +{ + return m_modSyncMaxRelativePathLength; +} + +void Settings::setModSyncMaxRelativePathLength(qint32 newValue) +{ + m_modSyncMaxRelativePathLength = newValue; +} + +bool Settings::getModSyncKeepBackups() const +{ + return m_modSyncKeepBackups; +} + +void Settings::setModSyncKeepBackups(bool newValue) +{ + m_modSyncKeepBackups = newValue; +} + QString Settings::getMailServerSendAddress() { return m_mailServerSendAddress; @@ -1041,9 +1102,46 @@ QStringList Settings::getActiveMods() return m_activeMods; } +QString Settings::stageActiveModsForRestart(const QStringList & activeMods) +{ + Mainapp* pApp = Mainapp::getInstance(); + if (pApp->getSlave() || Settings::getAiSlave() || !m_updateStep.isEmpty()) + { + return QString(); + } + QSettings settings(m_settingFile, QSettings::IniFormat); + settings.beginGroup("Mods"); + const QString prior = settings.value("Mods", QString()).toString(); + settings.setValue("Mods", getConfigString(activeMods)); + settings.endGroup(); + // Mirror the staged list into the in-memory active set so a saveSettings + // call before the manual restart cannot revert Mods/Mods to the stale list. + // Versions are cleared and rebuilt by setActiveMods on next boot. + m_activeMods = activeMods; + m_activeModVersions.clear(); + return prior; +} + +void Settings::restoreActiveModsRaw(const QString & rawValue) +{ + Mainapp* pApp = Mainapp::getInstance(); + if (pApp->getSlave() || Settings::getAiSlave() || !m_updateStep.isEmpty()) + { + return; + } + QSettings settings(m_settingFile, QSettings::IniFormat); + settings.beginGroup("Mods"); + settings.setValue("Mods", rawValue); + settings.endGroup(); + m_activeMods = rawValue.isEmpty() ? QStringList() : rawValue.split(QChar(',')); + m_activeModVersions.clear(); +} + void Settings::setActiveMods(const QStringList activeMods) { m_activeMods = activeMods; + // Rebuild versions so repeated setActiveMods calls stay aligned. + m_activeModVersions.clear(); qint32 i = 0; while (i < m_activeMods.size()) { @@ -1402,6 +1500,12 @@ void Settings::setup() MemoryManagement::create>("Network", "SlaveDespawnTime", &m_slaveDespawnTime, std::chrono::seconds(60 * 60 * 24), std::chrono::seconds(1), std::chrono::seconds(60 * 60 * 24 * 96)), MemoryManagement::create>("Network", "SuspendedDespawnTime", &m_suspendedDespawnTime, std::chrono::seconds(60 * 60 * 24), std::chrono::seconds(1), std::chrono::seconds(60 * 60 * 24 * 96)), MemoryManagement::create>("Network", "ReplayDeleteTime", &m_replayDeleteTime, std::chrono::seconds(60 * 60 * 24 * 7), std::chrono::seconds(1), std::chrono::seconds(60 * 60 * 24 * 96)), + MemoryManagement::create>("Network", "ModSyncEnabled", &m_modSyncEnabled, false, false, true), + MemoryManagement::create>("Network", "ModSyncMaxPerModBytes", &m_modSyncMaxPerModBytes, 64 * 1024 * 1024, 0, std::numeric_limits::max()), + MemoryManagement::create>("Network", "ModSyncMaxTotalBytes", &m_modSyncMaxTotalBytes, 256 * 1024 * 1024, 0, std::numeric_limits::max()), + MemoryManagement::create>("Network", "ModSyncMaxFiles", &m_modSyncMaxFiles, 5000, 0, std::numeric_limits::max()), + MemoryManagement::create>("Network", "ModSyncMaxRelativePathLength", &m_modSyncMaxRelativePathLength, 260, 1, std::numeric_limits::max()), + MemoryManagement::create>("Network", "ModSyncKeepBackups", &m_modSyncKeepBackups, false, false, true), // mailing MemoryManagement::create>("Mailing", "MailServerAddress", &m_mailServerAddress, "", "", ""), MemoryManagement::create>("Mailing", "MailServerPort", &m_mailServerPort, 0, 0, std::numeric_limits::max()), @@ -1449,6 +1553,10 @@ void Settings::loadSettings() } setUserPath(m_userPath); setFramesPerSecond(m_framesPerSecond); + // Apply any pending mod-sync swaps before setActiveMods, so just-synced folders are visible to its missing-folder pruning pass. + Filesupport::executePendingModSyncManifest(m_userPath, m_userPath); + // backupKeep follows ModSyncKeepBackups so a user who opted out reaps every leftover .bak (including ones that survived a prior post-swap removeRecursively failure). + Filesupport::reapModSyncFolders(m_userPath, m_modSyncKeepBackups ? 3 : 0); setActiveMods(m_activeMods); GameConsole::setLogLevel(m_defaultLogLevel); GameConsole::setActiveModules(m_defaultLogModuls); @@ -1494,7 +1602,7 @@ QString Settings::getModString() return getConfigString(m_activeMods); } -void Settings::filterCosmeticMods(QStringList mods, QStringList versions, bool filter) +void Settings::filterCosmeticMods(QStringList & mods, QStringList & versions, bool filter) { if (filter) { diff --git a/coreengine/settings.h b/coreengine/settings.h index b7388c055..edcf52ba4 100644 --- a/coreengine/settings.h +++ b/coreengine/settings.h @@ -353,6 +353,18 @@ class Settings final : public QObject Q_INVOKABLE void setServerPort(const quint16 ServerPort); Q_INVOKABLE QString getServerPassword(); Q_INVOKABLE void setServerPassword(const QString newServerPassword); + Q_INVOKABLE bool getModSyncEnabled() const; + Q_INVOKABLE void setModSyncEnabled(bool newModSyncEnabled); + Q_INVOKABLE qint32 getModSyncMaxPerModBytes() const; + Q_INVOKABLE void setModSyncMaxPerModBytes(qint32 newValue); + Q_INVOKABLE qint32 getModSyncMaxTotalBytes() const; + Q_INVOKABLE void setModSyncMaxTotalBytes(qint32 newValue); + Q_INVOKABLE qint32 getModSyncMaxFiles() const; + Q_INVOKABLE void setModSyncMaxFiles(qint32 newValue); + Q_INVOKABLE qint32 getModSyncMaxRelativePathLength() const; + Q_INVOKABLE void setModSyncMaxRelativePathLength(qint32 newValue); + Q_INVOKABLE bool getModSyncKeepBackups() const; + Q_INVOKABLE void setModSyncKeepBackups(bool newValue); Q_INVOKABLE QString getMailServerSendAddress(); Q_INVOKABLE void setMailServerSendAddress(const QString newMailServerSendAddress); Q_INVOKABLE qint32 getMailServerAuthMethod(); @@ -428,6 +440,9 @@ class Settings final : public QObject Q_INVOKABLE QStringList getActiveModVersions(); Q_INVOKABLE QStringList getActiveMods(); Q_INVOKABLE void setActiveMods(const QStringList activeMods); + // Stages Mods/Mods to ini without the missing-folder prune; returns prior raw value so the caller can restore on failure. + QString stageActiveModsForRestart(const QStringList & activeMods); + void restoreActiveModsRaw(const QString & rawValue); Q_INVOKABLE QString getSlaveServerName(); Q_INVOKABLE void setSlaveServerName(const QString slaveServerName); Q_INVOKABLE bool getSyncAnimations(); @@ -527,7 +542,7 @@ class Settings final : public QObject Q_INVOKABLE qint32 getMenuItemCount(); Q_INVOKABLE void setMenuItemCount(const qint32 MenuItemCount); Q_INVOKABLE QString getModString(); - Q_INVOKABLE void filterCosmeticMods(QStringList mods, QStringList versions, bool filter); + void filterCosmeticMods(QStringList & mods, QStringList & versions, bool filter); Q_INVOKABLE QString getConfigString(QStringList mods); Q_INVOKABLE quint32 getMultiTurnCounter(); Q_INVOKABLE void setMultiTurnCounter(const quint32 value); @@ -934,6 +949,17 @@ class Settings final : public QObject std::chrono::seconds m_suspendedDespawnTime{std::chrono::minutes(0)}; std::chrono::seconds m_replayDeleteTime{std::chrono::minutes(0)}; + // Default false until slice 4 wires the request receiver; operator opt-in. + bool m_modSyncEnabled{false}; + // TODO slice 2: enforced by the package builder and receive path. + qint32 m_modSyncMaxPerModBytes{64 * 1024 * 1024}; + qint32 m_modSyncMaxTotalBytes{256 * 1024 * 1024}; + qint32 m_modSyncMaxFiles{5000}; + // Inside-package relpath cap; the modPath identifier itself uses Filesupport::ModPathDefaultMaxLen. + qint32 m_modSyncMaxRelativePathLength{260}; + // Disable to skip keeping the .bak- directory after a successful staging swap; saves disk for users with large mods. + bool m_modSyncKeepBackups{false}; + // mailing QString m_mailServerAddress; quint16 m_mailServerPort{0}; diff --git a/coreengine/workerthread.cpp b/coreengine/workerthread.cpp index c1fbc2b0a..19c417e3a 100644 --- a/coreengine/workerthread.cpp +++ b/coreengine/workerthread.cpp @@ -8,13 +8,16 @@ #include "ai/aiprocesspipe.h" +#include "coreengine/filesupport.h" #include "coreengine/mainapp.h" #include "coreengine/workerthread.h" #include "coreengine/gameconsole.h" +#include "coreengine/settings.h" #include "coreengine/userdata.h" #include "coreengine/virtualpaths.h" #include "menue/mainwindow.h" +#include "multiplayer/multiplayermenu.h" #include "game/gameanimation/gameanimationfactory.h" @@ -228,6 +231,28 @@ void WorkerThread::showMainwindow() spLoadingScreen pLoadingScreen = LoadingScreen::getInstance(); pLoadingScreen->hide(); + + // Consume before the slave guard so a leftover cannot survive into a later normal launch. + const Filesupport::RejoinManifest rejoin = Filesupport::consumeRejoinManifest(Settings::getInstance()->getUserPath()); + // Clear the static slot before branching so non-rejoin paths do not retain plaintext. + const QString rejoinPassword = Mainapp::getRejoinPassword(); + Mainapp::setRejoinPassword(QString()); + + constexpr qint64 kRejoinFreshnessSeconds = 300; + const bool slaveLaunch = Mainapp::getSlave() || Settings::getInstance()->getAiSlave(); + if (!slaveLaunch && rejoin.valid) + { + const qint64 now = QDateTime::currentSecsSinceEpoch(); + const qint64 age = now - rejoin.timestamp; + if (age >= 0 && age <= kRejoinFreshnessSeconds) + { + CONSOLE_PRINT("Auto-rejoining " + rejoin.host + ":" + QString::number(rejoin.port) + " from .rejoin.json", GameConsole::eINFO); + oxygine::Stage::getStage()->addChild(MemoryManagement::create(rejoin.host, "", rejoin.port, rejoinPassword, Multiplayermenu::NetworkMode::Client)); + return; + } + CONSOLE_PRINT("Stale .rejoin.json (" + QString::number(age) + "s old), proceeding to main menu", GameConsole::eINFO); + } + auto window = MemoryManagement::create("ui/menu/mainmenu.xml"); oxygine::Stage::getStage()->addChild(window); } diff --git a/main.cpp b/main.cpp index a19467c31..277d0adef 100644 --- a/main.cpp +++ b/main.cpp @@ -110,7 +110,7 @@ int main(qint32 argc, char* argv[]) CONSOLE_PRINT("No automatic restart on android", GameConsole::eDEBUG); #else CONSOLE_PRINT("Restarting application", GameConsole::eDEBUG); - QProcess::startDetached(QCoreApplication::applicationFilePath(), QStringList()); + QProcess::startDetached(QCoreApplication::applicationFilePath(), Mainapp::getRestartArgv()); #endif } #ifdef UPDATESUPPORT diff --git a/menue/gamemenue.cpp b/menue/gamemenue.cpp index 0337059da..dbff6b394 100644 --- a/menue/gamemenue.cpp +++ b/menue/gamemenue.cpp @@ -705,13 +705,23 @@ void GameMenue::sendVerifyGameData(quint64 socketID) stream << mods[i]; stream << versions[i]; } - auto hostHash = Filesupport::getRuntimeHash(mods); - if (GameConsole::eDEBUG >= GameConsole::getLogLevel()) + quint32 capabilities = 0; + if (Settings::getInstance()->getModSyncEnabled()) { - QString hostString = GlobalUtils::getByteArrayString(hostHash); - CONSOLE_PRINT("Sending host hash: " + hostString, GameConsole::eDEBUG); + capabilities |= Filesupport::CapabilityModSync; } - Filesupport::writeByteArray(stream, hostHash); + // Stay on parent v1 wire format when no caps advertised so v1 clients still join. + if (capabilities == 0) + { + stream << static_cast(Filesupport::LegacyHashPayloadVersion); + } + else + { + stream << static_cast(Filesupport::CurrentHashPayloadVersion); + stream << capabilities; + } + Filesupport::writeMap(stream, Filesupport::getResourceFolderHashes()); + Filesupport::writeMap(stream, Filesupport::getPerModHashes(mods)); emit m_pNetworkInterface->sig_sendData(socketID, data, NetworkInterface::NetworkSerives::Multiplayer, false); } diff --git a/multiplayer/multiplayermenu.cpp b/multiplayer/multiplayermenu.cpp index 0656b70c4..df0db1414 100644 --- a/multiplayer/multiplayermenu.cpp +++ b/multiplayer/multiplayermenu.cpp @@ -1,7 +1,9 @@ -#include +#include +#include #include #include -#include +#include +#include #include "3rd_party/oxygine-framework/oxygine/actor/Stage.h" @@ -14,6 +16,7 @@ #include "coreengine/settings.h" #include "coreengine/filesupport.h" #include "coreengine/globalutils.h" +#include "coreengine/virtualpaths.h" #include "menue/gamemenue.h" #include "menue/mainwindow.h" @@ -41,7 +44,9 @@ Multiplayermenu::Multiplayermenu(const QString & address, const QString & second : MapSelectionMapsMenue(MemoryManagement::create(QStringList({".map", ".jsm"})), Settings::getInstance()->getSmallScreenDevice() ? oxygine::Stage::getStage()->getHeight() - 80 : oxygine::Stage::getStage()->getHeight() - 230), m_networkMode(networkMode), m_local(true), - m_password(password) + m_password(password), + m_serverAddress(address), + m_serverPort(port) { init(); if (m_networkMode != NetworkMode::Host) @@ -67,7 +72,9 @@ Multiplayermenu::Multiplayermenu(const QString & address, quint16 port, const Pa : MapSelectionMapsMenue(MemoryManagement::create(QStringList({".map", ".jsm"})), Settings::getInstance()->getSmallScreenDevice() ? oxygine::Stage::getStage()->getHeight() - 80 : oxygine::Stage::getStage()->getHeight() - 230), m_networkMode(networkMode), m_local(true), - m_password(*password) + m_password(*password), + m_serverAddress(address), + m_serverPort(port) { init(); initClientConnection(address, "", port); @@ -135,10 +142,12 @@ void Multiplayermenu::initClientAndWaitForConnection() connect(m_pPlayerSelection.get(), &PlayerSelection::sigDisconnect, this, &Multiplayermenu::buttonBack, Qt::QueuedConnection); // wait 10 minutes till timeout - spDialogConnecting pDialogConnecting = MemoryManagement::create(tr("Connecting"), 1000 * 60 * 5); - addChild(pDialogConnecting); - connect(pDialogConnecting.get(), &DialogConnecting::sigCancel, this, &Multiplayermenu::buttonBack, Qt::QueuedConnection); - connect(this, &Multiplayermenu::sigConnected, pDialogConnecting.get(), &DialogConnecting::connected, Qt::QueuedConnection); + m_pJoinConnectingDialog = MemoryManagement::create(tr("Connecting"), 1000 * 60 * 5); + addChild(m_pJoinConnectingDialog); + connect(m_pJoinConnectingDialog.get(), &DialogConnecting::sigCancel, this, &Multiplayermenu::buttonBack, Qt::QueuedConnection); + connect(this, &Multiplayermenu::sigConnected, m_pJoinConnectingDialog.get(), &DialogConnecting::connected, Qt::QueuedConnection); + // Drop the smart-ptr after the dialog's own connected() slot has detached it, so a retained reference can't keep the actor alive past the lobby join. + connect(this, &Multiplayermenu::sigConnected, this, [this](){ m_pJoinConnectingDialog.reset(); }, Qt::QueuedConnection); } void Multiplayermenu::init() @@ -536,6 +545,38 @@ void Multiplayermenu::recieveData(quint64 socketID, QByteArray data, NetworkInte { exitMenuToLobby(); } + else if (messageType == NetworkCommands::REQUESTMODSYNC) + { + handleModSyncRequest(stream, socketID); + } + else if (messageType == NetworkCommands::MODSYNCMANIFEST) + { + handleModSyncManifest(stream, socketID); + } + else if (messageType == NetworkCommands::MODSYNCDATA) + { + handleModSyncData(stream, socketID); + } + else if (messageType == NetworkCommands::MODSYNCMODBEGIN) + { + handleModSyncModBegin(stream, socketID); + } + else if (messageType == NetworkCommands::MODSYNCMODCHUNK) + { + handleModSyncModChunk(stream, socketID); + } + else if (messageType == NetworkCommands::MODSYNCMODEND) + { + handleModSyncModEnd(stream, socketID); + } + else if (messageType == NetworkCommands::MODSYNCREJECT) + { + handleModSyncReject(stream, socketID); + } + else if (messageType == NetworkCommands::MODSYNCCOMPLETE) + { + handleModSyncComplete(stream, socketID); + } else { CONSOLE_PRINT("Unknown command in Multiplayermenu::recieveData " + messageType + " received", GameConsole::eDEBUG); @@ -743,13 +784,23 @@ void Multiplayermenu::sendMapInfoUpdate(quint64 socketID) stream << mods[i]; stream << versions[i]; } - auto hostHash = Filesupport::getRuntimeHash(mods); - if (GameConsole::eDEBUG >= GameConsole::getLogLevel()) + quint32 capabilities = 0; + if (Settings::getInstance()->getModSyncEnabled()) + { + capabilities |= Filesupport::CapabilityModSync; + } + // Stay on parent v1 wire format when no caps advertised so v1 clients still join. + if (capabilities == 0) { - QString hostString = GlobalUtils::getByteArrayString(hostHash); - CONSOLE_PRINT("Sending host hash: " + hostString, GameConsole::eDEBUG); + stream << static_cast(Filesupport::LegacyHashPayloadVersion); } - Filesupport::writeByteArray(stream, hostHash); + else + { + stream << static_cast(Filesupport::CurrentHashPayloadVersion); + stream << capabilities; + } + Filesupport::writeMap(stream, Filesupport::getResourceFolderHashes()); + Filesupport::writeMap(stream, Filesupport::getPerModHashes(mods)); stream << m_saveGame; if (m_saveGame) { @@ -1165,7 +1216,12 @@ void Multiplayermenu::verifyGameData(QDataStream & stream, quint64 socketID) QStringList versions; QStringList myMods; QStringList myVersions; - readHashInfo(stream, socketID, mods, versions, myMods, myVersions, sameMods, differentHash, sameVersion); + QStringList mismatchedResourceFolders; + QStringList mismatchedMods; + quint32 hostCapabilities = 0; + bool cosmeticAllowed = false; + QMap hostModHashes; + readHashInfo(stream, socketID, mods, versions, myMods, myVersions, mismatchedResourceFolders, mismatchedMods, hostModHashes, hostCapabilities, sameMods, differentHash, sameVersion, cosmeticAllowed); if (sameVersion && sameMods && !differentHash) { QString command = QString(NetworkCommands::GAMEDATAVERIFIED); @@ -1178,7 +1234,7 @@ void Multiplayermenu::verifyGameData(QDataStream & stream, quint64 socketID) } else { - handleVersionMissmatch(mods, versions, myMods, myVersions, sameMods, differentHash, sameVersion); + handleVersionMissmatch(mods, versions, myMods, myVersions, mismatchedResourceFolders, mismatchedMods, hostModHashes, hostCapabilities, sameMods, differentHash, sameVersion, cosmeticAllowed); } } } @@ -1230,12 +1286,21 @@ bool Multiplayermenu::checkMods(const QStringList & mods, const QStringList & ve return sameMods; } -void Multiplayermenu::readHashInfo(QDataStream & stream, quint64 socketID, QStringList & mods, QStringList & versions, QStringList & myMods, QStringList & myVersions, bool & sameMods, bool & differentHash, bool & sameVersion) +void Multiplayermenu::readHashInfo(QDataStream & stream, quint64 socketID, QStringList & mods, QStringList & versions, QStringList & myMods, QStringList & myVersions, QStringList & mismatchedResourceFolders, QStringList & mismatchedMods, QMap & hostModHashes, quint32 & hostCapabilities, bool & sameMods, bool & differentHash, bool & sameVersion, bool & cosmeticAllowed) { + hostCapabilities = 0; + cosmeticAllowed = false; + hostModHashes.clear(); GameVersion version; version.deserializeObject(stream); + sameVersion = (version == GameVersion()); + if (!sameVersion) + { + return; + } bool filter = false; stream >> filter; + cosmeticAllowed = filter; qint32 size = 0; stream >> size; for (qint32 i = 0; i < size; i++) @@ -1248,17 +1313,74 @@ void Multiplayermenu::readHashInfo(QDataStream & stream, quint64 socketID, QStri versions.append(version); } sameMods = checkMods(mods, versions, myMods, myVersions, filter); - QByteArray hostRuntime = Filesupport::readByteArray(stream); - QByteArray ownRuntime = Filesupport::getRuntimeHash(mods); - if (GameConsole::eDEBUG >= GameConsole::getLogLevel()) + qint32 sentinel = 0; + stream >> sentinel; + + // Call once per readHashInfo: appends to mismatch lists without clearing. + auto compareMaps = [&](const QMap & hostResources, const QMap & hostMods) + { + // Surface host's per-mod hash for the inactive-local-copy verification in handleVersionMissmatch. + hostModHashes = hostMods; + auto ownResources = Filesupport::getResourceFolderHashes(); + auto ownMods = Filesupport::getPerModHashes(myMods); + for (auto iter = hostResources.constBegin(); iter != hostResources.constEnd(); ++iter) + { + if (ownResources.value(iter.key()) != iter.value()) + { + mismatchedResourceFolders.append(iter.key()); + } + } + for (const auto & mod : std::as_const(mods)) + { + // Mods only on one side belong in the membership-mismatch section, not here. + if (!myMods.contains(mod)) + { + continue; + } + if (hostMods.value(mod) != ownMods.value(mod)) + { + mismatchedMods.append(mod); + } + } + differentHash = !mismatchedResourceFolders.isEmpty() || !mismatchedMods.isEmpty(); + }; + + if (sentinel == Filesupport::LegacyRuntimeHashSize) + { + QByteArray hostRuntime; + for (qint32 i = 0; i < sentinel; ++i) + { + qint8 byte = 0; + stream >> byte; + hostRuntime.append(byte); + } + QByteArray ownRuntime = Filesupport::getLegacyRuntimeHash(mods); + if (GameConsole::eDEBUG >= GameConsole::getLogLevel()) + { + CONSOLE_PRINT("Received legacy host hash: " + GlobalUtils::getByteArrayString(hostRuntime), GameConsole::eDEBUG); + CONSOLE_PRINT("Own legacy hash: " + GlobalUtils::getByteArrayString(ownRuntime), GameConsole::eDEBUG); + } + differentHash = (hostRuntime != ownRuntime); + } + else if (sentinel == Filesupport::LegacyHashPayloadVersion) + { + // parent named-mod-mismatch wire format: two maps, no capabilities advertised. + auto hostResources = Filesupport::readMap(stream); + auto hostMods = Filesupport::readMap(stream); + compareMaps(hostResources, hostMods); + } + else if (sentinel == Filesupport::CurrentHashPayloadVersion) + { + stream >> hostCapabilities; + auto hostResources = Filesupport::readMap(stream); + auto hostMods = Filesupport::readMap(stream); + compareMaps(hostResources, hostMods); + } + else { - QString hostString = GlobalUtils::getByteArrayString(hostRuntime); - QString ownString = GlobalUtils::getByteArrayString(ownRuntime); - CONSOLE_PRINT("Received host hash: " + hostString, GameConsole::eDEBUG); - CONSOLE_PRINT("Own hash: " + ownString, GameConsole::eDEBUG); + CONSOLE_PRINT("Unknown hash payload sentinel " + QString::number(sentinel) + ", failing closed", GameConsole::eERROR); + differentHash = true; } - differentHash = (hostRuntime != ownRuntime); - sameVersion = version == GameVersion(); } void Multiplayermenu::clientMapInfo(QDataStream & stream, quint64 socketID) @@ -1273,7 +1395,12 @@ void Multiplayermenu::clientMapInfo(QDataStream & stream, quint64 socketID) QStringList versions; QStringList myMods; QStringList myVersions; - readHashInfo(stream, socketID, mods, versions, myMods, myVersions, sameMods, differentHash, sameVersion); + QStringList mismatchedResourceFolders; + QStringList mismatchedMods; + quint32 hostCapabilities = 0; + bool cosmeticAllowed = false; + QMap hostModHashes; + readHashInfo(stream, socketID, mods, versions, myMods, myVersions, mismatchedResourceFolders, mismatchedMods, hostModHashes, hostCapabilities, sameMods, differentHash, sameVersion, cosmeticAllowed); if (sameVersion && sameMods && !differentHash) { stream >> m_saveGame; @@ -1331,43 +1458,176 @@ void Multiplayermenu::clientMapInfo(QDataStream & stream, quint64 socketID) } else { - handleVersionMissmatch(mods, versions, myMods, myVersions, sameMods, differentHash, sameVersion); + handleVersionMissmatch(mods, versions, myMods, myVersions, mismatchedResourceFolders, mismatchedMods, hostModHashes, hostCapabilities, sameMods, differentHash, sameVersion, cosmeticAllowed); } } } -void Multiplayermenu::handleVersionMissmatch(const QStringList & mods, const QStringList & versions, const QStringList & myMods, const QStringList & myVersions, bool sameMods, bool differentHash, bool sameVersion) +void Multiplayermenu::handleVersionMissmatch(const QStringList & mods, const QStringList & versions, const QStringList & myMods, const QStringList & myVersions, const QStringList & mismatchedResourceFolders, const QStringList & mismatchedMods, const QMap & hostModHashes, quint32 hostCapabilities, bool sameMods, bool differentHash, bool sameVersion, bool cosmeticAllowed) { - // quit game with wrong version - spDialogMessageBox pDialogMessageBox; - if (differentHash) - { - pDialogMessageBox = MemoryManagement::create(tr("Host has a different version of a mod or the game resource folder has been modified by one of the games.")); - } - else if (!sameVersion) + // Mod/hash fields are stale on version mismatch because readHashInfo early-returns. + if (!sameVersion) { - pDialogMessageBox = MemoryManagement::create(tr("Host has a different game version. Leaving the game again.")); + spDialogMessageBox pDialogMessageBox = MemoryManagement::create(tr("Host has a different game version. Leaving the game again.")); + connect(pDialogMessageBox.get(), &DialogMessageBox::sigOk, this, &Multiplayermenu::buttonBack, Qt::QueuedConnection); + addChild(pDialogMessageBox); + return; } - else if (!sameMods) + + constexpr qint32 DISPLAY_LIMIT = 5; + auto * settings = Settings::getInstance(); + + QStringList missingHere; + QStringList extraHere; + QStringList versionDiffs; + QStringList modsToDownloadPaths; + if (!sameMods) { - QString hostModsInfo; for (qint32 i = 0; i < mods.size(); ++i) { - hostModsInfo += Settings::getInstance()->getModName(mods[i]) + " " + versions[i] + "\n"; + const QString & mod = mods[i]; + const qint32 j = myMods.indexOf(mod); + if (j < 0) + { + missingHere.append(settings->getModName(mod) + " " + versions[i]); + // Skip download only when local hash and version match host; disk presence alone is unsafe (stale or unrelated folder). + bool localSatisfies = false; + const QString resolvedAbs = VirtualPaths::find(mod, false); + if (!resolvedAbs.isEmpty() && QDir(resolvedAbs).exists()) + { + const QByteArray localHash = Filesupport::getPerModHashes(QStringList{mod}).value(mod); + const QString localVersion = settings->getModVersion(mod); + localSatisfies = (!localHash.isEmpty() && localHash == hostModHashes.value(mod) && localVersion == versions[i]); + if (!localSatisfies) + { + CONSOLE_PRINT("Inactive local copy of " + mod + " differs from host (hash or version); queueing download.", GameConsole::eDEBUG); + } + } + if (!localSatisfies) + { + modsToDownloadPaths.append(mod); + } + } + else if (versions[i] != myVersions[j]) + { + versionDiffs.append(tr("%1 (host: %2, you: %3)").arg(settings->getModName(mod), versions[i], myVersions[j])); + modsToDownloadPaths.append(mod); + } } - QString myModsInfo; for (qint32 i = 0; i < myMods.size(); ++i) { - myModsInfo += Settings::getInstance()->getModName(myMods[i]) + " " + myVersions[i] + "\n"; + if (!mods.contains(myMods[i])) + { + extraHere.append(settings->getModName(myMods[i]) + " " + myVersions[i]); + } + } + } + + QStringList contentDiffs; + for (const auto & mod : std::as_const(mismatchedMods)) + { + contentDiffs.append(settings->getModName(mod)); + if (!modsToDownloadPaths.contains(mod)) + { + modsToDownloadPaths.append(mod); + } + } + + auto logFullList = [](const QString & label, const QStringList & list) + { + if (!list.isEmpty()) + { + CONSOLE_PRINT(label + ": " + list.join(", "), GameConsole::eINFO); + } + }; + logFullList(QStringLiteral("Mods host has that you are missing"), missingHere); + logFullList(QStringLiteral("Mods you have that host does not"), extraHere); + logFullList(QStringLiteral("Mods with version-string mismatch"), versionDiffs); + logFullList(QStringLiteral("Mods with different content"), contentDiffs); + logFullList(QStringLiteral("Engine resource folders modified"), mismatchedResourceFolders); + + auto appendSection = [&](QString & dst, const QString & header, const QStringList & lines) + { + if (lines.isEmpty()) + { + return; + } + dst += header + "\n"; + const qint32 shown = std::min(static_cast(lines.size()), DISPLAY_LIMIT); + for (qint32 i = 0; i < shown; ++i) + { + dst += " " + lines[i] + "\n"; + } + if (lines.size() > DISPLAY_LIMIT) + { + dst += " " + tr("...and %1 more (see console.log)").arg(lines.size() - DISPLAY_LIMIT) + "\n"; + } + dst += "\n"; + }; + + QString message; + appendSection(message, tr("Missing mods (host has, you don't):"), missingHere); + appendSection(message, tr("Extra mods (you have, host doesn't):"), extraHere); + appendSection(message, tr("Version mismatch (mod.txt):"), versionDiffs); + appendSection(message, tr("Content mismatch:"), contentDiffs); + appendSection(message, tr("Engine resources differ:"), mismatchedResourceFolders); + + // Mod-sync is offerable when host advertised CapabilityModSync, no engine resource drift, and any mismatch class is non-empty (downloads OR settings-only activate/deactivate work). + const bool hostSupportsModSync = (hostCapabilities & Filesupport::CapabilityModSync) != 0; + const bool resourceDrift = !mismatchedResourceFolders.isEmpty(); + const bool fixableViaSync = hostSupportsModSync && !resourceDrift && (!missingHere.isEmpty() || !versionDiffs.isEmpty() || !contentDiffs.isEmpty() || !extraHere.isEmpty()); + + if (message.isEmpty()) + { + // Legacy and fail-closed payloads have no structured detail. + if (differentHash) + { + message = tr("Host has a different version of a mod or the game resource folder has been modified by one of the games."); + } + else + { + CONSOLE_PRINT("handleVersionMissmatch reached unreachable branch: !differentHash with no mod-set or hash diff detail. checkMods set !sameMods but our classification found nothing. Investigate.", GameConsole::eERROR); + message = tr("Failed to join game due to unknown verification failure."); + } + } + else if (fixableViaSync) + { + message = tr("Your game data differs from the host:") + "\n\n" + message + tr("Want me to download host's mod set, apply it, and restart automatically?"); + } + else + { + message = tr("Cannot join, your game data differs from the host:") + "\n\n" + message + tr("Leaving the game again."); + } + + if (fixableViaSync) + { + spDialogMessageBox pDialogMessageBox = MemoryManagement::create(message, true, tr("Apply"), tr("Leave game")); + // Host's advertised list is already cosmetic-filtered when the rule allows them; re-add client cosmetic-only mods so the user does not silently lose them on next boot. + QStringList postSyncActiveMods = mods; + if (cosmeticAllowed) + { + const QStringList clientFull = settings->getMods(); + for (const auto & mod : std::as_const(clientFull)) + { + if (!postSyncActiveMods.contains(mod) && settings->getIsCosmetic(mod)) + { + postSyncActiveMods.append(mod); + } + } } - pDialogMessageBox = MemoryManagement::create(tr("Host has different mods. Leaving the game again.\nHost mods:\n") + hostModsInfo + "\nYour Mods:\n" + myModsInfo); + connect(pDialogMessageBox.get(), &DialogMessageBox::sigOk, this, [this, modsToDownloadPaths, postSyncActiveMods]() + { + confirmModSync(modsToDownloadPaths, postSyncActiveMods); + }, Qt::QueuedConnection); + connect(pDialogMessageBox.get(), &DialogMessageBox::sigCancel, this, &Multiplayermenu::buttonBack, Qt::QueuedConnection); + addChild(pDialogMessageBox); } else { - pDialogMessageBox = MemoryManagement::create(tr("Failed to join game due to unknown verification failure.")); + spDialogMessageBox pDialogMessageBox = MemoryManagement::create(message); + connect(pDialogMessageBox.get(), &DialogMessageBox::sigOk, this, &Multiplayermenu::buttonBack, Qt::QueuedConnection); + addChild(pDialogMessageBox); } - connect(pDialogMessageBox.get(), &DialogMessageBox::sigOk, this, &Multiplayermenu::buttonBack, Qt::QueuedConnection); - addChild(pDialogMessageBox); } void Multiplayermenu::requestMap(quint64 socketID) @@ -2314,3 +2574,1242 @@ void Multiplayermenu::countdown() sendServerReady(false); } } + +// Pin reject codes to wire literals; filesupport.cpp's anonymous-namespace constants must match. +static_assert(static_cast(NetworkCommands::ModSyncNoReason) == 0, "ModSync reject code drift"); +static_assert(static_cast(NetworkCommands::ModSyncDisabled) == 1, "ModSync reject code drift"); +static_assert(static_cast(NetworkCommands::ModSyncUnknownMod) == 2, "ModSync reject code drift"); +static_assert(static_cast(NetworkCommands::ModSyncSizeCapExceeded) == 3, "ModSync reject code drift"); +static_assert(static_cast(NetworkCommands::ModSyncFileCountCapExceeded) == 4, "ModSync reject code drift"); +static_assert(static_cast(NetworkCommands::ModSyncInvalidPath) == 5, "ModSync reject code drift"); +static_assert(static_cast(NetworkCommands::ModSyncInternalError) == 6, "ModSync reject code drift"); +static_assert(static_cast(NetworkCommands::ModSyncBusy) == 7, "ModSync reject code drift"); + +namespace +{ + // Cap on number of mods in a single REQUESTMODSYNC; client mod count is bounded by what the user has installed. + constexpr qint32 MOD_SYNC_REQUEST_COUNT_MAX = 1024; + // Cap on reject-message char length; anything longer than this is truncated by the host or rejected. + constexpr qint32 MOD_SYNC_REASON_CHARS_MAX = 4096; + // Wire contract: host and client must agree on this value; chunkCount and per-chunk size are validated against it. Bump MODSYNCMODBEGIN's protocolVersion if it ever changes. + constexpr qint32 MOD_SYNC_CHUNK_BYTES = 1 * 1024 * 1024; + // Hard ceiling on chunkCount declared in MODSYNCMODBEGIN; even at perModCap = 1 GB that's only ~1024 chunks at 1 MiB. + constexpr qint32 MOD_SYNC_CHUNK_COUNT_MAX = 1024 * 1024; + + // QDataStream::operator>> pre-resizes to the declared length; bounded readers reject the header before allocation. + constexpr quint32 INT32_MAX_AS_U32 = 0x7FFFFFFFu; + + bool readBoundedQByteArray(QDataStream & stream, QByteArray & out, qint64 maxBytes) + { + if (maxBytes < 0) + { + return false; + } + quint32 declared = 0; + stream >> declared; + if (stream.status() != QDataStream::Ok) + { + return false; + } + if (declared == 0xFFFFFFFFu) + { + out.clear(); + return true; + } + if (declared > INT32_MAX_AS_U32 || static_cast(declared) > maxBytes) + { + return false; + } + out.resize(static_cast(declared)); + if (out.size() > 0 && stream.readRawData(out.data(), out.size()) != out.size()) + { + return false; + } + return true; + } + + bool readBoundedQString(QDataStream & stream, QString & out, qint32 maxChars) + { + if (maxChars < 0) + { + return false; + } + quint32 declared = 0; + stream >> declared; + if (stream.status() != QDataStream::Ok) + { + return false; + } + if (declared == 0xFFFFFFFFu) + { + out.clear(); + return true; + } + const qint64 maxBytes = static_cast(maxChars) * 2; + if (declared > INT32_MAX_AS_U32 || static_cast(declared) > maxBytes || (declared % 2) != 0) + { + return false; + } + QByteArray buf; + buf.resize(static_cast(declared)); + if (buf.size() > 0 && stream.readRawData(buf.data(), buf.size()) != buf.size()) + { + return false; + } + const qint32 codeUnits = buf.size() / 2; + out.resize(codeUnits); + const uchar * src = reinterpret_cast(buf.constData()); + // Wire is big-endian; build each code unit explicitly to skip platform-endian conversion in fromUtf16. + for (qint32 i = 0; i < codeUnits; ++i) + { + out[i] = QChar(static_cast((src[i * 2] << 8) | src[i * 2 + 1])); + } + return true; + } +} + +bool Multiplayermenu::requestModSync(const QStringList & modsToDownload, const QStringList & postSyncActiveMods) +{ + if (m_modSyncActive) + { + CONSOLE_PRINT("Mod-sync already in flight; ignoring duplicate requestModSync", GameConsole::eWARNING); + return false; + } + // Network guard runs before the empty-request branch so a host-side caller cannot persist client-side post-sync settings. + if (m_pNetworkInterface == nullptr || m_pNetworkInterface->getIsServer()) + { + CONSOLE_PRINT("requestModSync called without a client network interface, ignoring", GameConsole::eWARNING); + return false; + } + if (modsToDownload.size() > MOD_SYNC_REQUEST_COUNT_MAX) + { + CONSOLE_PRINT("requestModSync exceeds count cap (" + QString::number(modsToDownload.size()) + " > " + QString::number(MOD_SYNC_REQUEST_COUNT_MAX) + "), ignoring", GameConsole::eWARNING); + return false; + } + if (modsToDownload.isEmpty()) + { + // No downloads needed; persist the post-sync mod selection only. No manifest, no network round-trip. + Settings::getInstance()->stageActiveModsForRestart(postSyncActiveMods); + CONSOLE_PRINT("Mod-sync settings-only: " + QString::number(postSyncActiveMods.size()) + " mods staged for restart", GameConsole::eINFO); + return true; + } + m_modSyncActive = true; + m_modSyncStagings.clear(); + m_modSyncRequestedSet = QSet(modsToDownload.cbegin(), modsToDownload.cend()); + m_modSyncReceivedBytes = 0; + m_modSyncReceivedUncompressedBytes = 0; + m_modSyncExpectedUncompressedTotal = 0; + m_modSyncPostSyncActiveMods = postSyncActiveMods; + m_modSyncCurrentChunkMod = ModSyncChunkAccumulator{}; + + QByteArray data; + QDataStream stream(&data, QIODevice::WriteOnly); + stream.setVersion(QDataStream::Version::Qt_6_5); + stream << QString(NetworkCommands::REQUESTMODSYNC); + stream << static_cast(1); + stream << modsToDownload; + // Trailing optional flag; older hosts read past their known fields and ignore. New hosts try-read and fall back to legacy framing if absent. + stream << static_cast(NetworkCommands::ModSyncClientFlagChunked); + // socketID=0 routes to the server on a TCP client interface; same convention as other client-originated sends. + emit m_pNetworkInterface->sig_sendData(0, data, NetworkInterface::NetworkSerives::Multiplayer, false); + CONSOLE_PRINT("Requested mod-sync for " + QString::number(modsToDownload.size()) + " mods (chunked-capable)", GameConsole::eINFO); + return true; +} + +void Multiplayermenu::handleModSyncRequest(QDataStream & stream, quint64 socketID) +{ + if (m_pNetworkInterface == nullptr || !m_pNetworkInterface->getIsServer()) + { + CONSOLE_PRINT("REQUESTMODSYNC received on non-server, ignoring", GameConsole::eWARNING); + return; + } + auto * settings = Settings::getInstance(); + if (!settings->getModSyncEnabled()) + { + sendModSyncReject(socketID, NetworkCommands::ModSyncDisabled, QString(), tr("Mod sync is disabled on this host.")); + return; + } + // Host-wide pump state is single-slot; reject concurrent peers rather than clobber an in-flight transfer. + if (m_modSyncSendState.socketID != 0) + { + CONSOLE_PRINT("REQUESTMODSYNC arrived while another peer's send is still pumping; rejecting", GameConsole::eWARNING); + sendModSyncReject(socketID, NetworkCommands::ModSyncBusy, QString(), tr("Another peer is currently mod-syncing; try again shortly.")); + return; + } + qint32 protocolVersion = 0; + stream >> protocolVersion; + if (protocolVersion != 1) + { + sendModSyncReject(socketID, NetworkCommands::ModSyncInternalError, QString(), tr("Unsupported mod-sync protocol version.")); + return; + } + + const qint32 relPathMaxLen = settings->getModSyncMaxRelativePathLength(); + qint32 modCount = 0; + stream >> modCount; + if (stream.status() != QDataStream::Ok || modCount < 0 || modCount > MOD_SYNC_REQUEST_COUNT_MAX) + { + sendModSyncReject(socketID, NetworkCommands::ModSyncInternalError, QString(), tr("Malformed mod-sync request.")); + return; + } + QStringList requestedMods; + requestedMods.reserve(modCount); + { + // Dedupe at parse time so a hostile client cannot force repeated package builds and duplicate sends within the count cap. + QSet seen; + for (qint32 i = 0; i < modCount; ++i) + { + QString mod; + if (!readBoundedQString(stream, mod, relPathMaxLen)) + { + sendModSyncReject(socketID, NetworkCommands::ModSyncInvalidPath, QString(), tr("Malformed mod path in request.")); + return; + } + if (!seen.contains(mod)) + { + seen.insert(mod); + requestedMods.append(mod); + } + } + } + // Optional clientFlags trailer; older clients send 0 trailing bytes. Strict shape: nothing, or exactly one qint32. Anything else is malformed. + qint32 clientFlags = 0; + if (!stream.atEnd()) + { + stream >> clientFlags; + if (stream.status() != QDataStream::Ok || !stream.atEnd()) + { + sendModSyncReject(socketID, NetworkCommands::ModSyncInternalError, QString(), tr("Malformed mod-sync request trailer.")); + return; + } + } + const bool useChunked = (clientFlags & NetworkCommands::ModSyncClientFlagChunked) != 0; + + QStringList hostMods = settings->getMods(); + QStringList hostVersions = settings->getActiveModVersions(); + bool filter = false; + auto pMap = m_pMapSelectionView->getCurrentMap(); + if (pMap != nullptr && pMap->getGameRules() != nullptr) + { + filter = pMap->getGameRules()->getCosmeticModsAllowed(); + } + settings->filterCosmeticMods(hostMods, hostVersions, filter); + + Filesupport::ModSyncCaps caps; + caps.perModBytes = settings->getModSyncMaxPerModBytes(); + caps.fileCountMax = settings->getModSyncMaxFiles(); + caps.relPathMaxLen = relPathMaxLen; + const qint64 totalCap = settings->getModSyncMaxTotalBytes(); + qint64 totalSent = 0; + qint64 totalUncompressed = 0; + + auto packageRejectReason = [this](qint32 code, const QString & mod) -> QString + { + switch (code) + { + case NetworkCommands::ModSyncSizeCapExceeded: + return tr("Mod %1 exceeds the per-mod size cap on the host.").arg(mod); + case NetworkCommands::ModSyncFileCountCapExceeded: + return tr("Mod %1 exceeds the per-mod file-count cap on the host.").arg(mod); + case NetworkCommands::ModSyncInvalidPath: + return tr("Mod %1 has an unsafe internal file path.").arg(mod); + case NetworkCommands::ModSyncUnknownMod: + return tr("Mod %1 was not found on the host.").arg(mod); + default: + return tr("Failed to build mod package for %1.").arg(mod); + } + }; + + // Pre-build so the manifest carries exact sizes; peak ~totalCap, each blob freed after its MODSYNCDATA send. + QVector> builtPackages; + builtPackages.reserve(requestedMods.size()); + for (const auto & mod : std::as_const(requestedMods)) + { + if (!Filesupport::validateModPath(mod)) + { + sendModSyncReject(socketID, NetworkCommands::ModSyncInvalidPath, mod, tr("Invalid mod path.")); + return; + } + if (!hostMods.contains(mod)) + { + sendModSyncReject(socketID, NetworkCommands::ModSyncUnknownMod, mod, tr("Mod not advertised by host.")); + return; + } + // Active mods may resolve from CWD or the install/resource path, not just userPath; ask the VFS where the folder actually lives. + const QString resolvedAbs = VirtualPaths::find(mod, false); + if (resolvedAbs.isEmpty()) + { + sendModSyncReject(socketID, NetworkCommands::ModSyncUnknownMod, mod, tr("Mod folder not found in install search paths.")); + return; + } + QString resolvedRoot; + const QString suffixSlash = QStringLiteral("/") + mod; + if (resolvedAbs.endsWith(suffixSlash)) + { + resolvedRoot = resolvedAbs.left(resolvedAbs.size() - suffixSlash.size()); + } + else if (resolvedAbs == mod) + { + resolvedRoot = QString(); + } + else + { + sendModSyncReject(socketID, NetworkCommands::ModSyncInternalError, mod, tr("Mod path resolution shape unexpected.")); + return; + } + Filesupport::ModSyncPackage pkg = Filesupport::buildModSyncPackage(resolvedRoot, mod, caps); + if (pkg.rejectReason != 0) + { + sendModSyncReject(socketID, pkg.rejectReason, mod, packageRejectReason(pkg.rejectReason, mod)); + return; + } + if (totalSent + pkg.compressedBlob.size() > totalCap || totalUncompressed + pkg.declaredUncompressedSize > totalCap) + { + sendModSyncReject(socketID, NetworkCommands::ModSyncSizeCapExceeded, QString(), tr("Total sync size exceeds the host's cap.")); + return; + } + totalSent += pkg.compressedBlob.size(); + totalUncompressed += pkg.declaredUncompressedSize; + builtPackages.append(qMakePair(mod, std::move(pkg))); + } + + { + QByteArray manifestData; + QDataStream manifestStream(&manifestData, QIODevice::WriteOnly); + manifestStream.setVersion(QDataStream::Version::Qt_6_5); + manifestStream << QString(NetworkCommands::MODSYNCMANIFEST); + manifestStream << static_cast(1); + manifestStream << static_cast(builtPackages.size()); + for (const auto & entry : std::as_const(builtPackages)) + { + manifestStream << entry.first; + manifestStream << entry.second.declaredUncompressedSize; + } + emit m_pNetworkInterface->sig_sendData(socketID, manifestData, NetworkInterface::NetworkSerives::Multiplayer, false); + CONSOLE_PRINT("Sent MODSYNCMANIFEST; " + QString::number(builtPackages.size()) + " mods, " + QString::number(totalUncompressed) + " expected uncompressed bytes", GameConsole::eINFO); + } + + // Hand the data send off to a singleShot-driven pump so the GUI thread isn't pinned hot-looping over chunks for a multi-hundred-MB mod. + m_modSyncSendState = ModSyncSendState{}; + m_modSyncSendState.socketID = socketID; + m_modSyncSendState.packages = std::move(builtPackages); + m_modSyncSendState.useChunked = useChunked; + CONSOLE_PRINT("Mod-sync send queued (" + QString(useChunked ? "chunked" : "legacy") + "); " + QString::number(m_modSyncSendState.packages.size()) + " mods, " + QString::number(totalSent) + " compressed bytes, " + QString::number(totalUncompressed) + " uncompressed bytes", GameConsole::eINFO); + QTimer::singleShot(0, this, &Multiplayermenu::pumpModSyncSend); +} + +void Multiplayermenu::pumpModSyncSend() +{ + if (m_pNetworkInterface == nullptr || m_modSyncSendState.socketID == 0) + { + return; + } + auto & state = m_modSyncSendState; + if (state.currentMod >= state.packages.size()) + { + QByteArray data; + QDataStream sendStream(&data, QIODevice::WriteOnly); + sendStream.setVersion(QDataStream::Version::Qt_6_5); + sendStream << QString(NetworkCommands::MODSYNCCOMPLETE); + sendStream << static_cast(1); + emit m_pNetworkInterface->sig_sendData(state.socketID, data, NetworkInterface::NetworkSerives::Multiplayer, false); + CONSOLE_PRINT("Sent MODSYNCCOMPLETE; " + QString::number(state.packages.size()) + " mods", GameConsole::eINFO); + state = ModSyncSendState{}; + return; + } + auto & entry = state.packages[state.currentMod]; + if (!state.useChunked) + { + QByteArray data; + QDataStream sendStream(&data, QIODevice::WriteOnly); + sendStream.setVersion(QDataStream::Version::Qt_6_5); + sendStream << QString(NetworkCommands::MODSYNCDATA); + sendStream << static_cast(1); + sendStream << entry.first; + sendStream << entry.second.declaredUncompressedSize; + sendStream << entry.second.fileCount; + sendStream << entry.second.compressedBlob; + emit m_pNetworkInterface->sig_sendData(state.socketID, data, NetworkInterface::NetworkSerives::Multiplayer, false); + CONSOLE_PRINT("Sent MODSYNCDATA for " + entry.first + " (" + QString::number(entry.second.compressedBlob.size()) + " bytes)", GameConsole::eINFO); + entry.second.compressedBlob.clear(); + ++state.currentMod; + QTimer::singleShot(0, this, &Multiplayermenu::pumpModSyncSend); + return; + } + const qint64 compressedTotal = entry.second.compressedBlob.size(); + const qint32 chunkCount = compressedTotal == 0 ? 0 : static_cast((compressedTotal + MOD_SYNC_CHUNK_BYTES - 1) / MOD_SYNC_CHUNK_BYTES); + if (!state.beginEmitted) + { + QByteArray data; + QDataStream sendStream(&data, QIODevice::WriteOnly); + sendStream.setVersion(QDataStream::Version::Qt_6_5); + sendStream << QString(NetworkCommands::MODSYNCMODBEGIN); + sendStream << static_cast(1); + sendStream << entry.first; + sendStream << entry.second.declaredUncompressedSize; + sendStream << entry.second.fileCount; + sendStream << compressedTotal; + sendStream << chunkCount; + emit m_pNetworkInterface->sig_sendData(state.socketID, data, NetworkInterface::NetworkSerives::Multiplayer, false); + CONSOLE_PRINT("Sent MODSYNCMODBEGIN for " + entry.first + " (" + QString::number(compressedTotal) + " bytes, " + QString::number(chunkCount) + " chunks)", GameConsole::eINFO); + state.beginEmitted = true; + state.currentChunk = 0; + QTimer::singleShot(0, this, &Multiplayermenu::pumpModSyncSend); + return; + } + if (state.currentChunk < chunkCount) + { + const qint64 offset = static_cast(state.currentChunk) * MOD_SYNC_CHUNK_BYTES; + const qint32 sliceLen = static_cast(std::min(MOD_SYNC_CHUNK_BYTES, compressedTotal - offset)); + const QByteArray chunkBytes = entry.second.compressedBlob.mid(static_cast(offset), sliceLen); + QByteArray data; + QDataStream sendStream(&data, QIODevice::WriteOnly); + sendStream.setVersion(QDataStream::Version::Qt_6_5); + sendStream << QString(NetworkCommands::MODSYNCMODCHUNK); + sendStream << static_cast(1); + sendStream << entry.first; + sendStream << state.currentChunk; + sendStream << chunkBytes; + emit m_pNetworkInterface->sig_sendData(state.socketID, data, NetworkInterface::NetworkSerives::Multiplayer, false); + ++state.currentChunk; + QTimer::singleShot(0, this, &Multiplayermenu::pumpModSyncSend); + return; + } + { + QByteArray data; + QDataStream sendStream(&data, QIODevice::WriteOnly); + sendStream.setVersion(QDataStream::Version::Qt_6_5); + sendStream << QString(NetworkCommands::MODSYNCMODEND); + sendStream << static_cast(1); + sendStream << entry.first; + emit m_pNetworkInterface->sig_sendData(state.socketID, data, NetworkInterface::NetworkSerives::Multiplayer, false); + CONSOLE_PRINT("Sent MODSYNCMODEND for " + entry.first, GameConsole::eINFO); + } + entry.second.compressedBlob.clear(); + ++state.currentMod; + state.beginEmitted = false; + state.currentChunk = 0; + QTimer::singleShot(0, this, &Multiplayermenu::pumpModSyncSend); +} + +void Multiplayermenu::handleModSyncManifest(QDataStream & stream, quint64 socketID) +{ + Q_UNUSED(socketID); + if (!m_modSyncActive) + { + CONSOLE_PRINT("MODSYNCMANIFEST received with no active mod-sync session, ignoring", GameConsole::eWARNING); + return; + } + auto * settings = Settings::getInstance(); + const qint32 relPathMaxLen = settings->getModSyncMaxRelativePathLength(); + const qint64 totalCap = settings->getModSyncMaxTotalBytes(); + + qint32 protocolVersion = 0; + stream >> protocolVersion; + if (stream.status() != QDataStream::Ok || protocolVersion != 1) + { + CONSOLE_PRINT("MODSYNCMANIFEST unsupported protocol version, ignoring", GameConsole::eWARNING); + return; + } + qint32 entryCount = 0; + stream >> entryCount; + if (stream.status() != QDataStream::Ok || entryCount < 0 || entryCount > MOD_SYNC_REQUEST_COUNT_MAX) + { + CONSOLE_PRINT("MODSYNCMANIFEST malformed entry count, ignoring", GameConsole::eWARNING); + return; + } + qint64 expectedTotal = 0; + qint32 ignoredEntries = 0; + for (qint32 i = 0; i < entryCount; ++i) + { + QString modPath; + if (!readBoundedQString(stream, modPath, relPathMaxLen)) + { + CONSOLE_PRINT("MODSYNCMANIFEST mod path overflow or malformed, ignoring", GameConsole::eWARNING); + return; + } + qint32 declaredSize = 0; + stream >> declaredSize; + if (stream.status() != QDataStream::Ok || declaredSize < 0) + { + CONSOLE_PRINT("MODSYNCMANIFEST declared size out of range for " + modPath + ", ignoring", GameConsole::eWARNING); + return; + } + // Only count entries the client actually asked for; a hostile or buggy host could otherwise inflate expectedTotal to drive the bar to 100% before any data lands. + if (!m_modSyncRequestedSet.contains(modPath)) + { + ++ignoredEntries; + continue; + } + expectedTotal += declaredSize; + if (expectedTotal > totalCap) + { + CONSOLE_PRINT("MODSYNCMANIFEST expected total exceeds host total cap; clamping for display only", GameConsole::eWARNING); + expectedTotal = totalCap; + break; + } + } + if (ignoredEntries > 0) + { + CONSOLE_PRINT("MODSYNCMANIFEST ignored " + QString::number(ignoredEntries) + " entries not in the client's request set", GameConsole::eWARNING); + } + m_modSyncExpectedUncompressedTotal = expectedTotal; + if (m_modSyncProgressDialog != nullptr) + { + m_modSyncProgressDialog->setExpectedTotalBytes(expectedTotal); + } + CONSOLE_PRINT("Received MODSYNCMANIFEST; expected uncompressed total " + QString::number(expectedTotal) + " bytes across " + QString::number(entryCount) + " mods", GameConsole::eINFO); +} + +void Multiplayermenu::handleModSyncData(QDataStream & stream, quint64 socketID) +{ + Q_UNUSED(socketID); + if (!m_modSyncActive) + { + CONSOLE_PRINT("MODSYNCDATA received with no active mod-sync session, ignoring", GameConsole::eWARNING); + return; + } + auto * settings = Settings::getInstance(); + const qint32 relPathMaxLen = settings->getModSyncMaxRelativePathLength(); + const qint64 perModCap = settings->getModSyncMaxPerModBytes(); + const qint32 fileCountMax = settings->getModSyncMaxFiles(); + const qint64 totalCap = settings->getModSyncMaxTotalBytes(); + + auto failData = [this](const QString & uiReason) + { + cancelModSyncSession(); + onModSyncFailed(uiReason); + }; + + if (!m_modSyncCurrentChunkMod.modPath.isEmpty()) + { + CONSOLE_PRINT("MODSYNCDATA arrived while a chunked mod is in flight; protocol violation", GameConsole::eERROR); + failData(tr("Host mixed chunked and legacy mod-sync framing.")); + return; + } + + qint32 protocolVersion = 0; + stream >> protocolVersion; + if (stream.status() != QDataStream::Ok || protocolVersion != 1) + { + CONSOLE_PRINT("MODSYNCDATA unsupported protocol version", GameConsole::eERROR); + failData(tr("Unsupported mod-sync protocol from host.")); + return; + } + + QString modPath; + if (!readBoundedQString(stream, modPath, relPathMaxLen)) + { + CONSOLE_PRINT("MODSYNCDATA mod path overflow or malformed", GameConsole::eERROR); + failData(tr("Malformed mod-sync data frame.")); + return; + } + + qint32 declaredSize = 0; + qint32 fileCount = 0; + stream >> declaredSize; + stream >> fileCount; + if (stream.status() != QDataStream::Ok || declaredSize < 0 || fileCount < 0 || fileCount > fileCountMax) + { + CONSOLE_PRINT("MODSYNCDATA size/count out of range for " + modPath, GameConsole::eERROR); + failData(tr("Mod %1 exceeds the per-mod file-count cap.").arg(modPath)); + return; + } + + QByteArray compressedBlob; + if (!readBoundedQByteArray(stream, compressedBlob, perModCap)) + { + CONSOLE_PRINT("MODSYNCDATA blob overflow or malformed for " + modPath, GameConsole::eERROR); + failData(tr("Mod %1 exceeds the per-mod size cap.").arg(modPath)); + return; + } + + if (!Filesupport::validateModPath(modPath)) + { + CONSOLE_PRINT("MODSYNCDATA invalid mod path: " + modPath, GameConsole::eERROR); + failData(tr("Host sent an invalid mod path.")); + return; + } + if (!m_modSyncRequestedSet.contains(modPath)) + { + CONSOLE_PRINT("MODSYNCDATA for unrequested or duplicate mod: " + modPath, GameConsole::eERROR); + failData(tr("Host sent an unrequested or duplicate mod.")); + return; + } + + m_modSyncReceivedBytes += compressedBlob.size(); + m_modSyncReceivedUncompressedBytes += declaredSize; + if (m_modSyncReceivedBytes > totalCap || m_modSyncReceivedUncompressedBytes > totalCap) + { + CONSOLE_PRINT("Mod-sync exceeds total bytes cap, aborting", GameConsole::eERROR); + failData(tr("Mod-sync exceeds the total transfer cap.")); + return; + } + + Filesupport::ModSyncCaps caps; + caps.perModBytes = perModCap; + caps.fileCountMax = fileCountMax; + caps.relPathMaxLen = relPathMaxLen; + + qint32 rejectReason = 0; + auto files = Filesupport::extractModSyncPackage(compressedBlob, declaredSize, caps, rejectReason); + if (rejectReason != 0) + { + CONSOLE_PRINT("Mod-sync extract rejected (" + QString::number(rejectReason) + ") for " + modPath, GameConsole::eERROR); + failData(tr("Failed to unpack mod %1.").arg(modPath)); + return; + } + if (files.size() != fileCount) + { + CONSOLE_PRINT("Mod-sync file count mismatch for " + modPath + " (got " + QString::number(files.size()) + ", expected " + QString::number(fileCount) + ")", GameConsole::eERROR); + failData(tr("Mod %1 file count did not match the host's declaration.").arg(modPath)); + return; + } + + qint32 stageReason = 0; + QString stagingRel = Filesupport::stageModSync(settings->getUserPath(), modPath, files, caps, stageReason); + if (stageReason != 0 || stagingRel.isEmpty()) + { + CONSOLE_PRINT("Mod-sync stage rejected (" + QString::number(stageReason) + ") for " + modPath, GameConsole::eERROR); + failData(tr("Failed to stage mod %1 to disk.").arg(modPath)); + return; + } + m_modSyncStagings.append(qMakePair(stagingRel, modPath)); + m_modSyncRequestedSet.remove(modPath); + CONSOLE_PRINT("Mod-sync staged " + modPath + " (" + QString::number(files.size()) + " files)", GameConsole::eINFO); + onModSyncProgress(); +} + +void Multiplayermenu::handleModSyncModBegin(QDataStream & stream, quint64 socketID) +{ + Q_UNUSED(socketID); + if (!m_modSyncActive) + { + CONSOLE_PRINT("MODSYNCMODBEGIN received with no active mod-sync session, ignoring", GameConsole::eWARNING); + return; + } + auto * settings = Settings::getInstance(); + const qint32 relPathMaxLen = settings->getModSyncMaxRelativePathLength(); + const qint64 perModCap = settings->getModSyncMaxPerModBytes(); + const qint32 fileCountMax = settings->getModSyncMaxFiles(); + const qint64 totalCap = settings->getModSyncMaxTotalBytes(); + + auto failBegin = [this](const QString & uiReason) + { + cancelModSyncSession(); + onModSyncFailed(uiReason); + }; + + if (!m_modSyncCurrentChunkMod.modPath.isEmpty()) + { + CONSOLE_PRINT("MODSYNCMODBEGIN while a chunked mod is already in flight; protocol violation", GameConsole::eERROR); + failBegin(tr("Host opened a second chunked mod before finishing the first.")); + return; + } + + qint32 protocolVersion = 0; + stream >> protocolVersion; + if (stream.status() != QDataStream::Ok || protocolVersion != 1) + { + CONSOLE_PRINT("MODSYNCMODBEGIN unsupported protocol version", GameConsole::eERROR); + failBegin(tr("Unsupported mod-sync protocol from host.")); + return; + } + QString modPath; + if (!readBoundedQString(stream, modPath, relPathMaxLen)) + { + CONSOLE_PRINT("MODSYNCMODBEGIN mod path overflow or malformed", GameConsole::eERROR); + failBegin(tr("Malformed mod-sync begin frame.")); + return; + } + qint32 declaredUncompressedSize = 0; + qint32 fileCount = 0; + qint64 compressedTotal = 0; + qint32 chunkCount = 0; + stream >> declaredUncompressedSize; + stream >> fileCount; + stream >> compressedTotal; + stream >> chunkCount; + if (stream.status() != QDataStream::Ok) + { + CONSOLE_PRINT("MODSYNCMODBEGIN truncated header for " + modPath, GameConsole::eERROR); + failBegin(tr("Malformed mod-sync begin frame.")); + return; + } + if (declaredUncompressedSize < 0 || declaredUncompressedSize > perModCap) + { + CONSOLE_PRINT("MODSYNCMODBEGIN declaredUncompressedSize out of range for " + modPath, GameConsole::eERROR); + failBegin(tr("Mod %1 exceeds the per-mod size cap.").arg(modPath)); + return; + } + if (compressedTotal < 0 || compressedTotal > perModCap) + { + CONSOLE_PRINT("MODSYNCMODBEGIN compressedTotal out of range for " + modPath, GameConsole::eERROR); + failBegin(tr("Mod %1 exceeds the per-mod size cap.").arg(modPath)); + return; + } + // Defensive: QByteArray and downstream offsets use qint32 sizes throughout the slice 5 pipeline. perModCap is qint32 today, but guard explicitly so a future widening cannot silently overflow the casts below. + if (compressedTotal > std::numeric_limits::max()) + { + CONSOLE_PRINT("MODSYNCMODBEGIN compressedTotal exceeds qint32 range for " + modPath, GameConsole::eERROR); + failBegin(tr("Mod %1 is too large to receive.").arg(modPath)); + return; + } + if (fileCount < 0 || fileCount > fileCountMax) + { + CONSOLE_PRINT("MODSYNCMODBEGIN fileCount out of range for " + modPath, GameConsole::eERROR); + failBegin(tr("Mod %1 exceeds the per-mod file-count cap.").arg(modPath)); + return; + } + if (chunkCount < 0 || chunkCount > MOD_SYNC_CHUNK_COUNT_MAX) + { + CONSOLE_PRINT("MODSYNCMODBEGIN chunkCount out of range for " + modPath, GameConsole::eERROR); + failBegin(tr("Mod %1 has too many chunks.").arg(modPath)); + return; + } + const qint32 expectedChunkCount = compressedTotal == 0 ? 0 : static_cast((compressedTotal + MOD_SYNC_CHUNK_BYTES - 1) / MOD_SYNC_CHUNK_BYTES); + if (chunkCount != expectedChunkCount) + { + CONSOLE_PRINT("MODSYNCMODBEGIN chunkCount inconsistent with compressedTotal for " + modPath, GameConsole::eERROR); + failBegin(tr("Mod %1 chunk count is inconsistent.").arg(modPath)); + return; + } + if (m_modSyncReceivedBytes + compressedTotal > totalCap || m_modSyncReceivedUncompressedBytes + declaredUncompressedSize > totalCap) + { + CONSOLE_PRINT("MODSYNCMODBEGIN would exceed total cap for " + modPath, GameConsole::eERROR); + failBegin(tr("Mod-sync exceeds the total transfer cap.")); + return; + } + if (!Filesupport::validateModPath(modPath)) + { + CONSOLE_PRINT("MODSYNCMODBEGIN invalid mod path: " + modPath, GameConsole::eERROR); + failBegin(tr("Host sent an invalid mod path.")); + return; + } + if (!m_modSyncRequestedSet.contains(modPath)) + { + CONSOLE_PRINT("MODSYNCMODBEGIN for unrequested or duplicate mod: " + modPath, GameConsole::eERROR); + failBegin(tr("Host sent an unrequested or duplicate mod.")); + return; + } + + m_modSyncCurrentChunkMod = ModSyncChunkAccumulator{}; + m_modSyncCurrentChunkMod.modPath = modPath; + m_modSyncCurrentChunkMod.declaredUncompressedSize = declaredUncompressedSize; + m_modSyncCurrentChunkMod.fileCount = fileCount; + m_modSyncCurrentChunkMod.compressedTotal = compressedTotal; + m_modSyncCurrentChunkMod.expectedChunkCount = chunkCount; + if (compressedTotal > 0) + { + m_modSyncCurrentChunkMod.blob.reserve(static_cast(compressedTotal)); + } + CONSOLE_PRINT("Received MODSYNCMODBEGIN for " + modPath + " (" + QString::number(compressedTotal) + " bytes, " + QString::number(chunkCount) + " chunks)", GameConsole::eINFO); +} + +void Multiplayermenu::handleModSyncModChunk(QDataStream & stream, quint64 socketID) +{ + Q_UNUSED(socketID); + if (!m_modSyncActive) + { + CONSOLE_PRINT("MODSYNCMODCHUNK received with no active mod-sync session, ignoring", GameConsole::eWARNING); + return; + } + if (m_modSyncCurrentChunkMod.modPath.isEmpty()) + { + CONSOLE_PRINT("MODSYNCMODCHUNK without prior MODSYNCMODBEGIN; protocol violation", GameConsole::eERROR); + cancelModSyncSession(); + onModSyncFailed(tr("Host sent chunk without begin.")); + return; + } + auto * settings = Settings::getInstance(); + const qint32 relPathMaxLen = settings->getModSyncMaxRelativePathLength(); + const qint64 totalCap = settings->getModSyncMaxTotalBytes(); + + auto failChunk = [this](const QString & uiReason) + { + cancelModSyncSession(); + onModSyncFailed(uiReason); + }; + + qint32 protocolVersion = 0; + stream >> protocolVersion; + if (stream.status() != QDataStream::Ok || protocolVersion != 1) + { + CONSOLE_PRINT("MODSYNCMODCHUNK unsupported protocol version", GameConsole::eERROR); + failChunk(tr("Unsupported mod-sync protocol from host.")); + return; + } + QString modPath; + if (!readBoundedQString(stream, modPath, relPathMaxLen)) + { + CONSOLE_PRINT("MODSYNCMODCHUNK mod path overflow or malformed", GameConsole::eERROR); + failChunk(tr("Malformed mod-sync chunk frame.")); + return; + } + if (modPath != m_modSyncCurrentChunkMod.modPath) + { + CONSOLE_PRINT("MODSYNCMODCHUNK modPath mismatch; expected " + m_modSyncCurrentChunkMod.modPath + " got " + modPath, GameConsole::eERROR); + failChunk(tr("Host interleaved chunks across mods.")); + return; + } + qint32 chunkIndex = 0; + stream >> chunkIndex; + if (stream.status() != QDataStream::Ok || chunkIndex != m_modSyncCurrentChunkMod.receivedChunkCount) + { + CONSOLE_PRINT("MODSYNCMODCHUNK out-of-order chunkIndex for " + modPath, GameConsole::eERROR); + failChunk(tr("Host sent chunks out of order.")); + return; + } + QByteArray chunkBytes; + if (!readBoundedQByteArray(stream, chunkBytes, MOD_SYNC_CHUNK_BYTES)) + { + CONSOLE_PRINT("MODSYNCMODCHUNK chunk overflow or malformed for " + modPath, GameConsole::eERROR); + failChunk(tr("Mod-sync chunk exceeds size limit.")); + return; + } + const qint64 newAccumulated = static_cast(m_modSyncCurrentChunkMod.blob.size()) + chunkBytes.size(); + if (newAccumulated > m_modSyncCurrentChunkMod.compressedTotal) + { + CONSOLE_PRINT("MODSYNCMODCHUNK accumulated exceeds compressedTotal for " + modPath, GameConsole::eERROR); + failChunk(tr("Host sent more bytes than declared for mod %1.").arg(modPath)); + return; + } + const bool isFinalChunk = (m_modSyncCurrentChunkMod.receivedChunkCount + 1 == m_modSyncCurrentChunkMod.expectedChunkCount); + const qint64 expectedThisChunk = isFinalChunk + ? (m_modSyncCurrentChunkMod.compressedTotal - static_cast(m_modSyncCurrentChunkMod.blob.size())) + : MOD_SYNC_CHUNK_BYTES; + if (chunkBytes.size() != expectedThisChunk) + { + CONSOLE_PRINT("MODSYNCMODCHUNK size " + QString::number(chunkBytes.size()) + " differs from expected " + QString::number(expectedThisChunk) + " for " + modPath, GameConsole::eERROR); + failChunk(tr("Host sent a chunk of unexpected size.")); + return; + } + + m_modSyncCurrentChunkMod.blob.append(chunkBytes); + ++m_modSyncCurrentChunkMod.receivedChunkCount; + m_modSyncReceivedBytes += chunkBytes.size(); + // Display-side proportional uncompressed advance so the bar fraction can move during a single mod; snap-corrected at MODSYNCMODEND to absorb integer-division drift. + qint64 perChunkUncompressedDelta = 0; + if (m_modSyncCurrentChunkMod.compressedTotal > 0 && m_modSyncCurrentChunkMod.declaredUncompressedSize > 0) + { + perChunkUncompressedDelta = static_cast(chunkBytes.size()) * m_modSyncCurrentChunkMod.declaredUncompressedSize / m_modSyncCurrentChunkMod.compressedTotal; + } + m_modSyncCurrentChunkMod.uncompressedAdvanced += perChunkUncompressedDelta; + m_modSyncReceivedUncompressedBytes += perChunkUncompressedDelta; + if (m_modSyncReceivedBytes > totalCap || m_modSyncReceivedUncompressedBytes > totalCap) + { + CONSOLE_PRINT("Mod-sync exceeds total bytes cap mid-chunk, aborting", GameConsole::eERROR); + failChunk(tr("Mod-sync exceeds the total transfer cap.")); + return; + } + onModSyncProgress(); +} + +void Multiplayermenu::handleModSyncModEnd(QDataStream & stream, quint64 socketID) +{ + Q_UNUSED(socketID); + if (!m_modSyncActive) + { + CONSOLE_PRINT("MODSYNCMODEND received with no active mod-sync session, ignoring", GameConsole::eWARNING); + return; + } + if (m_modSyncCurrentChunkMod.modPath.isEmpty()) + { + CONSOLE_PRINT("MODSYNCMODEND without prior MODSYNCMODBEGIN; protocol violation", GameConsole::eERROR); + cancelModSyncSession(); + onModSyncFailed(tr("Host sent end without begin.")); + return; + } + auto * settings = Settings::getInstance(); + const qint32 relPathMaxLen = settings->getModSyncMaxRelativePathLength(); + const qint64 perModCap = settings->getModSyncMaxPerModBytes(); + const qint32 fileCountMax = settings->getModSyncMaxFiles(); + + auto failEnd = [this](const QString & uiReason) + { + cancelModSyncSession(); + onModSyncFailed(uiReason); + }; + + qint32 protocolVersion = 0; + stream >> protocolVersion; + if (stream.status() != QDataStream::Ok || protocolVersion != 1) + { + CONSOLE_PRINT("MODSYNCMODEND unsupported protocol version", GameConsole::eERROR); + failEnd(tr("Unsupported mod-sync protocol from host.")); + return; + } + QString modPath; + if (!readBoundedQString(stream, modPath, relPathMaxLen)) + { + CONSOLE_PRINT("MODSYNCMODEND mod path overflow or malformed", GameConsole::eERROR); + failEnd(tr("Malformed mod-sync end frame.")); + return; + } + if (modPath != m_modSyncCurrentChunkMod.modPath) + { + CONSOLE_PRINT("MODSYNCMODEND modPath mismatch; expected " + m_modSyncCurrentChunkMod.modPath + " got " + modPath, GameConsole::eERROR); + failEnd(tr("Host ended a different mod than the one in flight.")); + return; + } + if (m_modSyncCurrentChunkMod.receivedChunkCount != m_modSyncCurrentChunkMod.expectedChunkCount) + { + CONSOLE_PRINT("MODSYNCMODEND chunk count mismatch for " + modPath, GameConsole::eERROR); + failEnd(tr("Host ended mod %1 before delivering all chunks.").arg(modPath)); + return; + } + if (m_modSyncCurrentChunkMod.blob.size() != m_modSyncCurrentChunkMod.compressedTotal) + { + CONSOLE_PRINT("MODSYNCMODEND blob size mismatch for " + modPath, GameConsole::eERROR); + failEnd(tr("Host blob size for mod %1 did not match its declared total.").arg(modPath)); + return; + } + + Filesupport::ModSyncCaps caps; + caps.perModBytes = perModCap; + caps.fileCountMax = fileCountMax; + caps.relPathMaxLen = relPathMaxLen; + + qint32 rejectReason = 0; + auto files = Filesupport::extractModSyncPackage(m_modSyncCurrentChunkMod.blob, m_modSyncCurrentChunkMod.declaredUncompressedSize, caps, rejectReason); + if (rejectReason != 0) + { + CONSOLE_PRINT("MODSYNCMODEND extract rejected (" + QString::number(rejectReason) + ") for " + modPath, GameConsole::eERROR); + failEnd(tr("Failed to unpack mod %1.").arg(modPath)); + return; + } + if (files.size() != m_modSyncCurrentChunkMod.fileCount) + { + CONSOLE_PRINT("MODSYNCMODEND file count mismatch for " + modPath + " (got " + QString::number(files.size()) + ", expected " + QString::number(m_modSyncCurrentChunkMod.fileCount) + ")", GameConsole::eERROR); + failEnd(tr("Mod %1 file count did not match the host's declaration.").arg(modPath)); + return; + } + qint32 stageReason = 0; + QString stagingRel = Filesupport::stageModSync(settings->getUserPath(), modPath, files, caps, stageReason); + if (stageReason != 0 || stagingRel.isEmpty()) + { + CONSOLE_PRINT("MODSYNCMODEND stage rejected (" + QString::number(stageReason) + ") for " + modPath, GameConsole::eERROR); + failEnd(tr("Failed to stage mod %1 to disk.").arg(modPath)); + return; + } + + // Snap-correct the proportional uncompressed advance so the global counter equals the exact sum of declaredUncompressedSize values. + const qint64 snapDelta = static_cast(m_modSyncCurrentChunkMod.declaredUncompressedSize) - m_modSyncCurrentChunkMod.uncompressedAdvanced; + if (snapDelta != 0) + { + m_modSyncReceivedUncompressedBytes += snapDelta; + } + + m_modSyncStagings.append(qMakePair(stagingRel, modPath)); + m_modSyncRequestedSet.remove(modPath); + CONSOLE_PRINT("Mod-sync staged " + modPath + " via chunked path (" + QString::number(files.size()) + " files)", GameConsole::eINFO); + m_modSyncCurrentChunkMod = ModSyncChunkAccumulator{}; + onModSyncProgress(); +} + +void Multiplayermenu::handleModSyncReject(QDataStream & stream, quint64 socketID) +{ + Q_UNUSED(socketID); + if (!m_modSyncActive) + { + // Drop late or unsolicited rejects after cancel/success so we do not stack a second failure dialog. + CONSOLE_PRINT("MODSYNCREJECT received with no active mod-sync session, ignoring", GameConsole::eWARNING); + return; + } + auto * settings = Settings::getInstance(); + const qint32 relPathMaxLen = settings->getModSyncMaxRelativePathLength(); + + qint32 protocolVersion = 0; + stream >> protocolVersion; + if (stream.status() != QDataStream::Ok || protocolVersion != 1) + { + CONSOLE_PRINT("MODSYNCREJECT unsupported protocol version", GameConsole::eERROR); + cancelModSyncSession(); + onModSyncFailed(tr("Unsupported mod-sync protocol from host.")); + return; + } + QString modPath; + if (!readBoundedQString(stream, modPath, relPathMaxLen)) + { + CONSOLE_PRINT("MODSYNCREJECT mod path overflow or malformed", GameConsole::eERROR); + cancelModSyncSession(); + onModSyncFailed(tr("Malformed mod-sync reject frame.")); + return; + } + qint32 reasonCode = 0; + stream >> reasonCode; + QString reasonMessage; + if (!readBoundedQString(stream, reasonMessage, MOD_SYNC_REASON_CHARS_MAX)) + { + CONSOLE_PRINT("MODSYNCREJECT reason message overflow or malformed (code=" + QString::number(reasonCode) + " mod=" + modPath + ")", GameConsole::eERROR); + cancelModSyncSession(); + onModSyncFailed(tr("Malformed reject reason from host.")); + return; + } + CONSOLE_PRINT("Mod-sync rejected by host: code=" + QString::number(reasonCode) + " mod=" + modPath + " msg=" + reasonMessage, GameConsole::eERROR); + const QString uiReason = reasonMessage.isEmpty() + ? tr("Host rejected the request (code %1).").arg(reasonCode) + : reasonMessage; + cancelModSyncSession(); + onModSyncFailed(uiReason); +} + +void Multiplayermenu::handleModSyncComplete(QDataStream & stream, quint64 socketID) +{ + Q_UNUSED(socketID); + if (!m_modSyncActive) + { + // Drop late or unsolicited completes after cancel/success before parsing so a malformed stale frame does not stack a second failure dialog. + CONSOLE_PRINT("MODSYNCCOMPLETE received with no active mod-sync session, ignoring", GameConsole::eWARNING); + return; + } + qint32 protocolVersion = 0; + stream >> protocolVersion; + if (stream.status() != QDataStream::Ok || protocolVersion != 1) + { + CONSOLE_PRINT("MODSYNCCOMPLETE unsupported protocol version", GameConsole::eERROR); + cancelModSyncSession(); + onModSyncFailed(tr("Unsupported mod-sync protocol from host.")); + return; + } + if (!m_modSyncCurrentChunkMod.modPath.isEmpty()) + { + CONSOLE_PRINT("MODSYNCCOMPLETE arrived with chunked mod " + m_modSyncCurrentChunkMod.modPath + " still in flight; aborting", GameConsole::eERROR); + cancelModSyncSession(); + onModSyncFailed(tr("Host completed before finishing the chunked mod transfer.")); + return; + } + if (!m_modSyncRequestedSet.isEmpty()) + { + CONSOLE_PRINT("MODSYNCCOMPLETE arrived with " + QString::number(m_modSyncRequestedSet.size()) + " requested mods unsent; aborting", GameConsole::eERROR); + cancelModSyncSession(); + onModSyncFailed(tr("Host did not deliver every requested mod.")); + return; + } + if (m_modSyncStagings.isEmpty()) + { + CONSOLE_PRINT("MODSYNCCOMPLETE received with no stagings; nothing to apply", GameConsole::eWARNING); + m_modSyncActive = false; + m_modSyncReceivedBytes = 0; + m_modSyncReceivedUncompressedBytes = 0; + m_modSyncExpectedUncompressedTotal = 0; + m_modSyncCurrentChunkMod = ModSyncChunkAccumulator{}; + m_modSyncPostSyncActiveMods.clear(); + return; + } + auto * settings = Settings::getInstance(); + // Settings first; the manifest is the commit point so it must be the last thing written. + const QString priorActiveModsRaw = settings->stageActiveModsForRestart(m_modSyncPostSyncActiveMods); + QList> manifestSwaps; + for (const auto & p : std::as_const(m_modSyncStagings)) + { + manifestSwaps.append(qMakePair(p.first, p.second)); + } + if (!Filesupport::writePendingModSyncManifest(settings->getUserPath(), manifestSwaps)) + { + CONSOLE_PRINT("Failed to write pending mod-sync manifest; restoring prior active-mod list", GameConsole::eERROR); + settings->restoreActiveModsRaw(priorActiveModsRaw); + cancelModSyncSession(); + onModSyncFailed(tr("Failed to write the pending mod-sync manifest.")); + return; + } + CONSOLE_PRINT("Mod-sync complete: " + QString::number(m_modSyncStagings.size()) + " mods staged. Restarting to apply.", GameConsole::eINFO); + m_modSyncActive = false; + m_modSyncStagings.clear(); + m_modSyncRequestedSet.clear(); + m_modSyncReceivedBytes = 0; + m_modSyncReceivedUncompressedBytes = 0; + m_modSyncExpectedUncompressedTotal = 0; + m_modSyncCurrentChunkMod = ModSyncChunkAccumulator{}; + m_modSyncPostSyncActiveMods.clear(); + // Hold so the progress dialog gets a frame to paint at 100% before the success+restart sequence tears it down. + QTimer::singleShot(500, this, &Multiplayermenu::onModSyncSucceeded); +} + +void Multiplayermenu::sendModSyncReject(quint64 socketID, qint32 reasonCode, const QString & modPath, const QString & message) +{ + QByteArray data; + QDataStream stream(&data, QIODevice::WriteOnly); + stream.setVersion(QDataStream::Version::Qt_6_5); + stream << QString(NetworkCommands::MODSYNCREJECT); + stream << static_cast(1); + stream << modPath; + stream << reasonCode; + stream << message; + emit m_pNetworkInterface->sig_sendData(socketID, data, NetworkInterface::NetworkSerives::Multiplayer, false); + CONSOLE_PRINT("Sent MODSYNCREJECT code=" + QString::number(reasonCode) + " mod=" + modPath + " msg=" + message, GameConsole::eINFO); +} + +void Multiplayermenu::cancelModSyncSession() +{ + // Dialog teardown ahead of the active-session guard so a request that bailed before arming still tears down its progress UI. + if (m_modSyncProgressDialog != nullptr) + { + m_modSyncProgressDialog->detach(); + m_modSyncProgressDialog.reset(); + } + // Send-state clearing is host-side and runs even when m_modSyncActive (client-side flag) is false; the next pump tick short-circuits on socketID==0. + m_modSyncSendState = ModSyncSendState{}; + if (!m_modSyncActive) + { + return; + } + auto * settings = Settings::getInstance(); + const QString installRoot = settings->getUserPath(); + for (const auto & p : std::as_const(m_modSyncStagings)) + { + QString stagingAbs; + if (installRoot.isEmpty()) + { + stagingAbs = p.first; + } + else if (installRoot.endsWith(QChar('/'))) + { + stagingAbs = installRoot + p.first; + } + else + { + stagingAbs = installRoot + QChar('/') + p.first; + } + QDir(stagingAbs).removeRecursively(); + } + m_modSyncStagings.clear(); + m_modSyncRequestedSet.clear(); + m_modSyncReceivedBytes = 0; + m_modSyncReceivedUncompressedBytes = 0; + m_modSyncExpectedUncompressedTotal = 0; + m_modSyncActive = false; + m_modSyncCurrentChunkMod = ModSyncChunkAccumulator{}; + m_modSyncPostSyncActiveMods.clear(); +} + +void Multiplayermenu::confirmModSync(const QStringList & modsToDownload, const QStringList & postSyncActiveMods) +{ + if (modsToDownload.isEmpty()) + { + // Settings-only branch: no untrusted host content downloaded, skip the trust prompt. + const bool ok = requestModSync(modsToDownload, postSyncActiveMods); + if (ok) + { + onModSyncSucceeded(); + } + else + { + onModSyncFailed(tr("Could not start mod sync.")); + } + return; + } + // Trust prompt before any host-supplied mod content is downloaded; mod scripts execute under the QJSEngine in this process. + spDialogMessageBox pTrust = MemoryManagement::create( + tr("You are about to install unverified mods from this host. These mods may include scripts that run in your game. Only continue if you trust this host."), + true, tr("Install"), tr("Cancel")); + connect(pTrust.get(), &DialogMessageBox::sigOk, this, [this, modsToDownload, postSyncActiveMods]() + { + startModSyncDownload(modsToDownload, postSyncActiveMods); + }, Qt::QueuedConnection); + connect(pTrust.get(), &DialogMessageBox::sigCancel, this, &Multiplayermenu::buttonBack, Qt::QueuedConnection); + addChild(pTrust); +} + +void Multiplayermenu::startModSyncDownload(const QStringList & modsToDownload, const QStringList & postSyncActiveMods) +{ + const bool ok = requestModSync(modsToDownload, postSyncActiveMods); + if (!ok) + { + onModSyncFailed(tr("Could not start mod sync.")); + return; + } + if (m_pJoinConnectingDialog != nullptr) + { + m_pJoinConnectingDialog->detach(); + m_pJoinConnectingDialog.reset(); + } + if (m_modSyncProgressDialog != nullptr) + { + m_modSyncProgressDialog->detach(); + m_modSyncProgressDialog.reset(); + } + m_modSyncProgressDialog = MemoryManagement::create(static_cast(modsToDownload.size())); + // Defensive seed in case MODSYNCMANIFEST raced ahead of dialog construction. + m_modSyncProgressDialog->setExpectedTotalBytes(m_modSyncExpectedUncompressedTotal); + connect(m_modSyncProgressDialog.get(), &DialogModSyncProgress::sigCancel, this, [this]() + { + if (!m_modSyncActive) + { + return; + } + cancelModSyncSession(); + onModSyncFailed(tr("Mod sync canceled.")); + }, Qt::QueuedConnection); + addChild(m_modSyncProgressDialog); +} + +void Multiplayermenu::onModSyncProgress() +{ + if (m_modSyncProgressDialog == nullptr) + { + return; + } + // Compressed drives the EMA network-rate display; uncompressed drives bar fraction and ETA. + m_modSyncProgressDialog->setProgress(static_cast(m_modSyncStagings.size()), m_modSyncReceivedBytes, m_modSyncReceivedUncompressedBytes); +} + +void Multiplayermenu::onModSyncSucceeded() +{ + if (m_modSyncProgressDialog != nullptr) + { + m_modSyncProgressDialog->detach(); + m_modSyncProgressDialog.reset(); + } + auto * settings = Settings::getInstance(); + QString rejoinHost = m_serverAddress; + quint16 rejoinPort = m_serverPort; + // Prefer the actually-connected endpoint so secondary-fallback joins rejoin to the working address. + if (m_pNetworkInterface != nullptr) + { + const QString connected = m_pNetworkInterface->getConnectedAdress(); + const quint16 connectedPort = m_pNetworkInterface->getConnectedPort(); + if (!connected.isEmpty()) + { + rejoinHost = connected; + } + if (connectedPort != 0) + { + rejoinPort = connectedPort; + } + } + QStringList argv; + // Forward --userPath only when the parent had it on cmdline; passing it otherwise flips CWD-ini boot mode. + QString restartUserPath; + if (Mainapp::getInstance()->getParser().getUserPath(restartUserPath)) + { + argv << QStringLiteral("--userPath=") + restartUserPath; + } + const bool haveRejoinTarget = !rejoinHost.isEmpty() && rejoinPort != 0; + if (haveRejoinTarget && Filesupport::writeRejoinManifest(settings->getUserPath(), rejoinHost, rejoinPort)) + { + CONSOLE_PRINT("Wrote .rejoin.json for " + rejoinHost + ":" + QString::number(rejoinPort), GameConsole::eINFO); + // Password only after manifest succeeds; do not leak it on the no-rejoin restart. + argv << QStringLiteral("--rejoin-password=") + m_password.getPasswordText(); + } + else if (haveRejoinTarget) + { + CONSOLE_PRINT("Failed to write .rejoin.json; user will return to main menu after restart", GameConsole::eERROR); + } + Mainapp::setRestartArgv(argv); + QCoreApplication::exit(1); +} + +void Multiplayermenu::onModSyncFailed(const QString & reason) +{ + if (m_modSyncProgressDialog != nullptr) + { + m_modSyncProgressDialog->detach(); + m_modSyncProgressDialog.reset(); + } + // Escape because reason can include host-supplied text and DialogMessageBox renders via setHtmlText. + const QString safe = reason.toHtmlEscaped(); + spDialogMessageBox pDialog = MemoryManagement::create(tr("Mod sync failed: %1\n\nLeaving the game.").arg(safe)); + connect(pDialog.get(), &DialogMessageBox::sigOk, this, &Multiplayermenu::buttonBack, Qt::QueuedConnection); + addChild(pDialog); +} diff --git a/multiplayer/multiplayermenu.h b/multiplayer/multiplayermenu.h index 50d76dc71..c57b4ee20 100644 --- a/multiplayer/multiplayermenu.h +++ b/multiplayer/multiplayermenu.h @@ -4,9 +4,15 @@ #include #include #include +#include +#include +#include +#include #include "3rd_party/oxygine-framework/oxygine/actor/Button.h" +#include "coreengine/filesupport.h" + #include "menue/mapselectionmapsmenue.h" #include "network/networkInterface.h" @@ -16,6 +22,7 @@ #include "objects/base/chat.h" #include "objects/dialogs/dialogconnecting.h" +#include "objects/dialogs/dialogmodsyncprogress.h" class Multiplayermenu; using spMultiplayermenu = std::shared_ptr; @@ -184,16 +191,28 @@ protected slots: spGameMap createMapFromStream(QString mapFile, QString scriptFile, QDataStream &stream); QString getNewFileName(QString filename); void clientMapInfo(QDataStream & stream, quint64 socketID); - void readHashInfo(QDataStream & stream, quint64 socketID, QStringList & mods, QStringList & versions, QStringList & myMods, QStringList & myVersions, bool & sameMods, bool & differentHash, bool & sameVersion); - void handleVersionMissmatch(const QStringList & mods, const QStringList & versions, const QStringList & myMods, const QStringList & myVersions, bool sameMods, bool differentHash, bool sameVersion); + void readHashInfo(QDataStream & stream, quint64 socketID, QStringList & mods, QStringList & versions, QStringList & myMods, QStringList & myVersions, QStringList & mismatchedResourceFolders, QStringList & mismatchedMods, QMap & hostModHashes, quint32 & hostCapabilities, bool & sameMods, bool & differentHash, bool & sameVersion, bool & cosmeticAllowed); + void handleVersionMissmatch(const QStringList & mods, const QStringList & versions, const QStringList & myMods, const QStringList & myVersions, const QStringList & mismatchedResourceFolders, const QStringList & mismatchedMods, const QMap & hostModHashes, quint32 hostCapabilities, bool sameMods, bool differentHash, bool sameVersion, bool cosmeticAllowed); + void confirmModSync(const QStringList & modsToDownload, const QStringList & postSyncActiveMods); + void startModSyncDownload(const QStringList & modsToDownload, const QStringList & postSyncActiveMods); + void onModSyncProgress(); + void onModSyncSucceeded(); + void onModSyncFailed(const QString & reason); bool checkMods(const QStringList & mods, const QStringList & versions, QStringList & myMods, QStringList & myVersions, bool filter); void verifyGameData(QDataStream & stream, quint64 socketID); - /** - * @brief filterCosmeticMods - * @param mods - * @param versions - */ - void filterCosmeticMods(QStringList & mods, QStringList & versions, bool filter); + bool requestModSync(const QStringList & modsToDownload, const QStringList & postSyncActiveMods); + void handleModSyncRequest(QDataStream & stream, quint64 socketID); + void handleModSyncManifest(QDataStream & stream, quint64 socketID); + void handleModSyncData(QDataStream & stream, quint64 socketID); + void handleModSyncModBegin(QDataStream & stream, quint64 socketID); + void handleModSyncModChunk(QDataStream & stream, quint64 socketID); + void handleModSyncModEnd(QDataStream & stream, quint64 socketID); + void handleModSyncReject(QDataStream & stream, quint64 socketID); + void handleModSyncComplete(QDataStream & stream, quint64 socketID); + void sendModSyncReject(quint64 socketID, qint32 reasonCode, const QString & modPath, const QString & message); + void cancelModSyncSession(); + // Drives the host-side chunked send loop one chunk per event-loop iteration so a large mod cannot pin the GUI thread. + void pumpModSyncSend(); /** * @brief requestRule * @param socketID @@ -366,11 +385,52 @@ protected slots: bool m_slaveGameReady{false}; Password m_password; quint64 m_hostSocket{0}; + QString m_serverAddress; + quint16 m_serverPort{0}; spDialogConnecting m_pDialogConnecting; + // Held as a member so the mod-sync flow can dismiss it before stacking a second Cancel button. + spDialogConnecting m_pJoinConnectingDialog; QElapsedTimer m_slaveDespawnElapseTimer; QTimer m_slaveDespawnTimer{this}; bool m_despawning{false}; bool m_sameVersionAsServer{false}; + + // Mod-sync client-session state; cleared on completion or abort. + QList> m_modSyncStagings; + QSet m_modSyncRequestedSet; + QStringList m_modSyncPostSyncActiveMods; + qint64 m_modSyncReceivedBytes{0}; + qint64 m_modSyncReceivedUncompressedBytes{0}; + // Sum of declaredUncompressedSize from MODSYNCMANIFEST; 0 when older hosts skip the frame. + qint64 m_modSyncExpectedUncompressedTotal{0}; + bool m_modSyncActive{false}; + spDialogModSyncProgress m_modSyncProgressDialog; + + // Client-side chunked-receive accumulator. modPath empty when no chunked mod is in flight (legacy single-frame path stays untouched). + struct ModSyncChunkAccumulator + { + QString modPath; + qint32 declaredUncompressedSize{0}; + qint32 fileCount{0}; + qint64 compressedTotal{0}; + qint32 expectedChunkCount{0}; + qint32 receivedChunkCount{0}; + qint64 uncompressedAdvanced{0}; + QByteArray blob; + }; + ModSyncChunkAccumulator m_modSyncCurrentChunkMod; + + // Host-side chunked-send pump state. socketID==0 means no send in flight. + struct ModSyncSendState + { + quint64 socketID{0}; + QVector> packages; + qint32 currentMod{0}; + qint32 currentChunk{0}; + bool useChunked{false}; + bool beginEmitted{false}; + }; + ModSyncSendState m_modSyncSendState; }; Q_DECLARE_INTERFACE(Multiplayermenu, "Multiplayermenu"); diff --git a/multiplayer/networkcommands.h b/multiplayer/networkcommands.h index 4fe36c0a4..1df023790 100644 --- a/multiplayer/networkcommands.h +++ b/multiplayer/networkcommands.h @@ -158,6 +158,36 @@ namespace NetworkCommands * @brief GAMEDATAVERIFIED */ const char* const GAMEDATAVERIFIED = "GAMEDATAVERIFIED"; + // Mod-sync wire format v1. Gated by capability bit Filesupport::CapabilityModSync. + const char* const REQUESTMODSYNC = "REQUESTMODSYNC"; + // Optional, before MODSYNCDATA, lets the client budget a byte-based progress bar. Older hosts skip it; older clients ignore it. + const char* const MODSYNCMANIFEST = "MODSYNCMANIFEST"; + const char* const MODSYNCDATA = "MODSYNCDATA"; + // Chunked-transfer triple, used when the client signals ModSyncClientFlagChunked. BEGIN announces a mod, CHUNK*N delivers slices in order, END finalises. + const char* const MODSYNCMODBEGIN = "MODSYNCMODBEGIN"; + const char* const MODSYNCMODCHUNK = "MODSYNCMODCHUNK"; + const char* const MODSYNCMODEND = "MODSYNCMODEND"; + const char* const MODSYNCREJECT = "MODSYNCREJECT"; + const char* const MODSYNCCOMPLETE = "MODSYNCCOMPLETE"; + + // Bit flags appended (optional) to REQUESTMODSYNC; older hosts read past them via QDataStream EOF and treat absent flags as zero. + enum ModSyncClientFlag : qint32 + { + ModSyncClientFlagChunked = 0x00000001, + }; + + // Append-only; serialize as qint32, not the enum's underlying type. 0 reserved so callers can use truthy reads. + enum ModSyncRejectReason + { + ModSyncNoReason = 0, + ModSyncDisabled = 1, + ModSyncUnknownMod = 2, + ModSyncSizeCapExceeded = 3, + ModSyncFileCountCapExceeded = 4, + ModSyncInvalidPath = 5, + ModSyncInternalError = 6, + ModSyncBusy = 7, + }; /** * @brief JOINASPLAYER */ diff --git a/objects/dialogs/dialogconnecting.cpp b/objects/dialogs/dialogconnecting.cpp index df0cd8d80..14256d105 100644 --- a/objects/dialogs/dialogconnecting.cpp +++ b/objects/dialogs/dialogconnecting.cpp @@ -63,12 +63,17 @@ DialogConnecting::DialogConnecting(QString text, qint32 timeoutMs, bool showCanc void DialogConnecting::cancel() { CONSOLE_PRINT("Canceling DialogConnecting", GameConsole::eDEBUG); + m_Timer.stop(); + m_TimerConnectionTimeout.stop(); detach(); } void DialogConnecting::connected() { CONSOLE_PRINT("Connected in DialogConnecting", GameConsole::eDEBUG); + // Stop timers so a retained spDialogConnecting cannot fire a late connectionTimeout that re-emits sigCancel after the user is already in-game. + m_Timer.stop(); + m_TimerConnectionTimeout.stop(); emit sigConnected(); detach(); } diff --git a/objects/dialogs/dialogmodsyncprogress.cpp b/objects/dialogs/dialogmodsyncprogress.cpp new file mode 100644 index 000000000..7b7782e9b --- /dev/null +++ b/objects/dialogs/dialogmodsyncprogress.cpp @@ -0,0 +1,235 @@ +#include "3rd_party/oxygine-framework/oxygine/actor/Stage.h" + +#include "objects/dialogs/dialogmodsyncprogress.h" + +#include "coreengine/interpreter.h" +#include "coreengine/mainapp.h" + +#include "resource_management/objectmanager.h" +#include "resource_management/fontmanager.h" + +DialogModSyncProgress::DialogModSyncProgress(qint32 totalMods) + : QObject(), + m_totalMods(totalMods) +{ +#ifdef GRAPHICSUPPORT + setObjectName("DialogModSyncProgress"); +#endif + Interpreter::setCppOwnerShip(this); + ObjectManager* pObjectManager = ObjectManager::getInstance(); + oxygine::spBox9Sprite pSpriteBox = MemoryManagement::create(); + oxygine::ResAnim* pAnim = pObjectManager->getResAnim("codialog"); + pSpriteBox->setResAnim(pAnim); + pSpriteBox->setSize(oxygine::Stage::getStage()->getWidth(), oxygine::Stage::getStage()->getHeight()); + addChild(pSpriteBox); + pSpriteBox->setPosition(0, 0); + pSpriteBox->setPriority(static_cast(Mainapp::ZOrder::Objects)); + setPriority(static_cast(Mainapp::ZOrder::Dialogs)); + + oxygine::TextStyle headerStyle = oxygine::TextStyle(FontManager::getMainFont24()); + headerStyle.hAlign = oxygine::TextStyle::HALIGN_MIDDLE; + headerStyle.multiline = false; + + m_Header = MemoryManagement::create(); + m_Header->setStyle(headerStyle); + // Same full-width-with-HALIGN_MIDDLE pattern as m_Detail, dodging oxygine's stale getTextRect after setHtmlText. + m_Header->setSize(oxygine::Stage::getStage()->getWidth(), 30); + m_Header->setHtmlText(tr("Downloading host's mod set")); + m_Header->setPosition(0, oxygine::Stage::getStage()->getHeight() / 2 - 80); + pSpriteBox->addChild(m_Header); + + m_barWidth = 480; + const qint32 barHeight = 24; + const qint32 barX = oxygine::Stage::getStage()->getWidth() / 2 - m_barWidth / 2; + const qint32 barY = oxygine::Stage::getStage()->getHeight() / 2 - barHeight / 2; + + m_BarBackground = MemoryManagement::create(); + m_BarBackground->setSize(m_barWidth, barHeight); + m_BarBackground->setColor(QColor(100, 100, 100, 200)); + m_BarBackground->setPosition(barX, barY); + pSpriteBox->addChild(m_BarBackground); + + m_BarFill = MemoryManagement::create(); + m_BarFill->setSize(0, barHeight); + m_BarFill->setColor(QColor(35, 180, 80, 255)); + m_BarFill->setPosition(barX, barY); + pSpriteBox->addChild(m_BarFill); + + oxygine::TextStyle detailStyle = oxygine::TextStyle(FontManager::getMainFont24()); + detailStyle.hAlign = oxygine::TextStyle::HALIGN_MIDDLE; + detailStyle.multiline = false; + + m_Detail = MemoryManagement::create(); + m_Detail->setStyle(detailStyle); + // Full-width field with HALIGN_MIDDLE re-centers via layout on every setHtmlText, dodging oxygine's stale getTextRect on substantial text-length growth. + m_Detail->setSize(oxygine::Stage::getStage()->getWidth(), 30); + m_Detail->setHtmlText(tr("0 / %1 mods").arg(m_totalMods)); + m_Detail->setPosition(0, barY + barHeight + 10); + pSpriteBox->addChild(m_Detail); + + m_CancelButton = pObjectManager->createButton(tr("Cancel"), 150); + m_CancelButton->setPosition(oxygine::Stage::getStage()->getWidth() / 2 - m_CancelButton->getScaledWidth() / 2, + barY + barHeight + 60); + pSpriteBox->addChild(m_CancelButton); + m_CancelButton->addEventListener(oxygine::TouchEvent::CLICK, [this](oxygine::Event*) + { + emit sigCancel(); + }); + connect(this, &DialogModSyncProgress::sigCancel, this, &DialogModSyncProgress::remove, Qt::QueuedConnection); + m_timer.start(); +} + +void DialogModSyncProgress::setExpectedTotalBytes(qint64 expectedUncompressed) +{ + if (expectedUncompressed < 0) + { + expectedUncompressed = 0; + } + // Manifest is canonical and one-shot in the wire-correct flow; refuse downward revisions so the bar stays monotonic against intentional or accidental shrinkage. + if (m_expectedTotalBytes > 0 && expectedUncompressed < m_expectedTotalBytes) + { + return; + } + m_expectedTotalBytes = expectedUncompressed; +} + +void DialogModSyncProgress::setProgress(qint32 stagedMods, qint64 receivedCompressed, qint64 receivedUncompressed) +{ + if (stagedMods < 0) + { + stagedMods = 0; + } + if (m_totalMods > 0 && stagedMods > m_totalMods) + { + stagedMods = m_totalMods; + } + if (receivedCompressed < 0) + { + receivedCompressed = 0; + } + if (receivedUncompressed < 0) + { + receivedUncompressed = 0; + } + + // 50 ms minimum sample window so a hot LAN burst doesn't seed the EMA at multi-GB/s; lastSampleMs starts at 0 (constructor t0) so the first real sample is always rate-eligible. + constexpr qint64 RATE_MIN_DT_MS = 50; + constexpr double alpha = 0.3; + const qint64 nowMs = m_timer.isValid() ? m_timer.elapsed() : 0; + const qint64 dtMs = nowMs - m_lastSampleMs; + const qint64 dCompressed = receivedCompressed - m_lastReceivedCompressed; + const qint64 dUncompressed = receivedUncompressed - m_lastReceivedUncompressed; + if (dtMs >= RATE_MIN_DT_MS && dCompressed >= 0 && dUncompressed >= 0) + { + const double dtSec = static_cast(dtMs) / 1000.0; + const double instantCompressed = static_cast(dCompressed) / dtSec; + const double instantUncompressed = static_cast(dUncompressed) / dtSec; + m_smoothedRateBytesPerSec = alpha * instantCompressed + (1.0 - alpha) * m_smoothedRateBytesPerSec; + m_smoothedUncompressedRateBytesPerSec = alpha * instantUncompressed + (1.0 - alpha) * m_smoothedUncompressedRateBytesPerSec; + m_lastSampleMs = nowMs; + m_lastReceivedCompressed = receivedCompressed; + m_lastReceivedUncompressed = receivedUncompressed; + } + + float targetFraction = 0.0f; + if (m_expectedTotalBytes > 0) + { + targetFraction = static_cast(static_cast(receivedUncompressed) / static_cast(m_expectedTotalBytes)); + } + else if (m_totalMods > 0) + { + targetFraction = static_cast(stagedMods) / static_cast(m_totalMods); + } + if (targetFraction < 0.0f) + { + targetFraction = 0.0f; + } + if (targetFraction > 1.0f) + { + targetFraction = 1.0f; + } + if (targetFraction < m_lastDisplayedFraction) + { + targetFraction = m_lastDisplayedFraction; + } + m_lastDisplayedFraction = targetFraction; + m_BarFill->setWidth(static_cast(m_barWidth * targetFraction)); + + QString detail; + if (m_expectedTotalBytes > 0) + { + detail = tr("%1 / %2 mods, %3 / %4, %5, ETA %6") + .arg(stagedMods) + .arg(m_totalMods) + .arg(formatBytes(receivedUncompressed)) + .arg(formatBytes(m_expectedTotalBytes)) + .arg(formatRate(static_cast(m_smoothedRateBytesPerSec))) + .arg(formatEta(m_smoothedUncompressedRateBytesPerSec > 0.0 + ? static_cast(static_cast(m_expectedTotalBytes - receivedUncompressed) / m_smoothedUncompressedRateBytesPerSec) + : -1)); + } + else + { + detail = tr("%1 / %2 mods, %3, %4") + .arg(stagedMods) + .arg(m_totalMods) + .arg(formatBytes(receivedUncompressed)) + .arg(formatRate(static_cast(m_smoothedRateBytesPerSec))); + } + m_Detail->setHtmlText(detail); +} + +void DialogModSyncProgress::remove() +{ + detach(); +} + +QString DialogModSyncProgress::formatBytes(qint64 bytes) +{ + if (bytes < 1024) + { + return tr("%1 B").arg(bytes); + } + const double kb = static_cast(bytes) / 1024.0; + if (kb < 1024.0) + { + return tr("%1 KB").arg(QString::number(kb, 'f', 1)); + } + const double mb = kb / 1024.0; + if (mb < 1024.0) + { + return tr("%1 MB").arg(QString::number(mb, 'f', 1)); + } + const double gb = mb / 1024.0; + return tr("%1 GB").arg(QString::number(gb, 'f', 2)); +} + +QString DialogModSyncProgress::formatRate(qint64 bytesPerSecond) +{ + if (bytesPerSecond <= 0) + { + return tr("--"); + } + return tr("%1/s").arg(formatBytes(bytesPerSecond)); +} + +QString DialogModSyncProgress::formatEta(qint64 seconds) +{ + if (seconds < 0) + { + return tr("--"); + } + if (seconds < 60) + { + return tr("%1s").arg(seconds); + } + const qint64 minutes = seconds / 60; + const qint64 remSec = seconds % 60; + if (minutes < 60) + { + return tr("%1m %2s").arg(minutes).arg(remSec); + } + const qint64 hours = minutes / 60; + const qint64 remMin = minutes % 60; + return tr("%1h %2m").arg(hours).arg(remMin); +} diff --git a/objects/dialogs/dialogmodsyncprogress.h b/objects/dialogs/dialogmodsyncprogress.h new file mode 100644 index 000000000..19511cf10 --- /dev/null +++ b/objects/dialogs/dialogmodsyncprogress.h @@ -0,0 +1,52 @@ +#ifndef DIALOGMODSYNCPROGRESS_H +#define DIALOGMODSYNCPROGRESS_H + +#include +#include + +#include "3rd_party/oxygine-framework/oxygine/actor/Actor.h" +#include "3rd_party/oxygine-framework/oxygine/actor/Button.h" +#include "3rd_party/oxygine-framework/oxygine/actor/ColorRectSprite.h" +#include "3rd_party/oxygine-framework/oxygine/actor/TextField.h" + +class DialogModSyncProgress; +using spDialogModSyncProgress = std::shared_ptr; + +class DialogModSyncProgress final : public QObject, public oxygine::Actor +{ + Q_OBJECT +public: + explicit DialogModSyncProgress(qint32 totalMods); + virtual ~DialogModSyncProgress() = default; + + // 0 means unknown; once set the field refuses downward revision so a late or hostile follow-up can't shrink the bar. + void setExpectedTotalBytes(qint64 expectedUncompressed); + void setProgress(qint32 stagedMods, qint64 receivedCompressed, qint64 receivedUncompressed); +signals: + void sigCancel(); +public slots: + void remove(); +private: + static QString formatBytes(qint64 bytes); + static QString formatRate(qint64 bytesPerSecond); + static QString formatEta(qint64 seconds); + + qint32 m_totalMods{0}; + qint32 m_barWidth{0}; + qint64 m_expectedTotalBytes{0}; + qint64 m_lastReceivedCompressed{0}; + qint64 m_lastReceivedUncompressed{0}; + qint64 m_lastSampleMs{0}; + // Compressed rate drives the displayed network throughput; uncompressed rate drives ETA so units match remaining-uncompressed. + double m_smoothedRateBytesPerSec{0.0}; + double m_smoothedUncompressedRateBytesPerSec{0.0}; + float m_lastDisplayedFraction{0.0f}; + QElapsedTimer m_timer; + oxygine::spColorRectSprite m_BarBackground; + oxygine::spColorRectSprite m_BarFill; + oxygine::spTextField m_Header; + oxygine::spTextField m_Detail; + oxygine::spButton m_CancelButton; +}; + +#endif diff --git a/resources/ui/options/optionnetworkmenu.xml b/resources/ui/options/optionnetworkmenu.xml index 6ddd5b642..74702e3bc 100644 --- a/resources/ui/options/optionnetworkmenu.xml +++ b/resources/ui/options/optionnetworkmenu.xml @@ -99,6 +99,36 @@ 65535 settings.setGamePort(input) + + + lastX + lastWidth + 10 + lastY + QT_TRANSLATE_NOOP("GAME","When hosting, advertise the mod-sync capability so connecting clients can opt to download your mod set if theirs differs.") + settings.getModSyncEnabled() + settings.setModSyncEnabled(input) + + + + lastX + lastWidth + 10 + lastY + QT_TRANSLATE_NOOP("GAME","When syncing mods from a host, retain the previous mod folder as mods/<name>.bak-<timestamp>. Disable to save disk space if you have large mod files.") + settings.getModSyncKeepBackups() + settings.setModSyncKeepBackups(input) +