diff --git a/MareSynchronos/.editorconfig b/MareSynchronos/.editorconfig index a21012b..f6abd9f 100644 --- a/MareSynchronos/.editorconfig +++ b/MareSynchronos/.editorconfig @@ -100,3 +100,12 @@ dotnet_diagnostic.MA0075.severity = silent # S3358: Ternary operators should not be nested dotnet_diagnostic.S3358.severity = suggestion + +# S6678: Use PascalCase for named placeholders +dotnet_diagnostic.S6678.severity = suggestion + +# S6605: Collection-specific "Exists" method should be used instead of the "Any" extension +dotnet_diagnostic.S6605.severity = suggestion + +# S6667: Logging in a catch clause should pass the caught exception as a parameter. +dotnet_diagnostic.S6667.severity = suggestion diff --git a/MareSynchronos/FileCache/CacheMonitor.cs b/MareSynchronos/FileCache/CacheMonitor.cs index 3948cd7..af2e59f 100644 --- a/MareSynchronos/FileCache/CacheMonitor.cs +++ b/MareSynchronos/FileCache/CacheMonitor.cs @@ -5,6 +5,7 @@ using MareSynchronos.Services.Mediator; using MareSynchronos.Utils; using Microsoft.Extensions.Logging; using System.Collections.Concurrent; +using System.Diagnostics; namespace MareSynchronos.FileCache; @@ -25,6 +26,7 @@ public sealed class CacheMonitor : DisposableMediatorSubscriberBase FileCacheManager fileDbManager, MareMediator mediator, PerformanceCollectorService performanceCollector, DalamudUtilService dalamudUtil, FileCompactor fileCompactor) : base(logger, mediator) { + Logger.LogInformation("Creating CacheMonitor from {trace}", Environment.StackTrace); _ipcManager = ipcManager; _configService = configService; _fileDbManager = fileDbManager; diff --git a/MareSynchronos/FileCache/FileCacheManager.cs b/MareSynchronos/FileCache/FileCacheManager.cs index 49be997..48cc052 100644 --- a/MareSynchronos/FileCache/FileCacheManager.cs +++ b/MareSynchronos/FileCache/FileCacheManager.cs @@ -208,7 +208,7 @@ public sealed class FileCacheManager : IHostedService foreach (var entry in cleanedPaths) { - _logger.LogDebug("Checking {path}", entry.Value); + //_logger.LogDebug("Checking {path}", entry.Value); if (dict.TryGetValue(entry.Value, out var entity)) { @@ -336,7 +336,7 @@ public sealed class FileCacheManager : IHostedService if (!entries.Exists(u => string.Equals(u.PrefixedFilePath, fileCache.PrefixedFilePath, StringComparison.OrdinalIgnoreCase))) { - _logger.LogTrace("Adding to DB: {hash} => {path}", fileCache.Hash, fileCache.PrefixedFilePath); + //_logger.LogTrace("Adding to DB: {hash} => {path}", fileCache.Hash, fileCache.PrefixedFilePath); entries.Add(fileCache); } } @@ -359,7 +359,7 @@ public sealed class FileCacheManager : IHostedService private FileCacheEntity? GetValidatedFileCache(FileCacheEntity fileCache) { var resultingFileCache = ReplacePathPrefixes(fileCache); - _logger.LogTrace("Validating {path}", fileCache.PrefixedFilePath); + //_logger.LogTrace("Validating {path}", fileCache.PrefixedFilePath); resultingFileCache = Validate(resultingFileCache); return resultingFileCache; } diff --git a/MareSynchronos/FileCache/TransientResourceManager.cs b/MareSynchronos/FileCache/TransientResourceManager.cs index 8e30341..0328ce1 100644 --- a/MareSynchronos/FileCache/TransientResourceManager.cs +++ b/MareSynchronos/FileCache/TransientResourceManager.cs @@ -277,4 +277,14 @@ public sealed class TransientResourceManager : DisposableMediatorSubscriberBase Mediator.Publish(new TransientResourceChangedMessage(gameObject)); } } + + internal void RemoveTransientResource(ObjectKind objectKind, string path) + { + if (SemiTransientResources.TryGetValue(objectKind, out var resources)) + { + resources.RemoveWhere(f => string.Equals(path, f, StringComparison.OrdinalIgnoreCase)); + _configurationService.Current.PlayerPersistentTransientCache[PlayerPersistentDataKey] = resources; + _configurationService.Save(); + } + } } \ No newline at end of file diff --git a/MareSynchronos/MareConfiguration/ConfigurationServiceBase.cs b/MareSynchronos/MareConfiguration/ConfigurationServiceBase.cs index 61c4a0a..4e81655 100644 --- a/MareSynchronos/MareConfiguration/ConfigurationServiceBase.cs +++ b/MareSynchronos/MareConfiguration/ConfigurationServiceBase.cs @@ -40,6 +40,7 @@ public abstract class ConfigurationServiceBase : IDisposable where T : IMareC { _periodicCheckCts.Cancel(); _periodicCheckCts.Dispose(); + if (_configIsDirty) SaveDirtyConfig(); } protected T LoadConfig() @@ -94,10 +95,12 @@ public abstract class ConfigurationServiceBase : IDisposable where T : IMareC // ignore if file cannot be backupped once } - File.WriteAllText(ConfigurationPath, JsonSerializer.Serialize(Current, new JsonSerializerOptions() + var temp = ConfigurationPath + ".tmp"; + File.WriteAllText(temp, JsonSerializer.Serialize(Current, new JsonSerializerOptions() { WriteIndented = true })); + File.Move(temp, ConfigurationPath, true); _configLastWriteTime = new FileInfo(ConfigurationPath).LastWriteTimeUtc; } diff --git a/MareSynchronos/MareConfiguration/Configurations/TransientConfig.cs b/MareSynchronos/MareConfiguration/Configurations/TransientConfig.cs index 59ed8a5..668dc2b 100644 --- a/MareSynchronos/MareConfiguration/Configurations/TransientConfig.cs +++ b/MareSynchronos/MareConfiguration/Configurations/TransientConfig.cs @@ -4,4 +4,4 @@ public class TransientConfig : IMareConfiguration { public Dictionary> PlayerPersistentTransientCache { get; set; } = new(StringComparer.Ordinal); public int Version { get; set; } = 0; -} \ No newline at end of file +} diff --git a/MareSynchronos/MareConfiguration/Configurations/XivDataStorageConfig.cs b/MareSynchronos/MareConfiguration/Configurations/XivDataStorageConfig.cs new file mode 100644 index 0000000..4d56a9d --- /dev/null +++ b/MareSynchronos/MareConfiguration/Configurations/XivDataStorageConfig.cs @@ -0,0 +1,10 @@ +using System.Collections.Concurrent; + +namespace MareSynchronos.MareConfiguration.Configurations; + +public class XivDataStorageConfig : IMareConfiguration +{ + public ConcurrentDictionary TriangleDictionary { get; set; } = new(StringComparer.OrdinalIgnoreCase); + public ConcurrentDictionary>> BonesDictionary { get; set; } = new(StringComparer.OrdinalIgnoreCase); + public int Version { get; set; } = 0; +} \ No newline at end of file diff --git a/MareSynchronos/MareConfiguration/TransientConfigService.cs b/MareSynchronos/MareConfiguration/TransientConfigService.cs index d16da74..409407a 100644 --- a/MareSynchronos/MareConfiguration/TransientConfigService.cs +++ b/MareSynchronos/MareConfiguration/TransientConfigService.cs @@ -11,4 +11,4 @@ public class TransientConfigService : ConfigurationServiceBase } protected override string ConfigurationName => ConfigName; -} \ No newline at end of file +} diff --git a/MareSynchronos/MareConfiguration/XivDataStorageService.cs b/MareSynchronos/MareConfiguration/XivDataStorageService.cs new file mode 100644 index 0000000..96e08bf --- /dev/null +++ b/MareSynchronos/MareConfiguration/XivDataStorageService.cs @@ -0,0 +1,12 @@ +using MareSynchronos.MareConfiguration.Configurations; + +namespace MareSynchronos.MareConfiguration; + +public class XivDataStorageService : ConfigurationServiceBase +{ + public const string ConfigName = "xivdatastorage.json"; + + public XivDataStorageService(string configDir) : base(configDir) { } + + protected override string ConfigurationName => ConfigName; +} \ No newline at end of file diff --git a/MareSynchronos/MarePlugin.cs b/MareSynchronos/MarePlugin.cs index 46da741..03cb0c2 100644 --- a/MareSynchronos/MarePlugin.cs +++ b/MareSynchronos/MarePlugin.cs @@ -74,6 +74,7 @@ public class MarePlugin : MediatorSubscriberBase, IHostedService private readonly ServerConfigurationManager _serverConfigurationManager; private readonly IServiceScopeFactory _serviceScopeFactory; private IServiceScope? _runtimeServiceScope; + private Task? _launchTask = null; public MarePlugin(ILogger logger, MareConfigService mareConfigService, ServerConfigurationManager serverConfigurationManager, @@ -93,7 +94,7 @@ public class MarePlugin : MediatorSubscriberBase, IHostedService Mediator.Publish(new EventMessage(new Services.Events.Event(nameof(MarePlugin), Services.Events.EventSeverity.Informational, $"Starting Loporrit Sync {version.Major}.{version.Minor}.{version.Build}-lop{version.Revision}"))); - Mediator.Subscribe(this, (msg) => _ = Task.Run(WaitForPlayerAndLaunchCharacterManager)); + Mediator.Subscribe(this, (msg) => { if (_launchTask == null || _launchTask.IsCompleted) _launchTask = Task.Run(WaitForPlayerAndLaunchCharacterManager); }); Mediator.Subscribe(this, (_) => DalamudUtilOnLogIn()); Mediator.Subscribe(this, (_) => DalamudUtilOnLogOut()); @@ -116,8 +117,7 @@ public class MarePlugin : MediatorSubscriberBase, IHostedService private void DalamudUtilOnLogIn() { Logger?.LogDebug("Client login"); - - _ = Task.Run(WaitForPlayerAndLaunchCharacterManager); + if (_launchTask == null || _launchTask.IsCompleted) _launchTask = Task.Run(WaitForPlayerAndLaunchCharacterManager); } private void DalamudUtilOnLogOut() diff --git a/MareSynchronos/PlayerData/Factories/PairHandlerFactory.cs b/MareSynchronos/PlayerData/Factories/PairHandlerFactory.cs index f7679cf..6a150c7 100644 --- a/MareSynchronos/PlayerData/Factories/PairHandlerFactory.cs +++ b/MareSynchronos/PlayerData/Factories/PairHandlerFactory.cs @@ -20,13 +20,14 @@ public class PairHandlerFactory private readonly IpcManager _ipcManager; private readonly ILoggerFactory _loggerFactory; private readonly MareMediator _mareMediator; + private readonly XivDataAnalyzer _xivDataAnalyzer; private readonly PluginWarningNotificationService _pluginWarningNotificationManager; private readonly ServerConfigurationManager _serverConfigurationManager; public PairHandlerFactory(ILoggerFactory loggerFactory, GameObjectHandlerFactory gameObjectHandlerFactory, IpcManager ipcManager, FileDownloadManagerFactory fileDownloadManagerFactory, DalamudUtilService dalamudUtilService, PluginWarningNotificationService pluginWarningNotificationManager, ServerConfigurationManager serverConfigurationManager, - CancellationToken dalamudLifetime, FileCacheManager fileCacheManager, MareMediator mareMediator) + CancellationToken dalamudLifetime, FileCacheManager fileCacheManager, MareMediator mareMediator, XivDataAnalyzer modelAnalyzer) { _loggerFactory = loggerFactory; _gameObjectHandlerFactory = gameObjectHandlerFactory; @@ -38,12 +39,13 @@ public class PairHandlerFactory _dalamudLifetimeToken = dalamudLifetime; _fileCacheManager = fileCacheManager; _mareMediator = mareMediator; + _xivDataAnalyzer = modelAnalyzer; } public PairHandler Create(OnlineUserIdentDto onlineUserIdentDto) { return new PairHandler(_loggerFactory.CreateLogger(), onlineUserIdentDto, _gameObjectHandlerFactory, _ipcManager, _fileDownloadManagerFactory.Create(), _pluginWarningNotificationManager, _serverConfigurationManager, _dalamudUtilService, - _dalamudLifetimeToken, _fileCacheManager, _mareMediator); + _dalamudLifetimeToken, _fileCacheManager, _mareMediator, _xivDataAnalyzer); } } \ No newline at end of file diff --git a/MareSynchronos/PlayerData/Factories/PlayerDataFactory.cs b/MareSynchronos/PlayerData/Factories/PlayerDataFactory.cs index 03589e9..01ef727 100644 --- a/MareSynchronos/PlayerData/Factories/PlayerDataFactory.cs +++ b/MareSynchronos/PlayerData/Factories/PlayerDataFactory.cs @@ -2,9 +2,11 @@ using MareSynchronos.API.Data.Enum; using MareSynchronos.FileCache; using MareSynchronos.Interop.Ipc; +using MareSynchronos.MareConfiguration.Models; using MareSynchronos.PlayerData.Data; using MareSynchronos.PlayerData.Handlers; using MareSynchronos.Services; +using MareSynchronos.Services.Mediator; using Microsoft.Extensions.Logging; using CharacterData = MareSynchronos.PlayerData.Data.CharacterData; @@ -18,11 +20,13 @@ public class PlayerDataFactory private readonly IpcManager _ipcManager; private readonly ILogger _logger; private readonly PerformanceCollectorService _performanceCollector; + private readonly XivDataAnalyzer _modelAnalyzer; + private readonly MareMediator _mareMediator; private readonly TransientResourceManager _transientResourceManager; public PlayerDataFactory(ILogger logger, DalamudUtilService dalamudUtil, IpcManager ipcManager, TransientResourceManager transientResourceManager, FileCacheManager fileReplacementFactory, - PerformanceCollectorService performanceCollector) + PerformanceCollectorService performanceCollector, XivDataAnalyzer modelAnalyzer, MareMediator mareMediator) { _logger = logger; _dalamudUtil = dalamudUtil; @@ -30,7 +34,9 @@ public class PlayerDataFactory _transientResourceManager = transientResourceManager; _fileCacheManager = fileReplacementFactory; _performanceCollector = performanceCollector; - _logger.LogTrace("Creating " + nameof(PlayerDataFactory)); + _modelAnalyzer = modelAnalyzer; + _mareMediator = mareMediator; + _logger.LogTrace("Creating {this}", nameof(PlayerDataFactory)); } public async Task BuildCharacterData(CharacterData previousData, GameObjectHandler playerRelatedObject, CancellationToken token) @@ -128,12 +134,16 @@ public class PlayerDataFactory // wait until chara is not drawing and present so nothing spontaneously explodes await _dalamudUtil.WaitWhileCharacterIsDrawing(_logger, playerRelatedObject, Guid.NewGuid(), 30000, ct: token).ConfigureAwait(false); int totalWaitTime = 10000; - while (!await _dalamudUtil.IsObjectPresentAsync(await _dalamudUtil.CreateGameObjectAsync(charaPointer).ConfigureAwait(false)).ConfigureAwait(false) && totalWaitTime > 0) + while (!await _dalamudUtil.IsObjectPresentAsync(await _dalamudUtil.CreateGameObjectAsync(playerRelatedObject.Address).ConfigureAwait(false)).ConfigureAwait(false) && totalWaitTime > 0) { _logger.LogTrace("Character is null but it shouldn't be, waiting"); await Task.Delay(50, token).ConfigureAwait(false); totalWaitTime -= 50; } + Dictionary>? boneIndices = + objectKind != ObjectKind.Player + ? null + : await _dalamudUtil.RunOnFrameworkThread(() => _modelAnalyzer.GetSkeletonBoneIndices(playerRelatedObject)).ConfigureAwait(false); DateTime start = DateTime.UtcNow; @@ -226,11 +236,84 @@ public class PlayerDataFactory } } + if (objectKind == ObjectKind.Player) + { + try + { + await VerifyPlayerAnimationBones(boneIndices, previousData, objectKind).ConfigureAwait(false); + } + catch (Exception e) + { + _logger.LogWarning(e, "Failed to verify player animations, continuing without further verification"); + } + } + _logger.LogInformation("Building character data for {obj} took {time}ms", objectKind, TimeSpan.FromTicks(DateTime.UtcNow.Ticks - start.Ticks).TotalMilliseconds); return previousData; } + private async Task VerifyPlayerAnimationBones(Dictionary>? boneIndices, CharacterData previousData, ObjectKind objectKind) + { + if (boneIndices == null) return; + + foreach (var kvp in boneIndices) + { + _logger.LogDebug("Found {skellyname} ({idx} bone indices) on player: {bones}", kvp.Key, kvp.Value.Any() ? kvp.Value.Max() : 0, string.Join(',', kvp.Value)); + } + + if (boneIndices.All(u => u.Value.Count == 0)) return; + + int noValidationFailed = 0; + foreach (var file in previousData.FileReplacements[objectKind].Where(f => !f.IsFileSwap && f.GamePaths.First().EndsWith("pap", StringComparison.OrdinalIgnoreCase)).ToList()) + { + var skeletonIndices = await _dalamudUtil.RunOnFrameworkThread(() => _modelAnalyzer.GetBoneIndicesFromPap(file.Hash)).ConfigureAwait(false); + bool validationFailed = false; + if (skeletonIndices != null) + { + // 105 is the maximum vanilla skellington spoopy bone index + if (skeletonIndices.All(k => k.Value.Max() <= 105)) + { + _logger.LogTrace("All indices of {path} are <= 105, ignoring", file.ResolvedPath); + continue; + } + + _logger.LogDebug("Verifying bone indices for {path}, found {x} skeletons", file.ResolvedPath, skeletonIndices.Count); + + foreach (var boneCount in skeletonIndices.Select(k => k).ToList()) + { + if (boneCount.Value.Max() > boneIndices.SelectMany(b => b.Value).Max()) + { + _logger.LogWarning("Found more bone indices on the animation {path} skeleton {skl} (max indice {idx}) than on any player related skeleton (max indice {idx2})", + file.ResolvedPath, boneCount.Key, boneCount.Value.Max(), boneIndices.SelectMany(b => b.Value).Max()); + validationFailed = true; + break; + } + } + } + + if (validationFailed) + { + noValidationFailed++; + _logger.LogDebug("Removing {file} from sent file replacements and transient data", file.ResolvedPath); + previousData.FileReplacements[objectKind].Remove(file); + foreach (var gamePath in file.GamePaths) + { + _transientResourceManager.RemoveTransientResource(objectKind, gamePath); + } + } + + } + + if (noValidationFailed > 0) + { + _mareMediator.Publish(new NotificationMessage("Invalid Skeleton Setup", + $"Your client is attempting to send {noValidationFailed} animation files with invalid bone data. Those animation files have been removed from your sent data. " + + $"Verify that you are using the correct skeleton for those animation files (Check /xllog for more information).", + NotificationType.Warning, TimeSpan.FromSeconds(10))); + } + } + private async Task> GetFileReplacementsFromPaths(HashSet forwardResolve, HashSet reverseResolve) { var forwardPaths = forwardResolve.ToArray(); diff --git a/MareSynchronos/PlayerData/Handlers/PairHandler.cs b/MareSynchronos/PlayerData/Handlers/PairHandler.cs index a8ff37e..64577c5 100644 --- a/MareSynchronos/PlayerData/Handlers/PairHandler.cs +++ b/MareSynchronos/PlayerData/Handlers/PairHandler.cs @@ -24,6 +24,7 @@ public sealed class PairHandler : DisposableMediatorSubscriberBase private readonly DalamudUtilService _dalamudUtil; private readonly FileDownloadManager _downloadManager; private readonly FileCacheManager _fileDbManager; + private readonly XivDataAnalyzer _xivDataAnalyzer; private readonly GameObjectHandlerFactory _gameObjectHandlerFactory; private readonly IpcManager _ipcManager; private readonly CancellationToken _lifetime; @@ -42,13 +43,15 @@ public sealed class PairHandler : DisposableMediatorSubscriberBase private bool _redrawOnNextApplication = false; private CombatData? _dataReceivedInCombat; public long LastAppliedDataSize { get; private set; } + public long LastAppliedDataTris { get; private set; } public PairHandler(ILogger logger, OnlineUserIdentDto onlineUser, GameObjectHandlerFactory gameObjectHandlerFactory, IpcManager ipcManager, FileDownloadManager transferManager, PluginWarningNotificationService pluginWarningNotificationManager, ServerConfigurationManager serverConfigurationManager, DalamudUtilService dalamudUtil, CancellationToken lifetime, - FileCacheManager fileDbManager, MareMediator mediator) : base(logger, mediator) + FileCacheManager fileDbManager, MareMediator mediator, + XivDataAnalyzer modelAnalyzer) : base(logger, mediator) { OnlineUser = onlineUser; _gameObjectHandlerFactory = gameObjectHandlerFactory; @@ -59,6 +62,7 @@ public sealed class PairHandler : DisposableMediatorSubscriberBase _dalamudUtil = dalamudUtil; _lifetime = lifetime; _fileDbManager = fileDbManager; + _xivDataAnalyzer = modelAnalyzer; Mediator.Subscribe(this, (_) => FrameworkUpdate()); Mediator.Subscribe(this, (_) => @@ -100,6 +104,7 @@ public sealed class PairHandler : DisposableMediatorSubscriberBase }); LastAppliedDataSize = -1; + LastAppliedDataTris = -1; } public bool IsVisible @@ -377,7 +382,7 @@ public sealed class PairHandler : DisposableMediatorSubscriberBase _ = Task.Run(async () => { - Dictionary moddedPaths = new(StringComparer.Ordinal); + Dictionary<(string GamePath, string? Hash), string> moddedPaths = new(); if (updateModdedPaths) { @@ -444,12 +449,20 @@ public sealed class PairHandler : DisposableMediatorSubscriberBase if (updateModdedPaths) { - await _ipcManager.Penumbra.SetTemporaryModsAsync(Logger, _applicationId, _penumbraCollection, moddedPaths).ConfigureAwait(false); + await _ipcManager.Penumbra.SetTemporaryModsAsync(Logger, _applicationId, _penumbraCollection, + moddedPaths.ToDictionary(k => k.Key.GamePath, k => k.Value, StringComparer.Ordinal)).ConfigureAwait(false); LastAppliedDataSize = -1; + LastAppliedDataTris = -1; foreach (var path in moddedPaths.Values.Distinct(StringComparer.OrdinalIgnoreCase).Select(v => new FileInfo(v)).Where(p => p.Exists)) { + if (LastAppliedDataSize == -1) LastAppliedDataSize = 0; LastAppliedDataSize += path.Length; } + foreach (var key in moddedPaths.Keys.Where(k => !string.IsNullOrEmpty(k.Hash))) + { + if (LastAppliedDataTris == -1) LastAppliedDataTris = 0; + LastAppliedDataTris += await _xivDataAnalyzer.GetTrianglesByHash(key.Hash!).ConfigureAwait(false); + } } if (updateManip) @@ -619,12 +632,12 @@ public sealed class PairHandler : DisposableMediatorSubscriberBase } } - private List TryCalculateModdedDictionary(Guid applicationBase, CharacterData charaData, out Dictionary moddedDictionary, CancellationToken token) + private List TryCalculateModdedDictionary(Guid applicationBase, CharacterData charaData, out Dictionary<(string GamePath, string? Hash), string> moddedDictionary, CancellationToken token) { Stopwatch st = Stopwatch.StartNew(); ConcurrentBag missingFiles = []; - moddedDictionary = new Dictionary(StringComparer.Ordinal); - ConcurrentDictionary outputDict = new(StringComparer.Ordinal); + moddedDictionary = new Dictionary<(string GamePath, string? Hash), string>(); + ConcurrentDictionary<(string GamePath, string? Hash), string> outputDict = new(); bool hasMigrationChanges = false; try @@ -649,7 +662,7 @@ public sealed class PairHandler : DisposableMediatorSubscriberBase foreach (var gamePath in item.GamePaths) { - outputDict[gamePath] = fileCache.ResolvedFilepath; + outputDict[(gamePath, item.Hash)] = fileCache.ResolvedFilepath; } } else @@ -659,14 +672,14 @@ public sealed class PairHandler : DisposableMediatorSubscriberBase } }); - moddedDictionary = outputDict.ToDictionary(k => k.Key, k => k.Value, StringComparer.Ordinal); + moddedDictionary = outputDict.ToDictionary(k => k.Key, k => k.Value); foreach (var item in charaData.FileReplacements.SelectMany(k => k.Value.Where(v => !string.IsNullOrEmpty(v.FileSwapPath))).ToList()) { foreach (var gamePath in item.GamePaths) { Logger.LogTrace("[BASE-{appBase}] Adding file swap for {path}: {fileSwap}", applicationBase, gamePath, item.FileSwapPath); - moddedDictionary[gamePath] = item.FileSwapPath; + moddedDictionary[(gamePath, null)] = item.FileSwapPath; } } } diff --git a/MareSynchronos/PlayerData/Pairs/Pair.cs b/MareSynchronos/PlayerData/Pairs/Pair.cs index 166a7f4..8349228 100644 --- a/MareSynchronos/PlayerData/Pairs/Pair.cs +++ b/MareSynchronos/PlayerData/Pairs/Pair.cs @@ -50,6 +50,7 @@ public class Pair public string? PlayerName => GetPlayerName(); public uint PlayerCharacterId => GetPlayerCharacterId(); public long LastAppliedDataSize => CachedPlayer?.LastAppliedDataSize ?? -1; + public long LastAppliedDataTris => CachedPlayer?.LastAppliedDataTris ?? -1; public UserData UserData => UserPair?.User ?? GroupPair.First().Value.User; diff --git a/MareSynchronos/Plugin.cs b/MareSynchronos/Plugin.cs index 1781418..f5f15a8 100644 --- a/MareSynchronos/Plugin.cs +++ b/MareSynchronos/Plugin.cs @@ -85,8 +85,10 @@ public sealed class Plugin : IDalamudPlugin s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService(), CancellationTokenSource.CreateLinkedTokenSource(addonLifecycle.GameShuttingDownToken, addonLifecycle.DalamudUnloadingToken).Token, - s.GetRequiredService(), s.GetRequiredService())); + s.GetRequiredService(), s.GetRequiredService(), s.GetRequiredService())); collection.AddSingleton(); + collection.AddSingleton(s => new(s.GetRequiredService>(), s.GetRequiredService(), + s.GetRequiredService(), gameData)); collection.AddSingleton(); collection.AddSingleton(); collection.AddSingleton(); @@ -124,6 +126,7 @@ public sealed class Plugin : IDalamudPlugin collection.AddSingleton((s) => new ServerTagConfigService(pluginInterface.ConfigDirectory.FullName)); collection.AddSingleton((s) => new SyncshellConfigService(pluginInterface.ConfigDirectory.FullName)); collection.AddSingleton((s) => new TransientConfigService(pluginInterface.ConfigDirectory.FullName)); + collection.AddSingleton((s) => new XivDataStorageService(pluginInterface.ConfigDirectory.FullName)); collection.AddSingleton((s) => new ConfigurationMigrator(s.GetRequiredService>(), pluginInterface, s.GetRequiredService())); collection.AddSingleton(); diff --git a/MareSynchronos/Services/CharacterAnalyzer.cs b/MareSynchronos/Services/CharacterAnalyzer.cs index ab6e5ca..b408f7a 100644 --- a/MareSynchronos/Services/CharacterAnalyzer.cs +++ b/MareSynchronos/Services/CharacterAnalyzer.cs @@ -12,16 +12,22 @@ namespace MareSynchronos.Services; public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable { private readonly FileCacheManager _fileCacheManager; + private readonly XivDataAnalyzer _xivDataAnalyzer; private CancellationTokenSource? _analysisCts; + private CancellationTokenSource _baseAnalysisCts = new(); private string _lastDataHash = string.Empty; - public CharacterAnalyzer(ILogger logger, MareMediator mediator, FileCacheManager fileCacheManager) : base(logger, mediator) + public CharacterAnalyzer(ILogger logger, MareMediator mediator, FileCacheManager fileCacheManager, XivDataAnalyzer modelAnalyzer) + : base(logger, mediator) { Mediator.Subscribe(this, (msg) => { - _ = Task.Run(() => BaseAnalysis(msg.CharacterData.DeepClone())); + _baseAnalysisCts = _baseAnalysisCts.CancelRecreate(); + var token = _baseAnalysisCts.Token; + _ = BaseAnalysis(msg.CharacterData, token); }); _fileCacheManager = fileCacheManager; + _xivDataAnalyzer = modelAnalyzer; } public int CurrentFile { get; internal set; } @@ -87,7 +93,7 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable _analysisCts.CancelDispose(); } - private void BaseAnalysis(CharacterData charaData) + private async Task BaseAnalysis(CharacterData charaData, CancellationToken token) { if (string.Equals(charaData.DataHash.Value, _lastDataHash, StringComparison.Ordinal)) return; @@ -98,6 +104,8 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable Dictionary data = new(StringComparer.OrdinalIgnoreCase); foreach (var fileEntry in obj.Value) { + token.ThrowIfCancellationRequested(); + var fileCacheEntries = _fileCacheManager.GetAllFileCachesByHash(fileEntry.Hash, ignoreCacheEntries: true, validate: false).ToList(); if (fileCacheEntries.Count == 0) continue; @@ -113,12 +121,16 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable Logger.LogWarning(ex, "Could not identify extension for {path}", filePath); } + var tris = await _xivDataAnalyzer.GetTrianglesByHash(fileEntry.Hash).ConfigureAwait(false); + foreach (var entry in fileCacheEntries) { data[fileEntry.Hash] = new FileDataEntry(fileEntry.Hash, ext, [.. fileEntry.GamePaths], fileCacheEntries.Select(c => c.ResolvedFilepath).Distinct().ToList(), - entry.Size > 0 ? entry.Size.Value : 0, entry.CompressedSize > 0 ? entry.CompressedSize.Value : 0); + entry.Size > 0 ? entry.Size.Value : 0, + entry.CompressedSize > 0 ? entry.CompressedSize.Value : 0, + tris); } } @@ -176,7 +188,7 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable Logger.LogInformation("IMPORTANT NOTES:\n\r- For uploads and downloads only the compressed size is relevant.\n\r- An unusually high total files count beyond 200 and up will also increase your download time to others significantly."); } - internal sealed record FileDataEntry(string Hash, string FileType, List GamePaths, List FilePaths, long OriginalSize, long CompressedSize) + internal sealed record FileDataEntry(string Hash, string FileType, List GamePaths, List FilePaths, long OriginalSize, long CompressedSize, long Triangles) { public bool IsComputed => OriginalSize > 0 && CompressedSize > 0; public async Task ComputeSizes(FileCacheManager fileCacheManager, CancellationToken token) @@ -194,6 +206,7 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable } public long OriginalSize { get; private set; } = OriginalSize; public long CompressedSize { get; private set; } = CompressedSize; + public long Triangles { get; private set; } = Triangles; public Lazy Format = new(() => { diff --git a/MareSynchronos/Services/CommandManagerService.cs b/MareSynchronos/Services/CommandManagerService.cs index 788d829..33de948 100644 --- a/MareSynchronos/Services/CommandManagerService.cs +++ b/MareSynchronos/Services/CommandManagerService.cs @@ -72,7 +72,7 @@ public sealed class CommandManagerService : IDisposable { var splitArgs = args.ToLowerInvariant().Trim().Split(" ", StringSplitOptions.RemoveEmptyEntries); - if (splitArgs == null || splitArgs.Length == 0) + if (splitArgs.Length == 0) { // Interpret this as toggling the UI if (_mareConfigService.Current.HasValidSetup()) diff --git a/MareSynchronos/Services/XivDataAnalyzer.cs b/MareSynchronos/Services/XivDataAnalyzer.cs new file mode 100644 index 0000000..14ff8ae --- /dev/null +++ b/MareSynchronos/Services/XivDataAnalyzer.cs @@ -0,0 +1,202 @@ +using Dalamud.Plugin.Services; +using FFXIVClientStructs.FFXIV.Client.Game.Character; +using FFXIVClientStructs.FFXIV.Client.Graphics.Scene; +using FFXIVClientStructs.Havok.Animation; +using FFXIVClientStructs.Havok.Common.Base.Types; +using FFXIVClientStructs.Havok.Common.Serialize.Util; +using Lumina; +using Lumina.Data.Files; +using MareSynchronos.FileCache; +using MareSynchronos.MareConfiguration; +using MareSynchronos.PlayerData.Handlers; +using Microsoft.Extensions.Logging; +using System.Runtime.InteropServices; + +namespace MareSynchronos.Services; + +public sealed class XivDataAnalyzer +{ + private readonly ILogger _logger; + private readonly FileCacheManager _fileCacheManager; + private readonly XivDataStorageService _configService; + private readonly GameData _luminaGameData; + + public XivDataAnalyzer(ILogger logger, FileCacheManager fileCacheManager, + XivDataStorageService configService, IDataManager gameData) + { + _logger = logger; + _fileCacheManager = fileCacheManager; + _configService = configService; + _luminaGameData = new GameData(gameData.GameData.DataPath.FullName); + } + + public unsafe Dictionary>? GetSkeletonBoneIndices(GameObjectHandler handler) + { + if (handler.Address == nint.Zero) return null; + var chara = (CharacterBase*)(((Character*)handler.Address)->GameObject.DrawObject); + if (chara->GetModelType() != CharacterBase.ModelType.Human) return null; + var resHandles = chara->Skeleton->SkeletonResourceHandles; + Dictionary> outputIndices = []; + try + { + for (int i = 0; i < chara->Skeleton->PartialSkeletonCount; i++) + { + var handle = *(resHandles + i); + _logger.LogTrace("Iterating over SkeletonResourceHandle #{i}:{x}", i, ((nint)handle).ToString("X")); + if ((nint)handle == nint.Zero) continue; + var curBones = handle->BoneCount; + // this is unrealistic, the filename shouldn't ever be that long + if (handle->ResourceHandle.FileName.Length > 1024) continue; + var skeletonName = handle->ResourceHandle.FileName.ToString(); + if (string.IsNullOrEmpty(skeletonName)) continue; + outputIndices[skeletonName] = new(); + for (ushort boneIdx = 0; boneIdx < curBones; boneIdx++) + { + var boneName = handle->HavokSkeleton->Bones[boneIdx].Name.String; + if (boneName == null) continue; + outputIndices[skeletonName].Add((ushort)(boneIdx + 1)); + } + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Could not process skeleton data"); + } + + return (outputIndices.Count != 0 && outputIndices.Values.All(u => u.Count > 0)) ? outputIndices : null; + } + + public unsafe Dictionary>? GetBoneIndicesFromPap(string hash) + { + if (_configService.Current.BonesDictionary.TryGetValue(hash, out var bones)) return bones; + + var cacheEntity = _fileCacheManager.GetFileCacheByHash(hash); + if (cacheEntity == null) return null; + + using BinaryReader reader = new BinaryReader(File.Open(cacheEntity.ResolvedFilepath, FileMode.Open, FileAccess.Read, FileShare.Read)); + + // most of this shit is from vfxeditor, surely nothing will change in the pap format :copium: + reader.ReadInt32(); // ignore + reader.ReadInt32(); // ignore + reader.ReadInt16(); // read 2 (num animations) + reader.ReadInt16(); // read 2 (modelid) + var type = reader.ReadByte();// read 1 (type) + if (type != 0) return null; // it's not human, just ignore it, whatever + + reader.ReadByte(); // read 1 (variant) + reader.ReadInt32(); // ignore + var havokPosition = reader.ReadInt32(); + var footerPosition = reader.ReadInt32(); + var havokDataSize = footerPosition - havokPosition; + reader.BaseStream.Position = havokPosition; + var havokData = reader.ReadBytes(havokDataSize); + if (havokData.Length <= 8) return null; // no havok data + + var output = new Dictionary>(StringComparer.OrdinalIgnoreCase); + var tempHavokDataPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()) + ".hkx"; + var tempHavokDataPathAnsi = Marshal.StringToHGlobalAnsi(tempHavokDataPath); + + try + { + File.WriteAllBytes(tempHavokDataPath, havokData); + + var loadoptions = stackalloc hkSerializeUtil.LoadOptions[1]; + loadoptions->TypeInfoRegistry = hkBuiltinTypeRegistry.Instance()->GetTypeInfoRegistry(); + loadoptions->ClassNameRegistry = hkBuiltinTypeRegistry.Instance()->GetClassNameRegistry(); + loadoptions->Flags = new hkFlags + { + Storage = (int)(hkSerializeUtil.LoadOptionBits.Default) + }; + + var resource = hkSerializeUtil.LoadFromFile((byte*)tempHavokDataPathAnsi, null, loadoptions); + if (resource == null) + { + throw new InvalidOperationException("Resource was null after loading"); + } + + var rootLevelName = @"hkRootLevelContainer"u8; + fixed (byte* n1 = rootLevelName) + { + var container = (hkRootLevelContainer*)resource->GetContentsPointer(n1, hkBuiltinTypeRegistry.Instance()->GetTypeInfoRegistry()); + var animationName = @"hkaAnimationContainer"u8; + fixed (byte* n2 = animationName) + { + var animContainer = (hkaAnimationContainer*)container->findObjectByName(n2, null); + for (int i = 0; i < animContainer->Bindings.Length; i++) + { + var binding = animContainer->Bindings[i].ptr; + var boneTransform = binding->TransformTrackToBoneIndices; + string name = binding->OriginalSkeletonName.String! + "_" + i; + output[name] = []; + for (int boneIdx = 0; boneIdx < boneTransform.Length; boneIdx++) + { + output[name].Add((ushort)boneTransform[boneIdx]); + } + output[name].Sort(); + } + + } + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Could not load havok file in {path}", tempHavokDataPath); + } + finally + { + Marshal.FreeHGlobal(tempHavokDataPathAnsi); + File.Delete(tempHavokDataPath); + } + + _configService.Current.BonesDictionary[hash] = output; + _configService.Save(); + return output; + } + + public Task GetTrianglesFromGamePath(string gamePath) + { + if (_configService.Current.TriangleDictionary.TryGetValue(gamePath, out var cachedTris)) + return Task.FromResult(cachedTris); + + _logger.LogDebug("Detected Model File {path}, calculating Tris", gamePath); + var file = _luminaGameData.GetFile(gamePath); + if (file == null) + return Task.FromResult((long)0); + + if (file.FileHeader.LodCount <= 0) + return Task.FromResult((long)0); + var meshIdx = file.Lods[0].MeshIndex; + var meshCnt = file.Lods[0].MeshCount; + var tris = file.Meshes.Skip(meshIdx).Take(meshCnt).Sum(p => p.IndexCount) / 3; + + _logger.LogDebug("{filePath} => {tris} triangles", gamePath, tris); + _configService.Current.TriangleDictionary[gamePath] = tris; + _configService.Save(); + return Task.FromResult(tris); + } + + public Task GetTrianglesByHash(string hash) + { + if (_configService.Current.TriangleDictionary.TryGetValue(hash, out var cachedTris)) + return Task.FromResult(cachedTris); + + var path = _fileCacheManager.GetFileCacheByHash(hash); + if (path == null || !path.ResolvedFilepath.EndsWith(".mdl", StringComparison.OrdinalIgnoreCase)) + return Task.FromResult((long)0); + + var filePath = path.ResolvedFilepath; + + _logger.LogDebug("Detected Model File {path}, calculating Tris", filePath); + var file = _luminaGameData.GetFileFromDisk(filePath); + if (file.FileHeader.LodCount <= 0) + return Task.FromResult((long)0); + var meshIdx = file.Lods[0].MeshIndex; + var meshCnt = file.Lods[0].MeshCount; + var tris = file.Meshes.Skip(meshIdx).Take(meshCnt).Sum(p => p.IndexCount) / 3; + + _logger.LogDebug("{filePath} => {tris} triangles", filePath, tris); + _configService.Current.TriangleDictionary[hash] = tris; + _configService.Save(); + return Task.FromResult(tris); + } +} diff --git a/MareSynchronos/UI/Components/DrawGroupPair.cs b/MareSynchronos/UI/Components/DrawGroupPair.cs index 92052a7..8b559df 100644 --- a/MareSynchronos/UI/Components/DrawGroupPair.cs +++ b/MareSynchronos/UI/Components/DrawGroupPair.cs @@ -76,8 +76,14 @@ public class DrawGroupPair : DrawPairBase } if (_pair.LastAppliedDataSize >= 0) { - presenceText += UiSharedService.TooltipSeparator + - "Loaded Mods Size: " + UiSharedService.ByteToString(_pair.LastAppliedDataSize, true); + presenceText += UiSharedService.TooltipSeparator; + presenceText += ((!_pair.IsVisible) ? "(Last) " : string.Empty) + "Mods Info" + Environment.NewLine; + presenceText += "Files Size: " + UiSharedService.ByteToString(_pair.LastAppliedDataSize, true); + if (_pair.LastAppliedDataTris >= 0) + { + presenceText += Environment.NewLine + "Triangle Count (excl. Vanilla): " + + (_pair.LastAppliedDataTris > 1000 ? (_pair.LastAppliedDataTris / 1000d).ToString("0.0'k'") : _pair.LastAppliedDataTris); + } } } UiSharedService.AttachToolTip(presenceText); diff --git a/MareSynchronos/UI/Components/DrawUserPair.cs b/MareSynchronos/UI/Components/DrawUserPair.cs index 2e6dde2..d548745 100644 --- a/MareSynchronos/UI/Components/DrawUserPair.cs +++ b/MareSynchronos/UI/Components/DrawUserPair.cs @@ -74,8 +74,14 @@ public class DrawUserPair : DrawPairBase var visibleTooltip = _pair.UserData.AliasOrUID + " is visible: " + _pair.PlayerName! + Environment.NewLine + "Click to target this player"; if (_pair.LastAppliedDataSize >= 0) { - visibleTooltip += UiSharedService.TooltipSeparator + - "Loaded Mods Size: " + UiSharedService.ByteToString(_pair.LastAppliedDataSize, true); + visibleTooltip += UiSharedService.TooltipSeparator; + visibleTooltip += ((!_pair.IsVisible) ? "(Last) " : string.Empty) + "Mods Info" + Environment.NewLine; + visibleTooltip += "Files Size: " + UiSharedService.ByteToString(_pair.LastAppliedDataSize, true); + if (_pair.LastAppliedDataTris >= 0) + { + visibleTooltip += Environment.NewLine + "Triangle Count (excl. Vanilla): " + + (_pair.LastAppliedDataTris > 1000 ? (_pair.LastAppliedDataTris / 1000d).ToString("0.0'k'") : _pair.LastAppliedDataTris); + } } UiSharedService.AttachToolTip(visibleTooltip); diff --git a/MareSynchronos/UI/Components/Popup/PopupHandler.cs b/MareSynchronos/UI/Components/Popup/PopupHandler.cs index 427a2c8..0d1a655 100644 --- a/MareSynchronos/UI/Components/Popup/PopupHandler.cs +++ b/MareSynchronos/UI/Components/Popup/PopupHandler.cs @@ -15,7 +15,8 @@ public class PopupHandler : WindowMediatorSubscriberBase private readonly HashSet _handlers; private IPopupHandler? _currentHandler = null; - public PopupHandler(ILogger logger, MareMediator mediator, IEnumerable popupHandlers, PerformanceCollectorService performanceCollectorService) + public PopupHandler(ILogger logger, MareMediator mediator, IEnumerable popupHandlers, + PerformanceCollectorService performanceCollectorService) : base(logger, mediator, "MarePopupHandler", performanceCollectorService) { Flags = ImGuiWindowFlags.NoBringToFrontOnFocus diff --git a/MareSynchronos/UI/DataAnalysisUi.cs b/MareSynchronos/UI/DataAnalysisUi.cs index a69c277..bc960ac 100644 --- a/MareSynchronos/UI/DataAnalysisUi.cs +++ b/MareSynchronos/UI/DataAnalysisUi.cs @@ -158,7 +158,7 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase ImGui.TextUnformatted("Total size (compressed):"); ImGui.SameLine(); ImGui.TextUnformatted(UiSharedService.ByteToString(_cachedAnalysis!.Sum(c => c.Value.Sum(c => c.Value.CompressedSize)))); - + ImGui.TextUnformatted($"Total modded model triangles: {_cachedAnalysis.Sum(c => c.Value.Sum(f => f.Value.Triangles))}"); ImGui.Separator(); using var tabbar = ImRaii.TabBar("objectSelection"); @@ -195,6 +195,7 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase ImGui.TextUnformatted($"{kvp.Key} size (compressed):"); ImGui.SameLine(); ImGui.TextUnformatted(UiSharedService.ByteToString(kvp.Value.Sum(c => c.Value.CompressedSize))); + ImGui.TextUnformatted($"{kvp.Key} modded model triangles: {kvp.Value.Sum(f => f.Value.Triangles)}"); ImGui.Separator(); if (_selectedObjectTab != kvp.Key) @@ -334,8 +335,10 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase private void DrawTable(IGrouping fileGroup) { - using var table = ImRaii.Table("Analysis", string.Equals(fileGroup.Key, "tex", StringComparison.Ordinal) ? - (_enableBc7ConversionMode ? 7 : 6) : 5, ImGuiTableFlags.Sortable | ImGuiTableFlags.RowBg | ImGuiTableFlags.ScrollY | ImGuiTableFlags.SizingFixedFit, + var tableColumns = string.Equals(fileGroup.Key, "tex", StringComparison.Ordinal) + ? (_enableBc7ConversionMode ? 7 : 6) + : (string.Equals(fileGroup.Key, "mdl", StringComparison.Ordinal) ? 6 : 5); + using var table = ImRaii.Table("Analysis", tableColumns, ImGuiTableFlags.Sortable | ImGuiTableFlags.RowBg | ImGuiTableFlags.ScrollY | ImGuiTableFlags.SizingFixedFit, new Vector2(0, 300)); if (!table.Success) return; ImGui.TableSetupColumn("Hash"); @@ -348,6 +351,10 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase ImGui.TableSetupColumn("Format"); if (_enableBc7ConversionMode) ImGui.TableSetupColumn("Convert to BC7"); } + if (string.Equals(fileGroup.Key, "mdl", StringComparison.Ordinal)) + { + ImGui.TableSetupColumn("Triangles"); + } ImGui.TableSetupScrollFreeze(0, 1); ImGui.TableHeadersRow(); @@ -441,6 +448,12 @@ public class DataAnalysisUi : WindowMediatorSubscriberBase } } } + if (string.Equals(fileGroup.Key, "mdl", StringComparison.Ordinal)) + { + ImGui.TableNextColumn(); + ImGui.TextUnformatted(item.Triangles.ToString()); + if (ImGui.IsItemClicked()) _selectedHash = item.Hash; + } } } } \ No newline at end of file diff --git a/MareSynchronos/UI/GposeUi.cs b/MareSynchronos/UI/GposeUi.cs index e6448e5..00b9ac7 100644 --- a/MareSynchronos/UI/GposeUi.cs +++ b/MareSynchronos/UI/GposeUi.cs @@ -27,7 +27,6 @@ public class GposeUi : WindowMediatorSubscriberBase _dalamudUtil = dalamudUtil; _fileDialogManager = fileDialogManager; _configService = configService; - Mediator.Subscribe(this, (_) => StartGpose()); Mediator.Subscribe(this, (_) => EndGpose()); IsOpen = _dalamudUtil.IsInGpose; diff --git a/MareSynchronos/WebAPI/Files/FileDownloadManager.cs b/MareSynchronos/WebAPI/Files/FileDownloadManager.cs index 0889588..437ed4a 100644 --- a/MareSynchronos/WebAPI/Files/FileDownloadManager.cs +++ b/MareSynchronos/WebAPI/Files/FileDownloadManager.cs @@ -83,7 +83,11 @@ public partial class FileDownloadManager : DisposableMediatorSubscriberBase { stream.Dispose(); } - catch { } + catch + { + // do nothing + // + } } base.Dispose(disposing); }