DS2Revival/mods/multiplayer/news_ui.cpp
DS2Revival a1e2f8f9b5
Some checks failed
build / build (push) Failing after 1m35s
CI: build on the project's own runner at git.dsii.uk
2026-10-27 22:02:02 -04:00

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().Detail("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