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