summaryrefslogtreecommitdiff
path: root/sourcemod/scripting/gokz-replays
diff options
context:
space:
mode:
authornavewindre <nw@moneybot.cc>2023-12-04 18:06:10 +0100
committernavewindre <nw@moneybot.cc>2023-12-04 18:06:10 +0100
commitaef0d1c1268ab7d4bc18996c9c6b4da16a40aadc (patch)
tree43e766b51704f4ab8b383583bdc1871eeeb9c698 /sourcemod/scripting/gokz-replays
parent38f1140c11724da05a23a10385061200b907cf6e (diff)
bbbbbbbbwaaaaaaaaaaa
Diffstat (limited to 'sourcemod/scripting/gokz-replays')
-rw-r--r--sourcemod/scripting/gokz-replays/api.sp78
-rw-r--r--sourcemod/scripting/gokz-replays/commands.sp55
-rw-r--r--sourcemod/scripting/gokz-replays/controls.sp224
-rw-r--r--sourcemod/scripting/gokz-replays/nav.sp97
-rw-r--r--sourcemod/scripting/gokz-replays/playback.sp1501
-rw-r--r--sourcemod/scripting/gokz-replays/recording.sp990
-rw-r--r--sourcemod/scripting/gokz-replays/replay_cache.sp176
-rw-r--r--sourcemod/scripting/gokz-replays/replay_menu.sp139
8 files changed, 3260 insertions, 0 deletions
diff --git a/sourcemod/scripting/gokz-replays/api.sp b/sourcemod/scripting/gokz-replays/api.sp
new file mode 100644
index 0000000..3c115e1
--- /dev/null
+++ b/sourcemod/scripting/gokz-replays/api.sp
@@ -0,0 +1,78 @@
+static GlobalForward H_OnReplaySaved;
+static GlobalForward H_OnReplayDiscarded;
+static GlobalForward H_OnTimerEnd_Post;
+
+// =====[ NATIVES ]=====
+
+void CreateNatives()
+{
+ CreateNative("GOKZ_RP_GetPlaybackInfo", Native_RP_GetPlaybackInfo);
+ CreateNative("GOKZ_RP_LoadJumpReplay", Native_RP_LoadJumpReplay);
+ CreateNative("GOKZ_RP_UpdateReplayControlMenu", Native_RP_UpdateReplayControlMenu);
+}
+
+public int Native_RP_GetPlaybackInfo(Handle plugin, int numParams)
+{
+ HUDInfo info;
+ GetPlaybackState(GetNativeCell(1), info);
+ SetNativeArray(2, info, sizeof(HUDInfo));
+ return 1;
+}
+
+public int Native_RP_LoadJumpReplay(Handle plugin, int numParams)
+{
+ int len;
+ GetNativeStringLength(2, len);
+ char[] path = new char[len + 1];
+ GetNativeString(2, path, len + 1);
+ int botClient = LoadReplayBot(GetNativeCell(1), path);
+ return botClient;
+}
+
+public int Native_RP_UpdateReplayControlMenu(Handle plugin, int numParams)
+{
+ return view_as<int>(UpdateReplayControlMenu(GetNativeCell(1)));
+}
+
+// =====[ FORWARDS ]=====
+
+void CreateGlobalForwards()
+{
+ H_OnReplaySaved = new GlobalForward("GOKZ_RP_OnReplaySaved", ET_Event, Param_Cell, Param_Cell, Param_String, Param_Cell, Param_Cell, Param_Float, Param_String, Param_Cell);
+ H_OnReplayDiscarded = new GlobalForward("GOKZ_RP_OnReplayDiscarded", ET_Ignore, Param_Cell);
+ H_OnTimerEnd_Post = new GlobalForward("GOKZ_RP_OnTimerEnd_Post", ET_Ignore, Param_Cell, Param_String, Param_Cell, Param_Float, Param_Cell);
+}
+
+Action Call_OnReplaySaved(int client, int replayType, const char[] map, int course, int timeType, float time, const char[] filePath, bool tempReplay)
+{
+ Action result;
+ Call_StartForward(H_OnReplaySaved);
+ Call_PushCell(client);
+ Call_PushCell(replayType);
+ Call_PushString(map);
+ Call_PushCell(course);
+ Call_PushCell(timeType);
+ Call_PushFloat(time);
+ Call_PushString(filePath);
+ Call_PushCell(tempReplay);
+ Call_Finish(result);
+ return result;
+}
+
+void Call_OnReplayDiscarded(int client)
+{
+ Call_StartForward(H_OnReplayDiscarded);
+ Call_PushCell(client);
+ Call_Finish();
+}
+
+void Call_OnTimerEnd_Post(int client, const char[] filePath, int course, float time, int teleportsUsed)
+{
+ Call_StartForward(H_OnTimerEnd_Post);
+ Call_PushCell(client);
+ Call_PushString(filePath);
+ Call_PushCell(course);
+ Call_PushFloat(time);
+ Call_PushCell(teleportsUsed);
+ Call_Finish();
+}
diff --git a/sourcemod/scripting/gokz-replays/commands.sp b/sourcemod/scripting/gokz-replays/commands.sp
new file mode 100644
index 0000000..43251f6
--- /dev/null
+++ b/sourcemod/scripting/gokz-replays/commands.sp
@@ -0,0 +1,55 @@
+void RegisterCommands()
+{
+ RegConsoleCmd("sm_replay", CommandReplay, "[KZ] Open the replay loading menu.");
+ RegConsoleCmd("sm_replaycontrols", CommandReplayControls, "[KZ] Toggle the replay control menu.");
+ RegConsoleCmd("sm_rpcontrols", CommandReplayControls, "[KZ] Toggle the replay control menu.");
+ RegConsoleCmd("sm_replaygoto", CommandReplayGoto, "[KZ] Skip to a specific time in the replay (hh:mm:ss).");
+ RegConsoleCmd("sm_rpgoto", CommandReplayGoto, "[KZ] Skip to a specific time in the replay (hh:mm:ss).");
+}
+
+public Action CommandReplay(int client, int args)
+{
+ DisplayReplayModeMenu(client);
+ return Plugin_Handled;
+}
+
+public Action CommandReplayControls(int client, int args)
+{
+ ToggleReplayControls(client);
+ return Plugin_Handled;
+}
+
+public Action CommandReplayGoto(int client, int args)
+{
+ int seconds;
+ char timeString[32], split[3][32];
+
+ GetCmdArgString(timeString, sizeof(timeString));
+ int res = ExplodeString(timeString, ":", split, 3, 32, false);
+ switch (res)
+ {
+ case 1:
+ {
+ seconds = StringToInt(split[0]);
+ }
+
+ case 2:
+ {
+ seconds = StringToInt(split[0]) * 60 + StringToInt(split[1]);
+ }
+
+ case 3:
+ {
+ seconds = StringToInt(split[0]) * 3600 + StringToInt(split[1]) * 60 + StringToInt(split[2]);
+ }
+
+ default:
+ {
+ GOKZ_PrintToChat(client, true, "%t", "Replay Controls - Invalid Time");
+ return Plugin_Handled;
+ }
+ }
+
+ TrySkipToTime(client, seconds);
+ return Plugin_Handled;
+}
diff --git a/sourcemod/scripting/gokz-replays/controls.sp b/sourcemod/scripting/gokz-replays/controls.sp
new file mode 100644
index 0000000..cda7f07
--- /dev/null
+++ b/sourcemod/scripting/gokz-replays/controls.sp
@@ -0,0 +1,224 @@
+/*
+ Lets player control the replay bot.
+*/
+
+#define ITEM_INFO_PAUSE "pause"
+#define ITEM_INFO_SKIP "skip"
+#define ITEM_INFO_REWIND "rewind"
+#define ITEM_INFO_FREECAM "freecam"
+
+static int controllingPlayer[RP_MAX_BOTS];
+static int botTeleports[RP_MAX_BOTS];
+static bool showReplayControls[MAXPLAYERS + 1];
+
+
+
+// =====[ PUBLIC ]=====
+
+void OnPlayerRunCmdPost_ReplayControls(int client, int cmdnum)
+{
+ // Let the HUD plugin takes care of this if possible.
+ if (cmdnum % 6 == 3 && !gB_GOKZHUD)
+ {
+ UpdateReplayControlMenu(client);
+ }
+}
+
+bool UpdateReplayControlMenu(int client)
+{
+ if (!IsValidClient(client) || IsFakeClient(client))
+ {
+ return false;
+ }
+
+ int botClient = GetObserverTarget(client);
+ int bot = GetBotFromClient(botClient);
+ if (bot == -1)
+ {
+ return false;
+ }
+
+ if (!IsReplayBotControlled(bot, botClient) && !InBreather(bot))
+ {
+ CancelReplayControlsForBot(bot);
+ controllingPlayer[bot] = client;
+ }
+ else if (controllingPlayer[bot] != client)
+ {
+ return false;
+ }
+
+ if (showReplayControls[client] &&
+ GOKZ_HUD_GetOption(client, HUDOption_ShowControls) == ReplayControls_Enabled)
+ {
+ // We have to update this often if bot uses teleports.
+ if (GetClientMenu(client) == MenuSource_None ||
+ GOKZ_HUD_GetMenuShowing(client) && GetClientAvgLoss(client, NetFlow_Both) > EPSILON ||
+ GOKZ_HUD_GetMenuShowing(client) && GOKZ_HUD_GetOption(client, HUDOption_TimerText) == TimerText_TPMenu ||
+ GOKZ_HUD_GetMenuShowing(client) && PlaybackGetTeleports(bot) > 0)
+ {
+ botTeleports[bot] = PlaybackGetTeleports(bot);
+ ShowReplayControlMenu(client, bot);
+ }
+ return true;
+ }
+ return false;
+}
+
+void ShowReplayControlMenu(int client, int bot)
+{
+ char text[256];
+
+ Menu menu = new Menu(MenuHandler_ReplayControls);
+ menu.OptionFlags = MENUFLAG_NO_SOUND;
+ menu.Pagination = MENU_NO_PAGINATION;
+ menu.ExitButton = true;
+ if (gB_GOKZHUD)
+ {
+ if (GOKZ_HUD_GetOption(client, HUDOption_ShowSpectators) != ShowSpecs_Disabled &&
+ GOKZ_HUD_GetOption(client, HUDOption_SpecListPosition) == SpecListPosition_TPMenu)
+ {
+ HUDInfo info;
+ GetPlaybackState(client, info);
+ GOKZ_HUD_GetMenuSpectatorText(client, info, text, sizeof(text));
+ }
+ if (GOKZ_HUD_GetOption(client, HUDOption_TimerText) == TimerText_TPMenu)
+ {
+ Format(text, sizeof(text), "%s\n%T - %s", text, "Replay Controls - Title", client,
+ GOKZ_FormatTime(GetPlaybackTime(bot), GOKZ_HUD_GetOption(client, HUDOption_TimerStyle) == TimerStyle_Precise));
+ }
+ else
+ {
+ Format(text, sizeof(text), "%s%T", text, "Replay Controls - Title", client);
+ }
+ }
+ else
+ {
+ Format(text, sizeof(text), "%s%T", text, "Replay Controls - Title", client);
+ }
+
+
+ if (botTeleports[bot] > 0)
+ {
+ Format(text, sizeof(text), "%s\n%T", text, "Replay Controls - Teleports", client, botTeleports[bot]);
+ }
+
+ menu.SetTitle(text);
+
+ if (PlaybackPaused(bot))
+ {
+ FormatEx(text, sizeof(text), "%T", "Replay Controls - Resume", client);
+ menu.AddItem(ITEM_INFO_PAUSE, text);
+ }
+ else
+ {
+ FormatEx(text, sizeof(text), "%T", "Replay Controls - Pause", client);
+ menu.AddItem(ITEM_INFO_PAUSE, text);
+ }
+
+ FormatEx(text, sizeof(text), "%T", "Replay Controls - Skip", client);
+ menu.AddItem(ITEM_INFO_SKIP, text);
+
+ FormatEx(text, sizeof(text), "%T\n ", "Replay Controls - Rewind", client);
+ menu.AddItem(ITEM_INFO_REWIND, text);
+
+ FormatEx(text, sizeof(text), "%T", "Replay Controls - Freecam", client);
+ menu.AddItem(ITEM_INFO_FREECAM, text);
+
+ menu.Display(client, MENU_TIME_FOREVER);
+
+ if (gB_GOKZHUD)
+ {
+ GOKZ_HUD_SetMenuShowing(client, true);
+ }
+}
+
+void ToggleReplayControls(int client)
+{
+ if (showReplayControls[client])
+ {
+ CancelReplayControls(client);
+ }
+ else
+ {
+ showReplayControls[client] = true;
+ }
+}
+
+void EnableReplayControls(int client)
+{
+ showReplayControls[client] = true;
+}
+
+bool IsReplayBotControlled(int bot, int botClient)
+{
+ return IsValidClient(controllingPlayer[bot]) &&
+ (GetObserverTarget(controllingPlayer[bot]) == botClient ||
+ GetEntProp(controllingPlayer[bot], Prop_Send, "m_iObserverMode") == 6);
+}
+
+int MenuHandler_ReplayControls(Menu menu, MenuAction action, int param1, int param2)
+{
+ switch (action)
+ {
+ case MenuAction_Select:
+ {
+ if (!IsValidClient(param1))
+ {
+ return;
+ }
+
+ int bot = GetBotFromClient(GetObserverTarget(param1));
+ if (bot == -1 || controllingPlayer[bot] != param1)
+ {
+ return;
+ }
+
+ char info[16];
+ menu.GetItem(param2, info, sizeof(info));
+ if (StrEqual(info, ITEM_INFO_PAUSE, false))
+ {
+ PlaybackTogglePause(bot);
+ }
+ else if (StrEqual(info, ITEM_INFO_SKIP, false))
+ {
+ PlaybackSkipForward(bot);
+ }
+ else if (StrEqual(info, ITEM_INFO_REWIND, false))
+ {
+ PlaybackSkipBack(bot);
+ }
+ else if (StrEqual(info, ITEM_INFO_FREECAM, false))
+ {
+ SetEntProp(param1, Prop_Send, "m_iObserverMode", 6);
+ }
+ GOKZ_HUD_SetMenuShowing(param1, false);
+ }
+ case MenuAction_Cancel:
+ {
+ GOKZ_HUD_SetMenuShowing(param1, false);
+ if (param2 == MenuCancel_Exit)
+ {
+ CancelReplayControls(param1);
+ }
+ }
+ case MenuAction_End:
+ {
+ delete menu;
+ }
+ }
+}
+
+void CancelReplayControls(int client)
+{
+ if (IsValidClient(client) && showReplayControls[client])
+ {
+ CancelClientMenu(client);
+ showReplayControls[client] = false;
+ }
+}
+
+void CancelReplayControlsForBot(int bot)
+{
+ CancelReplayControls(controllingPlayer[bot]);
+}
diff --git a/sourcemod/scripting/gokz-replays/nav.sp b/sourcemod/scripting/gokz-replays/nav.sp
new file mode 100644
index 0000000..4e73c2f
--- /dev/null
+++ b/sourcemod/scripting/gokz-replays/nav.sp
@@ -0,0 +1,97 @@
+/*
+ Ensures that there is .nav file for the map so the server
+ does not to auto-generating one.
+*/
+
+
+
+// =====[ EVENTS ]=====
+
+void OnMapStart_Nav()
+{
+ if (!CheckForNavFile())
+ {
+ GenerateNavFile();
+ }
+}
+
+
+
+// =====[ PRIVATE ]=====
+
+static bool CheckForNavFile()
+{
+ // Make sure there's a nav file
+ // Credit to shavit's simple bhop timer - https://github.com/shavitush/bhoptimer
+
+ char mapPath[PLATFORM_MAX_PATH];
+ GetCurrentMap(mapPath, sizeof(mapPath));
+
+ char navFilePath[PLATFORM_MAX_PATH];
+ FormatEx(navFilePath, PLATFORM_MAX_PATH, "maps/%s.nav", mapPath);
+
+ return FileExists(navFilePath);
+}
+
+static void GenerateNavFile()
+{
+ // Generate (copy a) .nav file for the map
+ // Credit to shavit's simple bhop timer - https://github.com/shavitush/bhoptimer
+
+ char mapPath[PLATFORM_MAX_PATH];
+ GetCurrentMap(mapPath, sizeof(mapPath));
+
+ char[] navFilePath = new char[PLATFORM_MAX_PATH];
+ FormatEx(navFilePath, PLATFORM_MAX_PATH, "maps/%s.nav", mapPath);
+
+ if (!FileExists(RP_NAV_FILE))
+ {
+ SetFailState("Failed to load file: \"%s\". Check that it exists.", RP_NAV_FILE);
+ }
+ File_Copy(RP_NAV_FILE, navFilePath);
+ ForceChangeLevel(gC_CurrentMap, "[gokz-replays] Generate .nav file.");
+}
+
+/*
+ * Copies file source to destination
+ * Based on code of javalia:
+ * http://forums.alliedmods.net/showthread.php?t=159895
+ *
+ * Credit to shavit's simple bhop timer - https://github.com/shavitush/bhoptimer
+ *
+ * @param source Input file
+ * @param destination Output file
+ */
+static bool File_Copy(const char[] source, const char[] destination)
+{
+ File file_source = OpenFile(source, "rb");
+
+ if (file_source == null)
+ {
+ return false;
+ }
+
+ File file_destination = OpenFile(destination, "wb");
+
+ if (file_destination == null)
+ {
+ delete file_source;
+
+ return false;
+ }
+
+ int[] buffer = new int[32];
+ int cache = 0;
+
+ while (!IsEndOfFile(file_source))
+ {
+ cache = ReadFile(file_source, buffer, 32, 1);
+
+ file_destination.Write(buffer, cache, 1);
+ }
+
+ delete file_source;
+ delete file_destination;
+
+ return true;
+} \ No newline at end of file
diff --git a/sourcemod/scripting/gokz-replays/playback.sp b/sourcemod/scripting/gokz-replays/playback.sp
new file mode 100644
index 0000000..b3f6865
--- /dev/null
+++ b/sourcemod/scripting/gokz-replays/playback.sp
@@ -0,0 +1,1501 @@
+/*
+ Bot replay playback logic and processes.
+
+ The recorded files are read and their information and tick data
+ stored into variables. A bot is then used to playback the recorded
+ data by setting it's origin, velocity, etc. in OnPlayerRunCmd.
+*/
+
+
+
+static int preAndPostRunTickCount;
+
+static int playbackTick[RP_MAX_BOTS];
+static ArrayList playbackTickData[RP_MAX_BOTS];
+static bool inBreather[RP_MAX_BOTS];
+static float breatherStartTime[RP_MAX_BOTS];
+
+// Original bot caller, needed for OnClientPutInServer callback
+static int botCaller[RP_MAX_BOTS];
+// Original bot name after creation by bot_add, needed for bot removal
+static char botName[RP_MAX_BOTS][MAX_NAME_LENGTH];
+static bool botInGame[RP_MAX_BOTS];
+static int botClient[RP_MAX_BOTS];
+static bool botDataLoaded[RP_MAX_BOTS];
+static int botReplayType[RP_MAX_BOTS];
+static int botReplayVersion[RP_MAX_BOTS];
+static int botSteamAccountID[RP_MAX_BOTS];
+static int botCourse[RP_MAX_BOTS];
+static int botMode[RP_MAX_BOTS];
+static int botStyle[RP_MAX_BOTS];
+static float botTime[RP_MAX_BOTS];
+static int botTimeTicks[RP_MAX_BOTS];
+static char botAlias[RP_MAX_BOTS][MAX_NAME_LENGTH];
+static bool botPaused[RP_MAX_BOTS];
+static bool botPlaybackPaused[RP_MAX_BOTS];
+static int botKnife[RP_MAX_BOTS];
+static int botWeapon[RP_MAX_BOTS];
+static int botJumpType[RP_MAX_BOTS];
+static float botJumpDistance[RP_MAX_BOTS];
+static int botJumpBlockDistance[RP_MAX_BOTS];
+
+static int timeOnGround[RP_MAX_BOTS];
+static int timeInAir[RP_MAX_BOTS];
+static int botTeleportsUsed[RP_MAX_BOTS];
+static int botCurrentTeleport[RP_MAX_BOTS];
+static int botButtons[RP_MAX_BOTS];
+static MoveType botMoveType[RP_MAX_BOTS];
+static float botTakeoffSpeed[RP_MAX_BOTS];
+static float botSpeed[RP_MAX_BOTS];
+static float botLastOrigin[RP_MAX_BOTS][3];
+static bool hitBhop[RP_MAX_BOTS];
+static bool hitPerf[RP_MAX_BOTS];
+static bool botJumped[RP_MAX_BOTS];
+static bool botIsTakeoff[RP_MAX_BOTS];
+static bool botJustTeleported[RP_MAX_BOTS];
+static float botLandingSpeed[RP_MAX_BOTS];
+
+
+
+// =====[ PUBLIC ]=====
+
+// Returns the client index of the replay bot, or -1 otherwise
+int LoadReplayBot(int client, char[] path)
+{
+ // Safeguard Check
+ if (GOKZ_GetCoreOption(client, Option_Safeguard) > Safeguard_Disabled && GOKZ_GetTimerRunning(client) && GOKZ_GetValidTimer(client))
+ {
+ if (!GOKZ_GetPaused(client) && !GOKZ_GetCanPause(client))
+ {
+ GOKZ_PrintToChat(client, true, "%t", "Safeguard - Blocked");
+ GOKZ_PlayErrorSound(client);
+ return -1;
+ }
+ }
+ int bot;
+ if (GetBotsInUse() < RP_MAX_BOTS)
+ {
+ bot = GetUnusedBot();
+ }
+ else
+ {
+ GOKZ_PrintToChat(client, true, "%t", "No Bots Available");
+ GOKZ_PlayErrorSound(client);
+ return -1;
+ }
+
+ if (bot == -1)
+ {
+ LogError("Unused bot could not be found even though only %d out of %d are known to be in use.",
+ GetBotsInUse(), RP_MAX_BOTS);
+ GOKZ_PlayErrorSound(client);
+ return -1;
+ }
+
+ if (!LoadPlayback(client, bot, path))
+ {
+ GOKZ_PlayErrorSound(client);
+ return -1;
+ }
+
+ ServerCommand("bot_add");
+ botCaller[bot] = client;
+ return botClient[bot];
+}
+
+// Passes the current state of the replay into the HUDInfo struct
+void GetPlaybackState(int client, HUDInfo info)
+{
+ int bot, i;
+ for(i = 0; i < RP_MAX_BOTS; i++)
+ {
+ bot = botClient[i] == client ? i : bot;
+ }
+ if (i == RP_MAX_BOTS + 1) return;
+
+ if (playbackTickData[bot] == INVALID_HANDLE)
+ {
+ return;
+ }
+
+ info.TimerRunning = botReplayType[bot] == ReplayType_Jump ? false : true;
+ if (botReplayVersion[bot] == 1)
+ {
+ info.Time = playbackTick[bot] * GetTickInterval();
+ }
+ else if (botReplayVersion[bot] == 2)
+ {
+ if (playbackTick[bot] < preAndPostRunTickCount)
+ {
+ info.Time = 0.0;
+ }
+ else if (playbackTick[bot] >= playbackTickData[bot].Length - preAndPostRunTickCount)
+ {
+ info.Time = botTime[bot];
+ }
+ else if (playbackTick[bot] >= preAndPostRunTickCount)
+ {
+ info.Time = (playbackTick[bot] - preAndPostRunTickCount) * GetTickInterval();
+ }
+ }
+ info.TimerRunning = true;
+ info.TimeType = botTeleportsUsed[bot] > 0 ? TimeType_Nub : TimeType_Pro;
+ info.Speed = botSpeed[bot];
+ info.Paused = false;
+ info.OnLadder = (botMoveType[bot] == MOVETYPE_LADDER);
+ info.Noclipping = false;
+ info.OnGround = Movement_GetOnGround(client);
+ info.Ducking = botButtons[bot] & IN_DUCK > 0;
+ info.ID = botClient[bot];
+ info.Jumped = botJumped[bot];
+ info.HitBhop = hitBhop[bot];
+ info.HitPerf = hitPerf[bot];
+ info.Buttons = botButtons[bot];
+ info.TakeoffSpeed = botTakeoffSpeed[bot];
+ info.IsTakeoff = botIsTakeoff[bot] && !Movement_GetOnGround(client);
+ info.CurrentTeleport = botCurrentTeleport[bot];
+}
+
+int GetBotFromClient(int client)
+{
+ for (int bot = 0; bot < RP_MAX_BOTS; bot++)
+ {
+ if (botClient[bot] == client)
+ {
+ return bot;
+ }
+ }
+ return -1;
+}
+
+bool InBreather(int bot)
+{
+ return inBreather[bot];
+}
+
+bool PlaybackPaused(int bot)
+{
+ return botPlaybackPaused[bot];
+}
+
+void PlaybackTogglePause(int bot)
+{
+ if(botPlaybackPaused[bot])
+ {
+ botPlaybackPaused[bot] = false;
+ }
+ else
+ {
+ botPlaybackPaused[bot] = true;
+ }
+}
+
+void PlaybackSkipForward(int bot)
+{
+ if (playbackTick[bot] + RoundToZero(RP_SKIP_TIME / GetTickInterval()) < playbackTickData[bot].Length)
+ {
+ PlaybackSkipToTick(bot, playbackTick[bot] + RoundToZero(RP_SKIP_TIME / GetTickInterval()));
+ }
+}
+
+void PlaybackSkipBack(int bot)
+{
+ if (playbackTick[bot] < RoundToZero(RP_SKIP_TIME / GetTickInterval()))
+ {
+ PlaybackSkipToTick(bot, 0);
+ }
+ else
+ {
+ PlaybackSkipToTick(bot, playbackTick[bot] - RoundToZero(RP_SKIP_TIME / GetTickInterval()));
+ }
+}
+
+int PlaybackGetTeleports(int bot)
+{
+ return botCurrentTeleport[bot];
+}
+
+void TrySkipToTime(int client, int seconds)
+{
+ if (!IsValidClient(client))
+ {
+ return;
+ }
+
+ int tick = seconds * 128 + preAndPostRunTickCount;
+ int bot = GetBotFromClient(GetObserverTarget(client));
+
+ if (tick >= 0 && tick < playbackTickData[bot].Length)
+ {
+ PlaybackSkipToTick(bot, tick);
+ }
+ else
+ {
+ GOKZ_PrintToChat(client, true, "%t", "Replay Controls - Invalid Time");
+ }
+}
+
+float GetPlaybackTime(int bot)
+{
+ if (playbackTick[bot] < preAndPostRunTickCount)
+ {
+ return 0.0;
+ }
+ if (playbackTick[bot] >= playbackTickData[bot].Length - preAndPostRunTickCount)
+ {
+ return botTime[bot];
+ }
+ if (playbackTick[bot] >= preAndPostRunTickCount)
+ {
+ return (playbackTick[bot] - preAndPostRunTickCount) * GetTickInterval();
+ }
+
+ return 0.0;
+}
+
+
+
+// =====[ EVENTS ]=====
+
+void OnClientPutInServer_Playback(int client)
+{
+ if (!IsFakeClient(client) || IsClientSourceTV(client))
+ {
+ return;
+ }
+
+ // Check if an unassigned bot has joined, and assign it
+ for (int bot; bot < RP_MAX_BOTS; bot++)
+ {
+ // Also check if the bot was created by us.
+ if (!botInGame[bot] && botCaller[bot] != 0)
+ {
+ botInGame[bot] = true;
+ botClient[bot] = client;
+ GetClientName(client, botName[bot], sizeof(botName[]));
+ // The bot won't receive its weapons properly if we don't wait a frame
+ RequestFrame(SetBotStuff, bot);
+ if (IsValidClient(botCaller[bot]))
+ {
+ MakePlayerSpectate(botCaller[bot], botClient[bot]);
+ botCaller[bot] = 0;
+ }
+ break;
+ }
+ }
+}
+
+void OnClientDisconnect_Playback(int client)
+{
+ for (int bot; bot < RP_MAX_BOTS; bot++)
+ {
+ if (botClient[bot] != client)
+ {
+ continue;
+ }
+
+ botInGame[bot] = false;
+ if (playbackTickData[bot] != null)
+ {
+ playbackTickData[bot].Clear(); // Clear it all out
+ botDataLoaded[bot] = false;
+ }
+ }
+}
+
+void OnPlayerRunCmd_Playback(int client, int &buttons, float vel[3], float angles[3])
+{
+ if (!IsFakeClient(client))
+ {
+ return;
+ }
+
+ for (int bot; bot < RP_MAX_BOTS; bot++)
+ {
+ // Check if not the bot we're looking for
+ if (!botInGame[bot] || botClient[bot] != client || !botDataLoaded[bot])
+ {
+ continue;
+ }
+
+ switch (botReplayVersion[bot])
+ {
+ case 1: PlaybackVersion1(client, bot, buttons);
+ case 2: PlaybackVersion2(client, bot, buttons, vel, angles);
+ }
+ break;
+ }
+}
+
+void OnPlayerRunCmdPost_Playback(int client)
+{
+ for (int bot; bot < RP_MAX_BOTS; bot++)
+ {
+ // Check if not the bot we're looking for
+ if (!botInGame[bot] || botClient[bot] != client || !botDataLoaded[bot])
+ {
+ continue;
+ }
+ if (botReplayVersion[bot] == 2)
+ {
+ PlaybackVersion2Post(client, bot);
+ }
+ break;
+ }
+}
+
+void GOKZ_OnOptionsLoaded_Playback(int client)
+{
+ for (int bot = 0; bot < RP_MAX_BOTS; bot++)
+ {
+ if (botClient[bot] == client)
+ {
+ // Reset its movement options as it might be wrongfully changed
+ GOKZ_SetCoreOption(client, Option_Mode, botMode[bot]);
+ GOKZ_SetCoreOption(client, Option_Style, botStyle[bot]);
+ }
+ }
+}
+// =====[ PRIVATE ]=====
+
+// Returns false if there was a problem loading the playback e.g. doesn't exist
+static bool LoadPlayback(int client, int bot, char[] path)
+{
+ if (!FileExists(path))
+ {
+ GOKZ_PrintToChat(client, true, "%t", "No Replay Found");
+ return false;
+ }
+
+ File file = OpenFile(path, "rb");
+
+ // Check magic number in header
+ int magicNumber;
+ file.ReadInt32(magicNumber);
+ if (magicNumber != RP_MAGIC_NUMBER)
+ {
+ LogError("Failed to load invalid replay file: \"%s\".", path);
+ delete file;
+ return false;
+ }
+
+ // Check replay format version
+ int formatVersion;
+ file.ReadInt8(formatVersion);
+ switch(formatVersion)
+ {
+ case 1:
+ {
+ botReplayVersion[bot] = 1;
+ if (!LoadFormatVersion1Replay(file, bot))
+ {
+ return false;
+ }
+ }
+ case 2:
+ {
+ botReplayVersion[bot] = 2;
+ if (!LoadFormatVersion2Replay(file, client, bot))
+ {
+ return false;
+ }
+ }
+
+ default:
+ {
+ LogError("Failed to load replay file with unsupported format version: \"%s\".", path);
+ delete file;
+ return false;
+ }
+ }
+
+ return true;
+}
+
+static bool LoadFormatVersion1Replay(File file, int bot)
+{
+ // Old replays only support runs, not jumps
+ botReplayType[bot] = ReplayType_Run;
+
+ int length;
+
+ // GOKZ version
+ file.ReadInt8(length);
+ char[] gokzVersion = new char[length + 1];
+ file.ReadString(gokzVersion, length, length);
+ gokzVersion[length] = '\0';
+
+ // Map name
+ file.ReadInt8(length);
+ char[] mapName = new char[length + 1];
+ file.ReadString(mapName, length, length);
+ mapName[length] = '\0';
+
+ // Some integers...
+ file.ReadInt32(botCourse[bot]);
+ file.ReadInt32(botMode[bot]);
+ file.ReadInt32(botStyle[bot]);
+
+ // Old replays don't store the weapon information
+ botKnife[bot] = CS_WeaponIDToItemDefIndex(CSWeapon_KNIFE);
+ botWeapon[bot] = (botMode[bot] == Mode_Vanilla) ? -1 : CS_WeaponIDToItemDefIndex(CSWeapon_USP_SILENCER);
+
+ // Time
+ int timeAsInt;
+ file.ReadInt32(timeAsInt);
+ botTime[bot] = view_as<float>(timeAsInt);
+
+ // Some integers...
+ file.ReadInt32(botTeleportsUsed[bot]);
+ file.ReadInt32(botSteamAccountID[bot]);
+
+ // SteamID2
+ file.ReadInt8(length);
+ char[] steamID2 = new char[length + 1];
+ file.ReadString(steamID2, length, length);
+ steamID2[length] = '\0';
+
+ // IP
+ file.ReadInt8(length);
+ char[] IP = new char[length + 1];
+ file.ReadString(IP, length, length);
+ IP[length] = '\0';
+
+ // Alias
+ file.ReadInt8(length);
+ file.ReadString(botAlias[bot], sizeof(botAlias[]), length);
+ botAlias[bot][length] = '\0';
+
+ // Read tick data
+ file.ReadInt32(length);
+
+ // Setup playback tick data array list
+ if (playbackTickData[bot] == null)
+ {
+ playbackTickData[bot] = new ArrayList(IntMax(RP_V1_TICK_DATA_BLOCKSIZE, sizeof(ReplayTickData)), length);
+ }
+ else
+ { // Make sure it's all clear and the correct size
+ playbackTickData[bot].Clear();
+ playbackTickData[bot].Resize(length);
+ }
+
+ // The replay has no replay data, this shouldn't happen normally,
+ // but this would cause issues in other code, so we don't even try to load this.
+ if (length == 0)
+ {
+ delete file;
+ return false;
+ }
+
+ any tickData[RP_V1_TICK_DATA_BLOCKSIZE];
+ for (int i = 0; i < length; i++)
+ {
+ file.Read(tickData, RP_V1_TICK_DATA_BLOCKSIZE, 4);
+ playbackTickData[bot].Set(i, view_as<float>(tickData[0]), 0); // origin[0]
+ playbackTickData[bot].Set(i, view_as<float>(tickData[1]), 1); // origin[1]
+ playbackTickData[bot].Set(i, view_as<float>(tickData[2]), 2); // origin[2]
+ playbackTickData[bot].Set(i, view_as<float>(tickData[3]), 3); // angles[0]
+ playbackTickData[bot].Set(i, view_as<float>(tickData[4]), 4); // angles[1]
+ playbackTickData[bot].Set(i, view_as<int>(tickData[5]), 5); // buttons
+ playbackTickData[bot].Set(i, view_as<int>(tickData[6]), 6); // flags
+ }
+
+ playbackTick[bot] = 0;
+ botDataLoaded[bot] = true;
+
+ delete file;
+ return true;
+}
+
+static bool LoadFormatVersion2Replay(File file, int client, int bot)
+{
+ int length;
+
+ // Replay type
+ int replayType;
+ file.ReadInt8(replayType);
+
+ // GOKZ version
+ file.ReadInt8(length);
+ char[] gokzVersion = new char[length + 1];
+ file.ReadString(gokzVersion, length, length);
+ gokzVersion[length] = '\0';
+
+ // Map name
+ file.ReadInt8(length);
+ char[] mapName = new char[length + 1];
+ file.ReadString(mapName, length, length);
+ mapName[length] = '\0';
+ if (!StrEqual(mapName, gC_CurrentMap))
+ {
+ GOKZ_PrintToChat(client, true, "%t", "Replay Menu - Wrong Map", mapName);
+ delete file;
+ return false;
+ }
+
+ // Map filesize
+ int mapFileSize;
+ file.ReadInt32(mapFileSize);
+
+ // Server IP
+ int serverIP;
+ file.ReadInt32(serverIP);
+
+ // Timestamp
+ int timestamp;
+ file.ReadInt32(timestamp);
+
+ // Player Alias
+ file.ReadInt8(length);
+ file.ReadString(botAlias[bot], sizeof(botAlias[]), length);
+ botAlias[bot][length] = '\0';
+
+ // Player Steam ID
+ int steamID;
+ file.ReadInt32(steamID);
+
+ // Mode
+ file.ReadInt8(botMode[bot]);
+
+ // Style
+ file.ReadInt8(botStyle[bot]);
+
+ // Player Sensitivity
+ int intPlayerSensitivity;
+ file.ReadInt32(intPlayerSensitivity);
+ float playerSensitivity = view_as<float>(intPlayerSensitivity);
+
+ // Player MYAW
+ int intPlayerMYaw;
+ file.ReadInt32(intPlayerMYaw);
+ float playerMYaw = view_as<float>(intPlayerMYaw);
+
+ // Tickrate
+ int tickrateAsInt;
+ file.ReadInt32(tickrateAsInt);
+ float tickrate = view_as<float>(tickrateAsInt);
+ if (tickrate != RoundToZero(1 / GetTickInterval()))
+ {
+ GOKZ_PrintToChat(client, true, "%t", "Replay Menu - Wrong Tickrate", tickrate, (RoundToZero(1 / GetTickInterval())));
+ delete file;
+ return false;
+ }
+
+ // Tick Count
+ int tickCount;
+ file.ReadInt32(tickCount);
+
+ // The replay has no replay data, this shouldn't happen normally,
+ // but this would cause issues in other code, so we don't even try to load this.
+ if (tickCount == 0)
+ {
+ delete file;
+ return false;
+ }
+
+ // Equipped Weapon
+ file.ReadInt32(botWeapon[bot]);
+
+ // Equipped Knife
+ file.ReadInt32(botKnife[bot]);
+
+ // Big spit to console
+ PrintToConsole(client, "Replay Type: %d\nGOKZ Version: %s\nMap Name: %s\nMap Filesize: %d\nServer IP: %d\nTimestamp: %d\nPlayer Alias: %s\nPlayer Steam ID: %d\nMode: %d\nStyle: %d\nPlayer Sensitivity: %f\nPlayer m_yaw: %f\nTickrate: %f\nTick Count: %d\nWeapon: %d\nKnife: %d", replayType, gokzVersion, mapName, mapFileSize, serverIP, timestamp, botAlias[bot], steamID, botMode[bot], botStyle[bot], playerSensitivity, playerMYaw, tickrate, tickCount, botWeapon[bot], botKnife[bot]);
+
+ switch(replayType)
+ {
+ case ReplayType_Run:
+ {
+ // Time
+ int timeAsInt;
+ file.ReadInt32(timeAsInt);
+ botTime[bot] = view_as<float>(timeAsInt);
+ botTimeTicks[bot] = RoundToNearest(botTime[bot] * tickrate);
+
+ // Course
+ file.ReadInt8(botCourse[bot]);
+
+ // Teleports Used
+ file.ReadInt32(botTeleportsUsed[bot]);
+
+ // Type
+ botReplayType[bot] = ReplayType_Run;
+
+ // Finish spit to console
+ PrintToConsole(client, "Time: %f\nCourse: %d\nTeleports Used: %d", botTime[bot], botCourse[bot], botTeleportsUsed[bot]);
+ }
+ case ReplayType_Cheater:
+ {
+ // Reason
+ int reason;
+ file.ReadInt8(reason);
+
+ // Type
+ botReplayType[bot] = ReplayType_Cheater;
+
+ // Finish spit to console
+ PrintToConsole(client, "AC Reason: %s", gC_ACReasons[reason]);
+ }
+ case ReplayType_Jump:
+ {
+ // Jump Type
+ file.ReadInt8(botJumpType[bot]);
+
+ // Distance
+ file.ReadInt32(view_as<int>(botJumpDistance[bot]));
+
+ // Block Distance
+ file.ReadInt32(botJumpBlockDistance[bot]);
+
+ // Strafe Count
+ int strafeCount;
+ file.ReadInt8(strafeCount);
+
+ // Sync
+ float sync;
+ file.ReadInt32(view_as<int>(sync));
+
+ // Pre
+ float pre;
+ file.ReadInt32(view_as<int>(pre));
+
+ // Max
+ float max;
+ file.ReadInt32(view_as<int>(max));
+
+ // Airtime
+ int airtime;
+ file.ReadInt32(airtime);
+
+ // Type
+ botReplayType[bot] = ReplayType_Jump;
+
+ // Finish spit to console
+ PrintToConsole(client, "Jump Type: %s\nJump Distance: %f\nBlock Distance: %d\nStrafe Count: %d\nSync: %f\n Pre: %f\nMax: %f\nAirtime: %d",
+ gC_JumpTypes[botJumpType[bot]], botJumpDistance[bot], botJumpBlockDistance[bot], strafeCount, sync, pre, max, airtime);
+ }
+ }
+
+ // Tick Data
+ // Setup playback tick data array list
+ if (playbackTickData[bot] == null)
+ {
+ playbackTickData[bot] = new ArrayList(IntMax(RP_V1_TICK_DATA_BLOCKSIZE, sizeof(ReplayTickData)));
+ }
+ else
+ {
+ playbackTickData[bot].Clear();
+ }
+
+ // Read tick data
+ preAndPostRunTickCount = RoundToZero(RP_PLAYBACK_BREATHER_TIME / GetTickInterval());
+ any tickDataArray[RP_V2_TICK_DATA_BLOCKSIZE];
+ for (int i = 0; i < tickCount; i++)
+ {
+ file.ReadInt32(tickDataArray[RPDELTA_DELTAFLAGS]);
+
+ for (int index = 1; index < sizeof(tickDataArray); index++)
+ {
+ int currentFlag = (1 << index);
+ if (tickDataArray[RPDELTA_DELTAFLAGS] & currentFlag)
+ {
+ file.ReadInt32(tickDataArray[index]);
+ }
+ }
+
+ ReplayTickData tickData;
+ TickDataFromArray(tickDataArray, tickData);
+ // HACK: Jump replays don't record proper length sometimes. I don't know why.
+ // This leads to oversized replays full of 0s at the end.
+ // So, we do this horrible check to dodge that issue.
+ if (tickData.origin[0] == 0 && tickData.origin[1] == 0 && tickData.origin[2] == 0 && tickData.angles[0] == 0 && tickData.angles[1] == 0)
+ {
+ break;
+ }
+ playbackTickData[bot].PushArray(tickData);
+ }
+
+ playbackTick[bot] = 0;
+ botDataLoaded[bot] = true;
+
+ delete file;
+
+ return true;
+}
+
+static void PlaybackVersion1(int client, int bot, int &buttons)
+{
+ int size = playbackTickData[bot].Length;
+ float repOrigin[3], repAngles[3];
+ int repButtons, repFlags;
+
+ // If first or last frame of the playback
+ if (playbackTick[bot] == 0 || playbackTick[bot] == (size - 1))
+ {
+ // Move the bot and pause them at that tick
+ repOrigin[0] = playbackTickData[bot].Get(playbackTick[bot], 0);
+ repOrigin[1] = playbackTickData[bot].Get(playbackTick[bot], 1);
+ repOrigin[2] = playbackTickData[bot].Get(playbackTick[bot], 2);
+ repAngles[0] = playbackTickData[bot].Get(playbackTick[bot], 3);
+ repAngles[1] = playbackTickData[bot].Get(playbackTick[bot], 4);
+ TeleportEntity(client, repOrigin, repAngles, view_as<float>( { 0.0, 0.0, 0.0 } ));
+
+ if (!inBreather[bot])
+ {
+ // Start the breather period
+ inBreather[bot] = true;
+ breatherStartTime[bot] = GetEngineTime();
+ if (playbackTick[bot] == (size - 1))
+ {
+ GOKZ_EmitSoundToClientSpectators(client, gC_ModeEndSounds[GOKZ_GetCoreOption(client, Option_Mode)], _, "Timer End");
+ }
+ }
+ else if (GetEngineTime() > breatherStartTime[bot] + RP_PLAYBACK_BREATHER_TIME)
+ {
+ // End the breather period
+ inBreather[bot] = false;
+ botPlaybackPaused[bot] = false;
+ if (playbackTick[bot] == 0)
+ {
+ GOKZ_EmitSoundToClientSpectators(client, gC_ModeStartSounds[GOKZ_GetCoreOption(client, Option_Mode)], _, "Timer Start");
+ }
+ // Start the bot if first tick. Clear bot if last tick.
+ playbackTick[bot]++;
+ if (playbackTick[bot] == size)
+ {
+ playbackTickData[bot].Clear(); // Clear it all out
+ botDataLoaded[bot] = false;
+ CancelReplayControlsForBot(bot);
+ ServerCommand("bot_kick %s", botName[bot]);
+ }
+ }
+ }
+ else
+ {
+ // Check whether somebody is actually spectating the bot
+ int spec;
+ for (spec = 1; spec < MAXPLAYERS + 1; spec++)
+ {
+ if (IsValidClient(spec) && GetObserverTarget(spec) == botClient[bot])
+ {
+ break;
+ }
+ }
+ if (spec == MAXPLAYERS + 1 && !IsReplayBotControlled(bot, botClient[bot]))
+ {
+ playbackTickData[bot].Clear();
+ botDataLoaded[bot] = false;
+ CancelReplayControlsForBot(bot);
+ ServerCommand("bot_kick %s", botName[bot]);
+ return;
+ }
+
+ // Load in the next tick
+ repOrigin[0] = playbackTickData[bot].Get(playbackTick[bot], 0);
+ repOrigin[1] = playbackTickData[bot].Get(playbackTick[bot], 1);
+ repOrigin[2] = playbackTickData[bot].Get(playbackTick[bot], 2);
+ repAngles[0] = playbackTickData[bot].Get(playbackTick[bot], 3);
+ repAngles[1] = playbackTickData[bot].Get(playbackTick[bot], 4);
+ repButtons = playbackTickData[bot].Get(playbackTick[bot], 5);
+ repFlags = playbackTickData[bot].Get(playbackTick[bot], 6);
+
+ // Check if the replay is paused
+ if (botPlaybackPaused[bot])
+ {
+ TeleportEntity(client, repOrigin, repAngles, view_as<float>( { 0.0, 0.0, 0.0 } ));
+ return;
+ }
+
+ // Set velocity to travel from current origin to recorded origin
+ float currentOrigin[3], velocity[3];
+ Movement_GetOrigin(client, currentOrigin);
+ MakeVectorFromPoints(currentOrigin, repOrigin, velocity);
+ ScaleVector(velocity, 128.0); // Hard-coded 128 tickrate
+ TeleportEntity(client, NULL_VECTOR, repAngles, velocity);
+
+ // We need the velocity directly from the replay to calculate the speeds
+ // for the HUD.
+ MakeVectorFromPoints(botLastOrigin[bot], repOrigin, velocity);
+ ScaleVector(velocity, 128.0); // Hard-coded 128 tickrate
+ CopyVector(repOrigin, botLastOrigin[bot]);
+
+ botSpeed[bot] = GetVectorHorizontalLength(velocity);
+ buttons = repButtons;
+ botButtons[bot] = repButtons;
+
+ // Should the bot be ducking?!
+ if (repButtons & IN_DUCK || repFlags & FL_DUCKING)
+ {
+ buttons |= IN_DUCK;
+ }
+
+ // If the replay file says the bot's on the ground, then fine! Unless you're going too fast...
+ // Note that we don't mind if replay file says bot isn't on ground but the bot is.
+ if (repFlags & FL_ONGROUND && Movement_GetSpeed(client) < SPEED_NORMAL * 2)
+ {
+ if (timeInAir[bot] > 0)
+ {
+ botLandingSpeed[bot] = botSpeed[bot];
+ timeInAir[bot] = 0;
+ botIsTakeoff[bot] = false;
+ botJumped[bot] = false;
+ hitBhop[bot] = false;
+ hitPerf[bot] = false;
+ if (!Movement_GetOnGround(client))
+ {
+ timeOnGround[bot] = 0;
+ }
+ }
+
+ SetEntityFlags(client, GetEntityFlags(client) | FL_ONGROUND);
+ Movement_SetMovetype(client, MOVETYPE_WALK);
+
+ timeOnGround[bot]++;
+ botTakeoffSpeed[bot] = botSpeed[bot];
+ }
+ else
+ {
+ if (timeInAir[bot] == 0)
+ {
+ botIsTakeoff[bot] = true;
+ botJumped[bot] = botButtons[bot] & IN_JUMP > 0;
+ hitBhop[bot] = (timeOnGround[bot] <= RP_MAX_BHOP_GROUND_TICKS) && botJumped[bot];
+
+ if (botMode[bot] == Mode_SimpleKZ)
+ {
+ hitPerf[bot] = timeOnGround[bot] < 3 && botJumped[bot];
+ }
+ else
+ {
+ hitPerf[bot] = timeOnGround[bot] < 2 && botJumped[bot];
+ }
+
+ if (hitPerf[bot])
+ {
+ if (botMode[bot] == Mode_SimpleKZ)
+ {
+ botTakeoffSpeed[bot] = FloatMin(botLandingSpeed[bot], (0.2 * botLandingSpeed[bot] + 200));
+ }
+ else if (botMode[bot] == Mode_KZTimer)
+ {
+ botTakeoffSpeed[bot] = FloatMin(botLandingSpeed[bot], 380.0);
+ }
+ else
+ {
+ botTakeoffSpeed[bot] = FloatMin(botLandingSpeed[bot], 286.0);
+ }
+ }
+ }
+ else
+ {
+ botJumped[bot] = false;
+ botIsTakeoff[bot] = false;
+ }
+
+ timeInAir[bot]++;
+ Movement_SetMovetype(client, MOVETYPE_NOCLIP);
+ }
+
+ playbackTick[bot]++;
+ }
+}
+void PlaybackVersion2(int client, int bot, int &buttons, float vel[3], float angles[3])
+{
+ int size = playbackTickData[bot].Length;
+ ReplayTickData prevTickData;
+ ReplayTickData currentTickData;
+
+ // If first or last frame of the playback
+ if (playbackTick[bot] == 0 || playbackTick[bot] == (size - 1))
+ {
+ // Move the bot and pause them at that tick
+ playbackTickData[bot].GetArray(playbackTick[bot], currentTickData);
+ playbackTickData[bot].GetArray(IntMax(playbackTick[bot] - 1, 0), prevTickData);
+ TeleportEntity(client, currentTickData.origin, currentTickData.angles, view_as<float>( { 0.0, 0.0, 0.0 } ));
+
+ if (!inBreather[bot])
+ {
+ // Start the breather period
+ inBreather[bot] = true;
+ breatherStartTime[bot] = GetEngineTime();
+ }
+ else if (GetEngineTime() > breatherStartTime[bot] + RP_PLAYBACK_BREATHER_TIME)
+ {
+ // End the breather period
+ inBreather[bot] = false;
+ botPlaybackPaused[bot] = false;
+
+ // Start the bot if first tick. Clear bot if last tick.
+ playbackTick[bot]++;
+ if (playbackTick[bot] == size)
+ {
+ playbackTickData[bot].Clear(); // Clear it all out
+ botDataLoaded[bot] = false;
+ CancelReplayControlsForBot(bot);
+ ServerCommand("bot_kick %s", botName[bot]);
+ }
+ }
+ }
+ else
+ {
+ // Check whether somebody is actually spectating the bot
+ int spec;
+ for (spec = 1; spec < MAXPLAYERS + 1; spec++)
+ {
+ if (IsValidClient(spec) && GetObserverTarget(spec) == botClient[bot])
+ {
+ break;
+ }
+ }
+ if (spec == MAXPLAYERS + 1 && !IsReplayBotControlled(bot, botClient[bot]))
+ {
+ playbackTickData[bot].Clear();
+ botDataLoaded[bot] = false;
+ CancelReplayControlsForBot(bot);
+ ServerCommand("bot_kick %s", botName[bot]);
+ return;
+ }
+
+ // Load in the next tick
+ playbackTickData[bot].GetArray(playbackTick[bot], currentTickData);
+ playbackTickData[bot].GetArray(IntMax(playbackTick[bot] - 1, 0), prevTickData);
+
+ // Check if the replay is paused
+ if (botPlaybackPaused[bot])
+ {
+ TeleportEntity(client, currentTickData.origin, currentTickData.angles, view_as<float>( { 0.0, 0.0, 0.0 } ));
+ return;
+ }
+
+ // Play timer start/end sound, if necessary. Reset teleports
+ if (playbackTick[bot] == preAndPostRunTickCount && botReplayType[bot] == ReplayType_Run)
+ {
+ GOKZ_EmitSoundToClientSpectators(client, gC_ModeStartSounds[GOKZ_GetCoreOption(client, Option_Mode)], _, "Timer Start");
+ botCurrentTeleport[bot] = 0;
+ }
+ if (playbackTick[bot] == botTimeTicks[bot] + preAndPostRunTickCount && botReplayType[bot] == ReplayType_Run)
+ {
+ GOKZ_EmitSoundToClientSpectators(client, gC_ModeEndSounds[GOKZ_GetCoreOption(client, Option_Mode)], _, "Timer End");
+ }
+ // We use the previous position/velocity data to recreate sounds accurately.
+ // This might not be necessary as we already did do this in OnPlayerRunCmdPost of last tick,
+ // but we do it again just in case the values don't match up somehow (eg. collision with moving objects?)
+ TeleportEntity(client, NULL_VECTOR, prevTickData.angles, prevTickData.velocity);
+ // TeleportEntity does not set the absolute origin and velocity so we need to do it
+ // to prevent inaccurate eye position interpolation.
+ SetEntPropVector(client, Prop_Data, "m_vecVelocity", prevTickData.velocity);
+ SetEntPropVector(client, Prop_Data, "m_vecAbsVelocity", prevTickData.velocity);
+
+ SetEntPropVector(client, Prop_Data, "m_vecAbsOrigin", prevTickData.origin);
+ SetEntPropVector(client, Prop_Data, "m_vecOrigin", prevTickData.origin);
+
+
+ // Set buttons and potential inputs.
+ int newButtons;
+ if (currentTickData.flags & RP_IN_ATTACK)
+ {
+ newButtons |= IN_ATTACK;
+ }
+ if (currentTickData.flags & RP_IN_ATTACK2)
+ {
+ newButtons |= IN_ATTACK2;
+ }
+ if (currentTickData.flags & RP_IN_JUMP)
+ {
+ newButtons |= IN_JUMP;
+ }
+ if (currentTickData.flags & RP_IN_DUCK || currentTickData.flags & RP_FL_DUCKING)
+ {
+ newButtons |= IN_DUCK;
+ }
+ // Few assumptions here because the replay doesn't track them: Player doesn't use +klook or +strafe.
+ // If the assumptions are wrong we will just end up with wrong sound prediction, no big deal.
+ if (currentTickData.flags & RP_IN_FORWARD)
+ {
+ newButtons |= IN_FORWARD;
+ vel[0] += RP_PLAYER_ACCELSPEED;
+ }
+ if (currentTickData.flags & RP_IN_BACK)
+ {
+ newButtons |= IN_BACK;
+ vel[0] -= RP_PLAYER_ACCELSPEED;
+ }
+ if (currentTickData.flags & RP_IN_MOVELEFT)
+ {
+ newButtons |= IN_MOVELEFT;
+ vel[1] -= RP_PLAYER_ACCELSPEED;
+ }
+ if (currentTickData.flags & RP_IN_MOVERIGHT)
+ {
+ newButtons |= IN_MOVERIGHT;
+ vel[1] += RP_PLAYER_ACCELSPEED;
+ }
+ if (currentTickData.flags & RP_IN_LEFT)
+ {
+ newButtons |= IN_LEFT;
+ }
+ if (currentTickData.flags & RP_IN_RIGHT)
+ {
+ newButtons |= IN_RIGHT;
+ }
+ if (currentTickData.flags & RP_IN_RELOAD)
+ {
+ newButtons |= IN_RELOAD;
+ }
+ if (currentTickData.flags & RP_IN_SPEED)
+ {
+ newButtons |= IN_SPEED;
+ }
+ buttons = newButtons;
+ botButtons[bot] = buttons;
+ // The angles might be wrong if the player teleports, but this should only affect sound prediction.
+ angles = currentTickData.angles;
+
+ // Set the bot's MoveType
+ MoveType replayMoveType = view_as<MoveType>(prevTickData.flags & RP_MOVETYPE_MASK);
+ botMoveType[bot] = replayMoveType;
+ if (replayMoveType == MOVETYPE_WALK)
+ {
+ Movement_SetMovetype(client, MOVETYPE_WALK);
+ }
+ else if (replayMoveType == MOVETYPE_LADDER)
+ {
+ botPaused[bot] = false;
+ Movement_SetMovetype(client, MOVETYPE_LADDER);
+ }
+ else
+ {
+ Movement_SetMovetype(client, MOVETYPE_NOCLIP);
+ }
+ // Set some variables
+ if (currentTickData.flags & RP_TELEPORT_TICK)
+ {
+ botJustTeleported[bot] = true;
+ botCurrentTeleport[bot]++;
+ }
+
+ if (currentTickData.flags & RP_TAKEOFF_TICK)
+ {
+ hitPerf[bot] = currentTickData.flags & RP_HIT_PERF > 0;
+ botIsTakeoff[bot] = true;
+ botTakeoffSpeed[bot] = GetVectorHorizontalLength(currentTickData.velocity);
+ }
+
+ if ((currentTickData.flags & RP_SECONDARY_EQUIPPED) && !IsCurrentWeaponSecondary(client))
+ {
+ int item = GetPlayerWeaponSlot(client, CS_SLOT_SECONDARY);
+ if (item != -1)
+ {
+ char name[64];
+ GetEntityClassname(item, name, sizeof(name));
+ FakeClientCommand(client, "use %s", name);
+ }
+ }
+ else if (!(currentTickData.flags & RP_SECONDARY_EQUIPPED) && IsCurrentWeaponSecondary(client))
+ {
+ int item = GetPlayerWeaponSlot(client, CS_SLOT_KNIFE);
+ if (item != -1)
+ {
+ char name[64];
+ GetEntityClassname(item, name, sizeof(name));
+ FakeClientCommand(client, "use %s", name);
+ }
+ }
+
+ #if defined DEBUG
+ if(!botPlaybackPaused[bot])
+ {
+ PrintToServer("Tick: %d", playbackTick[bot]);
+ PrintToServer("X %f \nY %f \nZ %f\nPitch %f\nYaw %f", currentTickData.origin[0], currentTickData.origin[1], currentTickData.origin[2], currentTickData.angles[0], currentTickData.angles[1]);
+ if(currentTickData.flags & RP_MOVETYPE_MASK == view_as<int>(MOVETYPE_WALK)) PrintToServer("MOVETYPE_WALK");
+ if(currentTickData.flags & RP_MOVETYPE_MASK == view_as<int>(MOVETYPE_LADDER)) PrintToServer("MOVETYPE_LADDER");
+ if(currentTickData.flags & RP_MOVETYPE_MASK == view_as<int>(MOVETYPE_NOCLIP)) PrintToServer("MOVETYPE_NOCLIP");
+ if(currentTickData.flags & RP_MOVETYPE_MASK == view_as<int>(MOVETYPE_NOCLIP)) PrintToServer("MOVETYPE_NONE");
+
+ if(currentTickData.flags & RP_IN_ATTACK) PrintToServer("IN_ATTACK");
+ if(currentTickData.flags & RP_IN_ATTACK2) PrintToServer("IN_ATTACK2");
+ if(currentTickData.flags & RP_IN_JUMP) PrintToServer("IN_JUMP");
+ if(currentTickData.flags & RP_IN_DUCK) PrintToServer("IN_DUCK");
+ if(currentTickData.flags & RP_IN_FORWARD) PrintToServer("IN_FORWARD");
+ if(currentTickData.flags & RP_IN_BACK) PrintToServer("IN_BACK");
+ if(currentTickData.flags & RP_IN_LEFT) PrintToServer("IN_LEFT");
+ if(currentTickData.flags & RP_IN_RIGHT) PrintToServer("IN_RIGHT");
+ if(currentTickData.flags & RP_IN_MOVELEFT) PrintToServer("IN_MOVELEFT");
+ if(currentTickData.flags & RP_IN_MOVERIGHT) PrintToServer("IN_MOVERIGHT");
+ if(currentTickData.flags & RP_IN_RELOAD) PrintToServer("IN_RELOAD");
+ if(currentTickData.flags & RP_IN_SPEED) PrintToServer("IN_SPEED");
+ if(currentTickData.flags & RP_IN_USE) PrintToServer("IN_USE");
+ if(currentTickData.flags & RP_IN_BULLRUSH) PrintToServer("IN_BULLRUSH");
+
+ if(currentTickData.flags & RP_FL_ONGROUND) PrintToServer("FL_ONGROUND");
+ if(currentTickData.flags & RP_FL_DUCKING ) PrintToServer("FL_DUCKING");
+ if(currentTickData.flags & RP_FL_SWIM) PrintToServer("FL_SWIM");
+ if(currentTickData.flags & RP_UNDER_WATER) PrintToServer("WATERLEVEL!=0");
+ if(currentTickData.flags & RP_TELEPORT_TICK) PrintToServer("TELEPORT");
+ if(currentTickData.flags & RP_TAKEOFF_TICK) PrintToServer("TAKEOFF");
+ if(currentTickData.flags & RP_HIT_PERF) PrintToServer("PERF");
+ if(currentTickData.flags & RP_SECONDARY_EQUIPPED) PrintToServer("SECONDARY_WEAPON_EQUIPPED");
+ PrintToServer("==============================================================");
+ }
+ #endif
+ }
+}
+
+void PlaybackVersion2Post(int client, int bot)
+{
+ if (botPlaybackPaused[bot])
+ {
+ return;
+ }
+ int size = playbackTickData[bot].Length;
+ if (playbackTick[bot] != 0 && playbackTick[bot] != (size - 1))
+ {
+ ReplayTickData currentTickData;
+ ReplayTickData prevTickData;
+ playbackTickData[bot].GetArray(playbackTick[bot], currentTickData);
+ playbackTickData[bot].GetArray(IntMax(playbackTick[bot] - 1, 0), prevTickData);
+
+ // TeleportEntity does not set the absolute origin and velocity so we need to do it
+ // to prevent inaccurate eye position interpolation.
+ SetEntPropVector(client, Prop_Data, "m_vecVelocity", currentTickData.velocity);
+ SetEntPropVector(client, Prop_Data, "m_vecAbsVelocity", currentTickData.velocity);
+
+ SetEntPropVector(client, Prop_Data, "m_vecAbsOrigin", currentTickData.origin);
+ SetEntPropVector(client, Prop_Data, "m_vecOrigin", currentTickData.origin);
+
+ SetEntPropFloat(client, Prop_Send, "m_angEyeAngles[0]", currentTickData.angles[0]);
+ SetEntPropFloat(client, Prop_Send, "m_angEyeAngles[1]", currentTickData.angles[1]);
+
+ MoveType replayMoveType = view_as<MoveType>(currentTickData.flags & RP_MOVETYPE_MASK);
+ botMoveType[bot] = replayMoveType;
+ int entityFlags = GetEntityFlags(client);
+ if (replayMoveType == MOVETYPE_WALK)
+ {
+ if (currentTickData.flags & RP_FL_ONGROUND)
+ {
+ SetEntityFlags(client, entityFlags | FL_ONGROUND);
+ botPaused[bot] = false;
+ // The bot is on the ground, so there must be a ground entity attributed to the bot.
+ int groundEnt = GetEntPropEnt(client, Prop_Send, "m_hGroundEntity");
+ if (groundEnt == -1 && botJustTeleported[bot])
+ {
+ SetEntPropFloat(client, Prop_Send, "m_flFallVelocity", 0.0);
+ float endPosition[3], mins[3], maxs[3];
+ GetEntPropVector(client, Prop_Send, "m_vecMaxs", maxs);
+ GetEntPropVector(client, Prop_Send, "m_vecMins", mins);
+ endPosition = currentTickData.origin;
+ endPosition[2] -= 2.0;
+ TR_TraceHullFilter(currentTickData.origin, endPosition, mins, maxs, MASK_PLAYERSOLID, TraceEntityFilterPlayers);
+
+ // This should always hit.
+ if (TR_DidHit())
+ {
+ groundEnt = TR_GetEntityIndex();
+ SetEntPropEnt(client, Prop_Data, "m_hGroundEntity", groundEnt);
+ }
+ }
+ }
+ else
+ {
+ botJustTeleported[bot] = false;
+ }
+ }
+
+ if (currentTickData.flags & RP_UNDER_WATER)
+ {
+ SetEntityFlags(client, entityFlags | FL_INWATER);
+ }
+
+ botSpeed[bot] = GetVectorHorizontalLength(currentTickData.velocity);
+ playbackTick[bot]++;
+ }
+}
+
+// Set the bot client's GOKZ options, clan tag and name based on the loaded replay data
+static void SetBotStuff(int bot)
+{
+ if (!botInGame[bot] || !botDataLoaded[bot])
+ {
+ return;
+ }
+
+ int client = botClient[bot];
+
+ // Set its movement options just in case it could negatively affect the playback
+ GOKZ_SetCoreOption(client, Option_Mode, botMode[bot]);
+ GOKZ_SetCoreOption(client, Option_Style, botStyle[bot]);
+
+ // Clan tag and name
+ SetBotClanTag(bot);
+ SetBotName(bot);
+
+ // Bot takes one tick after being put in server to be able to respawn.
+ RequestFrame(RequestFrame_SetBotStuff, GetClientUserId(client));
+}
+
+public void RequestFrame_SetBotStuff(int userid)
+{
+ int client = GetClientOfUserId(userid);
+ if (!client)
+ {
+ return;
+ }
+ int bot;
+ for (bot = 0; bot <= RP_MAX_BOTS; bot++)
+ {
+ if (botClient[bot] == client)
+ {
+ break;
+ }
+ else if (bot == RP_MAX_BOTS)
+ {
+ return;
+ }
+ }
+ // Set the bot's team based on if it's NUB or PRO
+ if (botReplayType[bot] == ReplayType_Run
+ && GOKZ_GetTimeTypeEx(botTeleportsUsed[bot]) == TimeType_Pro)
+ {
+ GOKZ_JoinTeam(client, CS_TEAM_CT, .forceBroadcast = true);
+ }
+ else
+ {
+ GOKZ_JoinTeam(client, CS_TEAM_CT, .forceBroadcast = true);
+ }
+ // Set bot weapons
+ // Always start by removing the pistol and knife
+ int currentPistol = GetPlayerWeaponSlot(client, CS_SLOT_SECONDARY);
+ if (currentPistol != -1)
+ {
+ RemovePlayerItem(client, currentPistol);
+ }
+
+ int currentKnife = GetPlayerWeaponSlot(client, CS_SLOT_KNIFE);
+ if (currentKnife != -1)
+ {
+ RemovePlayerItem(client, currentKnife);
+ }
+
+ char weaponName[128];
+ // Give the bot the knife stored in the replay
+ /*
+ if (botKnife[bot] != 0)
+ {
+ CS_WeaponIDToAlias(CS_ItemDefIndexToID(botKnife[bot]), weaponName, sizeof(weaponName));
+ Format(weaponName, sizeof(weaponName), "weapon_%s", weaponName);
+ GivePlayerItem(client, weaponName);
+ }
+ else
+ {
+ GivePlayerItem(client, "weapon_knife");
+ }
+ */
+ // We are currently not doing that, as it would require us to disable the
+ // FollowCSGOServerGuidelines failsafe if the bot has a non-standard knife.
+ GivePlayerItem(client, "weapon_knife");
+
+ // Give the bot the pistol stored in the replay
+ if (botWeapon[bot] != -1)
+ {
+ CS_WeaponIDToAlias(CS_ItemDefIndexToID(botWeapon[bot]), weaponName, sizeof(weaponName));
+ Format(weaponName, sizeof(weaponName), "weapon_%s", weaponName);
+ GivePlayerItem(client, weaponName);
+ }
+
+ botCurrentTeleport[bot] = 0;
+}
+
+static void SetBotClanTag(int bot)
+{
+ char tag[MAX_NAME_LENGTH];
+
+ if (botReplayType[bot] == ReplayType_Run)
+ {
+ if (botCourse[bot] == 0)
+ {
+ // KZT PRO
+ FormatEx(tag, sizeof(tag), "%s %s",
+ gC_ModeNamesShort[botMode[bot]], gC_TimeTypeNames[GOKZ_GetTimeTypeEx(botTeleportsUsed[bot])]);
+ }
+ else
+ {
+ // KZT B2 PRO
+ FormatEx(tag, sizeof(tag), "%s B%d %s",
+ gC_ModeNamesShort[botMode[bot]], botCourse[bot], gC_TimeTypeNames[GOKZ_GetTimeTypeEx(botTeleportsUsed[bot])]);
+ }
+ }
+ else if (botReplayType[bot] == ReplayType_Jump)
+ {
+ // KZT LJ
+ FormatEx(tag, sizeof(tag), "%s %s",
+ gC_ModeNamesShort[botMode[bot]], gC_JumpTypesShort[botJumpType[bot]]);
+ }
+ else
+ {
+ // KZT
+ FormatEx(tag, sizeof(tag), "%s",
+ gC_ModeNamesShort[botMode[bot]]);
+ }
+
+ CS_SetClientClanTag(botClient[bot], tag);
+}
+
+static void SetBotName(int bot)
+{
+ char name[MAX_NAME_LENGTH];
+
+ if (botReplayType[bot] == ReplayType_Run)
+ {
+ // DanZay (01:23.45)
+ FormatEx(name, sizeof(name), "%s (%s)",
+ botAlias[bot], GOKZ_FormatTime(botTime[bot]));
+ }
+ else if (botReplayType[bot] == ReplayType_Jump)
+ {
+ if (botJumpBlockDistance[bot] == 0)
+ {
+ // DanZay (291.44)
+ FormatEx(name, sizeof(name), "%s (%.2f)",
+ botAlias[bot], botJumpDistance[bot]);
+ }
+ else
+ {
+ // DanZay (291.44 on 289 block)
+ FormatEx(name, sizeof(name), "%s (%.2f on %d block)",
+ botAlias[bot], botJumpDistance[bot], botJumpBlockDistance[bot]);
+ }
+ }
+ else
+ {
+ // DanZay
+ FormatEx(name, sizeof(name), "%s",
+ botAlias[bot]);
+ }
+
+ gB_HideNameChange = true;
+ SetClientName(botClient[bot], name);
+}
+
+// Returns the number of bots that are currently replaying
+static int GetBotsInUse()
+{
+ int botsInUse = 0;
+ for (int bot; bot < RP_MAX_BOTS; bot++)
+ {
+ if (botInGame[bot] && botDataLoaded[bot])
+ {
+ botsInUse++;
+ }
+ }
+ return botsInUse;
+}
+
+// Returns a bot that isn't currently replaying, or -1 if no unused bots found
+static int GetUnusedBot()
+{
+ for (int bot = 0; bot < RP_MAX_BOTS; bot++)
+ {
+ if (!botInGame[bot])
+ {
+ return bot;
+ }
+ }
+ return -1;
+}
+
+static void PlaybackSkipToTick(int bot, int tick)
+{
+ if (botReplayVersion[bot] == 1)
+ {
+ // Load in the next tick
+ float repOrigin[3], repAngles[3];
+ repOrigin[0] = playbackTickData[bot].Get(tick, 0);
+ repOrigin[1] = playbackTickData[bot].Get(tick, 1);
+ repOrigin[2] = playbackTickData[bot].Get(tick, 2);
+ repAngles[0] = playbackTickData[bot].Get(tick, 3);
+ repAngles[1] = playbackTickData[bot].Get(tick, 4);
+
+ TeleportEntity(botClient[bot], repOrigin, repAngles, view_as<float>( { 0.0, 0.0, 0.0 } ));
+ }
+ else if (botReplayVersion[bot] == 2)
+ {
+ // Load in the next tick
+ ReplayTickData currentTickData;
+ playbackTickData[bot].GetArray(tick, currentTickData);
+
+ TeleportEntity(botClient[bot], currentTickData.origin, currentTickData.angles, view_as<float>( { 0.0, 0.0, 0.0 } ));
+
+ int direction = tick < playbackTick[bot] ? -1 : 1;
+ for (int i = playbackTick[bot]; i != tick; i += direction)
+ {
+ playbackTickData[bot].GetArray(i, currentTickData);
+ if (currentTickData.flags & RP_TELEPORT_TICK)
+ {
+ botCurrentTeleport[bot] += direction;
+ }
+ }
+
+ #if defined DEBUG
+ PrintToServer("X %f \nY %f \nZ %f\nPitch %f\nYaw %f", currentTickData.origin[0], currentTickData.origin[1], currentTickData.origin[2], currentTickData.angles[0], currentTickData.angles[1]);
+ if(currentTickData.flags & RP_MOVETYPE_MASK == view_as<int>(MOVETYPE_WALK)) PrintToServer("MOVETYPE_WALK");
+ if(currentTickData.flags & RP_MOVETYPE_MASK == view_as<int>(MOVETYPE_LADDER)) PrintToServer("MOVETYPE_LADDER");
+ if(currentTickData.flags & RP_MOVETYPE_MASK == view_as<int>(MOVETYPE_NOCLIP)) PrintToServer("MOVETYPE_NOCLIP");
+ if(currentTickData.flags & RP_MOVETYPE_MASK == view_as<int>(MOVETYPE_NONE)) PrintToServer("MOVETYPE_NONE");
+
+ if(currentTickData.flags & RP_IN_ATTACK) PrintToServer("IN_ATTACK");
+ if(currentTickData.flags & RP_IN_ATTACK2) PrintToServer("IN_ATTACK2");
+ if(currentTickData.flags & RP_IN_JUMP) PrintToServer("IN_JUMP");
+ if(currentTickData.flags & RP_IN_DUCK) PrintToServer("IN_DUCK");
+ if(currentTickData.flags & RP_IN_FORWARD) PrintToServer("IN_FORWARD");
+ if(currentTickData.flags & RP_IN_BACK) PrintToServer("IN_BACK");
+ if(currentTickData.flags & RP_IN_LEFT) PrintToServer("IN_LEFT");
+ if(currentTickData.flags & RP_IN_RIGHT) PrintToServer("IN_RIGHT");
+ if(currentTickData.flags & RP_IN_MOVELEFT) PrintToServer("IN_MOVELEFT");
+ if(currentTickData.flags & RP_IN_MOVERIGHT) PrintToServer("IN_MOVERIGHT");
+ if(currentTickData.flags & RP_IN_RELOAD) PrintToServer("IN_RELOAD");
+ if(currentTickData.flags & RP_IN_SPEED) PrintToServer("IN_SPEED");
+ if(currentTickData.flags & RP_FL_ONGROUND) PrintToServer("FL_ONGROUND");
+ if(currentTickData.flags & RP_FL_DUCKING ) PrintToServer("FL_DUCKING");
+ if(currentTickData.flags & RP_FL_SWIM) PrintToServer("FL_SWIM");
+ if(currentTickData.flags & RP_UNDER_WATER) PrintToServer("WATERLEVEL!=0");
+ if(currentTickData.flags & RP_TELEPORT_TICK) PrintToServer("TELEPORT");
+ if(currentTickData.flags & RP_TAKEOFF_TICK) PrintToServer("TAKEOFF");
+ if(currentTickData.flags & RP_HIT_PERF) PrintToServer("PERF");
+ if(currentTickData.flags & RP_SECONDARY_EQUIPPED) PrintToServer("SECONDARY_WEAPON_EQUIPPED");
+ PrintToServer("==============================================================");
+ #endif
+ }
+
+ Movement_SetMovetype(botClient[bot], MOVETYPE_NOCLIP);
+ playbackTick[bot] = tick;
+}
+
+static bool IsCurrentWeaponSecondary(int client)
+{
+ int activeWeaponEnt = GetEntPropEnt(client, Prop_Send, "m_hActiveWeapon");
+ int secondaryEnt = GetPlayerWeaponSlot(client, CS_SLOT_SECONDARY);
+ return activeWeaponEnt == secondaryEnt;
+}
+
+static void MakePlayerSpectate(int client, int bot)
+{
+ GOKZ_JoinTeam(client, CS_TEAM_SPECTATOR);
+ SetEntProp(client, Prop_Send, "m_iObserverMode", 4);
+ SetEntPropEnt(client, Prop_Send, "m_hObserverTarget", bot);
+
+ int clientUserID = GetClientUserId(client);
+ DataPack data = new DataPack();
+ data.WriteCell(clientUserID);
+ data.WriteCell(GetClientUserId(bot));
+ CreateTimer(0.1, Timer_UpdateBotName, GetClientUserId(bot));
+ EnableReplayControls(client);
+}
+
+public Action Timer_UpdateBotName(Handle timer, int botUID)
+{
+ Event e = CreateEvent("spec_target_updated");
+ e.SetInt("userid", botUID);
+ e.Fire();
+ return Plugin_Continue;
+} \ No newline at end of file
diff --git a/sourcemod/scripting/gokz-replays/recording.sp b/sourcemod/scripting/gokz-replays/recording.sp
new file mode 100644
index 0000000..babbd5e
--- /dev/null
+++ b/sourcemod/scripting/gokz-replays/recording.sp
@@ -0,0 +1,990 @@
+/*
+ Bot replay recording logic and processes.
+
+ Records data every time OnPlayerRunCmdPost is called.
+ If the player doesn't have their timer running, it keeps track
+ of the last 2 minutes of their actions. If a player is banned
+ while their timer isn't running, those 2 minutes are saved.
+ If the player has their timer running, the recording is done from
+ the beginning of the run. If the player can no longer beat their PB,
+ then the recording goes back to only keeping track of the last
+ two minutes. Upon beating their PB, a temporary binary file will be
+ written with a 'header' containing information about the run,
+ followed by the recorded tick data from OnPlayerRunCmdPost.
+ The binary file will be permanently locally saved on the server
+ if the run beats the server record.
+*/
+
+static float tickrate;
+static int preAndPostRunTickCount;
+static int maxCheaterReplayTicks;
+static int recordingIndex[MAXPLAYERS + 1];
+static float playerSensitivity[MAXPLAYERS + 1];
+static float playerMYaw[MAXPLAYERS + 1];
+static bool isTeleportTick[MAXPLAYERS + 1];
+static ReplaySaveState replaySaveState[MAXPLAYERS + 1];
+static bool recordingPaused[MAXPLAYERS + 1];
+static bool postRunRecording[MAXPLAYERS + 1];
+static ArrayList recordedRecentData[MAXPLAYERS + 1];
+static ArrayList recordedRunData[MAXPLAYERS + 1];
+static ArrayList recordedPostRunData[MAXPLAYERS + 1];
+static Handle runningRunBreatherTimer[MAXPLAYERS + 1];
+static ArrayList runningJumpstatTimers[MAXPLAYERS + 1];
+
+// =====[ EVENTS ]=====
+
+void OnMapStart_Recording()
+{
+ CreateReplaysDirectory(gC_CurrentMap);
+ tickrate = 1/GetTickInterval();
+ preAndPostRunTickCount = RoundToZero(RP_PLAYBACK_BREATHER_TIME * tickrate);
+ maxCheaterReplayTicks = RoundToCeil(RP_MAX_CHEATER_REPLAY_LENGTH * tickrate);
+}
+
+void OnClientPutInServer_Recording(int client)
+{
+ ClearClientRecordingState(client);
+}
+
+void OnClientAuthorized_Recording(int client)
+{
+ // Apparently the client isn't valid yet here, so we can't check for that!
+ if(!IsFakeClient(client))
+ {
+ // Create directory path for player if not exists
+ char replayPath[PLATFORM_MAX_PATH];
+ BuildPath(Path_SM, replayPath, sizeof(replayPath), "%s/%d", RP_DIRECTORY_JUMPS, GetSteamAccountID(client));
+ if (!DirExists(replayPath))
+ {
+ CreateDirectory(replayPath, 511);
+ }
+ BuildPath(Path_SM, replayPath, sizeof(replayPath), "%s/%d/%s", RP_DIRECTORY_JUMPS, GetSteamAccountID(client), RP_DIRECTORY_BLOCKJUMPS);
+ if (!DirExists(replayPath))
+ {
+ CreateDirectory(replayPath, 511);
+ }
+ }
+}
+
+void OnClientDisconnect_Recording(int client)
+{
+ // Stop exceptions if OnClientPutInServer was never ran for this client id.
+ // As long as the arrays aren't null we'll be fine.
+ if (runningJumpstatTimers[client] == null)
+ {
+ return;
+ }
+
+ // Trigger all timers early
+ if(!IsFakeClient(client))
+ {
+ if (runningRunBreatherTimer[client] != INVALID_HANDLE)
+ {
+ TriggerTimer(runningRunBreatherTimer[client], false);
+ }
+
+ // We have to clone the array because the timer callback removes the timer
+ // from the array we're running over, and doing weird tricks is scary.
+ ArrayList timers = runningJumpstatTimers[client].Clone();
+ for (int i = 0; i < timers.Length; i++)
+ {
+ Handle timer = timers.Get(i);
+ TriggerTimer(timer, false);
+ }
+ delete timers;
+ }
+
+ ClearClientRecordingState(client);
+}
+
+void OnPlayerRunCmdPost_Recording(int client, int buttons, int tickCount, const float vel[3], const int mouse[2])
+{
+ if (!IsValidClient(client) || IsFakeClient(client) || !IsPlayerAlive(client) || recordingPaused[client])
+ {
+ return;
+ }
+
+ ReplayTickData tickData;
+
+ Movement_GetOrigin(client, tickData.origin);
+
+ tickData.mouse = mouse;
+ tickData.vel = vel;
+ Movement_GetVelocity(client, tickData.velocity);
+ Movement_GetEyeAngles(client, tickData.angles);
+ tickData.flags = EncodePlayerFlags(client, buttons, tickCount);
+ tickData.packetsPerSecond = GetClientAvgPackets(client, NetFlow_Incoming);
+ tickData.laggedMovementValue = GetEntPropFloat(client, Prop_Send, "m_flLaggedMovementValue");
+ tickData.buttonsForced = GetEntProp(client, Prop_Data, "m_afButtonForced");
+
+ // HACK: Reset teleport tick marker. Too bad!
+ if (isTeleportTick[client])
+ {
+ isTeleportTick[client] = false;
+ }
+
+ if (replaySaveState[client] != ReplaySave_Disabled)
+ {
+ int runTick = GetArraySize(recordedRunData[client]);
+ if (runTick < RP_MAX_DURATION)
+ {
+ // Resize might fail if the timer exceed the max duration,
+ // as it is not guaranteed to allocate more than 1GB of contiguous memory,
+ // causing mass lag spikes that kick everyone out of the server.
+ // We can still attempt to save the rest of the recording though.
+ recordedRunData[client].Resize(runTick + 1);
+ recordedRunData[client].SetArray(runTick, tickData);
+ }
+ }
+ if (postRunRecording[client])
+ {
+ int tick = GetArraySize(recordedPostRunData[client]);
+ if (tick < RP_MAX_DURATION)
+ {
+ recordedPostRunData[client].Resize(tick + 1);
+ recordedPostRunData[client].SetArray(tick, tickData);
+ }
+ }
+
+ int tick = recordingIndex[client];
+ if (recordedRecentData[client].Length < maxCheaterReplayTicks)
+ {
+ recordedRecentData[client].Resize(recordedRecentData[client].Length + 1);
+ recordingIndex[client] = recordingIndex[client] + 1 == maxCheaterReplayTicks ? 0 : recordingIndex[client] + 1;
+ }
+ else
+ {
+ recordingIndex[client] = RecordingIndexAdd(client, 1);
+ }
+
+ recordedRecentData[client].SetArray(tick, tickData);
+}
+
+Action GOKZ_OnTimerStart_Recording(int client)
+{
+ // Hack to fix an exception when starting the timer on the very
+ // first tick after loading the plugin.
+ if (recordedRecentData[client].Length == 0)
+ {
+ return Plugin_Handled;
+ }
+
+ return Plugin_Continue;
+}
+
+void GOKZ_OnTimerStart_Post_Recording(int client)
+{
+ replaySaveState[client] = ReplaySave_Local;
+ StartRunRecording(client);
+}
+
+void GOKZ_OnTimerEnd_Recording(int client, int course, float time, int teleportsUsed)
+{
+ if (replaySaveState[client] == ReplaySave_Disabled)
+ {
+ return;
+ }
+
+ DataPack data = new DataPack();
+ data.WriteCell(GetClientUserId(client));
+ data.WriteCell(course);
+ data.WriteFloat(time);
+ data.WriteCell(teleportsUsed);
+ data.WriteCell(replaySaveState[client]);
+ // The previous run breather still did not finish, end it now or
+ // we will start overwriting the data.
+ if (runningRunBreatherTimer[client] != INVALID_HANDLE)
+ {
+ TriggerTimer(runningRunBreatherTimer[client], false);
+ }
+
+ replaySaveState[client] = ReplaySave_Disabled;
+ postRunRecording[client] = true;
+
+ // Swap recordedRunData and recordedPostRunData.
+ // This lets new runs start immediately, before the post-run breather is
+ // finished recording.
+ ArrayList tmp = recordedPostRunData[client];
+ recordedPostRunData[client] = recordedRunData[client];
+ recordedRunData[client] = tmp;
+ recordedRunData[client].Clear();
+
+ runningRunBreatherTimer[client] = CreateTimer(RP_PLAYBACK_BREATHER_TIME, Timer_EndRecording, data);
+ if (runningRunBreatherTimer[client] == INVALID_HANDLE)
+ {
+ LogError("Could not create a timer so can't end the run replay recording");
+ }
+}
+
+public Action Timer_EndRecording(Handle timer, DataPack data)
+{
+ data.Reset();
+ int client = GetClientOfUserId(data.ReadCell());
+ int course = data.ReadCell();
+ float time = data.ReadFloat();
+ int teleportsUsed = data.ReadCell();
+ ReplaySaveState saveState = data.ReadCell();
+ delete data;
+
+ // The client left after the run was done but before the post-run
+ // breather had the chance to finish. This should not happen, as we
+ // trigger all running timers on disconnect.
+ if (!IsValidClient(client))
+ {
+ return Plugin_Stop;
+ }
+
+ runningRunBreatherTimer[client] = INVALID_HANDLE;
+ postRunRecording[client] = false;
+
+ if (gB_GOKZLocalDB && GOKZ_DB_IsCheater(client))
+ {
+ // Replay might be submitted globally, but will not be saved locally.
+ saveState = ReplaySave_Temp;
+ }
+
+ char path[PLATFORM_MAX_PATH];
+ if (SaveRecordingOfRun(path, client, course, time, teleportsUsed, saveState == ReplaySave_Temp))
+ {
+ Call_OnTimerEnd_Post(client, path, course, time, teleportsUsed);
+ }
+ else
+ {
+ Call_OnTimerEnd_Post(client, "", course, time, teleportsUsed);
+ }
+
+ return Plugin_Stop;
+}
+
+void GOKZ_OnPause_Recording(int client)
+{
+ PauseRecording(client);
+}
+
+void GOKZ_OnResume_Recording(int client)
+{
+ ResumeRecording(client);
+}
+
+void GOKZ_OnTimerStopped_Recording(int client)
+{
+ replaySaveState[client] = ReplaySave_Disabled;
+}
+
+void GOKZ_OnCountedTeleport_Recording(int client)
+{
+ if (gB_NubRecordMissed[client])
+ {
+ replaySaveState[client] = ReplaySave_Disabled;
+ }
+
+ isTeleportTick[client] = true;
+}
+
+void GOKZ_LR_OnRecordMissed_Recording(int client, int recordType)
+{
+ if (replaySaveState[client] == ReplaySave_Disabled)
+ {
+ return;
+ }
+ // If missed PRO record or both records, then can no longer beat a server record
+ if (recordType == RecordType_NubAndPro || recordType == RecordType_Pro)
+ {
+ replaySaveState[client] = ReplaySave_Temp;
+ }
+
+ // If on a NUB run and missed NUB record, then can no longer beat a server record
+ // Otherwise wait to see if they teleport before stopping the recording
+ if (recordType == RecordType_Nub)
+ {
+ if (GOKZ_GetTeleportCount(client) > 0)
+ {
+ replaySaveState[client] = ReplaySave_Temp;
+ }
+ }
+}
+
+public void GOKZ_LR_OnPBMissed(int client, float pbTime, int course, int mode, int style, int recordType)
+{
+ if (replaySaveState[client] == ReplaySave_Disabled)
+ {
+ return;
+ }
+ // If missed PRO record or both records, then can no longer beat PB
+ if (recordType == RecordType_NubAndPro || recordType == RecordType_Pro)
+ {
+ replaySaveState[client] = ReplaySave_Disabled;
+ }
+
+ // If on a NUB run and missed NUB record, then can no longer beat PB
+ // Otherwise wait to see if they teleport before stopping the recording
+ if (recordType == RecordType_Nub)
+ {
+ if (GOKZ_GetTeleportCount(client) > 0)
+ {
+ replaySaveState[client] = ReplaySave_Disabled;
+ }
+ }
+}
+
+void GOKZ_AC_OnPlayerSuspected_Recording(int client, ACReason reason)
+{
+ SaveRecordingOfCheater(client, reason);
+}
+
+void GOKZ_DB_OnJumpstatPB_Recording(int client, int jumptype, float distance, int block, int strafes, float sync, float pre, float max, int airtime)
+{
+ DataPack data = new DataPack();
+ data.WriteCell(GetClientUserId(client));
+ data.WriteCell(jumptype);
+ data.WriteFloat(distance);
+ data.WriteCell(block);
+ data.WriteCell(strafes);
+ data.WriteFloat(sync);
+ data.WriteFloat(pre);
+ data.WriteFloat(max);
+ data.WriteCell(airtime);
+
+ Handle timer = CreateTimer(RP_PLAYBACK_BREATHER_TIME, SaveJump, data);
+ if (timer != INVALID_HANDLE)
+ {
+ runningJumpstatTimers[client].Push(timer);
+ }
+ else
+ {
+ LogError("Could not create a timer so can't save jumpstat pb replay");
+ }
+}
+
+public Action SaveJump(Handle timer, DataPack data)
+{
+ data.Reset();
+ int client = GetClientOfUserId(data.ReadCell());
+ int jumptype = data.ReadCell();
+ float distance = data.ReadFloat();
+ int block = data.ReadCell();
+ int strafes = data.ReadCell();
+ float sync = data.ReadFloat();
+ float pre = data.ReadFloat();
+ float max = data.ReadFloat();
+ int airtime = data.ReadCell();
+ delete data;
+
+ // The client left after the jump was done but before the post-jump
+ // breather had the chance to finish. This should not happen, as we
+ // trigger all running timers on disconnect.
+ if (!IsValidClient(client))
+ {
+ return Plugin_Stop;
+ }
+
+ RemoveFromRunningTimers(client, timer);
+
+ SaveRecordingOfJump(client, jumptype, distance, block, strafes, sync, pre, max, airtime);
+ return Plugin_Stop;
+}
+
+
+
+// =====[ PRIVATE ]=====
+
+static void ClearClientRecordingState(int client)
+{
+ recordingIndex[client] = 0;
+ playerSensitivity[client] = -1.0;
+ playerMYaw[client] = -1.0;
+ isTeleportTick[client] = false;
+ replaySaveState[client] = ReplaySave_Disabled;
+ recordingPaused[client] = false;
+ postRunRecording[client] = false;
+ runningRunBreatherTimer[client] = INVALID_HANDLE;
+
+ if (recordedRecentData[client] == null)
+ recordedRecentData[client] = new ArrayList(sizeof(ReplayTickData));
+
+ if (recordedRunData[client] == null)
+ recordedRunData[client] = new ArrayList(sizeof(ReplayTickData));
+
+ if (recordedPostRunData[client] == null)
+ recordedPostRunData[client] = new ArrayList(sizeof(ReplayTickData));
+
+ if (runningJumpstatTimers[client] == null)
+ runningJumpstatTimers[client] = new ArrayList();
+
+ recordedRecentData[client].Clear();
+ recordedRunData[client].Clear();
+ recordedPostRunData[client].Clear();
+ runningJumpstatTimers[client].Clear();
+}
+
+static void StartRunRecording(int client)
+{
+ if (IsFakeClient(client))
+ {
+ return;
+ }
+
+ QueryClientConVar(client, "sensitivity", SensitivityCheck, client);
+ QueryClientConVar(client, "m_yaw", MYAWCheck, client);
+
+ DiscardRecording(client);
+ ResumeRecording(client);
+
+ // Copy pre data
+ int index;
+ recordedRunData[client].Resize(preAndPostRunTickCount);
+ if (recordedRecentData[client].Length < preAndPostRunTickCount)
+ {
+ index = recordingIndex[client] - preAndPostRunTickCount;
+ }
+ else
+ {
+ index = RecordingIndexAdd(client, -preAndPostRunTickCount);
+ }
+ for (int i = 0; i < preAndPostRunTickCount; i++)
+ {
+ ReplayTickData tickData;
+ if (index < 0)
+ {
+ recordedRecentData[client].GetArray(0, tickData);
+ recordedRunData[client].SetArray(i, tickData);
+ index += 1;
+ }
+ else
+ {
+ recordedRecentData[client].GetArray(index, tickData);
+ recordedRunData[client].SetArray(i, tickData);
+ index = RecordingIndexAdd(client, -preAndPostRunTickCount + i + 1);
+ }
+ }
+}
+
+static void DiscardRecording(int client)
+{
+ recordedRunData[client].Clear();
+ Call_OnReplayDiscarded(client);
+}
+
+static void PauseRecording(int client)
+{
+ recordingPaused[client] = true;
+}
+
+static void ResumeRecording(int client)
+{
+ recordingPaused[client] = false;
+}
+
+static bool SaveRecordingOfRun(char replayPath[PLATFORM_MAX_PATH], int client, int course, float time, int teleportsUsed, bool temp)
+{
+ // Prepare data
+ int timeType = GOKZ_GetTimeTypeEx(teleportsUsed);
+
+ // Create and fill General Header
+ GeneralReplayHeader generalHeader;
+ FillGeneralHeader(generalHeader, client, ReplayType_Run, recordedPostRunData[client].Length);
+
+ // Create and fill Run Header
+ RunReplayHeader runHeader;
+ runHeader.time = time;
+ runHeader.course = course;
+ runHeader.teleportsUsed = teleportsUsed;
+
+ // Build path and create/overwrite associated file
+ FormatRunReplayPath(replayPath, sizeof(replayPath), course, generalHeader.mode, generalHeader.style, timeType, temp);
+ if (FileExists(replayPath))
+ {
+ DeleteFile(replayPath);
+ }
+ else if (!temp)
+ {
+ AddToReplayInfoCache(course, generalHeader.mode, generalHeader.style, timeType);
+ SortReplayInfoCache();
+ }
+
+ File file = OpenFile(replayPath, "wb");
+ if (file == null)
+ {
+ LogError("Failed to create/open replay file to write to: \"%s\".", replayPath);
+ return false;
+ }
+
+ WriteGeneralHeader(file, generalHeader);
+
+ // Write run header
+ file.WriteInt32(view_as<int>(runHeader.time));
+ file.WriteInt8(runHeader.course);
+ file.WriteInt32(runHeader.teleportsUsed);
+
+ WriteTickData(file, client, ReplayType_Run);
+
+ delete file;
+ // If there is no plugin that wants to take over the replay file, we will delete it ourselves.
+ if (Call_OnReplaySaved(client, ReplayType_Run, gC_CurrentMap, course, timeType, time, replayPath, temp) == Plugin_Continue && temp)
+ {
+ DeleteFile(replayPath);
+ }
+
+ return true;
+}
+
+static bool SaveRecordingOfCheater(int client, ACReason reason)
+{
+ // Create and fill general header
+ GeneralReplayHeader generalHeader;
+ FillGeneralHeader(generalHeader, client, ReplayType_Cheater, recordedRecentData[client].Length);
+
+ // Create and fill cheater header
+ CheaterReplayHeader cheaterHeader;
+ cheaterHeader.ACReason = reason;
+
+ //Build path and create/overwrite associated file
+ char replayPath[PLATFORM_MAX_PATH];
+ FormatCheaterReplayPath(replayPath, sizeof(replayPath), client, generalHeader.mode, generalHeader.style);
+
+ File file = OpenFile(replayPath, "wb");
+ if (file == null)
+ {
+ LogError("Failed to create/open replay file to write to: \"%s\".", replayPath);
+ return false;
+ }
+
+ WriteGeneralHeader(file, generalHeader);
+ file.WriteInt8(view_as<int>(cheaterHeader.ACReason));
+ WriteTickData(file, client, ReplayType_Cheater);
+
+ delete file;
+
+ return true;
+}
+
+static bool SaveRecordingOfJump(int client, int jumptype, float distance, int block, int strafes, float sync, float pre, float max, int airtime)
+{
+ // Just cause I know how buggy jumpstats can be
+ int airtimeTicks = RoundToNearest((float(airtime) / GOKZ_DB_JS_AIRTIME_PRECISION) * tickrate);
+ if (airtimeTicks + 2 * preAndPostRunTickCount >= maxCheaterReplayTicks)
+ {
+ LogError("WARNING: Invalid airtime (this is probably a bugged jump, please report it!).");
+ return false;
+ }
+
+ // Create and fill general header
+ GeneralReplayHeader generalHeader;
+ FillGeneralHeader(generalHeader, client, ReplayType_Jump, 2 * preAndPostRunTickCount + airtimeTicks);
+
+ // Create and fill jump header
+ JumpReplayHeader jumpHeader;
+ FillJumpHeader(jumpHeader, jumptype, distance, block, strafes, sync, pre, max, airtime);
+
+ // Make sure the client is authenticated
+ if (GetSteamAccountID(client) == 0)
+ {
+ LogError("Failed to save jump, client is not authenticated.");
+ return false;
+ }
+
+ // Build path and create/overwrite associated file
+ char replayPath[PLATFORM_MAX_PATH];
+ if (block > 0)
+ {
+ FormatBlockJumpReplayPath(replayPath, sizeof(replayPath), client, block, jumpHeader.jumpType, generalHeader.mode, generalHeader.style);
+ }
+ else
+ {
+ FormatJumpReplayPath(replayPath, sizeof(replayPath), client, jumpHeader.jumpType, generalHeader.mode, generalHeader.style);
+ }
+
+ File file = OpenFile(replayPath, "wb");
+ if (file == null)
+ {
+ LogError("Failed to create/open replay file to write to: \"%s\".", replayPath);
+ delete file;
+ return false;
+ }
+
+ WriteGeneralHeader(file, generalHeader);
+ WriteJumpHeader(file, jumpHeader);
+ WriteTickData(file, client, ReplayType_Jump, airtimeTicks);
+
+ delete file;
+
+ return true;
+}
+
+static void FillGeneralHeader(GeneralReplayHeader generalHeader, int client, int replayType, int tickCount)
+{
+ // Prepare data
+ int mode = GOKZ_GetCoreOption(client, Option_Mode);
+ int style = GOKZ_GetCoreOption(client, Option_Style);
+
+ // Fill general header
+ generalHeader.magicNumber = RP_MAGIC_NUMBER;
+ generalHeader.formatVersion = RP_FORMAT_VERSION;
+ generalHeader.replayType = replayType;
+ generalHeader.gokzVersion = GOKZ_VERSION;
+ generalHeader.mapName = gC_CurrentMap;
+ generalHeader.mapFileSize = gI_CurrentMapFileSize;
+ generalHeader.serverIP = FindConVar("hostip").IntValue;
+ generalHeader.timestamp = GetTime();
+ GetClientName(client, generalHeader.playerAlias, sizeof(GeneralReplayHeader::playerAlias));
+ generalHeader.playerSteamID = GetSteamAccountID(client);
+ generalHeader.mode = mode;
+ generalHeader.style = style;
+ generalHeader.playerSensitivity = playerSensitivity[client];
+ generalHeader.playerMYaw = playerMYaw[client];
+ generalHeader.tickrate = tickrate;
+ generalHeader.tickCount = tickCount;
+ generalHeader.equippedWeapon = GetPlayerWeaponSlotDefIndex(client, CS_SLOT_SECONDARY);
+ generalHeader.equippedKnife = GetPlayerWeaponSlotDefIndex(client, CS_SLOT_KNIFE);
+}
+
+static void FillJumpHeader(JumpReplayHeader jumpHeader, int jumptype, float distance, int block, int strafes, float sync, float pre, float max, int airtime)
+{
+ jumpHeader.jumpType = jumptype;
+ jumpHeader.distance = distance;
+ jumpHeader.blockDistance = block;
+ jumpHeader.strafeCount = strafes;
+ jumpHeader.sync = sync;
+ jumpHeader.pre = pre;
+ jumpHeader.max = max;
+ jumpHeader.airtime = airtime;
+}
+
+static void WriteGeneralHeader(File file, GeneralReplayHeader generalHeader)
+{
+ file.WriteInt32(generalHeader.magicNumber);
+ file.WriteInt8(generalHeader.formatVersion);
+ file.WriteInt8(generalHeader.replayType);
+ file.WriteInt8(strlen(generalHeader.gokzVersion));
+ file.WriteString(generalHeader.gokzVersion, false);
+ file.WriteInt8(strlen(generalHeader.mapName));
+ file.WriteString(generalHeader.mapName, false);
+ file.WriteInt32(generalHeader.mapFileSize);
+ file.WriteInt32(generalHeader.serverIP);
+ file.WriteInt32(generalHeader.timestamp);
+ file.WriteInt8(strlen(generalHeader.playerAlias));
+ file.WriteString(generalHeader.playerAlias, false);
+ file.WriteInt32(generalHeader.playerSteamID);
+ file.WriteInt8(generalHeader.mode);
+ file.WriteInt8(generalHeader.style);
+ file.WriteInt32(view_as<int>(generalHeader.playerSensitivity));
+ file.WriteInt32(view_as<int>(generalHeader.playerMYaw));
+ file.WriteInt32(view_as<int>(generalHeader.tickrate));
+ file.WriteInt32(generalHeader.tickCount);
+ file.WriteInt32(generalHeader.equippedWeapon);
+ file.WriteInt32(generalHeader.equippedKnife);
+}
+
+static void WriteJumpHeader(File file, JumpReplayHeader jumpHeader)
+{
+ file.WriteInt8(jumpHeader.jumpType);
+ file.WriteInt32(view_as<int>(jumpHeader.distance));
+ file.WriteInt32(jumpHeader.blockDistance);
+ file.WriteInt8(jumpHeader.strafeCount);
+ file.WriteInt32(view_as<int>(jumpHeader.sync));
+ file.WriteInt32(view_as<int>(jumpHeader.pre));
+ file.WriteInt32(view_as<int>(jumpHeader.max));
+ file.WriteInt32((jumpHeader.airtime));
+}
+
+static void WriteTickData(File file, int client, int replayType, int airtime = 0)
+{
+ ReplayTickData tickData;
+ ReplayTickData prevTickData;
+ bool isFirstTick = true;
+ switch(replayType)
+ {
+ case ReplayType_Run:
+ {
+ for (int i = 0; i < recordedPostRunData[client].Length; i++)
+ {
+ recordedPostRunData[client].GetArray(i, tickData);
+ recordedPostRunData[client].GetArray(IntMax(0, i-1), prevTickData);
+ WriteTickDataToFile(file, isFirstTick, tickData, prevTickData);
+ isFirstTick = false;
+ }
+ }
+ case ReplayType_Cheater:
+ {
+ for (int i = 0; i < recordedRecentData[client].Length; i++)
+ {
+ int rollingI = RecordingIndexAdd(client, i);
+ recordedRecentData[client].GetArray(rollingI, tickData);
+ recordedRecentData[client].GetArray(IntMax(0, i-1), prevTickData);
+ WriteTickDataToFile(file, isFirstTick, tickData, prevTickData);
+ isFirstTick = false;
+ }
+
+ }
+ case ReplayType_Jump:
+ {
+ int replayLength = 2 * preAndPostRunTickCount + airtime;
+ for (int i = 0; i < replayLength; i++)
+ {
+ int rollingI = RecordingIndexAdd(client, i - replayLength);
+ recordedRecentData[client].GetArray(rollingI, tickData);
+ recordedRecentData[client].GetArray(IntMax(0, i-1), prevTickData);
+ WriteTickDataToFile(file, isFirstTick, tickData, prevTickData);
+ isFirstTick = false;
+ }
+ }
+ }
+}
+
+static void WriteTickDataToFile(File file, bool isFirstTick, ReplayTickData tickDataStruct, ReplayTickData prevTickDataStruct)
+{
+ any tickData[RP_V2_TICK_DATA_BLOCKSIZE];
+ any prevTickData[RP_V2_TICK_DATA_BLOCKSIZE];
+ TickDataToArray(tickDataStruct, tickData);
+ TickDataToArray(prevTickDataStruct, prevTickData);
+
+ int deltaFlags = (1 << RPDELTA_DELTAFLAGS);
+ if (isFirstTick)
+ {
+ // NOTE: Set every bit to 1 until RP_V2_TICK_DATA_BLOCKSIZE.
+ deltaFlags = (1 << (RP_V2_TICK_DATA_BLOCKSIZE)) - 1;
+ }
+ else
+ {
+ // NOTE: Test tickData against prevTickData for differences.
+ for (int i = 1; i < sizeof(tickData); i++)
+ {
+ // If the bits in tickData[i] are different to prevTickData[i], then
+ // set the corresponding bitflag.
+ if (tickData[i] ^ prevTickData[i])
+ {
+ deltaFlags |= (1 << i);
+ }
+ }
+ }
+
+ file.WriteInt32(deltaFlags);
+ // NOTE: write only data that has changed since the previous tick.
+ for (int i = 1; i < sizeof(tickData); i++)
+ {
+ int currentFlag = (1 << i);
+ if (deltaFlags & currentFlag)
+ {
+ file.WriteInt32(tickData[i]);
+ }
+ }
+}
+
+static void FormatRunReplayPath(char[] buffer, int maxlength, int course, int mode, int style, int timeType, bool tempPath)
+{
+ // Use GetEngineTime to prevent accidental replay overrides.
+ // Technically it would still be possible to override this file by accident,
+ // if somehow the server restarts to this exact map and course,
+ // and this function is run at the exact same time, but that is extremely unlikely.
+ // Also by then this file should have already been deleted.
+ char tempTimeString[32];
+ Format(tempTimeString, sizeof(tempTimeString), "%f_", GetEngineTime());
+ BuildPath(Path_SM, buffer, maxlength,
+ "%s/%s/%s%d_%s_%s_%s.%s",
+ tempPath ? RP_DIRECTORY_RUNS_TEMP : RP_DIRECTORY_RUNS,
+ gC_CurrentMap,
+ tempPath ? tempTimeString : "",
+ course,
+ gC_ModeNamesShort[mode],
+ gC_StyleNamesShort[style],
+ gC_TimeTypeNames[timeType],
+ RP_FILE_EXTENSION);
+}
+
+static void FormatCheaterReplayPath(char[] buffer, int maxlength, int client, int mode, int style)
+{
+ BuildPath(Path_SM, buffer, maxlength,
+ "%s/%d_%s_%d_%s_%s.%s",
+ RP_DIRECTORY_CHEATERS,
+ GetSteamAccountID(client),
+ gC_CurrentMap,
+ GetTime(),
+ gC_ModeNamesShort[mode],
+ gC_StyleNamesShort[style],
+ RP_FILE_EXTENSION);
+}
+
+static void FormatJumpReplayPath(char[] buffer, int maxlength, int client, int jumpType, int mode, int style)
+{
+ BuildPath(Path_SM, buffer, maxlength,
+ "%s/%d/%d_%s_%s.%s",
+ RP_DIRECTORY_JUMPS,
+ GetSteamAccountID(client),
+ jumpType,
+ gC_ModeNamesShort[mode],
+ gC_StyleNamesShort[style],
+ RP_FILE_EXTENSION);
+}
+
+static void FormatBlockJumpReplayPath(char[] buffer, int maxlength, int client, int block, int jumpType, int mode, int style)
+{
+ BuildPath(Path_SM, buffer, maxlength,
+ "%s/%d/%s/%d_%d_%s_%s.%s",
+ RP_DIRECTORY_JUMPS,
+ GetSteamAccountID(client),
+ RP_DIRECTORY_BLOCKJUMPS,
+ jumpType,
+ block,
+ gC_ModeNamesShort[mode],
+ gC_StyleNamesShort[style],
+ RP_FILE_EXTENSION);
+}
+
+static int EncodePlayerFlags(int client, int buttons, int tickCount)
+{
+ int flags = 0;
+ MoveType movetype = Movement_GetMovetype(client);
+ int clientFlags = GetEntityFlags(client);
+
+ flags = view_as<int>(movetype) & RP_MOVETYPE_MASK;
+
+ SetKthBit(flags, 4, IsBitSet(buttons, IN_ATTACK));
+ SetKthBit(flags, 5, IsBitSet(buttons, IN_ATTACK2));
+ SetKthBit(flags, 6, IsBitSet(buttons, IN_JUMP));
+ SetKthBit(flags, 7, IsBitSet(buttons, IN_DUCK));
+ SetKthBit(flags, 8, IsBitSet(buttons, IN_FORWARD));
+ SetKthBit(flags, 9, IsBitSet(buttons, IN_BACK));
+ SetKthBit(flags, 10, IsBitSet(buttons, IN_LEFT));
+ SetKthBit(flags, 11, IsBitSet(buttons, IN_RIGHT));
+ SetKthBit(flags, 12, IsBitSet(buttons, IN_MOVELEFT));
+ SetKthBit(flags, 13, IsBitSet(buttons, IN_MOVERIGHT));
+ SetKthBit(flags, 14, IsBitSet(buttons, IN_RELOAD));
+ SetKthBit(flags, 15, IsBitSet(buttons, IN_SPEED));
+ SetKthBit(flags, 16, IsBitSet(buttons, IN_USE));
+ SetKthBit(flags, 17, IsBitSet(buttons, IN_BULLRUSH));
+ SetKthBit(flags, 18, IsBitSet(clientFlags, FL_ONGROUND));
+ SetKthBit(flags, 19, IsBitSet(clientFlags, FL_DUCKING));
+ SetKthBit(flags, 20, IsBitSet(clientFlags, FL_SWIM));
+
+ SetKthBit(flags, 21, GetEntProp(client, Prop_Data, "m_nWaterLevel") != 0);
+
+ SetKthBit(flags, 22, isTeleportTick[client]);
+ SetKthBit(flags, 23, Movement_GetTakeoffTick(client) == tickCount);
+ SetKthBit(flags, 24, GOKZ_GetHitPerf(client));
+ SetKthBit(flags, 25, IsCurrentWeaponSecondary(client));
+
+ return flags;
+}
+
+// Function to set the bitNum bit in integer to value
+static void SetKthBit(int &number, int offset, bool value)
+{
+ int intValue = value ? 1 : 0;
+ number |= intValue << offset;
+}
+
+static bool IsBitSet(int number, int checkBit)
+{
+ return (number & checkBit) ? true : false;
+}
+
+static int GetPlayerWeaponSlotDefIndex(int client, int slot)
+{
+ int ent = GetPlayerWeaponSlot(client, slot);
+
+ // Nothing equipped in the slot
+ if (ent == -1)
+ {
+ return -1;
+ }
+
+ return GetEntProp(ent, Prop_Send, "m_iItemDefinitionIndex");
+}
+
+static bool IsCurrentWeaponSecondary(int client)
+{
+ int activeWeaponEnt = GetEntPropEnt(client, Prop_Send, "m_hActiveWeapon");
+ int secondaryEnt = GetPlayerWeaponSlot(client, CS_SLOT_SECONDARY);
+ return activeWeaponEnt == secondaryEnt;
+}
+
+static void CreateReplaysDirectory(const char[] map)
+{
+ char path[PLATFORM_MAX_PATH];
+
+ // Create parent replay directory
+ BuildPath(Path_SM, path, sizeof(path), RP_DIRECTORY);
+ if (!DirExists(path))
+ {
+ CreateDirectory(path, 511);
+ }
+
+ // Create maps parent replay directory
+ BuildPath(Path_SM, path, sizeof(path), "%s", RP_DIRECTORY_RUNS);
+ if (!DirExists(path))
+ {
+ CreateDirectory(path, 511);
+ }
+
+
+ // Create maps replay directory
+ BuildPath(Path_SM, path, sizeof(path), "%s/%s", RP_DIRECTORY_RUNS, map);
+ if (!DirExists(path))
+ {
+ CreateDirectory(path, 511);
+ }
+
+ // Create maps parent replay directory
+ BuildPath(Path_SM, path, sizeof(path), "%s", RP_DIRECTORY_RUNS_TEMP);
+ if (!DirExists(path))
+ {
+ CreateDirectory(path, 511);
+ }
+
+
+ // Create maps replay directory
+ BuildPath(Path_SM, path, sizeof(path), "%s/%s", RP_DIRECTORY_RUNS_TEMP, map);
+ if (!DirExists(path))
+ {
+ CreateDirectory(path, 511);
+ }
+
+ // Create cheaters replay directory
+ BuildPath(Path_SM, path, sizeof(path), "%s", RP_DIRECTORY_CHEATERS);
+ if (!DirExists(path))
+ {
+ CreateDirectory(path, 511);
+ }
+
+ // Create jumps parent replay directory
+ BuildPath(Path_SM, path, sizeof(path), "%s", RP_DIRECTORY_JUMPS);
+ if (!DirExists(path))
+ {
+ CreateDirectory(path, 511);
+ }
+}
+
+public void MYAWCheck(QueryCookie cookie, int client, ConVarQueryResult result, const char[] cvarName, const char[] cvarValue, any value)
+{
+ if (IsValidClient(client) && !IsFakeClient(client))
+ {
+ playerMYaw[client] = StringToFloat(cvarValue);
+ }
+}
+
+public void SensitivityCheck(QueryCookie cookie, int client, ConVarQueryResult result, const char[] cvarName, const char[] cvarValue, any value)
+{
+ if (IsValidClient(client) && !IsFakeClient(client))
+ {
+ playerSensitivity[client] = StringToFloat(cvarValue);
+ }
+}
+
+static int RecordingIndexAdd(int client, int offset)
+{
+ int index = recordingIndex[client] + offset;
+ if (index < 0)
+ {
+ index += recordedRecentData[client].Length;
+ }
+ return index % recordedRecentData[client].Length;
+}
+
+static void RemoveFromRunningTimers(int client, Handle timerToRemove)
+{
+ int index = runningJumpstatTimers[client].FindValue(timerToRemove);
+ if (index != -1)
+ {
+ runningJumpstatTimers[client].Erase(index);
+ }
+}
diff --git a/sourcemod/scripting/gokz-replays/replay_cache.sp b/sourcemod/scripting/gokz-replays/replay_cache.sp
new file mode 100644
index 0000000..83f36d0
--- /dev/null
+++ b/sourcemod/scripting/gokz-replays/replay_cache.sp
@@ -0,0 +1,176 @@
+/*
+ Cached info about the map's available replay bots stored in an ArrayList.
+*/
+
+
+
+// =====[ PUBLIC ]=====
+
+// Adds a replay to the cache
+void AddToReplayInfoCache(int course, int mode, int style, int timeType)
+{
+ int index = g_ReplayInfoCache.Length;
+ g_ReplayInfoCache.Resize(index + 1);
+ g_ReplayInfoCache.Set(index, course, 0);
+ g_ReplayInfoCache.Set(index, mode, 1);
+ g_ReplayInfoCache.Set(index, style, 2);
+ g_ReplayInfoCache.Set(index, timeType, 3);
+}
+
+// Use this to sort the cache after finished adding to it
+void SortReplayInfoCache()
+{
+ g_ReplayInfoCache.SortCustom(SortFunc_ReplayInfoCache);
+}
+
+public int SortFunc_ReplayInfoCache(int index1, int index2, Handle array, Handle hndl)
+{
+ // Do not expect any indexes to be 'equal'
+ int replayInfo1[RP_CACHE_BLOCKSIZE], replayInfo2[RP_CACHE_BLOCKSIZE];
+ g_ReplayInfoCache.GetArray(index1, replayInfo1);
+ g_ReplayInfoCache.GetArray(index2, replayInfo2);
+
+ // Compare courses - lower course number goes first
+ if (replayInfo1[0] < replayInfo2[0])
+ {
+ return -1;
+ }
+ else if (replayInfo1[0] > replayInfo2[0])
+ {
+ return 1;
+ }
+ // Same course, so compare mode
+ else if (replayInfo1[1] < replayInfo2[1])
+ {
+ return -1;
+ }
+ else if (replayInfo1[1] > replayInfo2[1])
+ {
+ return 1;
+ }
+ // Same course and mode, so compare style
+ else if (replayInfo1[2] < replayInfo2[2])
+ {
+ return -1;
+ }
+ else if (replayInfo1[2] > replayInfo2[2])
+ {
+ return 1;
+ }
+ // Same course, mode and style so compare time type, assuming can't be identical
+ else if (replayInfo1[3] == TimeType_Pro)
+ {
+ return 1;
+ }
+ return -1;
+}
+
+
+
+// =====[ EVENTS ]=====
+
+void OnMapStart_ReplayCache()
+{
+ if (g_ReplayInfoCache == null)
+ {
+ g_ReplayInfoCache = new ArrayList(RP_CACHE_BLOCKSIZE, 0);
+ }
+ else
+ {
+ g_ReplayInfoCache.Clear();
+ }
+
+ char path[PLATFORM_MAX_PATH];
+ BuildPath(Path_SM, path, sizeof(path), "%s/%s", RP_DIRECTORY_RUNS, gC_CurrentMap);
+ DirectoryListing dir = OpenDirectory(path);
+
+ // We want to find files that look like "0_KZT_NRM_PRO.rec"
+ char file[PLATFORM_MAX_PATH], pieces[4][16];
+ int length, dotpos, course, mode, style, timeType;
+
+ while (dir.GetNext(file, sizeof(file)))
+ {
+ // Some credit to Influx Timer - https://github.com/TotallyMehis/Influx-Timer
+
+ // Check file extension
+ length = strlen(file);
+ dotpos = 0;
+ for (int i = 0; i < length; i++)
+ {
+ if (file[i] == '.')
+ {
+ dotpos = i;
+ }
+ }
+ if (!StrEqual(file[dotpos + 1], RP_FILE_EXTENSION, false))
+ {
+ continue;
+ }
+
+ // Remove file extension
+ Format(file, dotpos + 1, file);
+
+ // Break down file name into pieces
+ if (ExplodeString(file, "_", pieces, sizeof(pieces), sizeof(pieces[])) != sizeof(pieces))
+ {
+ continue;
+ }
+
+ // Extract info from the pieces
+ course = StringToInt(pieces[0]);
+ mode = GetModeIDFromString(pieces[1]);
+ style = GetStyleIDFromString(pieces[2]);
+ timeType = GetTimeTypeIDFromString(pieces[3]);
+ if (!GOKZ_IsValidCourse(course) || mode == -1 || style == -1 || timeType == -1)
+ {
+ continue;
+ }
+
+ // Add it to the cache
+ AddToReplayInfoCache(course, mode, style, timeType);
+ }
+
+ SortReplayInfoCache();
+
+ delete dir;
+}
+
+
+
+// =====[ PRIVATE ]=====
+
+static int GetModeIDFromString(const char[] mode)
+{
+ for (int modeID = 0; modeID < MODE_COUNT; modeID++)
+ {
+ if (StrEqual(mode, gC_ModeNamesShort[modeID], false))
+ {
+ return modeID;
+ }
+ }
+ return -1;
+}
+
+static int GetStyleIDFromString(const char[] style)
+{
+ for (int styleID = 0; styleID < STYLE_COUNT; styleID++)
+ {
+ if (StrEqual(style, gC_StyleNamesShort[styleID], false))
+ {
+ return styleID;
+ }
+ }
+ return -1;
+}
+
+static int GetTimeTypeIDFromString(const char[] timeType)
+{
+ for (int timeTypeID = 0; timeTypeID < TIMETYPE_COUNT; timeTypeID++)
+ {
+ if (StrEqual(timeType, gC_TimeTypeNames[timeTypeID], false))
+ {
+ return timeTypeID;
+ }
+ }
+ return -1;
+} \ No newline at end of file
diff --git a/sourcemod/scripting/gokz-replays/replay_menu.sp b/sourcemod/scripting/gokz-replays/replay_menu.sp
new file mode 100644
index 0000000..94acd66
--- /dev/null
+++ b/sourcemod/scripting/gokz-replays/replay_menu.sp
@@ -0,0 +1,139 @@
+/*
+ Lets player select a replay bot to play back.
+*/
+
+
+
+static int selectedReplayMode[MAXPLAYERS + 1];
+
+
+
+// =====[ PUBLIC ]=====
+
+void DisplayReplayModeMenu(int client)
+{
+ if (g_ReplayInfoCache.Length == 0)
+ {
+ GOKZ_PrintToChat(client, true, "%t", "No Replays Found (Map)");
+ GOKZ_PlayErrorSound(client);
+ return;
+ }
+
+ Menu menu = new Menu(MenuHandler_ReplayMode);
+ menu.SetTitle("%T", "Replay Menu (Mode) - Title", client, gC_CurrentMap);
+ GOKZ_MenuAddModeItems(client, menu, false);
+ menu.Display(client, MENU_TIME_FOREVER);
+}
+
+
+
+// =====[ EVENTS ]=====
+
+public int MenuHandler_ReplayMode(Menu menu, MenuAction action, int param1, int param2)
+{
+ if (action == MenuAction_Select)
+ {
+ selectedReplayMode[param1] = param2;
+ DisplayReplayMenu(param1);
+ }
+ else if (action == MenuAction_End)
+ {
+ delete menu;
+ }
+ return 0;
+}
+
+public int MenuHandler_Replay(Menu menu, MenuAction action, int param1, int param2)
+{
+ if (action == MenuAction_Select)
+ {
+ char info[4];
+ menu.GetItem(param2, info, sizeof(info));
+ int replayIndex = StringToInt(info);
+ int replayInfo[RP_CACHE_BLOCKSIZE];
+ g_ReplayInfoCache.GetArray(replayIndex, replayInfo);
+
+ char path[PLATFORM_MAX_PATH];
+ BuildPath(Path_SM, path, sizeof(path),
+ "%s/%s/%d_%s_%s_%s.%s",
+ RP_DIRECTORY_RUNS, gC_CurrentMap, replayInfo[0], gC_ModeNamesShort[replayInfo[1]], gC_StyleNamesShort[replayInfo[2]], gC_TimeTypeNames[replayInfo[3]], RP_FILE_EXTENSION);
+ if (!FileExists(path))
+ {
+ BuildPath(Path_SM, path, sizeof(path),
+ "%s/%d_%s_%s_%s.%s",
+ RP_DIRECTORY, gC_CurrentMap, replayInfo[0], gC_ModeNamesShort[replayInfo[1]], gC_StyleNamesShort[replayInfo[2]], gC_TimeTypeNames[replayInfo[3]], RP_FILE_EXTENSION);
+ if (!FileExists(path))
+ {
+ LogError("Failed to load file: \"%s\".", path);
+ GOKZ_PrintToChat(param1, true, "%t", "Replay Menu - No File");
+ return 0;
+ }
+ }
+
+ LoadReplayBot(param1, path);
+ }
+ else if (action == MenuAction_Cancel)
+ {
+ DisplayReplayModeMenu(param1);
+ }
+ else if (action == MenuAction_End)
+ {
+ delete menu;
+ }
+ return 0;
+}
+
+
+
+// =====[ PRIVATE ]=====
+
+static void DisplayReplayMenu(int client)
+{
+ Menu menu = new Menu(MenuHandler_Replay);
+ menu.SetTitle("%T", "Replay Menu - Title", client, gC_CurrentMap, gC_ModeNames[selectedReplayMode[client]]);
+ if (ReplayMenuAddItems(client, menu) > 0)
+ {
+ menu.Display(client, MENU_TIME_FOREVER);
+ }
+ else
+ {
+ GOKZ_PrintToChat(client, true, "%t", "No Replays Found (Mode)", gC_ModeNames[selectedReplayMode[client]]);
+ GOKZ_PlayErrorSound(client);
+ DisplayReplayModeMenu(client);
+ }
+}
+
+// Returns the number of replay menu items added
+static int ReplayMenuAddItems(int client, Menu menu)
+{
+ int replaysAdded = 0;
+ int replayCount = g_ReplayInfoCache.Length;
+ int replayInfo[RP_CACHE_BLOCKSIZE];
+ char temp[32], indexString[4];
+
+ menu.RemoveAllItems();
+
+ for (int i = 0; i < replayCount; i++)
+ {
+ IntToString(i, indexString, sizeof(indexString));
+ g_ReplayInfoCache.GetArray(i, replayInfo);
+ if (replayInfo[1] != selectedReplayMode[client]) // Wrong mode!
+ {
+ continue;
+ }
+
+ if (replayInfo[0] == 0)
+ {
+ FormatEx(temp, sizeof(temp), "Main %s", gC_TimeTypeNames[replayInfo[3]]);
+ }
+ else
+ {
+ FormatEx(temp, sizeof(temp), "Bonus %d %s", replayInfo[0], gC_TimeTypeNames[replayInfo[3]]);
+ }
+ menu.AddItem(indexString, temp, ITEMDRAW_DEFAULT);
+
+ replaysAdded++;
+ }
+
+ return replaysAdded;
+} \ No newline at end of file