using FFXIVClientStructs.FFXIV.Client.Game.Character; 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; namespace MareSynchronos.PlayerData.Factories; public class PlayerDataFactory { private static readonly string[] _allowedExtensionsForGamePaths = [".mdl", ".tex", ".mtrl", ".tmb", ".pap", ".avfx", ".atex", ".sklb", ".eid", ".phyb", ".scd", ".skp", ".shpk"]; private readonly DalamudUtilService _dalamudUtil; private readonly FileCacheManager _fileCacheManager; 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, XivDataAnalyzer modelAnalyzer, MareMediator mareMediator) { _logger = logger; _dalamudUtil = dalamudUtil; _ipcManager = ipcManager; _transientResourceManager = transientResourceManager; _fileCacheManager = fileReplacementFactory; _performanceCollector = performanceCollector; _modelAnalyzer = modelAnalyzer; _mareMediator = mareMediator; _logger.LogTrace("Creating {this}", nameof(PlayerDataFactory)); } public async Task BuildCharacterData(CharacterData previousData, GameObjectHandler playerRelatedObject, CancellationToken token) { if (!_ipcManager.Initialized) { _mareMediator.Publish(new NotificationMessage("Penumbra/Glamourer inactive", "Mare attempted to process building your character data, but Penumbra or Glamourer are inactive. Enable and set up Penumbra and Glamourer for Mare to be able to function.", NotificationType.Error)); throw new InvalidOperationException("Penumbra or Glamourer is not connected"); } if (playerRelatedObject == null) return; bool pointerIsZero = true; try { pointerIsZero = playerRelatedObject.Address == IntPtr.Zero; try { pointerIsZero = await CheckForNullDrawObject(playerRelatedObject.Address).ConfigureAwait(false); } catch { pointerIsZero = true; _logger.LogDebug("NullRef for {object}", playerRelatedObject); } } catch (Exception ex) { _logger.LogWarning(ex, "Could not create data for {object}", playerRelatedObject); } if (pointerIsZero) { _logger.LogTrace("Pointer was zero for {objectKind}", playerRelatedObject.ObjectKind); previousData.FileReplacements.Remove(playerRelatedObject.ObjectKind); previousData.GlamourerString.Remove(playerRelatedObject.ObjectKind); previousData.CustomizePlusScale.Remove(playerRelatedObject.ObjectKind); return; } var previousFileReplacements = previousData.FileReplacements.ToDictionary(d => d.Key, d => d.Value); var previousGlamourerData = previousData.GlamourerString.ToDictionary(d => d.Key, d => d.Value); var previousCustomize = previousData.CustomizePlusScale.ToDictionary(d => d.Key, d => d.Value); try { await _performanceCollector.LogPerformance(this, $"CreateCharacterData>{playerRelatedObject.ObjectKind}", async () => { await CreateCharacterData(previousData, playerRelatedObject, token).ConfigureAwait(false); }).ConfigureAwait(true); return; } catch (OperationCanceledException) { _logger.LogDebug("Cancelled creating Character data for {object}", playerRelatedObject); throw; } catch (Exception e) { _logger.LogWarning(e, "Failed to create {object} data", playerRelatedObject); } previousData.FileReplacements = previousFileReplacements; previousData.GlamourerString = previousGlamourerData; previousData.CustomizePlusScale = previousCustomize; } private async Task CheckForNullDrawObject(IntPtr playerPointer) { return await _dalamudUtil.RunOnFrameworkThread(() => CheckForNullDrawObjectUnsafe(playerPointer)).ConfigureAwait(false); } private unsafe bool CheckForNullDrawObjectUnsafe(IntPtr playerPointer) { return ((Character*)playerPointer)->GameObject.DrawObject == null; } private async Task CreateCharacterData(CharacterData previousData, GameObjectHandler playerRelatedObject, CancellationToken token) { var objectKind = playerRelatedObject.ObjectKind; var charaPointer = playerRelatedObject.Address; _logger.LogDebug("Building character data for {obj}", playerRelatedObject); if (!previousData.FileReplacements.TryGetValue(objectKind, out HashSet? value)) { previousData.FileReplacements[objectKind] = new(FileReplacementComparer.Instance); } else { value.Clear(); } previousData.CustomizePlusScale.Remove(objectKind); // 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(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; // penumbra call, it's currently broken Dictionary>? resolvedPaths; resolvedPaths = (await _ipcManager.Penumbra.GetCharacterData(_logger, playerRelatedObject).ConfigureAwait(false)); if (resolvedPaths == null) throw new InvalidOperationException("Penumbra returned null data"); previousData.FileReplacements[objectKind] = new HashSet(resolvedPaths.Select(c => new FileReplacement([.. c.Value], c.Key)), FileReplacementComparer.Instance) .Where(p => p.HasFileReplacement).ToHashSet(); previousData.FileReplacements[objectKind].RemoveWhere(c => c.GamePaths.Any(g => !_allowedExtensionsForGamePaths.Any(e => g.EndsWith(e, StringComparison.OrdinalIgnoreCase)))); _logger.LogDebug("== Static Replacements =="); foreach (var replacement in previousData.FileReplacements[objectKind].Where(i => i.HasFileReplacement).OrderBy(i => i.GamePaths.First(), StringComparer.OrdinalIgnoreCase)) { _logger.LogDebug("=> {repl}", replacement); } // if it's pet then it's summoner, if it's summoner we actually want to keep all filereplacements alive at all times // or we get into redraw city for every change and nothing works properly if (objectKind == ObjectKind.Pet) { foreach (var item in previousData.FileReplacements[objectKind].Where(i => i.HasFileReplacement).SelectMany(p => p.GamePaths)) { _logger.LogDebug("Persisting {item}", item); _transientResourceManager.AddSemiTransientResource(objectKind, item); } } _logger.LogDebug("Handling transient update for {obj}", playerRelatedObject); // remove all potentially gathered paths from the transient resource manager that are resolved through static resolving _transientResourceManager.ClearTransientPaths(charaPointer, previousData.FileReplacements[objectKind].SelectMany(c => c.GamePaths).ToList()); // get all remaining paths and resolve them var transientPaths = ManageSemiTransientData(objectKind, charaPointer); var resolvedTransientPaths = await GetFileReplacementsFromPaths(transientPaths, new HashSet(StringComparer.Ordinal)).ConfigureAwait(false); _logger.LogDebug("== Transient Replacements =="); foreach (var replacement in resolvedTransientPaths.Select(c => new FileReplacement([.. c.Value], c.Key)).OrderBy(f => f.ResolvedPath, StringComparer.Ordinal)) { _logger.LogDebug("=> {repl}", replacement); previousData.FileReplacements[objectKind].Add(replacement); } // clean up all semi transient resources that don't have any file replacement (aka null resolve) _transientResourceManager.CleanUpSemiTransientResources(objectKind, [.. previousData.FileReplacements[objectKind]]); // make sure we only return data that actually has file replacements foreach (var item in previousData.FileReplacements) { previousData.FileReplacements[item.Key] = new HashSet(item.Value.Where(v => v.HasFileReplacement).OrderBy(v => v.ResolvedPath, StringComparer.Ordinal), FileReplacementComparer.Instance); } // gather up data from ipc previousData.ManipulationString = _ipcManager.Penumbra.GetMetaManipulations(); Task getHeelsOffset = _ipcManager.Heels.GetOffsetAsync(); Task getGlamourerData = _ipcManager.Glamourer.GetCharacterCustomizationAsync(playerRelatedObject.Address); Task getCustomizeData = _ipcManager.CustomizePlus.GetScaleAsync(playerRelatedObject.Address); previousData.GlamourerString[playerRelatedObject.ObjectKind] = await getGlamourerData.ConfigureAwait(false); _logger.LogDebug("Glamourer is now: {data}", previousData.GlamourerString[playerRelatedObject.ObjectKind]); var customizeScale = await getCustomizeData.ConfigureAwait(false); previousData.CustomizePlusScale[playerRelatedObject.ObjectKind] = customizeScale ?? string.Empty; _logger.LogDebug("Customize is now: {data}", previousData.CustomizePlusScale[playerRelatedObject.ObjectKind]); previousData.HonorificData = _ipcManager.Honorific.GetTitle(); _logger.LogDebug("Honorific is now: {data}", previousData.HonorificData); previousData.HeelsData = await getHeelsOffset.ConfigureAwait(false); _logger.LogDebug("Heels is now: {heels}", previousData.HeelsData); if (objectKind == ObjectKind.Player) { previousData.MoodlesData = await _ipcManager.Moodles.GetStatusAsync(playerRelatedObject.Address).ConfigureAwait(false) ?? string.Empty; _logger.LogDebug("Moodles is now: {moodles}", previousData.MoodlesData); } if (previousData.FileReplacements.TryGetValue(objectKind, out HashSet? fileReplacements)) { var toCompute = fileReplacements.Where(f => !f.IsFileSwap).ToArray(); _logger.LogDebug("Getting Hashes for {amount} Files", toCompute.Length); var computedPaths = _fileCacheManager.GetFileCachesByPaths(toCompute.Select(c => c.ResolvedPath).ToArray()); foreach (var file in toCompute) { file.Hash = computedPaths[file.ResolvedPath]?.Hash ?? string.Empty; } var removed = fileReplacements.RemoveWhere(f => !f.IsFileSwap && string.IsNullOrEmpty(f.Hash)); if (removed > 0) { _logger.LogDebug("Removed {amount} of invalid files", removed); } } 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(); var reversePaths = reverseResolve.ToArray(); Dictionary> resolvedPaths = new(StringComparer.Ordinal); var (forward, reverse) = await _ipcManager.Penumbra.ResolvePathsAsync(forwardPaths, reversePaths).ConfigureAwait(false); for (int i = 0; i < forwardPaths.Length; i++) { var filePath = forward[i].ToLowerInvariant(); if (resolvedPaths.TryGetValue(filePath, out var list)) { list.Add(forwardPaths[i].ToLowerInvariant()); } else { resolvedPaths[filePath] = [forwardPaths[i].ToLowerInvariant()]; } } for (int i = 0; i < reversePaths.Length; i++) { var filePath = reversePaths[i].ToLowerInvariant(); if (resolvedPaths.TryGetValue(filePath, out var list)) { list.AddRange(reverse[i].Select(c => c.ToLowerInvariant())); } else { resolvedPaths[filePath] = new List(reverse[i].Select(c => c.ToLowerInvariant()).ToList()); } } return resolvedPaths.ToDictionary(k => k.Key, k => k.Value.ToArray(), StringComparer.OrdinalIgnoreCase).AsReadOnly(); } private HashSet ManageSemiTransientData(ObjectKind objectKind, IntPtr charaPointer) { _transientResourceManager.PersistTransientResources(charaPointer, objectKind); HashSet pathsToResolve = new(StringComparer.Ordinal); foreach (var path in _transientResourceManager.GetSemiTransientResources(objectKind).Where(path => !string.IsNullOrEmpty(path))) { pathsToResolve.Add(path); } return pathsToResolve; } }