Added code for demo system. #16
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;
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user