From 20b173d69b1313ff133a212e59b9056d9168664c Mon Sep 17 00:00:00 2001 From: TheMasterCreed Date: Wed, 6 May 2026 19:18:25 +0000 Subject: [PATCH 01/23] Add per-mod and per-folder hashes alongside legacy combined hash Rename Filesupport::getRuntimeHash to getLegacyRuntimeHash so the existing combined-SHA-512 stays available for backward-compat on the mod-version handshake wire. Add getPerModHashes and getResourceFolderHashes returning QMap keyed by mod folder path or resource folder name. hashSingleFolder is a thin delegate over getHash so the 3-path expansion, traversal order, and debug logging are inherited mechanically. No behavior change at existing callers; all three (sendMapInfoUpdate, sendVerifyGameData, readHashInfo) use the renamed combined helper with byte-identical output. Groundwork for a per-mod mismatch report in the lobby/verify dialog. --- coreengine/filesupport.cpp | 27 ++++++++++++++++++++++++++- coreengine/filesupport.h | 22 +++++----------------- menue/gamemenue.cpp | 2 +- multiplayer/multiplayermenu.cpp | 4 ++-- 4 files changed, 34 insertions(+), 21 deletions(-) diff --git a/coreengine/filesupport.cpp b/coreengine/filesupport.cpp index 46da59349..89c936f10 100644 --- a/coreengine/filesupport.cpp +++ b/coreengine/filesupport.cpp @@ -54,7 +54,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 +63,31 @@ 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; +} + void Filesupport::writeByteArray(QDataStream& stream, const QByteArray& array) { stream << static_cast(array.size()); diff --git a/coreengine/filesupport.h b/coreengine/filesupport.h index eef65ad72..d710a8d97 100644 --- a/coreengine/filesupport.h +++ b/coreengine/filesupport.h @@ -5,6 +5,7 @@ #include #include #include +#include class Filesupport final { @@ -17,24 +18,11 @@ class Filesupport final static const char* const LIST_FILENAME_ENDING; 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); /** * @brief writeByteArray diff --git a/menue/gamemenue.cpp b/menue/gamemenue.cpp index 0337059da..fe36c5df6 100644 --- a/menue/gamemenue.cpp +++ b/menue/gamemenue.cpp @@ -705,7 +705,7 @@ void GameMenue::sendVerifyGameData(quint64 socketID) stream << mods[i]; stream << versions[i]; } - auto hostHash = Filesupport::getRuntimeHash(mods); + auto hostHash = Filesupport::getLegacyRuntimeHash(mods); if (GameConsole::eDEBUG >= GameConsole::getLogLevel()) { QString hostString = GlobalUtils::getByteArrayString(hostHash); diff --git a/multiplayer/multiplayermenu.cpp b/multiplayer/multiplayermenu.cpp index 0656b70c4..8be651707 100644 --- a/multiplayer/multiplayermenu.cpp +++ b/multiplayer/multiplayermenu.cpp @@ -743,7 +743,7 @@ void Multiplayermenu::sendMapInfoUpdate(quint64 socketID) stream << mods[i]; stream << versions[i]; } - auto hostHash = Filesupport::getRuntimeHash(mods); + auto hostHash = Filesupport::getLegacyRuntimeHash(mods); if (GameConsole::eDEBUG >= GameConsole::getLogLevel()) { QString hostString = GlobalUtils::getByteArrayString(hostHash); @@ -1249,7 +1249,7 @@ void Multiplayermenu::readHashInfo(QDataStream & stream, quint64 socketID, QStri } sameMods = checkMods(mods, versions, myMods, myVersions, filter); QByteArray hostRuntime = Filesupport::readByteArray(stream); - QByteArray ownRuntime = Filesupport::getRuntimeHash(mods); + QByteArray ownRuntime = Filesupport::getLegacyRuntimeHash(mods); if (GameConsole::eDEBUG >= GameConsole::getLogLevel()) { QString hostString = GlobalUtils::getByteArrayString(hostRuntime); From ec19b8eaba35a0625683f8d10acb140c46fba81d Mon Sep 17 00:00:00 2001 From: TheMasterCreed Date: Wed, 6 May 2026 20:57:21 +0000 Subject: [PATCH 02/23] Add sentinel-encoded per-bucket hash payload to mod-version handshake Replace the legacy combined SHA-512 byte array in MAPINFO and VERIFYGAMEDATA with a qint32 sentinel followed by either a 64-byte SHA-512 (sentinel == LegacyRuntimeHashSize) or two QMap for engine resources and per-mod content (sentinel == CurrentHashPayloadVersion). Receivers dispatch on the sentinel; unknown values fail closed without consuming further bytes. Old clients reading the new payload read 1 byte as a QByteArray length, fail differentHash, and drop the stream via handleVersionMissmatch without misalignment. New clients reading legacy hosts take the 64-byte branch. readHashInfo now early-outs after GameVersion deserialize on version mismatch, and the new-payload branch populates two intersection-only out-params (mismatchedResourceFolders, mismatchedMods) for the upcoming dialog refactor. --- coreengine/filesupport.h | 3 ++ menue/gamemenue.cpp | 10 ++-- multiplayer/multiplayermenu.cpp | 83 +++++++++++++++++++++++++-------- multiplayer/multiplayermenu.h | 2 +- 4 files changed, 71 insertions(+), 27 deletions(-) diff --git a/coreengine/filesupport.h b/coreengine/filesupport.h index d710a8d97..ab2f9e8de 100644 --- a/coreengine/filesupport.h +++ b/coreengine/filesupport.h @@ -16,6 +16,9 @@ class Filesupport final QStringList items; }; 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 CurrentHashPayloadVersion = 1; Filesupport() = delete; ~Filesupport() = delete; static QByteArray getLegacyRuntimeHash(const QStringList & mods); diff --git a/menue/gamemenue.cpp b/menue/gamemenue.cpp index fe36c5df6..2809073aa 100644 --- a/menue/gamemenue.cpp +++ b/menue/gamemenue.cpp @@ -705,13 +705,9 @@ void GameMenue::sendVerifyGameData(quint64 socketID) stream << mods[i]; stream << versions[i]; } - auto hostHash = Filesupport::getLegacyRuntimeHash(mods); - if (GameConsole::eDEBUG >= GameConsole::getLogLevel()) - { - QString hostString = GlobalUtils::getByteArrayString(hostHash); - CONSOLE_PRINT("Sending host hash: " + hostString, GameConsole::eDEBUG); - } - Filesupport::writeByteArray(stream, hostHash); + stream << static_cast(Filesupport::CurrentHashPayloadVersion); + 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 8be651707..1dbfe8302 100644 --- a/multiplayer/multiplayermenu.cpp +++ b/multiplayer/multiplayermenu.cpp @@ -743,13 +743,9 @@ void Multiplayermenu::sendMapInfoUpdate(quint64 socketID) stream << mods[i]; stream << versions[i]; } - auto hostHash = Filesupport::getLegacyRuntimeHash(mods); - if (GameConsole::eDEBUG >= GameConsole::getLogLevel()) - { - QString hostString = GlobalUtils::getByteArrayString(hostHash); - CONSOLE_PRINT("Sending host hash: " + hostString, GameConsole::eDEBUG); - } - Filesupport::writeByteArray(stream, hostHash); + stream << static_cast(Filesupport::CurrentHashPayloadVersion); + Filesupport::writeMap(stream, Filesupport::getResourceFolderHashes()); + Filesupport::writeMap(stream, Filesupport::getPerModHashes(mods)); stream << m_saveGame; if (m_saveGame) { @@ -1165,7 +1161,9 @@ 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; + readHashInfo(stream, socketID, mods, versions, myMods, myVersions, mismatchedResourceFolders, mismatchedMods, sameMods, differentHash, sameVersion); if (sameVersion && sameMods && !differentHash) { QString command = QString(NetworkCommands::GAMEDATAVERIFIED); @@ -1230,10 +1228,15 @@ 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, bool & sameMods, bool & differentHash, bool & sameVersion) { GameVersion version; version.deserializeObject(stream); + sameVersion = (version == GameVersion()); + if (!sameVersion) + { + return; + } bool filter = false; stream >> filter; qint32 size = 0; @@ -1248,17 +1251,57 @@ 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::getLegacyRuntimeHash(mods); - if (GameConsole::eDEBUG >= GameConsole::getLogLevel()) + qint32 sentinel = 0; + stream >> sentinel; + 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::CurrentHashPayloadVersion) + { + auto hostResources = Filesupport::readMap(stream); + auto hostMods = Filesupport::readMap(stream); + 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(); + } + 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 +1316,9 @@ 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; + readHashInfo(stream, socketID, mods, versions, myMods, myVersions, mismatchedResourceFolders, mismatchedMods, sameMods, differentHash, sameVersion); if (sameVersion && sameMods && !differentHash) { stream >> m_saveGame; diff --git a/multiplayer/multiplayermenu.h b/multiplayer/multiplayermenu.h index 50d76dc71..211003a83 100644 --- a/multiplayer/multiplayermenu.h +++ b/multiplayer/multiplayermenu.h @@ -184,7 +184,7 @@ 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 readHashInfo(QDataStream & stream, quint64 socketID, QStringList & mods, QStringList & versions, QStringList & myMods, QStringList & myVersions, QStringList & mismatchedResourceFolders, QStringList & mismatchedMods, 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); bool checkMods(const QStringList & mods, const QStringList & versions, QStringList & myMods, QStringList & myVersions, bool filter); void verifyGameData(QDataStream & stream, quint64 socketID); From 18b4e7cf0a69aa9340e233f9d15b14639684ed4c Mon Sep 17 00:00:00 2001 From: TheMasterCreed Date: Wed, 6 May 2026 22:55:03 +0000 Subject: [PATCH 03/23] Render mod mismatches as additive multi-section dialog Refactor Multiplayermenu::handleVersionMissmatch from a mutually exclusive else-if chain into an additive multi-section message that can surface several discrepancy categories at once. The dialog now shows up to five sections in diagnostic order: Missing mods (host has, you don't) Extra mods (you have, host doesn't) Version mismatch (mod.txt) Content mismatch Engine resources differ The membership/version sections come from comparing the host and local mod lists; the content and engine-resource sections come from slice 2's intersection-only mismatch out-params. Each section truncates at five items with a "...and N more" tail; the full list is always emitted at GameConsole::eINFO before truncation so console.log captures the complete picture. Game-version mismatch suppresses every other section because readHashInfo's early-out leaves mods, sameMods, and differentHash at their caller defaults; rendering them would mislead. Legacy 64-byte hash payloads and unknown-sentinel fail-closed paths produce no structured detail and fall back to the existing terse pre-PR strings. The unreachable "no detail and not differentHash" branch logs at eERROR for diagnostic purposes if it ever fires. Caller signatures (verifyGameData, clientMapInfo) updated to declare and pass the two new QStringList& out-params. --- multiplayer/multiplayermenu.cpp | 111 ++++++++++++++++++++++++++------ multiplayer/multiplayermenu.h | 2 +- 2 files changed, 94 insertions(+), 19 deletions(-) diff --git a/multiplayer/multiplayermenu.cpp b/multiplayer/multiplayermenu.cpp index 1dbfe8302..2eb55d14b 100644 --- a/multiplayer/multiplayermenu.cpp +++ b/multiplayer/multiplayermenu.cpp @@ -1176,7 +1176,7 @@ void Multiplayermenu::verifyGameData(QDataStream & stream, quint64 socketID) } else { - handleVersionMissmatch(mods, versions, myMods, myVersions, sameMods, differentHash, sameVersion); + handleVersionMissmatch(mods, versions, myMods, myVersions, mismatchedResourceFolders, mismatchedMods, sameMods, differentHash, sameVersion); } } } @@ -1376,41 +1376,116 @@ void Multiplayermenu::clientMapInfo(QDataStream & stream, quint64 socketID) } else { - handleVersionMissmatch(mods, versions, myMods, myVersions, sameMods, differentHash, sameVersion); + handleVersionMissmatch(mods, versions, myMods, myVersions, mismatchedResourceFolders, mismatchedMods, sameMods, differentHash, sameVersion); } } } -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, bool sameMods, bool differentHash, bool sameVersion) { - // 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; + 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]); + } + else if (versions[i] != myVersions[j]) + { + versionDiffs.append(tr("%1 (host: %2, you: %3)").arg(settings->getModName(mod), versions[i], myVersions[j])); + } } - 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)); + } + + 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); + + 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."); } - pDialogMessageBox = MemoryManagement::create(tr("Host has different mods. Leaving the game again.\nHost mods:\n") + hostModsInfo + "\nYour Mods:\n" + myModsInfo); } else { - pDialogMessageBox = MemoryManagement::create(tr("Failed to join game due to unknown verification failure.")); + message = tr("Cannot join, your game data differs from the host:") + "\n\n" + message + tr("Leaving the game again."); } + + spDialogMessageBox pDialogMessageBox = MemoryManagement::create(message); connect(pDialogMessageBox.get(), &DialogMessageBox::sigOk, this, &Multiplayermenu::buttonBack, Qt::QueuedConnection); addChild(pDialogMessageBox); } diff --git a/multiplayer/multiplayermenu.h b/multiplayer/multiplayermenu.h index 211003a83..623be44f5 100644 --- a/multiplayer/multiplayermenu.h +++ b/multiplayer/multiplayermenu.h @@ -185,7 +185,7 @@ protected slots: QString getNewFileName(QString filename); void clientMapInfo(QDataStream & stream, quint64 socketID); void readHashInfo(QDataStream & stream, quint64 socketID, QStringList & mods, QStringList & versions, QStringList & myMods, QStringList & myVersions, QStringList & mismatchedResourceFolders, QStringList & mismatchedMods, 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 handleVersionMissmatch(const QStringList & mods, const QStringList & versions, const QStringList & myMods, const QStringList & myVersions, const QStringList & mismatchedResourceFolders, const QStringList & mismatchedMods, bool sameMods, bool differentHash, bool sameVersion); bool checkMods(const QStringList & mods, const QStringList & versions, QStringList & myMods, QStringList & myVersions, bool filter); void verifyGameData(QDataStream & stream, quint64 socketID); /** From 88bec92613bdf1a816b1eaec0a095f08543b812d Mon Sep 17 00:00:00 2001 From: TheMasterCreed Date: Wed, 6 May 2026 23:24:50 +0000 Subject: [PATCH 04/23] Fix Settings::filterCosmeticMods pass-by-value bug The function took its mod and version lists by value, so the cosmetic removal loop only mutated locals and the cosmeticModsAllowed game rule was silently a no-op for years. All four call sites in the multiplayer handshake (multiplayermenu.cpp:738, :1188, :2142 and gamemenue.cpp:700) expected mutation. Change the signature to take QStringList & and drop the Q_INVOKABLE attribute (no JS callers; QJSEngine doesn't translate out-params well anyway). Behavior change: cosmetic-only mod content differences will now actually pass the mod-version handshake when cosmeticModsAllowed=ON. This is the documented intent of that setting; players who relied on the broken blocking will be surprised. Slice 4 is also load-bearing for the intersection-only mismatch lists in the named-mod-mismatch dialog, which would otherwise still surface cosmetic noise. Also remove the dead Multiplayermenu::filterCosmeticMods declaration in multiplayermenu.h, which had no definition or callers and was adjacent enough to confuse readers about which helper to call. --- coreengine/settings.cpp | 2 +- coreengine/settings.h | 2 +- multiplayer/multiplayermenu.h | 6 ------ 3 files changed, 2 insertions(+), 8 deletions(-) diff --git a/coreengine/settings.cpp b/coreengine/settings.cpp index a03eb226e..00d3dfbcb 100644 --- a/coreengine/settings.cpp +++ b/coreengine/settings.cpp @@ -1494,7 +1494,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..42598027d 100644 --- a/coreengine/settings.h +++ b/coreengine/settings.h @@ -527,7 +527,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); diff --git a/multiplayer/multiplayermenu.h b/multiplayer/multiplayermenu.h index 623be44f5..c98072aff 100644 --- a/multiplayer/multiplayermenu.h +++ b/multiplayer/multiplayermenu.h @@ -188,12 +188,6 @@ protected slots: void handleVersionMissmatch(const QStringList & mods, const QStringList & versions, const QStringList & myMods, const QStringList & myVersions, const QStringList & mismatchedResourceFolders, const QStringList & mismatchedMods, bool sameMods, bool differentHash, bool sameVersion); 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); /** * @brief requestRule * @param socketID From 2a862ad83e42642ab0a2bd4e2c0478e58bfedb33 Mon Sep 17 00:00:00 2001 From: TheMasterCreed Date: Thu, 7 May 2026 16:30:11 +0000 Subject: [PATCH 05/23] Add mod-sync handshake v2 with capabilities and validator helper Bump Filesupport::CurrentHashPayloadVersion to 2 and add a quint32 capabilities field plus a CapabilityModSync bit. Senders downgrade to v1 wire format when no caps are advertised so existing clients on the parent branch can still join, and the receiver parses all three sentinels (legacy, v1, v2) and surfaces host capabilities through a new out-parameter on readHashInfo. Filesupport::validateModPath now rejects NTFS-illegal characters (less-than, greater-than, colon, double-quote, pipe, question mark, asterisk), trailing dot or space, and Windows reserved device basenames including CONIN$ and CONOUT$, and takes a maxLen parameter for slice-2 relpath callers. Settings registers ModSyncEnabled defaulted false (operator opt-in) plus four caps under [Network]; m_modSyncMaxRelativePathLength is the inside-package path cap and modPath identifiers use Filesupport::ModPathDefaultMaxLen. NetworkCommands gains REQUESTMODSYNC, MODSYNCDATA, MODSYNCREJECT, and MODSYNCCOMPLETE constants and an append-only ModSyncRejectReason enum carried as qint32 on the wire, with ModSyncNoReason=0 reserved so a truthy check on the reason field never falsely reports a reject. Pre-existing fix folded in: Settings::setActiveMods now clears m_activeModVersions before rebuilding. The cmdline --mods flag at commandlineparser.cpp:255 and 260 calls setActiveMods after loadSettings has already populated the version list, so every binary run with --mods previously doubled it. Groundwork for the mod-sync wire surface. The receiver, disk staging, restart-and-rejoin flow, and UI button arrive in later slices. --- coreengine/filesupport.cpp | 67 +++++++++++++++++++++++++++ coreengine/filesupport.h | 12 ++++- coreengine/settings.cpp | 57 +++++++++++++++++++++++ coreengine/settings.h | 19 ++++++++ menue/gamemenue.cpp | 16 ++++++- multiplayer/multiplayermenu.cpp | 82 +++++++++++++++++++++++---------- multiplayer/multiplayermenu.h | 2 +- multiplayer/networkcommands.h | 17 +++++++ 8 files changed, 245 insertions(+), 27 deletions(-) diff --git a/coreengine/filesupport.cpp b/coreengine/filesupport.cpp index 89c936f10..e3dadf992 100644 --- a/coreengine/filesupport.cpp +++ b/coreengine/filesupport.cpp @@ -4,6 +4,7 @@ #include #include +#include const char* const Filesupport::LIST_FILENAME_ENDING = ".bl"; @@ -88,6 +89,72 @@ QMap Filesupport::getResourceFolderHashes() 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()); diff --git a/coreengine/filesupport.h b/coreengine/filesupport.h index ab2f9e8de..ea0a0a72e 100644 --- a/coreengine/filesupport.h +++ b/coreengine/filesupport.h @@ -18,7 +18,16 @@ class Filesupport final 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 CurrentHashPayloadVersion = 1; + 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; static QByteArray getLegacyRuntimeHash(const QStringList & mods); @@ -27,6 +36,7 @@ class Filesupport final static QByteArray hashSingleFolder(const QString & folder, const QStringList & filter); static QByteArray getHash(const QStringList & filter, const QStringList & folders); static void addHash(QCryptographicHash & hash, const QString & folder, const QStringList & filter); + static bool validateModPath(const QString & modPath, qint32 maxLen = ModPathDefaultMaxLen); /** * @brief writeByteArray * @param stream diff --git a/coreengine/settings.cpp b/coreengine/settings.cpp index 00d3dfbcb..dcad654bf 100644 --- a/coreengine/settings.cpp +++ b/coreengine/settings.cpp @@ -260,6 +260,56 @@ 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; +} + QString Settings::getMailServerSendAddress() { return m_mailServerSendAddress; @@ -1044,6 +1094,8 @@ QStringList Settings::getActiveMods() 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 +1454,11 @@ 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()), // mailing MemoryManagement::create>("Mailing", "MailServerAddress", &m_mailServerAddress, "", "", ""), MemoryManagement::create>("Mailing", "MailServerPort", &m_mailServerPort, 0, 0, std::numeric_limits::max()), diff --git a/coreengine/settings.h b/coreengine/settings.h index 42598027d..dffc8b749 100644 --- a/coreengine/settings.h +++ b/coreengine/settings.h @@ -353,6 +353,16 @@ 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 QString getMailServerSendAddress(); Q_INVOKABLE void setMailServerSendAddress(const QString newMailServerSendAddress); Q_INVOKABLE qint32 getMailServerAuthMethod(); @@ -934,6 +944,15 @@ 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}; + // mailing QString m_mailServerAddress; quint16 m_mailServerPort{0}; diff --git a/menue/gamemenue.cpp b/menue/gamemenue.cpp index 2809073aa..dbff6b394 100644 --- a/menue/gamemenue.cpp +++ b/menue/gamemenue.cpp @@ -705,7 +705,21 @@ void GameMenue::sendVerifyGameData(quint64 socketID) stream << mods[i]; stream << versions[i]; } - stream << static_cast(Filesupport::CurrentHashPayloadVersion); + 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) + { + 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 2eb55d14b..6be6983ad 100644 --- a/multiplayer/multiplayermenu.cpp +++ b/multiplayer/multiplayermenu.cpp @@ -743,7 +743,21 @@ void Multiplayermenu::sendMapInfoUpdate(quint64 socketID) stream << mods[i]; stream << versions[i]; } - stream << static_cast(Filesupport::CurrentHashPayloadVersion); + 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) + { + stream << static_cast(Filesupport::LegacyHashPayloadVersion); + } + else + { + stream << static_cast(Filesupport::CurrentHashPayloadVersion); + stream << capabilities; + } Filesupport::writeMap(stream, Filesupport::getResourceFolderHashes()); Filesupport::writeMap(stream, Filesupport::getPerModHashes(mods)); stream << m_saveGame; @@ -1163,7 +1177,9 @@ void Multiplayermenu::verifyGameData(QDataStream & stream, quint64 socketID) QStringList myVersions; QStringList mismatchedResourceFolders; QStringList mismatchedMods; - readHashInfo(stream, socketID, mods, versions, myMods, myVersions, mismatchedResourceFolders, mismatchedMods, sameMods, differentHash, sameVersion); + quint32 hostCapabilities = 0; + readHashInfo(stream, socketID, mods, versions, myMods, myVersions, mismatchedResourceFolders, mismatchedMods, hostCapabilities, sameMods, differentHash, sameVersion); + Q_UNUSED(hostCapabilities); if (sameVersion && sameMods && !differentHash) { QString command = QString(NetworkCommands::GAMEDATAVERIFIED); @@ -1228,8 +1244,9 @@ 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, QStringList & mismatchedResourceFolders, QStringList & mismatchedMods, 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, quint32 & hostCapabilities, bool & sameMods, bool & differentHash, bool & sameVersion) { + hostCapabilities = 0; GameVersion version; version.deserializeObject(stream); sameVersion = (version == GameVersion()); @@ -1253,27 +1270,10 @@ void Multiplayermenu::readHashInfo(QDataStream & stream, quint64 socketID, QStri sameMods = checkMods(mods, versions, myMods, myVersions, filter); qint32 sentinel = 0; stream >> sentinel; - 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::CurrentHashPayloadVersion) + + // Call once per readHashInfo: appends to mismatch lists without clearing. + auto compareMaps = [&](const QMap & hostResources, const QMap & hostMods) { - auto hostResources = Filesupport::readMap(stream); - auto hostMods = Filesupport::readMap(stream); auto ownResources = Filesupport::getResourceFolderHashes(); auto ownMods = Filesupport::getPerModHashes(myMods); for (auto iter = hostResources.constBegin(); iter != hostResources.constEnd(); ++iter) @@ -1296,6 +1296,38 @@ void Multiplayermenu::readHashInfo(QDataStream & stream, quint64 socketID, QStri } } 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 { @@ -1318,7 +1350,9 @@ void Multiplayermenu::clientMapInfo(QDataStream & stream, quint64 socketID) QStringList myVersions; QStringList mismatchedResourceFolders; QStringList mismatchedMods; - readHashInfo(stream, socketID, mods, versions, myMods, myVersions, mismatchedResourceFolders, mismatchedMods, sameMods, differentHash, sameVersion); + quint32 hostCapabilities = 0; + readHashInfo(stream, socketID, mods, versions, myMods, myVersions, mismatchedResourceFolders, mismatchedMods, hostCapabilities, sameMods, differentHash, sameVersion); + Q_UNUSED(hostCapabilities); if (sameVersion && sameMods && !differentHash) { stream >> m_saveGame; diff --git a/multiplayer/multiplayermenu.h b/multiplayer/multiplayermenu.h index c98072aff..388a15caf 100644 --- a/multiplayer/multiplayermenu.h +++ b/multiplayer/multiplayermenu.h @@ -184,7 +184,7 @@ 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, QStringList & mismatchedResourceFolders, QStringList & mismatchedMods, bool & sameMods, bool & differentHash, bool & sameVersion); + void readHashInfo(QDataStream & stream, quint64 socketID, QStringList & mods, QStringList & versions, QStringList & myMods, QStringList & myVersions, QStringList & mismatchedResourceFolders, QStringList & mismatchedMods, quint32 & hostCapabilities, bool & sameMods, bool & differentHash, bool & sameVersion); void handleVersionMissmatch(const QStringList & mods, const QStringList & versions, const QStringList & myMods, const QStringList & myVersions, const QStringList & mismatchedResourceFolders, const QStringList & mismatchedMods, bool sameMods, bool differentHash, bool sameVersion); bool checkMods(const QStringList & mods, const QStringList & versions, QStringList & myMods, QStringList & myVersions, bool filter); void verifyGameData(QDataStream & stream, quint64 socketID); diff --git a/multiplayer/networkcommands.h b/multiplayer/networkcommands.h index 4fe36c0a4..dfcc861dc 100644 --- a/multiplayer/networkcommands.h +++ b/multiplayer/networkcommands.h @@ -158,6 +158,23 @@ 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"; + const char* const MODSYNCDATA = "MODSYNCDATA"; + const char* const MODSYNCREJECT = "MODSYNCREJECT"; + const char* const MODSYNCCOMPLETE = "MODSYNCCOMPLETE"; + + // 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, + }; /** * @brief JOINASPLAYER */ From a2a0658df6d007ae59731d8ad077a09339838fab Mon Sep 17 00:00:00 2001 From: TheMasterCreed Date: Thu, 7 May 2026 16:30:22 +0000 Subject: [PATCH 06/23] Add filesystem helpers for mod-sync packaging and pending swaps Introduce ModSyncCaps and ModSyncPackage structs for cap parameters and packaging results. Filesupport::validateRelativeFilePath is the segment-walk equivalent of validateModPath for inside-package relpaths, allowing nested directories while applying the same NTFS-illegal-char and reserved-device rules per path segment. buildModSyncPackage walks a mod folder, filters .git, .svn, __pycache__, .sync-staging-*, and .bak-* directory segments, validates relpaths, enforces per-mod size and file-count caps, and qCompresses a QMap from relPath to file contents. extractModSyncPackage qUncompresses, revalidates caps and paths, and defends against decompression bombs and forged size headers. stageModSync writes the validated QMap to mods/.sync-staging-/ and deletes the partial staging on any write failure. reapModSyncFolders is the boot-time cleanup, dropping stale staging directories (mtime older than one hour) and pruning per-mod backups beyond a keep-most-recent-N count (default 3). writePendingModSyncManifest and executePendingModSyncManifest implement the JSON-based pending-swap protocol; the manifest lists {staging, final} pairs, the executor renames finalAbs to .bak- if it already exists then renames staging into place, and the manifest is deleted after successful application. Reject-reason values are qint32 rather than the NetworkCommands enum so coreengine stays decoupled from networkcommands.h. The kModSync* anonymous-namespace constants mirror NetworkCommands::ModSyncRejectReason and are pinned via static_assert in the receiver translation unit in slice 4. Groundwork for boot-time apply and per-client request handling. This slice ships filesystem helpers only; no network handlers or boot wiring yet. --- coreengine/filesupport.cpp | 444 +++++++++++++++++++++++++++++++++++++ coreengine/filesupport.h | 24 ++ 2 files changed, 468 insertions(+) diff --git a/coreengine/filesupport.cpp b/coreengine/filesupport.cpp index e3dadf992..9f5817595 100644 --- a/coreengine/filesupport.cpp +++ b/coreengine/filesupport.cpp @@ -4,6 +4,12 @@ #include #include +#include +#include +#include +#include +#include +#include #include const char* const Filesupport::LIST_FILENAME_ENDING = ".bl"; @@ -232,3 +238,441 @@ 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; + } + QMap files; + qint64 uncompressedTotal = 0; + qint32 fileCount = 0; + QDirIterator it(modRoot, QDir::Files | QDir::NoSymLinks, QDirIterator::Subdirectories); + while (it.hasNext()) + { + const QString absolute = it.next(); + const QString rel = QDir(modRoot).relativeFilePath(absolute); + // Filter VCS metadata, build artefacts, and our own staging/backup dirs. + if (rel.startsWith(QStringLiteral(".git/")) || + rel.startsWith(QStringLiteral(".svn/")) || + rel.startsWith(QStringLiteral("__pycache__/")) || + rel.contains(QStringLiteral(".sync-staging-")) || + rel.contains(QStringLiteral(".bak-"))) + { + 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); + writeMap(stream, files); + } + pkg.declaredUncompressedSize = static_cast(serialized.size()); + pkg.compressedBlob = qCompress(serialized); + 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 (compressedBlob.size() > caps.perModBytes) + { + rejectReason = kModSyncSizeCapExceeded; + return files; + } + if (declaredUncompressedSize <= 0 || declaredUncompressedSize > caps.perModBytes) + { + rejectReason = kModSyncSizeCapExceeded; + return files; + } + const QByteArray serialized = qUncompress(compressedBlob); + if (serialized.isEmpty() || serialized.size() != declaredUncompressedSize) + { + rejectReason = kModSyncInternalError; + return files; + } + QByteArray mut = serialized; + QDataStream stream(&mut, QIODevice::ReadOnly); + stream.setVersion(QDataStream::Version::Qt_6_5); + auto map = readMap(stream); + if (stream.status() != QDataStream::Ok) + { + rejectReason = kModSyncInternalError; + return files; + } + qint32 fileCount = 0; + qint64 uncompressedTotal = 0; + for (auto iter = map.constBegin(); iter != map.constEnd(); ++iter) + { + if (!validateRelativeFilePath(iter.key(), caps.relPathMaxLen)) + { + rejectReason = kModSyncInvalidPath; + files.clear(); + return files; + } + if (iter.value().size() > caps.perModBytes) + { + rejectReason = kModSyncSizeCapExceeded; + files.clear(); + return files; + } + uncompressedTotal += iter.value().size(); + if (uncompressedTotal > caps.perModBytes) + { + rejectReason = kModSyncSizeCapExceeded; + files.clear(); + return files; + } + ++fileCount; + if (fileCount > caps.fileCountMax) + { + rejectReason = kModSyncFileCountCapExceeded; + files.clear(); + return files; + } + } + files = map; + return files; +} + +QString Filesupport::stageModSync(const QString & installRoot, const QString & modPath, const QMap & files, qint32 & rejectReason) +{ + rejectReason = 0; + if (!validateModPath(modPath)) + { + rejectReason = kModSyncInvalidPath; + return QString(); + } + const qint64 pid = QCoreApplication::applicationPid(); + const QString stagingPath = joinPath(installRoot, modPath) + QStringLiteral(".sync-staging-") + QString::number(pid); + QDir stagingDir(stagingPath); + if (stagingDir.exists()) + { + stagingDir.removeRecursively(); + } + if (!QDir().mkpath(stagingPath)) + { + rejectReason = kModSyncInternalError; + return QString(); + } + for (auto iter = files.constBegin(); iter != files.constEnd(); ++iter) + { + const QString full = joinPath(stagingPath, iter.key()); + const QFileInfo fi(full); + if (!QDir().mkpath(fi.absolutePath())) + { + QDir(stagingPath).removeRecursively(); + rejectReason = kModSyncInternalError; + return QString(); + } + QSaveFile f(full); + if (!f.open(QIODevice::WriteOnly)) + { + QDir(stagingPath).removeRecursively(); + rejectReason = kModSyncInternalError; + return QString(); + } + f.write(iter.value()); + if (!f.commit()) + { + QDir(stagingPath).removeRecursively(); + rejectReason = kModSyncInternalError; + return QString(); + } + } + return stagingPath; +} + +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); + for (const auto & entry : entries) + { + const QString name = entry.fileName(); + const qint32 stagingIdx = name.indexOf(QStringLiteral(".sync-staging-")); + if (stagingIdx > 0) + { + // 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; + } + const qint32 bakIdx = name.indexOf(QStringLiteral(".bak-")); + if (bakIdx > 0) + { + backupsByMod[name.left(bakIdx)].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; +} + +void Filesupport::executePendingModSyncManifest(const QString & installRoot, const QString & userDataPath) +{ + const QString path = pendingModSyncManifestPath(userDataPath); + QFile f(path); + if (!f.exists()) + { + return; + } + if (!f.open(QIODevice::ReadOnly)) + { + CONSOLE_PRINT("Pending mod-sync manifest exists but cannot be read: " + path, GameConsole::eERROR); + return; + } + 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; + } + 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; + } + const QJsonArray swaps = root.value(QStringLiteral("swaps")).toArray(); + const QString isoStamp = QDateTime::currentDateTime().toString(QStringLiteral("yyyyMMdd-HHmmss")); + 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; + } + const QString stagingAbs = joinPath(installRoot, stagingRel); + const QString finalAbs = joinPath(installRoot, finalRel); + if (!QFileInfo::exists(stagingAbs)) + { + CONSOLE_PRINT("Manifest staging missing, skipping: " + stagingAbs, GameConsole::eERROR); + continue; + } + if (QFileInfo::exists(finalAbs)) + { + const QString backupAbs = finalAbs + QStringLiteral(".bak-") + isoStamp; + 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); + continue; + } + CONSOLE_PRINT("Mod sync applied: " + finalRel, GameConsole::eINFO); + } + QFile::remove(path); +} diff --git a/coreengine/filesupport.h b/coreengine/filesupport.h index ea0a0a72e..eccde7f9d 100644 --- a/coreengine/filesupport.h +++ b/coreengine/filesupport.h @@ -6,6 +6,7 @@ #include #include #include +#include class Filesupport final { @@ -15,6 +16,21 @@ class Filesupport final QString name; QStringList items; }; + struct ModSyncCaps + { + qint32 perModBytes{64 * 1024 * 1024}; + qint32 fileCountMax{5000}; + qint32 relPathMaxLen{260}; + }; + // rejectReason values match NetworkCommands::ModSyncRejectReason; kept as qint32 to avoid + // pulling networkcommands.h into coreengine. + 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. @@ -37,6 +53,14 @@ class Filesupport final static QByteArray getHash(const QStringList & filter, const QStringList & folders); 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); + static QString stageModSync(const QString & installRoot, const QString & modPath, const QMap & files, 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); + static void executePendingModSyncManifest(const QString & installRoot, const QString & userDataPath); /** * @brief writeByteArray * @param stream From bae73384760b56a8f8cd83d7c5005e8901c32f8a Mon Sep 17 00:00:00 2001 From: TheMasterCreed Date: Thu, 7 May 2026 16:30:34 +0000 Subject: [PATCH 07/23] Harden mod-sync helpers and tighten manifest swap semantics extractModSyncPackage now gates the qCompress framing-size header before calling qUncompress, so a forged size cannot trigger pre-allocation past caps.perModBytes. The map decoder is bounded across the whole entry; count is checked against fileCountMax up front, then each entry's length is read separately and validated before any allocation, with keys carried as UTF-8 QByteArrays so the bounded reader does not have to deal with QString's UTF-16BE shape. Duplicate-key rejection was added at the same site. buildModSyncPackage caps serialized size before the qint32 cast on declaredUncompressedSize, in addition to the existing raw and compressed checks, preventing silent truncation if perModBytes is ever raised past 2GB. The cruft filter is now segment-based, with .sync-staging-* and .bak-* matched only on directory segments so a legitimate filename like scripts/foo.bak-test.js is preserved; .DS_Store, Thumbs.db, and desktop.ini are also filtered. stageModSync returns the staging path relative to installRoot so the caller writes the same shape into the manifest that the executor reads, takes ModSyncCaps explicitly, and re-validates relpaths, file count, per-file size, and running total before any disk write. executePendingModSyncManifest validates the manifest staging-path shape (finalRel + ".sync-staging-" + digits) before any rename, requires both staging and final to be directories, rolls the backup back to final on partial-rename failure so the active mod folder is never left missing, and returns the list of applied final paths so callers can distinguish ground truth from the planned swap set. The backup name uses a UTC iso timestamp with millisecond precision plus a numeric counter loop to avoid same-instant collisions, and the manifest size is capped at 1MB before parse to defend against runaway JSON allocation. Defensive negative-cap reject was added at extractor entry. Groundwork for slice 3's boot-time apply path. The manifest format and caller contract are now stable. --- coreengine/filesupport.cpp | 263 ++++++++++++++++++++++++++++++------- coreengine/filesupport.h | 10 +- 2 files changed, 219 insertions(+), 54 deletions(-) diff --git a/coreengine/filesupport.cpp b/coreengine/filesupport.cpp index 9f5817595..cf7fd696f 100644 --- a/coreengine/filesupport.cpp +++ b/coreengine/filesupport.cpp @@ -11,6 +11,8 @@ #include #include #include +#include +#include const char* const Filesupport::LIST_FILENAME_ENDING = ".bl"; @@ -354,12 +356,30 @@ Filesupport::ModSyncPackage Filesupport::buildModSyncPackage(const QString & ins { const QString absolute = it.next(); const QString rel = QDir(modRoot).relativeFilePath(absolute); - // Filter VCS metadata, build artefacts, and our own staging/backup dirs. - if (rel.startsWith(QStringLiteral(".git/")) || - rel.startsWith(QStringLiteral(".svn/")) || - rel.startsWith(QStringLiteral("__pycache__/")) || - rel.contains(QStringLiteral(".sync-staging-")) || - rel.contains(QStringLiteral(".bak-"))) + 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; } @@ -399,7 +419,18 @@ Filesupport::ModSyncPackage Filesupport::buildModSyncPackage(const QString & ins { QDataStream stream(&serialized, QIODevice::WriteOnly); stream.setVersion(QDataStream::Version::Qt_6_5); - writeMap(stream, files); + // 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()); pkg.compressedBlob = qCompress(serialized); @@ -417,6 +448,11 @@ QMap Filesupport::extractModSyncPackage(const QByteArray & { 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; @@ -427,57 +463,97 @@ QMap Filesupport::extractModSyncPackage(const QByteArray & 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; } - QByteArray mut = serialized; - QDataStream stream(&mut, QIODevice::ReadOnly); + QDataStream stream(serialized); stream.setVersion(QDataStream::Version::Qt_6_5); - auto map = readMap(stream); - if (stream.status() != QDataStream::Ok) + qint32 mapSize = 0; + stream >> mapSize; + if (stream.status() != QDataStream::Ok || mapSize < 0 || mapSize > caps.fileCountMax) { - rejectReason = kModSyncInternalError; - return files; + rejectReason = kModSyncFileCountCapExceeded; + return QMap(); } - qint32 fileCount = 0; + // 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 (auto iter = map.constBegin(); iter != map.constEnd(); ++iter) + for (qint32 i = 0; i < mapSize; ++i) { - if (!validateRelativeFilePath(iter.key(), caps.relPathMaxLen)) + 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; - files.clear(); - return files; + return QMap(); } - if (iter.value().size() > caps.perModBytes) + if (!readBoundedBytes(value, caps.perModBytes)) { rejectReason = kModSyncSizeCapExceeded; - files.clear(); - return files; + return QMap(); + } + const QString key = QString::fromUtf8(keyUtf8); + if (!validateRelativeFilePath(key, caps.relPathMaxLen)) + { + rejectReason = kModSyncInvalidPath; + return QMap(); } - uncompressedTotal += iter.value().size(); + uncompressedTotal += value.size(); if (uncompressedTotal > caps.perModBytes) { rejectReason = kModSyncSizeCapExceeded; - files.clear(); - return files; + return QMap(); } - ++fileCount; - if (fileCount > caps.fileCountMax) + if (files.contains(key)) { - rejectReason = kModSyncFileCountCapExceeded; - files.clear(); - return files; + rejectReason = kModSyncInvalidPath; + return QMap(); } + files.insert(key, value); } - files = map; return files; } -QString Filesupport::stageModSync(const QString & installRoot, const QString & modPath, const QMap & files, qint32 & rejectReason) +QString Filesupport::stageModSync(const QString & installRoot, const QString & modPath, const QMap & files, const ModSyncCaps & caps, qint32 & rejectReason) { rejectReason = 0; if (!validateModPath(modPath)) @@ -485,44 +561,71 @@ QString Filesupport::stageModSync(const QString & installRoot, const QString & m 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 stagingPath = joinPath(installRoot, modPath) + QStringLiteral(".sync-staging-") + QString::number(pid); - QDir stagingDir(stagingPath); - if (stagingDir.exists()) + const QString stagingRel = modPath + QStringLiteral(".sync-staging-") + QString::number(pid); + const QString stagingAbs = joinPath(installRoot, stagingRel); + QDir stagingDir(stagingAbs); + if (stagingDir.exists() && !stagingDir.removeRecursively()) { - stagingDir.removeRecursively(); + rejectReason = kModSyncInternalError; + return QString(); } - if (!QDir().mkpath(stagingPath)) + if (!QDir().mkpath(stagingAbs)) { rejectReason = kModSyncInternalError; return QString(); } for (auto iter = files.constBegin(); iter != files.constEnd(); ++iter) { - const QString full = joinPath(stagingPath, iter.key()); + const QString full = joinPath(stagingAbs, iter.key()); const QFileInfo fi(full); if (!QDir().mkpath(fi.absolutePath())) { - QDir(stagingPath).removeRecursively(); + QDir(stagingAbs).removeRecursively(); rejectReason = kModSyncInternalError; return QString(); } QSaveFile f(full); if (!f.open(QIODevice::WriteOnly)) { - QDir(stagingPath).removeRecursively(); + QDir(stagingAbs).removeRecursively(); rejectReason = kModSyncInternalError; return QString(); } f.write(iter.value()); if (!f.commit()) { - QDir(stagingPath).removeRecursively(); + QDir(stagingAbs).removeRecursively(); rejectReason = kModSyncInternalError; return QString(); } } - return stagingPath; + return stagingRel; } void Filesupport::reapModSyncFolders(const QString & installRoot, qint32 backupKeep) @@ -609,18 +712,27 @@ bool Filesupport::writePendingModSyncManifest(const QString & userDataPath, cons return true; } -void Filesupport::executePendingModSyncManifest(const QString & installRoot, const QString & userDataPath) +QStringList Filesupport::executePendingModSyncManifest(const QString & installRoot, const QString & userDataPath) { + QStringList applied; const QString path = pendingModSyncManifestPath(userDataPath); QFile f(path); if (!f.exists()) { - return; + 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; + return applied; } const QByteArray data = f.readAll(); f.close(); @@ -630,17 +742,18 @@ void Filesupport::executePendingModSyncManifest(const QString & installRoot, con { CONSOLE_PRINT("Pending mod-sync manifest is invalid JSON: " + parseErr.errorString(), GameConsole::eERROR); QFile::remove(path); - return; + 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; + return applied; } const QJsonArray swaps = root.value(QStringLiteral("swaps")).toArray(); - const QString isoStamp = QDateTime::currentDateTime().toString(QStringLiteral("yyyyMMdd-HHmmss")); + // 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")); for (const auto & v : swaps) { const QJsonObject entry = v.toObject(); @@ -651,16 +764,59 @@ void Filesupport::executePendingModSyncManifest(const QString & installRoot, con 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); - if (!QFileInfo::exists(stagingAbs)) + const QFileInfo stagingInfo(stagingAbs); + if (!stagingInfo.exists()) { CONSOLE_PRINT("Manifest staging missing, skipping: " + stagingAbs, GameConsole::eERROR); continue; } - if (QFileInfo::exists(finalAbs)) + // 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) { - const QString backupAbs = finalAbs + QStringLiteral(".bak-") + isoStamp; + // 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); @@ -670,9 +826,16 @@ void Filesupport::executePendingModSyncManifest(const QString & installRoot, con 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); } QFile::remove(path); + return applied; } diff --git a/coreengine/filesupport.h b/coreengine/filesupport.h index eccde7f9d..315eb1703 100644 --- a/coreengine/filesupport.h +++ b/coreengine/filesupport.h @@ -22,8 +22,7 @@ class Filesupport final qint32 fileCountMax{5000}; qint32 relPathMaxLen{260}; }; - // rejectReason values match NetworkCommands::ModSyncRejectReason; kept as qint32 to avoid - // pulling networkcommands.h into coreengine. + // rejectReason matches NetworkCommands::ModSyncRejectReason; qint32 keeps coreengine decoupled from multiplayer/. struct ModSyncPackage { QByteArray compressedBlob; @@ -56,11 +55,14 @@ class Filesupport final 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); - static QString stageModSync(const QString & installRoot, const QString & modPath, const QMap & files, 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); - static void executePendingModSyncManifest(const QString & installRoot, const QString & userDataPath); + // 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); /** * @brief writeByteArray * @param stream From be75f781cff52c81693e0cd7fc6e4079b60d4a33 Mon Sep 17 00:00:00 2001 From: TheMasterCreed Date: Thu, 7 May 2026 16:30:45 +0000 Subject: [PATCH 08/23] Wire mod-sync executor and reaper into Settings::loadSettings Settings::loadSettings now calls Filesupport::executePendingModSyncManifest(m_userPath, m_userPath) followed by Filesupport::reapModSyncFolders(m_userPath) between setUserPath(m_userPath) and setActiveMods(m_activeMods). Ordering matters: the executor must commit any pending swap before setActiveMods runs its existence-check prune, otherwise a just-synced folder would be silently dropped from m_activeMods. m_userPath has already been re-read from ini and normalized through setUserPath at this point, so the executor sees the configured user path; the empty (CWD-relative default install) and non-empty (custom user path) cases both work symmetrically through joinPath. reapModSyncFolders tightened its directory-name matchers so the reaper only acts on the exact slice-2-generated shapes. Staging requires .sync-staging- where the prefix passes validateModPath and the suffix is an all-digit pid. Backup requires .bak- with optional collision counter, where isoStamp is the exact 19-character yyyyMMdd-HHmmsszzzZ format and the optional counter is a hyphen followed by at least one digit. A legitimately weirdly-named mod folder no longer matches. Index-based shape checks were chosen over QRegularExpression to avoid the regex compile cost; the format is captured in a comment at the call site. Groundwork for the slice 4 receiver, which writes the manifest that writePendingModSyncManifest reads here. Slice 4 hand-offs: the receiver translation unit pins kModSync* against NetworkCommands::ModSync* via static_assert once both headers are visible; the manifest writer must use Settings::getInstance()->getUserPath() so the path matches what the executor reads at boot; and any settings mutator that runs after a partial executor failure must consume the executor's applied-path list as ground truth rather than treating the planned swap set as success. --- coreengine/filesupport.cpp | 74 +++++++++++++++++++++++++++++++++++--- coreengine/settings.cpp | 5 +++ 2 files changed, 74 insertions(+), 5 deletions(-) diff --git a/coreengine/filesupport.cpp b/coreengine/filesupport.cpp index cf7fd696f..28dcae202 100644 --- a/coreengine/filesupport.cpp +++ b/coreengine/filesupport.cpp @@ -639,11 +639,76 @@ void Filesupport::reapModSyncFolders(const QString & installRoot, qint32 backupK 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(); - const qint32 stagingIdx = name.indexOf(QStringLiteral(".sync-staging-")); - if (stagingIdx > 0) + QString prefix; + if (matchStagingShape(name, prefix)) { // Mtime fallback heuristic; cheap, no platform-specific PID liveness check. if (entry.lastModified() < cutoff) @@ -653,10 +718,9 @@ void Filesupport::reapModSyncFolders(const QString & installRoot, qint32 backupK } continue; } - const qint32 bakIdx = name.indexOf(QStringLiteral(".bak-")); - if (bakIdx > 0) + if (matchBackupShape(name, prefix)) { - backupsByMod[name.left(bakIdx)].append(entry); + backupsByMod[prefix].append(entry); } } for (auto iter = backupsByMod.begin(); iter != backupsByMod.end(); ++iter) diff --git a/coreengine/settings.cpp b/coreengine/settings.cpp index dcad654bf..e8d900e42 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" @@ -1506,6 +1507,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. + CONSOLE_PRINT("Checking pending mod-sync manifest", GameConsole::eDEBUG); + Filesupport::executePendingModSyncManifest(m_userPath, m_userPath); + Filesupport::reapModSyncFolders(m_userPath); setActiveMods(m_activeMods); GameConsole::setLogLevel(m_defaultLogLevel); GameConsole::setActiveModules(m_defaultLogModuls); From e78610380cfed53064a9757fe64a4dd98bfdb56b Mon Sep 17 00:00:00 2001 From: TheMasterCreed Date: Thu, 7 May 2026 16:30:56 +0000 Subject: [PATCH 09/23] Drop mod-sync boot breadcrumb that fires before LogLevel applies The eDEBUG CONSOLE_PRINT at this call site runs before GameConsole::setLogLevel(m_defaultLogLevel) executes lower in loadSettings, so the line is unreachable at any LogLevel value. Removing it is the cheapest fix; the reaper and executor still log their own actions when they trigger, so the boot path stays diagnosable once LogLevel rises later in the same loadSettings call. --- coreengine/settings.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/coreengine/settings.cpp b/coreengine/settings.cpp index e8d900e42..c773b3391 100644 --- a/coreengine/settings.cpp +++ b/coreengine/settings.cpp @@ -1508,7 +1508,6 @@ 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. - CONSOLE_PRINT("Checking pending mod-sync manifest", GameConsole::eDEBUG); Filesupport::executePendingModSyncManifest(m_userPath, m_userPath); Filesupport::reapModSyncFolders(m_userPath); setActiveMods(m_activeMods); From 74b6521dc64bb47eff562f92a6092bb6d59c489d Mon Sep 17 00:00:00 2001 From: TheMasterCreed Date: Thu, 7 May 2026 16:31:08 +0000 Subject: [PATCH 10/23] Add mod-sync request, data, reject, and complete handlers Multiplayermenu::requestModSync sends REQUESTMODSYNC to the host via socketID=0, matching the existing client-originated convention used by startCountdown and sendInitUpdate, and arms a session that tracks staged mods, a pending requested-set, compressed and declared-uncompressed byte counters, and the post-sync active mod list. An empty modsToDownload short-circuits to a settings-only path that calls Settings::stageActiveModsForRestart and skips both network and manifest. handleModSyncRequest validates the protocol version, parses the requested-mod list with bounded readers and a 1024-entry cap, dedupes via QSet at parse time, applies the cosmetic-mod filter against the active map's GameRules, resolves each mod's actual on-disk root through VirtualPaths::find(mod, false) so userPath, CWD, and applicationDirPath layouts all work, enforces totalCap on both compressed wire bytes and declared uncompressed bytes, and streams MODSYNCDATA frames followed by MODSYNCCOMPLETE. handleModSyncData validates the frame's protocolVersion, reads modPath, declaredSize, fileCount, and the compressedBlob through bounded readers that reject declared sizes above qint32 max before any QByteArray::resize call, uses fileCount as both a pre-extract cap gate and a post-extract files.size() cross-check, and removes modPath from the requested-set after a successful stage so duplicate or unrequested deliveries abort the session. handleModSyncComplete requires the requested-set to be empty before applying, calls Settings::stageActiveModsForRestart to write Mods/Mods directly to the ini without perturbing live m_activeMods or m_activeModVersions, then writes the pending-swap manifest as the commit point; if writePendingModSyncManifest fails, Settings::restoreActiveModsRaw rolls the ini back to its prior raw value before cancelModSyncSession tears down the staging directories. Settings::stageActiveModsForRestart returns the prior raw Mods/Mods value and gates on slave/aiSlave/m_updateStep matching saveSettings; restoreActiveModsRaw mirrors the same gate so the rollback path is symmetric. Reject codes are pinned to wire literals via static_assert against NetworkCommands::ModSync* values 0..6. Bounded readers reject declared sizes above 0x7FFFFFFFu and negative caps before any resize, closing the cast-negative path through QByteArray::resize(qint32). Groundwork for the slice 4b UI button (Apply host's mod set), the slice 4c auto-restart and rejoin flow that closes the saveSettings-revert window between MODSYNCCOMPLETE and the actual restart, and the slice 4d settings checkbox for ModSyncEnabled. --- coreengine/settings.cpp | 28 ++ coreengine/settings.h | 3 + multiplayer/multiplayermenu.cpp | 527 ++++++++++++++++++++++++++++++++ multiplayer/multiplayermenu.h | 17 ++ 4 files changed, 575 insertions(+) diff --git a/coreengine/settings.cpp b/coreengine/settings.cpp index c773b3391..f3527d9fe 100644 --- a/coreengine/settings.cpp +++ b/coreengine/settings.cpp @@ -1092,6 +1092,34 @@ 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(); + 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(); +} + void Settings::setActiveMods(const QStringList activeMods) { m_activeMods = activeMods; diff --git a/coreengine/settings.h b/coreengine/settings.h index dffc8b749..f0b6f320c 100644 --- a/coreengine/settings.h +++ b/coreengine/settings.h @@ -438,6 +438,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(); diff --git a/multiplayer/multiplayermenu.cpp b/multiplayer/multiplayermenu.cpp index 6be6983ad..9a3049f4a 100644 --- a/multiplayer/multiplayermenu.cpp +++ b/multiplayer/multiplayermenu.cpp @@ -14,6 +14,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" @@ -536,6 +537,22 @@ void Multiplayermenu::recieveData(quint64 socketID, QByteArray data, NetworkInte { exitMenuToLobby(); } + else if (messageType == NetworkCommands::REQUESTMODSYNC) + { + handleModSyncRequest(stream, socketID); + } + else if (messageType == NetworkCommands::MODSYNCDATA) + { + handleModSyncData(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); @@ -2468,3 +2485,513 @@ 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"); + +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; + + // 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; + } +} + +void Multiplayermenu::requestModSync(const QStringList & modsToDownload, const QStringList & postSyncActiveMods) +{ + if (m_modSyncActive) + { + CONSOLE_PRINT("Mod-sync already in flight; ignoring duplicate requestModSync", GameConsole::eWARNING); + return; + } + // 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; + } + 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; + } + 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; + } + m_modSyncActive = true; + m_modSyncStagings.clear(); + m_modSyncRequestedSet = QSet(modsToDownload.cbegin(), modsToDownload.cend()); + m_modSyncReceivedBytes = 0; + m_modSyncReceivedUncompressedBytes = 0; + m_modSyncPostSyncActiveMods = postSyncActiveMods; + + QByteArray data; + QDataStream stream(&data, QIODevice::WriteOnly); + stream.setVersion(QDataStream::Version::Qt_6_5); + stream << QString(NetworkCommands::REQUESTMODSYNC); + stream << static_cast(1); + stream << modsToDownload; + // 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", GameConsole::eINFO); +} + +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; + } + 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); + } + } + } + + 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; + + 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, tr("Failed to build mod package.")); + return; + } + if (totalSent + pkg.compressedBlob.size() > totalCap || totalUncompressed + pkg.declaredUncompressedSize > totalCap) + { + sendModSyncReject(socketID, NetworkCommands::ModSyncSizeCapExceeded, QString(), tr("Total sync size exceeds cap.")); + return; + } + totalSent += pkg.compressedBlob.size(); + totalUncompressed += pkg.declaredUncompressedSize; + + QByteArray data; + QDataStream sendStream(&data, QIODevice::WriteOnly); + sendStream.setVersion(QDataStream::Version::Qt_6_5); + sendStream << QString(NetworkCommands::MODSYNCDATA); + sendStream << static_cast(1); + sendStream << mod; + sendStream << pkg.declaredUncompressedSize; + sendStream << pkg.fileCount; + sendStream << pkg.compressedBlob; + emit m_pNetworkInterface->sig_sendData(socketID, data, NetworkInterface::NetworkSerives::Multiplayer, false); + CONSOLE_PRINT("Sent MODSYNCDATA for " + mod + " (" + QString::number(pkg.compressedBlob.size()) + " bytes)", GameConsole::eINFO); + } + + 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(socketID, data, NetworkInterface::NetworkSerives::Multiplayer, false); + CONSOLE_PRINT("Sent MODSYNCCOMPLETE; " + QString::number(requestedMods.size()) + " mods, " + QString::number(totalSent) + " compressed bytes, " + QString::number(totalUncompressed) + " uncompressed bytes", 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(); + + qint32 protocolVersion = 0; + stream >> protocolVersion; + if (stream.status() != QDataStream::Ok || protocolVersion != 1) + { + CONSOLE_PRINT("MODSYNCDATA unsupported protocol version", GameConsole::eERROR); + cancelModSyncSession(); + return; + } + + QString modPath; + if (!readBoundedQString(stream, modPath, relPathMaxLen)) + { + CONSOLE_PRINT("MODSYNCDATA mod path overflow or malformed", GameConsole::eERROR); + cancelModSyncSession(); + 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); + cancelModSyncSession(); + return; + } + + QByteArray compressedBlob; + if (!readBoundedQByteArray(stream, compressedBlob, perModCap)) + { + CONSOLE_PRINT("MODSYNCDATA blob overflow or malformed for " + modPath, GameConsole::eERROR); + cancelModSyncSession(); + return; + } + + if (!Filesupport::validateModPath(modPath)) + { + CONSOLE_PRINT("MODSYNCDATA invalid mod path: " + modPath, GameConsole::eERROR); + cancelModSyncSession(); + return; + } + if (!m_modSyncRequestedSet.contains(modPath)) + { + CONSOLE_PRINT("MODSYNCDATA for unrequested or duplicate mod: " + modPath, GameConsole::eERROR); + cancelModSyncSession(); + 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); + cancelModSyncSession(); + 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); + cancelModSyncSession(); + 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); + cancelModSyncSession(); + 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); + cancelModSyncSession(); + return; + } + m_modSyncStagings.append(qMakePair(stagingRel, modPath)); + m_modSyncRequestedSet.remove(modPath); + CONSOLE_PRINT("Mod-sync staged " + modPath + " (" + QString::number(files.size()) + " files)", GameConsole::eINFO); +} + +void Multiplayermenu::handleModSyncReject(QDataStream & stream, quint64 socketID) +{ + Q_UNUSED(socketID); + 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(); + return; + } + QString modPath; + if (!readBoundedQString(stream, modPath, relPathMaxLen)) + { + CONSOLE_PRINT("MODSYNCREJECT mod path overflow or malformed", GameConsole::eERROR); + cancelModSyncSession(); + 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(); + return; + } + CONSOLE_PRINT("Mod-sync rejected by host: code=" + QString::number(reasonCode) + " mod=" + modPath + " msg=" + reasonMessage, GameConsole::eERROR); + cancelModSyncSession(); +} + +void Multiplayermenu::handleModSyncComplete(QDataStream & stream, quint64 socketID) +{ + Q_UNUSED(socketID); + qint32 protocolVersion = 0; + stream >> protocolVersion; + if (stream.status() != QDataStream::Ok || protocolVersion != 1) + { + CONSOLE_PRINT("MODSYNCCOMPLETE unsupported protocol version", GameConsole::eERROR); + cancelModSyncSession(); + return; + } + if (!m_modSyncActive) + { + CONSOLE_PRINT("MODSYNCCOMPLETE received with no active mod-sync session, ignoring", GameConsole::eWARNING); + return; + } + if (!m_modSyncRequestedSet.isEmpty()) + { + CONSOLE_PRINT("MODSYNCCOMPLETE arrived with " + QString::number(m_modSyncRequestedSet.size()) + " requested mods unsent; aborting", GameConsole::eERROR); + cancelModSyncSession(); + 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_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(); + return; + } + CONSOLE_PRINT("Mod-sync complete: " + QString::number(m_modSyncStagings.size()) + " mods staged. Restart the game to apply.", GameConsole::eINFO); + m_modSyncActive = false; + m_modSyncStagings.clear(); + m_modSyncRequestedSet.clear(); + m_modSyncReceivedBytes = 0; + m_modSyncReceivedUncompressedBytes = 0; + m_modSyncPostSyncActiveMods.clear(); +} + +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() +{ + 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_modSyncActive = false; + m_modSyncPostSyncActiveMods.clear(); +} diff --git a/multiplayer/multiplayermenu.h b/multiplayer/multiplayermenu.h index 388a15caf..da76bc76f 100644 --- a/multiplayer/multiplayermenu.h +++ b/multiplayer/multiplayermenu.h @@ -4,6 +4,8 @@ #include #include #include +#include +#include #include "3rd_party/oxygine-framework/oxygine/actor/Button.h" @@ -188,6 +190,13 @@ protected slots: void handleVersionMissmatch(const QStringList & mods, const QStringList & versions, const QStringList & myMods, const QStringList & myVersions, const QStringList & mismatchedResourceFolders, const QStringList & mismatchedMods, bool sameMods, bool differentHash, bool sameVersion); bool checkMods(const QStringList & mods, const QStringList & versions, QStringList & myMods, QStringList & myVersions, bool filter); void verifyGameData(QDataStream & stream, quint64 socketID); + void requestModSync(const QStringList & modsToDownload, const QStringList & postSyncActiveMods); + void handleModSyncRequest(QDataStream & stream, quint64 socketID); + void handleModSyncData(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(); /** * @brief requestRule * @param socketID @@ -365,6 +374,14 @@ protected slots: 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}; + bool m_modSyncActive{false}; }; Q_DECLARE_INTERFACE(Multiplayermenu, "Multiplayermenu"); From 46fc09c44693e83cbd5bb5cabc220f96a40dd5b9 Mon Sep 17 00:00:00 2001 From: TheMasterCreed Date: Thu, 7 May 2026 17:27:01 +0000 Subject: [PATCH 11/23] Add Apply host's mod set button and progress dialog to mismatch flow Multiplayermenu::handleVersionMissmatch now offers a two-button DialogMessageBox ("Apply host's mod set" and "Leave game") when the host advertised Filesupport::CapabilityModSync, no engine resource folder is drift-mismatched, and either the download list or extras-only list is non-empty. Single-button "Leaving the game again." stays for non-fixable cases (engine resource drift, host without the capability, version-mismatch where readHashInfo early-returned). The dialog text gets a casual closing line "Want me to download host's mod set and apply it on the next start?" when the offer is presented. The handler now collects a parallel modsToDownloadPaths list of raw modPath identifiers alongside the existing display strings (missingHere, versionDiffs, contentDiffs), deduped between version-diff and content-diff branches via QStringList::contains, and computes postSyncActiveMods from the host's filtered mods list plus any locally active cosmetic mod that was filtered out of the comparison when cosmeticAllowed is true. Both recieveData call sites (verifyGameData and clientMapInfo branches) and readHashInfo plumb the new bool cosmeticAllowed out-parameter through from the existing wire-level filter byte so the offer logic does not have to recompute it from the current map's GameRules. DialogModSyncProgress (objects/dialogs/dialogmodsyncprogress.{h,cpp}) is a new full-stage Box9 dialog with a manual ColorRectSprite progress bar (480 by 24, gray background, green fill), a centered header ("Downloading host's mod set"), a centered detail label ("X / Y mods (N KB)"), and a Cancel button. setProgress recomputes the fill width using float division so the qint32-as-float bug at progressinfobar.cpp:38-49 is sidestepped, and the detail line is recentered each tick. confirmModSync calls requestModSync first and gates on its new bool return: empty download list returns success or failure based on the bool, non-empty creates the progress dialog only after the request was accepted. The cancel lambda inside the progress dialog has its own m_modSyncActive early-return so a queued cancel firing after success or after a previous cancel is a no-op. handleModSyncData calls onModSyncProgress after each successful stage so the dialog updates every frame, and uses the declared uncompressed bytes counter (m_modSyncReceivedUncompressedBytes) for the KB display so the on-disk delta matches what the user sees, not the wire-compressed size. handleModSyncReject and handleModSyncComplete now early-return on !m_modSyncActive at the very top of the function, before any stream reads, matching handleModSyncData's pattern, so a stale or unsolicited frame after cancel or success cannot stack a second failure dialog. cancelModSyncSession tears down the progress dialog ahead of its active-session guard so a request that bailed before arming still cleans up its UI. onModSyncFailed runs reason.toHtmlEscaped() on the host-supplied reject message before interpolating into DialogMessageBox::setHtmlText so a malicious or buggy host cannot inject HTML into the failure dialog. requestModSync now returns bool: false on already-in-flight, null or server-side network interface, or count cap exceeded; true after a successful settings-only stage or after arming the session and sending REQUESTMODSYNC. The dead m_modSyncTotalRequested member is gone from the header and all reset sites; the dialog's own m_totalMods constructor argument is the single source of truth for "X of Y" displays. --- CMakeLists.txt | 1 + multiplayer/multiplayermenu.cpp | 213 ++++++++++++++++++---- multiplayer/multiplayermenu.h | 12 +- objects/dialogs/dialogmodsyncprogress.cpp | 104 +++++++++++ objects/dialogs/dialogmodsyncprogress.h | 36 ++++ 5 files changed, 332 insertions(+), 34 deletions(-) create mode 100644 objects/dialogs/dialogmodsyncprogress.cpp create mode 100644 objects/dialogs/dialogmodsyncprogress.h 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/multiplayer/multiplayermenu.cpp b/multiplayer/multiplayermenu.cpp index 9a3049f4a..77cb618e4 100644 --- a/multiplayer/multiplayermenu.cpp +++ b/multiplayer/multiplayermenu.cpp @@ -1195,8 +1195,8 @@ void Multiplayermenu::verifyGameData(QDataStream & stream, quint64 socketID) QStringList mismatchedResourceFolders; QStringList mismatchedMods; quint32 hostCapabilities = 0; - readHashInfo(stream, socketID, mods, versions, myMods, myVersions, mismatchedResourceFolders, mismatchedMods, hostCapabilities, sameMods, differentHash, sameVersion); - Q_UNUSED(hostCapabilities); + bool cosmeticAllowed = false; + readHashInfo(stream, socketID, mods, versions, myMods, myVersions, mismatchedResourceFolders, mismatchedMods, hostCapabilities, sameMods, differentHash, sameVersion, cosmeticAllowed); if (sameVersion && sameMods && !differentHash) { QString command = QString(NetworkCommands::GAMEDATAVERIFIED); @@ -1209,7 +1209,7 @@ void Multiplayermenu::verifyGameData(QDataStream & stream, quint64 socketID) } else { - handleVersionMissmatch(mods, versions, myMods, myVersions, mismatchedResourceFolders, mismatchedMods, sameMods, differentHash, sameVersion); + handleVersionMissmatch(mods, versions, myMods, myVersions, mismatchedResourceFolders, mismatchedMods, hostCapabilities, sameMods, differentHash, sameVersion, cosmeticAllowed); } } } @@ -1261,9 +1261,10 @@ 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, QStringList & mismatchedResourceFolders, QStringList & mismatchedMods, quint32 & hostCapabilities, 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, quint32 & hostCapabilities, bool & sameMods, bool & differentHash, bool & sameVersion, bool & cosmeticAllowed) { hostCapabilities = 0; + cosmeticAllowed = false; GameVersion version; version.deserializeObject(stream); sameVersion = (version == GameVersion()); @@ -1273,6 +1274,7 @@ void Multiplayermenu::readHashInfo(QDataStream & stream, quint64 socketID, QStri } bool filter = false; stream >> filter; + cosmeticAllowed = filter; qint32 size = 0; stream >> size; for (qint32 i = 0; i < size; i++) @@ -1368,8 +1370,8 @@ void Multiplayermenu::clientMapInfo(QDataStream & stream, quint64 socketID) QStringList mismatchedResourceFolders; QStringList mismatchedMods; quint32 hostCapabilities = 0; - readHashInfo(stream, socketID, mods, versions, myMods, myVersions, mismatchedResourceFolders, mismatchedMods, hostCapabilities, sameMods, differentHash, sameVersion); - Q_UNUSED(hostCapabilities); + bool cosmeticAllowed = false; + readHashInfo(stream, socketID, mods, versions, myMods, myVersions, mismatchedResourceFolders, mismatchedMods, hostCapabilities, sameMods, differentHash, sameVersion, cosmeticAllowed); if (sameVersion && sameMods && !differentHash) { stream >> m_saveGame; @@ -1427,12 +1429,12 @@ void Multiplayermenu::clientMapInfo(QDataStream & stream, quint64 socketID) } else { - handleVersionMissmatch(mods, versions, myMods, myVersions, mismatchedResourceFolders, mismatchedMods, sameMods, differentHash, sameVersion); + handleVersionMissmatch(mods, versions, myMods, myVersions, mismatchedResourceFolders, mismatchedMods, hostCapabilities, sameMods, differentHash, sameVersion, cosmeticAllowed); } } } -void Multiplayermenu::handleVersionMissmatch(const QStringList & mods, const QStringList & versions, const QStringList & myMods, const QStringList & myVersions, const QStringList & mismatchedResourceFolders, const QStringList & mismatchedMods, 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, quint32 hostCapabilities, bool sameMods, bool differentHash, bool sameVersion, bool cosmeticAllowed) { // Mod/hash fields are stale on version mismatch because readHashInfo early-returns. if (!sameVersion) @@ -1449,6 +1451,7 @@ void Multiplayermenu::handleVersionMissmatch(const QStringList & mods, const QSt QStringList missingHere; QStringList extraHere; QStringList versionDiffs; + QStringList modsToDownloadPaths; if (!sameMods) { for (qint32 i = 0; i < mods.size(); ++i) @@ -1458,10 +1461,12 @@ void Multiplayermenu::handleVersionMissmatch(const QStringList & mods, const QSt if (j < 0) { missingHere.append(settings->getModName(mod) + " " + versions[i]); + 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); } } for (qint32 i = 0; i < myMods.size(); ++i) @@ -1477,6 +1482,10 @@ void Multiplayermenu::handleVersionMissmatch(const QStringList & mods, const QSt 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) @@ -1518,6 +1527,11 @@ void Multiplayermenu::handleVersionMissmatch(const QStringList & mods, const QSt 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 we have something to fix. + const bool hostSupportsModSync = (hostCapabilities & Filesupport::CapabilityModSync) != 0; + const bool resourceDrift = !mismatchedResourceFolders.isEmpty(); + const bool fixableViaSync = hostSupportsModSync && !resourceDrift && (!modsToDownloadPaths.isEmpty() || !extraHere.isEmpty()); + if (message.isEmpty()) { // Legacy and fail-closed payloads have no structured detail. @@ -1531,14 +1545,44 @@ void Multiplayermenu::handleVersionMissmatch(const QStringList & mods, const QSt 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 and apply it on the next start?"); + } else { message = tr("Cannot join, your game data differs from the host:") + "\n\n" + message + tr("Leaving the game again."); } - spDialogMessageBox pDialogMessageBox = MemoryManagement::create(message); - connect(pDialogMessageBox.get(), &DialogMessageBox::sigOk, this, &Multiplayermenu::buttonBack, Qt::QueuedConnection); - addChild(pDialogMessageBox); + if (fixableViaSync) + { + spDialogMessageBox pDialogMessageBox = MemoryManagement::create(message, true, tr("Apply host's mod set"), 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); + } + } + } + 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 + { + spDialogMessageBox pDialogMessageBox = MemoryManagement::create(message); + connect(pDialogMessageBox.get(), &DialogMessageBox::sigOk, this, &Multiplayermenu::buttonBack, Qt::QueuedConnection); + addChild(pDialogMessageBox); + } } void Multiplayermenu::requestMap(quint64 socketID) @@ -2574,30 +2618,30 @@ namespace } } -void Multiplayermenu::requestModSync(const QStringList & modsToDownload, const QStringList & postSyncActiveMods) +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; + 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; + 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; + 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; + return true; } m_modSyncActive = true; m_modSyncStagings.clear(); @@ -2615,6 +2659,7 @@ void Multiplayermenu::requestModSync(const QStringList & modsToDownload, const Q // 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", GameConsole::eINFO); + return true; } void Multiplayermenu::handleModSyncRequest(QDataStream & stream, quint64 socketID) @@ -2769,12 +2814,18 @@ void Multiplayermenu::handleModSyncData(QDataStream & stream, quint64 socketID) const qint32 fileCountMax = settings->getModSyncMaxFiles(); const qint64 totalCap = settings->getModSyncMaxTotalBytes(); + auto failData = [this](const QString & uiReason) + { + cancelModSyncSession(); + onModSyncFailed(uiReason); + }; + qint32 protocolVersion = 0; stream >> protocolVersion; if (stream.status() != QDataStream::Ok || protocolVersion != 1) { CONSOLE_PRINT("MODSYNCDATA unsupported protocol version", GameConsole::eERROR); - cancelModSyncSession(); + failData(tr("Unsupported mod-sync protocol from host.")); return; } @@ -2782,7 +2833,7 @@ void Multiplayermenu::handleModSyncData(QDataStream & stream, quint64 socketID) if (!readBoundedQString(stream, modPath, relPathMaxLen)) { CONSOLE_PRINT("MODSYNCDATA mod path overflow or malformed", GameConsole::eERROR); - cancelModSyncSession(); + failData(tr("Malformed mod-sync data frame.")); return; } @@ -2793,7 +2844,7 @@ void Multiplayermenu::handleModSyncData(QDataStream & stream, quint64 socketID) if (stream.status() != QDataStream::Ok || declaredSize < 0 || fileCount < 0 || fileCount > fileCountMax) { CONSOLE_PRINT("MODSYNCDATA size/count out of range for " + modPath, GameConsole::eERROR); - cancelModSyncSession(); + failData(tr("Mod %1 exceeds the per-mod file-count cap.").arg(modPath)); return; } @@ -2801,20 +2852,20 @@ void Multiplayermenu::handleModSyncData(QDataStream & stream, quint64 socketID) if (!readBoundedQByteArray(stream, compressedBlob, perModCap)) { CONSOLE_PRINT("MODSYNCDATA blob overflow or malformed for " + modPath, GameConsole::eERROR); - cancelModSyncSession(); + 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); - cancelModSyncSession(); + 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); - cancelModSyncSession(); + failData(tr("Host sent an unrequested or duplicate mod.")); return; } @@ -2823,7 +2874,7 @@ void Multiplayermenu::handleModSyncData(QDataStream & stream, quint64 socketID) if (m_modSyncReceivedBytes > totalCap || m_modSyncReceivedUncompressedBytes > totalCap) { CONSOLE_PRINT("Mod-sync exceeds total bytes cap, aborting", GameConsole::eERROR); - cancelModSyncSession(); + failData(tr("Mod-sync exceeds the total transfer cap.")); return; } @@ -2837,13 +2888,13 @@ void Multiplayermenu::handleModSyncData(QDataStream & stream, quint64 socketID) if (rejectReason != 0) { CONSOLE_PRINT("Mod-sync extract rejected (" + QString::number(rejectReason) + ") for " + modPath, GameConsole::eERROR); - cancelModSyncSession(); + 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); - cancelModSyncSession(); + failData(tr("Mod %1 file count did not match the host's declaration.").arg(modPath)); return; } @@ -2852,17 +2903,24 @@ void Multiplayermenu::handleModSyncData(QDataStream & stream, quint64 socketID) if (stageReason != 0 || stagingRel.isEmpty()) { CONSOLE_PRINT("Mod-sync stage rejected (" + QString::number(stageReason) + ") for " + modPath, GameConsole::eERROR); - cancelModSyncSession(); + 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::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(); @@ -2872,6 +2930,7 @@ void Multiplayermenu::handleModSyncReject(QDataStream & stream, quint64 socketID { CONSOLE_PRINT("MODSYNCREJECT unsupported protocol version", GameConsole::eERROR); cancelModSyncSession(); + onModSyncFailed(tr("Unsupported mod-sync protocol from host.")); return; } QString modPath; @@ -2879,6 +2938,7 @@ void Multiplayermenu::handleModSyncReject(QDataStream & stream, quint64 socketID { CONSOLE_PRINT("MODSYNCREJECT mod path overflow or malformed", GameConsole::eERROR); cancelModSyncSession(); + onModSyncFailed(tr("Malformed mod-sync reject frame.")); return; } qint32 reasonCode = 0; @@ -2888,32 +2948,40 @@ void Multiplayermenu::handleModSyncReject(QDataStream & stream, quint64 socketID { 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(); - return; - } - if (!m_modSyncActive) - { - CONSOLE_PRINT("MODSYNCCOMPLETE received with no active mod-sync session, ignoring", GameConsole::eWARNING); + onModSyncFailed(tr("Unsupported mod-sync protocol from host.")); 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()) @@ -2938,6 +3006,7 @@ void Multiplayermenu::handleModSyncComplete(QDataStream & stream, quint64 socket 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. Restart the game to apply.", GameConsole::eINFO); @@ -2947,6 +3016,7 @@ void Multiplayermenu::handleModSyncComplete(QDataStream & stream, quint64 socket m_modSyncReceivedBytes = 0; m_modSyncReceivedUncompressedBytes = 0; m_modSyncPostSyncActiveMods.clear(); + onModSyncSucceeded(); } void Multiplayermenu::sendModSyncReject(quint64 socketID, qint32 reasonCode, const QString & modPath, const QString & message) @@ -2965,6 +3035,12 @@ void Multiplayermenu::sendModSyncReject(quint64 socketID, qint32 reasonCode, con 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(); + } if (!m_modSyncActive) { return; @@ -2995,3 +3071,78 @@ void Multiplayermenu::cancelModSyncSession() m_modSyncActive = false; m_modSyncPostSyncActiveMods.clear(); } + +void Multiplayermenu::confirmModSync(const QStringList & modsToDownload, const QStringList & postSyncActiveMods) +{ + const bool ok = requestModSync(modsToDownload, postSyncActiveMods); + if (modsToDownload.isEmpty()) + { + // Settings-only branch already either staged Mods/Mods or refused; surface the matching prompt. + if (ok) + { + onModSyncSucceeded(); + } + else + { + onModSyncFailed(tr("Could not start mod sync.")); + } + return; + } + if (!ok) + { + onModSyncFailed(tr("Could not start mod sync.")); + return; + } + if (m_modSyncProgressDialog != nullptr) + { + m_modSyncProgressDialog->detach(); + m_modSyncProgressDialog.reset(); + } + m_modSyncProgressDialog = MemoryManagement::create(static_cast(modsToDownload.size())); + 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; + } + // Show declared uncompressed bytes so the user sees on-disk size, not wire-compressed size. + m_modSyncProgressDialog->setProgress(static_cast(m_modSyncStagings.size()), m_modSyncReceivedUncompressedBytes); +} + +void Multiplayermenu::onModSyncSucceeded() +{ + if (m_modSyncProgressDialog != nullptr) + { + m_modSyncProgressDialog->detach(); + m_modSyncProgressDialog.reset(); + } + spDialogMessageBox pDialog = MemoryManagement::create(tr("Mod sync complete. Restart the game to apply the host's mod set.")); + connect(pDialog.get(), &DialogMessageBox::sigOk, this, &Multiplayermenu::buttonBack, Qt::QueuedConnection); + addChild(pDialog); +} + +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 da76bc76f..bd6ceb723 100644 --- a/multiplayer/multiplayermenu.h +++ b/multiplayer/multiplayermenu.h @@ -18,6 +18,7 @@ #include "objects/base/chat.h" #include "objects/dialogs/dialogconnecting.h" +#include "objects/dialogs/dialogmodsyncprogress.h" class Multiplayermenu; using spMultiplayermenu = std::shared_ptr; @@ -186,11 +187,15 @@ 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, QStringList & mismatchedResourceFolders, QStringList & mismatchedMods, quint32 & hostCapabilities, bool & sameMods, bool & differentHash, bool & sameVersion); - void handleVersionMissmatch(const QStringList & mods, const QStringList & versions, const QStringList & myMods, const QStringList & myVersions, const QStringList & mismatchedResourceFolders, const QStringList & mismatchedMods, bool sameMods, bool differentHash, bool sameVersion); + void readHashInfo(QDataStream & stream, quint64 socketID, QStringList & mods, QStringList & versions, QStringList & myMods, QStringList & myVersions, QStringList & mismatchedResourceFolders, QStringList & mismatchedMods, 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, quint32 hostCapabilities, bool sameMods, bool differentHash, bool sameVersion, bool cosmeticAllowed); + void confirmModSync(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); - void requestModSync(const QStringList & modsToDownload, const QStringList & postSyncActiveMods); + bool requestModSync(const QStringList & modsToDownload, const QStringList & postSyncActiveMods); void handleModSyncRequest(QDataStream & stream, quint64 socketID); void handleModSyncData(QDataStream & stream, quint64 socketID); void handleModSyncReject(QDataStream & stream, quint64 socketID); @@ -382,6 +387,7 @@ protected slots: qint64 m_modSyncReceivedBytes{0}; qint64 m_modSyncReceivedUncompressedBytes{0}; bool m_modSyncActive{false}; + spDialogModSyncProgress m_modSyncProgressDialog; }; Q_DECLARE_INTERFACE(Multiplayermenu, "Multiplayermenu"); diff --git a/objects/dialogs/dialogmodsyncprogress.cpp b/objects/dialogs/dialogmodsyncprogress.cpp new file mode 100644 index 000000000..6de7c6893 --- /dev/null +++ b/objects/dialogs/dialogmodsyncprogress.cpp @@ -0,0 +1,104 @@ +#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); + m_Header->setHtmlText(tr("Downloading host's mod set")); + m_Header->setPosition(oxygine::Stage::getStage()->getWidth() / 2 - m_Header->getTextRect().width() / 2, + 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); + m_Detail->setHtmlText(tr("0 / %1 mods").arg(m_totalMods)); + m_Detail->setPosition(oxygine::Stage::getStage()->getWidth() / 2 - m_Detail->getTextRect().width() / 2, + 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); +} + +void DialogModSyncProgress::setProgress(qint32 stagedMods, qint64 receivedBytes) +{ + if (m_totalMods <= 0) + { + return; + } + if (stagedMods < 0) + { + stagedMods = 0; + } + if (stagedMods > m_totalMods) + { + stagedMods = m_totalMods; + } + const float fraction = static_cast(stagedMods) / static_cast(m_totalMods); + m_BarFill->setWidth(m_barWidth * fraction); + const qint64 receivedKb = receivedBytes / 1024; + m_Detail->setHtmlText(tr("%1 / %2 mods (%3 KB)").arg(stagedMods).arg(m_totalMods).arg(receivedKb)); + m_Detail->setPosition(oxygine::Stage::getStage()->getWidth() / 2 - m_Detail->getTextRect().width() / 2, + m_Detail->getY()); +} + +void DialogModSyncProgress::remove() +{ + detach(); +} diff --git a/objects/dialogs/dialogmodsyncprogress.h b/objects/dialogs/dialogmodsyncprogress.h new file mode 100644 index 000000000..dc2fbc439 --- /dev/null +++ b/objects/dialogs/dialogmodsyncprogress.h @@ -0,0 +1,36 @@ +#ifndef DIALOGMODSYNCPROGRESS_H +#define DIALOGMODSYNCPROGRESS_H + +#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; + + void setProgress(qint32 stagedMods, qint64 receivedBytes); +signals: + void sigCancel(); +public slots: + void remove(); +private: + qint32 m_totalMods{0}; + qint32 m_barWidth{0}; + oxygine::spColorRectSprite m_BarBackground; + oxygine::spColorRectSprite m_BarFill; + oxygine::spTextField m_Header; + oxygine::spTextField m_Detail; + oxygine::spButton m_CancelButton; +}; + +#endif From 71638667655afb582b187d336d19c057fb85e0c5 Mon Sep 17 00:00:00 2001 From: TheMasterCreed Date: Thu, 7 May 2026 17:58:39 +0000 Subject: [PATCH 12/23] Fix relative-path keying in buildModSyncPackage for empty UserPath Filesupport::buildModSyncPackage previously called QDir::relativeFilePath on the iterator's emitted file path with modRoot as the base. When installRoot resolved to "." (the default with empty UserPath in ini), modRoot ended up as "./mods/" and the iterator emitted "./mods//" relative paths to match. QDir::relativeFilePath short-circuits to verbatim when either operand is relative, so the package keys became "mods//" instead of just "". The client then joined those keys against the staging directory and produced "/mods//", a doubly-nested layout where the actual mod files ended up two levels too deep. After the executor renamed staging into place, the post-restart mod folder looked like "mods//mods//" and the engine could neither read mod.txt at the expected location nor get a clean per-mod hash, surfacing as a perpetual version mismatch with placeholder version "1.0.0" on every restart. Cache the absolute mod directory once via QDir::absolutePath, then run relativeFilePath against absolute file paths produced by QFileInfo::absoluteFilePath. With both sides absolute, the short-circuit never trips and keys come out as plain file-relative paths regardless of whether installRoot was passed as "" or "." or a normalized absolute UserPath. --- coreengine/filesupport.cpp | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/coreengine/filesupport.cpp b/coreengine/filesupport.cpp index 28dcae202..23a582609 100644 --- a/coreengine/filesupport.cpp +++ b/coreengine/filesupport.cpp @@ -348,14 +348,16 @@ Filesupport::ModSyncPackage Filesupport::buildModSyncPackage(const QString & ins 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 = it.next(); - const QString rel = QDir(modRoot).relativeFilePath(absolute); + 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-. From 0b4ae06f23b4e95a20f9c2efd56f4866a1a9f8a6 Mon Sep 17 00:00:00 2001 From: TheMasterCreed Date: Thu, 7 May 2026 18:17:24 +0000 Subject: [PATCH 13/23] Surface specific reject reason in mod-sync failure dialog handleModSyncRequest's reject path that fires after buildModSyncPackage now translates pkg.rejectReason into a mod-named, reason-specific message before sending it to the client. The previous "Failed to build mod package." string was identical for every reason code, so a user who hit the per-mod size cap on a 700MB audio mod saw the same dialog as someone whose host advertised a path the package builder couldn't read. The new mapping covers ModSyncSizeCapExceeded, ModSyncFileCountCapExceeded, ModSyncInvalidPath, and ModSyncUnknownMod, with a fallback for ModSyncInternalError. The total-cap reject message also picks up the host attribution. Reject codes themselves are unchanged on the wire; this commit only changes the human-readable string the client shows in onModSyncFailed. --- multiplayer/multiplayermenu.cpp | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/multiplayer/multiplayermenu.cpp b/multiplayer/multiplayermenu.cpp index 77cb618e4..6ca1ae36f 100644 --- a/multiplayer/multiplayermenu.cpp +++ b/multiplayer/multiplayermenu.cpp @@ -2730,6 +2730,23 @@ void Multiplayermenu::handleModSyncRequest(QDataStream & stream, quint64 socketI 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); + } + }; + for (const auto & mod : std::as_const(requestedMods)) { if (!Filesupport::validateModPath(mod)) @@ -2767,12 +2784,12 @@ void Multiplayermenu::handleModSyncRequest(QDataStream & stream, quint64 socketI Filesupport::ModSyncPackage pkg = Filesupport::buildModSyncPackage(resolvedRoot, mod, caps); if (pkg.rejectReason != 0) { - sendModSyncReject(socketID, pkg.rejectReason, mod, tr("Failed to build mod package.")); + 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 cap.")); + sendModSyncReject(socketID, NetworkCommands::ModSyncSizeCapExceeded, QString(), tr("Total sync size exceeds the host's cap.")); return; } totalSent += pkg.compressedBlob.size(); From 60d9b1531e7abe9614a2835b74b05823518b2862 Mon Sep 17 00:00:00 2001 From: TheMasterCreed Date: Thu, 7 May 2026 18:24:26 +0000 Subject: [PATCH 14/23] Skip mod-sync download when the local copy is on disk but inactive handleVersionMissmatch's missingHere branch was queueing a download for any mod the host had active that the client did not have in its active list, even when the mod folder was already on disk under mods/. The downstream effect was an unnecessary REQUESTMODSYNC for mods that just needed activation, which on the host could fall over against the per-mod size cap for large local-only assets like audio packs that both sides already have. After "Apply host's mod set" the client would see a "Mod %1 exceeds the per-mod size cap on the host." dialog and abort the whole session, even though no actual download was needed. Add a VirtualPaths::find probe before adding the mod to modsToDownloadPaths in the j < 0 branch. If the resolver returns a path and that path exists on disk, leave the mod off the download list; the post-sync active list still contains it (because postSyncActiveMods seeds from the host's filtered mods), so the next-boot setActiveMods pass picks it up from disk and enables it. The version-mismatch and content-mismatch branches still queue downloads unconditionally because the local copy in those cases either has the wrong mod.txt version string or the wrong .js/.csv content; both genuinely need replacement. --- multiplayer/multiplayermenu.cpp | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/multiplayer/multiplayermenu.cpp b/multiplayer/multiplayermenu.cpp index 6ca1ae36f..e3f3a49be 100644 --- a/multiplayer/multiplayermenu.cpp +++ b/multiplayer/multiplayermenu.cpp @@ -1461,7 +1461,12 @@ void Multiplayermenu::handleVersionMissmatch(const QStringList & mods, const QSt if (j < 0) { missingHere.append(settings->getModName(mod) + " " + versions[i]); - modsToDownloadPaths.append(mod); + // Only queue a download if the mod folder is absent from disk; otherwise post-sync activation re-enables the local copy. + const QString resolvedAbs = VirtualPaths::find(mod, false); + if (resolvedAbs.isEmpty() || !QDir(resolvedAbs).exists()) + { + modsToDownloadPaths.append(mod); + } } else if (versions[i] != myVersions[j]) { From ddb069b43e647b9c26073b65779c1443bc16e2c7 Mon Sep 17 00:00:00 2001 From: TheMasterCreed Date: Thu, 7 May 2026 18:55:05 +0000 Subject: [PATCH 15/23] Verify hash and version before mod-sync skip, fix offer predicate The on-disk skip path added in 5fa654e29 trusted folder existence alone, which is unsafe: an inactive stale or unrelated folder under the same name would let the settings-only fast path activate the wrong content, the next handshake would surface the same content mismatch, and the user would loop. Plumb the host's per-mod hash map out of readHashInfo (one assignment inside the existing compareMaps lambda, no recomputation, no wire change) so handleVersionMissmatch can verify before skipping. The j<0 missing-from-active branch now reads VirtualPaths::find for the local folder, computes Filesupport::getPerModHashes for that single mod, reads Settings::getModVersion for its mod.txt version, and skips the download only when the local hash equals hostModHashes.value(mod) and the local version equals versions[i]. Empty local hash, empty host hash entry, hash mismatch, or version mismatch all queue the download instead, with an eDEBUG breadcrumb identifying which condition failed. Both readHashInfo call sites (verifyGameData branch and clientMapInfo branch) declare the local QMap and thread it through. The offer predicate that decides whether to surface "Apply host's mod set" was based on modsToDownloadPaths, but that list is now derived (filtered by the disk-presence-and-content gate above), not the canonical "is there anything to fix" signal. With every missing mod successfully verified locally, the download list is empty and the old predicate suppressed the dialog even though settings-only activation was the correct action. Switch the predicate to fire whenever any mismatch class is non-empty: missingHere, versionDiffs, contentDiffs, or extraHere. The unreachable differentHash-with-no-classifications branch keeps all four lists empty, so the predicate stays false and falls through to the existing single-button "Cannot join" dialog without a spurious offer. The all-on-disk-and-content-matched scenario now correctly fires the offer, the empty download list short-circuits requestModSync to the settings-only stage, and the success dialog renders. --- multiplayer/multiplayermenu.cpp | 36 ++++++++++++++++++++++++--------- multiplayer/multiplayermenu.h | 5 +++-- 2 files changed, 29 insertions(+), 12 deletions(-) diff --git a/multiplayer/multiplayermenu.cpp b/multiplayer/multiplayermenu.cpp index e3f3a49be..5d6755b82 100644 --- a/multiplayer/multiplayermenu.cpp +++ b/multiplayer/multiplayermenu.cpp @@ -1196,7 +1196,8 @@ void Multiplayermenu::verifyGameData(QDataStream & stream, quint64 socketID) QStringList mismatchedMods; quint32 hostCapabilities = 0; bool cosmeticAllowed = false; - readHashInfo(stream, socketID, mods, versions, myMods, myVersions, mismatchedResourceFolders, mismatchedMods, hostCapabilities, sameMods, differentHash, sameVersion, cosmeticAllowed); + 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); @@ -1209,7 +1210,7 @@ void Multiplayermenu::verifyGameData(QDataStream & stream, quint64 socketID) } else { - handleVersionMissmatch(mods, versions, myMods, myVersions, mismatchedResourceFolders, mismatchedMods, hostCapabilities, sameMods, differentHash, sameVersion, cosmeticAllowed); + handleVersionMissmatch(mods, versions, myMods, myVersions, mismatchedResourceFolders, mismatchedMods, hostModHashes, hostCapabilities, sameMods, differentHash, sameVersion, cosmeticAllowed); } } } @@ -1261,10 +1262,11 @@ 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, QStringList & mismatchedResourceFolders, QStringList & mismatchedMods, quint32 & hostCapabilities, bool & sameMods, bool & differentHash, bool & sameVersion, bool & cosmeticAllowed) +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()); @@ -1293,6 +1295,8 @@ void Multiplayermenu::readHashInfo(QDataStream & stream, quint64 socketID, QStri // 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) @@ -1371,7 +1375,8 @@ void Multiplayermenu::clientMapInfo(QDataStream & stream, quint64 socketID) QStringList mismatchedMods; quint32 hostCapabilities = 0; bool cosmeticAllowed = false; - readHashInfo(stream, socketID, mods, versions, myMods, myVersions, mismatchedResourceFolders, mismatchedMods, hostCapabilities, sameMods, differentHash, sameVersion, cosmeticAllowed); + QMap hostModHashes; + readHashInfo(stream, socketID, mods, versions, myMods, myVersions, mismatchedResourceFolders, mismatchedMods, hostModHashes, hostCapabilities, sameMods, differentHash, sameVersion, cosmeticAllowed); if (sameVersion && sameMods && !differentHash) { stream >> m_saveGame; @@ -1429,12 +1434,12 @@ void Multiplayermenu::clientMapInfo(QDataStream & stream, quint64 socketID) } else { - handleVersionMissmatch(mods, versions, myMods, myVersions, mismatchedResourceFolders, mismatchedMods, hostCapabilities, sameMods, differentHash, sameVersion, cosmeticAllowed); + 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, const QStringList & mismatchedResourceFolders, const QStringList & mismatchedMods, quint32 hostCapabilities, bool sameMods, bool differentHash, bool sameVersion, bool cosmeticAllowed) +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) { // Mod/hash fields are stale on version mismatch because readHashInfo early-returns. if (!sameVersion) @@ -1461,9 +1466,20 @@ void Multiplayermenu::handleVersionMissmatch(const QStringList & mods, const QSt if (j < 0) { missingHere.append(settings->getModName(mod) + " " + versions[i]); - // Only queue a download if the mod folder is absent from disk; otherwise post-sync activation re-enables the local copy. + // 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()) + 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); } @@ -1532,10 +1548,10 @@ void Multiplayermenu::handleVersionMissmatch(const QStringList & mods, const QSt 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 we have something to fix. + // 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 && (!modsToDownloadPaths.isEmpty() || !extraHere.isEmpty()); + const bool fixableViaSync = hostSupportsModSync && !resourceDrift && (!missingHere.isEmpty() || !versionDiffs.isEmpty() || !contentDiffs.isEmpty() || !extraHere.isEmpty()); if (message.isEmpty()) { diff --git a/multiplayer/multiplayermenu.h b/multiplayer/multiplayermenu.h index bd6ceb723..29baa4fbb 100644 --- a/multiplayer/multiplayermenu.h +++ b/multiplayer/multiplayermenu.h @@ -4,6 +4,7 @@ #include #include #include +#include #include #include @@ -187,8 +188,8 @@ 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, QStringList & mismatchedResourceFolders, QStringList & mismatchedMods, 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, quint32 hostCapabilities, bool sameMods, bool differentHash, bool sameVersion, bool cosmeticAllowed); + 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 onModSyncProgress(); void onModSyncSucceeded(); From 8533203921c7d3e7e041b750b35b7b10d5bf984e Mon Sep 17 00:00:00 2001 From: TheMasterCreed Date: Thu, 7 May 2026 19:29:06 +0000 Subject: [PATCH 16/23] Mirror staged mod list into in-memory active set during sync stageActiveModsForRestart writes Mods/Mods to QSettings but left m_activeMods untouched, on the original theory that the in-memory list should not change until the user restarts and the next-boot setActiveMods pass rebuilds it. That left a window where any saveSettings call between MODSYNCCOMPLETE and the close-and-relaunch step would overwrite the staged ini with getConfigString(m_activeMods), where m_activeMods still holds the pre-sync stale list. The repro was multiple resync attempts in a row, every restart presenting the same mismatch dialog with the same mods, with disk inspection confirming Mods/Mods reverted to the pre-sync client list rather than the host list it was staged to. saveSettings runs on a number of paths the user can hit between MODSYNCCOMPLETE and the relaunch, including game-quit cleanup and any path that touches a Q_INVOKABLE writer. Assign m_activeMods to the post-sync active list at the same point stageActiveModsForRestart writes the ini, and clear m_activeModVersions so the parallel-list invariant from setActiveMods is preserved. Versions get rebuilt from each mod's mod.txt at next-boot setActiveMods, the same way they would on a clean fresh boot. Mirror the rollback in restoreActiveModsRaw: on manifest-write failure, the prior raw value is written back to QSettings, m_activeMods is rebuilt by splitting that raw value on the comma separator, and m_activeModVersions is cleared the same way. Empty raw value yields an empty list rather than a one-element list containing the empty string, matching getConfigString's round-trip contract. The slave and ai-slave guards already at the top of both functions still short-circuit, so headless processes do not touch their in-memory state. --- coreengine/settings.cpp | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/coreengine/settings.cpp b/coreengine/settings.cpp index f3527d9fe..f45f5aa13 100644 --- a/coreengine/settings.cpp +++ b/coreengine/settings.cpp @@ -1104,6 +1104,11 @@ QString Settings::stageActiveModsForRestart(const QStringList & activeMods) 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; } @@ -1118,6 +1123,8 @@ void Settings::restoreActiveModsRaw(const QString & rawValue) 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) From 636007d1f26bbd1bee76d5a548ad91570fa39652 Mon Sep 17 00:00:00 2001 From: TheMasterCreed Date: Thu, 7 May 2026 20:09:20 +0000 Subject: [PATCH 17/23] Shorten Apply mod-sync button label to fit DialogMessageBox button width The DialogMessageBox two-button layout assigns a fixed width per button and the previous "Apply host's mod set" label was truncated to "Apply hos..." at runtime. Shorten to "Apply" so the label fits any reasonable font scale and locale rendering of the same DialogMessageBox button width. --- multiplayer/multiplayermenu.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/multiplayer/multiplayermenu.cpp b/multiplayer/multiplayermenu.cpp index 5d6755b82..8b4e37be8 100644 --- a/multiplayer/multiplayermenu.cpp +++ b/multiplayer/multiplayermenu.cpp @@ -1577,7 +1577,7 @@ void Multiplayermenu::handleVersionMissmatch(const QStringList & mods, const QSt if (fixableViaSync) { - spDialogMessageBox pDialogMessageBox = MemoryManagement::create(message, true, tr("Apply host's mod set"), tr("Leave game")); + 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) From 94296c04ddb7e3682ba6c047bc759ca558889799 Mon Sep 17 00:00:00 2001 From: TheMasterCreed Date: Thu, 7 May 2026 20:56:26 +0000 Subject: [PATCH 18/23] Auto-restart and rejoin after mod-sync; fix progress bar paint timing handleModSyncComplete defers onModSyncSucceeded by QTimer::singleShot(500ms) so the progress dialog paints at 100% before teardown. onModSyncSucceeded writes userData/.rejoin.json (host, port, timestamp; no password), sets Mainapp::setRestartArgv with --userPath when the parent had it on the command line and --rejoin-password (plaintext) only on manifest-write success, and calls QCoreApplication::exit(1). main.cpp now passes Mainapp::getRestartArgv to the existing QProcess::startDetached restart path. CommandLineParser::parseArgsPhaseOne consumes --rejoin-password into Mainapp::m_rejoinPassword. WorkerThread::showMainwindow consumes the manifest unconditionally so a slave relaunch still discards it, moves the password from the static slot into a local, and if the manifest is fresh (within 5 minutes) and the launch is not slave or aiSlave, constructs a Multiplayermenu directly with the connected host endpoint and skips the main menu. Multiplayermenu caches the constructor primary endpoint as fallback when the interface has no recorded connected address. Filesupport gains writeRejoinManifest, consumeRejoinManifest, rejoinManifestPath: QSaveFile-atomic write, 4KB read cap, version plus host plus port validated, consumed and deleted on first read regardless of validity. --- coreengine/commandlineparser.cpp | 9 +++- coreengine/commandlineparser.h | 2 + coreengine/filesupport.cpp | 85 ++++++++++++++++++++++++++++++++ coreengine/filesupport.h | 12 +++++ coreengine/mainapp.cpp | 22 +++++++++ coreengine/mainapp.h | 7 +++ coreengine/workerthread.cpp | 25 ++++++++++ main.cpp | 2 +- multiplayer/multiplayermenu.cpp | 59 ++++++++++++++++++---- multiplayer/multiplayermenu.h | 2 + 10 files changed, 214 insertions(+), 11 deletions(-) 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 23a582609..1c4e4f838 100644 --- a/coreengine/filesupport.cpp +++ b/coreengine/filesupport.cpp @@ -905,3 +905,88 @@ QStringList Filesupport::executePendingModSyncManifest(const QString & installRo 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 315eb1703..21b71b7da 100644 --- a/coreengine/filesupport.h +++ b/coreengine/filesupport.h @@ -63,6 +63,18 @@ class Filesupport final 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/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/multiplayer/multiplayermenu.cpp b/multiplayer/multiplayermenu.cpp index 8b4e37be8..2ce295e19 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" @@ -42,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) @@ -68,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); @@ -3047,14 +3053,15 @@ void Multiplayermenu::handleModSyncComplete(QDataStream & stream, quint64 socket onModSyncFailed(tr("Failed to write the pending mod-sync manifest.")); return; } - CONSOLE_PRINT("Mod-sync complete: " + QString::number(m_modSyncStagings.size()) + " mods staged. Restart the game to apply.", GameConsole::eINFO); + 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_modSyncPostSyncActiveMods.clear(); - onModSyncSucceeded(); + // 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) @@ -3166,9 +3173,43 @@ void Multiplayermenu::onModSyncSucceeded() m_modSyncProgressDialog->detach(); m_modSyncProgressDialog.reset(); } - spDialogMessageBox pDialog = MemoryManagement::create(tr("Mod sync complete. Restart the game to apply the host's mod set.")); - connect(pDialog.get(), &DialogMessageBox::sigOk, this, &Multiplayermenu::buttonBack, Qt::QueuedConnection); - addChild(pDialog); + 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) diff --git a/multiplayer/multiplayermenu.h b/multiplayer/multiplayermenu.h index 29baa4fbb..e85536a26 100644 --- a/multiplayer/multiplayermenu.h +++ b/multiplayer/multiplayermenu.h @@ -375,6 +375,8 @@ protected slots: bool m_slaveGameReady{false}; Password m_password; quint64 m_hostSocket{0}; + QString m_serverAddress; + quint16 m_serverPort{0}; spDialogConnecting m_pDialogConnecting; QElapsedTimer m_slaveDespawnElapseTimer; QTimer m_slaveDespawnTimer{this}; From 08eacb43babfc17396215d4f2ac8679c0147e45b Mon Sep 17 00:00:00 2001 From: TheMasterCreed Date: Thu, 7 May 2026 23:58:47 +0000 Subject: [PATCH 19/23] Add Settings checkboxes for mod sync hosting, backup retention, and trust prompt Settings gains m_modSyncEnabled and m_modSyncKeepBackups checkboxes in the Network options menu. The host toggle was previously INI-only and gates whether CapabilityModSync is advertised in the handshake. The new keep-backups toggle defaults false; when off, executePendingModSyncManifest still creates the .bak directory during the staging swap so a partial-rename failure can roll back, then removes it after the swap succeeds. The boot reaper still runs on the next launch and prunes anything left behind from interrupted sessions. confirmModSync now shows a trust dialog before any host content is downloaded. Clicking Apply on the version mismatch dialog leads to "You are about to install mods from this host. These are unverified scripts that will run in your game. Only continue if you trust this host." with Yes install or Cancel. The settings only branch (no downloads, just toggling activation flags) skips the prompt because no host content is being executed. The download path moves to a new startModSyncDownload helper so confirmModSync stays a thin trust gate and router. resources/ui/options/optionnetworkmenu.xml gets the two label plus checkbox pairs at the bottom of the Network panel. m_modSyncKeepBackups gets a new Q_INVOKABLE getter and setter on Settings, plus the matching Network/ModSyncKeepBackups INI entry next to the other ModSync caps. --- coreengine/filesupport.cpp | 10 ++++++++ coreengine/settings.cpp | 14 +++++++++- coreengine/settings.h | 4 +++ multiplayer/multiplayermenu.cpp | 19 ++++++++++++-- multiplayer/multiplayermenu.h | 1 + resources/ui/options/optionnetworkmenu.xml | 30 ++++++++++++++++++++++ 6 files changed, 75 insertions(+), 3 deletions(-) diff --git a/coreengine/filesupport.cpp b/coreengine/filesupport.cpp index 1c4e4f838..2adb97385 100644 --- a/coreengine/filesupport.cpp +++ b/coreengine/filesupport.cpp @@ -820,6 +820,8 @@ QStringList Filesupport::executePendingModSyncManifest(const QString & installRo 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(); @@ -901,6 +903,14 @@ QStringList Filesupport::executePendingModSyncManifest(const QString & installRo } 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; diff --git a/coreengine/settings.cpp b/coreengine/settings.cpp index f45f5aa13..c655e476c 100644 --- a/coreengine/settings.cpp +++ b/coreengine/settings.cpp @@ -311,6 +311,16 @@ 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; @@ -1495,6 +1505,7 @@ void Settings::setup() 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()), @@ -1544,7 +1555,8 @@ void Settings::loadSettings() 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); - Filesupport::reapModSyncFolders(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); diff --git a/coreengine/settings.h b/coreengine/settings.h index f0b6f320c..edcf52ba4 100644 --- a/coreengine/settings.h +++ b/coreengine/settings.h @@ -363,6 +363,8 @@ class Settings final : public QObject 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(); @@ -955,6 +957,8 @@ class Settings final : public QObject 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; diff --git a/multiplayer/multiplayermenu.cpp b/multiplayer/multiplayermenu.cpp index 2ce295e19..cf2212d28 100644 --- a/multiplayer/multiplayermenu.cpp +++ b/multiplayer/multiplayermenu.cpp @@ -3119,10 +3119,10 @@ void Multiplayermenu::cancelModSyncSession() void Multiplayermenu::confirmModSync(const QStringList & modsToDownload, const QStringList & postSyncActiveMods) { - const bool ok = requestModSync(modsToDownload, postSyncActiveMods); if (modsToDownload.isEmpty()) { - // Settings-only branch already either staged Mods/Mods or refused; surface the matching prompt. + // Settings-only branch: no untrusted host content downloaded, skip the trust prompt. + const bool ok = requestModSync(modsToDownload, postSyncActiveMods); if (ok) { onModSyncSucceeded(); @@ -3133,6 +3133,21 @@ void Multiplayermenu::confirmModSync(const QStringList & modsToDownload, const Q } 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.")); diff --git a/multiplayer/multiplayermenu.h b/multiplayer/multiplayermenu.h index e85536a26..c588803f8 100644 --- a/multiplayer/multiplayermenu.h +++ b/multiplayer/multiplayermenu.h @@ -191,6 +191,7 @@ protected slots: 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); 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) + From d9eb5d203e22f0ef58ffc3dc4fd09491213d8ceb Mon Sep 17 00:00:00 2001 From: TheMasterCreed Date: Sat, 9 May 2026 14:32:48 +0000 Subject: [PATCH 20/23] Implement slice 5a/5b DialogModSyncProgress now shows a smoothed download rate, an ETA, and a byte-based bar fraction. Network throughput uses an exponential moving average over compressed-byte deltas with a 50 ms minimum sample window, and ETA divides the remaining uncompressed bytes by a parallel uncompressed-rate EMA so the units match what the bar fills against. When the host's manifest has not arrived the bar falls back to staged mods over total mods. A monotonic floor on the displayed fraction prevents late or out-of-order events from rewinding the visual. The initial join "Connecting" dialog is now held as a Multiplayermenu member so the mod-sync flow can dismiss it before opening the progress dialog, eliminating a stacked second Cancel button. DialogConnecting::connected and ::cancel stop both their internal timers so a retained smart pointer cannot fire a late connectionTimeout. A queued lambda on sigConnected then drops the smart pointer after the dialog has detached itself. The host emits a new MODSYNCMANIFEST frame before the first MODSYNCDATA carrying per-mod expected uncompressed sizes. The client sums only entries that match its requested set, feeds the total to the dialog, and refuses any later downward revision. Older hosts skip the frame and clients fall back cleanly; older clients drop the unknown frame on the existing dispatcher branch. The host now prebuilds all packages so the manifest can carry exact sizes, releasing each compressed blob after its MODSYNCDATA send so peak memory stays near one total cap. qCompress level drops from default 6 to 1 in buildModSyncPackage; the size penalty on text-heavy mods is small and the host CPU stall improvement is meaningful for slow-CPU clients. qUncompress is level-agnostic so the wire format is unchanged. --- coreengine/filesupport.cpp | 3 +- multiplayer/multiplayermenu.cpp | 133 ++++++++++++++++++-- multiplayer/multiplayermenu.h | 5 + multiplayer/networkcommands.h | 2 + objects/dialogs/dialogconnecting.cpp | 5 + objects/dialogs/dialogmodsyncprogress.cpp | 145 ++++++++++++++++++++-- objects/dialogs/dialogmodsyncprogress.h | 18 ++- 7 files changed, 291 insertions(+), 20 deletions(-) diff --git a/coreengine/filesupport.cpp b/coreengine/filesupport.cpp index 2adb97385..c4048a286 100644 --- a/coreengine/filesupport.cpp +++ b/coreengine/filesupport.cpp @@ -435,7 +435,8 @@ Filesupport::ModSyncPackage Filesupport::buildModSyncPackage(const QString & ins return pkg; } pkg.declaredUncompressedSize = static_cast(serialized.size()); - pkg.compressedBlob = qCompress(serialized); + // 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) { diff --git a/multiplayer/multiplayermenu.cpp b/multiplayer/multiplayermenu.cpp index cf2212d28..aef553a3c 100644 --- a/multiplayer/multiplayermenu.cpp +++ b/multiplayer/multiplayermenu.cpp @@ -142,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() @@ -547,6 +549,10 @@ void Multiplayermenu::recieveData(quint64 socketID, QByteArray data, NetworkInte { handleModSyncRequest(stream, socketID); } + else if (messageType == NetworkCommands::MODSYNCMANIFEST) + { + handleModSyncManifest(stream, socketID); + } else if (messageType == NetworkCommands::MODSYNCDATA) { handleModSyncData(stream, socketID); @@ -2675,6 +2681,7 @@ bool Multiplayermenu::requestModSync(const QStringList & modsToDownload, const Q m_modSyncRequestedSet = QSet(modsToDownload.cbegin(), modsToDownload.cend()); m_modSyncReceivedBytes = 0; m_modSyncReceivedUncompressedBytes = 0; + m_modSyncExpectedUncompressedTotal = 0; m_modSyncPostSyncActiveMods = postSyncActiveMods; QByteArray data; @@ -2774,6 +2781,9 @@ void Multiplayermenu::handleModSyncRequest(QDataStream & stream, quint64 socketI } }; + // 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)) @@ -2821,18 +2831,40 @@ void Multiplayermenu::handleModSyncRequest(QDataStream & stream, quint64 socketI } 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); + } + + for (auto & entry : builtPackages) + { QByteArray data; QDataStream sendStream(&data, QIODevice::WriteOnly); sendStream.setVersion(QDataStream::Version::Qt_6_5); sendStream << QString(NetworkCommands::MODSYNCDATA); sendStream << static_cast(1); - sendStream << mod; - sendStream << pkg.declaredUncompressedSize; - sendStream << pkg.fileCount; - sendStream << pkg.compressedBlob; + sendStream << entry.first; + sendStream << entry.second.declaredUncompressedSize; + sendStream << entry.second.fileCount; + sendStream << entry.second.compressedBlob; emit m_pNetworkInterface->sig_sendData(socketID, data, NetworkInterface::NetworkSerives::Multiplayer, false); - CONSOLE_PRINT("Sent MODSYNCDATA for " + mod + " (" + QString::number(pkg.compressedBlob.size()) + " bytes)", GameConsole::eINFO); + CONSOLE_PRINT("Sent MODSYNCDATA for " + entry.first + " (" + QString::number(entry.second.compressedBlob.size()) + " bytes)", GameConsole::eINFO); + // QByteArray COW: clearing here drops only our ref; the queued send keeps its own. + entry.second.compressedBlob.clear(); } QByteArray data; @@ -2844,6 +2876,75 @@ void Multiplayermenu::handleModSyncRequest(QDataStream & stream, quint64 socketI CONSOLE_PRINT("Sent MODSYNCCOMPLETE; " + QString::number(requestedMods.size()) + " mods, " + QString::number(totalSent) + " compressed bytes, " + QString::number(totalUncompressed) + " uncompressed bytes", GameConsole::eINFO); } +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); @@ -3034,6 +3135,7 @@ void Multiplayermenu::handleModSyncComplete(QDataStream & stream, quint64 socket m_modSyncActive = false; m_modSyncReceivedBytes = 0; m_modSyncReceivedUncompressedBytes = 0; + m_modSyncExpectedUncompressedTotal = 0; m_modSyncPostSyncActiveMods.clear(); return; } @@ -3059,6 +3161,7 @@ void Multiplayermenu::handleModSyncComplete(QDataStream & stream, quint64 socket m_modSyncRequestedSet.clear(); m_modSyncReceivedBytes = 0; m_modSyncReceivedUncompressedBytes = 0; + m_modSyncExpectedUncompressedTotal = 0; 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); @@ -3113,6 +3216,7 @@ void Multiplayermenu::cancelModSyncSession() m_modSyncRequestedSet.clear(); m_modSyncReceivedBytes = 0; m_modSyncReceivedUncompressedBytes = 0; + m_modSyncExpectedUncompressedTotal = 0; m_modSyncActive = false; m_modSyncPostSyncActiveMods.clear(); } @@ -3153,12 +3257,19 @@ void Multiplayermenu::startModSyncDownload(const QStringList & modsToDownload, c 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) @@ -3177,8 +3288,8 @@ void Multiplayermenu::onModSyncProgress() { return; } - // Show declared uncompressed bytes so the user sees on-disk size, not wire-compressed size. - m_modSyncProgressDialog->setProgress(static_cast(m_modSyncStagings.size()), m_modSyncReceivedUncompressedBytes); + // 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() diff --git a/multiplayer/multiplayermenu.h b/multiplayer/multiplayermenu.h index c588803f8..18b23c04a 100644 --- a/multiplayer/multiplayermenu.h +++ b/multiplayer/multiplayermenu.h @@ -199,6 +199,7 @@ protected slots: void verifyGameData(QDataStream & stream, quint64 socketID); 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 handleModSyncReject(QDataStream & stream, quint64 socketID); void handleModSyncComplete(QDataStream & stream, quint64 socketID); @@ -379,6 +380,8 @@ protected slots: 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}; @@ -390,6 +393,8 @@ protected slots: 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; }; diff --git a/multiplayer/networkcommands.h b/multiplayer/networkcommands.h index dfcc861dc..93e58067e 100644 --- a/multiplayer/networkcommands.h +++ b/multiplayer/networkcommands.h @@ -160,6 +160,8 @@ namespace NetworkCommands 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"; const char* const MODSYNCREJECT = "MODSYNCREJECT"; const char* const MODSYNCCOMPLETE = "MODSYNCCOMPLETE"; 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 index 6de7c6893..1f97c0ebd 100644 --- a/objects/dialogs/dialogmodsyncprogress.cpp +++ b/objects/dialogs/dialogmodsyncprogress.cpp @@ -74,26 +74,107 @@ DialogModSyncProgress::DialogModSyncProgress(qint32 totalMods) emit sigCancel(); }); connect(this, &DialogModSyncProgress::sigCancel, this, &DialogModSyncProgress::remove, Qt::QueuedConnection); + m_timer.start(); } -void DialogModSyncProgress::setProgress(qint32 stagedMods, qint64 receivedBytes) +void DialogModSyncProgress::setExpectedTotalBytes(qint64 expectedUncompressed) { - if (m_totalMods <= 0) + 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 (stagedMods > m_totalMods) + if (m_totalMods > 0 && stagedMods > m_totalMods) { stagedMods = m_totalMods; } - const float fraction = static_cast(stagedMods) / static_cast(m_totalMods); - m_BarFill->setWidth(m_barWidth * fraction); - const qint64 receivedKb = receivedBytes / 1024; - m_Detail->setHtmlText(tr("%1 / %2 mods (%3 KB)").arg(stagedMods).arg(m_totalMods).arg(receivedKb)); + 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); m_Detail->setPosition(oxygine::Stage::getStage()->getWidth() / 2 - m_Detail->getTextRect().width() / 2, m_Detail->getY()); } @@ -102,3 +183,53 @@ 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 index dc2fbc439..19511cf10 100644 --- a/objects/dialogs/dialogmodsyncprogress.h +++ b/objects/dialogs/dialogmodsyncprogress.h @@ -1,6 +1,7 @@ #ifndef DIALOGMODSYNCPROGRESS_H #define DIALOGMODSYNCPROGRESS_H +#include #include #include "3rd_party/oxygine-framework/oxygine/actor/Actor.h" @@ -18,14 +19,29 @@ class DialogModSyncProgress final : public QObject, public oxygine::Actor explicit DialogModSyncProgress(qint32 totalMods); virtual ~DialogModSyncProgress() = default; - void setProgress(qint32 stagedMods, qint64 receivedBytes); + // 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; From 177dc4d919e01ef023b34193d39fb64ba3ffce77 Mon Sep 17 00:00:00 2001 From: TheMasterCreed Date: Sat, 9 May 2026 18:05:57 +0000 Subject: [PATCH 21/23] Implement slice 6 Mod-sync now delivers each mod as a sequence of MODSYNCMODBEGIN, one or more MODSYNCMODCHUNK frames, and MODSYNCMODEND when both endpoints opt in via the new ModSyncClientFlagChunked bit on REQUESTMODSYNC. Chunks are 1 MiB each (declared as a wire contract on both sides; the chunkCount and per-chunk size are validated against this constant). The receiver advances the uncompressed-byte counter proportionally on every chunk and snap-corrects to the exact declared total on MODSYNCMODEND, so the dialog's bar fraction and ETA finally move during a single large mod transfer. Cap enforcement still runs against the live counter every chunk, drip-feed defense intact. The legacy single-frame MODSYNCDATA path is preserved verbatim for older hosts and older clients. Compatibility holds in all four pair combinations: new-on-new speaks chunked, new-on-old falls through to legacy because the trailing clientFlags qint32 is silently ignored by older hosts, old-on-new sends no flag and the host serves legacy, old-on-old continues unchanged. The host send loop is no longer synchronous. handleModSyncRequest pre-builds packages and emits MODSYNCMANIFEST as before, then hands the queue to a member ModSyncSendState and kicks off pumpModSyncSend via QTimer::singleShot. Each tick emits exactly one frame, yields to the event loop, and reschedules itself, so a multi-hundred-megabyte transfer no longer pins the GUI thread. Concurrent REQUESTMODSYNC from a second peer rejects with the new ModSyncBusy reason rather than clobbering the in-flight pump; per-socket send state is the right future hardening. The optional clientFlags trailer parse on REQUESTMODSYNC is strict: zero trailing bytes mean legacy, exactly one qint32 means new client. Partial qint32 (1 to 3 bytes) and post-flag-trailing bytes both reject as malformed instead of being silently treated as flags zero. BEGIN-time validation rejects before any QByteArray reserve. compressedTotal is bounded by perModCap and explicitly capped to the qint32 range as belt-and-suspenders against a future widening of perModBytes. chunkCount must equal ceil(compressedTotal divided by MOD_SYNC_CHUNK_BYTES) and stay under the sanity ceiling. modPath must validate and live in the requested set, the same gate the legacy path uses. CHUNK-time validation enforces sequential chunkIndex, modPath match against the in-flight mod, exact size match against the position in the sequence, and accumulated overflow against compressedTotal. END-time validation re-checks the size and chunk-count totals before extract, then runs the existing extractModSyncPackage and stageModSync path on the assembled blob. State guards reject MODSYNCDATA mixed with chunked, MODSYNCMODBEGIN while another is in flight, and MODSYNCCOMPLETE while a chunked mod is unfinished. cancelModSyncSession now clears the host-side send state ahead of the client-side active-flag guard so a host teardown path that runs with the client flag false still tears down the pump. requestModSync and the success paths reset the per-mod chunk accumulator alongside the existing counters so a cancel-and-reconnect leaves no stale per-mod state behind. The pump itself short-circuits on socketID equal to zero so a reset between ticks is safe. Files: multiplayer/multiplayermenu.cpp, multiplayer/multiplayermenu.h, multiplayer/networkcommands.h. --- multiplayer/multiplayermenu.cpp | 486 +++++++++++++++++++++++++++++++- multiplayer/multiplayermenu.h | 34 +++ multiplayer/networkcommands.h | 11 + 3 files changed, 519 insertions(+), 12 deletions(-) diff --git a/multiplayer/multiplayermenu.cpp b/multiplayer/multiplayermenu.cpp index aef553a3c..4f7c9fed3 100644 --- a/multiplayer/multiplayermenu.cpp +++ b/multiplayer/multiplayermenu.cpp @@ -557,6 +557,18 @@ void Multiplayermenu::recieveData(quint64 socketID, QByteArray data, NetworkInte { 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); @@ -2571,6 +2583,7 @@ static_assert(static_cast(NetworkCommands::ModSyncSizeCapExceeded) == 3, 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 { @@ -2578,6 +2591,10 @@ namespace 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; @@ -2683,6 +2700,7 @@ bool Multiplayermenu::requestModSync(const QStringList & modsToDownload, const Q m_modSyncReceivedUncompressedBytes = 0; m_modSyncExpectedUncompressedTotal = 0; m_modSyncPostSyncActiveMods = postSyncActiveMods; + m_modSyncCurrentChunkMod = ModSyncChunkAccumulator{}; QByteArray data; QDataStream stream(&data, QIODevice::WriteOnly); @@ -2690,9 +2708,11 @@ bool Multiplayermenu::requestModSync(const QStringList & modsToDownload, const Q 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", GameConsole::eINFO); + CONSOLE_PRINT("Requested mod-sync for " + QString::number(modsToDownload.size()) + " mods (chunked-capable)", GameConsole::eINFO); return true; } @@ -2709,6 +2729,13 @@ void Multiplayermenu::handleModSyncRequest(QDataStream & stream, quint64 socketI 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) @@ -2745,6 +2772,18 @@ void Multiplayermenu::handleModSyncRequest(QDataStream & stream, quint64 socketI } } } + // 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(); @@ -2850,7 +2889,36 @@ void Multiplayermenu::handleModSyncRequest(QDataStream & stream, quint64 socketI CONSOLE_PRINT("Sent MODSYNCMANIFEST; " + QString::number(builtPackages.size()) + " mods, " + QString::number(totalUncompressed) + " expected uncompressed bytes", GameConsole::eINFO); } - for (auto & entry : builtPackages) + // 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); @@ -2861,19 +2929,67 @@ void Multiplayermenu::handleModSyncRequest(QDataStream & stream, quint64 socketI sendStream << entry.second.declaredUncompressedSize; sendStream << entry.second.fileCount; sendStream << entry.second.compressedBlob; - emit m_pNetworkInterface->sig_sendData(socketID, data, NetworkInterface::NetworkSerives::Multiplayer, false); + 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); - // QByteArray COW: clearing here drops only our ref; the queued send keeps its own. entry.second.compressedBlob.clear(); + ++state.currentMod; + QTimer::singleShot(0, this, &Multiplayermenu::pumpModSyncSend); + return; } - - 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(socketID, data, NetworkInterface::NetworkSerives::Multiplayer, false); - CONSOLE_PRINT("Sent MODSYNCCOMPLETE; " + QString::number(requestedMods.size()) + " mods, " + QString::number(totalSent) + " compressed bytes, " + QString::number(totalUncompressed) + " uncompressed bytes", GameConsole::eINFO); + 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) @@ -2965,6 +3081,13 @@ void Multiplayermenu::handleModSyncData(QDataStream & stream, quint64 socketID) 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) @@ -3057,6 +3180,333 @@ void Multiplayermenu::handleModSyncData(QDataStream & stream, quint64 socketID) 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); @@ -3122,6 +3572,13 @@ void Multiplayermenu::handleModSyncComplete(QDataStream & stream, quint64 socket 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); @@ -3136,6 +3593,7 @@ void Multiplayermenu::handleModSyncComplete(QDataStream & stream, quint64 socket m_modSyncReceivedBytes = 0; m_modSyncReceivedUncompressedBytes = 0; m_modSyncExpectedUncompressedTotal = 0; + m_modSyncCurrentChunkMod = ModSyncChunkAccumulator{}; m_modSyncPostSyncActiveMods.clear(); return; } @@ -3162,6 +3620,7 @@ void Multiplayermenu::handleModSyncComplete(QDataStream & stream, quint64 socket 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); @@ -3189,6 +3648,8 @@ void Multiplayermenu::cancelModSyncSession() 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; @@ -3218,6 +3679,7 @@ void Multiplayermenu::cancelModSyncSession() m_modSyncReceivedUncompressedBytes = 0; m_modSyncExpectedUncompressedTotal = 0; m_modSyncActive = false; + m_modSyncCurrentChunkMod = ModSyncChunkAccumulator{}; m_modSyncPostSyncActiveMods.clear(); } diff --git a/multiplayer/multiplayermenu.h b/multiplayer/multiplayermenu.h index 18b23c04a..c57b4ee20 100644 --- a/multiplayer/multiplayermenu.h +++ b/multiplayer/multiplayermenu.h @@ -7,9 +7,12 @@ #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" @@ -201,10 +204,15 @@ protected slots: 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 @@ -397,6 +405,32 @@ protected slots: 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 93e58067e..1df023790 100644 --- a/multiplayer/networkcommands.h +++ b/multiplayer/networkcommands.h @@ -163,9 +163,19 @@ namespace NetworkCommands // 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 { @@ -176,6 +186,7 @@ namespace NetworkCommands ModSyncFileCountCapExceeded = 4, ModSyncInvalidPath = 5, ModSyncInternalError = 6, + ModSyncBusy = 7, }; /** * @brief JOINASPLAYER From e214e13b6cf3fc8f2cb68f42e8f21d2c3a11abf4 Mon Sep 17 00:00:00 2001 From: TheMasterCreed Date: Sat, 9 May 2026 18:20:58 +0000 Subject: [PATCH 22/23] Fix mod-sync progress dialog text centering The detail and header lines on the mod-sync progress dialog shifted to the left of the dialog because the constructor centered each field by computing x from m_*->getTextRect().width() right after setHtmlText. Oxygine's TextField returns an unreliable rect at that point, especially when text length grows substantially in a later setHtmlText call (the chunked-transfer detail line) but also in some configurations on first construction (the header). Both fields now use an explicit full-stage width via setSize and place at x=0; the existing HALIGN_MIDDLE on the TextStyle then centers any text inside the laid-out field on every setHtmlText. setProgress no longer needs to re-position the detail field on each update. --- objects/dialogs/dialogmodsyncprogress.cpp | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/objects/dialogs/dialogmodsyncprogress.cpp b/objects/dialogs/dialogmodsyncprogress.cpp index 1f97c0ebd..7b7782e9b 100644 --- a/objects/dialogs/dialogmodsyncprogress.cpp +++ b/objects/dialogs/dialogmodsyncprogress.cpp @@ -32,9 +32,10 @@ DialogModSyncProgress::DialogModSyncProgress(qint32 totalMods) 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(oxygine::Stage::getStage()->getWidth() / 2 - m_Header->getTextRect().width() / 2, - oxygine::Stage::getStage()->getHeight() / 2 - 80); + m_Header->setPosition(0, oxygine::Stage::getStage()->getHeight() / 2 - 80); pSpriteBox->addChild(m_Header); m_barWidth = 480; @@ -60,9 +61,10 @@ DialogModSyncProgress::DialogModSyncProgress(qint32 totalMods) 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(oxygine::Stage::getStage()->getWidth() / 2 - m_Detail->getTextRect().width() / 2, - barY + barHeight + 10); + m_Detail->setPosition(0, barY + barHeight + 10); pSpriteBox->addChild(m_Detail); m_CancelButton = pObjectManager->createButton(tr("Cancel"), 150); @@ -175,8 +177,6 @@ void DialogModSyncProgress::setProgress(qint32 stagedMods, qint64 receivedCompre .arg(formatRate(static_cast(m_smoothedRateBytesPerSec))); } m_Detail->setHtmlText(detail); - m_Detail->setPosition(oxygine::Stage::getStage()->getWidth() / 2 - m_Detail->getTextRect().width() / 2, - m_Detail->getY()); } void DialogModSyncProgress::remove() From 4108a346c63e953457aaab28214bd33db1af5e62 Mon Sep 17 00:00:00 2001 From: TheMasterCreed Date: Sat, 9 May 2026 19:32:48 +0000 Subject: [PATCH 23/23] Make mod-sync mismatch prompt say the game restarts automatically The mismatch dialog asked "Want me to download host's mod set and apply it on the next start?", which leaves the user wondering whether they need to relaunch the game manually. The flow already triggers an automatic restart and rejoin via the rejoin manifest after staging completes, so spell that out: "Want me to download host's mod set, apply it, and restart automatically?". --- multiplayer/multiplayermenu.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/multiplayer/multiplayermenu.cpp b/multiplayer/multiplayermenu.cpp index 4f7c9fed3..df0db1414 100644 --- a/multiplayer/multiplayermenu.cpp +++ b/multiplayer/multiplayermenu.cpp @@ -1592,7 +1592,7 @@ void Multiplayermenu::handleVersionMissmatch(const QStringList & mods, const QSt } else if (fixableViaSync) { - message = tr("Your game data differs from the host:") + "\n\n" + message + tr("Want me to download host's mod set and apply it on the next start?"); + 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 {