From 36ca7e208996757dccb78633e3529772f3deaee7 Mon Sep 17 00:00:00 2001 From: Nuko Date: Fri, 15 May 2026 16:06:41 +0800 Subject: [PATCH 1/2] optimize replay & record modules - move replay deserialization off the main thread to prevent stuttering - add .idx sidecars to persist fallback records across server crashes/restarts - unify main and stage pending replay processing - unify replay playback caches - dynamically size ReplayFrameBuffer based on previous run length - add immediate disk cleanup for expired .tmp files and orphaned sidecars - add immediate disk cleanup for expired .tmp files and orphaned sidecars - add timer_replay_fallback_ttl convar - pass ReplayFrameData as reference to ReplayFrameBuffer.Add to avoid copying - use power-of-two ring buffer with bitmasking instead of modulo in ReplayFrameBuffer for less cpu cycles with the cost of slightly higher memory usage - parallelize WR checkpoint loading with Task.WhenAll to drastically reduce record load time --- Timer/Modules/RecordModule.cs | 50 ++- Timer/Modules/Replay/PendingRecordResult.cs | 2 +- Timer/Modules/Replay/ReplayBotData.cs | 3 +- Timer/Modules/Replay/ReplayFrameBuffer.cs | 40 +- Timer/Modules/Replay/ReplayShared.cs | 13 +- Timer/Modules/ReplayPlaybackModule.cs | 346 +++++++-------- Timer/Modules/ReplayRecorderModule.cs | 456 ++++++++++++-------- 7 files changed, 506 insertions(+), 404 deletions(-) diff --git a/Timer/Modules/RecordModule.cs b/Timer/Modules/RecordModule.cs index bc2171e..89f13fb 100644 --- a/Timer/Modules/RecordModule.cs +++ b/Timer/Modules/RecordModule.cs @@ -84,7 +84,7 @@ internal partial class RecordModule : IModule, IGameListener, IRecordModule, ITi private readonly ListenerHub _listenerHub; // Per-slot session start time (engine time when player joined this map) - private readonly double[] _sessionStartTime = new double[64]; + private readonly double[] _sessionStartTime = new double[PlayerSlot.MaxPlayerCount]; // Late-resolved to avoid circular DI (ReplayRecorderModule depends on IRecordModule) private IReplayRecorderModule _replayRecorder = null!; @@ -191,24 +191,24 @@ public void OnServerActivate() ).ConfigureAwait(false); // Load WR checkpoints for each (style, track) combination - var wrCheckpointMap = new Dictionary<(int style, int track), IReadOnlyList>(); + var wrPerTrack = records + .GroupBy(r => (r.Style, r.Track)) + .Select(g => g.OrderBy(r => r.Time).ThenBy(r => r.Id).First()); - var wrByTrack = records.GroupBy(r => (r.Style, r.Track)); - - foreach (var group in wrByTrack) + var wrCheckpointTasks = wrPerTrack.Select(async wr => { - var wr = group.OrderBy(r => r.Time).ThenBy(r => r.Id).FirstOrDefault(); + var checkpoints = await RetryHelper.RetryAsync(() => _request.GetRecordCheckpoints(wr.Id), + RetryHelper.IsTransient, + _logger, + "GetRecordCheckpoints").ConfigureAwait(false); - if (wr is not null) - { - var checkpoints = await RetryHelper.RetryAsync( - () => _request.GetRecordCheckpoints(wr.Id), - RetryHelper.IsTransient, _logger, "GetRecordCheckpoints" - ).ConfigureAwait(false); + return (key: (wr.Style, wr.Track), checkpoints); + }); - wrCheckpointMap[(wr.Style, wr.Track)] = checkpoints; - } - } + var wrCheckpointResults = await Task.WhenAll(wrCheckpointTasks).ConfigureAwait(false); + + var wrCheckpointMap = wrCheckpointResults + .ToDictionary(r => r.key, r => r.checkpoints); await _bridge.ModSharp.InvokeFrameActionAsync(() => { @@ -243,18 +243,16 @@ await _bridge.ModSharp.InvokeFrameActionAsync(() => public void OnGameShutdown() { // Flush playtime for all connected players before map change - for (var i = 0; i < 64; i++) + for (PlayerSlot i = 0; i < PlayerSlot.MaxPlayerCount; i++) { if (_sessionStartTime[i] <= 0) { continue; } - var playerSlot = new PlayerSlot(i); - - if (_bridge.ClientManager.GetGameClient(playerSlot) is { IsFakeClient: false } client) + if (_bridge.ClientManager.GetGameClient(i) is { IsFakeClient: false } client) { - FlushPlayerMapStats(playerSlot, client.SteamId); + FlushPlayerMapStats(i, client.SteamId); } else { @@ -322,7 +320,7 @@ public void OnClientPutInServer(PlayerSlot slot) } _playerCache.Clear(slot); - _sessionStartTime[(int)slot] = _bridge.ModSharp.EngineTime(); + _sessionStartTime[slot] = _bridge.ModSharp.EngineTime(); } public void OnClientDisconnected(PlayerSlot slot) @@ -333,7 +331,7 @@ public void OnClientDisconnected(PlayerSlot slot) } FlushPlayerMapStats(slot, client.SteamId); - _sessionStartTime[(int)slot] = 0; // player left, clear session + _sessionStartTime[slot] = 0; // player left, clear session _playerCache.Clear(slot); } @@ -411,14 +409,14 @@ public int GetTotalRecordCount(int style, int track) => public float GetSessionTime(PlayerSlot slot) { - var start = _sessionStartTime[(int)slot]; + var start = _sessionStartTime[slot]; + return start > 0 ? (float)(_bridge.ModSharp.EngineTime() - start) : 0f; } private void FlushPlayerMapStats(PlayerSlot slot, SteamID steamId) { - var index = (int)slot; - var start = _sessionStartTime[index]; + var start = _sessionStartTime[slot]; if (start <= 0) { @@ -428,7 +426,7 @@ private void FlushPlayerMapStats(PlayerSlot slot, SteamID steamId) var delta = (float)(_bridge.ModSharp.EngineTime() - start); var mapName = _bridge.GlobalVars.MapName; - _sessionStartTime[index] = _bridge.ModSharp.EngineTime(); // reset for next session segment + _sessionStartTime[slot] = _bridge.ModSharp.EngineTime(); // reset for next session segment if (delta <= 0f) { diff --git a/Timer/Modules/Replay/PendingRecordResult.cs b/Timer/Modules/Replay/PendingRecordResult.cs index e53c0ee..6b5367b 100644 --- a/Timer/Modules/Replay/PendingRecordResult.cs +++ b/Timer/Modules/Replay/PendingRecordResult.cs @@ -21,7 +21,7 @@ namespace Source2Surf.Timer.Modules.Replay; /// /// Stores a record result when OnRecordSaved arrives before the post-frame timer expires. -/// Consumed by CreateAndStorePendingReplay to bypass PendingReplayStore. +/// Consumed by StorePendingReplay to bypass PendingReplayStore. /// internal sealed class PendingRecordResult { diff --git a/Timer/Modules/Replay/ReplayBotData.cs b/Timer/Modules/Replay/ReplayBotData.cs index c8191a8..bbb2f27 100644 --- a/Timer/Modules/Replay/ReplayBotData.cs +++ b/Timer/Modules/Replay/ReplayBotData.cs @@ -26,7 +26,8 @@ namespace Source2Surf.Timer.Modules.Replay; internal class ReplayBotData : IReplayBotData { - public required ReplayBotConfig Config { get; init; } + public required ReplayBotConfig Config { get; init; } + public required int ConfigIndex { get; init; } public EntityIndex Index { get; init; } public required IPlayerController Controller { get; init; } diff --git a/Timer/Modules/Replay/ReplayFrameBuffer.cs b/Timer/Modules/Replay/ReplayFrameBuffer.cs index ef7bcb8..2ad720f 100644 --- a/Timer/Modules/Replay/ReplayFrameBuffer.cs +++ b/Timer/Modules/Replay/ReplayFrameBuffer.cs @@ -18,21 +18,26 @@ using System; using System.Collections; using System.Collections.Generic; +using System.Numerics; using Source2Surf.Timer.Shared.Models.Replay; namespace Source2Surf.Timer.Modules.Replay; /// /// Simple ring buffer to avoid front-shifting allocations when trimming pre-run frames. +/// Capacity is always a power of two so the wrap can use a bitmask instead of modulo. /// internal sealed class ReplayFrameBuffer : IReadOnlyList { private ReplayFrameData[] _buffer; private int _head; + private int _mask; public ReplayFrameBuffer(int capacity = 64) { - _buffer = capacity > 0 ? new ReplayFrameData[capacity] : Array.Empty(); + var size = RoundUpToPow2(capacity > 0 ? capacity : 1); + _buffer = new ReplayFrameData[size]; + _mask = size - 1; } public int Count { get; private set; } @@ -48,18 +53,15 @@ public ReplayFrameData this[int index] throw new ArgumentOutOfRangeException(nameof(index)); } - var realIndex = (_head + index) % _buffer.Length; - - return _buffer[realIndex]; + return _buffer[(_head + index) & _mask]; } } - public void Add(ReplayFrameData frame) + public void Add(in ReplayFrameData frame) { EnsureCapacity(Count + 1); - var tail = (_head + Count) % _buffer.Length; - _buffer[tail] = frame; + _buffer[(_head + Count) & _mask] = frame; Count++; } @@ -76,14 +78,14 @@ public void RemoveOldest(int removeCount) return; } - _head = (_head + removeCount) % _buffer.Length; + _head = (_head + removeCount) & _mask; Count -= removeCount; } public void Clear() { - _head = 0; - Count = 0; + _head = 0; + Count = 0; } public void EnsureCapacity(int capacity) @@ -93,20 +95,23 @@ public void EnsureCapacity(int capacity) return; } - var newSize = Math.Max(capacity, _buffer.Length == 0 ? 4 : _buffer.Length * 2); + var newSize = RoundUpToPow2(Math.Max(capacity, _buffer.Length * 2)); var newArr = new ReplayFrameData[newSize]; // copy existing frames in order for (var i = 0; i < Count; i++) { - var realIndex = (_head + i) % _buffer.Length; - newArr[i] = _buffer[realIndex]; + newArr[i] = _buffer[(_head + i) & _mask]; } _buffer = newArr; + _mask = newSize - 1; _head = 0; } + private static int RoundUpToPow2(int n) + => n <= 1 ? 1 : (int) BitOperations.RoundUpToPowerOf2((uint) n); + public Enumerator GetEnumerator() => new (this); @@ -128,13 +133,7 @@ internal Enumerator(ReplayFrameBuffer buffer) } public ReplayFrameData Current - { - get - { - var realIndex = (_buffer._head + _index) % _buffer._buffer.Length; - return _buffer._buffer[realIndex]; - } - } + => _buffer._buffer[(_buffer._head + _index) & _buffer._mask]; object IEnumerator.Current => Current; @@ -154,4 +153,3 @@ public void Dispose() } } } - diff --git a/Timer/Modules/Replay/ReplayShared.cs b/Timer/Modules/Replay/ReplayShared.cs index 7348c52..f09e2fe 100644 --- a/Timer/Modules/Replay/ReplayShared.cs +++ b/Timer/Modules/Replay/ReplayShared.cs @@ -244,12 +244,13 @@ public static ReplaySaveSnapshot CreateMainReplaySnapshot(PlayerFrameData frame) { var framesBuffer = frame.Frames; - // Inherit the old buffer's Capacity to avoid repeated exponential resizing on long maps. - // ReplayFrameBuffer doubles via EnsureCapacity when full (same as List). - // Initial capacity (Tickrate*60*5 ≈ 38400 at 128tick) only covers ~5 min; - // long maps (20+ min) would re-climb the same doubling chain without this. - var inheritedCapacity = Math.Max(framesBuffer.Capacity, TimerConstants.Tickrate * 60 * 5); - frame.Frames = new ReplayFrameBuffer(inheritedCapacity); + // Size the next buffer to ~2x the last run's frame count (or baseline, whichever is larger). + // Steady-length players skip the doubling chain; players whose run length collapses + // (one long warm-up, then short attempts) get memory back instead of holding a + // worst-case buffer forever. + var baseline = TimerConstants.Tickrate * 60 * 5; + var newCapacity = Math.Max(baseline, framesBuffer.Count * 2); + frame.Frames = new ReplayFrameBuffer(newCapacity); var header = new ReplayFileHeader { diff --git a/Timer/Modules/ReplayPlaybackModule.cs b/Timer/Modules/ReplayPlaybackModule.cs index 658885b..8cb0075 100644 --- a/Timer/Modules/ReplayPlaybackModule.cs +++ b/Timer/Modules/ReplayPlaybackModule.cs @@ -57,15 +57,15 @@ internal class ReplayPlaybackModule : IReplayPlaybackModule, private readonly IPlayerManager _playerManager; private readonly ILogger _logger; - // Replay caches - private readonly Dictionary<(int style, int track), ReplayContent> _replayCache = []; - private readonly Dictionary<(int style, int track, int stage), ReplayContent> _stageReplayCache = []; + // Replay cache: stage == 0 means main; stage >= 1 means stage replay + private readonly Dictionary<(int style, int track, int stage), ReplayContent> _replayCache = []; // Bot management private readonly bool _hasNoBotParam; private readonly List _replayBots = []; private readonly ReplayBotData?[] _replayBotBySlot; private readonly ReplayBotConfig[] _replayBotConfigs; + private readonly bool[] _configSlotInUse; // Listener hub private readonly ListenerHub _replayListenerHub; @@ -146,6 +146,7 @@ public ReplayPlaybackModule(InterfaceBridge bridge, var replayConfigPath = Path.Combine(configDir, "timer-replay.jsonc"); _replayBotConfigs = ReplayShared.LoadReplayBotConfigs(replayConfigPath, logger); + _configSlotInUse = new bool[_replayBotConfigs.Length]; ReplayShared.EnsureReplayDirectories(_replayDirectory); } @@ -224,26 +225,35 @@ public void OnMapRecordsLoaded() var stopwatch = new Stopwatch(); var wrKeys = CollectWRKeys(); - _logger.LogInformation("Found {main} main WRs and {stage} stage WRs", - wrKeys.MainKeys.Count, wrKeys.StageKeys.Count); - // Collect results into temp dictionaries to avoid writing to main-thread caches from background - var mainResults = new Dictionary<(int, int), ReplayContent>(); - var stageResults = new Dictionary<(int, int, int), ReplayContent>(); + var mainKeyCount = 0; + var stageKeyCount = 0; + + foreach (var (_, _, stage, _) in wrKeys) + { + if (stage == 0) mainKeyCount++; + else stageKeyCount++; + } + + _logger.LogInformation("Found {main} main WRs and {stage} stage WRs", mainKeyCount, stageKeyCount); + + // Collect results into a temp dictionary to avoid writing to main-thread cache from background + var results = new Dictionary<(int style, int track, int stage), ReplayContent>(); stopwatch.Start(); - LoadReplaysFromDisk(wrKeys, mainResults, stageResults, linkedToken.Token); + LoadReplaysFromDisk(wrKeys, results, linkedToken.Token); stopwatch.Stop(); _logger.LogInformation("LoadReplay (disk): {elapsed}", stopwatch.Elapsed); stopwatch.Restart(); - await LoadMissingReplaysFromRemote(wrKeys, mainResults, stageResults, linkedToken.Token); + await LoadMissingReplaysFromRemote(wrKeys, results, linkedToken.Token); stopwatch.Stop(); _logger.LogInformation("LoadReplay (remote): {elapsed}", stopwatch.Elapsed); linkedToken.Token.ThrowIfCancellationRequested(); - // Flush results to main-thread caches + // Flush results to main-thread cache, preserving any fresher in-memory replays + // (a player can finish a run while load is in-flight; that newer replay must win). await _bridge.ModSharp.InvokeFrameActionAsync(() => { if (linkedToken.IsCancellationRequested) @@ -251,20 +261,31 @@ await _bridge.ModSharp.InvokeFrameActionAsync(() => return; } - foreach (var (key, content) in mainResults) + foreach (var (key, content) in results) { - _replayCache[key] = content; - } - - foreach (var (key, content) in stageResults) - { - _stageReplayCache[key] = content; + if (_replayCache.TryGetValue(key, out var existing) + && existing.Header.Time <= content.Header.Time) + { + continue; + } + + _replayCache[key] = content; + UpdateReplayBots(key.style, key.track, key.stage); } }, linkedToken.Token); + var mainResultCount = 0; + var stageResultCount = 0; + + foreach (var ((_, _, stage), _) in results) + { + if (stage == 0) mainResultCount++; + else stageResultCount++; + } + _logger.LogInformation("Replay cache updated: {main} main, {stage} stage", - mainResults.Count, stageResults.Count); + mainResultCount, stageResultCount); } catch (OperationCanceledException) { @@ -289,8 +310,8 @@ public void OnGameShutdown() _replayBots.Clear(); _replayCache.Clear(); - _stageReplayCache.Clear(); Array.Clear(_replayBotBySlot, 0, _replayBotBySlot.Length); + Array.Clear(_configSlotInUse, 0, _configSlotInUse.Length); } public void OnClientPutInServer(PlayerSlot slot) @@ -330,6 +351,19 @@ public void OnClientPutInServer(PlayerSlot slot) return; } + var configIndex = FindFreeBotConfigSlot(); + + if (configIndex < 0) + { + _logger.LogWarning("No free replay bot config slot; kicking unexpected bot"); + + _bridge.ClientManager.KickClient(client, + "no", + NetworkDisconnectionReason.Kicked); + + return; + } + var botData = new ReplayBotData { Controller = controller, @@ -339,11 +373,13 @@ public void OnClientPutInServer(PlayerSlot slot) Client = client, Status = EReplayBotStatus.Idle, Type = EReplayBotType.Looping, - Config = _replayBotConfigs[_replayBots.Count], + Config = _replayBotConfigs[configIndex], + ConfigIndex = configIndex, }; _replayBots.Add(botData); - _replayBotBySlot[slot] = botData; + _replayBotBySlot[slot] = botData; + _configSlotInUse[configIndex] = true; if (!botData.Config.StageBot) { @@ -366,6 +402,7 @@ public void OnClientDisconnected(PlayerSlot slot) { if (_replayBots.Find(i => i.Client.Equals(client)) is { } bot) { + _configSlotInUse[bot.ConfigIndex] = false; _replayBots.Remove(bot); _replayBotBySlot[slot] = null; } @@ -373,28 +410,21 @@ public void OnClientDisconnected(PlayerSlot slot) } public bool OnNewMainReplaySaved(int style, int track, ReplayContent content, ReplaySaveContext context) - { - if (_replayCache.TryGetValue((style, track), out var existing) - && existing.Header.Time <= context.FinishTime) - { - return false; - } - - _replayCache[(style, track)] = content; - UpdateMainReplayBots(style, track); - return true; - } + => TryStoreReplay(style, track, 0, content, context); public bool OnNewStageReplaySaved(int style, int track, int stage, ReplayContent content, ReplaySaveContext context) + => TryStoreReplay(style, track, stage, content, context); + + private bool TryStoreReplay(int style, int track, int stage, ReplayContent content, ReplaySaveContext context) { - if (_stageReplayCache.TryGetValue((style, track, stage), out var existing) + if (_replayCache.TryGetValue((style, track, stage), out var existing) && existing.Header.Time <= context.FinishTime) { return false; } - _stageReplayCache[(style, track, stage)] = content; - UpdateStageReplayBots(style, track, stage); + _replayCache[(style, track, stage)] = content; + UpdateReplayBots(style, track, stage); return true; } @@ -442,7 +472,7 @@ private void Timer_CheckReplayBot() if (_bridge.ClientManager.GetClientCount(true) == 0) return; - if (_replayBots.Count == 0) + if (_replayBots.Count < _replayBotConfigs.Length) { AddReplayBot(); @@ -472,23 +502,53 @@ private void Timer_CheckReplayBot() } } + private int FindFreeBotConfigSlot() + { + for (var i = 0; i < _configSlotInUse.Length; i++) + { + if (!_configSlotInUse[i]) + { + return i; + } + } + + return -1; + } + private unsafe void AddReplayBot() { - mp_randomspawn.Set(1); + var toAdd = _replayBotConfigs.Length - _replayBots.Count; - for (var i = 0; i < _replayBotConfigs.Length; i++) + if (toAdd <= 0) { - _expectingBot = true; + return; + } + + mp_randomspawn.Set(1); - if (!CCSBotManager_BotAddCommand(0, Random.Shared.Next(2, 4), 0, 0, CStrikeWeaponType.Unknown, 0)) + try + { + for (var i = 0; i < toAdd; i++) { - _logger.LogError("Failed to add bot"); - } + _expectingBot = true; - _expectingBot = false; + try + { + if (!CCSBotManager_BotAddCommand(0, Random.Shared.Next(2, 4), 0, 0, CStrikeWeaponType.Unknown, 0)) + { + _logger.LogError("Failed to add bot"); + } + } + finally + { + _expectingBot = false; + } + } + } + finally + { + mp_randomspawn.Set(0); } - - mp_randomspawn.Set(0); } private void StartReplay(ReplayBotData bot) @@ -551,7 +611,7 @@ private void FindNextReplay(ReplayBotData bot) continue; } - if (_replayCache.TryGetValue((style, track), out var content)) + if (_replayCache.TryGetValue((style, track, 0), out var content)) { bot.Header = content.Header; bot.Frames = content.Frames; @@ -563,11 +623,8 @@ private void FindNextReplay(ReplayBotData bot) } } - if (bot.Track < 0) - { - bot.Track = 0; - bot.Style = 0; - } + // No replay matched. Keep bot in wildcard state (Track/Style at -1) + // so any newly saved replay matches via IsReplayBotMatch. } private void FindNextStageReplay(ReplayBotData bot) @@ -600,6 +657,12 @@ private void FindNextStageReplay(ReplayBotData bot) var stage = idx / (maxTrack * maxStyle); + // stage=0 entries belong to main replays in the unified cache; skip for stage bots + if (stage == 0) + { + continue; + } + var rem = idx % (maxTrack * maxStyle); var track = rem / maxStyle; @@ -616,7 +679,7 @@ private void FindNextStageReplay(ReplayBotData bot) continue; } - if (_stageReplayCache.TryGetValue((style, track, stage), out var content)) + if (_replayCache.TryGetValue((style, track, stage), out var content)) { bot.Header = content.Header; bot.Frames = content.Frames; @@ -629,11 +692,8 @@ private void FindNextStageReplay(ReplayBotData bot) } } - if (bot.Track < 0) - { - bot.Track = 0; - bot.Style = 0; - } + // No replay matched. Keep bot in wildcard state (Track/Style at -1) + // so any newly saved replay matches via IsReplayBotMatch. } private void OnPlayerProcessMovementPre(Sharp.Shared.HookParams.IPlayerProcessMoveForwardParams arg) @@ -799,67 +859,47 @@ private void SetupReplayBotName(ReplayBotData bot) bot.Client.SetName(name); } - private void UpdateMainReplayBots(int style, int track) + private void UpdateReplayBots(int style, int track, int stage) { - if (!_replayCache.TryGetValue((style, track), out var replayContent)) + if (!_replayCache.TryGetValue((style, track, stage), out var content)) { return; } foreach (var bot in _replayBots) { - if (!IsMainReplayBotMatch(bot, style, track)) + if (!IsReplayBotMatch(bot, style, track, stage)) { continue; } - bot.Frames = replayContent.Frames; - bot.Header = replayContent.Header; - StartReplay(bot); - } - } - - private void UpdateStageReplayBots(int style, int track, int stage) - { - if (!_stageReplayCache.TryGetValue((style, track, stage), out var content)) - { - return; - } + bot.Frames = content.Frames; + bot.Header = content.Header; - foreach (var bot in _replayBots) - { - if (!IsStageReplayBotMatch(bot, style, track, stage)) + if (stage > 0) { - continue; + bot.Stage = stage; } - bot.Frames = content.Frames; - bot.Header = content.Header; - bot.Stage = stage; StartReplay(bot); } } - private static bool IsMainReplayBotMatch(ReplayBotData bot, int style, int track) - => (bot.Style == style || bot.Style < 0) - && (bot.Track == track || bot.Track < 0) - && !bot.Config.StageBot; - - private static bool IsStageReplayBotMatch(ReplayBotData bot, int style, int track, int stage) - => (bot.Style == style || bot.Style < 0) - && (bot.Track == track || bot.Track < 0) - && bot.Config.StageBot - && bot.Stage == stage; + private static bool IsReplayBotMatch(ReplayBotData bot, int style, int track, int stage) + { + if ((bot.Style != style && bot.Style >= 0) || (bot.Track != track && bot.Track >= 0)) + { + return false; + } - private readonly record struct WRKeySet( - List<(int style, int track, RunRecord wr)> MainKeys, - List<(int style, int track, int stage, RunRecord wr)> StageKeys - ); + return stage == 0 + ? !bot.Config.StageBot + : bot.Config.StageBot && bot.Stage == stage; + } - private WRKeySet CollectWRKeys() + private List<(int style, int track, int stage, RunRecord wr)> CollectWRKeys() { - var mainKeys = new List<(int style, int track, RunRecord wr)>(); - var stageKeys = new List<(int style, int track, int stage, RunRecord wr)>(); + var keys = new List<(int style, int track, int stage, RunRecord wr)>(); var styleCount = _styleModule.GetStyleCount(); @@ -869,64 +909,45 @@ private WRKeySet CollectWRKeys() { if (_recordModule.GetWR(style, track) is { } wr) { - mainKeys.Add((style, track, wr)); + keys.Add((style, track, 0, wr)); } for (var stage = 1; stage < TimerConstants.MAX_STAGE; stage++) { if (_recordModule.GetWR(style, track, stage) is { } stageWr) { - stageKeys.Add((style, track, stage, stageWr)); + keys.Add((style, track, stage, stageWr)); } } } } - return new WRKeySet(mainKeys, stageKeys); + return keys; } - private void LoadReplaysFromDisk(WRKeySet wrKeys, - Dictionary<(int, int), ReplayContent> mainResults, - Dictionary<(int, int, int), ReplayContent> stageResults, + private void LoadReplaysFromDisk(List<(int style, int track, int stage, RunRecord wr)> wrKeys, + Dictionary<(int style, int track, int stage), ReplayContent> results, CancellationToken token) { var mapName = _bridge.GlobalVars.MapName; - Parallel.ForEach(wrKeys.MainKeys, - new ParallelOptions { CancellationToken = token, MaxDegreeOfParallelism = Environment.ProcessorCount }, - () => new Decompressor(), - (key, _, decompressor) => - { - var (style, track, wr) = key; - var filePath = ReplayShared.BuildMainReplayPath(_replayDirectory, mapName, style, track, wr.Id); - - if (File.Exists(filePath) - && ReplayShared.LoadReplayFromPath(filePath, style, track, 0, decompressor, _logger) is { } result) - { - lock (mainResults) - { - mainResults[(style, track)] = result.Content; - } - } - - return decompressor; - }, - decompressor => decompressor.Dispose()); - - Parallel.ForEach(wrKeys.StageKeys, + Parallel.ForEach(wrKeys, new ParallelOptions { CancellationToken = token, MaxDegreeOfParallelism = Environment.ProcessorCount }, () => new Decompressor(), (key, _, decompressor) => { var (style, track, stage, wr) = key; - var filePath = ReplayShared.BuildStageReplayPath(_replayDirectory, mapName, style, track, stage, wr.Id); + + var filePath = stage == 0 + ? ReplayShared.BuildMainReplayPath(_replayDirectory, mapName, style, track, wr.Id) + : ReplayShared.BuildStageReplayPath(_replayDirectory, mapName, style, track, stage, wr.Id); if (File.Exists(filePath) && ReplayShared.LoadReplayFromPath(filePath, style, track, stage, decompressor, _logger) is { } result) { - lock (stageResults) + lock (results) { - stageResults[(style, track, stage)] = result.Content; + results[(style, track, stage)] = result.Content; } } @@ -936,9 +957,8 @@ private void LoadReplaysFromDisk(WRKeySet wrKeys, } private async Task LoadMissingReplaysFromRemote( - WRKeySet wrKeys, - Dictionary<(int, int), ReplayContent> mainResults, - Dictionary<(int, int, int), ReplayContent> stageResults, + List<(int style, int track, int stage, RunRecord wr)> wrKeys, + Dictionary<(int style, int track, int stage), ReplayContent> results, CancellationToken token) { if (!_replayProviderProxy.IsAvailable) return; @@ -949,20 +969,12 @@ private async Task LoadMissingReplaysFromRemote( var tasks = new List(); - foreach (var (style, track, _) in wrKeys.MainKeys) + foreach (var (style, track, stage, _) in wrKeys) { token.ThrowIfCancellationRequested(); - if (mainResults.ContainsKey((style, track))) continue; - tasks.Add(LoadSingleRemoteReplay(semaphore, mapName, style, track, mainResults, token)); - } - - foreach (var (style, track, stage, _) in wrKeys.StageKeys) - { - token.ThrowIfCancellationRequested(); - - if (stageResults.ContainsKey((style, track, stage))) continue; - tasks.Add(LoadSingleRemoteStageReplay(semaphore, mapName, style, track, stage, stageResults, token)); + if (results.ContainsKey((style, track, stage))) continue; + tasks.Add(LoadSingleRemoteReplay(semaphore, mapName, style, track, stage, results, token)); } if (tasks.Count > 0) @@ -973,43 +985,8 @@ private async Task LoadMissingReplaysFromRemote( } private async Task LoadSingleRemoteReplay( - SemaphoreSlim semaphore, string mapName, int style, int track, - Dictionary<(int, int), ReplayContent> results, - CancellationToken token) - { - await semaphore.WaitAsync(token); - try - { - token.ThrowIfCancellationRequested(); - - var bytes = await _replayProviderProxy.GetReplayAsync(mapName, style, track); - - token.ThrowIfCancellationRequested(); - - if (bytes != null && ReplayShared.DeserializeReplay(bytes, style, track, 0, _logger) is { } result) - { - lock (results) - { - results[(style, track)] = result.Content; - } - } - } - catch (OperationCanceledException) - { - } - catch (Exception e) - { - _logger.LogError(e, "Failed to load remote replay for style={style} track={track}", style, track); - } - finally - { - semaphore.Release(); - } - } - - private async Task LoadSingleRemoteStageReplay( SemaphoreSlim semaphore, string mapName, int style, int track, int stage, - Dictionary<(int, int, int), ReplayContent> results, + Dictionary<(int style, int track, int stage), ReplayContent> results, CancellationToken token) { await semaphore.WaitAsync(token); @@ -1017,7 +994,9 @@ private async Task LoadSingleRemoteStageReplay( { token.ThrowIfCancellationRequested(); - var bytes = await _replayProviderProxy.GetStageReplayAsync(mapName, style, track, stage); + var bytes = stage == 0 + ? await _replayProviderProxy.GetReplayAsync(mapName, style, track) + : await _replayProviderProxy.GetStageReplayAsync(mapName, style, track, stage); token.ThrowIfCancellationRequested(); @@ -1034,7 +1013,14 @@ private async Task LoadSingleRemoteStageReplay( } catch (Exception e) { - _logger.LogError(e, "Failed to load remote stage replay for style={style} track={track} stage={stage}", style, track, stage); + if (stage == 0) + { + _logger.LogError(e, "Failed to load remote replay for style={style} track={track}", style, track); + } + else + { + _logger.LogError(e, "Failed to load remote stage replay for style={style} track={track} stage={stage}", style, track, stage); + } } finally { diff --git a/Timer/Modules/ReplayRecorderModule.cs b/Timer/Modules/ReplayRecorderModule.cs index 3f7bb67..afcbea2 100644 --- a/Timer/Modules/ReplayRecorderModule.cs +++ b/Timer/Modules/ReplayRecorderModule.cs @@ -19,6 +19,7 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.IO; +using System.Text.Json; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Sharp.Shared.Enums; @@ -83,6 +84,7 @@ internal class ReplayRecorderModule : IReplayRecorderModule, private readonly IConVar timer_replay_file_compression_level; private readonly IConVar timer_replay_file_compression_workers; private readonly IConVar timer_replay_pending_timeout; + private readonly IConVar timer_replay_fallback_ttl; // ReSharper restore InconsistentNaming @@ -131,6 +133,14 @@ public ReplayRecorderModule(InterfaceBridge bridge, 300.0f, "Timeout in seconds for pending replay before fallback save")!; + timer_replay_fallback_ttl + = bridge.ConVarManager.CreateConVar("timer_replay_fallback_ttl", + 15.0f, + 1.0f, + 1440.0f, + "Minutes a fallback replay record waits for OnRecordSaved before being discarded") + !; + _replayDirectory = Path.Combine(bridge.TimerDataPath, "replays"); } @@ -164,8 +174,12 @@ public void Shutdown() public void OnServerActivate() { - // TTL cleanup: only remove entries older than 10 minutes. - var expiryCutoff = DateTime.UtcNow.AddMinutes(-10); + // Restore any fallback records persisted from a previous session before doing TTL cleanup, + // so a late OnRecordSaved after a crash/restart can still find its .tmp. + LoadFallbackRecordsFromDisk(); + + // TTL cleanup: drop entries older than timer_replay_fallback_ttl. + var expiryCutoff = DateTime.UtcNow.AddMinutes(-timer_replay_fallback_ttl.GetFloat()); List? expiredKeys = null; foreach (var (key, record) in _fallbackRecords) @@ -181,7 +195,22 @@ public void OnServerActivate() { foreach (var key in expiredKeys) { - _fallbackRecords.Remove(key); + if (_fallbackRecords.Remove(key, out var fallback)) + { + DeleteFallbackSidecar(fallback.TempFilePath); + + try + { + if (File.Exists(fallback.TempFilePath)) + { + File.Delete(fallback.TempFilePath); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to delete expired fallback temp file: {Path}", fallback.TempFilePath); + } + } _logger.LogWarning("Removed expired fallback record on map activate for {SteamId} style={Style} track={Track} stage={Stage} attemptId={AttemptId}", key.SteamId, @@ -192,7 +221,7 @@ public void OnServerActivate() } } - // Orphaned temp file cleanup + // Orphaned temp file cleanup (>24h, no matching sidecar in-flight) try { if (Directory.Exists(_replayDirectory)) @@ -208,6 +237,7 @@ public void OnServerActivate() if (creationTime < cutoff) { File.Delete(tmpFile); + DeleteFallbackSidecar(tmpFile); _logger.LogInformation("Deleted orphaned temp replay file: {Path}", tmpFile); } } @@ -216,6 +246,27 @@ public void OnServerActivate() _logger.LogWarning(ex, "Failed to delete orphaned temp file: {Path}", tmpFile); } } + + // Orphaned sidecar cleanup (.idx without a corresponding .tmp) + foreach (var sidecarFile in Directory.GetFiles(_replayDirectory, + "*.tmp" + FallbackSidecarSuffix, + SearchOption.AllDirectories)) + { + try + { + var tmpPath = sidecarFile[..^FallbackSidecarSuffix.Length]; + + if (!File.Exists(tmpPath)) + { + File.Delete(sidecarFile); + _logger.LogInformation("Deleted orphaned fallback sidecar: {Path}", sidecarFile); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to delete orphaned sidecar: {Path}", sidecarFile); + } + } } } catch (Exception ex) @@ -427,17 +478,20 @@ public void OnPlayerStageTimerFinish(IPlayerController controller, frame.StagePostFrameTimer = _bridge.ModSharp.PushTimer(() => { + frame.StagePostFrameTimer = null; + var startTick = Math.Max(0, timerStartTick - preRunFrameLength); - CreateAndStoreStageReplay(frame, + var snapshot = ReplayShared.CreateStageReplaySnapshot(frame, startTick, timerStartTick, newStageTicks, postRunFrameLength, - finishedStage, time); + StorePendingReplay(frame, snapshot, finishedStage); + return TimerAction.Stop; }, delay, @@ -475,7 +529,9 @@ public void OnPlayerFinishMap(IPlayerController controller, } frame.StagePostFrameTimer = null; - CreateAndStorePendingReplay(slot, frame); + + var snapshot = ReplayShared.CreateMainReplaySnapshot(frame); + StorePendingReplay(frame, snapshot, stage: 0); return TimerAction.Stop; }, @@ -545,36 +601,41 @@ public void OnRecordSaved(PlayerRecordSavedEvent recordEvent) } /// - /// Creates a main replay snapshot, then either processes it directly - /// (if PendingMainRecordResult is set) or stores it in PendingReplayStore - /// with a timeout timer. + /// Validates and stores a replay snapshot. If the matching record save already + /// arrived (PendingMainRecordResult / PendingStageRecordResults), the replay is + /// processed directly. Otherwise it lands in PendingReplayStore with a timeout + /// timer that falls back to disk if the record save never arrives. /// - private void CreateAndStorePendingReplay(PlayerSlot slot, PlayerFrameData frame) + private void StorePendingReplay(PlayerFrameData frame, ReplaySaveSnapshot snapshot, int stage) { - var style = frame.Style; - var track = frame.Track; - - var snapshot = ReplayShared.CreateMainReplaySnapshot(frame); + var style = frame.Style; + var track = frame.Track; + var isStage = stage > 0; if (snapshot.Frames.Count < MinValidFrames) { - _logger.LogDebug("Discarding main replay snapshot for {SteamId} style={Style} track={Track}: " - + "only {FrameCount} frames (min {MinFrames})", - frame.SteamId, - style, - track, - snapshot.Frames.Count, - MinValidFrames); + if (isStage) + { + _logger.LogDebug("Discarding stage replay snapshot for {SteamId} style={Style} track={Track} stage={Stage}: " + + "only {FrameCount} frames (min {MinFrames})", + frame.SteamId, style, track, stage, snapshot.Frames.Count, MinValidFrames); + } + else + { + _logger.LogDebug("Discarding main replay snapshot for {SteamId} style={Style} track={Track}: " + + "only {FrameCount} frames (min {MinFrames})", + frame.SteamId, style, track, snapshot.Frames.Count, MinValidFrames); + } return; } // Temp file in same directory as final → atomic same-partition File.Move - var tempPath = ReplayShared.BuildMainReplayPath(_replayDirectory, - _bridge.GlobalVars.MapName, - style, - track, - null); + var mapName = _bridge.GlobalVars.MapName; + + var tempPath = isStage + ? ReplayShared.BuildStageReplayPath(_replayDirectory, mapName, style, track, stage, null) + : ReplayShared.BuildMainReplayPath(_replayDirectory, mapName, style, track, null); tempPath = Path.ChangeExtension(tempPath, ".tmp"); @@ -584,113 +645,27 @@ private void CreateAndStorePendingReplay(PlayerSlot slot, PlayerFrameData frame) frame.SteamId.AsPrimitive(), style, track, - 0, + stage, frame.AttemptId); // Record arrived before post-frame ended — process directly. - if (frame.PendingMainRecordResult is { } pendingResult) - { - frame.PendingMainRecordResult = null; - - var pending = new PendingReplay { Snapshot = snapshot, TempFilePath = tempPath }; - - ProcessPendingReplay(pending, key, pendingResult.RunId, pendingResult.RecordEvent); - - return; - } + PendingRecordResult? pendingResult; - var pendingReplay = new PendingReplay { Snapshot = snapshot, TempFilePath = tempPath }; - - var replaced = _pendingReplayStore.Add(key, pendingReplay); - - if (replaced is not null) + if (isStage) { - _logger.LogWarning("Replaced existing PendingReplay for key {Key}", key); - SavePendingReplayAsFallback(key, replaced); + frame.PendingStageRecordResults.Remove(stage, out pendingResult); } - - var timeoutSeconds = timer_replay_pending_timeout.GetFloat(); - var capturedKey = key; - - var timerId = _bridge.ModSharp.PushTimer(() => - { - var timedOut = _pendingReplayStore.TakeMatch(capturedKey); - - if (timedOut is not null) - { - timedOut.TimeoutTimerId = null; - - _logger - .LogWarning("Pending replay timed out for {SteamId} style={Style} track={Track} attemptId={AttemptId}", - capturedKey.SteamId, - capturedKey.Style, - capturedKey.Track, - capturedKey.AttemptId); - - SavePendingReplayAsFallback(capturedKey, timedOut); - } - - return TimerAction.Stop; - }, - timeoutSeconds, - GameTimerFlags.StopOnMapEnd); - - pendingReplay.TimeoutTimerId = timerId; - } - - private void CreateAndStoreStageReplay(PlayerFrameData frame, - int startTick, - int stageStartFrame, - int stageFinishFrame, - int postRunFrameCount, - int stage, - float finishTime) - { - frame.StagePostFrameTimer = null; - - var style = frame.Style; - var track = frame.Track; - - var snapshot = ReplayShared.CreateStageReplaySnapshot(frame, - startTick, - stageStartFrame, - stageFinishFrame, - postRunFrameCount, - finishTime); - - if (snapshot.Frames.Count < MinValidFrames) + else { - _logger.LogDebug("Discarding stage replay snapshot for {SteamId} style={Style} track={Track} stage={Stage}: " - + "only {FrameCount} frames (min {MinFrames})", - frame.SteamId, - style, - track, - stage, - snapshot.Frames.Count, - MinValidFrames); + pendingResult = frame.PendingMainRecordResult; - return; + if (pendingResult is not null) + { + frame.PendingMainRecordResult = null; + } } - var tempPath = ReplayShared.BuildStageReplayPath(_replayDirectory, - _bridge.GlobalVars.MapName, - style, - track, - stage, - null); - - tempPath = Path.ChangeExtension(tempPath, ".tmp"); - - var mapId = _mapInfoModule.GetCurrentMapProfile().MapId; - - var key = new ReplayMatchKey(mapId, - frame.SteamId.AsPrimitive(), - style, - track, - stage, - frame.AttemptId); - - if (frame.PendingStageRecordResults.TryGetValue(stage, out var pendingResult)) + if (pendingResult is not null) { var pending = new PendingReplay { Snapshot = snapshot, TempFilePath = tempPath }; @@ -705,21 +680,29 @@ private void CreateAndStoreStageReplay(PlayerFrameData frame, if (replaced is not null) { - _logger.LogWarning("Replaced existing stage PendingReplay for key {Key}", key); + _logger.LogWarning(isStage + ? "Replaced existing stage PendingReplay for key {Key}" + : "Replaced existing PendingReplay for key {Key}", + key); + SavePendingReplayAsFallback(key, replaced); } var timeoutSeconds = timer_replay_pending_timeout.GetFloat(); - var capturedKey = key; + var capturedKey = key; var timerId = _bridge.ModSharp.PushTimer(() => { var timedOut = _pendingReplayStore.TakeMatch(capturedKey); - if (timedOut is not null) - { - timedOut.TimeoutTimerId = null; + if (timedOut is null) + return TimerAction.Stop; + + timedOut.TimeoutTimerId = null; #if DEBUG + + if (isStage) + { _logger .LogWarning("Pending stage replay timed out for {SteamId} style={Style} track={Track} stage={Stage} attemptId={AttemptId}", capturedKey.SteamId, @@ -727,9 +710,19 @@ private void CreateAndStoreStageReplay(PlayerFrameData frame, capturedKey.Track, capturedKey.Stage, capturedKey.AttemptId); -#endif - SavePendingReplayAsFallback(capturedKey, timedOut); } + else + { + _logger + .LogWarning("Pending replay timed out for {SteamId} style={Style} track={Track} attemptId={AttemptId}", + capturedKey.SteamId, + capturedKey.Style, + capturedKey.Track, + capturedKey.AttemptId); + } + +#endif + SavePendingReplayAsFallback(capturedKey, timedOut); return TimerAction.Stop; }, @@ -934,6 +927,12 @@ private void SavePendingReplayAsFallback(ReplayMatchKey key, PendingReplay pendi var compressionLevel = timer_replay_file_compression_level.GetInt32(); var compressionWorkers = timer_replay_file_compression_workers.GetInt32(); + var createdAt = DateTime.UtcNow; + + // Write the sidecar synchronously so a late OnRecordSaved after a process restart + // can reassociate the .tmp file with this ReplayMatchKey. + WriteFallbackSidecar(tempPath, key, createdAt); + var writeTask = Task.Run(async () => { try @@ -990,7 +989,7 @@ private void SavePendingReplayAsFallback(ReplayMatchKey key, PendingReplay pendi { TempFilePath = tempPath, WriteTask = writeTask, - CreatedAt = DateTime.UtcNow, + CreatedAt = createdAt, }; #if DEBUG @@ -1003,8 +1002,9 @@ private void SavePendingReplayAsFallback(ReplayMatchKey key, PendingReplay pendi tempPath); #endif - // Expire old entries (> 10 min) - var expiryCutoff = DateTime.UtcNow.AddMinutes(-10); + // Expire old entries (> timer_replay_fallback_ttl) + var ttlMinutes = timer_replay_fallback_ttl.GetFloat(); + var expiryCutoff = DateTime.UtcNow.AddMinutes(-ttlMinutes); List? expiredKeys = null; foreach (var (fbKey, fbRecord) in _fallbackRecords) @@ -1020,14 +1020,18 @@ private void SavePendingReplayAsFallback(ReplayMatchKey key, PendingReplay pendi { foreach (var expiredKey in expiredKeys) { - _fallbackRecords.Remove(expiredKey); + if (_fallbackRecords.Remove(expiredKey, out var expiredRecord)) + { + DeleteFallbackSidecar(expiredRecord.TempFilePath); + } - _logger.LogWarning("Removed expired fallback record for {SteamId} style={Style} track={Track} stage={Stage} attemptId={AttemptId} (older than 10 minutes)", + _logger.LogWarning("Removed expired fallback record for {SteamId} style={Style} track={Track} stage={Stage} attemptId={AttemptId} (older than {Ttl}s)", expiredKey.SteamId, expiredKey.Style, expiredKey.Track, expiredKey.Stage, - expiredKey.AttemptId); + expiredKey.AttemptId, + ttlMinutes); } } } @@ -1115,6 +1119,8 @@ await RetryOnIOException(() => logger, "File.Move", tempPath).ConfigureAwait(false); + + DeleteFallbackSidecar(tempPath); } catch (IOException ex) { @@ -1157,22 +1163,25 @@ await RetryOnIOException(() => AttemptResult = attemptResult, }; + ReplayContent? deserialized = null; + + if (replayBytes is not null + && ReplayShared.DeserializeReplay(replayBytes, style, track, stage, logger) is { } loaded) + { + deserialized = loaded.Content; + } + var isNewBest = false; - await bridge.ModSharp.InvokeFrameActionAsync(() => + if (deserialized is { } content) { - if (replayBytes is not null) + await bridge.ModSharp.InvokeFrameActionAsync(() => { - var loadResult = ReplayShared.DeserializeReplay(replayBytes, style, track, stage, logger); - - if (loadResult is { } loaded) - { - isNewBest = stage == 0 - ? playbackModule.OnNewMainReplaySaved(style, track, loaded.Content, context) - : playbackModule.OnNewStageReplaySaved(style, track, stage, loaded.Content, context); - } - } - }).ConfigureAwait(false); + isNewBest = stage == 0 + ? playbackModule.OnNewMainReplaySaved(style, track, content, context) + : playbackModule.OnNewStageReplaySaved(style, track, stage, content, context); + }).ConfigureAwait(false); + } var providerReady = replayProviderProxy.IsAvailable; var uploadNonPB = replayProviderProxy.UploadNonPersonalBest; @@ -1266,7 +1275,18 @@ await replayProviderProxy.UploadStageReplayAsync(mapName, }); } - private static async Task RetryOnIOException(Func action, ILogger logger, string operationName, string path) + private static Task RetryOnIOException(Func action, ILogger logger, string operationName, string path) + => RetryOnIOException(async () => + { + await action().ConfigureAwait(false); + + return null; + }, + logger, + operationName, + path); + + private static async Task RetryOnIOException(Func> action, ILogger logger, string operationName, string path) { const int maxRetries = 3; const int delayMs = 100; @@ -1275,9 +1295,7 @@ private static async Task RetryOnIOException(Func action, ILogger logger, { try { - await action().ConfigureAwait(false); - - return; + return await action().ConfigureAwait(false); } catch (IOException) when (attempt < maxRetries) { @@ -1291,34 +1309,134 @@ private static async Task RetryOnIOException(Func action, ILogger logger, await Task.Delay(delayMs).ConfigureAwait(false); } } + + // This should never be reached due to the when clause, but satisfies the compiler. + throw new InvalidOperationException("Unreachable"); } - private static async Task RetryOnIOException(Func> action, ILogger logger, string operationName, string path) + private const string FallbackSidecarSuffix = ".idx"; + + private sealed class FallbackSidecarDto { - const int maxRetries = 3; - const int delayMs = 100; + public ulong MapId { get; set; } + public ulong SteamId { get; set; } + public int Style { get; set; } + public int Track { get; set; } + public int Stage { get; set; } + public int AttemptId { get; set; } + public DateTime CreatedAt { get; set; } + } - for (var attempt = 0; attempt <= maxRetries; attempt++) + private void WriteFallbackSidecar(string tempPath, ReplayMatchKey key, DateTime createdAt) + { + var dto = new FallbackSidecarDto { - try + MapId = key.MapId, + SteamId = key.SteamId, + Style = key.Style, + Track = key.Track, + Stage = key.Stage, + AttemptId = key.AttemptId, + CreatedAt = createdAt, + }; + + try + { + File.WriteAllText(tempPath + FallbackSidecarSuffix, JsonSerializer.Serialize(dto)); + } + catch (Exception e) + { + _logger.LogWarning(e, "Failed to write fallback sidecar at {Path}", tempPath + FallbackSidecarSuffix); + } + } + + private void DeleteFallbackSidecar(string tempPath) + { + var sidecarPath = tempPath + FallbackSidecarSuffix; + + try + { + if (File.Exists(sidecarPath)) { - return await action().ConfigureAwait(false); + File.Delete(sidecarPath); } - catch (IOException) when (attempt < maxRetries) + } + catch (Exception e) + { + _logger.LogWarning(e, "Failed to delete fallback sidecar at {Path}", sidecarPath); + } + } + + private void LoadFallbackRecordsFromDisk() + { + if (!Directory.Exists(_replayDirectory)) + { + return; + } + + var ttlMinutes = timer_replay_fallback_ttl.GetFloat(); + var cutoff = DateTime.UtcNow.AddMinutes(-ttlMinutes); + var loaded = 0; + + foreach (var sidecarPath in Directory.GetFiles(_replayDirectory, + "*.tmp" + FallbackSidecarSuffix, + SearchOption.AllDirectories)) + { + var tempPath = sidecarPath[..^FallbackSidecarSuffix.Length]; + + try { - logger.LogWarning("{Operation} failed on attempt {Attempt}/{MaxRetries} for {Path}, retrying in {Delay}ms", - operationName, - attempt + 1, - maxRetries, - path, - delayMs); + var json = File.ReadAllText(sidecarPath); + var dto = JsonSerializer.Deserialize(json); - await Task.Delay(delayMs).ConfigureAwait(false); + if (dto is null || !File.Exists(tempPath) || dto.CreatedAt < cutoff) + { + TryDelete(sidecarPath); + TryDelete(tempPath); + + continue; + } + + var key = new ReplayMatchKey(dto.MapId, dto.SteamId, dto.Style, dto.Track, dto.Stage, dto.AttemptId); + + _fallbackRecords[key] = new FallbackReplayRecord + { + TempFilePath = tempPath, + WriteTask = Task.CompletedTask, + CreatedAt = dto.CreatedAt, + }; + + loaded++; + } + catch (Exception e) + { + _logger.LogWarning(e, "Failed to load fallback sidecar at {Path}; deleting", sidecarPath); + TryDelete(sidecarPath); + TryDelete(tempPath); } } - // This should never be reached due to the when clause, but satisfies the compiler. - throw new InvalidOperationException("Unreachable"); + if (loaded > 0) + { + _logger.LogInformation("Restored {Count} fallback replay records from disk", loaded); + } + + return; + + void TryDelete(string path) + { + try + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + catch (Exception e) + { + _logger.LogWarning(e, "Failed to delete stale file at {Path}", path); + } + } } private void OnPlayerRunCommandPost(IPlayerRunCommandHookParams arg, HookReturnValue hook) From c7bea594f670a27b537c804f8b49c0397ea25c4b Mon Sep 17 00:00:00 2001 From: Nuko Date: Sun, 17 May 2026 22:18:47 +0800 Subject: [PATCH 2/2] code cleanup --- Timer.Shared/Models/Zone/EZoneType.cs | 13 +- Timer/Modules/Replay/PlayerFrameData.cs | 3 +- Timer/Modules/Replay/ReplayFrameBuffer.cs | 155 ---------------------- Timer/Modules/Replay/ReplayShared.cs | 4 +- Timer/Modules/ReplayRecorderModule.cs | 19 +-- Timer/Modules/Timer/Movement.cs | 50 ++++--- Timer/Modules/ZoneModule.cs | 154 ++++++++++++++------- 7 files changed, 160 insertions(+), 238 deletions(-) delete mode 100644 Timer/Modules/Replay/ReplayFrameBuffer.cs diff --git a/Timer.Shared/Models/Zone/EZoneType.cs b/Timer.Shared/Models/Zone/EZoneType.cs index 62a418b..41c0f14 100644 --- a/Timer.Shared/Models/Zone/EZoneType.cs +++ b/Timer.Shared/Models/Zone/EZoneType.cs @@ -19,10 +19,11 @@ namespace Source2Surf.Timer.Shared.Models.Zone; public enum EZoneType : sbyte { - Invalid = -1, - Start = 0, - End = 1, - Stage = 2, - Checkpoint = 3, - StopTimer = 4, + Invalid = -1, + Start, + End, + Stage, + Checkpoint, + StopTimer, + Max, } diff --git a/Timer/Modules/Replay/PlayerFrameData.cs b/Timer/Modules/Replay/PlayerFrameData.cs index fd6f113..5a7fc24 100644 --- a/Timer/Modules/Replay/PlayerFrameData.cs +++ b/Timer/Modules/Replay/PlayerFrameData.cs @@ -18,6 +18,7 @@ using System; using System.Collections.Generic; using Sharp.Shared.Units; +using Source2Surf.Timer.Shared.Models.Replay; namespace Source2Surf.Timer.Modules.Replay; @@ -37,7 +38,7 @@ internal class PlayerFrameData public bool GrabbingPostFrame { get; set; } = false; - public ReplayFrameBuffer Frames { get; set; } = []; + public List Frames { get; set; } = []; public int AttemptId { get; set; } diff --git a/Timer/Modules/Replay/ReplayFrameBuffer.cs b/Timer/Modules/Replay/ReplayFrameBuffer.cs deleted file mode 100644 index 2ad720f..0000000 --- a/Timer/Modules/Replay/ReplayFrameBuffer.cs +++ /dev/null @@ -1,155 +0,0 @@ -/* - * Source2Surf/Timer - * Copyright (C) 2025 Nukoooo and Kxnrl - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -using System; -using System.Collections; -using System.Collections.Generic; -using System.Numerics; -using Source2Surf.Timer.Shared.Models.Replay; - -namespace Source2Surf.Timer.Modules.Replay; - -/// -/// Simple ring buffer to avoid front-shifting allocations when trimming pre-run frames. -/// Capacity is always a power of two so the wrap can use a bitmask instead of modulo. -/// -internal sealed class ReplayFrameBuffer : IReadOnlyList -{ - private ReplayFrameData[] _buffer; - private int _head; - private int _mask; - - public ReplayFrameBuffer(int capacity = 64) - { - var size = RoundUpToPow2(capacity > 0 ? capacity : 1); - _buffer = new ReplayFrameData[size]; - _mask = size - 1; - } - - public int Count { get; private set; } - - public int Capacity => _buffer.Length; - - public ReplayFrameData this[int index] - { - get - { - if ((uint) index >= (uint) Count) - { - throw new ArgumentOutOfRangeException(nameof(index)); - } - - return _buffer[(_head + index) & _mask]; - } - } - - public void Add(in ReplayFrameData frame) - { - EnsureCapacity(Count + 1); - - _buffer[(_head + Count) & _mask] = frame; - Count++; - } - - public void RemoveOldest(int removeCount) - { - if (removeCount <= 0 || Count == 0) - { - return; - } - - if (removeCount >= Count) - { - Clear(); - return; - } - - _head = (_head + removeCount) & _mask; - Count -= removeCount; - } - - public void Clear() - { - _head = 0; - Count = 0; - } - - public void EnsureCapacity(int capacity) - { - if (capacity <= _buffer.Length) - { - return; - } - - var newSize = RoundUpToPow2(Math.Max(capacity, _buffer.Length * 2)); - var newArr = new ReplayFrameData[newSize]; - - // copy existing frames in order - for (var i = 0; i < Count; i++) - { - newArr[i] = _buffer[(_head + i) & _mask]; - } - - _buffer = newArr; - _mask = newSize - 1; - _head = 0; - } - - private static int RoundUpToPow2(int n) - => n <= 1 ? 1 : (int) BitOperations.RoundUpToPowerOf2((uint) n); - - public Enumerator GetEnumerator() - => new (this); - - IEnumerator IEnumerable.GetEnumerator() - => GetEnumerator(); - - IEnumerator IEnumerable.GetEnumerator() - => GetEnumerator(); - - public struct Enumerator : IEnumerator - { - private readonly ReplayFrameBuffer _buffer; - private int _index; - - internal Enumerator(ReplayFrameBuffer buffer) - { - _buffer = buffer; - _index = -1; - } - - public ReplayFrameData Current - => _buffer._buffer[(_buffer._head + _index) & _buffer._mask]; - - object IEnumerator.Current => Current; - - public bool MoveNext() - { - _index++; - return _index < _buffer.Count; - } - - public void Reset() - { - _index = -1; - } - - public void Dispose() - { - } - } -} diff --git a/Timer/Modules/Replay/ReplayShared.cs b/Timer/Modules/Replay/ReplayShared.cs index f09e2fe..8d4255a 100644 --- a/Timer/Modules/Replay/ReplayShared.cs +++ b/Timer/Modules/Replay/ReplayShared.cs @@ -250,7 +250,7 @@ public static ReplaySaveSnapshot CreateMainReplaySnapshot(PlayerFrameData frame) // worst-case buffer forever. var baseline = TimerConstants.Tickrate * 60 * 5; var newCapacity = Math.Max(baseline, framesBuffer.Count * 2); - frame.Frames = new ReplayFrameBuffer(newCapacity); + frame.Frames = new List(newCapacity); var header = new ReplayFileHeader { @@ -319,7 +319,7 @@ public static void TrimPreRunFrames(PlayerFrameData frameData, int maxPreFrame) if (excess > 0) { - frameData.Frames.RemoveOldest(excess); + frameData.Frames.RemoveRange(0, excess); } } diff --git a/Timer/Modules/ReplayRecorderModule.cs b/Timer/Modules/ReplayRecorderModule.cs index afcbea2..ae7859f 100644 --- a/Timer/Modules/ReplayRecorderModule.cs +++ b/Timer/Modules/ReplayRecorderModule.cs @@ -304,7 +304,9 @@ public void OnClientPutInServer(PlayerSlot slot) var data = new PlayerFrameData { - Frames = new ReplayFrameBuffer(TimerConstants.Tickrate * 60 * 5), SteamId = client.SteamId, Name = client.Name, + Frames = new List(TimerConstants.Tickrate * 60 * 5), + SteamId = client.SteamId, + Name = client.Name, }; _playerFrameData[slot] = data; @@ -1310,7 +1312,6 @@ private static async Task RetryOnIOException(Func> action, ILogger } } - // This should never be reached due to the when clause, but satisfies the compiler. throw new InvalidOperationException("Unreachable"); } @@ -1318,13 +1319,13 @@ private static async Task RetryOnIOException(Func> action, ILogger private sealed class FallbackSidecarDto { - public ulong MapId { get; set; } - public ulong SteamId { get; set; } - public int Style { get; set; } - public int Track { get; set; } - public int Stage { get; set; } - public int AttemptId { get; set; } - public DateTime CreatedAt { get; set; } + public ulong MapId { get; init; } + public ulong SteamId { get; init; } + public int Style { get; init; } + public int Track { get; init; } + public int Stage { get; init; } + public int AttemptId { get; init; } + public DateTime CreatedAt { get; init; } } private void WriteFallbackSidecar(string tempPath, ReplayMatchKey key, DateTime createdAt) diff --git a/Timer/Modules/Timer/Movement.cs b/Timer/Modules/Timer/Movement.cs index 2be57e2..457a993 100644 --- a/Timer/Modules/Timer/Movement.cs +++ b/Timer/Modules/Timer/Movement.cs @@ -139,37 +139,53 @@ private void OnPlayerRunCommandPost(IPlayerRunCommandHookParams arg, HookReturnV return; } - if (_timerInfo[client.Slot] is not { } timerInfo || _stageTimerInfo[client.Slot] is not { } stageTimer) + var slot = client.Slot; + + if (_timerInfo[slot] is not { } timerInfo || _stageTimerInfo[slot] is not { } stageTimer) + { + return; + } + + var mainRunning = timerInfo.IsTimerRunning(); + var stageRunning = stageTimer.IsTimerRunning(); + + if (!mainRunning && !stageRunning) { return; } + var service = arg.Service; + var origin = pawn.GetAbsOrigin(); var angles = pawn.GetEyeAngles(); var velocity = pawn.GetAbsVelocity(); - var service = arg.Service; + var onGround = pawn.GroundEntityHandle.IsValid(); - var leftmove = service.GetNetVar("m_flLeftMove"); + var isSurfing = false; - var collision = pawn.GetCollisionProperty()!; + if (!onGround) + { + var hull = service.GetNetVar("m_bDucked") ? DuckedHull : StandingHull; - var hull = service.GetNetVar("m_bDucked") ? DuckedHull : StandingHull; + var collision = pawn.GetCollisionProperty()!; - var origin = pawn.GetAbsOrigin(); - var end = origin; - end.Z -= 54; + var end = origin; + end.Z -= 54; - var attribute = RnQueryShapeAttr.PlayerMovement(collision.CollisionAttribute.InteractsWith); - attribute.SetEntityToIgnore(pawn, 0); + var attribute = RnQueryShapeAttr.PlayerMovement(collision.CollisionAttribute.InteractsWith); + attribute.SetEntityToIgnore(pawn, 0); - var result = _bridge.PhysicsQueryManager.TraceShapePlayerMovement(new (hull), - origin, - end, - attribute); + var result = _bridge.PhysicsQueryManager.TraceShapePlayerMovement(new (hull), + origin, + end, + attribute); - var isSurfing = result.DidHit() && Math.Abs(result.PlaneNormal.Z) < sv_standable_normal.GetFloat(); + isSurfing = result.DidHit() && Math.Abs(result.PlaneNormal.Z) < sv_standable_normal.GetFloat(); + } + + var leftmove = service.GetNetVar("m_flLeftMove"); - if (timerInfo.IsTimerRunning()) + if (mainRunning) { timerInfo.TimerTick++; @@ -196,7 +212,7 @@ private void OnPlayerRunCommandPost(IPlayerRunCommandHookParams arg, HookReturnV timerInfo.LastYaw = angles.Y; } - if (stageTimer.IsTimerRunning()) + if (stageRunning) { stageTimer.TimerTick++; diff --git a/Timer/Modules/ZoneModule.cs b/Timer/Modules/ZoneModule.cs index 28e5ea2..1e4e8b6 100644 --- a/Timer/Modules/ZoneModule.cs +++ b/Timer/Modules/ZoneModule.cs @@ -79,6 +79,11 @@ internal partial class ZoneModule : IModule, IZoneModule, IEntityListener, IGame private readonly Dictionary _zones = []; private readonly BuildZoneInfo?[] _buildZoneInfo; + private const int ZoneTypeCount = (int) EZoneType.Max; + + private readonly List[,] _zonesByTrackType + = new List[TimerConstants.MAX_TRACK, ZoneTypeCount]; + // ReSharper disable InconsistentNaming private unsafe delegate* unmanaged CreateTrigger; @@ -95,6 +100,14 @@ public ZoneModule(InterfaceBridge bridge, _commandManager = commandManager; _requestManager = requestManager; _buildZoneInfo = new BuildZoneInfo?[PlayerSlot.MaxPlayerCount]; + + for (var t = 0; t < TimerConstants.MAX_TRACK; t++) + { + for (var z = 0; z < ZoneTypeCount; z++) + { + _zonesByTrackType[t, z] = []; + } + } } public void OnEntitySpawned(IBaseEntity entity) @@ -161,6 +174,8 @@ public void OnEntityDeleted(IBaseEntity entity) if (!_zones.Remove(entity.Handle.GetValue(), out var info)) return; + UnindexZone(info); + #if DEBUG _logger.LogWarning("Removed zone {t}", info.TargetName); #endif @@ -246,6 +261,14 @@ public void OnGameShutdown() } _zones.Clear(); + + for (var t = 0; t < TimerConstants.MAX_TRACK; t++) + { + for (var z = 0; z < ZoneTypeCount; z++) + { + _zonesByTrackType[t, z].Clear(); + } + } } public void OnServerActivate() @@ -395,7 +418,10 @@ public unsafe void AddZone(ZoneInfo info) info.Origin = origin; info.Index = ent.Index; - _zones.TryAdd(ent.Handle.GetValue(), info); + if (_zones.TryAdd(ent.Handle.GetValue(), info)) + { + IndexZone(info); + } if (info.ZoneType is EZoneType.Start or EZoneType.End) { @@ -405,26 +431,28 @@ public unsafe void AddZone(ZoneInfo info) public bool TeleportToZone(IPlayerPawn pawn, int track, EZoneType type) { - foreach (var (_, zoneInfo) in _zones) + if (GetZoneBucket(track, type) is not { Count: > 0 } bucket) { - if (zoneInfo.Track != track || type != zoneInfo.ZoneType) - { - continue; - } + return false; + } - pawn.Teleport(zoneInfo.TeleportOrigin ?? zoneInfo.Origin, null, new Vector()); + var zoneInfo = bucket[0]; - return true; - } + pawn.Teleport(zoneInfo.TeleportOrigin ?? zoneInfo.Origin, null, new Vector()); - return false; + return true; } public bool TeleportToStage(IPlayerPawn pawn, int track, int stage) { - foreach (var (_, zoneInfo) in _zones) + if (GetZoneBucket(track, EZoneType.Stage) is not { Count: > 0 } bucket) + { + return false; + } + + foreach (var zoneInfo in bucket) { - if (zoneInfo.Track != track || zoneInfo.ZoneType != EZoneType.Stage || zoneInfo.Data != stage) + if (zoneInfo.Data != stage) { continue; } @@ -441,47 +469,28 @@ public bool IsCurrentTrackLinear(int track) => _currentMaxStages[track] <= 1; public bool HasZone(int track, EZoneType type) - { - foreach (var (_, info) in _zones) - { - if (info.Track == track && info.ZoneType == type) - { - return true; - } - } - - return false; - } + => GetZoneBucket(track, type) is { Count: > 0 }; public bool CurrentTrackHasCheckpoints(int track) - { - foreach (var (_, info) in _zones) - { - if (info.Track == track && info.ZoneType == EZoneType.Checkpoint) - { - return true; - } - } - - return false; - } + => GetZoneBucket(track, EZoneType.Checkpoint) is { Count: > 0 }; public int GetTotalStages(int track) => _currentMaxStages[track]; public int GetCurrentTrackCheckpointCount(int track) + => GetZoneBucket(track, EZoneType.Checkpoint)?.Count ?? 0; + + private List? GetZoneBucket(int track, EZoneType type) { - var count = 0; + var typeIndex = (int) type; - foreach (var (_, info) in _zones) + if ((uint) track >= TimerConstants.MAX_TRACK + || (uint) typeIndex >= ZoneTypeCount) { - if (info.Track == track && info.ZoneType == EZoneType.Checkpoint) - { - count++; - } + return null; } - return count; + return _zonesByTrackType[track, typeIndex]; } private bool AddPrebuiltZone(IBaseEntity entity, string targetName, EZoneType type) @@ -518,7 +527,7 @@ private bool AddPrebuiltZone(IBaseEntity entity, string targetName, EZoneType ty { if (!ZoneMatcher.IsBonusStartZone(targetName, out track)) { - return ZoneMatcher.IsStartZone(targetName) && _zones.TryAdd(handle, info); + return ZoneMatcher.IsStartZone(targetName) && TryAddZoneToIndex(handle, info); } if (_currentMaxStages[track] < 1) @@ -528,18 +537,18 @@ private bool AddPrebuiltZone(IBaseEntity entity, string targetName, EZoneType ty info.Track = track; - return _zones.TryAdd(handle, info); + return TryAddZoneToIndex(handle, info); } case EZoneType.End: { if (!ZoneMatcher.IsBonusEndZone(targetName, out track)) { - return ZoneMatcher.IsEndZone(targetName) && _zones.TryAdd(handle, info); + return ZoneMatcher.IsEndZone(targetName) && TryAddZoneToIndex(handle, info); } info.Track = track; - return _zones.TryAdd(handle, info); + return TryAddZoneToIndex(handle, info); } case EZoneType.Stage: { @@ -555,7 +564,7 @@ private bool AddPrebuiltZone(IBaseEntity entity, string targetName, EZoneType ty info.Data = stage; - return _zones.TryAdd(handle, info); + return TryAddZoneToIndex(handle, info); } case EZoneType.Checkpoint: { @@ -563,7 +572,7 @@ private bool AddPrebuiltZone(IBaseEntity entity, string targetName, EZoneType ty { info.Data = cp; - return _zones.TryAdd(handle, info); + return TryAddZoneToIndex(handle, info); } if (ZoneMatcher.IsBonusCheckpointZone(targetName, out var bonusTrack, out cp)) @@ -571,7 +580,7 @@ private bool AddPrebuiltZone(IBaseEntity entity, string targetName, EZoneType ty info.Track = bonusTrack; info.Data = cp; - return _zones.TryAdd(handle, info); + return TryAddZoneToIndex(handle, info); } break; @@ -581,6 +590,55 @@ private bool AddPrebuiltZone(IBaseEntity entity, string targetName, EZoneType ty return false; } + private bool TryAddZoneToIndex(uint handle, ZoneInfo info) + { + if (!_zones.TryAdd(handle, info)) + { + return false; + } + + IndexZone(info); + return true; + } + + private void IndexZone(ZoneInfo info) + { + var typeIndex = (int) info.ZoneType; + + if (typeIndex is < 0 or >= ZoneTypeCount) + { + return; + } + + var track = info.Track; + + if ((uint) track >= TimerConstants.MAX_TRACK) + { + return; + } + + _zonesByTrackType[track, typeIndex].Add(info); + } + + private void UnindexZone(ZoneInfo info) + { + var typeIndex = (int) info.ZoneType; + + if (typeIndex is < 0 or >= ZoneTypeCount) + { + return; + } + + var track = info.Track; + + if ((uint) track >= TimerConstants.MAX_TRACK) + { + return; + } + + _zonesByTrackType[track, typeIndex].Remove(info); + } + private void CreateBeam(uint handle) { if (!_zones.TryGetValue(handle, out var val))