202 lines
13 KiB
C++
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
|