364 lines
17 KiB
C++
364 lines
17 KiB
C++
// The news banner's text and button (docs/multiplayer/news.md, "Option 2").
|
|
//
|
|
// The game's "New Patch is available" banner (shown once the version check says newver=1, news.cpp) becomes a news
|
|
// banner: its text is the news headline and its button opens a native dialog with the full text, with a Yes that opens
|
|
// the news page in the browser. The game's own patch download (and its Authenticode check) never runs: the two handlers
|
|
// that start it are replaced by ours.
|
|
// - text: the plugin fetches /motd/news.txt from the news server on its own thread at start (WinHTTP loaded
|
|
// dynamically) and keeps headline, text, url and date; the banner's text operand points at a buffer.
|
|
// - button: UIGamespy's event "open_patch_install_dialog" (sub_4d310b) -> our dialog via the game's own dialog helper
|
|
// (sub_4d0303, event "install_patch" on Yes); "install_patch" (sub_4d323f) -> open the URL, hide the banner.
|
|
// - seen: the id of the opened item is written to HKCU\Software\DS2Revival\NewsSeen (private test instances: a file in
|
|
// DS2DEV_DOCS, and no browser is ever started there), so the server stops offering it.
|
|
// Steam v2.3 addresses (docs/multiplayer/news.md); every patch checks the original bytes first.
|
|
#include "multiplayer.h"
|
|
#include <shellapi.h>
|
|
#include <winhttp.h>
|
|
#include <cstdio>
|
|
#include <cstring>
|
|
#include <mutex>
|
|
#include <string>
|
|
|
|
namespace {
|
|
|
|
// ---- game addresses ---------------------------------------------------------------------------------------------
|
|
// Steam v2.3 (docs/multiplayer/news.md) and the Broken World exe (the same UIGamespy code at other addresses, found from
|
|
// the strings "patch_available" / "open_patch_install_dialog" / "install_patch" and the event table that binds them).
|
|
using StrCtor_t = void*(__fastcall*)(void* self, void* edx, const char* s); // gpbstring<char>(const char*)
|
|
using StrDtor_t = void(__fastcall*)(void* self); // ~gpbstring<char>
|
|
using WStrDtor_t = void(__fastcall*)(void* self); // ~ of the localized string type
|
|
using Localize_t = void*(__cdecl*)(void* dst, const char* key); // "$MSG$..." lookup
|
|
using Dialog_t = void(__fastcall*)(void* self, void* edx, void* text, void* eventName, void* noEvent); // yes/no dialog
|
|
using HideBanner_t = void(__fastcall*)(void* self); // hides patch_available & co
|
|
|
|
struct GameSites {
|
|
DWORD bannerOperand, bannerOrig; // push imm32 in the banner function: "$MSG$New Patch is available..."
|
|
DWORD openOperand, openOrig; // mov [eax+8], imm32 in the event setup: handler of open_patch_install_dialog
|
|
DWORD installOperand, installOrig; // the same for install_patch
|
|
DWORD emptyString; // the game's shared empty string variable
|
|
DWORD strCtor, strDtor, wstrDtor, localize, dialog, hideBanner;
|
|
};
|
|
constexpr GameSites kSteam = {0x4D04E3, 0x00AA0DC0, 0x4D41DB, 0x004D310B, 0x4D4273, 0x004D323F, 0x00BBEA78,
|
|
0x404829, 0x4016C5, 0x4016FD, 0x62CB34, 0x4D0303, 0x4D051C};
|
|
constexpr GameSites kBrokenWorld = {0x4DD002, 0x00BA24B8, 0x4E0E97, 0x004DFD73, 0x4E0F2F, 0x004DFEA7, 0x00CE6000,
|
|
0x401A15, 0x401435, 0x40146D, 0x6064D9, 0x4DCE22, 0x4DD03B};
|
|
StrCtor_t kStrCtor;
|
|
StrDtor_t kStrDtor;
|
|
WStrDtor_t kWStrDtor;
|
|
Localize_t kLocalize;
|
|
Dialog_t kDialog;
|
|
HideBanner_t kHideBanner;
|
|
DWORD kEmptyString;
|
|
|
|
// ---- the news item --------------------------------------------------------------------------------------------
|
|
struct Item {
|
|
bool valid = false;
|
|
char id[48] = {};
|
|
char headline[128] = {};
|
|
char url[400] = {};
|
|
char date[16] = {};
|
|
char text[1700] = {};
|
|
};
|
|
std::mutex g_lock;
|
|
Item g_item;
|
|
mp::NewsTarget g_target;
|
|
char g_banner[512] = "$MSG$DS2 Revival news"; // what the banner's text operand points at (sub_62cbc5 formats it: no bare %)
|
|
|
|
bool Harness() { return GetEnvironmentVariableA("DS2DEV_DOCS", nullptr, 0) != 0; } // a private test instance
|
|
|
|
void Log(const char* what, const char* detail) { ds2::log().Write("news: %s%s%s", what, detail[0] ? ": " : "", detail); }
|
|
|
|
std::string BaseUrl() {
|
|
char b[320];
|
|
bool def = g_target.port == (g_target.secure ? 443 : 80);
|
|
char host[256];
|
|
WideCharToMultiByte(CP_ACP, 0, g_target.host, -1, host, sizeof(host), nullptr, nullptr);
|
|
if (def) sprintf_s(b, "%s://%s", g_target.secure ? "https" : "http", host);
|
|
else sprintf_s(b, "%s://%s:%u", g_target.secure ? "https" : "http", host, g_target.port);
|
|
return b;
|
|
}
|
|
|
|
bool SafeId(const char* s) {
|
|
if (!*s || strlen(s) > 40) return false;
|
|
for (; *s; ++s)
|
|
if (!((*s >= '0' && *s <= '9') || (*s >= 'a' && *s <= 'z') || *s == '-')) return false;
|
|
return true;
|
|
}
|
|
|
|
// The URL the Yes opens: https, or http to the news server itself (a test server); nothing else, ever.
|
|
bool UrlAllowed(const char* url) {
|
|
if (strlen(url) > 380) return false;
|
|
for (const char* c = url; *c; ++c)
|
|
if (*c <= ' ' || *c == '"' || *c == '\'' || *c == '<' || *c == '>' || *c == '\\' || *c == '^' || *c == '|' || *c == '`') return false;
|
|
if (_strnicmp(url, "https://", 8) == 0) return true;
|
|
std::string base = BaseUrl();
|
|
return _strnicmp(url, "http://", 7) == 0 && _strnicmp(url, base.c_str(), base.size()) == 0 && base.compare(0, 7, "http://") == 0;
|
|
}
|
|
|
|
// ---- NewsSeen -------------------------------------------------------------------------------------------------
|
|
void SeenPath(char* out, size_t cap) {
|
|
char docs[MAX_PATH] = {};
|
|
GetEnvironmentVariableA("DS2DEV_DOCS", docs, sizeof(docs));
|
|
sprintf_s(out, cap, "%s\\newsseen.txt", docs);
|
|
}
|
|
|
|
void WriteSeen(const char* id) {
|
|
if (!SafeId(id)) return;
|
|
if (Harness()) { // never the real user's registry from a test instance
|
|
char p[MAX_PATH];
|
|
SeenPath(p, sizeof(p));
|
|
FILE* f = nullptr;
|
|
if (fopen_s(&f, p, "wb") == 0 && f) { fputs(id, f); fclose(f); }
|
|
return;
|
|
}
|
|
HKEY k;
|
|
if (RegCreateKeyExA(HKEY_CURRENT_USER, R"(Software\DS2Revival)", 0, nullptr, 0, KEY_SET_VALUE, nullptr, &k, nullptr) != ERROR_SUCCESS) return;
|
|
RegSetValueExA(k, "NewsSeen", 0, REG_SZ, reinterpret_cast<const BYTE*>(id), static_cast<DWORD>(strlen(id) + 1));
|
|
RegCloseKey(k);
|
|
}
|
|
|
|
} // namespace
|
|
|
|
namespace mp {
|
|
|
|
void ReadSeen(char* out, size_t cap) {
|
|
out[0] = 0;
|
|
if (Harness()) {
|
|
char p[MAX_PATH];
|
|
SeenPath(p, sizeof(p));
|
|
FILE* f = nullptr;
|
|
if (fopen_s(&f, p, "rb") == 0 && f) {
|
|
size_t n = fread(out, 1, cap - 1, f);
|
|
out[n] = 0;
|
|
fclose(f);
|
|
}
|
|
} else {
|
|
HKEY k;
|
|
if (RegOpenKeyExA(HKEY_CURRENT_USER, R"(Software\DS2Revival)", 0, KEY_QUERY_VALUE, &k) != ERROR_SUCCESS) return;
|
|
DWORD type = 0, size = static_cast<DWORD>(cap - 1);
|
|
if (RegQueryValueExA(k, "NewsSeen", nullptr, &type, reinterpret_cast<BYTE*>(out), &size) != ERROR_SUCCESS || type != REG_SZ) out[0] = 0;
|
|
else out[size < cap - 1 ? size : cap - 1] = 0;
|
|
RegCloseKey(k);
|
|
}
|
|
for (size_t n = strlen(out); n && (out[n - 1] == '\r' || out[n - 1] == '\n' || out[n - 1] == ' '); out[--n] = 0) {}
|
|
if (!SafeId(out)) out[0] = 0;
|
|
}
|
|
|
|
} // namespace mp
|
|
|
|
namespace {
|
|
|
|
// ---- fetch (own thread, WinHTTP loaded dynamically) -----------------------------------------------------------
|
|
using Open_t = HINTERNET(WINAPI*)(LPCWSTR, DWORD, LPCWSTR, LPCWSTR, DWORD);
|
|
using Connect_t = HINTERNET(WINAPI*)(HINTERNET, LPCWSTR, INTERNET_PORT, DWORD);
|
|
using OpenReq_t = HINTERNET(WINAPI*)(HINTERNET, LPCWSTR, LPCWSTR, LPCWSTR, LPCWSTR, LPCWSTR*, DWORD);
|
|
using Send_t = BOOL(WINAPI*)(HINTERNET, LPCWSTR, DWORD, LPVOID, DWORD, DWORD, DWORD_PTR);
|
|
using Recv_t = BOOL(WINAPI*)(HINTERNET, LPVOID);
|
|
using Query_t = BOOL(WINAPI*)(HINTERNET, DWORD, LPCWSTR, LPVOID, LPDWORD, LPDWORD);
|
|
using Read_t = BOOL(WINAPI*)(HINTERNET, LPVOID, DWORD, LPDWORD);
|
|
using Close_t = BOOL(WINAPI*)(HINTERNET);
|
|
|
|
bool HttpGet(const wchar_t* path, std::string& out) {
|
|
HMODULE m = LoadLibraryA("winhttp.dll");
|
|
if (!m) return false;
|
|
auto open = reinterpret_cast<Open_t>(GetProcAddress(m, "WinHttpOpen"));
|
|
auto connect = reinterpret_cast<Connect_t>(GetProcAddress(m, "WinHttpConnect"));
|
|
auto openReq = reinterpret_cast<OpenReq_t>(GetProcAddress(m, "WinHttpOpenRequest"));
|
|
auto send = reinterpret_cast<Send_t>(GetProcAddress(m, "WinHttpSendRequest"));
|
|
auto recv = reinterpret_cast<Recv_t>(GetProcAddress(m, "WinHttpReceiveResponse"));
|
|
auto query = reinterpret_cast<Query_t>(GetProcAddress(m, "WinHttpQueryHeaders"));
|
|
auto read = reinterpret_cast<Read_t>(GetProcAddress(m, "WinHttpReadData"));
|
|
auto close = reinterpret_cast<Close_t>(GetProcAddress(m, "WinHttpCloseHandle"));
|
|
if (!open || !connect || !openReq || !send || !recv || !query || !read || !close) return false;
|
|
bool ok = false;
|
|
HINTERNET s = open(L"DS2Revival-news/1.0", WINHTTP_ACCESS_TYPE_DEFAULT_PROXY, nullptr, nullptr, 0);
|
|
HINTERNET c = s ? connect(s, g_target.host, g_target.port, 0) : nullptr;
|
|
HINTERNET r = c ? openReq(c, L"GET", path, nullptr, nullptr, nullptr, g_target.secure ? WINHTTP_FLAG_SECURE : 0) : nullptr;
|
|
if (r && send(r, nullptr, 0, nullptr, 0, 0, 0) && recv(r, nullptr)) {
|
|
DWORD status = 0, sz = sizeof(status);
|
|
if (query(r, WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER, nullptr, &status, &sz, nullptr) && status == 200) {
|
|
char buf[2048];
|
|
DWORD n;
|
|
ok = true;
|
|
while (out.size() < 8192 && read(r, buf, sizeof(buf), &n) && n) out.append(buf, n);
|
|
}
|
|
}
|
|
if (r) close(r);
|
|
if (c) close(c);
|
|
if (s) close(s);
|
|
return ok;
|
|
}
|
|
|
|
void Clean(char* dst, size_t cap, const std::string& src) { // printable ASCII and newlines only
|
|
size_t n = 0;
|
|
for (char ch : src)
|
|
if (n + 1 < cap && ((ch >= 0x20 && ch <= 0x7e) || ch == '\n')) dst[n++] = ch;
|
|
dst[n] = 0;
|
|
}
|
|
|
|
void ParseNewsTxt(const std::string& body, Item& it) {
|
|
size_t split = body.find("\n\n");
|
|
std::string head = body.substr(0, split == std::string::npos ? body.size() : split);
|
|
std::string text = split == std::string::npos ? "" : body.substr(split + 2);
|
|
while (!text.empty() && (text.back() == '\n' || text.back() == '\r')) text.pop_back();
|
|
size_t pos = 0;
|
|
while (pos < head.size()) {
|
|
size_t e = head.find('\n', pos);
|
|
std::string line = head.substr(pos, e == std::string::npos ? std::string::npos : e - pos);
|
|
pos = e == std::string::npos ? head.size() : e + 1;
|
|
size_t eq = line.find('=');
|
|
if (eq == std::string::npos) continue;
|
|
std::string k = line.substr(0, eq), v = line.substr(eq + 1);
|
|
if (k == "id") Clean(it.id, sizeof(it.id), v);
|
|
else if (k == "headline") Clean(it.headline, sizeof(it.headline), v);
|
|
else if (k == "url") Clean(it.url, sizeof(it.url), v);
|
|
else if (k == "date") Clean(it.date, sizeof(it.date), v);
|
|
}
|
|
Clean(it.text, sizeof(it.text), text);
|
|
it.valid = SafeId(it.id) && it.headline[0] && it.text[0];
|
|
}
|
|
|
|
void SetBanner(const Item& it) {
|
|
// "$MSG$headline" (+ a hint line when it fits on the first line); the news text has no '%' (the game formats it)
|
|
std::string t = std::string("$MSG$") + it.headline;
|
|
if (strlen(it.headline) <= 50) t += "\nClick on the button to the left to read more.";
|
|
char tmp[sizeof(g_banner)];
|
|
strncpy_s(tmp, t.c_str(), _TRUNCATE);
|
|
memcpy(g_banner, tmp, sizeof(tmp)); // same size: the game reads the buffer only when it shows the banner
|
|
}
|
|
|
|
DWORD WINAPI FetchThread(LPVOID) {
|
|
char seen[64];
|
|
mp::ReadSeen(seen, sizeof(seen));
|
|
wchar_t path[200];
|
|
wchar_t wseen[64] = {};
|
|
for (int i = 0; seen[i]; ++i) wseen[i] = static_cast<unsigned char>(seen[i]);
|
|
swprintf_s(path, L"/motd/news.txt?seen=%s", wseen);
|
|
std::string body;
|
|
if (!HttpGet(path, body)) { Log("fetch failed", "banner keeps its generic text"); return 0; }
|
|
Item it;
|
|
ParseNewsTxt(body, it);
|
|
if (!it.valid) { Log("no news to show", ""); return 0; }
|
|
{
|
|
std::lock_guard<std::mutex> g(g_lock);
|
|
g_item = it;
|
|
}
|
|
SetBanner(it);
|
|
Log("news item loaded", it.id);
|
|
return 0;
|
|
}
|
|
|
|
// The dialog shows about 12 lines of ~56 characters and clips the rest from both ends (seen in game), so the text
|
|
// is cut at a word boundary with "..." to what fits; the news page has the whole item.
|
|
int WrappedLines(const std::string& t, size_t width) {
|
|
int lines = 1;
|
|
size_t col = 0;
|
|
size_t i = 0;
|
|
while (i < t.size()) {
|
|
if (t[i] == '\n') { ++lines; col = 0; ++i; continue; }
|
|
size_t j = i;
|
|
while (j < t.size() && t[j] != ' ' && t[j] != '\n') ++j;
|
|
size_t w = j - i;
|
|
if (col && col + 1 + w > width) { ++lines; col = 0; }
|
|
col += (col ? 1 : 0) + w;
|
|
i = j;
|
|
if (i < t.size() && t[i] == ' ') ++i;
|
|
}
|
|
return lines;
|
|
}
|
|
|
|
std::string FitDialogText(const std::string& headline, const std::string& body, const std::string& prompt) {
|
|
constexpr size_t kWidth = 54;
|
|
constexpr int kMaxLines = 12;
|
|
std::string b = body;
|
|
bool cut = false;
|
|
auto compose = [&](const std::string& x) { return headline + "\n\n" + x + "\n\n" + prompt; };
|
|
while (WrappedLines(compose(cut ? b + " ..." : b), kWidth) > kMaxLines && b.size() > 20) {
|
|
size_t sp = b.find_last_of(" \n", b.size() - 2);
|
|
b.resize(sp == std::string::npos ? b.size() - 1 : sp);
|
|
while (!b.empty() && (b.back() == '\n' || b.back() == ' ')) b.pop_back();
|
|
cut = true;
|
|
}
|
|
return compose(cut ? b + " ..." : b);
|
|
}
|
|
|
|
// ---- the game's handlers ---------------------------------------------------------------------------------------
|
|
void ShowNewsDialog(void* self, const std::string& text) {
|
|
std::string key = "$MSG$" + text;
|
|
char* name = nullptr;
|
|
char* wtext[2] = {nullptr, nullptr};
|
|
kStrCtor(&name, nullptr, "install_patch"); // the Yes button's event: our install handler below
|
|
kLocalize(wtext, key.c_str());
|
|
kDialog(self, nullptr, wtext, &name, reinterpret_cast<void*>(kEmptyString));
|
|
kWStrDtor(wtext);
|
|
kStrDtor(&name);
|
|
}
|
|
|
|
// replaces sub_4d310b: the banner's button
|
|
void __fastcall OpenDialog(void* self, void*, DWORD) {
|
|
if (!*reinterpret_cast<DWORD*>(static_cast<BYTE*>(self) + 0x20)) return; // as the original: only with a patch pending
|
|
Item it;
|
|
{
|
|
std::lock_guard<std::mutex> g(g_lock);
|
|
it = g_item;
|
|
}
|
|
std::string text;
|
|
if (it.valid) {
|
|
text = FitDialogText(it.headline, it.text, "Open the news page in your browser?");
|
|
WriteSeen(it.id);
|
|
Log("dialog opened", it.id);
|
|
} else {
|
|
text = "There is news from the DS2 Revival project.\n\nOpen the news page in your browser?";
|
|
Log("dialog opened", "(no text loaded)");
|
|
}
|
|
ShowNewsDialog(self, text);
|
|
}
|
|
|
|
// replaces sub_4d323f: Yes in that dialog
|
|
void __fastcall OpenNews(void* self, void*, DWORD) {
|
|
std::string url;
|
|
{
|
|
std::lock_guard<std::mutex> g(g_lock);
|
|
url = g_item.valid && g_item.url[0] ? g_item.url : BaseUrl() + "/news";
|
|
}
|
|
if (!UrlAllowed(url.c_str())) url = BaseUrl() + "/news";
|
|
if (Harness()) {
|
|
Log("would open in the browser (private test instance: not started)", url.c_str());
|
|
} else {
|
|
Log("opening in the browser", url.c_str());
|
|
ShellExecuteA(nullptr, "open", url.c_str(), nullptr, nullptr, SW_SHOWNORMAL);
|
|
}
|
|
*reinterpret_cast<DWORD*>(static_cast<BYTE*>(self) + 0x20) = 0; // nothing pending: the banner is not shown again
|
|
kHideBanner(self);
|
|
}
|
|
|
|
} // namespace
|
|
|
|
namespace mp {
|
|
|
|
void InstallNewsUi(const ds2::Ini& ini, ds2::Exe exe, const NewsTarget& target) {
|
|
g_target = target;
|
|
if (!ini.Bool("Network", "NewsBanner", true)) { Log("NewsBanner=0 - the version check shows the game's own text", ""); return; }
|
|
CreateThread(nullptr, 0, FetchThread, nullptr, 0, nullptr);
|
|
const GameSites& g = exe == ds2::Exe::Steam23 ? kSteam : kBrokenWorld;
|
|
kStrCtor = reinterpret_cast<StrCtor_t>(g.strCtor);
|
|
kStrDtor = reinterpret_cast<StrDtor_t>(g.strDtor);
|
|
kWStrDtor = reinterpret_cast<WStrDtor_t>(g.wstrDtor);
|
|
kLocalize = reinterpret_cast<Localize_t>(g.localize);
|
|
kDialog = reinterpret_cast<Dialog_t>(g.dialog);
|
|
kHideBanner = reinterpret_cast<HideBanner_t>(g.hideBanner);
|
|
kEmptyString = g.emptyString;
|
|
// All three or none: the banner text alone would still lead to the game's own downloader.
|
|
DWORD textOrig = g.bannerOrig, openOrig = g.openOrig, instOrig = g.installOrig;
|
|
if (std::memcmp(reinterpret_cast<void*>(g.bannerOperand), &textOrig, 4) || std::memcmp(reinterpret_cast<void*>(g.openOperand), &openOrig, 4) ||
|
|
std::memcmp(reinterpret_cast<void*>(g.installOperand), &instOrig, 4)) {
|
|
Log("banner sites not as expected (different exe build?)", "the game's own patch text and dialog stay");
|
|
return;
|
|
}
|
|
DWORD textNew = reinterpret_cast<DWORD>(g_banner), openNew = reinterpret_cast<DWORD>(&OpenDialog), instNew = reinterpret_cast<DWORD>(&OpenNews);
|
|
ds2::PatchBytes(g.bannerOperand, &textOrig, &textNew, 4, "news banner: text = headline");
|
|
ds2::PatchBytes(g.openOperand, &openOrig, &openNew, 4, "news banner: button opens the news dialog");
|
|
ds2::PatchBytes(g.installOperand, &instOrig, &instNew, 4, "news dialog: Yes opens the news page (no patch download)");
|
|
}
|
|
|
|
} // namespace mp
|