#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 [[nodiscard]] auto use_predicted_data(Func func, Args&&... args); template [[nodiscard]] 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; } // set 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) { repeat(); } else { // don't repeat demo if it was stopped manually 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 snapshot (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{}; }; const 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)