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)** diff --git a/Spawner.vcxproj b/Spawner.vcxproj index a6dde7a4..55e77fff 100644 --- a/Spawner.vcxproj +++ b/Spawner.vcxproj @@ -19,6 +19,8 @@ + + @@ -27,6 +29,7 @@ + @@ -68,6 +71,7 @@ + @@ -78,12 +82,16 @@ + + + + diff --git a/YRpp b/YRpp index 2ff81ad4..3ba94954 160000 --- a/YRpp +++ b/YRpp @@ -1 +1 @@ -Subproject commit 2ff81ad47e1c301c8c653de059ab617d185e4009 +Subproject commit 3ba949540aa3b0889bf4f815e85b14ed1fecb562 diff --git a/src/Ext/Session/Body.cpp b/src/Ext/Session/Body.cpp new file mode 100644 index 00000000..741e4c48 --- /dev/null +++ b/src/Ext/Session/Body.cpp @@ -0,0 +1,181 @@ +/** +* 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 + +bool SessionExt::IsOutOfSync[SessionExt::MaxPlayers] = {}; + +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; +} + +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..e50d0b8f --- /dev/null +++ b/src/Ext/Session/Body.h @@ -0,0 +1,56 @@ +/** +* 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). + // Set by the desync-detection hook and read by the dialog to colour each + // player's status. + extern bool IsOutOfSync[MaxPlayers]; + + bool Is_Out_of_Sync(int house_id); + void Mark_Player_As_Out_of_Sync(int house_id); + + // 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/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..57b642a8 --- /dev/null +++ b/src/UI/DesyncDialog.Hook.cpp @@ -0,0 +1,229 @@ +/** +* 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, layout and +// owner-draw, mainly around Network_Call_Back (0x48D1E0) and Execute_DoList. + +#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_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.) + * + * 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). 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); + * 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..83f3538e --- /dev/null +++ b/src/UI/DesyncDialog.Resource.h @@ -0,0 +1,39 @@ +/** +* 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: 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_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 new file mode 100644 index 00000000..640976f5 --- /dev/null +++ b/src/UI/DesyncDialog.cpp @@ -0,0 +1,1035 @@ +/** +* 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 . +* +* 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" +#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 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; + + // 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 + * 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; + 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 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(); + Update_Chat_Placeholder(); + + if (Decision == IDC_DESYNC_QUIT) { + outcome = DESYNC_OUTCOME_QUIT; + break; + } + + if (ContinueReceived || Decision == IDC_DESYNC_CONTINUE) { + + if (Decision == IDC_DESYNC_CONTINUE) { + Send_Continue(); + } + outcome = DESYNC_OUTCOME_CONTINUE; + break; + } + + 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) { + 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); + SetForegroundWindow(Window); + + /** + * 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. + */ + 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"); +} + + +/** + * 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 (!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 = StringTable::TryFetchString("GUI:DesyncStatusQuit", L"Quit"); + color = RGB(200, 0, 0); + } else if (SessionExt::Is_Out_of_Sync(i)) { + status = StringTable::TryFetchString("GUI:DesyncStatusDesynced", L"Desynced"); + color = RGB(200, 200, 0); + } else { + status = StringTable::TryFetchString("GUI:DesyncStatusOK", 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. + * + * 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() +{ + if (!Is_Active()) { + return; + } + + HWND edit = GetDlgItem(Window, IDC_DESYNC_CHAT_EDIT); + if (edit == nullptr) { + 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 + * 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"")); + + /** + * 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); + + 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 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::Update_Chat_Placeholder() +{ + if (!Is_Active()) { + return; + } + + HWND edit = GetDlgItem(Window, IDC_DESYNC_CHAT_EDIT); + if (edit == nullptr) { + return; + } + + 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); + } + } +} + + +/** + * 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). 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() +{ + 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; + EventClass* offending_event = nullptr; + 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 (offending_event == nullptr) { + offending_event = &event; + } + } + } + + if (!newly_desynced) { + return false; + } + + 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) { + + 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(); + 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; + } +} + + +/** + * 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; + } +} + + +/** + * 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_CHAT_SEND: + DesyncDialog.Send_Chat(); + return TRUE; + + 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..0102d4dd --- /dev/null +++ b/src/UI/DesyncDialog.h @@ -0,0 +1,150 @@ +/** +* 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. +*/ + +#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_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 + * 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 Update_Chat_Placeholder(); + void Send_Heartbeat(); + void Send_Continue(); + void Send_Sign_Off(); + void Check_Heartbeat_Timeouts(); + + 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; + + /** + * 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..fb385cb1 --- /dev/null +++ b/src/UI/DesyncDialog.rc @@ -0,0 +1,61 @@ +#include + +#include "DesyncDialog.Resource.h" + +// 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 +// 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 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,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,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,230,14,ES_AUTOHSCROLL | NOT WS_BORDER + CONTROL "GUI:DesyncSendButton",IDC_DESYNC_CHAT_SEND,"Button",BS_OWNERDRAW | + WS_TABSTOP,297,301,66,16 + CONTROL "GUI:DesyncContinueButton",IDC_DESYNC_CONTINUE,"Button",BS_OWNERDRAW | + WS_TABSTOP,425,122,108,23 + CONTROL "GUI:DesyncQuitButton",IDC_DESYNC_QUIT,"Button",BS_OWNERDRAW | + WS_TABSTOP,425,149,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,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,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,230,14,ES_AUTOHSCROLL | NOT WS_BORDER + CONTROL "GUI:DesyncSendButton",IDC_DESYNC_CHAT_SEND,"Button",BS_OWNERDRAW | + WS_TABSTOP,297,301,66,16 + CONTROL "GUI:DesyncQuitButton",IDC_DESYNC_QUIT,"Button",BS_OWNERDRAW | + WS_TABSTOP | WS_DISABLED,425,122,108,23 + CONTROL "",1685,"Static",SS_LEFT | SS_CENTERIMAGE,2,355,303,12 +END