diff --git a/.gitmodules b/.gitmodules index c19efef0..f4c54d98 100644 --- a/.gitmodules +++ b/.gitmodules @@ -44,3 +44,6 @@ [submodule "deps/curl"] path = deps/curl url = https://github.com/curl/curl.git +[submodule "deps/discord-rpc"] + path = deps/discord-rpc + url = https://github.com/fedddddd/discord-rpc diff --git a/deps/discord-rpc b/deps/discord-rpc new file mode 160000 index 00000000..b3383798 --- /dev/null +++ b/deps/discord-rpc @@ -0,0 +1 @@ +Subproject commit b3383798b353c31ea6770fee673740c27f6e3489 diff --git a/deps/premake/discord-rpc.lua b/deps/premake/discord-rpc.lua new file mode 100644 index 00000000..ef28bcb3 --- /dev/null +++ b/deps/premake/discord-rpc.lua @@ -0,0 +1,39 @@ +discordrpc = { + source = path.join(dependencies.basePath, "discord-rpc"), +} + +function discordrpc.import() + links { "discord-rpc" } + discordrpc.includes() +end + +function discordrpc.includes() + includedirs { + path.join(discordrpc.source, "include"), + } +end + +function discordrpc.project() + project "discord-rpc" + language "C++" + + discordrpc.includes() + rapidjson.import(); + + files { + path.join(discordrpc.source, "src/*.h"), + path.join(discordrpc.source, "src/*.cpp"), + } + + removefiles { + path.join(discordrpc.source, "src/dllmain.cpp"), + path.join(discordrpc.source, "src/*_linux.cpp"), + path.join(discordrpc.source, "src/*_unix.cpp"), + path.join(discordrpc.source, "src/*_osx.cpp"), + } + + warnings "Off" + kind "StaticLib" +end + +table.insert(dependencies, discordrpc) diff --git a/src/client/component/console/terminal.cpp b/src/client/component/console/terminal.cpp index ec0d1ed3..ea0e8616 100644 --- a/src/client/component/console/terminal.cpp +++ b/src/client/component/console/terminal.cpp @@ -341,14 +341,14 @@ namespace terminal public: component() { - if(!game::environment::is_dedi()) + printf_hook.create(printf, printf_stub); + + if (!game::environment::is_dedi()) ShowWindow(GetConsoleWindow(), SW_HIDE); } void post_unpack() override { - printf_hook.create(printf, printf_stub); - ShowWindow(GetConsoleWindow(), SW_SHOW); SetConsoleTitle("IW7-Mod: " VERSION); diff --git a/src/client/component/discord.cpp b/src/client/component/discord.cpp new file mode 100644 index 00000000..6ab049ce --- /dev/null +++ b/src/client/component/discord.cpp @@ -0,0 +1,496 @@ +#include +#include "loader/component_loader.hpp" + +#include "console/console.hpp" +#include "command.hpp" +#include "discord.hpp" +#include "party.hpp" +#include "scheduler.hpp" + +#include "game/game.hpp" +#include "game/ui_scripting/execution.hpp" + +#include +#include +#include + +#include + +/* +#define DEFAULT_AVATAR "discord_default_avatar" +#define AVATAR "discord_avatar_%s" + +#define DEFAULT_AVATAR_URL "https://cdn.discordapp.com/embed/avatars/0.png" +#define AVATAR_URL "https://cdn.discordapp.com/avatars/%s/%s.png?size=128" +*/ + +namespace discord +{ + namespace + { + struct discord_presence_state_t + { + int start_timestamp; + int party_size; + int party_max; + }; + + struct discord_presence_strings_t + { + std::string state; + std::string details; + std::string small_image_key; + std::string small_image_text; + std::string large_image_key; + std::string large_image_text; + std::string party_id; + std::string join_secret; + }; + + DiscordRichPresence discord_presence{}; + discord_presence_strings_t discord_strings; + + std::mutex avatar_map_mutex; + std::unordered_map avatar_material_map; + game::Material* default_avatar_material{}; + + const char* get_large_image_name() + { + const auto mode = game::Com_GameMode_GetActiveGameMode(); + switch (mode) + { + case game::GAME_MODE_SP: + return "menu_singleplayer"; + case game::GAME_MODE_CP: + return "menu_zombies"; + case game::GAME_MODE_MP: + default: + return "menu_multiplayer"; + } + } + + void update_discord_frontend() + { + discord_presence.details = game::G_GAME_MODE_STRINGS_FORMATTED[game::Com_GameMode_GetActiveGameMode()]; + discord_presence.startTimestamp = 0; + + /* + static const auto in_firing_range = game::Dvar_FindVar("virtualLobbyInFiringRange"); + if (in_firing_range != nullptr && in_firing_range->current.enabled == 1) + { + discord_presence.state = "Firing Range"; + discord_presence.largeImageKey = "mp_vlobby_room"; + } + else + { + */ + discord_presence.state = "Main Menu"; + discord_presence.largeImageKey = get_large_image_name(); + //} + + Discord_UpdatePresence(&discord_presence); + } + + void update_discord_ingame() + { + static const auto mapname_dvar = game::Dvar_FindVar("mapname"); + auto mapname = mapname_dvar->current.string; + + discord_strings.large_image_key = mapname; + + const auto mode = game::Com_GameMode_GetActiveGameMode(); + const auto presence_key = utils::string::va( + "PRESENCE_%s%s", + (mode == game::GAME_MODE_SP ? "SP_" : (mode == game::GAME_MODE_CP ? "CP_" : "MP_")), + mapname); + + if (game::DB_XAssetExists(game::ASSET_TYPE_LOCALIZE_ENTRY, presence_key) && + !game::DB_IsXAssetDefault(game::ASSET_TYPE_LOCALIZE_ENTRY, presence_key)) + { + mapname = game::UI_SafeTranslateString(presence_key); + } + + if (mode == game::GAME_MODE_CP || mode == game::GAME_MODE_MP) + { + static const auto gametype_dvar = game::Dvar_FindVar("g_gametype"); + static const auto max_clients_dvar = game::Dvar_FindVar("sv_maxclients"); + static const auto hostname_dvar = game::Dvar_FindVar("sv_hostname"); + + const auto gametype_display_name = game::UI_GetGameTypeDisplayName(gametype_dvar->current.string); + const auto gametype = utils::string::strip(gametype_display_name); + + discord_strings.details = std::format("{} on {}", gametype, mapname); + + // cant find anything for numClients rn in iw7 :p + /* + const auto client_state = *game::client_state; + if (client_state != nullptr) + { + discord_presence.partySize = client_state->num_players; + } + */ + discord_presence.partySize = 1; + + if (game::SV_Loaded() && !game::Com_FrontEnd_IsInFrontEnd()) + { + discord_strings.state = "Private Match"; + discord_presence.partyMax = max_clients_dvar->current.integer; + discord_presence.partyPrivacy = DISCORD_PARTY_PRIVATE; + } + else + { + discord_strings.state = utils::string::strip(hostname_dvar->current.string); + + const auto server_connection_state = party::get_server_connection_state(); + const auto server_ip_port = std::format("{}.{}.{}.{}:{}", + static_cast(server_connection_state.host.ip[0]), + static_cast(server_connection_state.host.ip[1]), + static_cast(server_connection_state.host.ip[2]), + static_cast(server_connection_state.host.ip[3]), + static_cast(ntohs(server_connection_state.host.port)) + ); + + discord_strings.party_id = utils::cryptography::sha1::compute(server_ip_port, true).substr(0, 8); + discord_presence.partyMax = server_connection_state.max_clients; + discord_presence.partyPrivacy = DISCORD_PARTY_PUBLIC; + discord_strings.join_secret = server_ip_port; + } + + auto server_discord_info = party::get_server_discord_info(); + if (server_discord_info.has_value()) + { + discord_strings.small_image_key = server_discord_info->image; + discord_strings.small_image_text = server_discord_info->image_text; + } + } + else if (mode == game::GAME_MODE_SP) + { + discord_strings.details = mapname; + } + + if (discord_presence.startTimestamp == 0) + { + discord_presence.startTimestamp = std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count(); + } + + discord_presence.state = discord_strings.state.data(); + discord_presence.details = discord_strings.details.data(); + discord_presence.smallImageKey = discord_strings.small_image_key.data(); + discord_presence.smallImageText = discord_strings.small_image_text.data(); + discord_presence.largeImageKey = discord_strings.large_image_key.data(); + discord_presence.largeImageText = discord_strings.large_image_text.data(); + discord_presence.partyId = discord_strings.party_id.data(); + discord_presence.joinSecret = discord_strings.join_secret.data(); + + Discord_UpdatePresence(&discord_presence); + } + + void update_discord() + { + const auto saved_time = discord_presence.startTimestamp; + discord_presence = {}; + discord_presence.startTimestamp = saved_time; + + //const auto game_initialized = game::client_actives_something[0] == 1; // 196 * client num = resultc + const auto game_initialized = false; // TODO: figure out a way to do this + if (!game_initialized || game::Com_FrontEndScene_IsActive()) + { + update_discord_frontend(); + } + else + { + update_discord_ingame(); + } + } + + /* + game::Material* create_avatar_material(const std::string& name, const std::string& data) + { + const auto material = materials::create_material(name); + try + { + if (!materials::setup_material_image(material, data)) + { + materials::free_material(material); + return nullptr; + } + + { + std::lock_guard _0(avatar_map_mutex); + avatar_material_map.insert(std::make_pair(name, material)); + } + + return material; + } + catch (const std::exception& e) + { + materials::free_material(material); + console::error("Failed to load user avatar image: %s\n", e.what()); + } + + return nullptr; + } + + void download_user_avatar(const std::string& id, const std::string& avatar) + { + const auto data = utils::http::get_data( + utils::string::va(AVATAR_URL, id.data(), avatar.data())); + if (!data.has_value()) + { + return; + } + + const auto& value = data.value(); + if (value.code != CURLE_OK) + { + return; + } + + const auto name = utils::string::va(AVATAR, id.data()); + create_avatar_material(name, value.buffer); + } + + void download_default_avatar() + { + const auto data = utils::http::get_data(DEFAULT_AVATAR_URL); + if (!data.has_value()) + { + return; + } + + const auto& value = data.value(); + if (value.code != CURLE_OK) + { + return; + } + + default_avatar_material = create_avatar_material(DEFAULT_AVATAR, value.buffer); + } + */ + + void ready(const DiscordUser* request) + { + DiscordRichPresence presence{}; + presence.instance = 1; + presence.state = ""; + console::info("Discord: Ready on %s (%s)\n", request->username, request->userId); + Discord_UpdatePresence(&presence); + } + + void errored(const int error_code, const char* message) + { + console::error("Discord: %s (%i)\n", message, error_code); + } + + void join_game(const char* join_secret) + { +#ifdef DEBUG + console::debug("Discord: join_game called with secret '%s'\n", join_secret); +#endif + + scheduler::once([=] + { + game::netadr_s target{}; + if (game::NET_StringToAdr(join_secret, &target)) + { + console::info("Discord: Connecting to server '%s'\n", join_secret); + party::connect(target); + } + }, scheduler::pipeline::main); + } + + /* + std::string get_display_name(const DiscordUser* user) + { + if (user->discriminator != nullptr && user->discriminator != "0"s) + { + return std::format("{}#{}", user->username, user->discriminator); + } + else if (user->globalName[0] != 0) + { + return user->globalName; + } + else + { + return user->username; + } + } + */ + + void join_request(const DiscordUser* request) + { + console::debug("Discord: Join request from %s (%s)\n", request->username, request->userId); + Discord_Respond(request->userId, DISCORD_REPLY_IGNORE); + + /* + if (game::Com_FrontEnd_IsInFrontEnd() || !ui_scripting::lui_running()) + { + Discord_Respond(request->userId, DISCORD_REPLY_IGNORE); + return; + } + + static std::unordered_map last_requests; + + const std::string user_id = request->userId; + const std::string avatar = request->avatar; + const std::string discriminator = request->discriminator; + const std::string username = request->username; + const auto display_name = get_display_name(request); + + const auto now = std::chrono::high_resolution_clock::now(); + auto iter = last_requests.find(user_id); + if (iter != last_requests.end()) + { + if ((now - iter->second) < 15s) + { + return; + } + else + { + iter->second = now; + } + } + else + { + last_requests.insert(std::make_pair(user_id, now)); + } + + // TODO: lui work to support join requests (check h1-mod) + scheduler::once([=] + { + const ui_scripting::table request_table{}; + request_table.set("avatar", avatar); + request_table.set("discriminator", discriminator); + request_table.set("userid", user_id); + request_table.set("username", username); + request_table.set("displayname", display_name); + + ui_scripting::notify("discord_join_request", + { + {"request", request_table} + }); + }, scheduler::pipeline::lui); + + const auto material_name = utils::string::va(AVATAR, user_id.data()); + if (!avatar.empty() && !avatar_material_map.contains(material_name)) + { + download_user_avatar(user_id, avatar); + } + */ + } + + // TODO + /* + void set_default_bindings() + { + const auto set_binding = [](const std::string& command, const game::keyNum_t key) + { + const auto binding = game::Key_GetBindingForCmd(command.data()); + for (auto i = 0; i < 256; i++) + { + if (game::playerKeys[0].keys[i].binding == binding) + { + return; + } + } + + if (game::playerKeys[0].keys[key].binding == 0) + { + game::Key_SetBinding(0, key, binding); + } + }; + + set_binding("discord_accept", game::K_F1); + set_binding("discord_deny", game::K_F2); + } + */ + } + + /* + game::Material* get_avatar_material(const std::string& id) + { + const auto material_name = utils::string::va(AVATAR, id.data()); + const auto iter = avatar_material_map.find(material_name); + if (iter == avatar_material_map.end()) + { + return default_avatar_material; + } + + return iter->second; + } + + void respond(const std::string& id, int reply) + { + scheduler::once([=]() + { + Discord_Respond(id.data(), reply); + }, scheduler::pipeline::async); + } + */ + + class component final : public component_interface + { + public: + void post_unpack() override + { + if (game::environment::is_dedi()) + { + return; + } + + DiscordEventHandlers handlers{}; + handlers.ready = ready; + handlers.errored = errored; + handlers.disconnected = errored; + handlers.spectateGame = nullptr; + handlers.joinGame = join_game; + handlers.joinRequest = join_request; // not fully supported yet + + Discord_Initialize("1215500480873103400", &handlers, 1, nullptr); + + /** + if (game::environment::is_mp()) + { + scheduler::on_game_initialized([] + { + scheduler::once(download_default_avatar, scheduler::async); + set_default_bindings(); + }, scheduler::main); + } + */ + + scheduler::loop(Discord_RunCallbacks, scheduler::async, 500ms); + scheduler::loop(update_discord, scheduler::async, 5s); + + initialized_ = true; + + /* + command::add("discord_accept", []() + { + ui_scripting::notify("discord_response", {{"accept", true}}); + }); + + command::add("discord_deny", []() + { + ui_scripting::notify("discord_response", {{"accept", false}}); + }); + */ + } + + void pre_destroy() override + { + if (!initialized_ || game::environment::is_dedi()) + { + return; + } + + Discord_Shutdown(); + } + + private: + bool initialized_ = false; + }; +} + +//REGISTER_COMPONENT(discord::component) diff --git a/src/client/component/discord.hpp b/src/client/component/discord.hpp new file mode 100644 index 00000000..716b5f83 --- /dev/null +++ b/src/client/component/discord.hpp @@ -0,0 +1,9 @@ +#pragma once + +//#include "game/game.hpp" + +namespace discord +{ + //game::Material* get_avatar_material(const std::string& id); + //void respond(const std::string& id, int reply); +} diff --git a/src/client/component/party.cpp b/src/client/component/party.cpp index 6a20517a..1e3660bc 100644 --- a/src/client/component/party.cpp +++ b/src/client/component/party.cpp @@ -24,6 +24,32 @@ namespace party namespace { connection_state server_connection_state{}; + std::optional server_discord_info{}; + + /* + struct usermap_file + { + std::string extension; + std::string name; + bool optional; + }; + + // snake case these names before release + std::vector usermap_files = + { + {".ff", "usermap_hash", false}, + {"_load.ff", "usermap_load_hash", true}, + {".arena", "usermap_arena_hash", true}, + {".pak", "usermap_pak_hash", true}, + }; + + std::vector mod_files = + { + {".ff", "mod_hash", false}, + {"_pre_gfx.ff", "mod_pre_gfx_hash", true}, + {".pak", "mod_pak_hash", true}, + }; + */ bool preloaded_map = false; @@ -38,8 +64,8 @@ namespace party void connect_to_party(const game::netadr_s& target, const std::string& mapname, const std::string& gametype, int sv_maxclients) { - if (game::Com_GameMode_GetActiveGameMode() != game::GAME_MODE_MP && - game::Com_GameMode_GetActiveGameMode() != game::GAME_MODE_CP) + const auto mode = game::Com_GameMode_GetActiveGameMode(); + if (mode != game::GAME_MODE_MP && mode != game::GAME_MODE_CP) { return; } @@ -336,11 +362,16 @@ namespace party game::Com_SetLocalizedErrorMessage(error.data(), "MENU_NOTICE"); } - connection_state get_server_connection_state() + connection_state get_server_connection_state() { return server_connection_state; } + std::optional get_server_discord_info() + { + return server_discord_info; + } + class component final : public component_interface { public: @@ -499,6 +530,34 @@ namespace party info.set("playmode", utils::string::va("%i", game::Com_GameMode_GetActiveGameMode())); info.set("sv_running", utils::string::va("%i", get_dvar_bool("sv_running") && !game::Com_FrontEndScene_IsActive())); info.set("dedicated", utils::string::va("%i", get_dvar_bool("dedicated"))); + info.set("sv_wwwBaseUrl", get_dvar_string("sv_wwwBaseUrl")); + info.set("sv_discordImageUrl", get_dvar_string("sv_discordImageUrl")); + info.set("sv_discordImageText", get_dvar_string("sv_discordImageText")); + + /* + if (!fastfiles::is_stock_map(mapname)) + { + for (const auto& file : usermap_files) + { + const auto path = get_usermap_file_path(mapname, file.extension); + const auto hash = get_file_hash(path); + info.set(file.name, hash); + } + } + + const auto fs_game = get_dvar_string("fs_game"); + info.set("fs_game", fs_game); + + if (!fs_game.empty()) + { + for (const auto& file : mod_files) + { + const auto hash = get_file_hash(utils::string::va("%s/mod%s", + fs_game.data(), file.extension.data())); + info.set(file.name, hash); + } + } + */ network::send(target, "infoResponse", info.build(), '\n'); }); @@ -518,6 +577,13 @@ namespace party return; } + const auto protocol = info.get("protocol"); + if (std::atoi(protocol.data()) != PROTOCOL) + { + info_response_error("Connection failed: Invalid protocol."); + return; + } + if (info.get("challenge") != server_connection_state.challenge) { info_response_error("Connection failed: Invalid challenge."); @@ -567,6 +633,14 @@ namespace party return; } + server_connection_state.base_url = info.get("sv_wwwBaseUrl"); + /* + if (download_files(target, info, false)) + { + return; + } + */ + server_connection_state.motd = info.get("sv_motd"); server_connection_state.max_clients = std::stoi(sv_maxclients_str); @@ -579,6 +653,14 @@ namespace party const auto profile_info_value = profile_info.value_or(profile_infos::profile_info{}); profile_infos::send_profile_info(target, steam::SteamUser()->GetSteamID().bits, profile_info_value); + discord_information discord_info{}; + discord_info.image = info.get("sv_discordImageUrl"); + discord_info.image_text = info.get("sv_discordImageText"); + if (!discord_info.image.empty() || !discord_info.image_text.empty()) + { + server_discord_info.emplace(discord_info); + } + connect_to_party(target, mapname, gametype, sv_maxclients); }); } diff --git a/src/client/component/party.hpp b/src/client/component/party.hpp index ec0a4b71..3f68dfae 100644 --- a/src/client/component/party.hpp +++ b/src/client/component/party.hpp @@ -28,9 +28,9 @@ namespace party void connect(const game::netadr_s& target); void start_map(const std::string& mapname, bool dev = false); - //void clear_sv_motd(); + void clear_sv_motd(); connection_state get_server_connection_state(); - //std::optional get_server_discord_info(); + std::optional get_server_discord_info(); int get_client_num_by_name(const std::string& name); diff --git a/src/client/game/game.cpp b/src/client/game/game.cpp index ea467fcb..032dc29a 100644 --- a/src/client/game/game.cpp +++ b/src/client/game/game.cpp @@ -85,6 +85,14 @@ namespace game "cp", }; + const char* G_GAME_MODE_STRINGS_FORMATTED[] = + { + "Multiplayer", // this is really none, but its for discord presence :P + "Singleplayer", + "Multiplayer", + "Zombies", + }; + const char* Com_GameMode_GetActiveGameModeStr() { return G_GAME_MODE_STRINGS[game::Com_GameMode_GetActiveGameMode()]; diff --git a/src/client/game/game.hpp b/src/client/game/game.hpp index 177e8823..633418c5 100644 --- a/src/client/game/game.hpp +++ b/src/client/game/game.hpp @@ -54,6 +54,7 @@ namespace game int SV_Cmd_Argc(); const char* SV_Cmd_Argv(int index); + extern const char* G_GAME_MODE_STRINGS_FORMATTED[]; const char* Com_GameMode_GetActiveGameModeStr(); const char* Com_GameMode_GetGameModeStr(GameModeType gameMode); diff --git a/src/client/game/symbols.hpp b/src/client/game/symbols.hpp index 17346310..65a5594e 100644 --- a/src/client/game/symbols.hpp +++ b/src/client/game/symbols.hpp @@ -31,6 +31,8 @@ namespace game WEAK symbol Com_FrontEndScene_ShutdownAndDisable{ 0x5AEFB0 }; WEAK symbol Com_FrontEndScene_Shutdown{ 0x5AED00 }; + WEAK symbol s_frontEndScene_state{ 0x4BFF608 }; + WEAK symbol Com_GameMode_SetDesiredGameMode{ 0x5AFDA0 }; WEAK symbol Com_GameMode_GetActiveGameMode{ 0x5AFD50 }; WEAK symbol Com_GameMode_SupportsMap{ 0x5AFE10 }; @@ -258,6 +260,7 @@ namespace game WEAK symbol UI_GetMapDisplayName{ 0xCC6270 }; WEAK symbol UI_GetGameTypeDisplayName{ 0xCC61C0 }; WEAK symbol UI_RunMenuScript{ 0xCC9710 }; + WEAK symbol UI_SafeTranslateString{ 0xCC9790 }; WEAK symbol longjmp{ 0x12C0758 }; WEAK symbol _setjmp{ 0x1423110 }; @@ -266,6 +269,8 @@ namespace game * Variables **************************************************************/ + //WEAK symbol client_actives_something{ 0x2246C51 }; // 0x140995A8B in IW7 idb shows this to be cgameinitialized times the local client number + WEAK symbol g_script_error_level{ 0x6B16298 }; WEAK symbol g_script_error{ 0x6B162A0 }; @@ -327,7 +332,7 @@ namespace game WEAK symbol g_quitRequested{ 0x779CD44 }; - WEAK symbol gameEntityId{ 0x665A124 }; + WEAK symbol gameEntityId{ 0x665A124 }; WEAK symbol level_time{ 0x3C986D8 }; namespace hks diff --git a/src/common/utils/string.cpp b/src/common/utils/string.cpp index 8323c778..93ba3df4 100644 --- a/src/common/utils/string.cpp +++ b/src/common/utils/string.cpp @@ -136,6 +136,14 @@ namespace utils::string *out = '\0'; } + std::string strip(const std::string& string) + { + std::string new_string; + new_string.resize(string.size() + 1, 0); + strip(string.data(), new_string.data(), static_cast(new_string.size())); + return new_string.data(); + } + #pragma warning(push) #pragma warning(disable: 4100) std::string convert(const std::wstring& wstr) diff --git a/src/common/utils/string.hpp b/src/common/utils/string.hpp index 6ea06926..3c539942 100644 --- a/src/common/utils/string.hpp +++ b/src/common/utils/string.hpp @@ -93,6 +93,7 @@ namespace utils::string std::string get_clipboard_data(); void strip(const char* in, char* out, int max); + std::string strip(const std::string& string); std::string convert(const std::wstring& wstr); std::wstring convert(const std::string& str);