From 61673dd43b64e421176d42a462c47d0dccdd3a4f Mon Sep 17 00:00:00 2001 From: nullsystem <15316579+nullsystem@users.noreply.github.com> Date: Mon, 14 Sep 2026 19:12:45 +0100 Subject: [PATCH 1/5] Match snapshots, restore ghost index + spawn hdl, output to chat message * Added `sv_neo_restore_round_snapshot` to restore a round to a snapshot. This will also immediately reset the round to the snapshot round. * Add ability to save and restore ghost index chosen for each round * Add ability to save and restore spawn handle chosen for each round but this feature is limited to snapshots, won't be done for session restore as it won't be do-able for crashed server restore * Output logs to chat message --- src/game/server/neo/neo_gamerules_restore.cpp | 263 +++++++++++++++--- src/game/server/neo/neo_gamerules_restore.h | 6 +- src/game/server/neo/neo_player.h | 4 + src/game/server/neo/neo_spawn_manager.cpp | 42 ++- src/game/server/neo/neo_spawn_manager.h | 4 +- src/game/shared/neo/neo_gamerules.cpp | 59 ++-- src/game/shared/neo/neo_gamerules.h | 2 + 7 files changed, 307 insertions(+), 73 deletions(-) diff --git a/src/game/server/neo/neo_gamerules_restore.cpp b/src/game/server/neo/neo_gamerules_restore.cpp index a6897a0b6a..a0957b7ef1 100644 --- a/src/game/server/neo/neo_gamerules_restore.cpp +++ b/src/game/server/neo/neo_gamerules_restore.cpp @@ -2,6 +2,7 @@ #include "cbase.h" #include "convar.h" +#include #include "KeyValues.h" #include "filesystem.h" #include "neo_gamerules.h" @@ -23,16 +24,81 @@ static ConVar sv_neo_restore_session_allow_name_match("sv_neo_restore_session_al extern ConVar sv_neo_comp; +// NOTE: Spawn restore is snapshot only, session restore cannot +// from a crashed session cannot reliably restore spawn from +// handle. + +struct MatchSnapshotPlayer +{ + int iUserID; + int iXP; + int iDeaths; + int iSpawnHdlEntryIndex; + int iSpawnHdlSerialNumber; +}; + +struct MatchSnapshot +{ + int iScoreJinrai; + int iScoreNSF; + int iRoundsWonJinrai; + int iRoundsWonNSF; + int iGhostSpawnIdx; + MatchSnapshotPlayer players[MAX_PLAYERS_ARRAY_SAFE]; + int iPlayersSize; +}; + +static constexpr const int SNAPSHOTS_TOTAL = 64; +static MatchSnapshot gSnapshots[SNAPSHOTS_TOTAL]; +static int giSnapshotsMax = 0; + +void ClearSnapshots() +{ + V_memset(gSnapshots, 0, sizeof(gSnapshots)); + giSnapshotsMax = 0; +} + +static void PrintToMsgAndTalk(PRINTF_FORMAT_STRING const char *pFormat, ...) +{ + static const constexpr int MAX_LEN_IN_CHARS = 128; + char szDest[MAX_LEN_IN_CHARS] = {}; + + va_list params; + va_start(params, pFormat); + V_vsnprintf(szDest, MAX_LEN_IN_CHARS, pFormat, params); + va_end(params); + + Msg("%s\n", szDest); + UTIL_ClientPrintAll(HUD_PRINTTALK, szDest); +} + +static void ErrorToWarningAndTalk(PRINTF_FORMAT_STRING const char *pFormat, ...) +{ + static const constexpr int MAX_LEN_IN_CHARS = 128; + char szDest[MAX_LEN_IN_CHARS] = {}; + char szDestAll[MAX_LEN_IN_CHARS + 16] = {}; + + va_list params; + va_start(params, pFormat); + V_vsnprintf(szDest, MAX_LEN_IN_CHARS, pFormat, params); + va_end(params); + + V_sprintf_safe(szDestAll, "[ERROR]: %s", szDest); + + Warning("%s\n", szDestAll); + UTIL_ClientPrintAll(HUD_PRINTTALK, szDestAll); +} + static void RestoreSetRoundNumber(const int iRoundNumber, const char *pszFuncName) { if (iRoundNumber < 0) { - Warning("%s: error: Cannot have negative round number\n", pszFuncName); + ErrorToWarningAndTalk("%s: error: Cannot have negative round number", pszFuncName); return; } NEORules()->SetRoundNumber(iRoundNumber); - if (NEORules()->InReadyUpState() || NEORules()->InRoundState()) + if (NEORules()->InReadyUpState() || NEORules()->IsRoundOn()) { NEORules()->m_iNextRestore.iRoundNumber = iRoundNumber; NEORules()->m_iNextRestore.flags |= NEXT_ROUND_GAMERULE_RESTORE_FLAG_ROUND_NUMBER; @@ -42,20 +108,20 @@ static void RestoreSetRoundNumber(const int iRoundNumber, const char *pszFuncNam NEORules()->m_iNextRestore.flags &= ~(NEXT_ROUND_GAMERULE_RESTORE_FLAG_ROUND_NUMBER); } - Msg("%s: Round number set %d\n", pszFuncName, NEORules()->roundNumber()); + PrintToMsgAndTalk("%s: Round number set %d", pszFuncName, iRoundNumber); } static void RestoreSetRoundsWon(const int iRoundsWonJinrai, const int iRoundsWonNSF, const char *pszFuncName) { if (iRoundsWonJinrai < 0 || iRoundsWonNSF < 0) { - Warning("%s: error: Cannot have negative rounds won\n", pszFuncName); + ErrorToWarningAndTalk("%s: error: Cannot have negative rounds won", pszFuncName); return; } GetGlobalTeam(TEAM_JINRAI)->SetRoundsWon(iRoundsWonJinrai); GetGlobalTeam(TEAM_NSF)->SetRoundsWon(iRoundsWonNSF); - if (NEORules()->InReadyUpState() || NEORules()->InRoundState()) + if (NEORules()->InReadyUpState() || NEORules()->IsRoundOn()) { NEORules()->m_iNextRestore.iRoundsWonJinrai = iRoundsWonJinrai; NEORules()->m_iNextRestore.iRoundsWonNSF = iRoundsWonNSF; @@ -66,17 +132,35 @@ static void RestoreSetRoundsWon(const int iRoundsWonJinrai, const int iRoundsWon NEORules()->m_iNextRestore.flags &= ~(NEXT_ROUND_GAMERULE_RESTORE_FLAG_ROUNDSWONS); } - Msg("%s: Rounds won set: Jinrai %d, NSF %d\n", - pszFuncName, - GetGlobalTeam(TEAM_JINRAI)->GetRoundsWon(), - GetGlobalTeam(TEAM_NSF)->GetRoundsWon()); + PrintToMsgAndTalk("%s: Rounds won set: Jinrai %d, NSF %d", pszFuncName, iRoundsWonJinrai, iRoundsWonNSF); +} + +static void RestoreSetGhostSpawnIdx(const int iGhostSpawnIdx, const char *pszFuncName) +{ + if (iGhostSpawnIdx < 0) + { + ErrorToWarningAndTalk("%s: error: Cannot have negative spawn index", pszFuncName); + return; + } + + if (NEORules()->InReadyUpState() || NEORules()->IsRoundOn()) + { + NEORules()->m_iNextRestore.iGhostSpawnIdx = iGhostSpawnIdx; + NEORules()->m_iNextRestore.flags |= NEXT_ROUND_GAMERULE_RESTORE_FLAG_GHOST; + } + else + { + NEORules()->m_iNextRestore.flags &= ~(NEXT_ROUND_GAMERULE_RESTORE_FLAG_GHOST); + } + + PrintToMsgAndTalk("%s: Ghost spawn index set: %d", pszFuncName, iGhostSpawnIdx); } static void RestoreSetScore(const int iScoreJinrai, const int iScoreNSF, const char *pszFuncName) { GetGlobalTeam(TEAM_JINRAI)->SetScore(iScoreJinrai); GetGlobalTeam(TEAM_NSF)->SetScore(iScoreNSF); - if (NEORules()->InReadyUpState() || NEORules()->InRoundState()) + if (NEORules()->InReadyUpState() || NEORules()->IsRoundOn()) { NEORules()->m_iNextRestore.iScoreJinrai = iScoreJinrai; NEORules()->m_iNextRestore.iScoreNSF = iScoreNSF; @@ -87,16 +171,14 @@ static void RestoreSetScore(const int iScoreJinrai, const int iScoreNSF, const c NEORules()->m_iNextRestore.flags &= ~(NEXT_ROUND_GAMERULE_RESTORE_FLAG_SCORES); } - Msg("%s: Score set Jinrai %d, NSF %d\n", - pszFuncName, - GetGlobalTeam(TEAM_JINRAI)->GetScore(), GetGlobalTeam(TEAM_NSF)->GetScore()); + PrintToMsgAndTalk("%s: Score set Jinrai %d, NSF %d", pszFuncName, iScoreJinrai, iScoreNSF); } // NEO NOTE (nullsystem): If iDeaths < 0, it won't be set static void RestoreSetXPDeath(CNEO_Player *pNeoPlayer, const int iXP, const int iDeaths, const char *pszFuncName) { - if (false == NEORules()->InRoundState()) + if (false == NEORules()->IsRoundOn()) { pNeoPlayer->m_iXP.Set(iXP); if (iDeaths >= 0) @@ -106,7 +188,7 @@ static void RestoreSetXPDeath(CNEO_Player *pNeoPlayer, const int iXP, const int } } - if (NEORules()->InReadyUpState() || NEORules()->InRoundState()) + if (NEORules()->InReadyUpState() || NEORules()->IsRoundOn()) { pNeoPlayer->m_iNextRestore.iXP = iXP; pNeoPlayer->m_iNextRestore.flags |= NEXT_ROUND_PLAYER_RESTORE_FLAG_XP; @@ -128,28 +210,100 @@ static void RestoreSetXPDeath(CNEO_Player *pNeoPlayer, const int iXP, const int if (iDeaths >= 0) { - Msg("%s: Given %d XP and %d deaths to %s%s\n", pszFuncName, iXP, iDeaths, - pNeoPlayer->GetNeoPlayerName(), NEORules()->InRoundState() ? " next round" : ""); + PrintToMsgAndTalk("%s: Given %d XP and %d deaths to %s%s", pszFuncName, iXP, iDeaths, + pNeoPlayer->GetNeoPlayerName(), NEORules()->IsRoundOn() ? " next round" : ""); + } + else + { + PrintToMsgAndTalk("%s: Given %d XP to %s%s", pszFuncName, iXP, + pNeoPlayer->GetNeoPlayerName(), NEORules()->IsRoundOn() ? " next round" : ""); + } +} + +static void RestoreSetSpawn(CNEO_Player *pNeoPlayer, + const int iSpawnHdlEntryIndex, + const int iSpawnHdlSerialNumber, + const char *pszFuncName) +{ + if (NEORules()->InReadyUpState() || NEORules()->IsRoundOn()) + { + pNeoPlayer->m_iNextRestore.iSpawnHdlEntryIndex = iSpawnHdlEntryIndex; + pNeoPlayer->m_iNextRestore.iSpawnHdlSerialNumber = iSpawnHdlSerialNumber; + pNeoPlayer->m_iNextRestore.flags |= NEXT_ROUND_PLAYER_RESTORE_FLAG_SPAWN; + PrintToMsgAndTalk("%s: Set spawn %d %d for %s", pszFuncName, + iSpawnHdlEntryIndex, iSpawnHdlSerialNumber, + pNeoPlayer->GetNeoPlayerName()); } else { - Msg("%s: Given %d XP to %s%s\n", pszFuncName, iXP, - pNeoPlayer->GetNeoPlayerName(), NEORules()->InRoundState() ? " next round" : ""); + // Never going to make sense for this scenario + pNeoPlayer->m_iNextRestore.flags &= ~(NEXT_ROUND_PLAYER_RESTORE_FLAG_SPAWN); + ErrorToWarningAndTalk("%s: Cannot set spawn for %s", pszFuncName, + pNeoPlayer->GetNeoPlayerName()); } } +CON_COMMAND(sv_neo_restore_round_snapshot, "Restore the current match's recorded round snapshot") +{ + if (2 != args.ArgC()) + { + ErrorToWarningAndTalk("Usage: %s ", __func__); + return; + } + + const int iRoundNumber = V_atoi(args[1]); + if (iRoundNumber < 1 || iRoundNumber > giSnapshotsMax) + { + if (giSnapshotsMax == 0) + { + ErrorToWarningAndTalk("%s: There's no snapshots", __func__); + } + else + { + ErrorToWarningAndTalk("%s: Round number must be within 1 to %d", __func__, giSnapshotsMax); + } + return; + } + + const MatchSnapshot *pSnapshot = &gSnapshots[iRoundNumber]; + RestoreSetScore(pSnapshot->iScoreJinrai, pSnapshot->iScoreNSF, __func__); + RestoreSetRoundNumber(iRoundNumber, __func__); + RestoreSetRoundsWon(pSnapshot->iRoundsWonJinrai, pSnapshot->iRoundsWonNSF, __func__); + RestoreSetGhostSpawnIdx(pSnapshot->iGhostSpawnIdx, __func__); + for (int i = 0; i < pSnapshot->iPlayersSize; ++i) + { + const MatchSnapshotPlayer *pSnPlayer = &pSnapshot->players[i]; + for (int i = 1; i <= gpGlobals->maxClients; ++i) + { + if (auto pNeoPlayer = static_cast(UTIL_PlayerByIndex(i)); + pNeoPlayer && pNeoPlayer->GetUserID() == pSnPlayer->iUserID) + { + RestoreSetXPDeath(pNeoPlayer, pSnPlayer->iXP, pSnPlayer->iDeaths, __func__); + RestoreSetSpawn(pNeoPlayer, pSnPlayer->iSpawnHdlEntryIndex, pSnPlayer->iSpawnHdlSerialNumber, __func__); + break; + } + } + } + + // Unlike sv_neo_restore_session, this immediately resets to the restoring round + NEORules()->StartNextRound(); + char szCenterPrint[64]; + V_sprintf_safe(szCenterPrint, "- MATCH RESTORED TO ROUND %d SNAPSHOT -\n", iRoundNumber); + UTIL_CenterPrintAll(szCenterPrint); +} + CON_COMMAND(sv_neo_restore_session, "Restore the previous session") { if (false == sv_neo_restore_xp_death_any_round.GetBool() && false == NEORules()->InReadyUpState()) { - Warning("%s: error: Cannot set XPs if not idle and in a ready up lobby\n", __func__); + ErrorToWarningAndTalk("%s: error: Cannot set XPs if not idle and in a ready up lobby", __func__); return; } KeyValues *kv = new KeyValues(GIVEXP_SESSION_RESTORE_KV_ROOT); if (false == kv->LoadFromFile(g_pFullFileSystem, "scripts/" GIVEXP_SESSION_RESTORE_FNAME)) { - Warning("%s: error: No restore session file found\n", __func__); + ErrorToWarningAndTalk("%s: error: No restore session file found", __func__); kv->deleteThis(); return; } @@ -160,7 +314,7 @@ CON_COMMAND(sv_neo_restore_session, "Restore the previous session") const char *pszCurMap = gpGlobals->mapname.ToCStr(); if (0 != V_strcmp(pszInfileMap, pszCurMap)) { - Warning("%s: Will not restore since map session differs: in file %s vs current %s\n", + ErrorToWarningAndTalk("%s: Will not restore since map session differs: in file %s vs current %s", __func__, pszInfileMap, pszCurMap); kv->deleteThis(); return; @@ -176,6 +330,7 @@ CON_COMMAND(sv_neo_restore_session, "Restore the previous session") { RestoreSetRoundNumber(kvRounds->GetInt("number"), __func__); RestoreSetRoundsWon(kvRounds->GetInt("jinrai"), kvRounds->GetInt("nsf"), __func__); + RestoreSetGhostSpawnIdx(kvRounds->GetInt("ghost"), __func__); } if (KeyValues *kvPlayersList = kv->FindKey("players_list")) @@ -235,7 +390,7 @@ CON_COMMAND(sv_neo_restore_session, "Restore the previous session") } else { - Warning("%s: Player steamID: %s, name: %s skipped: not found\n", + ErrorToWarningAndTalk("%s: Player steamID: %s, name: %s skipped: not found", __func__, pszSteamID3, pszName); } } @@ -271,22 +426,42 @@ void MatchSessionBackup() kv->AddSubKey(kvInfo); } + // NEO NOTE (nullsystem): Just fall back to unused index 0 if rounds somewhat + // manages to get to 64+ or some invalid index for some reason. + // From then on it won't crash the snapshotter as it just starts writing to + // snapshot index-0, but round numbers are indexed-1 so they won't get used anyway. + const int iRoundNumber = NEORules()->roundNumber(); + MatchSnapshot *pSnapshot = + (iRoundNumber < 0 || iRoundNumber >= SNAPSHOTS_TOTAL) ? + &gSnapshots[0] : &gSnapshots[iRoundNumber]; + giSnapshotsMax = Max(giSnapshotsMax, iRoundNumber); + { + pSnapshot->iScoreJinrai = GetGlobalTeam(TEAM_JINRAI)->GetScore(); + pSnapshot->iScoreNSF = GetGlobalTeam(TEAM_NSF)->GetScore(); + KeyValues *kvScore = new KeyValues("score"); - kvScore->SetInt("jinrai", GetGlobalTeam(TEAM_JINRAI)->GetScore()); - kvScore->SetInt("nsf", GetGlobalTeam(TEAM_NSF)->GetScore()); + kvScore->SetInt("jinrai", pSnapshot->iScoreJinrai); + kvScore->SetInt("nsf", pSnapshot->iScoreNSF); kv->AddSubKey(kvScore); } { + pSnapshot->iRoundsWonJinrai = GetGlobalTeam(TEAM_JINRAI)->GetRoundsWon(); + pSnapshot->iRoundsWonNSF = GetGlobalTeam(TEAM_NSF)->GetRoundsWon(); + pSnapshot->iGhostSpawnIdx = NEORules()->m_iGhostSpawnIdx; + KeyValues *kvRounds = new KeyValues("rounds"); - kvRounds->SetInt("number", NEORules()->roundNumber()); - kvRounds->SetInt("jinrai", GetGlobalTeam(TEAM_JINRAI)->GetRoundsWon()); - kvRounds->SetInt("nsf", GetGlobalTeam(TEAM_NSF)->GetRoundsWon()); + kvRounds->SetInt("number", iRoundNumber); + kvRounds->SetInt("jinrai", pSnapshot->iRoundsWonJinrai); + kvRounds->SetInt("nsf", pSnapshot->iRoundsWonNSF); + kvRounds->SetInt("ghost", pSnapshot->iGhostSpawnIdx); kv->AddSubKey(kvRounds); } { + pSnapshot->iPlayersSize = 0; + KeyValues *kvPlayersList = new KeyValues("players_list"); for (int i = 1; i <= gpGlobals->maxClients; i++) { @@ -295,9 +470,16 @@ void MatchSessionBackup() && (pNeoPlayer->GetTeamNumber() == TEAM_JINRAI || pNeoPlayer->GetTeamNumber() == TEAM_NSF)) { + MatchSnapshotPlayer *pSnPlayer = &pSnapshot->players[pSnapshot->iPlayersSize++]; + pSnPlayer->iUserID = pNeoPlayer->GetUserID(); + pSnPlayer->iXP = pNeoPlayer->m_iXP.Get(); + pSnPlayer->iDeaths = pNeoPlayer->DeathCount(); + pSnPlayer->iSpawnHdlEntryIndex = pNeoPlayer->m_iSpawnHdlEntryIndex; + pSnPlayer->iSpawnHdlSerialNumber = pNeoPlayer->m_iSpawnHdlSerialNumber; + KeyValues *kvPlayer = new KeyValues("player"); - kvPlayer->SetInt("xp", pNeoPlayer->m_iXP.Get()); - kvPlayer->SetInt("deaths", pNeoPlayer->DeathCount()); + kvPlayer->SetInt("xp", pSnPlayer->iXP); + kvPlayer->SetInt("deaths", pSnPlayer->iDeaths); // team - Unused on de-serialization as steamid3 is enough, but have descriptive purpose kvPlayer->SetString("team", (pNeoPlayer->GetTeamNumber() == TEAM_JINRAI) ? "j" : "n"); // name - Always set regardless of sv_neo_restore_session_name_match, have a descriptive @@ -318,11 +500,12 @@ void MatchSessionBackup() if (kv->SaveToFile(g_pFullFileSystem, "scripts/" GIVEXP_SESSION_RESTORE_FNAME)) { + // Would be spammy in text chat if going as usual, so kept as Msg here Msg("Session backed up at %s\n", szDateTime); } else { - Warning("Session backup failed to save at %s!\n", szDateTime); + ErrorToWarningAndTalk("Session backup failed to save at %s!", szDateTime); } kv->deleteThis(); @@ -332,7 +515,7 @@ CON_COMMAND(sv_neo_restore_round_number, "Set the next round number") { if (2 != args.ArgC()) { - Warning("Usage: %s \n", __func__); + ErrorToWarningAndTalk("Usage: %s ", __func__); return; } @@ -344,7 +527,7 @@ CON_COMMAND(sv_neo_restore_rounds_won, "Set the rounds won") { if (3 != args.ArgC()) { - Warning("Usage: %s \n", __func__); + ErrorToWarningAndTalk("Usage: %s ", __func__); return; } @@ -357,7 +540,7 @@ CON_COMMAND(sv_neo_restore_team_scores, "Set the scores for each team (not used { if (3 != args.ArgC()) { - Warning("Usage: %s \n", __func__); + ErrorToWarningAndTalk("Usage: %s ", __func__); return; } @@ -368,11 +551,11 @@ CON_COMMAND(sv_neo_restore_team_scores, "Set the scores for each team (not used CON_COMMAND(sv_neo_restore_xp, "Give a player XP (and death) count") { - static constexpr const char SZ_COMMON_USAGE_PF[] = "Usage: %s \n"; + static constexpr const char SZ_COMMON_USAGE_PF[] = "Usage: %s "; if (!IN_BETWEEN_EQ(3, args.ArgC(), 4)) { - Warning(SZ_COMMON_USAGE_PF, __func__); + ErrorToWarningAndTalk(SZ_COMMON_USAGE_PF, __func__); return; } @@ -381,14 +564,14 @@ CON_COMMAND(sv_neo_restore_xp, "Give a player XP (and death) count") const int iDeaths = (args.ArgC() == 4) ? V_atoi(args[3]) : -1; if (4 == args.ArgC() && (iDeaths < 0)) { - Warning("%s: error: Death count must be positive\n", __func__); - Warning(SZ_COMMON_USAGE_PF, __func__); + ErrorToWarningAndTalk("%s: error: Death count must be positive", __func__); + ErrorToWarningAndTalk(SZ_COMMON_USAGE_PF, __func__); return; } if (false == sv_neo_restore_xp_death_any_round.GetBool() && false == NEORules()->InReadyUpState()) { - Warning("%s: error: Cannot set XPs if not idle and in a ready up lobby\n", __func__); + ErrorToWarningAndTalk("%s: error: Cannot set XPs if not idle and in a ready up lobby", __func__); return; } @@ -406,6 +589,6 @@ CON_COMMAND(sv_neo_restore_xp, "Give a player XP (and death) count") } } - Warning("%s: error: Cannot find player \"%s\"\n", __func__, pszNameFind); + ErrorToWarningAndTalk("%s: error: Cannot find player \"%s\"", __func__, pszNameFind); } diff --git a/src/game/server/neo/neo_gamerules_restore.h b/src/game/server/neo/neo_gamerules_restore.h index 5e4f818d3d..83b024696c 100644 --- a/src/game/server/neo/neo_gamerules_restore.h +++ b/src/game/server/neo/neo_gamerules_restore.h @@ -7,6 +7,7 @@ enum NextRoundGameruleRestoreFlag_ NEXT_ROUND_GAMERULE_RESTORE_FLAG_SCORES = 1 << 0, NEXT_ROUND_GAMERULE_RESTORE_FLAG_ROUND_NUMBER = 1 << 1, NEXT_ROUND_GAMERULE_RESTORE_FLAG_ROUNDSWONS = 1 << 2, + NEXT_ROUND_GAMERULE_RESTORE_FLAG_GHOST = 1 << 3, }; typedef int NextRoundGameruleRestoreFlags; @@ -15,9 +16,12 @@ enum NextRoundPlayerRestoreFlag_ NEXT_ROUND_PLAYER_RESTORE_FLAG_NIL = 0, NEXT_ROUND_PLAYER_RESTORE_FLAG_XP = 1 << 0, NEXT_ROUND_PLAYER_RESTORE_FLAG_DEATH = 1 << 1, + NEXT_ROUND_PLAYER_RESTORE_FLAG_SPAWN = 1 << 2, }; typedef int NextRoundPlayerRestoreFlags; -// Backup current match state to disk +// Backup current match state to disk (for sv_neo_restore_session) +// and in memory (for sv_neo_restore_round_snapshot) void MatchSessionBackup(); +void ClearSnapshots(); diff --git a/src/game/server/neo/neo_player.h b/src/game/server/neo/neo_player.h index dd603fda49..ff2b808258 100644 --- a/src/game/server/neo/neo_player.h +++ b/src/game/server/neo/neo_player.h @@ -280,8 +280,12 @@ class CNEO_Player : public CHL2MP_Player NextRoundPlayerRestoreFlags flags; int iXP; int iDeaths; + int iSpawnHdlEntryIndex; + int iSpawnHdlSerialNumber; }; NeoRestore m_iNextRestore = {}; + int m_iSpawnHdlEntryIndex; + int m_iSpawnHdlSerialNumber; CNetworkVar(int, m_iLoadoutWepChoice); CNetworkVar(int, m_iNextSpawnClassChoice); diff --git a/src/game/server/neo/neo_spawn_manager.cpp b/src/game/server/neo/neo_spawn_manager.cpp index 6c1a92ea48..cf58fd24a1 100644 --- a/src/game/server/neo/neo_spawn_manager.cpp +++ b/src/game/server/neo/neo_spawn_manager.cpp @@ -22,7 +22,7 @@ class CNEO_SpawnManager : public CGameEventListener { public: CNEO_SpawnManager(); - CNEOSpawnPoint* RequestSpawn(int team, CBasePlayer* player); + CNEOSpawnPoint* RequestSpawn(int team, CNEO_Player *player); virtual void FireGameEvent(IGameEvent* event) override final; CUtlVector m_spawns; @@ -59,7 +59,7 @@ namespace NeoSpawnManager manager.StopListeningForAllEvents(); } - CNEOSpawnPoint* RequestSpawn(int team, CBasePlayer* player) + CNEOSpawnPoint* RequestSpawn(int team, CNEO_Player *player) { // Nothing we can do to salvage this... This will fall back // to spawning at info_player_start or related logic in the caller. @@ -80,8 +80,12 @@ namespace NeoSpawnManager } CNEOSpawnPoint* backup = nullptr; - auto idx = manager.m_spawns.FindPredicate( - [rules, team, player, &backup](const auto& spawn)->bool + + bool bRestoreSpawn = (player->m_iNextRestore.flags & NEXT_ROUND_PLAYER_RESTORE_FLAG_SPAWN + && player->m_iNextRestore.iSpawnHdlEntryIndex >= 0 + && player->m_iNextRestore.iSpawnHdlSerialNumber >= 0); + + auto FindSpawn = [rules, team, player, &backup, bRestoreSpawn](const auto& spawn)->bool { if (!spawn.handle || !spawn.handle.IsValid()) { @@ -110,8 +114,28 @@ namespace NeoSpawnManager if (!rules->IsSpawnPointValid(spawn.handle, player)) return false; - return true; - }); + if (bRestoreSpawn) + { + return (spawn.handle.GetEntryIndex() == player->m_iNextRestore.iSpawnHdlEntryIndex + && spawn.handle.GetSerialNumber() == player->m_iNextRestore.iSpawnHdlSerialNumber); + } + else + { + return true; + } + }; + + int idx = manager.m_spawns.FindPredicate(FindSpawn); + + // Try again if it's restoring spawn but cannot find it + if (bRestoreSpawn && idx == manager.m_spawns.InvalidIndex()) + { + bRestoreSpawn = false; + idx = manager.m_spawns.FindPredicate(FindSpawn); + } + + player->m_iSpawnHdlEntryIndex = -1; + player->m_iSpawnHdlSerialNumber = -1; if (idx == manager.m_spawns.InvalidIndex()) { @@ -127,7 +151,11 @@ namespace NeoSpawnManager // We only care if it's been used before or not if there are no respawns manager.m_spawns[idx].isUsed = true; } - return manager.m_spawns[idx].handle; + + auto handle = manager.m_spawns[idx].handle; + player->m_iSpawnHdlEntryIndex = handle.GetEntryIndex(); + player->m_iSpawnHdlSerialNumber = handle.GetSerialNumber(); + return handle; } void Register(CNEOSpawnPoint* spawn) diff --git a/src/game/server/neo/neo_spawn_manager.h b/src/game/server/neo/neo_spawn_manager.h index ea0c1b7b42..c8e14ef746 100644 --- a/src/game/server/neo/neo_spawn_manager.h +++ b/src/game/server/neo/neo_spawn_manager.h @@ -2,11 +2,11 @@ #include "neo_player_spawnpoint.h" -class CBasePlayer; +class CNEO_Player; class CNEOSpawnPoint; namespace NeoSpawnManager { - CNEOSpawnPoint* RequestSpawn(int team, CBasePlayer* player); + CNEOSpawnPoint* RequestSpawn(int team, CNEO_Player *player); void Init(); void Deinit(); diff --git a/src/game/shared/neo/neo_gamerules.cpp b/src/game/shared/neo/neo_gamerules.cpp index 196aca010a..732b57ce7e 100644 --- a/src/game/shared/neo/neo_gamerules.cpp +++ b/src/game/shared/neo/neo_gamerules.cpp @@ -2035,39 +2035,44 @@ void CNEORules::SpawnTheGhost(const Vector *origin) // I'm not touching this right now cuz I don't want to risk breaking the parity behaviour Assert(!m_ghostSpawns.IsEmpty()); - int desiredSpawn; // zero-indexed + int desiredSpawn = m_iGhostSpawnIdx; // zero-indexed - // If round number is zero, the match hasn't started yet, so the bias is not meaningful. - // Parity behaviour is to not spawn a ghost at all, but it's more useful to just spawn it somewhere. - if (!sv_neo_ghost_spawn_bias.GetBool() || roundNumber() == 0) - { - desiredSpawn = RandomInt(0, m_ghostSpawns.Count()-1); - } - else + if (desiredSpawn < 0 || desiredSpawn >= m_ghostSpawns.Count()) { - // Round numbers are one-indexed - Assert(roundNumber() > 0); - bool isFirstRound = (roundNumber() == 1); - if (isFirstRound) + // If round number is zero, the match hasn't started yet, so the bias is not meaningful. + // Parity behaviour is to not spawn a ghost at all, but it's more useful to just spawn it somewhere. + if (!sv_neo_ghost_spawn_bias.GetBool() || roundNumber() == 0) { - // Plugin parity: we want to shuffle the list of ghost spawns once at match beginning, - // and then play through them in round pairs, using the cycling logic right below this if-block. - m_ghostSpawns.Shuffle(); + desiredSpawn = RandomInt(0, m_ghostSpawns.Count()-1); } + else + { + // Round numbers are one-indexed + Assert(roundNumber() > 0); + bool isFirstRound = (roundNumber() == 1); + if (isFirstRound) + { + // Plugin parity: we want to shuffle the list of ghost spawns once at match beginning, + // and then play through them in round pairs, using the cycling logic right below this if-block. + m_ghostSpawns.Shuffle(); + } - desiredSpawn = Ceil2Int(roundNumber() / 2.f) % m_ghostSpawns.Count(); - } + desiredSpawn = Ceil2Int(roundNumber() / 2.f) % m_ghostSpawns.Count(); + } - if (sv_neo_ghost_spawn_force.GetInt() >= 0) - { - desiredSpawn = sv_neo_ghost_spawn_force.GetInt() % m_ghostSpawns.Count(); - Msg("sv_neo_ghost_spawn_force: pinned ghost spawn %d of %d for this map\n", - desiredSpawn, m_ghostSpawns.Count()); + if (sv_neo_ghost_spawn_force.GetInt() >= 0) + { + desiredSpawn = sv_neo_ghost_spawn_force.GetInt() % m_ghostSpawns.Count(); + Msg("sv_neo_ghost_spawn_force: pinned ghost spawn %d of %d for this map\n", + desiredSpawn, m_ghostSpawns.Count()); + } } Assert(desiredSpawn >= 0); Assert(desiredSpawn < m_ghostSpawns.Count()); + m_iGhostSpawnIdx = desiredSpawn; + auto *ghostSpawn = m_ghostSpawns[desiredSpawn].Get(); if (ghostSpawn) { @@ -2872,8 +2877,11 @@ void CNEORules::StartNextRound() pJinrai->SetRoundsWon(0); pNSF->SetScore(0); pNSF->SetRoundsWon(0); + + ClearSnapshots(); } + m_iGhostSpawnIdx = -1; if (m_iNextRestore.flags & NEXT_ROUND_GAMERULE_RESTORE_FLAG_SCORES) { GetGlobalTeam(TEAM_JINRAI)->SetScore(m_iNextRestore.iScoreJinrai); @@ -2890,6 +2898,10 @@ void CNEORules::StartNextRound() GetGlobalTeam(TEAM_JINRAI)->SetRoundsWon(m_iNextRestore.iRoundsWonJinrai); GetGlobalTeam(TEAM_NSF)->SetRoundsWon(m_iNextRestore.iRoundsWonNSF); } + if (m_iNextRestore.flags & NEXT_ROUND_GAMERULE_RESTORE_FLAG_GHOST) + { + m_iGhostSpawnIdx = m_iNextRestore.iGhostSpawnIdx; + } m_iNextRestore = {}; // Zero-out for (int i = 1; i <= gpGlobals->maxClients; i++) @@ -2942,6 +2954,7 @@ void CNEORules::StartNextRound() pPlayer->SetTestMessageVisible(false); + // NEXT_ROUND_PLAYER_RESTORE_FLAG_SPAWN already set by NeoSpawnManager::RequestSpawn if (pPlayer->m_iNextRestore.flags & NEXT_ROUND_PLAYER_RESTORE_FLAG_XP) { pPlayer->m_iXP.Set(pPlayer->m_iNextRestore.iXP); @@ -2963,7 +2976,6 @@ void CNEORules::StartNextRound() V_memset(m_arrayiEntPrevCap, 0, sizeof(m_arrayiEntPrevCap)); m_iEntPrevCapSize = 0; - MatchSessionBackup(); FireLegacyEvent_NeoRoundEnd(); char RoundMsg[27]; @@ -2972,6 +2984,7 @@ void CNEORules::StartNextRound() UTIL_CenterPrintAll(RoundMsg); SetGameRelatedVars(); + MatchSessionBackup(); IGameEvent *event = gameeventmanager->CreateEvent("round_start"); if (event) diff --git a/src/game/shared/neo/neo_gamerules.h b/src/game/shared/neo/neo_gamerules.h index 48f008ee8f..4fc88179e2 100644 --- a/src/game/shared/neo/neo_gamerules.h +++ b/src/game/shared/neo/neo_gamerules.h @@ -599,8 +599,10 @@ class CNEORules : public CHL2MPRules, public CGameEventListener int iRoundNumber; int iRoundsWonJinrai; int iRoundsWonNSF; + int iGhostSpawnIdx; }; NeoRestore m_iNextRestore = {}; + int m_iGhostSpawnIdx = -1; #endif }; From 7fcab40b2c177158e3a5bf7061cefe000956014e Mon Sep 17 00:00:00 2001 From: nullsystem <15316579+nullsystem@users.noreply.github.com> Date: Mon, 14 Sep 2026 22:42:25 +0100 Subject: [PATCH 2/5] guard iPlayersSize --- src/game/server/neo/neo_gamerules_restore.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/game/server/neo/neo_gamerules_restore.cpp b/src/game/server/neo/neo_gamerules_restore.cpp index a0957b7ef1..96508c6208 100644 --- a/src/game/server/neo/neo_gamerules_restore.cpp +++ b/src/game/server/neo/neo_gamerules_restore.cpp @@ -463,7 +463,7 @@ void MatchSessionBackup() pSnapshot->iPlayersSize = 0; KeyValues *kvPlayersList = new KeyValues("players_list"); - for (int i = 1; i <= gpGlobals->maxClients; i++) + for (int i = 1; i <= gpGlobals->maxClients && pSnapshot->iPlayersSize < MAX_PLAYERS_ARRAY_SAFE; i++) { auto pNeoPlayer = static_cast(UTIL_PlayerByIndex(i)); if (pNeoPlayer From 5af71e86cef45c46614f60ab110f99e8486edf0a Mon Sep 17 00:00:00 2001 From: nullsystem <15316579+nullsystem@users.noreply.github.com> Date: Tue, 15 Sep 2026 18:06:30 +0100 Subject: [PATCH 3/5] don't shadow for loop i --- src/game/server/neo/neo_gamerules_restore.cpp | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/game/server/neo/neo_gamerules_restore.cpp b/src/game/server/neo/neo_gamerules_restore.cpp index 96508c6208..c2d1098885 100644 --- a/src/game/server/neo/neo_gamerules_restore.cpp +++ b/src/game/server/neo/neo_gamerules_restore.cpp @@ -270,12 +270,12 @@ CON_COMMAND(sv_neo_restore_round_snapshot, "Restore the current match's recorded RestoreSetRoundNumber(iRoundNumber, __func__); RestoreSetRoundsWon(pSnapshot->iRoundsWonJinrai, pSnapshot->iRoundsWonNSF, __func__); RestoreSetGhostSpawnIdx(pSnapshot->iGhostSpawnIdx, __func__); - for (int i = 0; i < pSnapshot->iPlayersSize; ++i) + for (int idxSnPlayer = 0; idxSnPlayer < pSnapshot->iPlayersSize; ++idxSnPlayer) { - const MatchSnapshotPlayer *pSnPlayer = &pSnapshot->players[i]; - for (int i = 1; i <= gpGlobals->maxClients; ++i) + const MatchSnapshotPlayer *pSnPlayer = &pSnapshot->players[idxSnPlayer]; + for (int idxClient = 1; idxClient <= gpGlobals->maxClients; ++idxClient) { - if (auto pNeoPlayer = static_cast(UTIL_PlayerByIndex(i)); + if (auto pNeoPlayer = static_cast(UTIL_PlayerByIndex(idxClient)); pNeoPlayer && pNeoPlayer->GetUserID() == pSnPlayer->iUserID) { RestoreSetXPDeath(pNeoPlayer, pSnPlayer->iXP, pSnPlayer->iDeaths, __func__); From ebeb820d58dcf237753077d290eed9d422e43ce6 Mon Sep 17 00:00:00 2001 From: nullsystem <15316579+nullsystem@users.noreply.github.com> Date: Tue, 15 Sep 2026 18:35:06 +0100 Subject: [PATCH 4/5] sv_neo_restore_xp support steamid3 --- src/game/server/neo/neo_gamerules_restore.cpp | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/src/game/server/neo/neo_gamerules_restore.cpp b/src/game/server/neo/neo_gamerules_restore.cpp index c2d1098885..bdc6f0de4c 100644 --- a/src/game/server/neo/neo_gamerules_restore.cpp +++ b/src/game/server/neo/neo_gamerules_restore.cpp @@ -551,7 +551,9 @@ CON_COMMAND(sv_neo_restore_team_scores, "Set the scores for each team (not used CON_COMMAND(sv_neo_restore_xp, "Give a player XP (and death) count") { - static constexpr const char SZ_COMMON_USAGE_PF[] = "Usage: %s "; + static constexpr const char SZ_COMMON_USAGE_PF[] = + "Usage: %s \n" + "steamid3 = uniqueid in \"status\" command, must be surrounded with quotes"; if (!IN_BETWEEN_EQ(3, args.ArgC(), 4)) { @@ -575,13 +577,27 @@ CON_COMMAND(sv_neo_restore_xp, "Give a player XP (and death) count") return; } + CSteamID steamIDFind; + steamIDFind.SetFromStringStrict(pszNameFind, k_EUniversePublic); + for (int i = 1; i <= gpGlobals->maxClients; ++i) { auto pNeoPlayer = static_cast(UTIL_PlayerByIndex(i)); if (pNeoPlayer) { - const char *pszNameCmp = pNeoPlayer->GetNeoPlayerName(); - if (0 == V_strcmp(pszNameCmp, pszNameFind)) + bool bFoundPlayer = false; + if (steamIDFind.IsValid()) + { + const CSteamID playerSteamID = GetSteamIDForPlayerIndex(pNeoPlayer->entindex()); + bFoundPlayer = (playerSteamID.IsValid() && playerSteamID == steamIDFind); + } + else + { + const char *pszNameCmp = pNeoPlayer->GetNeoPlayerName(); + bFoundPlayer = (0 == V_strcmp(pszNameCmp, pszNameFind)); + } + + if (bFoundPlayer) { RestoreSetXPDeath(pNeoPlayer, iXP, iDeaths, __func__); return; From cdec04abf1437fe94ab00e667ca99e29630930e1 Mon Sep 17 00:00:00 2001 From: nullsystem <15316579+nullsystem@users.noreply.github.com> Date: Tue, 15 Sep 2026 18:41:04 +0100 Subject: [PATCH 5/5] mention sv_neo_restore_xp_death_any_round 1 --- src/game/server/neo/neo_gamerules_restore.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/game/server/neo/neo_gamerules_restore.cpp b/src/game/server/neo/neo_gamerules_restore.cpp index bdc6f0de4c..9c2bd70733 100644 --- a/src/game/server/neo/neo_gamerules_restore.cpp +++ b/src/game/server/neo/neo_gamerules_restore.cpp @@ -296,7 +296,8 @@ CON_COMMAND(sv_neo_restore_session, "Restore the previous session") { if (false == sv_neo_restore_xp_death_any_round.GetBool() && false == NEORules()->InReadyUpState()) { - ErrorToWarningAndTalk("%s: error: Cannot set XPs if not idle and in a ready up lobby", __func__); + ErrorToWarningAndTalk("%s: error: Cannot set restore session if not idle and in a ready up lobby", __func__); + ErrorToWarningAndTalk("Set \"sv_neo_restore_xp_death_any_round 1\" if need to set anytime.", __func__); return; } @@ -574,6 +575,7 @@ CON_COMMAND(sv_neo_restore_xp, "Give a player XP (and death) count") if (false == sv_neo_restore_xp_death_any_round.GetBool() && false == NEORules()->InReadyUpState()) { ErrorToWarningAndTalk("%s: error: Cannot set XPs if not idle and in a ready up lobby", __func__); + ErrorToWarningAndTalk("Set \"sv_neo_restore_xp_death_any_round 1\" if need to set anytime.", __func__); return; }