diff --git a/src/client/component/demo_playback.cpp b/src/client/component/demo_playback.cpp new file mode 100644 index 0000000..26ca198 --- /dev/null +++ b/src/client/component/demo_playback.cpp @@ -0,0 +1,946 @@ +#include "std_include.hpp" +#include "loader/component_loader.hpp" + +#include "demo_playback.hpp" +#include "demo_utils.hpp" + +#include "command.hpp" +#include "console.hpp" +#include "scheduler.hpp" +#include "utils/command_line.hpp" +#include "utils/hook.hpp" + +using namespace demo_utils; + +namespace // demo class +{ + class demo_playback_t + { + public: + demo_playback_t(); + + [[nodiscard]] bool is_demo_playing() const; + [[nodiscard]] bool is_demo_repeating() const; + [[nodiscard]] const std::optional& get_gamestate() const; + [[nodiscard]] bool rewind_demo(std::optional msec); + [[nodiscard]] bool start(std::filesystem::path& path, bool repeat); + [[nodiscard]] std::optional> restart(); + template auto use_predicted_data(Func func, Args&&... args); + template auto handle_fast_forward(Func func, Args&&... args); + + void parse_demo_wrapper(); + void stop(); + + private: + void repeat(); + + bool repeat_{}; + bool skip_timeout_{}; + bool retain_timescale_{}; + std::size_t post_map_header_file_offset_{}; + std::size_t pred_data_index_{}; + std::array pred_data_{}; + std::optional rewind_snap_svr_time_; + std::optional gs_; + std::vector buffer_; + std::filesystem::path path_; + std::ifstream file_; + } demo; + + demo_playback_t::demo_playback_t() + { + buffer_.reserve(MAX_SIZE); + } + + bool demo_playback_t::is_demo_playing() const + { + return file_.good() && file_.is_open(); + } + + bool demo_playback_t::is_demo_repeating() const + { + return repeat_; + } + + const std::optional& demo_playback_t::get_gamestate() const + { + return gs_; + } + + template + auto demo_playback_t::use_predicted_data(Func func, Args&& ...args) + { + return func(pred_data_, pred_data_index_, std::forward(args)...); + } + + template + auto demo_playback_t::handle_fast_forward(Func func, Args&& ...args) + { + return func(rewind_snap_svr_time_, skip_timeout_, std::forward(args)...); + } + + bool demo_playback_t::rewind_demo(std::optional msec) + { + if (!is_demo_playing()) + { + return false; + } + + // trigger demo end-of-file which will repeat the demo + repeat_ = true; + file_.seekg(0, std::ios::end); + + if (msec) + { + retain_timescale_ = true; + + const auto snap_svr_time = game::mp::cl->snap.serverTime; + if (const auto delta_time = snap_svr_time - static_cast(*msec) - 50; delta_time > 0) + { + rewind_snap_svr_time_ = delta_time; + } + } + + // workaround to get rewinding working without delay at low timescales + fast_forward_demo(1000); + + return true; + } + + bool demo_playback_t::start(std::filesystem::path& path, bool repeat) + { + // check if the file can be opened before taking any action with the demo file stream + std::ifstream file(path, std::ios::binary); + + const auto can_open = file.good() && file.is_open(); + if (!can_open) + { + return false; + } + + stop(); + + file_ = std::move(file); + assert(file_.good() && file_.is_open()); + + // (re)set data + repeat_ = repeat; + gs_.reset(); + path_ = std::move(path); + get_persistent_data() = {}; + + if (auto* demo_timescale = game::Dvar_FindVar("demotimescale"); demo_timescale) + { + demo_timescale->current.value = 1.0f; + } + + if (game::Live_SyncOnlineDataFlags(0)) + { + console::info("the demo should start to load in a few seconds\n"); + } + + return true; + } + + std::optional> demo_playback_t::restart() + { + if (!start(path_, repeat_)) + { + return path_; + } + + return std::nullopt; + } + + void demo_playback_t::parse_demo_wrapper() + { + if (!parse_demo(file_, buffer_, post_map_header_file_offset_, pred_data_, pred_data_index_, gs_)) + { + if (is_demo_repeating() && *game::mp::connstate != game::CA_DISCONNECTED) + { // don't repeat demo if it was stopped manually + repeat(); + } + else + { + stop(); + } + } + + assert(*game::mp::connstate != game::CA_DISCONNECTED || !is_demo_playing()); + } + + void demo_playback_t::stop() + { + skip_timeout_ = {}; + retain_timescale_ = {}; + post_map_header_file_offset_ = {}; + pred_data_index_ = {}; + pred_data_.fill({}); + rewind_snap_svr_time_ = {}; + gs_.reset(); + buffer_.clear(); + file_.clear(); + file_.close(); + + // ensure that sv_cheats is always disabled after demo playback + if (auto* sv_cheats = game::Dvar_FindVar("sv_cheats"); sv_cheats) + { + sv_cheats->current.enabled = false; + } + } + + void demo_playback_t::repeat() + { + assert(post_map_header_file_offset_ < 256); + + if (auto& connstate = *game::mp::connstate; connstate == game::CA_ACTIVE) + { + // prevent old snapshots from being processed; would mess up the server command sequences + connstate = game::CA_PRIMED; + } + + // reset game data + game::mp::clc->serverCommandSequence = {}; + game::mp::clc->lastExecutedServerCommand = {}; + game::mp::cgs->serverCommandSequence = {}; + get_persistent_data() = {}; + + if (auto* demo_timescale = game::Dvar_FindVar("demotimescale"); demo_timescale) + { + if (!retain_timescale_) + { + demo_timescale->current.value = 1.0f; + } + } + + // reset class data + skip_timeout_ = {}; + retain_timescale_ = {}; + pred_data_index_ = {}; + + file_.clear(); + file_.seekg(post_map_header_file_offset_, std::ios::beg); + } +} + +namespace // hooks +{ + utils::hook::detour CG_UpdateOmnvars_hook; + utils::hook::detour CG_VisionSetStartLerp_To_hook; + utils::hook::detour CL_GetPredictedPlayerInformationForServerTime_hook; + utils::hook::detour CL_GetPredictedVehicleForServerTime_hook; + utils::hook::detour CL_SetCGameTime_hook; + utils::hook::detour CL_WritePacket_hook; + utils::hook::detour GetRemoteEyeValues_hook; + utils::hook::detour LUI_IntermissionBegan_hook; + utils::hook::detour VehicleCam_UpdatePlayerControlCam_hook; + + game::dvar_t* demo_vision; + + static constexpr std::array VISION_SETS{ + "disabled", "no vision", "default", + + "ac130", "ac130_enhanced_mp", "ac130_inverted", "aftermath", "aftermath_glow", "aftermath_post", "black_bw", + "cheat_bw", "coup_sunblind", "default_night", "default_night_mp", "end_game", "missilecam", "mpintro", + "mpnuke", "mpnuke_aftermath", "mpoutro", "near_death", "near_death_mp", "nuke_global_flash", "thermal_mp", "thermal_snowlevel_mp", + + "aftermath_map", static_cast(nullptr) + }; + static_assert( + std::string_view(VISION_SETS[0]) == "disabled" && + std::string_view(VISION_SETS[1]) == "no vision" && + std::string_view(VISION_SETS[2]) == "default" && + std::string_view(VISION_SETS[VISION_SETS.size() - 2]) == "aftermath_map" && + VISION_SETS.back() == nullptr // enum dvars require a nullptr as last value + ); + + bool handle_vision_override(game::mp::ClientVisionSetData& cvs_data, std::int32_t index, const char* vision_name, std::int32_t duration, std::int32_t time) + { + if (demo.is_demo_playing()) + { + const auto vision_set = static_cast((demo_vision) ? demo_vision->current.integer : 0); + + if (vision_set == 1) + { + // enable 'post apply' vision + cvs_data.visionSetLerpData[index].style = 1; + cvs_data.postApplyLerpDest = 1.0f; + + return true; + } + else if (vision_set >= 2 && vision_set + 2 <= VISION_SETS.size()) + { + // enable 'post apply' vision + cvs_data.visionSetLerpData[index].style = 0; + cvs_data.postApplyLerpDest = 1.0f; + + if (vision_set + 2 == VISION_SETS.size()) + { + if (const auto mapname = get_mapname(false); mapname.empty()) + { + return CG_VisionSetStartLerp_To_hook.invoke(&cvs_data, index, "aftermath_post", duration, time); + } + else + { + // a small number of maps have a vision called 'aftermath_mp_mapname' + return CG_VisionSetStartLerp_To_hook.invoke(&cvs_data, index, std::format("aftermath_{}", mapname).c_str(), duration, time); + } + } + else + { + return CG_VisionSetStartLerp_To_hook.invoke(&cvs_data, index, VISION_SETS[vision_set], duration, time); + } + } + } + + return CG_VisionSetStartLerp_To_hook.invoke(&cvs_data, index, vision_name, duration, time); + } + + void handle_vision() + { + if (demo_vision && demo_vision->modified) + { + const auto vision_set = demo_vision->current.integer; + + for (auto i = 0; i < game::CS_VISIONSET_COUNT; ++i) + { + const std::string_view vision_name = (!vision_set) + ? game::mp::cg->cvsData.visionName[i] + : ""; + + handle_vision_override(game::mp::cg->cvsData, i, vision_name.data(), 1, game::mp::cl->serverTime); + } + + if (!vision_set) + { + // disable 'post apply' vision if the game hasn't used it yet + if (game::mp::cg->cvsData.visionName[5][0] == '\0') + { + game::mp::cg->cvsData.postApplyLerpDest = 0.0f; + } + } + else if (vision_set == 1) + { + std::memset(&game::mp::cg->cvsData.visionSetCurrent, 0, sizeof(game::mp::cg->cvsData.visionSetCurrent)); + } + + // reset modified flag to avoid doing the same work each frame + demo_vision->modified = false; + } + } + + void handle_fast_forward(auto old_connstate) + { + if (const auto new_connstate = *game::mp::connstate; new_connstate == game::CA_ACTIVE) + { + const auto server_time = game::mp::cl->serverTime; + const auto snap_svr_time = game::mp::cl->snap.serverTime; + + demo.handle_fast_forward([old_connstate, server_time, snap_svr_time]( + std::optional& rewind_snap_svr_time, bool& skip_timeout) + { + if (old_connstate == game::CA_PRIMED) + { + if (rewind_snap_svr_time) + { + // handle fast forward after rewinding + if (*rewind_snap_svr_time > snap_svr_time) + { + fast_forward_demo(*rewind_snap_svr_time - snap_svr_time); + } + + rewind_snap_svr_time = {}; + } + } + else if (old_connstate == game::CA_ACTIVE && !skip_timeout) + { + if (const auto delta_time = snap_svr_time - server_time; delta_time >= 1000) + { + // skip timeout between first and second snapshots (for user demos) + fast_forward_demo(delta_time); + skip_timeout = true; + } + } + }); + } + } + + void handle_demo_patches() + { + // set cg_t->demoType to DEMO_TYPE_CLIENT to disable mouse input and avoid frame interpolation issues + game::mp::cg->demoType = game::mp::DEMO_TYPE_CLIENT; + + // ensure that sv_cheats is always enabled during demo playback + if (auto* sv_cheats = game::Dvar_FindVar("sv_cheats"); sv_cheats) + { + sv_cheats->current.enabled = true; + } + + // sometimes the hud isn't updated after a weapon change; force update the weapon name and ammo (clip) when changing weapons + // various other Call of Duty games are also affected by this bug + if (game::mp::cg->weaponSelect.data != static_cast(game::mp::cg->ps.weapon)) + { + game::CG_SelectWeapon(0, game::mp::cg->ps.weapon, 0); + } + } + + void handle_demo_reading(const std::int32_t client_num) + { + if (game::Live_SyncOnlineDataFlags(0) || !demo.is_demo_playing()) + { + // set demoType to default value + // normally the game does this unconditionally, but that code has been removed + game::mp::cg->demoType = game::mp::DEMO_TYPE_NONE; + + CL_SetCGameTime_hook.invoke(client_num); + } + else + { + const auto old_connstate = *game::mp::connstate; + if (old_connstate == game::CA_ACTIVE) + { + // reset cl->newSnapshots to prevent CL_SetCGameTime from making a call to CL_AdjustTimeDelta, + // which interferes with the timescale + game::mp::cl->newSnapshots = 0; + } + + CL_SetCGameTime_hook.invoke(client_num); + + handle_fast_forward(old_connstate); + handle_vision(); + handle_demo_patches(); + + demo.parse_demo_wrapper(); + } + } + + void write_packet(const std::int32_t client_num) + { + if (!demo.is_demo_playing()) + { + CL_WritePacket_hook.invoke(client_num); + } + } + + bool get_predicted_player_data(const game::mp::clientActive_t& cl, std::int32_t ps_time, game::mp::playerState_t& ps) + { + if (!demo.is_demo_playing()) + { + return CL_GetPredictedPlayerInformationForServerTime_hook.invoke(&cl, ps_time, &ps); + } + + // hide regular hud when showing special hud for killstreak reward + // must not unset this 15th bit when dead to avoid a crash in CG_SimulateBulletFire + ps.otherFlags = (ps.pm_type == 7 || ps.remoteControlEnt != 2047 || ps.vehicleState.entity != 2047) + ? ps.otherFlags | 0x4000 + : ps.otherFlags & ~0x4000; + + demo.use_predicted_data([&ps, ps_time](const std::array& pred_data, std::size_t pred_data_index) + { + static constexpr auto pred_data_size = std::tuple_size_v>; + + // find most recent predicted player data + for (std::size_t count = 0, index = pred_data_index - 1; count < pred_data_size; ++count, --index) + { + const auto& p_data = pred_data[index % pred_data_size]; + if (p_data.cad.serverTime <= ps_time && ps_time - p_data.cad.serverTime < 500) + { + std::memcpy(&ps.origin[0], &p_data.cad.origin[0], sizeof(ps.origin)); + std::memcpy(&ps.velocity[0], &p_data.cad.velocity[0], sizeof(ps.velocity)); + std::memcpy(&ps.viewangles[0], &p_data.viewangles[0], sizeof(ps.viewangles)); + ps.bobCycle = p_data.cad.bobCycle; + ps.movementDir = p_data.cad.movementDir; + + break; + } + } + }); + + // must return true otherwise branch is taken that undoes the work of this function + return true; + } + + bool get_predicted_vehicle_data(const game::mp::clientActive_t& cl, std::int32_t ps_time, game::PlayerVehicleState& ps_vehicle) + { + if (!demo.is_demo_playing()) + { + return CL_GetPredictedVehicleForServerTime_hook.invoke(&cl, ps_time, &ps_vehicle); + } + + demo.use_predicted_data([&ps_vehicle, ps_time](const std::array& pred_data, std::size_t pred_data_index) + { + static constexpr auto pred_data_size = std::tuple_size_v>; + + // find most recent predicted vehicle data + for (std::size_t count = 0, index = pred_data_index - 1; count < pred_data_size; ++count, --index) + { + const auto& p_data = pred_data[index % pred_data_size]; + if (p_data.cad.serverTime <= ps_time && ps_time - p_data.cad.serverTime < 500) + { + const auto org_entity = ps_vehicle.entity; + ps_vehicle = p_data.cad.playerVehStateClientArchive; + ps_vehicle.entity = org_entity; + + break; + } + } + }); + + // must return true otherwise branch is taken that undoes the work of this function + return true; + } + + bool show_intermission_menu(const std::int32_t client_num, void* lua_vm) + { + // it appeared easier to prevent the intermission menu from being shown instead of closing or hiding it + + if (!demo.is_demo_playing() || !demo.is_demo_repeating()) + { + return LUI_IntermissionBegan_hook.invoke(client_num, lua_vm); + } + + // rewind demo and don't display intermission menu for repeated demos + static_cast(demo.rewind_demo(std::nullopt)); + + return false; + } + + void control_ui_element(const std::int32_t client_num, const game::mp::snapshot_t& old_snap, game::mp::snapshot_t& new_snap) + { + // it appeared easier to prevent these menus from being shown instead of closing or hiding them + + if (demo.is_demo_playing()) + { + // skip team selection menu (and loadout selection menu) + assert(std::string_view(*reinterpret_cast(game::Omnvar_GetDef(103))) == "ui_options_menu"); + new_snap.omnvars[103] = {}; + + if (demo.is_demo_repeating()) + { + // skip extinction mode end-of-game score / stats menu + assert(std::string_view(*reinterpret_cast(game::Omnvar_GetDef(194))) == "ui_alien_show_eog_score"); + new_snap.omnvars[194] = {}; + } + } + + CG_UpdateOmnvars_hook.invoke(client_num, &old_snap, &new_snap); + } + + std::int32_t update_gamestate_config_strings(game::msg_t& msg) + { + if (demo.is_demo_playing()) + { + // update command sequences and overwrite config strings if that data is available + if (const auto& gs = demo.get_gamestate(); gs) + { + game::mp::cls->gameState = gs->data; + game::mp::clc->serverCommandSequence = gs->svr_cmd_seq; + game::mp::clc->lastExecutedServerCommand = gs->svr_cmd_seq; + game::mp::cgs->serverCommandSequence = gs->svr_cmd_seq; + } + else + { + assert(is_gamestate_valid(game::mp::cls->gameState, false)); + + game::mp::clc->lastExecutedServerCommand = game::mp::clc->serverCommandSequence; + game::mp::cgs->serverCommandSequence = game::mp::clc->serverCommandSequence; + } + } + + // call MSG_ReadLong that this hook replaces + return game::MSG_ReadLong(&msg); + } + + void ignore_mouse_input_killstreak_reward_1(const std::int32_t client_num, game::mp::cg_t& cg, game::vec3_t& out_view_origin, game::vec3_t& out_view_angles) + { + auto& in_killcam = cg.inKillCam; + + if (!demo.is_demo_playing() || in_killcam) + { + VehicleCam_UpdatePlayerControlCam_hook.invoke(client_num, &cg, &out_view_origin, &out_view_angles); + } + else + { + // required for e.g. the Gryphon killstreak reward + in_killcam = 1; + VehicleCam_UpdatePlayerControlCam_hook.invoke(client_num, &cg, &out_view_origin, &out_view_angles); + in_killcam = 0; + } + } + + bool ignore_mouse_input_killstreak_reward_2(const std::int32_t client_num) + { + auto& other_flags = game::mp::cg->ps.otherFlags; + + if (!demo.is_demo_playing() || !(other_flags & 0x4000)) + { + return GetRemoteEyeValues_hook.invoke(client_num); + } + else + { + // required for e.g. the Trinity Rocket killstreak reward + other_flags &= ~0x4000; + const auto result = GetRemoteEyeValues_hook.invoke(client_num); + other_flags |= 0x4000; + + return result; + } + } +} + +namespace // command execution +{ + bool execute_playback_command_internal(std::filesystem::path& file_path, bool repeat, bool print_error) + { + if (demo.start(file_path, repeat)) + { + return true; + } + + if (print_error) + { + console::error("could not play demo %s\n", file_path.string().c_str()); + } + + return false; + } + + bool execute_playback_command_internal(std::string_view file_name, bool repeat) + { + const auto fs_basepath = get_base_path(false); + if (fs_basepath.empty()) + { + console::error("could not find fs_basepath\n"); + return false; + } + + const auto mod = get_mod_directory(); + + auto file_path1 = std::filesystem::path(fs_basepath) / std::format("demos/client/{2}/user/{0}{1}", file_name, DEMO_EXTENSION, mod); + if (execute_playback_command_internal(file_path1, repeat, false)) + { + return true; + } + + auto file_path2 = std::filesystem::path(fs_basepath) / std::format("demos/client/{2}/auto/{0}{1}", file_name, DEMO_EXTENSION, mod); + if (execute_playback_command_internal(file_path2, repeat, false)) + { + return true; + } + + auto file_path3 = std::filesystem::path(fs_basepath) / std::format("demos/client/{0}{1}", file_name, DEMO_EXTENSION); + if (execute_playback_command_internal(file_path3, repeat, false)) + { + return true; + } + + auto file_path4 = std::filesystem::path(fs_basepath) / std::format("demos/server/{2}/{0}{1}", file_name, DEMO_EXTENSION, mod); + if (execute_playback_command_internal(file_path4, repeat, false)) + { + return true; + } + + auto file_path5 = std::filesystem::path(fs_basepath) / std::format("demos/server/{0}{1}", file_name, DEMO_EXTENSION); + if (execute_playback_command_internal(file_path5, repeat, false)) + { + return true; + } + + auto file_path6 = std::filesystem::path(fs_basepath) / std::format("demos/{0}{1}", file_name, DEMO_EXTENSION); + if (execute_playback_command_internal(file_path6, repeat, false)) + { + return true; + } + + console::error("could not play demo %s\n", file_path1.string().c_str()); + console::error("could not play demo %s\n", file_path2.string().c_str()); + console::error("could not play demo %s\n", file_path3.string().c_str()); + console::error("could not play demo %s\n", file_path4.string().c_str()); + console::error("could not play demo %s\n", file_path5.string().c_str()); + console::error("could not play demo %s\n", file_path6.string().c_str()); + + return false; + } + + template + bool execute_playback_command(const command::params& params) + { + struct args_t + { + bool fullpath{}; + bool repeat{}; + }; + + auto parsed_arguments = [¶ms]() -> std::optional + { + if (params.size() == 3) + { + if (std::string_view(params.get(2)) == "fullpath") + { + return args_t{ true, false }; + } + else if (std::string_view(params.get(2)) == "repeat") + { + return args_t{ false, true }; + } + else + { + return {}; + } + } + else if (params.size() == 4) + { + if (std::string_view(params.get(2)) == "fullpath" && std::string_view(params.get(3)) == "repeat") + { + return args_t{ true, true }; + } + else + { + return {}; + } + } + else + { + return args_t{ false, false }; + } + }(); + + if (params.size() < 2 || params.size() > 4 || !parsed_arguments) + { + console::info("usage: demoplay , fullpath(optional), repeat(optional)\n"); + return false; + } + + if constexpr (!force_playback) + { + if (!demo.is_demo_playing() && game::CL_IsCgameInitialized()) + { + console::warn("could not play demo; please disconnect first\n"); + return false; + } + } + + if (!parsed_arguments->fullpath) + { + return execute_playback_command_internal(params.get(1), parsed_arguments->repeat); + } + else + { + auto file_path = std::filesystem::path(params.get(1)); + return execute_playback_command_internal(file_path, parsed_arguments->repeat, true); + } + } + + void execute_playback_instant_play_command(const command::params& params) + { + // the demo recording code (demo_recording_t::shutdown_watcher) should take care of properly closing any recording demo files! + + const auto demo_playing = demo.is_demo_playing(); + if (execute_playback_command(params)) + { + if (auto* sv_running = game::Dvar_FindVar("sv_running"); sv_running && sv_running->current.enabled) + { + // demo cannot play if local server is still considered to be running + sv_running->current.enabled = false; + } + + if (!demo_playing) + { + if (auto& connstate = *game::mp::connstate; connstate == game::CA_ACTIVE) + { + // prevent old snapshots from being processed, + // would mess up the server command sequences and server times + connstate = game::CA_PRIMED; + } + } + } + } + + void execute_playback_replay_command(const command::params& params) + { + if (params.size() != 1) + { + console::info("usage: demoreplay\n"); + return; + } + + if (const auto path = demo.restart(); path) + { + console::error("could not play demo %s\n", path->get().string().c_str()); + } + } + + void execute_playback_rewind_command(const command::params& params) + { + if (params.size() < 1 || params.size() > 2) + { + console::info("usage: demorewind (optional)\n"); + return; + } + + if (params.size() == 2) + { + const auto msec = std::atoi(params.get(1)); + if (msec <= 0) + { + console::error("demorewind invalid value %s\n", params.get(1)); + return; + } + + if (!demo.rewind_demo(msec)) + { + console::error("demo must be playing to rewind\n"); + } + } + else if (!demo.rewind_demo(std::nullopt)) + { + console::error("demo must be playing to rewind\n"); + } + } + + void execute_playback_fast_forward_command(const command::params& params) + { + if (params.size() != 2) + { + console::info("usage: demoforward \n"); + return; + } + + const auto msec = std::atoi(params.get(1)); + if (msec <= 0) + { + console::error("demoforward invalid value %s\n", params.get(1)); + return; + } + + if (demo.is_demo_playing()) + { + fast_forward_demo(static_cast(msec)); + } + else + { + console::error("demo must be playing to fast forward\n"); + } + } +} + +namespace demo_playback +{ + bool playing() + { + return demo.is_demo_playing(); + } + + bool startplay(std::string_view file_name, bool repeat) + { + return execute_playback_command_internal(file_name, repeat); + } + + bool startplay(std::filesystem::path& file_name, bool repeat) + { + return execute_playback_command_internal(file_name, repeat, true); + } + + void stopplay() + { + demo.stop(); + } + + class component final : public component_interface + { + public: + void post_unpack() override + { + if (!game::environment::is_mp()) + { + return; + } + + // check run-time address assertions + check_address_assertions(); + + // primary hook for demo playback; read demo data after server time has been updated + CL_SetCGameTime_hook.create(game::CL_SetCGameTime, handle_demo_reading); + + // don't write packets when playing a demo + CL_WritePacket_hook.create(game::CL_WritePacket, write_packet); + + // replace functions to get predicted data, because they skip the viewangles + CL_GetPredictedPlayerInformationForServerTime_hook.create( + game::CL_GetPredictedPlayerInformationForServerTime, get_predicted_player_data); + CL_GetPredictedVehicleForServerTime_hook.create( + game::CL_GetPredictedVehicleForServerTime, get_predicted_vehicle_data); + + // ignore mouse input when updating the camera for certain killstreak rewards + VehicleCam_UpdatePlayerControlCam_hook.create( + game::VehicleCam_UpdatePlayerControlCam, ignore_mouse_input_killstreak_reward_1); + GetRemoteEyeValues_hook.create( + game::GetRemoteEyeValues, ignore_mouse_input_killstreak_reward_2); + + // optional skip of intermission menu + LUI_IntermissionBegan_hook.create(game::LUI_IntermissionBegan, show_intermission_menu); + + // don't open any menu when demo playback has started + CG_UpdateOmnvars_hook.create(game::CG_UpdateOmnvars, control_ui_element); + + // allow user to override visual settings + CG_VisionSetStartLerp_To_hook.create(0x1402A3200, handle_vision_override); + demo_vision = game::Dvar_RegisterEnum( + "demovision", const_cast(VISION_SETS.data()), 0, game::DVAR_FLAG_NONE, "Override vision set for demos"); + + // update the gamestate configs strings with 'live' data from the demo (in CL_ParseGamestate) + utils::hook::call(0x1402CCD56, update_gamestate_config_strings); + + // don't let the game set cg_t->demoType in CG_DrawActiveFrame, + // otherwise demo playback will have mouse input and suffer from frame interpolation issues + utils::hook::nop(0x14029981C, 6); + + // replace thread local storage lookup for cg.bgs in function CG_EmissiveBlendCommand with hardcoded address + // when CG_ExecuteNewServerCommands is called manually (e.g. from demo_utils::process_server_commands), + // this address in tls may be a nullptr and the game could crash here (in extinction mode at least) + static constexpr auto patch_address = 0x14028844B; + static constexpr std::array patch{ 0x48, 0x8D, 0x1D, 0x68, 0xBC, 0x59, 0x01 }; + + utils::hook::nop(patch_address, 0xE); + utils::hook::nop(patch_address + 0x1C, 4); + utils::hook::nop(patch_address + 0x22, 4); + utils::hook::copy(patch_address + 0xE, patch.data(), patch.size()); // lea rbx, [0x1418240C8] (point RBX to cg.bgs) + assert(std::bit_cast(&game::mp::cg->bgs) == + patch_address + 0xE + patch.size() + *reinterpret_cast(patch_address + 0x11)); + + // add console command support to play demos + command::add("demoplay", execute_playback_command); + + // add experimental command console support to play demos, + // skip disconnecting from local or online server and skip map (re)loading + command::add("demoinstantplay", execute_playback_instant_play_command); + + // replay most recent demo (if any) + command::add("demoreplay", execute_playback_replay_command); + + // rewind demo (by N msec) + command::add("demorewind", execute_playback_rewind_command); + + // fast forward demo by N msec + command::add("demoforward", execute_playback_fast_forward_command); + + // add command line support to play demos; this makes it possible to double click on a demo file to play it + const auto& args = utils::command_line::get_args(); + for (const auto& arg : args) + { + if (arg.extension() == DEMO_EXTENSION) + { + scheduler::on_game_initialized([cmd = std::format(R"(demoplay "{}" fullpath repeat)", arg.string())]() mutable + { + command::execute(std::move(cmd), true); + }, scheduler::main); + + console::info("the demo should start to load in a few seconds\n"); + break; + } + } + } + }; +} + +REGISTER_COMPONENT(demo_playback::component) diff --git a/src/client/component/demo_playback.hpp b/src/client/component/demo_playback.hpp new file mode 100644 index 0000000..149978f --- /dev/null +++ b/src/client/component/demo_playback.hpp @@ -0,0 +1,9 @@ +#pragma once + +namespace demo_playback +{ + [[nodiscard]] bool playing(); + [[nodiscard]] bool startplay(std::string_view file_name, bool repeat = true); + [[nodiscard]] bool startplay(std::filesystem::path& file_path, bool repeat = true); + [[nodiscard]] void stopplay(); +} diff --git a/src/client/component/demo_recording.cpp b/src/client/component/demo_recording.cpp new file mode 100644 index 0000000..66c1e6d --- /dev/null +++ b/src/client/component/demo_recording.cpp @@ -0,0 +1,746 @@ +#include "std_include.hpp" +#include "loader/component_loader.hpp" + +#include "demo_playback.hpp" +#include "demo_recording.hpp" +#include "demo_utils.hpp" + +#include "command.hpp" +#include "console.hpp" +#include "game/game.hpp" +#include "scheduler.hpp" +#include "utils/hook.hpp" + +using namespace demo_utils; + +namespace // demo class +{ + game::dvar_t* demo_autorecord; + + class demo_recording_t + { + static constexpr auto NON_DELTA_SNAPSHOT_COUNT = 4; + + struct stored_times_t + { + std::int32_t flush_time{}; + std::int32_t predicted_data_time{}; + std::int32_t cur_svr_time{}; + std::int32_t first_svr_time_user_file{}; + std::int32_t first_svr_time_auto_file{}; + }; + + public: + demo_recording_t() = default; + ~demo_recording_t(); + demo_recording_t(const demo_recording_t&) = delete; + demo_recording_t(demo_recording_t&&) noexcept = delete; + demo_recording_t& operator=(const demo_recording_t&) = delete; + demo_recording_t& operator=(demo_recording_t&&) noexcept = delete; + + [[nodiscard]] std::uint32_t get_demo_count() const; + [[nodiscard]] std::int32_t get_demo_length() const; + [[nodiscard]] bool is_recording() const; + [[nodiscard]] bool non_delta_data_requested() const; + [[nodiscard]] bool start(const std::filesystem::path& user_path, const std::filesystem::path& auto_path); + [[nodiscard]] bool stop(); + + void shutdown_watcher(); + void process_network_data(auto old_connstate, auto new_connstate, std::span network_data); + void process_predicted_data(); + + private: + [[nodiscard]] bool is_auto_recording() const; + [[nodiscard]] bool non_delta_data_request_acknowledged(); + [[nodiscard]] bool start_user_file(const std::filesystem::path& path); + [[nodiscard]] bool start_auto_file(const std::filesystem::path& path); + [[nodiscard]] static bool stop_internal(std::ofstream& file, std::int32_t first_svr_time, std::int32_t last_svr_time); + + void flush_filestreams(); + void check_auto_recording(); + void process_old_data(); + void process_new_gamestate(std::span network_data); + void process_first_snapshot(std::span network_data); + void process_special_network_data(auto old_connstate, auto new_connstate, std::span network_data); + void process_helo_pilot_turret_fire(); + + std::uint32_t demo_count_{}; + std::uint32_t non_delta_count_{}; + stored_times_t times_; + buffer_t crit_data_; + std::ofstream user_file_; + buffer_t auto_buffer_; + std::ofstream auto_file_; + } demo; + + demo_recording_t::~demo_recording_t() + { + process_old_data(); + } + + std::uint32_t demo_recording_t::get_demo_count() const + { + return demo_count_; + } + + std::int32_t demo_recording_t::get_demo_length() const + { + return (times_.cur_svr_time - times_.first_svr_time_user_file) / 1000; + } + + bool demo_recording_t::is_recording() const + { + return user_file_.good() && user_file_.is_open(); + } + + bool demo_recording_t::is_auto_recording() const + { + return auto_file_.good() && auto_file_.is_open(); + } + + bool demo_recording_t::non_delta_data_requested() const + { + return non_delta_count_ > 0; + } + + bool demo_recording_t::non_delta_data_request_acknowledged() + { + if (non_delta_data_requested()) + { + const auto delta_num = game::mp::cl->snap.deltaNum; + if (static constexpr auto no_delta = -1; delta_num == no_delta) + { + --non_delta_count_; + return true; + } + } + + return false; + } + + bool demo_recording_t::start_user_file(const std::filesystem::path& path) + { + const auto crit_data = crit_data_.get(); + if (!crit_data.size()) + { + return false; + } + + std::ofstream file(path, std::ios::binary); + if (!file.good() || !file.is_open()) + { + return false; + } + + write_general_header(file); + + // store mod name so if there's mod support the mod can be loaded + write_mod_header(file); + + // store map name and game type so that the map can be preloaded + if (!write_map_header(file)) + { + return false; + } + + // store the current gamestate config strings so that the original strings are overwritten if they were modified + if (!write_gamestate_data(file)) + { + return false; + } + + // required for demos (and live matches) to function at all + file.write(reinterpret_cast(crit_data.data()), crit_data.size()); + + // set the non-delta snapshot count, the demo won't be valid otherwise + non_delta_count_ = NON_DELTA_SNAPSHOT_COUNT; + + // store first server time so that demo length can be tracked + times_.first_svr_time_user_file = game::mp::cl->snap.serverTime; + + // increase demo count for the text render function to work properly + ++demo_count_; + + user_file_ = std::move(file); + + return true; + } + + bool demo_recording_t::start_auto_file(const std::filesystem::path& path) + { + std::ofstream file(path, std::ios::binary); + if (!file.good() || !file.is_open()) + { + return false; + } + + const auto buffer = auto_buffer_.get(); + file.write(reinterpret_cast(buffer.data()), buffer.size()); + + auto_file_ = std::move(file); + auto_buffer_.clear(); + + return true; + } + + bool demo_recording_t::start(const std::filesystem::path& user_path, const std::filesystem::path& auto_path) + { + if (!is_recording()) + { + if (!is_auto_recording() && !start_auto_file(auto_path)) + { + return false; + } + + if (!start_user_file(user_path)) + { + return false; + } + + return true; + } + + return false; + } + + bool demo_recording_t::stop_internal(std::ofstream& file, std::int32_t first_svr_time, std::int32_t last_svr_time) + { + write_general_footer(file, first_svr_time, last_svr_time); + write_end_of_file(file); + + file.flush(); + file.close(); + + return file.good() && !file.is_open(); + } + + bool demo_recording_t::stop() + { + return is_recording() && stop_internal(user_file_, times_.first_svr_time_user_file, times_.cur_svr_time); + } + + void demo_recording_t::process_old_data() + { + if (is_recording() && !stop()) + { + console::error("could not stop user demo\n"); + } + + if (is_auto_recording() && !stop_internal(auto_file_, times_.first_svr_time_auto_file, times_.cur_svr_time)) + { + console::error("could not close auto demo\n"); + } + + demo_count_ = {}; + non_delta_count_ = {}; + times_ = {}; + crit_data_.clear(); + auto_buffer_.clear(); + assert(!is_recording()); + assert(!is_auto_recording()); + } + + void demo_recording_t::shutdown_watcher() + { + auto execute_on_disconnect = [this, cgame_init = false]() mutable + { + if (demo_playback::playing()) + { + if (is_recording() || is_auto_recording()) + { + // this is only relevant when playing back demos without disconnecting from the server (instant play) + cgame_init = false; + process_old_data(); + } + return; + } + + if (!cgame_init && game::CL_IsCgameInitialized()) + { + cgame_init = true; + } + + if (cgame_init && !game::CL_IsCgameInitialized()) + { + cgame_init = false; + process_old_data(); + } + }; + + scheduler::loop(execute_on_disconnect, scheduler::main); + } + + void demo_recording_t::flush_filestreams() + { + const auto real_time = game::mp::cls->realtime; + if (real_time >= times_.flush_time + 15'000) + { + times_.flush_time = real_time; + + if (is_recording()) + { + user_file_.flush(); + } + if (is_auto_recording()) + { + auto_file_.flush(); + } + } + } + + void demo_recording_t::check_auto_recording() + { + if (!is_auto_recording() && times_.first_svr_time_auto_file && demo_autorecord && demo_autorecord->current.enabled) + { + assert(!game::Live_SyncOnlineDataFlags(0) && game::CL_IsCgameInitialized()); + + const auto opt_auto_dir = create_directory_auto_demo(); + if (!opt_auto_dir) + { + console::error("could not create demo auto directory\n"); + return; + } + + const auto opt_auto_path = create_path_auto_demo(*opt_auto_dir); + if (!opt_auto_path) + { + console::error("could not create demo auto file\n"); + return; + } + + if (!start_auto_file(*opt_auto_path)) + { + // turn auto recording off to prevent spamming the console with errors + demo_autorecord->current.enabled = false; + + console::error("could not create demo auto file %s\n", opt_auto_path->string().c_str()); + return; + } + } + } + + void demo_recording_t::process_new_gamestate(std::span network_data) + { + process_old_data(); + + // store gamestate for user file + write_network_data(crit_data_, network_data); + + // reserve memory (low size if auto recording is enabled, because then the demo writes almost directly to disk) + const auto reserve_size = (demo_autorecord && demo_autorecord->current.enabled) ? 8192 : 1024 * 1024; + auto_buffer_.reserve_memory(reserve_size); + + // write headers to auto buffer (ahead of the gamestate itself) + write_general_header(auto_buffer_); + write_mod_header(auto_buffer_); + write_map_header(auto_buffer_); + } + + void demo_recording_t::process_first_snapshot(std::span network_data) + { + assert(!is_recording() && crit_data_.size() + && !times_.cur_svr_time && !times_.first_svr_time_user_file && !times_.first_svr_time_auto_file); + + // store first snapshot for user file + write_network_data(crit_data_, network_data); + + times_.first_svr_time_auto_file = game::mp::cl->snap.serverTime; + } + + void demo_recording_t::process_special_network_data(auto old_connstate, auto new_connstate, std::span network_data) + { + assert(new_connstate >= game::CA_PRIMED); + + if (old_connstate < game::CA_LOADING) + { + assert(!game::mp::cl->snap.valid && !game::mp::cl->snap.serverTime); + + process_new_gamestate(network_data); + } + else if (old_connstate >= game::CA_PRIMED) + { + if (game::mp::cl->snap.valid && game::mp::cl->snap.serverTime && !times_.cur_svr_time) + { + process_first_snapshot(network_data); + } + + times_.cur_svr_time = game::mp::cl->snap.serverTime; + } + } + + void demo_recording_t::process_network_data(auto old_connstate, auto new_connstate, std::span network_data) + { + if (new_connstate < game::CA_PRIMED) + { + return; + } + + process_special_network_data(old_connstate, new_connstate, network_data); + check_auto_recording(); + + if (is_recording()) + { + if (!non_delta_data_requested() || non_delta_data_request_acknowledged()) + { + write_network_data(user_file_, network_data); + } + } + + if (is_auto_recording()) + { + write_network_data(auto_file_, network_data); + } + else + { + write_network_data(auto_buffer_, network_data); + } + + flush_filestreams(); + } + + void demo_recording_t::process_helo_pilot_turret_fire() + { + const auto turret_fire = cpy_cast(reinterpret_cast(0x1419A9394)); + if (turret_fire > 0) + { + if (is_recording()) + { + write_helo_pilot_turret_fire(user_file_, turret_fire); + } + + if (is_auto_recording()) + { + write_helo_pilot_turret_fire(auto_file_, turret_fire); + } + else + { + write_helo_pilot_turret_fire(auto_buffer_, turret_fire); + } + } + } + + void demo_recording_t::process_predicted_data() + { + process_helo_pilot_turret_fire(); + + if (is_recording()) + { + // only one predicted data packet is needed per (1000 / sv_fps) msec + // the best way to limit the amount of predicted data is to attempt to predict the next playerState_t::commandTime + // because during demo playback the playerState_t::commandTime determines which predicted data is used + + const auto& cl = *game::mp::cl; + const auto cmd_number = static_cast(cl.cmdNumber); + const auto pred_svr_time_delta = cl.cmds[cmd_number % std::size(cl.cmds)].serverTime + - cl.cmds[(cmd_number - 1) % std::size(cl.cmds)].serverTime; + + const auto pred_svr_time = cl.cgamePredictedDataServerTime; + const auto svr_time_delta = std::clamp(cl.snap.serverTime - cl.oldSnapServerTime, 50, 100); + + const auto cur_cmd_time = cl.snap.ps.commandTime; + const auto next_cmd_time = cur_cmd_time + svr_time_delta; + const auto threshold = (3 * pred_svr_time_delta) / 2; + + if (pred_svr_time + threshold >= next_cmd_time && next_cmd_time + threshold >= pred_svr_time) + { + times_.predicted_data_time = cl.serverTime; + write_predicted_data(user_file_); + } + else if (cl.serverTime >= times_.predicted_data_time + svr_time_delta - 5) + { + times_.predicted_data_time = cl.serverTime; + write_predicted_data(user_file_); + } + } + + if (is_auto_recording()) + { + write_predicted_data(auto_file_); + } + else + { + write_predicted_data(auto_buffer_); + } + } +} + +namespace // hooks +{ + utils::hook::detour CL_ParseServerMessage_hook; + + void capture_data(const std::int32_t client_num, game::msg_t& msg) + { + const auto old_connstate = *game::mp::connstate; + CL_ParseServerMessage_hook.invoke(client_num, &msg); + const auto new_connstate = *game::mp::connstate; + + if (demo_playback::playing()) + { + return; + } + + if (new_connstate < game::CA_DISCONNECTED || new_connstate > game::CA_ACTIVE) + { + console::warn("invalid connection state after processing data in CL_PacketEvent\n"); + return; + } + + if (!msg.data || msg.overflowed || static_cast(msg.cursize) > MAX_SIZE) + { + if (demo.is_recording()) + { + console::warn("invalid data in CL_PacketEvent\n"); + } + return; + } + + demo.process_network_data(old_connstate, new_connstate, + std::span(reinterpret_cast(msg.data), msg.cursize)); + } + + void capture_predicted_data(game::msg_t& msg, std::int32_t value, std::uint32_t bits) + { + if (!demo_playback::playing()) + { + demo.process_predicted_data(); + } + + // call MSG_WriteBits that this hook replaces + game::MSG_WriteBits(&msg, value, bits); + } + + void request_non_delta_data(game::msg_t& msg, std::int32_t value, std::uint32_t bits) + { + if (!demo_playback::playing() && demo.non_delta_data_requested()) + { + // request a non-delta snapshot from the server + // to have a valid start for a new (user) demo + value = 1; + } + + // call MSG_WriteBits that this hook replaces + game::MSG_WriteBits(&msg, value, bits); + } +} + +namespace // command execution +{ + void add_recording_text_to_hud(const std::filesystem::path& path) + { + const auto* font = game::R_RegisterFont("fonts/normalfont"); + if (!font) + { + console::error("could not register font\n"); + return; + } + + const auto* view_placement = game::ScrPlace_GetViewPlacement(); + if (!view_placement) + { + console::error("could not find view placement\n"); + return; + } + + auto create_resources = [&path, font, view_placement]() + { + struct resources_t + { + const game::Font_t* font{}; + float y{}; + std::int32_t demo_length{}; + std::uint32_t demo_count{}; + std::uint32_t org_text_size{}; + std::string text; + }; + + resources_t res{ + .font = font, + .y = view_placement->realViewportSize[1], + .demo_length = demo.get_demo_length(), + .demo_count = demo.get_demo_count(), + .text = "recording: " + path.stem().string() + }; + + res.org_text_size = static_cast(res.text.size()); + res.text.reserve(res.text.size() + 12); + + return res; + }; + + scheduler::schedule([res = create_resources()]() mutable + { + if (!demo.is_recording() || res.demo_count != demo.get_demo_count() || !game::CL_IsCgameInitialized()) + { + return scheduler::cond_end; + } + + if (res.demo_length != demo.get_demo_length()) + { + res.demo_length = demo.get_demo_length(); + + // resize to original size and append demo length in seconds + res.text.resize(res.org_text_size); + res.text += std::format(" ({} sec)", res.demo_length); + } + + static constexpr std::array color_red{ 178.0f / 255.0f, 34.0f / 255.0f, 34.0f / 255.0f, 1.0f }; + + // draw in bottom left corner + game::R_AddCmdDrawText(res.text.c_str(), std::numeric_limits::max(), + res.font, 10.0f, res.y - 5.0f, 0.75f, 0.75f, 0.0f, color_red.data(), 0); + + return scheduler::cond_continue; + }, scheduler::renderer); + } + + bool execute_demo_start_command_internal(std::string_view file_name, bool overwrite) + { + if (game::Live_SyncOnlineDataFlags(0) || !game::CL_IsCgameInitialized() || *game::mp::connstate != game::CA_ACTIVE) + { + console::warn("must be in a match to record a demo\n"); + return false; + } + + if (demo.is_recording()) + { + console::warn("must stop demo recording before starting to record\n"); + return false; + } + + if (demo_playback::playing()) + { + console::warn("cannot record a demo during demo playback\n"); + return false; + } + + const auto opt_user_dir = create_directory_user_demo(); + if (!opt_user_dir) + { + console::error("could not create demo user directory\n"); + return false; + } + + const auto opt_auto_dir = create_directory_auto_demo(); + if (!opt_auto_dir) + { + console::error("could not create demo auto directory\n"); + return false; + } + + const auto opt_user_path = (file_name.empty()) + ? create_path_user_demo(*opt_user_dir) + : create_path_user_demo(*opt_user_dir, file_name, overwrite); + if (!opt_user_path) + { + console::error("could not create demo user file\n"); + return false; + } + + const auto opt_auto_path = create_path_auto_demo(*opt_auto_dir); + if (!opt_auto_path) + { + console::error("could not create demo auto file\n"); + return false; + } + + if (!demo.start(*opt_user_path, *opt_auto_path)) + { + console::error("could not create demo user file %s\n", opt_user_path->string().c_str()); + console::error("could not create demo auto file %s\n", opt_auto_path->string().c_str()); + return false; + } + + add_recording_text_to_hud(*opt_user_path); + + return true; + } + + void execute_demo_start_command(const command::params& params) + { + if (params.size() < 1 || params.size() > 3 || (params.size() == 3 && std::string_view(params.get(2)) != "overwrite")) + { + console::info("usage: demostart (optional), overwrite(optional)\n"); + return; + } + + assert(params.size() != 3 || (params.size() == 3 && std::string_view(params.get(2)) == "overwrite")); + execute_demo_start_command_internal((params.size() >= 2) ? params.get(1) : std::string_view{}, (params.size() == 3)); + } + + void execute_demo_stop_command(const command::params& params) + { + if (params.size() != 1) + { + console::info("usage: demostop\n"); + return; + } + + if (demo.is_recording() && !demo.stop()) + { + console::error("could not stop demo\n"); + } + } +} + +namespace demo_recording +{ + bool recording() + { + return demo.is_recording(); + } + + bool startrecord(std::string_view file_name, bool overwrite) + { + return execute_demo_start_command_internal(file_name, overwrite); + } + + bool demo_recording::stoprecord() + { + return demo.stop(); + } + + class component final : public component_interface + { + public: + void post_unpack() override + { + if (!game::environment::is_mp()) + { + return; + } + + // check run-time address assertions + check_address_assertions(); + + // capture incoming packets + CL_ParseServerMessage_hook.create(game::CL_ParseServerMessage, capture_data); + + // capture client predicted data (in CL_WritePacket) + utils::hook::call(0x1402C21B9, capture_predicted_data); + + // request the server for no delta data (in CL_WritePacket) + utils::hook::call(0x1402C2026, request_non_delta_data); + + // add support to auto record + demo_autorecord = game::Dvar_RegisterBool( + "demoautorecord", false, game::DVAR_FLAG_NONE, "Automatically start recording a demo."); + + // add console command support to record demos + command::add("demostart", execute_demo_start_command); + command::add("demostop", execute_demo_stop_command); + + scheduler::on_game_initialized([]() + { + demo.shutdown_watcher(); + + // check if demo directories exist / could be created + if (!can_create_demo_directories()) + { + console::error("could not create demo directories\n"); + } + }, scheduler::main); + } + }; +} + +REGISTER_COMPONENT(demo_recording::component) diff --git a/src/client/component/demo_recording.hpp b/src/client/component/demo_recording.hpp new file mode 100644 index 0000000..99d67ea --- /dev/null +++ b/src/client/component/demo_recording.hpp @@ -0,0 +1,8 @@ +#pragma once + +namespace demo_recording +{ + [[nodiscard]] bool recording(); + [[nodiscard]] bool startrecord(std::string_view file_name = {}, bool overwrite = {}); + [[nodiscard]] bool stoprecord(); +} diff --git a/src/client/component/demo_sv_recording.cpp b/src/client/component/demo_sv_recording.cpp new file mode 100644 index 0000000..11af494 --- /dev/null +++ b/src/client/component/demo_sv_recording.cpp @@ -0,0 +1,664 @@ +#include "std_include.hpp" +#include "loader/component_loader.hpp" + +#include "demo_sv_recording.hpp" +#include "demo_utils.hpp" + +#include "command.hpp" +#include "console.hpp" +#include "scheduler.hpp" +#include "utils/hook.hpp" +#include "utils/string.hpp" + +using namespace demo_utils; + +namespace // checked client num class +{ + inline constexpr std::size_t SV_MAX_CLIENTS = + sizeof(game::mp::serverStatic_t::clients) / sizeof(game::mp::serverStatic_t::clients[0]); + + class sv_client_num_t + { + public: + explicit sv_client_num_t(std::size_t client_num); + explicit sv_client_num_t(std::string_view client_id); + + [[nodiscard]] bool valid() const; + [[nodiscard]] std::uint8_t value() const; + + private: + [[nodiscard]] static std::optional check_client_num(std::size_t client_num); + [[nodiscard]] static std::optional parse_client_id(std::string_view str); + + const std::optional client_num_; + }; + + sv_client_num_t::sv_client_num_t(std::size_t client_num) + : client_num_(check_client_num(client_num)) + { + assert(valid()); + } + + sv_client_num_t::sv_client_num_t(std::string_view client_id) + : client_num_(parse_client_id(client_id)) + {} + + bool sv_client_num_t::valid() const + { + return client_num_.has_value(); + } + + std::uint8_t sv_client_num_t::value() const + { + assert(valid()); + + return client_num_.value(); + } + + std::optional sv_client_num_t::check_client_num(std::size_t client_num) + { + if (client_num >= SV_MAX_CLIENTS) + { + return {}; + } + + if (client_num >= static_cast(game::mp::svs->clientCount)) + { + return {}; + } + + assert(game::mp::svs->clientCount == game::Dvar_FindVar("sv_maxclients")->current.integer); + assert(client_num == static_cast(game::mp::svs->clients[client_num].gentity->s.number)); + + return static_cast(client_num); + } + + std::optional sv_client_num_t::parse_client_id(std::string_view str) + { + if (str.starts_with("pid")) + { + const auto pid = std::string_view(str.begin() + 3, str.end()); + const auto all_digits = std::all_of(pid.begin(), pid.end(), [](unsigned char c) + { + return std::isdigit(c); + }); + + auto value = std::numeric_limits::max(); + const auto ec = std::from_chars(pid.data(), pid.data() + pid.size(), value).ec; + + if (all_digits && ec == std::errc{}) + { + const auto client_num = check_client_num(value); + if (client_num) + { + return client_num; + } + } + } + + for (const auto& cl : game::mp::svs->clients) + { + if (cl.header.state != game::CS_ACTIVE || str != utils::string::strip(cl.name, true)) + { + continue; + } + + const auto client_num = check_client_num(cl.gentity->s.number); + if (client_num) + { + return client_num; + } + } + + return {}; + } +} + +namespace // server demos class +{ + game::dvar_t* sv_demos; + game::dvar_t* sv_demo_autorecord; + + bool sv_execute_demo_start_command_internal(sv_client_num_t client_num, std::string_view file_name, bool overwrite); + + class server_rotation_t + { + struct data_t + { + bool svr_restart_bit{}; + std::size_t map_hash{}; + std::size_t gametype_hash{}; + }; + + public: + [[nodiscard]] bool has_changed() + { + const auto svr_restart_bit = static_cast(game::mp::svs->snapFlagServerBit & 4); + const auto has_value = data_.has_value(); + + if (!has_value || data_->svr_restart_bit != svr_restart_bit) + { + const auto dvar_mapname = get_mapname(false); + const auto dvar_gametype = get_gametype(false); + + if (dvar_mapname.empty() || dvar_gametype.empty()) + { + console::error("the server demo recording code may not function properly if dvars mapname or g_gametype are not available\n"); + } + + const auto map_hash = std::hash()(dvar_mapname); + const auto gametype_hash = std::hash()(dvar_gametype); + + if (!has_value || (data_->map_hash != map_hash || data_->gametype_hash != gametype_hash)) + { + data_.emplace(data_t{ + .svr_restart_bit = svr_restart_bit, + .map_hash = map_hash, + .gametype_hash = gametype_hash + }); + + return has_value; + } + + // server has not rotated despite restart bit change + data_->svr_restart_bit = svr_restart_bit; + } + + return false; + } + + private: + std::optional data_; + }; + + struct client_data_t + { + private: + struct stored_times_t + { + std::int32_t first_svr_time{}; + std::int32_t cur_svr_time{}; + std::optional last_conn_time; + }; + + public: + [[nodiscard]] bool is_buffer_active() const + { + return buffer_active; + } + + [[nodiscard]] bool is_recording() const + { + return file_active; + } + + bool buffer_active{}; + bool file_active{}; + stored_times_t times; + buffer_t buffer; + std::ofstream file; + }; + + struct sv_demo_recordings_t + { + public: + sv_demo_recordings_t() = default; + ~sv_demo_recordings_t(); + sv_demo_recordings_t(const sv_demo_recordings_t&) = delete; + sv_demo_recordings_t(sv_demo_recordings_t&&) noexcept = delete; + sv_demo_recordings_t& operator=(const sv_demo_recordings_t&) = delete; + sv_demo_recordings_t& operator=(sv_demo_recordings_t&&) noexcept = delete; + + [[nodiscard]] bool is_recording(sv_client_num_t client_num) const; + [[nodiscard]] bool start(sv_client_num_t client_num, const std::filesystem::path& path); + [[nodiscard]] bool stop(sv_client_num_t client_num); + + void write_data(sv_client_num_t client_num, const game::mp::client_t& cl, + std::span network_data, bool client_loading); + void shutdown_watcher(); + + private: + [[nodiscard]] const client_data_t& get_client(sv_client_num_t client_num) const; + [[nodiscard]] client_data_t& get_client(sv_client_num_t client_num); + + bool stop_internal(client_data_t& client, bool reset_client); + bool update_state(sv_client_num_t client_num, std::int32_t last_conn_time, bool client_loading); + void process_old_data(); + + server_rotation_t svr_rotation_data_; + std::array client_data_{}; + } demos; + + sv_demo_recordings_t::~sv_demo_recordings_t() + { + process_old_data(); + } + + const client_data_t& sv_demo_recordings_t::get_client(sv_client_num_t client_num) const + { + assert(client_num.valid()); + + return client_data_[client_num.value()]; + } + + client_data_t& sv_demo_recordings_t::get_client(sv_client_num_t client_num) + { + assert(client_num.valid()); + + return client_data_[client_num.value()]; + } + + bool sv_demo_recordings_t::is_recording(sv_client_num_t client_num) const + { + return client_num.valid() && get_client(client_num).is_recording(); + } + + bool sv_demo_recordings_t::start(sv_client_num_t client_num, const std::filesystem::path& path) + { + if (!client_num.valid()) + { + return false; + } + + auto& client = get_client(client_num); + + if (client.is_buffer_active()) + { + if (!client.is_recording()) + { + std::ofstream file(path, std::ios::binary); + + if (file.good() && file.is_open()) + { + const auto buffer = client.buffer.get(); + + client.file = std::move(file); + client.file.write(reinterpret_cast(buffer.data()), buffer.size()); + client.file.flush(); + client.file_active = true; + + return true; + } + } + } + else + { + console::error("client pid %d cannot be recorded at this time; sv_demos was possibly enabled too late!\n", client_num.value()); + } + + return false; + } + + bool sv_demo_recordings_t::stop_internal(client_data_t& client, bool reset_client) + { + if (client.is_recording()) + { + write_general_footer(client.file, client.times.first_svr_time, client.times.cur_svr_time); + write_end_of_file(client.file); + + client.file.flush(); + client.file.close(); + client.file_active = {}; + } + + if (reset_client) + { + client.buffer_active = {}; + client.times = {}; + client.buffer.clear(); + } + + return client.file.good() && !client.file.is_open(); + } + + bool sv_demo_recordings_t::stop(sv_client_num_t client_num) + { + return client_num.valid() && stop_internal(get_client(client_num), false); + } + + void sv_demo_recordings_t::process_old_data() + { + for (auto& client : client_data_) + { + stop_internal(client, true); + } + + svr_rotation_data_ = {}; + } + + void sv_demo_recordings_t::shutdown_watcher() + { + auto execute_on_shutdown = [this, sv_loaded = false]() mutable + { + if (!sv_loaded && game::SV_Loaded()) + { + sv_loaded = true; + } + + if (sv_loaded && !game::SV_Loaded()) + { + sv_loaded = false; + process_old_data(); + } + }; + + scheduler::loop(execute_on_shutdown, scheduler::main); + } + + bool sv_demo_recordings_t::update_state(sv_client_num_t client_num, std::int32_t last_conn_time, bool client_loading) + { + // handle server map change: close files and reset data for all clients; should ignore fast restarts + if (svr_rotation_data_.has_changed()) + { + process_old_data(); + } + + auto& client = get_client(client_num); + if (!client_loading && !client.is_buffer_active()) + { + // sv_demos was possibly enabled too late for this player for this match! + return false; + } + + // handle client last connect time change: close file and reset data + if (!client.times.last_conn_time || *client.times.last_conn_time != last_conn_time) + { + if (client.times.last_conn_time) + { + stop_internal(client, true); + } + + client.times.last_conn_time = last_conn_time; + } + + // write general, mod and map headers to the buffer once + if (!client.is_buffer_active()) + { + // reserve memory (low size if auto recording is enabled, because then the demo writes almost directly to disk) + const auto reserve_size = (sv_demo_autorecord && sv_demo_autorecord->current.enabled) ? 8192 : 512 * 1024; + client.buffer.reserve_memory(reserve_size); + + sv_write_general_header(client.buffer, game::mp::svs->clients[client_num.value()].name); + write_mod_header(client.buffer); + + if (write_map_header(client.buffer)) + { + client.buffer_active = true; + } + } + + // store first and current server times to keep track of demo length + client.times.cur_svr_time = game::mp::svs->time; + if (!client_loading && !client.times.first_svr_time && client.times.cur_svr_time) + { + client.times.first_svr_time = client.times.cur_svr_time; + } + + // if not already recording, start demo for client when auto recording is enabled + if (!client.is_recording() && sv_demo_autorecord && sv_demo_autorecord->current.enabled) + { + if (!sv_execute_demo_start_command_internal(client_num, std::string_view{}, false)) + { + console::error("failed to start demo automatically for client num %d\n", client_num.value()); + } + } + + return true; + } + + void sv_demo_recordings_t::write_data(sv_client_num_t client_num, + const game::mp::client_t& cl, std::span network_data, bool client_loading) + { + if (!client_num.valid() || !update_state(client_num, cl.lastConnectTime, client_loading)) + { + return; + } + + auto write_data_internal = [&cl, network_data, client_loading](auto& output) + { + const auto send_msg_count = static_cast(cl.header.sendMessageCount); + const auto svr_msg_sequence = static_cast(cl.header.netchan.outgoingSequence); + + if (!client_loading) + { + sv_write_predicted_data(output, cl.gentity->client->ps, send_msg_count); + } + + write_id_and_size(output, 4 + network_data.size(), demo_data_id::network_data); + output.write(reinterpret_cast(&svr_msg_sequence), 4); + output.write(reinterpret_cast(network_data.data()), network_data.size()); + }; + + auto& client = get_client(client_num); + + if (client.is_buffer_active()) + { + write_data_internal(client.buffer); + } + if (client.is_recording()) + { + write_data_internal(client.file); + + if (static_cast(cl.header.sendMessageCount) % 512 == 0) + { + client.file.flush(); + } + } + } +} + +namespace // hooks +{ + utils::hook::detour SV_Netchan_Transmit_hook; + + bool sv_capture_data(game::mp::client_t& cl, const char* data, std::int32_t length) + { + const auto valid_player = cl.gentity && cl.gentity->client && cl.testClient == game::TC_NONE + && (cl.header.state == game::CS_CLIENTLOADING || cl.header.state == game::CS_ACTIVE); + + if (sv_demos && sv_demos->current.enabled && valid_player) + { + const auto client_num = sv_client_num_t(static_cast(cl.gentity->s.number)); + + if (!client_num.valid()) + { + console::error("invalid client num %d\n", cl.gentity->s.number); + } + else + { + const auto client_loading = (cl.header.state == game::CS_CLIENTLOADING); + const auto size = static_cast(length); + + if ((!client_loading || cl.gamestateMessageNum == cl.header.netchan.outgoingSequence) && size < MAX_SIZE) + { + const std::span network_data(reinterpret_cast(data), size); + demos.write_data(client_num, cl, network_data, client_loading); + } + else + { + console::warn("invalid data for client num %d, message count %d, message size %zu\n", + client_num.value(), cl.header.sendMessageCount, size); + } + } + } + + return SV_Netchan_Transmit_hook.invoke(&cl, data, length); + } +} + +namespace // command execution +{ + bool sv_execute_demo_start_command_internal(sv_client_num_t client_num, std::string_view file_name, bool overwrite) + { + if (game::Live_SyncOnlineDataFlags(0)) + { + console::error("server must be initialized to record a demo\n"); + return false; + } + + if (const auto* sv_running = game::Dvar_FindVar("sv_running"); !sv_running || !sv_running->current.enabled) + { + console::error("server must be online to record a demo\n"); + return false; + } + + if (!sv_demos || !sv_demos->current.enabled) + { + console::error("cannot record a demo with sv_demos disabled\n"); + return false; + } + + if (!client_num.valid()) + { + console::error("invalid client num\n"); + return false; + } + + const auto state = game::mp::svs->clients[client_num.value()].header.state; + if (state != game::CS_CLIENTLOADING && state != game::CS_ACTIVE) + { + console::error("client needs to be fully connected\n"); + return false; + } + + const auto client_type = game::mp::svs->clients[client_num.value()].testClient; + if (client_type != game::TC_NONE) + { + console::error("can only record actual players\n"); + return false; + } + + if (demos.is_recording(client_num)) + { + console::error("already recording client\n"); + return false; + } + + const auto opt_dir_path = sv_create_demo_directory(); + if (!opt_dir_path) + { + console::error("could not create demo directory\n"); + return false; + } + + const auto& cl = game::mp::svs->clients[client_num.value()]; + const auto empty_guid = (cl.playerGuid[0] == '\0'); + if (empty_guid) + { + console::warn("player guid appears empty\n"); + } + + const auto opt_server_path = (file_name.empty()) + ? sv_create_path_server_demo(*opt_dir_path, (!empty_guid) ? cl.playerGuid : utils::string::strip(cl.name, true)) + : sv_create_path_server_demo(*opt_dir_path, file_name, overwrite); + + if (!opt_server_path) + { + console::error("could not create demo file\n"); + return false; + } + + if (!demos.start(client_num, *opt_server_path)) + { + console::error("could not create demo file %s\n", opt_server_path->string().c_str()); + return false; + } + + return true; + } + + void sv_execute_demo_start_command(const command::params& params) + { + if (params.size() < 2 || params.size() > 4 || (params.size() == 4 && std::string_view(params.get(3)) != "overwrite")) + { + console::info("usage: sv_demostart , (optional), overwrite(optional)\n"); + return; + } + + assert(params.size() != 4 || (params.size() == 4 && std::string_view(params.get(3)) == "overwrite")); + sv_execute_demo_start_command_internal( + sv_client_num_t(params.get(1)), (params.size() >= 3) ? params.get(2) : std::string_view{}, (params.size() == 4)); + } + + void sv_execute_demo_stop_command(const command::params& params) + { + if (params.size() != 2) + { + console::info("usage: sv_demostop \n"); + return; + } + + const sv_client_num_t client_num(params.get(1)); + if (!client_num.valid()) + { + console::error("invalid client num %s\n", params.get(1)); + } + else if (!demos.stop(client_num)) + { + console::error("could not stop demo\n"); + } + } +} + +namespace demo_sv_recording +{ + bool sv_recording(std::size_t client_num) + { + return demos.is_recording(sv_client_num_t(client_num)); + } + + bool sv_startrecord(std::size_t client_num, std::string_view file_name, bool overwrite) + { + return sv_execute_demo_start_command_internal(sv_client_num_t(client_num), file_name, overwrite); + } + + bool sv_stoprecord(std::size_t client_num) + { + return demos.stop(sv_client_num_t(client_num)); + } + + class component final : public component_interface + { + public: + void post_unpack() override + { + if (!game::environment::is_dedi()) + { + return; + } + + // check run-time address assertions + check_address_assertions(); + + // capture outgoing packets to client + SV_Netchan_Transmit_hook.create(game::SV_Netchan_Transmit, sv_capture_data); + + // execute server demo code based on this value; if it's is enabled mid-match, + // then the demos recorded during that match (if any) are likely corrupt! + sv_demos = game::Dvar_RegisterBool( + "sv_demos", false, game::DVAR_FLAG_NONE, "Enable server demos"); + + // add support to auto record all players + sv_demo_autorecord = game::Dvar_RegisterBool( + "sv_demoautorecord", false, game::DVAR_FLAG_NONE, + "Automatically start recording a demo for each connected client."); + + // add console command support to record server demos + command::add("sv_demostart", sv_execute_demo_start_command); + command::add("sv_demostop", sv_execute_demo_stop_command); + + scheduler::on_game_initialized([]() + { + demos.shutdown_watcher(); + + // check if demo directory exists / could be created + if (!sv_can_create_demo_directory()) + { + console::error("could not create demo directory\n"); + } + }, scheduler::main); + } + }; +} + +REGISTER_COMPONENT(demo_sv_recording::component) diff --git a/src/client/component/demo_sv_recording.hpp b/src/client/component/demo_sv_recording.hpp new file mode 100644 index 0000000..d0e4c6d --- /dev/null +++ b/src/client/component/demo_sv_recording.hpp @@ -0,0 +1,8 @@ +#pragma once + +namespace demo_sv_recording +{ + [[nodiscard]] bool sv_recording(std::size_t client_num); + [[nodiscard]] bool sv_startrecord(std::size_t client_num, std::string_view file_name = {}, bool overwrite = {}); + [[nodiscard]] bool sv_stoprecord(std::size_t client_num); +} diff --git a/src/client/component/demo_timescale.cpp b/src/client/component/demo_timescale.cpp new file mode 100644 index 0000000..dc37194 --- /dev/null +++ b/src/client/component/demo_timescale.cpp @@ -0,0 +1,139 @@ +#include "std_include.hpp" +#include "loader/component_loader.hpp" + +#include "demo_playback.hpp" + +#include "fps.hpp" +#include "game/game.hpp" +#include "utils/hook.hpp" +#include "utils/string.hpp" + +namespace demo_timescale +{ + namespace + { + utils::hook::detour Com_TimeScaleMsec_hook; + + game::dvar_t* demo_timescale; + + void generate_pattern(std::array& pattern, std::size_t ones_count, std::size_t zeros_count) + { + assert(ones_count + zeros_count == pattern.size()); + + for (std::size_t i = 0, zeros = 1, ones = 1; i < pattern.size(); ++i) + { + if (ones * zeros_count < zeros * ones_count) + { + ++ones; + pattern[i] = 1; + } + else + { + ++zeros; + pattern[i] = 0; + } + } + + assert(std::accumulate(pattern.begin(), pattern.end(), 0) > 0); + } + + void calculate_pattern(std::array& pattern, float fps, float timescale) + { + // Com_TimeScaleMsec is called once per frame, so the number of calls it takes to advance 1000 ms + // can be calculated by using the following formula: fps / timescale + + // example: 500 fps and 0.01 timescale -> 500 / 0.01 = 50'000 + // a pattern needs to be generated where 1000 * 1ms and 49'000 * 0ms are interleaved, + // and fit in an array of size 1000 + + const auto call_count_per_sec = static_cast(std::clamp(fps / timescale, 1000.0f, 1'000'000.0f)); + const auto ones_count = static_cast(pattern.size() / (call_count_per_sec / static_cast(pattern.size()))); + const auto zeros_count = pattern.size() - ones_count; + + generate_pattern(pattern, ones_count, zeros_count); + } + + std::int32_t Com_TimeScaleMsec_stub(std::int32_t msec) + { + if (!demo_playback::playing() || !demo_timescale || demo_timescale->current.value == 1.0f) + { + return Com_TimeScaleMsec_hook.invoke(msec); + } + if (demo_timescale->current.value == 0.0f) + { + return 0; // pause game + } + + // the code below generates a pattern of interleaved 0s and 1s based on calculated avg fps + // a new pattern is generated every 1000 frames, or after timescale or maxfps changes + // the pattern determines the speed at which the game advances + + const auto timescale = demo_timescale->current.value; + const auto avg_fps = fps::get_avg_fps(); + const auto frame_time = timescale * 1000.0f / avg_fps; + const auto* com_maxfps = game::Dvar_FindVar("com_maxfps"); + const auto maxfps = (com_maxfps) ? com_maxfps->current.integer : static_cast(avg_fps); + + static auto last_timescale = timescale; + static auto last_maxfps = maxfps; + static std::size_t pattern_index; + static std::array pattern; + + if (last_timescale != timescale || last_maxfps != maxfps) + { + last_timescale = timescale; + last_maxfps = maxfps; + + // update pattern using the maxfps instead of avg fps for now + calculate_pattern(pattern, static_cast(maxfps), timescale); + + // update the pattern again in the near future when the average fps is more accurate + pattern_index = 95 * pattern.size() / 100; + } + + if (frame_time > 1.0f) + { + const auto i_frame_time = static_cast(frame_time); + const auto ones_count = static_cast(frame_time * 1000 - i_frame_time * 1000); + if (ones_count <= 1) + { + return i_frame_time; + } + + if (pattern_index % pattern.size() == 0) + { + const auto zeros_count = pattern.size() - ones_count; + generate_pattern(pattern, ones_count, zeros_count); + } + + return i_frame_time + pattern[pattern_index++ % pattern.size()]; + } + else if (pattern_index % pattern.size() == 0) + { + calculate_pattern(pattern, avg_fps, timescale); + } + + // advance (1ms) or pause (0ms) based on the pattern + return pattern[pattern_index++ % pattern.size()]; + } + } + + class component final : public component_interface + { + public: + void post_unpack() override + { + if (!game::environment::is_mp()) + { + return; + } + + // add timescale support for demo playback + Com_TimeScaleMsec_hook.create(game::Com_TimeScaleMsec, Com_TimeScaleMsec_stub); + demo_timescale = game::Dvar_RegisterFloat( + "demotimescale", 1.0f, 0.0f, 1000.0f, game::DVAR_FLAG_NONE, "Set playback speed for demos"); + } + }; +} + +REGISTER_COMPONENT(demo_timescale::component) diff --git a/src/client/component/demo_utils.cpp b/src/client/component/demo_utils.cpp new file mode 100644 index 0000000..0df1890 --- /dev/null +++ b/src/client/component/demo_utils.cpp @@ -0,0 +1,1526 @@ +#include "std_include.hpp" + +#include "demo_utils.hpp" + +#include "console.hpp" +#include "party.hpp" +#include "utils/compression.hpp" +#include "utils/string.hpp" + +namespace demo_utils +{ + namespace types + { + std::size_t buffer_t::size() const + { + return buffer_.size(); + } + + std::span buffer_t::get() const + { + return std::span(buffer_); + } + + void buffer_t::reserve_memory(std::size_t size) + { + buffer_.reserve(size); + } + + void buffer_t::write(const char* src, std::size_t size) + { + buffer_.insert(buffer_.end(), src, src + size); + } + + void buffer_t::clear() + { + buffer_.clear(); + } + + demo_data_id operator|(demo_data_id lhs, demo_data_id rhs) + { + using type = std::underlying_type_t; + return demo_data_id(static_cast(lhs) | static_cast(rhs)); + } + + demo_data_id operator&(demo_data_id lhs, demo_data_id rhs) + { + using type = std::underlying_type_t; + return demo_data_id(static_cast(lhs) & static_cast(rhs)); + } + + demo_data_id operator~(demo_data_id value) + { + using type = std::underlying_type_t; + return demo_data_id(~static_cast(value)); + } + } + + namespace gen_utilities + { + std::string_view get_dvar_string(std::string_view dvar_name, bool valid_string) + { + const auto* dvar = game::Dvar_FindVar(dvar_name.data()); + + if (!dvar || !dvar->current.string || dvar->current.string[0] == '\0') + { + return (valid_string) + ? std::string_view("") + : std::string_view(); + } + + return dvar->current.string; + } + + std::string_view get_mod_directory() + { + return get_dvar_string("fs_game", true); + } + + std::string_view get_base_path(bool default_string) + { + const auto string = get_dvar_string("fs_basepath", true); + if (string.empty() && default_string) + { + return "unknown_basepath"; + } + + return string; + } + + std::string_view get_mapname(bool default_string) + { + const auto string = get_dvar_string("mapname", true); + if (string.empty() && default_string) + { + return "unknown_mapname"; + } + + return string; + } + + std::string get_shortened_mapname_lowercase(bool default_string) + { + const auto mapname = get_mapname(default_string); + if (mapname.starts_with("mp_")) + { + return utils::string::to_lower(std::string(mapname.begin() + 3, mapname.end())); + } + + return utils::string::to_lower(std::string(mapname)); + } + + std::string get_mapname_lowercase(bool default_string) + { + return utils::string::to_lower(std::string(get_mapname(default_string))); + } + + std::string_view get_gametype(bool default_string) + { + const auto string = get_dvar_string("g_gametype", true); + if (string.empty() && default_string) + { + return "unknown_gametype"; + } + + return string; + } + + std::string get_gametype_lowercase(bool default_string) + { + return utils::string::to_lower(std::string(get_gametype(default_string))); + } + + std::string_view get_ui_mapname() + { + static constexpr std::string_view unknown_mapname = "unknown_ui_mapname"; + + const auto dvar_mapname = get_mapname(false); + if (dvar_mapname.empty()) + { + return unknown_mapname; + } + + const auto* ui_mapname = game::UI_LocalizeMapname(dvar_mapname.data()); + if (!ui_mapname) + { + return unknown_mapname; + } + + const auto mapname = std::string_view(ui_mapname); + if (mapname.empty() || mapname.size() >= 64) + { + return unknown_mapname; + } + + return mapname; + } + + std::string_view get_ui_gametype() + { + static constexpr std::string_view unknown_gametype = "unknown_ui_gametype"; + + const auto dvar_gametype = get_gametype(false); + if (dvar_gametype.empty()) + { + return unknown_gametype; + } + + const auto* ui_gametype = game::UI_LocalizeGametype(dvar_gametype.data()); + if (!ui_gametype) + { + return unknown_gametype; + } + + const auto gametype = std::string_view(ui_gametype); + if (gametype.empty() || gametype.size() >= 64) + { + return unknown_gametype; + } + + return gametype; + } + + std::string get_datetime() + { + const auto now = std::chrono::system_clock::now(); + const auto time_zone = std::chrono::current_zone(); + const auto local_time = time_zone->to_local(now); + const auto time_point = std::chrono::time_point_cast(local_time); + const auto yy_mm_dd = std::chrono::year_month_day(time_point); + const auto hh_mm_ss = std::chrono::hh_mm_ss + (std::chrono::duration_cast(local_time.time_since_epoch()) % std::chrono::days(1)); + + return std::format("[{:04}.{:02}.{:02}][{:02}.{:02}.{:02}]", + yy_mm_dd.year().operator int(), + yy_mm_dd.month().operator unsigned int(), + yy_mm_dd.day().operator unsigned int(), + hh_mm_ss.hours().count(), + hh_mm_ss.minutes().count(), + hh_mm_ss.seconds().count() + ); + } + } + + namespace file_directory + { + std::optional create_directory_user_demo() + { + const auto fs_basepath = get_base_path(false); + if (fs_basepath.empty()) + { + console::error("could not find fs_basepath\n"); + return std::nullopt; + } + + const std::optional opt( + std::filesystem::path(fs_basepath) / std::format("demos/client/{}/user", get_mod_directory()) + ); + + const auto& path = *opt; + if (!std::filesystem::exists(path) && !std::filesystem::create_directories(path)) + { + console::error("could not create user demo directory %s\n", path.string().c_str()); + return std::nullopt; + } + + return opt; + } + + std::optional create_directory_auto_demo() + { + const auto fs_basepath = get_base_path(false); + if (fs_basepath.empty()) + { + console::error("could not find fs_basepath\n"); + return std::nullopt; + } + + const std::optional opt( + std::filesystem::path(fs_basepath) / std::format("demos/client/{}/auto", get_mod_directory()) + ); + + const auto& path = *opt; + if (!std::filesystem::exists(path) && !std::filesystem::create_directories(path)) + { + console::error("could not create auto demo directory %s\n", path.string().c_str()); + return std::nullopt; + } + + return opt; + } + + bool can_create_demo_directories() + { + return create_directory_user_demo() && create_directory_auto_demo(); + } + + std::optional create_path_user_demo(const std::filesystem::path& dir_path) + { + const auto mapname = get_shortened_mapname_lowercase(true); + + for (std::size_t index = 0; index < 10'000; ++index) + { + if (index < 5000 && index % 100 == 0) + { + // look ahead + const auto demo_path = dir_path / std::format("{}.{:04}{}", mapname, index + 100, DEMO_EXTENSION); + + if (std::filesystem::exists(demo_path)) + { + index += 99; + continue; + } + } + + const std::optional demo_path = + dir_path / std::format("{}.{:04}{}", mapname, index, DEMO_EXTENSION); + + if (std::filesystem::exists(*demo_path)) + { + continue; + } + + return demo_path; + } + + return std::nullopt; + } + + std::optional create_path_user_demo( + const std::filesystem::path& dir_path, std::string_view demo_name, bool overwrite) + { + const std::optional demo_path = dir_path / std::format("{}{}", demo_name, DEMO_EXTENSION); + if (!overwrite && std::filesystem::exists(*demo_path)) + { + return std::nullopt; + } + + return demo_path; + } + + std::optional create_path_auto_demo(const std::filesystem::path& dir_path) + { + const auto mapname = get_shortened_mapname_lowercase(true); + + const std::optional demo_path = + dir_path / std::format("{}[{}]{}", get_datetime(), mapname, DEMO_EXTENSION); + + if (std::filesystem::exists(*demo_path)) + { + return std::nullopt; + } + + return demo_path; + } + } + + namespace file_directory_server + { + std::optional sv_create_demo_directory() + { + const auto fs_basepath = get_base_path(false); + if (fs_basepath.empty()) + { + console::error("could not find fs_basepath\n"); + return std::nullopt; + } + + const std::optional result( + std::filesystem::path(fs_basepath) / std::format("demos/server/{}/", get_mod_directory()) + ); + + const auto& svr_dir_path = *result; + if (!std::filesystem::exists(svr_dir_path) && !std::filesystem::create_directories(svr_dir_path)) + { + console::error("could not create demos directory %s\n", svr_dir_path.string().c_str()); + return std::nullopt; + } + + return result; + } + + bool sv_can_create_demo_directory() + { + return sv_create_demo_directory().has_value(); + } + + std::optional sv_create_path_server_demo( + const std::filesystem::path& dir_path, std::string_view client_id) + { + const auto mapname = get_shortened_mapname_lowercase(true); + + const std::optional demo_path = (!client_id.empty()) + ? dir_path / std::format("{}[{}][{}]{}", get_datetime(), mapname, client_id, DEMO_EXTENSION) + : dir_path / std::format("{}[{}]{}", get_datetime(), mapname, DEMO_EXTENSION); + + if (std::filesystem::exists(*demo_path)) + { + return std::nullopt; + } + + return demo_path; + } + + std::optional sv_create_path_server_demo( + const std::filesystem::path& dir_path, std::string_view demo_name, bool overwrite) + { + const std::optional demo_path = dir_path / std::format("{}{}", demo_name, DEMO_EXTENSION); + if (!overwrite && std::filesystem::exists(*demo_path)) + { + return std::nullopt; + } + + return demo_path; + } + } + + namespace serialization + { + // controllable vehicles - killstreak rewards + namespace + { + const auto hash_gryphon = std::hash()("remote_uav_mp"); + bool is_gryphon_ks_reward(std::string_view name) + { + return hash_gryphon == std::hash()(name); + } + + const auto hash_helo_pilot = std::hash()("heli_pilot_mp"); + bool is_helo_pilot_ks_reward(std::string_view name) + { + return hash_helo_pilot == std::hash()(name); + } + + const auto hash_odin = std::hash()("odin_mp"); + bool is_odin_ks_reward(std::string_view name) + { + return hash_odin == std::hash()(name); + } + + predicted_data_id get_predicted_vehicle_type(const game::mp::playerState_t& ps) + { + const auto** vehicle_def = reinterpret_cast(game::CG_GetVehicleDef(&ps)); + const auto* vehicle_name = (vehicle_def) ? *vehicle_def : nullptr; + + if (vehicle_name) + { + if (is_gryphon_ks_reward(vehicle_name)) + { + return predicted_data_id::vehicle_gryphon; + } + else if (is_helo_pilot_ks_reward(vehicle_name)) + { + return predicted_data_id::vehicle_helo_pilot; + } + else if (is_odin_ks_reward(vehicle_name)) + { + return predicted_data_id::vehicle_odin; + } + } + + assert(false); + return predicted_data_id::vehicle_unknown; + } + } + + void write_id_and_size_(auto& output, std::size_t size, demo_data_id id) + { + assert(id <= demo_data_id::eof); + + if (size < 256) + { + const auto complete_id = id | demo_data_id::one_byte_flag; + output.write(reinterpret_cast(&complete_id), 1); + output.write(reinterpret_cast(&size), 1); + } + else + { + const auto complete_id = id; + output.write(reinterpret_cast(&complete_id), 1); + output.write(reinterpret_cast(&size), 4); + } + } + + void write_id_and_size(buffer_t& output, std::size_t size, demo_data_id id) + { + write_id_and_size_(output, size, id); + } + + void write_id_and_size(std::ofstream& output, std::size_t size, demo_data_id id) + { + write_id_and_size_(output, size, id); + } + + void write_network_data_(auto& output, std::span network_data) + { + write_id_and_size(output, network_data.size(), demo_data_id::network_data); + output.write(reinterpret_cast(network_data.data()), network_data.size()); + } + + void write_network_data(buffer_t& output, std::span network_data) + { + write_network_data_(output, network_data); + } + + void write_network_data(std::ofstream& output, std::span network_data) + { + write_network_data_(output, network_data); + } + + void write_predicted_player_data_(auto& output, const game::ClientArchiveData& cad, + const game::vec3_t& viewangles, std::uint8_t cad_index, predicted_data_id id) + { + write_id_and_size(output, 50, demo_data_id::predicted_data); + output.write(reinterpret_cast(&id), 1); + output.write(reinterpret_cast(&cad_index), 1); + + output.write(reinterpret_cast(&cad.serverTime), 4); + output.write(reinterpret_cast(&cad.origin[0]), 12); + output.write(reinterpret_cast(&cad.velocity[0]), 12); + output.write(reinterpret_cast(&cad.bobCycle), 4); + output.write(reinterpret_cast(&cad.movementDir), 4); + output.write(reinterpret_cast(&viewangles[0]), 12); + } + + void write_predicted_player_data(buffer_t& output, const game::ClientArchiveData& cad, + const game::vec3_t& viewangles, std::uint8_t cad_index, predicted_data_id id) + { + write_predicted_player_data_(output, cad, viewangles, cad_index, id); + } + + void write_predicted_player_data(std::ofstream& output, const game::ClientArchiveData& cad, + const game::vec3_t& viewangles, std::uint8_t cad_index, predicted_data_id id) + { + write_predicted_player_data_(output, cad, viewangles, cad_index, id); + } + + void write_predicted_vehicle_data_(auto& output, const game::ClientArchiveData& cad, + const game::vec3_t& viewangles, std::uint8_t cad_index, predicted_data_id id) + { + const auto size = (id != predicted_data_id::vehicle_gryphon) ? 58 : 82; + + write_id_and_size(output, size, demo_data_id::predicted_data); + output.write(reinterpret_cast(&id), 1); + output.write(reinterpret_cast(&cad_index), 1); + + const auto& vehicle = cad.playerVehStateClientArchive; + output.write(reinterpret_cast(&cad.serverTime), 4); + output.write(reinterpret_cast(&cad.origin[0]), 12); + output.write(reinterpret_cast(&viewangles[0]), 12); + output.write(reinterpret_cast(&vehicle.flags), 4); + output.write(reinterpret_cast(&vehicle.origin[0]), 12); + output.write(reinterpret_cast(&vehicle.angles[0]), 12); + + if (id == predicted_data_id::vehicle_gryphon) + { + output.write(reinterpret_cast(&vehicle.velocity[0]), 12); + output.write(reinterpret_cast(&vehicle.angVelocity[0]), 12); + } + + assert(!vehicle.splineId); + } + + void write_predicted_vehicle_data(buffer_t& output, const game::ClientArchiveData& cad, + const game::vec3_t& viewangles, std::uint8_t cad_index, predicted_data_id id) + { + write_predicted_vehicle_data_(output, cad, viewangles, cad_index, id); + } + + void write_predicted_vehicle_data(std::ofstream& output, const game::ClientArchiveData& cad, + const game::vec3_t& viewangles, std::uint8_t cad_index, predicted_data_id id) + { + write_predicted_vehicle_data_(output, cad, viewangles, cad_index, id); + } + + void write_helo_pilot_turret_fire_(auto& output, std::uint8_t fire_count) + { + static constexpr auto id = predicted_data_id::vehicle_helo_pilot_turret_fire; + + const auto snap_svr_time = game::mp::cl->snap.serverTime; + + write_id_and_size(output, 6, demo_data_id::predicted_data); + output.write(reinterpret_cast(&id), 1); + output.write(reinterpret_cast(&snap_svr_time), 4); + output.write(reinterpret_cast(&fire_count), 1); + } + + void write_helo_pilot_turret_fire(buffer_t& output, std::uint8_t fire_count) + { + write_helo_pilot_turret_fire_(output, fire_count); + } + + void write_helo_pilot_turret_fire(std::ofstream& output, std::uint8_t fire_count) + { + write_helo_pilot_turret_fire_(output, fire_count); + } + + void write_mod_header_(auto& output) + { + const auto mod = get_mod_directory(); + if (mod.size()) + { + write_id_and_size(output, mod.size() + 1, demo_data_id::mod_header); + + static constexpr auto null_terminator = '\0'; + output.write(mod.data(), mod.size()); + output.write(&null_terminator, 1); + } + } + + void write_mod_header(buffer_t& output) + { + write_mod_header_(output); + } + + void write_mod_header(std::ofstream& output) + { + write_mod_header_(output); + } + + bool write_map_header_(auto& output) + { + const auto mapname = get_mapname_lowercase(false); + const auto gametype = get_gametype_lowercase(false); + + const auto size = mapname.size() + gametype.size() + 2; + if (mapname.empty() || gametype.empty() || size < 4 || size > 127) + { + return false; + } + + write_id_and_size(output, size, demo_data_id::map_header); + + static constexpr auto null_terminator = '\0'; + output.write(mapname.c_str(), mapname.size()); + output.write(&null_terminator, 1); + output.write(gametype.c_str(), gametype.size()); + output.write(&null_terminator, 1); + + return true; + } + + bool write_map_header(buffer_t& output) + { + return write_map_header_(output); + } + + bool write_map_header(std::ofstream& output) + { + return write_map_header_(output); + } + + bool write_gamestate_data_(auto& output) + { + const auto& persistent_data = get_persistent_data(); + const auto& gs = game::mp::cls->gameState; + const auto svr_cmd_seq = game::mp::clc->serverCommandSequence; + const auto strings_size = static_cast(gs.dataCount); + + assert(is_gamestate_valid(game::mp::cls->gameState, false)); + if (!strings_size || strings_size >= sizeof(game::gameState_t::stringData)) + { + return false; + } + + const std::span sd( + reinterpret_cast(&gs.stringData[0]), strings_size); + const std::span so( + reinterpret_cast(&gs.stringOffsets[0]), sizeof(game::gameState_t::stringOffsets)); + + const auto string_data = utils::compression::zlib::compress(sd, 2048); + const auto string_offsets = utils::compression::zlib::compress(so, 1024); + + assert(std::equal(sd.begin(), sd.end(), + utils::compression::zlib::decompress(string_data, 4096).begin())); + assert(std::equal(so.begin(), so.end(), + utils::compression::zlib::decompress(string_offsets, sizeof(game::gameState_t::stringOffsets)).begin())); + + if (string_data.empty() || string_data.size() >= sizeof(game::gameState_t::stringData)) + { + return false; + } + if (string_offsets.empty() || string_offsets.size() > sizeof(game::gameState_t::stringOffsets)) + { + return false; + } + + const packed_gamestate_sizes pgs{ + .string_data = string_data.size(), + .string_offsets = string_offsets.size(), + .compressed = 1 + }; + + assert(pgs.string_data == string_data.size() && pgs.string_offsets == string_offsets.size()); + const auto size = 12 + string_data.size() + string_offsets.size() + sizeof(persistent_data_t); + + write_id_and_size(output, size, demo_data_id::update_gamestate_data); + output.write(reinterpret_cast(&svr_cmd_seq), 4); + output.write(reinterpret_cast(&pgs), 8); + output.write(reinterpret_cast(string_data.data()), string_data.size()); + output.write(reinterpret_cast(string_offsets.data()), string_offsets.size()); + output.write(reinterpret_cast(&persistent_data), sizeof(persistent_data_t)); + + return true; + } + + bool write_gamestate_data(buffer_t& output) + { + return write_gamestate_data_(output); + } + + bool write_gamestate_data(std::ofstream& output) + { + return write_gamestate_data_(output); + } + + void write_predicted_data_(auto& output) + { + const auto& ps = game::mp::cg->ps; + const auto& viewangles = game::mp::cg->refdefViewAngles; + const auto cad_index = static_cast(game::mp::cl->clientArchiveIndex - 1); + const auto cad_array = std::span(game::mp::cl->clientArchive); + + if (const auto& cad = cad_array[cad_index]; cad.serverTime) + { + const auto vehicle_in_use = (ps.vehicleState.entity && ps.vehicleState.entity != 2047); + if (!vehicle_in_use) + { + write_predicted_player_data(output, cad, viewangles, cad_index, predicted_data_id::player); + } + else + { + const auto data_type = get_predicted_vehicle_type(ps); + write_predicted_vehicle_data(output, cad, viewangles, cad_index, data_type); + } + } + } + + void write_predicted_data(buffer_t& output) + { + write_predicted_data_(output); + } + + void write_predicted_data(std::ofstream& output) + { + write_predicted_data_(output); + } + + void write_general_header_internal(auto& output, + std::string_view demo_description, std::string_view svr_name_sv, std::string_view player_name_sv) + { + const auto datetime = get_datetime(); + + const auto protocol = std::to_string(PROTOCOL); + const auto* build_num_ptr = game::LiveStorage_FetchFFotD(); + const auto build_num = (build_num_ptr) + ? std::string_view(build_num_ptr) + : "unknown_build_number"; + + const auto svr_name = utils::string::strip(svr_name_sv, true); + const auto player_name = utils::string::strip(player_name_sv, true); + + const auto ui_mapname = get_ui_mapname(); + const auto ui_gametype = get_ui_gametype(); + + const auto size = datetime.size() + 1 + + demo_description.size() + 1 + + DEMO_CODE_VERSION.size() + 1 + + protocol.size() + 1 + + build_num.size() + 1 + + svr_name.size() + 1 + + player_name.size() + 1 + + ui_mapname.size() + 1 + + ui_gametype.size() + 1; + + write_id_and_size(output, size, demo_data_id::gen_header); + + static constexpr auto null_terminator = '\0'; + output.write(datetime.c_str(), datetime.size()); + output.write(&null_terminator, 1); + output.write(demo_description.data(), demo_description.size()); + output.write(&null_terminator, 1); + output.write(DEMO_CODE_VERSION.data(), DEMO_CODE_VERSION.size()); + output.write(&null_terminator, 1); + output.write(protocol.c_str(), protocol.size()); + output.write(&null_terminator, 1); + output.write(build_num.data(), build_num.size()); + output.write(&null_terminator, 1); + output.write(svr_name.data(), svr_name.size()); + output.write(&null_terminator, 1); + output.write(player_name.data(), player_name.size()); + output.write(&null_terminator, 1); + output.write(ui_mapname.data(), ui_mapname.size()); + output.write(&null_terminator, 1); + output.write(ui_gametype.data(), ui_gametype.size()); + output.write(&null_terminator, 1); + } + + void write_general_header_(auto& output) + { + static constexpr std::string_view description = "Call of Duty: Ghosts - client demo"; + + const auto* host_bptr = game::mp::cgs->szHostName; + const auto* host_eptr = std::find(host_bptr, host_bptr + 128, '\0'); + const auto host_name = (std::distance(host_bptr, host_eptr) > 0 && std::distance(host_bptr, host_eptr) < 128) + ? std::string_view(host_bptr, host_eptr) + : "unknown_server_host_name"; + + std::array buffer{}; + std::string_view player_name; + + const auto success = game::CL_GetClientNameColorize(0, game::mp::cg->clientNum, buffer.data(), static_cast(buffer.size())); + if (success) + { + player_name = std::string_view(buffer.data()); + } + + if (player_name.empty()) + { + player_name = get_dvar_string("name", true); + } + + if (player_name.empty()) + { + player_name = "unknown_player_name"; + } + + write_general_header_internal(output, description, host_name, player_name); + } + + void write_general_header(buffer_t& output) + { + write_general_header_(output); + } + + void write_general_header(std::ofstream& output) + { + write_general_header_(output); + } + + void write_general_footer_(auto& output, std::int32_t first_svr_time, std::int32_t last_svr_time) + { + assert(first_svr_time > 0 && last_svr_time > 0 && last_svr_time > first_svr_time); + + const auto duration = (last_svr_time - first_svr_time) / 1000; + const auto fmt_duration = std::format("[{:02}:{:02}:{:02}]", duration / 3600, (duration % 3600) / 60, duration % 60); + const auto size = 2 * 4 + fmt_duration.size() + 1; + + write_id_and_size(output, size, demo_data_id::gen_footer); + + static constexpr auto null_terminator = '\0'; + output.write(reinterpret_cast(&first_svr_time), 4); + output.write(reinterpret_cast(&last_svr_time), 4); + output.write(fmt_duration.c_str(), fmt_duration.size()); + output.write(&null_terminator, 1); + } + + void write_general_footer(buffer_t& output, std::int32_t first_svr_time, std::int32_t last_svr_time) + { + write_general_footer_(output, first_svr_time, last_svr_time); + } + + void write_general_footer(std::ofstream& output, std::int32_t first_svr_time, std::int32_t last_svr_time) + { + write_general_footer_(output, first_svr_time, last_svr_time); + } + + void write_end_of_file_(auto& output) + { + write_id_and_size(output, 0, demo_data_id::eof); + } + + void write_end_of_file(buffer_t& output) + { + write_end_of_file_(output); + } + + void write_end_of_file(std::ofstream& output) + { + write_end_of_file_(output); + } + } + + namespace serialization_server + { + void sv_write_predicted_player_data_(auto& output, const game::mp::playerState_t& ps, + std::uint8_t cad_index, predicted_data_id id) + { + write_id_and_size(output, 50, demo_data_id::predicted_data); + output.write(reinterpret_cast(&id), 1); + output.write(reinterpret_cast(&cad_index), 1); + + output.write(reinterpret_cast(&ps.commandTime), 4); + output.write(reinterpret_cast(&ps.origin[0]), 12); + output.write(reinterpret_cast(&ps.velocity[0]), 12); + output.write(reinterpret_cast(&ps.bobCycle), 4); + output.write(reinterpret_cast(&ps.movementDir), 4); + output.write(reinterpret_cast(&ps.viewangles[0]), 12); + } + + void sv_write_predicted_player_data(buffer_t& output, const game::mp::playerState_t& ps, + std::uint8_t cad_index, predicted_data_id id) + { + sv_write_predicted_player_data_(output, ps, cad_index, id); + } + + void sv_write_predicted_player_data(std::ofstream& output, const game::mp::playerState_t& ps, + std::uint8_t cad_index, predicted_data_id id) + { + sv_write_predicted_player_data_(output, ps, cad_index, id); + } + + void sv_write_predicted_vehicle_data_(auto& output, const game::mp::playerState_t& ps, + std::uint8_t cad_index, predicted_data_id id) + { + const auto size = (id != predicted_data_id::vehicle_gryphon) ? 58 : 82; + + write_id_and_size(output, size, demo_data_id::predicted_data); + output.write(reinterpret_cast(&id), 1); + output.write(reinterpret_cast(&cad_index), 1); + + const auto& vehicle = ps.vehicleState; + output.write(reinterpret_cast(&ps.commandTime), 4); + output.write(reinterpret_cast(&ps.origin[0]), 12); + output.write(reinterpret_cast(&ps.viewangles[0]), 12); + output.write(reinterpret_cast(&vehicle.flags), 4); + output.write(reinterpret_cast(&vehicle.origin[0]), 12); + output.write(reinterpret_cast(&vehicle.angles[0]), 12); + + if (id == predicted_data_id::vehicle_gryphon) + { + output.write(reinterpret_cast(&vehicle.velocity[0]), 12); + output.write(reinterpret_cast(&vehicle.angVelocity[0]), 12); + } + + assert(!vehicle.splineId); + } + + void sv_write_predicted_vehicle_data(buffer_t& output, const game::mp::playerState_t& ps, + std::uint8_t cad_index, predicted_data_id id) + { + sv_write_predicted_vehicle_data_(output, ps, cad_index, id); + } + + void sv_write_predicted_vehicle_data(std::ofstream& output, const game::mp::playerState_t& ps, + std::uint8_t cad_index, predicted_data_id id) + { + sv_write_predicted_vehicle_data_(output, ps, cad_index, id); + } + + void sv_write_predicted_data_(auto& output, const game::mp::playerState_t& ps, std::size_t send_msg_count) + { + const auto vehicle_in_use = (ps.vehicleState.entity && ps.vehicleState.entity != 2047); + if (!vehicle_in_use) + { + sv_write_predicted_player_data(output, ps, static_cast(send_msg_count), predicted_data_id::player); + } + else + { + const auto data_type = get_predicted_vehicle_type(ps); + if (data_type == predicted_data_id::vehicle_helo_pilot) + { + const auto is_firing = static_cast(ps.eFlags & 0x800000); + if (is_firing && send_msg_count % 3 == 0) + { + write_helo_pilot_turret_fire(output, 1); + } + } + + sv_write_predicted_vehicle_data(output, ps, static_cast(send_msg_count), data_type); + } + } + + void sv_write_predicted_data(buffer_t& output, const game::mp::playerState_t& ps, std::size_t send_msg_count) + { + sv_write_predicted_data_(output, ps, send_msg_count); + } + + void sv_write_predicted_data(std::ofstream& output, const game::mp::playerState_t& ps, std::size_t send_msg_count) + { + sv_write_predicted_data_(output, ps, send_msg_count); + } + + void sv_write_general_header_(auto& output, std::string_view player_name) + { + static constexpr std::string_view description = "Call of Duty: Ghosts - server demo"; + + const auto sv_hostname = get_dvar_string("sv_hostname", true); + const auto host_name = (sv_hostname.size() < 128) + ? sv_hostname + : "unknown_server_host_name"; + + if (player_name.empty()) + { + player_name = "unknown_player_name"; + } + + write_general_header_internal(output, description, host_name, player_name); + } + + void sv_write_general_header(buffer_t& output, std::string_view player_name) + { + sv_write_general_header_(output, player_name); + } + + void sv_write_general_header(std::ofstream& output, std::string_view player_name) + { + sv_write_general_header_(output, player_name); + } + } + + namespace deserialization + { + void process_mod_header(std::span buffer) + { + const std::string_view mod(reinterpret_cast(buffer.data()), buffer.size()); + if (mod.size() > 5 && mod.starts_with("mods/")) + { + if (const auto* fs_game = game::Dvar_FindVar("fs_game"); fs_game) + { + game::Dvar_SetString(fs_game, mod.data() + 5); + } + } + } + + void process_map_header(std::span buffer) + { + assert(!game::Live_SyncOnlineDataFlags(0)); + + const std::string_view strings(reinterpret_cast(buffer.data()), buffer.size()); + const std::string_view mapname(strings.begin(), std::find(strings.begin(), strings.end(), '\0')); + const auto begin = strings.begin() + mapname.size() + ((mapname.size() > 0) ? 1 : 0); + const std::string_view gametype(begin, std::find(begin, strings.end(), '\0')); + + // call CL_ConnectAndPreloadMap to preload the map; this preparatory work is required for the map to load properly + party::connect_to_dummy_party(std::string(mapname), std::string(gametype)); + } + + void process_network_data(std::span buffer) + { + if (buffer.size() > 8) + { + const auto svr_msg_sequence = cpy_cast(buffer.data()); + const auto reliable_acknowledge = cpy_cast(buffer.data() + 4); + const std::span data(buffer.begin() + 8, buffer.end()); + + // store server message sequence; is used by the game for delta snapshots + game::mp::clc->serverMessageSequence = svr_msg_sequence; + + // to prevent 'Client command overflow' error (EXE_ERR_CLIENT_CMD_OVERFLOW) + game::mp::clc->reliableAcknowledge = game::mp::clc->reliableSequence; + + game::msg_t msg{ + .data = reinterpret_cast(data.data()), + .cursize = static_cast(std::ssize(data)), + .useZlib = reliable_acknowledge >> 31 + }; + + // parse the network data + game::CL_ParseServerMessage(0, &msg); + } + } + + void trigger_helo_pilot_turret_fire(std::size_t fire_count) + { + const std::array entity_nums{ game::mp::cg->ps.viewlocked_entNum, game::mp::cg->ps.vehicleState.entity }; + for (const auto entity_num : entity_nums) + { + if (entity_num >= 2047 || entity_num <= 0) + { + continue; + } + + for (std::size_t i = 0; i < std::min(fire_count, std::size_t(10)); ++i) + { + game::CG_HandleTurretFire(0, &game::mp::centities[entity_num], 58, 0, false); + } + + break; + } + } + + void process_predicted_data(std::span buffer, + std::array& p_data, std::size_t& p_data_index) + { + static constexpr auto array_size = std::tuple_size_v>; + + if (buffer.size() < 6) + { + return; + } + + auto inc_offset = [offset = std::size_t(0)](std::size_t add_offset) mutable + { + const auto old_offset = offset; + offset += add_offset; + + return old_offset; + }; + + const auto id = cpy_cast(buffer.data() + inc_offset(1)); + + if (id == predicted_data_id::vehicle_helo_pilot_turret_fire) + { + if (buffer.size() == 6) + { + [[maybe_unused]] const auto svr_time = cpy_cast(buffer.data() + inc_offset(4)); + const auto fire_count = cpy_cast(buffer.data() + inc_offset(1)); + + if (fire_count) + { + trigger_helo_pilot_turret_fire(fire_count); + } + } + } + else + { + [[maybe_unused]] const auto index = cpy_cast(buffer.data() + inc_offset(1)); + const auto svr_time = cpy_cast(buffer.data() + inc_offset(4)); + + auto& data = p_data[p_data_index % array_size]; + auto& vehicle = data.cad.playerVehStateClientArchive; + + if (id == predicted_data_id::player) + { + if (buffer.size() == 50) + { + data.cad.serverTime = svr_time; + std::memcpy(&data.cad.origin[0], buffer.data() + inc_offset(12), 12); + std::memcpy(&data.cad.velocity[0], buffer.data() + inc_offset(12), 12); + std::memcpy(&data.cad.bobCycle, buffer.data() + inc_offset(4), 4); + std::memcpy(&data.cad.movementDir, buffer.data() + inc_offset(4), 4); + std::memcpy(&data.viewangles[0], buffer.data() + inc_offset(12), 12); + + ++p_data_index %= array_size; + } + } + else + { + auto process_vehicle_data = [&inc_offset, &data, &vehicle, &p_data_index, buffer, svr_time](std::size_t expected_buffer_size) + { + if (buffer.size() != expected_buffer_size) + { + return false; + } + + data.cad.serverTime = svr_time; + std::memcpy(&data.cad.origin[0], buffer.data() + inc_offset(12), 12); + std::memcpy(&data.viewangles[0], buffer.data() + inc_offset(12), 12); + std::memcpy(&vehicle.flags, buffer.data() + inc_offset(4), 4); + std::memcpy(&vehicle.origin[0], buffer.data() + inc_offset(12), 12); + std::memcpy(&vehicle.angles[0], buffer.data() + inc_offset(12), 12); + + vehicle.angles[0] = data.viewangles[0]; + vehicle.angles[1] = data.viewangles[1]; + + data.cad.velocity[0] = {}; + data.cad.velocity[1] = {}; + data.cad.velocity[2] = {}; + data.cad.bobCycle = {}; + data.cad.movementDir = {}; + + ++p_data_index %= array_size; + return true; + }; + + if (id == predicted_data_id::vehicle_gryphon) + { + if (process_vehicle_data(82)) + { + std::memcpy(&vehicle.velocity[0], buffer.data() + inc_offset(12), 12); + std::memcpy(&vehicle.angVelocity[0], buffer.data() + inc_offset(12), 12); + }; + } + else if (id == predicted_data_id::vehicle_helo_pilot) + { + if (process_vehicle_data(58)) + { + vehicle.targetEntity = -1; + vehicle.gunAngles[0] = -2.0f; + vehicle.gunAngles[1] = 0.75f; + }; + } + else if (id == predicted_data_id::vehicle_odin) + { + if (process_vehicle_data(58)) + { + vehicle.angles[0] = {}; + vehicle.angles[2] = {}; + }; + } + else if (id == predicted_data_id::vehicle_unknown) + { + process_vehicle_data(58); + } + } + } + + assert(inc_offset(0) == buffer.size()); + } + + bool is_gamestate_valid(game::gameState_t& gs, bool sanitize) + { + // attempt to validate the data because CoD command / config strings are notoriously susceptible to exploits + + const auto new_string_data = std::span(gs.stringData); + const auto new_string_offsets = std::span(gs.stringOffsets); + + if (new_string_data.front() != '\0' || new_string_data.back() != '\0') + { + return false; + } + + for (const auto signed_index : new_string_offsets) + { + const auto unsigned_index = static_cast(signed_index); + if (unsigned_index >= new_string_data.size()) + { + return false; + } + + const std::span str(new_string_data.begin() + unsigned_index, + std::find(new_string_data.begin() + unsigned_index, new_string_data.end(), '\0')); + if (str.size() >= 8192) + { + return false; + } + + if (sanitize) + { + for (auto& c : str) + { + if (c == static_cast(146)) + { + // CoD string sanitization: [’] -> ['] + c = static_cast(39); + } + else if (c <= static_cast(30) || c >= static_cast(127)) + { + // Quake3 string sanitization: non char -> [.] + c = static_cast(46); + } + } + } + else + { + const auto non_char_count = std::count_if(str.begin(), str.end(), [](char c) + { + return (c <= static_cast(30) || c >= static_cast(127)); + }); + + if ((unsigned_index >= 2 && non_char_count >= 16) || non_char_count >= 32) + { + return false; + } + } + } + + return true; + } + + std::optional create_gamestate(std::span string_data, std::span string_offsets) + { + if (string_data.size() >= sizeof(game::gameState_t::stringData)) + { + return {}; + } + if (string_offsets.size() != sizeof(game::gameState_t::stringOffsets)) + { + return {}; + } + + std::optional opt(std::in_place_t{}); + + auto& gs = *opt; + gs.dataCount = static_cast(string_data.size()); + std::memcpy(&gs.stringData[0], string_data.data(), string_data.size()); + std::memcpy(&gs.stringOffsets[0], string_offsets.data(), string_offsets.size()); + + if (!is_gamestate_valid(gs, true)) + { + return {}; + } + + return opt; + } + + void process_gamestate_data(std::span buffer, std::optional& opt_gs) + { + if (buffer.size() < 12) + { + return; + } + + auto inc_offset = [offset = std::size_t(0)](std::size_t add_offset) mutable + { + const auto old_offset = offset; + offset += add_offset; + + return old_offset; + }; + + const auto svr_cmd_seq = cpy_cast(buffer.data() + inc_offset(4)); + const auto pgs = cpy_cast(buffer.data() + inc_offset(8)); + const auto size = inc_offset(0) + pgs.string_data + pgs.string_offsets + sizeof(persistent_data_t); + + if (!pgs.compressed || buffer.size() != size) + { + return; + } + + namespace zlib = utils::compression::zlib; + const std::span sd(buffer.data() + inc_offset(pgs.string_data), pgs.string_data); + const auto string_data = zlib::decompress(sd, 4096); + + const std::span so(buffer.data() + inc_offset(pgs.string_offsets), pgs.string_offsets); + const auto string_offsets = zlib::decompress(so, sizeof(game::gameState_t::stringOffsets)); + + const auto gs = create_gamestate(string_data, string_offsets); + if (!gs) + { + return; + } + + opt_gs.emplace(gamestate_t{ + .svr_cmd_seq = svr_cmd_seq, + .data = *gs + }); + + const auto* src = buffer.data() + inc_offset(sizeof(persistent_data_t)); + auto& dst = get_persistent_data(); + std::memcpy(&dst, src, sizeof(persistent_data_t)); + + assert(inc_offset(0) == buffer.size()); + } + + bool read_demo_data(std::ifstream& file, std::vector& buffer, bool one_byte) + { + buffer.clear(); + + std::size_t size{}; + file.read(reinterpret_cast(&size), (one_byte) ? 1 : 4); + + if (size > MAX_SIZE) + { + return false; + } + + buffer.resize(size); + file.read(reinterpret_cast(buffer.data()), size); + + return file.good() && file.is_open() && !file.eof(); + } + + void process_server_commands() + { + static_assert(std::size(game::mp::clientConnection_t{}.serverCommands) > 100); + assert(game::mp::clc->lastExecutedServerCommand <= game::mp::clc->serverCommandSequence); + + // process server commands before they're overwritten + if (game::mp::clc->lastExecutedServerCommand + 100 <= game::mp::clc->serverCommandSequence) + { + game::CG_ExecuteNewServerCommands(0, game::mp::clc->serverCommandSequence); + } + } + + bool continue_demo_reading() + { + process_server_commands(); + + // this affects the playback speed + return game::mp::cl->snap.serverTime <= game::mp::cl->serverTime; + } + + std::tuple read_demo_data_id(std::ifstream& file) + { + auto id(demo_data_id::eof | demo_data_id::one_byte_flag); + file.read(reinterpret_cast(&id), 1); + + return { + id, + id & ~demo_data_id::flags, + static_cast(id & demo_data_id::unused_flag_1), + static_cast(id & demo_data_id::unused_flag_2), + + // check if the id byte contains the flag to indicate the size is one byte instead of four + static_cast(id & demo_data_id::one_byte_flag) + }; + } + + bool parse_demo(std::ifstream& file, std::vector& buffer, + std::size_t& post_map_header_file_offset, std::array& pred_data, + std::size_t& pred_data_index, std::optional& gs) + { + while (continue_demo_reading()) + { + const auto [full_id, id, flag1, flag2, one_byte] = read_demo_data_id(file); + + if (id == demo_data_id::eof) + { + return false; + } + if (flag1 || flag2) + { + console::warn("could not parse demo data id %d\n", full_id); + return false; + } + if (!read_demo_data(file, buffer, one_byte)) + { + console::warn("could not parse demo data\n"); + return false; + } + + switch (id) + { + case demo_data_id::mod_header: + process_mod_header(buffer); + continue; + case demo_data_id::map_header: + // store the file offset here to skip map preloading when rewinding + post_map_header_file_offset = static_cast(file.tellg()); + + process_map_header(buffer); + continue; + case demo_data_id::network_data: + process_network_data(buffer); + continue; + case demo_data_id::predicted_data: + process_predicted_data(buffer, pred_data, pred_data_index); + continue; + case demo_data_id::update_gamestate_data: + process_gamestate_data(buffer, gs); + continue; + + case demo_data_id::gen_header: + case demo_data_id::gen_footer: + continue; + } + + assert(false); + continue; + } + + return true; + } + } + + namespace misc + { + persistent_data_t& get_persistent_data() + { + // not sure if this is strictly well-defined behavior + return *reinterpret_cast(0x1445A71A4); + } + + void fast_forward_demo(std::uint32_t msec) + { + if (*game::mp::connstate == game::CA_ACTIVE) + { + game::mp::cls->realtime += msec; + } + } + + void check_address_assertions() + { + assert(std::bit_cast(game::mp::connstate.get()) == 0x1419E1AE0); + assert(std::bit_cast(&game::mp::cls->realtime) == 0x141D1AC80); + + static_assert(offsetof(game::mp::clientStatic_t, gameState) == 0x80EE0); + assert(std::bit_cast(&game::mp::cls->gameState) == 0x141D9BA40); + + static_assert(offsetof(game::mp::clientActive_t, snap) == 0x8 && offsetof(game::mp::clSnapshot_t, serverTime) == 0x347C); + assert(std::bit_cast(&game::mp::cl->snap.serverTime) == 0x1419E50F4); + + static_assert(offsetof(game::mp::clientActive_t, snap) == 0x8 && offsetof(game::mp::clSnapshot_t, deltaNum) == 0x3484); + assert(std::bit_cast(&game::mp::cl->snap.deltaNum) == 0x1419E50FC); + + static_assert(offsetof(game::mp::clientActive_t, serverTime) == 0x34D0); + assert(std::bit_cast(&game::mp::cl->serverTime) == 0x1419E5140); + + static_assert(offsetof(game::mp::clientActive_t, newSnapshots) == 0x34DC); + assert(std::bit_cast(&game::mp::cl->newSnapshots) == 0x1419E514C); + + static_assert(offsetof(game::mp::clientActive_t, clientArchive) == 0x542C); + assert(std::bit_cast(&game::mp::cl->clientArchive) == 0x1419E709C); + + static_assert(offsetof(game::mp::clientActive_t, clientArchiveIndex) == 0xE02C); + assert(std::bit_cast(&game::mp::cl->clientArchiveIndex) == 0x1419EFC9C); + + static_assert(offsetof(game::mp::clientConnection_t, reliableSequence) == 0x12C); + assert(std::bit_cast(&game::mp::clc->reliableSequence) == 0x141CB547C); + + static_assert(offsetof(game::mp::clientConnection_t, reliableAcknowledge) == 0x130); + assert(std::bit_cast(&game::mp::clc->reliableAcknowledge) == 0x141CB5480); + + static_assert(offsetof(game::mp::clientConnection_t, serverCommandSequence) == 0x20138); + assert(std::bit_cast(&game::mp::clc->serverCommandSequence) == 0x141CD5488); + + static_assert(offsetof(game::mp::clientConnection_t, lastExecutedServerCommand) == 0x2013C); + assert(std::bit_cast(&game::mp::clc->lastExecutedServerCommand) == 0x141CD548C); + + static_assert(offsetof(game::mp::cgs_t, serverCommandSequence) == 0x18); + assert(std::bit_cast(&game::mp::cgs->serverCommandSequence) == 0x14187EB98); + + static_assert(offsetof(game::mp::cgs_t, szHostName) == 0x44); + assert(std::bit_cast(&game::mp::cgs->szHostName) == 0x14187EBC4); + + static_assert(offsetof(game::mp::cg_t, clientNum) == 0x3360); + assert(std::bit_cast(&game::mp::cg->clientNum) == 0x141771F60); + + static_assert(offsetof(game::mp::cg_t, demoType) == 0x3368); + assert(std::bit_cast(&game::mp::cg->demoType) == 0x141771F68); + + static_assert(offsetof(game::mp::cg_t, refdefViewAngles) == 0xA9A90); + assert(std::bit_cast(&game::mp::cg->refdefViewAngles) == 0x141818690); + + static_assert(offsetof(game::mp::cg_t, weaponSelect) == 0xB2768); + assert(std::bit_cast(&game::mp::cg->weaponSelect) == 0x141821368); + + static_assert(offsetof(game::mp::cg_t, inKillCam) == 0xB537C); + assert(std::bit_cast(&game::mp::cg->inKillCam) == 0x141823F7C); + + static_assert(offsetof(game::mp::cg_t, bgs) == 0xB54C8); + assert(std::bit_cast(&game::mp::cg->bgs) == 0x1418240C8); + + static_assert(offsetof(game::mp::cg_t, cvsData) == 0x10AD28); + assert(std::bit_cast(&game::mp::cg->cvsData) == 0x141879928); + + static_assert(offsetof(game::mp::cg_t, ps) == 0 + && offsetof(game::mp::playerState_t, vehicleState) == 0xBC + && offsetof(game::PlayerVehicleState, entity) == 0); + assert(std::bit_cast(&game::mp::cg->ps.vehicleState.entity) == 0x14176ECBC); + + static_assert(offsetof(game::mp::cg_t, ps) == 0 && offsetof(game::mp::playerState_t, otherFlags) == 0x10); + assert(std::bit_cast(&game::mp::cg->ps.otherFlags) == 0x14176EC10); + + static_assert(offsetof(game::mp::cg_t, ps) == 0 && offsetof(game::mp::playerState_t, viewlocked_entNum) == 0x1E8); + assert(std::bit_cast(&game::mp::cg->ps.viewlocked_entNum) == 0x14176EDE8); + + static_assert(offsetof(game::mp::playerState_t, bobCycle) == 0x18); + static_assert(offsetof(game::mp::playerState_t, origin) == 0x1C); + static_assert(offsetof(game::mp::playerState_t, velocity) == 0x28); + static_assert(offsetof(game::mp::playerState_t, movementDir) == 0xA8); + static_assert(offsetof(game::mp::playerState_t, viewangles) == 0x184); + + static_assert(offsetof(game::msg_t, useZlib) == 0x30); + + static_assert(offsetof(game::mp::client_t, gentity) == 0x41E68); + static_assert(offsetof(game::mp::client_t, name) == 0x41E70); + static_assert(offsetof(game::mp::client_t, lastConnectTime) == 0x41E84); + static_assert(offsetof(game::mp::client_t, playerGuid) == 0x41EB4); + static_assert(offsetof(game::mp::client_t, testClient) == 0x41ECC); + + static_assert(offsetof(game::mp::serverStatic_t, time) == 0x1681D00); + assert(std::bit_cast(&game::mp::svs->time) == 0x14647B280); + + static_assert(offsetof(game::mp::serverStatic_t, snapFlagServerBit) == 0x1681D08); + assert(std::bit_cast(&game::mp::svs->snapFlagServerBit) == 0x14647B288); + + static_assert(offsetof(game::mp::serverStatic_t, clientCount) == 0x1681D0C); + assert(std::bit_cast(&game::mp::svs->clientCount) == 0x14647B28C); + + static_assert(offsetof(game::mp::serverStatic_t, clients) == 0x1681D10); + assert(std::bit_cast(&game::mp::svs->clients) == 0x14647B290); + } + } +} diff --git a/src/client/component/demo_utils.hpp b/src/client/component/demo_utils.hpp new file mode 100644 index 0000000..94217b1 --- /dev/null +++ b/src/client/component/demo_utils.hpp @@ -0,0 +1,217 @@ +#pragma once + +#include "game/game.hpp" + +namespace demo_utils +{ + namespace globals + { + inline constexpr std::string_view DEMO_CODE_VERSION = "v0.0.1"; + inline constexpr std::string_view DEMO_EXTENSION = ".dm_iw6"; + inline constexpr std::size_t MAX_SIZE = 131'072; + } + using namespace globals; + + namespace types + { + enum struct demo_data_id : std::uint8_t + { + mod_header = 0, + map_header = 1, + network_data = 2, + predicted_data = 3, + update_gamestate_data = 4, + gen_header = 5, + gen_footer = 6, + + eof = 31, + + unused_flag_1 = 0b0010'0000, + unused_flag_2 = 0b0100'0000, + one_byte_flag = 0b1000'0000, + + flags = (unused_flag_1 | unused_flag_2 | one_byte_flag) + }; + + demo_data_id operator|(demo_data_id lhs, demo_data_id rhs); + demo_data_id operator&(demo_data_id lhs, demo_data_id rhs); + demo_data_id operator~(demo_data_id value); + + enum struct predicted_data_id : std::uint8_t + { + player = 0, + vehicle_gryphon = 1, + vehicle_helo_pilot = 2, + vehicle_odin = 3, + vehicle_unknown = 4, + vehicle_helo_pilot_turret_fire = 5 + }; + + struct persistent_data_t + { + // ksr -> killstreak reward + std::int32_t cur_kill_count{}; + std::int32_t req_kill_count_next_ksr{}; + std::int32_t unknown1{ 1 }; + std::int32_t active_ksr_count{}; + std::int32_t unknown2{ -1 }; + std::int32_t active_ksr_flags{}; + std::int32_t unknown3{}; + std::int32_t first_ksr_icon{}; + std::int32_t second_ksr_icon{}; + std::int32_t third_ksr_icon{}; + }; + + struct packed_gamestate_sizes + { + std::uint64_t string_data : 20{}; + std::uint64_t string_offsets : 16{}; + std::uint64_t unused : 27{}; + std::uint64_t compressed : 1{}; + }; + static_assert(sizeof(packed_gamestate_sizes) == 8); + + struct predicted_data_t + { + game::vec3_t viewangles{}; + game::ClientArchiveData cad{}; + }; + + struct gamestate_t + { + std::uint32_t svr_cmd_seq{}; + game::gameState_t data{}; + }; + + class buffer_t + { + public: + [[nodiscard]] std::size_t size() const; + [[nodiscard]] std::span get() const; + void reserve_memory(std::size_t size); + void write(const char* src, std::size_t size); + void clear(); + + private: + std::vector buffer_; + }; + } + using namespace types; + + namespace gen_utilities + { + std::string_view get_dvar_string(std::string_view dvar_name, bool valid_string); + std::string_view get_mod_directory(); + std::string_view get_base_path(bool default_string); + std::string_view get_mapname(bool default_string); + std::string get_shortened_mapname_lowercase(bool default_string); + std::string get_mapname_lowercase(bool default_string); + std::string_view get_gametype(bool default_string); + std::string get_gametype_lowercase(bool default_string); + //std::string get_datetime(); + } + using namespace gen_utilities; + + namespace file_directory + { + std::optional create_directory_user_demo(); + std::optional create_directory_auto_demo(); + bool can_create_demo_directories(); + std::optional create_path_user_demo(const std::filesystem::path& dir_path); + std::optional create_path_user_demo( + const std::filesystem::path& dir_path, std::string_view demo_name, bool overwrite); + std::optional create_path_auto_demo(const std::filesystem::path& dir_path); + } + using namespace file_directory; + + namespace file_directory_server + { + std::optional sv_create_demo_directory(); + bool sv_can_create_demo_directory(); + std::optional sv_create_path_server_demo( + const std::filesystem::path& dir_path, std::string_view client_id); + std::optional sv_create_path_server_demo( + const std::filesystem::path& dir_path, std::string_view demo_name, bool overwrite); + } + using namespace file_directory_server; + + namespace serialization + { + void write_id_and_size(buffer_t& output, std::size_t size, demo_data_id id); + void write_id_and_size(std::ofstream& output, std::size_t size, demo_data_id id); + + void write_network_data(buffer_t& output, std::span network_data); + void write_network_data(std::ofstream& output, std::span network_data); + + //void write_predicted_player_data(buffer_t& output, const game::ClientArchiveData& cad, + //const game::vec3_t& viewangles, std::uint8_t cad_index, predicted_data_id id); + //void write_predicted_player_data(std::ofstream& output, const game::ClientArchiveData& cad, + //const game::vec3_t& viewangles, std::uint8_t cad_index, predicted_data_id id); + + //void write_predicted_vehicle_data(buffer_t& output, const game::ClientArchiveData& cad, + //const game::vec3_t& viewangles, std::uint8_t cad_index, predicted_data_id id); + //void write_predicted_vehicle_data(std::ofstream& output, const game::ClientArchiveData& cad, + //const game::vec3_t& viewangles, std::uint8_t cad_index, predicted_data_id id); + + void write_helo_pilot_turret_fire(buffer_t& output, std::uint8_t fire_count); + void write_helo_pilot_turret_fire(std::ofstream& output, std::uint8_t fire_count); + + void write_mod_header(buffer_t& output); + void write_mod_header(std::ofstream& output); + + bool write_map_header(buffer_t& output); + bool write_map_header(std::ofstream& output); + + bool write_gamestate_data(buffer_t& output); + bool write_gamestate_data(std::ofstream& output); + + void write_predicted_data(buffer_t& output); + void write_predicted_data(std::ofstream& output); + + void write_general_header(buffer_t& output); + void write_general_header(std::ofstream& output); + + //void write_general_footer(buffer_t& output, std::int32_t first_svr_time, std::int32_t last_svr_time); + void write_general_footer(std::ofstream& output, std::int32_t first_svr_time, std::int32_t last_svr_time); + + //void write_end_of_file(buffer_t& output); + void write_end_of_file(std::ofstream& output); + } + using namespace serialization; + + namespace serialization_server + { + void sv_write_predicted_data(buffer_t& output, const game::mp::playerState_t& ps, std::size_t send_msg_count); + void sv_write_predicted_data(std::ofstream& output, const game::mp::playerState_t& ps, std::size_t send_msg_count); + + void sv_write_general_header(buffer_t& output, std::string_view player_name); + void sv_write_general_header(std::ofstream& output, std::string_view player_name); + } + using namespace serialization_server; + + namespace deserialization + { + bool is_gamestate_valid(game::gameState_t& gs, bool sanitize); + bool parse_demo(std::ifstream& file, std::vector& buffer, + std::size_t& post_map_header_file_offset, std::array& pred_data, + std::size_t& pred_data_index, std::optional& gs); + } + using namespace deserialization; + + namespace misc + { + template + requires (std::is_trivially_copyable_v&& std::is_trivially_copyable_v) + [[nodiscard]] To cpy_cast(const From* src) + { + To dst; + std::memcpy(&dst, src, sizeof(To)); + return dst; + } + + persistent_data_t& get_persistent_data(); + void fast_forward_demo(std::uint32_t msec); + void check_address_assertions(); + } + using namespace misc; +}