#include #include #include "Theatre.hpp" #include "UIFeeder.hpp" namespace Components { Theatre::DemoInfo Theatre::CurrentInfo; unsigned int Theatre::CurrentSelection; std::vector Theatre::Demos; Dvar::Var Theatre::CLAutoRecord; Dvar::Var Theatre::CLDemosKeep; char Theatre::BaselineSnapshot[131072] = {0}; int Theatre::BaselineSnapshotMsgLen; int Theatre::BaselineSnapshotMsgOff; nlohmann::json Theatre::DemoInfo::to_json() const { return nlohmann::json { { "mapname", mapname }, { "gametype", gametype }, { "author", author }, { "length", length }, { "timestamp", std::to_string(timeStamp) }, }; } void Theatre::GamestateWriteStub(Game::msg_t* msg, char byte) { Game::MSG_WriteLong(msg, 0); Game::MSG_WriteByte(msg, byte); } void Theatre::RecordGamestateStub() { const auto sequence = (*Game::serverMessageSequence - 1); Game::FS_WriteToDemo(&sequence, 4, *Game::demoFile); } void Theatre::StoreBaseline(PBYTE snapshotMsg) { // Store offset and length BaselineSnapshotMsgLen = *reinterpret_cast(snapshotMsg + 20); BaselineSnapshotMsgOff = *reinterpret_cast(snapshotMsg + 28) - 7; // Copy to our snapshot buffer std::memcpy(BaselineSnapshot, *reinterpret_cast(snapshotMsg + 8), *reinterpret_cast(snapshotMsg + 20)); } __declspec(naked) void Theatre::BaselineStoreStub() { _asm { push edi call StoreBaseline pop edi mov edx, 5ABEF5h jmp edx } } void Theatre::WriteBaseline() { static unsigned char bufData[131072]; static unsigned char cmpData[131072]; Game::msg_t buf; Game::MSG_Init(&buf, bufData, 131072); Game::MSG_WriteData(&buf, &BaselineSnapshot[BaselineSnapshotMsgOff], BaselineSnapshotMsgLen - BaselineSnapshotMsgOff); Game::MSG_WriteByte(&buf, 6); const auto compressedSize = Game::MSG_WriteBitsCompress(false, buf.data, cmpData, buf.cursize); const auto fileCompressedSize = compressedSize + 4; int byte8 = 8; char byte0 = 0; Game::FS_WriteToDemo(&byte0, 1, *Game::demoFile); Game::FS_WriteToDemo(Game::serverMessageSequence, 4, *Game::demoFile); Game::FS_WriteToDemo(&fileCompressedSize, 4, *Game::demoFile); Game::FS_WriteToDemo(&byte8, 4, *Game::demoFile); for (auto i = 0; i < compressedSize; i += 1024) { const auto size = std::min(compressedSize - i, 1024); if (i + size >= sizeof(cmpData)) { Logger::PrintError(Game::CON_CHANNEL_ERROR, "Writing compressed demo baseline exceeded buffer\n"); break; } Game::FS_WriteToDemo(&cmpData[i], size, *Game::demoFile); } } __declspec(naked) void Theatre::BaselineToFileStub() { __asm { pushad call WriteBaseline popad // Restore overwritten operation mov ecx, 0A5E9C4h mov [ecx], 0 // Return to original code push 5A863Ah retn } } __declspec(naked) void Theatre::AdjustTimeDeltaStub() { __asm { mov eax, Game::demoPlaying mov eax, [eax] test al, al jz continue // delta doesn't drift for demos retn continue: push 5A1AD0h retn } } __declspec(naked) void Theatre::ServerTimedOutStub() { __asm { mov eax, Game::demoPlaying mov eax, [eax] test al, al jz continue mov eax, 5A8E70h jmp eax continue: mov eax, 0B2BB90h push 5A8E08h retn } } __declspec(naked) void Theatre::UISetActiveMenuStub() { __asm { mov eax, Game::demoPlaying mov eax, [eax] test al, al jz continue mov eax, 4CB49Ch jmp eax continue: mov ecx, [esp + 10h] push 10h push ecx push 4CB3F6h retn } } void Theatre::CL_WriteDemoClientArchive_Hk(void(*write)(const void* buffer, int len, int localClientNum), const Game::playerState_s* ps, const float* viewangles, [[maybe_unused]] const float* selectedLocation, [[maybe_unused]] const float selectedLocationAngle, int localClientNum, int index) { assert(write); assert(ps); const unsigned char msgType = 1; (write)(&msgType, sizeof(unsigned char), localClientNum); (write)(&index, sizeof(int), localClientNum); (write)(ps->origin, sizeof(float[3]), localClientNum); (write)(ps->velocity, sizeof(float[3]), localClientNum); (write)(&ps->movementDir, sizeof(int), localClientNum); (write)(&ps->bobCycle, sizeof(int), localClientNum); (write)(ps, sizeof(Game::playerState_s*), localClientNum); (write)(viewangles, sizeof(float[3]), localClientNum); // Disable locationSelectionInfo const auto locationSelectionInfo = 0; (write)(&locationSelectionInfo, sizeof(int), localClientNum); } void Theatre::RecordStub(int channel, char* message, char* file) { Game::Com_Printf(channel, message, file); CurrentInfo.name = file; CurrentInfo.mapname = (*Game::sv_mapname)->current.string; CurrentInfo.gametype = (*Game::sv_gametype)->current.string; CurrentInfo.author = Steam::SteamFriends()->GetPersonaName(); CurrentInfo.length = Game::Sys_Milliseconds(); std::time(&CurrentInfo.timeStamp); } void Theatre::StopRecordStub(int channel, char* message) { Game::Com_Printf(channel, message); // Store correct length CurrentInfo.length = Game::Sys_Milliseconds() - CurrentInfo.length; // Write metadata FileSystem::FileWriter meta(std::format("{}.json", CurrentInfo.name)); meta.write(nlohmann::json(CurrentInfo.to_json()).dump()); } void Theatre::LoadDemos([[maybe_unused]] const UIScript::Token& token, [[maybe_unused]] const Game::uiInfo_s* info) { CurrentSelection = 0; Demos.clear(); const auto demos = FileSystem::GetFileList("demos/", "dm_13"); for (auto demo : demos) { if (FileSystem::File meta = std::format("demos/{}.json", demo)) { nlohmann::json metaObject; try { metaObject = nlohmann::json::parse(meta.getBuffer()); DemoInfo demoInfo; demoInfo.name = demo.substr(0, demo.find_last_of(".")); demoInfo.author = metaObject["author"].get(); demoInfo.gametype = metaObject["gametype"].get(); demoInfo.mapname = metaObject["mapname"].get(); demoInfo.length = metaObject["length"].get(); auto timestamp = metaObject["timestamp"].get(); demoInfo.timeStamp = std::strtoll(timestamp.data(), nullptr, 10); Demos.push_back(demoInfo); } catch (const nlohmann::json::parse_error& ex) { Logger::PrintError(Game::CON_CHANNEL_ERROR, "Json Parse Error: {}\n", ex.what()); } } } // Reverse, latest demo first! std::ranges::reverse(Demos); } void Theatre::DeleteDemo([[maybe_unused]] const UIScript::Token& token, [[maybe_unused]] const Game::uiInfo_s* info) { if (CurrentSelection < Demos.size()) { auto demoInfo = Demos.at(CurrentSelection); Logger::Print("Deleting demo {}...\n", demoInfo.name); FileSystem::_DeleteFile("demos", demoInfo.name + ".dm_13"); FileSystem::_DeleteFile("demos", demoInfo.name + ".dm_13.json"); // Reset our ui_demo_* dvars here, because the theater menu needs it. Dvar::Var("ui_demo_mapname").set(""); Dvar::Var("ui_demo_mapname_localized").set(""); Dvar::Var("ui_demo_gametype").set(""); Dvar::Var("ui_demo_length").set(""); Dvar::Var("ui_demo_author").set(""); Dvar::Var("ui_demo_date").set(""); // Reload demos LoadDemos(UIScript::Token(), info); } } void Theatre::PlayDemo([[maybe_unused]] const UIScript::Token& token, [[maybe_unused]] const Game::uiInfo_s* info) { if (CurrentSelection < Demos.size()) { Command::Execute(std::format("demo {}", Demos[CurrentSelection].name), true); Command::Execute("demoback", false); } } unsigned int Theatre::GetDemoCount() { return Demos.size(); } // Omit column here const char* Theatre::GetDemoText(unsigned int item, int /*column*/) { if (item < Demos.size()) { auto info = Demos.at(item); return Utils::String::VA("%s on %s", Game::UI_LocalizeGameType(info.gametype.data()), Game::UI_LocalizeMapName(info.mapname.data())); } return ""; } void Theatre::SelectDemo(unsigned int index) { if (index < Demos.size()) { CurrentSelection = index; auto info = Demos.at(index); tm time; char buffer[1000] = {0}; localtime_s(&time, &info.timeStamp); asctime_s(buffer, sizeof buffer, &time); Dvar::Var("ui_demo_mapname").set(info.mapname); Dvar::Var("ui_demo_mapname_localized").set(Game::UI_LocalizeMapName(info.mapname.data())); Dvar::Var("ui_demo_gametype").set(Game::UI_LocalizeGameType(info.gametype.data())); Dvar::Var("ui_demo_length").set(Utils::String::FormatTimeSpan(info.length)); Dvar::Var("ui_demo_author").set(info.author); Dvar::Var("ui_demo_date").set(buffer); } } int Theatre::CL_FirstSnapshot_Stub() { if (CLAutoRecord.get() && !*Game::demoPlaying) { std::vector files; auto demos = FileSystem::GetFileList("demos/", "dm_13"); for (auto& demo : demos) { if (Utils::String::StartsWith(demo, "auto_")) { files.push_back(demo); } } auto numDel = static_cast(files.size()) - CLDemosKeep.get(); for (auto i = 0; i < numDel; ++i) { Logger::Print("Deleting old demo {}\n", files[i]); FileSystem::_DeleteFile("demos", files[i]); FileSystem::_DeleteFile("demos", std::format("%s.json", files[i])); } Command::Execute(Utils::String::VA("record auto_%lld", std::time(nullptr)), true); } return Utils::Hook::Call(0x42BBB0)(); // DB_GetLoadedFlags } void Theatre::SV_SpawnServer_Stub() { StopRecording(); Utils::Hook::Call(0x464A60)(); // Com_SyncThreads } void Theatre::StopRecording() { if (*Game::demoRecording) { Command::Execute("stoprecord", true); } } Theatre::Theatre() { CLAutoRecord = Dvar::Register("cl_autoRecord", true, Game::DVAR_ARCHIVE, "Automatically record games"); CLDemosKeep = Dvar::Register("cl_demosKeep", 30, 1, 999, Game::DVAR_ARCHIVE, "How many demos to keep with autorecord"); Utils::Hook(0x5A8370, GamestateWriteStub, HOOK_CALL).install()->quick(); Utils::Hook(0x5A85D2, RecordGamestateStub, HOOK_CALL).install()->quick(); Utils::Hook(0x5ABE36, BaselineStoreStub, HOOK_JUMP).install()->quick(); Utils::Hook(0x5A8630, BaselineToFileStub, HOOK_JUMP).install()->quick(); Utils::Hook(0x4CB3EF, UISetActiveMenuStub, HOOK_JUMP).install()->quick(); Utils::Hook(0x50320E, AdjustTimeDeltaStub, HOOK_CALL).install()->quick(); Utils::Hook(0x5A8E03, ServerTimedOutStub, HOOK_JUMP).install()->quick(); // Fix issue with locationSelectionInfo by disabling it Utils::Hook(0x5AC20F, CL_WriteDemoClientArchive_Hk, HOOK_CALL).install()->quick(); // Hook commands to enforce metadata generation Utils::Hook(0x5A82AE, RecordStub, HOOK_CALL).install()->quick(); Utils::Hook(0x5A8156, StopRecordStub, HOOK_CALL).install()->quick(); // Autorecording Utils::Hook(0x5A1D6A, CL_FirstSnapshot_Stub, HOOK_CALL).install()->quick(); Utils::Hook(0x4A712A, SV_SpawnServer_Stub, HOOK_CALL).install()->quick(); // UIScripts UIScript::Add("loadDemos", LoadDemos); UIScript::Add("launchDemo", PlayDemo); UIScript::Add("deleteDemo", DeleteDemo); // Feeder UIFeeder::Add(10.0f, GetDemoCount, GetDemoText, SelectDemo); // set the configstrings stuff to load the default (empty) string table; this should allow demo recording on all gametypes/maps if (!Dedicated::IsEnabled()) Utils::Hook::Set(0x47440B, "mp/defaultStringTable.csv"); // Change font size Utils::Hook::Set(0x5AC854, 2); Utils::Hook::Set(0x5AC85A, 2); } }