diff --git a/Core/GameEngine/Include/GameNetwork/GameInfo.h b/Core/GameEngine/Include/GameNetwork/GameInfo.h index d17a3bd3c1..f2eb17f1bc 100644 --- a/Core/GameEngine/Include/GameNetwork/GameInfo.h +++ b/Core/GameEngine/Include/GameNetwork/GameInfo.h @@ -273,8 +273,9 @@ UnsignedShort GameInfo::getSuperweaponRestriction() const { return m_superweapon Bool GameInfo::oldFactionsOnly() const { return m_oldFactionsOnly; } void GameInfo::setOldFactionsOnly( Bool oldFactionsOnly ) { m_oldFactionsOnly = oldFactionsOnly; } -AsciiString GameInfoToAsciiString( const GameInfo *game ); -Bool ParseAsciiStringToGameInfo( GameInfo *game, AsciiString options ); +// TheSuperHackers @info arcticdolphin 02/03/2026 Added includeSeed and requireSeed parameters, defaulted to retail behavior. +AsciiString GameInfoToAsciiString( const GameInfo *game, Bool includeSeed = TRUE ); +Bool ParseAsciiStringToGameInfo( GameInfo *game, AsciiString options, Bool requireSeed = TRUE ); /** diff --git a/Core/GameEngine/Include/GameNetwork/LANAPI.h b/Core/GameEngine/Include/GameNetwork/LANAPI.h index a562aa257b..d8c0a177b7 100644 --- a/Core/GameEngine/Include/GameNetwork/LANAPI.h +++ b/Core/GameEngine/Include/GameNetwork/LANAPI.h @@ -173,6 +173,13 @@ struct LANMessage MSG_INACTIVE, ///< I've alt-tabbed out. Unaccept me cause I'm a poo-flinging monkey. MSG_REQUEST_GAME_INFO, ///< For direct connect, get the game info from a specific IP Address + +#if !RETAIL_COMPATIBLE_NETWORKING + // TheSuperHackers @feature arcticdolphin 02/03/2026 Commit-reveal protocol message types. + MSG_SEED_COMMIT, ///< Seed commitment broadcast + MSG_SEED_REVEAL, ///< Seed reveal broadcast + MSG_SEED_READY, ///< Seed protocol complete acknowledgment +#endif } messageType; WideChar name[g_lanPlayerNameLength+1]; ///< My name, for convenience @@ -267,6 +274,29 @@ struct LANMessage char options[m_lanMaxOptionsLength+1]; } GameOptions; +#if !RETAIL_COMPATIBLE_NETWORKING + // TheSuperHackers @feature arcticdolphin 02/03/2026 Commit-reveal protocol message payloads. + struct + { + BYTE roundNonce[4]; ///< First 4 bytes of host commit: ties this message to the current round + BYTE commit[32]; ///< SHA-256(secret || senderSlot) + BYTE senderSlot; ///< Slot index of the sending player + } SeedCommit; + + struct + { + BYTE roundNonce[4]; ///< First 4 bytes of host commit: ties this message to the current round + BYTE secret[16]; ///< The original 128-bit secret value + BYTE senderSlot; ///< Slot index of the sending player + } SeedReveal; + + struct + { + BYTE roundNonce[4]; ///< First 4 bytes of host commit: ties this ack to the current round + BYTE senderSlot; ///< Slot index of the sending player + } SeedReady; +#endif + }; }; #pragma pack(pop) @@ -386,6 +416,49 @@ class LANAPI : public LANAPIInterface Bool m_isActive; ///< is the game currently active? +#if !RETAIL_COMPATIBLE_NETWORKING + // TheSuperHackers @feature arcticdolphin 02/03/2026 Commit-reveal protocol state. + enum SeedPhase + { + SEED_PHASE_NONE = 0, ///< Not in protocol + SEED_PHASE_AWAITING_COMMITS, ///< Host: waiting for all commits; Non-host: committed, awaiting reveal trigger + SEED_PHASE_AWAITING_REVEALS, ///< Host: waiting for all reveals; Non-host: revealed, awaiting game start + }; + static const UnsignedInt s_seedPhaseTimeoutMs; ///< Per-phase timeout + static const UnsignedInt s_seedResendIntervalMs; ///< Interval between seed message resends + SeedPhase m_seedPhase; + Bool m_seedReady; ///< TRUE once seed protocol completed successfully + BYTE m_localSeedSecret[16]; ///< Local random 128-bit secret + BYTE m_localSeedCommit[32]; ///< Commitment hash of local secret + BYTE m_slotSeedCommit[MAX_SLOTS][32];///< Received commits per slot + BYTE m_slotSeedReveal[MAX_SLOTS][16];///< Received 128-bit secrets per slot + Bool m_slotCommitReceived[MAX_SLOTS]; + Bool m_slotRevealReceived[MAX_SLOTS]; + Bool m_slotSeedReady[MAX_SLOTS]; ///< Which slots have acknowledged seed ready + BYTE m_slotPendingRevealSecret[MAX_SLOTS][16]; ///< Buffered early reveal secret (before commit arrived) + BYTE m_slotPendingRevealNonce[MAX_SLOTS][4]; ///< Round nonce from buffered early reveal + Bool m_slotPendingRevealValid[MAX_SLOTS]; ///< Whether a pending reveal is buffered for this slot + UnsignedInt m_seedPhaseDeadline; ///< Phase deadline + UnsignedInt m_seedResendTime; ///< Next seed message resend time + + void resetSeedProtocolState(); ///< Clear all per-round seed protocol state + void beginSeedCommitPhase(); ///< Generate secret, broadcast commit, enter commit phase + void beginSeedRevealPhase(); ///< Enter reveal phase and broadcast own reveal + void finalizeSeed(); ///< XOR secrets, set seed, notify readiness + void abortSeedProtocol(const wchar_t *reason = nullptr); + void processVerifiedReveal(Int slot, const BYTE secret[16], const BYTE roundNonce[4]); ///< Verify nonce+commitment, store reveal, advance protocol + void flushPendingReveal(Int slot); ///< Drain buffered early reveal for slot if commit is now available + Bool checkSeedSlotFlags(const Bool flags[], Bool skipLocal) const; + Bool allSeedCommitsReceived() { return checkSeedSlotFlags(m_slotCommitReceived, TRUE); } + Bool allSeedRevealsReceived() { return checkSeedSlotFlags(m_slotRevealReceived, TRUE); } + Bool allSeedReadyReceived() { return checkSeedSlotFlags(m_slotSeedReady, FALSE); } ///< Includes local slot + static Bool generateLocalSecret(BYTE secret[16]); + static Bool computeSeedCommitment(const BYTE secret[16], BYTE senderSlot, BYTE outCommit[32]); + void handleSeedCommit(LANMessage *msg, UnsignedInt senderIP); + void handleSeedReveal(LANMessage *msg, UnsignedInt senderIP); + void handleSeedReady(LANMessage *msg, UnsignedInt senderIP); +#endif + protected: void sendMessage(LANMessage *msg, UnsignedInt ip = 0); // Convenience function void removePlayer(LANPlayer *player); diff --git a/Core/GameEngine/Source/GameNetwork/GameInfo.cpp b/Core/GameEngine/Source/GameNetwork/GameInfo.cpp index 475f51a868..4d56855e1a 100644 --- a/Core/GameEngine/Source/GameNetwork/GameInfo.cpp +++ b/Core/GameEngine/Source/GameNetwork/GameInfo.cpp @@ -888,7 +888,8 @@ Bool GameInfo::isSandbox() static const char slotListID = 'S'; -AsciiString GameInfoToAsciiString( const GameInfo *game ) +// TheSuperHackers @info arcticdolphin 02/03/2026 Added includeSeed parameter. +AsciiString GameInfoToAsciiString( const GameInfo *game, Bool includeSeed ) { if (!game) return AsciiString::TheEmptyString; @@ -917,14 +918,26 @@ AsciiString GameInfoToAsciiString( const GameInfo *game ) DEBUG_LOG(("Map name is %s", mapName.str())); } +#if RETAIL_COMPATIBLE_NETWORKING + // TheSuperHackers @info arcticdolphin 03/03/2026 Added includeSeed parameter to conditionally insert SD=. + (void)includeSeed; + const Bool emitSeed = TRUE; +#else + const Bool emitSeed = includeSeed; +#endif + + AsciiString seedField; + if (emitSeed) + seedField.format("SD=%d;", game->getSeed()); + AsciiString optionsString; #if RTS_GENERALS - optionsString.format("M=%2.2x%s;MC=%X;MS=%d;SD=%d;C=%d;", game->getMapContentsMask(), newMapName.str(), - game->getMapCRC(), game->getMapSize(), game->getSeed(), game->getCRCInterval()); + optionsString.format("M=%2.2x%s;MC=%X;MS=%d;%sC=%d;", game->getMapContentsMask(), newMapName.str(), + game->getMapCRC(), game->getMapSize(), seedField.str(), game->getCRCInterval()); #else - optionsString.format("US=%d;M=%2.2x%s;MC=%X;MS=%d;SD=%d;C=%d;SR=%u;SC=%u;O=%c;", game->getUseStats(), game->getMapContentsMask(), newMapName.str(), - game->getMapCRC(), game->getMapSize(), game->getSeed(), game->getCRCInterval(), game->getSuperweaponRestriction(), - game->getStartingCash().countMoney(), game->oldFactionsOnly() ? 'Y' : 'N' ); + optionsString.format("US=%d;M=%2.2x%s;MC=%X;MS=%d;%sC=%d;SR=%u;SC=%u;O=%c;", game->getUseStats(), game->getMapContentsMask(), newMapName.str(), + game->getMapCRC(), game->getMapSize(), seedField.str(), game->getCRCInterval(), game->getSuperweaponRestriction(), + game->getStartingCash().countMoney(), game->oldFactionsOnly() ? 'Y' : 'N'); #endif //add player info for each slot @@ -1000,8 +1013,14 @@ static Int grabHexInt(const char *s) Int b = strtol(tmp, nullptr, 16); return b; } -Bool ParseAsciiStringToGameInfo(GameInfo *game, AsciiString options) + +// TheSuperHackers @info arcticdolphin 02/03/2026 Added requireSeed parameter. +Bool ParseAsciiStringToGameInfo(GameInfo *game, AsciiString options, Bool requireSeed) { +#if RETAIL_COMPATIBLE_NETWORKING + // TheSuperHackers @info arcticdolphin 02/03/2026 requireSeed is unused in retail builds; seed is always required. + (void)requireSeed; +#endif // Parse game options char *buf = strdup(options.str()); char *bufPtr = buf; @@ -1482,7 +1501,12 @@ Bool ParseAsciiStringToGameInfo(GameInfo *game, AsciiString options) // * StartingCash // * OldFactionsOnly // In Generals they never were. +#if !RETAIL_COMPATIBLE_NETWORKING + // TheSuperHackers @info arcticdolphin 02/03/2026 SD= may be absent when requireSeed=false. + if (optionsOk && sawMap && sawMapCRC && sawMapSize && (!requireSeed || sawSeed) && sawSlotlist && sawCRC) +#else if (optionsOk && sawMap && sawMapCRC && sawMapSize && sawSeed && sawSlotlist && sawCRC) +#endif { // We were setting the Global Data directly here, but Instead, I'm now // first setting the data in game. We'll set the global data when @@ -1499,7 +1523,13 @@ Bool ParseAsciiStringToGameInfo(GameInfo *game, AsciiString options) game->setMapCRC(mapCRC); game->setMapSize(mapSize); game->setMapContentsMask(mapContentsMask); - game->setSeed(seed); + // TheSuperHackers @info arcticdolphin 02/03/2026 Only apply seed when SD= was present. + // When SD= is omitted (non-retail commit-reveal path), leave the seed unchanged so that + // periodic game-options re-broadcasts do not overwrite the value set by finalizeSeed(). + if (sawSeed) + { + game->setSeed(seed); + } game->setCRCInterval(crc); game->setUseStats(useStats); game->setSuperweaponRestriction(restriction); diff --git a/Core/GameEngine/Source/GameNetwork/LANAPI.cpp b/Core/GameEngine/Source/GameNetwork/LANAPI.cpp index 1016d26f66..ea5f2aa597 100644 --- a/Core/GameEngine/Source/GameNetwork/LANAPI.cpp +++ b/Core/GameEngine/Source/GameNetwork/LANAPI.cpp @@ -29,6 +29,10 @@ #include "Common/crc.h" #include "Common/GameState.h" #include "Common/Registry.h" +#if !RETAIL_COMPATIBLE_NETWORKING +// TheSuperHackers @feature arcticdolphin 02/03/2026 CryptoAPI header used by the seed protocol. +#include +#endif #include "GameNetwork/LANAPI.h" #include "GameNetwork/networkutil.h" #include "Common/GlobalData.h" @@ -44,6 +48,11 @@ static const UnsignedShort lobbyPort = 8086; ///< This is the UDP port used by a AsciiString GetMessageTypeString(UnsignedInt type); const UnsignedInt LANAPI::s_resendDelta = 10 * 1000; ///< This is how often we announce ourselves to the world +#if !RETAIL_COMPATIBLE_NETWORKING +// TheSuperHackers @feature arcticdolphin 02/03/2026 Seed protocol timing constants. +const UnsignedInt LANAPI::s_seedPhaseTimeoutMs = 2500; ///< Per-phase timeout +const UnsignedInt LANAPI::s_seedResendIntervalMs = 500; ///< Interval between seed message resends +#endif /* LANGame::LANGame() { @@ -87,6 +96,11 @@ LANAPI::LANAPI() : m_transport(nullptr) m_lastUpdate = 0; m_transport = new Transport; m_isActive = TRUE; + +#if !RETAIL_COMPATIBLE_NETWORKING + // TheSuperHackers @feature arcticdolphin 02/03/2026 Initialize seed protocol state. + resetSeedProtocolState(); +#endif } LANAPI::~LANAPI() @@ -112,6 +126,11 @@ void LANAPI::init() m_lastGameopt = ""; +#if !RETAIL_COMPATIBLE_NETWORKING + // TheSuperHackers @feature arcticdolphin 02/03/2026 Reset seed protocol state. + resetSeedProtocolState(); +#endif + #if TELL_COMPUTER_IDENTITY_IN_LAN_LOBBY char userName[UNLEN + 1]; DWORD bufSize = ARRAY_SIZE(userName); @@ -256,6 +275,18 @@ AsciiString GetMessageTypeString(UnsignedInt type) case LANMessage::MSG_INACTIVE: returnString.format("Inactive (%d)", type); break; +#if !RETAIL_COMPATIBLE_NETWORKING + // TheSuperHackers @feature arcticdolphin 02/03/2026 Seed protocol message type strings. + case LANMessage::MSG_SEED_COMMIT: + returnString.format("Seed Commit (%d)", type); + break; + case LANMessage::MSG_SEED_REVEAL: + returnString.format("Seed Reveal (%d)", type); + break; + case LANMessage::MSG_SEED_READY: + returnString.format("Seed Ready (%d)", type); + break; +#endif default: returnString.format("Unknown Message (%d)",type); } @@ -425,6 +456,19 @@ void LANAPI::update() handleInActive( msg, senderIP ); break; +#if !RETAIL_COMPATIBLE_NETWORKING + // TheSuperHackers @feature arcticdolphin 02/03/2026 Commit-reveal seed protocol messages. + case LANMessage::MSG_SEED_COMMIT: + handleSeedCommit(msg, senderIP); + break; + case LANMessage::MSG_SEED_REVEAL: + handleSeedReveal(msg, senderIP); + break; + case LANMessage::MSG_SEED_READY: + handleSeedReady(msg, senderIP); + break; +#endif + default: DEBUG_LOG(("Unknown LAN message type %d", msg->messageType)); } @@ -435,6 +479,79 @@ void LANAPI::update() } if(LANbuttonPushed) return; + +#if !RETAIL_COMPATIBLE_NETWORKING + // TheSuperHackers @feature arcticdolphin 02/03/2026 Abort seed protocol on phase timeout. + if (m_currentGame && m_seedPhase != SEED_PHASE_NONE && now >= m_seedPhaseDeadline) + { + DEBUG_LOG(("LANAPI: seed protocol phase %d timed out, aborting", m_seedPhase)); + abortSeedProtocol(); + } + + // TheSuperHackers @feature arcticdolphin 03/03/2026 Periodically resend seed messages to recover from UDP packet loss. + if (m_currentGame && m_seedResendTime && now >= m_seedResendTime) + { + const Int localSlot = m_currentGame->getLocalSlotNum(); + Bool didResend = FALSE; + + if (m_seedPhase == SEED_PHASE_AWAITING_COMMITS + && localSlot >= 0 && localSlot < MAX_SLOTS && m_slotCommitReceived[localSlot]) + { + LANMessage msg = {}; + fillInLANMessage(&msg); + msg.messageType = LANMessage::MSG_SEED_COMMIT; + memcpy(msg.SeedCommit.commit, m_localSeedCommit, sizeof(msg.SeedCommit.commit)); + msg.SeedCommit.senderSlot = static_cast(localSlot); + memcpy(msg.SeedCommit.roundNonce, m_slotSeedCommit[0], sizeof(msg.SeedCommit.roundNonce)); + sendMessage(&msg); + didResend = TRUE; + DEBUG_LOG(("LANAPI: resending seed commit")); + } + else + { + // Keep resending our reveal even after local finalization: peers that missed our + // reveal are still in AWAITING_REVEALS and will time out unless we keep sending. + // Resends stop when resetSeedProtocolState() clears m_slotRevealReceived. + if ((m_seedPhase == SEED_PHASE_AWAITING_REVEALS + || (m_seedPhase == SEED_PHASE_NONE && m_seedReady)) + && localSlot >= 0 && localSlot < MAX_SLOTS && m_slotRevealReceived[localSlot]) + { + LANMessage msg = {}; + fillInLANMessage(&msg); + msg.messageType = LANMessage::MSG_SEED_REVEAL; + memcpy(msg.SeedReveal.secret, m_localSeedSecret, 16); + msg.SeedReveal.senderSlot = static_cast(localSlot); + memcpy(msg.SeedReveal.roundNonce, m_slotSeedCommit[0], sizeof(msg.SeedReveal.roundNonce)); + sendMessage(&msg); + didResend = TRUE; + DEBUG_LOG(("LANAPI: resending seed reveal")); + } + // Seed-ready resend is independent of reveal resend: both can fire post-finalize. + if (m_seedReady && !m_currentGame->amIHost()) + { + if (localSlot < 0 || localSlot >= MAX_SLOTS) + { + DEBUG_LOG(("LANAPI: clearing m_seedReady: local slot invalid during seed-ready resend")); + m_seedReady = FALSE; + } + else + { + LANMessage msg = {}; + fillInLANMessage(&msg); + msg.messageType = LANMessage::MSG_SEED_READY; + memcpy(msg.SeedReady.roundNonce, m_slotSeedCommit[0], sizeof(msg.SeedReady.roundNonce)); + msg.SeedReady.senderSlot = static_cast(localSlot); + sendMessage(&msg, m_currentGame->getIP(0)); + didResend = TRUE; + DEBUG_LOG(("LANAPI: resending seed ready")); + } + } + } + + m_seedResendTime = didResend ? now + s_seedResendIntervalMs : 0; + } +#endif + // Send out periodic I'm Here messages if (now > s_resendDelta + m_lastResendTime) { @@ -711,7 +828,12 @@ void LANAPI::RequestGameAnnounce() fillInLANMessage( &reply ); reply.messageType = LANMessage::MSG_GAME_ANNOUNCE; +#if !RETAIL_COMPATIBLE_NETWORKING + // TheSuperHackers @info arcticdolphin 02/03/2026 Omit SD= from announces; seed is negotiated via commit-reveal. + AsciiString gameOpts = GameInfoToAsciiString(m_currentGame, FALSE); +#else AsciiString gameOpts = GameInfoToAsciiString(m_currentGame); +#endif strlcpy(reply.GameInfo.options,gameOpts.str(), ARRAY_SIZE(reply.GameInfo.options)); wcslcpy(reply.GameInfo.gameName, m_currentGame->getName().str(), ARRAY_SIZE(reply.GameInfo.gameName)); reply.GameInfo.inProgress = m_currentGame->isGameInProgress(); @@ -795,6 +917,28 @@ void LANAPI::RequestGameStart() if (m_inLobby || !m_currentGame || m_currentGame->getIP(0) != m_localIP) return; +#if !RETAIL_COMPATIBLE_NETWORKING + // TheSuperHackers @feature arcticdolphin 03/03/2026 Seed protocol gates game start. + if (m_seedReady && m_seedPhase == SEED_PHASE_NONE && allSeedReadyReceived()) + { + // Seed protocol complete: proceed with game start. + LANMessage msg = {}; + fillInLANMessage(&msg); + msg.messageType = LANMessage::MSG_GAME_START; + sendMessage(&msg); + m_transport->update(); // flush before handoff + OnGameStart(); + } + else + { + // Seed protocol not started or still in progress: begin/re-poll. + // Phase timeouts are enforced separately in update(). + if (m_seedPhase == SEED_PHASE_NONE && !m_seedReady) + beginSeedCommitPhase(); + m_gameStartTime = timeGetTime() + 200; + m_gameStartSeconds = 0; + } +#else LANMessage msg; msg.messageType = LANMessage::MSG_GAME_START; fillInLANMessage( &msg ); @@ -802,8 +946,255 @@ void LANAPI::RequestGameStart() m_transport->update(); // force a send OnGameStart(); +#endif +} + +#if !RETAIL_COMPATIBLE_NETWORKING +// TheSuperHackers @feature arcticdolphin 02/03/2026 Generate 128-bit secret using CryptoAPI CSPRNG for seed protocol. +Bool LANAPI::generateLocalSecret(BYTE secret[16]) +{ + HCRYPTPROV hProv = 0; + if (!CryptAcquireContext(&hProv, nullptr, nullptr, PROV_RSA_AES, CRYPT_VERIFYCONTEXT)) + { + DEBUG_LOG(("LANAPI: CryptAcquireContext failed for RNG 0x%08X", GetLastError())); + return FALSE; + } + const Bool ok = CryptGenRandom(hProv, 16, secret) ? TRUE : FALSE; + if (!ok) + DEBUG_LOG(("LANAPI: CryptGenRandom failed 0x%08X", GetLastError())); + CryptReleaseContext(hProv, 0); + return ok; +} + +// TheSuperHackers @feature arcticdolphin 02/03/2026 Compute SHA-256 hash of secret concatenated with sender slot index. +Bool LANAPI::computeSeedCommitment(const BYTE secret[16], BYTE senderSlot, BYTE outCommit[32]) +{ + memset(outCommit, 0, 32); + HCRYPTPROV hProv = 0; + if (!CryptAcquireContext(&hProv, nullptr, nullptr, PROV_RSA_AES, CRYPT_VERIFYCONTEXT)) + { + DEBUG_LOG(("LANAPI: CryptAcquireContext failed 0x%08X", GetLastError())); + return FALSE; + } + Bool success = FALSE; + HCRYPTHASH hHash = 0; + if (CryptCreateHash(hProv, CALG_SHA_256, 0, 0, &hHash)) + { + BYTE input[17]; // 16-byte secret + 1-byte slot + memcpy(input, secret, 16); + input[16] = senderSlot; + if (CryptHashData(hHash, input, sizeof(input), 0)) + { + DWORD digestLen = 32; + if (CryptGetHashParam(hHash, HP_HASHVAL, outCommit, &digestLen, 0)) + success = TRUE; + else + DEBUG_LOG(("LANAPI: CryptGetHashParam failed 0x%08X", GetLastError())); + } + else + DEBUG_LOG(("LANAPI: CryptHashData failed 0x%08X", GetLastError())); + CryptDestroyHash(hHash); + } + else + DEBUG_LOG(("LANAPI: CryptCreateHash failed 0x%08X", GetLastError())); + CryptReleaseContext(hProv, 0); + return success; +} + +// TheSuperHackers @feature arcticdolphin 02/03/2026 Clear all per-round seed protocol state. +void LANAPI::resetSeedProtocolState() +{ + m_seedPhase = SEED_PHASE_NONE; + m_seedReady = FALSE; + memset(m_localSeedSecret, 0, sizeof(m_localSeedSecret)); + memset(m_localSeedCommit, 0, sizeof(m_localSeedCommit)); + m_seedPhaseDeadline = 0; + m_seedResendTime = 0; + memset(m_slotSeedCommit, 0, sizeof(m_slotSeedCommit)); + memset(m_slotSeedReveal, 0, sizeof(m_slotSeedReveal)); + memset(m_slotCommitReceived, 0, sizeof(m_slotCommitReceived)); + memset(m_slotRevealReceived, 0, sizeof(m_slotRevealReceived)); + memset(m_slotSeedReady, 0, sizeof(m_slotSeedReady)); + memset(m_slotPendingRevealSecret, 0, sizeof(m_slotPendingRevealSecret)); + memset(m_slotPendingRevealNonce, 0, sizeof(m_slotPendingRevealNonce)); + memset(m_slotPendingRevealValid, 0, sizeof(m_slotPendingRevealValid)); +} + +// TheSuperHackers @feature arcticdolphin 02/03/2026 Initialize seed protocol commit phase. +void LANAPI::beginSeedCommitPhase() +{ + resetSeedProtocolState(); + + const Int localSlot = m_currentGame->getLocalSlotNum(); + if (localSlot < 0 || localSlot >= MAX_SLOTS) + { + abortSeedProtocol(L"Could not start the game: invalid local slot. Please try again."); + return; + } + + if (!generateLocalSecret(m_localSeedSecret)) + { + abortSeedProtocol(L"Could not start the game: failed to generate a secure random secret. Please try again."); + return; + } + if (!computeSeedCommitment(m_localSeedSecret, static_cast(localSlot), m_localSeedCommit)) + { + abortSeedProtocol(L"Could not start the game: failed to compute seed commitment. Please try again."); + return; + } + + // Pre-fill host slot + { + memcpy(m_slotSeedCommit[localSlot], m_localSeedCommit, 32); + m_slotCommitReceived[localSlot] = TRUE; + } + + m_seedPhase = SEED_PHASE_AWAITING_COMMITS; + m_seedPhaseDeadline = timeGetTime() + s_seedPhaseTimeoutMs; + + LANMessage msg = {}; + fillInLANMessage(&msg); + msg.messageType = LANMessage::MSG_SEED_COMMIT; + memcpy(msg.SeedCommit.commit, m_localSeedCommit, sizeof(msg.SeedCommit.commit)); + msg.SeedCommit.senderSlot = static_cast(localSlot); + memcpy(msg.SeedCommit.roundNonce, m_localSeedCommit, sizeof(msg.SeedCommit.roundNonce)); + sendMessage(&msg); + m_seedResendTime = timeGetTime() + s_seedResendIntervalMs; + UnsignedInt dbgCommit; + memcpy(&dbgCommit, m_localSeedCommit, sizeof(dbgCommit)); + DEBUG_LOG(("LANAPI: seed commit phase started slot=%d commit[0..3]=0x%08X", localSlot, dbgCommit)); + + // Solo host: no other commits expected, advance immediately + if (allSeedCommitsReceived()) + beginSeedRevealPhase(); +} + +// TheSuperHackers @feature arcticdolphin 02/03/2026 Check if all human slots have a given per-slot boolean flag set. +Bool LANAPI::checkSeedSlotFlags(const Bool flags[], Bool skipLocal) const +{ + if (!m_currentGame) return FALSE; + for (Int i = 0; i < MAX_SLOTS; ++i) + { + if (skipLocal && m_currentGame->getIP(i) == m_localIP) + continue; + GameSlot *slot = m_currentGame->getSlot(i); + if (!slot || !slot->isHuman()) + continue; + if (!flags[i]) + return FALSE; + } + return TRUE; +} + +// TheSuperHackers @feature arcticdolphin 02/03/2026 Transition to reveal phase and broadcast local secret. +void LANAPI::beginSeedRevealPhase() +{ + const Int localSlot = m_currentGame->getLocalSlotNum(); + if (localSlot < 0 || localSlot >= MAX_SLOTS) + { + abortSeedProtocol(L"Could not start the game: invalid local slot. Please try again."); + return; + } + + // Pre-fill own reveal slot + { + memcpy(m_slotSeedReveal[localSlot], m_localSeedSecret, 16); + m_slotRevealReceived[localSlot] = TRUE; + } + + m_seedPhase = SEED_PHASE_AWAITING_REVEALS; + m_seedPhaseDeadline = timeGetTime() + s_seedPhaseTimeoutMs; + DEBUG_LOG(("LANAPI: seed reveal phase started")); + + LANMessage msg = {}; + fillInLANMessage(&msg); + msg.messageType = LANMessage::MSG_SEED_REVEAL; + memcpy(msg.SeedReveal.secret, m_localSeedSecret, 16); + msg.SeedReveal.senderSlot = static_cast(localSlot); + memcpy(msg.SeedReveal.roundNonce, m_slotSeedCommit[0], sizeof(msg.SeedReveal.roundNonce)); + sendMessage(&msg); + m_seedResendTime = timeGetTime() + s_seedResendIntervalMs; } +// TheSuperHackers @feature arcticdolphin 02/03/2026 Compute final seed from all revealed secrets and signal readiness. +void LANAPI::finalizeSeed() +{ + // XOR all 128-bit secrets, then truncate to 32-bit game seed for replay compatibility. + BYTE xorResult[16]; + memset(xorResult, 0, sizeof(xorResult)); + for (Int i = 0; i < MAX_SLOTS; ++i) + { + if (m_slotRevealReceived[i]) + { + for (int b = 0; b < 16; ++b) + xorResult[b] ^= m_slotSeedReveal[i][b]; + UnsignedInt dbgCommit; + memcpy(&dbgCommit, m_slotSeedCommit[i], sizeof(dbgCommit)); + DEBUG_LOG(("LANAPI: slot %d commit[0..3]=0x%08X", i, dbgCommit)); + } + } + UnsignedInt finalSeed; + memcpy(&finalSeed, xorResult, sizeof(finalSeed)); + // Wipe intermediate secret material from the stack immediately; only finalSeed is needed now. + memset(xorResult, 0, sizeof(xorResult)); + DEBUG_LOG(("LANAPI: final game seed 0x%08X", finalSeed)); + m_currentGame->setSeed(finalSeed); + // Wipe the per-slot reveal array early; it is no longer needed here. + // m_localSeedSecret is intentionally NOT wiped here: the resend loop still reads it to + // retransmit MSG_SEED_REVEAL to peers that haven't finalized yet. It is wiped by + // resetSeedProtocolState() once the game start is committed. + // m_slotSeedCommit is kept for the same reason (round nonce for MSG_SEED_READY resends). + memset(m_slotSeedReveal, 0, sizeof(m_slotSeedReveal)); + m_seedPhase = SEED_PHASE_NONE; + m_seedReady = TRUE; + + if (m_currentGame->amIHost()) + { + // Mark own slot as seed-ready so allSeedReadyReceived includes the host. + Int localSlot = m_currentGame->getLocalSlotNum(); + if (localSlot >= 0 && localSlot < MAX_SLOTS) + m_slotSeedReady[localSlot] = TRUE; + } + else + { + // Inform host of completion; nonce (first 4 bytes of host commit) filters stale packets. + const Int localSlot = m_currentGame->getLocalSlotNum(); + if (localSlot < 0 || localSlot >= MAX_SLOTS) + { + abortSeedProtocol(L"Could not start the game: invalid local slot. Please try again."); + return; + } + LANMessage msg = {}; + fillInLANMessage(&msg); + msg.messageType = LANMessage::MSG_SEED_READY; + memcpy(msg.SeedReady.roundNonce, m_slotSeedCommit[0], sizeof(msg.SeedReady.roundNonce)); + msg.SeedReady.senderSlot = static_cast(localSlot); + sendMessage(&msg, m_currentGame->getIP(0)); + m_seedResendTime = timeGetTime() + s_seedResendIntervalMs; + DEBUG_LOG(("LANAPI: sent seed ready acknowledgment to host")); + } +} + +// TheSuperHackers @feature arcticdolphin 02/03/2026 Abort seed protocol and reset all state so the host can retry. +void LANAPI::abortSeedProtocol(const wchar_t *reason) +{ + resetSeedProtocolState(); + + // Cancel countdown and force re-accept so the host can safely retry. + ResetGameStartTimer(); + if (m_currentGame) + { + m_currentGame->resetAccepted(); + if (m_currentGame->amIHost()) + RequestGameOptions(GenerateGameOptionsString(), TRUE); + } + + const wchar_t *msg = reason ? reason : L"Could not start the game: not all players finished negotiating the random seed. Please try again."; + OnChat(UnicodeString::TheEmptyString, m_localIP, UnicodeString(msg), LANCHAT_SYSTEM); +} + +#endif // !RETAIL_COMPATIBLE_NETWORKING + void LANAPI::ResetGameStartTimer() { m_gameStartTime = 0; @@ -815,6 +1206,12 @@ void LANAPI::RequestGameStartTimer( Int seconds ) if (m_inLobby || !m_currentGame || m_currentGame->getIP(0) != m_localIP) return; +#if !RETAIL_COMPATIBLE_NETWORKING + // TheSuperHackers @feature arcticdolphin 03/03/2026 Start seed protocol during countdown. + if (m_seedPhase == SEED_PHASE_NONE && !m_seedReady) + beginSeedCommitPhase(); +#endif + UnsignedInt now = timeGetTime(); m_gameStartTime = now + 1000; m_gameStartSeconds = (seconds) ? seconds - 1 : 0; diff --git a/Core/GameEngine/Source/GameNetwork/LANAPIhandlers.cpp b/Core/GameEngine/Source/GameNetwork/LANAPIhandlers.cpp index 7f88b835f8..c65c1e0baf 100644 --- a/Core/GameEngine/Source/GameNetwork/LANAPIhandlers.cpp +++ b/Core/GameEngine/Source/GameNetwork/LANAPIhandlers.cpp @@ -192,7 +192,12 @@ void LANAPI::handleRequestGameInfo( LANMessage *msg, UnsignedInt senderIP ) fillInLANMessage( &reply ); reply.messageType = LANMessage::MSG_GAME_ANNOUNCE; +#if !RETAIL_COMPATIBLE_NETWORKING + // TheSuperHackers @info arcticdolphin 02/03/2026 Omit SD= from announces; seed is negotiated via commit-reveal. + AsciiString gameOpts = GameInfoToAsciiString(m_currentGame, FALSE); +#else AsciiString gameOpts = GameInfoToAsciiString(m_currentGame); +#endif strlcpy(reply.GameInfo.options,gameOpts.str(), ARRAY_SIZE(reply.GameInfo.options)); wcslcpy(reply.GameInfo.gameName, m_currentGame->getName().str(), ARRAY_SIZE(reply.GameInfo.gameName)); reply.GameInfo.inProgress = m_currentGame->isGameInProgress(); @@ -352,7 +357,7 @@ void LANAPI::handleRequestJoin( LANMessage *msg, UnsignedInt senderIP ) DEBUG_LOG(("LANAPI::handleRequestJoin - join denied because of illegal characters in the player name.")); } - } + } // Then see if the player has a duplicate name for (player = 0; canJoin && playerisGameInProgress()) + return; + + // Identify sender by explicit slot ID; validate IP matches the known slot address. + const Int slot = static_cast(msg->SeedCommit.senderSlot); + if (slot < 0 || slot >= MAX_SLOTS || m_currentGame->getIP(slot) != senderIP) + { + DEBUG_LOG(("LANAPI: ignoring seed commit: slot %d IP mismatch or out of range", slot)); + return; + } + + // If the host sends a fresh commit while per-round state remains, reset for the new round. + // A resent commit with identical bytes is treated as a duplicate. + if (!m_currentGame->amIHost() && slot == 0 && (m_seedPhase != SEED_PHASE_NONE || m_seedReady)) + { + if (m_slotCommitReceived[slot] && memcmp(m_slotSeedCommit[slot], msg->SeedCommit.commit, 32) == 0) + { + DEBUG_LOG(("LANAPI: ignoring resent host commit (matches current round)")); + return; + } + DEBUG_LOG(("LANAPI: (client) new host commit (phase=%d seedReady=%d) - resetting stale seed state", m_seedPhase, m_seedReady)); + resetSeedProtocolState(); + } + + // Non-host commits carry the round nonce derived from the host commit, so the host commit + // must be known before a non-host commit can be validated. Drop early arrivals; the sender + // will resend within s_seedResendIntervalMs and the commit will be accepted then. + if (slot != 0 && !m_slotCommitReceived[0]) + { + DEBUG_LOG(("LANAPI: dropping seed commit from slot %d: host commit not yet received", slot)); + return; + } + + // Validate round nonce for non-host commits to filter stale packets from prior rounds. + if (slot != 0 && + memcmp(msg->SeedCommit.roundNonce, m_slotSeedCommit[0], sizeof(msg->SeedCommit.roundNonce)) != 0) + { + DEBUG_LOG(("LANAPI: ignoring seed commit from slot %d: round nonce mismatch", slot)); + return; + } + + // Ignore duplicate commits and commits arriving after the commit phase. + if (m_slotCommitReceived[slot]) + { + DEBUG_LOG(("LANAPI: ignoring duplicate commit from slot %d", slot)); + return; + } + if (m_seedPhase != SEED_PHASE_NONE && m_seedPhase != SEED_PHASE_AWAITING_COMMITS) + { + DEBUG_LOG(("LANAPI: ignoring commit from slot %d in phase %d", slot, m_seedPhase)); + return; + } + + memcpy(m_slotSeedCommit[slot], msg->SeedCommit.commit, 32); + m_slotCommitReceived[slot] = TRUE; + UnsignedInt dbgCommit; + memcpy(&dbgCommit, msg->SeedCommit.commit, sizeof(dbgCommit)); + DEBUG_LOG(("LANAPI: stored seed commit from slot %d: commit[0..3]=0x%08X", slot, dbgCommit)); + + // Host commit triggers client response; reply exactly once. + if (!m_currentGame->amIHost() && slot == 0 && m_seedPhase == SEED_PHASE_NONE) + { + const Int localSlot = m_currentGame->getLocalSlotNum(); + if (localSlot < 0 || localSlot >= MAX_SLOTS) + { + abortSeedProtocol(L"Could not start the game: invalid local slot. Please try again."); + return; + } + if (!generateLocalSecret(m_localSeedSecret)) + { + abortSeedProtocol(L"Could not start the game: failed to generate a secure random secret. Please try again."); + return; + } + if (!computeSeedCommitment(m_localSeedSecret, static_cast(localSlot), m_localSeedCommit)) + { + abortSeedProtocol(L"Could not start the game: failed to compute seed commitment. Please try again."); + return; + } + m_seedPhase = SEED_PHASE_AWAITING_COMMITS; + m_seedPhaseDeadline = timeGetTime() + s_seedPhaseTimeoutMs; + + // Pre-fill own slot + { + memcpy(m_slotSeedCommit[localSlot], m_localSeedCommit, 32); + m_slotCommitReceived[localSlot] = TRUE; + } + + LANMessage reply = {}; + fillInLANMessage(&reply); + reply.messageType = LANMessage::MSG_SEED_COMMIT; + memcpy(reply.SeedCommit.commit, m_localSeedCommit, sizeof(reply.SeedCommit.commit)); + reply.SeedCommit.senderSlot = static_cast(localSlot); + memcpy(reply.SeedCommit.roundNonce, m_slotSeedCommit[0], sizeof(reply.SeedCommit.roundNonce)); + sendMessage(&reply); + m_seedResendTime = timeGetTime() + s_seedResendIntervalMs; + UnsignedInt dbgCommit; + memcpy(&dbgCommit, m_localSeedCommit, sizeof(dbgCommit)); + DEBUG_LOG(("LANAPI: sent seed commit commit[0..3]=0x%08X", dbgCommit)); + } + + // Flush any reveal that arrived before this commit (or before the host commit established the nonce). + flushPendingReveal(slot); + // If this was the host commit, all other slots' pending reveals can now be flushed too + // because the round nonce (first 4 bytes of host commit) is now known. + if (slot == 0) + { + for (Int i = 1; i < MAX_SLOTS; ++i) + flushPendingReveal(i); + } + + // Host advances to reveals once all commits received. + if (m_currentGame->amIHost() && m_seedPhase == SEED_PHASE_AWAITING_COMMITS && allSeedCommitsReceived()) + { + beginSeedRevealPhase(); + } +} + +// TheSuperHackers @feature arcticdolphin 02/03/2026 Handle incoming seed reveal message and verify commitment. +void LANAPI::handleSeedReveal(LANMessage *msg, UnsignedInt senderIP) +{ + if (!m_currentGame || m_currentGame->isGameInProgress()) + return; + + // Identify sender by explicit slot ID; validate IP matches the known slot address. + const Int slot = static_cast(msg->SeedReveal.senderSlot); + if (slot < 0 || slot >= MAX_SLOTS || m_currentGame->getIP(slot) != senderIP) + { + DEBUG_LOG(("LANAPI: ignoring seed reveal: slot %d IP mismatch or out of range", slot)); + return; + } + + // If the host commit has already arrived the round nonce is known; drop stale reveals early + // so we never buffer garbage from a previous round. Skip this guard when the host commit is + // not yet in hand — we cannot know the nonce yet and will validate at flush time instead. + if (m_slotCommitReceived[0] && + memcmp(msg->SeedReveal.roundNonce, m_slotSeedCommit[0], sizeof(msg->SeedReveal.roundNonce)) != 0) + { + DEBUG_LOG(("LANAPI: ignoring seed reveal from slot %d: round nonce mismatch", slot)); + return; + } + + // Ignore duplicate reveals. + if (m_slotRevealReceived[slot]) + { + DEBUG_LOG(("LANAPI: ignoring duplicate reveal from slot %d", slot)); + return; + } + + // Buffer the reveal if either the sender's commit or the host commit has not arrived yet. + // Both are required before processVerifiedReveal can verify the nonce and commitment. + // Nonce and commitment checks are deferred to processVerifiedReveal via flushPendingReveal. + if (!m_slotCommitReceived[slot] || !m_slotCommitReceived[0]) + { + if (!m_slotPendingRevealValid[slot]) + { + memcpy(m_slotPendingRevealSecret[slot], msg->SeedReveal.secret, 16); + memcpy(m_slotPendingRevealNonce[slot], msg->SeedReveal.roundNonce, sizeof(msg->SeedReveal.roundNonce)); + m_slotPendingRevealValid[slot] = TRUE; + DEBUG_LOG(("LANAPI: buffered early reveal from slot %d (commit[slot]=%d commit[0]=%d)", + slot, m_slotCommitReceived[slot], m_slotCommitReceived[0])); + } + return; + } + + processVerifiedReveal(slot, msg->SeedReveal.secret, msg->SeedReveal.roundNonce); +} + +// TheSuperHackers @feature arcticdolphin 02/03/2026 Verify nonce+commitment for a reveal, store it, and advance the protocol. +void LANAPI::processVerifiedReveal(Int slot, const BYTE secret[16], const BYTE roundNonce[4]) +{ + // Validate round nonce to filter stale reveals from prior rounds. + if (memcmp(roundNonce, m_slotSeedCommit[0], 4) != 0) + { + DEBUG_LOG(("LANAPI: ignoring seed reveal from slot %d: round nonce mismatch", slot)); + return; + } + + // Verify commitment before accepting the reveal. + BYTE expectedCommit[32]; + if (!computeSeedCommitment(secret, static_cast(slot), expectedCommit)) + { + DEBUG_LOG(("LANAPI: computeSeedCommitment failed during reveal verification for slot %d", slot)); + abortSeedProtocol(L"Could not start the game: failed to verify seed commitment. Please try again."); + return; + } + if (memcmp(expectedCommit, m_slotSeedCommit[slot], 32) != 0) + { + DEBUG_LOG(("LANAPI: seed reveal FAILED for slot %d - commitment mismatch, aborting", slot)); + UnicodeString abortMsg; + abortMsg.format(L"Could not start the game: %ls's random seed did not match the agreed value. Please try again.", + m_currentGame->getSlot(slot)->getName().str()); + abortSeedProtocol(abortMsg.str()); + return; + } + + // Non-host: host's reveal signals commit phase done -- enter reveal phase and send our own reveal. + const Bool isHostReveal = !m_currentGame->amIHost() && slot == 0; + if (isHostReveal && (m_seedPhase == SEED_PHASE_AWAITING_COMMITS || m_seedPhase == SEED_PHASE_NONE)) + { + const Int localSlot = m_currentGame->getLocalSlotNum(); + if (localSlot < 0 || localSlot >= MAX_SLOTS) + { + abortSeedProtocol(L"Could not start the game: invalid local slot. Please try again."); + return; + } + { + memcpy(m_slotSeedReveal[localSlot], m_localSeedSecret, 16); + m_slotRevealReceived[localSlot] = TRUE; + } + + m_seedPhase = SEED_PHASE_AWAITING_REVEALS; + m_seedPhaseDeadline = timeGetTime() + s_seedPhaseTimeoutMs; + + LANMessage reply = {}; + fillInLANMessage(&reply); + reply.messageType = LANMessage::MSG_SEED_REVEAL; + memcpy(reply.SeedReveal.secret, m_localSeedSecret, 16); + reply.SeedReveal.senderSlot = static_cast(localSlot); + memcpy(reply.SeedReveal.roundNonce, m_slotSeedCommit[0], sizeof(reply.SeedReveal.roundNonce)); + sendMessage(&reply); + m_seedResendTime = timeGetTime() + s_seedResendIntervalMs; + UnsignedInt dbgCommit; + memcpy(&dbgCommit, m_localSeedCommit, sizeof(dbgCommit)); + DEBUG_LOG(("LANAPI: (client) entered reveal phase, sent reveal for commit[0..3]=0x%08X", dbgCommit)); + } + // Store the verified reveal. + memcpy(m_slotSeedReveal[slot], secret, 16); + m_slotRevealReceived[slot] = TRUE; + UnsignedInt dbgCommit; + memcpy(&dbgCommit, m_slotSeedCommit[slot], sizeof(dbgCommit)); + DEBUG_LOG(("LANAPI: verified seed reveal slot %d: commit[0..3]=0x%08X", slot, dbgCommit)); + + if (allSeedRevealsReceived()) + finalizeSeed(); +} + +// TheSuperHackers @feature arcticdolphin 02/03/2026 Drain a buffered early reveal for a slot once both its commit and the host commit are available. +void LANAPI::flushPendingReveal(Int slot) +{ + if (!m_slotPendingRevealValid[slot]) + return; + if (!m_slotCommitReceived[slot] || !m_slotCommitReceived[0]) + return; + + DEBUG_LOG(("LANAPI: flushing buffered early reveal for slot %d", slot)); + m_slotPendingRevealValid[slot] = FALSE; + processVerifiedReveal(slot, m_slotPendingRevealSecret[slot], m_slotPendingRevealNonce[slot]); +} + +// TheSuperHackers @feature arcticdolphin 03/03/2026 Host receives seed-ready acknowledgments from clients. +void LANAPI::handleSeedReady(LANMessage *msg, UnsignedInt senderIP) +{ + if (!m_currentGame || m_currentGame->isGameInProgress()) + return; + + // Only the host processes seed-ready acknowledgments. + if (!m_currentGame->amIHost()) + return; + + // Identify sender by explicit slot ID; validate IP matches the known slot address. + const Int slot = static_cast(msg->SeedReady.senderSlot); + if (slot < 0 || slot >= MAX_SLOTS || m_currentGame->getIP(slot) != senderIP) + { + DEBUG_LOG(("LANAPI: ignoring seed-ready: slot %d IP mismatch or out of range", slot)); + return; + } + + // Validate the round nonce: must match the first 4 bytes of our own commit. + // Drops stale acks from a previous round that arrived late. + if (memcmp(msg->SeedReady.roundNonce, m_localSeedCommit, sizeof(msg->SeedReady.roundNonce)) != 0) + { + DEBUG_LOG(("LANAPI: ignoring seed-ready from slot %d: round nonce mismatch", slot)); + return; + } + + // Safe to accept early (before host finalizeSeed): the round nonce ensures this ack is + // for the current round, and RequestGameStart() independently checks m_seedReady before + // starting the game. + m_slotSeedReady[slot] = TRUE; + DEBUG_LOG(("LANAPI: slot %d reported seed ready", slot)); +} + +#endif // !RETAIL_COMPATIBLE_NETWORKING diff --git a/Core/GameEngine/Source/GameNetwork/LANGameInfo.cpp b/Core/GameEngine/Source/GameNetwork/LANGameInfo.cpp index 300d56a1e0..b79214ce04 100644 --- a/Core/GameEngine/Source/GameNetwork/LANGameInfo.cpp +++ b/Core/GameEngine/Source/GameNetwork/LANGameInfo.cpp @@ -245,7 +245,12 @@ AsciiString GenerateGameOptionsString() if(!TheLAN->GetMyGame() || !TheLAN->GetMyGame()->amIHost()) return AsciiString::TheEmptyString; +#if !RETAIL_COMPATIBLE_NETWORKING + // TheSuperHackers @info arcticdolphin 02/03/2026 Omit seed from lobby broadcasts. + return GameInfoToAsciiString(TheLAN->GetMyGame(), FALSE); +#else return GameInfoToAsciiString(TheLAN->GetMyGame()); +#endif } Bool ParseGameOptionsString(LANGameInfo *game, AsciiString options) @@ -274,7 +279,12 @@ Bool ParseGameOptionsString(LANGameInfo *game, AsciiString options) } } - if (ParseAsciiStringToGameInfo(game, options)) +#if !RETAIL_COMPATIBLE_NETWORKING + // TheSuperHackers @info arcticdolphin 02/03/2026 Added requireSeed parameter; non-retail options strings omit SD=. + if (ParseAsciiStringToGameInfo(game, options, FALSE)) +#else + if (ParseAsciiStringToGameInfo(game, options, TRUE)) +#endif { Int newLocalSlotNum = (game->isInGame()) ? game->getLocalSlotNum() : -1; Bool isInGame = newLocalSlotNum >= 0;