#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)