DS2Revival/mods/multiplayer/version_check.cpp

202 lines
13 KiB
C++

// A joiner whose game differs from the host's gets a message instead of a hang (roadmap R8; docs/multiplayer/mod-compatibility.md).
//
// What the game does without this. The host answers the joiner's DirectPlay enumeration with its NetPipeSession::Info (0x43
// bytes: a version block with the product "0010", the PE header checksum, the FuBi sync digest - a CRC of the exe FILE - and
// flags; then the content spec). The joiner's enumeration handler (sub_8d1ccd, DPN_MSGID_ENUM_HOSTS_RESPONSE) stores the answer
// as a session record: the Info at record+0x30 and the result of the version-block compare at record+0xac (the whole Info at
// +0xad). NetPipe::ConnectTo (sub_8cdadb) returns 1 at once when +0xac is false, and its two callers (sub_4eb402 "the host has
// started the game" and sub_4ead72 the Join Game button) ignore the 1: the joiner never connects, says nothing and stays in
// the staging area while the host plays alone. A different DungeonSiege2.exe file (an embedded manifest, a patched, cracked or
// re-packed exe, another build) is the usual cause: R7 found it, R6's test tool made it.
//
// What we do. Both ConnectTo call sites call our function. When the original would refuse for that reason (a session record
// whose +0xac is false, no connection open) we compare the host's Info with our own (sub_8cb359 builds it), log both
// digests, name what differs and show the game's own one-button message box (the helpers the game's own join-failure messages
// use, sub_752850 / sub_7979a0). Its OK button raises the staging area's "leave" event ("mp_dialog_leave_staging_area_yes"), the
// handler behind Leave > Yes (sub_4e23d7 -> sub_4ebd6d / sub_4ea022), which disconnects and requests the lobby state of the
// provider (34 Internet, 35 LAN, 36 GameSpy): the joiner is back in the game list. Everything else (a free session, the
// content mismatch the host refuses with code 0xb) stays the game's.
// Steam v2.3 addresses only; every call site is checked byte for byte first.
#include "multiplayer.h"
#include <cstdio>
#include <cstring>
#include <string>
namespace {
constexpr DWORD kConnectTo = 0x8cdadb; // int __thiscall (NetPipe*, SessionRecord** , const void*), ret 8
constexpr DWORD kCallSites[2] = {0x4eae14, 0x4eb474}; // Join Game button (sub_4ead72), host started (sub_4eb402)
constexpr DWORD kBuildInfo = 0x8cb359; // Info* __fastcall (Info*): the local Info, 0x43 bytes
constexpr DWORD kUiStagingPtr = 0xbcbbb8; // the staging area UI object; +0x28 = its message-box manager
constexpr DWORD kStrCtor = 0x404829, kStrDtor = 0x4016c5, kWStrDtor = 0x4016fd; // gpbstring<char> / wide
constexpr DWORD kLocalize = 0x62cb34; // (dst, "$MSG$text")
constexpr DWORD kButtons = 0x752850; // button list ctor: this, (type 1 = OK, &event, &empty)
constexpr DWORD kShowBox = 0x7979a0; // message box: this = manager, (&buttons, &text, 0)
constexpr DWORD kVecDtor = 0x40f53a; // (array, element size, count, element dtor)
constexpr DWORD kEmptyString = 0xbbea78;
constexpr char kLeaveEvent[] = "mp_dialog_leave_staging_area_yes";
// Info layout (offsets from the start of the 0x43 bytes), from sub_8cb359 / sub_8cb0bb / sub_8c9fb6 / sub_8ca12e.
constexpr int kOffProduct = 9; // "0010" (the version block starts at +4; the compare also reads +0xd and the flag byte)
constexpr int kOffProtocol = 0xd; // 2
constexpr int kOffExeCrc = 0x16; // PE header checksum of the running exe (sub_5eb787)
constexpr int kOffFubi = 0x1a; // FuBi sync spec: 4 dwords {0x10, 0x10000, digest over the exe FILE and exports, module count}
constexpr int kOffFlags = 0x2a; // bits 0 and 1 of NetPipe+0x17a / +0x17b
constexpr int kOffContent = 0x2b; // content sync spec: 4 dwords (gas checksum, template skrits, formulas, PContentDb)
constexpr int kRecInfo = 0x30; // Info inside the session record
constexpr int kRecVersionOk = 0xac;
using ConnectTo_t = int(__fastcall*)(void* pipe, void* edx, void* session, void* extra);
using StrCtor_t = void*(__fastcall*)(void* self, void* edx, const char* s);
using StrDtor_t = void(__fastcall*)(void* self);
using Localize_t = void*(__cdecl*)(void* dst, const char* key);
using Buttons_t = void*(__fastcall*)(void* self, void* edx, int type, void* event, void* empty);
using ShowBox_t = void(__fastcall*)(void* mgr, void* edx, void* buttons, void* text, void* scratch);
using VecDtor_t = void(__stdcall*)(void* array, int size, int count, void* dtor);
using BuildInfo_t = void*(__fastcall*)(void* info);
DWORD U32(const BYTE* p) { DWORD v; std::memcpy(&v, p, 4); return v; }
std::string Hex4(const BYTE* p) {
char b[48];
sprintf_s(b, "%08X %08X %08X %08X", U32(p), U32(p + 4), U32(p + 8), U32(p + 12));
return b;
}
std::string Product(const BYTE* info) {
char b[5] = {};
std::memcpy(b, info + kOffProduct, 4);
for (char& c : b) if (c && (c < 0x20 || c > 0x7e)) c = '?';
return b;
}
void ShowOk(const std::string& text) {
void* ui = *reinterpret_cast<void**>(kUiStagingPtr);
void* manager = ui ? *reinterpret_cast<void**>(static_cast<BYTE*>(ui) + 0x28) : nullptr;
if (!manager) {
ds2::log().Write("join check: no message-box manager (staging area not up) - no message shown");
return;
}
std::string key = "$MSG$" + text;
char* wtext[2] = {nullptr, nullptr};
char* event = nullptr;
alignas(8) BYTE buttons[0x30] = {};
reinterpret_cast<Localize_t>(kLocalize)(wtext, key.c_str());
reinterpret_cast<StrCtor_t>(kStrCtor)(&event, nullptr, kLeaveEvent);
reinterpret_cast<Buttons_t>(kButtons)(buttons, nullptr, 1, &event, reinterpret_cast<void*>(kEmptyString));
reinterpret_cast<StrDtor_t>(kStrDtor)(&event);
reinterpret_cast<ShowBox_t>(kShowBox)(manager, nullptr, buttons, wtext, nullptr);
reinterpret_cast<VecDtor_t>(kVecDtor)(buttons + 4, 4, 5, reinterpret_cast<void*>(kStrDtor));
reinterpret_cast<StrDtor_t>(kWStrDtor)(wtext);
}
DWORD g_lastShown; // GetTickCount of the last message: the two call sites can both fire for one START
// True = the join was refused here (a message is shown and ConnectTo must report 1).
bool Explain(const BYTE* host) {
alignas(8) BYTE local[0x60] = {};
reinterpret_cast<BuildInfo_t>(kBuildInfo)(local);
bool exeCrc = U32(local + kOffExeCrc) != U32(host + kOffExeCrc);
bool fubi = std::memcmp(local + kOffFubi, host + kOffFubi, 16) != 0;
bool product = std::memcmp(local + kOffProduct, host + kOffProduct, 4) != 0 || local[kOffProtocol] != host[kOffProtocol];
bool flags = ((local[kOffFlags] ^ host[kOffFlags]) & 3) != 0;
bool content = std::memcmp(local + kOffContent, host + kOffContent, 16) != 0;
ds2::log().Write("join check: the host's game does not match ours - the game would refuse silently (ConnectTo returns 1)");
ds2::log().Write(" product ours '%s' host '%s' protocol %u / %u flags %02X / %02X", Product(local).c_str(),
Product(host).c_str(), local[kOffProtocol], host[kOffProtocol], local[kOffFlags], host[kOffFlags]);
ds2::log().Write(" exe crc ours %08X host %08X (PE header checksum)", U32(local + kOffExeCrc), U32(host + kOffExeCrc));
ds2::log().Write(" fubi sync ours %s", Hex4(local + kOffFubi).c_str());
ds2::log().Write(" host %s (dword 2 = digest over the exe file)", Hex4(host + kOffFubi).c_str());
ds2::log().Write(" content ours %s", Hex4(local + kOffContent).c_str());
ds2::log().Write(" host %s", Hex4(host + kOffContent).c_str());
ds2::log().Write(" differs: exe crc %d, fubi/exe file %d, product/protocol %d, flags %d, content %d", exeCrc, fubi, product,
flags, content);
DWORD now = GetTickCount();
if (g_lastShown && now - g_lastShown < 5000) return true;
g_lastShown = now ? now : 1;
if (U32(host) == 0) { // the host's answer carried no version information at all
ShowOk("This game cannot be joined: the host did not send any version information, so it is not a compatible "
"Dungeon Siege II game.\n\nOK takes you back to the game list.");
return true;
}
std::string t = "This game was hosted with a different version of Dungeon Siege II.\n\n";
if (fubi || exeCrc)
t += "Your DungeonSiege2.exe is not the same file as the host's (another build, or a patched, cracked or re-packed exe). "
"The game checks every byte of it.\n";
if (product)
t += "The game versions differ (yours " + Product(local) + ", the host's " + Product(host) + ").\n";
if (flags && !fubi && !exeCrc && !product) t += "The two games were built differently.\n";
if (content) t += "The game data (mods or edited .gas files) differ as well.\n";
if (!fubi && !exeCrc && !product && !flags && !content) t += "The two games' version information differs.\n";
t += "\nHost and joiner need the same game files. OK takes you back to the game list.";
ShowOk(t);
return true;
}
int __fastcall HookConnectTo(void* pipe, void* edx, void* session, void* extra) {
const BYTE* record = session ? *reinterpret_cast<BYTE* const*>(static_cast<const BYTE*>(session) + 4) : nullptr;
if (record && *reinterpret_cast<DWORD*>(static_cast<BYTE*>(pipe) + 0x94) == 0 && record[kRecVersionOk] == 0 &&
Explain(record + kRecInfo))
return 1;
return reinterpret_cast<ConnectTo_t>(kConnectTo)(pipe, edx, session, extra);
}
// The game list's own refusal (sub_4d9334: the host's published "cid" differs from ours) shows the message box
// "gamelist_server_content_mismatch_error" whose text says that the GAME YOU ARE TRYING TO JOIN has been modified, also when
// it is the joiner who runs a mod. The box is built by sub_4e566b from the table entry sub_4e559b returns (value: +4 the text,
// +8 the title, both wide strings, passed by address to the box); we answer that one key with an entry that has our text.
// The list knows only the sum "cid" (exe checksum + gas checksum + formulas + PContentDb), so it cannot say which part
// differs; the text names the possibilities and what to do.
constexpr DWORD kMessageLookup = 0x4e559b, kMessageLookupSite = 0x4e5678;
constexpr char kContentKey[] = "gamelist_server_content_mismatch_error";
using Lookup_t = BYTE*(__fastcall*)(void* ctx, void* edx, void* key);
DWORD g_contentEntry[8]; // a copy of the game's entry with another text (never freed: the strings are shared)
BYTE* __fastcall HookMessageLookup(void* ctx, void* edx, void* key) {
BYTE* entry = reinterpret_cast<Lookup_t>(kMessageLookup)(ctx, edx, key);
const char* name = key ? *static_cast<const char* const*>(key) : nullptr;
if (!entry || !name || std::strcmp(name, kContentKey) != 0) return entry;
if (!g_contentEntry[1]) {
std::memcpy(g_contentEntry, entry, 12);
char* text[2] = {nullptr, nullptr};
reinterpret_cast<Localize_t>(kLocalize)(
text,
"$MSG$You cannot join this game: your game and the host's game do not have the same game data."
"\n\n"
"One of you plays with a mod or edited game files (.gas), or with another build of Dungeon Siege II. "
"Host and joiner need the same game files and the same mods."
"\n\n"
"Remove your mods or install the host's, then try again.");
g_contentEntry[1] = reinterpret_cast<DWORD>(text[0]); // the reference stays with us
}
ds2::log().Write("join check: the game list refuses the host's game (content id differs) - our text instead of the game's");
return reinterpret_cast<BYTE*>(g_contentEntry);
}
// The game's own message for a connect the host refuses because the content differs (code 0xb: "This game is using a mod that you
// currently do not have installed ...", sub_4e3811) has an OK button with the event "failed_to_join_ok", which does nothing: after
// it the joiner stays in the staging area of a game it cannot join. Only for that code we give the OK the leave event too.
constexpr DWORD kModRefusedEventOperand = 0x4e390d; // the operand of the push of "failed_to_join_ok" (0xaa3c74) in the 0xb branch
constexpr char kLeaveEventConst[] = "mp_dialog_leave_staging_area_yes";
} // namespace
namespace mp {
void InstallJoinVersionCheck(const ds2::Ini& ini, ds2::Exe exe) {
if (!ini.Bool("Multiplayer", "JoinVersionMessage", true)) return;
if (exe != ds2::Exe::Steam23) { // the Broken World exe has the same code at other addresses (not located yet)
ds2::log().Write("SKIP join version message: Steam v2.3 addresses only");
return;
}
for (DWORD site : kCallSites)
ds2::RedirectCall(site, kConnectTo, reinterpret_cast<DWORD>(&HookConnectTo), "join: explain a refused connect (version/exe mismatch)");
const BYTE okEvent[4] = {0x74, 0x3c, 0xaa, 0x00};
DWORD leave = reinterpret_cast<DWORD>(kLeaveEventConst);
ds2::PatchBytes(kModRefusedEventOperand, okEvent, &leave, 4, "join: OK of the host's 'mod missing' refusal leaves the staging area");
ds2::RedirectCall(kMessageLookupSite, kMessageLookup, reinterpret_cast<DWORD>(&HookMessageLookup),
"join: content-mismatch box names both possibilities");
}
} // namespace mp