Merged Cake and Abel branched into 2.0.3 (#131)

Co-authored-by: azyges <aaaaaa@aaa.aaa>
Co-authored-by: cake <admin@cakeandbanana.nl>
Co-authored-by: defnotken <itsdefnotken@gmail.com>
Reviewed-on: #131
This commit was merged in pull request #131.
This commit is contained in:
2026-01-05 00:45:14 +00:00
parent e0b8070aa8
commit 30717ba200
67 changed files with 13247 additions and 802 deletions

View File

@@ -6,6 +6,7 @@ using FFXIVClientStructs.Interop;
using FFXIVClientStructs.FFXIV.Client.Game.Character;
using FFXIVClientStructs.FFXIV.Client.Game.Object;
using FFXIVClientStructs.FFXIV.Client.Graphics.Scene;
using LightlessSync.PlayerData.Handlers;
using LightlessSync.Services.Mediator;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
@@ -16,7 +17,7 @@ using LightlessObjectKind = LightlessSync.API.Data.Enum.ObjectKind;
namespace LightlessSync.Services.ActorTracking;
public sealed class ActorObjectService : IHostedService, IDisposable
public sealed class ActorObjectService : IHostedService, IDisposable, IMediatorSubscriber
{
public readonly record struct ActorDescriptor(
string Name,
@@ -36,6 +37,8 @@ public sealed class ActorObjectService : IHostedService, IDisposable
private readonly IClientState _clientState;
private readonly ICondition _condition;
private readonly LightlessMediator _mediator;
private readonly object _playerRelatedHandlerLock = new();
private readonly HashSet<GameObjectHandler> _playerRelatedHandlers = [];
private readonly ConcurrentDictionary<nint, ActorDescriptor> _activePlayers = new();
private readonly ConcurrentDictionary<nint, ActorDescriptor> _gposePlayers = new();
@@ -71,6 +74,25 @@ public sealed class ActorObjectService : IHostedService, IDisposable
_clientState = clientState;
_condition = condition;
_mediator = mediator;
_mediator.Subscribe<GameObjectHandlerCreatedMessage>(this, (msg) =>
{
if (!msg.OwnedObject) return;
lock (_playerRelatedHandlerLock)
{
_playerRelatedHandlers.Add(msg.GameObjectHandler);
}
RefreshTrackedActors(force: true);
});
_mediator.Subscribe<GameObjectHandlerDestroyedMessage>(this, (msg) =>
{
if (!msg.OwnedObject) return;
lock (_playerRelatedHandlerLock)
{
_playerRelatedHandlers.Remove(msg.GameObjectHandler);
}
RefreshTrackedActors(force: true);
});
}
private bool IsZoning => _condition[ConditionFlag.BetweenAreas] || _condition[ConditionFlag.BetweenAreas51];
@@ -84,6 +106,7 @@ public sealed class ActorObjectService : IHostedService, IDisposable
public IReadOnlyList<ActorDescriptor> PlayerDescriptors => Snapshot.PlayerDescriptors;
public IReadOnlyList<ActorDescriptor> OwnedDescriptors => Snapshot.OwnedDescriptors;
public IReadOnlyList<ActorDescriptor> GposeDescriptors => CurrentGposeSnapshot.GposeDescriptors;
public LightlessMediator Mediator => _mediator;
public bool TryGetActorByHash(string hash, out ActorDescriptor descriptor) => _actorsByHash.TryGetValue(hash, out descriptor);
public bool TryGetValidatedActorByHash(string hash, out ActorDescriptor descriptor)
@@ -213,18 +236,25 @@ public sealed class ActorObjectService : IHostedService, IDisposable
return false;
}
public async Task WaitForFullyLoadedAsync(nint address, CancellationToken cancellationToken = default)
public async Task<bool> WaitForFullyLoadedAsync(nint address, CancellationToken cancellationToken = default, int timeOutMs = 30000)
{
if (address == nint.Zero)
throw new ArgumentException("Address cannot be zero.", nameof(address));
var timeoutAt = timeOutMs > 0 ? Environment.TickCount64 + timeOutMs : long.MaxValue;
while (true)
{
cancellationToken.ThrowIfCancellationRequested();
var isLoaded = await _framework.RunOnFrameworkThread(() => IsObjectFullyLoaded(address)).ConfigureAwait(false);
if (!IsZoning && isLoaded)
return;
var loadState = await _framework.RunOnFrameworkThread(() => GetObjectLoadState(address)).ConfigureAwait(false);
if (!loadState.IsValid)
return false;
if (!IsZoning && loadState.IsLoaded)
return true;
if (Environment.TickCount64 >= timeoutAt)
return false;
await Task.Delay(100, cancellationToken).ConfigureAwait(false);
}
@@ -317,6 +347,11 @@ public sealed class ActorObjectService : IHostedService, IDisposable
_actorsByHash.Clear();
_actorsByName.Clear();
_pendingHashResolutions.Clear();
_mediator.UnsubscribeAll(this);
lock (_playerRelatedHandlerLock)
{
_playerRelatedHandlers.Clear();
}
Volatile.Write(ref _snapshot, ActorSnapshot.Empty);
Volatile.Write(ref _gposeSnapshot, GposeSnapshot.Empty);
return Task.CompletedTask;
@@ -493,7 +528,9 @@ public sealed class ActorObjectService : IHostedService, IDisposable
if (objectKind is DalamudObjectKind.MountType or DalamudObjectKind.Companion)
{
var expectedMinionOrMount = GetMinionOrMountAddress(localPlayerAddress, localEntityId);
if (expectedMinionOrMount != nint.Zero && (nint)gameObject == expectedMinionOrMount)
if (expectedMinionOrMount != nint.Zero
&& (nint)gameObject == expectedMinionOrMount
&& IsPlayerRelatedOwnedAddress(expectedMinionOrMount, LightlessObjectKind.MinionOrMount))
{
var resolvedOwner = ownerId != 0 ? ownerId : localEntityId;
return (LightlessObjectKind.MinionOrMount, resolvedOwner);
@@ -507,16 +544,37 @@ public sealed class ActorObjectService : IHostedService, IDisposable
return (null, ownerId);
var expectedPet = GetPetAddress(localPlayerAddress, localEntityId);
if (expectedPet != nint.Zero && (nint)gameObject == expectedPet)
if (expectedPet != nint.Zero
&& (nint)gameObject == expectedPet
&& IsPlayerRelatedOwnedAddress(expectedPet, LightlessObjectKind.Pet))
return (LightlessObjectKind.Pet, ownerId);
var expectedCompanion = GetCompanionAddress(localPlayerAddress, localEntityId);
if (expectedCompanion != nint.Zero && (nint)gameObject == expectedCompanion)
if (expectedCompanion != nint.Zero
&& (nint)gameObject == expectedCompanion
&& IsPlayerRelatedOwnedAddress(expectedCompanion, LightlessObjectKind.Companion))
return (LightlessObjectKind.Companion, ownerId);
return (null, ownerId);
}
private bool IsPlayerRelatedOwnedAddress(nint address, LightlessObjectKind expectedKind)
{
if (address == nint.Zero)
return false;
lock (_playerRelatedHandlerLock)
{
foreach (var handler in _playerRelatedHandlers)
{
if (handler.Address == address && handler.ObjectKind == expectedKind)
return true;
}
}
return false;
}
private unsafe nint GetMinionOrMountAddress(nint localPlayerAddress, uint ownerEntityId)
{
if (localPlayerAddress == nint.Zero)
@@ -524,20 +582,20 @@ public sealed class ActorObjectService : IHostedService, IDisposable
var playerObject = (GameObject*)localPlayerAddress;
var candidateAddress = _objectTable.GetObjectAddress(playerObject->ObjectIndex + 1);
if (ownerEntityId == 0)
return nint.Zero;
if (candidateAddress != nint.Zero)
{
var candidate = (GameObject*)candidateAddress;
var candidateKind = (DalamudObjectKind)candidate->ObjectKind;
if (candidateKind is DalamudObjectKind.MountType or DalamudObjectKind.Companion)
{
if (ownerEntityId == 0 || ResolveOwnerId(candidate) == ownerEntityId)
if (ResolveOwnerId(candidate) == ownerEntityId)
return candidateAddress;
}
}
if (ownerEntityId == 0)
return candidateAddress;
foreach (var obj in _objectTable)
{
if (obj is null || obj.Address == nint.Zero || obj.Address == localPlayerAddress)
@@ -551,7 +609,7 @@ public sealed class ActorObjectService : IHostedService, IDisposable
return obj.Address;
}
return candidateAddress;
return nint.Zero;
}
private unsafe nint GetPetAddress(nint localPlayerAddress, uint ownerEntityId)
@@ -1022,6 +1080,7 @@ public sealed class ActorObjectService : IHostedService, IDisposable
public void Dispose()
{
DisposeHooks();
_mediator.UnsubscribeAll(this);
GC.SuppressFinalize(this);
}
@@ -1143,6 +1202,18 @@ public sealed class ActorObjectService : IHostedService, IDisposable
return results;
}
private LoadState GetObjectLoadState(nint address)
{
if (address == nint.Zero)
return LoadState.Invalid;
var obj = _objectTable.CreateObjectReference(address);
if (obj is null || obj.Address != address)
return LoadState.Invalid;
return new LoadState(true, IsObjectFullyLoaded(address));
}
private static unsafe bool IsObjectFullyLoaded(nint address)
{
if (address == nint.Zero)
@@ -1169,6 +1240,11 @@ public sealed class ActorObjectService : IHostedService, IDisposable
return true;
}
private readonly record struct LoadState(bool IsValid, bool IsLoaded)
{
public static LoadState Invalid => new(false, false);
}
private sealed record OwnedObjectSnapshot(
IReadOnlyList<nint> RenderedPlayers,
IReadOnlyList<nint> RenderedCompanions,

View File

@@ -28,7 +28,7 @@ public sealed class CharacterAnalyzer : MediatorSubscriberBase, IDisposable
{
_baseAnalysisCts = _baseAnalysisCts.CancelRecreate();
var token = _baseAnalysisCts.Token;
_ = BaseAnalysis(msg.CharacterData, token);
_ = Task.Run(async () => await BaseAnalysis(msg.CharacterData, token).ConfigureAwait(false), token);
});
_fileCacheManager = fileCacheManager;
_xivDataAnalyzer = modelAnalyzer;

View File

@@ -22,8 +22,10 @@ using LightlessSync.Utils;
using Lumina.Excel.Sheets;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using System.Diagnostics;
using System.Numerics;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Text;
using BattleNpcSubKind = FFXIVClientStructs.FFXIV.Client.Game.Object.BattleNpcSubKind;
using DalamudObjectKind = Dalamud.Game.ClientState.Objects.Enums.ObjectKind;
@@ -843,31 +845,41 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
return Task.CompletedTask;
}
public async Task WaitWhileCharacterIsDrawing(ILogger logger, GameObjectHandler handler, Guid redrawId, int timeOut = 5000, CancellationToken? ct = null)
public async Task WaitWhileCharacterIsDrawing(
ILogger logger,
GameObjectHandler handler,
Guid redrawId,
int timeOut = 5000,
CancellationToken? ct = null)
{
if (!_clientState.IsLoggedIn) return;
if (ct == null)
ct = CancellationToken.None;
var token = ct ?? CancellationToken.None;
const int tick = 250;
const int initialSettle = 50;
var sw = Stopwatch.StartNew();
const int tick = 250;
int curWaitTime = 0;
try
{
logger.LogTrace("[{redrawId}] Starting wait for {handler} to draw", redrawId, handler);
await Task.Delay(tick, ct.Value).ConfigureAwait(true);
curWaitTime += tick;
while ((!ct.Value.IsCancellationRequested)
&& curWaitTime < timeOut
&& await handler.IsBeingDrawnRunOnFrameworkAsync().ConfigureAwait(false)) // 0b100000000000 is "still rendering" or something
await Task.Delay(initialSettle, token).ConfigureAwait(false);
while (!token.IsCancellationRequested
&& sw.ElapsedMilliseconds < timeOut
&& await handler.IsBeingDrawnRunOnFrameworkAsync().ConfigureAwait(false))
{
logger.LogTrace("[{redrawId}] Waiting for {handler} to finish drawing", redrawId, handler);
curWaitTime += tick;
await Task.Delay(tick, ct.Value).ConfigureAwait(true);
await Task.Delay(tick, token).ConfigureAwait(false);
}
logger.LogTrace("[{redrawId}] Finished drawing after {curWaitTime}ms", redrawId, curWaitTime);
logger.LogTrace("[{redrawId}] Finished drawing after {ms}ms", redrawId, sw.ElapsedMilliseconds);
}
catch (OperationCanceledException)
{
// ignore
}
catch (AccessViolationException ex)
{
@@ -1032,7 +1044,7 @@ public class DalamudUtilService : IHostedService, IMediatorSubscriber
if (actor.ObjectIndex >= 200)
continue;
if (_blockedCharacterHandler.IsCharacterBlocked(playerAddress, out bool firstTime) && firstTime)
if (_blockedCharacterHandler.IsCharacterBlocked(playerAddress, actor.ObjectIndex, out bool firstTime) && firstTime)
{
_logger.LogTrace("Skipping character {addr}, blocked/muted", playerAddress.ToString("X"));
continue;

View File

@@ -73,7 +73,7 @@ public record HubClosedMessage(Exception? Exception) : SameThreadMessage;
public record ResumeScanMessage(string Source) : MessageBase;
public record FileCacheInitializedMessage : MessageBase;
public record DownloadReadyMessage(Guid RequestId) : MessageBase;
public record DownloadStartedMessage(GameObjectHandler DownloadId, Dictionary<string, FileDownloadStatus> DownloadStatus) : MessageBase;
public record DownloadStartedMessage(GameObjectHandler DownloadId, IReadOnlyDictionary<string, FileDownloadStatus> DownloadStatus) : MessageBase;
public record DownloadFinishedMessage(GameObjectHandler DownloadId) : MessageBase;
public record UiToggleMessage(Type UiType) : MessageBase;
public record PlayerUploadingMessage(GameObjectHandler Handler, bool IsUploading) : MessageBase;
@@ -104,6 +104,7 @@ public record PairUiUpdatedMessage(PairUiSnapshot Snapshot) : MessageBase;
public record CensusUpdateMessage(byte Gender, byte RaceId, byte TribeId) : MessageBase;
public record TargetPairMessage(Pair Pair) : MessageBase;
public record PairFocusCharacterMessage(Pair Pair) : SameThreadMessage;
public record PairOnlineMessage(PairUniqueIdentifier PairIdent) : MessageBase;
public record CombatStartMessage : MessageBase;
public record CombatEndMessage : MessageBase;
public record PerformanceStartMessage : MessageBase;
@@ -138,4 +139,4 @@ public record OpenUserProfileMessage(UserData User) : MessageBase;
public record LocationSharingMessage(UserData User, LocationInfo LocationInfo, DateTimeOffset ExpireAt) : MessageBase;
public record MapChangedMessage(uint MapId) : MessageBase;
#pragma warning restore S2094
#pragma warning restore MA0048 // File name must match type name
#pragma warning restore MA0048 // File name must match type name

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,381 @@
using LightlessSync.FileCache;
using LightlessSync.LightlessConfiguration;
using Microsoft.Extensions.Logging;
using System.Collections.Concurrent;
using System.Globalization;
namespace LightlessSync.Services.ModelDecimation;
public sealed class ModelDecimationService
{
private const int MaxConcurrentJobs = 1;
private const double MinTargetRatio = 0.01;
private const double MaxTargetRatio = 0.99;
private readonly ILogger<ModelDecimationService> _logger;
private readonly LightlessConfigService _configService;
private readonly FileCacheManager _fileCacheManager;
private readonly PlayerPerformanceConfigService _performanceConfigService;
private readonly XivDataStorageService _xivDataStorageService;
private readonly SemaphoreSlim _decimationSemaphore = new(MaxConcurrentJobs);
private readonly ConcurrentDictionary<string, Task> _activeJobs = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, string> _decimatedPaths = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, byte> _failedHashes = new(StringComparer.OrdinalIgnoreCase);
public ModelDecimationService(
ILogger<ModelDecimationService> logger,
LightlessConfigService configService,
FileCacheManager fileCacheManager,
PlayerPerformanceConfigService performanceConfigService,
XivDataStorageService xivDataStorageService)
{
_logger = logger;
_configService = configService;
_fileCacheManager = fileCacheManager;
_performanceConfigService = performanceConfigService;
_xivDataStorageService = xivDataStorageService;
}
public void ScheduleDecimation(string hash, string filePath, string? gamePath = null)
{
if (!ShouldScheduleDecimation(hash, filePath, gamePath))
{
return;
}
if (_decimatedPaths.ContainsKey(hash) || _failedHashes.ContainsKey(hash) || _activeJobs.ContainsKey(hash))
{
return;
}
_logger.LogInformation("Queued model decimation for {Hash}", hash);
_activeJobs[hash] = Task.Run(async () =>
{
await _decimationSemaphore.WaitAsync().ConfigureAwait(false);
try
{
await DecimateInternalAsync(hash, filePath).ConfigureAwait(false);
}
catch (Exception ex)
{
_failedHashes[hash] = 1;
_logger.LogWarning(ex, "Model decimation failed for {Hash}", hash);
}
finally
{
_decimationSemaphore.Release();
_activeJobs.TryRemove(hash, out _);
}
}, CancellationToken.None);
}
public bool ShouldScheduleDecimation(string hash, string filePath, string? gamePath = null)
=> IsDecimationEnabled()
&& filePath.EndsWith(".mdl", StringComparison.OrdinalIgnoreCase)
&& IsDecimationAllowed(gamePath)
&& !ShouldSkipByTriangleCache(hash);
public string GetPreferredPath(string hash, string originalPath)
{
if (!IsDecimationEnabled())
{
return originalPath;
}
if (_decimatedPaths.TryGetValue(hash, out var existing) && File.Exists(existing))
{
return existing;
}
var resolved = GetExistingDecimatedPath(hash);
if (!string.IsNullOrEmpty(resolved))
{
_decimatedPaths[hash] = resolved;
return resolved;
}
return originalPath;
}
public Task WaitForPendingJobsAsync(IEnumerable<string>? hashes, CancellationToken token)
{
if (hashes is null)
{
return Task.CompletedTask;
}
var pending = new List<Task>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var hash in hashes)
{
if (string.IsNullOrEmpty(hash) || !seen.Add(hash))
{
continue;
}
if (_activeJobs.TryGetValue(hash, out var job))
{
pending.Add(job);
}
}
if (pending.Count == 0)
{
return Task.CompletedTask;
}
return Task.WhenAll(pending).WaitAsync(token);
}
private Task DecimateInternalAsync(string hash, string sourcePath)
{
if (!File.Exists(sourcePath))
{
_failedHashes[hash] = 1;
_logger.LogWarning("Cannot decimate model {Hash}; source path missing: {Path}", hash, sourcePath);
return Task.CompletedTask;
}
if (!TryGetDecimationSettings(out var triangleThreshold, out var targetRatio))
{
_logger.LogInformation("Model decimation disabled or invalid settings for {Hash}", hash);
return Task.CompletedTask;
}
_logger.LogInformation("Starting model decimation for {Hash} (threshold {Threshold}, ratio {Ratio:0.##})", hash, triangleThreshold, targetRatio);
var destination = Path.Combine(GetDecimatedDirectory(), $"{hash}.mdl");
if (File.Exists(destination))
{
RegisterDecimatedModel(hash, sourcePath, destination);
return Task.CompletedTask;
}
if (!MdlDecimator.TryDecimate(sourcePath, destination, triangleThreshold, targetRatio, _logger))
{
_failedHashes[hash] = 1;
_logger.LogInformation("Model decimation skipped for {Hash}", hash);
return Task.CompletedTask;
}
RegisterDecimatedModel(hash, sourcePath, destination);
_logger.LogInformation("Decimated model {Hash} -> {Path}", hash, destination);
return Task.CompletedTask;
}
private void RegisterDecimatedModel(string hash, string sourcePath, string destination)
{
_decimatedPaths[hash] = destination;
var performanceConfig = _performanceConfigService.Current;
if (performanceConfig.KeepOriginalModelFiles)
{
return;
}
if (string.Equals(sourcePath, destination, StringComparison.OrdinalIgnoreCase))
{
return;
}
if (!TryReplaceCacheEntryWithDecimated(hash, sourcePath, destination))
{
return;
}
TryDelete(sourcePath);
}
private bool TryReplaceCacheEntryWithDecimated(string hash, string sourcePath, string destination)
{
try
{
var cacheEntry = _fileCacheManager.GetFileCacheByHash(hash);
if (cacheEntry is null || !cacheEntry.IsCacheEntry)
{
return File.Exists(sourcePath) ? false : true;
}
var cacheFolder = _configService.Current.CacheFolder;
if (string.IsNullOrEmpty(cacheFolder))
{
return false;
}
if (!destination.StartsWith(cacheFolder, StringComparison.OrdinalIgnoreCase))
{
return false;
}
var info = new FileInfo(destination);
if (!info.Exists)
{
return false;
}
var relative = Path.GetRelativePath(cacheFolder, destination)
.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar);
var sanitizedRelative = relative.TrimStart(Path.DirectorySeparatorChar);
var prefixed = Path.Combine(FileCacheManager.CachePrefix, sanitizedRelative);
var replacement = new FileCacheEntity(
hash,
prefixed,
info.LastWriteTimeUtc.Ticks.ToString(CultureInfo.InvariantCulture),
info.Length,
cacheEntry.CompressedSize);
replacement.SetResolvedFilePath(destination);
if (!string.Equals(cacheEntry.PrefixedFilePath, prefixed, StringComparison.OrdinalIgnoreCase))
{
_fileCacheManager.RemoveHashedFile(cacheEntry.Hash, cacheEntry.PrefixedFilePath, removeDerivedFiles: false);
}
_fileCacheManager.UpdateHashedFile(replacement, computeProperties: false);
_fileCacheManager.WriteOutFullCsv();
_logger.LogTrace("Replaced cache entry for model {Hash} to decimated path {Path}", hash, destination);
return true;
}
catch (Exception ex)
{
_logger.LogTrace(ex, "Failed to replace cache entry for model {Hash}", hash);
return false;
}
}
private bool IsDecimationEnabled()
=> _performanceConfigService.Current.EnableModelDecimation;
private bool ShouldSkipByTriangleCache(string hash)
{
if (string.IsNullOrEmpty(hash))
{
return false;
}
if (!_xivDataStorageService.Current.TriangleDictionary.TryGetValue(hash, out var cachedTris) || cachedTris <= 0)
{
return false;
}
var threshold = Math.Max(0, _performanceConfigService.Current.ModelDecimationTriangleThreshold);
return threshold > 0 && cachedTris < threshold;
}
private bool IsDecimationAllowed(string? gamePath)
{
if (string.IsNullOrWhiteSpace(gamePath))
{
return true;
}
var normalized = NormalizeGamePath(gamePath);
if (normalized.Contains("/hair/", StringComparison.Ordinal))
{
return false;
}
if (normalized.Contains("/chara/equipment/", StringComparison.Ordinal))
{
return _performanceConfigService.Current.ModelDecimationAllowClothing;
}
if (normalized.Contains("/chara/accessory/", StringComparison.Ordinal))
{
return _performanceConfigService.Current.ModelDecimationAllowAccessories;
}
if (normalized.Contains("/chara/human/", StringComparison.Ordinal))
{
if (normalized.Contains("/body/", StringComparison.Ordinal))
{
return _performanceConfigService.Current.ModelDecimationAllowBody;
}
if (normalized.Contains("/face/", StringComparison.Ordinal) || normalized.Contains("/head/", StringComparison.Ordinal))
{
return _performanceConfigService.Current.ModelDecimationAllowFaceHead;
}
if (normalized.Contains("/tail/", StringComparison.Ordinal))
{
return _performanceConfigService.Current.ModelDecimationAllowTail;
}
}
return true;
}
private static string NormalizeGamePath(string path)
=> path.Replace('\\', '/').ToLowerInvariant();
private bool TryGetDecimationSettings(out int triangleThreshold, out double targetRatio)
{
triangleThreshold = 15_000;
targetRatio = 0.8;
var config = _performanceConfigService.Current;
if (!config.EnableModelDecimation)
{
return false;
}
triangleThreshold = Math.Max(0, config.ModelDecimationTriangleThreshold);
targetRatio = config.ModelDecimationTargetRatio;
if (double.IsNaN(targetRatio) || double.IsInfinity(targetRatio))
{
return false;
}
targetRatio = Math.Clamp(targetRatio, MinTargetRatio, MaxTargetRatio);
return true;
}
private string? GetExistingDecimatedPath(string hash)
{
var candidate = Path.Combine(GetDecimatedDirectory(), $"{hash}.mdl");
return File.Exists(candidate) ? candidate : null;
}
private string GetDecimatedDirectory()
{
var directory = Path.Combine(_configService.Current.CacheFolder, "decimated");
if (!Directory.Exists(directory))
{
try
{
Directory.CreateDirectory(directory);
}
catch (Exception ex)
{
_logger.LogTrace(ex, "Failed to create decimated directory {Directory}", directory);
}
}
return directory;
}
private static void TryDelete(string? path)
{
if (string.IsNullOrEmpty(path))
{
return;
}
try
{
if (File.Exists(path))
{
File.Delete(path);
}
}
catch
{
// ignored
}
}
}

View File

@@ -4,6 +4,7 @@ using LightlessSync.LightlessConfiguration;
using LightlessSync.PlayerData.Pairs;
using LightlessSync.Services.Events;
using LightlessSync.Services.Mediator;
using LightlessSync.Services.ModelDecimation;
using LightlessSync.Services.TextureCompression;
using LightlessSync.UI;
using LightlessSync.WebAPI.Files.Models;
@@ -18,12 +19,14 @@ public class PlayerPerformanceService
private readonly ILogger<PlayerPerformanceService> _logger;
private readonly LightlessMediator _mediator;
private readonly PlayerPerformanceConfigService _playerPerformanceConfigService;
private readonly ModelDecimationService _modelDecimationService;
private readonly TextureDownscaleService _textureDownscaleService;
private readonly Dictionary<string, bool> _warnedForPlayers = new(StringComparer.Ordinal);
public PlayerPerformanceService(ILogger<PlayerPerformanceService> logger, LightlessMediator mediator,
PlayerPerformanceConfigService playerPerformanceConfigService, FileCacheManager fileCacheManager,
XivDataAnalyzer xivDataAnalyzer, TextureDownscaleService textureDownscaleService)
XivDataAnalyzer xivDataAnalyzer, TextureDownscaleService textureDownscaleService,
ModelDecimationService modelDecimationService)
{
_logger = logger;
_mediator = mediator;
@@ -31,6 +34,7 @@ public class PlayerPerformanceService
_fileCacheManager = fileCacheManager;
_xivDataAnalyzer = xivDataAnalyzer;
_textureDownscaleService = textureDownscaleService;
_modelDecimationService = modelDecimationService;
}
public async Task<bool> CheckBothThresholds(IPairPerformanceSubject pairHandler, CharacterData charaData)
@@ -111,10 +115,12 @@ public class PlayerPerformanceService
var config = _playerPerformanceConfigService.Current;
long triUsage = 0;
long effectiveTriUsage = 0;
if (!charaData.FileReplacements.TryGetValue(API.Data.Enum.ObjectKind.Player, out List<FileReplacementData>? playerReplacements))
{
pairHandler.LastAppliedDataTris = 0;
pairHandler.LastAppliedApproximateEffectiveTris = 0;
return true;
}
@@ -123,14 +129,40 @@ public class PlayerPerformanceService
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
var skipDecimation = config.SkipModelDecimationForPreferredPairs && pairHandler.IsDirectlyPaired && pairHandler.HasStickyPermissions;
foreach (var hash in moddedModelHashes)
{
triUsage += await _xivDataAnalyzer.GetTrianglesByHash(hash).ConfigureAwait(false);
var tris = await _xivDataAnalyzer.GetTrianglesByHash(hash).ConfigureAwait(false);
triUsage += tris;
long effectiveTris = tris;
var fileEntry = _fileCacheManager.GetFileCacheByHash(hash);
if (fileEntry != null)
{
var preferredPath = fileEntry.ResolvedFilepath;
if (!skipDecimation)
{
preferredPath = _modelDecimationService.GetPreferredPath(hash, fileEntry.ResolvedFilepath);
}
if (!string.Equals(preferredPath, fileEntry.ResolvedFilepath, StringComparison.OrdinalIgnoreCase))
{
var decimatedTris = await _xivDataAnalyzer.GetEffectiveTrianglesByHash(hash, preferredPath).ConfigureAwait(false);
if (decimatedTris > 0)
{
effectiveTris = decimatedTris;
}
}
}
effectiveTriUsage += effectiveTris;
}
pairHandler.LastAppliedDataTris = triUsage;
pairHandler.LastAppliedApproximateEffectiveTris = effectiveTriUsage;
_logger.LogDebug("Calculated VRAM usage for {p}", pairHandler);
_logger.LogDebug("Calculated triangle usage for {p}", pairHandler);
// no warning of any kind on ignored pairs
if (config.UIDsToIgnore
@@ -167,7 +199,9 @@ public class PlayerPerformanceService
public bool ComputeAndAutoPauseOnVRAMUsageThresholds(IPairPerformanceSubject pairHandler, CharacterData charaData, List<DownloadFileTransfer> toDownloadFiles)
{
var config = _playerPerformanceConfigService.Current;
bool skipDownscale = pairHandler.IsDirectlyPaired && pairHandler.HasStickyPermissions;
bool skipDownscale = config.SkipTextureDownscaleForPreferredPairs
&& pairHandler.IsDirectlyPaired
&& pairHandler.HasStickyPermissions;
long vramUsage = 0;
long effectiveVramUsage = 0;
@@ -274,4 +308,4 @@ public class PlayerPerformanceService
private static bool CheckForThreshold(bool thresholdEnabled, long threshold, long value, bool checkForPrefPerm, bool isPrefPerm) =>
thresholdEnabled && threshold > 0 && threshold < value && ((checkForPrefPerm && isPrefPerm) || !isPrefPerm);
}
}

View File

@@ -77,16 +77,39 @@ public sealed class TextureDownscaleService
}
public void ScheduleDownscale(string hash, string filePath, TextureMapKind mapKind)
=> ScheduleDownscale(hash, filePath, () => mapKind);
public void ScheduleDownscale(string hash, string filePath, Func<TextureMapKind> mapKindFactory)
{
if (!filePath.EndsWith(".tex", StringComparison.OrdinalIgnoreCase)) return;
if (_activeJobs.ContainsKey(hash)) return;
_activeJobs[hash] = Task.Run(async () =>
{
TextureMapKind mapKind;
try
{
mapKind = mapKindFactory();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to determine texture map kind for {Hash}; skipping downscale", hash);
return;
}
await DownscaleInternalAsync(hash, filePath, mapKind).ConfigureAwait(false);
}, CancellationToken.None);
}
public bool ShouldScheduleDownscale(string filePath)
{
if (!filePath.EndsWith(".tex", StringComparison.OrdinalIgnoreCase))
return false;
var performanceConfig = _playerPerformanceConfigService.Current;
return performanceConfig.EnableNonIndexTextureMipTrim || performanceConfig.EnableIndexTextureDownscale;
}
public string GetPreferredPath(string hash, string originalPath)
{
if (_downscaledPaths.TryGetValue(hash, out var existing) && File.Exists(existing))
@@ -655,7 +678,7 @@ public sealed class TextureDownscaleService
if (!string.Equals(cacheEntry.PrefixedFilePath, prefixed, StringComparison.OrdinalIgnoreCase))
{
_fileCacheManager.RemoveHashedFile(cacheEntry.Hash, cacheEntry.PrefixedFilePath);
_fileCacheManager.RemoveHashedFile(cacheEntry.Hash, cacheEntry.PrefixedFilePath, removeDerivedFiles: false);
}
_fileCacheManager.UpdateHashedFile(replacement, computeProperties: false);

View File

@@ -6,18 +6,22 @@ using FFXIVClientStructs.Havok.Common.Serialize.Util;
using LightlessSync.FileCache;
using LightlessSync.Interop.GameModel;
using LightlessSync.LightlessConfiguration;
using LightlessSync.PlayerData.Factories;
using LightlessSync.PlayerData.Handlers;
using Microsoft.Extensions.Logging;
using System.Collections.Concurrent;
using System.Runtime.InteropServices;
using System.Text.RegularExpressions;
namespace LightlessSync.Services;
public sealed class XivDataAnalyzer
public sealed partial class XivDataAnalyzer
{
private readonly ILogger<XivDataAnalyzer> _logger;
private readonly FileCacheManager _fileCacheManager;
private readonly XivDataStorageService _configService;
private readonly List<string> _failedCalculatedTris = [];
private readonly List<string> _failedCalculatedEffectiveTris = [];
public XivDataAnalyzer(ILogger<XivDataAnalyzer> logger, FileCacheManager fileCacheManager,
XivDataStorageService configService)
@@ -29,127 +33,441 @@ public sealed class XivDataAnalyzer
public unsafe Dictionary<string, List<ushort>>? 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<string, List<ushort>> outputIndices = [];
if (handler is null || handler.Address == nint.Zero)
return null;
Dictionary<string, HashSet<ushort>> sets = new(StringComparer.OrdinalIgnoreCase);
try
{
for (int i = 0; i < chara->Skeleton->PartialSkeletonCount; i++)
var drawObject = ((Character*)handler.Address)->GameObject.DrawObject;
if (drawObject == null)
return null;
var chara = (CharacterBase*)drawObject;
if (chara->GetModelType() != CharacterBase.ModelType.Human)
return null;
var skeleton = chara->Skeleton;
if (skeleton == null)
return null;
var resHandles = skeleton->SkeletonResourceHandles;
var partialCount = skeleton->PartialSkeletonCount;
if (partialCount <= 0)
return null;
for (int i = 0; i < partialCount; 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->FileName.Length > 1024) continue;
var skeletonName = handle->FileName.ToString();
if (string.IsNullOrEmpty(skeletonName)) continue;
outputIndices[skeletonName] = [];
for (ushort boneIdx = 0; boneIdx < curBones; boneIdx++)
if ((nint)handle == nint.Zero)
continue;
if (handle->FileName.Length > 1024)
continue;
var rawName = handle->FileName.ToString();
if (string.IsNullOrWhiteSpace(rawName))
continue;
var skeletonKey = CanonicalizeSkeletonKey(rawName);
if (string.IsNullOrEmpty(skeletonKey))
continue;
var boneCount = handle->BoneCount;
if (boneCount == 0)
continue;
var havokSkel = handle->HavokSkeleton;
if ((nint)havokSkel == nint.Zero)
continue;
if (!sets.TryGetValue(skeletonKey, out var set))
{
var boneName = handle->HavokSkeleton->Bones[boneIdx].Name.String;
if (boneName == null) continue;
outputIndices[skeletonName].Add((ushort)(boneIdx + 1));
set = [];
sets[skeletonKey] = set;
}
uint maxExclusive = boneCount;
uint ushortExclusive = (uint)ushort.MaxValue + 1u;
if (maxExclusive > ushortExclusive)
maxExclusive = ushortExclusive;
for (uint boneIdx = 0; boneIdx < maxExclusive; boneIdx++)
{
var name = havokSkel->Bones[boneIdx].Name.String;
if (name == null)
continue;
set.Add((ushort)boneIdx);
}
_logger.LogTrace("Local skeleton raw file='{raw}', key='{key}', boneCount={count}",
rawName, skeletonKey, boneCount);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not process skeleton data");
return null;
}
return (outputIndices.Count != 0 && outputIndices.Values.All(u => u.Count > 0)) ? outputIndices : null;
if (sets.Count == 0)
return null;
var output = new Dictionary<string, List<ushort>>(sets.Count, StringComparer.OrdinalIgnoreCase);
foreach (var (key, set) in sets)
{
if (set.Count == 0)
continue;
var list = set.ToList();
list.Sort();
output[key] = list;
}
return (output.Count != 0 && output.Values.All(v => v.Count > 0)) ? output : null;
}
public unsafe Dictionary<string, List<ushort>>? GetBoneIndicesFromPap(string hash)
public unsafe Dictionary<string, List<ushort>>? GetBoneIndicesFromPap(string hash, bool persistToConfig = true)
{
if (_configService.Current.BonesDictionary.TryGetValue(hash, out var bones)) return bones;
if (string.IsNullOrWhiteSpace(hash))
return null;
if (_configService.Current.BonesDictionary.TryGetValue(hash, out var cached) && cached is not null)
return cached;
var cacheEntity = _fileCacheManager.GetFileCacheByHash(hash);
if (cacheEntity == null) return null;
if (cacheEntity == null || string.IsNullOrEmpty(cacheEntity.ResolvedFilepath) || !File.Exists(cacheEntity.ResolvedFilepath))
return null;
using BinaryReader reader = new(File.Open(cacheEntity.ResolvedFilepath, FileMode.Open, FileAccess.Read, FileShare.Read));
using var fs = File.Open(cacheEntity.ResolvedFilepath, FileMode.Open, FileAccess.Read, FileShare.Read);
using var reader = new BinaryReader(fs);
// 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
// PAP header (mostly from vfxeditor)
_ = reader.ReadInt32(); // ignore
_ = reader.ReadInt32(); // ignore
_ = reader.ReadInt16(); // num animations
_ = reader.ReadInt16(); // modelid
var type = reader.ReadByte(); // type
if (type != 0)
return null; // not human
_ = reader.ReadByte(); // variant
_ = reader.ReadInt32(); // ignore
reader.ReadByte(); // read 1 (variant)
reader.ReadInt32(); // ignore
var havokPosition = reader.ReadInt32();
var footerPosition = reader.ReadInt32();
var havokDataSize = footerPosition - havokPosition;
// sanity checks
if (havokPosition <= 0 || footerPosition <= havokPosition || footerPosition > fs.Length)
return null;
var havokDataSizeLong = (long)footerPosition - havokPosition;
if (havokDataSizeLong <= 8 || havokDataSizeLong > int.MaxValue)
return null;
var havokDataSize = (int)havokDataSizeLong;
reader.BaseStream.Position = havokPosition;
var havokData = reader.ReadBytes(havokDataSize);
if (havokData.Length <= 8) return null; // no havok data
if (havokData.Length <= 8)
return null;
var output = new Dictionary<string, List<ushort>>(StringComparer.OrdinalIgnoreCase);
var tempHavokDataPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()) + ".hkx";
var tempHavokDataPathAnsi = Marshal.StringToHGlobalAnsi(tempHavokDataPath);
var tempSets = new Dictionary<string, HashSet<ushort>>(StringComparer.OrdinalIgnoreCase);
var tempHavokDataPath = Path.Combine(Path.GetTempPath(), $"lightless_{Guid.NewGuid():N}.hkx");
IntPtr tempHavokDataPathAnsi = IntPtr.Zero;
try
{
File.WriteAllBytes(tempHavokDataPath, havokData);
if (!File.Exists(tempHavokDataPath))
{
_logger.LogTrace("Temporary havok file did not exist when attempting to load: {path}", tempHavokDataPath);
return null;
}
tempHavokDataPathAnsi = Marshal.StringToHGlobalAnsi(tempHavokDataPath);
var loadoptions = stackalloc hkSerializeUtil.LoadOptions[1];
loadoptions->TypeInfoRegistry = hkBuiltinTypeRegistry.Instance()->GetTypeInfoRegistry();
loadoptions->ClassNameRegistry = hkBuiltinTypeRegistry.Instance()->GetClassNameRegistry();
loadoptions->Flags = new hkFlags<hkSerializeUtil.LoadOptionBits, int>
{
Storage = (int)(hkSerializeUtil.LoadOptionBits.Default)
Storage = (int)hkSerializeUtil.LoadOptionBits.Default
};
var resource = hkSerializeUtil.LoadFromFile((byte*)tempHavokDataPathAnsi, null, loadoptions);
if (resource == null)
{
throw new InvalidOperationException("Resource was null after loading");
_logger.LogWarning("Havok resource was null after loading from {path}", tempHavokDataPath);
return null;
}
var rootLevelName = @"hkRootLevelContainer"u8;
fixed (byte* n1 = rootLevelName)
{
var container = (hkRootLevelContainer*)resource->GetContentsPointer(n1, hkBuiltinTypeRegistry.Instance()->GetTypeInfoRegistry());
if (container == null)
return null;
var animationName = @"hkaAnimationContainer"u8;
fixed (byte* n2 = animationName)
{
var animContainer = (hkaAnimationContainer*)container->findObjectByName(n2, null);
if (animContainer == null)
return null;
for (int i = 0; i < animContainer->Bindings.Length; i++)
{
var binding = animContainer->Bindings[i].ptr;
if (binding == null)
continue;
var rawSkel = binding->OriginalSkeletonName.String;
var skeletonKey = CanonicalizeSkeletonKey(rawSkel);
if (string.IsNullOrEmpty(skeletonKey))
continue;
var boneTransform = binding->TransformTrackToBoneIndices;
string name = binding->OriginalSkeletonName.String! + "_" + i;
output[name] = [];
if (boneTransform.Length <= 0)
continue;
if (!tempSets.TryGetValue(skeletonKey, out var set))
{
set = [];
tempSets[skeletonKey] = set;
}
for (int boneIdx = 0; boneIdx < boneTransform.Length; boneIdx++)
{
output[name].Add((ushort)boneTransform[boneIdx]);
var v = boneTransform[boneIdx];
if (v < 0) continue;
set.Add((ushort)v);
}
output[name].Sort();
}
}
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not load havok file in {path}", tempHavokDataPath);
return null;
}
finally
{
Marshal.FreeHGlobal(tempHavokDataPathAnsi);
File.Delete(tempHavokDataPath);
if (tempHavokDataPathAnsi != IntPtr.Zero)
Marshal.FreeHGlobal(tempHavokDataPathAnsi);
try
{
if (File.Exists(tempHavokDataPath))
File.Delete(tempHavokDataPath);
}
catch (Exception ex)
{
_logger.LogTrace(ex, "Could not delete temporary havok file: {path}", tempHavokDataPath);
}
}
if (tempSets.Count == 0)
return null;
var output = new Dictionary<string, List<ushort>>(tempSets.Count, StringComparer.OrdinalIgnoreCase);
foreach (var (key, set) in tempSets)
{
if (set.Count == 0) continue;
var list = set.ToList();
list.Sort();
output[key] = list;
}
if (output.Count == 0)
return null;
_configService.Current.BonesDictionary[hash] = output;
_configService.Save();
if (persistToConfig)
_configService.Save();
return output;
}
public static string CanonicalizeSkeletonKey(string? raw)
{
if (string.IsNullOrWhiteSpace(raw))
return string.Empty;
var s = raw.Replace('\\', '/').Trim();
var underscore = s.LastIndexOf('_');
if (underscore > 0 && underscore + 1 < s.Length && char.IsDigit(s[underscore + 1]))
s = s[..underscore];
if (s.StartsWith("skeleton", StringComparison.OrdinalIgnoreCase))
return "skeleton";
var m = _bucketPathRegex.Match(s);
if (m.Success)
return m.Groups["bucket"].Value.ToLowerInvariant();
m = _bucketSklRegex.Match(s);
if (m.Success)
return m.Groups["bucket"].Value.ToLowerInvariant();
m = _bucketLooseRegex.Match(s);
if (m.Success)
return m.Groups["bucket"].Value.ToLowerInvariant();
return string.Empty;
}
public static bool ContainsIndexCompat(
HashSet<ushort> available,
ushort idx,
bool papLikelyOneBased,
bool allowOneBasedShift,
bool allowNeighborTolerance)
{
Span<ushort> candidates = stackalloc ushort[2];
int count = 0;
candidates[count++] = idx;
if (allowOneBasedShift && papLikelyOneBased && idx > 0)
candidates[count++] = (ushort)(idx - 1);
for (int i = 0; i < count; i++)
{
var c = candidates[i];
if (available.Contains(c))
return true;
if (allowNeighborTolerance)
{
if (c > 0 && available.Contains((ushort)(c - 1)))
return true;
if (c < ushort.MaxValue && available.Contains((ushort)(c + 1)))
return true;
}
}
return false;
}
public static bool IsPapCompatible(
IReadOnlyDictionary<string, HashSet<ushort>> localBoneSets,
IReadOnlyDictionary<string, List<ushort>> papBoneIndices,
AnimationValidationMode mode,
bool allowOneBasedShift,
bool allowNeighborTolerance,
out string reason)
{
reason = string.Empty;
if (mode == AnimationValidationMode.Unsafe)
return true;
var papBuckets = papBoneIndices.Keys
.Select(CanonicalizeSkeletonKey)
.Where(k => !string.IsNullOrEmpty(k))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (papBuckets.Count == 0)
{
reason = "No skeleton bucket bindings found in the PAP";
return false;
}
if (mode == AnimationValidationMode.Safe)
{
if (papBuckets.Any(b => localBoneSets.ContainsKey(b)))
return true;
reason = $"No matching skeleton bucket between PAP [{string.Join(", ", papBuckets)}] and local [{string.Join(", ", localBoneSets.Keys.Order())}].";
return false;
}
foreach (var bucket in papBuckets)
{
if (!localBoneSets.TryGetValue(bucket, out var available))
{
reason = $"Missing skeleton bucket '{bucket}' on local actor.";
return false;
}
var indices = papBoneIndices
.Where(kvp => string.Equals(CanonicalizeSkeletonKey(kvp.Key), bucket, StringComparison.OrdinalIgnoreCase))
.SelectMany(kvp => kvp.Value ?? Enumerable.Empty<ushort>())
.Distinct()
.ToList();
if (indices.Count == 0)
continue;
bool has0 = false, has1 = false;
ushort min = ushort.MaxValue;
foreach (var v in indices)
{
if (v == 0) has0 = true;
if (v == 1) has1 = true;
if (v < min) min = v;
}
bool papLikelyOneBased = allowOneBasedShift && (min == 1) && has1 && !has0;
foreach (var idx in indices)
{
if (!ContainsIndexCompat(available, idx, papLikelyOneBased, allowOneBasedShift, allowNeighborTolerance))
{
reason = $"No compatible local skeleton for PAP '{bucket}': missing bone index {idx}.";
return false;
}
}
}
return true;
}
public void DumpLocalSkeletonIndices(GameObjectHandler handler, string? filter = null)
{
var skels = GetSkeletonBoneIndices(handler);
if (skels == null)
{
_logger.LogTrace("DumpLocalSkeletonIndices: local skeleton indices are null or not found");
return;
}
var keys = skels.Keys
.Order(StringComparer.OrdinalIgnoreCase)
.ToArray();
_logger.LogTrace("Local skeleton indices found ({count}): {keys}",
keys.Length,
string.Join(", ", keys));
if (!string.IsNullOrWhiteSpace(filter))
{
var hits = keys.Where(k =>
k.Equals(filter, StringComparison.OrdinalIgnoreCase) ||
k.StartsWith(filter + "_", StringComparison.OrdinalIgnoreCase) ||
filter.StartsWith(k + "_", StringComparison.OrdinalIgnoreCase) ||
k.Contains(filter, StringComparison.OrdinalIgnoreCase))
.ToArray();
_logger.LogTrace("Matches found for '{filter}': {hits}",
filter,
hits.Length == 0 ? "<none>" : string.Join(", ", hits));
}
}
public async Task<long> GetTrianglesByHash(string hash)
{
if (_configService.Current.TriangleDictionary.TryGetValue(hash, out var cachedTris) && cachedTris > 0)
@@ -162,16 +480,41 @@ public sealed class XivDataAnalyzer
if (path == null || !path.ResolvedFilepath.EndsWith(".mdl", StringComparison.OrdinalIgnoreCase))
return 0;
var filePath = path.ResolvedFilepath;
return CalculateTrianglesFromPath(hash, path.ResolvedFilepath, _configService.Current.TriangleDictionary, _failedCalculatedTris);
}
public async Task<long> GetEffectiveTrianglesByHash(string hash, string filePath)
{
if (_configService.Current.EffectiveTriangleDictionary.TryGetValue(hash, out var cachedTris) && cachedTris > 0)
return cachedTris;
if (_failedCalculatedEffectiveTris.Contains(hash, StringComparer.Ordinal))
return 0;
if (string.IsNullOrEmpty(filePath)
|| !filePath.EndsWith(".mdl", StringComparison.OrdinalIgnoreCase)
|| !File.Exists(filePath))
{
return 0;
}
return CalculateTrianglesFromPath(hash, filePath, _configService.Current.EffectiveTriangleDictionary, _failedCalculatedEffectiveTris);
}
private long CalculateTrianglesFromPath(
string hash,
string filePath,
ConcurrentDictionary<string, long> cache,
List<string> failedList)
{
try
{
_logger.LogDebug("Detected Model File {path}, calculating Tris", filePath);
var file = new MdlFile(filePath);
if (file.LodCount <= 0)
{
_failedCalculatedTris.Add(hash);
_configService.Current.TriangleDictionary[hash] = 0;
failedList.Add(hash);
cache[hash] = 0;
_configService.Save();
return 0;
}
@@ -195,7 +538,7 @@ public sealed class XivDataAnalyzer
if (tris > 0)
{
_logger.LogDebug("TriAnalysis: {filePath} => {tris} triangles", filePath, tris);
_configService.Current.TriangleDictionary[hash] = tris;
cache[hash] = tris;
_configService.Save();
break;
}
@@ -205,11 +548,30 @@ public sealed class XivDataAnalyzer
}
catch (Exception e)
{
_failedCalculatedTris.Add(hash);
_configService.Current.TriangleDictionary[hash] = 0;
failedList.Add(hash);
cache[hash] = 0;
_configService.Save();
_logger.LogWarning(e, "Could not parse file {file}", filePath);
return 0;
}
}
// Regexes for canonicalizing skeleton keys
private static readonly Regex _bucketPathRegex =
BucketRegex();
private static readonly Regex _bucketSklRegex =
SklRegex();
private static readonly Regex _bucketLooseRegex =
LooseBucketRegex();
[GeneratedRegex(@"(?i)(?:^|/)(?<bucket>c\d{4})(?:/|$)", RegexOptions.Compiled, "en-NL")]
private static partial Regex BucketRegex();
[GeneratedRegex(@"(?i)\bskl_(?<bucket>c\d{4})[a-z]\d{4}\b", RegexOptions.Compiled, "en-NL")]
private static partial Regex SklRegex();
[GeneratedRegex(@"(?i)(?<![a-z0-9])(?<bucket>c\d{4})(?!\d)", RegexOptions.Compiled, "en-NL")]
private static partial Regex LooseBucketRegex();
}