#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(auto old_connstate, auto new_connstate); 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(auto old_connstate, auto new_connstate) { if (old_connstate < game::CA_ACTIVE || new_connstate < game::CA_ACTIVE) { return; } process_helo_pilot_turret_fire(); if (is_recording()) { 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_predicted_data(old_connstate, new_connstate); demo.process_network_data(old_connstate, new_connstate, std::span(reinterpret_cast(msg.data), msg.cursize)); } 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); // 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)