using System; using System.IO; using LightlessSync.API.Data; using LightlessSync.API.Data.Extensions; using LightlessSync.FileCache; using LightlessSync.LightlessConfiguration; using LightlessSync.PlayerData.Pairs; using LightlessSync.Services.Events; using LightlessSync.Services.Mediator; using LightlessSync.Services.TextureCompression; using LightlessSync.UI; using LightlessSync.WebAPI.Files.Models; using Microsoft.Extensions.Logging; namespace LightlessSync.Services; public class PlayerPerformanceService { private readonly FileCacheManager _fileCacheManager; private readonly XivDataAnalyzer _xivDataAnalyzer; private readonly ILogger _logger; private readonly LightlessMediator _mediator; private readonly PlayerPerformanceConfigService _playerPerformanceConfigService; private readonly TextureDownscaleService _textureDownscaleService; private readonly Dictionary _warnedForPlayers = new(StringComparer.Ordinal); public PlayerPerformanceService(ILogger logger, LightlessMediator mediator, PlayerPerformanceConfigService playerPerformanceConfigService, FileCacheManager fileCacheManager, XivDataAnalyzer xivDataAnalyzer, TextureDownscaleService textureDownscaleService) { _logger = logger; _mediator = mediator; _playerPerformanceConfigService = playerPerformanceConfigService; _fileCacheManager = fileCacheManager; _xivDataAnalyzer = xivDataAnalyzer; _textureDownscaleService = textureDownscaleService; } public async Task CheckBothThresholds(IPairPerformanceSubject pairHandler, CharacterData charaData) { var config = _playerPerformanceConfigService.Current; bool notPausedAfterVram = ComputeAndAutoPauseOnVRAMUsageThresholds(pairHandler, charaData, []); if (!notPausedAfterVram) return false; bool notPausedAfterTris = await CheckTriangleUsageThresholds(pairHandler, charaData).ConfigureAwait(false); if (!notPausedAfterTris) return false; if (config.UIDsToIgnore .Exists(uid => string.Equals(uid, pairHandler.UserData.Alias, StringComparison.Ordinal) || string.Equals(uid, pairHandler.UserData.UID, StringComparison.Ordinal))) return true; var vramUsage = pairHandler.LastAppliedApproximateVRAMBytes; var triUsage = pairHandler.LastAppliedDataTris; bool isPrefPerm = pairHandler.HasStickyPermissions; bool exceedsTris = CheckForThreshold(config.WarnOnExceedingThresholds, config.TrisWarningThresholdThousands * 1000L, triUsage, config.WarnOnPreferredPermissionsExceedingThresholds, isPrefPerm); bool exceedsVram = CheckForThreshold(config.WarnOnExceedingThresholds, config.VRAMSizeWarningThresholdMiB * 1024L * 1024L, vramUsage, config.WarnOnPreferredPermissionsExceedingThresholds, isPrefPerm); if (_warnedForPlayers.TryGetValue(pairHandler.UserData.UID, out bool hadWarning) && hadWarning) { _warnedForPlayers[pairHandler.UserData.UID] = exceedsTris || exceedsVram; return true; } _warnedForPlayers[pairHandler.UserData.UID] = exceedsTris || exceedsVram; if (exceedsVram) { _mediator.Publish(new EventMessage(new Event(pairHandler.PlayerName, pairHandler.UserData, nameof(PlayerPerformanceService), EventSeverity.Warning, $"Exceeds VRAM threshold: ({UiSharedService.ByteToString(vramUsage, addSuffix: true)}/{config.VRAMSizeWarningThresholdMiB} MiB)"))); } if (exceedsTris) { _mediator.Publish(new EventMessage(new Event(pairHandler.PlayerName, pairHandler.UserData, nameof(PlayerPerformanceService), EventSeverity.Warning, $"Exceeds triangle threshold: ({triUsage}/{config.TrisAutoPauseThresholdThousands * 1000} triangles)"))); } if (exceedsTris || exceedsVram) { string warningText = string.Empty; if (exceedsTris && !exceedsVram) { warningText = $"Player {pairHandler.PlayerName} ({pairHandler.UserData.AliasOrUID}) exceeds your configured triangle warning threshold\n" + $"{triUsage}/{config.TrisWarningThresholdThousands * 1000} triangles"; } else if (!exceedsTris) { warningText = $"Player {pairHandler.PlayerName} ({pairHandler.UserData.AliasOrUID}) exceeds your configured VRAM warning threshold\n" + $"{UiSharedService.ByteToString(vramUsage, true)}/{config.VRAMSizeWarningThresholdMiB} MiB"; } else { warningText = $"Player {pairHandler.PlayerName} ({pairHandler.UserData.AliasOrUID}) exceeds both VRAM warning threshold and triangle warning threshold\n" + $"{UiSharedService.ByteToString(vramUsage, true)}/{config.VRAMSizeWarningThresholdMiB} MiB and {triUsage}/{config.TrisWarningThresholdThousands * 1000} triangles"; } _mediator.Publish(new PerformanceNotificationMessage( $"{pairHandler.PlayerName} ({pairHandler.UserData.AliasOrUID}) exceeds performance threshold(s)", warningText, pairHandler.UserData, pairHandler.IsPaused, pairHandler.PlayerName)); } return true; } public async Task CheckTriangleUsageThresholds(IPairPerformanceSubject pairHandler, CharacterData charaData) { var config = _playerPerformanceConfigService.Current; long triUsage = 0; if (!charaData.FileReplacements.TryGetValue(API.Data.Enum.ObjectKind.Player, out List? playerReplacements)) { pairHandler.LastAppliedDataTris = 0; return true; } var moddedModelHashes = playerReplacements.Where(p => string.IsNullOrEmpty(p.FileSwapPath) && p.GamePaths.Any(g => g.EndsWith("mdl", StringComparison.OrdinalIgnoreCase))) .Select(p => p.Hash) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); foreach (var hash in moddedModelHashes) { triUsage += await _xivDataAnalyzer.GetTrianglesByHash(hash).ConfigureAwait(false); } pairHandler.LastAppliedDataTris = triUsage; _logger.LogDebug("Calculated VRAM usage for {p}", pairHandler); // no warning of any kind on ignored pairs if (config.UIDsToIgnore .Exists(uid => string.Equals(uid, pairHandler.UserData.Alias, StringComparison.Ordinal) || string.Equals(uid, pairHandler.UserData.UID, StringComparison.Ordinal))) return true; bool isPrefPerm = pairHandler.HasStickyPermissions; // now check auto pause if (CheckForThreshold(config.AutoPausePlayersExceedingThresholds, config.TrisAutoPauseThresholdThousands * 1000L, triUsage, config.AutoPausePlayersWithPreferredPermissionsExceedingThresholds, isPrefPerm)) { var message = $"Player {pairHandler.PlayerName} ({pairHandler.UserData.AliasOrUID}) exceeded your configured triangle auto pause threshold and has been automatically paused\n" + $"{triUsage}/{config.TrisAutoPauseThresholdThousands * 1000} triangles"; _mediator.Publish(new PerformanceNotificationMessage( $"{pairHandler.PlayerName} ({pairHandler.UserData.AliasOrUID}) automatically paused", message, pairHandler.UserData, true, pairHandler.PlayerName)); _mediator.Publish(new EventMessage(new Event(pairHandler.PlayerName, pairHandler.UserData, nameof(PlayerPerformanceService), EventSeverity.Warning, $"Exceeds triangle threshold: automatically paused ({triUsage}/{config.TrisAutoPauseThresholdThousands * 1000} triangles)"))); _mediator.Publish(new PauseMessage(pairHandler.UserData)); return false; } return true; } public bool ComputeAndAutoPauseOnVRAMUsageThresholds(IPairPerformanceSubject pairHandler, CharacterData charaData, List toDownloadFiles) { var config = _playerPerformanceConfigService.Current; bool skipDownscale = pairHandler.IsDirectlyPaired && pairHandler.HasStickyPermissions; long vramUsage = 0; long effectiveVramUsage = 0; if (!charaData.FileReplacements.TryGetValue(API.Data.Enum.ObjectKind.Player, out List? playerReplacements)) { pairHandler.LastAppliedApproximateVRAMBytes = 0; pairHandler.LastAppliedApproximateEffectiveVRAMBytes = 0; return true; } var moddedTextureHashes = playerReplacements.Where(p => string.IsNullOrEmpty(p.FileSwapPath) && p.GamePaths.Any(g => g.EndsWith(".tex", StringComparison.OrdinalIgnoreCase))) .Select(p => p.Hash) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); foreach (var hash in moddedTextureHashes) { long fileSize = 0; long effectiveSize = 0; var download = toDownloadFiles.Find(f => string.Equals(hash, f.Hash, StringComparison.OrdinalIgnoreCase)); if (download != null) { fileSize = download.TotalRaw; effectiveSize = fileSize; } else { var fileEntry = _fileCacheManager.GetFileCacheByHash(hash); if (fileEntry == null) continue; if (fileEntry.Size == null) { fileEntry.Size = new FileInfo(fileEntry.ResolvedFilepath).Length; _fileCacheManager.UpdateHashedFile(fileEntry, computeProperties: true); } fileSize = fileEntry.Size.Value; effectiveSize = fileSize; if (!skipDownscale) { var preferredPath = _textureDownscaleService.GetPreferredPath(hash, fileEntry.ResolvedFilepath); if (!string.IsNullOrEmpty(preferredPath) && File.Exists(preferredPath)) { try { effectiveSize = new FileInfo(preferredPath).Length; } catch (Exception ex) { _logger.LogTrace(ex, "Failed to read size for preferred texture path {Path}", preferredPath); effectiveSize = fileSize; } } else { effectiveSize = fileSize; } } } vramUsage += fileSize; effectiveVramUsage += effectiveSize; } pairHandler.LastAppliedApproximateVRAMBytes = vramUsage; pairHandler.LastAppliedApproximateEffectiveVRAMBytes = effectiveVramUsage; _logger.LogDebug("Calculated VRAM usage for {p}", pairHandler); // no warning of any kind on ignored pairs if (config.UIDsToIgnore .Exists(uid => string.Equals(uid, pairHandler.UserData.Alias, StringComparison.Ordinal) || string.Equals(uid, pairHandler.UserData.UID, StringComparison.Ordinal))) return true; bool isPrefPerm = pairHandler.HasStickyPermissions; // now check auto pause if (CheckForThreshold(config.AutoPausePlayersExceedingThresholds, config.VRAMSizeAutoPauseThresholdMiB * 1024L * 1024L, vramUsage, config.AutoPausePlayersWithPreferredPermissionsExceedingThresholds, isPrefPerm)) { var message = $"Player {pairHandler.PlayerName} ({pairHandler.UserData.AliasOrUID}) exceeded your configured VRAM auto pause threshold and has been automatically paused\n" + $"{UiSharedService.ByteToString(vramUsage, addSuffix: true)}/{config.VRAMSizeAutoPauseThresholdMiB}MiB"; _mediator.Publish(new PerformanceNotificationMessage( $"{pairHandler.PlayerName} ({pairHandler.UserData.AliasOrUID}) automatically paused", message, pairHandler.UserData, true, pairHandler.PlayerName)); _mediator.Publish(new PauseMessage(pairHandler.UserData)); _mediator.Publish(new EventMessage(new Event(pairHandler.PlayerName, pairHandler.UserData, nameof(PlayerPerformanceService), EventSeverity.Warning, $"Exceeds VRAM threshold: automatically paused ({UiSharedService.ByteToString(vramUsage, addSuffix: true)}/{config.VRAMSizeAutoPauseThresholdMiB} MiB)"))); return false; } return true; } private static bool CheckForThreshold(bool thresholdEnabled, long threshold, long value, bool checkForPrefPerm, bool isPrefPerm) => thresholdEnabled && threshold > 0 && threshold < value && ((checkForPrefPerm && isPrefPerm) || !isPrefPerm); }