diff --git a/src/client/component/download.cpp b/src/client/component/download.cpp index 5605eee1..32674cad 100644 --- a/src/client/component/download.cpp +++ b/src/client/component/download.cpp @@ -11,6 +11,7 @@ #include #include #include +#include namespace download { @@ -56,9 +57,25 @@ namespace download }); } - int progress_callback(size_t progress) + auto last_update = std::chrono::high_resolution_clock::now(); + int progress_callback(size_t total, size_t progress) { - console::debug("Download progress: %lli\n", progress); + const auto now = std::chrono::high_resolution_clock::now(); + if (now - last_update > 20ms) + { + last_update = std::chrono::high_resolution_clock::now(); + const auto fraction = static_cast(static_cast(progress) / + static_cast(std::max(size_t(1), total))); + scheduler::once([=]() + { + ui_scripting::notify("mod_download_progress", + { + {"fraction", fraction}, + }); + }, scheduler::pipeline::lui); + } + + console::debug("Download progress: %lli/%lli\n", progress, total); if (download_aborted()) { return -1; @@ -76,7 +93,7 @@ namespace download } } - void start_download(const game::netadr_s& target, const utils::info_string& info) + void start_download(const game::netadr_s& target, const utils::info_string& info, const std::vector& files) { if (download_active()) { @@ -84,7 +101,7 @@ namespace download { if (!download_active()) { - start_download(target, info); + start_download(target, info, files); return scheduler::cond_end; } @@ -106,27 +123,17 @@ namespace download return; } - const auto mod = info.get("fs_game") + "/mod.ff"; - const auto url = base + "/" + mod; - - console::debug("Downloading %s from %s: %s\n", mod.data(), base.data(), url.data()); - scheduler::once([=]() { const ui_scripting::table mod_data_table{}; - mod_data_table.set("name", mod.data()); - ui_scripting::notify("mod_download_start", - { - {"request", mod_data_table} - }); + ui_scripting::notify("mod_download_start", {}); }, scheduler::pipeline::lui); scheduler::once([=]() { { const auto _0 = gsl::finally(&mark_unactive); - mark_active(); if (download_aborted()) @@ -134,26 +141,50 @@ namespace download return; } - const auto data = utils::http::get_data(url, {}, {}, &progress_callback); - if (!data.has_value()) + for (const auto& file : files) { - menu_error("Download failed: An unknown error occurred, please try again."); - return; - } + scheduler::once([=]() + { + const ui_scripting::table data_table{}; + data_table.set("name", file.name.data()); - if (download_aborted()) - { - return; - } + ui_scripting::notify("mod_download_set_file", + { + {"request", data_table} + }); + }, scheduler::pipeline::lui); - const auto& result = data.value(); - if (result.code != CURLE_OK) - { - menu_error(utils::string::va("Download failed: %s (%i)\n", result.code, curl_easy_strerror(result.code))); - return; - } + const auto url = utils::string::va("%s/%s", base.data(), file.name.data()); + console::debug("Downloading %s from %s: %s\n", file.name.data(), base.data(), url); + const auto data = utils::http::get_data(url, {}, {}, &progress_callback); + if (!data.has_value()) + { + menu_error("Download failed: An unknown error occurred, please try again."); + return; + } - utils::io::write_file(mod, result.buffer, false); + if (download_aborted()) + { + return; + } + + const auto& result = data.value(); + if (result.code != CURLE_OK) + { + menu_error(utils::string::va("Download failed: %s (%i)\n", result.code, curl_easy_strerror(result.code))); + return; + } + + const auto hash = utils::cryptography::sha1::compute(result.buffer, true); + if (hash != file.hash) + { + menu_error(utils::string::va("Download failed: file hash doesn't match the server's (%s: %s != %s)\n", + file.name.data(), hash.data(), file.hash.data())); + return; + } + + utils::io::write_file(file.name, result.buffer, false); + } } scheduler::once([]() diff --git a/src/client/component/download.hpp b/src/client/component/download.hpp index 71d62a71..131a0f06 100644 --- a/src/client/component/download.hpp +++ b/src/client/component/download.hpp @@ -5,6 +5,12 @@ namespace download { - void start_download(const game::netadr_s& target, const utils::info_string& info); + struct file_t + { + std::string name; + std::string hash; + }; + + void start_download(const game::netadr_s& target, const utils::info_string& info, const std::vector& files); void stop_download(); } diff --git a/src/client/component/fastfiles.cpp b/src/client/component/fastfiles.cpp index e1389e46..1e704fc6 100644 --- a/src/client/component/fastfiles.cpp +++ b/src/client/component/fastfiles.cpp @@ -16,6 +16,7 @@ namespace fastfiles { static utils::concurrency::container current_fastfile; + static utils::concurrency::container> current_usermap; namespace { @@ -204,6 +205,32 @@ namespace fastfiles return handle; } + HANDLE find_usermap(const std::string& mapname) + { + const auto usermap = fastfiles::get_current_usermap(); + if (!usermap.has_value()) + { + return INVALID_HANDLE_VALUE; + } + + const auto& usermap_value = usermap.value(); + const auto usermap_file = utils::string::va("%s.ff", usermap_value.data()); + const auto usermap_load_file = utils::string::va("%s_load.ff", usermap_value.data()); + + if (mapname == usermap_file || mapname == usermap_load_file) + { + const auto path = utils::string::va("usermaps\\%s\\%s", + usermap_value.data(), mapname.data()); + if (utils::io::file_exists(path)) + { + return CreateFileA(path, GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING, + FILE_FLAG_OVERLAPPED | FILE_FLAG_NO_BUFFERING, nullptr); + } + } + + return INVALID_HANDLE_VALUE; + } + utils::hook::detour sys_createfile_hook; HANDLE sys_create_file_stub(game::Sys_Folder folder, const char* base_filename) { @@ -237,6 +264,12 @@ namespace fastfiles return handle; } + const auto usermap = find_usermap(name); + if (usermap != INVALID_HANDLE_VALUE) + { + return usermap; + } + if (name.ends_with(".ff")) { handle = find_fastfile(name, true); @@ -245,6 +278,18 @@ namespace fastfiles return handle; } + utils::hook::detour db_file_exists_hook; + bool db_file_exists_stub(const char* file, int a2) + { + const auto file_exists = db_file_exists_hook.invoke(file, a2); + if (file_exists) + { + return file_exists; + } + + return fastfiles::usermap_exists(file); + } + template inline void merge(std::vector* target, T* source, size_t length) { @@ -311,6 +356,19 @@ namespace fastfiles return; } + const auto usermap = fastfiles::get_current_usermap(); + if (usermap.has_value()) + { + const auto& usermap_value = usermap.value(); + const auto usermap_load = usermap_value + "_load"; + + if (fastfile == usermap_value || fastfile == usermap_load) + { + console::error("Usermap tried to load a lua file!\n"); + return; + } + } + utils::hook::invoke(0x39CA90_b, a1); } @@ -379,6 +437,54 @@ namespace fastfiles } } + void set_usermap(const std::string& usermap) + { + current_usermap.access([&](std::optional& current_usermap_) + { + current_usermap_ = usermap; + }); + } + + void clear_usermap() + { + current_usermap.access([&](std::optional& current_usermap_) + { + current_usermap_.reset(); + }); + } + + std::optional get_current_usermap() + { + return current_usermap.access>([&]( + std::optional& current_usermap_) + { + return current_usermap_; + }); + } + + bool usermap_exists(const std::string& name) + { + if (is_stock_map(name)) + { + return false; + } + + return utils::io::file_exists(utils::string::va("usermaps\\%s\\%s.ff", name.data(), name.data())); + } + + bool is_stock_map(const std::string& name) + { + for (auto map = &game::maps[0]; map->unk; ++map) + { + if (map->name == name) + { + return true; + } + } + + return false; + } + class component final : public component_interface { public: @@ -404,6 +510,7 @@ namespace fastfiles // Add custom zone paths sys_createfile_hook.create(game::Sys_CreateFile, sys_create_file_stub); + db_file_exists_hook.create(0x394DC0_b, db_file_exists_stub); // load our custom pre_gfx zones utils::hook::call(SELECT_VALUE(0x3862ED_b, 0x15C3FD_b), load_pre_gfx_zones); diff --git a/src/client/component/fastfiles.hpp b/src/client/component/fastfiles.hpp index 154f57df..608983be 100644 --- a/src/client/component/fastfiles.hpp +++ b/src/client/component/fastfiles.hpp @@ -12,4 +12,10 @@ namespace fastfiles const std::function& callback, const bool includeOverride); void close_fastfile_handles(); + + void set_usermap(const std::string& usermap); + void clear_usermap(); + std::optional get_current_usermap(); + bool usermap_exists(const std::string& name); + bool is_stock_map(const std::string& name); } diff --git a/src/client/component/party.cpp b/src/client/component/party.cpp index 239700ea..8f40b8b6 100644 --- a/src/client/component/party.cpp +++ b/src/client/component/party.cpp @@ -8,6 +8,7 @@ #include "scheduler.hpp" #include "server_list.hpp" #include "download.hpp" +#include "fastfiles.hpp" #include "game/game.hpp" @@ -33,8 +34,6 @@ namespace party std::string sv_motd; int sv_maxclients; - std::optional mod_hash{}; - void perform_game_initialization() { command::execute("onlinegame 1", true); @@ -82,6 +81,11 @@ namespace party utils::hook::invoke(0x13C9C0_b, 1); } + if (!fastfiles::is_stock_map(mapname)) + { + fastfiles::set_usermap(mapname); + } + // CL_ConnectFromParty char session_info[0x100] = {}; utils::hook::invoke(0x12DFF0_b, 0, session_info, &target, mapname.data(), gametype.data()); @@ -153,7 +157,50 @@ namespace party cl_disconnect_hook.invoke(showMainMenu); } - bool download_mod(const game::netadr_s& target, const utils::info_string& info) + std::optional get_file_hash(const std::string& file) + { + if (!utils::io::file_exists(file)) + { + return {}; + } + + const auto data = utils::io::read_file(file); + const auto sha = utils::cryptography::sha1::compute(data, true); + return {sha}; + } + + void check_download_map(const utils::info_string& info, std::vector& files) + { + const auto mapname = info.get("mapname"); + const auto usermap_hash = info.get("usermaphash"); + const auto usermap_load_hash = info.get("usermaploadhash"); + + const auto check_file = [&](const std::string& key, const std::string& filename) + { + if (mapname.contains('.') || mapname.contains("::")) + { + throw std::runtime_error(utils::string::va("Invalid server mapname value %s\n", mapname.data())); + } + + const auto source_hash = info.get(key); + if (source_hash.empty()) + { + return; + } + + const auto hash = get_file_hash(filename); + if (!hash.has_value() || hash.value() != source_hash) + { + files.emplace_back(filename, source_hash); + return; + } + }; + + check_file("usermaphash", utils::string::va("usermaps/%s/%s.ff", mapname.data(), mapname.data())); + check_file("usermaploadhash", utils::string::va("usermaps/%s/%s_load.ff", mapname.data(), mapname.data())); + } + + bool check_download_mod(const utils::info_string& info, std::vector& files) { const auto server_fs_game = utils::string::to_lower(info.get("fs_game")); if (server_fs_game.empty()) @@ -161,17 +208,15 @@ namespace party return false; } - if (!server_fs_game.starts_with("mods/") || server_fs_game.contains('.')) + if (!server_fs_game.starts_with("mods/") || server_fs_game.contains('.') || server_fs_game.contains("::")) { - menu_error(utils::string::va("Invalid server fs_game value %s\n", server_fs_game.data())); - return true; + throw std::runtime_error(utils::string::va("Invalid server fs_game value %s\n", server_fs_game.data())); } const auto source_hash = info.get("modHash"); if (source_hash.empty()) { - menu_error("Connection failed: Server mod hash is empty."); - return true; + throw std::runtime_error("Connection failed: Server mod hash is empty."); } static const auto fs_game = game::Dvar_FindVar("fs_game"); @@ -179,6 +224,7 @@ namespace party const auto mod_path = server_fs_game + "/mod.ff"; auto has_to_download = !utils::io::file_exists(mod_path); + if (!has_to_download) { const auto data = utils::io::read_file(mod_path); @@ -186,38 +232,138 @@ namespace party has_to_download = source_hash != hash; } - else - { - console::debug("Failed to find mod, downloading\n"); - } if (has_to_download) { - console::debug("Starting download of mod '%s'\n", server_fs_game.data()); - - download::stop_download(); - download::start_download(target, info); - - return true; + files.emplace_back(mod_path, source_hash); + return false; } else if (client_fs_game != server_fs_game) { - game::Dvar_SetFromStringByNameFromSource("fs_game", server_fs_game.data(), + game::Dvar_SetFromStringByNameFromSource("fs_game", server_fs_game.data(), game::DVAR_SOURCE_INTERNAL); - command::execute("vid_restart"); - - // set fs_game to the mod the server is on, "restart" game, and then reconnect - scheduler::once([=]() - { - command::execute("lui_open_popup popup_acceptinginvite", false); - connect(target); - }, scheduler::pipeline::main); - return true; } return false; } + + bool needs_vid_restart = false; + + bool download_files(const game::netadr_s& target, const utils::info_string& info) + { + try + { + std::vector files{}; + + const auto needs_restart = check_download_mod(info, files); + needs_vid_restart = needs_vid_restart || needs_restart; + check_download_map(info, files); + + if (files.size() > 0) + { + download::stop_download(); + download::start_download(target, info, files); + return true; + } + else if (needs_restart || needs_vid_restart) + { + command::execute("vid_restart"); + needs_vid_restart = false; + scheduler::once([=]() + { + connect(target); + }, scheduler::pipeline::main); + return true; + } + } + catch (const std::exception& e) + { + menu_error(e.what()); + return false; + } + + return false; + } + + void set_new_map(const char* mapname, const char* gametype, game::msg_t* msg) + { + char buffer[0x400] = {0}; + // dont bother disconnecting just to update the usermap loadscreen + const std::string usermap_hash = game::MSG_ReadStringLine(msg, + buffer, static_cast(sizeof(buffer))); + + if (!fastfiles::is_stock_map(mapname)) + { + const auto filename = utils::string::va("usermaps/%s/%s.ff", mapname, mapname); + const auto hash = get_file_hash(filename); + if (!hash.has_value() || hash.value() != usermap_hash) + { + command::execute("disconnect"); + scheduler::once([] + { + connect(connect_state.host); + }, scheduler::pipeline::main); + return; + } + } + + utils::hook::invoke(0x13AAD0_b, mapname, gametype); + } + + void loading_new_map_cl_stub(utils::hook::assembler& a) + { + a.pushad64(); + a.mov(r8, rdi); + a.call_aligned(set_new_map); + a.popad64(); + + a.mov(al, 1); + a.jmp(0x12FCAA_b); + } + + std::string current_sv_mapname; + + void sv_spawn_server_stub(const char* map, void* a2, void* a3, void* a4, void* a5) + { + if (!fastfiles::is_stock_map(map)) + { + fastfiles::set_usermap(map); + } + + current_sv_mapname = map; + utils::hook::invoke(0x54BBB0_b, map, a2, a3, a4, a5); + } + + utils::hook::detour net_out_of_band_print_hook; + void net_out_of_band_print_stub(game::netsrc_t sock, game::netadr_s* addr, const char* data) + { + if (!strstr(data, "loadingnewmap")) + { + return net_out_of_band_print_hook.invoke(sock, addr, data); + } + + char buffer[0x400] = {0}; + const auto is_usermap = fastfiles::usermap_exists(current_sv_mapname); + std::string hash{}; + if (is_usermap) + { + const auto filename = utils::string::va("usermaps\\%s\\%s.ff", + current_sv_mapname.data(), current_sv_mapname.data()); + const auto hash_value = get_file_hash(filename); + if (hash_value.has_value()) + { + hash = hash_value.value(); + } + } + + auto* sv_gametype = game::Dvar_FindVar("g_gametype"); + + sprintf_s(buffer, sizeof(buffer), + "loadingnewmap\n%s\n%s\n%s", current_sv_mapname.data(), sv_gametype->current.string, hash.data()); + + net_out_of_band_print_hook.invoke(sock, addr, buffer); + } } void menu_error(const std::string& error) @@ -228,6 +374,11 @@ namespace party command::execute("lui_close popup_acceptinginvite", false); } + if (game::Menu_IsMenuOpenAndVisible(0, "generic_waiting_popup_")) + { + command::execute("lui_close generic_waiting_popup_", false); + } + utils::hook::invoke(0x17D770_b, error.data(), "MENU_NOTICE"); // Com_SetLocalizedErrorMessage *reinterpret_cast(0x2ED2F78_b) = 1; } @@ -425,6 +576,11 @@ namespace party // allow custom didyouknow based on sv_motd utils::hook::call(0x1A8A3A_b, get_didyouknow_stub); + // add usermaphash to loadingnewmap command + utils::hook::jump(0x12FA68_b, utils::hook::assemble(loading_new_map_cl_stub), true); + utils::hook::call(0x54CC98_b, sv_spawn_server_stub); + net_out_of_band_print_hook.create(game::NET_OutOfBandPrint, net_out_of_band_print_stub); + command::add("map", [](const command::params& argument) { if (argument.size() != 2) @@ -651,6 +807,8 @@ namespace party network::on("getInfo", [](const game::netadr_s& target, const std::string_view& data) { + const auto mapname = get_dvar_string("mapname"); + utils::info_string info{}; info.set("challenge", std::string{data}); info.set("gamename", "H1"); @@ -658,7 +816,7 @@ namespace party info.set("gametype", get_dvar_string("g_gametype")); info.set("sv_motd", get_dvar_string("sv_motd")); info.set("xuid", utils::string::va("%llX", steam::SteamUser()->GetSteamID().bits)); - info.set("mapname", get_dvar_string("mapname")); + info.set("mapname", mapname); info.set("isPrivate", get_dvar_string("g_password").empty() ? "0" : "1"); info.set("clients", utils::string::va("%i", get_client_count())); info.set("bots", utils::string::va("%i", get_bot_count())); @@ -669,22 +827,30 @@ namespace party info.set("dedicated", utils::string::va("%i", get_dvar_bool("dedicated"))); info.set("sv_wwwBaseUrl", get_dvar_string("sv_wwwBaseUrl")); + if (!fastfiles::is_stock_map(mapname)) + { + const auto hash = get_file_hash(utils::string::va("usermaps/%s/%s.ff", mapname.data(), mapname.data())); + if (hash.has_value()) + { + info.set("usermaphash", hash.value()); + } + + const auto load_file_hash = get_file_hash(utils::string::va("usermaps/%s/%s_load.ff", mapname.data(), mapname.data())); + if (load_file_hash.has_value()) + { + info.set("usermaploadhash", load_file_hash.value()); + } + } + const auto fs_game = get_dvar_string("fs_game"); info.set("fs_game", fs_game); - if (mod_hash.has_value()) + if (!fs_game.empty()) { - info.set("modHash", mod_hash.value()); - } - else - { - const auto mod_path = fs_game + "/mod.ff"; - if (utils::io::file_exists(mod_path)) + const auto hash = get_file_hash(utils::string::va("%s/mod.ff", fs_game.data())); + if (hash.has_value()) { - const auto mod_data = utils::io::read_file(mod_path); - const auto sha = utils::cryptography::sha1::compute(mod_data, true); - mod_hash = sha; // todo: clear this somewhere - info.set("modHash", sha); + info.set("modHash", hash.value()); } } @@ -742,7 +908,7 @@ namespace party return; } - if (download_mod(target, info)) + if (download_files(target, info)) { return; } diff --git a/src/client/game/scripting/lua/context.cpp b/src/client/game/scripting/lua/context.cpp index 3acf9d9d..ed97c930 100644 --- a/src/client/game/scripting/lua/context.cpp +++ b/src/client/game/scripting/lua/context.cpp @@ -699,7 +699,7 @@ namespace scripting::lua return; } - const auto result = utils::http::get_data(url, fields_string, headers_map, [cur_task_id](size_t value) + const auto result = utils::http::get_data(url, fields_string, headers_map, [cur_task_id](size_t total, size_t value) { ::scheduler::once([cur_task_id, value]() { diff --git a/src/client/game/symbols.hpp b/src/client/game/symbols.hpp index 44ee6ff2..20e57836 100644 --- a/src/client/game/symbols.hpp +++ b/src/client/game/symbols.hpp @@ -120,6 +120,8 @@ namespace game WEAK symbol Material_RegisterHandle{0x56EA20, 0x692360}; + WEAK symbol MSG_ReadStringLine{0x0, 0x4EB9D0}; + WEAK symbol NetadrToSockadr{0x416580, 0x59E580}; WEAK symbol NET_OutOfBandPrint{0x3AA550, 0x4F1EB0}; WEAK symbol NET_SendLoopPacket{0x0, 0x4F2070}; diff --git a/src/common/utils/http.cpp b/src/common/utils/http.cpp index 509e55bf..987dadaf 100644 --- a/src/common/utils/http.cpp +++ b/src/common/utils/http.cpp @@ -8,17 +8,17 @@ namespace utils::http { struct progress_helper { - const std::function* callback{}; + const std::function* callback{}; std::exception_ptr exception{}; }; - int progress_callback(void* clientp, const curl_off_t /*dltotal*/, const curl_off_t dlnow, const curl_off_t /*ultotal*/, const curl_off_t /*ulnow*/) + int progress_callback(void* clientp, const curl_off_t dltotal, const curl_off_t dlnow, const curl_off_t /*ultotal*/, const curl_off_t /*ulnow*/) { auto* helper = static_cast(clientp); try { - if (*helper->callback && (*helper->callback)(dlnow) == -1) + if (*helper->callback && (*helper->callback)(dltotal, dlnow) == -1) { return -1; } @@ -43,7 +43,7 @@ namespace utils::http } std::optional get_data(const std::string& url, const std::string& fields, - const headers& headers, const std::function& callback) + const headers& headers, const std::function& callback) { curl_slist* header_list = nullptr; auto* curl = curl_easy_init(); @@ -74,6 +74,7 @@ namespace utils::http curl_easy_setopt(curl, CURLOPT_WRITEDATA, &buffer); curl_easy_setopt(curl, CURLOPT_XFERINFOFUNCTION, progress_callback); curl_easy_setopt(curl, CURLOPT_XFERINFODATA, &helper); + curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1); curl_easy_setopt(curl, CURLOPT_NOPROGRESS, 0); if (!fields.empty()) @@ -104,7 +105,7 @@ namespace utils::http } std::future> get_data_async(const std::string& url, const std::string& fields, - const headers& headers, const std::function& callback) + const headers& headers, const std::function& callback) { return std::async(std::launch::async, [url, fields, headers, callback]() { diff --git a/src/common/utils/http.hpp b/src/common/utils/http.hpp index 5dc2d579..d0e7c35c 100644 --- a/src/common/utils/http.hpp +++ b/src/common/utils/http.hpp @@ -18,7 +18,7 @@ namespace utils::http using headers = std::unordered_map; std::optional get_data(const std::string& url, const std::string& fields = {}, - const headers& headers = {}, const std::function& callback = {}); + const headers& headers = {}, const std::function& callback = {}); std::future> get_data_async(const std::string& url, const std::string& fields = {}, - const headers& headers = {}, const std::function& callback = {}); + const headers& headers = {}, const std::function& callback = {}); }