Resolved merge conflicts. #17
							
								
								
									
										946
									
								
								src/client/component/demo_playback.cpp
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										946
									
								
								src/client/component/demo_playback.cpp
									
									
									
									
									
										Normal file
									
								
							| @@ -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<gamestate_t>& get_gamestate() const; | ||||||
|  | 		[[nodiscard]] bool rewind_demo(std::optional<std::uint32_t> msec); | ||||||
|  | 		[[nodiscard]] bool start(std::filesystem::path& path, bool repeat); | ||||||
|  | 		[[nodiscard]] std::optional<std::reference_wrapper<std::filesystem::path>> restart(); | ||||||
|  | 		template <typename Func, typename... Args> auto use_predicted_data(Func func, Args&&... args); | ||||||
|  | 		template <typename Func, typename... Args> 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<predicted_data_t, 256> pred_data_{}; | ||||||
|  | 		std::optional<std::int32_t> rewind_snap_svr_time_; | ||||||
|  | 		std::optional<gamestate_t> gs_; | ||||||
|  | 		std::vector<std::uint8_t> 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<gamestate_t>& demo_playback_t::get_gamestate() const | ||||||
|  | 	{ | ||||||
|  | 		return gs_; | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	template <typename Func, typename ...Args> | ||||||
|  | 	auto demo_playback_t::use_predicted_data(Func func, Args&& ...args) | ||||||
|  | 	{ | ||||||
|  | 		return func(pred_data_, pred_data_index_, std::forward<Args>(args)...); | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	template <typename Func, typename ...Args> | ||||||
|  | 	auto demo_playback_t::handle_fast_forward(Func func, Args&& ...args) | ||||||
|  | 	{ | ||||||
|  | 		return func(rewind_snap_svr_time_, skip_timeout_, std::forward<Args>(args)...); | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	bool demo_playback_t::rewind_demo(std::optional<std::uint32_t> 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<std::int32_t>(*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<std::reference_wrapper<std::filesystem::path>> 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<const char*>(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<std::size_t>((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<bool>(&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<bool>(&cvs_data, index, std::format("aftermath_{}", mapname).c_str(), duration, time); | ||||||
|  | 					} | ||||||
|  | 				} | ||||||
|  | 				else | ||||||
|  | 				{ | ||||||
|  | 					return CG_VisionSetStartLerp_To_hook.invoke<bool>(&cvs_data, index, VISION_SETS[vision_set], duration, time); | ||||||
|  | 				} | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		return CG_VisionSetStartLerp_To_hook.invoke<bool>(&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<std::int32_t>& 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<std::size_t>(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<void>(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<void>(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<void>(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<bool>(&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<predicted_data_t, 256>& pred_data, std::size_t pred_data_index) | ||||||
|  | 		{ | ||||||
|  | 			static constexpr auto pred_data_size = std::tuple_size_v<std::remove_reference_t<decltype(pred_data)>>; | ||||||
|  |  | ||||||
|  | 			// 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<bool>(&cl, ps_time, &ps_vehicle); | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		demo.use_predicted_data([&ps_vehicle, ps_time](const std::array<predicted_data_t, 256>& pred_data, std::size_t pred_data_index) | ||||||
|  | 		{ | ||||||
|  | 			static constexpr auto pred_data_size = std::tuple_size_v<std::remove_reference_t<decltype(pred_data)>>; | ||||||
|  |  | ||||||
|  | 			// 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<bool>(client_num, lua_vm); | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		// rewind demo and don't display intermission menu for repeated demos | ||||||
|  | 		static_cast<void>(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<const char**>(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<const char**>(game::Omnvar_GetDef(194))) == "ui_alien_show_eog_score"); | ||||||
|  | 				new_snap.omnvars[194] = {}; | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		CG_UpdateOmnvars_hook.invoke<void>(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<void>(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<void>(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<bool>(client_num); | ||||||
|  | 		} | ||||||
|  | 		else | ||||||
|  | 		{ | ||||||
|  | 			// required for e.g. the Trinity Rocket killstreak reward | ||||||
|  | 			other_flags &= ~0x4000; | ||||||
|  | 			const auto result = GetRemoteEyeValues_hook.invoke<bool>(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 force_playback> | ||||||
|  | 	bool execute_playback_command(const command::params& params) | ||||||
|  | 	{ | ||||||
|  | 		struct args_t | ||||||
|  | 		{ | ||||||
|  | 			bool fullpath{}; | ||||||
|  | 			bool repeat{}; | ||||||
|  | 		}; | ||||||
|  |  | ||||||
|  | 		auto parsed_arguments = [¶ms]() -> std::optional<args_t> | ||||||
|  | 		{ | ||||||
|  | 			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 <file_name>, 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<true>(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 <milliseconds>(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 <milliseconds>\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<std::uint32_t>(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<const char**>(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<std::uint8_t, 7> 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<std::size_t>(&game::mp::cg->bgs) == | ||||||
|  | 				patch_address + 0xE + patch.size() + *reinterpret_cast<const std::uint32_t*>(patch_address + 0x11)); | ||||||
|  |  | ||||||
|  | 			// add console command support to play demos | ||||||
|  | 			command::add("demoplay", execute_playback_command<false>); | ||||||
|  |  | ||||||
|  | 			// 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) | ||||||
							
								
								
									
										9
									
								
								src/client/component/demo_playback.hpp
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								src/client/component/demo_playback.hpp
									
									
									
									
									
										Normal file
									
								
							| @@ -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(); | ||||||
|  | } | ||||||
							
								
								
									
										746
									
								
								src/client/component/demo_recording.cpp
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										746
									
								
								src/client/component/demo_recording.cpp
									
									
									
									
									
										Normal file
									
								
							| @@ -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<const std::uint8_t> 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<const std::uint8_t> network_data); | ||||||
|  | 		void process_first_snapshot(std::span<const std::uint8_t> network_data); | ||||||
|  | 		void process_special_network_data(auto old_connstate, auto new_connstate, std::span<const std::uint8_t> 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<const char*>(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<const char*>(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<const std::uint8_t> 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<const std::uint8_t> 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<const std::uint8_t> 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<const std::uint8_t> 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<std::uint8_t>(reinterpret_cast<const std::uint8_t*>(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<std::size_t>(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<std::size_t>(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<const std::uint8_t>(reinterpret_cast<const std::uint8_t*>(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<std::uint32_t>(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<float, 4> 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<std::int32_t>::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 <file_name>(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) | ||||||
							
								
								
									
										8
									
								
								src/client/component/demo_recording.hpp
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								src/client/component/demo_recording.hpp
									
									
									
									
									
										Normal file
									
								
							| @@ -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(); | ||||||
|  | } | ||||||
							
								
								
									
										664
									
								
								src/client/component/demo_sv_recording.cpp
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										664
									
								
								src/client/component/demo_sv_recording.cpp
									
									
									
									
									
										Normal file
									
								
							| @@ -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<std::uint8_t> check_client_num(std::size_t client_num); | ||||||
|  | 		[[nodiscard]] static std::optional<std::uint8_t> parse_client_id(std::string_view str); | ||||||
|  |  | ||||||
|  | 		const std::optional<std::uint8_t> 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<std::uint8_t> sv_client_num_t::check_client_num(std::size_t client_num) | ||||||
|  | 	{ | ||||||
|  | 		if (client_num >= SV_MAX_CLIENTS) | ||||||
|  | 		{ | ||||||
|  | 			return {}; | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		if (client_num >= static_cast<std::size_t>(game::mp::svs->clientCount)) | ||||||
|  | 		{ | ||||||
|  | 			return {}; | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		assert(game::mp::svs->clientCount == game::Dvar_FindVar("sv_maxclients")->current.integer); | ||||||
|  | 		assert(client_num == static_cast<std::size_t>(game::mp::svs->clients[client_num].gentity->s.number)); | ||||||
|  |  | ||||||
|  | 		return static_cast<std::uint8_t>(client_num); | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	std::optional<std::uint8_t> 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<std::size_t>::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<bool>(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<std::string_view>()(dvar_mapname); | ||||||
|  | 				const auto gametype_hash = std::hash<std::string_view>()(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_t> data_; | ||||||
|  | 	}; | ||||||
|  |  | ||||||
|  | 	struct client_data_t | ||||||
|  | 	{ | ||||||
|  | 	private: | ||||||
|  | 		struct stored_times_t | ||||||
|  | 		{ | ||||||
|  | 			std::int32_t first_svr_time{}; | ||||||
|  | 			std::int32_t cur_svr_time{}; | ||||||
|  | 			std::optional<std::int32_t> 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<const std::uint8_t> 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_t, SV_MAX_CLIENTS> 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<const char*>(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<const std::uint8_t> 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<std::size_t>(cl.header.sendMessageCount); | ||||||
|  | 			const auto svr_msg_sequence = static_cast<std::size_t>(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<const char*>(&svr_msg_sequence), 4); | ||||||
|  | 			output.write(reinterpret_cast<const char*>(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<std::size_t>(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<std::size_t>(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<std::size_t>(length); | ||||||
|  |  | ||||||
|  | 				if ((!client_loading || cl.gamestateMessageNum == cl.header.netchan.outgoingSequence) && size < MAX_SIZE) | ||||||
|  | 				{ | ||||||
|  | 					const std::span<const std::uint8_t> network_data(reinterpret_cast<const std::uint8_t*>(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<bool>(&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 <client_num | player_name>, <file_name>(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 <client_num | player_name>\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) | ||||||
							
								
								
									
										8
									
								
								src/client/component/demo_sv_recording.hpp
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								src/client/component/demo_sv_recording.hpp
									
									
									
									
									
										Normal file
									
								
							| @@ -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); | ||||||
|  | } | ||||||
							
								
								
									
										139
									
								
								src/client/component/demo_timescale.cpp
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										139
									
								
								src/client/component/demo_timescale.cpp
									
									
									
									
									
										Normal file
									
								
							| @@ -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<std::uint8_t, 1000>& 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<std::uint8_t, 1000>& 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::size_t>(std::clamp(fps / timescale, 1000.0f, 1'000'000.0f)); | ||||||
|  | 			const auto ones_count = static_cast<std::size_t>(pattern.size() / (call_count_per_sec / static_cast<float>(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<std::int32_t>(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<std::int32_t>(avg_fps); | ||||||
|  |  | ||||||
|  | 			static auto last_timescale = timescale; | ||||||
|  | 			static auto last_maxfps = maxfps; | ||||||
|  | 			static std::size_t pattern_index; | ||||||
|  | 			static std::array<std::uint8_t, 1000> 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<float>(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<std::int32_t>(frame_time); | ||||||
|  | 				const auto ones_count = static_cast<std::size_t>(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) | ||||||
							
								
								
									
										1526
									
								
								src/client/component/demo_utils.cpp
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1526
									
								
								src/client/component/demo_utils.cpp
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										217
									
								
								src/client/component/demo_utils.hpp
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										217
									
								
								src/client/component/demo_utils.hpp
									
									
									
									
									
										Normal file
									
								
							| @@ -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<const std::uint8_t> get() const; | ||||||
|  | 			void reserve_memory(std::size_t size); | ||||||
|  | 			void write(const char* src, std::size_t size); | ||||||
|  | 			void clear(); | ||||||
|  |  | ||||||
|  | 		private: | ||||||
|  | 			std::vector<std::uint8_t> 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<std::filesystem::path> create_directory_user_demo(); | ||||||
|  | 		std::optional<std::filesystem::path> create_directory_auto_demo(); | ||||||
|  | 		bool can_create_demo_directories(); | ||||||
|  | 		std::optional<std::filesystem::path> create_path_user_demo(const std::filesystem::path& dir_path); | ||||||
|  | 		std::optional<std::filesystem::path> create_path_user_demo( | ||||||
|  | 			const std::filesystem::path& dir_path, std::string_view demo_name, bool overwrite); | ||||||
|  | 		std::optional<std::filesystem::path> create_path_auto_demo(const std::filesystem::path& dir_path); | ||||||
|  | 	} | ||||||
|  | 	using namespace file_directory; | ||||||
|  |  | ||||||
|  | 	namespace file_directory_server | ||||||
|  | 	{ | ||||||
|  | 		std::optional<std::filesystem::path> sv_create_demo_directory(); | ||||||
|  | 		bool sv_can_create_demo_directory(); | ||||||
|  | 		std::optional<std::filesystem::path> sv_create_path_server_demo( | ||||||
|  | 			const std::filesystem::path& dir_path, std::string_view client_id); | ||||||
|  | 		std::optional<std::filesystem::path> 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<const std::uint8_t> network_data); | ||||||
|  | 		void write_network_data(std::ofstream& output, std::span<const std::uint8_t> 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<std::uint8_t>& buffer, | ||||||
|  | 			std::size_t& post_map_header_file_offset, std::array<predicted_data_t, 256>& pred_data, | ||||||
|  | 			std::size_t& pred_data_index, std::optional<gamestate_t>& gs); | ||||||
|  | 	} | ||||||
|  | 	using namespace deserialization; | ||||||
|  |  | ||||||
|  | 	namespace misc | ||||||
|  | 	{ | ||||||
|  | 		template <typename To, typename From> | ||||||
|  | 			requires (std::is_trivially_copyable_v<From>&& std::is_trivially_copyable_v<To>) | ||||||
|  | 		[[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; | ||||||
|  | } | ||||||
		Reference in New Issue
	
	Block a user