From 71e956030299fe319a2206c1ce43dbd5ec94f228 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Fri, 19 Jun 2026 21:59:07 +0300 Subject: [PATCH 1/9] Implement the desync dialog --- Spawner.vcxproj | 9 + YRpp | 2 +- src/Ext/Session/Body.cpp | 201 ++++++ src/Ext/Session/Body.h | 68 ++ src/Misc/TestForceDesync.cpp | 93 +++ src/Spawner/GlobalPacketExt.h | 76 ++ src/Spawner/Spawner.Config.cpp | 1 + src/Spawner/Spawner.Config.h | 2 + src/Spawner/Spawner.cpp | 14 + src/UI/DesyncDialog.Hook.cpp | 232 +++++++ src/UI/DesyncDialog.Resource.h | 41 ++ src/UI/DesyncDialog.cpp | 1193 ++++++++++++++++++++++++++++++++ src/UI/DesyncDialog.h | 167 +++++ src/UI/DesyncDialog.rc | 67 ++ 14 files changed, 2165 insertions(+), 1 deletion(-) create mode 100644 src/Ext/Session/Body.cpp create mode 100644 src/Ext/Session/Body.h create mode 100644 src/Misc/TestForceDesync.cpp create mode 100644 src/Spawner/GlobalPacketExt.h create mode 100644 src/UI/DesyncDialog.Hook.cpp create mode 100644 src/UI/DesyncDialog.Resource.h create mode 100644 src/UI/DesyncDialog.cpp create mode 100644 src/UI/DesyncDialog.h create mode 100644 src/UI/DesyncDialog.rc diff --git a/Spawner.vcxproj b/Spawner.vcxproj index a6dde7a4..811494b5 100644 --- a/Spawner.vcxproj +++ b/Spawner.vcxproj @@ -19,6 +19,8 @@ + + @@ -27,6 +29,7 @@ + @@ -50,6 +53,7 @@ + @@ -68,6 +72,7 @@ + @@ -78,12 +83,16 @@ + + + + diff --git a/YRpp b/YRpp index 2ff81ad4..086d4487 160000 --- a/YRpp +++ b/YRpp @@ -1 +1 @@ -Subproject commit 2ff81ad47e1c301c8c653de059ab617d185e4009 +Subproject commit 086d4487777b6d54868f47b5bca5c91709bd01ad diff --git a/src/Ext/Session/Body.cpp b/src/Ext/Session/Body.cpp new file mode 100644 index 00000000..4296da3d --- /dev/null +++ b/src/Ext/Session/Body.cpp @@ -0,0 +1,201 @@ +/** +* yrpp-spawner +* +* Copyright(C) 2022-present CnCNet +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with this program.If not, see . +*/ + +#include "Body.h" + +#include +#include + +#include +#include +#include +#include +#include + +#include + +#include + +bool SessionExt::IsOutOfSync[SessionExt::MaxPlayers] = {}; +int SessionExt::OutOfSyncFrame = -1; +bool SessionExt::IsChatToAllies = false; + +bool SessionExt::Is_Out_of_Sync(int house_id) +{ + if (house_id < 0 || house_id >= MaxPlayers) + return false; + + return IsOutOfSync[house_id]; +} + +void SessionExt::Mark_Player_As_Out_of_Sync(int house_id) +{ + if (house_id < 0 || house_id >= MaxPlayers) + return; + + IsOutOfSync[house_id] = true; + + if (OutOfSyncFrame < 0) + OutOfSyncFrame = Unsorted::CurrentFrame; +} + +void SessionExt::Clear_Out_Of_Sync_Data() +{ + for (bool& flag : IsOutOfSync) + flag = false; + + OutOfSyncFrame = -1; +} + +bool SessionExt::Is_Spawner_Session() +{ + return Spawner::Enabled; +} + +void SessionExt::Set_Master(int house_id) +{ + SessionClass::Instance.MasterPlayerID() = house_id; + + wchar_t* const master_name = SessionClass::Instance.MasterPlayerName(); + master_name[0] = L'\0'; + + if (HouseClass::Array.Count > house_id && house_id >= 0) + { + if (HouseClass* house = HouseClass::Array[house_id]) + { + // MasterPlayerName is wchar_t[21]; UIName is the same width. + wcsncpy(master_name, house->UIName, 20); + master_name[20] = L'\0'; + } + } +} + +void SessionExt::Announce_Master() +{ + HouseClass* const me = HouseClass::CurrentPlayer; + if (me == nullptr) + return; + + // Record ourselves as the master locally; we never receive our own packet. + Set_Master(me->ArrayIndex); + + ExtGlobalPacketType packet {}; + packet.Command = EXT_NET_HOST_ANNOUNCE; + packet.Heartbeat.HouseID = static_cast(me->ArrayIndex); + packet.Heartbeat.IsHost = 1; + + // Send to every other player. As in the engine's own beacons, index 0 is the + // local player, so start at 1. (Addresses come from the connections that were + // just created.) + auto& players = NodeNameType::Array; + for (int i = 1; i < players.Count; i++) + { + NodeNameType* const node = players[i]; + if (node == nullptr) + continue; + + IPXManagerClass::Instance.Send_Global_Message( + &packet, sizeof(packet), 1, + reinterpret_cast(&node->Address), 0, 0); + } + IPXManagerClass::Instance.Service(); +} + +void SessionExt::Update_Master_After_Player_Removal() +{ + // Mirrors Vinifera: decide the master from the connected-players list + // (NodeNameType::Array == SessionClass::Players, 0xA8DA74), which + // Destroy_Connection shrinks immediately - even while the desync dialog has + // game logic suspended. We must NOT use the houses' Defeated flag here: a + // departed house is only flagged defeated when its queued E_REMOVEPLAYER + // event runs, which never happens while suspended, so the old master would + // keep looking valid and never get replaced. node->HouseIndex is the house + // id (the same value Set_Master/MasterPlayerID use). + const int current = SessionClass::Instance.MasterPlayerID(); + auto& players = NodeNameType::Array; + + // If the current master is still connected, there is nothing to do. + if (current != -1) + { + for (int i = 0; i < players.Count; i++) + { + NodeNameType* const node = players[i]; + if (node != nullptr && node->HouseIndex == current) + return; + } + } + + // Otherwise promote the connected player with the lowest house id. This is + // deterministic, so every remaining client agrees without negotiation. + int new_master = -1; + for (int i = 0; i < players.Count; i++) + { + NodeNameType* const node = players[i]; + if (node == nullptr) + continue; + + const int id = node->HouseIndex; + if (id >= 0 && (new_master == -1 || id < new_master)) + new_master = id; + } + + if (new_master != -1 && new_master != current) + Set_Master(new_master); +} + +/** + * Replacement for SessionClass::Am_I_Master (0x697E70). + * + * The vanilla implementation only consults MasterPlayerID/MasterPlayerName in + * GameMode::Internet (WOL) sessions, falling back to "the first non-defeated + * human house is the master" otherwise. Spawner multiplayer games run as + * GameMode::LAN, so the host/master we record through Set_Master (from the + * EXT_NET_HOST_ANNOUNCE at game start) would be ignored. Extend the check to LAN + * so the announced master is honoured - including after host migration. Mirrors + * Vinifera's SessionClassExt::_Am_I_Master. + * + * __fastcall(ECX=this, EDX unused, stack: who) reproduces the original __thiscall + * (retn 4); the whole function is replaced via DEFINE_FUNCTION_JUMP below. + */ +static bool __fastcall SessionClass_Am_I_Master(SessionClass* pThis, void*, HouseClass* who) +{ + if (who == nullptr) + who = HouseClass::CurrentPlayer; + + if ((pThis->GameMode == GameMode::Internet || pThis->GameMode == GameMode::LAN) && who != nullptr) + { + const int master = pThis->MasterPlayerID(); + if (master != -1) + return who->ArrayIndex == master; + + if (_wcsicmp(who->UIName, pThis->MasterPlayerName()) == 0) + return true; + } + + // Fallback: the first non-defeated human house is the master. + for (int i = 0; i < HouseClass::Array.Count; i++) + { + HouseClass* const house = HouseClass::Array[i]; + if (house && house->IsHumanPlayer && !house->Defeated) + return who == house; + } + + return false; +} +DEFINE_FUNCTION_JUMP(LJMP, 0x697E70, SessionClass_Am_I_Master) diff --git a/src/Ext/Session/Body.h b/src/Ext/Session/Body.h new file mode 100644 index 00000000..a9842439 --- /dev/null +++ b/src/Ext/Session/Body.h @@ -0,0 +1,68 @@ +/** +* yrpp-spawner +* +* Copyright(C) 2022-present CnCNet +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with this program.If not, see . +*/ + +// Re-created bits of Vinifera's SessionClassExtension that the desync dialog +// needs. YR's SessionClass has no equivalent of these (it only has a single +// global OutOfSync bool and no per-player tracking), so this holds the new +// state spawner-side. The game's native master/host fields (MasterPlayerID / +// MasterPlayerName) DO exist and are written through here. + +#pragma once + +class HouseClass; + +namespace SessionExt +{ + // The engine supports up to 8 multiplayer houses. + constexpr int MaxPlayers = 8; + + // --- Per-player out-of-sync tracking (the engine only has one global flag). + // Populated by the desync-detection hook (to be wired in separately) and + // read by the dialog to colour each player's status. + extern bool IsOutOfSync[MaxPlayers]; + + // Frame the game first went out of sync, or -1. + extern int OutOfSyncFrame; + + // Scope flag for an outgoing chat message (true = allies only). Kept for + // parity with Vinifera; the desync dialog broadcasts to everyone. + extern bool IsChatToAllies; + + bool Is_Out_of_Sync(int house_id); + void Mark_Player_As_Out_of_Sync(int house_id); + void Clear_Out_Of_Sync_Data(); + + // True when running under the spawner (the analog of Vinifera's + // SessionClassExtension::IsSpawnerSession). + bool Is_Spawner_Session(); + + // Assigns the game master/host, writing the engine's native MasterPlayerID + // and MasterPlayerName so SessionClass::Am_I_Master() agrees. + void Set_Master(int house_id); + + // Called on the host at game start: records itself as the master and tells + // every other player who the host is (EXT_NET_HOST_ANNOUNCE), so MasterPlayerID + // is authoritative on all machines before any desync. Mirrors Vinifera's + // SessionClassExtension::Announce_Master. + void Announce_Master(); + + // Recomputes the master after a player has been removed: if the current + // master is gone, promotes the first remaining non-defeated human house. + void Update_Master_After_Player_Removal(); +} diff --git a/src/Misc/TestForceDesync.cpp b/src/Misc/TestForceDesync.cpp new file mode 100644 index 00000000..6035d8de --- /dev/null +++ b/src/Misc/TestForceDesync.cpp @@ -0,0 +1,93 @@ +/** +* yrpp-spawner +* +* Copyright(C) 2022-present CnCNet +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with this program.If not, see . +*/ + +// ============================================================================= +// TEST ONLY -- deliberate desync, for exercising the desync dialog. +// DELETE THIS FILE (and its entry in Spawner.vcxproj) when done. +// ============================================================================= +// +// Over frames 100-200, exactly one machine -- the one controlling the human +// player with the highest array index (every machine agrees on which that is, +// so it picks a single machine regardless of which slots the humans occupy) -- +// burns an extra synchronized random number each frame. That permanently +// offsets its in-match RNG, so its game-state CRC diverges from everyone +// else's and the desync dialog is triggered. +// +// Hooked in Main_Loop just after the LogicClass::AI call (0x55DCA3), so the +// extra draw happens during the frame's logic, before its CRC is computed. + +#include +#include + +#include +#include +#include +#include + +namespace +{ + // True on exactly one machine: the one whose local player is the human with + // the highest array index. Houses are synchronized, so every machine agrees + // on which house that is, and only its owner returns true. + bool Is_Designated_Desync_Machine() + { + HouseClass* const me = HouseClass::CurrentPlayer; + if (me == nullptr || !me->IsHumanPlayer) + return false; + + int highest = -1; + for (int i = 0; i < HouseClass::Array.Count; i++) { + HouseClass* const house = HouseClass::Array[i]; + if (house != nullptr && house->IsHumanPlayer && !house->Defeated && house->ArrayIndex > highest) + highest = house->ArrayIndex; + } + + return me->ArrayIndex == highest; + } +} + +DEFINE_HOOK(0x55DCA3, MainLoop_TestForceDesync, 0x5) +{ + static int last_frame = -1; + static bool announced = false; + + const int frame = Unsorted::CurrentFrame; + + // Re-arm for each new game (the frame counter restarts from zero). + if (frame < last_frame) + announced = false; + last_frame = frame; + + if (!SessionClass::IsMultiplayer() || frame < 100 || frame > 200) + return 0; + + if (!Is_Designated_Desync_Machine()) + return 0; + + if (!announced) { + announced = true; + Debug::Log("TEST: desyncing this machine (local house %d) over frames 100-200.\n", + HouseClass::CurrentPlayer->ArrayIndex); + } + + // Burn an extra synced random number each frame so this machine's state + // diverges from everyone else's; the offset persists and is detected. + ScenarioClass::Instance->Random.Random(); + return 0; +} diff --git a/src/Spawner/GlobalPacketExt.h b/src/Spawner/GlobalPacketExt.h new file mode 100644 index 00000000..95a77c64 --- /dev/null +++ b/src/Spawner/GlobalPacketExt.h @@ -0,0 +1,76 @@ +/** +* yrpp-spawner +* +* Copyright(C) 2022-present CnCNet +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with this program.If not, see . +*/ + +// New, spawner-added global network packet types. +// +// This mirrors Vinifera's ExtGlobalPacketType: the engine's out-of-band global +// packet (GlobalPacketType) is 0x1C7 = 455 bytes, and the IPX layer accepts a +// raw buffer of that size, so an alternative struct of the same size can ride +// the very same channel. Command sits at offset 0 (matching GlobalPacketType), +// which is what Network_Call_Back's dispatch switch reads; commands the engine +// does not know fall through to its `default` case, which is where the receiver +// for these is meant to be hooked in. + +#pragma once + +// Command values for the spawner's own global packets. +// +// The engine's native command values run up to ~0x2F. These are placed well +// above that range so they never collide and always reach the unknown-command +// (default) dispatch path. +enum ExtNetCommandType : int +{ + EXT_NET_DESYNC_HEARTBEAT = 0xE0, // Periodic keep-alive while the desync dialog is open; also detects departures. + EXT_NET_DESYNC_CONTINUE = 0xE1, // The host's decision to continue the game without the desynced players. + EXT_NET_DESYNC_CHAT = 0xE2, // A chat line typed in the desync dialog while game logic is halted. + EXT_NET_HOST_ANNOUNCE = 0xE3, // The host announcing itself at game start so everyone records the master. +}; + +#pragma pack(push, 1) +struct ExtGlobalPacketType +{ + // Must alias GlobalPacketType::Command (offset 0) so the engine dispatch + // reads it correctly. + int Command; + + // Sender's display name (ANSI; converted from the wide UIName on send). + char Name[32]; + + union + { + struct + { + char HouseID; // Sender's house (ArrayIndex). + char IsHost; // Non-zero if the sender is the game master. + } Heartbeat; + + struct + { + char SenderHouseID; + char Text[200]; // ANSI chat text. + } Chat; + + // Forces the whole struct to the engine's GlobalPacketType size so the + // IPX layer treats it identically. + char _padding[455 - sizeof(int) - 32]; + }; +}; +#pragma pack(pop) + +static_assert(sizeof(ExtGlobalPacketType) == 455, "ExtGlobalPacketType must match the engine's GlobalPacketType (0x1C7 bytes)"); diff --git a/src/Spawner/Spawner.Config.cpp b/src/Spawner/Spawner.Config.cpp index 205eca9d..45776662 100644 --- a/src/Spawner/Spawner.Config.cpp +++ b/src/Spawner/Spawner.Config.cpp @@ -86,6 +86,7 @@ void SpawnerConfig::LoadFromINIFile(CCINIClass* pINI) PreCalcMaxAhead = pINI->ReadInteger(pSettingsSection, "PreCalcMaxAhead", PreCalcMaxAhead); MaxLatencyLevel = (byte)pINI->ReadInteger(pSettingsSection, "MaxLatencyLevel", (int)MaxLatencyLevel); ForceMultiplayer = pINI->ReadBool(pSettingsSection, "ForceMultiplayer", ForceMultiplayer); + Host = pINI->ReadBool(pSettingsSection, "Host", Host); } { // Tunnel Options diff --git a/src/Spawner/Spawner.Config.h b/src/Spawner/Spawner.Config.h index fca7b2be..35262aba 100644 --- a/src/Spawner/Spawner.Config.h +++ b/src/Spawner/Spawner.Config.h @@ -124,6 +124,7 @@ class SpawnerConfig int PreCalcMaxAhead; byte MaxLatencyLevel; bool ForceMultiplayer; + bool Host; // True on the machine hosting the game; announces itself as the game master at start. // Tunnel Options int TunnelId; @@ -202,6 +203,7 @@ class SpawnerConfig , PreCalcMaxAhead { 0 } , MaxLatencyLevel { 0xFF } , ForceMultiplayer { false } + , Host { false } // Tunnel Options , TunnelId { 0 } diff --git a/src/Spawner/Spawner.cpp b/src/Spawner/Spawner.cpp index 2cc048db..20f0f682 100644 --- a/src/Spawner/Spawner.cpp +++ b/src/Spawner/Spawner.cpp @@ -25,6 +25,8 @@ #include #include +#include + #include #include #include @@ -338,12 +340,24 @@ bool Spawner::StartScenario(const char* pScenarioName) pSession->GameMode = GameMode::LAN; + // Until the host announces itself there is no known game master. This + // makes our Am_I_Master replacement fall back to the "first human house" + // heuristic, and clears any stale value from a previous game (the session + // is a reused singleton). Mirrors Vinifera's spawner. + pSession->MasterPlayerID() = -1; + pSession->MasterPlayerName()[0] = L'\0'; + if (Config->LoadSaveGame && !Spawner::Reconcile_Players()) return false; if (!pSession->CreateConnections()) return false; + // Let the other players know who the game host is, so the master is known + // on every machine (host migration, the desync dialog's host icon, etc.). + if (Config->Host) + SessionExt::Announce_Master(); + // Ares does not support MultiEngineer switching in multiplayer, however // we can disable it simply by setting EngineerCaptureLevel to 1 - Belonit diff --git a/src/UI/DesyncDialog.Hook.cpp b/src/UI/DesyncDialog.Hook.cpp new file mode 100644 index 00000000..cb631ed5 --- /dev/null +++ b/src/UI/DesyncDialog.Hook.cpp @@ -0,0 +1,232 @@ +/** +* yrpp-spawner +* +* Copyright(C) 2022-present CnCNet +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with this program.If not, see . +*/ + +// Hooks that wire the desync dialog into the engine's networking. +// Mirrors what Vinifera added to TS's IPX_Call_Back (0x462DC0); here the +// equivalent function is Network_Call_Back (0x48D1E0). + +#include "DesyncDialog.h" +#include "DesyncDialog.Resource.h" + +#include +#include +#include + +#include +#include +#include + +/** + * Make the desync dialogs full-screen "menu" dialogs. + * + * OwnerDraw::Draw_Menu picks a dialog's backdrop from its element's LayoutBand, + * which OwnerDraw's TrySetDialogLayoutBand1 (0x60C540) sets to 1 - and lays the + * dialog out full-screen - only for a hard-coded list of dialog ids. Ours are + * not in that list, so we would get the plain fallback background instead of the + * in-game screen (BKGDSM/MD/LG.SHP + sidebar) the engine draws behind its own + * full-screen dialogs (e.g. the "waiting for players" dialog, id 234). + * + * At 0x60C5A1 EAX holds the dialog id (just loaded from element+0x6C). If it is + * one of ours, jump straight to the function's success tail at 0x60C7B7 (which + * sets LayoutBand = 1 and returns true with ecx = element, edx = 1 still live); + * otherwise fall through to the engine's own id checks. + */ +DEFINE_HOOK(0x60C5A1, OwnerDraw_TrySetDialogLayoutBand1_DesyncDialog, 0x5) +{ + GET(int, dialog_id, EAX); + + if (dialog_id == IDD_DESYNC_HOST || dialog_id == IDD_DESYNC_WAIT) { + return 0x60C7B7; + } + + return 0; +} + +/** + * Position our buttons on the full-screen background like the engine's menu + * dialogs do. + * + * When the framework scales a full-screen dialog's children (EnumChildProc_60C0C0), + * controls of dialogs in another hard-coded id list are sent to UI_60B7A0, which + * offsets them to centre the 800x600 layout within the screen. Controls that miss + * it (our buttons) fall to the plain default move and land top-left. At 0x60C399 + * EDI holds the parent dialog id; if it's one of ours, jump to the UI_60B7A0 call. + */ +DEFINE_HOOK(0x60C399, OwnerDraw_CenterDesyncDialogButtons, 0x6) +{ + GET(int, dialog_id, EDI); + + if (dialog_id == IDD_DESYNC_HOST || dialog_id == IDD_DESYNC_WAIT) { + return 0x60C3F6; + } + + return 0; +} + +/** + * Mark the desync dialogs as recognised full-screen menu dialogs. + * + * ResolveIDs_601360 is the engine's "is this a full-screen menu dialog?" predicate + * (id == one of a big hard-coded list). It gates, among other things, the special + * bottom-anchored placement of the tooltip bar (control 1685) during full-screen + * scaling. ECX holds the dialog id; for ours, jump to the function's "return true" + * tail (0x6015D5) - it has no stack frame, so this is a clean early-out. Only our + * ids are affected; every other dialog still runs the original checks. + */ +DEFINE_HOOK(0x601360, OwnerDraw_ResolveIDs_DesyncDialog, 0x6) +{ + GET(int, dialog_id, ECX); + + if (dialog_id == IDD_DESYNC_HOST || dialog_id == IDD_DESYNC_WAIT) { + return 0x6015D5; + } + + return 0; +} + +/** + * Place our owner-draw buttons on the in-game sidebar button strip, the way the + * engine's full-screen menu dialogs do (e.g. the map-generator dialog 261, whose + * Load/Save/Delete/Use buttons stack on the strip). + * + * During full-screen child scaling, an owner-draw control is sent to UI_60B000 + * (which positions it on the sidebar strip, vertical slot from its y) only when + * UI_Is_Static_And_Or_OwnerDraw returns true for the (dialog id, control id) + * pair. Ours aren't recognised, so the buttons fell to the plain default move. + * At 0x608D27 ESI = dialog id and EAX = control id (just fetched); for our + * buttons jump to the function's "return true" tail at 0x608F34. Only our button + * ids are matched, so static/list controls are unaffected. + */ +DEFINE_HOOK(0x608D27, OwnerDraw_DesyncDialogSidebarButtons, 0x6) +{ + GET(int, dialog_id, ESI); + GET(int, control_id, EAX); + + if ((dialog_id == IDD_DESYNC_HOST || dialog_id == IDD_DESYNC_WAIT) + && (control_id == IDC_DESYNC_LOAD || control_id == IDC_DESYNC_CONTINUE || control_id == IDC_DESYNC_QUIT)) { + return 0x608F34; + } + + return 0; +} + +/** + * Receive dispatch. Network_Call_Back routes received global packets through a + * switch on GPacket.Command; commands outside the engine's range (our desync + * commands are 0xE0+) fall to the default case at 0x48DAC4, which would call + * Process_Global_Packet. Intercept there: if it is one of ours, hand it to the + * dialog and skip the engine's default handling; otherwise let it run. + * + * 0x48DAC4 is `mov edx, offset GAddress` (5 bytes, absolute), so `return 0` + * safely restores it and continues into the original mov/call. + */ +DEFINE_HOOK(0x48DAC4, NetworkCallBack_DesyncPacket, 0x5) +{ + enum { SkipDefaultHandler = 0x48DAD3 }; // continue past Process_Global_Packet + + auto* const packet = reinterpret_cast(&SessionClass::GlobalReceivePacket); + auto* const address = reinterpret_cast(&SessionClass::GlobalReceiveAddress); + + if (DesyncDialog.Handle_Global_Packet(packet, address)) + return SkipDefaultHandler; + + return 0; // not one of ours: run the engine's Process_Global_Packet +} + +/** + * Sign-off -> player-left. On NET_SIGN_OFF the engine calls + * Destroy_Connection(id, 0) at 0x48D859; tell the dialog so the departing + * player shows as "Quit". (Heartbeat timeouts already notify from + * DesyncDialogClass::Check_Heartbeat_Timeouts. Mirrors Vinifera's sign-off + * path: Destroy_Connection -> Update_Master_After_Player_Removal -> notify.) + * + * We perform the drop ourselves and jump past the original call so the notify + * runs *after* the connection is gone and the master reassigned: if the host + * is the one leaving, Notify_Player_Left -> Morph_To_Host_Dialog_If_Needed must + * see the new master to promote a waiting player. `return 0` would run before + * the restored call (wrong order). ECX = id, EDX = error (0). + */ +DEFINE_HOOK(0x48D859, NetworkCallBack_SignOff_NotifyPlayerLeft, 0x5) +{ + enum { AfterDestroyConnection = 0x48D85E }; + + GET(int, id, ECX); + + SessionClass::Destroy_Connection(id, 0); + SessionExt::Update_Master_After_Player_Removal(); + DesyncDialog.Notify_Player_Left(id); + + return AfterDestroyConnection; +} + +/** + * Trigger (Hook A). Mirrors Vinifera replacing Execute_DoList: we detect + * per-player desync ourselves and run our dialog instead of the engine's + * stock "out of sync" message box + quit. + * + * Returns 0 from Execute_DoList to the caller (Queue_AI_Multiplayer), whose + * failure path then stops the game. Execute_DoList cleans 3 stack args (0xC); + * this stub runs in place of the function, so the prologue never executed and + * there are no saved registers to restore. + */ +static __declspec(naked) void Execute_DoList_Quit() +{ + __asm + { + xor eax, eax + retn 0Ch + } +} + +/** + * Execute_DoList entry: detect desync and, if any, run the dialog. The 4th arg + * (skip_crc) is the engine's initial CRC-skip window; respect it so a new game + * does not falsely desync. On "continue" let the original run (out-of-sync + * players are skipped by the hook below); on "quit" return 0 so the caller + * stops the game. + */ +DEFINE_HOOK(0x64C380, Execute_DoList_DesyncDialog, 0xA) +{ + GET_STACK(CDTimerClass*, skip_crc, 0x8); + + const bool in_skip_window = skip_crc && skip_crc->GetTimeLeft() != 0; + + if (!in_skip_window && DesyncDialog.Check_And_Handle_Desync()) + return reinterpret_cast(&Execute_DoList_Quit); + + return 0; // run the original Execute_DoList +} + +/** + * Execute_DoList outer loop: skip houses we are out of sync with, so their + * FRAMEINFO is not re-checked (no stock message box) and their events are not + * executed after a "continue". EAX holds the house index here; 0x64CC3D is the + * loop's "advance to the next house" path. + */ +DEFINE_HOOK(0x64C4EC, Execute_DoList_SkipOutOfSyncHouse, 0x5) +{ + enum { SkipToNextHouse = 0x64CC3D }; + + GET(int, house_index, EAX); + + if (SessionExt::Is_Out_of_Sync(house_index)) + return SkipToNextHouse; + + return 0; // process this house normally +} diff --git a/src/UI/DesyncDialog.Resource.h b/src/UI/DesyncDialog.Resource.h new file mode 100644 index 00000000..bfe185b0 --- /dev/null +++ b/src/UI/DesyncDialog.Resource.h @@ -0,0 +1,41 @@ +/** +* yrpp-spawner +* +* Copyright(C) 2022-present CnCNet +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with this program.If not, see . +*/ + +// Resource and control IDs for the multiplayer desync dialog. +// Shared between the .rc script and the dialog code. +// +// Per the spawner convention, brand-new control IDs start at 5000 so they never +// collide with the engine's own dialog/control IDs. + +#pragma once + +// Dialog templates (live in this DLL; found via the FetchResource hook). +#define IDD_DESYNC_HOST 5000 // Game master: Load/Continue/Quit. +#define IDD_DESYNC_WAIT 5001 // Everyone else: wait for the master. + +// Controls. +#define IDC_DESYNC_HEADER 5010 +#define IDC_DESYNC_PLAYER_LIST 5011 +#define IDC_DESYNC_CHAT_LIST 5012 +#define IDC_DESYNC_CHAT_EDIT 5013 +#define IDC_DESYNC_COUNTDOWN_TEXT 5014 +#define IDC_DESYNC_COUNTDOWN_BAR 5015 +#define IDC_DESYNC_LOAD 5020 +#define IDC_DESYNC_CONTINUE 5021 +#define IDC_DESYNC_QUIT 5022 diff --git a/src/UI/DesyncDialog.cpp b/src/UI/DesyncDialog.cpp new file mode 100644 index 00000000..4a0d776e --- /dev/null +++ b/src/UI/DesyncDialog.cpp @@ -0,0 +1,1193 @@ +/** +* yrpp-spawner +* +* Copyright(C) 2022-present CnCNet +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with this program.If not, see . +* +* Dialog shown to the players when a multiplayer game goes out of sync. +* Ported from Vinifera (Tiberian Sun) to Yuri's Revenge. +* +* Porting notes (TS/Vinifera -> YR): +* - Owner-draw dialog API: OwnerDraw::BeginDialog/SubclassDlg/EndDialog -> +* UI::BeginDialog/RegisterWindow/EndDialog; OwnerDraw::DrawItem -> +* OwnerDraw::DrawItem; OwnerDraw::DrawDialogBack -> OwnerDraw::Paint; +* WinDialogClass::Center_Window -> UI::CenterWindow. +* - Listbox cells: OwnerDraw::CellData -> WWUIListBoxCell (strings are WIDE); +* OD_ADDCOLUMN/OD_SETCELL -> WW_LB_ADDCOLUMN/WW_LB_SETCELLTEXT. +* - Network transport mirrors the engine's own in-game global packets +* (beacons): IPXManagerClass::Send_Global_Message to each peer's address +* from SessionClass::Players, packet type ExtGlobalPacketType. +* - Master/host: SessionClass::Am_I_Master / MasterPlayerID (native). +* - Per-player out-of-sync / chat scope: re-created in SessionExt. +* - Save-load (Load button, countdown): ported but DISABLED for now; the +* Load button is disabled and the load/countdown code is left under #if 0. +*/ + +#include "DesyncDialog.h" +#include "DesyncDialog.Resource.h" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + + +DesyncDialogClass DesyncDialog; + + +namespace +{ + enum + { + HEARTBEAT_TIMER = 1, + QUIT_ENABLE_TIMER = 2, + }; + + constexpr int HEARTBEAT_INTERVAL_MS = 1000; + constexpr int HEARTBEAT_TIMEOUT_MS = 25000; + constexpr int QUIT_ENABLE_DELAY_MS = 10000; + constexpr int LOAD_COUNTDOWN_MS = 5000; + constexpr int CHAT_BACKLOG_MAX = 50; + + // Chat message buffer size; matches the engine's global chat message field. + constexpr int MAX_MESSAGE_LENGTH = 112; + + // Engine global-packet command for a player signing off. + constexpr int NET_SIGN_OFF = 0xA; + + constexpr char CHAT_EDIT_PLACEHOLDER[] = "Type here to chat..."; + + /** + * Player list column positions, in pixels within the listbox client + * area (the same units the game's lobby player lists use). + */ + constexpr int PLAYER_LIST_HOST_COL_X = 2; + constexpr int PLAYER_LIST_NAME_COL_X = 20; + constexpr int PLAYER_LIST_STATUS_COL_WIDTH = 56; + + /** + * The x-position of the status column. Computed from the listbox's + * actual size, because the dialog is not rescaled by the game and so + * its pixel size depends on the system's font metrics. + */ + int Player_List_Status_Col_X(HWND list) + { + RECT rect {}; + GetClientRect(list, &rect); + return rect.right - PLAYER_LIST_STATUS_COL_WIDTH; + } + + /** + * Converts a wide string to ANSI into the given buffer (NUL-terminated). + */ + void Wide_To_Ansi(const wchar_t* src, char* dst, int dst_size) + { + if (dst_size <= 0) + return; + + const int written = WideCharToMultiByte(CP_ACP, 0, src ? src : L"", -1, dst, dst_size, nullptr, nullptr); + if (written <= 0) + dst[0] = '\0'; + else + dst[dst_size - 1] = '\0'; + } + + /** + * The local player's display name as ANSI. + */ + void Local_Player_Name(char* dst, int dst_size) + { + const wchar_t* name = L""; + if (HouseClass::CurrentPlayer != nullptr) + name = HouseClass::CurrentPlayer->UIName; + Wide_To_Ansi(name, dst, dst_size); + } + + /** + * Sends one of our global packets to every other player in the session. + * Addresses each peer exactly the way the engine's own in-game packets + * (beacons) do: SessionClass::Players[i]->Address, with index 0 being the + * local player. + */ + void Broadcast_Global_Packet(ExtGlobalPacketType& packet, int ack_req) + { + auto& players = NodeNameType::Array; + for (int i = 1; i < players.Count; i++) { + NodeNameType* node = players[i]; + if (node == nullptr) + continue; + + IPXManagerClass::Instance.Send_Global_Message( + &packet, sizeof(packet), ack_req, + reinterpret_cast(&node->Address), 0, 0); + } + IPXManagerClass::Instance.Service(); + } +} + + +/** + * Shows the dialog and pumps it until a decision has been made. + * + * Game logic is halted for the duration: this function does not return + * until the master has decided what to do (or we have decided to quit). + * The network is serviced the whole time, so chat, sign-offs and the + * master's decision still come through. + */ +DesyncDialogOutcomeType DesyncDialogClass::Run() +{ + Debug::Log("DesyncDialog: opening on frame %d.\n", Unsorted::CurrentFrame); + + /** + * Freeze game logic while the dialog is open: with Session.Suspended (which + * is the same field the engine calls SystemResponseMessages) non-zero, + * OwnerDraw::DialogMessageHandler services Call_Back() each iteration instead + * of running Main_Loop(), so the world stays put while the network keeps + * flowing. Lock user input so the dialog owns the keyboard and mouse. + */ + SessionClass::Instance.Suspended()++; + const bool was_input_locked = Unsorted::UserInputLocked; + Unsorted::UserInputLocked = true; + + /** + * Hand the mouse to the dialog the way the engine's own in-game network + * dialogs do (e.g. the reconnect/kick dialog, Wait_For_Players @0x648C7C): + * release the captured software cursor so Windows draws the system cursor + * over the dialog, and show it. Without this the game's software cursor + * stays drawn on the frozen backbuffer (game logic is suspended) - a stale + * duplicate cursor that doesn't move. Re-captured on close below. + */ + const bool was_mouse_captured = WWMouseClass::Instance->IsCaptured() != 0; + if (was_mouse_captured) { + WWMouseClass::Instance->ReleaseMouse(); + } + WWMouseClass::Instance->ShowCursor(); + + Decision = 0; + ContinueReceived = false; + LoadCountdownActive = false; + std::fill(std::begin(PlayerLeft), std::end(PlayerLeft), false); + std::fill(std::begin(LastHeartbeatFrom), std::end(LastHeartbeatFrom), std::chrono::steady_clock::now()); + ChatBacklog.clear(); + + Create_Dialog(); + + DesyncDialogOutcomeType outcome; + + if (Window == nullptr) { + + /** + * If the dialog could not be created for whatever reason, fall back + * to the old behavior of continuing without the desynced players. + */ + Debug::Log("DesyncDialog: failed to create the dialog!\n"); + outcome = DESYNC_OUTCOME_CONTINUE; + + } else while (true) { + + /** + * Pump the dialog the way the engine runs its own in-game dialogs: + * DialogMessageHandler dispatches input, repaints the dialog, and (while + * we are suspended) services the network via Call_Back. If it ever + * reports that the game itself has ended, bail out. + */ + if (UI::Updated()) { + outcome = DESYNC_OUTCOME_QUIT; + break; + } + + Check_Heartbeat_Timeouts(); + + /** + * Quitting is always allowed, even during the load countdown. + */ + if (Decision == IDC_DESYNC_QUIT) { + outcome = DESYNC_OUTCOME_QUIT; + break; + } + + // --- Save-load path: ported but DISABLED until multiplayer save-load + // is implemented. The Load button is disabled, so no load is ever + // scheduled and the countdown below never starts. +#if 0 + if (!LoadCountdownActive && PendingMultiplayerSaveLoadTime) { + Start_Load_Countdown(); + } + + if (LoadCountdownActive) { + + Update_Countdown_Text(); + InvalidateRect(Window, nullptr, FALSE); + + if (std::chrono::steady_clock::now() >= *PendingMultiplayerSaveLoadTime) { + outcome = DESYNC_OUTCOME_LOAD; + break; + } + + } else +#endif + if (ContinueReceived || Decision == IDC_DESYNC_CONTINUE) { + + if (Decision == IDC_DESYNC_CONTINUE) { + Send_Continue(); + } + outcome = DESYNC_OUTCOME_CONTINUE; + break; + + } else if (Decision == IDC_DESYNC_LOAD) { + + // --- Open the stock load dialog so the master can pick a save. + // DISABLED for now (the Load button is disabled); needs the + // multiplayer save-load logic to be ported first. +#if 0 + KillTimer(Window, HEARTBEAT_TIMER); + EnableWindow(Window, FALSE); + LoadOptionsClass().Load_Dialog(); + EnableWindow(Window, TRUE); + SetFocus(GetDlgItem(Window, IDC_DESYNC_PLAYER_LIST)); + SetTimer(Window, HEARTBEAT_TIMER, HEARTBEAT_INTERVAL_MS, nullptr); +#endif + } + + Decision = 0; + } + + Destroy_Dialog(); + + Unsorted::UserInputLocked = was_input_locked; + SessionClass::Instance.Suspended()--; + + // Give the mouse back to the game (re-capture the software cursor), matching + // the open above. + if (was_mouse_captured) { + WWMouseClass::Instance->CaptureMouse(); + } + + MapClass::Instance.RedrawSidebar(2); // GScreenClass::Flag_To_Redraw @0x4F42F0: full redraw + + Debug::Log("DesyncDialog: closed with outcome %d.\n", static_cast(outcome)); + return outcome; +} + + +/** + * Creates the dialog appropriate for the local player - the decision + * dialog for the game master, the wait dialog for everyone else. + */ +void DesyncDialogClass::Create_Dialog() +{ + IsHostDialog = SessionClass::Instance.Am_I_Master(); + + /** + * BeginDialog finds our template via the hooked Fetch_Resource, which + * falls back to this DLL for resources the game's own resources don't + * have, and registers the dialog with the message loop. + */ + Window = UI::BeginDialog(MAKEINTRESOURCE(IsHostDialog ? IDD_DESYNC_HOST : IDD_DESYNC_WAIT), &Dialog_Proc, 0); + Debug::Log("DesyncDialog: BeginDialog returned %p (host=%d).\n", static_cast(Window), IsHostDialog); + if (Window == nullptr) { + return; + } + + /** + * The owner-draw framework lays the dialog out: because our ids are accepted + * by TrySetDialogLayoutBand1 (hooked in DesyncDialog.Hook.cpp), WM_INITDIALOG + * has already moved us full-screen, scaled the controls, and tagged the + * element so OwnerDraw::Draw_Menu paints the in-game screen (BKGD*.SHP + + * sidebar) behind us - the same treatment the engine's own full-screen + * in-game dialogs get. So there's nothing to size or centre here. + * + * Set up the player list columns: player name, host icon, status. + * The name column must be added first: the listbox automatically + * gives every new row a PRIMARY cell (which draws the row's string) + * in the first column that was added, and INVALID cells in the rest. + */ + HWND list = GetDlgItem(Window, IDC_DESYNC_PLAYER_LIST); + if (list != nullptr) { + const int status_x = Player_List_Status_Col_X(list); + SendMessage(list, WW_LB_ADDCOLUMN, status_x - PLAYER_LIST_NAME_COL_X - 6, PLAYER_LIST_NAME_COL_X); + SendMessage(list, WW_LB_ADDCOLUMN, 0, PLAYER_LIST_HOST_COL_X); + SendMessage(list, WW_LB_ADDCOLUMN, 0, status_x); + } + + /** + * The chat list is also a cell-based owner-draw listbox, so it needs a + * column for the row text to land in. One full-width column is enough. + * (wParam = column width, lParam = column x; see WW_LB_ADDCOLUMN.) + */ + HWND chat_list = GetDlgItem(Window, IDC_DESYNC_CHAT_LIST); + if (chat_list != nullptr) { + RECT chat_client {}; + GetClientRect(chat_list, &chat_client); + // Full-width column so the chat text spans the same width as the chat + // edit box below it (both are the full control width). + SendMessage(chat_list, WW_LB_ADDCOLUMN, chat_client.right, 0); + } + + Update_Player_List(); + + if (IsHostDialog) { + // Load is disabled for now (multiplayer save-load is further work). + EnableWindow(GetDlgItem(Window, IDC_DESYNC_LOAD), FALSE); + EnableWindow(GetDlgItem(Window, IDC_DESYNC_CONTINUE), TRUE); + } else { + + /** + * The Quit button starts out disabled (so the player doesn't + * instantly quit out of reflex) and is enabled after a delay. + */ + SetTimer(Window, QUIT_ENABLE_TIMER, QUIT_ENABLE_DELAY_MS, nullptr); + } + + SetTimer(Window, HEARTBEAT_TIMER, HEARTBEAT_INTERVAL_MS, nullptr); + + Refill_Chat_List(); + + ShowWindow(Window, SW_SHOWNORMAL); + + /** + * Give the dialog focus the way the engine focuses its own dialogs, then + * move focus to the player list rather than the chat edit box, so the + * player isn't typing into chat the instant the dialog appears. + */ + SetForegroundWindow(Window); + HWND llist = GetDlgItem(Window, IDC_DESYNC_PLAYER_LIST); + SetFocus(llist); + Debug::Log("DesyncDialog: Create_Dialog complete.\n"); +} + + +/** + * Destroys the dialog. + */ +void DesyncDialogClass::Destroy_Dialog() +{ + if (Window != nullptr) { + KillTimer(Window, HEARTBEAT_TIMER); + UI::EndDialog(Window); + Window = nullptr; + } +} + + +/** + * If the game master has left and we have been promoted in their place, + * replace the wait dialog with the decision dialog. + */ +void DesyncDialogClass::Morph_To_Host_Dialog_If_Needed() +{ + if (!Is_Active() || IsHostDialog) { + return; + } + + /** + * If a load countdown is already running, the decision has been made; + * there is nothing left to decide. + */ + if (LoadCountdownActive) { + return; + } + + if (!SessionClass::Instance.Am_I_Master()) { + return; + } + + Debug::Log("DesyncDialog: we are the new game master, switching to the decision dialog.\n"); + + Destroy_Dialog(); + Create_Dialog(); +} + + +/** + * Refills the player list with every player's name and status. + */ +void DesyncDialogClass::Update_Player_List() +{ + if (!Is_Active()) { + return; + } + + HWND list = GetDlgItem(Window, IDC_DESYNC_PLAYER_LIST); + if (list == nullptr) { + return; + } + + ListBox_ResetContent(list); + + const int status_x = Player_List_Status_Col_X(list); + const int host_id = SessionClass::Instance.MasterPlayerID(); + + for (int i = 0; i < SessionExt::MaxPlayers && i < HouseClass::Array.Count; i++) { + + HouseClass* house = HouseClass::Array[i]; + if (house == nullptr || !house->IsHumanPlayer) { + continue; + } + + /** + * The owner-draw listbox is cell-based; rows are added with the engine's + * own WW_LB_ADDSTRINGW (the plain LB_ADDSTRING does not build the cell and + * returns LB_ERR). The new row's PRIMARY cell - the row string - lands in + * the first column added (the name column), and the host/status columns are + * filled in below with WW_LB_SETCELLTEXT. + */ + const int row = static_cast(SendMessage(list, WW_LB_ADDSTRINGW, 0, reinterpret_cast(house->UIName))); + if (row < 0) { + continue; + } + + /** + * The game master gets the same host icon the lobby uses. + */ + if (i == host_id) { + // WWUIListBoxCell holds WideWstring members whose default ctor is + // explicit, so it cannot be brace-initialized; build it field by + // field (the unset wide strings keep their null buffer, which the + // framework's copy treats as empty). + WWUIListBoxCell host_cell; + host_cell.Format = WWUIListBoxCellFormat::Image; + host_cell.TextColor = 0; + PCX::Instance.LoadFile("wolhost.pcx"); + host_cell.Image = PCX::Instance.GetSurface("wolhost.pcx"); + host_cell.Value = 0; + SendMessage(list, WW_LB_SETCELLTEXT, MAKEWPARAM(PLAYER_LIST_HOST_COL_X, row), reinterpret_cast(&host_cell)); + } + + const wchar_t* status; + COLORREF color; + if (PlayerLeft[i]) { + status = L"Quit"; + color = RGB(200, 0, 0); + } else if (SessionExt::Is_Out_of_Sync(i)) { + status = L"Desynced"; + color = RGB(200, 200, 0); + } else { + status = L"OK"; + color = RGB(0, 200, 0); + } + + WWUIListBoxCell status_cell; + status_cell.Format = WWUIListBoxCellFormat::Text; + status_cell.PrimaryText = status; // WideWstring, deep-copied by the message + status_cell.TextColor = color; + status_cell.Image = nullptr; + status_cell.Value = 0; + SendMessage(list, WW_LB_SETCELLTEXT, MAKEWPARAM(status_x, row), reinterpret_cast(&status_cell)); + } + + InvalidateRect(list, nullptr, FALSE); +} + + +/** + * Refills the chat list from the backlog after the dialog is (re-)created. + */ +void DesyncDialogClass::Refill_Chat_List() +{ + if (!Is_Active()) { + return; + } + + HWND list = GetDlgItem(Window, IDC_DESYNC_CHAT_LIST); + if (list == nullptr) { + return; + } + + ListBox_ResetContent(list); + for (const auto& line : ChatBacklog) { + SendMessage(list, WW_LB_ADDSTRINGA, 0, reinterpret_cast(line.c_str())); + } + ListBox_SetTopIndex(list, ListBox_GetCount(list) - 1); +} + + +/** + * Appends a line to the chat list (and the backlog). + */ +void DesyncDialogClass::Append_Chat_Line(const char* line) +{ + ChatBacklog.emplace_back(line); + if (static_cast(ChatBacklog.size()) > CHAT_BACKLOG_MAX) { + ChatBacklog.erase(ChatBacklog.begin()); + } + + if (!Is_Active()) { + return; + } + + HWND list = GetDlgItem(Window, IDC_DESYNC_CHAT_LIST); + if (list == nullptr) { + return; + } + + SendMessage(list, WW_LB_ADDSTRINGA, 0, reinterpret_cast(line)); + while (ListBox_GetCount(list) > CHAT_BACKLOG_MAX) { + ListBox_DeleteString(list, 0); + } + ListBox_SetTopIndex(list, ListBox_GetCount(list) - 1); +} + + +/** + * Sends the message currently in the chat edit box to the other players. + * + * Vinifera reused the engine's native network chat here. While the game is + * frozen the in-game chat UI is not running, so the desync dialog carries its + * own chat over a dedicated global packet instead, and echoes it locally. + */ +void DesyncDialogClass::Send_Chat() +{ + if (!Is_Active()) { + return; + } + + HWND edit = GetDlgItem(Window, IDC_DESYNC_CHAT_EDIT); + if (edit == nullptr) { + return; + } + + /** + * The chat edit is an owner-draw NewEdit: it keeps its text in the engine's + * own buffer rather than the Win32 window text, so read and clear it with the + * WW_*TEXT messages, not Get/SetWindowText. + */ + char buf[MAX_MESSAGE_LENGTH]; + buf[0] = '\0'; + SendMessage(edit, WW_GETTEXTA, sizeof(buf), reinterpret_cast(buf)); + if (buf[0] == '\0') { + return; + } + + SendMessage(edit, WW_SETTEXTW, 0, reinterpret_cast(L"")); + SetFocus(edit); + + SessionExt::IsChatToAllies = false; // broadcast scope (parity with Vinifera) + + ExtGlobalPacketType packet {}; + packet.Command = EXT_NET_DESYNC_CHAT; + Local_Player_Name(packet.Name, sizeof(packet.Name)); + if (HouseClass::CurrentPlayer != nullptr) + packet.Chat.SenderHouseID = static_cast(HouseClass::CurrentPlayer->ArrayIndex); + std::strncpy(packet.Chat.Text, buf, sizeof(packet.Chat.Text) - 1); + Broadcast_Global_Packet(packet, 1); + + /** + * Show our own message locally (the broadcast only reaches the others). + */ + Notify_Chat(packet.Name, buf); +} + + +/** + * Manages the hint text in the chat edit box: the hint is shown while the + * box is empty and unfocused, and cleared when the player clicks into it. + */ +void DesyncDialogClass::On_Chat_Edit_Focus(bool gained) +{ + if (!Is_Active()) { + return; + } + + HWND edit = GetDlgItem(Window, IDC_DESYNC_CHAT_EDIT); + if (edit == nullptr) { + return; + } + + if (gained && ChatPlaceholderActive) { + SetWindowText(edit, ""); + ChatPlaceholderActive = false; + } else if (!gained && GetWindowTextLength(edit) == 0) { + SetWindowText(edit, CHAT_EDIT_PLACEHOLDER); + ChatPlaceholderActive = true; + } +} + + +/** + * Lets the other players know we are still alive while game logic is halted. + * This both keeps the connections (and any NAT mappings) warm and lets + * everyone detect players that have silently disappeared. + */ +void DesyncDialogClass::Send_Heartbeat() +{ + /** + * Guard against being called while the world is in an inconsistent state + * (e.g. a save load tearing down and rebuilding the player list under us). + */ + if (HouseClass::CurrentPlayer == nullptr || NodeNameType::Array.Count == 0) { + return; + } + + ExtGlobalPacketType packet {}; + packet.Command = EXT_NET_DESYNC_HEARTBEAT; + Local_Player_Name(packet.Name, sizeof(packet.Name)); + packet.Heartbeat.HouseID = static_cast(HouseClass::CurrentPlayer->ArrayIndex); + packet.Heartbeat.IsHost = SessionClass::Instance.Am_I_Master() ? 1 : 0; + + Broadcast_Global_Packet(packet, 0); +} + + +/** + * Broadcasts the master's decision to continue without the desynced players. + */ +void DesyncDialogClass::Send_Continue() +{ + Debug::Log("DesyncDialog: broadcasting the decision to continue.\n"); + + ExtGlobalPacketType packet {}; + packet.Command = EXT_NET_DESYNC_CONTINUE; + Local_Player_Name(packet.Name, sizeof(packet.Name)); + + Broadcast_Global_Packet(packet, 1); +} + + +/** + * Drops players we have not heard from in a long while (e.g. their game has + * crashed without sending a sign-off). This keeps the dialog from waiting on + * a dead master forever, and removes dead players from the player list so a + * subsequent save load reconciles them to the AI cleanly. + */ +void DesyncDialogClass::Check_Heartbeat_Timeouts() +{ + const auto now = std::chrono::steady_clock::now(); + + auto& players = NodeNameType::Array; + for (int i = players.Count - 1; i >= 1; i--) { + + NodeNameType* node = players[i]; + if (node == nullptr) { + continue; + } + + const int id = node->HouseIndex; + if (id < 0 || id >= SessionExt::MaxPlayers) { + continue; + } + + if (now - LastHeartbeatFrom[id] > std::chrono::milliseconds(HEARTBEAT_TIMEOUT_MS)) { + Debug::Log("DesyncDialog: no heartbeat from house %d for %d seconds, dropping them.\n", id, HEARTBEAT_TIMEOUT_MS / 1000); + + /** + * A non-zero error makes Destroy_Connection remove the player + * via a queued remove-player event rather than an immediate AI + * takeover, and print a "connection lost" message. + */ + SessionClass::Destroy_Connection(id, 1); + SessionExt::Update_Master_After_Player_Removal(); + Notify_Player_Left(id); + } + } +} + + +/** + * Broadcasts a sign-off so the other players drop our connection. + */ +void DesyncDialogClass::Send_Sign_Off() +{ + Debug::Log("DesyncDialog: signing off.\n"); + + ExtGlobalPacketType packet {}; + packet.Command = NET_SIGN_OFF; + Local_Player_Name(packet.Name, sizeof(packet.Name)); + + /** + * Send twice for good measure, since these are not acked. + */ + Broadcast_Global_Packet(packet, 0); + Broadcast_Global_Packet(packet, 0); +} + + +/** + * Detects per-player desync for the current frame and, if any player has + * diverged, shows the dialog and applies the chosen outcome. Returns true if + * the game should stop (quit), false to resume. + * + * Called from the Execute_DoList entry hook (which gates the engine's + * start-of-game CRC-skip window). Mirrors Vinifera's _Execute_DoList: detect + * per-player desync ourselves, show the dialog, and on "continue" drop the + * desynced players (whose events are then kept out of the frame by the + * out-of-sync skip hook), instead of the engine's stock message-box-and-quit. + */ +bool DesyncDialogClass::Check_And_Handle_Desync() +{ + if (!SessionClass::IsMultiplayer()) { + return false; + } + + /** + * Never re-enter while the dialog is already open. If the pump's Call_Back + * ever re-ran the frame logic and reached Execute_DoList again, this would + * otherwise recurse into Run() and overflow the stack. + */ + if (Is_Active()) { + Debug::Log("DesyncDialog: re-entrant desync check ignored (dialog already open).\n"); + return false; + } + + /** + * Detect desync the same way the engine does: for each FRAMEINFO event + * scheduled this frame, compare the frame's reported CRC against ours and + * mark every player that diverged. + */ + bool newly_desynced = false; + const unsigned int current_frame = static_cast(Unsorted::CurrentFrame); + + for (int i = 0; i < EventClass::DoList.Count; i++) { + + EventClass& event = EventClass::DoList[i]; + + if (event.Type != EventType::FrameInfo || event.Frame != current_frame) { + continue; + } + + const int id = event.HouseIndex; + if (id < 0 || id >= SessionExt::MaxPlayers || SessionExt::Is_Out_of_Sync(id)) { + continue; + } + + const int index = static_cast((event.Frame - event.FrameInfo.Delay) & 0xFF); + if (EventClass::LatestFramesCRC[index] != event.FrameInfo.CRC) { + SessionExt::Mark_Player_As_Out_of_Sync(id); + newly_desynced = true; + } + } + + if (!newly_desynced) { + return false; + } + + Debug::Log("DesyncDialog: desync detected on frame %u.\n", current_frame); + + /** + * (A scheduled multiplayer save load would re-sync everyone instead; that + * path is deferred until save-load is ported.) + */ + const DesyncDialogOutcomeType outcome = Run(); + + switch (outcome) { + + case DESYNC_OUTCOME_CONTINUE: + /** + * Continue without the out-of-sync players. Destroy_Connection queues + * their removal; the out-of-sync skip hook keeps their events (and + * FRAMEINFO) out of the executing DoList this frame so the game resumes + * cleanly. Symmetric: the desynced players drop us the same way. + */ + for (int id = 0; id < SessionExt::MaxPlayers; id++) { + if (SessionExt::Is_Out_of_Sync(id)) { + SessionClass::Destroy_Connection(id, -1); + } + } + SessionExt::Update_Master_After_Player_Removal(); + SessionExt::OutOfSyncFrame = -1; + return false; + + case DESYNC_OUTCOME_QUIT: + /** + * Sign off so the others drop us, then let the caller stop the game + * (the Execute_DoList hook returns failure to it). + */ + Send_Sign_Off(); + return true; + + case DESYNC_OUTCOME_LOAD: + default: + /** + * A multiplayer save load would happen here; deferred for now, so stop. + */ + return true; + } +} + + +/** + * Starts the countdown to a scheduled multiplayer save load. + * + * DISABLED: depends on the multiplayer save-load logic, which is further work. + */ +void DesyncDialogClass::Start_Load_Countdown() +{ +#if 0 + Debug::Log("DesyncDialog: starting the load countdown.\n"); + + LoadCountdownActive = true; + LastCountdownSecond = -1; + + if (!Is_Active()) { + return; + } + + Append_Chat_Line("Loading the saved game..."); + + ShowWindow(GetDlgItem(Window, IDC_DESYNC_COUNTDOWN_TEXT), SW_SHOW); + ShowWindow(GetDlgItem(Window, IDC_DESYNC_COUNTDOWN_BAR), SW_SHOW); + Update_Countdown_Text(); + + if (IsHostDialog) { + EnableWindow(GetDlgItem(Window, IDC_DESYNC_LOAD), FALSE); + EnableWindow(GetDlgItem(Window, IDC_DESYNC_CONTINUE), FALSE); + } + + InvalidateRect(Window, nullptr, FALSE); +#endif +} + + +/** + * Updates the countdown label with the number of seconds remaining. + * + * DISABLED: depends on the multiplayer save-load logic, which is further work. + */ +void DesyncDialogClass::Update_Countdown_Text() +{ +#if 0 + if (!Is_Active() || !LoadCountdownActive || !PendingMultiplayerSaveLoadTime) { + return; + } + + using namespace std::chrono; + int remaining_ms = static_cast(duration_cast(*PendingMultiplayerSaveLoadTime - steady_clock::now()).count()); + if (remaining_ms < 0) { + remaining_ms = 0; + } + + /** + * Round up so the label shows "5" for the first second, down to "1" for + * the last, and never a bare "0". + */ + int seconds = (remaining_ms + 999) / 1000; + if (seconds < 1) { + seconds = 1; + } + + if (seconds == LastCountdownSecond) { + return; + } + LastCountdownSecond = seconds; + + char buf[64]; + std::snprintf(buf, std::size(buf), "Loading the saved game in %d second%s...", seconds, seconds == 1 ? "" : "s"); + SetDlgItemText(Window, IDC_DESYNC_COUNTDOWN_TEXT, buf); +#endif +} + + +/** + * Draws the load countdown progress bar, the same way the vanilla + * reconnection dialog draws its sync bars. + * + * DISABLED: depends on the multiplayer save-load logic, which is further work. + * The YR-adapted drawing is kept below for when it is re-enabled. + */ +void DesyncDialogClass::Draw_Countdown_Bar(HWND window) +{ + (void)window; +#if 0 + if (!LoadCountdownActive || !PendingMultiplayerSaveLoadTime) { + return; + } + + HWND bar = GetDlgItem(window, IDC_DESYNC_COUNTDOWN_BAR); + if (bar == nullptr) { + return; + } + + /** + * Get the placeholder control's rectangle, relative to the client + * area of the game's window (which is what the game surfaces map to). + */ + RECT winrect; + GetWindowRect(bar, &winrect); + + RECT client {}; + GetClientRect(Game::hWnd, &client); + ClientToScreen(Game::hWnd, reinterpret_cast(&client)); + + RectangleStruct bar_rect; + bar_rect.X = winrect.left - client.left; + bar_rect.Y = winrect.top - client.top; + bar_rect.Width = winrect.right - winrect.left; + bar_rect.Height = winrect.bottom - winrect.top; + + using namespace std::chrono; + int remaining = static_cast(duration_cast(*PendingMultiplayerSaveLoadTime - steady_clock::now()).count()); + remaining = std::clamp(remaining, 0, LOAD_COUNTDOWN_MS); + + /** + * The bar shrinks and goes from green to yellow to red as the + * countdown progresses. + */ + unsigned color = Drawing::RGB_To_Int(0, 200, 0); + const int elapsed = LOAD_COUNTDOWN_MS - remaining; + if (elapsed > LOAD_COUNTDOWN_MS * 2 / 5) { + color = Drawing::RGB_To_Int(200, 200, 0); + if (elapsed > LOAD_COUNTDOWN_MS * 4 / 5) { + color = Drawing::RGB_To_Int(200, 0, 0); + } + } + + bar_rect.Width = std::max(6, bar_rect.Width * remaining / LOAD_COUNTDOWN_MS); + + RectangleStruct surface_rect = DSurface::Alternate->GetRect(); + DSurface::Alternate->FillRectEx(&surface_rect, &bar_rect, color); +#endif +} + + +/** + * Appends a chat message to the dialog's chat list. + */ +void DesyncDialogClass::Notify_Chat(const char* name, const char* text) +{ + if (!Is_Active()) { + return; + } + + char buf[256]; + std::snprintf(buf, std::size(buf), "%s: %s", name, text); + Append_Chat_Line(buf); +} + + +/** + * Called when a player has left the game (signed off or timed out) + * while the dialog was open. + */ +void DesyncDialogClass::Notify_Player_Left(int house_id) +{ + if (!Is_Active()) { + return; + } + + if (house_id >= 0 && house_id < SessionExt::MaxPlayers) { + PlayerLeft[house_id] = true; + } + + if (house_id >= 0 && house_id < HouseClass::Array.Count && HouseClass::Array[house_id] != nullptr) { + char name[64]; + Wide_To_Ansi(HouseClass::Array[house_id]->UIName, name, sizeof(name)); + char buf[128]; + std::snprintf(buf, std::size(buf), "%s has left the game.", name); + Append_Chat_Line(buf); + } + + Update_Player_List(); + + /** + * If the master is the one who left, SessionExt::Update_Master_After_Player_Removal + * (run by our callers before this) has already promoted a replacement; if it + * promoted us, switch to the host dialog. + */ + Morph_To_Host_Dialog_If_Needed(); +} + + +/** + * Called when the master has decided to continue the game without + * the desynced players. + */ +void DesyncDialogClass::Notify_Continue() +{ + if (!Is_Active()) { + return; + } + + Debug::Log("DesyncDialog: the game master has chosen to continue.\n"); + ContinueReceived = true; +} + + +/** + * Called when a heartbeat has been received from another player. + */ +void DesyncDialogClass::Notify_Heartbeat(int house_id, bool is_host) +{ + if (!Is_Active()) { + return; + } + + if (house_id < 0 || house_id >= SessionExt::MaxPlayers) { + return; + } + + LastHeartbeatFrom[house_id] = std::chrono::steady_clock::now(); + + /** + * Self-heal the master fields in case we missed the announcement, + * and move the host icon in the player list to the right row. + */ + if (is_host && SessionClass::Instance.MasterPlayerID() == -1) { + SessionExt::Set_Master(house_id); + Update_Player_List(); + } +} + + +/** + * Routes a received spawner global packet to the right notification. + * Returns true if the packet was one of ours and was consumed. + * + * This is the receive side. It is ready to be called from the engine's global + * packet dispatch (Network_Call_Back, 0x48D1E0): commands the engine does not + * recognise fall through to its default case (~0x48DACE -> Process_Global_Packet), + * which is where this should be spliced in. The hook itself is left to be wired + * in once the exact splice point/registers are confirmed, e.g.: + * + * DEFINE_HOOK(0x48DACE, NetworkCallBack_DesyncPacket, ) + * { + * GET(ExtGlobalPacketType*, packet, ); + * GET(IPXAddressClass*, address, ); + * if (DesyncDialog.Handle_Global_Packet(packet, address)) + * return
; + * return 0; + * } + * + * Player departures (sign-offs) are likewise routed by hooking the engine's + * Destroy_Connection (0x5DA750) to also call DesyncDialog.Notify_Player_Left(id). + */ +bool DesyncDialogClass::Handle_Global_Packet(const ExtGlobalPacketType* packet, const IPXAddressClass* address) +{ + (void)address; + + if (packet == nullptr) { + return false; + } + + switch (packet->Command) { + case EXT_NET_HOST_ANNOUNCE: + // Sent by the host at game start (before any dialog is open), so record + // the master unconditionally; this is what makes MasterPlayerID + // authoritative everywhere - including for the dialog's host icon. + SessionExt::Set_Master(packet->Heartbeat.HouseID); + return true; + + case EXT_NET_DESYNC_HEARTBEAT: + Notify_Heartbeat(packet->Heartbeat.HouseID, packet->Heartbeat.IsHost != 0); + return true; + + case EXT_NET_DESYNC_CONTINUE: + Notify_Continue(); + return true; + + case EXT_NET_DESYNC_CHAT: + Notify_Chat(packet->Name, packet->Chat.Text); + return true; + + default: + return false; + } +} + + +/** + * Checks if any multiplayer save that is actually loadable in the current + * session exists. + * + * DISABLED: multiplayer save-load is further work; always reports none for now. + */ +bool DesyncDialogClass::Any_Multiplayer_Save_Exists() +{ + return false; +} + + +/** + * The window procedure for both dialog variants. + */ +BOOL CALLBACK DesyncDialogClass::Dialog_Proc(HWND window, UINT message, WPARAM wparam, LPARAM lparam) +{ + /** + * Let the owner-draw framework do all of its standard handling first: it + * subclasses the controls and lays out the dialog on WM_INITDIALOG, paints + * the dialog background, draws the owner-draw controls (WM_DRAWITEM), + * colours the controls, and tears the dialog down on WM_DESTROY. If it + * consumed the message, we are done. (This is exactly how the engine's own + * in-game dialogs, e.g. the diplomacy dialog, are written.) + */ + if (LRESULT handled = UI::StandardWndProc(window, message, wparam, lparam)) { + return static_cast(handled); + } + + switch (message) { + + case WM_MOVING: + // Game::OnWindowMoving clamps the drag rect (lParam) to the screen. + Game::OnWindowMoving(reinterpret_cast(lparam)); + return TRUE; + + case WM_TIMER: + + /** + * Heartbeats are sent from a timer rather than the pump loop, + * so that they keep flowing while a nested dialog runs its own + * message loop. + */ + if (wparam == HEARTBEAT_TIMER) { + DesyncDialog.Send_Heartbeat(); + } else if (wparam == QUIT_ENABLE_TIMER) { + EnableWindow(GetDlgItem(window, IDC_DESYNC_QUIT), TRUE); + KillTimer(window, QUIT_ENABLE_TIMER); + } + break; + + case WW_EDIT_ENTERPRESSED: + /** + * The owner-draw chat edit reports Enter through this message + * (lParam = the edit's HWND), not as an IDOK WM_COMMAND. + */ + if (reinterpret_cast(lparam) == GetDlgItem(window, IDC_DESYNC_CHAT_EDIT)) { + DesyncDialog.Send_Chat(); + return TRUE; + } + break; + + case WM_COMMAND: + switch (LOWORD(wparam)) { + + case IDC_DESYNC_LOAD: + case IDC_DESYNC_CONTINUE: + case IDC_DESYNC_QUIT: + DesyncDialog.Decision = LOWORD(wparam); + break; + } + break; + } + + return FALSE; +} diff --git a/src/UI/DesyncDialog.h b/src/UI/DesyncDialog.h new file mode 100644 index 00000000..4ae06a6e --- /dev/null +++ b/src/UI/DesyncDialog.h @@ -0,0 +1,167 @@ +/** +* yrpp-spawner +* +* Copyright(C) 2022-present CnCNet +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation, either version 3 of the License, or +* (at your option) any later version. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with this program.If not, see . +* +* Dialog shown to the players when a multiplayer game goes out of sync. +* Ported from Vinifera (Tiberian Sun) to Yuri's Revenge. +*/ + +#pragma once + +#include + +#include +#include + +#include +#include +#include + +class IPXAddressClass; + +/** + * The decision the desync dialog was closed with. + */ +enum DesyncDialogOutcomeType +{ + DESYNC_OUTCOME_CONTINUE, // Continue playing without the desynced players. + DESYNC_OUTCOME_LOAD, // A multiplayer save load has been scheduled; let it happen. + DESYNC_OUTCOME_QUIT, // The local player wants to exit the game. +}; + +/** + * Manages the modal "Synchronization Error" dialog that is shown when a + * multiplayer game goes out of sync. The game master gets a dialog with + * Load Game/Continue/Quit options; everyone else gets a dialog asking them + * to wait for the master's decision. Both variants have a chat box. + * + * While the dialog is open, game logic is halted, but the network is + * serviced and connections are kept alive with periodic heartbeats. + */ +class DesyncDialogClass +{ +public: + DesyncDialogClass() = default; + ~DesyncDialogClass() = default; + + /** + * Shows the dialog and pumps it until a decision has been made. + */ + DesyncDialogOutcomeType Run(); + + bool Is_Active() const { return Window != nullptr; } + + /** + * Notifications from the incoming global packet processor. + * All of these are no-ops while the dialog is not open. + */ + void Notify_Chat(const char* name, const char* text); + void Notify_Player_Left(int house_id); + void Notify_Continue(); + void Notify_Heartbeat(int house_id, bool is_host); + + /** + * Routes a received spawner global packet to the right notification. + * Returns true if the packet was one of ours and was consumed. + * Meant to be called from the global-packet receive hook (to be wired + * in separately); see DesyncDialog.cpp. + */ + bool Handle_Global_Packet(const ExtGlobalPacketType* packet, const IPXAddressClass* address); + + /** + * Detects per-player desync for the current frame; if any player has + * diverged, shows the dialog and applies the chosen outcome. Returns true + * if the game should stop (quit), false to resume. Called from the + * Execute_DoList hook. + */ + bool Check_And_Handle_Desync(); + +private: + void Create_Dialog(); + void Destroy_Dialog(); + void Morph_To_Host_Dialog_If_Needed(); + void Update_Player_List(); + void Refill_Chat_List(); + void Append_Chat_Line(const char* line); + void Send_Chat(); + void On_Chat_Edit_Focus(bool gained); + void Send_Heartbeat(); + void Send_Continue(); + void Send_Sign_Off(); + void Check_Heartbeat_Timeouts(); + void Start_Load_Countdown(); + void Update_Countdown_Text(); + void Draw_Countdown_Bar(HWND window); + + static bool Any_Multiplayer_Save_Exists(); + static BOOL CALLBACK Dialog_Proc(HWND window, UINT message, WPARAM wparam, LPARAM lparam); + +private: + /** + * The dialog window, null while the dialog is not open. + */ + HWND Window = nullptr; + + /** + * Is the currently open dialog the host (game master) variant? + */ + bool IsHostDialog = false; + + /** + * Control ID of the button the player pressed, consumed by the pump loop. + */ + int Decision = 0; + + /** + * Has the game master decided to continue without the desynced players? + */ + bool ContinueReceived = false; + + /** + * Is the chat edit box currently showing its hint text? + */ + bool ChatPlaceholderActive = false; + + /** + * Is the 5-second countdown to a scheduled multiplayer save load running? + */ + bool LoadCountdownActive = false; + + /** + * The whole-second value last shown in the countdown text, so we only + * refresh the label when it actually changes. + */ + int LastCountdownSecond = -1; + + /** + * Players that have left the game while the dialog was open, by house ID. + */ + bool PlayerLeft[SessionExt::MaxPlayers] = {}; + + /** + * Heartbeat bookkeeping for detecting players that silently disappear. + */ + std::chrono::steady_clock::time_point LastHeartbeatFrom[SessionExt::MaxPlayers] = {}; + + /** + * All chat lines shown so far, so the list can be refilled when the + * dialog is re-created (e.g. when a waiting player becomes the master). + */ + std::vector ChatBacklog; +}; + +extern DesyncDialogClass DesyncDialog; diff --git a/src/UI/DesyncDialog.rc b/src/UI/DesyncDialog.rc new file mode 100644 index 00000000..0d29f468 --- /dev/null +++ b/src/UI/DesyncDialog.rc @@ -0,0 +1,67 @@ +#include + +#include "DesyncDialog.Resource.h" + +// Multiplayer "Synchronization Error" dialogs, ported from Vinifera. +// Both are full-screen owner-draw child dialogs (STYLE WS_CHILD) laid out like +// the engine's own in-game dialogs (e.g. "waiting for players", res 234, and the +// IPX options dialog, res 215): a 533x369 template that the owner-draw framework +// moves full-screen and scales onto the in-game background (BKGD*.SHP + sidebar). +// Action buttons sit in the right-hand column (x=425, 108x23); content fills the +// left/centre. The host variant offers Load/Continue/Quit; the wait variant only +// lets a non-host player Quit. +// +// Control captions are CSF labels ("GUI:..."); the owner-draw framework resolves +// them through the string table when the dialog is initialised. + +IDD_DESYNC_HOST DIALOG DISCARDABLE 0, 0, 533, 369 +STYLE WS_CHILD +FONT 8, "MS Sans Serif" +BEGIN + CTEXT "GUI:DesyncTitle",IDC_DESYNC_HEADER,63,14,347,14,NOT WS_GROUP + LTEXT "GUI:DesyncPlayers",-1,63,44,160,12,NOT WS_GROUP + LISTBOX IDC_DESYNC_PLAYER_LIST,63,58,160,120,NOT LBS_NOTIFY | + LBS_OWNERDRAWFIXED | LBS_HASSTRINGS | + LBS_NOINTEGRALHEIGHT | LBS_NOSEL | NOT WS_BORDER + LTEXT "GUI:DesyncOutOfSync",-1,235,44,175,12,NOT WS_GROUP + LTEXT "GUI:DesyncHostLoadInfo",-1,235,58,175,32,NOT WS_GROUP + LTEXT "GUI:DesyncHostContinueInfo",-1,235,92,175,44,NOT WS_GROUP + LTEXT "GUI:DesyncHostQuitInfo",-1,235,138,175,44,NOT WS_GROUP + LISTBOX IDC_DESYNC_CHAT_LIST,63,188,347,110,NOT LBS_NOTIFY | + LBS_OWNERDRAWFIXED | LBS_HASSTRINGS | + LBS_NOINTEGRALHEIGHT | LBS_NOSEL | NOT WS_BORDER + EDITTEXT IDC_DESYNC_CHAT_EDIT,63,302,347,14,ES_AUTOHSCROLL | NOT WS_BORDER + LTEXT "GUI:DesyncLoadingCountdown",IDC_DESYNC_COUNTDOWN_TEXT,63,322,240,12,NOT WS_GROUP | NOT WS_VISIBLE + GROUPBOX "",IDC_DESYNC_COUNTDOWN_BAR,300,320,110,14,NOT WS_VISIBLE + CONTROL "GUI:DesyncLoadButton",IDC_DESYNC_LOAD,"Button",BS_OWNERDRAW | + WS_TABSTOP,422,122,108,23 + CONTROL "GUI:DesyncContinueButton",IDC_DESYNC_CONTINUE,"Button",BS_OWNERDRAW | + WS_TABSTOP,422,149,108,23 + CONTROL "GUI:DesyncQuitButton",IDC_DESYNC_QUIT,"Button",BS_OWNERDRAW | + WS_TABSTOP,422,176,108,23 + CONTROL "",1685,"Static",SS_LEFT | SS_CENTERIMAGE,2,355,303,12 +END + +IDD_DESYNC_WAIT DIALOG DISCARDABLE 0, 0, 533, 369 +STYLE WS_CHILD +FONT 8, "MS Sans Serif" +BEGIN + CTEXT "GUI:DesyncTitle",IDC_DESYNC_HEADER,63,14,347,14,NOT WS_GROUP + LTEXT "GUI:DesyncPlayers",-1,63,44,160,12,NOT WS_GROUP + LISTBOX IDC_DESYNC_PLAYER_LIST,63,58,160,120,NOT LBS_NOTIFY | + LBS_OWNERDRAWFIXED | LBS_HASSTRINGS | + LBS_NOINTEGRALHEIGHT | LBS_NOSEL | NOT WS_BORDER + LTEXT "GUI:DesyncOutOfSync",-1,235,44,175,12,NOT WS_GROUP + LTEXT "GUI:DesyncWaitLoadInfo",-1,235,58,175,32,NOT WS_GROUP + LTEXT "GUI:DesyncWaitContinueInfo",-1,235,92,175,44,NOT WS_GROUP + LTEXT "GUI:DesyncWaitInfo",-1,235,138,175,44,NOT WS_GROUP + LISTBOX IDC_DESYNC_CHAT_LIST,63,188,347,110,NOT LBS_NOTIFY | + LBS_OWNERDRAWFIXED | LBS_HASSTRINGS | + LBS_NOINTEGRALHEIGHT | LBS_NOSEL | NOT WS_BORDER + EDITTEXT IDC_DESYNC_CHAT_EDIT,63,302,347,14,ES_AUTOHSCROLL | NOT WS_BORDER + LTEXT "GUI:DesyncLoadingCountdown",IDC_DESYNC_COUNTDOWN_TEXT,63,322,240,12,NOT WS_GROUP | NOT WS_VISIBLE + GROUPBOX "",IDC_DESYNC_COUNTDOWN_BAR,300,320,110,14,NOT WS_VISIBLE + CONTROL "GUI:DesyncQuitButton",IDC_DESYNC_QUIT,"Button",BS_OWNERDRAW | + WS_TABSTOP | WS_DISABLED,422,122,108,23 + CONTROL "",1685,"Static",SS_LEFT | SS_CENTERIMAGE,2,355,303,12 +END From 2576a0c9d5d9dea64d9c50f64ebc53569f7d98bf Mon Sep 17 00:00:00 2001 From: ZivDero Date: Mon, 22 Jun 2026 22:55:51 +0300 Subject: [PATCH 2/9] Cut mid-game MP save-load from the desync dialog --- src/UI/DesyncDialog.Hook.cpp | 2 +- src/UI/DesyncDialog.Resource.h | 5 +- src/UI/DesyncDialog.cpp | 216 +-------------------------------- src/UI/DesyncDialog.h | 18 +-- src/UI/DesyncDialog.rc | 22 ++-- 5 files changed, 15 insertions(+), 248 deletions(-) diff --git a/src/UI/DesyncDialog.Hook.cpp b/src/UI/DesyncDialog.Hook.cpp index cb631ed5..cd492b6a 100644 --- a/src/UI/DesyncDialog.Hook.cpp +++ b/src/UI/DesyncDialog.Hook.cpp @@ -119,7 +119,7 @@ DEFINE_HOOK(0x608D27, OwnerDraw_DesyncDialogSidebarButtons, 0x6) GET(int, control_id, EAX); if ((dialog_id == IDD_DESYNC_HOST || dialog_id == IDD_DESYNC_WAIT) - && (control_id == IDC_DESYNC_LOAD || control_id == IDC_DESYNC_CONTINUE || control_id == IDC_DESYNC_QUIT)) { + && (control_id == IDC_DESYNC_CONTINUE || control_id == IDC_DESYNC_QUIT)) { return 0x608F34; } diff --git a/src/UI/DesyncDialog.Resource.h b/src/UI/DesyncDialog.Resource.h index bfe185b0..48dc23ff 100644 --- a/src/UI/DesyncDialog.Resource.h +++ b/src/UI/DesyncDialog.Resource.h @@ -26,7 +26,7 @@ #pragma once // Dialog templates (live in this DLL; found via the FetchResource hook). -#define IDD_DESYNC_HOST 5000 // Game master: Load/Continue/Quit. +#define IDD_DESYNC_HOST 5000 // Game master: Continue/Quit. #define IDD_DESYNC_WAIT 5001 // Everyone else: wait for the master. // Controls. @@ -34,8 +34,5 @@ #define IDC_DESYNC_PLAYER_LIST 5011 #define IDC_DESYNC_CHAT_LIST 5012 #define IDC_DESYNC_CHAT_EDIT 5013 -#define IDC_DESYNC_COUNTDOWN_TEXT 5014 -#define IDC_DESYNC_COUNTDOWN_BAR 5015 -#define IDC_DESYNC_LOAD 5020 #define IDC_DESYNC_CONTINUE 5021 #define IDC_DESYNC_QUIT 5022 diff --git a/src/UI/DesyncDialog.cpp b/src/UI/DesyncDialog.cpp index 4a0d776e..0c5a69bc 100644 --- a/src/UI/DesyncDialog.cpp +++ b/src/UI/DesyncDialog.cpp @@ -50,11 +50,11 @@ #include #include #include -#include #include #include #include #include +#include #include #include @@ -77,7 +77,6 @@ namespace constexpr int HEARTBEAT_INTERVAL_MS = 1000; constexpr int HEARTBEAT_TIMEOUT_MS = 25000; constexpr int QUIT_ENABLE_DELAY_MS = 10000; - constexpr int LOAD_COUNTDOWN_MS = 5000; constexpr int CHAT_BACKLOG_MAX = 50; // Chat message buffer size; matches the engine's global chat message field. @@ -196,7 +195,6 @@ DesyncDialogOutcomeType DesyncDialogClass::Run() Decision = 0; ContinueReceived = false; - LoadCountdownActive = false; std::fill(std::begin(PlayerLeft), std::end(PlayerLeft), false); std::fill(std::begin(LastHeartbeatFrom), std::end(LastHeartbeatFrom), std::chrono::steady_clock::now()); ChatBacklog.clear(); @@ -229,34 +227,11 @@ DesyncDialogOutcomeType DesyncDialogClass::Run() Check_Heartbeat_Timeouts(); - /** - * Quitting is always allowed, even during the load countdown. - */ if (Decision == IDC_DESYNC_QUIT) { outcome = DESYNC_OUTCOME_QUIT; break; } - // --- Save-load path: ported but DISABLED until multiplayer save-load - // is implemented. The Load button is disabled, so no load is ever - // scheduled and the countdown below never starts. -#if 0 - if (!LoadCountdownActive && PendingMultiplayerSaveLoadTime) { - Start_Load_Countdown(); - } - - if (LoadCountdownActive) { - - Update_Countdown_Text(); - InvalidateRect(Window, nullptr, FALSE); - - if (std::chrono::steady_clock::now() >= *PendingMultiplayerSaveLoadTime) { - outcome = DESYNC_OUTCOME_LOAD; - break; - } - - } else -#endif if (ContinueReceived || Decision == IDC_DESYNC_CONTINUE) { if (Decision == IDC_DESYNC_CONTINUE) { @@ -264,20 +239,6 @@ DesyncDialogOutcomeType DesyncDialogClass::Run() } outcome = DESYNC_OUTCOME_CONTINUE; break; - - } else if (Decision == IDC_DESYNC_LOAD) { - - // --- Open the stock load dialog so the master can pick a save. - // DISABLED for now (the Load button is disabled); needs the - // multiplayer save-load logic to be ported first. -#if 0 - KillTimer(Window, HEARTBEAT_TIMER); - EnableWindow(Window, FALSE); - LoadOptionsClass().Load_Dialog(); - EnableWindow(Window, TRUE); - SetFocus(GetDlgItem(Window, IDC_DESYNC_PLAYER_LIST)); - SetTimer(Window, HEARTBEAT_TIMER, HEARTBEAT_INTERVAL_MS, nullptr); -#endif } Decision = 0; @@ -358,8 +319,6 @@ void DesyncDialogClass::Create_Dialog() Update_Player_List(); if (IsHostDialog) { - // Load is disabled for now (multiplayer save-load is further work). - EnableWindow(GetDlgItem(Window, IDC_DESYNC_LOAD), FALSE); EnableWindow(GetDlgItem(Window, IDC_DESYNC_CONTINUE), TRUE); } else { @@ -411,14 +370,6 @@ void DesyncDialogClass::Morph_To_Host_Dialog_If_Needed() return; } - /** - * If a load countdown is already running, the decision has been made; - * there is nothing left to decide. - */ - if (LoadCountdownActive) { - return; - } - if (!SessionClass::Instance.Am_I_Master()) { return; } @@ -488,13 +439,13 @@ void DesyncDialogClass::Update_Player_List() const wchar_t* status; COLORREF color; if (PlayerLeft[i]) { - status = L"Quit"; + status = StringTable::TryFetchString("GUI:DesyncStatusQuit", L"Quit"); color = RGB(200, 0, 0); } else if (SessionExt::Is_Out_of_Sync(i)) { - status = L"Desynced"; + status = StringTable::TryFetchString("GUI:DesyncStatusDesynced", L"Desynced"); color = RGB(200, 200, 0); } else { - status = L"OK"; + status = StringTable::TryFetchString("GUI:DesyncStatusOK", L"OK"); color = RGB(0, 200, 0); } @@ -794,10 +745,6 @@ bool DesyncDialogClass::Check_And_Handle_Desync() Debug::Log("DesyncDialog: desync detected on frame %u.\n", current_frame); - /** - * (A scheduled multiplayer save load would re-sync everyone instead; that - * path is deferred until save-load is ported.) - */ const DesyncDialogOutcomeType outcome = Run(); switch (outcome) { @@ -819,154 +766,14 @@ bool DesyncDialogClass::Check_And_Handle_Desync() return false; case DESYNC_OUTCOME_QUIT: + default: /** * Sign off so the others drop us, then let the caller stop the game * (the Execute_DoList hook returns failure to it). */ Send_Sign_Off(); return true; - - case DESYNC_OUTCOME_LOAD: - default: - /** - * A multiplayer save load would happen here; deferred for now, so stop. - */ - return true; - } -} - - -/** - * Starts the countdown to a scheduled multiplayer save load. - * - * DISABLED: depends on the multiplayer save-load logic, which is further work. - */ -void DesyncDialogClass::Start_Load_Countdown() -{ -#if 0 - Debug::Log("DesyncDialog: starting the load countdown.\n"); - - LoadCountdownActive = true; - LastCountdownSecond = -1; - - if (!Is_Active()) { - return; } - - Append_Chat_Line("Loading the saved game..."); - - ShowWindow(GetDlgItem(Window, IDC_DESYNC_COUNTDOWN_TEXT), SW_SHOW); - ShowWindow(GetDlgItem(Window, IDC_DESYNC_COUNTDOWN_BAR), SW_SHOW); - Update_Countdown_Text(); - - if (IsHostDialog) { - EnableWindow(GetDlgItem(Window, IDC_DESYNC_LOAD), FALSE); - EnableWindow(GetDlgItem(Window, IDC_DESYNC_CONTINUE), FALSE); - } - - InvalidateRect(Window, nullptr, FALSE); -#endif -} - - -/** - * Updates the countdown label with the number of seconds remaining. - * - * DISABLED: depends on the multiplayer save-load logic, which is further work. - */ -void DesyncDialogClass::Update_Countdown_Text() -{ -#if 0 - if (!Is_Active() || !LoadCountdownActive || !PendingMultiplayerSaveLoadTime) { - return; - } - - using namespace std::chrono; - int remaining_ms = static_cast(duration_cast(*PendingMultiplayerSaveLoadTime - steady_clock::now()).count()); - if (remaining_ms < 0) { - remaining_ms = 0; - } - - /** - * Round up so the label shows "5" for the first second, down to "1" for - * the last, and never a bare "0". - */ - int seconds = (remaining_ms + 999) / 1000; - if (seconds < 1) { - seconds = 1; - } - - if (seconds == LastCountdownSecond) { - return; - } - LastCountdownSecond = seconds; - - char buf[64]; - std::snprintf(buf, std::size(buf), "Loading the saved game in %d second%s...", seconds, seconds == 1 ? "" : "s"); - SetDlgItemText(Window, IDC_DESYNC_COUNTDOWN_TEXT, buf); -#endif -} - - -/** - * Draws the load countdown progress bar, the same way the vanilla - * reconnection dialog draws its sync bars. - * - * DISABLED: depends on the multiplayer save-load logic, which is further work. - * The YR-adapted drawing is kept below for when it is re-enabled. - */ -void DesyncDialogClass::Draw_Countdown_Bar(HWND window) -{ - (void)window; -#if 0 - if (!LoadCountdownActive || !PendingMultiplayerSaveLoadTime) { - return; - } - - HWND bar = GetDlgItem(window, IDC_DESYNC_COUNTDOWN_BAR); - if (bar == nullptr) { - return; - } - - /** - * Get the placeholder control's rectangle, relative to the client - * area of the game's window (which is what the game surfaces map to). - */ - RECT winrect; - GetWindowRect(bar, &winrect); - - RECT client {}; - GetClientRect(Game::hWnd, &client); - ClientToScreen(Game::hWnd, reinterpret_cast(&client)); - - RectangleStruct bar_rect; - bar_rect.X = winrect.left - client.left; - bar_rect.Y = winrect.top - client.top; - bar_rect.Width = winrect.right - winrect.left; - bar_rect.Height = winrect.bottom - winrect.top; - - using namespace std::chrono; - int remaining = static_cast(duration_cast(*PendingMultiplayerSaveLoadTime - steady_clock::now()).count()); - remaining = std::clamp(remaining, 0, LOAD_COUNTDOWN_MS); - - /** - * The bar shrinks and goes from green to yellow to red as the - * countdown progresses. - */ - unsigned color = Drawing::RGB_To_Int(0, 200, 0); - const int elapsed = LOAD_COUNTDOWN_MS - remaining; - if (elapsed > LOAD_COUNTDOWN_MS * 2 / 5) { - color = Drawing::RGB_To_Int(200, 200, 0); - if (elapsed > LOAD_COUNTDOWN_MS * 4 / 5) { - color = Drawing::RGB_To_Int(200, 0, 0); - } - } - - bar_rect.Width = std::max(6, bar_rect.Width * remaining / LOAD_COUNTDOWN_MS); - - RectangleStruct surface_rect = DSurface::Alternate->GetRect(); - DSurface::Alternate->FillRectEx(&surface_rect, &bar_rect, color); -#endif } @@ -1115,18 +922,6 @@ bool DesyncDialogClass::Handle_Global_Packet(const ExtGlobalPacketType* packet, } -/** - * Checks if any multiplayer save that is actually loadable in the current - * session exists. - * - * DISABLED: multiplayer save-load is further work; always reports none for now. - */ -bool DesyncDialogClass::Any_Multiplayer_Save_Exists() -{ - return false; -} - - /** * The window procedure for both dialog variants. */ @@ -1180,7 +975,6 @@ BOOL CALLBACK DesyncDialogClass::Dialog_Proc(HWND window, UINT message, WPARAM w case WM_COMMAND: switch (LOWORD(wparam)) { - case IDC_DESYNC_LOAD: case IDC_DESYNC_CONTINUE: case IDC_DESYNC_QUIT: DesyncDialog.Decision = LOWORD(wparam); diff --git a/src/UI/DesyncDialog.h b/src/UI/DesyncDialog.h index 4ae06a6e..4cdfc396 100644 --- a/src/UI/DesyncDialog.h +++ b/src/UI/DesyncDialog.h @@ -39,14 +39,13 @@ class IPXAddressClass; enum DesyncDialogOutcomeType { DESYNC_OUTCOME_CONTINUE, // Continue playing without the desynced players. - DESYNC_OUTCOME_LOAD, // A multiplayer save load has been scheduled; let it happen. DESYNC_OUTCOME_QUIT, // The local player wants to exit the game. }; /** * Manages the modal "Synchronization Error" dialog that is shown when a * multiplayer game goes out of sync. The game master gets a dialog with - * Load Game/Continue/Quit options; everyone else gets a dialog asking them + * Continue/Quit options; everyone else gets a dialog asking them * to wait for the master's decision. Both variants have a chat box. * * While the dialog is open, game logic is halted, but the network is @@ -103,11 +102,7 @@ class DesyncDialogClass void Send_Continue(); void Send_Sign_Off(); void Check_Heartbeat_Timeouts(); - void Start_Load_Countdown(); - void Update_Countdown_Text(); - void Draw_Countdown_Bar(HWND window); - static bool Any_Multiplayer_Save_Exists(); static BOOL CALLBACK Dialog_Proc(HWND window, UINT message, WPARAM wparam, LPARAM lparam); private: @@ -136,17 +131,6 @@ class DesyncDialogClass */ bool ChatPlaceholderActive = false; - /** - * Is the 5-second countdown to a scheduled multiplayer save load running? - */ - bool LoadCountdownActive = false; - - /** - * The whole-second value last shown in the countdown text, so we only - * refresh the label when it actually changes. - */ - int LastCountdownSecond = -1; - /** * Players that have left the game while the dialog was open, by house ID. */ diff --git a/src/UI/DesyncDialog.rc b/src/UI/DesyncDialog.rc index 0d29f468..fef8c35a 100644 --- a/src/UI/DesyncDialog.rc +++ b/src/UI/DesyncDialog.rc @@ -8,7 +8,7 @@ // IPX options dialog, res 215): a 533x369 template that the owner-draw framework // moves full-screen and scales onto the in-game background (BKGD*.SHP + sidebar). // Action buttons sit in the right-hand column (x=425, 108x23); content fills the -// left/centre. The host variant offers Load/Continue/Quit; the wait variant only +// left/centre. The host variant offers Continue/Quit; the wait variant only // lets a non-host player Quit. // // Control captions are CSF labels ("GUI:..."); the owner-draw framework resolves @@ -24,21 +24,16 @@ BEGIN LBS_OWNERDRAWFIXED | LBS_HASSTRINGS | LBS_NOINTEGRALHEIGHT | LBS_NOSEL | NOT WS_BORDER LTEXT "GUI:DesyncOutOfSync",-1,235,44,175,12,NOT WS_GROUP - LTEXT "GUI:DesyncHostLoadInfo",-1,235,58,175,32,NOT WS_GROUP - LTEXT "GUI:DesyncHostContinueInfo",-1,235,92,175,44,NOT WS_GROUP - LTEXT "GUI:DesyncHostQuitInfo",-1,235,138,175,44,NOT WS_GROUP + LTEXT "GUI:DesyncHostContinueInfo",-1,235,58,175,44,NOT WS_GROUP + LTEXT "GUI:DesyncHostQuitInfo",-1,235,104,175,44,NOT WS_GROUP LISTBOX IDC_DESYNC_CHAT_LIST,63,188,347,110,NOT LBS_NOTIFY | LBS_OWNERDRAWFIXED | LBS_HASSTRINGS | LBS_NOINTEGRALHEIGHT | LBS_NOSEL | NOT WS_BORDER EDITTEXT IDC_DESYNC_CHAT_EDIT,63,302,347,14,ES_AUTOHSCROLL | NOT WS_BORDER - LTEXT "GUI:DesyncLoadingCountdown",IDC_DESYNC_COUNTDOWN_TEXT,63,322,240,12,NOT WS_GROUP | NOT WS_VISIBLE - GROUPBOX "",IDC_DESYNC_COUNTDOWN_BAR,300,320,110,14,NOT WS_VISIBLE - CONTROL "GUI:DesyncLoadButton",IDC_DESYNC_LOAD,"Button",BS_OWNERDRAW | - WS_TABSTOP,422,122,108,23 CONTROL "GUI:DesyncContinueButton",IDC_DESYNC_CONTINUE,"Button",BS_OWNERDRAW | - WS_TABSTOP,422,149,108,23 + WS_TABSTOP,422,122,108,23 CONTROL "GUI:DesyncQuitButton",IDC_DESYNC_QUIT,"Button",BS_OWNERDRAW | - WS_TABSTOP,422,176,108,23 + WS_TABSTOP,422,149,108,23 CONTROL "",1685,"Static",SS_LEFT | SS_CENTERIMAGE,2,355,303,12 END @@ -52,15 +47,12 @@ BEGIN LBS_OWNERDRAWFIXED | LBS_HASSTRINGS | LBS_NOINTEGRALHEIGHT | LBS_NOSEL | NOT WS_BORDER LTEXT "GUI:DesyncOutOfSync",-1,235,44,175,12,NOT WS_GROUP - LTEXT "GUI:DesyncWaitLoadInfo",-1,235,58,175,32,NOT WS_GROUP - LTEXT "GUI:DesyncWaitContinueInfo",-1,235,92,175,44,NOT WS_GROUP - LTEXT "GUI:DesyncWaitInfo",-1,235,138,175,44,NOT WS_GROUP + LTEXT "GUI:DesyncWaitContinueInfo",-1,235,58,175,44,NOT WS_GROUP + LTEXT "GUI:DesyncWaitInfo",-1,235,104,175,44,NOT WS_GROUP LISTBOX IDC_DESYNC_CHAT_LIST,63,188,347,110,NOT LBS_NOTIFY | LBS_OWNERDRAWFIXED | LBS_HASSTRINGS | LBS_NOINTEGRALHEIGHT | LBS_NOSEL | NOT WS_BORDER EDITTEXT IDC_DESYNC_CHAT_EDIT,63,302,347,14,ES_AUTOHSCROLL | NOT WS_BORDER - LTEXT "GUI:DesyncLoadingCountdown",IDC_DESYNC_COUNTDOWN_TEXT,63,322,240,12,NOT WS_GROUP | NOT WS_VISIBLE - GROUPBOX "",IDC_DESYNC_COUNTDOWN_BAR,300,320,110,14,NOT WS_VISIBLE CONTROL "GUI:DesyncQuitButton",IDC_DESYNC_QUIT,"Button",BS_OWNERDRAW | WS_TABSTOP | WS_DISABLED,422,122,108,23 CONTROL "",1685,"Static",SS_LEFT | SS_CENTERIMAGE,2,355,303,12 From 33f6de355011c0bae3583085d46865911d6fc8bd Mon Sep 17 00:00:00 2001 From: ZivDero Date: Mon, 22 Jun 2026 23:09:27 +0300 Subject: [PATCH 3/9] Clean up comments --- YRpp | 2 +- src/UI/DesyncDialog.Hook.cpp | 13 +++++------- src/UI/DesyncDialog.cpp | 38 ++++++++++++------------------------ src/UI/DesyncDialog.h | 1 - src/UI/DesyncDialog.rc | 2 +- 5 files changed, 19 insertions(+), 37 deletions(-) diff --git a/YRpp b/YRpp index 086d4487..5f0541da 160000 --- a/YRpp +++ b/YRpp @@ -1 +1 @@ -Subproject commit 086d4487777b6d54868f47b5bca5c91709bd01ad +Subproject commit 5f0541da5fb12e8cd4c54cfc246cff059d2b0138 diff --git a/src/UI/DesyncDialog.Hook.cpp b/src/UI/DesyncDialog.Hook.cpp index cd492b6a..57b642a8 100644 --- a/src/UI/DesyncDialog.Hook.cpp +++ b/src/UI/DesyncDialog.Hook.cpp @@ -17,9 +17,8 @@ * along with this program.If not, see . */ -// Hooks that wire the desync dialog into the engine's networking. -// Mirrors what Vinifera added to TS's IPX_Call_Back (0x462DC0); here the -// equivalent function is Network_Call_Back (0x48D1E0). +// Hooks that wire the desync dialog into the engine's networking, layout and +// owner-draw, mainly around Network_Call_Back (0x48D1E0) and Execute_DoList. #include "DesyncDialog.h" #include "DesyncDialog.Resource.h" @@ -153,8 +152,7 @@ DEFINE_HOOK(0x48DAC4, NetworkCallBack_DesyncPacket, 0x5) * Sign-off -> player-left. On NET_SIGN_OFF the engine calls * Destroy_Connection(id, 0) at 0x48D859; tell the dialog so the departing * player shows as "Quit". (Heartbeat timeouts already notify from - * DesyncDialogClass::Check_Heartbeat_Timeouts. Mirrors Vinifera's sign-off - * path: Destroy_Connection -> Update_Master_After_Player_Removal -> notify.) + * DesyncDialogClass::Check_Heartbeat_Timeouts.) * * We perform the drop ourselves and jump past the original call so the notify * runs *after* the connection is gone and the master reassigned: if the host @@ -176,9 +174,8 @@ DEFINE_HOOK(0x48D859, NetworkCallBack_SignOff_NotifyPlayerLeft, 0x5) } /** - * Trigger (Hook A). Mirrors Vinifera replacing Execute_DoList: we detect - * per-player desync ourselves and run our dialog instead of the engine's - * stock "out of sync" message box + quit. + * Trigger (Hook A). Detects per-player desync at Execute_DoList and runs our + * dialog instead of the engine's stock "out of sync" message box + quit. * * Returns 0 from Execute_DoList to the caller (Queue_AI_Multiplayer), whose * failure path then stops the game. Execute_DoList cleans 3 stack args (0xC); diff --git a/src/UI/DesyncDialog.cpp b/src/UI/DesyncDialog.cpp index 0c5a69bc..04d55bf5 100644 --- a/src/UI/DesyncDialog.cpp +++ b/src/UI/DesyncDialog.cpp @@ -16,23 +16,11 @@ * You should have received a copy of the GNU General Public License * along with this program.If not, see . * -* Dialog shown to the players when a multiplayer game goes out of sync. -* Ported from Vinifera (Tiberian Sun) to Yuri's Revenge. -* -* Porting notes (TS/Vinifera -> YR): -* - Owner-draw dialog API: OwnerDraw::BeginDialog/SubclassDlg/EndDialog -> -* UI::BeginDialog/RegisterWindow/EndDialog; OwnerDraw::DrawItem -> -* OwnerDraw::DrawItem; OwnerDraw::DrawDialogBack -> OwnerDraw::Paint; -* WinDialogClass::Center_Window -> UI::CenterWindow. -* - Listbox cells: OwnerDraw::CellData -> WWUIListBoxCell (strings are WIDE); -* OD_ADDCOLUMN/OD_SETCELL -> WW_LB_ADDCOLUMN/WW_LB_SETCELLTEXT. -* - Network transport mirrors the engine's own in-game global packets -* (beacons): IPXManagerClass::Send_Global_Message to each peer's address -* from SessionClass::Players, packet type ExtGlobalPacketType. -* - Master/host: SessionClass::Am_I_Master / MasterPlayerID (native). -* - Per-player out-of-sync / chat scope: re-created in SessionExt. -* - Save-load (Load button, countdown): ported but DISABLED for now; the -* Load button is disabled and the load/countdown code is left under #if 0. +* Modal "Synchronization Error" dialog shown when a multiplayer game goes out +* of sync. The game master gets Continue/Quit; everyone else waits for the +* master's decision. Both variants have a chat box. Game logic is halted while +* the dialog is open, but the network is serviced so chat, sign-offs and the +* decision still flow. */ #include "DesyncDialog.h" @@ -207,7 +195,7 @@ DesyncDialogOutcomeType DesyncDialogClass::Run() /** * If the dialog could not be created for whatever reason, fall back - * to the old behavior of continuing without the desynced players. + * to continuing without the desynced players. */ Debug::Log("DesyncDialog: failed to create the dialog!\n"); outcome = DESYNC_OUTCOME_CONTINUE; @@ -514,9 +502,8 @@ void DesyncDialogClass::Append_Chat_Line(const char* line) /** * Sends the message currently in the chat edit box to the other players. * - * Vinifera reused the engine's native network chat here. While the game is - * frozen the in-game chat UI is not running, so the desync dialog carries its - * own chat over a dedicated global packet instead, and echoes it locally. + * The in-game chat UI is not running while the game is frozen, so the dialog + * carries its own chat over a dedicated global packet and echoes it locally. */ void DesyncDialogClass::Send_Chat() { @@ -544,7 +531,7 @@ void DesyncDialogClass::Send_Chat() SendMessage(edit, WW_SETTEXTW, 0, reinterpret_cast(L"")); SetFocus(edit); - SessionExt::IsChatToAllies = false; // broadcast scope (parity with Vinifera) + SessionExt::IsChatToAllies = false; // broadcast to everyone, not just allies ExtGlobalPacketType packet {}; packet.Command = EXT_NET_DESYNC_CHAT; @@ -690,10 +677,9 @@ void DesyncDialogClass::Send_Sign_Off() * the game should stop (quit), false to resume. * * Called from the Execute_DoList entry hook (which gates the engine's - * start-of-game CRC-skip window). Mirrors Vinifera's _Execute_DoList: detect - * per-player desync ourselves, show the dialog, and on "continue" drop the - * desynced players (whose events are then kept out of the frame by the - * out-of-sync skip hook), instead of the engine's stock message-box-and-quit. + * start-of-game CRC-skip window). Replaces the engine's stock + * message-box-and-quit: on "continue" the desynced players are dropped and + * their events kept out of the frame by the out-of-sync skip hook. */ bool DesyncDialogClass::Check_And_Handle_Desync() { diff --git a/src/UI/DesyncDialog.h b/src/UI/DesyncDialog.h index 4cdfc396..5986480a 100644 --- a/src/UI/DesyncDialog.h +++ b/src/UI/DesyncDialog.h @@ -17,7 +17,6 @@ * along with this program.If not, see . * * Dialog shown to the players when a multiplayer game goes out of sync. -* Ported from Vinifera (Tiberian Sun) to Yuri's Revenge. */ #pragma once diff --git a/src/UI/DesyncDialog.rc b/src/UI/DesyncDialog.rc index fef8c35a..2d591d86 100644 --- a/src/UI/DesyncDialog.rc +++ b/src/UI/DesyncDialog.rc @@ -2,7 +2,7 @@ #include "DesyncDialog.Resource.h" -// Multiplayer "Synchronization Error" dialogs, ported from Vinifera. +// Multiplayer "Synchronization Error" dialogs. // Both are full-screen owner-draw child dialogs (STYLE WS_CHILD) laid out like // the engine's own in-game dialogs (e.g. "waiting for players", res 234, and the // IPX options dialog, res 215): a 533x369 template that the owner-draw framework From fd93b9f6d1697d16aa6c0bb0072accb40aa56ab5 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Tue, 23 Jun 2026 00:28:45 +0300 Subject: [PATCH 4/9] Add chat Send button and hint text to the desync dialog --- src/UI/DesyncDialog.Resource.h | 1 + src/UI/DesyncDialog.cpp | 77 +++++++++++++++++++++++++++------- src/UI/DesyncDialog.h | 2 +- src/UI/DesyncDialog.rc | 18 ++++---- 4 files changed, 73 insertions(+), 25 deletions(-) diff --git a/src/UI/DesyncDialog.Resource.h b/src/UI/DesyncDialog.Resource.h index 48dc23ff..83f3538e 100644 --- a/src/UI/DesyncDialog.Resource.h +++ b/src/UI/DesyncDialog.Resource.h @@ -34,5 +34,6 @@ #define IDC_DESYNC_PLAYER_LIST 5011 #define IDC_DESYNC_CHAT_LIST 5012 #define IDC_DESYNC_CHAT_EDIT 5013 +#define IDC_DESYNC_CHAT_SEND 5014 #define IDC_DESYNC_CONTINUE 5021 #define IDC_DESYNC_QUIT 5022 diff --git a/src/UI/DesyncDialog.cpp b/src/UI/DesyncDialog.cpp index 04d55bf5..67d2a36a 100644 --- a/src/UI/DesyncDialog.cpp +++ b/src/UI/DesyncDialog.cpp @@ -73,7 +73,11 @@ namespace // Engine global-packet command for a player signing off. constexpr int NET_SIGN_OFF = 0xA; - constexpr char CHAT_EDIT_PLACEHOLDER[] = "Type here to chat..."; + // The chat edit's hint text, from the string table (English fallback). + const wchar_t* Chat_Edit_Placeholder() + { + return StringTable::TryFetchString("GUI:DesyncChatHint", L"Type here to chat..."); + } /** * Player list column positions, in pixels within the listbox client @@ -214,6 +218,7 @@ DesyncDialogOutcomeType DesyncDialogClass::Run() } Check_Heartbeat_Timeouts(); + Update_Chat_Placeholder(); if (Decision == IDC_DESYNC_QUIT) { outcome = DESYNC_OUTCOME_QUIT; @@ -322,15 +327,19 @@ void DesyncDialogClass::Create_Dialog() Refill_Chat_List(); ShowWindow(Window, SW_SHOWNORMAL); + SetForegroundWindow(Window); /** - * Give the dialog focus the way the engine focuses its own dialogs, then - * move focus to the player list rather than the chat edit box, so the - * player isn't typing into chat the instant the dialog appears. + * Seed the chat edit's hint text unless it already has focus; thereafter + * Update_Chat_Placeholder (polled from the pump) clears it while the player + * is typing and restores it when the box is left empty. */ - SetForegroundWindow(Window); - HWND llist = GetDlgItem(Window, IDC_DESYNC_PLAYER_LIST); - SetFocus(llist); + HWND edit = GetDlgItem(Window, IDC_DESYNC_CHAT_EDIT); + if (edit != nullptr && GetFocus() != edit) { + SendMessage(edit, WW_SETTEXTW, 0, reinterpret_cast(Chat_Edit_Placeholder())); + ChatPlaceholderActive = true; + } + Debug::Log("DesyncDialog: Create_Dialog complete.\n"); } @@ -516,6 +525,13 @@ void DesyncDialogClass::Send_Chat() return; } + /** + * The box is showing its hint text, not a real message - nothing to send. + */ + if (ChatPlaceholderActive) { + return; + } + /** * The chat edit is an owner-draw NewEdit: it keeps its text in the engine's * own buffer rather than the Win32 window text, so read and clear it with the @@ -529,6 +545,17 @@ void DesyncDialogClass::Send_Chat() } SendMessage(edit, WW_SETTEXTW, 0, reinterpret_cast(L"")); + + /** + * Repaint the edit right away. The owner-draw edit only redraws itself on + * a text change when it has focus, so clearing it from the Send button + * (where the button, not the edit, is focused) would otherwise leave the + * old text on screen until the next pump cycle. Pressing Enter doesn't hit + * this because the edit is already focused. + */ + InvalidateRect(edit, nullptr, TRUE); + UpdateWindow(edit); + SetFocus(edit); SessionExt::IsChatToAllies = false; // broadcast to everyone, not just allies @@ -549,10 +576,15 @@ void DesyncDialogClass::Send_Chat() /** - * Manages the hint text in the chat edit box: the hint is shown while the - * box is empty and unfocused, and cleared when the player clicks into it. + * Manages the hint text in the chat edit box: the hint is shown while the box + * is empty and unfocused, and cleared once the player clicks into it. + * + * Polled from the pump loop: the owner-draw NewEdit keeps its text in the + * engine's own buffer (the WW_*TEXT messages, not the Win32 window text) and + * sends no focus notifications, so there is nothing to drive this off events. + * We track focus with GetFocus instead. */ -void DesyncDialogClass::On_Chat_Edit_Focus(bool gained) +void DesyncDialogClass::Update_Chat_Placeholder() { if (!Is_Active()) { return; @@ -563,12 +595,21 @@ void DesyncDialogClass::On_Chat_Edit_Focus(bool gained) return; } - if (gained && ChatPlaceholderActive) { - SetWindowText(edit, ""); - ChatPlaceholderActive = false; - } else if (!gained && GetWindowTextLength(edit) == 0) { - SetWindowText(edit, CHAT_EDIT_PLACEHOLDER); - ChatPlaceholderActive = true; + if (GetFocus() == edit) { + if (ChatPlaceholderActive) { + SendMessage(edit, WW_SETTEXTW, 0, reinterpret_cast(L"")); + ChatPlaceholderActive = false; + InvalidateRect(edit, nullptr, TRUE); + } + } else if (!ChatPlaceholderActive) { + char buf[MAX_MESSAGE_LENGTH]; + buf[0] = '\0'; + SendMessage(edit, WW_GETTEXTA, sizeof(buf), reinterpret_cast(buf)); + if (buf[0] == '\0') { + SendMessage(edit, WW_SETTEXTW, 0, reinterpret_cast(Chat_Edit_Placeholder())); + ChatPlaceholderActive = true; + InvalidateRect(edit, nullptr, TRUE); + } } } @@ -961,6 +1002,10 @@ BOOL CALLBACK DesyncDialogClass::Dialog_Proc(HWND window, UINT message, WPARAM w case WM_COMMAND: switch (LOWORD(wparam)) { + case IDC_DESYNC_CHAT_SEND: + DesyncDialog.Send_Chat(); + return TRUE; + case IDC_DESYNC_CONTINUE: case IDC_DESYNC_QUIT: DesyncDialog.Decision = LOWORD(wparam); diff --git a/src/UI/DesyncDialog.h b/src/UI/DesyncDialog.h index 5986480a..0102d4dd 100644 --- a/src/UI/DesyncDialog.h +++ b/src/UI/DesyncDialog.h @@ -96,7 +96,7 @@ class DesyncDialogClass void Refill_Chat_List(); void Append_Chat_Line(const char* line); void Send_Chat(); - void On_Chat_Edit_Focus(bool gained); + void Update_Chat_Placeholder(); void Send_Heartbeat(); void Send_Continue(); void Send_Sign_Off(); diff --git a/src/UI/DesyncDialog.rc b/src/UI/DesyncDialog.rc index 2d591d86..ab23c0c3 100644 --- a/src/UI/DesyncDialog.rc +++ b/src/UI/DesyncDialog.rc @@ -23,13 +23,14 @@ BEGIN LISTBOX IDC_DESYNC_PLAYER_LIST,63,58,160,120,NOT LBS_NOTIFY | LBS_OWNERDRAWFIXED | LBS_HASSTRINGS | LBS_NOINTEGRALHEIGHT | LBS_NOSEL | NOT WS_BORDER - LTEXT "GUI:DesyncOutOfSync",-1,235,44,175,12,NOT WS_GROUP - LTEXT "GUI:DesyncHostContinueInfo",-1,235,58,175,44,NOT WS_GROUP - LTEXT "GUI:DesyncHostQuitInfo",-1,235,104,175,44,NOT WS_GROUP + LTEXT "GUI:DesyncOutOfSync",-1,235,58,175,12,NOT WS_GROUP + LTEXT "GUI:DesyncHostInfo",-1,235,72,175,110,NOT WS_GROUP LISTBOX IDC_DESYNC_CHAT_LIST,63,188,347,110,NOT LBS_NOTIFY | LBS_OWNERDRAWFIXED | LBS_HASSTRINGS | LBS_NOINTEGRALHEIGHT | LBS_NOSEL | NOT WS_BORDER - EDITTEXT IDC_DESYNC_CHAT_EDIT,63,302,347,14,ES_AUTOHSCROLL | NOT WS_BORDER + EDITTEXT IDC_DESYNC_CHAT_EDIT,63,302,276,14,ES_AUTOHSCROLL | NOT WS_BORDER + CONTROL "GUI:DesyncSendButton",IDC_DESYNC_CHAT_SEND,"Button",BS_OWNERDRAW | + WS_TABSTOP,344,301,66,16 CONTROL "GUI:DesyncContinueButton",IDC_DESYNC_CONTINUE,"Button",BS_OWNERDRAW | WS_TABSTOP,422,122,108,23 CONTROL "GUI:DesyncQuitButton",IDC_DESYNC_QUIT,"Button",BS_OWNERDRAW | @@ -46,13 +47,14 @@ BEGIN LISTBOX IDC_DESYNC_PLAYER_LIST,63,58,160,120,NOT LBS_NOTIFY | LBS_OWNERDRAWFIXED | LBS_HASSTRINGS | LBS_NOINTEGRALHEIGHT | LBS_NOSEL | NOT WS_BORDER - LTEXT "GUI:DesyncOutOfSync",-1,235,44,175,12,NOT WS_GROUP - LTEXT "GUI:DesyncWaitContinueInfo",-1,235,58,175,44,NOT WS_GROUP - LTEXT "GUI:DesyncWaitInfo",-1,235,104,175,44,NOT WS_GROUP + LTEXT "GUI:DesyncOutOfSync",-1,235,58,175,12,NOT WS_GROUP + LTEXT "GUI:DesyncWaitInfo",-1,235,72,175,110,NOT WS_GROUP LISTBOX IDC_DESYNC_CHAT_LIST,63,188,347,110,NOT LBS_NOTIFY | LBS_OWNERDRAWFIXED | LBS_HASSTRINGS | LBS_NOINTEGRALHEIGHT | LBS_NOSEL | NOT WS_BORDER - EDITTEXT IDC_DESYNC_CHAT_EDIT,63,302,347,14,ES_AUTOHSCROLL | NOT WS_BORDER + EDITTEXT IDC_DESYNC_CHAT_EDIT,63,302,276,14,ES_AUTOHSCROLL | NOT WS_BORDER + CONTROL "GUI:DesyncSendButton",IDC_DESYNC_CHAT_SEND,"Button",BS_OWNERDRAW | + WS_TABSTOP,344,301,66,16 CONTROL "GUI:DesyncQuitButton",IDC_DESYNC_QUIT,"Button",BS_OWNERDRAW | WS_TABSTOP | WS_DISABLED,422,122,108,23 CONTROL "",1685,"Static",SS_LEFT | SS_CENTERIMAGE,2,355,303,12 From 22d85aa00fc4414d53c6ab239859592b10599483 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Tue, 23 Jun 2026 00:30:18 +0300 Subject: [PATCH 5/9] Credits --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 4d69d5d2..7837653a 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ Credits - Whole lot of desync fixes - **[ZivDero](https://github.com/ZivDero)** - Handicaps (difficulty & credits) support + - Multiplayer desync dialog - **[Starkku](https://github.com/Starkku)** - Allow customizing whether or not special house is ally to all players via spawn.ini option (#51) - **[RAZER](https://github.com/CnCRAZER)** From 6622eb01ebc1951b7adb9c202f15a7c6d051b303 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Tue, 23 Jun 2026 00:57:28 +0300 Subject: [PATCH 6/9] Match desync dialog padding to the engine's in-game dialogs --- src/UI/DesyncDialog.rc | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/src/UI/DesyncDialog.rc b/src/UI/DesyncDialog.rc index ab23c0c3..fb385cb1 100644 --- a/src/UI/DesyncDialog.rc +++ b/src/UI/DesyncDialog.rc @@ -18,23 +18,23 @@ IDD_DESYNC_HOST DIALOG DISCARDABLE 0, 0, 533, 369 STYLE WS_CHILD FONT 8, "MS Sans Serif" BEGIN - CTEXT "GUI:DesyncTitle",IDC_DESYNC_HEADER,63,14,347,14,NOT WS_GROUP - LTEXT "GUI:DesyncPlayers",-1,63,44,160,12,NOT WS_GROUP - LISTBOX IDC_DESYNC_PLAYER_LIST,63,58,160,120,NOT LBS_NOTIFY | + CTEXT "GUI:DesyncTitle",IDC_DESYNC_HEADER,63,14,300,14,NOT WS_GROUP + LTEXT "GUI:DesyncPlayers",-1,63,44,150,12,NOT WS_GROUP + LISTBOX IDC_DESYNC_PLAYER_LIST,63,58,150,120,NOT LBS_NOTIFY | LBS_OWNERDRAWFIXED | LBS_HASSTRINGS | LBS_NOINTEGRALHEIGHT | LBS_NOSEL | NOT WS_BORDER - LTEXT "GUI:DesyncOutOfSync",-1,235,58,175,12,NOT WS_GROUP - LTEXT "GUI:DesyncHostInfo",-1,235,72,175,110,NOT WS_GROUP - LISTBOX IDC_DESYNC_CHAT_LIST,63,188,347,110,NOT LBS_NOTIFY | + LTEXT "GUI:DesyncOutOfSync",-1,220,58,143,12,NOT WS_GROUP + LTEXT "GUI:DesyncHostInfo",-1,220,72,143,110,NOT WS_GROUP + LISTBOX IDC_DESYNC_CHAT_LIST,63,188,300,110,NOT LBS_NOTIFY | LBS_OWNERDRAWFIXED | LBS_HASSTRINGS | LBS_NOINTEGRALHEIGHT | LBS_NOSEL | NOT WS_BORDER - EDITTEXT IDC_DESYNC_CHAT_EDIT,63,302,276,14,ES_AUTOHSCROLL | NOT WS_BORDER + EDITTEXT IDC_DESYNC_CHAT_EDIT,63,302,230,14,ES_AUTOHSCROLL | NOT WS_BORDER CONTROL "GUI:DesyncSendButton",IDC_DESYNC_CHAT_SEND,"Button",BS_OWNERDRAW | - WS_TABSTOP,344,301,66,16 + WS_TABSTOP,297,301,66,16 CONTROL "GUI:DesyncContinueButton",IDC_DESYNC_CONTINUE,"Button",BS_OWNERDRAW | - WS_TABSTOP,422,122,108,23 + WS_TABSTOP,425,122,108,23 CONTROL "GUI:DesyncQuitButton",IDC_DESYNC_QUIT,"Button",BS_OWNERDRAW | - WS_TABSTOP,422,149,108,23 + WS_TABSTOP,425,149,108,23 CONTROL "",1685,"Static",SS_LEFT | SS_CENTERIMAGE,2,355,303,12 END @@ -42,20 +42,20 @@ IDD_DESYNC_WAIT DIALOG DISCARDABLE 0, 0, 533, 369 STYLE WS_CHILD FONT 8, "MS Sans Serif" BEGIN - CTEXT "GUI:DesyncTitle",IDC_DESYNC_HEADER,63,14,347,14,NOT WS_GROUP - LTEXT "GUI:DesyncPlayers",-1,63,44,160,12,NOT WS_GROUP - LISTBOX IDC_DESYNC_PLAYER_LIST,63,58,160,120,NOT LBS_NOTIFY | + CTEXT "GUI:DesyncTitle",IDC_DESYNC_HEADER,63,14,300,14,NOT WS_GROUP + LTEXT "GUI:DesyncPlayers",-1,63,44,150,12,NOT WS_GROUP + LISTBOX IDC_DESYNC_PLAYER_LIST,63,58,150,120,NOT LBS_NOTIFY | LBS_OWNERDRAWFIXED | LBS_HASSTRINGS | LBS_NOINTEGRALHEIGHT | LBS_NOSEL | NOT WS_BORDER - LTEXT "GUI:DesyncOutOfSync",-1,235,58,175,12,NOT WS_GROUP - LTEXT "GUI:DesyncWaitInfo",-1,235,72,175,110,NOT WS_GROUP - LISTBOX IDC_DESYNC_CHAT_LIST,63,188,347,110,NOT LBS_NOTIFY | + LTEXT "GUI:DesyncOutOfSync",-1,220,58,143,12,NOT WS_GROUP + LTEXT "GUI:DesyncWaitInfo",-1,220,72,143,110,NOT WS_GROUP + LISTBOX IDC_DESYNC_CHAT_LIST,63,188,300,110,NOT LBS_NOTIFY | LBS_OWNERDRAWFIXED | LBS_HASSTRINGS | LBS_NOINTEGRALHEIGHT | LBS_NOSEL | NOT WS_BORDER - EDITTEXT IDC_DESYNC_CHAT_EDIT,63,302,276,14,ES_AUTOHSCROLL | NOT WS_BORDER + EDITTEXT IDC_DESYNC_CHAT_EDIT,63,302,230,14,ES_AUTOHSCROLL | NOT WS_BORDER CONTROL "GUI:DesyncSendButton",IDC_DESYNC_CHAT_SEND,"Button",BS_OWNERDRAW | - WS_TABSTOP,344,301,66,16 + WS_TABSTOP,297,301,66,16 CONTROL "GUI:DesyncQuitButton",IDC_DESYNC_QUIT,"Button",BS_OWNERDRAW | - WS_TABSTOP | WS_DISABLED,422,122,108,23 + WS_TABSTOP | WS_DISABLED,425,122,108,23 CONTROL "",1685,"Static",SS_LEFT | SS_CENTERIMAGE,2,355,303,12 END From 51759201f0535d57658c2bfc4242e631745efa74 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Tue, 23 Jun 2026 00:58:18 +0300 Subject: [PATCH 7/9] Remove the test force-desync hook --- Spawner.vcxproj | 1 - src/Misc/TestForceDesync.cpp | 93 ------------------------------------ 2 files changed, 94 deletions(-) delete mode 100644 src/Misc/TestForceDesync.cpp diff --git a/Spawner.vcxproj b/Spawner.vcxproj index 811494b5..55e77fff 100644 --- a/Spawner.vcxproj +++ b/Spawner.vcxproj @@ -53,7 +53,6 @@ - diff --git a/src/Misc/TestForceDesync.cpp b/src/Misc/TestForceDesync.cpp deleted file mode 100644 index 6035d8de..00000000 --- a/src/Misc/TestForceDesync.cpp +++ /dev/null @@ -1,93 +0,0 @@ -/** -* yrpp-spawner -* -* Copyright(C) 2022-present CnCNet -* -* This program is free software: you can redistribute it and/or modify -* it under the terms of the GNU General Public License as published by -* the Free Software Foundation, either version 3 of the License, or -* (at your option) any later version. -* -* This program is distributed in the hope that it will be useful, -* but WITHOUT ANY WARRANTY; without even the implied warranty of -* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.See the -* GNU General Public License for more details. -* -* You should have received a copy of the GNU General Public License -* along with this program.If not, see . -*/ - -// ============================================================================= -// TEST ONLY -- deliberate desync, for exercising the desync dialog. -// DELETE THIS FILE (and its entry in Spawner.vcxproj) when done. -// ============================================================================= -// -// Over frames 100-200, exactly one machine -- the one controlling the human -// player with the highest array index (every machine agrees on which that is, -// so it picks a single machine regardless of which slots the humans occupy) -- -// burns an extra synchronized random number each frame. That permanently -// offsets its in-match RNG, so its game-state CRC diverges from everyone -// else's and the desync dialog is triggered. -// -// Hooked in Main_Loop just after the LogicClass::AI call (0x55DCA3), so the -// extra draw happens during the frame's logic, before its CRC is computed. - -#include -#include - -#include -#include -#include -#include - -namespace -{ - // True on exactly one machine: the one whose local player is the human with - // the highest array index. Houses are synchronized, so every machine agrees - // on which house that is, and only its owner returns true. - bool Is_Designated_Desync_Machine() - { - HouseClass* const me = HouseClass::CurrentPlayer; - if (me == nullptr || !me->IsHumanPlayer) - return false; - - int highest = -1; - for (int i = 0; i < HouseClass::Array.Count; i++) { - HouseClass* const house = HouseClass::Array[i]; - if (house != nullptr && house->IsHumanPlayer && !house->Defeated && house->ArrayIndex > highest) - highest = house->ArrayIndex; - } - - return me->ArrayIndex == highest; - } -} - -DEFINE_HOOK(0x55DCA3, MainLoop_TestForceDesync, 0x5) -{ - static int last_frame = -1; - static bool announced = false; - - const int frame = Unsorted::CurrentFrame; - - // Re-arm for each new game (the frame counter restarts from zero). - if (frame < last_frame) - announced = false; - last_frame = frame; - - if (!SessionClass::IsMultiplayer() || frame < 100 || frame > 200) - return 0; - - if (!Is_Designated_Desync_Machine()) - return 0; - - if (!announced) { - announced = true; - Debug::Log("TEST: desyncing this machine (local house %d) over frames 100-200.\n", - HouseClass::CurrentPlayer->ArrayIndex); - } - - // Burn an extra synced random number each frame so this machine's state - // diverges from everyone else's; the offset persists and is detected. - ScenarioClass::Instance->Random.Random(); - return 0; -} From 3b30482217123662730b66ddc0bead75c7b0fe74 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Tue, 23 Jun 2026 13:15:54 +0300 Subject: [PATCH 8/9] Remove unused SessionExt members --- src/Ext/Session/Body.cpp | 20 -------------------- src/Ext/Session/Body.h | 16 ++-------------- src/UI/DesyncDialog.cpp | 3 --- 3 files changed, 2 insertions(+), 37 deletions(-) diff --git a/src/Ext/Session/Body.cpp b/src/Ext/Session/Body.cpp index 4296da3d..741e4c48 100644 --- a/src/Ext/Session/Body.cpp +++ b/src/Ext/Session/Body.cpp @@ -19,12 +19,10 @@ #include "Body.h" -#include #include #include #include -#include #include #include @@ -33,8 +31,6 @@ #include bool SessionExt::IsOutOfSync[SessionExt::MaxPlayers] = {}; -int SessionExt::OutOfSyncFrame = -1; -bool SessionExt::IsChatToAllies = false; bool SessionExt::Is_Out_of_Sync(int house_id) { @@ -50,22 +46,6 @@ void SessionExt::Mark_Player_As_Out_of_Sync(int house_id) return; IsOutOfSync[house_id] = true; - - if (OutOfSyncFrame < 0) - OutOfSyncFrame = Unsorted::CurrentFrame; -} - -void SessionExt::Clear_Out_Of_Sync_Data() -{ - for (bool& flag : IsOutOfSync) - flag = false; - - OutOfSyncFrame = -1; -} - -bool SessionExt::Is_Spawner_Session() -{ - return Spawner::Enabled; } void SessionExt::Set_Master(int house_id) diff --git a/src/Ext/Session/Body.h b/src/Ext/Session/Body.h index a9842439..e50d0b8f 100644 --- a/src/Ext/Session/Body.h +++ b/src/Ext/Session/Body.h @@ -33,24 +33,12 @@ namespace SessionExt constexpr int MaxPlayers = 8; // --- Per-player out-of-sync tracking (the engine only has one global flag). - // Populated by the desync-detection hook (to be wired in separately) and - // read by the dialog to colour each player's status. + // Set by the desync-detection hook and read by the dialog to colour each + // player's status. extern bool IsOutOfSync[MaxPlayers]; - // Frame the game first went out of sync, or -1. - extern int OutOfSyncFrame; - - // Scope flag for an outgoing chat message (true = allies only). Kept for - // parity with Vinifera; the desync dialog broadcasts to everyone. - extern bool IsChatToAllies; - bool Is_Out_of_Sync(int house_id); void Mark_Player_As_Out_of_Sync(int house_id); - void Clear_Out_Of_Sync_Data(); - - // True when running under the spawner (the analog of Vinifera's - // SessionClassExtension::IsSpawnerSession). - bool Is_Spawner_Session(); // Assigns the game master/host, writing the engine's native MasterPlayerID // and MasterPlayerName so SessionClass::Am_I_Master() agrees. diff --git a/src/UI/DesyncDialog.cpp b/src/UI/DesyncDialog.cpp index 67d2a36a..1510f8fa 100644 --- a/src/UI/DesyncDialog.cpp +++ b/src/UI/DesyncDialog.cpp @@ -558,8 +558,6 @@ void DesyncDialogClass::Send_Chat() SetFocus(edit); - SessionExt::IsChatToAllies = false; // broadcast to everyone, not just allies - ExtGlobalPacketType packet {}; packet.Command = EXT_NET_DESYNC_CHAT; Local_Player_Name(packet.Name, sizeof(packet.Name)); @@ -789,7 +787,6 @@ bool DesyncDialogClass::Check_And_Handle_Desync() } } SessionExt::Update_Master_After_Player_Removal(); - SessionExt::OutOfSyncFrame = -1; return false; case DESYNC_OUTCOME_QUIT: From d6be42a17895a84fdefa69ba07d97c6cc60120d0 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Tue, 23 Jun 2026 15:22:05 +0300 Subject: [PATCH 9/9] Restore CRC log printing --- YRpp | 2 +- src/UI/DesyncDialog.cpp | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/YRpp b/YRpp index 5f0541da..3ba94954 160000 --- a/YRpp +++ b/YRpp @@ -1 +1 @@ -Subproject commit 5f0541da5fb12e8cd4c54cfc246cff059d2b0138 +Subproject commit 3ba949540aa3b0889bf4f815e85b14ed1fecb562 diff --git a/src/UI/DesyncDialog.cpp b/src/UI/DesyncDialog.cpp index 1510f8fa..640976f5 100644 --- a/src/UI/DesyncDialog.cpp +++ b/src/UI/DesyncDialog.cpp @@ -742,6 +742,7 @@ bool DesyncDialogClass::Check_And_Handle_Desync() * mark every player that diverged. */ bool newly_desynced = false; + EventClass* offending_event = nullptr; const unsigned int current_frame = static_cast(Unsorted::CurrentFrame); for (int i = 0; i < EventClass::DoList.Count; i++) { @@ -761,6 +762,9 @@ bool DesyncDialogClass::Check_And_Handle_Desync() if (EventClass::LatestFramesCRC[index] != event.FrameInfo.CRC) { SessionExt::Mark_Player_As_Out_of_Sync(id); newly_desynced = true; + if (offending_event == nullptr) { + offending_event = &event; + } } } @@ -770,6 +774,22 @@ bool DesyncDialogClass::Check_And_Handle_Desync() Debug::Log("DesyncDialog: desync detected on frame %u.\n", current_frame); + /** + * Write the engine's sync-debug dump (SYNC*.TXT) for the offending event, + * exactly as the stock out-of-sync handler does (Execute_DoList @0x64CC68). + * Our entry hook intercepts before that handler runs, so reproduce it here + * or desyncs become impossible to diagnose. + */ + if (offending_event != nullptr) { + if (Game::EnableMPSyncDebug) { + for (int slot = 0; slot < 256; slot++) { + EventClass::Print_CRCs_All_Players(slot, offending_event); + } + } else { + EventClass::Print_CRCs_Current_Player(offending_event); + } + } + const DesyncDialogOutcomeType outcome = Run(); switch (outcome) {