using Dalamud.Utility; using FFXIVClientStructs.FFXIV.Client.Game.Character; using FFXIVClientStructs.FFXIV.Client.Game.Object; using LightlessSync.FileCache; using LightlessSync.Interop.Ipc; using LightlessSync.LightlessConfiguration; using LightlessSync.LightlessConfiguration.Models; using LightlessSync.PlayerData.Data; using LightlessSync.PlayerData.Handlers; using LightlessSync.Services; using LightlessSync.Services.Mediator; using LightlessSync.Utils; using Microsoft.Extensions.Logging; using System.Collections.Concurrent; using System.Diagnostics; using System.Runtime.InteropServices; using ObjectKind = LightlessSync.API.Data.Enum.ObjectKind; namespace LightlessSync.PlayerData.Factories; public class PlayerDataFactory { private readonly DalamudUtilService _dalamudUtil; private readonly FileCacheManager _fileCacheManager; private readonly IpcManager _ipcManager; private readonly ILogger _logger; private readonly PerformanceCollectorService _performanceCollector; private readonly LightlessConfigService _configService; private readonly XivDataAnalyzer _modelAnalyzer; private readonly LightlessMediator _lightlessMediator; private readonly TransientResourceManager _transientResourceManager; private static readonly SemaphoreSlim _papParseLimiter = new(1, 1); // Transient resolved entries threshold private const int _maxTransientResolvedEntries = 1000; // Character build caches private readonly ConcurrentDictionary> _characterBuildInflight = new(); private readonly ConcurrentDictionary _characterBuildCache = new(); // Time out thresholds private static readonly TimeSpan _characterCacheTtl = TimeSpan.FromMilliseconds(750); private static readonly TimeSpan _softReturnIfBusyAfter = TimeSpan.FromMilliseconds(250); private static readonly TimeSpan _hardBuildTimeout = TimeSpan.FromSeconds(30); public PlayerDataFactory( ILogger logger, DalamudUtilService dalamudUtil, IpcManager ipcManager, TransientResourceManager transientResourceManager, FileCacheManager fileReplacementFactory, PerformanceCollectorService performanceCollector, XivDataAnalyzer modelAnalyzer, LightlessMediator lightlessMediator, LightlessConfigService configService) { _logger = logger; _dalamudUtil = dalamudUtil; _ipcManager = ipcManager; _transientResourceManager = transientResourceManager; _fileCacheManager = fileReplacementFactory; _performanceCollector = performanceCollector; _modelAnalyzer = modelAnalyzer; _lightlessMediator = lightlessMediator; _configService = configService; _logger.LogTrace("Creating {this}", nameof(PlayerDataFactory)); } private sealed record CacheEntry(CharacterDataFragment Fragment, DateTime CreatedUtc); public async Task BuildCharacterData(GameObjectHandler playerRelatedObject, CancellationToken token) { if (!_ipcManager.Initialized) throw new InvalidOperationException("Penumbra or Glamourer is not connected"); if (playerRelatedObject == null) return null; 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}; couldn't build character", playerRelatedObject.ObjectKind); return null; } try { return await _performanceCollector.LogPerformance( this, $"CreateCharacterData>{playerRelatedObject.ObjectKind}", async () => await CreateCharacterData(playerRelatedObject, token).ConfigureAwait(false) ).ConfigureAwait(false); } catch (OperationCanceledException) { _logger.LogDebug("Cancelled creating Character data for {object}", playerRelatedObject); throw; } catch (Exception e) { _logger.LogWarning(e, "Failed to create {object} data", playerRelatedObject); } return null; } private static readonly int _characterGameObjectOffset = (int)Marshal.OffsetOf(nameof(Character.GameObject)); private static readonly int _gameObjectDrawObjectOffset = (int)Marshal.OffsetOf(nameof(GameObject.DrawObject)); private async Task CheckForNullDrawObject(IntPtr playerPointer) => await _dalamudUtil.RunOnFrameworkThread(() => CheckForNullDrawObjectSafe(playerPointer)) .ConfigureAwait(false); private static bool CheckForNullDrawObjectSafe(nint playerPointer) { if (playerPointer == nint.Zero) return true; var drawObjPtrAddress = playerPointer + _characterGameObjectOffset + _gameObjectDrawObjectOffset; // Read the DrawObject pointer from memory if (!MemoryProcessProbe.TryReadIntPtr(drawObjPtrAddress, out var drawObj)) return true; return drawObj == nint.Zero; } private static bool IsCacheFresh(CacheEntry entry) => (DateTime.UtcNow - entry.CreatedUtc) <= _characterCacheTtl; private Task CreateCharacterData(GameObjectHandler playerRelatedObject, CancellationToken ct) => CreateCharacterDataCoalesced(playerRelatedObject, ct); private async Task CreateCharacterDataCoalesced(GameObjectHandler obj, CancellationToken ct) { var key = obj.Address; if (_characterBuildCache.TryGetValue(key, out var cached) && IsCacheFresh(cached) && !_characterBuildInflight.ContainsKey(key)) return cached.Fragment; var buildTask = _characterBuildInflight.GetOrAdd(key, valueFactory: k => BuildAndCacheAsync(obj, k)); if (_characterBuildCache.TryGetValue(key, out cached)) { var completed = await Task.WhenAny(buildTask, Task.Delay(_softReturnIfBusyAfter, ct)).ConfigureAwait(false); if (completed != buildTask && (DateTime.UtcNow - cached.CreatedUtc) <= TimeSpan.FromSeconds(5)) { return cached.Fragment; } } return await WithCancellation(buildTask, ct).ConfigureAwait(false); } private async Task BuildAndCacheAsync(GameObjectHandler obj, nint key) { try { using var cts = new CancellationTokenSource(_hardBuildTimeout); var fragment = await CreateCharacterDataInternal(obj, cts.Token).ConfigureAwait(false); _characterBuildCache[key] = new CacheEntry(fragment, DateTime.UtcNow); PruneCharacterCacheIfNeeded(); return fragment; } finally { _characterBuildInflight.TryRemove(key, out _); } } private void PruneCharacterCacheIfNeeded() { if (_characterBuildCache.Count < 2048) return; var cutoff = DateTime.UtcNow - TimeSpan.FromSeconds(10); foreach (var kv in _characterBuildCache) { if (kv.Value.CreatedUtc < cutoff) _characterBuildCache.TryRemove(kv.Key, out _); } } private static async Task WithCancellation(Task task, CancellationToken ct) => await task.WaitAsync(ct).ConfigureAwait(false); private async Task CreateCharacterDataInternal(GameObjectHandler playerRelatedObject, CancellationToken ct) { var objectKind = playerRelatedObject.ObjectKind; CharacterDataFragment fragment = objectKind == ObjectKind.Player ? new CharacterDataFragmentPlayer() : new(); var logDebug = _logger.IsEnabled(LogLevel.Debug); var sw = Stopwatch.StartNew(); _logger.LogDebug("Building character data for {obj}", playerRelatedObject); await EnsureObjectPresentAsync(playerRelatedObject, ct).ConfigureAwait(false); ct.ThrowIfCancellationRequested(); var waitRecordingTask = _transientResourceManager.WaitForRecording(ct); await _dalamudUtil.WaitWhileCharacterIsDrawing(_logger, playerRelatedObject, Guid.NewGuid(), 30000, ct: ct) .ConfigureAwait(false); // get all remaining paths and resolve them ct.ThrowIfCancellationRequested(); if (await CheckForNullDrawObject(playerRelatedObject.Address).ConfigureAwait(false)) throw new InvalidOperationException("DrawObject became null during build (actor despawned)"); Task getGlamourerData = _ipcManager.Glamourer.GetCharacterCustomizationAsync(playerRelatedObject.Address); Task getCustomizeData = _ipcManager.CustomizePlus.GetScaleAsync(playerRelatedObject.Address); Task? getMoodlesData = null; Task? getHeelsOffset = null; Task? getHonorificTitle = null; if (objectKind == ObjectKind.Player) { getHeelsOffset = _ipcManager.Heels.GetOffsetAsync(); getHonorificTitle = _ipcManager.Honorific.GetTitle(); getMoodlesData = _ipcManager.Moodles.GetStatusAsync(playerRelatedObject.Address); } var resolvedPaths = await _ipcManager.Penumbra.GetCharacterData(_logger, playerRelatedObject).ConfigureAwait(false) ?? throw new InvalidOperationException("Penumbra returned null data; couldn't proceed with character"); ct.ThrowIfCancellationRequested(); var staticBuildTask = Task.Run(() => BuildStaticReplacements(resolvedPaths), ct); fragment.FileReplacements = await staticBuildTask.ConfigureAwait(false); if (logDebug) { _logger.LogDebug("== Static Replacements =="); foreach (var replacement in fragment.FileReplacements .Where(i => i.HasFileReplacement) .OrderBy(i => i.GamePaths.First(), StringComparer.OrdinalIgnoreCase)) { _logger.LogDebug("=> {repl}", replacement); ct.ThrowIfCancellationRequested(); } } var staticReplacements = new HashSet(fragment.FileReplacements, FileReplacementComparer.Instance); var transientTask = ResolveTransientReplacementsAsync( playerRelatedObject, objectKind, staticReplacements, waitRecordingTask, ct); fragment.GlamourerString = await getGlamourerData.ConfigureAwait(false); _logger.LogDebug("Glamourer is now: {data}", fragment.GlamourerString); var customizeScale = await getCustomizeData.ConfigureAwait(false); fragment.CustomizePlusScale = customizeScale ?? string.Empty; _logger.LogDebug("Customize is now: {data}", fragment.CustomizePlusScale); if (objectKind == ObjectKind.Player) { CharacterDataFragmentPlayer? playerFragment = fragment as CharacterDataFragmentPlayer ?? throw new InvalidOperationException("Failed to cast CharacterDataFragment to Player variant"); playerFragment.ManipulationString = _ipcManager.Penumbra.GetMetaManipulations(); playerFragment.HonorificData = await getHonorificTitle!.ConfigureAwait(false); _logger.LogDebug("Honorific is now: {data}", playerFragment!.HonorificData); playerFragment.PetNamesData = _ipcManager.PetNames.GetLocalNames(); _logger.LogDebug("Pet Nicknames is now: {petnames}", playerFragment!.PetNamesData); playerFragment.HeelsData = await getHeelsOffset!.ConfigureAwait(false); _logger.LogDebug("Heels is now: {heels}", playerFragment!.HeelsData); playerFragment.MoodlesData = (await getMoodlesData!.ConfigureAwait(false)) ?? string.Empty; _logger.LogDebug("Moodles is now: {moodles}", playerFragment!.MoodlesData); } ct.ThrowIfCancellationRequested(); var (resolvedTransientPaths, clearedForPet) = await transientTask.ConfigureAwait(false); if (clearedForPet != null) fragment.FileReplacements.Clear(); if (logDebug) { _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); fragment.FileReplacements.Add(replacement); } } else { foreach (var replacement in resolvedTransientPaths.Select(c => new FileReplacement([.. c.Value], c.Key))) fragment.FileReplacements.Add(replacement); } _transientResourceManager.CleanUpSemiTransientResources(objectKind, [.. fragment.FileReplacements]); fragment.FileReplacements = new HashSet( fragment.FileReplacements .Where(v => v.HasFileReplacement) .OrderBy(v => v.ResolvedPath, StringComparer.Ordinal), FileReplacementComparer.Instance); ct.ThrowIfCancellationRequested(); var toCompute = fragment.FileReplacements.Where(f => !f.IsFileSwap).ToArray(); _logger.LogDebug("Getting Hashes for {amount} Files", toCompute.Length); await Task.Run(() => { var computedPaths = _fileCacheManager.GetFileCachesByPaths([.. toCompute.Select(c => c.ResolvedPath)]); foreach (var file in toCompute) { ct.ThrowIfCancellationRequested(); file.Hash = computedPaths[file.ResolvedPath]?.Hash ?? string.Empty; } }, ct).ConfigureAwait(false); var removed = fragment.FileReplacements.RemoveWhere(f => !f.IsFileSwap && string.IsNullOrEmpty(f.Hash)); if (removed > 0) _logger.LogDebug("Removed {amount} of invalid files", removed); ct.ThrowIfCancellationRequested(); Dictionary>? boneIndices = null; var hasPapFiles = false; if (objectKind == ObjectKind.Player) { hasPapFiles = fragment.FileReplacements.Any(f => !f.IsFileSwap && f.GamePaths.Any(p => p.EndsWith(".pap", StringComparison.OrdinalIgnoreCase))); if (hasPapFiles) { boneIndices = await _dalamudUtil .RunOnFrameworkThread(() => _modelAnalyzer.GetSkeletonBoneIndices(playerRelatedObject)) .ConfigureAwait(false); } try { #if DEBUG if (hasPapFiles && boneIndices != null) _modelAnalyzer.DumpLocalSkeletonIndices(playerRelatedObject); #endif if (hasPapFiles) { await VerifyPlayerAnimationBones(boneIndices, (CharacterDataFragmentPlayer)fragment, ct) .ConfigureAwait(false); } } catch (OperationCanceledException e) { _logger.LogDebug(e, "Cancelled during player animation verification"); throw; } 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, sw.Elapsed.TotalMilliseconds); return fragment; } private async Task EnsureObjectPresentAsync(GameObjectHandler handler, CancellationToken ct) { var remaining = 10000; while (remaining > 0) { ct.ThrowIfCancellationRequested(); var obj = await _dalamudUtil.CreateGameObjectAsync(handler.Address).ConfigureAwait(false); if (await _dalamudUtil.IsObjectPresentAsync(obj).ConfigureAwait(false)) return; _logger.LogTrace("Character is null but it shouldn't be, waiting"); await Task.Delay(50, ct).ConfigureAwait(false); remaining -= 50; } } private static HashSet BuildStaticReplacements(Dictionary> resolvedPaths) { var set = new HashSet(FileReplacementComparer.Instance); foreach (var kvp in resolvedPaths) { var fr = new FileReplacement([.. kvp.Value], kvp.Key); if (!fr.HasFileReplacement) continue; var allAllowed = fr.GamePaths.All(g => CacheMonitor.AllowedFileExtensions.Any(e => g.EndsWith(e, StringComparison.OrdinalIgnoreCase))); if (!allAllowed) continue; set.Add(fr); } return set; } private async Task<(IReadOnlyDictionary ResolvedPaths, HashSet? ClearedReplacements)> ResolveTransientReplacementsAsync( GameObjectHandler obj, ObjectKind objectKind, HashSet staticReplacements, Task waitRecordingTask, CancellationToken ct) { await waitRecordingTask.ConfigureAwait(false); HashSet? clearedReplacements = null; if (objectKind == ObjectKind.Pet) { foreach (var item in staticReplacements.Where(i => i.HasFileReplacement).SelectMany(p => p.GamePaths)) { if (_transientResourceManager.AddTransientResource(objectKind, item)) _logger.LogDebug("Marking static {item} for Pet as transient", item); } _logger.LogTrace("Clearing {count} Static Replacements for Pet", staticReplacements.Count); clearedReplacements = staticReplacements; } ct.ThrowIfCancellationRequested(); _transientResourceManager.ClearTransientPaths(objectKind, [.. staticReplacements.SelectMany(c => c.GamePaths)]); var transientPaths = ManageSemiTransientData(objectKind); if (transientPaths.Count == 0) return (new Dictionary(StringComparer.Ordinal), clearedReplacements); var resolved = await GetFileReplacementsFromPaths(obj, transientPaths, new HashSet(StringComparer.Ordinal)) .ConfigureAwait(false); if (_maxTransientResolvedEntries > 0 && resolved.Count > _maxTransientResolvedEntries) { _logger.LogWarning("Transient entries ({resolved}) are above the threshold {max}; Please consider disable some mods (VFX have heavy load) to reduce transient load", resolved.Count, _maxTransientResolvedEntries); } return (resolved, clearedReplacements); } private async Task VerifyPlayerAnimationBones( Dictionary>? playerBoneIndices, CharacterDataFragmentPlayer fragment, CancellationToken ct) { var mode = _configService.Current.AnimationValidationMode; var allowBasedShift = _configService.Current.AnimationAllowOneBasedShift; var allownNightIndex = _configService.Current.AnimationAllowNeighborIndexTolerance; if (mode == AnimationValidationMode.Unsafe) return; if (playerBoneIndices == null || playerBoneIndices.Count == 0) return; var localBoneSets = new Dictionary>(StringComparer.OrdinalIgnoreCase); foreach (var (rawLocalKey, indices) in playerBoneIndices) { if (indices is not { Count: > 0 }) continue; var key = XivDataAnalyzer.CanonicalizeSkeletonKey(rawLocalKey); if (string.IsNullOrEmpty(key)) continue; if (!localBoneSets.TryGetValue(key, out var set)) localBoneSets[key] = set = []; foreach (var idx in indices) set.Add(idx); } if (localBoneSets.Count == 0) return; if (_logger.IsEnabled(LogLevel.Debug)) { _logger.LogDebug("SEND local buckets: {b}", string.Join(", ", localBoneSets.Keys.Order(StringComparer.Ordinal))); foreach (var kvp in localBoneSets.OrderBy(k => k.Key, StringComparer.OrdinalIgnoreCase)) { var min = kvp.Value.Count > 0 ? kvp.Value.Min() : 0; var max = kvp.Value.Count > 0 ? kvp.Value.Max() : 0; _logger.LogDebug("Local bucket {bucket}: count={count} min={min} max={max}", kvp.Key, kvp.Value.Count, min, max); } } var papGroups = fragment.FileReplacements .Where(f => !f.IsFileSwap && !string.IsNullOrEmpty(f.Hash) && f.GamePaths is { Count: > 0 } && f.GamePaths.Any(p => p.EndsWith(".pap", StringComparison.OrdinalIgnoreCase))) .GroupBy(f => f.Hash!, StringComparer.OrdinalIgnoreCase) .ToList(); int noValidationFailed = 0; foreach (var g in papGroups) { ct.ThrowIfCancellationRequested(); var hash = g.Key; var resolvedPath = g.Select(f => f.ResolvedPath).Distinct(StringComparer.OrdinalIgnoreCase); var papPathSummary = string.Join(", ", resolvedPath); if (papPathSummary.IsNullOrEmpty()) papPathSummary = ""; Dictionary>? papIndices = null; await _papParseLimiter.WaitAsync(ct).ConfigureAwait(false); try { var cacheEntity = _fileCacheManager.GetFileCacheByHash(hash); var papPath = cacheEntity?.ResolvedFilepath; if (!string.IsNullOrEmpty(papPath) && File.Exists(papPath)) { var havokBytes = await Task.Run(() => XivDataAnalyzer.ReadHavokBytesFromPap(papPath), ct) .ConfigureAwait(false); if (havokBytes is { Length: > 8 }) { papIndices = await _dalamudUtil.RunOnFrameworkThread( () => _modelAnalyzer.ParseHavokBytesOnFrameworkThread(havokBytes, hash, persistToConfig: false)) .ConfigureAwait(false); } } } finally { _papParseLimiter.Release(); } if (papIndices == null || papIndices.Count == 0) continue; if (_logger.IsEnabled(LogLevel.Debug)) { try { var papBuckets = papIndices .Where(kvp => kvp.Value is { Count: > 0 }) .Select(kvp => new { Raw = kvp.Key, Key = XivDataAnalyzer.CanonicalizeSkeletonKey(kvp.Key), Indices = kvp.Value }) .Where(x => x.Indices is { Count: > 0 }) .GroupBy(x => string.IsNullOrEmpty(x.Key) ? x.Raw : x.Key!, StringComparer.OrdinalIgnoreCase) .Select(grp => { var all = grp.SelectMany(v => v.Indices).ToList(); var min = all.Count > 0 ? all.Min() : 0; var max = all.Count > 0 ? all.Max() : 0; var raws = string.Join(',', grp.Select(v => v.Raw).Distinct(StringComparer.OrdinalIgnoreCase)); return $"{grp.Key}(min={min},max={max},raw=[{raws}])"; }) .ToList(); _logger.LogDebug("SEND pap buckets for hash={hash}: {b}", hash, string.Join(" | ", papBuckets)); } catch (Exception ex) { _logger.LogDebug(ex, "Error logging PAP bucket details for hash={hash}", hash); } } bool isCompatible = false; string reason = string.Empty; try { isCompatible = XivDataAnalyzer.IsPapCompatible(localBoneSets, papIndices, mode, allowBasedShift, allownNightIndex, out reason); } catch (Exception ex) { _logger.LogWarning(ex, "Error checking PAP compatibility for hash={hash}, path={path}. Treating as incompatible.", hash, papPathSummary); reason = $"Exception during compatibility check: {ex.Message}"; isCompatible = false; } if (isCompatible) continue; noValidationFailed++; _logger.LogWarning( "Animation PAP is not compatible with local skeletons; dropping mappings for {papPath}. Reason: {reason}", papPathSummary, reason); var removedGamePaths = fragment.FileReplacements .Where(fr => !fr.IsFileSwap && string.Equals(fr.Hash, hash, StringComparison.OrdinalIgnoreCase) && fr.GamePaths.Any(p => p.EndsWith(".pap", StringComparison.OrdinalIgnoreCase))) .SelectMany(fr => fr.GamePaths.Where(p => p.EndsWith(".pap", StringComparison.OrdinalIgnoreCase))) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); fragment.FileReplacements.RemoveWhere(fr => !fr.IsFileSwap && string.Equals(fr.Hash, hash, StringComparison.OrdinalIgnoreCase) && fr.GamePaths.Any(p => p.EndsWith(".pap", StringComparison.OrdinalIgnoreCase))); foreach (var gp in removedGamePaths) _transientResourceManager.RemoveTransientResource(ObjectKind.Player, gp); } if (noValidationFailed > 0) { _lightlessMediator.Publish(new NotificationMessage( "Invalid Skeleton Setup", $"Your client is attempting to send {noValidationFailed} animation files that don't match your current skeleton validation mode ({mode}). " + "Please adjust your skeleton/mods or change the validation mode if this is unexpected. " + "Those animation files have been removed from your sent (player) data. (Check /xllog for details).", NotificationType.Warning, TimeSpan.FromSeconds(10))); } } private async Task> GetFileReplacementsFromPaths( GameObjectHandler handler, HashSet forwardResolve, HashSet reverseResolve) { var forwardPaths = forwardResolve.ToArray(); var reversePaths = reverseResolve.ToArray(); if (forwardPaths.Length == 0 && reversePaths.Length == 0) { return new Dictionary(StringComparer.OrdinalIgnoreCase).AsReadOnly(); } var forwardPathsLower = forwardPaths.Length == 0 ? [] : forwardPaths.Select(p => p.ToLowerInvariant()).ToArray(); var reversePathsLower = reversePaths.Length == 0 ? [] : reversePaths.Select(p => p.ToLowerInvariant()).ToArray(); Dictionary> resolvedPaths = new(forwardPaths.Length + reversePaths.Length, StringComparer.Ordinal); if (handler.ObjectKind != ObjectKind.Player) { var (objectIndex, forwardResolved, reverseResolved) = await _dalamudUtil.RunOnFrameworkThread(() => { var idx = handler.GetGameObject()?.ObjectIndex; if (!idx.HasValue) return ((int?)null, Array.Empty(), Array.Empty()); var resolvedForward = new string[forwardPaths.Length]; for (int i = 0; i < forwardPaths.Length; i++) resolvedForward[i] = _ipcManager.Penumbra.ResolveGameObjectPath(forwardPaths[i], idx.Value); var resolvedReverse = new string[reversePaths.Length][]; for (int i = 0; i < reversePaths.Length; i++) resolvedReverse[i] = _ipcManager.Penumbra.ReverseResolveGameObjectPath(reversePaths[i], idx.Value); return (idx, resolvedForward, resolvedReverse); }).ConfigureAwait(false); if (objectIndex.HasValue) { for (int i = 0; i < forwardPaths.Length; i++) { var filePath = forwardResolved[i]?.ToLowerInvariant(); if (string.IsNullOrEmpty(filePath)) continue; if (resolvedPaths.TryGetValue(filePath, out var list)) list.Add(forwardPaths[i].ToLowerInvariant()); else { resolvedPaths[filePath] = [forwardPathsLower[i]]; } } for (int i = 0; i < reversePaths.Length; i++) { var filePath = reversePathsLower[i]; var reverseResolvedLower = new string[reverseResolved[i].Length]; for (var j = 0; j < reverseResolvedLower.Length; j++) { reverseResolvedLower[j] = reverseResolved[i][j].ToLowerInvariant(); } if (resolvedPaths.TryGetValue(filePath, out var list)) list.AddRange(reverseResolved[i].Select(c => c.ToLowerInvariant())); else resolvedPaths[filePath] = [.. reverseResolved[i].Select(c => c.ToLowerInvariant()).ToList()]; } return resolvedPaths.ToDictionary(k => k.Key, k => k.Value.ToArray(), StringComparer.OrdinalIgnoreCase).AsReadOnly(); } } 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 = reversePathsLower[i]; var reverseResolvedLower = new string[reverse[i].Length]; for (var j = 0; j < reverseResolvedLower.Length; j++) { reverseResolvedLower[j] = reverse[i][j].ToLowerInvariant(); } if (resolvedPaths.TryGetValue(filePath, out var list)) list.AddRange(reverse[i].Select(c => c.ToLowerInvariant())); else resolvedPaths[filePath] = [.. 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) { _transientResourceManager.PersistTransientResources(objectKind); HashSet pathsToResolve = new(StringComparer.Ordinal); int scanned = 0, skippedEmpty = 0, skippedVfx = 0; foreach (var path in _transientResourceManager.GetSemiTransientResources(objectKind)) { scanned++; if (string.IsNullOrEmpty(path)) { skippedEmpty++; continue; } pathsToResolve.Add(path); } if (_logger.IsEnabled(LogLevel.Debug)) { _logger.LogDebug( "ManageSemiTransientData({kind}): scanned={scanned}, added={added}, skippedEmpty={skippedEmpty}, skippedVfx={skippedVfx}", objectKind, scanned, pathsToResolve.Count, skippedEmpty, skippedVfx); } return pathsToResolve; } }