forked from alterware/iw6-mod
948 lines
28 KiB
C++
948 lines
28 KiB
C++
#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> [[nodiscard]] auto use_predicted_data(Func func, Args&&... args);
|
|
template <typename Func, typename... Args> [[nodiscard]] 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;
|
|
}
|
|
|
|
// set 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)
|
|
{
|
|
repeat();
|
|
}
|
|
else
|
|
{
|
|
// don't repeat demo if it was stopped manually
|
|
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 snapshot (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{};
|
|
};
|
|
|
|
const 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)
|