using MareSynchronos.API.Data; using MareSynchronos.FileCache; using MareSynchronos.MareConfiguration; using MareSynchronos.PlayerData.Handlers; using MareSynchronos.Services.Events; using MareSynchronos.Services.Mediator; using MareSynchronos.Services.ServerConfiguration; using MareSynchronos.UI; using MareSynchronos.WebAPI.Files.Models; using Microsoft.Extensions.Logging; namespace MareSynchronos.Services; public class PlayerPerformanceService : DisposableMediatorSubscriberBase { // Limits that will still be enforced when no limits are enabled public const int MaxVRAMUsageThreshold = 2000; // 2GB public const int MaxTriUsageThreshold = 2000000; // 2 million triangles private readonly FileCacheManager _fileCacheManager; private readonly XivDataAnalyzer _xivDataAnalyzer; private readonly ILogger _logger; private readonly MareMediator _mediator; private readonly ServerConfigurationManager _serverConfigurationManager; private readonly PlayerPerformanceConfigService _playerPerformanceConfigService; private readonly Dictionary _warnedForPlayers = new(StringComparer.Ordinal); public PlayerPerformanceService(ILogger logger, MareMediator mediator, ServerConfigurationManager serverConfigurationManager, PlayerPerformanceConfigService playerPerformanceConfigService, FileCacheManager fileCacheManager, XivDataAnalyzer xivDataAnalyzer) : base(logger, mediator) { _logger = logger; _mediator = mediator; _serverConfigurationManager = serverConfigurationManager; _playerPerformanceConfigService = playerPerformanceConfigService; _fileCacheManager = fileCacheManager; _xivDataAnalyzer = xivDataAnalyzer; } public async Task CheckBothThresholds(PairHandler pairHandler, CharacterData charaData) { bool notPausedAfterVram = ComputeAndAutoPauseOnVRAMUsageThresholds(pairHandler, charaData, []); if (!notPausedAfterVram) return false; bool notPausedAfterTris = await CheckTriangleUsageThresholds(pairHandler, charaData).ConfigureAwait(false); if (!notPausedAfterTris) return false; return true; } public async Task CheckTriangleUsageThresholds(PairHandler pairHandler, CharacterData charaData) { var config = _playerPerformanceConfigService.Current; var pair = pairHandler.Pair; long triUsage = 0; var moddedModelHashes = charaData.FileReplacements.SelectMany(k => k.Value) .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 Task.Run(() => _xivDataAnalyzer.GetTrianglesByHash(hash)).ConfigureAwait(false); } pair.LastAppliedDataTris = triUsage; _logger.LogDebug("Calculated Triangle usage for {p}", pairHandler); long triUsageThreshold = config.TrisAutoPauseThresholdThousands * 1000; bool isDirect = pair.UserPair != null; bool autoPause = config.AutoPausePlayersExceedingThresholds; bool notify = isDirect ? config.NotifyAutoPauseDirectPairs : config.NotifyAutoPauseGroupPairs; if (autoPause && isDirect && config.IgnoreDirectPairs) autoPause = false; if (!autoPause || _serverConfigurationManager.IsUidWhitelisted(pair.UserData.UID)) triUsageThreshold = MaxTriUsageThreshold; if (triUsage > triUsageThreshold) { if (notify && !pair.IsApplicationBlocked) { _mediator.Publish(new NotificationMessage($"{pair.PlayerName} ({pair.UserData.AliasOrUID}) automatically blocked", $"Player {pair.PlayerName} ({pair.UserData.AliasOrUID}) exceeded your configured triangle auto block threshold (" + $"{triUsage}/{triUsageThreshold} triangles)" + $" and has been automatically blocked.", MareConfiguration.Models.NotificationType.Warning)); } _mediator.Publish(new EventMessage(new Event(pair.PlayerName, pair.UserData, nameof(PlayerPerformanceService), EventSeverity.Warning, $"Exceeds triangle threshold: ({triUsage}/{triUsageThreshold} triangles)"))); return false; } return true; } public bool ComputeAndAutoPauseOnVRAMUsageThresholds(PairHandler pairHandler, CharacterData charaData, List toDownloadFiles, bool affect = false) { var config = _playerPerformanceConfigService.Current; var pair = pairHandler.Pair; long vramUsage = 0; var moddedTextureHashes = charaData.FileReplacements.SelectMany(k => k.Value) .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; var download = toDownloadFiles.Find(f => string.Equals(hash, f.Hash, StringComparison.OrdinalIgnoreCase)); if (download != null) { fileSize = download.TotalRaw; } else { var fileEntry = _fileCacheManager.GetFileCacheByHash(hash, preferSubst: true); if (fileEntry == null) continue; if (fileEntry.Size == null) { fileEntry.Size = new FileInfo(fileEntry.ResolvedFilepath).Length; _fileCacheManager.UpdateHashedFile(fileEntry, computeProperties: true); } fileSize = fileEntry.Size.Value; } vramUsage += fileSize; } pair.LastAppliedApproximateVRAMBytes = vramUsage; _logger.LogDebug("Calculated VRAM usage for {p}", pairHandler); long vramUsageThreshold = config.VRAMSizeAutoPauseThresholdMiB; bool isDirect = pair.UserPair != null; bool autoPause = config.AutoPausePlayersExceedingThresholds; bool notify = isDirect ? config.NotifyAutoPauseDirectPairs : config.NotifyAutoPauseGroupPairs; if (autoPause && isDirect && config.IgnoreDirectPairs) autoPause = false; if (!autoPause || _serverConfigurationManager.IsUidWhitelisted(pair.UserData.UID)) vramUsageThreshold = MaxVRAMUsageThreshold; if (vramUsage > vramUsageThreshold * 1024 * 1024) { if (!affect) return false; if (notify && !pair.IsApplicationBlocked) { _mediator.Publish(new NotificationMessage($"{pair.PlayerName} ({pair.UserData.AliasOrUID}) automatically blocked", $"Player {pair.PlayerName} ({pair.UserData.AliasOrUID}) exceeded your configured VRAM auto block threshold (" + $"{UiSharedService.ByteToString(vramUsage, addSuffix: true)}/{vramUsageThreshold}MiB)" + $" and has been automatically blocked.", MareConfiguration.Models.NotificationType.Warning)); } _mediator.Publish(new EventMessage(new Event(pair.PlayerName, pair.UserData, nameof(PlayerPerformanceService), EventSeverity.Warning, $"Exceeds VRAM threshold: ({UiSharedService.ByteToString(vramUsage, addSuffix: true)}/{vramUsageThreshold} MiB)"))); return false; } return true; } public async Task ShrinkTextures(PairHandler pairHandler, CharacterData charaData, CancellationToken token) { var config = _playerPerformanceConfigService.Current; if (config.TextureShrinkMode == MareConfiguration.Models.TextureShrinkMode.Never) return false; // XXX: Temporary if (config.TextureShrinkMode == MareConfiguration.Models.TextureShrinkMode.Default) return false; var moddedTextureHashes = charaData.FileReplacements.SelectMany(k => k.Value) .Where(p => string.IsNullOrEmpty(p.FileSwapPath) && p.GamePaths.Any(g => g.EndsWith(".tex", StringComparison.OrdinalIgnoreCase))) .Select(p => p.Hash) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); bool shrunken = false; await Parallel.ForEachAsync(moddedTextureHashes, token, async (hash, token) => { var fileEntry = _fileCacheManager.GetFileCacheByHash(hash, preferSubst: true); if (fileEntry == null) return; if (fileEntry.IsSubstEntry) return; var texFormat = _xivDataAnalyzer.GetTexFormatByHash(hash); var filePath = fileEntry.ResolvedFilepath; var tmpFilePath = _fileCacheManager.GetSubstFilePath(Guid.NewGuid().ToString(), "tmp"); var newFilePath = _fileCacheManager.GetSubstFilePath(hash, "tex"); var mipLevel = 0; uint width = texFormat.Width; uint height = texFormat.Height; long offsetDelta = 0; uint bitsPerPixel = texFormat.Format switch { 0x1130 => 8, // L8 0x1131 => 8, // A8 0x1440 => 16, // A4R4G4B4 0x1441 => 16, // A1R5G5B5 0x1450 => 32, // A8R8G8B8 0x1451 => 32, // X8R8G8B8 0x2150 => 32, // R32F 0x2250 => 32, // G16R16F 0x2260 => 64, // R32G32F 0x2460 => 64, // A16B16G16R16F 0x2470 => 128, // A32B32G32R32F 0x3420 => 4, // DXT1 0x3430 => 8, // DXT3 0x3431 => 8, // DXT5 0x4140 => 16, // D16 0x4250 => 32, // D24S8 0x6120 => 4, // BC4 0x6230 => 8, // BC5 0x6432 => 8, // BC7 _ => 0 }; uint maxSize = (bitsPerPixel <= 8) ? (2048U * 2048U) : (1024U * 1024U); while (width * height > maxSize && mipLevel < texFormat.MipCount - 1) { offsetDelta += width * height * bitsPerPixel / 8; mipLevel++; width /= 2; height /= 2; } if (offsetDelta == 0) return; _logger.LogDebug("Shrinking {hash} from from {a}x{b} to {c}x{d}", hash, texFormat.Width, texFormat.Height, width, height); try { var inFile = new FileStream(filePath, FileMode.Open, FileAccess.Read); using var reader = new BinaryReader(inFile); var header = reader.ReadBytes(80); reader.BaseStream.Position = 14; byte mipByte = reader.ReadByte(); byte mipCount = (byte)(mipByte & 0x7F); var outFile = new FileStream(tmpFilePath, FileMode.Create, FileAccess.Write, FileShare.None); using var writer = new BinaryWriter(outFile); writer.Write(header); // Update width/height writer.BaseStream.Position = 8; writer.Write((ushort)width); writer.Write((ushort)height); // Update the mip count writer.BaseStream.Position = 14; writer.Write((ushort)((mipByte & 0x80) | (mipCount - mipLevel))); // Reset all of the LoD mips writer.BaseStream.Position = 16; for (int i = 0; i < 3; ++i) writer.Write((uint)0); // Reset all of the mip offsets // (This data is garbage in a lot of modded textures, so its hard to fix it up correctly) writer.BaseStream.Position = 28; for (int i = 0; i < 13; ++i) writer.Write((uint)80); // Write the texture data shifted outFile.Position = 80; inFile.Position = 80 + offsetDelta; await inFile.CopyToAsync(outFile, 81920, token).ConfigureAwait(false); reader.Dispose(); writer.Dispose(); File.Move(tmpFilePath, newFilePath); var substEntry = _fileCacheManager.CreateSubstEntry(newFilePath); if (substEntry != null) substEntry.CompressedSize = fileEntry.CompressedSize; shrunken = true; // Make sure its a cache file before trying to delete it !! bool shouldDelete = fileEntry.IsCacheEntry && File.Exists(filePath); if (_playerPerformanceConfigService.Current.TextureShrinkDeleteOriginal && shouldDelete) { try { _logger.LogDebug("Deleting original texture: {filePath}", filePath); File.Delete(filePath); } catch { } } } catch (Exception e) { _logger.LogWarning(e, "Failed to shrink texture {hash}", hash); if (File.Exists(tmpFilePath)) File.Delete(tmpFilePath); } } ).ConfigureAwait(false); return shrunken; } }